Merge branch 'main' into main

This commit is contained in:
Max Peintner
2024-11-27 15:47:49 +01:00
committed by GitHub
79 changed files with 1920 additions and 1039 deletions

View File

@@ -5,12 +5,12 @@ const passwordConfirmField = "password-confirm-text-input";
export async function registerUserScreenPassword(page: Page, firstname: string, lastname: string, email: string) { export async function registerUserScreenPassword(page: Page, firstname: string, lastname: string, email: string) {
await registerUserScreen(page, firstname, lastname, email); await registerUserScreen(page, firstname, lastname, email);
await page.getByTestId("Password-radio").click(); await page.getByTestId("password-radio").click();
} }
export async function registerUserScreenPasskey(page: Page, firstname: string, lastname: string, email: string) { export async function registerUserScreenPasskey(page: Page, firstname: string, lastname: string, email: string) {
await registerUserScreen(page, firstname, lastname, email); await registerUserScreen(page, firstname, lastname, email);
await page.getByTestId("Passkeys-radio").click(); await page.getByTestId("passkey-radio").click();
} }
export async function registerPasswordScreen(page: Page, password1: string, password2: string) { export async function registerPasswordScreen(page: Page, password1: string, password2: string) {

View File

@@ -10,6 +10,16 @@ describe("register", () => {
result: [{ id: "256088834543534543" }], result: [{ id: "256088834543534543" }],
}, },
}); });
stub("zitadel.settings.v2.SettingsService", "GetLoginSettings", {
data: {
settings: {
passkeysType: 1,
allowRegister: true,
allowUsernamePassword: true,
defaultRedirectUri: "",
},
},
});
stub("zitadel.user.v2.UserService", "AddHumanUser", { stub("zitadel.user.v2.UserService", "AddHumanUser", {
data: { data: {
userId: "221394658884845598", userId: "221394658884845598",
@@ -53,9 +63,11 @@ describe("register", () => {
it("should redirect a user who selects passwordless on register to /passkey/set", () => { it("should redirect a user who selects passwordless on register to /passkey/set", () => {
cy.visit("/register"); cy.visit("/register");
cy.get('input[autocomplete="firstname"]').focus().type("John"); cy.get('input[data-testid="firstname-text-input"]').focus().type("John");
cy.get('input[autocomplete="lastname"]').focus().type("Doe"); cy.get('input[data-testid="lastname-text-input"]').focus().type("Doe");
cy.get('input[autocomplete="email"]').focus().type("john@zitadel.com"); cy.get('input[data-testid="email-text-input"]')
.focus()
.type("john@zitadel.com");
cy.get('input[type="checkbox"][value="privacypolicy"]').check(); cy.get('input[type="checkbox"][value="privacypolicy"]').check();
cy.get('input[type="checkbox"][value="tos"]').check(); cy.get('input[type="checkbox"][value="tos"]').check();
cy.get('button[type="submit"]').click(); cy.get('button[type="submit"]').click();

View File

@@ -122,6 +122,18 @@
} }
}, },
"register": { "register": {
"methods": {
"passkey": "Passkey",
"password": "Password"
},
"disabled": {
"title": "Registrierung deaktiviert",
"description": "Die Registrierung ist deaktiviert. Bitte wenden Sie sich an den Administrator."
},
"missingdata": {
"title": "Registrierung fehlgeschlagen",
"description": "Einige Daten fehlen. Bitte überprüfen Sie Ihre Eingaben."
},
"title": "Registrieren", "title": "Registrieren",
"description": "Erstellen Sie Ihr ZITADEL-Konto.", "description": "Erstellen Sie Ihr ZITADEL-Konto.",
"selectMethod": "Wählen Sie die Methode, mit der Sie sich authentifizieren möchten", "selectMethod": "Wählen Sie die Methode, mit der Sie sich authentifizieren möchten",
@@ -151,7 +163,8 @@
}, },
"signedin": { "signedin": {
"title": "Willkommen {user}!", "title": "Willkommen {user}!",
"description": "Sie sind angemeldet." "description": "Sie sind angemeldet.",
"continue": "Weiter"
}, },
"verify": { "verify": {
"userIdMissing": "Keine Benutzer-ID angegeben!", "userIdMissing": "Keine Benutzer-ID angegeben!",

View File

@@ -122,6 +122,18 @@
} }
}, },
"register": { "register": {
"methods": {
"passkey": "Passkey",
"password": "Password"
},
"disabled": {
"title": "Registration disabled",
"description": "The registration is disabled. Please contact your administrator."
},
"missingdata": {
"title": "Missing data",
"description": "Provide email, first and last name to register."
},
"title": "Register", "title": "Register",
"description": "Create your ZITADEL account.", "description": "Create your ZITADEL account.",
"selectMethod": "Select the method you would like to authenticate", "selectMethod": "Select the method you would like to authenticate",
@@ -151,7 +163,8 @@
}, },
"signedin": { "signedin": {
"title": "Welcome {user}!", "title": "Welcome {user}!",
"description": "You are signed in." "description": "You are signed in.",
"continue": "Continue"
}, },
"verify": { "verify": {
"userIdMissing": "No userId provided!", "userIdMissing": "No userId provided!",

View File

@@ -122,6 +122,18 @@
} }
}, },
"register": { "register": {
"methods": {
"passkey": "Clave de acceso",
"password": "Contraseña"
},
"disabled": {
"title": "Registro deshabilitado",
"description": "Registrarse está deshabilitado en este momento."
},
"missingdata": {
"title": "Datos faltantes",
"description": "No se proporcionaron datos suficientes para el registro."
},
"title": "Registrarse", "title": "Registrarse",
"description": "Crea tu cuenta ZITADEL.", "description": "Crea tu cuenta ZITADEL.",
"selectMethod": "Selecciona el método con el que deseas autenticarte", "selectMethod": "Selecciona el método con el que deseas autenticarte",
@@ -151,7 +163,8 @@
}, },
"signedin": { "signedin": {
"title": "¡Bienvenido {user}!", "title": "¡Bienvenido {user}!",
"description": "Has iniciado sesión." "description": "Has iniciado sesión.",
"continue": "Continuar"
}, },
"verify": { "verify": {
"userIdMissing": "¡No se proporcionó userId!", "userIdMissing": "¡No se proporcionó userId!",

View File

@@ -122,6 +122,18 @@
} }
}, },
"register": { "register": {
"methods": {
"passkey": "Passkey",
"password": "Password"
},
"disabled": {
"title": "Registration disabled",
"description": "Registrazione disabilitata. Contatta l'amministratore di sistema per assistenza."
},
"missingdata": {
"title": "Registrazione",
"description": "Inserisci i tuoi dati per registrarti."
},
"title": "Registrati", "title": "Registrati",
"description": "Crea il tuo account ZITADEL.", "description": "Crea il tuo account ZITADEL.",
"selectMethod": "Seleziona il metodo con cui desideri autenticarti", "selectMethod": "Seleziona il metodo con cui desideri autenticarti",
@@ -151,7 +163,8 @@
}, },
"signedin": { "signedin": {
"title": "Benvenuto {user}!", "title": "Benvenuto {user}!",
"description": "Sei connesso." "description": "Sei connesso.",
"continue": "Continua"
}, },
"verify": { "verify": {
"userIdMissing": "Nessun userId fornito!", "userIdMissing": "Nessun userId fornito!",

View File

@@ -2,7 +2,15 @@
{ {
"service": "zitadel.settings.v2.SettingsService", "service": "zitadel.settings.v2.SettingsService",
"method": "GetBrandingSettings", "method": "GetBrandingSettings",
"out": {} "out": {
"data": {
"settings": {
"darkTheme": {
"backgroundColor": "#ff0000"
}
}
}
}
}, },
{ {
"service": "zitadel.settings.v2.SettingsService", "service": "zitadel.settings.v2.SettingsService",

View File

@@ -2,4 +2,4 @@
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -36,7 +36,9 @@ const secureHeaders = [
const nextConfig = { const nextConfig = {
reactStrictMode: true, // Recommended for the `pages` directory, default in `app`. reactStrictMode: true, // Recommended for the `pages` directory, default in `app`.
swcMinify: true, experimental: {
dynamicIO: true,
},
images: { images: {
remotePatterns: [ remotePatterns: [
{ {

View File

@@ -3,7 +3,7 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev --turbopack",
"test": "concurrently --timings --kill-others-on-fail 'npm:test:unit' 'npm:test:integration'", "test": "concurrently --timings --kill-others-on-fail 'npm:test:unit' 'npm:test:integration'",
"test:watch": "concurrently --kill-others 'npm:test:unit:watch' 'npm:test:integration:watch'", "test:watch": "concurrently --kill-others 'npm:test:unit:watch' 'npm:test:integration:watch'",
"test:unit": "vitest", "test:unit": "vitest",
@@ -45,13 +45,13 @@
"copy-to-clipboard": "^3.3.3", "copy-to-clipboard": "^3.3.3",
"deepmerge": "^4.3.1", "deepmerge": "^4.3.1",
"moment": "^2.29.4", "moment": "^2.29.4",
"next": "14.2.14", "next": "15.0.4-canary.23",
"next-intl": "^3.20.0", "next-intl": "^3.25.1",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"nice-grpc": "2.0.1", "nice-grpc": "2.0.1",
"qrcode.react": "^3.1.0", "qrcode.react": "^3.1.0",
"react": "^18.3.1", "react": "19.0.0-rc-66855b96-20241106",
"react-dom": "18.3.1", "react-dom": "19.0.0-rc-66855b96-20241106",
"react-hook-form": "7.39.5", "react-hook-form": "7.39.5",
"swr": "^2.2.0", "swr": "^2.2.0",
"tinycolor2": "1.4.2" "tinycolor2": "1.4.2"
@@ -62,8 +62,8 @@
"@testing-library/react": "^16.0.1", "@testing-library/react": "^16.0.1",
"@types/ms": "0.7.34", "@types/ms": "0.7.34",
"@types/node": "22.9.0", "@types/node": "22.9.0",
"@types/react": "18.3.12", "@types/react": "npm:types-react@19.0.0-rc.1",
"@types/react-dom": "18.3.1", "@types/react-dom": "npm:types-react-dom@19.0.0-rc.1",
"@types/tinycolor2": "1.4.3", "@types/tinycolor2": "1.4.3",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@vercel/git-hooks": "1.0.0", "@vercel/git-hooks": "1.0.0",

View File

@@ -116,7 +116,7 @@ If the user has set up an additional **single** second factor, it is redirected
**NO MFA, FORCE MFA:** If no MFA method is available, and the settings force MFA, the user is sent to `/mfa/set` which prompts to setup a second factor. **NO MFA, FORCE MFA:** If no MFA method is available, and the settings force MFA, the user is sent to `/mfa/set` which prompts to setup a second factor.
**PROMPT PASSKEY** If the settings do not enforce MFA, we check if passkeys are allowed with `loginSettings?.passkeysType === PasskeysType.ALLOWED` and redirect the user to `/passkey/set` if no passkeys are setup. This step can be skipped. **PROMPT PASSKEY** If the settings do not enforce MFA, we check if passkeys are allowed with `loginSettings?.passkeysType == PasskeysType.ALLOWED` and redirect the user to `/passkey/set` if no passkeys are setup. This step can be skipped.
If none of the previous conditions apply, we continue to sign in. If none of the previous conditions apply, we continue to sign in.
@@ -386,3 +386,10 @@ In future, self service options to jump to are shown below, like:
- logout - logout
> NOTE: This page has to be explicitly enabled or act as a fallback if no default redirect is set. > NOTE: This page has to be explicitly enabled or act as a fallback if no default redirect is set.
## Currently NOT Supported
- loginSettings.disableLoginWithEmail
- loginSettings.disableLoginWithPhone
- loginSettings.allowExternalIdp - this will be deprecated with the new login as it can be determined by the available IDPs
- loginSettings.forceMfaLocalOnly

View File

@@ -20,11 +20,10 @@ async function loadSessions() {
} }
} }
export default async function Page({ export default async function Page(props: {
searchParams, searchParams: Promise<Record<string | number | symbol, string | undefined>>;
}: {
searchParams: Record<string | number | symbol, string | undefined>;
}) { }) {
const searchParams = await props.searchParams;
const locale = getLocale(); const locale = getLocale();
const t = await getTranslations({ locale, namespace: "accounts" }); const t = await getTranslations({ locale, namespace: "accounts" });

View File

@@ -15,11 +15,10 @@ import {
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { getLocale, getTranslations } from "next-intl/server"; import { getLocale, getTranslations } from "next-intl/server";
export default async function Page({ export default async function Page(props: {
searchParams, searchParams: Promise<Record<string | number | symbol, string | undefined>>;
}: {
searchParams: Record<string | number | symbol, string | undefined>;
}) { }) {
const searchParams = await props.searchParams;
const locale = getLocale(); const locale = getLocale();
const t = await getTranslations({ locale, namespace: "authenticator" }); const t = await getTranslations({ locale, namespace: "authenticator" });
const tError = await getTranslations({ locale, namespace: "error" }); const tError = await getTranslations({ locale, namespace: "error" });

View File

@@ -12,13 +12,11 @@ const PROVIDER_NAME_MAPPING: {
[IdentityProviderType.AZURE_AD]: "Microsoft", [IdentityProviderType.AZURE_AD]: "Microsoft",
}; };
export default async function Page({ export default async function Page(props: {
searchParams, searchParams: Promise<Record<string | number | symbol, string | undefined>>;
params, params: Promise<{ provider: string }>;
}: {
searchParams: Record<string | number | symbol, string | undefined>;
params: { provider: string };
}) { }) {
const searchParams = await props.searchParams;
const locale = getLocale(); const locale = getLocale();
const t = await getTranslations({ locale, namespace: "idp" }); const t = await getTranslations({ locale, namespace: "idp" });

View File

@@ -29,13 +29,12 @@ async function loginFailed(branding?: BrandingSettings) {
</DynamicTheme> </DynamicTheme>
); );
} }
export default async function Page({ export default async function Page(props: {
searchParams, searchParams: Promise<Record<string | number | symbol, string | undefined>>;
params, params: Promise<{ provider: string }>;
}: {
searchParams: Record<string | number | symbol, string | undefined>;
params: { provider: string };
}) { }) {
const params = await props.params;
const searchParams = await props.searchParams;
const locale = getLocale(); const locale = getLocale();
const t = await getTranslations({ locale, namespace: "idp" }); const t = await getTranslations({ locale, namespace: "idp" });
const { id, token, authRequestId, organization } = searchParams; const { id, token, authRequestId, organization } = searchParams;

View File

@@ -12,11 +12,10 @@ function getIdentityProviders(orgId?: string) {
}); });
} }
export default async function Page({ export default async function Page(props: {
searchParams, searchParams: Promise<Record<string | number | symbol, string | undefined>>;
}: {
searchParams: Record<string | number | symbol, string | undefined>;
}) { }) {
const searchParams = await props.searchParams;
const locale = getLocale(); const locale = getLocale();
const t = await getTranslations({ locale, namespace: "idp" }); const t = await getTranslations({ locale, namespace: "idp" });

View File

@@ -9,11 +9,10 @@ import {
} from "@/lib/zitadel"; } from "@/lib/zitadel";
import { getLocale, getTranslations } from "next-intl/server"; import { getLocale, getTranslations } from "next-intl/server";
export default async function Page({ export default async function Page(props: {
searchParams, searchParams: Promise<Record<string | number | symbol, string | undefined>>;
}: {
searchParams: Record<string | number | symbol, string | undefined>;
}) { }) {
const searchParams = await props.searchParams;
const locale = getLocale(); const locale = getLocale();
const t = await getTranslations({ locale, namespace: "invite" }); const t = await getTranslations({ locale, namespace: "invite" });

View File

@@ -7,11 +7,10 @@ import { HumanUser, User } from "@zitadel/proto/zitadel/user/v2/user_pb";
import { getLocale, getTranslations } from "next-intl/server"; import { getLocale, getTranslations } from "next-intl/server";
import Link from "next/link"; import Link from "next/link";
export default async function Page({ export default async function Page(props: {
searchParams, searchParams: Promise<Record<string | number | symbol, string | undefined>>;
}: {
searchParams: Record<string | number | symbol, string | undefined>;
}) { }) {
const searchParams = await props.searchParams;
const locale = getLocale(); const locale = getLocale();
const t = await getTranslations({ locale, namespace: "invite" }); const t = await getTranslations({ locale, namespace: "invite" });

View File

@@ -1,39 +1,46 @@
import "@/styles/globals.scss"; import "@/styles/globals.scss";
import { LanguageProvider } from "@/components/language-provider";
import { LanguageSwitcher } from "@/components/language-switcher"; import { LanguageSwitcher } from "@/components/language-switcher";
import { Skeleton } from "@/components/skeleton";
import { Theme } from "@/components/theme"; import { Theme } from "@/components/theme";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
import { Analytics } from "@vercel/analytics/react"; import { Analytics } from "@vercel/analytics/react";
import { NextIntlClientProvider } from "next-intl";
import { getLocale, getMessages } from "next-intl/server";
import { Lato } from "next/font/google"; import { Lato } from "next/font/google";
import { ReactNode } from "react"; import { ReactNode, Suspense } from "react";
const lato = Lato({ const lato = Lato({
weight: ["400", "700", "900"], weight: ["400", "700", "900"],
subsets: ["latin"], subsets: ["latin"],
}); });
export const revalidate = 60; // revalidate every minute
export default async function RootLayout({ export default async function RootLayout({
children, children,
}: { }: {
children: ReactNode; children: ReactNode;
}) { }) {
const locale = await getLocale();
const messages = await getMessages();
return ( return (
<html <html className={`${lato.className}`} suppressHydrationWarning>
lang={locale}
className={`${lato.className}`}
suppressHydrationWarning
>
<head /> <head />
<body> <body>
<ThemeProvider> <ThemeProvider>
<NextIntlClientProvider messages={messages}> <Suspense
fallback={
<div
className={`relative min-h-screen bg-background-light-600 dark:bg-background-dark-600 flex flex-col justify-center`}
>
<div className="relative mx-auto max-w-[440px] py-8 w-full">
<Skeleton>
<div className="h-40"></div>
</Skeleton>
<div className="flex flex-row justify-end py-4 items-center space-x-4">
<Theme />
</div>
</div>
</div>
}
>
<LanguageProvider>
<div <div
className={`relative min-h-screen bg-background-light-600 dark:bg-background-dark-600 flex flex-col justify-center`} className={`relative min-h-screen bg-background-light-600 dark:bg-background-dark-600 flex flex-col justify-center`}
> >
@@ -45,10 +52,10 @@ export default async function RootLayout({
</div> </div>
</div> </div>
</div> </div>
</LanguageProvider>
<Analytics /> </Suspense>
</NextIntlClientProvider>
</ThemeProvider> </ThemeProvider>
<Analytics />
</body> </body>
</html> </html>
); );

View File

@@ -19,11 +19,10 @@ function getIdentityProviders(orgId?: string) {
}); });
} }
export default async function Page({ export default async function Page(props: {
searchParams, searchParams: Promise<Record<string | number | symbol, string | undefined>>;
}: {
searchParams: Record<string | number | symbol, string | undefined>;
}) { }) {
const searchParams = await props.searchParams;
const locale = getLocale(); const locale = getLocale();
const t = await getTranslations({ locale, namespace: "loginname" }); const t = await getTranslations({ locale, namespace: "loginname" });

View File

@@ -12,11 +12,10 @@ import {
} from "@/lib/zitadel"; } from "@/lib/zitadel";
import { getLocale, getTranslations } from "next-intl/server"; import { getLocale, getTranslations } from "next-intl/server";
export default async function Page({ export default async function Page(props: {
searchParams, searchParams: Promise<Record<string | number | symbol, string | undefined>>;
}: {
searchParams: Record<string | number | symbol, string | undefined>;
}) { }) {
const searchParams = await props.searchParams;
const locale = getLocale(); const locale = getLocale();
const t = await getTranslations({ locale, namespace: "mfa" }); const t = await getTranslations({ locale, namespace: "mfa" });
const tError = await getTranslations({ locale, namespace: "error" }); const tError = await getTranslations({ locale, namespace: "error" });

View File

@@ -32,11 +32,10 @@ function isSessionValid(session: Partial<Session>): {
return { valid, verifiedAt }; return { valid, verifiedAt };
} }
export default async function Page({ export default async function Page(props: {
searchParams, searchParams: Promise<Record<string | number | symbol, string | undefined>>;
}: {
searchParams: Record<string | number | symbol, string | undefined>;
}) { }) {
const searchParams = await props.searchParams;
const locale = getLocale(); const locale = getLocale();
const t = await getTranslations({ locale, namespace: "mfa" }); const t = await getTranslations({ locale, namespace: "mfa" });
const tError = await getTranslations({ locale, namespace: "error" }); const tError = await getTranslations({ locale, namespace: "error" });

View File

@@ -3,16 +3,15 @@ import { DynamicTheme } from "@/components/dynamic-theme";
import { LoginOTP } from "@/components/login-otp"; import { LoginOTP } from "@/components/login-otp";
import { UserAvatar } from "@/components/user-avatar"; import { UserAvatar } from "@/components/user-avatar";
import { loadMostRecentSession } from "@/lib/session"; import { loadMostRecentSession } from "@/lib/session";
import { getBrandingSettings } from "@/lib/zitadel"; import { getBrandingSettings, getLoginSettings } from "@/lib/zitadel";
import { getLocale, getTranslations } from "next-intl/server"; import { getLocale, getTranslations } from "next-intl/server";
export default async function Page({ export default async function Page(props: {
searchParams, searchParams: Promise<Record<string | number | symbol, string | undefined>>;
params, params: Promise<Record<string | number | symbol, string | undefined>>;
}: {
searchParams: Record<string | number | symbol, string | undefined>;
params: Record<string | number | symbol, string | undefined>;
}) { }) {
const params = await props.params;
const searchParams = await props.searchParams;
const locale = getLocale(); const locale = getLocale();
const t = await getTranslations({ locale, namespace: "otp" }); const t = await getTranslations({ locale, namespace: "otp" });
const tError = await getTranslations({ locale, namespace: "error" }); const tError = await getTranslations({ locale, namespace: "error" });
@@ -29,6 +28,8 @@ export default async function Page({
const branding = await getBrandingSettings(organization); const branding = await getBrandingSettings(organization);
const loginSettings = await getLoginSettings(organization);
return ( return (
<DynamicTheme branding={branding}> <DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4"> <div className="flex flex-col items-center space-y-4">
@@ -65,6 +66,7 @@ export default async function Page({
authRequestId={authRequestId} authRequestId={authRequestId}
organization={organization} organization={organization}
method={method} method={method}
loginSettings={loginSettings}
></LoginOTP> ></LoginOTP>
)} )}
</div> </div>

View File

@@ -9,6 +9,7 @@ import {
addOTPEmail, addOTPEmail,
addOTPSMS, addOTPSMS,
getBrandingSettings, getBrandingSettings,
getLoginSettings,
registerTOTP, registerTOTP,
} from "@/lib/zitadel"; } from "@/lib/zitadel";
import { RegisterTOTPResponse } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; import { RegisterTOTPResponse } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
@@ -16,13 +17,12 @@ import { getLocale, getTranslations } from "next-intl/server";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
export default async function Page({ export default async function Page(props: {
searchParams, searchParams: Promise<Record<string | number | symbol, string | undefined>>;
params, params: Promise<Record<string | number | symbol, string | undefined>>;
}: {
searchParams: Record<string | number | symbol, string | undefined>;
params: Record<string | number | symbol, string | undefined>;
}) { }) {
const params = await props.params;
const searchParams = await props.searchParams;
const locale = getLocale(); const locale = getLocale();
const t = await getTranslations({ locale, namespace: "otp" }); const t = await getTranslations({ locale, namespace: "otp" });
const tError = await getTranslations({ locale, namespace: "error" }); const tError = await getTranslations({ locale, namespace: "error" });
@@ -32,6 +32,8 @@ export default async function Page({
const { method } = params; const { method } = params;
const branding = await getBrandingSettings(organization); const branding = await getBrandingSettings(organization);
const loginSettings = await getLoginSettings(organization);
const session = await loadMostRecentSession({ const session = await loadMostRecentSession({
loginName, loginName,
organization, organization,
@@ -137,6 +139,7 @@ export default async function Page({
authRequestId={authRequestId} authRequestId={authRequestId}
organization={organization} organization={organization}
checkAfter={checkAfter === "true"} checkAfter={checkAfter === "true"}
loginSettings={loginSettings}
></TotpRegister> ></TotpRegister>
</div>{" "} </div>{" "}
</> </>

View File

@@ -4,14 +4,17 @@ import { LoginPasskey } from "@/components/login-passkey";
import { UserAvatar } from "@/components/user-avatar"; import { UserAvatar } from "@/components/user-avatar";
import { getSessionCookieById } from "@/lib/cookies"; import { getSessionCookieById } from "@/lib/cookies";
import { loadMostRecentSession } from "@/lib/session"; import { loadMostRecentSession } from "@/lib/session";
import { getBrandingSettings, getSession } from "@/lib/zitadel"; import {
getBrandingSettings,
getLoginSettings,
getSession,
} from "@/lib/zitadel";
import { getLocale, getTranslations } from "next-intl/server"; import { getLocale, getTranslations } from "next-intl/server";
export default async function Page({ export default async function Page(props: {
searchParams, searchParams: Promise<Record<string | number | symbol, string | undefined>>;
}: {
searchParams: Record<string | number | symbol, string | undefined>;
}) { }) {
const searchParams = await props.searchParams;
const locale = getLocale(); const locale = getLocale();
const t = await getTranslations({ locale, namespace: "passkey" }); const t = await getTranslations({ locale, namespace: "passkey" });
const tError = await getTranslations({ locale, namespace: "error" }); const tError = await getTranslations({ locale, namespace: "error" });
@@ -37,6 +40,8 @@ export default async function Page({
const branding = await getBrandingSettings(organization); const branding = await getBrandingSettings(organization);
const loginSettings = await getLoginSettings(organization);
return ( return (
<DynamicTheme branding={branding}> <DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4"> <div className="flex flex-col items-center space-y-4">
@@ -61,6 +66,7 @@ export default async function Page({
authRequestId={authRequestId} authRequestId={authRequestId}
altPassword={altPassword === "true"} altPassword={altPassword === "true"}
organization={organization} organization={organization}
loginSettings={loginSettings}
/> />
)} )}
</div> </div>

View File

@@ -6,11 +6,10 @@ import { loadMostRecentSession } from "@/lib/session";
import { getBrandingSettings } from "@/lib/zitadel"; import { getBrandingSettings } from "@/lib/zitadel";
import { getLocale, getTranslations } from "next-intl/server"; import { getLocale, getTranslations } from "next-intl/server";
export default async function Page({ export default async function Page(props: {
searchParams, searchParams: Promise<Record<string | number | symbol, string | undefined>>;
}: {
searchParams: Record<string | number | symbol, string | undefined>;
}) { }) {
const searchParams = await props.searchParams;
const locale = getLocale(); const locale = getLocale();
const t = await getTranslations({ locale, namespace: "passkey" }); const t = await getTranslations({ locale, namespace: "passkey" });
const tError = await getTranslations({ locale, namespace: "error" }); const tError = await getTranslations({ locale, namespace: "error" });

View File

@@ -10,11 +10,10 @@ import {
} from "@/lib/zitadel"; } from "@/lib/zitadel";
import { getLocale, getTranslations } from "next-intl/server"; import { getLocale, getTranslations } from "next-intl/server";
export default async function Page({ export default async function Page(props: {
searchParams, searchParams: Promise<Record<string | number | symbol, string | undefined>>;
}: {
searchParams: Record<string | number | symbol, string | undefined>;
}) { }) {
const searchParams = await props.searchParams;
const locale = getLocale(); const locale = getLocale();
const t = await getTranslations({ locale, namespace: "password" }); const t = await getTranslations({ locale, namespace: "password" });
const tError = await getTranslations({ locale, namespace: "error" }); const tError = await getTranslations({ locale, namespace: "error" });

View File

@@ -12,11 +12,10 @@ import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb";
import { PasskeysType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; import { PasskeysType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { getLocale, getTranslations } from "next-intl/server"; import { getLocale, getTranslations } from "next-intl/server";
export default async function Page({ export default async function Page(props: {
searchParams, searchParams: Promise<Record<string | number | symbol, string | undefined>>;
}: {
searchParams: Record<string | number | symbol, string | undefined>;
}) { }) {
const searchParams = await props.searchParams;
const locale = getLocale(); const locale = getLocale();
const t = await getTranslations({ locale, namespace: "password" }); const t = await getTranslations({ locale, namespace: "password" });
const tError = await getTranslations({ locale, namespace: "error" }); const tError = await getTranslations({ locale, namespace: "error" });
@@ -83,7 +82,7 @@ export default async function Page({
organization={organization} // stick to "organization" as we still want to do user discovery based on the searchParams not the default organization, later the organization is determined by the found user organization={organization} // stick to "organization" as we still want to do user discovery based on the searchParams not the default organization, later the organization is determined by the found user
loginSettings={loginSettings} loginSettings={loginSettings}
promptPasswordless={ promptPasswordless={
loginSettings?.passkeysType === PasskeysType.ALLOWED loginSettings?.passkeysType == PasskeysType.ALLOWED
} }
isAlternative={alt === "true"} isAlternative={alt === "true"}
/> />

View File

@@ -13,11 +13,10 @@ import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { HumanUser, User } from "@zitadel/proto/zitadel/user/v2/user_pb"; import { HumanUser, User } from "@zitadel/proto/zitadel/user/v2/user_pb";
import { getLocale, getTranslations } from "next-intl/server"; import { getLocale, getTranslations } from "next-intl/server";
export default async function Page({ export default async function Page(props: {
searchParams, searchParams: Promise<Record<string | number | symbol, string | undefined>>;
}: {
searchParams: Record<string | number | symbol, string | undefined>;
}) { }) {
const searchParams = await props.searchParams;
const locale = getLocale(); const locale = getLocale();
const t = await getTranslations({ locale, namespace: "password" }); const t = await getTranslations({ locale, namespace: "password" });
const tError = await getTranslations({ locale, namespace: "error" }); const tError = await getTranslations({ locale, namespace: "error" });

View File

@@ -1,20 +1,19 @@
import { DynamicTheme } from "@/components/dynamic-theme"; import { DynamicTheme } from "@/components/dynamic-theme";
import { RegisterFormWithoutPassword } from "@/components/register-form-without-password"; import { RegisterForm } from "@/components/register-form";
import { SetRegisterPasswordForm } from "@/components/set-register-password-form";
import { import {
getBrandingSettings, getBrandingSettings,
getDefaultOrg, getDefaultOrg,
getLegalAndSupportSettings, getLegalAndSupportSettings,
getLoginSettings,
getPasswordComplexitySettings, getPasswordComplexitySettings,
} from "@/lib/zitadel"; } from "@/lib/zitadel";
import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb";
import { getLocale, getTranslations } from "next-intl/server"; import { getLocale, getTranslations } from "next-intl/server";
export default async function Page({ export default async function Page(props: {
searchParams, searchParams: Promise<Record<string | number | symbol, string | undefined>>;
}: {
searchParams: Record<string | number | symbol, string | undefined>;
}) { }) {
const searchParams = await props.searchParams;
const locale = getLocale(); const locale = getLocale();
const t = await getTranslations({ locale, namespace: "register" }); const t = await getTranslations({ locale, namespace: "register" });
@@ -28,47 +27,41 @@ export default async function Page({
} }
} }
const setPassword = !!(firstname && lastname && email);
const legal = await getLegalAndSupportSettings(organization); const legal = await getLegalAndSupportSettings(organization);
const passwordComplexitySettings = const passwordComplexitySettings =
await getPasswordComplexitySettings(organization); await getPasswordComplexitySettings(organization);
const branding = await getBrandingSettings(organization); const branding = await getBrandingSettings(organization);
return setPassword ? ( const loginSettings = await getLoginSettings(organization);
if (!loginSettings?.allowRegister) {
return (
<DynamicTheme branding={branding}> <DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4"> <div className="flex flex-col items-center space-y-4">
<h1>{t("password.title")}</h1> <h1>{t("disabled.title")}</h1>
<p className="ztdl-p">{t("description")}</p> <p className="ztdl-p">{t("disabled.description")}</p>
{legal && passwordComplexitySettings && (
<SetRegisterPasswordForm
passwordComplexitySettings={passwordComplexitySettings}
email={email}
firstname={firstname}
lastname={lastname}
organization={organization}
authRequestId={authRequestId}
></SetRegisterPasswordForm>
)}
</div> </div>
</DynamicTheme> </DynamicTheme>
) : ( );
}
return (
<DynamicTheme branding={branding}> <DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4"> <div className="flex flex-col items-center space-y-4">
<h1>{t("title")}</h1> <h1>{t("title")}</h1>
<p className="ztdl-p">{t("description")}</p> <p className="ztdl-p">{t("description")}</p>
{legal && passwordComplexitySettings && ( {legal && passwordComplexitySettings && (
<RegisterFormWithoutPassword <RegisterForm
legal={legal} legal={legal}
organization={organization} organization={organization}
firstname={firstname} firstname={firstname}
lastname={lastname} lastname={lastname}
email={email} email={email}
authRequestId={authRequestId} authRequestId={authRequestId}
></RegisterFormWithoutPassword> loginSettings={loginSettings}
></RegisterForm>
)} )}
</div> </div>
</DynamicTheme> </DynamicTheme>

View File

@@ -0,0 +1,73 @@
import { DynamicTheme } from "@/components/dynamic-theme";
import { SetRegisterPasswordForm } from "@/components/set-register-password-form";
import {
getBrandingSettings,
getDefaultOrg,
getLegalAndSupportSettings,
getLoginSettings,
getPasswordComplexitySettings,
} from "@/lib/zitadel";
import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb";
import { getLocale, getTranslations } from "next-intl/server";
export default async function Page(props: {
searchParams: Promise<Record<string | number | symbol, string | undefined>>;
}) {
const searchParams = await props.searchParams;
const locale = getLocale();
const t = await getTranslations({ locale, namespace: "register" });
let { firstname, lastname, email, organization, authRequestId } =
searchParams;
if (!organization) {
const org: Organization | null = await getDefaultOrg();
if (org) {
organization = org.id;
}
}
const missingData = !firstname || !lastname || !email;
const legal = await getLegalAndSupportSettings(organization);
const passwordComplexitySettings =
await getPasswordComplexitySettings(organization);
const branding = await getBrandingSettings(organization);
const loginSettings = await getLoginSettings(organization);
return missingData ? (
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
<h1>{t("missingdata.title")}</h1>
<p className="ztdl-p">{t("missingdata.description")}</p>
</div>
</DynamicTheme>
) : loginSettings?.allowRegister && loginSettings.allowUsernamePassword ? (
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
<h1>{t("password.title")}</h1>
<p className="ztdl-p">{t("description")}</p>
{legal && passwordComplexitySettings && (
<SetRegisterPasswordForm
passwordComplexitySettings={passwordComplexitySettings}
email={email}
firstname={firstname}
lastname={lastname}
organization={organization}
authRequestId={authRequestId}
></SetRegisterPasswordForm>
)}
</div>
</DynamicTheme>
) : (
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
<h1>{t("disabled.title")}</h1>
<p className="ztdl-p">{t("disabled.description")}</p>
</div>
</DynamicTheme>
);
}

View File

@@ -1,14 +1,21 @@
import { Button, ButtonVariants } from "@/components/button";
import { DynamicTheme } from "@/components/dynamic-theme"; import { DynamicTheme } from "@/components/dynamic-theme";
import { SelfServiceMenu } from "@/components/self-service-menu"; import { SelfServiceMenu } from "@/components/self-service-menu";
import { UserAvatar } from "@/components/user-avatar"; import { UserAvatar } from "@/components/user-avatar";
import { getMostRecentCookieWithLoginname } from "@/lib/cookies"; import { getMostRecentCookieWithLoginname } from "@/lib/cookies";
import { createCallback, getBrandingSettings, getSession } from "@/lib/zitadel"; import {
createCallback,
getBrandingSettings,
getLoginSettings,
getSession,
} from "@/lib/zitadel";
import { create } from "@zitadel/client"; import { create } from "@zitadel/client";
import { import {
CreateCallbackRequestSchema, CreateCallbackRequestSchema,
SessionSchema, SessionSchema,
} from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb"; } from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb";
import { getLocale, getTranslations } from "next-intl/server"; import { getLocale, getTranslations } from "next-intl/server";
import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
async function loadSession(loginName: string, authRequestId?: string) { async function loadSession(loginName: string, authRequestId?: string) {
@@ -39,7 +46,8 @@ async function loadSession(loginName: string, authRequestId?: string) {
); );
} }
export default async function Page({ searchParams }: { searchParams: any }) { export default async function Page(props: { searchParams: Promise<any> }) {
const searchParams = await props.searchParams;
const locale = getLocale(); const locale = getLocale();
const t = await getTranslations({ locale, namespace: "signedin" }); const t = await getTranslations({ locale, namespace: "signedin" });
@@ -48,6 +56,11 @@ export default async function Page({ searchParams }: { searchParams: any }) {
const branding = await getBrandingSettings(organization); const branding = await getBrandingSettings(organization);
let loginSettings;
if (!authRequestId) {
loginSettings = await getLoginSettings(organization);
}
return ( return (
<DynamicTheme branding={branding}> <DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4"> <div className="flex flex-col items-center space-y-4">
@@ -66,6 +79,22 @@ export default async function Page({ searchParams }: { searchParams: any }) {
{sessionFactors?.id && ( {sessionFactors?.id && (
<SelfServiceMenu sessionId={sessionFactors?.id} /> <SelfServiceMenu sessionId={sessionFactors?.id} />
)} )}
{loginSettings?.defaultRedirectUri && (
<div className="mt-8 flex w-full flex-row items-center">
<span className="flex-grow"></span>
<Link href={loginSettings?.defaultRedirectUri}>
<Button
type="submit"
className="self-end"
variant={ButtonVariants.Primary}
>
{t("continue")}
</Button>
</Link>
</div>
)}
</div> </div>
</DynamicTheme> </DynamicTheme>
); );

View File

@@ -7,11 +7,10 @@ import { loadMostRecentSession } from "@/lib/session";
import { getBrandingSettings, getSession } from "@/lib/zitadel"; import { getBrandingSettings, getSession } from "@/lib/zitadel";
import { getLocale, getTranslations } from "next-intl/server"; import { getLocale, getTranslations } from "next-intl/server";
export default async function Page({ export default async function Page(props: {
searchParams, searchParams: Promise<Record<string | number | symbol, string | undefined>>;
}: {
searchParams: Record<string | number | symbol, string | undefined>;
}) { }) {
const searchParams = await props.searchParams;
const locale = getLocale(); const locale = getLocale();
const t = await getTranslations({ locale, namespace: "u2f" }); const t = await getTranslations({ locale, namespace: "u2f" });
const tError = await getTranslations({ locale, namespace: "error" }); const tError = await getTranslations({ locale, namespace: "error" });

View File

@@ -6,11 +6,10 @@ import { loadMostRecentSession } from "@/lib/session";
import { getBrandingSettings } from "@/lib/zitadel"; import { getBrandingSettings } from "@/lib/zitadel";
import { getLocale, getTranslations } from "next-intl/server"; import { getLocale, getTranslations } from "next-intl/server";
export default async function Page({ export default async function Page(props: {
searchParams, searchParams: Promise<Record<string | number | symbol, string | undefined>>;
}: {
searchParams: Record<string | number | symbol, string | undefined>;
}) { }) {
const searchParams = await props.searchParams;
const locale = getLocale(); const locale = getLocale();
const t = await getTranslations({ locale, namespace: "u2f" }); const t = await getTranslations({ locale, namespace: "u2f" });
const tError = await getTranslations({ locale, namespace: "error" }); const tError = await getTranslations({ locale, namespace: "error" });

View File

@@ -12,7 +12,8 @@ import { HumanUser, User } from "@zitadel/proto/zitadel/user/v2/user_pb";
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import { getLocale, getTranslations } from "next-intl/server"; import { getLocale, getTranslations } from "next-intl/server";
export default async function Page({ searchParams }: { searchParams: any }) { export default async function Page(props: { searchParams: Promise<any> }) {
const searchParams = await props.searchParams;
const locale = getLocale(); const locale = getLocale();
const t = await getTranslations({ locale, namespace: "verify" }); const t = await getTranslations({ locale, namespace: "verify" });
const tError = await getTranslations({ locale, namespace: "error" }); const tError = await getTranslations({ locale, namespace: "error" });

View File

@@ -1,16 +1,16 @@
"use client"; "use client";
import { RadioGroup } from "@headlessui/react"; import { RadioGroup } from "@headlessui/react";
import { useTranslations } from "next-intl";
export enum AuthenticationMethod {
Passkey = "passkey",
Password = "password",
}
export const methods = [ export const methods = [
{ AuthenticationMethod.Passkey,
name: "Passkeys", AuthenticationMethod.Password,
description: "Authenticate with your device.",
},
{
name: "Password",
description: "Authenticate with a password",
},
]; ];
export function AuthenticationMethodRadio({ export function AuthenticationMethodRadio({
@@ -20,58 +20,68 @@ export function AuthenticationMethodRadio({
selected: any; selected: any;
selectionChanged: (value: any) => void; selectionChanged: (value: any) => void;
}) { }) {
const t = useTranslations("register");
return ( return (
<div className="w-full"> <div className="w-full">
<div className="mx-auto w-full max-w-md"> <div className="mx-auto w-full max-w-md">
<RadioGroup value={selected} onChange={selectionChanged}> <RadioGroup value={selected} onChange={selectionChanged}>
<RadioGroup.Label className="sr-only">Server size</RadioGroup.Label> <RadioGroup.Label className="sr-only">Server size</RadioGroup.Label>
<div className="grid grid-cols-2 space-x-2"> <div className="flex flex-row space-x-4">
{methods.map((method) => ( {methods.map((method) => (
<RadioGroup.Option <RadioGroup.Option
key={method.name} key={method}
value={method} value={method}
data-testid={method.name + "-radio"} data-testid={method + "-radio"}
className={({ active, checked }) => className={({ active, checked }) =>
`${ `${
active active
? "h-full ring-2 ring-opacity-60 ring-primary-light-500 dark:ring-white/20" ? "ring-2 ring-opacity-60 ring-primary-light-500 dark:ring-white/20"
: "h-full " : ""
} }
${ ${
checked checked
? "bg-background-light-400 dark:bg-background-dark-400" ? "bg-background-light-400 dark:bg-background-dark-400 ring-2 ring-primary-light-500 dark:ring-primary-dark-500"
: "bg-background-light-400 dark:bg-background-dark-400" : "bg-background-light-400 dark:bg-background-dark-400"
} }
relative border boder-divider-light dark:border-divider-dark flex cursor-pointer rounded-lg px-5 py-4 focus:outline-none hover:shadow-lg dark:hover:bg-white/10` h-full flex-1 relative border boder-divider-light dark:border-divider-dark flex cursor-pointer rounded-lg px-5 py-4 focus:outline-none hover:shadow-lg dark:hover:bg-white/10`
} }
> >
{({ active, checked }) => ( {({ active, checked }) => (
<> <>
<div className="flex w-full items-center justify-between"> <div className="flex flex-col items-center w-full text-sm">
<div className="flex items-center"> {method === "passkey" && (
<div className="text-sm"> <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 mb-3"
>
<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>
)}
{method === "password" && (
<svg
className="w-8 h-8 mb-3 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>
)}
<RadioGroup.Label <RadioGroup.Label
as="p" as="p"
className={`font-medium ${checked ? "" : ""}`} className={`font-medium ${checked ? "" : ""}`}
> >
{method.name} {t(`methods.${method}`)}
</RadioGroup.Label> </RadioGroup.Label>
<RadioGroup.Description
as="span"
className={`text-xs text-opacity-80 dark:text-opacity-80 inline ${
checked ? "" : ""
}`}
>
{method.description}
<span aria-hidden="true">&middot;</span>{" "}
</RadioGroup.Description>
</div>
</div>
{checked && (
<div className="shrink-0 text-white">
<CheckIcon className="h-6 w-6" />
</div>
)}
</div> </div>
</> </>
)} )}
@@ -83,24 +93,3 @@ export function AuthenticationMethodRadio({
</div> </div>
); );
} }
function CheckIcon(props: any) {
return (
<svg viewBox="0 0 24 24" fill="none" {...props}>
<circle
className="fill-current text-black/50 dark:text-white/50"
cx={12}
cy={12}
r={12}
opacity="0.2"
/>
<path
d="M7 13l3 3 7-7"
className="stroke-black dark:stroke-white"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}

View File

@@ -12,7 +12,6 @@ import { create } from "@zitadel/client";
import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb"; import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { redirect } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { FieldValues, useForm } from "react-hook-form"; import { FieldValues, useForm } from "react-hook-form";
import { Alert } from "./alert"; import { Alert } from "./alert";
@@ -65,6 +64,7 @@ export function ChangePasswordForm({
}) })
.catch(() => { .catch(() => {
setError("Could not change password"); setError("Could not change password");
return;
}) })
.finally(() => { .finally(() => {
setLoading(false); setLoading(false);
@@ -107,10 +107,6 @@ export function ChangePasswordForm({
return; return;
} }
if (passwordResponse && passwordResponse.nextStep) {
return redirect(passwordResponse.nextStep);
}
return; return;
} }

View File

@@ -25,7 +25,7 @@ export function ChooseAuthenticatorToSetup({
} else { } else {
return ( return (
<> <>
{loginSettings.passkeysType === PasskeysType.NOT_ALLOWED && {loginSettings.passkeysType == PasskeysType.NOT_ALLOWED &&
!loginSettings.allowUsernamePassword && ( !loginSettings.allowUsernamePassword && (
<Alert type={AlertType.ALERT}>{t("noMethodsAvailable")}</Alert> <Alert type={AlertType.ALERT}>{t("noMethodsAvailable")}</Alert>
)} )}
@@ -35,7 +35,7 @@ export function ChooseAuthenticatorToSetup({
loginSettings.allowUsernamePassword && loginSettings.allowUsernamePassword &&
PASSWORD(false, "/password/set?" + params)} PASSWORD(false, "/password/set?" + params)}
{!authMethods.includes(AuthenticationMethodType.PASSKEY) && {!authMethods.includes(AuthenticationMethodType.PASSKEY) &&
loginSettings.passkeysType === PasskeysType.ALLOWED && loginSettings.passkeysType == PasskeysType.ALLOWED &&
PASSKEYS(false, "/passkey/set?" + params)} PASSKEYS(false, "/passkey/set?" + params)}
</div> </div>
</> </>

View File

@@ -35,30 +35,18 @@ export function IdpSignin({
}, },
authRequestId, authRequestId,
}) })
.then((session) => { .then((response) => {
if (authRequestId && session && session.id) { if (response && "error" in response && response?.error) {
return router.push( setError(response?.error);
`/login?` + return;
new URLSearchParams({
sessionId: session.id,
authRequest: authRequestId,
}),
);
} else {
const params = new URLSearchParams({});
if (session.factors?.user?.loginName) {
params.set("loginName", session.factors?.user?.loginName);
} }
if (authRequestId) { if (response && "redirect" in response && response?.redirect) {
params.set("authRequestId", authRequestId); return router.push(response.redirect);
}
return router.push(`/signedin?` + params);
} }
}) })
.catch((error) => { .catch(() => {
setError(error.message); setError("An internal error occurred");
return; return;
}) })
.finally(() => { .finally(() => {

View File

@@ -1,4 +1,5 @@
"use client"; "use client";
import { CheckCircleIcon } from "@heroicons/react/24/solid"; import { CheckCircleIcon } from "@heroicons/react/24/solid";
import { clsx } from "clsx"; import { clsx } from "clsx";
import { import {
@@ -64,6 +65,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
{label} {required && "*"} {label} {required && "*"}
</span> </span>
<input <input
suppressHydrationWarning
ref={ref} ref={ref}
className={styles(!!error, !!disabled)} className={styles(!!error, !!disabled)}
defaultValue={defaultValue} defaultValue={defaultValue}

View File

@@ -0,0 +1,13 @@
import { NextIntlClientProvider } from "next-intl";
import { getMessages } from "next-intl/server";
import { ReactNode } from "react";
export async function LanguageProvider({ children }: { children: ReactNode }) {
const messages = await getMessages();
return (
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
);
}

View File

@@ -1,9 +1,11 @@
"use client"; "use client";
import { getNextUrl } from "@/lib/client";
import { updateSession } from "@/lib/server/session"; import { updateSession } from "@/lib/server/session";
import { create } from "@zitadel/client"; import { create } from "@zitadel/client";
import { RequestChallengesSchema } from "@zitadel/proto/zitadel/session/v2/challenge_pb"; import { RequestChallengesSchema } from "@zitadel/proto/zitadel/session/v2/challenge_pb";
import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
@@ -22,6 +24,7 @@ type Props = {
organization?: string; organization?: string;
method: string; method: string;
code?: string; code?: string;
loginSettings?: LoginSettings;
}; };
type Inputs = { type Inputs = {
@@ -35,6 +38,7 @@ export function LoginOTP({
organization, organization,
method, method,
code, code,
loginSettings,
}: Props) { }: Props) {
const t = useTranslations("otp"); const t = useTranslations("otp");
@@ -59,6 +63,7 @@ export function LoginOTP({
updateSessionForOTPChallenge() updateSessionForOTPChallenge()
.catch((error) => { .catch((error) => {
setError(error); setError(error);
return;
}) })
.finally(() => { .finally(() => {
setLoading(false); setLoading(false);
@@ -91,6 +96,7 @@ export function LoginOTP({
}) })
.catch((error) => { .catch((error) => {
setError(error.message ?? "Could not request OTP challenge"); setError(error.message ?? "Could not request OTP challenge");
return;
}) })
.finally(() => { .finally(() => {
setLoading(false); setLoading(false);
@@ -152,41 +158,30 @@ export function LoginOTP({
} }
function setCodeAndContinue(values: Inputs, organization?: string) { function setCodeAndContinue(values: Inputs, organization?: string) {
return submitCode(values, organization).then((response) => { return submitCode(values, organization).then(async (response) => {
if (response) { if (response) {
if (authRequestId && response && response.sessionId) { const url =
const params = new URLSearchParams({ authRequestId && response.sessionId
? await getNextUrl(
{
sessionId: response.sessionId, sessionId: response.sessionId,
authRequest: authRequestId, authRequestId: authRequestId,
}); organization: response.factors?.user?.organizationId,
},
loginSettings?.defaultRedirectUri,
)
: response.factors?.user
? await getNextUrl(
{
loginName: response.factors.user.loginName,
organization: response.factors?.user?.organizationId,
},
loginSettings?.defaultRedirectUri,
)
: null;
if (organization) { if (url) {
params.append("organization", organization); router.push(url);
}
if (authRequestId) {
params.append("authRequest", authRequestId);
}
if (sessionId) {
params.append("sessionId", sessionId);
}
return router.push(`/login?` + params);
} else {
const params = new URLSearchParams();
if (response?.factors?.user?.loginName) {
params.append("loginName", response.factors.user.loginName);
}
if (authRequestId) {
params.append("authRequestId", authRequestId);
}
if (organization) {
params.append("organization", organization);
}
return router.push(`/signedin?` + params);
} }
} }
}); });
@@ -209,6 +204,7 @@ export function LoginOTP({
updateSessionForOTPChallenge() updateSessionForOTPChallenge()
.catch((error) => { .catch((error) => {
setError(error); setError(error);
return;
}) })
.finally(() => { .finally(() => {
setLoading(false); setLoading(false);

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { coerceToArrayBuffer, coerceToBase64Url } from "@/helpers/base64"; import { coerceToArrayBuffer, coerceToBase64Url } from "@/helpers/base64";
import { getNextUrl } from "@/lib/client";
import { updateSession } from "@/lib/server/session"; import { updateSession } from "@/lib/server/session";
import { create } from "@zitadel/client"; import { create } from "@zitadel/client";
import { import {
@@ -8,6 +9,7 @@ import {
UserVerificationRequirement, UserVerificationRequirement,
} from "@zitadel/proto/zitadel/session/v2/challenge_pb"; } from "@zitadel/proto/zitadel/session/v2/challenge_pb";
import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
@@ -24,6 +26,7 @@ type Props = {
altPassword: boolean; altPassword: boolean;
login?: boolean; login?: boolean;
organization?: string; organization?: string;
loginSettings?: LoginSettings;
}; };
export function LoginPasskey({ export function LoginPasskey({
@@ -33,6 +36,7 @@ export function LoginPasskey({
altPassword, altPassword,
organization, organization,
login = true, login = true,
loginSettings,
}: Props) { }: Props) {
const t = useTranslations("passkey"); const t = useTranslations("passkey");
@@ -63,6 +67,7 @@ export function LoginPasskey({
return submitLoginAndContinue(pK) return submitLoginAndContinue(pK)
.catch((error) => { .catch((error) => {
setError(error); setError(error);
return;
}) })
.finally(() => { .finally(() => {
setLoading(false); setLoading(false);
@@ -70,6 +75,7 @@ export function LoginPasskey({
}) })
.catch((error) => { .catch((error) => {
setError(error); setError(error);
return;
}) })
.finally(() => { .finally(() => {
setLoading(false); setLoading(false);
@@ -98,6 +104,7 @@ export function LoginPasskey({
}) })
.catch(() => { .catch(() => {
setError("Could not request passkey challenge"); setError("Could not request passkey challenge");
return;
}) })
.finally(() => { .finally(() => {
setLoading(false); setLoading(false);
@@ -119,6 +126,7 @@ export function LoginPasskey({
}) })
.catch(() => { .catch(() => {
setError("Could not verify passkey"); setError("Could not verify passkey");
return;
}) })
.finally(() => { .finally(() => {
setLoading(false); setLoading(false);
@@ -147,7 +155,6 @@ export function LoginPasskey({
}) })
.then((assertedCredential: any) => { .then((assertedCredential: any) => {
if (!assertedCredential) { if (!assertedCredential) {
setLoading(false);
setError("An error on retrieving passkey"); setError("An error on retrieving passkey");
return; return;
} }
@@ -175,28 +182,34 @@ export function LoginPasskey({
}, },
}; };
return submitLogin(data).then((resp) => { return submitLogin(data).then(async (resp) => {
if (authRequestId && resp && resp.sessionId) { const url =
return router.push( authRequestId && resp?.sessionId
`/login?` + ? await getNextUrl(
new URLSearchParams({ {
sessionId: resp.sessionId, sessionId: resp.sessionId,
authRequest: authRequestId, authRequestId: authRequestId,
}), organization: organization,
); },
} else { loginSettings?.defaultRedirectUri,
const params = new URLSearchParams({}); )
: resp?.factors?.user?.loginName
? await getNextUrl(
{
loginName: resp.factors.user.loginName,
organization: organization,
},
loginSettings?.defaultRedirectUri,
)
: null;
if (authRequestId) { if (url) {
params.set("authRequestId", authRequestId); router.push(url);
}
if (resp?.factors?.user?.loginName) {
params.set("loginName", resp.factors.user.loginName);
}
return router.push(`/signedin?` + params);
} }
}); });
})
.finally(() => {
setLoading(false);
}); });
} }
@@ -268,6 +281,7 @@ export function LoginPasskey({
return submitLoginAndContinue(pK) return submitLoginAndContinue(pK)
.catch((error) => { .catch((error) => {
setError(error); setError(error);
return;
}) })
.finally(() => { .finally(() => {
setLoading(false); setLoading(false);

View File

@@ -74,8 +74,8 @@ export function PasswordForm({
return; return;
} }
if (response && response.nextStep) { if (response && "redirect" in response && response.redirect) {
return router.push(response.nextStep); return router.push(response.redirect);
} }
} }

View File

@@ -2,12 +2,17 @@
import { registerUser } from "@/lib/server/register"; import { registerUser } from "@/lib/server/register";
import { LegalAndSupportSettings } from "@zitadel/proto/zitadel/settings/v2/legal_settings_pb"; import { LegalAndSupportSettings } from "@zitadel/proto/zitadel/settings/v2/legal_settings_pb";
import {
LoginSettings,
PasskeysType,
} from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { FieldValues, useForm } from "react-hook-form"; import { FieldValues, useForm } from "react-hook-form";
import { Alert } from "./alert"; import { Alert } from "./alert";
import { import {
AuthenticationMethod,
AuthenticationMethodRadio, AuthenticationMethodRadio,
methods, methods,
} from "./authentication-method-radio"; } from "./authentication-method-radio";
@@ -32,15 +37,17 @@ type Props = {
email?: string; email?: string;
organization?: string; organization?: string;
authRequestId?: string; authRequestId?: string;
loginSettings?: LoginSettings;
}; };
export function RegisterFormWithoutPassword({ export function RegisterForm({
legal, legal,
email, email,
firstname, firstname,
lastname, lastname,
organization, organization,
authRequestId, authRequestId,
loginSettings,
}: Props) { }: Props) {
const t = useTranslations("register"); const t = useTranslations("register");
@@ -54,7 +61,7 @@ export function RegisterFormWithoutPassword({
}); });
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const [selected, setSelected] = useState(methods[0]); const [selected, setSelected] = useState<AuthenticationMethod>(methods[0]);
const [error, setError] = useState<string>(""); const [error, setError] = useState<string>("");
const router = useRouter(); const router = useRouter();
@@ -76,11 +83,15 @@ export function RegisterFormWithoutPassword({
setLoading(false); setLoading(false);
}); });
if (response && "error" in response) { if (response && "error" in response && response.error) {
setError(response.error); setError(response.error);
return; return;
} }
if (response && "redirect" in response && response.redirect) {
return router.push(response.redirect);
}
return response; return response;
} }
@@ -98,8 +109,11 @@ export function RegisterFormWithoutPassword({
registerParams.authRequestId = authRequestId; registerParams.authRequestId = authRequestId;
} }
// redirect user to /register/password if password is chosen
if (withPassword) { if (withPassword) {
return router.push(`/register?` + new URLSearchParams(registerParams)); return router.push(
`/register/password?` + new URLSearchParams(registerParams),
);
} else { } else {
return submitAndRegister(value); return submitAndRegister(value);
} }
@@ -108,7 +122,6 @@ export function RegisterFormWithoutPassword({
const { errors } = formState; const { errors } = formState;
const [tosAndPolicyAccepted, setTosAndPolicyAccepted] = useState(false); const [tosAndPolicyAccepted, setTosAndPolicyAccepted] = useState(false);
return ( return (
<form className="w-full"> <form className="w-full">
<div className="grid grid-cols-2 gap-4 mb-4"> <div className="grid grid-cols-2 gap-4 mb-4">
@@ -146,38 +159,45 @@ export function RegisterFormWithoutPassword({
/> />
</div> </div>
</div> </div>
{legal && ( {legal && (
<PrivacyPolicyCheckboxes <PrivacyPolicyCheckboxes
legal={legal} legal={legal}
onChange={setTosAndPolicyAccepted} onChange={setTosAndPolicyAccepted}
/> />
)} )}
<p className="mt-4 ztdl-p mb-6 block text-left">{t("selectMethod")}</p> <p className="mt-4 ztdl-p mb-6 block text-left">{t("selectMethod")}</p>
{/* show chooser if both methods are allowed */}
{loginSettings &&
loginSettings.allowUsernamePassword &&
loginSettings.passkeysType == PasskeysType.ALLOWED && (
<div className="pb-4"> <div className="pb-4">
<AuthenticationMethodRadio <AuthenticationMethodRadio
selected={selected} selected={selected}
selectionChanged={setSelected} selectionChanged={setSelected}
/> />
</div> </div>
)}
{error && ( {error && (
<div className="py-4"> <div className="py-4">
<Alert>{error}</Alert> <Alert>{error}</Alert>
</div> </div>
)} )}
<div className="mt-8 flex w-full flex-row items-center justify-between"> <div className="mt-8 flex w-full flex-row items-center justify-between">
<BackButton data-testid="back-button" /> <BackButton data-testid="back-button" />
<Button <Button
type="submit" type="submit"
variant={ButtonVariants.Primary} variant={ButtonVariants.Primary}
disabled={loading || !formState.isValid || !tosAndPolicyAccepted} disabled={loading || !formState.isValid || !tosAndPolicyAccepted}
onClick={handleSubmit((values) => onClick={handleSubmit((values) => {
submitAndContinue(values, !(selected.name === methods[0].name)), const usePasswordToContinue: boolean =
)} loginSettings?.allowUsernamePassword &&
loginSettings?.passkeysType == PasskeysType.ALLOWED
? !!!(selected === methods[0]) // choose selection if both available
: !!loginSettings?.allowUsernamePassword; // if password is chosen
// set password as default if only password is allowed
return submitAndContinue(values, usePasswordToContinue);
})}
data-testid="submit-button" data-testid="submit-button"
> >
{loading && <Spinner className="h-5 w-5 mr-2" />} {loading && <Spinner className="h-5 w-5 mr-2" />}

View File

@@ -53,6 +53,7 @@ export function RegisterPasskey({
}) })
.catch(() => { .catch(() => {
setError("Could not verify Passkey"); setError("Could not verify Passkey");
return;
}) })
.finally(() => { .finally(() => {
setLoading(false); setLoading(false);
@@ -68,6 +69,7 @@ export function RegisterPasskey({
}) })
.catch(() => { .catch(() => {
setError("Could not register passkey"); setError("Could not register passkey");
return;
}) })
.finally(() => { .finally(() => {
setLoading(false); setLoading(false);

View File

@@ -1,7 +1,9 @@
"use client"; "use client";
import { coerceToArrayBuffer, coerceToBase64Url } from "@/helpers/base64"; import { coerceToArrayBuffer, coerceToBase64Url } from "@/helpers/base64";
import { getNextUrl } from "@/lib/client";
import { addU2F, verifyU2F } from "@/lib/server/u2f"; import { addU2F, verifyU2F } from "@/lib/server/u2f";
import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { RegisterU2FResponse } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; import { RegisterU2FResponse } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -17,6 +19,7 @@ type Props = {
authRequestId?: string; authRequestId?: string;
organization?: string; organization?: string;
checkAfter: boolean; checkAfter: boolean;
loginSettings?: LoginSettings;
}; };
export function RegisterU2f({ export function RegisterU2f({
@@ -25,6 +28,7 @@ export function RegisterU2f({
organization, organization,
authRequestId, authRequestId,
checkAfter, checkAfter,
loginSettings,
}: Props) { }: Props) {
const t = useTranslations("u2f"); const t = useTranslations("u2f");
@@ -50,6 +54,7 @@ export function RegisterU2f({
}) })
.catch(() => { .catch(() => {
setError("An error on verifying passkey occurred"); setError("An error on verifying passkey occurred");
return;
}) })
.finally(() => { .finally(() => {
setLoading(false); setLoading(false);
@@ -57,12 +62,13 @@ export function RegisterU2f({
if (response && "error" in response && response?.error) { if (response && "error" in response && response?.error) {
setError(response?.error); setError(response?.error);
return;
} }
return response; return response;
} }
async function submitRegisterAndContinue(): Promise<boolean | void> { async function submitRegisterAndContinue(): Promise<boolean | void | null> {
setError(""); setError("");
setLoading(true); setLoading(true);
const response = await addU2F({ const response = await addU2F({
@@ -70,6 +76,7 @@ export function RegisterU2f({
}) })
.catch(() => { .catch(() => {
setError("An error on registering passkey"); setError("An error on registering passkey");
return;
}) })
.finally(() => { .finally(() => {
setLoading(false); setLoading(false);
@@ -77,6 +84,7 @@ export function RegisterU2f({
if (response && "error" in response && response?.error) { if (response && "error" in response && response?.error) {
setError(response?.error); setError(response?.error);
return;
} }
if (!response || !("u2fId" in response)) { if (!response || !("u2fId" in response)) {
@@ -146,38 +154,47 @@ export function RegisterU2f({
return; return;
} }
if (checkAfter) {
const paramsToContinue = new URLSearchParams({}); const paramsToContinue = new URLSearchParams({});
let urlToContinue = "/accounts";
if (sessionId) { if (sessionId) {
paramsToContinue.append("sessionId", sessionId); paramsToContinue.append("sessionId", sessionId);
} }
if (loginName) { if (loginName) {
paramsToContinue.append("loginName", loginName); paramsToContinue.append("loginName", loginName);
} }
if (organization) { if (organization) {
paramsToContinue.append("organization", organization); paramsToContinue.append("organization", organization);
} }
if (checkAfter) {
if (authRequestId) { if (authRequestId) {
paramsToContinue.append("authRequestId", authRequestId); paramsToContinue.append("authRequestId", authRequestId);
} }
urlToContinue = `/u2f?` + paramsToContinue;
} else if (authRequestId && sessionId) {
if (authRequestId) {
paramsToContinue.append("authRequest", authRequestId);
}
urlToContinue = `/login?` + paramsToContinue;
} else if (loginName) {
if (authRequestId) {
paramsToContinue.append("authRequestId", authRequestId);
}
urlToContinue = `/signedin?` + paramsToContinue;
}
router.push(urlToContinue); return router.push(`/u2f?` + paramsToContinue);
} else {
const url =
authRequestId && sessionId
? await getNextUrl(
{
sessionId: sessionId,
authRequestId: authRequestId,
organization: organization,
},
loginSettings?.defaultRedirectUri,
)
: loginName
? await getNextUrl(
{
loginName: loginName,
organization: organization,
},
loginSettings?.defaultRedirectUri,
)
: null;
if (url) {
return router.push(url);
}
}
} }
} }

View File

@@ -1,11 +1,12 @@
"use client"; "use client";
import { cleanupSession } from "@/lib/server/session"; import { sendLoginname } from "@/lib/server/loginname";
import { cleanupSession, continueWithSession } from "@/lib/server/session";
import { XCircleIcon } from "@heroicons/react/24/outline"; import { XCircleIcon } from "@heroicons/react/24/outline";
import { Timestamp, timestampDate } from "@zitadel/client"; import { Timestamp, timestampDate } from "@zitadel/client";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import moment from "moment"; import moment from "moment";
import Link from "next/link"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { Avatar } from "./avatar"; import { Avatar } from "./avatar";
@@ -43,6 +44,7 @@ export function SessionItem({
}) })
.catch((error) => { .catch((error) => {
setError(error.message); setError(error.message);
return;
}) })
.finally(() => { .finally(() => {
setLoading(false); setLoading(false);
@@ -55,43 +57,41 @@ export function SessionItem({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const router = useRouter();
return ( return (
<Link <button
prefetch={false} onClick={async () => {
href={ if (valid && session?.factors?.user) {
valid && authRequestId return continueWithSession({
? `/login?` + ...session,
new URLSearchParams({ authRequestId: authRequestId,
// loginName: session.factors?.user?.loginName as string, });
sessionId: session.id, } else if (session.factors?.user) {
authRequest: authRequestId, setLoading(true);
const res = await sendLoginname({
loginName: session.factors?.user?.loginName,
organization: session.factors.user.organizationId,
authRequestId: authRequestId,
}) })
: !valid .catch(() => {
? `/loginname?` + setError("An internal error occurred");
new URLSearchParams( return;
authRequestId })
? { .finally(() => {
loginName: session.factors?.user?.loginName as string, setLoading(false);
submit: "true", });
authRequestId,
if (res?.redirect) {
return router.push(res.redirect);
} }
: {
loginName: session.factors?.user?.loginName as string, if (res?.error) {
submit: "true", setError(res.error);
}, return;
)
: "/signedin?" +
new URLSearchParams(
authRequestId
? {
loginName: session.factors?.user?.loginName as string,
authRequestId,
} }
: {
loginName: session.factors?.user?.loginName as string,
},
)
} }
}}
className="group flex flex-row items-center bg-background-light-400 dark:bg-background-dark-400 border border-divider-light hover:shadow-lg dark:hover:bg-white/10 py-2 px-4 rounded-md transition-all" className="group flex flex-row items-center bg-background-light-400 dark:bg-background-dark-400 border border-divider-light hover:shadow-lg dark:hover:bg-white/10 py-2 px-4 rounded-md transition-all"
> >
<div className="pr-4"> <div className="pr-4">
@@ -132,6 +132,6 @@ export function SessionItem({
}} }}
/> />
</div> </div>
</Link> </button>
); );
} }

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import { timestampMs } from "@zitadel/client";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useState } from "react"; import { useState } from "react";
@@ -11,6 +12,14 @@ type Props = {
authRequestId?: string; authRequestId?: string;
}; };
function sortFc(a: Session, b: Session) {
if (a.changeDate && b.changeDate) {
return timestampMs(a.changeDate) - timestampMs(b.changeDate);
} else {
return 0;
}
}
export function SessionsList({ sessions, authRequestId }: Props) { export function SessionsList({ sessions, authRequestId }: Props) {
const t = useTranslations("accounts"); const t = useTranslations("accounts");
const [list, setList] = useState<Session[]>(sessions); const [list, setList] = useState<Session[]>(sessions);
@@ -18,6 +27,7 @@ export function SessionsList({ sessions, authRequestId }: Props) {
<div className="flex flex-col space-y-2"> <div className="flex flex-col space-y-2">
{list {list
.filter((session) => session?.factors?.user?.loginName) .filter((session) => session?.factors?.user?.loginName)
.sort(sortFc)
.map((session, index) => { .map((session, index) => {
return ( return (
<SessionItem <SessionItem

View File

@@ -11,7 +11,7 @@ import { create } from "@zitadel/client";
import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb"; import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { redirect } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { FieldValues, useForm } from "react-hook-form"; import { FieldValues, useForm } from "react-hook-form";
import { Alert } from "./alert"; import { Alert } from "./alert";
@@ -60,6 +60,8 @@ export function SetPasswordForm({
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string>(""); const [error, setError] = useState<string>("");
const router = useRouter();
async function submitPassword(values: Inputs) { async function submitPassword(values: Inputs) {
setLoading(true); setLoading(true);
let payload: { userId: string; password: string; code?: string } = { let payload: { userId: string; password: string; code?: string } = {
@@ -127,8 +129,12 @@ export function SetPasswordForm({
return; return;
} }
if (passwordResponse && passwordResponse.nextStep) { if (
return redirect(passwordResponse.nextStep); passwordResponse &&
"redirect" in passwordResponse &&
passwordResponse.redirect
) {
return router.push(passwordResponse.redirect);
} }
return; return;

View File

@@ -9,6 +9,7 @@ import {
import { registerUser } from "@/lib/server/register"; import { registerUser } from "@/lib/server/register";
import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb"; import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { FieldValues, useForm } from "react-hook-form"; import { FieldValues, useForm } from "react-hook-form";
import { Alert } from "./alert"; import { Alert } from "./alert";
@@ -56,6 +57,8 @@ export function SetRegisterPasswordForm({
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string>(""); const [error, setError] = useState<string>("");
const router = useRouter();
async function submitRegister(values: Inputs) { async function submitRegister(values: Inputs) {
setLoading(true); setLoading(true);
const response = await registerUser({ const response = await registerUser({
@@ -68,15 +71,20 @@ export function SetRegisterPasswordForm({
}) })
.catch(() => { .catch(() => {
setError("Could not register user"); setError("Could not register user");
return;
}) })
.finally(() => { .finally(() => {
setLoading(false); setLoading(false);
}); });
if (response && "error" in response) { if (response && "error" in response && response.error) {
setError(response.error); setError(response.error);
return; return;
} }
if (response && "redirect" in response && response.redirect) {
return router.push(response.redirect);
}
} }
const { errors } = formState; const { errors } = formState;

View File

@@ -56,22 +56,14 @@ export function SignInWithIdp({
}) })
.catch(() => { .catch(() => {
setError("Could not start IDP flow"); setError("Could not start IDP flow");
return;
}) })
.finally(() => { .finally(() => {
setLoading(false); setLoading(false);
}); });
return response; if (response && "redirect" in response && response?.redirect) {
} return router.push(response.redirect);
async function navigateToAuthUrl(id: string, type: IdentityProviderType) {
const startFlowResponse = await startFlow(id, idpTypeToSlug(type));
if (
startFlowResponse &&
startFlowResponse.nextStep.case === "authUrl" &&
startFlowResponse?.nextStep.value
) {
router.push(startFlowResponse.nextStep.value);
} }
} }
@@ -86,7 +78,7 @@ export function SignInWithIdp({
key={`idp-${i}`} key={`idp-${i}`}
name={idp.name} name={idp.name}
onClick={() => onClick={() =>
navigateToAuthUrl(idp.id, IdentityProviderType.APPLE) startFlow(idp.id, idpTypeToSlug(IdentityProviderType.APPLE))
} }
></SignInWithApple> ></SignInWithApple>
); );
@@ -96,7 +88,7 @@ export function SignInWithIdp({
key={`idp-${i}`} key={`idp-${i}`}
name={idp.name} name={idp.name}
onClick={() => onClick={() =>
navigateToAuthUrl(idp.id, IdentityProviderType.OAUTH) startFlow(idp.id, idpTypeToSlug(IdentityProviderType.OAUTH))
} }
></SignInWithGeneric> ></SignInWithGeneric>
); );
@@ -106,7 +98,7 @@ export function SignInWithIdp({
key={`idp-${i}`} key={`idp-${i}`}
name={idp.name} name={idp.name}
onClick={() => onClick={() =>
navigateToAuthUrl(idp.id, IdentityProviderType.OIDC) startFlow(idp.id, idpTypeToSlug(IdentityProviderType.OIDC))
} }
></SignInWithGeneric> ></SignInWithGeneric>
); );
@@ -116,7 +108,10 @@ export function SignInWithIdp({
key={`idp-${i}`} key={`idp-${i}`}
name={idp.name} name={idp.name}
onClick={() => onClick={() =>
navigateToAuthUrl(idp.id, IdentityProviderType.GITHUB) startFlow(
idp.id,
idpTypeToSlug(IdentityProviderType.GITHUB),
)
} }
></SignInWithGithub> ></SignInWithGithub>
); );
@@ -126,7 +121,10 @@ export function SignInWithIdp({
key={`idp-${i}`} key={`idp-${i}`}
name={idp.name} name={idp.name}
onClick={() => onClick={() =>
navigateToAuthUrl(idp.id, IdentityProviderType.GITHUB_ES) startFlow(
idp.id,
idpTypeToSlug(IdentityProviderType.GITHUB_ES),
)
} }
></SignInWithGithub> ></SignInWithGithub>
); );
@@ -136,7 +134,10 @@ export function SignInWithIdp({
key={`idp-${i}`} key={`idp-${i}`}
name={idp.name} name={idp.name}
onClick={() => onClick={() =>
navigateToAuthUrl(idp.id, IdentityProviderType.AZURE_AD) startFlow(
idp.id,
idpTypeToSlug(IdentityProviderType.AZURE_AD),
)
} }
></SignInWithAzureAd> ></SignInWithAzureAd>
); );
@@ -147,7 +148,10 @@ export function SignInWithIdp({
e2e="google" e2e="google"
name={idp.name} name={idp.name}
onClick={() => onClick={() =>
navigateToAuthUrl(idp.id, IdentityProviderType.GOOGLE) startFlow(
idp.id,
idpTypeToSlug(IdentityProviderType.GOOGLE),
)
} }
></SignInWithGoogle> ></SignInWithGoogle>
); );
@@ -157,7 +161,10 @@ export function SignInWithIdp({
key={`idp-${i}`} key={`idp-${i}`}
name={idp.name} name={idp.name}
onClick={() => onClick={() =>
navigateToAuthUrl(idp.id, IdentityProviderType.GITLAB) startFlow(
idp.id,
idpTypeToSlug(IdentityProviderType.GITLAB),
)
} }
></SignInWithGitlab> ></SignInWithGitlab>
); );
@@ -167,9 +174,9 @@ export function SignInWithIdp({
key={`idp-${i}`} key={`idp-${i}`}
name={idp.name} name={idp.name}
onClick={() => onClick={() =>
navigateToAuthUrl( startFlow(
idp.id, idp.id,
IdentityProviderType.GITLAB_SELF_HOSTED, idpTypeToSlug(IdentityProviderType.GITLAB_SELF_HOSTED),
) )
} }
></SignInWithGitlab> ></SignInWithGitlab>

View File

@@ -0,0 +1,9 @@
import { ReactNode } from "react";
export function Skeleton({ children }: { children?: ReactNode }) {
return (
<div className="skeleton py-12 px-8 rounded-lg bg-background-light-600 dark:bg-background-dark-600 flex flex-row items-center justify-center">
{children}
</div>
);
}

View File

@@ -1,5 +1,7 @@
"use client"; "use client";
import { getNextUrl } from "@/lib/client";
import { verifyTOTP } from "@/lib/server-actions"; import { verifyTOTP } from "@/lib/server-actions";
import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -24,6 +26,7 @@ type Props = {
authRequestId?: string; authRequestId?: string;
organization?: string; organization?: string;
checkAfter?: boolean; checkAfter?: boolean;
loginSettings?: LoginSettings;
}; };
export function TotpRegister({ export function TotpRegister({
uri, uri,
@@ -33,6 +36,7 @@ export function TotpRegister({
authRequestId, authRequestId,
organization, organization,
checkAfter, checkAfter,
loginSettings,
}: Props) { }: Props) {
const t = useTranslations("otp"); const t = useTranslations("otp");
@@ -50,7 +54,7 @@ export function TotpRegister({
async function continueWithCode(values: Inputs) { async function continueWithCode(values: Inputs) {
setLoading(true); setLoading(true);
return verifyTOTP(values.code, loginName, organization) return verifyTOTP(values.code, loginName, organization)
.then((response) => { .then(async () => {
// if attribute is set, validate MFA after it is setup, otherwise proceed as usual (when mfa is enforced to login) // if attribute is set, validate MFA after it is setup, otherwise proceed as usual (when mfa is enforced to login)
if (checkAfter) { if (checkAfter) {
const params = new URLSearchParams({}); const params = new URLSearchParams({});
@@ -67,35 +71,34 @@ export function TotpRegister({
return router.push(`/otp/time-based?` + params); return router.push(`/otp/time-based?` + params);
} else { } else {
if (authRequestId && sessionId) { const url =
const params = new URLSearchParams({ authRequestId && sessionId
? await getNextUrl(
{
sessionId: sessionId, sessionId: sessionId,
authRequest: authRequestId, authRequestId: authRequestId,
}); organization: organization,
},
loginSettings?.defaultRedirectUri,
)
: loginName
? await getNextUrl(
{
loginName: loginName,
organization: organization,
},
loginSettings?.defaultRedirectUri,
)
: null;
if (organization) { if (url) {
params.append("organization", organization); return router.push(url);
}
return router.push(`/login?` + params);
} else if (loginName) {
const params = new URLSearchParams({
loginName,
});
if (authRequestId) {
params.append("authRequestId", authRequestId);
}
if (organization) {
params.append("organization", organization);
}
return router.push(`/signedin?` + params);
} }
} }
}) })
.catch((e) => { .catch((e) => {
setError(e.message); setError(e.message);
return;
}) })
.finally(() => { .finally(() => {
setLoading(false); setLoading(false);

View File

@@ -55,13 +55,19 @@ export function UsernameForm({
}) })
.catch(() => { .catch(() => {
setError("An internal error occurred"); setError("An internal error occurred");
return;
}) })
.finally(() => { .finally(() => {
setLoading(false); setLoading(false);
}); });
if (res?.redirect) {
return router.push(res.redirect);
}
if (res?.error) { if (res?.error) {
setError(res.error); setError(res.error);
return;
} }
return res; return res;

View File

@@ -3,6 +3,7 @@
import { Alert } from "@/components/alert"; import { Alert } from "@/components/alert";
import { resendVerification, sendVerification } from "@/lib/server/email"; import { resendVerification, sendVerification } from "@/lib/server/email";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { Button, ButtonVariants } from "./button"; import { Button, ButtonVariants } from "./button";
@@ -23,6 +24,8 @@ type Props = {
export function VerifyForm({ userId, code, isInvite, params }: Props) { export function VerifyForm({ userId, code, isInvite, params }: Props) {
const t = useTranslations("verify"); const t = useTranslations("verify");
const router = useRouter();
const { register, handleSubmit, formState } = useForm<Inputs>({ const { register, handleSubmit, formState } = useForm<Inputs>({
mode: "onBlur", mode: "onBlur",
defaultValues: { defaultValues: {
@@ -59,7 +62,7 @@ export function VerifyForm({ userId, code, isInvite, params }: Props) {
): Promise<boolean | void> { ): Promise<boolean | void> {
setLoading(true); setLoading(true);
await sendVerification({ const response = await sendVerification({
code: value.code, code: value.code,
userId, userId,
isInvite: isInvite, isInvite: isInvite,
@@ -71,6 +74,15 @@ export function VerifyForm({ userId, code, isInvite, params }: Props) {
.finally(() => { .finally(() => {
setLoading(false); setLoading(false);
}); });
if (response?.error) {
setError(response.error);
return;
}
if (response?.redirect) {
return router.push(response?.redirect);
}
}, },
[isInvite, userId], [isInvite, userId],
); );

View File

@@ -5,7 +5,7 @@ import { cookies } from "next/headers";
export default getRequestConfig(async () => { export default getRequestConfig(async () => {
const fallback = "en"; const fallback = "en";
const cookiesList = cookies(); const cookiesList = await cookies();
const locale: string = cookiesList.get(LANGUAGE_COOKIE_NAME)?.value ?? "en"; const locale: string = cookiesList.get(LANGUAGE_COOKIE_NAME)?.value ?? "en";
const userMessages = (await import(`../../locales/${locale}.json`)).default; const userMessages = (await import(`../../locales/${locale}.json`)).default;

View File

@@ -0,0 +1,38 @@
type FinishFlowCommand =
| {
sessionId: string;
authRequestId: string;
}
| { loginName: string };
/**
* for client: redirects user back to OIDC application or to a success page when using authRequestId, check if a default redirect and redirect to it, or just redirect to a success page with the loginName
* @param command
* @returns
*/
export async function getNextUrl(
command: FinishFlowCommand & { organization?: string },
defaultRedirectUri?: string,
): Promise<string> {
if ("sessionId" in command && "authRequestId" in command) {
const url =
`/login?` +
new URLSearchParams({
sessionId: command.sessionId,
authRequest: command.authRequestId,
});
return url;
}
if (defaultRedirectUri) {
return defaultRedirectUri;
}
const signedInUrl =
`/signedin?` +
new URLSearchParams({
loginName: command.loginName,
});
return signedInUrl;
}

View File

@@ -20,8 +20,8 @@ export type Cookie = {
type SessionCookie<T> = Cookie & T; type SessionCookie<T> = Cookie & T;
function setSessionHttpOnlyCookie<T>(sessions: SessionCookie<T>[]) { async function setSessionHttpOnlyCookie<T>(sessions: SessionCookie<T>[]) {
const cookiesList = cookies(); const cookiesList = await cookies();
return cookiesList.set({ return cookiesList.set({
name: "sessions", name: "sessions",
@@ -32,7 +32,7 @@ function setSessionHttpOnlyCookie<T>(sessions: SessionCookie<T>[]) {
} }
export async function setLanguageCookie(language: string) { export async function setLanguageCookie(language: string) {
const cookiesList = cookies(); const cookiesList = await cookies();
await cookiesList.set({ await cookiesList.set({
name: LANGUAGE_COOKIE_NAME, name: LANGUAGE_COOKIE_NAME,
@@ -46,7 +46,7 @@ export async function addSessionToCookie<T>(
session: SessionCookie<T>, session: SessionCookie<T>,
cleanup: boolean = false, cleanup: boolean = false,
): Promise<any> { ): Promise<any> {
const cookiesList = cookies(); const cookiesList = await cookies();
const stringifiedCookie = cookiesList.get("sessions"); const stringifiedCookie = cookiesList.get("sessions");
let currentSessions: SessionCookie<T>[] = stringifiedCookie?.value let currentSessions: SessionCookie<T>[] = stringifiedCookie?.value
@@ -90,7 +90,7 @@ export async function updateSessionCookie<T>(
session: SessionCookie<T>, session: SessionCookie<T>,
cleanup: boolean = false, cleanup: boolean = false,
): Promise<any> { ): Promise<any> {
const cookiesList = cookies(); const cookiesList = await cookies();
const stringifiedCookie = cookiesList.get("sessions"); const stringifiedCookie = cookiesList.get("sessions");
const sessions: SessionCookie<T>[] = stringifiedCookie?.value const sessions: SessionCookie<T>[] = stringifiedCookie?.value
@@ -121,7 +121,7 @@ export async function removeSessionFromCookie<T>(
session: SessionCookie<T>, session: SessionCookie<T>,
cleanup: boolean = false, cleanup: boolean = false,
): Promise<any> { ): Promise<any> {
const cookiesList = cookies(); const cookiesList = await cookies();
const stringifiedCookie = cookiesList.get("sessions"); const stringifiedCookie = cookiesList.get("sessions");
const sessions: SessionCookie<T>[] = stringifiedCookie?.value const sessions: SessionCookie<T>[] = stringifiedCookie?.value
@@ -143,7 +143,7 @@ export async function removeSessionFromCookie<T>(
} }
export async function getMostRecentSessionCookie<T>(): Promise<any> { export async function getMostRecentSessionCookie<T>(): Promise<any> {
const cookiesList = cookies(); const cookiesList = await cookies();
const stringifiedCookie = cookiesList.get("sessions"); const stringifiedCookie = cookiesList.get("sessions");
if (stringifiedCookie?.value) { if (stringifiedCookie?.value) {
@@ -166,7 +166,7 @@ export async function getSessionCookieById<T>({
sessionId: string; sessionId: string;
organization?: string; organization?: string;
}): Promise<SessionCookie<T>> { }): Promise<SessionCookie<T>> {
const cookiesList = cookies(); const cookiesList = await cookies();
const stringifiedCookie = cookiesList.get("sessions"); const stringifiedCookie = cookiesList.get("sessions");
if (stringifiedCookie?.value) { if (stringifiedCookie?.value) {
@@ -194,7 +194,7 @@ export async function getSessionCookieByLoginName<T>({
loginName?: string; loginName?: string;
organization?: string; organization?: string;
}): Promise<SessionCookie<T>> { }): Promise<SessionCookie<T>> {
const cookiesList = cookies(); const cookiesList = await cookies();
const stringifiedCookie = cookiesList.get("sessions"); const stringifiedCookie = cookiesList.get("sessions");
if (stringifiedCookie?.value) { if (stringifiedCookie?.value) {
@@ -222,7 +222,7 @@ export async function getSessionCookieByLoginName<T>({
export async function getAllSessionCookieIds<T>( export async function getAllSessionCookieIds<T>(
cleanup: boolean = false, cleanup: boolean = false,
): Promise<any> { ): Promise<any> {
const cookiesList = cookies(); const cookiesList = await cookies();
const stringifiedCookie = cookiesList.get("sessions"); const stringifiedCookie = cookiesList.get("sessions");
if (stringifiedCookie?.value) { if (stringifiedCookie?.value) {
@@ -253,7 +253,7 @@ export async function getAllSessionCookieIds<T>(
export async function getAllSessions<T>( export async function getAllSessions<T>(
cleanup: boolean = false, cleanup: boolean = false,
): Promise<SessionCookie<T>[]> { ): Promise<SessionCookie<T>[]> {
const cookiesList = cookies(); const cookiesList = await cookies();
const stringifiedCookie = cookiesList.get("sessions"); const stringifiedCookie = cookiesList.get("sessions");
if (stringifiedCookie?.value) { if (stringifiedCookie?.value) {
@@ -287,7 +287,7 @@ export async function getMostRecentCookieWithLoginname<T>({
loginName?: string; loginName?: string;
organization?: string; organization?: string;
}): Promise<any> { }): Promise<any> {
const cookiesList = cookies(); const cookiesList = await cookies();
const stringifiedCookie = cookiesList.get("sessions"); const stringifiedCookie = cookiesList.get("sessions");
if (stringifiedCookie?.value) { if (stringifiedCookie?.value) {

View File

@@ -7,7 +7,7 @@ import {
getSession, getSession,
setSession, setSession,
} from "@/lib/zitadel"; } from "@/lib/zitadel";
import { timestampMs } from "@zitadel/client"; import { Duration, timestampMs } from "@zitadel/client";
import { import {
Challenges, Challenges,
RequestChallenges, RequestChallenges,
@@ -30,6 +30,7 @@ export async function createSessionAndUpdateCookie(
checks: Checks, checks: Checks,
challenges: RequestChallenges | undefined, challenges: RequestChallenges | undefined,
authRequestId: string | undefined, authRequestId: string | undefined,
lifetime?: Duration,
): Promise<Session> { ): Promise<Session> {
const createdSession = await createSessionFromChecks(checks, challenges); const createdSession = await createSessionFromChecks(checks, challenges);
@@ -82,10 +83,12 @@ export async function createSessionForIdpAndUpdateCookie(
idpIntentToken?: string | undefined; idpIntentToken?: string | undefined;
}, },
authRequestId: string | undefined, authRequestId: string | undefined,
lifetime?: Duration,
): Promise<Session> { ): Promise<Session> {
const createdSession = await createSessionForUserIdAndIdpIntent( const createdSession = await createSessionForUserIdAndIdpIntent(
userId, userId,
idpIntent, idpIntent,
lifetime,
); );
if (createdSession) { if (createdSession) {
@@ -140,12 +143,14 @@ export async function setSessionAndUpdateCookie(
checks?: Checks, checks?: Checks,
challenges?: RequestChallenges, challenges?: RequestChallenges,
authRequestId?: string, authRequestId?: string,
lifetime?: Duration,
) { ) {
return setSession( return setSession(
recentCookie.id, recentCookie.id,
recentCookie.token, recentCookie.token,
challenges, challenges,
checks, checks,
lifetime,
).then((updatedSession) => { ).then((updatedSession) => {
if (updatedSession) { if (updatedSession) {
const sessionCookie: CustomCookieData = { const sessionCookie: CustomCookieData = {

View File

@@ -10,7 +10,6 @@ import {
} from "@/lib/zitadel"; } from "@/lib/zitadel";
import { create } from "@zitadel/client"; import { create } from "@zitadel/client";
import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { redirect } from "next/navigation";
import { createSessionAndUpdateCookie } from "./cookie"; import { createSessionAndUpdateCookie } from "./cookie";
type VerifyUserByEmailCommand = { type VerifyUserByEmailCommand = {
@@ -74,7 +73,7 @@ export async function sendVerification(command: VerifyUserByEmailCommand) {
if (session.factors?.user?.loginName) { if (session.factors?.user?.loginName) {
params.set("loginName", session.factors?.user?.loginName); params.set("loginName", session.factors?.user?.loginName);
} }
return redirect(`/authenticator/set?${params}`); return { redirect: `/authenticator/set?${params}` };
} }
} }
@@ -134,6 +133,6 @@ export async function sendVerificationRedirectWithoutCheck(command: {
if (session.factors?.user?.loginName) { if (session.factors?.user?.loginName) {
params.set("loginName", session.factors?.user?.loginName); params.set("loginName", session.factors?.user?.loginName);
} }
return redirect(`/authenticator/set?${params}`); return { redirect: `/authenticator/set?${params}` };
} }
} }

View File

@@ -15,5 +15,13 @@ export async function startIDPFlow(command: StartIDPFlowCommand) {
successUrl: command.successUrl, successUrl: command.successUrl,
failureUrl: command.failureUrl, failureUrl: command.failureUrl,
}, },
}).then((response) => {
if (
response &&
response.nextStep.case === "authUrl" &&
response?.nextStep.value
) {
return { redirect: response.nextStep.value };
}
}); });
} }

View File

@@ -20,7 +20,7 @@ export type RegisterUserResponse = {
}; };
export async function inviteUser(command: InviteUserCommand) { export async function inviteUser(command: InviteUserCommand) {
const host = headers().get("host"); const host = (await headers()).get("host");
const human = await addHumanUser({ const human = await addHumanUser({
email: command.email, email: command.email,

View File

@@ -5,7 +5,6 @@ import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_
import { UserState } from "@zitadel/proto/zitadel/user/v2/user_pb"; import { UserState } from "@zitadel/proto/zitadel/user/v2/user_pb";
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { idpTypeToIdentityProviderType, idpTypeToSlug } from "../idp"; import { idpTypeToIdentityProviderType, idpTypeToSlug } from "../idp";
import { import {
@@ -44,7 +43,7 @@ export async function sendLoginname(command: SendLoginnameCommand) {
}); });
if (identityProviders.length === 1) { if (identityProviders.length === 1) {
const host = headers().get("host"); const host = (await headers()).get("host");
const identityProviderType = identityProviders[0].type; const identityProviderType = identityProviders[0].type;
const provider = idpTypeToSlug(identityProviderType); const provider = idpTypeToSlug(identityProviderType);
@@ -70,7 +69,7 @@ export async function sendLoginname(command: SendLoginnameCommand) {
}); });
if (resp?.nextStep.case === "authUrl") { if (resp?.nextStep.case === "authUrl") {
return redirect(resp.nextStep.value); return { redirect: resp.nextStep.value };
} }
} }
}; };
@@ -81,7 +80,7 @@ export async function sendLoginname(command: SendLoginnameCommand) {
}); });
if (identityProviders.length === 1) { if (identityProviders.length === 1) {
const host = headers().get("host"); const host = (await headers()).get("host");
const identityProviderId = identityProviders[0].idpId; const identityProviderId = identityProviders[0].idpId;
const idp = await getIDPByID(identityProviderId); const idp = await getIDPByID(identityProviderId);
@@ -115,7 +114,7 @@ export async function sendLoginname(command: SendLoginnameCommand) {
}); });
if (resp?.nextStep.case === "authUrl") { if (resp?.nextStep.case === "authUrl") {
return redirect(resp.nextStep.value); return { redirect: resp.nextStep.value };
} }
} }
}; };
@@ -154,7 +153,7 @@ export async function sendLoginname(command: SendLoginnameCommand) {
params.append("authRequestid", command.authRequestId); params.append("authRequestid", command.authRequestId);
} }
return redirect("/password/set?" + params); return { redirect: "/password/set?" + params };
} }
const methods = await listAuthenticationMethodTypes( const methods = await listAuthenticationMethodTypes(
@@ -170,6 +169,7 @@ export async function sendLoginname(command: SendLoginnameCommand) {
const paramsVerify = new URLSearchParams({ const paramsVerify = new URLSearchParams({
loginName: session.factors?.user?.loginName, loginName: session.factors?.user?.loginName,
userId: session.factors?.user?.id, // verify needs user id userId: session.factors?.user?.id, // verify needs user id
invite: "true", // TODO: check - set this to true as we dont expect old email verification method here
}); });
if (command.organization || session.factors?.user?.organizationId) { if (command.organization || session.factors?.user?.organizationId) {
@@ -183,7 +183,7 @@ export async function sendLoginname(command: SendLoginnameCommand) {
paramsVerify.append("authRequestId", command.authRequestId); paramsVerify.append("authRequestId", command.authRequestId);
} }
redirect("/verify?" + paramsVerify); return { redirect: "/verify?" + paramsVerify };
} }
const paramsAuthenticatorSetup = new URLSearchParams({ const paramsAuthenticatorSetup = new URLSearchParams({
@@ -202,7 +202,7 @@ export async function sendLoginname(command: SendLoginnameCommand) {
paramsAuthenticatorSetup.append("authRequestId", command.authRequestId); paramsAuthenticatorSetup.append("authRequestId", command.authRequestId);
} }
redirect("/authenticator/set?" + paramsAuthenticatorSetup); return { redirect: "/authenticator/set?" + paramsAuthenticatorSetup };
} }
if (methods.authMethodTypes.length == 1) { if (methods.authMethodTypes.length == 1) {
@@ -224,7 +224,10 @@ export async function sendLoginname(command: SendLoginnameCommand) {
paramsPassword.authRequestId = command.authRequestId; paramsPassword.authRequestId = command.authRequestId;
} }
return redirect("/password?" + new URLSearchParams(paramsPassword)); return {
redirect: "/password?" + new URLSearchParams(paramsPassword),
};
case AuthenticationMethodType.PASSKEY: // AuthenticationMethodType.AUTHENTICATION_METHOD_TYPE_PASSKEY case AuthenticationMethodType.PASSKEY: // AuthenticationMethodType.AUTHENTICATION_METHOD_TYPE_PASSKEY
const paramsPasskey: any = { loginName: command.loginName }; const paramsPasskey: any = { loginName: command.loginName };
if (command.authRequestId) { if (command.authRequestId) {
@@ -236,7 +239,7 @@ export async function sendLoginname(command: SendLoginnameCommand) {
command.organization ?? session.factors?.user?.organizationId; command.organization ?? session.factors?.user?.organizationId;
} }
return redirect("/passkey?" + new URLSearchParams(paramsPasskey)); return { redirect: "/passkey?" + new URLSearchParams(paramsPasskey) };
} }
} else { } else {
// prefer passkey in favor of other methods // prefer passkey in favor of other methods
@@ -255,7 +258,7 @@ export async function sendLoginname(command: SendLoginnameCommand) {
command.organization ?? session.factors?.user?.organizationId; command.organization ?? session.factors?.user?.organizationId;
} }
return redirect("/passkey?" + new URLSearchParams(passkeyParams)); return { redirect: "/passkey?" + new URLSearchParams(passkeyParams) };
} else if ( } else if (
methods.authMethodTypes.includes(AuthenticationMethodType.IDP) methods.authMethodTypes.includes(AuthenticationMethodType.IDP)
) { ) {
@@ -275,9 +278,9 @@ export async function sendLoginname(command: SendLoginnameCommand) {
command.organization ?? session.factors?.user?.organizationId; command.organization ?? session.factors?.user?.organizationId;
} }
return redirect( return {
"/password?" + new URLSearchParams(paramsPasswordDefault), redirect: "/password?" + new URLSearchParams(paramsPasswordDefault),
); };
} }
} }
} }
@@ -325,7 +328,7 @@ export async function sendLoginname(command: SendLoginnameCommand) {
params.set("loginName", command.loginName); params.set("loginName", command.loginName);
} }
return redirect("/register?" + params); return { redirect: "/register?" + params };
} }
} }
@@ -342,7 +345,7 @@ export async function sendLoginname(command: SendLoginnameCommand) {
paramsPasswordDefault.append("organization", command.organization); paramsPasswordDefault.append("organization", command.organization);
} }
return redirect("/password?" + paramsPasswordDefault); return { redirect: "/password?" + paramsPasswordDefault };
} }
// fallbackToPassword // fallbackToPassword

View File

@@ -12,6 +12,7 @@ import {
getSessionCookieById, getSessionCookieById,
getSessionCookieByLoginName, getSessionCookieByLoginName,
} from "../cookies"; } from "../cookies";
import { getLoginSettings } from "../zitadel";
export type SetOTPCommand = { export type SetOTPCommand = {
loginName?: string; loginName?: string;
@@ -23,22 +24,23 @@ export type SetOTPCommand = {
}; };
export async function setOTP(command: SetOTPCommand) { export async function setOTP(command: SetOTPCommand) {
const recentPromise = command.sessionId const recentSession = command.sessionId
? getSessionCookieById({ sessionId: command.sessionId }).catch((error) => { ? await getSessionCookieById({ sessionId: command.sessionId }).catch(
(error) => {
return Promise.reject(error); return Promise.reject(error);
}) },
)
: command.loginName : command.loginName
? getSessionCookieByLoginName({ ? await getSessionCookieByLoginName({
loginName: command.loginName, loginName: command.loginName,
organization: command.organization, organization: command.organization,
}).catch((error) => { }).catch((error) => {
return Promise.reject(error); return Promise.reject(error);
}) })
: getMostRecentSessionCookie().catch((error) => { : await getMostRecentSessionCookie().catch((error) => {
return Promise.reject(error); return Promise.reject(error);
}); });
return recentPromise.then((recent) => {
const checks = create(ChecksSchema, {}); const checks = create(ChecksSchema, {});
if (command.method === "time-based") { if (command.method === "time-based") {
@@ -55,11 +57,14 @@ export async function setOTP(command: SetOTPCommand) {
}); });
} }
const loginSettings = await getLoginSettings(command.organization);
return setSessionAndUpdateCookie( return setSessionAndUpdateCookie(
recent, recentSession,
checks, checks,
undefined, undefined,
command.authRequestId, command.authRequestId,
loginSettings?.secondFactorCheckLifetime,
).then((session) => { ).then((session) => {
return { return {
sessionId: session.id, sessionId: session.id,
@@ -67,5 +72,4 @@ export async function setOTP(command: SetOTPCommand) {
challenges: session.challenges, challenges: session.challenges,
}; };
}); });
});
} }

View File

@@ -37,7 +37,7 @@ export async function registerPasskeyLink(
sessionToken: sessionCookie.token, sessionToken: sessionCookie.token,
}); });
const host = headers().get("host"); const host = (await headers()).get("host");
if (!host) { if (!host) {
throw new Error("Could not get domain"); throw new Error("Could not get domain");
@@ -73,7 +73,7 @@ export async function verifyPasskey(command: VerifyPasskeyCommand) {
// if no name is provided, try to generate one from the user agent // if no name is provided, try to generate one from the user agent
let passkeyName = command.passkeyName; let passkeyName = command.passkeyName;
if (!!!passkeyName) { if (!!!passkeyName) {
const headersList = headers(); const headersList = await headers();
const userAgentStructure = { headers: headersList }; const userAgentStructure = { headers: headersList };
const { browser, device, os } = userAgent(userAgentStructure); const { browser, device, os } = userAgent(userAgentStructure);

View File

@@ -5,6 +5,7 @@ import {
setSessionAndUpdateCookie, setSessionAndUpdateCookie,
} from "@/lib/server/cookie"; } from "@/lib/server/cookie";
import { import {
getLoginSettings,
getUserByID, getUserByID,
listAuthenticationMethodTypes, listAuthenticationMethodTypes,
listUsers, listUsers,
@@ -16,10 +17,11 @@ import {
Checks, Checks,
ChecksSchema, ChecksSchema,
} from "@zitadel/proto/zitadel/session/v2/session_service_pb"; } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { User, UserState } from "@zitadel/proto/zitadel/user/v2/user_pb"; import { User, UserState } from "@zitadel/proto/zitadel/user/v2/user_pb";
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { redirect } from "next/navigation"; import { getNextUrl } from "../client";
import { getSessionCookieByLoginName } from "../cookies"; import { getSessionCookieByLoginName } from "../cookies";
type ResetPasswordCommand = { type ResetPasswordCommand = {
@@ -28,7 +30,7 @@ type ResetPasswordCommand = {
}; };
export async function resetPassword(command: ResetPasswordCommand) { export async function resetPassword(command: ResetPasswordCommand) {
const host = headers().get("host"); const host = (await headers()).get("host");
const users = await listUsers({ const users = await listUsers({
loginName: command.loginName, loginName: command.loginName,
@@ -65,6 +67,8 @@ export async function sendPassword(command: UpdateSessionCommand) {
let session; let session;
let user: User; let user: User;
let loginSettings: LoginSettings | undefined;
if (!sessionCookie) { if (!sessionCookie) {
const users = await listUsers({ const users = await listUsers({
loginName: command.loginName, loginName: command.loginName,
@@ -79,10 +83,13 @@ export async function sendPassword(command: UpdateSessionCommand) {
password: { password: command.checks.password?.password }, password: { password: command.checks.password?.password },
}); });
loginSettings = await getLoginSettings(command.organization);
session = await createSessionAndUpdateCookie( session = await createSessionAndUpdateCookie(
checks, checks,
undefined, undefined,
command.authRequestId, command.authRequestId,
loginSettings?.passwordCheckLifetime,
); );
} }
@@ -94,6 +101,7 @@ export async function sendPassword(command: UpdateSessionCommand) {
command.checks, command.checks,
undefined, undefined,
command.authRequestId, command.authRequestId,
loginSettings?.passwordCheckLifetime,
); );
if (!session?.factors?.user?.id) { if (!session?.factors?.user?.id) {
@@ -109,6 +117,12 @@ export async function sendPassword(command: UpdateSessionCommand) {
user = userResponse.user; user = userResponse.user;
} }
if (!loginSettings) {
loginSettings = await getLoginSettings(
command.organization ?? session.factors?.user?.organizationId,
);
}
if (!session?.factors?.user?.id || !sessionCookie) { if (!session?.factors?.user?.id || !sessionCookie) {
return { error: "Could not create session for user" }; return { error: "Could not create session for user" };
} }
@@ -153,13 +167,13 @@ export async function sendPassword(command: UpdateSessionCommand) {
const factor = availableSecondFactors[0]; const factor = availableSecondFactors[0];
// if passwordless is other method, but user selected password as alternative, perform a login // if passwordless is other method, but user selected password as alternative, perform a login
if (factor === AuthenticationMethodType.TOTP) { if (factor === AuthenticationMethodType.TOTP) {
return redirect(`/otp/time-based?` + params); return { redirect: `/otp/time-based?` + params };
} else if (factor === AuthenticationMethodType.OTP_SMS) { } else if (factor === AuthenticationMethodType.OTP_SMS) {
return redirect(`/otp/sms?` + params); return { redirect: `/otp/sms?` + params };
} else if (factor === AuthenticationMethodType.OTP_EMAIL) { } else if (factor === AuthenticationMethodType.OTP_EMAIL) {
return redirect(`/otp/email?` + params); return { redirect: `/otp/email?` + params };
} else if (factor === AuthenticationMethodType.U2F) { } else if (factor === AuthenticationMethodType.U2F) {
return redirect(`/u2f?` + params); return { redirect: `/u2f?` + params };
} }
} else if (availableSecondFactors?.length >= 1) { } else if (availableSecondFactors?.length >= 1) {
const params = new URLSearchParams({ const params = new URLSearchParams({
@@ -177,7 +191,7 @@ export async function sendPassword(command: UpdateSessionCommand) {
); );
} }
return redirect(`/mfa?` + params); return { redirect: `/mfa?` + params };
} else if (user.state === UserState.INITIAL) { } else if (user.state === UserState.INITIAL) {
const params = new URLSearchParams({ const params = new URLSearchParams({
loginName: session.factors.user.loginName, loginName: session.factors.user.loginName,
@@ -194,7 +208,7 @@ export async function sendPassword(command: UpdateSessionCommand) {
); );
} }
return redirect(`/password/change?` + params); return { redirect: `/password/change?` + params };
} else if (command.forceMfa && !availableSecondFactors.length) { } else if (command.forceMfa && !availableSecondFactors.length) {
const params = new URLSearchParams({ const params = new URLSearchParams({
loginName: session.factors.user.loginName, loginName: session.factors.user.loginName,
@@ -214,7 +228,7 @@ export async function sendPassword(command: UpdateSessionCommand) {
} }
// TODO: provide a way to setup passkeys on mfa page? // TODO: provide a way to setup passkeys on mfa page?
return redirect(`/mfa/set?` + params); return { redirect: `/mfa/set?` + params };
} }
// TODO: implement passkey setup // TODO: implement passkey setup
@@ -240,41 +254,28 @@ export async function sendPassword(command: UpdateSessionCommand) {
// return router.push(`/passkey/set?` + params); // return router.push(`/passkey/set?` + params);
// } // }
else if (command.authRequestId && session.id) { else if (command.authRequestId && session.id) {
const params = new URLSearchParams({ const nextUrl = await getNextUrl(
{
sessionId: session.id, sessionId: session.id,
authRequest: command.authRequestId,
});
if (command.organization || session.factors?.user?.organizationId) {
params.append(
"organization",
command.organization ?? session.factors?.user?.organizationId,
);
}
return { nextStep: `/login?${params}` };
}
// without OIDC flow
const params = new URLSearchParams(
command.authRequestId
? {
loginName: session.factors.user.loginName,
authRequestId: command.authRequestId, authRequestId: command.authRequestId,
} organization:
: {
loginName: session.factors.user.loginName,
},
);
if (command.organization || session.factors?.user?.organizationId) {
params.append(
"organization",
command.organization ?? session.factors?.user?.organizationId, command.organization ?? session.factors?.user?.organizationId,
},
loginSettings?.defaultRedirectUri,
); );
return { redirect: nextUrl };
} }
return redirect(`/signedin?` + params); const url = await getNextUrl(
{
loginName: session.factors.user.loginName,
organization: session.factors?.user?.organizationId,
},
loginSettings?.defaultRedirectUri,
);
return { redirect: url };
} }
export async function changePassword(command: { export async function changePassword(command: {

View File

@@ -1,14 +1,14 @@
"use server"; "use server";
import { createSessionAndUpdateCookie } from "@/lib/server/cookie"; import { createSessionAndUpdateCookie } from "@/lib/server/cookie";
import { addHumanUser } from "@/lib/zitadel"; import { addHumanUser, getLoginSettings } from "@/lib/zitadel";
import { create } from "@zitadel/client"; import { create } from "@zitadel/client";
import { Factors } from "@zitadel/proto/zitadel/session/v2/session_pb"; import { Factors } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { import {
ChecksJson, ChecksJson,
ChecksSchema, ChecksSchema,
} from "@zitadel/proto/zitadel/session/v2/session_service_pb"; } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { redirect } from "next/navigation"; import { getNextUrl } from "../client";
type RegisterUserCommand = { type RegisterUserCommand = {
email: string; email: string;
@@ -37,6 +37,8 @@ export async function registerUser(command: RegisterUserCommand) {
return { error: "Could not create user" }; return { error: "Could not create user" };
} }
const loginSettings = await getLoginSettings(command.organization);
let checkPayload: any = { let checkPayload: any = {
user: { search: { case: "userId", value: human.userId } }, user: { search: { case: "userId", value: human.userId } },
}; };
@@ -54,6 +56,7 @@ export async function registerUser(command: RegisterUserCommand) {
checks, checks,
undefined, undefined,
command.authRequestId, command.authRequestId,
command.password ? loginSettings?.passwordCheckLifetime : undefined,
); );
if (!session || !session.factors?.user) { if (!session || !session.factors?.user) {
@@ -70,20 +73,22 @@ export async function registerUser(command: RegisterUserCommand) {
params.append("authRequestId", command.authRequestId); params.append("authRequestId", command.authRequestId);
} }
return redirect("/passkey/set?" + params); return { redirect: "/passkey/set?" + params };
} else { } else {
const params = new URLSearchParams({ const url = await getNextUrl(
command.authRequestId && session.id
? {
sessionId: session.id,
authRequestId: command.authRequestId,
organization: session.factors.user.organizationId,
}
: {
loginName: session.factors.user.loginName, loginName: session.factors.user.loginName,
organization: session.factors.user.organizationId, organization: session.factors.user.organizationId,
}); },
loginSettings?.defaultRedirectUri,
);
if (command.authRequestId && session.factors.user.id) { return { redirect: url };
params.append("authRequest", command.authRequestId);
params.append("sessionId", session.id);
return redirect("/login?" + params);
} else {
return redirect("/signedin?" + params);
}
} }
} }

View File

@@ -4,9 +4,19 @@ import {
createSessionForIdpAndUpdateCookie, createSessionForIdpAndUpdateCookie,
setSessionAndUpdateCookie, setSessionAndUpdateCookie,
} from "@/lib/server/cookie"; } from "@/lib/server/cookie";
import { deleteSession, listAuthenticationMethodTypes } from "@/lib/zitadel"; import {
deleteSession,
getLoginSettings,
getUserByID,
listAuthenticationMethodTypes,
} from "@/lib/zitadel";
import { Duration } from "@zitadel/client";
import { RequestChallenges } from "@zitadel/proto/zitadel/session/v2/challenge_pb"; import { RequestChallenges } from "@zitadel/proto/zitadel/session/v2/challenge_pb";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { getNextUrl } from "../client";
import { import {
getMostRecentSessionCookie, getMostRecentSessionCookie,
getSessionCookieById, getSessionCookieById,
@@ -31,7 +41,75 @@ export async function createNewSessionForIdp(options: CreateNewSessionCommand) {
if (!userId || !idpIntent) { if (!userId || !idpIntent) {
throw new Error("No userId or loginName provided"); throw new Error("No userId or loginName provided");
} }
return createSessionForIdpAndUpdateCookie(userId, idpIntent, authRequestId);
const user = await getUserByID(userId);
if (!user) {
return { error: "Could not find user" };
}
const loginSettings = await getLoginSettings(user.details?.resourceOwner);
const session = await createSessionForIdpAndUpdateCookie(
userId,
idpIntent,
authRequestId,
loginSettings?.externalLoginCheckLifetime,
);
if (!session || !session.factors?.user) {
return { error: "Could not create session" };
}
const url = await getNextUrl(
authRequestId && session.id
? {
sessionId: session.id,
authRequestId: authRequestId,
organization: session.factors.user.organizationId,
}
: {
loginName: session.factors.user.loginName,
organization: session.factors.user.organizationId,
},
loginSettings?.defaultRedirectUri,
);
if (url) {
return { redirect: url };
}
}
export async function continueWithSession({
authRequestId,
...session
}: Session & { authRequestId?: string }) {
const loginSettings = await getLoginSettings(
session.factors?.user?.organizationId,
);
const url =
authRequestId && session.id && session.factors?.user
? await getNextUrl(
{
sessionId: session.id,
authRequestId: authRequestId,
organization: session.factors.user.organizationId,
},
loginSettings?.defaultRedirectUri,
)
: session.factors?.user
? await getNextUrl(
{
loginName: session.factors.user.loginName,
organization: session.factors.user.organizationId,
},
loginSettings?.defaultRedirectUri,
)
: null;
if (url) {
return redirect(url);
}
} }
export type UpdateSessionCommand = { export type UpdateSessionCommand = {
@@ -41,6 +119,7 @@ export type UpdateSessionCommand = {
checks?: Checks; checks?: Checks;
authRequestId?: string; authRequestId?: string;
challenges?: RequestChallenges; challenges?: RequestChallenges;
lifetime?: Duration;
}; };
export async function updateSession(options: UpdateSessionCommand) { export async function updateSession(options: UpdateSessionCommand) {
@@ -52,22 +131,21 @@ export async function updateSession(options: UpdateSessionCommand) {
authRequestId, authRequestId,
challenges, challenges,
} = options; } = options;
const sessionPromise = sessionId const recentSession = sessionId
? getSessionCookieById({ sessionId }).catch((error) => { ? await getSessionCookieById({ sessionId }).catch((error) => {
return Promise.reject(error); return Promise.reject(error);
}) })
: loginName : loginName
? getSessionCookieByLoginName({ loginName, organization }).catch( ? await getSessionCookieByLoginName({ loginName, organization }).catch(
(error) => { (error) => {
return Promise.reject(error); return Promise.reject(error);
}, },
) )
: getMostRecentSessionCookie().catch((error) => { : await getMostRecentSessionCookie().catch((error) => {
return Promise.reject(error); return Promise.reject(error);
}); });
// TODO remove ports from host header for URL with port const host = (await headers()).get("host");
const host = "localhost";
if ( if (
host && host &&
@@ -76,16 +154,24 @@ export async function updateSession(options: UpdateSessionCommand) {
!challenges.webAuthN.domain !challenges.webAuthN.domain
) { ) {
const [hostname, port] = host.split(":"); const [hostname, port] = host.split(":");
challenges.webAuthN.domain = hostname; challenges.webAuthN.domain = hostname;
} }
const recent = await sessionPromise; const loginSettings = await getLoginSettings(organization);
const lifetime = checks?.webAuthN
? loginSettings?.multiFactorCheckLifetime // TODO different lifetime for webauthn u2f/passkey
: checks?.otpEmail || checks?.otpSms
? loginSettings?.secondFactorCheckLifetime
: undefined;
const session = await setSessionAndUpdateCookie( const session = await setSessionAndUpdateCookie(
recent, recentSession,
checks, checks,
challenges, challenges,
authRequestId, authRequestId,
lifetime,
); );
// if password, check if user has MFA methods // if password, check if user has MFA methods

View File

@@ -32,7 +32,7 @@ export async function addU2F(command: RegisterU2FCommand) {
sessionToken: sessionCookie.token, sessionToken: sessionCookie.token,
}); });
const domain = headers().get("host"); const domain = (await headers()).get("host");
if (!domain) { if (!domain) {
return { error: "Could not get domain" }; return { error: "Could not get domain" };
@@ -54,7 +54,7 @@ export async function addU2F(command: RegisterU2FCommand) {
export async function verifyU2F(command: VerifyU2FCommand) { export async function verifyU2F(command: VerifyU2FCommand) {
let passkeyName = command.passkeyName; let passkeyName = command.passkeyName;
if (!!!passkeyName) { if (!!!passkeyName) {
const headersList = headers(); const headersList = await headers();
const userAgentStructure = { headers: headersList }; const userAgentStructure = { headers: headersList };
const { browser, device, os } = userAgent(userAgentStructure); const { browser, device, os } = userAgent(userAgentStructure);

View File

@@ -18,17 +18,11 @@ import {
VerifyU2FRegistrationRequest, VerifyU2FRegistrationRequest,
} from "@zitadel/proto/zitadel/user/v2/user_service_pb"; } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import { create, fromJson, toJson } from "@zitadel/client"; import { create, Duration } from "@zitadel/client";
import { TextQueryMethod } from "@zitadel/proto/zitadel/object/v2/object_pb"; import { TextQueryMethod } from "@zitadel/proto/zitadel/object/v2/object_pb";
import { CreateCallbackRequest } from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb"; import { CreateCallbackRequest } from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb";
import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb";
import { BrandingSettingsSchema } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb"; import { IdentityProviderType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { LegalAndSupportSettingsSchema } from "@zitadel/proto/zitadel/settings/v2/legal_settings_pb";
import {
IdentityProviderType,
LoginSettingsSchema,
} from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { PasswordComplexitySettingsSchema } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb";
import type { RedirectURLsJson } from "@zitadel/proto/zitadel/user/v2/idp_pb"; import type { RedirectURLsJson } from "@zitadel/proto/zitadel/user/v2/idp_pb";
import { import {
NotificationType, NotificationType,
@@ -43,15 +37,9 @@ import {
User, User,
UserState, UserState,
} from "@zitadel/proto/zitadel/user/v2/user_pb"; } from "@zitadel/proto/zitadel/user/v2/user_pb";
import { unstable_cache } from "next/cache"; import { unstable_cacheLife as cacheLife } from "next/cache";
import { PROVIDER_MAPPING } from "./idp"; import { PROVIDER_MAPPING } from "./idp";
const SESSION_LIFETIME_S = 3600; // TODO load from oidc settings
const CACHE_REVALIDATION_INTERVAL_IN_SECONDS = process.env
.CACHE_REVALIDATION_INTERVAL_IN_SECONDS
? Number(process.env.CACHE_REVALIDATION_INTERVAL_IN_SECONDS)
: 3600;
const transport = createServerTransport( const transport = createServerTransport(
process.env.ZITADEL_SERVICE_USER_TOKEN!, process.env.ZITADEL_SERVICE_USER_TOKEN!,
{ baseUrl: process.env.ZITADEL_API_URL! }, { baseUrl: process.env.ZITADEL_API_URL! },
@@ -62,47 +50,31 @@ export const userService = createUserServiceClient(transport);
export const oidcService = createOIDCServiceClient(transport); export const oidcService = createOIDCServiceClient(transport);
export const idpService = createIdpServiceClient(transport); export const idpService = createIdpServiceClient(transport);
export const orgService = createOrganizationServiceClient(transport); export const orgService = createOrganizationServiceClient(transport);
export const settingsService = createSettingsServiceClient(transport); export const settingsService = createSettingsServiceClient(transport);
const useCache = process.env.DEBUG !== "true";
async function cacheWrapper<T>(callback: Promise<T>) {
"use cache";
cacheLife("hours");
return callback;
}
export async function getBrandingSettings(organization?: string) { export async function getBrandingSettings(organization?: string) {
return unstable_cache( const callback = settingsService
async () => {
return await settingsService
.getBrandingSettings({ ctx: makeReqCtx(organization) }, {}) .getBrandingSettings({ ctx: makeReqCtx(organization) }, {})
.then((resp) => .then((resp) => (resp.settings ? resp.settings : undefined));
resp.settings
? toJson(BrandingSettingsSchema, resp.settings) return useCache ? cacheWrapper(callback) : callback;
: undefined,
);
},
["brandingSettings", organization ?? "default"],
{
revalidate: CACHE_REVALIDATION_INTERVAL_IN_SECONDS,
tags: ["brandingSettings"],
},
)().then((resp) =>
resp ? fromJson(BrandingSettingsSchema, resp) : undefined,
);
} }
export async function getLoginSettings(orgId?: string) { export async function getLoginSettings(orgId?: string) {
return unstable_cache( const callback = settingsService
async () => {
return await settingsService
.getLoginSettings({ ctx: makeReqCtx(orgId) }, {}) .getLoginSettings({ ctx: makeReqCtx(orgId) }, {})
.then((resp) => .then((resp) => (resp.settings ? resp.settings : undefined));
resp.settings
? toJson(LoginSettingsSchema, resp.settings) return useCache ? cacheWrapper(callback) : callback;
: undefined,
);
},
["loginSettings", orgId ?? "default"],
{
revalidate: CACHE_REVALIDATION_INTERVAL_IN_SECONDS,
tags: ["loginSettings"],
},
)().then((resp) => (resp ? fromJson(LoginSettingsSchema, resp) : undefined));
} }
export async function listIDPLinks(userId: string) { export async function listIDPLinks(userId: string) {
@@ -132,65 +104,39 @@ export async function registerTOTP(userId: string) {
} }
export async function getGeneralSettings() { export async function getGeneralSettings() {
return settingsService const callback = settingsService
.getGeneralSettings({}, {}) .getGeneralSettings({}, {})
.then((resp) => resp.supportedLanguages); .then((resp) => resp.supportedLanguages);
return useCache ? cacheWrapper(callback) : callback;
} }
export async function getLegalAndSupportSettings(organization?: string) { export async function getLegalAndSupportSettings(organization?: string) {
return unstable_cache( const callback = settingsService
async () => {
return await settingsService
.getLegalAndSupportSettings({ ctx: makeReqCtx(organization) }, {}) .getLegalAndSupportSettings({ ctx: makeReqCtx(organization) }, {})
.then((resp) => .then((resp) => (resp.settings ? resp.settings : undefined));
resp.settings
? toJson(LegalAndSupportSettingsSchema, resp.settings) return useCache ? cacheWrapper(callback) : callback;
: undefined,
);
},
["legalAndSupportSettings", organization ?? "default"],
{
revalidate: CACHE_REVALIDATION_INTERVAL_IN_SECONDS,
tags: ["legalAndSupportSettings"],
},
)().then((resp) =>
resp ? fromJson(LegalAndSupportSettingsSchema, resp) : undefined,
);
} }
export async function getPasswordComplexitySettings(organization?: string) { export async function getPasswordComplexitySettings(organization?: string) {
return unstable_cache( const callback = settingsService
async () => {
return await settingsService
.getPasswordComplexitySettings({ ctx: makeReqCtx(organization) }) .getPasswordComplexitySettings({ ctx: makeReqCtx(organization) })
.then((resp) => .then((resp) => (resp.settings ? resp.settings : undefined));
resp.settings
? toJson(PasswordComplexitySettingsSchema, resp.settings) return useCache ? cacheWrapper(callback) : callback;
: undefined,
);
},
["complexitySettings", organization ?? "default"],
{
revalidate: CACHE_REVALIDATION_INTERVAL_IN_SECONDS,
tags: ["complexitySettings"],
},
)().then((resp) =>
resp ? fromJson(PasswordComplexitySettingsSchema, resp) : undefined,
);
} }
export async function createSessionFromChecks( export async function createSessionFromChecks(
checks: Checks, checks: Checks,
challenges: RequestChallenges | undefined, challenges: RequestChallenges | undefined,
lifetime?: Duration,
) { ) {
return sessionService.createSession( return sessionService.createSession(
{ {
checks: checks, checks: checks,
challenges, challenges,
lifetime: { lifetime,
seconds: BigInt(SESSION_LIFETIME_S),
nanos: 0,
},
}, },
{}, {},
); );
@@ -202,6 +148,7 @@ export async function createSessionForUserIdAndIdpIntent(
idpIntentId?: string | undefined; idpIntentId?: string | undefined;
idpIntentToken?: string | undefined; idpIntentToken?: string | undefined;
}, },
lifetime?: Duration,
) { ) {
return sessionService.createSession({ return sessionService.createSession({
checks: { checks: {
@@ -213,10 +160,7 @@ export async function createSessionForUserIdAndIdpIntent(
}, },
idpIntent, idpIntent,
}, },
// lifetime: { lifetime,
// seconds: 300,
// nanos: 0,
// },
}); });
} }
@@ -225,6 +169,7 @@ export async function setSession(
sessionToken: string, sessionToken: string,
challenges: RequestChallenges | undefined, challenges: RequestChallenges | undefined,
checks?: Checks, checks?: Checks,
lifetime?: Duration,
) { ) {
return sessionService.setSession( return sessionService.setSession(
{ {
@@ -233,6 +178,7 @@ export async function setSession(
challenges, challenges,
checks: checks ? checks : {}, checks: checks ? checks : {},
metadata: {}, metadata: {},
lifetime,
}, },
{}, {},
); );

View File

@@ -1,5 +1,5 @@
// include styles from the ui package // include styles from the ui package
@import "./vars.scss"; @use "./vars.scss";
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@@ -24,3 +24,42 @@ html {
.form-checkbox:checked { .form-checkbox:checked {
background-image: url("/checkbox.svg"); background-image: url("/checkbox.svg");
} }
.skeleton {
--accents-2: var(--theme-light-background-400);
--accents-1: var(--theme-light-background-500);
background-image: linear-gradient(
270deg,
var(--accents-1),
var(--accents-2),
var(--accents-2),
var(--accents-1)
);
background-size: 400% 100%;
animation: skeleton_loading 8s ease-in-out infinite;
}
.dark .skeleton {
--accents-2: var(--theme-dark-background-400);
--accents-1: var(--theme-dark-background-500);
background-image: linear-gradient(
270deg,
var(--accents-1),
var(--accents-2),
var(--accents-2),
var(--accents-1)
);
background-size: 400% 100%;
animation: skeleton_loading 8s ease-in-out infinite;
}
@keyframes skeleton_loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}

View File

@@ -1,4 +1,4 @@
const sharedConfig = require("zitadel-tailwind-config/tailwind.config.mjs"); import sharedConfig from "zitadel-tailwind-config/tailwind.config.mjs";
let colors = { let colors = {
background: { light: { contrast: {} }, dark: { contrast: {} } }, background: { light: { contrast: {} }, dark: { contrast: {} } },
@@ -35,7 +35,7 @@ types.forEach((type) => {
}); });
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
module.exports = { export default {
presets: [sharedConfig], presets: [sharedConfig],
darkMode: "class", darkMode: "class",
content: ["./src/**/*.{js,ts,jsx,tsx}"], content: ["./src/**/*.{js,ts,jsx,tsx}"],

View File

@@ -27,7 +27,9 @@
}, },
"pnpm": { "pnpm": {
"overrides": { "overrides": {
"@typescript-eslint/parser": "^7.9.0" "@typescript-eslint/parser": "^7.9.0",
"@types/react": "npm:types-react@19.0.0-rc.1",
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.1"
} }
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,6 +1,13 @@
module.exports = { module.exports = {
parser: "@babel/eslint-parser",
extends: ["next", "turbo", "prettier"], extends: ["next", "turbo", "prettier"],
rules: { rules: {
"@next/next/no-html-link-for-pages": "off", "@next/next/no-html-link-for-pages": "off",
}, },
parserOptions: {
requireConfigFile: false,
babelOptions: {
presets: ["next/babel"],
},
},
}; };

View File

@@ -7,10 +7,11 @@
"access": "public" "access": "public"
}, },
"dependencies": { "dependencies": {
"eslint-config-next": "^14.2.3",
"@typescript-eslint/parser": "^7.9.0", "@typescript-eslint/parser": "^7.9.0",
"eslint-config-next": "^14.2.18",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-config-turbo": "^2.0.9",
"eslint-plugin-react": "^7.34.1", "eslint-plugin-react": "^7.34.1",
"eslint-config-turbo": "^2.0.9" "@babel/eslint-parser": "^7.25.9"
} }
} }

View File

@@ -4,4 +4,4 @@ export { NewAuthorizationBearerInterceptor } from "./interceptors";
// TODO: Move this to `./protobuf.ts` and export it from there // TODO: Move this to `./protobuf.ts` and export it from there
export { create, fromJson, toJson } from "@bufbuild/protobuf"; export { create, fromJson, toJson } from "@bufbuild/protobuf";
export { TimestampSchema, timestampDate, timestampFromDate, timestampFromMs, timestampMs } from "@bufbuild/protobuf/wkt"; export { TimestampSchema, timestampDate, timestampFromDate, timestampFromMs, timestampMs } from "@bufbuild/protobuf/wkt";
export type { Timestamp } from "@bufbuild/protobuf/wkt"; export type { Duration, Timestamp } from "@bufbuild/protobuf/wkt";

View File

@@ -1,12 +1,8 @@
const colors = require("tailwindcss/colors"); import colors from "tailwindcss/colors";
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
module.exports = { export default {
content: [ content: ["./app/**/*.{js,ts,jsx,tsx}", "./page/**/*.{js,ts,jsx,tsx}", "./ui/**/*.{js,ts,jsx,tsx}"],
"./app/**/*.{js,ts,jsx,tsx}",
"./page/**/*.{js,ts,jsx,tsx}",
"./ui/**/*.{js,ts,jsx,tsx}",
],
future: { future: {
hoverOnlyWhenSupported: true, hoverOnlyWhenSupported: true,
}, },
@@ -48,10 +44,10 @@ module.exports = {
}, },
backgroundImage: ({ theme }) => ({ backgroundImage: ({ theme }) => ({
"dark-vc-border-gradient": `radial-gradient(at left top, ${theme( "dark-vc-border-gradient": `radial-gradient(at left top, ${theme(
"colors.gray.800" "colors.gray.800",
)}, 50px, ${theme("colors.gray.800")} 50%)`, )}, 50px, ${theme("colors.gray.800")} 50%)`,
"vc-border-gradient": `radial-gradient(at left top, ${theme( "vc-border-gradient": `radial-gradient(at left top, ${theme(
"colors.gray.200" "colors.gray.200",
)}, 50px, ${theme("colors.gray.300")} 50%)`, )}, 50px, ${theme("colors.gray.300")} 50%)`,
}), }),
keyframes: ({ theme }) => ({ keyframes: ({ theme }) => ({

1163
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff