فهرست منبع

Let an account that signed up with Google set a password (#566)

* Let an account that signed up with Google set a password

An account with no password was shown the Change password card, which asked for a current password it does not have and answered Credential account not found. The two-factor card led to a password field it could not fill in either.

The account page now offers Set a password to such an account. The server only sets one within ten minutes of signing in, never from a technician app sign-in, and mails the account a notice. When the sign-in is older, the card says so and a button signs the person out, through sign-in and back to the page. Two-factor says to set a password first.

Also translates the Cancel button in the two-factor setup dialog.

* Say so when signing out for a new sign-in fails on the password card

The Sign in again button went to the sign-in page even when the sign-out came back with an error, and that page sends a signed-in visitor straight back. It now stays put and shows an error. Also creates the test account through Prisma in the database test, which the Better Auth helper's signature did not allow.

* Wait for the audit row in the set-password database test and remove it afterwards
Bernt Christian Egeland 1 روز پیش
والد
کامیت
589245d976
39فایلهای تغییر یافته به همراه1169 افزوده شده و 41 حذف شده
  1. 137 0
      e2e/specs/cloud/google-password.spec.ts
  2. 2 14
      e2e/specs/cloud/google-sign-in.spec.ts
  3. 21 0
      e2e/support/cloud.ts
  4. 16 0
      e2e/support/db.ts
  5. 1 0
      messages/de/audit.json
  6. 11 0
      messages/de/settings.json
  7. 1 0
      messages/en/audit.json
  8. 11 0
      messages/en/settings.json
  9. 1 0
      messages/es/audit.json
  10. 11 0
      messages/es/settings.json
  11. 1 0
      messages/fr/audit.json
  12. 11 0
      messages/fr/settings.json
  13. 1 0
      messages/it/audit.json
  14. 11 0
      messages/it/settings.json
  15. 1 0
      messages/lt/audit.json
  16. 11 0
      messages/lt/settings.json
  17. 1 0
      messages/nb/audit.json
  18. 11 0
      messages/nb/settings.json
  19. 1 0
      messages/nl/audit.json
  20. 11 0
      messages/nl/settings.json
  21. 1 0
      messages/pl/audit.json
  22. 11 0
      messages/pl/settings.json
  23. 1 0
      messages/pt-BR/audit.json
  24. 11 0
      messages/pt-BR/settings.json
  25. 1 0
      messages/pt-PT/audit.json
  26. 11 0
      messages/pt-PT/settings.json
  27. 1 0
      messages/ru/audit.json
  28. 11 0
      messages/ru/settings.json
  29. 1 0
      messages/tr/audit.json
  30. 11 0
      messages/tr/settings.json
  31. 193 0
      src/__tests__/db/set-account-password.db.test.ts
  32. 228 0
      src/__tests__/features/settings/account-set-password.test.tsx
  33. 159 0
      src/__tests__/features/settings/set-account-password.test.ts
  34. 131 16
      src/app/(authenticated)/settings/account/account-settings.tsx
  35. 6 1
      src/app/(authenticated)/settings/account/page.tsx
  36. 69 0
      src/features/settings/Actions/setAccountPassword.ts
  37. 16 0
      src/features/settings/Schema/accountPassword.ts
  38. 32 9
      src/lib/reauth.server.ts
  39. 3 1
      src/lib/reauth.ts

+ 137 - 0
e2e/specs/cloud/google-password.spec.ts

@@ -0,0 +1,137 @@
+import { expect, test } from '@playwright/test'
+import {
+  chooseGoogleAccount,
+  completeOnboarding,
+  continueWithGoogle,
+  registerGoogleAccount,
+  routeGoogleToStandin,
+  signInWithPassword,
+} from '../../support/cloud'
+import { ageSessionsOf, personWithEmail } from '../../support/db'
+import { fillSettled, settle } from '../../support/hydration'
+import { waitForMail } from '../../support/mail'
+
+/**
+ * A password for someone who signed up with Google.
+ *
+ * Their account has none, so the Change password card had no current
+ * password to check and answered "Credential account not found", and the
+ * two-factor card walked them as far as a password field they could not fill
+ * in. The account page now offers to set one instead.
+ *
+ * A password is a way into the account that outlives the session, so the
+ * server only sets one straight after a sign-in. That is the path worth a
+ * browser: the person is told to sign in again, is taken there, comes back to
+ * the same page, and finishes. Then the password has to be one the sign-in
+ * page accepts, which only shows with the auth pieces assembled.
+ */
+
+// Signing up and onboarding a workshop takes longer than an ordinary test.
+test.describe.configure({ mode: 'serial', timeout: 180_000 })
+
+// A stranger's browser, every time.
+test.use({ storageState: { cookies: [], origins: [] } })
+
+const stamp = Date.now()
+const PERSON = `e2e-google-set-password-${stamp}@example.com`
+const FIRST = `E2e-first-${stamp}`
+const SECOND = `E2e-second-${stamp}`
+
+test.beforeEach(async ({ context }) => {
+  await routeGoogleToStandin(context)
+})
+
+test.describe('someone who signed up with Google', () => {
+  test('is offered to set a password, and is not asked for a current one', async ({ page }) => {
+    await registerGoogleAccount({ email: PERSON, name: 'E2E Google Password' })
+    await continueWithGoogle(page, '/auth/sign-up', PERSON)
+    await page.waitForURL(/\/onboarding/, { timeout: 30_000 })
+    await completeOnboarding(page, `E2E Google Password Garage ${stamp}`, { sampleData: false })
+
+    await page.goto('/settings/account')
+    await settle(page)
+
+    await expect(page.getByText('Set a Password', { exact: true })).toBeVisible()
+    await expect(page.getByRole('button', { name: 'Set Password', exact: true })).toBeVisible()
+    await expect(page.locator('#currentPassword'), 'no current password to ask for').toHaveCount(0)
+
+    // Two-factor confirms with the account's password, so it waits for one.
+    await expect(page.getByRole('button', { name: 'Enable 2FA', exact: true })).toBeDisabled()
+    await expect(page.getByText(/Set a password first/)).toBeVisible()
+  })
+
+  test('is sent to sign in again when the sign-in is not recent, and comes back to finish', async ({
+    page,
+  }) => {
+    await continueWithGoogle(page, '/auth/sign-in', PERSON)
+    await page.waitForURL((url) => !/^\/(auth|onboarding)/.test(url.pathname), { timeout: 30_000 })
+    // The tab has been open since this morning.
+    await ageSessionsOf(PERSON, 30)
+
+    await page.goto('/settings/account')
+    await settle(page)
+    await fillSettled(page.locator('#newPassword'), FIRST)
+    await fillSettled(page.locator('#confirmPassword'), FIRST)
+    await page.getByRole('button', { name: 'Set Password', exact: true }).click()
+
+    await expect(page.getByText('Sign in again before setting a password.')).toBeVisible()
+    expect((await personWithEmail(PERSON)).providers, 'nothing was set').toEqual(['google'])
+
+    // Out, through Google, and back to the page they were on.
+    await page.getByRole('button', { name: 'Sign in again', exact: true }).click()
+    await page.waitForURL(/\/auth\/sign-in\?redirect=%2Fsettings%2Faccount/, { timeout: 30_000 })
+    await chooseGoogleAccount(page, PERSON)
+    await page.waitForURL(/\/settings\/account/, { timeout: 30_000 })
+    await settle(page)
+
+    await fillSettled(page.locator('#newPassword'), FIRST)
+    await fillSettled(page.locator('#confirmPassword'), FIRST)
+    await page.getByRole('button', { name: 'Set Password', exact: true }).click()
+
+    await expect(page.getByText(/Password set\./)).toBeVisible()
+    // The card is the ordinary one from here on, and two-factor is open.
+    await expect(page.locator('#currentPassword')).toBeVisible()
+    await expect(page.getByRole('button', { name: 'Change Password', exact: true })).toBeVisible()
+    await expect(page.getByRole('button', { name: 'Enable 2FA', exact: true })).toBeEnabled()
+
+    expect((await personWithEmail(PERSON)).providers).toEqual(['credential', 'google'])
+
+    // And the address is told, in case it was not them.
+    const notice = await waitForMail(PERSON, { subject: /password was added/i })
+    expect(notice.text).toContain('A password was just set on your Torqvoice account')
+  })
+
+  test('signs in with the email address and that password, and can change it', async ({ page }) => {
+    await signInWithPassword(page, { email: PERSON, password: FIRST })
+    await expect(page.getByText(`E2E Google Password Garage ${stamp}`).first()).toBeVisible()
+
+    await page.goto('/settings/account')
+    await settle(page)
+    await fillSettled(page.locator('#currentPassword'), FIRST)
+    await fillSettled(page.locator('#newPassword'), SECOND)
+    await fillSettled(page.locator('#confirmPassword'), SECOND)
+    await page.getByRole('button', { name: 'Change Password', exact: true }).click()
+
+    await expect(page.getByText('Password changed', { exact: true })).toBeVisible()
+  })
+
+  test('signs in with the changed password, and Google still works too', async ({
+    page,
+    browser,
+  }) => {
+    await signInWithPassword(page, { email: PERSON, password: SECOND })
+    await expect(page.getByText(`E2E Google Password Garage ${stamp}`).first()).toBeVisible()
+
+    const other = await browser.newContext({ storageState: { cookies: [], origins: [] } })
+    await routeGoogleToStandin(other)
+    const viaGoogle = await other.newPage()
+    await continueWithGoogle(viaGoogle, '/auth/sign-in', PERSON)
+    await viaGoogle.waitForURL((url) => !/^\/(auth|onboarding)/.test(url.pathname), {
+      timeout: 30_000,
+    })
+    await expect(viaGoogle.getByText(`E2E Google Password Garage ${stamp}`).first()).toBeVisible()
+    await other.close()
+
+    expect((await personWithEmail(PERSON)).users, 'still one person').toBe(1)
+  })
+})

+ 2 - 14
e2e/specs/cloud/google-sign-in.spec.ts

@@ -1,7 +1,8 @@
-import { expect, type Page, test } from '@playwright/test'
+import { expect, test } from '@playwright/test'
 import {
 import {
   clearGoogleStandin,
   clearGoogleStandin,
   completeOnboarding,
   completeOnboarding,
+  continueWithGoogle,
   googleStandin,
   googleStandin,
   registerGoogleAccount,
   registerGoogleAccount,
   routeGoogleToStandin,
   routeGoogleToStandin,
@@ -74,19 +75,6 @@ test.beforeEach(async ({ context }) => {
   await routeGoogleToStandin(context)
   await routeGoogleToStandin(context)
 })
 })
 
 
-/** Presses "Continue with Google" and picks an account in the chooser. */
-async function continueWithGoogle(page: Page, from: string, email: string): Promise<void> {
-  await page.goto(from)
-  await settle(page)
-  await expect(async () => {
-    await page.getByRole('button', { name: 'Continue with Google' }).click()
-    await expect(page.getByRole('heading', { name: 'Choose an account' })).toBeVisible({
-      timeout: 5_000,
-    })
-  }).toPass({ timeout: 30_000 })
-  await page.getByRole('button', { name: email, exact: true }).click()
-}
-
 test.describe('Google sign-in', () => {
 test.describe('Google sign-in', () => {
   test('is offered on the sign-in and the sign-up page', async ({ page }) => {
   test('is offered on the sign-in and the sign-up page', async ({ page }) => {
     for (const path of ['/auth/sign-in', '/auth/sign-up']) {
     for (const path of ['/auth/sign-in', '/auth/sign-up']) {

+ 21 - 0
e2e/support/cloud.ts

@@ -72,6 +72,27 @@ export async function routeGoogleToStandin(context: BrowserContext): Promise<voi
   })
   })
 }
 }
 
 
+/**
+ * Presses "Continue with Google" on the page that is open and picks an
+ * account in the stand-in's chooser.
+ */
+export async function chooseGoogleAccount(page: Page, email: string): Promise<void> {
+  await settle(page)
+  await expect(async () => {
+    await page.getByRole('button', { name: 'Continue with Google' }).click()
+    await expect(page.getByRole('heading', { name: 'Choose an account' })).toBeVisible({
+      timeout: 5_000,
+    })
+  }).toPass({ timeout: 30_000 })
+  await page.getByRole('button', { name: email, exact: true }).click()
+}
+
+/** Opens `from`, presses "Continue with Google" and picks an account in the chooser. */
+export async function continueWithGoogle(page: Page, from: string, email: string): Promise<void> {
+  await page.goto(from)
+  await chooseGoogleAccount(page, email)
+}
+
 /** Signs up with a password on the cloud sign-up page, landing in onboarding. */
 /** Signs up with a password on the cloud sign-up page, landing in onboarding. */
 export async function signUpWithPassword(
 export async function signUpWithPassword(
   page: Page,
   page: Page,

+ 16 - 0
e2e/support/db.ts

@@ -1152,6 +1152,22 @@ export async function sessionCountFor(email: string): Promise<number> {
   })
   })
 }
 }
 
 
+/**
+ * Makes every open sign-in of a person look `minutes` old, the way a tab left
+ * open ages. What the app asks of a sign-in that is no longer recent cannot
+ * be waited for in a test.
+ */
+export async function ageSessionsOf(email: string, minutes: number): Promise<void> {
+  await withDb(async (db) => {
+    await db.query(
+      `update sessions s set "createdAt" = now() - make_interval(mins => $2::int)
+         from users u
+        where u.id = s."userId" and lower(u.email) = lower($1)`,
+      [email, minutes]
+    )
+  })
+}
+
 /** Device rows a person has whose user agent mentions `needle`. */
 /** Device rows a person has whose user agent mentions `needle`. */
 export async function deviceCountFor(email: string, needle: string): Promise<number> {
 export async function deviceCountFor(email: string, needle: string): Promise<number> {
   return withDb(async (db) => {
   return withDb(async (db) => {

+ 1 - 0
messages/de/audit.json

@@ -88,6 +88,7 @@
     "auth_login": "Benutzer angemeldet",
     "auth_login": "Benutzer angemeldet",
     "auth_sessionRevoked": "Gerät abgemeldet",
     "auth_sessionRevoked": "Gerät abgemeldet",
     "auth_sessionsRevoked": "Andere Geräte abgemeldet",
     "auth_sessionsRevoked": "Andere Geräte abgemeldet",
+    "auth_passwordSet": "Kontopasswort festgelegt",
     "auth_loginFailed": "Fehlgeschlagener Anmeldeversuch",
     "auth_loginFailed": "Fehlgeschlagener Anmeldeversuch",
     "auth_register": "Benutzer registriert",
     "auth_register": "Benutzer registriert",
     "auth_passwordReset": "Passwort-Zurücksetzung angefordert",
     "auth_passwordReset": "Passwort-Zurücksetzung angefordert",

+ 11 - 0
messages/de/settings.json

@@ -245,10 +245,20 @@
     "passwordsNoMatch": "Passworter stimmen nicht uberein",
     "passwordsNoMatch": "Passworter stimmen nicht uberein",
     "passwordMinLength": "Passwort muss mindestens 8 Zeichen lang sein",
     "passwordMinLength": "Passwort muss mindestens 8 Zeichen lang sein",
     "failedChangePassword": "Passwort konnte nicht geandert werden",
     "failedChangePassword": "Passwort konnte nicht geandert werden",
+    "setPasswordTitle": "Passwort festlegen",
+    "setPasswordDescription": "Dieses Konto meldet sich ohne Passwort an, zum Beispiel mit Google. Legen Sie eines fest, um sich auch mit Ihrer E-Mail-Adresse und einem Passwort anzumelden.",
+    "setPassword": "Passwort festlegen",
+    "passwordSet": "Passwort festgelegt. Sie können sich jetzt mit Ihrer E-Mail-Adresse und diesem Passwort anmelden.",
+    "failedSetPassword": "Passwort konnte nicht festgelegt werden",
+    "passwordAlreadySet": "Dieses Konto hat bereits ein Passwort. Ändern Sie es stattdessen hier.",
+    "setPasswordFreshHint": "Zu Ihrer Sicherheit funktioniert dies nur innerhalb von {minutes} Minuten nach der Anmeldung. Ist es länger her, werden Sie gebeten, sich erneut anzumelden.",
+    "setPasswordSignInAgainHint": "Sie haben sich vor mehr als {minutes} Minuten angemeldet. Melden Sie sich erneut an und legen Sie das Passwort direkt danach fest.",
+    "signInAgain": "Erneut anmelden",
     "twoFactorTitle": "Zwei-Faktor-Authentifizierung",
     "twoFactorTitle": "Zwei-Faktor-Authentifizierung",
     "twoFactorEnabled": "Zwei-Faktor-Authentifizierung ist aktiviert",
     "twoFactorEnabled": "Zwei-Faktor-Authentifizierung ist aktiviert",
     "twoFactorDisablePrompt": "Geben Sie Ihr Passwort ein, um die Zwei-Faktor-Authentifizierung zu deaktivieren.",
     "twoFactorDisablePrompt": "Geben Sie Ihr Passwort ein, um die Zwei-Faktor-Authentifizierung zu deaktivieren.",
     "twoFactorEnablePrompt": "Fugen Sie eine zusatzliche Sicherheitsebene zu Ihrem Konto hinzu, indem Sie die Zwei-Faktor-Authentifizierung mit einer Authenticator-App aktivieren.",
     "twoFactorEnablePrompt": "Fugen Sie eine zusatzliche Sicherheitsebene zu Ihrem Konto hinzu, indem Sie die Zwei-Faktor-Authentifizierung mit einer Authenticator-App aktivieren.",
+    "twoFactorNeedsPassword": "Die Zwei-Faktor-Authentifizierung ergänzt die Anmeldung mit Passwort um einen Code. Legen Sie zuerst ein Passwort fest.",
     "password": "Passwort",
     "password": "Passwort",
     "enterPassword": "Passwort eingeben",
     "enterPassword": "Passwort eingeben",
     "disable2FA": "2FA deaktivieren",
     "disable2FA": "2FA deaktivieren",
@@ -360,6 +370,7 @@
     "reauthSignInAgainAccount": "Melden Sie sich erneut an, bevor Sie Ihr Konto löschen.",
     "reauthSignInAgainAccount": "Melden Sie sich erneut an, bevor Sie Ihr Konto löschen.",
     "reauthSignInAgainWorkshop": "Melden Sie sich erneut an, bevor Sie die Werkstatt löschen.",
     "reauthSignInAgainWorkshop": "Melden Sie sich erneut an, bevor Sie die Werkstatt löschen.",
     "reauthSignInAgainRestore": "Melden Sie sich erneut an, bevor Sie die Werkstatt wiederherstellen.",
     "reauthSignInAgainRestore": "Melden Sie sich erneut an, bevor Sie die Werkstatt wiederherstellen.",
+    "reauthSignInAgainPassword": "Melden Sie sich erneut an, bevor Sie ein Passwort festlegen.",
     "deleteAccountMoved": "Möchten Sie Ihr Konto löschen?",
     "deleteAccountMoved": "Möchten Sie Ihr Konto löschen?",
     "deleteAccountMovedLink": "Das können Sie in den Dateneinstellungen tun.",
     "deleteAccountMovedLink": "Das können Sie in den Dateneinstellungen tun.",
     "signatureTitle": "Unterschrift",
     "signatureTitle": "Unterschrift",

+ 1 - 0
messages/en/audit.json

@@ -88,6 +88,7 @@
     "auth_login": "User Logged In",
     "auth_login": "User Logged In",
     "auth_sessionRevoked": "Signed Out a Device",
     "auth_sessionRevoked": "Signed Out a Device",
     "auth_sessionsRevoked": "Signed Out Other Devices",
     "auth_sessionsRevoked": "Signed Out Other Devices",
+    "auth_passwordSet": "Set an Account Password",
     "auth_loginFailed": "Failed Login Attempt",
     "auth_loginFailed": "Failed Login Attempt",
     "auth_register": "User Registered",
     "auth_register": "User Registered",
     "auth_passwordReset": "Password Reset Requested",
     "auth_passwordReset": "Password Reset Requested",

+ 11 - 0
messages/en/settings.json

@@ -245,10 +245,20 @@
     "passwordsNoMatch": "Passwords do not match",
     "passwordsNoMatch": "Passwords do not match",
     "passwordMinLength": "Password must be at least 8 characters",
     "passwordMinLength": "Password must be at least 8 characters",
     "failedChangePassword": "Failed to change password",
     "failedChangePassword": "Failed to change password",
+    "setPasswordTitle": "Set a Password",
+    "setPasswordDescription": "This account signs in without a password, for example with Google. Set one to also sign in with your email address and a password.",
+    "setPassword": "Set Password",
+    "passwordSet": "Password set. You can now sign in with your email address and this password.",
+    "failedSetPassword": "Failed to set password",
+    "passwordAlreadySet": "This account already has a password. Change it here instead.",
+    "setPasswordFreshHint": "For your security this only works within {minutes} minutes of signing in. If it has been longer, you will be asked to sign in again.",
+    "setPasswordSignInAgainHint": "You signed in more than {minutes} minutes ago. Sign in again, then set the password straight away.",
+    "signInAgain": "Sign in again",
     "twoFactorTitle": "Two-Factor Authentication",
     "twoFactorTitle": "Two-Factor Authentication",
     "twoFactorEnabled": "Two-factor authentication is enabled",
     "twoFactorEnabled": "Two-factor authentication is enabled",
     "twoFactorDisablePrompt": "Enter your password to disable two-factor authentication.",
     "twoFactorDisablePrompt": "Enter your password to disable two-factor authentication.",
     "twoFactorEnablePrompt": "Add an extra layer of security to your account by enabling two-factor authentication with an authenticator app.",
     "twoFactorEnablePrompt": "Add an extra layer of security to your account by enabling two-factor authentication with an authenticator app.",
+    "twoFactorNeedsPassword": "Two-factor authentication adds a code to signing in with a password. Set a password first.",
     "password": "Password",
     "password": "Password",
     "enterPassword": "Enter your password",
     "enterPassword": "Enter your password",
     "disable2FA": "Disable 2FA",
     "disable2FA": "Disable 2FA",
@@ -360,6 +370,7 @@
     "reauthSignInAgainAccount": "Sign in again before deleting your account.",
     "reauthSignInAgainAccount": "Sign in again before deleting your account.",
     "reauthSignInAgainWorkshop": "Sign in again before deleting the workshop.",
     "reauthSignInAgainWorkshop": "Sign in again before deleting the workshop.",
     "reauthSignInAgainRestore": "Sign in again before restoring the workshop.",
     "reauthSignInAgainRestore": "Sign in again before restoring the workshop.",
+    "reauthSignInAgainPassword": "Sign in again before setting a password.",
     "deleteAccountMoved": "Want to delete your account?",
     "deleteAccountMoved": "Want to delete your account?",
     "deleteAccountMovedLink": "You can do that in Data settings.",
     "deleteAccountMovedLink": "You can do that in Data settings.",
     "signatureTitle": "Signature",
     "signatureTitle": "Signature",

+ 1 - 0
messages/es/audit.json

@@ -88,6 +88,7 @@
     "auth_login": "Usuario conectado",
     "auth_login": "Usuario conectado",
     "auth_sessionRevoked": "Cerró sesión en un dispositivo",
     "auth_sessionRevoked": "Cerró sesión en un dispositivo",
     "auth_sessionsRevoked": "Cerró sesión en otros dispositivos",
     "auth_sessionsRevoked": "Cerró sesión en otros dispositivos",
+    "auth_passwordSet": "Estableció una contraseña de cuenta",
     "auth_loginFailed": "Intento de inicio de sesión fallido",
     "auth_loginFailed": "Intento de inicio de sesión fallido",
     "auth_register": "Usuario registrado",
     "auth_register": "Usuario registrado",
     "auth_passwordReset": "Restablecimiento de contraseña solicitado",
     "auth_passwordReset": "Restablecimiento de contraseña solicitado",

+ 11 - 0
messages/es/settings.json

@@ -245,10 +245,20 @@
     "passwordsNoMatch": "Las contraseñas no coinciden",
     "passwordsNoMatch": "Las contraseñas no coinciden",
     "passwordMinLength": "La contraseña debe tener al menos 8 caracteres",
     "passwordMinLength": "La contraseña debe tener al menos 8 caracteres",
     "failedChangePassword": "Error al cambiar la contraseña",
     "failedChangePassword": "Error al cambiar la contraseña",
+    "setPasswordTitle": "Establecer contraseña",
+    "setPasswordDescription": "Esta cuenta inicia sesión sin contraseña, por ejemplo con Google. Establezca una para poder iniciar sesión también con su correo electrónico y una contraseña.",
+    "setPassword": "Establecer contraseña",
+    "passwordSet": "Contraseña establecida. Ahora puede iniciar sesión con su correo electrónico y esta contraseña.",
+    "failedSetPassword": "Error al establecer la contraseña",
+    "passwordAlreadySet": "Esta cuenta ya tiene una contraseña. Cámbiela aquí.",
+    "setPasswordFreshHint": "Por su seguridad, esto solo funciona durante los {minutes} minutos siguientes a iniciar sesión. Si ha pasado más tiempo, se le pedirá que vuelva a iniciar sesión.",
+    "setPasswordSignInAgainHint": "Inició sesión hace más de {minutes} minutos. Vuelva a iniciar sesión y establezca la contraseña de inmediato.",
+    "signInAgain": "Volver a iniciar sesión",
     "twoFactorTitle": "Autenticacion de dos factores",
     "twoFactorTitle": "Autenticacion de dos factores",
     "twoFactorEnabled": "La autenticacion de dos factores esta habilitada",
     "twoFactorEnabled": "La autenticacion de dos factores esta habilitada",
     "twoFactorDisablePrompt": "Ingrese su contraseña para deshabilitar la autenticacion de dos factores.",
     "twoFactorDisablePrompt": "Ingrese su contraseña para deshabilitar la autenticacion de dos factores.",
     "twoFactorEnablePrompt": "Agregue una capa adicional de seguridad a su cuenta habilitando la autenticacion de dos factores con una aplicación de autenticacion.",
     "twoFactorEnablePrompt": "Agregue una capa adicional de seguridad a su cuenta habilitando la autenticacion de dos factores con una aplicación de autenticacion.",
+    "twoFactorNeedsPassword": "La autenticación de dos factores añade un código al inicio de sesión con contraseña. Establezca primero una contraseña.",
     "password": "Contraseña",
     "password": "Contraseña",
     "enterPassword": "Ingrese su contraseña",
     "enterPassword": "Ingrese su contraseña",
     "disable2FA": "Deshabilitar 2FA",
     "disable2FA": "Deshabilitar 2FA",
@@ -360,6 +370,7 @@
     "reauthSignInAgainAccount": "Vuelva a iniciar sesión antes de eliminar su cuenta.",
     "reauthSignInAgainAccount": "Vuelva a iniciar sesión antes de eliminar su cuenta.",
     "reauthSignInAgainWorkshop": "Vuelva a iniciar sesión antes de eliminar el taller.",
     "reauthSignInAgainWorkshop": "Vuelva a iniciar sesión antes de eliminar el taller.",
     "reauthSignInAgainRestore": "Vuelva a iniciar sesión antes de restaurar el taller.",
     "reauthSignInAgainRestore": "Vuelva a iniciar sesión antes de restaurar el taller.",
+    "reauthSignInAgainPassword": "Vuelva a iniciar sesión antes de establecer una contraseña.",
     "deleteAccountMoved": "¿Quiere eliminar su cuenta?",
     "deleteAccountMoved": "¿Quiere eliminar su cuenta?",
     "deleteAccountMovedLink": "Puede hacerlo en la configuración de datos.",
     "deleteAccountMovedLink": "Puede hacerlo en la configuración de datos.",
     "signatureTitle": "Firma",
     "signatureTitle": "Firma",

+ 1 - 0
messages/fr/audit.json

@@ -88,6 +88,7 @@
     "auth_login": "Utilisateur connecté",
     "auth_login": "Utilisateur connecté",
     "auth_sessionRevoked": "Appareil déconnecté",
     "auth_sessionRevoked": "Appareil déconnecté",
     "auth_sessionsRevoked": "Autres appareils déconnectés",
     "auth_sessionsRevoked": "Autres appareils déconnectés",
+    "auth_passwordSet": "Mot de passe du compte défini",
     "auth_loginFailed": "Tentative de connexion échouée",
     "auth_loginFailed": "Tentative de connexion échouée",
     "auth_register": "Utilisateur inscrit",
     "auth_register": "Utilisateur inscrit",
     "auth_passwordReset": "Réinitialisation du mot de passe demandée",
     "auth_passwordReset": "Réinitialisation du mot de passe demandée",

+ 11 - 0
messages/fr/settings.json

@@ -245,10 +245,20 @@
     "passwordsNoMatch": "Les mots de passe ne correspondent pas",
     "passwordsNoMatch": "Les mots de passe ne correspondent pas",
     "passwordMinLength": "Le mot de passe doit comporter au moins 8 caractères",
     "passwordMinLength": "Le mot de passe doit comporter au moins 8 caractères",
     "failedChangePassword": "Échec du changement de mot de passe",
     "failedChangePassword": "Échec du changement de mot de passe",
+    "setPasswordTitle": "Définir un mot de passe",
+    "setPasswordDescription": "Ce compte se connecte sans mot de passe, par exemple avec Google. Définissez-en un pour pouvoir aussi vous connecter avec votre adresse e-mail et un mot de passe.",
+    "setPassword": "Définir le mot de passe",
+    "passwordSet": "Mot de passe défini. Vous pouvez maintenant vous connecter avec votre adresse e-mail et ce mot de passe.",
+    "failedSetPassword": "Échec de la définition du mot de passe",
+    "passwordAlreadySet": "Ce compte a déjà un mot de passe. Modifiez-le plutôt ici.",
+    "setPasswordFreshHint": "Pour votre sécurité, cette action n'est possible que dans les {minutes} minutes qui suivent la connexion. Au-delà, vous devrez vous reconnecter.",
+    "setPasswordSignInAgainHint": "Vous vous êtes connecté il y a plus de {minutes} minutes. Reconnectez-vous, puis définissez le mot de passe tout de suite.",
+    "signInAgain": "Se reconnecter",
     "twoFactorTitle": "Authentification a deux facteurs",
     "twoFactorTitle": "Authentification a deux facteurs",
     "twoFactorEnabled": "L'authentification a deux facteurs est activee",
     "twoFactorEnabled": "L'authentification a deux facteurs est activee",
     "twoFactorDisablePrompt": "Entrez votre mot de passe pour désactiver l'authentification a deux facteurs.",
     "twoFactorDisablePrompt": "Entrez votre mot de passe pour désactiver l'authentification a deux facteurs.",
     "twoFactorEnablePrompt": "Ajoutez une couche de sécurité supplémentaire a votre compte en activant l'authentification a deux facteurs avec une application d'authentification.",
     "twoFactorEnablePrompt": "Ajoutez une couche de sécurité supplémentaire a votre compte en activant l'authentification a deux facteurs avec une application d'authentification.",
+    "twoFactorNeedsPassword": "L'authentification à deux facteurs ajoute un code à la connexion par mot de passe. Définissez d'abord un mot de passe.",
     "password": "Mot de passe",
     "password": "Mot de passe",
     "enterPassword": "Entrez votre mot de passe",
     "enterPassword": "Entrez votre mot de passe",
     "disable2FA": "Désactiver 2FA",
     "disable2FA": "Désactiver 2FA",
@@ -360,6 +370,7 @@
     "reauthSignInAgainAccount": "Reconnectez-vous avant de supprimer votre compte.",
     "reauthSignInAgainAccount": "Reconnectez-vous avant de supprimer votre compte.",
     "reauthSignInAgainWorkshop": "Reconnectez-vous avant de supprimer l'atelier.",
     "reauthSignInAgainWorkshop": "Reconnectez-vous avant de supprimer l'atelier.",
     "reauthSignInAgainRestore": "Reconnectez-vous avant de restaurer l'atelier.",
     "reauthSignInAgainRestore": "Reconnectez-vous avant de restaurer l'atelier.",
+    "reauthSignInAgainPassword": "Reconnectez-vous avant de définir un mot de passe.",
     "deleteAccountMoved": "Vous souhaitez supprimer votre compte ?",
     "deleteAccountMoved": "Vous souhaitez supprimer votre compte ?",
     "deleteAccountMovedLink": "Vous pouvez le faire dans les paramètres des données.",
     "deleteAccountMovedLink": "Vous pouvez le faire dans les paramètres des données.",
     "signatureTitle": "Signature",
     "signatureTitle": "Signature",

+ 1 - 0
messages/it/audit.json

@@ -88,6 +88,7 @@
     "auth_login": "Utente connesso",
     "auth_login": "Utente connesso",
     "auth_sessionRevoked": "Dispositivo disconnesso",
     "auth_sessionRevoked": "Dispositivo disconnesso",
     "auth_sessionsRevoked": "Altri dispositivi disconnessi",
     "auth_sessionsRevoked": "Altri dispositivi disconnessi",
+    "auth_passwordSet": "Password dell'account impostata",
     "auth_loginFailed": "Tentativo di accesso fallito",
     "auth_loginFailed": "Tentativo di accesso fallito",
     "auth_register": "Utente registrato",
     "auth_register": "Utente registrato",
     "auth_passwordReset": "Reimpostazione password richiesta",
     "auth_passwordReset": "Reimpostazione password richiesta",

+ 11 - 0
messages/it/settings.json

@@ -245,10 +245,20 @@
     "passwordsNoMatch": "Le password non corrispondono",
     "passwordsNoMatch": "Le password non corrispondono",
     "passwordMinLength": "La password deve contenere almeno 8 caratteri",
     "passwordMinLength": "La password deve contenere almeno 8 caratteri",
     "failedChangePassword": "Cambio password non riuscito",
     "failedChangePassword": "Cambio password non riuscito",
+    "setPasswordTitle": "Imposta una password",
+    "setPasswordDescription": "Questo account accede senza password, ad esempio con Google. Impostane una per accedere anche con il tuo indirizzo email e una password.",
+    "setPassword": "Imposta password",
+    "passwordSet": "Password impostata. Ora puoi accedere con il tuo indirizzo email e questa password.",
+    "failedSetPassword": "Impostazione della password non riuscita",
+    "passwordAlreadySet": "Questo account ha già una password. Cambiala qui.",
+    "setPasswordFreshHint": "Per la tua sicurezza, questa operazione è possibile solo entro {minutes} minuti dall'accesso. Se è passato più tempo, ti verrà chiesto di accedere di nuovo.",
+    "setPasswordSignInAgainHint": "Hai effettuato l'accesso più di {minutes} minuti fa. Accedi di nuovo, poi imposta subito la password.",
+    "signInAgain": "Accedi di nuovo",
     "twoFactorTitle": "Autenticazione a due fattori",
     "twoFactorTitle": "Autenticazione a due fattori",
     "twoFactorEnabled": "L'autenticazione a due fattori e attiva",
     "twoFactorEnabled": "L'autenticazione a due fattori e attiva",
     "twoFactorDisablePrompt": "Inserisci la tua password per disabilitare l'autenticazione a due fattori.",
     "twoFactorDisablePrompt": "Inserisci la tua password per disabilitare l'autenticazione a due fattori.",
     "twoFactorEnablePrompt": "Aggiungi un ulteriore livello di sicurezza al tuo account abilitando l'autenticazione a due fattori con un'app di autenticazione.",
     "twoFactorEnablePrompt": "Aggiungi un ulteriore livello di sicurezza al tuo account abilitando l'autenticazione a due fattori con un'app di autenticazione.",
+    "twoFactorNeedsPassword": "L'autenticazione a due fattori aggiunge un codice all'accesso con password. Imposta prima una password.",
     "password": "Password",
     "password": "Password",
     "enterPassword": "Inserisci la tua password",
     "enterPassword": "Inserisci la tua password",
     "disable2FA": "Disabilita 2FA",
     "disable2FA": "Disabilita 2FA",
@@ -360,6 +370,7 @@
     "reauthSignInAgainAccount": "Accedi di nuovo prima di eliminare il tuo account.",
     "reauthSignInAgainAccount": "Accedi di nuovo prima di eliminare il tuo account.",
     "reauthSignInAgainWorkshop": "Accedi di nuovo prima di eliminare l'officina.",
     "reauthSignInAgainWorkshop": "Accedi di nuovo prima di eliminare l'officina.",
     "reauthSignInAgainRestore": "Accedi di nuovo prima di ripristinare l'officina.",
     "reauthSignInAgainRestore": "Accedi di nuovo prima di ripristinare l'officina.",
+    "reauthSignInAgainPassword": "Accedi di nuovo prima di impostare una password.",
     "deleteAccountMoved": "Vuoi eliminare il tuo account?",
     "deleteAccountMoved": "Vuoi eliminare il tuo account?",
     "deleteAccountMovedLink": "Puoi farlo nelle impostazioni dei dati.",
     "deleteAccountMovedLink": "Puoi farlo nelle impostazioni dei dati.",
     "signatureTitle": "Firma",
     "signatureTitle": "Firma",

+ 1 - 0
messages/lt/audit.json

@@ -88,6 +88,7 @@
     "auth_login": "Naudotojas prisijungė",
     "auth_login": "Naudotojas prisijungė",
     "auth_sessionRevoked": "Atjungtas įrenginys",
     "auth_sessionRevoked": "Atjungtas įrenginys",
     "auth_sessionsRevoked": "Atjungti kiti įrenginiai",
     "auth_sessionsRevoked": "Atjungti kiti įrenginiai",
+    "auth_passwordSet": "Nustatytas paskyros slaptažodis",
     "auth_loginFailed": "Nesėkmingas prisijungimo bandymas",
     "auth_loginFailed": "Nesėkmingas prisijungimo bandymas",
     "auth_register": "Naudotojas užsiregistravo",
     "auth_register": "Naudotojas užsiregistravo",
     "auth_passwordReset": "Prašomas slaptažodžio atstatymas",
     "auth_passwordReset": "Prašomas slaptažodžio atstatymas",

+ 11 - 0
messages/lt/settings.json

@@ -245,10 +245,20 @@
     "passwordsNoMatch": "Slaptažodžiai nesutampa",
     "passwordsNoMatch": "Slaptažodžiai nesutampa",
     "passwordMinLength": "Slaptažodis turi būti bent 8 simbolių",
     "passwordMinLength": "Slaptažodis turi būti bent 8 simbolių",
     "failedChangePassword": "Nepavyko pakeisti slaptažodžio",
     "failedChangePassword": "Nepavyko pakeisti slaptažodžio",
+    "setPasswordTitle": "Nustatyti slaptažodį",
+    "setPasswordDescription": "Ši paskyra prisijungia be slaptažodžio, pavyzdžiui, per Google. Nustatykite slaptažodį, kad galėtumėte prisijungti ir el. pašto adresu bei slaptažodžiu.",
+    "setPassword": "Nustatyti slaptažodį",
+    "passwordSet": "Slaptažodis nustatytas. Dabar galite prisijungti el. pašto adresu ir šiuo slaptažodžiu.",
+    "failedSetPassword": "Nepavyko nustatyti slaptažodžio",
+    "passwordAlreadySet": "Ši paskyra jau turi slaptažodį. Pakeiskite jį čia.",
+    "setPasswordFreshHint": "Saugumo sumetimais tai veikia tik per {minutes} min. nuo prisijungimo. Jei praėjo daugiau laiko, jūsų bus paprašyta prisijungti iš naujo.",
+    "setPasswordSignInAgainHint": "Prisijungėte daugiau nei prieš {minutes} min. Prisijunkite iš naujo ir iškart nustatykite slaptažodį.",
+    "signInAgain": "Prisijungti iš naujo",
     "twoFactorTitle": "Dviejų veiksnių autentifikavimas",
     "twoFactorTitle": "Dviejų veiksnių autentifikavimas",
     "twoFactorEnabled": "Dviejų veiksnių autentifikavimas įjungtas",
     "twoFactorEnabled": "Dviejų veiksnių autentifikavimas įjungtas",
     "twoFactorDisablePrompt": "Įveskite slaptažodį, kad išjungtumėte dviejų veiksnių autentifikavimą.",
     "twoFactorDisablePrompt": "Įveskite slaptažodį, kad išjungtumėte dviejų veiksnių autentifikavimą.",
     "twoFactorEnablePrompt": "Pridėkite papildomą saugumo lygmenį savo paskyrai, įjungdami dviejų veiksnių autentifikavimą su autentifikavimo programėle.",
     "twoFactorEnablePrompt": "Pridėkite papildomą saugumo lygmenį savo paskyrai, įjungdami dviejų veiksnių autentifikavimą su autentifikavimo programėle.",
+    "twoFactorNeedsPassword": "Dviejų veiksnių autentifikavimas prie prisijungimo slaptažodžiu prideda kodą. Pirmiausia nustatykite slaptažodį.",
     "password": "Slaptažodis",
     "password": "Slaptažodis",
     "enterPassword": "Įveskite slaptažodį",
     "enterPassword": "Įveskite slaptažodį",
     "disable2FA": "Išjungti 2FA",
     "disable2FA": "Išjungti 2FA",
@@ -360,6 +370,7 @@
     "reauthSignInAgainAccount": "Prieš ištrindami paskyrą, prisijunkite iš naujo.",
     "reauthSignInAgainAccount": "Prieš ištrindami paskyrą, prisijunkite iš naujo.",
     "reauthSignInAgainWorkshop": "Prieš ištrindami dirbtuves, prisijunkite iš naujo.",
     "reauthSignInAgainWorkshop": "Prieš ištrindami dirbtuves, prisijunkite iš naujo.",
     "reauthSignInAgainRestore": "Prieš atkurdami dirbtuvę, prisijunkite iš naujo.",
     "reauthSignInAgainRestore": "Prieš atkurdami dirbtuvę, prisijunkite iš naujo.",
+    "reauthSignInAgainPassword": "Prieš nustatydami slaptažodį, prisijunkite iš naujo.",
     "deleteAccountMoved": "Norite ištrinti savo paskyrą?",
     "deleteAccountMoved": "Norite ištrinti savo paskyrą?",
     "deleteAccountMovedLink": "Tai galite padaryti duomenų nustatymuose.",
     "deleteAccountMovedLink": "Tai galite padaryti duomenų nustatymuose.",
     "signatureTitle": "Parašas",
     "signatureTitle": "Parašas",

+ 1 - 0
messages/nb/audit.json

@@ -88,6 +88,7 @@
     "auth_login": "Bruker logget inn",
     "auth_login": "Bruker logget inn",
     "auth_sessionRevoked": "Logget ut en enhet",
     "auth_sessionRevoked": "Logget ut en enhet",
     "auth_sessionsRevoked": "Logget ut andre enheter",
     "auth_sessionsRevoked": "Logget ut andre enheter",
+    "auth_passwordSet": "Angav kontopassord",
     "auth_loginFailed": "Mislykket påloggingsforsøk",
     "auth_loginFailed": "Mislykket påloggingsforsøk",
     "auth_register": "Bruker registrert",
     "auth_register": "Bruker registrert",
     "auth_passwordReset": "Passord-tilbakestilling forespurt",
     "auth_passwordReset": "Passord-tilbakestilling forespurt",

+ 11 - 0
messages/nb/settings.json

@@ -245,10 +245,20 @@
     "passwordsNoMatch": "Passordene samsvarer ikke",
     "passwordsNoMatch": "Passordene samsvarer ikke",
     "passwordMinLength": "Passordet må være minst 8 tegn",
     "passwordMinLength": "Passordet må være minst 8 tegn",
     "failedChangePassword": "Kunne ikke endre passord",
     "failedChangePassword": "Kunne ikke endre passord",
+    "setPasswordTitle": "Angi passord",
+    "setPasswordDescription": "Denne kontoen logger inn uten passord, for eksempel med Google. Angi et passord for også å kunne logge inn med e-postadressen din og et passord.",
+    "setPassword": "Angi passord",
+    "passwordSet": "Passord angitt. Du kan nå logge inn med e-postadressen din og dette passordet.",
+    "failedSetPassword": "Kunne ikke angi passord",
+    "passwordAlreadySet": "Denne kontoen har allerede et passord. Endre det her i stedet.",
+    "setPasswordFreshHint": "Av sikkerhetshensyn fungerer dette bare innen {minutes} minutter etter at du logget inn. Har det gått lenger tid, blir du bedt om å logge inn på nytt.",
+    "setPasswordSignInAgainHint": "Du logget inn for mer enn {minutes} minutter siden. Logg inn på nytt, og angi passordet med en gang.",
+    "signInAgain": "Logg inn på nytt",
     "twoFactorTitle": "Tofaktorautentisering",
     "twoFactorTitle": "Tofaktorautentisering",
     "twoFactorEnabled": "Tofaktorautentisering er aktivert",
     "twoFactorEnabled": "Tofaktorautentisering er aktivert",
     "twoFactorDisablePrompt": "Skriv inn passordet ditt for å deaktivere tofaktorautentisering.",
     "twoFactorDisablePrompt": "Skriv inn passordet ditt for å deaktivere tofaktorautentisering.",
     "twoFactorEnablePrompt": "Legg til et ekstra sikkerhetslag på kontoen din ved å aktivere tofaktorautentisering med en autentiseringsapp.",
     "twoFactorEnablePrompt": "Legg til et ekstra sikkerhetslag på kontoen din ved å aktivere tofaktorautentisering med en autentiseringsapp.",
+    "twoFactorNeedsPassword": "Tofaktorautentisering legger til en kode når du logger inn med passord. Angi et passord først.",
     "password": "Passord",
     "password": "Passord",
     "enterPassword": "Skriv inn passordet ditt",
     "enterPassword": "Skriv inn passordet ditt",
     "disable2FA": "Deaktiver 2FA",
     "disable2FA": "Deaktiver 2FA",
@@ -360,6 +370,7 @@
     "reauthSignInAgainAccount": "Logg inn på nytt før du sletter kontoen din.",
     "reauthSignInAgainAccount": "Logg inn på nytt før du sletter kontoen din.",
     "reauthSignInAgainWorkshop": "Logg inn på nytt før du sletter verkstedet.",
     "reauthSignInAgainWorkshop": "Logg inn på nytt før du sletter verkstedet.",
     "reauthSignInAgainRestore": "Logg inn på nytt før du gjenoppretter verkstedet.",
     "reauthSignInAgainRestore": "Logg inn på nytt før du gjenoppretter verkstedet.",
+    "reauthSignInAgainPassword": "Logg inn på nytt før du angir et passord.",
     "deleteAccountMoved": "Vil du slette kontoen din?",
     "deleteAccountMoved": "Vil du slette kontoen din?",
     "deleteAccountMovedLink": "Det kan du gjøre i datainnstillingene.",
     "deleteAccountMovedLink": "Det kan du gjøre i datainnstillingene.",
     "signatureTitle": "Signatur",
     "signatureTitle": "Signatur",

+ 1 - 0
messages/nl/audit.json

@@ -88,6 +88,7 @@
     "auth_login": "Gebruiker ingelogd",
     "auth_login": "Gebruiker ingelogd",
     "auth_sessionRevoked": "Apparaat uitgelogd",
     "auth_sessionRevoked": "Apparaat uitgelogd",
     "auth_sessionsRevoked": "Andere apparaten uitgelogd",
     "auth_sessionsRevoked": "Andere apparaten uitgelogd",
+    "auth_passwordSet": "Accountwachtwoord ingesteld",
     "auth_loginFailed": "Mislukte inlogpoging",
     "auth_loginFailed": "Mislukte inlogpoging",
     "auth_register": "Gebruiker geregistreerd",
     "auth_register": "Gebruiker geregistreerd",
     "auth_passwordReset": "Wachtwoord reset aangevraagd",
     "auth_passwordReset": "Wachtwoord reset aangevraagd",

+ 11 - 0
messages/nl/settings.json

@@ -245,10 +245,20 @@
     "passwordsNoMatch": "Wachtwoorden komen niet overeen",
     "passwordsNoMatch": "Wachtwoorden komen niet overeen",
     "passwordMinLength": "Wachtwoord moet minimaal 8 tekens bevatten",
     "passwordMinLength": "Wachtwoord moet minimaal 8 tekens bevatten",
     "failedChangePassword": "Wachtwoord wijzigen mislukt",
     "failedChangePassword": "Wachtwoord wijzigen mislukt",
+    "setPasswordTitle": "Wachtwoord instellen",
+    "setPasswordDescription": "Dit account logt in zonder wachtwoord, bijvoorbeeld met Google. Stel er een in om ook met uw e-mailadres en een wachtwoord te kunnen inloggen.",
+    "setPassword": "Wachtwoord instellen",
+    "passwordSet": "Wachtwoord ingesteld. U kunt nu inloggen met uw e-mailadres en dit wachtwoord.",
+    "failedSetPassword": "Wachtwoord instellen mislukt",
+    "passwordAlreadySet": "Dit account heeft al een wachtwoord. Wijzig het hier.",
+    "setPasswordFreshHint": "Voor uw veiligheid werkt dit alleen binnen {minutes} minuten na het inloggen. Is het langer geleden, dan wordt u gevraagd opnieuw in te loggen.",
+    "setPasswordSignInAgainHint": "U bent meer dan {minutes} minuten geleden ingelogd. Log opnieuw in en stel daarna meteen het wachtwoord in.",
+    "signInAgain": "Opnieuw inloggen",
     "twoFactorTitle": "Tweefactorauthenticatie",
     "twoFactorTitle": "Tweefactorauthenticatie",
     "twoFactorEnabled": "Tweefactorauthenticatie is ingeschakeld",
     "twoFactorEnabled": "Tweefactorauthenticatie is ingeschakeld",
     "twoFactorDisablePrompt": "Voer uw wachtwoord in om tweefactorauthenticatie uit te schakelen.",
     "twoFactorDisablePrompt": "Voer uw wachtwoord in om tweefactorauthenticatie uit te schakelen.",
     "twoFactorEnablePrompt": "Voeg een extra beveiligingslaag toe aan uw account door tweefactorauthenticatie in te schakelen met een authenticatie-app.",
     "twoFactorEnablePrompt": "Voeg een extra beveiligingslaag toe aan uw account door tweefactorauthenticatie in te schakelen met een authenticatie-app.",
+    "twoFactorNeedsPassword": "Tweefactorauthenticatie voegt een code toe aan het inloggen met een wachtwoord. Stel eerst een wachtwoord in.",
     "password": "Wachtwoord",
     "password": "Wachtwoord",
     "enterPassword": "Voer uw wachtwoord in",
     "enterPassword": "Voer uw wachtwoord in",
     "disable2FA": "2FA uitschakelen",
     "disable2FA": "2FA uitschakelen",
@@ -360,6 +370,7 @@
     "reauthSignInAgainAccount": "Log opnieuw in voordat u uw account verwijdert.",
     "reauthSignInAgainAccount": "Log opnieuw in voordat u uw account verwijdert.",
     "reauthSignInAgainWorkshop": "Log opnieuw in voordat u de werkplaats verwijdert.",
     "reauthSignInAgainWorkshop": "Log opnieuw in voordat u de werkplaats verwijdert.",
     "reauthSignInAgainRestore": "Log opnieuw in voordat u de werkplaats terugzet.",
     "reauthSignInAgainRestore": "Log opnieuw in voordat u de werkplaats terugzet.",
+    "reauthSignInAgainPassword": "Log opnieuw in voordat u een wachtwoord instelt.",
     "deleteAccountMoved": "Wilt u uw account verwijderen?",
     "deleteAccountMoved": "Wilt u uw account verwijderen?",
     "deleteAccountMovedLink": "Dat kan in de gegevensinstellingen.",
     "deleteAccountMovedLink": "Dat kan in de gegevensinstellingen.",
     "signatureTitle": "Handtekening",
     "signatureTitle": "Handtekening",

+ 1 - 0
messages/pl/audit.json

@@ -88,6 +88,7 @@
     "auth_login": "Użytkownik zalogowany",
     "auth_login": "Użytkownik zalogowany",
     "auth_sessionRevoked": "Wylogowano urządzenie",
     "auth_sessionRevoked": "Wylogowano urządzenie",
     "auth_sessionsRevoked": "Wylogowano pozostałe urządzenia",
     "auth_sessionsRevoked": "Wylogowano pozostałe urządzenia",
+    "auth_passwordSet": "Ustawiono hasło konta",
     "auth_loginFailed": "Nieudana próba logowania",
     "auth_loginFailed": "Nieudana próba logowania",
     "auth_register": "Użytkownik zarejestrowany",
     "auth_register": "Użytkownik zarejestrowany",
     "auth_passwordReset": "Żądanie resetowania hasła",
     "auth_passwordReset": "Żądanie resetowania hasła",

+ 11 - 0
messages/pl/settings.json

@@ -245,10 +245,20 @@
     "passwordsNoMatch": "Hasla nie sa zgodne",
     "passwordsNoMatch": "Hasla nie sa zgodne",
     "passwordMinLength": "Haslo musi mieć co najmniej 8 znaków",
     "passwordMinLength": "Haslo musi mieć co najmniej 8 znaków",
     "failedChangePassword": "Nie udało się zmienić hasła",
     "failedChangePassword": "Nie udało się zmienić hasła",
+    "setPasswordTitle": "Ustaw hasło",
+    "setPasswordDescription": "To konto loguje się bez hasła, na przykład przez Google. Ustaw hasło, aby logować się także adresem e-mail i hasłem.",
+    "setPassword": "Ustaw hasło",
+    "passwordSet": "Hasło ustawione. Możesz teraz logować się adresem e-mail i tym hasłem.",
+    "failedSetPassword": "Nie udało się ustawić hasła",
+    "passwordAlreadySet": "To konto ma już hasło. Zmień je tutaj.",
+    "setPasswordFreshHint": "Ze względów bezpieczeństwa działa to tylko w ciągu {minutes} minut od zalogowania. Jeśli minęło więcej czasu, poprosimy o ponowne zalogowanie.",
+    "setPasswordSignInAgainHint": "Zalogowano się ponad {minutes} minut temu. Zaloguj się ponownie i od razu ustaw hasło.",
+    "signInAgain": "Zaloguj się ponownie",
     "twoFactorTitle": "Uwierzytelnianie dwuskładnikowe",
     "twoFactorTitle": "Uwierzytelnianie dwuskładnikowe",
     "twoFactorEnabled": "Uwierzytelnianie dwuskładnikowe jest włączone",
     "twoFactorEnabled": "Uwierzytelnianie dwuskładnikowe jest włączone",
     "twoFactorDisablePrompt": "Wprowadź hasło, aby wyłączyć uwierzytelnianie dwuskładnikowe.",
     "twoFactorDisablePrompt": "Wprowadź hasło, aby wyłączyć uwierzytelnianie dwuskładnikowe.",
     "twoFactorEnablePrompt": "Dodaj dodatkową warstwę bezpieczeństwa do swojego konta, włączając uwierzytelnianie dwuskładnikowe z aplikacją uwierzytelniającą.",
     "twoFactorEnablePrompt": "Dodaj dodatkową warstwę bezpieczeństwa do swojego konta, włączając uwierzytelnianie dwuskładnikowe z aplikacją uwierzytelniającą.",
+    "twoFactorNeedsPassword": "Uwierzytelnianie dwuskładnikowe dodaje kod do logowania hasłem. Najpierw ustaw hasło.",
     "password": "Haslo",
     "password": "Haslo",
     "enterPassword": "Wprowadź hasło",
     "enterPassword": "Wprowadź hasło",
     "disable2FA": "Wyłącz 2FA",
     "disable2FA": "Wyłącz 2FA",
@@ -360,6 +370,7 @@
     "reauthSignInAgainAccount": "Zaloguj się ponownie, zanim usuniesz konto.",
     "reauthSignInAgainAccount": "Zaloguj się ponownie, zanim usuniesz konto.",
     "reauthSignInAgainWorkshop": "Zaloguj się ponownie, zanim usuniesz warsztat.",
     "reauthSignInAgainWorkshop": "Zaloguj się ponownie, zanim usuniesz warsztat.",
     "reauthSignInAgainRestore": "Zaloguj się ponownie, zanim przywrócisz warsztat.",
     "reauthSignInAgainRestore": "Zaloguj się ponownie, zanim przywrócisz warsztat.",
+    "reauthSignInAgainPassword": "Zaloguj się ponownie, zanim ustawisz hasło.",
     "deleteAccountMoved": "Chcesz usunąć swoje konto?",
     "deleteAccountMoved": "Chcesz usunąć swoje konto?",
     "deleteAccountMovedLink": "Możesz to zrobić w ustawieniach danych.",
     "deleteAccountMovedLink": "Możesz to zrobić w ustawieniach danych.",
     "signatureTitle": "Podpis",
     "signatureTitle": "Podpis",

+ 1 - 0
messages/pt-BR/audit.json

@@ -88,6 +88,7 @@
     "auth_login": "Usuário conectado",
     "auth_login": "Usuário conectado",
     "auth_sessionRevoked": "Desconectou um dispositivo",
     "auth_sessionRevoked": "Desconectou um dispositivo",
     "auth_sessionsRevoked": "Desconectou os outros dispositivos",
     "auth_sessionsRevoked": "Desconectou os outros dispositivos",
+    "auth_passwordSet": "Definiu uma senha da conta",
     "auth_loginFailed": "Tentativa de login falhada",
     "auth_loginFailed": "Tentativa de login falhada",
     "auth_register": "Usuário registrado",
     "auth_register": "Usuário registrado",
     "auth_passwordReset": "Redefinição de senha solicitada",
     "auth_passwordReset": "Redefinição de senha solicitada",

+ 11 - 0
messages/pt-BR/settings.json

@@ -245,10 +245,20 @@
     "passwordsNoMatch": "As senhas não correspondem",
     "passwordsNoMatch": "As senhas não correspondem",
     "passwordMinLength": "A senha deve ter pelo menos 8 caracteres",
     "passwordMinLength": "A senha deve ter pelo menos 8 caracteres",
     "failedChangePassword": "Falha ao alterar a senha",
     "failedChangePassword": "Falha ao alterar a senha",
+    "setPasswordTitle": "Definir senha",
+    "setPasswordDescription": "Esta conta entra sem senha, por exemplo com o Google. Defina uma para também poder entrar com seu endereço de e-mail e uma senha.",
+    "setPassword": "Definir senha",
+    "passwordSet": "Senha definida. Agora você pode entrar com seu endereço de e-mail e esta senha.",
+    "failedSetPassword": "Falha ao definir a senha",
+    "passwordAlreadySet": "Esta conta já tem uma senha. Altere-a aqui.",
+    "setPasswordFreshHint": "Por segurança, isso só funciona até {minutes} minutos depois de entrar. Se já passou mais tempo, será solicitado que você entre novamente.",
+    "setPasswordSignInAgainHint": "Você entrou há mais de {minutes} minutos. Entre novamente e defina a senha logo em seguida.",
+    "signInAgain": "Entrar novamente",
     "twoFactorTitle": "Autenticação de dois fatores",
     "twoFactorTitle": "Autenticação de dois fatores",
     "twoFactorEnabled": "A autenticação de dois fatores está ativada",
     "twoFactorEnabled": "A autenticação de dois fatores está ativada",
     "twoFactorDisablePrompt": "Digite sua senha para desativar a autenticação de dois fatores.",
     "twoFactorDisablePrompt": "Digite sua senha para desativar a autenticação de dois fatores.",
     "twoFactorEnablePrompt": "Adicione uma camada extra de segurança a sua conta ativando a autenticação de dois fatores com um aplicativo autenticador.",
     "twoFactorEnablePrompt": "Adicione uma camada extra de segurança a sua conta ativando a autenticação de dois fatores com um aplicativo autenticador.",
+    "twoFactorNeedsPassword": "A autenticação de dois fatores adiciona um código ao login com senha. Defina uma senha primeiro.",
     "password": "Senha",
     "password": "Senha",
     "enterPassword": "Digite sua senha",
     "enterPassword": "Digite sua senha",
     "disable2FA": "Desativar 2FA",
     "disable2FA": "Desativar 2FA",
@@ -360,6 +370,7 @@
     "reauthSignInAgainAccount": "Entre novamente antes de excluir sua conta.",
     "reauthSignInAgainAccount": "Entre novamente antes de excluir sua conta.",
     "reauthSignInAgainWorkshop": "Entre novamente antes de excluir a oficina.",
     "reauthSignInAgainWorkshop": "Entre novamente antes de excluir a oficina.",
     "reauthSignInAgainRestore": "Entre novamente antes de restaurar a oficina.",
     "reauthSignInAgainRestore": "Entre novamente antes de restaurar a oficina.",
+    "reauthSignInAgainPassword": "Entre novamente antes de definir uma senha.",
     "deleteAccountMoved": "Quer excluir sua conta?",
     "deleteAccountMoved": "Quer excluir sua conta?",
     "deleteAccountMovedLink": "Você pode fazer isso nas configurações de dados.",
     "deleteAccountMovedLink": "Você pode fazer isso nas configurações de dados.",
     "signatureTitle": "Assinatura",
     "signatureTitle": "Assinatura",

+ 1 - 0
messages/pt-PT/audit.json

@@ -88,6 +88,7 @@
     "auth_login": "Utilizador iniciou sessão",
     "auth_login": "Utilizador iniciou sessão",
     "auth_sessionRevoked": "Terminou a sessão num dispositivo",
     "auth_sessionRevoked": "Terminou a sessão num dispositivo",
     "auth_sessionsRevoked": "Terminou a sessão nos outros dispositivos",
     "auth_sessionsRevoked": "Terminou a sessão nos outros dispositivos",
+    "auth_passwordSet": "Definiu uma palavra-passe da conta",
     "auth_loginFailed": "Tentativa de início de sessão falhada",
     "auth_loginFailed": "Tentativa de início de sessão falhada",
     "auth_register": "Utilizador registado",
     "auth_register": "Utilizador registado",
     "auth_passwordReset": "Reposição da palavra-passe pedida",
     "auth_passwordReset": "Reposição da palavra-passe pedida",

+ 11 - 0
messages/pt-PT/settings.json

@@ -245,10 +245,20 @@
     "passwordsNoMatch": "As palavras-passe não coincidem",
     "passwordsNoMatch": "As palavras-passe não coincidem",
     "passwordMinLength": "A palavra-passe deve ter pelo menos 8 caracteres",
     "passwordMinLength": "A palavra-passe deve ter pelo menos 8 caracteres",
     "failedChangePassword": "Não foi possível alterar a palavra-passe",
     "failedChangePassword": "Não foi possível alterar a palavra-passe",
+    "setPasswordTitle": "Definir palavra-passe",
+    "setPasswordDescription": "Esta conta inicia sessão sem palavra-passe, por exemplo com a Google. Defina uma para também poder iniciar sessão com o seu endereço de e-mail e uma palavra-passe.",
+    "setPassword": "Definir palavra-passe",
+    "passwordSet": "Palavra-passe definida. Já pode iniciar sessão com o seu endereço de e-mail e esta palavra-passe.",
+    "failedSetPassword": "Não foi possível definir a palavra-passe",
+    "passwordAlreadySet": "Esta conta já tem uma palavra-passe. Altere-a aqui.",
+    "setPasswordFreshHint": "Por segurança, isto só funciona nos {minutes} minutos seguintes ao início de sessão. Se já passou mais tempo, ser-lhe-á pedido que inicie sessão novamente.",
+    "setPasswordSignInAgainHint": "Iniciou sessão há mais de {minutes} minutos. Inicie sessão novamente e defina a palavra-passe de imediato.",
+    "signInAgain": "Iniciar sessão novamente",
     "twoFactorTitle": "Autenticação de dois fatores",
     "twoFactorTitle": "Autenticação de dois fatores",
     "twoFactorEnabled": "A autenticação de dois fatores está ativada",
     "twoFactorEnabled": "A autenticação de dois fatores está ativada",
     "twoFactorDisablePrompt": "Introduza a sua palavra-passe para desativar a autenticação de dois fatores.",
     "twoFactorDisablePrompt": "Introduza a sua palavra-passe para desativar a autenticação de dois fatores.",
     "twoFactorEnablePrompt": "Adicione uma camada extra de segurança à sua conta ao ativar a autenticação de dois fatores com uma aplicação de autenticação.",
     "twoFactorEnablePrompt": "Adicione uma camada extra de segurança à sua conta ao ativar a autenticação de dois fatores com uma aplicação de autenticação.",
+    "twoFactorNeedsPassword": "A autenticação de dois fatores acrescenta um código ao início de sessão com palavra-passe. Defina primeiro uma palavra-passe.",
     "password": "Palavra-passe",
     "password": "Palavra-passe",
     "enterPassword": "Introduza a sua palavra-passe",
     "enterPassword": "Introduza a sua palavra-passe",
     "disable2FA": "Desativar 2FA",
     "disable2FA": "Desativar 2FA",
@@ -360,6 +370,7 @@
     "reauthSignInAgainAccount": "Inicie sessão novamente antes de eliminar a sua conta.",
     "reauthSignInAgainAccount": "Inicie sessão novamente antes de eliminar a sua conta.",
     "reauthSignInAgainWorkshop": "Inicie sessão novamente antes de eliminar a oficina.",
     "reauthSignInAgainWorkshop": "Inicie sessão novamente antes de eliminar a oficina.",
     "reauthSignInAgainRestore": "Inicie sessão novamente antes de repor a oficina.",
     "reauthSignInAgainRestore": "Inicie sessão novamente antes de repor a oficina.",
+    "reauthSignInAgainPassword": "Inicie sessão novamente antes de definir uma palavra-passe.",
     "deleteAccountMoved": "Quer eliminar a sua conta?",
     "deleteAccountMoved": "Quer eliminar a sua conta?",
     "deleteAccountMovedLink": "Pode fazê-lo nas definições de Dados.",
     "deleteAccountMovedLink": "Pode fazê-lo nas definições de Dados.",
     "signatureTitle": "Assinatura",
     "signatureTitle": "Assinatura",

+ 1 - 0
messages/ru/audit.json

@@ -88,6 +88,7 @@
     "auth_login": "Пользователь вошёл в систему",
     "auth_login": "Пользователь вошёл в систему",
     "auth_sessionRevoked": "Отключено устройство",
     "auth_sessionRevoked": "Отключено устройство",
     "auth_sessionsRevoked": "Отключены другие устройства",
     "auth_sessionsRevoked": "Отключены другие устройства",
+    "auth_passwordSet": "Задан пароль аккаунта",
     "auth_loginFailed": "Неудачная попытка входа",
     "auth_loginFailed": "Неудачная попытка входа",
     "auth_register": "Пользователь зарегистрировался",
     "auth_register": "Пользователь зарегистрировался",
     "auth_passwordReset": "Запрошен сброс пароля",
     "auth_passwordReset": "Запрошен сброс пароля",

+ 11 - 0
messages/ru/settings.json

@@ -245,10 +245,20 @@
     "passwordsNoMatch": "Пароли не совпадают",
     "passwordsNoMatch": "Пароли не совпадают",
     "passwordMinLength": "Пароль должен содержать не менее 8 символов",
     "passwordMinLength": "Пароль должен содержать не менее 8 символов",
     "failedChangePassword": "Не удалось изменить пароль",
     "failedChangePassword": "Не удалось изменить пароль",
+    "setPasswordTitle": "Задать пароль",
+    "setPasswordDescription": "Этот аккаунт входит без пароля, например через Google. Задайте пароль, чтобы также входить по адресу эл. почты и паролю.",
+    "setPassword": "Задать пароль",
+    "passwordSet": "Пароль задан. Теперь вы можете входить по адресу эл. почты и этому паролю.",
+    "failedSetPassword": "Не удалось задать пароль",
+    "passwordAlreadySet": "У этого аккаунта уже есть пароль. Измените его здесь.",
+    "setPasswordFreshHint": "В целях безопасности это возможно только в течение {minutes} минут после входа. Если прошло больше времени, вас попросят войти снова.",
+    "setPasswordSignInAgainHint": "Вы вошли более {minutes} минут назад. Войдите снова и сразу задайте пароль.",
+    "signInAgain": "Войти снова",
     "twoFactorTitle": "Двухфакторная аутентификация",
     "twoFactorTitle": "Двухфакторная аутентификация",
     "twoFactorEnabled": "Двухфакторная аутентификация включена",
     "twoFactorEnabled": "Двухфакторная аутентификация включена",
     "twoFactorDisablePrompt": "Введите пароль для отключения двухфакторной аутентификации.",
     "twoFactorDisablePrompt": "Введите пароль для отключения двухфакторной аутентификации.",
     "twoFactorEnablePrompt": "Добавьте дополнительный уровень безопасности, включив двухфакторную аутентификацию через приложение-аутентификатор.",
     "twoFactorEnablePrompt": "Добавьте дополнительный уровень безопасности, включив двухфакторную аутентификацию через приложение-аутентификатор.",
+    "twoFactorNeedsPassword": "Двухфакторная аутентификация добавляет код ко входу по паролю. Сначала задайте пароль.",
     "password": "Пароль",
     "password": "Пароль",
     "enterPassword": "Введите пароль",
     "enterPassword": "Введите пароль",
     "disable2FA": "Отключить 2FA",
     "disable2FA": "Отключить 2FA",
@@ -360,6 +370,7 @@
     "reauthSignInAgainAccount": "Войдите снова, прежде чем удалять аккаунт.",
     "reauthSignInAgainAccount": "Войдите снова, прежде чем удалять аккаунт.",
     "reauthSignInAgainWorkshop": "Войдите снова, прежде чем удалять мастерскую.",
     "reauthSignInAgainWorkshop": "Войдите снова, прежде чем удалять мастерскую.",
     "reauthSignInAgainRestore": "Войдите снова, прежде чем восстанавливать мастерскую.",
     "reauthSignInAgainRestore": "Войдите снова, прежде чем восстанавливать мастерскую.",
+    "reauthSignInAgainPassword": "Войдите снова, прежде чем задавать пароль.",
     "deleteAccountMoved": "Хотите удалить свою учётную запись?",
     "deleteAccountMoved": "Хотите удалить свою учётную запись?",
     "deleteAccountMovedLink": "Это можно сделать в настройках данных.",
     "deleteAccountMovedLink": "Это можно сделать в настройках данных.",
     "signatureTitle": "Подпись",
     "signatureTitle": "Подпись",

+ 1 - 0
messages/tr/audit.json

@@ -88,6 +88,7 @@
     "auth_login": "Kullanıcı giriş yaptı",
     "auth_login": "Kullanıcı giriş yaptı",
     "auth_sessionRevoked": "Bir cihazın oturumu kapatıldı",
     "auth_sessionRevoked": "Bir cihazın oturumu kapatıldı",
     "auth_sessionsRevoked": "Diğer cihazların oturumu kapatıldı",
     "auth_sessionsRevoked": "Diğer cihazların oturumu kapatıldı",
+    "auth_passwordSet": "Hesap şifresi belirlendi",
     "auth_loginFailed": "Başarısız giriş denemesi",
     "auth_loginFailed": "Başarısız giriş denemesi",
     "auth_register": "Kullanıcı kaydoldu",
     "auth_register": "Kullanıcı kaydoldu",
     "auth_passwordReset": "Şifre sıfırlama talep edildi",
     "auth_passwordReset": "Şifre sıfırlama talep edildi",

+ 11 - 0
messages/tr/settings.json

@@ -245,10 +245,20 @@
     "passwordsNoMatch": "Şifreler eşleşmiyor",
     "passwordsNoMatch": "Şifreler eşleşmiyor",
     "passwordMinLength": "Şifre en az 8 karakter olmalıdır",
     "passwordMinLength": "Şifre en az 8 karakter olmalıdır",
     "failedChangePassword": "Şifre değiştirilemedi",
     "failedChangePassword": "Şifre değiştirilemedi",
+    "setPasswordTitle": "Şifre Belirle",
+    "setPasswordDescription": "Bu hesap, örneğin Google ile şifresiz giriş yapıyor. E-posta adresiniz ve bir şifreyle de giriş yapabilmek için bir şifre belirleyin.",
+    "setPassword": "Şifre Belirle",
+    "passwordSet": "Şifre belirlendi. Artık e-posta adresiniz ve bu şifreyle giriş yapabilirsiniz.",
+    "failedSetPassword": "Şifre belirlenemedi",
+    "passwordAlreadySet": "Bu hesabın zaten bir şifresi var. Bunun yerine şifreyi buradan değiştirin.",
+    "setPasswordFreshHint": "Güvenliğiniz için bu işlem yalnızca giriş yaptıktan sonraki {minutes} dakika içinde yapılabilir. Daha uzun süre geçtiyse yeniden giriş yapmanız istenir.",
+    "setPasswordSignInAgainHint": "{minutes} dakikadan uzun süre önce giriş yaptınız. Yeniden giriş yapın ve şifreyi hemen belirleyin.",
+    "signInAgain": "Yeniden giriş yap",
     "twoFactorTitle": "İki Faktörlü Kimlik Doğrulama",
     "twoFactorTitle": "İki Faktörlü Kimlik Doğrulama",
     "twoFactorEnabled": "İki faktörlü kimlik doğrulama etkin",
     "twoFactorEnabled": "İki faktörlü kimlik doğrulama etkin",
     "twoFactorDisablePrompt": "İki faktörlü kimlik doğrulamayı devre dışı bırakmak için şifrenizi girin.",
     "twoFactorDisablePrompt": "İki faktörlü kimlik doğrulamayı devre dışı bırakmak için şifrenizi girin.",
     "twoFactorEnablePrompt": "Bir doğrulayıcı uygulama ile iki faktörlü kimlik doğrulamayı etkinleştirerek hesabınıza ekstra bir güvenlik katmanı ekleyin.",
     "twoFactorEnablePrompt": "Bir doğrulayıcı uygulama ile iki faktörlü kimlik doğrulamayı etkinleştirerek hesabınıza ekstra bir güvenlik katmanı ekleyin.",
+    "twoFactorNeedsPassword": "İki faktörlü kimlik doğrulama, şifreyle girişe bir kod ekler. Önce bir şifre belirleyin.",
     "password": "Şifre",
     "password": "Şifre",
     "enterPassword": "Şifrenizi girin",
     "enterPassword": "Şifrenizi girin",
     "disable2FA": "2FA Devre Dışı Bırak",
     "disable2FA": "2FA Devre Dışı Bırak",
@@ -360,6 +370,7 @@
     "reauthSignInAgainAccount": "Hesabınızı silmeden önce yeniden giriş yapın.",
     "reauthSignInAgainAccount": "Hesabınızı silmeden önce yeniden giriş yapın.",
     "reauthSignInAgainWorkshop": "Atölyeyi silmeden önce yeniden giriş yapın.",
     "reauthSignInAgainWorkshop": "Atölyeyi silmeden önce yeniden giriş yapın.",
     "reauthSignInAgainRestore": "Atölyeyi geri yüklemeden önce yeniden giriş yapın.",
     "reauthSignInAgainRestore": "Atölyeyi geri yüklemeden önce yeniden giriş yapın.",
+    "reauthSignInAgainPassword": "Şifre belirlemeden önce yeniden giriş yapın.",
     "deleteAccountMoved": "Hesabınızı silmek mi istiyorsunuz?",
     "deleteAccountMoved": "Hesabınızı silmek mi istiyorsunuz?",
     "deleteAccountMovedLink": "Bunu veri ayarlarından yapabilirsiniz.",
     "deleteAccountMovedLink": "Bunu veri ayarlarından yapabilirsiniz.",
     "signatureTitle": "İmza",
     "signatureTitle": "İmza",

+ 193 - 0
src/__tests__/db/set-account-password.db.test.ts

@@ -0,0 +1,193 @@
+/**
+ * Giving a Google-only account its first password, on a real PostgreSQL and
+ * the real Better Auth (`npm run test:db` with E2E_DATABASE_URL set).
+ *
+ * The unit tests mock Better Auth, so they cannot say whether the password
+ * that was "set" is one the sign-in page accepts: that depends on the row
+ * Better Auth writes and the row its sign-in looks for, and a credential row
+ * that sign-in cannot find reads as "Invalid email or password" with a
+ * perfectly good hash in the database. This runs the action as the account
+ * page calls it and then signs in with the result.
+ *
+ * It makes one person and one workshop of its own and removes them again; it
+ * empties nothing.
+ */
+import { createHmac, randomUUID } from 'node:crypto'
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
+
+const url = process.env.E2E_DATABASE_URL ?? ''
+const databaseName = url.split('/').pop()?.split('?')[0]?.toLowerCase() ?? ''
+if (url && !/test|e2e/.test(databaseName)) {
+  throw new Error(
+    `Refusing to use database "${databaseName}": its name must say test or e2e, like the rest of this suite.`
+  )
+}
+
+// Set before the app's modules load, so the client connects to the test database.
+process.env.DATABASE_URL = url
+process.env.BETTER_AUTH_SECRET ||= 'db-test-secret-that-is-long-enough-to-sign-with'
+
+/** The request the action believes it is answering. */
+const request = vi.hoisted(() => ({ headers: new Headers() }))
+const sendAccountMail = vi.hoisted(() => vi.fn(async () => undefined))
+
+vi.mock('next/headers', () => ({
+  headers: async () => request.headers,
+  cookies: async () => ({ get: () => undefined, delete: () => undefined }),
+}))
+// No mail server here, and the notice is asserted on instead.
+vi.mock('@/lib/account-mail', () => ({ sendAccountMail }))
+
+const stamp = randomUUID().slice(0, 8)
+const EMAIL = `google-only-${stamp}@example.com`
+const FIRST_PASSWORD = `First-pass-${stamp}`
+const SECOND_PASSWORD = `Second-pass-${stamp}`
+
+let db: typeof import('@/lib/db')['db']
+let auth: typeof import('@/lib/auth')['auth']
+let setAccountPassword: typeof import('@/features/settings/Actions/setAccountPassword')['setAccountPassword']
+let REAUTH_ERRORS: typeof import('@/lib/reauth')['REAUTH_ERRORS']
+let ACCOUNT_HAS_PASSWORD: string
+
+let userId = ''
+let organizationId = ''
+let sessionId = ''
+
+/** Moves the sign-in back in time, the way a tab left open ages. */
+async function signedInMinutesAgo(minutes: number) {
+  await db.session.update({
+    where: { id: sessionId },
+    data: { createdAt: new Date(Date.now() - minutes * 60_000) },
+  })
+}
+
+async function providers(): Promise<string[]> {
+  const rows = await db.account.findMany({ where: { userId }, select: { providerId: true } })
+  return rows.map((r) => r.providerId).sort()
+}
+
+async function canSignInWith(password: string): Promise<boolean> {
+  return auth.api
+    .signInEmail({ body: { email: EMAIL, password } })
+    .then((result) => result.user.id === userId)
+    .catch(() => false)
+}
+
+beforeAll(async () => {
+  if (!url) throw new Error('E2E_DATABASE_URL is not set')
+  ;({ db } = await import('@/lib/db'))
+  ;({ auth } = await import('@/lib/auth'))
+  ;({ setAccountPassword } = await import('@/features/settings/Actions/setAccountPassword'))
+  ;({ REAUTH_ERRORS } = await import('@/lib/reauth'))
+  ;({ ACCOUNT_HAS_PASSWORD } = await import('@/features/settings/Schema/accountPassword'))
+
+  // What a first "Continue with Google" leaves behind: a person, and one
+  // account row that says Google vouches for them. No credential row.
+  const ctx = await auth.$context
+  const user = await db.user.create({
+    data: { email: EMAIL, name: 'Google Only', emailVerified: true },
+  })
+  userId = user.id
+  await ctx.internalAdapter.linkAccount({
+    userId,
+    providerId: 'google',
+    accountId: `google-sub-${stamp}`,
+  })
+
+  const organization = await db.organization.create({
+    data: { name: `Set Password Garage ${stamp}` },
+  })
+  organizationId = organization.id
+  await db.organizationMember.create({ data: { userId, organizationId, role: 'owner' } })
+
+  // Signed in, as the browser would be after Google sent them back.
+  const session = await ctx.internalAdapter.createSession(userId)
+  sessionId = session.id
+  const signature = createHmac('sha256', ctx.secret).update(session.token).digest('base64')
+  const cookie = `${ctx.authCookies.sessionToken.name}=${encodeURIComponent(`${session.token}.${signature}`)}`
+  request.headers = new Headers({ cookie })
+})
+
+afterAll(async () => {
+  if (!db) return
+  if (organizationId) await db.organization.deleteMany({ where: { id: organizationId } })
+  if (userId) {
+    // Deleting the user only blanks the audit row's user, so remove it first.
+    await db.auditLog.deleteMany({ where: { userId } })
+    await db.user.deleteMany({ where: { id: userId } })
+  }
+})
+
+describe('an account that signed up with Google', () => {
+  it('is signed in, and has no password to change', async () => {
+    const session = await auth.api.getSession({ headers: request.headers })
+    expect(session?.user.id).toBe(userId)
+    expect(await providers()).toEqual(['google'])
+
+    // What the Change password card ran into
+    await expect(
+      auth.api.changePassword({
+        body: { currentPassword: 'anything-at-all', newPassword: FIRST_PASSWORD },
+        headers: request.headers,
+      })
+    ).rejects.toThrow('Credential account not found')
+  })
+
+  it('is not given a password on a sign-in from earlier in the day', async () => {
+    await signedInMinutesAgo(11)
+
+    const result = await setAccountPassword({ newPassword: FIRST_PASSWORD })
+
+    expect(result).toMatchObject({ success: false, error: REAUTH_ERRORS.signInAgainPassword })
+    expect(await providers()).toEqual(['google'])
+    expect(await canSignInWith(FIRST_PASSWORD)).toBe(false)
+    expect(sendAccountMail).not.toHaveBeenCalled()
+  })
+
+  it('sets a password straight after signing in, and can sign in with it', async () => {
+    await signedInMinutesAgo(1)
+
+    const result = await setAccountPassword({ newPassword: FIRST_PASSWORD })
+
+    expect(result).toMatchObject({ success: true })
+    expect(await providers()).toEqual(['credential', 'google'])
+    expect(await canSignInWith(FIRST_PASSWORD)).toBe(true)
+    expect(await canSignInWith('Not-the-password-1')).toBe(false)
+
+    // The address is told, and the workshop's audit log is not where it goes
+    expect(sendAccountMail).toHaveBeenCalledWith(
+      expect.objectContaining({
+        to: EMAIL,
+        subject: 'A password was added to your Torqvoice account',
+      })
+    )
+    // The action writes it without waiting, so look until it is there.
+    const audit = await vi.waitFor(async () => {
+      const row = await db.auditLog.findFirst({ where: { userId, action: 'auth.passwordSet' } })
+      if (!row) throw new Error('no audit row yet')
+      return row
+    })
+    expect(audit).toMatchObject({ action: 'auth.passwordSet', organizationId: null })
+  })
+
+  it('cannot set it a second time, only change it by proving the first', async () => {
+    const again = await setAccountPassword({ newPassword: SECOND_PASSWORD })
+    expect(again).toMatchObject({ success: false, error: ACCOUNT_HAS_PASSWORD })
+    expect(await canSignInWith(SECOND_PASSWORD)).toBe(false)
+
+    await auth.api.changePassword({
+      body: { currentPassword: FIRST_PASSWORD, newPassword: SECOND_PASSWORD },
+      headers: request.headers,
+    })
+    expect(await canSignInWith(SECOND_PASSWORD)).toBe(true)
+    expect(await canSignInWith(FIRST_PASSWORD)).toBe(false)
+  })
+
+  it('can turn two-factor on now that there is a password to confirm with', async () => {
+    const enrolment = await auth.api.enableTwoFactor({
+      body: { password: SECOND_PASSWORD, method: 'totp' },
+      headers: request.headers,
+    })
+    expect(enrolment).toMatchObject({ totpURI: expect.stringContaining('otpauth://') })
+  })
+})

+ 228 - 0
src/__tests__/features/settings/account-set-password.test.tsx

@@ -0,0 +1,228 @@
+/**
+ * The password card on the account page, for the two kinds of account.
+ *
+ * Someone who signed up with Google has no password. The card used to ask
+ * them for their current one anyway and answer "Credential account not
+ * found", and the two-factor card walked them into a password field they
+ * could not fill in.
+ */
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const setAccountPassword = vi.hoisted(() => vi.fn())
+const changePassword = vi.hoisted(() => vi.fn())
+const signOut = vi.hoisted(() => vi.fn())
+const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), info: vi.fn() }))
+
+vi.mock('sonner', () => ({ toast }))
+vi.mock('next/navigation', () => ({ useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }) }))
+vi.mock('@/lib/auth-client', () => ({
+  useSession: () => ({
+    data: { user: { name: 'Google Person', email: 'google-person@x.test' } },
+  }),
+  authClient: {
+    changePassword,
+    signOut,
+    updateUser: vi.fn(),
+    sendVerificationEmail: vi.fn(),
+    twoFactor: { enable: vi.fn(), disable: vi.fn(), verifyTotp: vi.fn() },
+  },
+}))
+vi.mock('@/features/settings/Actions/setAccountPassword', () => ({ setAccountPassword }))
+vi.mock('@/features/settings/Actions/accountActions', () => ({ requestEmailChange: vi.fn() }))
+// The rest of the page: each has its own tests and its own server calls.
+vi.mock('@/app/(authenticated)/settings/account/signed-in-devices', () => ({
+  SignedInDevices: () => null,
+}))
+vi.mock('@/features/signatures/Components/SignatureCard', () => ({ SignatureCard: () => null }))
+vi.mock('@/features/settings/Components/passkey-settings', () => ({
+  PasskeySettings: () => null,
+}))
+vi.mock('qrcode.react', () => ({ QRCodeSVG: () => null }))
+
+import { AccountSettings } from '@/app/(authenticated)/settings/account/account-settings'
+
+const assign = vi.fn()
+
+function renderPage(hasPassword: boolean) {
+  return render(
+    <AccountSettings
+      twoFactorEnabled={false}
+      emailVerified
+      emailVerificationRequired={false}
+      hasPassword={hasPassword}
+      devices={[]}
+      signature={null}
+    />
+  )
+}
+
+function typePasswords(first: string, second = first) {
+  fireEvent.change(screen.getByLabelText('New Password'), { target: { value: first } })
+  fireEvent.change(screen.getByLabelText('Confirm New Password'), { target: { value: second } })
+}
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  setAccountPassword.mockResolvedValue({ success: true, data: { set: true } })
+  changePassword.mockResolvedValue({ data: {}, error: null })
+  signOut.mockResolvedValue({ data: {}, error: null })
+  vi.stubGlobal('location', { ...window.location, assign })
+})
+
+afterEach(() => {
+  vi.unstubAllGlobals()
+})
+
+describe('an account that signed up with Google and has no password', () => {
+  it('is offered to set a password, and is not asked for a current one', () => {
+    renderPage(false)
+
+    expect(screen.getByText('Set a Password')).toBeInTheDocument()
+    expect(screen.getByRole('button', { name: 'Set Password' })).toBeInTheDocument()
+    expect(screen.queryByLabelText('Current Password')).toBeNull()
+    expect(screen.queryByRole('button', { name: 'Change Password' })).toBeNull()
+  })
+
+  it('sets the password, and from then on offers to change it', async () => {
+    renderPage(false)
+    typePasswords('correct horse battery')
+    fireEvent.click(screen.getByRole('button', { name: 'Set Password' }))
+
+    await waitFor(() =>
+      expect(setAccountPassword).toHaveBeenCalledWith({ newPassword: 'correct horse battery' })
+    )
+    // Never the call that answered "Credential account not found"
+    expect(changePassword).not.toHaveBeenCalled()
+    await waitFor(() =>
+      expect(toast.success).toHaveBeenCalledWith(
+        'Password set. You can now sign in with your email address and this password.'
+      )
+    )
+
+    expect(await screen.findByLabelText('Current Password')).toBeInTheDocument()
+    expect(screen.getByRole('button', { name: 'Change Password' })).toBeInTheDocument()
+    expect(screen.getByLabelText('New Password')).toHaveValue('')
+  })
+
+  it('says to sign in again when the sign-in is too old, and takes them there and back', async () => {
+    setAccountPassword.mockResolvedValue({
+      success: false,
+      error: 'Sign in again before setting a password.',
+    })
+    renderPage(false)
+    typePasswords('correct horse battery')
+    fireEvent.click(screen.getByRole('button', { name: 'Set Password' }))
+
+    const notice = await screen.findByRole('alert')
+    expect(notice).toHaveTextContent('Sign in again before setting a password.')
+    expect(toast.error).not.toHaveBeenCalled()
+
+    fireEvent.click(screen.getByRole('button', { name: 'Sign in again' }))
+    // The sign-in page sends a signed-in visitor straight back, so out first
+    await waitFor(() => expect(signOut).toHaveBeenCalled())
+    await waitFor(() =>
+      expect(assign).toHaveBeenCalledWith('/auth/sign-in?redirect=%2Fsettings%2Faccount')
+    )
+    expect(signOut.mock.invocationCallOrder[0]).toBeLessThan(assign.mock.invocationCallOrder[0])
+  })
+
+  it('stays on the page and says so when signing out for the new sign-in fails', async () => {
+    setAccountPassword.mockResolvedValue({
+      success: false,
+      error: 'Sign in again before setting a password.',
+    })
+    // Still signed in: the sign-in page would only send them straight back
+    signOut.mockResolvedValue({ data: null, error: { message: 'Server error', status: 500 } })
+    renderPage(false)
+    typePasswords('correct horse battery')
+    fireEvent.click(screen.getByRole('button', { name: 'Set Password' }))
+    await screen.findByRole('alert')
+
+    fireEvent.click(screen.getByRole('button', { name: 'Sign in again' }))
+
+    await waitFor(() =>
+      expect(toast.error).toHaveBeenCalledWith('An unexpected error occurred. Please try again.')
+    )
+    expect(assign).not.toHaveBeenCalled()
+    expect(screen.getByRole('button', { name: 'Sign in again' })).toBeEnabled()
+  })
+
+  it('sends nothing when the two passwords differ', () => {
+    renderPage(false)
+    typePasswords('correct horse battery', 'correct horse batteri')
+    fireEvent.click(screen.getByRole('button', { name: 'Set Password' }))
+
+    expect(toast.error).toHaveBeenCalledWith('Passwords do not match')
+    expect(setAccountPassword).not.toHaveBeenCalled()
+  })
+
+  it('switches to changing the password when one turns out to exist already', async () => {
+    // Set in another tab while this one was open
+    setAccountPassword.mockResolvedValue({
+      success: false,
+      error: 'This account already has a password.',
+    })
+    renderPage(false)
+    typePasswords('correct horse battery')
+    fireEvent.click(screen.getByRole('button', { name: 'Set Password' }))
+
+    expect(await screen.findByLabelText('Current Password')).toBeInTheDocument()
+    expect(toast.error).not.toHaveBeenCalled()
+  })
+
+  it('says so when the password could not be set', async () => {
+    setAccountPassword.mockRejectedValue(new TypeError('Failed to fetch'))
+    renderPage(false)
+    typePasswords('correct horse battery')
+    fireEvent.click(screen.getByRole('button', { name: 'Set Password' }))
+
+    await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Failed to set password'))
+    // And the form is usable again
+    expect(screen.getByRole('button', { name: 'Set Password' })).toBeEnabled()
+  })
+
+  it('cannot start two-factor until it has a password, and is told why', async () => {
+    renderPage(false)
+
+    expect(screen.getByRole('button', { name: 'Enable 2FA' })).toBeDisabled()
+    expect(
+      screen.getByText(
+        'Two-factor authentication adds a code to signing in with a password. Set a password first.'
+      )
+    ).toBeInTheDocument()
+
+    typePasswords('correct horse battery')
+    fireEvent.click(screen.getByRole('button', { name: 'Set Password' }))
+
+    await waitFor(() => expect(screen.getByRole('button', { name: 'Enable 2FA' })).toBeEnabled())
+    expect(screen.queryByText(/Set a password first/)).toBeNull()
+  })
+})
+
+describe('an account with a password', () => {
+  it('changes it against the current one, as before', async () => {
+    renderPage(true)
+
+    expect(screen.queryByText('Set a Password')).toBeNull()
+    fireEvent.change(screen.getByLabelText('Current Password'), { target: { value: 'old-pass-1' } })
+    typePasswords('correct horse battery')
+    fireEvent.click(screen.getByRole('button', { name: 'Change Password' }))
+
+    await waitFor(() =>
+      expect(changePassword).toHaveBeenCalledWith({
+        currentPassword: 'old-pass-1',
+        newPassword: 'correct horse battery',
+        revokeOtherSessions: true,
+      })
+    )
+    expect(setAccountPassword).not.toHaveBeenCalled()
+  })
+
+  it('can start two-factor', () => {
+    renderPage(true)
+
+    expect(screen.getByRole('button', { name: 'Enable 2FA' })).toBeEnabled()
+    expect(screen.queryByText(/Set a password first/)).toBeNull()
+  })
+})

