mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
fix(login): translate authentication and OTP strings (#12668)
Closes #12481 Closes #12226 # Which Problems Are Solved Login V2 still contained several hardcoded authentication-method and OTP strings, preventing them from following the selected locale or being overridden through custom Login translations. Additionally, the password-reset flow already referenced `password.errors.passwordResetNotAllowed`, but the key was missing from the Login V2 translations, causing the raw translation key to be displayed when password reset is disabled. # How the Problems Are Solved * Replaced the hardcoded authentication-method labels with Login V2 translation keys. * Replaced the OTP challenge and verification fallback messages with translated values. * Reused the existing `otp.verify.resendCode` translation for the resend button's accessible label. * Added `password.errors.passwordResetNotAllowed`. * Added the new keys to all supported Login V2 locales and mirrored them in `internal/query/v2-default.json` so frontend and API-provided default translations remain consistent. * Added unit coverage for the translated authentication labels and OTP failure paths. # How the Changes Are Tested * `pnpm nx run @zitadel/login:test-unit` * `pnpm nx run @zitadel/login:lint` * `pnpm nx run @zitadel/login:build` * `pnpm nx run-many --projects @zitadel/login @zitadel/client @zitadel/proto --targets lint build test` * Manually verified Login V2 with multiple locales, including authentication-method labels, OTP failure fallbacks, and the disabled password-reset flow.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { expect, Page } from "@playwright/test";
|
||||
import en from "../../locales/en.json";
|
||||
|
||||
const codeTextInput = "code-text-input";
|
||||
|
||||
@@ -8,5 +9,5 @@ export async function codeScreen(page: Page, code: string) {
|
||||
|
||||
export async function codeScreenExpect(page: Page, code: string) {
|
||||
await expect(page.getByTestId(codeTextInput)).toHaveValue(code);
|
||||
await expect(page.getByTestId("error").locator("div")).toContainText("Could not verify OTP code");
|
||||
await expect(page.getByTestId("error").locator("div")).toContainText(en.otp.errors.couldNotVerifyCode);
|
||||
}
|
||||
|
||||
@@ -152,7 +152,8 @@
|
||||
"failedPrecondition": "فشل الشرط المسبق",
|
||||
"sessionNotValid": "الجلسة غير صالحة",
|
||||
"passwordVerificationMissing": "التحقق من كلمة المرور مفقود.",
|
||||
"passwordVerificationTooOld": "التحقق من كلمة المرور قديم جدًا. يرجى التحقق من كلمة المرور مرة أخرى."
|
||||
"passwordVerificationTooOld": "التحقق من كلمة المرور قديم جدًا. يرجى التحقق من كلمة المرور مرة أخرى.",
|
||||
"passwordResetNotAllowed": "إعادة تعيين كلمة المرور غير مسموح بها. اتصل بالمسؤول لمزيد من المعلومات."
|
||||
}
|
||||
},
|
||||
"idp": {
|
||||
@@ -261,6 +262,10 @@
|
||||
"required": {
|
||||
"code": "هذا الحقل مطلوب"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"couldNotRequestChallenge": "تعذر طلب تحدي OTP",
|
||||
"couldNotVerifyCode": "تعذر التحقق من رمز OTP"
|
||||
}
|
||||
},
|
||||
"passkey": {
|
||||
@@ -422,7 +427,15 @@
|
||||
"description": "حدد الطريقة التي ترغب في المصادقة بها",
|
||||
"noMethodsAvailable": "لا توجد طرق مصادقة متاحة",
|
||||
"allSetup": "لقد قمت بالفعل بإعداد مصادق!",
|
||||
"linkWithIDP": "أو الربط مع مزود هوية"
|
||||
"linkWithIDP": "أو الربط مع مزود هوية",
|
||||
"methods": {
|
||||
"totp": "تطبيق المصادقة",
|
||||
"u2f": "العامل الثاني العالمي",
|
||||
"otpEmail": "رمز عبر البريد الإلكتروني",
|
||||
"otpSms": "رمز عبر الرسائل القصيرة",
|
||||
"passkey": "مفاتيح المرور",
|
||||
"password": "كلمة المرور"
|
||||
}
|
||||
},
|
||||
"device": {
|
||||
"usercode": {
|
||||
|
||||
@@ -152,7 +152,8 @@
|
||||
"failedPrecondition": "Vorbedingung fehlgeschlagen",
|
||||
"sessionNotValid": "Sitzung ist nicht gültig",
|
||||
"passwordVerificationMissing": "Passwort-Verifizierung fehlt.",
|
||||
"passwordVerificationTooOld": "Passwort-Verifizierung ist zu alt. Bitte verifizieren Sie Ihr Passwort erneut."
|
||||
"passwordVerificationTooOld": "Passwort-Verifizierung ist zu alt. Bitte verifizieren Sie Ihr Passwort erneut.",
|
||||
"passwordResetNotAllowed": "Das Zurücksetzen des Passworts ist nicht zulässig. Wenden Sie sich für weitere Informationen an Ihren Administrator."
|
||||
}
|
||||
},
|
||||
"idp": {
|
||||
@@ -261,6 +262,10 @@
|
||||
"required": {
|
||||
"code": "Dieses Feld ist erforderlich"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"couldNotRequestChallenge": "OTP-Challenge konnte nicht angefordert werden",
|
||||
"couldNotVerifyCode": "OTP-Code konnte nicht verifiziert werden"
|
||||
}
|
||||
},
|
||||
"passkey": {
|
||||
@@ -422,7 +427,15 @@
|
||||
"description": "Wählen Sie die Methode, mit der Sie sich authentifizieren möchten.",
|
||||
"noMethodsAvailable": "Keine Authentifizierungsmethoden verfügbar",
|
||||
"allSetup": "Sie haben bereits einen Authentifikator eingerichtet!",
|
||||
"linkWithIDP": "oder verknüpfe mit einem Identitätsanbieter"
|
||||
"linkWithIDP": "oder verknüpfe mit einem Identitätsanbieter",
|
||||
"methods": {
|
||||
"totp": "Authentifizierungs-App",
|
||||
"u2f": "Universeller zweiter Faktor",
|
||||
"otpEmail": "Code per E-Mail",
|
||||
"otpSms": "Code per SMS",
|
||||
"passkey": "Passkeys",
|
||||
"password": "Passwort"
|
||||
}
|
||||
},
|
||||
"device": {
|
||||
"usercode": {
|
||||
|
||||
@@ -152,7 +152,8 @@
|
||||
"failedPrecondition": "Failed precondition",
|
||||
"sessionNotValid": "Session is not valid",
|
||||
"passwordVerificationMissing": "Password verification is missing.",
|
||||
"passwordVerificationTooOld": "Password verification is too old. Please verify your password again."
|
||||
"passwordVerificationTooOld": "Password verification is too old. Please verify your password again.",
|
||||
"passwordResetNotAllowed": "Password reset is not allowed. Contact your administrator for more information."
|
||||
}
|
||||
},
|
||||
"idp": {
|
||||
@@ -261,6 +262,10 @@
|
||||
"required": {
|
||||
"code": "This field is required"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"couldNotRequestChallenge": "Could not request OTP challenge",
|
||||
"couldNotVerifyCode": "Could not verify OTP code"
|
||||
}
|
||||
},
|
||||
"passkey": {
|
||||
@@ -422,7 +427,15 @@
|
||||
"description": "Select the method you would like to authenticate",
|
||||
"noMethodsAvailable": "No authentication methods available",
|
||||
"allSetup": "You have already setup an authenticator!",
|
||||
"linkWithIDP": "or link with an Identity Provider"
|
||||
"linkWithIDP": "or link with an Identity Provider",
|
||||
"methods": {
|
||||
"totp": "Authenticator App",
|
||||
"u2f": "Universal Second Factor",
|
||||
"otpEmail": "Code via Email",
|
||||
"otpSms": "Code via SMS",
|
||||
"passkey": "Passkeys",
|
||||
"password": "Password"
|
||||
}
|
||||
},
|
||||
"device": {
|
||||
"usercode": {
|
||||
|
||||
@@ -152,7 +152,8 @@
|
||||
"failedPrecondition": "Precondición fallida",
|
||||
"sessionNotValid": "La sesión no es válida",
|
||||
"passwordVerificationMissing": "Falta la verificación de la contraseña.",
|
||||
"passwordVerificationTooOld": "La verificación de la contraseña es demasiado antigua. Por favor, verifique su contraseña de nuevo."
|
||||
"passwordVerificationTooOld": "La verificación de la contraseña es demasiado antigua. Por favor, verifique su contraseña de nuevo.",
|
||||
"passwordResetNotAllowed": "El restablecimiento de contraseña no está permitido. Contacte con su administrador para más información."
|
||||
}
|
||||
},
|
||||
"idp": {
|
||||
@@ -261,6 +262,10 @@
|
||||
"required": {
|
||||
"code": "Este campo es obligatorio"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"couldNotRequestChallenge": "No se pudo solicitar el desafío OTP",
|
||||
"couldNotVerifyCode": "No se pudo verificar el código OTP"
|
||||
}
|
||||
},
|
||||
"passkey": {
|
||||
@@ -422,7 +427,15 @@
|
||||
"description": "Selecciona el método con el que deseas autenticarte",
|
||||
"noMethodsAvailable": "No hay métodos de autenticación disponibles",
|
||||
"allSetup": "¡Ya has configurado un autenticador!",
|
||||
"linkWithIDP": "o vincúlalo con un proveedor de identidad"
|
||||
"linkWithIDP": "o vincúlalo con un proveedor de identidad",
|
||||
"methods": {
|
||||
"totp": "Aplicación de autenticación",
|
||||
"u2f": "Segundo factor universal",
|
||||
"otpEmail": "Código por correo electrónico",
|
||||
"otpSms": "Código por SMS",
|
||||
"passkey": "Clave de acceso",
|
||||
"password": "Contraseña"
|
||||
}
|
||||
},
|
||||
"device": {
|
||||
"usercode": {
|
||||
|
||||
@@ -152,7 +152,8 @@
|
||||
"failedPrecondition": "Pré-condition non remplie",
|
||||
"sessionNotValid": "La session n'est pas valide",
|
||||
"passwordVerificationMissing": "La vérification du mot de passe est manquante.",
|
||||
"passwordVerificationTooOld": "La vérification du mot de passe est trop ancienne. Veuillez vérifier à nouveau votre mot de passe."
|
||||
"passwordVerificationTooOld": "La vérification du mot de passe est trop ancienne. Veuillez vérifier à nouveau votre mot de passe.",
|
||||
"passwordResetNotAllowed": "La réinitialisation du mot de passe n'est pas autorisée. Contactez votre administrateur pour plus d'informations."
|
||||
}
|
||||
},
|
||||
"idp": {
|
||||
@@ -261,6 +262,10 @@
|
||||
"required": {
|
||||
"code": "Ce champ est requis"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"couldNotRequestChallenge": "Impossible de demander le challenge OTP",
|
||||
"couldNotVerifyCode": "Impossible de vérifier le code OTP"
|
||||
}
|
||||
},
|
||||
"passkey": {
|
||||
@@ -422,7 +427,15 @@
|
||||
"description": "Sélectionnez la méthode que vous souhaitez utiliser pour vous authentifier",
|
||||
"noMethodsAvailable": "Aucune méthode d'authentification disponible",
|
||||
"allSetup": "Vous avez déjà configuré un authentificateur !",
|
||||
"linkWithIDP": "ou lier avec un fournisseur d'identité"
|
||||
"linkWithIDP": "ou lier avec un fournisseur d'identité",
|
||||
"methods": {
|
||||
"totp": "Application d'authentification",
|
||||
"u2f": "Second facteur universel",
|
||||
"otpEmail": "Code par e-mail",
|
||||
"otpSms": "Code par SMS",
|
||||
"passkey": "Clés d'accès",
|
||||
"password": "Mot de passe"
|
||||
}
|
||||
},
|
||||
"device": {
|
||||
"usercode": {
|
||||
|
||||
@@ -152,7 +152,8 @@
|
||||
"failedPrecondition": "Az előfeltétel nem teljesült",
|
||||
"sessionNotValid": "A munkamenet nem érvényes",
|
||||
"passwordVerificationMissing": "A jelszó hitelesítés hiányzik.",
|
||||
"passwordVerificationTooOld": "A jelszó hitelesítés túl régi. Kérjük, erősítsd meg újra a jelszavadat."
|
||||
"passwordVerificationTooOld": "A jelszó hitelesítés túl régi. Kérjük, erősítsd meg újra a jelszavadat.",
|
||||
"passwordResetNotAllowed": "A jelszó-visszaállítás nem engedélyezett. További információért fordulj a rendszergazdához."
|
||||
}
|
||||
},
|
||||
"idp": {
|
||||
@@ -261,6 +262,10 @@
|
||||
"required": {
|
||||
"code": "Ez a mező kötelező"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"couldNotRequestChallenge": "Nem sikerült OTP-kihívást kérni",
|
||||
"couldNotVerifyCode": "Nem sikerült ellenőrizni az OTP-kódot"
|
||||
}
|
||||
},
|
||||
"passkey": {
|
||||
@@ -422,7 +427,15 @@
|
||||
"description": "Válaszd ki, milyen módszerrel szeretnél hitelesíteni",
|
||||
"noMethodsAvailable": "Nincs elérhető hitelesítési módszer",
|
||||
"allSetup": "Már beállítottál egy hitelesítőt!",
|
||||
"linkWithIDP": "vagy kapcsold össze egy azonosítószolgáltatóval"
|
||||
"linkWithIDP": "vagy kapcsold össze egy azonosítószolgáltatóval",
|
||||
"methods": {
|
||||
"totp": "Hitelesítő alkalmazás",
|
||||
"u2f": "Univerzális második faktor",
|
||||
"otpEmail": "Kód e-mailben",
|
||||
"otpSms": "Kód SMS-ben",
|
||||
"passkey": "Jelkulcs",
|
||||
"password": "Jelszó"
|
||||
}
|
||||
},
|
||||
"device": {
|
||||
"usercode": {
|
||||
|
||||
@@ -152,7 +152,8 @@
|
||||
"failedPrecondition": "Precondizione non soddisfatta",
|
||||
"sessionNotValid": "La sessione non è valida",
|
||||
"passwordVerificationMissing": "Manca la verifica della password.",
|
||||
"passwordVerificationTooOld": "La verifica della password è troppo vecchia. Si prega di verificare nuovamente la password."
|
||||
"passwordVerificationTooOld": "La verifica della password è troppo vecchia. Si prega di verificare nuovamente la password.",
|
||||
"passwordResetNotAllowed": "Il ripristino della password non è consentito. Contatta il tuo amministratore per ulteriori informazioni."
|
||||
}
|
||||
},
|
||||
"idp": {
|
||||
@@ -261,6 +262,10 @@
|
||||
"required": {
|
||||
"code": "Questo campo è obbligatorio"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"couldNotRequestChallenge": "Impossibile richiedere la challenge OTP",
|
||||
"couldNotVerifyCode": "Impossibile verificare il codice OTP"
|
||||
}
|
||||
},
|
||||
"passkey": {
|
||||
@@ -422,7 +427,15 @@
|
||||
"description": "Seleziona il metodo con cui desideri autenticarti",
|
||||
"noMethodsAvailable": "Nessun metodo di autenticazione disponibile",
|
||||
"allSetup": "Hai già configurato un autenticatore!",
|
||||
"linkWithIDP": "o collega con un Identity Provider"
|
||||
"linkWithIDP": "o collega con un Identity Provider",
|
||||
"methods": {
|
||||
"totp": "App di autenticazione",
|
||||
"u2f": "Secondo fattore universale",
|
||||
"otpEmail": "Codice via email",
|
||||
"otpSms": "Codice via SMS",
|
||||
"passkey": "Passkey",
|
||||
"password": "Password"
|
||||
}
|
||||
},
|
||||
"device": {
|
||||
"usercode": {
|
||||
|
||||
@@ -135,7 +135,8 @@
|
||||
"couldNotLoadSession": "セッションを読み込めませんでした",
|
||||
"couldNotLoadAuthMethods": "認証方法を読み込めませんでした",
|
||||
"failedPrecondition": "前提条件に失敗しました",
|
||||
"sessionNotValid": "セッションが無効です"
|
||||
"sessionNotValid": "セッションが無効です",
|
||||
"passwordResetNotAllowed": "パスワードのリセットは許可されていません。詳細については管理者に問い合わせてください。"
|
||||
},
|
||||
"complexity": {
|
||||
"length": "{minLength} 文字以上である必要があります。",
|
||||
@@ -254,6 +255,10 @@
|
||||
"required": {
|
||||
"code": "必須項目です"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"couldNotRequestChallenge": "OTPチャレンジをリクエストできませんでした",
|
||||
"couldNotVerifyCode": "OTPコードを確認できませんでした"
|
||||
}
|
||||
},
|
||||
"passkey": {
|
||||
@@ -415,7 +420,15 @@
|
||||
"description": "認証に使用する方法を選択してください",
|
||||
"noMethodsAvailable": "使用可能な認証方法がありません",
|
||||
"allSetup": "認証の設定は既に完了しています!",
|
||||
"linkWithIDP": "またはIDプロバイダーと連携"
|
||||
"linkWithIDP": "またはIDプロバイダーと連携",
|
||||
"methods": {
|
||||
"totp": "認証アプリ",
|
||||
"u2f": "ユニバーサルセカンドファクター",
|
||||
"otpEmail": "メールでコードを受け取る",
|
||||
"otpSms": "SMSでコードを受け取る",
|
||||
"passkey": "パスキー",
|
||||
"password": "パスワード"
|
||||
}
|
||||
},
|
||||
"device": {
|
||||
"usercode": {
|
||||
|
||||
@@ -152,7 +152,8 @@
|
||||
"failedPrecondition": "Voorwaarde mislukt",
|
||||
"sessionNotValid": "Sessie is niet geldig",
|
||||
"passwordVerificationMissing": "Wachtwoordverificatie ontbreekt.",
|
||||
"passwordVerificationTooOld": "Wachtwoordverificatie is te oud. Verifieer je wachtwoord opnieuw."
|
||||
"passwordVerificationTooOld": "Wachtwoordverificatie is te oud. Verifieer je wachtwoord opnieuw.",
|
||||
"passwordResetNotAllowed": "Wachtwoord resetten is niet toegestaan. Neem contact op met uw beheerder voor meer informatie."
|
||||
}
|
||||
},
|
||||
"idp": {
|
||||
@@ -261,6 +262,10 @@
|
||||
"required": {
|
||||
"code": "Dit veld is verplicht"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"couldNotRequestChallenge": "Kon OTP-uitdaging niet aanvragen",
|
||||
"couldNotVerifyCode": "Kon OTP-code niet verifiëren"
|
||||
}
|
||||
},
|
||||
"passkey": {
|
||||
@@ -422,7 +427,15 @@
|
||||
"description": "Selecteer de methode waarmee u wilt verifiëren",
|
||||
"noMethodsAvailable": "Geen authenticatiemethoden beschikbaar",
|
||||
"allSetup": "U heeft al een authenticator ingesteld!",
|
||||
"linkWithIDP": "of link met een identiteitsprovider"
|
||||
"linkWithIDP": "of link met een identiteitsprovider",
|
||||
"methods": {
|
||||
"totp": "Authenticator-app",
|
||||
"u2f": "Universele tweede factor",
|
||||
"otpEmail": "Code via e-mail",
|
||||
"otpSms": "Code via sms",
|
||||
"passkey": "Passkeys",
|
||||
"password": "Wachtwoord"
|
||||
}
|
||||
},
|
||||
"device": {
|
||||
"usercode": {
|
||||
|
||||
@@ -152,7 +152,8 @@
|
||||
"failedPrecondition": "Nie spełniono warunków wstępnych",
|
||||
"sessionNotValid": "Sesja jest nieważna",
|
||||
"passwordVerificationMissing": "Brak weryfikacji hasła.",
|
||||
"passwordVerificationTooOld": "Weryfikacja hasła jest przestarzała. Proszę zweryfikować hasło ponownie."
|
||||
"passwordVerificationTooOld": "Weryfikacja hasła jest przestarzała. Proszę zweryfikować hasło ponownie.",
|
||||
"passwordResetNotAllowed": "Resetowanie hasła jest niedozwolone. Skontaktuj się z administratorem, aby uzyskać więcej informacji."
|
||||
}
|
||||
},
|
||||
"idp": {
|
||||
@@ -261,6 +262,10 @@
|
||||
"required": {
|
||||
"code": "To pole jest wymagane"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"couldNotRequestChallenge": "Nie można zażądać wyzwania OTP",
|
||||
"couldNotVerifyCode": "Nie można zweryfikować kodu OTP"
|
||||
}
|
||||
},
|
||||
"passkey": {
|
||||
@@ -422,7 +427,15 @@
|
||||
"description": "Wybierz metodę, której chcesz użyć do uwierzytelnienia.",
|
||||
"noMethodsAvailable": "Brak dostępnych metod uwierzytelniania",
|
||||
"allSetup": "Już skonfigurowałeś metodę uwierzytelniania!",
|
||||
"linkWithIDP": "lub połącz z dostawcą tożsamości"
|
||||
"linkWithIDP": "lub połącz z dostawcą tożsamości",
|
||||
"methods": {
|
||||
"totp": "Aplikacja uwierzytelniająca",
|
||||
"u2f": "Uniwersalny drugi składnik",
|
||||
"otpEmail": "Kod przez e-mail",
|
||||
"otpSms": "Kod przez SMS",
|
||||
"passkey": "Klucz dostępu",
|
||||
"password": "Hasło"
|
||||
}
|
||||
},
|
||||
"device": {
|
||||
"usercode": {
|
||||
|
||||
@@ -152,7 +152,8 @@
|
||||
"failedPrecondition": "Pré-condição falhou",
|
||||
"sessionNotValid": "A sessão não é válida",
|
||||
"passwordVerificationMissing": "A verificação de senha está ausente.",
|
||||
"passwordVerificationTooOld": "A verificação de senha expirou. Por favor, verifique sua senha novamente."
|
||||
"passwordVerificationTooOld": "A verificação de senha expirou. Por favor, verifique sua senha novamente.",
|
||||
"passwordResetNotAllowed": "A redefinição de senha não é permitida. Entre em contato com o administrador para mais informações."
|
||||
}
|
||||
},
|
||||
"idp": {
|
||||
@@ -261,6 +262,10 @@
|
||||
"required": {
|
||||
"code": "Este campo é obrigatório"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"couldNotRequestChallenge": "Não foi possível solicitar o desafio OTP",
|
||||
"couldNotVerifyCode": "Não foi possível verificar o código OTP"
|
||||
}
|
||||
},
|
||||
"passkey": {
|
||||
@@ -422,7 +427,15 @@
|
||||
"description": "Selecione o método de autenticação desejado",
|
||||
"noMethodsAvailable": "Nenhum método de autenticação disponível",
|
||||
"allSetup": "Você já configurou um autenticador!",
|
||||
"linkWithIDP": "ou vincule com um Provedor de Identidade"
|
||||
"linkWithIDP": "ou vincule com um Provedor de Identidade",
|
||||
"methods": {
|
||||
"totp": "Aplicativo autenticador",
|
||||
"u2f": "Segundo fator universal",
|
||||
"otpEmail": "Código via e-mail",
|
||||
"otpSms": "Código via SMS",
|
||||
"passkey": "Chave de Acesso",
|
||||
"password": "Senha"
|
||||
}
|
||||
},
|
||||
"device": {
|
||||
"usercode": {
|
||||
|
||||
@@ -152,7 +152,8 @@
|
||||
"failedPrecondition": "Нарушено предварительное условие",
|
||||
"sessionNotValid": "Сессия недействительна",
|
||||
"passwordVerificationMissing": "Отсутствует проверка пароля.",
|
||||
"passwordVerificationTooOld": "Проверка пароля устарела. Пожалуйста, подтвердите пароль еще раз."
|
||||
"passwordVerificationTooOld": "Проверка пароля устарела. Пожалуйста, подтвердите пароль еще раз.",
|
||||
"passwordResetNotAllowed": "Сброс пароля запрещен. Обратитесь к администратору за дополнительной информацией."
|
||||
}
|
||||
},
|
||||
"idp": {
|
||||
@@ -261,6 +262,10 @@
|
||||
"required": {
|
||||
"code": "Это поле обязательно для заполнения"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"couldNotRequestChallenge": "Не удалось запросить OTP-челлендж",
|
||||
"couldNotVerifyCode": "Не удалось проверить OTP-код"
|
||||
}
|
||||
},
|
||||
"passkey": {
|
||||
@@ -422,7 +427,15 @@
|
||||
"description": "Выберите предпочитаемый метод аутентификации",
|
||||
"noMethodsAvailable": "Нет доступных методов аутентификации",
|
||||
"allSetup": "Аутентификатор уже настроен!",
|
||||
"linkWithIDP": "или привязать через Identity Provider"
|
||||
"linkWithIDP": "или привязать через Identity Provider",
|
||||
"methods": {
|
||||
"totp": "Приложение-аутентификатор",
|
||||
"u2f": "Универсальный второй фактор",
|
||||
"otpEmail": "Код по email",
|
||||
"otpSms": "Код по SMS",
|
||||
"passkey": "Пасскей",
|
||||
"password": "Пароль"
|
||||
}
|
||||
},
|
||||
"device": {
|
||||
"usercode": {
|
||||
|
||||
@@ -152,7 +152,8 @@
|
||||
"failedPrecondition": "Ön koşul başarısız",
|
||||
"sessionNotValid": "Oturum geçerli değil",
|
||||
"passwordVerificationMissing": "Şifre doğrulaması eksik.",
|
||||
"passwordVerificationTooOld": "Şifre doğrulaması çok eski. Lütfen şifrenizi tekrar doğrulayın."
|
||||
"passwordVerificationTooOld": "Şifre doğrulaması çok eski. Lütfen şifrenizi tekrar doğrulayın.",
|
||||
"passwordResetNotAllowed": "Şifre sıfırlamaya izin verilmiyor. Daha fazla bilgi için yöneticinizle iletişime geçin."
|
||||
}
|
||||
},
|
||||
"idp": {
|
||||
@@ -261,6 +262,10 @@
|
||||
"required": {
|
||||
"code": "Bu alan zorunludur"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"couldNotRequestChallenge": "OTP meydan okuması istenemedi",
|
||||
"couldNotVerifyCode": "OTP kodu doğrulanamadı"
|
||||
}
|
||||
},
|
||||
"passkey": {
|
||||
@@ -422,7 +427,15 @@
|
||||
"description": "Kimlik doğrulaması yapmak istediğiniz yöntemi seçin",
|
||||
"noMethodsAvailable": "Kullanılabilir kimlik doğrulama yöntemi yok",
|
||||
"allSetup": "Zaten bir kimlik doğrulayıcı kurdunuz!",
|
||||
"linkWithIDP": "veya bir Kimlik Sağlayıcı ile bağlantı kurun"
|
||||
"linkWithIDP": "veya bir Kimlik Sağlayıcı ile bağlantı kurun",
|
||||
"methods": {
|
||||
"totp": "Kimlik doğrulayıcı uygulaması",
|
||||
"u2f": "Evrensel ikinci faktör",
|
||||
"otpEmail": "E-posta ile kod",
|
||||
"otpSms": "SMS ile kod",
|
||||
"passkey": "Passkey",
|
||||
"password": "Şifre"
|
||||
}
|
||||
},
|
||||
"device": {
|
||||
"usercode": {
|
||||
|
||||
@@ -152,7 +152,8 @@
|
||||
"failedPrecondition": "Не виконано передумову",
|
||||
"sessionNotValid": "Сеанс недійсний",
|
||||
"passwordVerificationMissing": "Відсутня перевірка пароля.",
|
||||
"passwordVerificationTooOld": "Перевірка пароля застаріла. Будь ласка, перевірте пароль ще раз."
|
||||
"passwordVerificationTooOld": "Перевірка пароля застаріла. Будь ласка, перевірте пароль ще раз.",
|
||||
"passwordResetNotAllowed": "Скидання пароля заборонено. Зверніться до адміністратора за додатковою інформацією."
|
||||
}
|
||||
},
|
||||
"idp": {
|
||||
@@ -261,6 +262,10 @@
|
||||
"required": {
|
||||
"code": "Це поле обов'язкове"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"couldNotRequestChallenge": "Не вдалося запросити OTP-виклик",
|
||||
"couldNotVerifyCode": "Не вдалося перевірити OTP-код"
|
||||
}
|
||||
},
|
||||
"passkey": {
|
||||
@@ -422,7 +427,15 @@
|
||||
"description": "Виберіть метод автентифікації",
|
||||
"noMethodsAvailable": "Немає доступних методів автентифікації",
|
||||
"allSetup": "Ви вже налаштували автентифікатор!",
|
||||
"linkWithIDP": "або прив'яжіть до постачальника ідентичності"
|
||||
"linkWithIDP": "або прив'яжіть до постачальника ідентичності",
|
||||
"methods": {
|
||||
"totp": "Додаток автентифікації",
|
||||
"u2f": "Універсальний другий фактор",
|
||||
"otpEmail": "Код через електронну пошту",
|
||||
"otpSms": "Код через SMS",
|
||||
"passkey": "Ключ доступу",
|
||||
"password": "Пароль"
|
||||
}
|
||||
},
|
||||
"device": {
|
||||
"usercode": {
|
||||
|
||||
@@ -152,7 +152,8 @@
|
||||
"failedPrecondition": "前置条件失败",
|
||||
"sessionNotValid": "会话无效",
|
||||
"passwordVerificationMissing": "缺少密码验证。",
|
||||
"passwordVerificationTooOld": "密码验证已过期。请重新通过验证。"
|
||||
"passwordVerificationTooOld": "密码验证已过期。请重新通过验证。",
|
||||
"passwordResetNotAllowed": "不允许重置密码。请联系您的管理员以获取更多信息。"
|
||||
}
|
||||
},
|
||||
"idp": {
|
||||
@@ -261,6 +262,10 @@
|
||||
"required": {
|
||||
"code": "此字段为必填项"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"couldNotRequestChallenge": "无法请求 OTP 质询",
|
||||
"couldNotVerifyCode": "无法验证 OTP 验证码"
|
||||
}
|
||||
},
|
||||
"passkey": {
|
||||
@@ -422,7 +427,15 @@
|
||||
"description": "选择您想使用的认证方法",
|
||||
"noMethodsAvailable": "没有可用的认证方法",
|
||||
"allSetup": "您已经设置好了一个认证器!",
|
||||
"linkWithIDP": "或将其与身份提供者关联"
|
||||
"linkWithIDP": "或将其与身份提供者关联",
|
||||
"methods": {
|
||||
"totp": "认证器应用",
|
||||
"u2f": "通用第二因素",
|
||||
"otpEmail": "通过电子邮件接收验证码",
|
||||
"otpSms": "通过短信接收验证码",
|
||||
"passkey": "密钥",
|
||||
"password": "密码"
|
||||
}
|
||||
},
|
||||
"device": {
|
||||
"usercode": {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import type { AnchorHTMLAttributes } from "react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { EMAIL, PASSKEYS, PASSWORD, SMS, TOTP, U2F } from "./auth-methods";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: (namespace?: string) => (key: string) => `${namespace ? `${namespace}.` : ""}${key}`,
|
||||
}));
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ href, children, ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { href: string }) => (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("auth-methods", () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
test.each([
|
||||
{
|
||||
name: "TOTP",
|
||||
card: TOTP,
|
||||
translatedText: "authenticator.methods.totp",
|
||||
},
|
||||
{
|
||||
name: "U2F",
|
||||
card: U2F,
|
||||
translatedText: "authenticator.methods.u2f",
|
||||
},
|
||||
{
|
||||
name: "EMAIL",
|
||||
card: EMAIL,
|
||||
translatedText: "authenticator.methods.otpEmail",
|
||||
},
|
||||
{
|
||||
name: "SMS",
|
||||
card: SMS,
|
||||
translatedText: "authenticator.methods.otpSms",
|
||||
},
|
||||
{
|
||||
name: "PASSKEYS",
|
||||
card: PASSKEYS,
|
||||
translatedText: "authenticator.methods.passkey",
|
||||
},
|
||||
{
|
||||
name: "PASSWORD",
|
||||
card: PASSWORD,
|
||||
translatedText: "authenticator.methods.password",
|
||||
},
|
||||
])("renders $name label from translation key", ({ card, translatedText }) => {
|
||||
render(card(false, "/auth/method"));
|
||||
|
||||
const translatedLabel = screen.getByText(translatedText);
|
||||
expect(translatedLabel).toBeInTheDocument();
|
||||
expect(translatedLabel).toHaveAttribute("data-i18n-key", translatedText);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { clsx } from "clsx";
|
||||
import Link from "next/link";
|
||||
import { ReactNode } from "react";
|
||||
import { BadgeState, StateBadge } from "./state-badge";
|
||||
import { Translated } from "./translated";
|
||||
|
||||
const cardClasses = (alreadyAdded: boolean) =>
|
||||
clsx(
|
||||
@@ -32,7 +33,7 @@ export const TOTP = (alreadyAdded: boolean, link: string) => {
|
||||
<title>timer-lock-outline</title>
|
||||
<path d="M11 8H13V14H11V8M13 19.92C12.67 19.97 12.34 20 12 20C8.13 20 5 16.87 5 13S8.13 6 12 6C14.82 6 17.24 7.67 18.35 10.06C18.56 10.04 18.78 10 19 10C19.55 10 20.07 10.11 20.57 10.28C20.23 9.22 19.71 8.24 19.03 7.39L20.45 5.97C20 5.46 19.55 5 19.04 4.56L17.62 6C16.07 4.74 14.12 4 12 4C7.03 4 3 8.03 3 13S7.03 22 12 22C12.42 22 12.83 21.96 13.24 21.91C13.09 21.53 13 21.12 13 20.7V19.92M15 1H9V3H15V1M23 17.3V20.8C23 21.4 22.4 22 21.7 22H16.2C15.6 22 15 21.4 15 20.7V17.2C15 16.6 15.6 16 16.2 16V14.5C16.2 13.1 17.6 12 19 12S21.8 13.1 21.8 14.5V16C22.4 16 23 16.6 23 17.3M20.5 14.5C20.5 13.7 19.8 13.2 19 13.2S17.5 13.7 17.5 14.5V16H20.5V14.5Z" />
|
||||
</svg>{" "}
|
||||
<span>Authenticator App</span>
|
||||
<Translated i18nKey="methods.totp" namespace="authenticator" />
|
||||
</div>
|
||||
{alreadyAdded && (
|
||||
<>
|
||||
@@ -61,7 +62,7 @@ export const U2F = (alreadyAdded: boolean, link: string) => {
|
||||
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>
|
||||
<span>Universal Second Factor</span>
|
||||
<Translated i18nKey="methods.u2f" namespace="authenticator" />
|
||||
</div>
|
||||
{alreadyAdded && (
|
||||
<>
|
||||
@@ -91,7 +92,7 @@ export const EMAIL = (alreadyAdded: boolean, link: string) => {
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<span>Code via Email</span>
|
||||
<Translated i18nKey="methods.otpEmail" namespace="authenticator" />
|
||||
</div>
|
||||
{alreadyAdded && (
|
||||
<>
|
||||
@@ -120,7 +121,7 @@ export const SMS = (alreadyAdded: boolean, link: string) => {
|
||||
d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"
|
||||
/>
|
||||
</svg>
|
||||
<span>Code via SMS</span>
|
||||
<Translated i18nKey="methods.otpSms" namespace="authenticator" />
|
||||
</div>
|
||||
{alreadyAdded && (
|
||||
<>
|
||||
@@ -149,7 +150,7 @@ export const PASSKEYS = (alreadyAdded: boolean, link: string) => {
|
||||
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>
|
||||
<span>Passkeys</span>
|
||||
<Translated i18nKey="methods.passkey" namespace="authenticator" />
|
||||
</div>
|
||||
{alreadyAdded && (
|
||||
<>
|
||||
@@ -168,7 +169,7 @@ export const PASSWORD = (alreadyAdded: boolean, link: string) => {
|
||||
<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>
|
||||
<span>Password</span>
|
||||
<Translated i18nKey="methods.password" namespace="authenticator" />
|
||||
</div>
|
||||
{alreadyAdded && (
|
||||
<>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { LoginOTP } from "./login-otp";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
@@ -20,10 +20,50 @@ vi.mock("@/lib/client", () => ({
|
||||
}));
|
||||
|
||||
describe("LoginOTP", () => {
|
||||
let mockUpdateOrCreateSession: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const { updateOrCreateSession } = await import("@/lib/server/session");
|
||||
mockUpdateOrCreateSession = vi.mocked(updateOrCreateSession);
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
test("should autofocus the code input on mount", () => {
|
||||
const { getByTestId } = render(<LoginOTP host={null} method="time-based" />);
|
||||
expect(getByTestId("code-text-input")).toHaveFocus();
|
||||
});
|
||||
|
||||
test("should display translated fallback error when OTP verification request rejects", async () => {
|
||||
mockUpdateOrCreateSession.mockRejectedValueOnce(new Error("network down"));
|
||||
|
||||
render(<LoginOTP host={null} method="time-based" />);
|
||||
|
||||
const input = screen.getByTestId("code-text-input");
|
||||
fireEvent.change(input, { target: { value: "123456" } });
|
||||
|
||||
const submitButton = screen.getByTestId("submit-button");
|
||||
await waitFor(() => {
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("errors.couldNotVerifyCode")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("should display translated fallback error when OTP challenge request rejects", async () => {
|
||||
mockUpdateOrCreateSession.mockRejectedValueOnce(new Error("challenge request failed"));
|
||||
|
||||
render(<LoginOTP host={null} method="sms" />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "verify.resendCode" })).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("errors.couldNotRequestChallenge")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,7 +94,7 @@ export function LoginOTP({ host, loginName, sessionId, requestId, organization,
|
||||
requestId,
|
||||
});
|
||||
} catch {
|
||||
return { error: "Could not request OTP challenge" };
|
||||
return { error: t("errors.couldNotRequestChallenge") };
|
||||
}
|
||||
|
||||
if (response && "error" in response && response.error) {
|
||||
@@ -102,7 +102,7 @@ export function LoginOTP({ host, loginName, sessionId, requestId, organization,
|
||||
}
|
||||
|
||||
return response;
|
||||
}, [method, host, requestId, loginName, sessionId, organization]);
|
||||
}, [method, host, requestId, loginName, sessionId, organization, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialized.current && ["email", "sms"].includes(method) && !code) {
|
||||
@@ -162,7 +162,7 @@ export function LoginOTP({ host, loginName, sessionId, requestId, organization,
|
||||
requestId,
|
||||
})
|
||||
.catch(() => {
|
||||
setError("Could not verify OTP code");
|
||||
setError(t("errors.couldNotVerifyCode"));
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -220,7 +220,7 @@ export function LoginOTP({ host, loginName, sessionId, requestId, organization,
|
||||
<Translated i18nKey="verify.noCodeReceived" namespace="otp" />
|
||||
</span>
|
||||
<button
|
||||
aria-label="Resend OTP Code"
|
||||
aria-label={t("verify.resendCode")}
|
||||
disabled={loading}
|
||||
type="button"
|
||||
className="text-primary-light-500 hover:text-primary-light-400 dark:text-primary-dark-500 hover:dark:text-primary-dark-400 ml-4 cursor-pointer disabled:cursor-default disabled:text-gray-400 dark:disabled:text-gray-700"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { create } from "@zitadel/client";
|
||||
import { PasswordComplexitySettingsSchema } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
@@ -45,6 +45,19 @@ describe("SetPasswordForm", () => {
|
||||
expect(getByTestId("code-text-input")).toHaveFocus();
|
||||
});
|
||||
|
||||
test("should use translated resend accessible name when codeRequired is true", () => {
|
||||
render(
|
||||
<SetPasswordForm
|
||||
passwordComplexitySettings={defaultComplexitySettings}
|
||||
loginName="[email protected]"
|
||||
userId="user-1"
|
||||
codeRequired={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "set.resend" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("should autofocus the password input when codeRequired is false", () => {
|
||||
const { getByTestId } = render(
|
||||
<SetPasswordForm
|
||||
|
||||
@@ -190,7 +190,7 @@ export function SetPasswordForm({
|
||||
<Translated i18nKey="set.noCodeReceived" namespace="password" />
|
||||
</span>
|
||||
<button
|
||||
aria-label="Resend OTP Code"
|
||||
aria-label={t("set.resend")}
|
||||
disabled={loading}
|
||||
type="button"
|
||||
className="text-primary-light-500 hover:text-primary-light-400 dark:text-primary-dark-500 hover:dark:text-primary-dark-400 ml-4 cursor-pointer disabled:cursor-default disabled:text-gray-400 dark:disabled:text-gray-700"
|
||||
|
||||
@@ -462,24 +462,46 @@ describe("resetPassword", () => {
|
||||
let mockSearchUsers: any;
|
||||
let mockGetLoginSettings: any;
|
||||
let mockPasswordReset: any;
|
||||
let mockGetTranslations: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
const { headers } = await import("next/headers");
|
||||
const { getTranslations } = await import("next-intl/server");
|
||||
const { getServiceConfig } = await import("../service-url");
|
||||
const { getLoginSettings, passwordReset, searchUsers } = await import("../zitadel");
|
||||
|
||||
mockHeaders = vi.mocked(headers);
|
||||
mockGetTranslations = vi.mocked(getTranslations);
|
||||
mockGetServiceConfig = vi.mocked(getServiceConfig);
|
||||
mockSearchUsers = vi.mocked(searchUsers);
|
||||
mockGetLoginSettings = vi.mocked(getLoginSettings);
|
||||
mockPasswordReset = vi.mocked(passwordReset);
|
||||
|
||||
mockHeaders.mockResolvedValue({ get: vi.fn(() => "example.com") });
|
||||
mockGetTranslations.mockResolvedValue((key: string) => key);
|
||||
mockGetServiceConfig.mockReturnValue({ serviceConfig: { baseUrl: "https://api.example.com" } });
|
||||
});
|
||||
|
||||
test("should return translated password reset not allowed error when hidePasswordReset is true", async () => {
|
||||
mockGetLoginSettings.mockResolvedValue({ hidePasswordReset: true });
|
||||
mockGetTranslations.mockResolvedValue((key: string) => {
|
||||
if (key === "errors.passwordResetNotAllowed") {
|
||||
return "translated-password-reset-not-allowed";
|
||||
}
|
||||
return key;
|
||||
});
|
||||
|
||||
const result = await resetPassword({
|
||||
loginName: "[email protected]",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ error: "translated-password-reset-not-allowed" });
|
||||
expect(mockSearchUsers).not.toHaveBeenCalled();
|
||||
expect(mockPasswordReset).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should return generic success when user not found and ignoreUnknownUsernames is true", async () => {
|
||||
mockSearchUsers.mockResolvedValue({ result: [] });
|
||||
mockGetLoginSettings.mockResolvedValue({ ignoreUnknownUsernames: true });
|
||||
|
||||
Reference in New Issue
Block a user