From fb331ce935ebe1f93390232fd3e160aa5d4aaa26 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Wed, 11 Dec 2024 09:20:40 +0100 Subject: [PATCH 1/9] improve language handling --- apps/login/src/app/sessions/route.ts | 30 ---------------------------- apps/login/src/i18n/request.ts | 22 +++++++++++++++++--- apps/login/src/lib/i18n.ts | 1 + 3 files changed, 20 insertions(+), 33 deletions(-) delete mode 100644 apps/login/src/app/sessions/route.ts diff --git a/apps/login/src/app/sessions/route.ts b/apps/login/src/app/sessions/route.ts deleted file mode 100644 index 9f7655664d..0000000000 --- a/apps/login/src/app/sessions/route.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { getAllSessions } from "@/lib/cookies"; -import { listSessions } from "@/lib/zitadel"; -import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; -import { NextRequest, NextResponse } from "next/server"; - -async function loadSessions(ids: string[]): Promise { - const response = await listSessions( - ids.filter((id: string | undefined) => !!id), - ); - - return response?.sessions ?? []; -} - -export async function GET(request: NextRequest) { - const sessionCookies = await getAllSessions(); - const ids = sessionCookies.map((s) => s.id); - let sessions: Session[] = []; - if (ids && ids.length) { - sessions = await loadSessions(ids); - } - - const responseHeaders = new Headers(); - responseHeaders.set("Access-Control-Allow-Origin", "*"); - responseHeaders.set("Access-Control-Allow-Headers", "*"); - - return NextResponse.json( - { sessions }, - { status: 200, headers: responseHeaders }, - ); -} diff --git a/apps/login/src/i18n/request.ts b/apps/login/src/i18n/request.ts index 2641c0fa26..59c9da42cc 100644 --- a/apps/login/src/i18n/request.ts +++ b/apps/login/src/i18n/request.ts @@ -1,12 +1,28 @@ -import { LANGUAGE_COOKIE_NAME } from "@/lib/i18n"; +import { LANGS, LANGUAGE_COOKIE_NAME, LANGUAGE_HEADER_NAME } from "@/lib/i18n"; import deepmerge from "deepmerge"; import { getRequestConfig } from "next-intl/server"; -import { cookies } from "next/headers"; +import { cookies, headers } from "next/headers"; export default getRequestConfig(async () => { const fallback = "en"; const cookiesList = await cookies(); - const locale: string = cookiesList.get(LANGUAGE_COOKIE_NAME)?.value ?? "en"; + + let locale: string = fallback; + + const languageHeader = await (await headers()).get(LANGUAGE_HEADER_NAME); + if (languageHeader) { + const headerLocale = languageHeader.split(",")[0].split("-")[0]; // Extract the language code + if (LANGS.map((l) => l.code).includes(headerLocale)) { + locale = headerLocale; + } + } + + const languageCookie = cookiesList?.get(LANGUAGE_COOKIE_NAME); + if (languageCookie && languageCookie.value) { + if (LANGS.map((l) => l.code).includes(languageCookie.value)) { + locale = languageCookie.value; + } + } const userMessages = (await import(`../../locales/${locale}.json`)).default; const fallbackMessages = (await import(`../../locales/${fallback}.json`)) diff --git a/apps/login/src/lib/i18n.ts b/apps/login/src/lib/i18n.ts index aba1d3069c..6d41256077 100644 --- a/apps/login/src/lib/i18n.ts +++ b/apps/login/src/lib/i18n.ts @@ -23,3 +23,4 @@ export const LANGS: Lang[] = [ ]; export const LANGUAGE_COOKIE_NAME = "NEXT_LOCALE"; +export const LANGUAGE_HEADER_NAME = "accept-language"; From 1a26192a0d6b14ac56702610cf85e148f01c3552 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Wed, 11 Dec 2024 09:56:49 +0100 Subject: [PATCH 2/9] missing zh translations --- apps/login/locales/zh.json | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/login/locales/zh.json b/apps/login/locales/zh.json index 68348b3fe7..e09fe04215 100644 --- a/apps/login/locales/zh.json +++ b/apps/login/locales/zh.json @@ -122,6 +122,18 @@ } }, "register": { + "methods": { + "passkey": "密钥", + "password": "密码" + }, + "disabled": { + "title": "注册已禁用", + "description": "您的设置不允许注册新用户。" + }, + "missingdata": { + "title": "缺少数据", + "description": "请提供所有必需的数据。" + }, "title": "注册", "description": "创建您的 ZITADEL 账户。", "selectMethod": "选择您想使用的认证方法", @@ -151,7 +163,8 @@ }, "signedin": { "title": "欢迎 {user}!", - "description": "您已登录。" + "description": "您已登录。", + "continue": "继续" }, "verify": { "userIdMissing": "未提供用户 ID!", From b68ea3274814aab4a830eeb0be84198f88a3d82f Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Thu, 12 Dec 2024 16:37:58 +0100 Subject: [PATCH 3/9] let users change their password if mfa is enforce but no mfa is yet set --- .../src/app/(login)/password/change/page.tsx | 5 +- .../src/components/change-password-form.tsx | 19 ++-- apps/login/src/lib/self.ts | 14 +-- apps/login/src/lib/zitadel.ts | 90 +++++++++++++++++++ 4 files changed, 111 insertions(+), 17 deletions(-) diff --git a/apps/login/src/app/(login)/password/change/page.tsx b/apps/login/src/app/(login)/password/change/page.tsx index 1ba48a589d..d20b8b935c 100644 --- a/apps/login/src/app/(login)/password/change/page.tsx +++ b/apps/login/src/app/(login)/password/change/page.tsx @@ -32,7 +32,9 @@ export default async function Page(props: { sessionFactors?.factors?.user?.organizationId, ); - const loginSettings = await getLoginSettings(organization); + const loginSettings = await getLoginSettings( + sessionFactors?.factors?.user?.organizationId, + ); return ( @@ -68,6 +70,7 @@ export default async function Page(props: { authRequestId={authRequestId} organization={organization} passwordComplexitySettings={passwordComplexity} + loginSettings={loginSettings} /> ) : (
diff --git a/apps/login/src/components/change-password-form.tsx b/apps/login/src/components/change-password-form.tsx index 416966c62b..5cb794a473 100644 --- a/apps/login/src/components/change-password-form.tsx +++ b/apps/login/src/components/change-password-form.tsx @@ -6,10 +6,11 @@ import { symbolValidator, upperCaseValidator, } from "@/helpers/validators"; -import { setMyPassword } from "@/lib/self"; import { sendPassword } from "@/lib/server/password"; +import { checkSessionAndSetPassword } from "@/lib/zitadel"; import { create } from "@zitadel/client"; import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; @@ -31,6 +32,7 @@ type Inputs = type Props = { passwordComplexitySettings: PasswordComplexitySettings; + loginSettings?: LoginSettings; sessionId: string; loginName: string; authRequestId?: string; @@ -39,6 +41,7 @@ type Props = { export function ChangePasswordForm({ passwordComplexitySettings, + loginSettings, sessionId, loginName, authRequestId, @@ -60,9 +63,11 @@ export function ChangePasswordForm({ async function submitChange(values: Inputs) { setLoading(true); - const changeResponse = await setMyPassword({ - sessionId: sessionId, + + const changeResponse = checkSessionAndSetPassword({ + sessionId, password: values.password, + forceMfa: !!(loginSettings?.forceMfa || loginSettings?.forceMfaLocalOnly), }) .catch(() => { setError("Could not change password"); @@ -72,8 +77,12 @@ export function ChangePasswordForm({ setLoading(false); }); - if (changeResponse && "error" in changeResponse) { - setError(changeResponse.error); + if (changeResponse && "error" in changeResponse && changeResponse.error) { + setError( + typeof changeResponse.error === "string" + ? changeResponse.error + : "Unknown error", + ); return; } diff --git a/apps/login/src/lib/self.ts b/apps/login/src/lib/self.ts index ebe08fe518..6eea8206c9 100644 --- a/apps/login/src/lib/self.ts +++ b/apps/login/src/lib/self.ts @@ -1,9 +1,6 @@ "use server"; -import { - createSessionServiceClient, - createUserServiceClient, -} from "@zitadel/client/v2"; +import { createUserServiceClient } from "@zitadel/client/v2"; import { createServerTransport } from "@zitadel/node"; import { getSessionCookieById } from "./cookies"; import { getSession } from "./zitadel"; @@ -13,12 +10,6 @@ const transport = (token: string) => baseUrl: process.env.ZITADEL_API_URL!, }); -const sessionService = (sessionId: string) => { - return getSessionCookieById({ sessionId }).then((session) => { - return createSessionServiceClient(transport(session.token)); - }); -}; - const myUserService = (sessionToken: string) => { return createUserServiceClient(transport(sessionToken)); }; @@ -41,7 +32,7 @@ export async function setMyPassword({ return { error: "Could not load session" }; } - const service = await myUserService(sessionCookie.token); + const service = await myUserService(`${sessionCookie.token}`); if (!session?.factors?.user?.id) { return { error: "No user id found in session" }; @@ -56,6 +47,7 @@ export async function setMyPassword({ {}, ) .catch((error) => { + console.log(error); if (error.code === 7) { return { error: "Session is not valid." }; } diff --git a/apps/login/src/lib/zitadel.ts b/apps/login/src/lib/zitadel.ts index e273a24c65..3740c578da 100644 --- a/apps/login/src/lib/zitadel.ts +++ b/apps/login/src/lib/zitadel.ts @@ -12,6 +12,7 @@ import { RequestChallenges } from "@zitadel/proto/zitadel/session/v2/challenge_p import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; import { IDPInformation } from "@zitadel/proto/zitadel/user/v2/idp_pb"; import { + AuthenticationMethodType, RetrieveIdentityProviderIntentRequest, SetPasswordRequestSchema, VerifyPasskeyRegistrationRequest, @@ -38,6 +39,7 @@ import { UserState, } from "@zitadel/proto/zitadel/user/v2/user_pb"; import { unstable_cacheLife as cacheLife } from "next/cache"; +import { getSessionCookieById } from "./cookies"; import { PROVIDER_MAPPING } from "./idp"; const transport = createServerTransport( @@ -582,6 +584,94 @@ export async function setPassword( }); } +type CheckSessionAndSetPasswordCommand = { + sessionId: string; + password: string; + forceMfa: boolean; +}; + +export async function checkSessionAndSetPassword({ + sessionId, + password, + forceMfa, +}: CheckSessionAndSetPasswordCommand) { + const sessionCookie = await getSessionCookieById({ sessionId }); + + const { session } = await getSession({ + sessionId: sessionCookie.id, + sessionToken: sessionCookie.token, + }); + + if (!session || !session.factors?.user?.id) { + return { error: "Could not load session" }; + } + + const payload = create(SetPasswordRequestSchema, { + userId: session.factors.user.id, + newPassword: { + password, + }, + }); + + // check if the user has no password set in order to set a password + const authmethods = await listAuthenticationMethodTypes( + session.factors.user.id, + ); + + if (!authmethods) { + return { error: "Could not load auth methods" }; + } + + const requiredAuthMethodsForForceMFA = [ + AuthenticationMethodType.OTP_EMAIL, + AuthenticationMethodType.OTP_SMS, + AuthenticationMethodType.TOTP, + AuthenticationMethodType.U2F, + ]; + + const hasNoMFAMethods = requiredAuthMethodsForForceMFA.every( + (method) => !authmethods.authMethodTypes.includes(method), + ); + + // if the user has no MFA but MFA is enforced, we can set a password otherwise we use the token of the user + if (forceMfa && hasNoMFAMethods) { + return userService.setPassword(payload, {}).catch((error) => { + // throw error if failed precondition (ex. User is not yet initialized) + if (error.code === 9 && error.message) { + return { error: "Failed precondition" }; + } else { + throw error; + } + }); + } else { + const myUserService = (sessionToken: string) => { + return createUserServiceClient( + createServerTransport(sessionToken, { + baseUrl: process.env.ZITADEL_API_URL!, + }), + ); + }; + + const selfService = await myUserService(`${sessionCookie.token}`); + + return selfService + .setPassword( + { + userId: session.factors.user.id, + newPassword: { password, changeRequired: false }, + }, + {}, + ) + .catch((error) => { + console.log(error); + if (error.code === 7) { + return { error: "Session is not valid." }; + } + throw error; + }); + } +} + /** * * @param server From 911edd39b0fb1a8e9591b626088b31e1e4803ebb Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Thu, 12 Dec 2024 17:46:11 +0100 Subject: [PATCH 4/9] streamlined resend code buttons --- apps/login/locales/de.json | 2 + apps/login/locales/en.json | 2 + apps/login/locales/es.json | 2 + apps/login/locales/it.json | 2 + apps/login/locales/zh.json | 2 + .../src/components/set-password-form.tsx | 52 +++++++++++-------- apps/login/src/components/verify-form.tsx | 30 ++++++++--- 7 files changed, 62 insertions(+), 30 deletions(-) diff --git a/apps/login/locales/de.json b/apps/login/locales/de.json index 4d5f0c1530..ba0fce27e3 100644 --- a/apps/login/locales/de.json +++ b/apps/login/locales/de.json @@ -24,6 +24,7 @@ "title": "Passwort festlegen", "description": "Legen Sie das Passwort für Ihr Konto fest", "codeSent": "Ein Code wurde an Ihre E-Mail-Adresse gesendet.", + "noCodeReceived": "Keinen Code erhalten?", "resend": "Erneut senden", "submit": "Weiter" }, @@ -173,6 +174,7 @@ "verify": { "title": "Benutzer verifizieren", "description": "Geben Sie den Code ein, der in der Bestätigungs-E-Mail angegeben ist.", + "noCodeReceived": "Keinen Code erhalten?", "resendCode": "Code erneut senden", "submit": "Weiter" } diff --git a/apps/login/locales/en.json b/apps/login/locales/en.json index c7fd5e30b9..265304387f 100644 --- a/apps/login/locales/en.json +++ b/apps/login/locales/en.json @@ -24,6 +24,7 @@ "title": "Set Password", "description": "Set the password for your account", "codeSent": "A code has been sent to your email address.", + "noCodeReceived": "Didn't receive a code?", "resend": "Resend code", "submit": "Continue" }, @@ -173,6 +174,7 @@ "verify": { "title": "Verify user", "description": "Enter the Code provided in the verification email.", + "noCodeReceived": "Didn't receive a code?", "resendCode": "Resend code", "submit": "Continue" } diff --git a/apps/login/locales/es.json b/apps/login/locales/es.json index e722db5812..35bbb9385d 100644 --- a/apps/login/locales/es.json +++ b/apps/login/locales/es.json @@ -24,6 +24,7 @@ "title": "Establecer Contraseña", "description": "Establece la contraseña para tu cuenta", "codeSent": "Se ha enviado un código a su correo electrónico.", + "noCodeReceived": "¿No recibiste un código?", "resend": "Reenviar código", "submit": "Continuar" }, @@ -173,6 +174,7 @@ "verify": { "title": "Verificar usuario", "description": "Introduce el código proporcionado en el correo electrónico de verificación.", + "noCodeReceived": "¿No recibiste un código?", "resendCode": "Reenviar código", "submit": "Continuar" } diff --git a/apps/login/locales/it.json b/apps/login/locales/it.json index 9467f0ba84..4e21e3dc9d 100644 --- a/apps/login/locales/it.json +++ b/apps/login/locales/it.json @@ -24,6 +24,7 @@ "title": "Imposta Password", "description": "Imposta la password per il tuo account", "codeSent": "Un codice è stato inviato al tuo indirizzo email.", + "noCodeReceived": "Non hai ricevuto un codice?", "resend": "Invia di nuovo", "submit": "Continua" }, @@ -173,6 +174,7 @@ "verify": { "title": "Verifica utente", "description": "Inserisci il codice fornito nell'email di verifica.", + "noCodeReceived": "Non hai ricevuto un codice?", "resendCode": "Invia di nuovo il codice", "submit": "Continua" } diff --git a/apps/login/locales/zh.json b/apps/login/locales/zh.json index e09fe04215..818a8b449e 100644 --- a/apps/login/locales/zh.json +++ b/apps/login/locales/zh.json @@ -24,6 +24,7 @@ "title": "设置密码", "description": "为您的账户设置密码", "codeSent": "验证码已发送到您的邮箱。", + "noCodeReceived": "没有收到验证码?", "resend": "重发验证码", "submit": "继续" }, @@ -173,6 +174,7 @@ "verify": { "title": "验证用户", "description": "输入验证邮件中的验证码。", + "noCodeReceived": "没有收到验证码?", "resendCode": "重发验证码", "submit": "继续" } diff --git a/apps/login/src/components/set-password-form.tsx b/apps/login/src/components/set-password-form.tsx index d2e5c73940..fd801640d7 100644 --- a/apps/login/src/components/set-password-form.tsx +++ b/apps/login/src/components/set-password-form.tsx @@ -18,7 +18,7 @@ import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { FieldValues, useForm } from "react-hook-form"; -import { Alert } from "./alert"; +import { Alert, AlertType } from "./alert"; import { BackButton } from "./back-button"; import { Button, ButtonVariants } from "./button"; import { TextInput } from "./input"; @@ -192,31 +192,39 @@ export function SetPasswordForm({
{codeRequired && ( -
-
- -
- -
- +
+ + )} + {codeRequired && ( +
+
)}
diff --git a/apps/login/src/components/verify-form.tsx b/apps/login/src/components/verify-form.tsx index 377d5b9cfc..308732cba9 100644 --- a/apps/login/src/components/verify-form.tsx +++ b/apps/login/src/components/verify-form.tsx @@ -1,11 +1,12 @@ "use client"; -import { Alert } from "@/components/alert"; +import { Alert, AlertType } from "@/components/alert"; import { resendVerification, sendVerification } from "@/lib/server/email"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useState } from "react"; import { useForm } from "react-hook-form"; +import { BackButton } from "./back-button"; import { Button, ButtonVariants } from "./button"; import { TextInput } from "./input"; import { Spinner } from "./spinner"; @@ -96,6 +97,25 @@ export function VerifyForm({ userId, code, isInvite, params }: Props) { return ( <> + +
+ + {t("verify.noCodeReceived")} + + +
+
- +