+ 159 - 0
src/__tests__/features/settings/set-account-password.test.ts

@@ -0,0 +1,159 @@
+/**
+ * Giving an account its first password. Someone who signed up with Google
+ * has none, and "change password" answered them "Credential account not
+ * found". The action that sets one is a way into the account that outlives
+ * the session, so what matters here is everything it refuses.
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const state = vi.hoisted(() => ({
+  passwordHash: null as string | null,
+  sessionCreatedAt: new Date(),
+  /** Set for a session a workshop minted for a technician's phone. */
+  sessionOrganizationId: null as string | null,
+  email: 'google-person@x.test',
+}))
+
+const setPassword = vi.hoisted(() => vi.fn(async () => ({ status: true })))
+const sendAccountMail = vi.hoisted(() => vi.fn(async () => undefined))
+const logAudit = vi.hoisted(() => vi.fn(async () => undefined))
+
+vi.mock('next/headers', () => ({
+  headers: vi.fn(async () => new Headers()),
+  cookies: vi.fn(async () => ({ get: vi.fn(), delete: vi.fn() })),
+}))
+vi.mock('@/lib/cached-session', () => ({
+  getCachedSession: vi.fn(async () => ({
+    user: { id: 'user-a', email: state.email, name: 'A' },
+    session: { createdAt: state.sessionCreatedAt, organizationId: state.sessionOrganizationId },
+  })),
+  getCachedMembership: vi.fn(async () => ({
+    organizationId: 'org-a',
+    role: 'member',
+    roleId: null,
+    customRole: null,
+  })),
+}))
+vi.mock('@/lib/auth', () => ({
+  auth: { api: { setPassword }, $context: Promise.resolve({}) },
+}))
+vi.mock('@/lib/db', () => ({
+  db: {
+    account: {
+      findFirst: vi.fn(async () => (state.passwordHash ? { password: state.passwordHash } : null)),
+    },
+    user: {
+      findUnique: vi.fn(async () => ({ isSuperAdmin: false, email: state.email, name: 'A' })),
+    },
+    organizationMember: { findFirst: vi.fn(async () => ({ role: 'member' })) },
+  },
+}))
+vi.mock('@/lib/account-mail', () => ({ sendAccountMail }))
+vi.mock('@/lib/audit', () => ({ logAudit, auditDetails: vi.fn(() => undefined) }))
+
+import { setAccountPassword } from '@/features/settings/Actions/setAccountPassword'
+import { ACCOUNT_HAS_PASSWORD } from '@/features/settings/Schema/accountPassword'
+import { REAUTH_ERRORS } from '@/lib/reauth'
+import { SCOPED_SESSION_MESSAGE } from '@/lib/session-scope'
+
+const minutesAgo = (minutes: number) => new Date(Date.now() - minutes * 60_000)
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  state.passwordHash = null
+  state.sessionCreatedAt = minutesAgo(1)
+  state.sessionOrganizationId = null
+  state.email = 'google-person@x.test'
+  vi.spyOn(console, 'error').mockImplementation(() => undefined)
+})
+
+describe('setting a first password', () => {
+  it('sets it for an account with none, straight after signing in', async () => {
+    const result = await setAccountPassword({ newPassword: 'correct horse battery' })
+
+    expect(result).toMatchObject({ success: true })
+    expect(setPassword).toHaveBeenCalledTimes(1)
+    expect(setPassword).toHaveBeenCalledWith(
+      expect.objectContaining({ body: { newPassword: 'correct horse battery' } })
+    )
+  })
+
+  it('tells the account’s own address that a password was added', async () => {
+    await setAccountPassword({ newPassword: 'correct horse battery' })
+
+    expect(sendAccountMail).toHaveBeenCalledTimes(1)
+    expect(sendAccountMail).toHaveBeenCalledWith(
+      expect.objectContaining({
+        to: 'google-person@x.test',
+        subject: 'A password was added to your Torqvoice account',
+      })
+    )
+    expect(logAudit).toHaveBeenCalledWith(
+      expect.objectContaining({ userId: 'user-a' }),
+      expect.objectContaining({ action: 'auth.passwordSet' })
+    )
+  })
+
+  it('still sets the password when the notice cannot be sent', async () => {
+    sendAccountMail.mockRejectedValueOnce(new Error('no mail server'))
+
+    const result = await setAccountPassword({ newPassword: 'correct horse battery' })
+
+    expect(result).toMatchObject({ success: true })
+    expect(setPassword).toHaveBeenCalledTimes(1)
+  })
+
+  it('sends no notice to a placeholder address nobody reads', async () => {
+    state.email = 'tech-123@technician.torqvoice.invalid'
+
+    await setAccountPassword({ newPassword: 'correct horse battery' })
+
+    expect(setPassword).toHaveBeenCalledTimes(1)
+    expect(sendAccountMail).not.toHaveBeenCalled()
+  })
+})
+
+describe('what it refuses', () => {
+  it('an account that already has a password, which must prove the current one', async () => {
+    state.passwordHash = 'hashed:hunter22'
+
+    const result = await setAccountPassword({ newPassword: 'correct horse battery' })
+
+    expect(result).toMatchObject({ success: false, error: ACCOUNT_HAS_PASSWORD })
+    expect(setPassword).not.toHaveBeenCalled()
+    expect(sendAccountMail).not.toHaveBeenCalled()
+  })
+
+  it('a session that was not signed in to just now', async () => {
+    // A laptop left open at the counter, or a cookie somebody else has
+    state.sessionCreatedAt = minutesAgo(11)
+
+    const result = await setAccountPassword({ newPassword: 'correct horse battery' })
+
+    expect(result).toMatchObject({ success: false, error: REAUTH_ERRORS.signInAgainPassword })
+    expect(setPassword).not.toHaveBeenCalled()
+  })
+
+  it('a sign-in a workshop made for a technician’s phone', async () => {
+    state.sessionOrganizationId = 'org-a'
+
+    const result = await setAccountPassword({ newPassword: 'correct horse battery' })
+
+    expect(result).toMatchObject({ success: false, error: SCOPED_SESSION_MESSAGE })
+    expect(setPassword).not.toHaveBeenCalled()
+  })
+
+  it('a password shorter than eight characters', async () => {
+    const result = await setAccountPassword({ newPassword: 'short' })
+
+    expect(result.success).toBe(false)
+    expect(setPassword).not.toHaveBeenCalled()
+  })
+
+  it('a password longer than the sign-in page would accept', async () => {
+    const result = await setAccountPassword({ newPassword: 'x'.repeat(129) })
+
+    expect(result.success).toBe(false)
+    expect(setPassword).not.toHaveBeenCalled()
+  })
+})

+ 131 - 16
src/app/(authenticated)/settings/account/account-settings.tsx

@@ -36,6 +36,13 @@ import {
 import { PasskeySettings } from '@/features/settings/Components/passkey-settings'
 import { PasskeySettings } from '@/features/settings/Components/passkey-settings'
 import { QRCodeSVG } from 'qrcode.react'
 import { QRCodeSVG } from 'qrcode.react'
 import { requestEmailChange } from '@/features/settings/Actions/accountActions'
 import { requestEmailChange } from '@/features/settings/Actions/accountActions'
+import { setAccountPassword } from '@/features/settings/Actions/setAccountPassword'
+import {
+  ACCOUNT_HAS_PASSWORD,
+  PASSWORD_MIN_LENGTH,
+} from '@/features/settings/Schema/accountPassword'
+import { REAUTH_FRESH_SESSION_MINUTES, reauthErrorMessageKey } from '@/lib/reauth'
+import { signInUrlFor } from '@/lib/signed-out'
 import { useCooldown } from '@/hooks/use-cooldown'
 import { useCooldown } from '@/hooks/use-cooldown'
 import { useRouter } from 'next/navigation'
 import { useRouter } from 'next/navigation'
 import { useTranslations } from 'next-intl'
 import { useTranslations } from 'next-intl'
@@ -44,12 +51,19 @@ export function AccountSettings({
   twoFactorEnabled: initialTwoFactorEnabled,
   twoFactorEnabled: initialTwoFactorEnabled,
   emailVerified: initialEmailVerified,
   emailVerified: initialEmailVerified,
   emailVerificationRequired,
   emailVerificationRequired,
+  hasPassword: initialHasPassword,
   devices,
   devices,
   signature,
   signature,
 }: {
 }: {
   twoFactorEnabled: boolean
   twoFactorEnabled: boolean
   emailVerified: boolean
   emailVerified: boolean
   emailVerificationRequired: boolean
   emailVerificationRequired: boolean
+  /**
+   * False for an account that signed up with Google or Microsoft and never
+   * chose a password. It gets "set a password" instead of "change password",
+   * which would have no current password to check.
+   */
+  hasPassword: boolean
   devices: SignedInDevice[]
   devices: SignedInDevice[]
   /** The caller's saved signature for this workshop, as a data URI. */
   /** The caller's saved signature for this workshop, as a data URI. */
   signature: string | null
   signature: string | null
@@ -57,6 +71,7 @@ export function AccountSettings({
   const { data: session } = useSession()
   const { data: session } = useSession()
   const router = useRouter()
   const router = useRouter()
   const t = useTranslations('settings')
   const t = useTranslations('settings')
+  const tCommon = useTranslations('common')
   const [name, setName] = useState('')
   const [name, setName] = useState('')
   const [email, setEmail] = useState('')
   const [email, setEmail] = useState('')
 
 
@@ -74,6 +89,10 @@ export function AccountSettings({
   const [newPassword, setNewPassword] = useState('')
   const [newPassword, setNewPassword] = useState('')
   const [confirmPassword, setConfirmPassword] = useState('')
   const [confirmPassword, setConfirmPassword] = useState('')
   const [savingPassword, setSavingPassword] = useState(false)
   const [savingPassword, setSavingPassword] = useState(false)
+  const [hasPassword, setHasPassword] = useState(initialHasPassword)
+  /** The server wants a newer sign-in before it will set a first password. */
+  const [signInAgainNeeded, setSignInAgainNeeded] = useState(false)
+  const [leavingToSignIn, setLeavingToSignIn] = useState(false)
 
 
   // 2FA state
   // 2FA state
   const [twoFactorPassword, setTwoFactorPassword] = useState('')
   const [twoFactorPassword, setTwoFactorPassword] = useState('')
@@ -258,6 +277,63 @@ export function AccountSettings({
     setSavingPassword(false)
     setSavingPassword(false)
   }
   }
 
 
+  const handleSetPassword = async () => {
+    if (newPassword !== confirmPassword) {
+      toast.error(t('account.passwordsNoMatch'))
+      return
+    }
+    if (newPassword.length < PASSWORD_MIN_LENGTH) {
+      toast.error(t('account.passwordMinLength'))
+      return
+    }
+    setSavingPassword(true)
+    try {
+      const result = await setAccountPassword({ newPassword })
+      if (result.success) {
+        toast.success(t('account.passwordSet'))
+        setHasPassword(true)
+        setSignInAgainNeeded(false)
+        setNewPassword('')
+        setConfirmPassword('')
+        router.refresh()
+      } else if (reauthErrorMessageKey(result.error) === 'account.reauthSignInAgainPassword') {
+        // Said in the card, with the way to do it, rather than in a toast
+        // that is gone before the person has read what to do next.
+        setSignInAgainNeeded(true)
+      } else if (result.error === ACCOUNT_HAS_PASSWORD) {
+        toast.info(t('account.passwordAlreadySet'))
+        setHasPassword(true)
+        setNewPassword('')
+        setConfirmPassword('')
+      } else {
+        toast.error(t('account.failedSetPassword'))
+      }
+    } catch {
+      toast.error(t('account.failedSetPassword'))
+    }
+    setSavingPassword(false)
+  }
+
+  // The sign-in page sends anyone with a session straight on, so signing in
+  // again means signing out first. It comes back here afterwards.
+  const handleSignInAgain = async () => {
+    setLeavingToSignIn(true)
+    let signedOut = true
+    try {
+      const result = await authClient.signOut()
+      if (result?.error) signedOut = false
+    } catch {
+      signedOut = false
+    }
+    if (!signedOut) {
+      // Without a sign-out the sign-in page would bounce straight back here.
+      setLeavingToSignIn(false)
+      toast.error(tCommon('errors.unexpected'))
+      return
+    }
+    window.location.assign(signInUrlFor('/settings/account'))
+  }
+
   // const initials = session?.user?.name
   // const initials = session?.user?.name
   //   ? session.user.name
   //   ? session.user.name
   //       .split(' ')
   //       .split(' ')
@@ -371,29 +447,39 @@ export function AccountSettings({
         <SignatureCard initial={signature} />
         <SignatureCard initial={signature} />
       </div>
       </div>
 
 
-      {/* Change Password */}
+      {/* Change the password, or set the first one. An account that signed up
+          with Google has none, and asking it for a current password is a
+          question with no right answer. */}
       <AppCard
       <AppCard
         id="account-password"
         id="account-password"
         icon={KeyRound}
         icon={KeyRound}
-        title={t('account.changePasswordTitle')}
+        title={hasPassword ? t('account.changePasswordTitle') : t('account.setPasswordTitle')}
         contentClassName="space-y-4"
         contentClassName="space-y-4"
       >
       >
-        <p className="text-sm text-muted-foreground">{t('account.changePasswordDescription')}</p>
+        <p className="text-sm text-muted-foreground">
+          {hasPassword
+            ? t('account.changePasswordDescription')
+            : t('account.setPasswordDescription')}
+        </p>
         <div className="grid gap-4 sm:grid-cols-1 max-w-sm">
         <div className="grid gap-4 sm:grid-cols-1 max-w-sm">
-          <div className="space-y-2">
-            <Label htmlFor="currentPassword">{t('account.currentPassword')}</Label>
-            <Input
-              id="currentPassword"
-              type="password"
-              value={currentPassword}
-              onChange={(e) => setCurrentPassword(e.target.value)}
-            />
-          </div>
+          {hasPassword && (
+            <div className="space-y-2">
+              <Label htmlFor="currentPassword">{t('account.currentPassword')}</Label>
+              <Input
+                id="currentPassword"
+                type="password"
+                autoComplete="current-password"
+                value={currentPassword}
+                onChange={(e) => setCurrentPassword(e.target.value)}
+              />
+            </div>
+          )}
           <div className="space-y-2">
           <div className="space-y-2">
             <Label htmlFor="newPassword">{t('account.newPassword')}</Label>
             <Label htmlFor="newPassword">{t('account.newPassword')}</Label>
             <Input
             <Input
               id="newPassword"
               id="newPassword"
               type="password"
               type="password"
+              autoComplete="new-password"
               value={newPassword}
               value={newPassword}
               onChange={(e) => setNewPassword(e.target.value)}
               onChange={(e) => setNewPassword(e.target.value)}
             />
             />
@@ -403,20 +489,43 @@ export function AccountSettings({
             <Input
             <Input
               id="confirmPassword"
               id="confirmPassword"
               type="password"
               type="password"
+              autoComplete="new-password"
               value={confirmPassword}
               value={confirmPassword}
               onChange={(e) => setConfirmPassword(e.target.value)}
               onChange={(e) => setConfirmPassword(e.target.value)}
             />
             />
           </div>
           </div>
         </div>
         </div>
+        {!hasPassword &&
+          (signInAgainNeeded ? (
+            <div role="alert" className="max-w-xl space-y-3 rounded-lg border p-4">
+              <p className="text-sm font-medium">{t('account.reauthSignInAgainPassword')}</p>
+              <p className="text-sm text-muted-foreground">
+                {t('account.setPasswordSignInAgainHint', {
+                  minutes: REAUTH_FRESH_SESSION_MINUTES,
+                })}
+              </p>
+              <Button variant="outline" onClick={handleSignInAgain} disabled={leavingToSignIn}>
+                {leavingToSignIn && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+                {t('account.signInAgain')}
+              </Button>
+            </div>
+          ) : (
+            <p className="max-w-xl text-xs text-muted-foreground">
+              {t('account.setPasswordFreshHint', { minutes: REAUTH_FRESH_SESSION_MINUTES })}
+            </p>
+          ))}
         <Separator />
         <Separator />
         <div className="flex items-center gap-3">
         <div className="flex items-center gap-3">
-          <Button onClick={handleChangePassword} disabled={savingPassword}>
+          <Button
+            onClick={hasPassword ? handleChangePassword : handleSetPassword}
+            disabled={savingPassword}
+          >
             {savingPassword ? (
             {savingPassword ? (
               <Loader2 className="mr-2 h-4 w-4 animate-spin" />
               <Loader2 className="mr-2 h-4 w-4 animate-spin" />
             ) : (
             ) : (
               <KeyRound className="mr-2 h-4 w-4" />
               <KeyRound className="mr-2 h-4 w-4" />
             )}
             )}
-            {t('account.changePassword')}
+            {hasPassword ? t('account.changePassword') : t('account.setPassword')}
           </Button>
           </Button>
         </div>
         </div>
       </AppCard>
       </AppCard>
@@ -459,8 +568,14 @@ export function AccountSettings({
         ) : (
         ) : (
           <>
           <>
             <p className="text-sm text-muted-foreground">{t('account.twoFactorEnablePrompt')}</p>
             <p className="text-sm text-muted-foreground">{t('account.twoFactorEnablePrompt')}</p>
+            {/* Turning it on asks for the account's password, and an account
+                that signed up with Google has none: the dialog used to take
+                them as far as a password field they could not fill in. */}
+            {!hasPassword && (
+              <p className="text-sm text-muted-foreground">{t('account.twoFactorNeedsPassword')}</p>
+            )}
             <Separator />
             <Separator />
-            <Button onClick={handleOpenSetupDialog}>
+            <Button onClick={handleOpenSetupDialog} disabled={!hasPassword}>
               <Shield className="mr-2 h-4 w-4" />
               <Shield className="mr-2 h-4 w-4" />
               {t('account.enable2FA')}
               {t('account.enable2FA')}
             </Button>
             </Button>
@@ -504,7 +619,7 @@ export function AccountSettings({
               </div>
               </div>
               <DialogFooter>
               <DialogFooter>
                 <Button variant="outline" onClick={() => setDialogOpen(false)}>
                 <Button variant="outline" onClick={() => setDialogOpen(false)}>
-                  Cancel
+                  {t('account.cancel')}
                 </Button>
                 </Button>
                 <Button onClick={handleEnable2FA} disabled={enabling2FA}>
                 <Button onClick={handleEnable2FA} disabled={enabling2FA}>
                   {enabling2FA && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
                   {enabling2FA && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}

+ 6 - 1
src/app/(authenticated)/settings/account/page.tsx

@@ -4,12 +4,13 @@ import { redirect } from 'next/navigation'
 import { AccountSettings } from './account-settings'
 import { AccountSettings } from './account-settings'
 import { listMyDevices } from '@/features/settings/Actions/sessionActions'
 import { listMyDevices } from '@/features/settings/Actions/sessionActions'
 import { getMySignature } from '@/features/signatures/Actions/signatureActions'
 import { getMySignature } from '@/features/signatures/Actions/signatureActions'
+import { hasPassword } from '@/lib/reauth.server'
 
 
 export default async function AccountSettingsPage() {
 export default async function AccountSettingsPage() {
   const session = await getCachedSession()
   const session = await getCachedSession()
   if (!session) redirect('/auth/sign-in')
   if (!session) redirect('/auth/sign-in')
 
 
-  const [user, verificationSetting, devices, signature] = await Promise.all([
+  const [user, verificationSetting, devices, signature, withPassword] = await Promise.all([
     db.user.findUnique({
     db.user.findUnique({
       where: { id: session.user.id },
       where: { id: session.user.id },
       select: { twoFactorEnabled: true, emailVerified: true },
       select: { twoFactorEnabled: true, emailVerified: true },
@@ -20,6 +21,9 @@ export default async function AccountSettingsPage() {
     }),
     }),
     listMyDevices(),
     listMyDevices(),
     getMySignature(),
     getMySignature(),
+    // Someone who signed up with Google has none: they are offered "set a
+    // password", since "change" has no current one to check.
+    hasPassword(session.user.id),
   ])
   ])
 
 
   return (
   return (
@@ -27,6 +31,7 @@ export default async function AccountSettingsPage() {
       twoFactorEnabled={user?.twoFactorEnabled ?? false}
       twoFactorEnabled={user?.twoFactorEnabled ?? false}
       emailVerified={user?.emailVerified ?? false}
       emailVerified={user?.emailVerified ?? false}
       emailVerificationRequired={verificationSetting?.value === 'true'}
       emailVerificationRequired={verificationSetting?.value === 'true'}
+      hasPassword={withPassword}
       devices={devices}
       devices={devices}
       signature={signature.success ? (signature.data ?? null) : null}
       signature={signature.success ? (signature.data ?? null) : null}
     />
     />

+ 69 - 0
src/features/settings/Actions/setAccountPassword.ts

@@ -0,0 +1,69 @@
+'use server'
+
+import { headers } from 'next/headers'
+import { isPlaceholderEmail } from '@/features/team/Lib/technicianRole'
+import { logAudit } from '@/lib/audit'
+import { auth } from '@/lib/auth'
+import { db } from '@/lib/db'
+import { demoGuard } from '@/lib/demo'
+import { REAUTH_ERRORS } from '@/lib/reauth'
+import { assertRecentSignIn, hasPassword } from '@/lib/reauth.server'
+import { withAuth } from '@/lib/with-auth'
+import { ACCOUNT_HAS_PASSWORD, setAccountPasswordSchema } from '../Schema/accountPassword'
+
+/**
+ * Gives an account that has no password its first one.
+ *
+ * Someone who signed up with Google has no password, so "change password"
+ * has no current one to check and answered "Credential account not found".
+ * This is the other door, and it is deliberately narrow:
+ *
+ * - Only for an account with no password. One that has a password changes it
+ *   by proving the current one, which nothing here asks for.
+ * - Only straight after signing in. A password is a way into the account that
+ *   outlives the session, so a laptop left open at the counter, or a cookie
+ *   somebody else got hold of, must not be enough to mint one.
+ * - Never from a session a workshop minted for a phone (`accountLevel`).
+ * - The account's address is told, the way it is told about a new email.
+ */
+export async function setAccountPassword(input: { newPassword: string }) {
+  return withAuth(
+    async ({ userId }) => {
+      demoGuard()
+      const { newPassword } = setAccountPasswordSchema.parse(input)
+
+      if (await hasPassword(userId)) throw new Error(ACCOUNT_HAS_PASSWORD)
+      await assertRecentSignIn(userId, REAUTH_ERRORS.signInAgainPassword)
+
+      await auth.api.setPassword({ body: { newPassword }, headers: await headers() })
+
+      logAudit(
+        { userId, organizationId: '' },
+        { action: 'auth.passwordSet', message: 'Set a password on the account' }
+      ).catch(() => undefined)
+
+      const user = await db.user.findUnique({
+        where: { id: userId },
+        select: { name: true, email: true },
+      })
+      if (user?.email && !isPlaceholderEmail(user.email)) {
+        const { sendAccountMail } = await import('@/lib/account-mail')
+        await sendAccountMail({
+          to: user.email,
+          subject: 'A password was added to your Torqvoice account',
+          name: user.name,
+          paragraphs: [
+            'A password was just set on your Torqvoice account. You can now sign in with this email address and that password, as well as the way you signed in before.',
+          ],
+          notes: [
+            'If this was you, there is nothing to do.',
+            'If it was not, reset your password from the sign-in page now, and sign out of the devices you do not recognise from Settings.',
+          ],
+        }).catch(() => undefined)
+      }
+
+      return { set: true }
+    },
+    { accountLevel: true }
+  )
+}

+ 16 - 0
src/features/settings/Schema/accountPassword.ts

@@ -0,0 +1,16 @@
+import { z } from 'zod'
+
+/** Better Auth's own limits (its defaults; `src/lib/auth.ts` sets neither). */
+export const PASSWORD_MIN_LENGTH = 8
+export const PASSWORD_MAX_LENGTH = 128
+
+export const setAccountPasswordSchema = z.object({
+  newPassword: z.string().min(PASSWORD_MIN_LENGTH).max(PASSWORD_MAX_LENGTH),
+})
+
+/**
+ * What `setAccountPassword` refuses with when the account turns out to have a
+ * password after all (set in another tab, say). The account page reads it to
+ * switch to the change form instead of showing an error.
+ */
+export const ACCOUNT_HAS_PASSWORD = 'This account already has a password.'

+ 32 - 9
src/lib/reauth.server.ts

@@ -30,6 +30,15 @@ async function passwordHash(userId: string): Promise<string | null> {
   return account?.password || null
   return account?.password || null
 }
 }
 
 
+/**
+ * Whether the account can sign in with a password. One that signed up with
+ * Google has none, so "change password" has nothing to check the current one
+ * against; the account page offers to set one instead.
+ */
+export async function hasPassword(userId: string): Promise<boolean> {
+  return (await passwordHash(userId)) !== null
+}
+
 /** What this account is asked for; the dialogs read it to show the right field. */
 /** What this account is asked for; the dialogs read it to show the right field. */
 export async function reauthRequirement(userId: string): Promise<ReauthRequirement> {
 export async function reauthRequirement(userId: string): Promise<ReauthRequirement> {
   if (await passwordHash(userId)) return 'password'
   if (await passwordHash(userId)) return 'password'
@@ -126,16 +135,30 @@ export async function verifyReauth(
     return
     return
   }
   }
 
 
+  await assertRecentSignIn(
+    userId,
+    purpose === 'account'
+      ? REAUTH_ERRORS.signInAgainAccount
+      : purpose === 'restore'
+        ? REAUTH_ERRORS.signInAgainRestore
+        : REAUTH_ERRORS.signInAgainWorkshop,
+    now
+  )
+}
+
+/**
+ * Throws `refusal` unless the caller's session was created in the last
+ * REAUTH_FRESH_SESSION_MINUTES. For an account with nothing to type, signing
+ * in again is the only proof there is that the person at the keyboard is the
+ * account holder and not whoever found the laptop open.
+ */
+export async function assertRecentSignIn(
+  userId: string,
+  refusal: string,
+  now: number = Date.now()
+): Promise<void> {
   const session = await getCachedSession()
   const session = await getCachedSession()
   const createdAt = session?.user?.id === userId ? session.session.createdAt : null
   const createdAt = session?.user?.id === userId ? session.session.createdAt : null
   const age = createdAt ? now - new Date(createdAt).getTime() : Number.POSITIVE_INFINITY
   const age = createdAt ? now - new Date(createdAt).getTime() : Number.POSITIVE_INFINITY
-  if (!(age <= REAUTH_FRESH_SESSION_MINUTES * 60_000)) {
-    throw new Error(
-      purpose === 'account'
-        ? REAUTH_ERRORS.signInAgainAccount
-        : purpose === 'restore'
-          ? REAUTH_ERRORS.signInAgainRestore
-          : REAUTH_ERRORS.signInAgainWorkshop
-    )
-  }
+  if (!(age <= REAUTH_FRESH_SESSION_MINUTES * 60_000)) throw new Error(refusal)
 }
 }

+ 3 - 1
src/lib/reauth.ts

@@ -2,7 +2,8 @@
  * What the delete-account and delete-workshop dialogs ask for before the
  * What the delete-account and delete-workshop dialogs ask for before the
  * server will go ahead, and the sentences it refuses with. Shared by the
  * server will go ahead, and the sentences it refuses with. Shared by the
  * server check (reauth.server.ts) and the dialogs, which show the refusal in
  * server check (reauth.server.ts) and the dialogs, which show the refusal in
- * the person's own language by looking its key up here.
+ * the person's own language by looking its key up here. Giving an account
+ * with no password its first one asks for the same recent sign-in.
  *
  *
  * - 'password': the account has a password, and must type it.
  * - 'password': the account has a password, and must type it.
  * - 'totp': no password, but two-factor is on: a code from the app.
  * - 'totp': no password, but two-factor is on: a code from the app.
@@ -21,6 +22,7 @@ export const REAUTH_ERRORS = {
   signInAgainAccount: 'Sign in again before deleting your account.',
   signInAgainAccount: 'Sign in again before deleting your account.',
   signInAgainWorkshop: 'Sign in again before deleting the workshop.',
   signInAgainWorkshop: 'Sign in again before deleting the workshop.',
   signInAgainRestore: 'Sign in again before restoring the workshop.',
   signInAgainRestore: 'Sign in again before restoring the workshop.',
+  signInAgainPassword: 'Sign in again before setting a password.',
 } as const
 } as const
 
 
 export type ReauthError = keyof typeof REAUTH_ERRORS
 export type ReauthError = keyof typeof REAUTH_ERRORS