From 3d7e8be9aa74ec93591cc2f4d3510bbd0474b6d8 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 12 Oct 2026 17:06:52 +0200 Subject: [PATCH] fix(oidc): a sign-in links to an account by email only when the address was not set by the account itself An SSO login that matched no account by its subject took the account with the same email address and linked the provider identity to it. That address proves nothing when the account typed it in itself: anyone could sign up with, or switch their profile to, a colleague's address and receive the colleague's first SSO login. The provider switch branch (#2110) relinked a row on any email match without even asking for email_verified, and also for another subject of the same provider. users.email_self_asserted remembers an address the account set itself: signup (not the first account) and an email change in the profile settings set it, an admin entering a new address clears it, and accounts the provider creates start without it. Existing accounts keep linking, except those whose address moved away from the registered one without an admin editing it. A login by email now needs a verified address and an unmarked account, and is refused for another subject of the same issuer, with email_link_blocked and a translated message on the login screen. An email change in the profile settings writes a user.email_change audit row. Refs #2110 --- client/src/pages/login/useLogin.test.tsx | 19 +++ client/src/pages/login/useLogin.ts | 2 + server/scripts/size-baseline.json | 2 +- server/src/db/entities/Users.entity.ts | 2 + server/src/db/kysely/Users.table.ts | 1 + ...00_an_email_the_account_typed_in_itself.ts | 80 ++++++++++ .../src/db/repositories/Users.repository.ts | 21 ++- server/src/nest/admin/admin.service.ts | 4 + server/src/nest/audit/audit.service.ts | 1 + server/src/nest/auth/auth.controller.ts | 5 + server/src/nest/auth/auth.service.ts | 8 +- server/src/nest/auth/user-profile.service.ts | 11 +- server/src/nest/oidc/oidc.service.ts | 36 +++-- server/tests/integration/oidc.test.ts | 87 ++++++++++ .../db/email-self-asserted-migration.test.ts | 94 +++++++++++ server/tests/unit/db/legacy-baseline.test.ts | 1 + .../db/repositories/Users.repository.test.ts | 13 ++ server/tests/unit/nest/admin.service.test.ts | 15 ++ .../tests/unit/nest/auth.controller.test.ts | 31 ++++ server/tests/unit/nest/auth.service.test.ts | 7 + server/tests/unit/nest/oidc.service.test.ts | 149 +++++++++++++++++- .../unit/nest/user-profile.service.test.ts | 26 +++ shared/src/i18n/ar/login.ts | 3 + shared/src/i18n/az/login.ts | 4 + shared/src/i18n/br/login.ts | 4 + shared/src/i18n/ca/login.ts | 4 + shared/src/i18n/cs/login.ts | 4 + shared/src/i18n/de/login.ts | 4 + shared/src/i18n/en/login.ts | 4 + shared/src/i18n/es/login.ts | 4 + shared/src/i18n/et/login.ts | 4 + shared/src/i18n/fr/login.ts | 4 + shared/src/i18n/gr/login.ts | 4 + shared/src/i18n/hu/login.ts | 4 + shared/src/i18n/id/login.ts | 4 + shared/src/i18n/it/login.ts | 4 + shared/src/i18n/ja/login.ts | 4 + shared/src/i18n/ko/login.ts | 3 + shared/src/i18n/nl/login.ts | 4 + shared/src/i18n/pl/login.ts | 4 + shared/src/i18n/ru/login.ts | 4 + shared/src/i18n/sk/login.ts | 4 + shared/src/i18n/sv/login.ts | 4 + shared/src/i18n/th/login.ts | 4 + shared/src/i18n/tr/login.ts | 4 + shared/src/i18n/uk/login.ts | 4 + shared/src/i18n/vi/login.ts | 4 + shared/src/i18n/zh-TW/login.ts | 2 + shared/src/i18n/zh/login.ts | 2 + 49 files changed, 688 insertions(+), 29 deletions(-) create mode 100644 server/src/db/migrations/Migration20200101042800_an_email_the_account_typed_in_itself.ts create mode 100644 server/tests/unit/db/email-self-asserted-migration.test.ts diff --git a/client/src/pages/login/useLogin.test.tsx b/client/src/pages/login/useLogin.test.tsx index 28eedf41c..0b23b7e49 100644 --- a/client/src/pages/login/useLogin.test.tsx +++ b/client/src/pages/login/useLogin.test.tsx @@ -392,6 +392,25 @@ describe('useLogin — OIDC callback', () => { expect(sessionStorage.getItem('oidc_redirect')).toBeNull(); }); + it('FE-LOGIN-HOOK-020b: says why a sign-in was not linked to an existing account by email', async () => { + setSearch('?oidc_error=email_link_blocked'); + const blocked = renderLogin(); + await waitFor(() => + expect(blocked.result.current.error).toBe( + 'This sign-in could not be linked to the existing account with this email. Ask your administrator to confirm the address.', + ), + ); + blocked.unmount(); + + setSearch('?oidc_error=email_not_verified'); + const unverified = renderLogin(); + await waitFor(() => + expect(unverified.result.current.error).toBe( + 'Your sign-in provider has not confirmed this email address, so it cannot be linked to an existing account.', + ), + ); + }); + it('FE-LOGIN-HOOK-021: passes an unknown oidc_error through unchanged', async () => { setSearch('?oidc_error=some_new_provider_code'); diff --git a/client/src/pages/login/useLogin.ts b/client/src/pages/login/useLogin.ts index 6d77851cc..2e0293a6a 100644 --- a/client/src/pages/login/useLogin.ts +++ b/client/src/pages/login/useLogin.ts @@ -237,6 +237,8 @@ export function useLogin() { no_email: t('login.oidc.noEmail'), token_failed: t('login.oidc.tokenFailed'), invalid_state: t('login.oidc.invalidState'), + email_not_verified: t('login.oidc.emailNotVerified'), + email_link_blocked: t('login.oidc.emailLinkBlocked'), } setError(errorMessages[oidcError] || oidcError) sessionStorage.removeItem('oidc_redirect') diff --git a/server/scripts/size-baseline.json b/server/scripts/size-baseline.json index 8a01b5206..9fc3b4114 100644 --- a/server/scripts/size-baseline.json +++ b/server/scripts/size-baseline.json @@ -4,7 +4,7 @@ "src/db/repositories/Places.repository.ts": 1440, "src/db/repositories/Reservations.repository.ts": 1675, "src/db/repositories/Trips.repository.ts": 1121, - "src/db/repositories/Users.repository.ts": 1446, + "src/db/repositories/Users.repository.ts": 1445, "src/nest/atlas/atlas-geo.ts": 1005, "src/nest/atlas/atlas.service.ts": 1182, "src/nest/auth/auth.service.ts": 1143, diff --git a/server/src/db/entities/Users.entity.ts b/server/src/db/entities/Users.entity.ts index cb9f895c3..b14c8803e 100644 --- a/server/src/db/entities/Users.entity.ts +++ b/server/src/db/entities/Users.entity.ts @@ -115,6 +115,7 @@ export class Users { airtrail_write_enabled?: number | null = 0; display_name?: string | null; immich_allow_insecure_tls: number & Opt = 0; + email_self_asserted: number & Opt = 0; assignment_participants_collection = new Collection(this); audit_log_collection = new Collection(this); bucket_list_collection = new Collection(this); @@ -255,6 +256,7 @@ export const UsersSchema = defineEntity({ airtrail_write_enabled: p.integer().nullable(), display_name: p.text().nullable(), immich_allow_insecure_tls: p.integer().default(0), + email_self_asserted: p.integer().default(0), assignment_participants_collection: () => p.oneToMany(AssignmentParticipants).mappedBy('user').hidden(), audit_log_collection: () => p.oneToMany(AuditLog).mappedBy('user').hidden(), bucket_list_collection: () => p.oneToMany(BucketList).mappedBy('user').hidden(), diff --git a/server/src/db/kysely/Users.table.ts b/server/src/db/kysely/Users.table.ts index 655acf6a1..62705ce33 100644 --- a/server/src/db/kysely/Users.table.ts +++ b/server/src/db/kysely/Users.table.ts @@ -43,4 +43,5 @@ export interface UsersTable { airtrail_write_enabled: InsertOptional; display_name: InsertOptional; immich_allow_insecure_tls: InsertOptional; + email_self_asserted: InsertOptional; } diff --git a/server/src/db/migrations/Migration20200101042800_an_email_the_account_typed_in_itself.ts b/server/src/db/migrations/Migration20200101042800_an_email_the_account_typed_in_itself.ts new file mode 100644 index 000000000..ecc6fb690 --- /dev/null +++ b/server/src/db/migrations/Migration20200101042800_an_email_the_account_typed_in_itself.ts @@ -0,0 +1,80 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +interface UserEmailRow { + id: number; + email: string; +} + +interface AuditRow { + user_id: number | null; + resource: string | null; + details: string | null; +} + +/** The parsed `details` of an audit row, or null for an empty or broken one. */ +function detailsOf(row: AuditRow): Record | null { + if (!row.details) return null; + try { + const parsed: unknown = JSON.parse(row.details); + return parsed && typeof parsed === 'object' ? (parsed as Record) : null; + } catch { + return null; + } +} + +/** + * Remembers whether an account's email address is one the account typed in + * itself. + * + * An SSO sign-in that matches no linked account falls back to the email + * address and links the provider identity to whichever account holds it. That + * address proves nothing when the account chose it at signup or in its profile + * settings: anyone could put a colleague's address on their own account and + * receive the colleague's first SSO sign-in. `email_self_asserted` is 1 for + * such an address and keeps the sign-in from linking to it; an address an admin + * set, or one the provider created the account with, stays 0. + * + * Existing accounts start at 0, so instances keep linking the way they did. + * The exception is an account whose address provably moved away from the one + * it registered with (its `user.register` audit row) without an admin ever + * editing its email (an `admin.user_update` row naming `email`): only the + * account itself can have changed it. The audit details are JSON text, read + * and compared here rather than in SQL. + */ +export class Migration20200101042800_an_email_the_account_typed_in_itself extends Migration { + override name = 'Migration20200101042800_an_email_the_account_typed_in_itself'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'email_self_asserted', 'email_self_asserted INTEGER NOT NULL DEFAULT 0'); + + const registrations = (await this.execute( + `SELECT user_id, resource, details FROM audit_log WHERE action = 'user.register' AND user_id IS NOT NULL`, + )) as AuditRow[]; + if (registrations.length === 0) return; + + const registeredWith = new Map(); + for (const row of registrations) { + const email = detailsOf(row)?.email; + if (typeof email !== 'string' || row.user_id === null) continue; + registeredWith.set(row.user_id, [...(registeredWith.get(row.user_id) ?? []), email.trim().toLowerCase()]); + } + + const adminEdits = (await this.execute( + `SELECT user_id, resource, details FROM audit_log WHERE action = 'admin.user_update'`, + )) as AuditRow[]; + const editedByAdmin = new Set(); + for (const row of adminEdits) { + const fields = detailsOf(row)?.fields; + if (row.resource && Array.isArray(fields) && fields.includes('email')) editedByAdmin.add(row.resource); + } + + const users = (await this.execute(`SELECT id, email FROM users WHERE COALESCE(is_guest, 0) = 0`)) as UserEmailRow[]; + for (const user of users) { + const registered = registeredWith.get(user.id); + if (!registered || editedByAdmin.has(String(user.id))) continue; + if (registered.includes(user.email.trim().toLowerCase())) continue; + await this.execute('UPDATE users SET email_self_asserted = 1 WHERE id = ?', [user.id]); + } + } +} diff --git a/server/src/db/repositories/Users.repository.ts b/server/src/db/repositories/Users.repository.ts index cb65aeec6..5a0a2dab4 100644 --- a/server/src/db/repositories/Users.repository.ts +++ b/server/src/db/repositories/Users.repository.ts @@ -63,6 +63,7 @@ export interface UserRow { airtrail_write_enabled: number | null; display_name: string | null; immich_allow_insecure_tls: number; + email_self_asserted: number; } /** @@ -85,6 +86,8 @@ export interface NewUserRow { oidc_sub?: string | null; oidc_issuer?: string | null; avatar?: string | null; + /** 1 for an address typed into the signup form (nobody vouches for it), 0 for the first account and SSO ones. */ + email_self_asserted?: number; } /** @@ -119,6 +122,7 @@ export interface UserProfilePatch { amap_api_key?: string | null; username?: string; email?: string; + email_self_asserted?: number; } /** `UserProfileService.getApiKeyColumns`/`currentKeys` (UP1). */ @@ -280,6 +284,7 @@ export interface AdminEditPatch { role?: string; password_hash?: string; password_version?: number; + email_self_asserted?: number; } export class UsersRepository extends TrekRepository { @@ -474,6 +479,7 @@ export class UsersRepository extends TrekRepository { oidc_sub: row.oidc_sub ?? null, oidc_issuer: row.oidc_issuer ?? null, avatar: row.avatar ?? null, + email_self_asserted: row.email_self_asserted ?? 0, }), ); const inserted = await this.findById(id); @@ -990,17 +996,10 @@ export class UsersRepository extends TrekRepository { // --------------------------------------------------------------------- /** - * `` UPDATE users SET ${updates.join(', ')}, updated_at = CURRENT_TIMESTAMP - * WHERE id = ? `` — the SET clause is built at the CALL SITE from a fixed - * set of 6 possible literal column names, never here: `patchProfile` takes - * only the already-decided final columns as a typed partial and writes - * them in ONE `nativeUpdate` (never `assign`+`flush`, which would be a - * second statement shape for the identical intent) — the entity-API - * equivalent of the legacy's dynamic `SET` list, without ever building SQL - * text. `updated_at` is always stamped, matching the legacy: this method's - * only caller (`UserProfileService.updateSettings`) calls it only when at - * least one field changed (`updates.length > 0`), the same guard the - * legacy statement was built under. + * `` UPDATE users SET ${updates.join(', ')}, updated_at = CURRENT_TIMESTAMP WHERE id = ? ``: the + * caller decides the columns of {@link UserProfilePatch}, this writes them in ONE `nativeUpdate` + * (never `assign`+`flush`) and never builds SQL text. `updated_at` is always stamped, as in the + * legacy; the only caller (`UserProfileService.updateSettings`) calls it only when a field changed. */ async patchProfile(id: number, changes: UserProfilePatch): Promise { const platform = this.getEntityManager().getPlatform(); diff --git a/server/src/nest/admin/admin.service.ts b/server/src/nest/admin/admin.service.ts index 10bc795b5..254baf50a 100644 --- a/server/src/nest/admin/admin.service.ts +++ b/server/src/nest/admin/admin.service.ts @@ -271,6 +271,10 @@ export class AdminService { const patch: AdminEditPatch = {}; if (username) patch.username = username; if (email) patch.email = email; + // An address the admin enters is one the admin vouches for, so an SSO sign-in + // may link to it again. The edit dialog sends the email on every save, so only + // a new address counts, not the unchanged one or the same one in another case. + if (email && email.toLowerCase() !== user.email.toLowerCase()) patch.email_self_asserted = 0; if (role) patch.role = role; if (password) { patch.password_hash = passwordHash!; diff --git a/server/src/nest/audit/audit.service.ts b/server/src/nest/audit/audit.service.ts index 5a928e54b..047cdb2ea 100644 --- a/server/src/nest/audit/audit.service.ts +++ b/server/src/nest/audit/audit.service.ts @@ -15,6 +15,7 @@ const ACTION_LABELS: Record = { 'user.account_delete': 'deleted account', 'user.mfa_enable': 'enabled MFA', 'user.mfa_disable': 'disabled MFA', + 'user.email_change': 'changed email', 'user.session_revoke': 'signed out a session', 'user.sessions_revoke_others': 'signed out other sessions', 'settings.app_update': 'updated settings', diff --git a/server/src/nest/auth/auth.controller.ts b/server/src/nest/auth/auth.controller.ts index 34d06d19e..d39bee688 100644 --- a/server/src/nest/auth/auth.controller.ts +++ b/server/src/nest/auth/auth.controller.ts @@ -196,6 +196,11 @@ export class AuthController { async updateSettings(@CurrentUser() user: User, @Body() body: SettingsUpdateDto, @Req() req: Request) { const result = await this.profile.updateSettings(user.id, body); await this.auditApiKeys(user.id, result.changedKeys ?? [], req); + // The address itself stays out of the row, like the key values above; the row + // is what tells an admin later that the account chose its current address. + if (result.emailChanged) { + await this.audit.writeAudit({ userId: user.id, action: 'user.email_change', ip: getClientIp(req) }); + } return { success: result.success, user: result.user }; } diff --git a/server/src/nest/auth/auth.service.ts b/server/src/nest/auth/auth.service.ts index 98dbd33b7..1c3776126 100644 --- a/server/src/nest/auth/auth.service.ts +++ b/server/src/nest/auth/auth.service.ts @@ -468,10 +468,9 @@ export class AuthService { const password_hash = bcrypt.hashSync(password, BCRYPT_COST); try { - // One transaction for the whole signup: a mid-sequence throw (invite - // bookkeeping, trip auto-join) must not leave a half-registered user. - // The collision check and the first-user count run inside it too, so two - // signups at once cannot both pass the check or both become admin. + // One transaction for the whole signup: a mid-sequence throw (invite bookkeeping, trip auto-join) + // must not leave a half-registered user. The collision check and the first-user count run inside + // it too, so two signups at once cannot both pass the check or both become admin. return await this.uow.transactional(async () => { // Ignore guests (#1362): their synthetic username/email must never block a real signup. const existingUserId = await this.usersRepo.findIdByEmailOrUsernameCI(email, username); @@ -482,6 +481,7 @@ export class AuthService { const role = isFirstUser ? 'admin' : 'user'; const inserted = await this.usersRepo.insertUser({ username, email, password_hash, role, first_seen_version: readEnv().app.appVersion || '0.0.0', + email_self_asserted: isFirstUser ? 0 : 1, }); const user = { id: inserted.id, username, email, role, avatar: null, mfa_enabled: false }; diff --git a/server/src/nest/auth/user-profile.service.ts b/server/src/nest/auth/user-profile.service.ts index 060aa3fe1..b0bdd0e32 100644 --- a/server/src/nest/auth/user-profile.service.ts +++ b/server/src/nest/auth/user-profile.service.ts @@ -210,7 +210,7 @@ export class UserProfileService { async updateSettings( userId: number, rawBody: unknown, - ): Promise<{ success?: boolean; user?: Record; changedKeys?: string[] }> { + ): Promise<{ success?: boolean; user?: Record; changedKeys?: string[]; emailChanged?: boolean }> { const body = rawBody as { maps_api_key?: string; openweather_api_key?: string; @@ -261,6 +261,14 @@ export class UserProfileService { if (username !== undefined) changes.username = username.trim(); if (email !== undefined) changes.email = email.trim(); + // A new address typed in here is the account's own word for it, so an SSO + // sign-in no longer links to it by email (see OidcService.refreshLinkedUser). + // Changing only the case keeps the address and its standing. + const previousEmail = changes.email !== undefined ? await this.usersRepo.getEmail(userId) : null; + const emailChanged = + changes.email !== undefined && changes.email.toLowerCase() !== (previousEmail ?? '').toLowerCase(); + if (emailChanged) changes.email_self_asserted = 1; + // Read before the write, so the comparison sees the old value; the role in // the same row decides whether the two instance-wide names travel with it. const current = await this.currentKeys(userId); @@ -297,6 +305,7 @@ export class UserProfileService { avatar_url: avatarUrl(updated || {}), }, changedKeys, + emailChanged, }; } diff --git a/server/src/nest/oidc/oidc.service.ts b/server/src/nest/oidc/oidc.service.ts index 056df638a..594904aa3 100644 --- a/server/src/nest/oidc/oidc.service.ts +++ b/server/src/nest/oidc/oidc.service.ts @@ -665,21 +665,35 @@ export class OidcService implements OnModuleDestroy { userInfo: OidcUserInfo, picture: string | null, ): Promise<{ user: User; roleChange?: OidcRoleChange } | { error: string }> { - // Reaching here without an oidc_sub means we matched an existing local - // account by email. Only auto-link the OIDC identity when the IdP asserts - // the email is verified; an unverified email must not auto-link. - if (!user.oidc_sub) { + // A row that does not carry this sub and issuer was found by its email + // address alone. Linking it takes two promises: the IdP says the address is + // verified, and the address on the row was not typed in by the account itself + // (signup, profile settings), or whoever did that would receive this person's + // sign-in. The unverified answer comes first, so it never tells an unverified + // login whether such an account exists. + if (user.oidc_sub !== sub || user.oidc_issuer !== config.issuer) { const emailVerified = userInfo.email_verified === true || userInfo.email_verified === 'true'; if (!emailVerified) { return { error: 'email_not_verified' }; } - await this.usersRepo.linkOidcIdentity(user.id, sub, config.issuer); - user = { ...user, oidc_sub: sub, oidc_issuer: config.issuer }; - } else if (user.oidc_issuer !== config.issuer || user.oidc_sub !== sub) { - // The admin pointed the instance at a different IdP. We got here through the - // verified-email lookup, so this is the same person arriving from the new - // provider; leaving the old sub and issuer on the row would keep the account - // pinned to a provider that no longer exists (#2110). + if (user.email_self_asserted) { + console.warn( + `[OIDC] Refused to link a sign-in to user ${user.id} by email: the account set that address itself. An admin can confirm it by changing the email in the admin panel.`, + ); + return { error: 'email_link_blocked' }; + } + // The same provider with another subject is another person at that + // provider, never this account under a new name. + if (user.oidc_sub && user.oidc_issuer === config.issuer) { + console.warn( + `[OIDC] Refused to link a sign-in to user ${user.id} by email: it is linked to another subject of this provider.`, + ); + return { error: 'email_link_blocked' }; + } + // Without an oidc_sub this is the account's first SSO sign-in. With one from + // another issuer, the admin pointed the instance at a different IdP: leaving + // the old sub and issuer on the row would keep the account pinned to a + // provider that no longer exists (#2110). await this.usersRepo.linkOidcIdentity(user.id, sub, config.issuer); user = { ...user, oidc_sub: sub, oidc_issuer: config.issuer }; } diff --git a/server/tests/integration/oidc.test.ts b/server/tests/integration/oidc.test.ts index 028e035c9..74a30f34e 100644 --- a/server/tests/integration/oidc.test.ts +++ b/server/tests/integration/oidc.test.ts @@ -12,6 +12,7 @@ import { db as testDb } from '../../src/db/database'; import { AuditLog } from '../../src/db/entities/AuditLog.entity'; import { Users } from '../../src/db/entities/Users.entity'; import { OidcService } from '../../src/nest/oidc/oidc.service'; +import { authCookie } from '../helpers/auth'; import { createUser } from '../helpers/factories'; import type { FactoryOrm } from '../helpers/factories/context'; import { findRow, findRows } from '../helpers/factories/rows'; @@ -349,6 +350,92 @@ describe('GET /api/auth/oidc/callback', () => { }); }); +// ── an email the account set itself ────────────────────────────────────────── + +describe('GET /api/auth/oidc/callback onto an email the account set itself', () => { + /** One verified SSO sign-in for this sub and email, run through the real callback. */ + async function ssoSignIn(sub: string, email: string): Promise { + mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC); + mockExchangeCode.mockResolvedValueOnce({ access_token: 'tok', id_token: 'fake.id.token', _ok: true, _status: 200 }); + mockVerifyIdToken.mockResolvedValueOnce({ ok: true, claims: { sub } }); + mockGetUserInfo.mockResolvedValueOnce({ sub, email, name: 'Colleague', email_verified: true }); + const { state } = await oidcSvc.createState('http://localhost:3001/api/auth/oidc/callback'); + return request(app) + .get(`/api/auth/oidc/callback?code=c-${sub}&state=${state}`) + .set('Cookie', `trek_oidc_state=${state}`); + } + + async function register(username: string, email: string): Promise { + const res = await request(app).post('/api/auth/register').send({ username, email, password: 'Secure123!x' }); + expect(res.status).toBe(201); + return res; + } + + it('OIDC-030: a signup with a colleague address does not receive the colleague sign-in', async () => { + await register('owner', 'owner@example.com'); + const squatter = await register('squatter', 'colleague@example.com'); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + try { + const res = await ssoSignIn('sub-colleague', 'colleague@example.com'); + + expect(res.status).toBe(302); + expect(res.headers.location).toContain('/login?oidc_error=email_link_blocked'); + const row = await readUser(orm(), squatter.body.user.id); + expect(row.oidc_sub).toBeNull(); + } finally { + warn.mockRestore(); + } + }); + + it('OIDC-031: an address changed in the profile settings does not receive the sign-in either', async () => { + await register('owner', 'owner@example.com'); + const member = await register('member', 'member@example.com'); + const cookie = readCookie(member, 'trek_session'); + const change = await request(app) + .put('/api/auth/me/settings') + .set('Cookie', `trek_session=${cookie}`) + .send({ email: 'colleague@example.com' }); + expect(change.status).toBe(200); + const audit = await findRows(orm(), AuditLog, { user: member.body.user.id, action: 'user.email_change' }); + expect(audit).toHaveLength(1); + expect(audit[0].details).toBeNull(); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + try { + const res = await ssoSignIn('sub-colleague', 'colleague@example.com'); + expect(res.headers.location).toContain('/login?oidc_error=email_link_blocked'); + expect((await readUser(orm(), member.body.user.id)).oidc_sub).toBeNull(); + } finally { + warn.mockRestore(); + } + }); + + it('OIDC-032: an account an admin created, or whose email an admin changed, links on its first sign-in', async () => { + const owner = await register('owner', 'owner@example.com'); + const created = await request(app) + .post('/api/admin/users') + .set('Cookie', authCookie(owner.body.user.id)) + .send({ username: 'colleague', email: 'colleague@example.com', password: 'Secure123!x' }); + expect(created.status).toBe(201); + + const linked = await ssoSignIn('sub-colleague', 'colleague@example.com'); + expect(linked.headers.location).toContain('/login?oidc_code='); + expect((await readUser(orm(), created.body.user.id)).oidc_sub).toBe('sub-colleague'); + + // A blocked account is released by the admin entering the address. + const member = await register('member', 'typo@example.com'); + const edited = await request(app) + .put(`/api/admin/users/${member.body.user.id}`) + .set('Cookie', authCookie(owner.body.user.id)) + .send({ email: 'member@example.com' }); + expect(edited.status).toBe(200); + const res = await ssoSignIn('sub-member', 'member@example.com'); + expect(res.headers.location).toContain('/login?oidc_code='); + expect((await readUser(orm(), member.body.user.id)).oidc_sub).toBe('sub-member'); + }); +}); + // ── /exchange ───────────────────────────────────────────────────────────────── describe('GET /api/auth/oidc/exchange', () => { diff --git a/server/tests/unit/db/email-self-asserted-migration.test.ts b/server/tests/unit/db/email-self-asserted-migration.test.ts new file mode 100644 index 000000000..84cfbd532 --- /dev/null +++ b/server/tests/unit/db/email-self-asserted-migration.test.ts @@ -0,0 +1,94 @@ +/** + * `Migration20200101042800_an_email_the_account_typed_in_itself`: every + * existing account starts out trusted (0), except one whose address provably + * moved away from the one it registered with while no admin ever edited its + * email. A broken audit row is skipped, and a second run changes nothing. + */ +import { Migration20200101042800_an_email_the_account_typed_in_itself } from '../../../src/db/migrations/Migration20200101042800_an_email_the_account_typed_in_itself'; +import { + createMigrationOrm, + migrateTo, + pendingNames, + rawExec, + rawQuery, + runMigrationDirect, +} from '../../helpers/migration-step'; +import type { MikroORM } from '@mikro-orm/sqlite'; + +import { describe, expect, it } from 'vitest'; + +const TARGET = 'Migration20200101042800_an_email_the_account_typed_in_itself'; + +async function ormBeforeTarget(): Promise { + const orm = await createMigrationOrm(); + const names = await pendingNames(orm); + const idx = names.indexOf(TARGET); + expect(idx).toBeGreaterThan(0); + await migrateTo(orm, names[idx - 1]); + return orm; +} + +async function seed(orm: MikroORM): Promise { + // 1 registered and kept the address; 2 registered and changed it itself; + // 3 registered, changed it, and an admin set an email later; 4 was made by + // an admin (no register row); 5 registered in another case only; 6 has a + // broken register row. + await rawExec( + orm, + `INSERT INTO users (id, username, email, password_hash) VALUES + (1, 'kept', 'kept@test', 'x'), + (2, 'moved', 'colleague@test', 'x'), + (3, 'fixed', 'fixed-by-admin@test', 'x'), + (4, 'made', 'made@test', 'x'), + (5, 'cased', 'Cased@Test', 'x'), + (6, 'broken', 'broken-now@test', 'x')`, + ); + await rawExec( + orm, + `INSERT INTO audit_log (user_id, action, resource, details) VALUES + (1, 'user.register', NULL, '{"username":"kept","email":"kept@test","role":"user"}'), + (2, 'user.register', NULL, '{"username":"moved","email":"moved@test","role":"user"}'), + (3, 'user.register', NULL, '{"username":"fixed","email":"fixed@test","role":"user"}'), + (4, 'admin.user_update', '3', '{"targetUser":"fixed-self@test","fields":["username","email","role"]}'), + (4, 'admin.user_update', '2', '{"targetUser":"colleague@test","fields":["role"]}'), + (5, 'user.register', NULL, '{"username":"cased","email":"cased@test","role":"user"}'), + (6, 'user.register', NULL, 'not json')`, + ); +} + +const flags = (orm: MikroORM) => + rawQuery<{ id: number; email_self_asserted: number }>(orm, 'SELECT id, email_self_asserted FROM users ORDER BY id'); + +const EXPECTED = [ + { id: 1, email_self_asserted: 0 }, + { id: 2, email_self_asserted: 1 }, + { id: 3, email_self_asserted: 0 }, + { id: 4, email_self_asserted: 0 }, + { id: 5, email_self_asserted: 0 }, + { id: 6, email_self_asserted: 0 }, +]; + +describe('email self-asserted migration', () => { + it('EMAILSELF-MIG-001: only an address the account provably changed itself is marked', async () => { + const orm = await ormBeforeTarget(); + try { + await seed(orm); + await migrateTo(orm, TARGET); + expect(await flags(orm)).toEqual(EXPECTED); + } finally { + await orm.close(true); + } + }, 30000); + + it('EMAILSELF-MIG-002: running it again changes nothing', async () => { + const orm = await ormBeforeTarget(); + try { + await seed(orm); + await migrateTo(orm, TARGET); + await runMigrationDirect(orm, Migration20200101042800_an_email_the_account_typed_in_itself); + expect(await flags(orm)).toEqual(EXPECTED); + } finally { + await orm.close(true); + } + }, 30000); +}); diff --git a/server/tests/unit/db/legacy-baseline.test.ts b/server/tests/unit/db/legacy-baseline.test.ts index c601f18e0..773c0aba9 100644 --- a/server/tests/unit/db/legacy-baseline.test.ts +++ b/server/tests/unit/db/legacy-baseline.test.ts @@ -39,6 +39,7 @@ const UNNUMBERED = [ 'Migration20200101042500_a_cron_tick_runs_in_one_process', 'Migration20200101042600_a_session_row_per_sign_in', 'Migration20200101042700_timestamps_stored_in_one_spelling', + 'Migration20200101042800_an_email_the_account_typed_in_itself', ]; // The last step the positional runner ever had; planLegacyBaseline refuses a // schema_version past it. diff --git a/server/tests/unit/db/repositories/Users.repository.test.ts b/server/tests/unit/db/repositories/Users.repository.test.ts index 54ed0e6b1..2e38dd756 100644 --- a/server/tests/unit/db/repositories/Users.repository.test.ts +++ b/server/tests/unit/db/repositories/Users.repository.test.ts @@ -240,6 +240,19 @@ describe('UsersRepository', () => { expect(raw).toMatchObject({ username: 'localuser', email: 'local@example.com', role: 'user', login_count: 0 }); }); + it('USERSREPO-016b: the self-set email mark is 0 unless the caller names it', async () => { + const base = { password_hash: 'hashed', role: 'user', first_seen_version: '1.2.3' }; + const plain = await users.insertUser({ ...base, username: 'plainmark', email: 'plain@example.com' }); + const own = await users.insertUser({ + ...base, + username: 'ownmark', + email: 'own@example.com', + email_self_asserted: 1, + }); + expect(plain.email_self_asserted).toBe(0); + expect(own.email_self_asserted).toBe(1); + }); + it('USERSREPO-017: SSO registration shape (O14) — oidc_sub/oidc_issuer/avatar set', async () => { const row = await users.insertUser({ username: 'ssouser', diff --git a/server/tests/unit/nest/admin.service.test.ts b/server/tests/unit/nest/admin.service.test.ts index d7f7ba015..f65cf4bff 100644 --- a/server/tests/unit/nest/admin.service.test.ts +++ b/server/tests/unit/nest/admin.service.test.ts @@ -795,6 +795,21 @@ describe('admin quirk fixes (post-fold)', () => { const row = await readUser(orm, user.id); expect(row.username).toBe(user.username); }); + + // The edit dialog sends the email on every save, so only a new address is the + // admin vouching for it; the account's own mark survives any other edit. + it('ADMIN-SVC-073 — a new email from the admin clears the self-set mark, an unchanged one keeps it', async () => { + const { user } = createUser(testDb, { email: 'self@example.com' }); + await updateRows(orm, Users, { id: user.id }, { email_self_asserted: 1 }); + + await updateUser(String(user.id), { email: 'self@example.com', role: 'user', username: user.username }); + expect((await readUser(orm, user.id)).email_self_asserted).toBe(1); + await updateUser(String(user.id), { email: 'SELF@example.com' }); + expect((await readUser(orm, user.id)).email_self_asserted).toBe(1); + + await updateUser(String(user.id), { email: 'confirmed@example.com' }); + expect((await readUser(orm, user.id)).email_self_asserted).toBe(0); + }); }); // ── What an admin password reset ends, and what an ordinary edit must not ───── diff --git a/server/tests/unit/nest/auth.controller.test.ts b/server/tests/unit/nest/auth.controller.test.ts index a7a3d04ad..63170fd40 100644 --- a/server/tests/unit/nest/auth.controller.test.ts +++ b/server/tests/unit/nest/auth.controller.test.ts @@ -707,6 +707,37 @@ describe('AuthController (authenticated)', () => { expect(writeAudit).not.toHaveBeenCalled(); }); + it('update-settings logs a new email address without the address, and nothing for any other save', async () => { + writeAudit.mockClear(); + const withEmail = ac( + asvc({}), + rl(), + {}, + { + updateSettings: vi + .fn() + .mockReturnValue({ success: true, user: { id: 1 }, changedKeys: [], emailChanged: true }), + }, + ); + expect(await withEmail.updateSettings(user, {}, req)).toEqual({ success: true, user: { id: 1 } }); + expect(writeAudit).toHaveBeenCalledTimes(1); + expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ userId: user.id, action: 'user.email_change' })); + expect(writeAudit.mock.calls[0][0].details).toBeUndefined(); + + writeAudit.mockClear(); + await ac( + asvc({}), + rl(), + {}, + { + updateSettings: vi + .fn() + .mockReturnValue({ success: true, user: { id: 1 }, changedKeys: [], emailChanged: false }), + }, + ).updateSettings(user, {}, req); + expect(writeAudit).not.toHaveBeenCalled(); + }); + it('update-settings + get-settings map errors, else return their payloads', async () => { expect( await thrownAsync(() => diff --git a/server/tests/unit/nest/auth.service.test.ts b/server/tests/unit/nest/auth.service.test.ts index 30be3dba3..d066d8a39 100644 --- a/server/tests/unit/nest/auth.service.test.ts +++ b/server/tests/unit/nest/auth.service.test.ts @@ -921,6 +921,13 @@ describe('registerUser — success paths', () => { expect(result.auditDetails).toEqual({ username: 'first', email: 'first@x.com', role: 'admin' }); }); + it('AUTH-DB-056b: a signup marks the address it typed in, except for the first account', async () => { + const first = await svc.registerUser({ username: 'owner', email: 'owner@x.com', password: 'Secure123!' }); + const second = await svc.registerUser({ username: 'joiner', email: 'joiner@x.com', password: 'Secure123!' }); + expect((await readUser(orm, first.auditUserId!)).email_self_asserted).toBe(0); + expect((await readUser(orm, second.auditUserId!)).email_self_asserted).toBe(1); + }); + it('AUTH-DB-057: missing fields / bad email / duplicate answer their bespoke 400/409s', async () => { createUser(testDb, { username: 'taken', email: 'taken@x.com' }); expect(await asLegacyResult(svc.registerUser({ username: '', email: 'a@x.com', password: 'Secure123!' }))).toEqual({ diff --git a/server/tests/unit/nest/oidc.service.test.ts b/server/tests/unit/nest/oidc.service.test.ts index f6035181b..3157a0a8e 100644 --- a/server/tests/unit/nest/oidc.service.test.ts +++ b/server/tests/unit/nest/oidc.service.test.ts @@ -125,7 +125,13 @@ const orm = () => sharedTestOrm(testDb); /** Writes columns of the user row directly, the state a login starts from. */ async function setUserColumns( userId: number, - data: { oidc_sub?: string | null; oidc_issuer?: string | null; avatar?: string | null; updated_at?: string }, + data: { + oidc_sub?: string | null; + oidc_issuer?: string | null; + avatar?: string | null; + updated_at?: string; + email_self_asserted?: number; + }, ): Promise { await updateRows(await orm(), Users, { id: userId }, data); } @@ -1030,6 +1036,147 @@ describe('findOrCreateUser', () => { // ── claim-based role mapping on login (#2364) ──────────────────────────────── +// A sign-in that finds an account only through its email address. The address is +// proof of nothing when the account typed it in itself (signup, profile settings): +// whoever did that would otherwise get every later SSO login for that address. +describe('findOrCreateUser linking by email', () => { + const SELF = { email_self_asserted: 1 }; + + it('OIDC-SVC-100: a verified sign-in is not linked to an account that set this email itself', async () => { + const { user } = createUser(testDb, { email: 'colleague@corp.test' }); + await setUserColumns(user.id, { oidc_sub: null, oidc_issuer: null, ...SELF }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + try { + const result = await svc.findOrCreateUser( + { sub: 'victim-sub', email: 'colleague@corp.test', name: 'Colleague', email_verified: true }, + MOCK_CONFIG, + ); + + expect(result).toEqual({ error: 'email_link_blocked' }); + const row = await readUser(await orm(), user.id); + expect({ oidc_sub: row.oidc_sub, oidc_issuer: row.oidc_issuer }).toEqual({ oidc_sub: null, oidc_issuer: null }); + } finally { + warn.mockRestore(); + } + }); + + it('OIDC-SVC-101: an unverified sign-in against such an account says email_not_verified, not that the account exists', async () => { + const { user } = createUser(testDb, { email: 'quiet@corp.test' }); + await setUserColumns(user.id, { oidc_sub: null, oidc_issuer: null, ...SELF }); + + const result = await svc.findOrCreateUser({ sub: 'other-sub', email: 'quiet@corp.test' }, MOCK_CONFIG); + + expect(result).toEqual({ error: 'email_not_verified' }); + expect((await readUser(await orm(), user.id)).oidc_sub).toBeNull(); + }); + + it('OIDC-SVC-102: an account an admin created still links on its first verified sign-in', async () => { + const { user } = createUser(testDb, { email: 'made-by-admin@corp.test' }); + await setUserColumns(user.id, { oidc_sub: null, oidc_issuer: null, email_self_asserted: 0 }); + + const result = await svc.findOrCreateUser( + { sub: 'sub-made-by-admin', email: 'made-by-admin@corp.test', email_verified: 'true' }, + MOCK_CONFIG, + ); + + expect((result as { user: { id: number } }).user.id).toBe(user.id); + expect((await readUser(await orm(), user.id)).oidc_sub).toBe('sub-made-by-admin'); + }); + + it('OIDC-SVC-103: the same provider with another subject is refused, verified or not, and the row keeps its link', async () => { + const { user } = createUser(testDb, { email: 'linked@corp.test' }); + await setUserColumns(user.id, { oidc_sub: 'sub-owner', oidc_issuer: MOCK_CONFIG.issuer }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + try { + const verified = await svc.findOrCreateUser( + { sub: 'sub-stranger', email: 'linked@corp.test', email_verified: true }, + MOCK_CONFIG, + ); + const unverified = await svc.findOrCreateUser({ sub: 'sub-stranger', email: 'linked@corp.test' }, MOCK_CONFIG); + + expect(verified).toEqual({ error: 'email_link_blocked' }); + expect(unverified).toEqual({ error: 'email_not_verified' }); + const row = await readUser(await orm(), user.id); + expect({ oidc_sub: row.oidc_sub, oidc_issuer: row.oidc_issuer }).toEqual({ + oidc_sub: 'sub-owner', + oidc_issuer: MOCK_CONFIG.issuer, + }); + } finally { + warn.mockRestore(); + } + }); + + it('OIDC-SVC-104: a provider switch needs a verified email, the row stays on the old provider otherwise', async () => { + const { user } = createUser(testDb, { email: 'mover@corp.test' }); + await setUserColumns(user.id, { oidc_sub: 'sub-old', oidc_issuer: 'https://old-idp.example.com' }); + + const result = await svc.findOrCreateUser( + { sub: 'sub-new', email: 'mover@corp.test' }, + { ...MOCK_CONFIG, issuer: 'https://new-idp.example.com' }, + ); + + expect(result).toEqual({ error: 'email_not_verified' }); + const row = await readUser(await orm(), user.id); + expect({ oidc_sub: row.oidc_sub, oidc_issuer: row.oidc_issuer }).toEqual({ + oidc_sub: 'sub-old', + oidc_issuer: 'https://old-idp.example.com', + }); + }); + + it('OIDC-SVC-105: a provider switch onto an email the account set itself is refused', async () => { + const { user } = createUser(testDb, { email: 'switched-self@corp.test' }); + await setUserColumns(user.id, { oidc_sub: 'sub-old', oidc_issuer: 'https://old-idp.example.com', ...SELF }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + try { + const result = await svc.findOrCreateUser( + { sub: 'sub-new', email: 'switched-self@corp.test', email_verified: true }, + { ...MOCK_CONFIG, issuer: 'https://new-idp.example.com' }, + ); + + expect(result).toEqual({ error: 'email_link_blocked' }); + expect((await readUser(await orm(), user.id)).oidc_sub).toBe('sub-old'); + } finally { + warn.mockRestore(); + } + }); + + it('OIDC-SVC-106: a refused link never hands the account the role from the claims', async () => { + process.env.OIDC_ADMIN_VALUE = 'trek-admins'; + const { user } = createUser(testDb, { email: 'trap@corp.test', role: 'user' }); + await setUserColumns(user.id, { oidc_sub: null, oidc_issuer: null, ...SELF }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + try { + await svc.findOrCreateUser( + { sub: 'admin-sub', email: 'trap@corp.test', email_verified: true, groups: ['trek-admins'] }, + MOCK_CONFIG, + ); + expect((await readUser(await orm(), user.id)).role).toBe('user'); + } finally { + warn.mockRestore(); + } + }); + + it('OIDC-SVC-107: an account found by its own subject signs in, whoever set its email', async () => { + const { user } = createUser(testDb, { email: 'own@corp.test' }); + await setUserColumns(user.id, { oidc_sub: 'sub-own', oidc_issuer: MOCK_CONFIG.issuer, ...SELF }); + + const result = await svc.findOrCreateUser({ sub: 'sub-own', email: 'own@corp.test' }, MOCK_CONFIG); + + expect((result as { user: { id: number } }).user.id).toBe(user.id); + }); + + it('OIDC-SVC-108: an account the provider creates starts out with a trusted email', async () => { + createUser(testDb, { email: 'someone@corp.test' }); + await svc.findOrCreateUser({ sub: 'sub-fresh', email: 'fresh@corp.test', name: 'Fresh' }, MOCK_CONFIG); + + expect((await userByEmail('fresh@corp.test'))?.email_self_asserted).toBe(0); + }); +}); + describe('findOrCreateUser role mapping', () => { type RoleResult = { user: any; roleChange?: { from: string; to: string; claim: string } }; diff --git a/server/tests/unit/nest/user-profile.service.test.ts b/server/tests/unit/nest/user-profile.service.test.ts index 05597dd20..a950c1b20 100644 --- a/server/tests/unit/nest/user-profile.service.test.ts +++ b/server/tests/unit/nest/user-profile.service.test.ts @@ -143,6 +143,32 @@ describe('updateSettings', () => { }); }); +// An address the account types in itself is not one an SSO sign-in may link to by email. +describe('updateSettings marks an email the account set itself', () => { + const flagOf = async (id: number) => (await readUser(await sharedTestOrm(testDb), id)).email_self_asserted; + + it('AUTH-DB-120: a new address is marked and reported as changed', async () => { + const { user } = createUser(testDb, { email: 'before@example.com' }); + const result = await profile.updateSettings(user.id, { email: 'colleague@example.com' }); + expect(result.emailChanged).toBe(true); + expect(await flagOf(user.id)).toBe(1); + }); + + it('AUTH-DB-121: the same address in another case keeps its standing', async () => { + const { user } = createUser(testDb, { email: 'same@example.com' }); + const result = await profile.updateSettings(user.id, { email: ' Same@Example.com ' }); + expect(result.emailChanged).toBe(false); + expect(await flagOf(user.id)).toBe(0); + }); + + it('AUTH-DB-122: a save without an email leaves the mark alone', async () => { + const { user } = createUser(testDb); + const result = await profile.updateSettings(user.id, { username: 'renamed' }); + expect(result.emailChanged).toBe(false); + expect(await flagOf(user.id)).toBe(0); + }); +}); + // --------------------------------------------------------------------------- // getSettings // --------------------------------------------------------------------------- diff --git a/shared/src/i18n/ar/login.ts b/shared/src/i18n/ar/login.ts index e7d4e7871..a5bbc0dc8 100644 --- a/shared/src/i18n/ar/login.ts +++ b/shared/src/i18n/ar/login.ts @@ -23,6 +23,9 @@ const login: TranslationStrings = { 'login.oidc.noEmail': 'لم يتم استلام بريد إلكتروني من المزوّد.', 'login.oidc.tokenFailed': 'فشلت المصادقة.', 'login.oidc.invalidState': 'جلسة غير صالحة. حاول مرة أخرى.', + 'login.oidc.emailNotVerified': 'لم يؤكد مزود تسجيل الدخول عنوان البريد الإلكتروني هذا، لذا لا يمكن ربطه بحساب موجود.', + 'login.oidc.emailLinkBlocked': + 'تعذر ربط تسجيل الدخول هذا بالحساب الموجود بهذا البريد الإلكتروني. اطلب من المسؤول تأكيد العنوان.', 'login.demoFailed': 'فشل الدخول إلى العرض التجريبي', 'login.oidcSignIn': 'تسجيل الدخول عبر {name}', 'login.oidcOnly': 'تم تعطيل المصادقة بكلمة المرور. يرجى تسجيل الدخول عبر مزود SSO.', diff --git a/shared/src/i18n/az/login.ts b/shared/src/i18n/az/login.ts index 99cebc001..62d401796 100644 --- a/shared/src/i18n/az/login.ts +++ b/shared/src/i18n/az/login.ts @@ -25,6 +25,10 @@ const login: TranslationStrings = { 'login.oidc.noEmail': 'Provayderdən e-poçt ünvanı alınmadı.', 'login.oidc.tokenFailed': 'Autentifikasiya uğursuz oldu.', 'login.oidc.invalidState': 'Sessiya etibarsızdır. Yenidən cəhd edin.', + 'login.oidc.emailNotVerified': + 'Giriş provayderiniz bu e-poçt ünvanını təsdiqləməyib, buna görə o, mövcud hesabla əlaqələndirilə bilməz.', + 'login.oidc.emailLinkBlocked': + 'Bu giriş həmin e-poçtla mövcud hesaba bağlana bilmədi. Ünvanı təsdiqləmək üçün administratora müraciət edin.', 'login.demoFailed': 'Demo girişi uğursuz oldu', 'login.oidcSignIn': '{name} ilə daxil ol', 'login.oidcOnly': 'Parolla autentifikasiya deaktiv edilib. SSO provayderinizdən istifadə edərək daxil olun.', diff --git a/shared/src/i18n/br/login.ts b/shared/src/i18n/br/login.ts index daca82698..7a24d1cc9 100644 --- a/shared/src/i18n/br/login.ts +++ b/shared/src/i18n/br/login.ts @@ -24,6 +24,10 @@ const login: TranslationStrings = { 'login.oidc.noEmail': 'Nenhum e-mail recebido do provedor.', 'login.oidc.tokenFailed': 'Falha na autenticação.', 'login.oidc.invalidState': 'Sessão inválida. Tente novamente.', + 'login.oidc.emailNotVerified': + 'Seu provedor de login não confirmou este endereço de e-mail, por isso ele não pode ser vinculado a uma conta existente.', + 'login.oidc.emailLinkBlocked': + 'Não foi possível vincular este login à conta existente com este e-mail. Peça ao administrador para confirmar o endereço.', 'login.demoFailed': 'Falha no login de demonstração', 'login.oidcSignIn': 'Entrar com {name}', 'login.oidcOnly': 'Login por senha desativado. Use o provedor SSO.', diff --git a/shared/src/i18n/ca/login.ts b/shared/src/i18n/ca/login.ts index 2eab75d0a..1750463eb 100644 --- a/shared/src/i18n/ca/login.ts +++ b/shared/src/i18n/ca/login.ts @@ -61,6 +61,10 @@ const login: TranslationStrings = { 'login.resetPasswordFailed': "Restabliment fallit. L'enllaç pot haver caducat.", 'login.oidc.tokenFailed': "L'autenticació ha fallat.", 'login.oidc.invalidState': 'Sessió no vàlida. Torna-ho a provar.', + 'login.oidc.emailNotVerified': + "El teu proveïdor d'inici de sessió no ha confirmat aquesta adreça de correu, així que no es pot vincular a un compte existent.", + 'login.oidc.emailLinkBlocked': + "Aquest inici de sessió no s'ha pogut vincular al compte existent amb aquest correu. Demana a l'administrador que confirmi l'adreça.", 'login.demoFailed': "Ha fallat l'accés a la demo", 'login.oidcSignIn': 'Entra amb {name}', 'login.demoHint': 'Prova la demo: no cal que et registris', diff --git a/shared/src/i18n/cs/login.ts b/shared/src/i18n/cs/login.ts index 4463b818b..612887003 100644 --- a/shared/src/i18n/cs/login.ts +++ b/shared/src/i18n/cs/login.ts @@ -24,6 +24,10 @@ const login: TranslationStrings = { 'login.oidc.noEmail': 'Od poskytovatele nebyl přijat žádný e-mail.', 'login.oidc.tokenFailed': 'Ověření se nezdařilo.', 'login.oidc.invalidState': 'Neplatná relace. Zkuste to prosím znovu.', + 'login.oidc.emailNotVerified': + 'Váš poskytovatel přihlášení tuto e-mailovou adresu nepotvrdil, proto ji nelze propojit s existujícím účtem.', + 'login.oidc.emailLinkBlocked': + 'Toto přihlášení se nepodařilo propojit s existujícím účtem s tímto e-mailem. Požádejte správce o potvrzení adresy.', 'login.demoFailed': 'Přihlášení do dema se nezdařilo', 'login.oidcSignIn': 'Přihlásit se přes {name}', 'login.oidcOnly': 'Ověřování heslem je zakázáno. Přihlaste se prosím přes SSO poskytovatele.', diff --git a/shared/src/i18n/de/login.ts b/shared/src/i18n/de/login.ts index 8dea1b28b..895fd5146 100644 --- a/shared/src/i18n/de/login.ts +++ b/shared/src/i18n/de/login.ts @@ -24,6 +24,10 @@ const login: TranslationStrings = { 'login.oidc.noEmail': 'Keine E-Mail vom Provider erhalten.', 'login.oidc.tokenFailed': 'Authentifizierung fehlgeschlagen.', 'login.oidc.invalidState': 'Ungültige Sitzung. Bitte erneut versuchen.', + 'login.oidc.emailNotVerified': + 'Dein Anmeldeanbieter hat diese E-Mail-Adresse nicht bestätigt, daher kann sie nicht mit einem bestehenden Konto verknüpft werden.', + 'login.oidc.emailLinkBlocked': + 'Diese Anmeldung konnte nicht mit dem bestehenden Konto zu dieser E-Mail verknüpft werden. Bitte den Administrator, die Adresse zu bestätigen.', 'login.demoFailed': 'Demo-Login fehlgeschlagen', 'login.oidcSignIn': 'Anmelden mit {name}', 'login.oidcOnly': 'Passwort-Authentifizierung ist deaktiviert. Bitte melde dich über deinen SSO-Anbieter an.', diff --git a/shared/src/i18n/en/login.ts b/shared/src/i18n/en/login.ts index 2bcbfb6de..bfeb3334b 100644 --- a/shared/src/i18n/en/login.ts +++ b/shared/src/i18n/en/login.ts @@ -24,6 +24,10 @@ const login: TranslationStrings = { 'login.oidc.noEmail': 'No email received from provider.', 'login.oidc.tokenFailed': 'Authentication failed.', 'login.oidc.invalidState': 'Invalid session. Please try again.', + 'login.oidc.emailNotVerified': + 'Your sign-in provider has not confirmed this email address, so it cannot be linked to an existing account.', + 'login.oidc.emailLinkBlocked': + 'This sign-in could not be linked to the existing account with this email. Ask your administrator to confirm the address.', 'login.demoFailed': 'Demo login failed', 'login.oidcSignIn': 'Sign in with {name}', 'login.oidcOnly': 'Password authentication is disabled. Please sign in using your SSO provider.', diff --git a/shared/src/i18n/es/login.ts b/shared/src/i18n/es/login.ts index 46194227c..68d3fd81c 100644 --- a/shared/src/i18n/es/login.ts +++ b/shared/src/i18n/es/login.ts @@ -62,6 +62,10 @@ const login: TranslationStrings = { 'login.resetPasswordFailed': 'Restablecimiento fallido. El enlace puede haber caducado.', 'login.oidc.tokenFailed': 'La autenticación falló.', 'login.oidc.invalidState': 'Sesión no válida. Inténtalo de nuevo.', + 'login.oidc.emailNotVerified': + 'Tu proveedor de inicio de sesión no ha confirmado esta dirección de correo, así que no se puede vincular a una cuenta existente.', + 'login.oidc.emailLinkBlocked': + 'No se pudo vincular este inicio de sesión a la cuenta existente con este correo. Pide al administrador que confirme la dirección.', 'login.demoFailed': 'Falló el acceso a la demo', 'login.oidcSignIn': 'Entrar con {name}', 'login.demoHint': 'Prueba la demo: no necesitas registrarte', diff --git a/shared/src/i18n/et/login.ts b/shared/src/i18n/et/login.ts index 2efb62846..92b5895fc 100644 --- a/shared/src/i18n/et/login.ts +++ b/shared/src/i18n/et/login.ts @@ -24,6 +24,10 @@ const login: TranslationStrings = { 'login.oidc.noEmail': 'Teenusepakkujalt ei saadud e-posti aadressi.', 'login.oidc.tokenFailed': 'Autentimine ebaõnnestus.', 'login.oidc.invalidState': 'Vigane seanss. Proovi uuesti.', + 'login.oidc.emailNotVerified': + 'Sinu sisselogimise pakkuja ei ole seda e-posti aadressi kinnitanud, seega ei saa seda olemasoleva kontoga siduda.', + 'login.oidc.emailLinkBlocked': + 'Seda sisselogimist ei õnnestunud siduda selle e-postiga olemasoleva kontoga. Palu administraatoril aadress kinnitada.', 'login.demoFailed': 'Demoversiooni sisselogimine ebaõnnestus', 'login.oidcSignIn': 'Logi sisse teenusega {name}', 'login.oidcOnly': 'Parooliga autentimine on keelatud. Logi sisse oma SSO-teenuse kaudu.', diff --git a/shared/src/i18n/fr/login.ts b/shared/src/i18n/fr/login.ts index 272852a21..f26643a47 100644 --- a/shared/src/i18n/fr/login.ts +++ b/shared/src/i18n/fr/login.ts @@ -63,6 +63,10 @@ const login: TranslationStrings = { 'login.resetPasswordFailed': 'Échec de la réinitialisation. Le lien a peut-être expiré.', 'login.oidc.tokenFailed': "L'authentification a échoué.", 'login.oidc.invalidState': 'Session invalide. Veuillez réessayer.', + 'login.oidc.emailNotVerified': + "Votre fournisseur de connexion n'a pas confirmé cette adresse e-mail, elle ne peut donc pas être liée à un compte existant.", + 'login.oidc.emailLinkBlocked': + "Cette connexion n'a pas pu être liée au compte existant avec cet e-mail. Demandez à votre administrateur de confirmer l'adresse.", 'login.demoFailed': 'Échec de la connexion démo', 'login.oidcSignIn': 'Se connecter avec {name}', 'login.oidcOnly': diff --git a/shared/src/i18n/gr/login.ts b/shared/src/i18n/gr/login.ts index 056671cb5..4698a5bbc 100644 --- a/shared/src/i18n/gr/login.ts +++ b/shared/src/i18n/gr/login.ts @@ -25,6 +25,10 @@ const login: TranslationStrings = { 'login.oidc.noEmail': 'Δεν ελήφθη email από τον πάροχο.', 'login.oidc.tokenFailed': 'Ο έλεγχος ταυτότητας απέτυχε.', 'login.oidc.invalidState': 'Μη έγκυρη συνεδρία. Παρακαλώ δοκιμάστε ξανά.', + 'login.oidc.emailNotVerified': + 'Ο πάροχος σύνδεσης δεν έχει επιβεβαιώσει αυτή τη διεύθυνση email, επομένως δεν μπορεί να συνδεθεί με υπάρχοντα λογαριασμό.', + 'login.oidc.emailLinkBlocked': + 'Αυτή η σύνδεση δεν ήταν δυνατό να συνδεθεί με τον υπάρχοντα λογαριασμό με αυτό το email. Ζητήστε από τον διαχειριστή να επιβεβαιώσει τη διεύθυνση.', 'login.demoFailed': 'Η σύνδεση demo απέτυχε', 'login.oidcSignIn': 'Σύνδεση με {name}', 'login.oidcOnly': diff --git a/shared/src/i18n/hu/login.ts b/shared/src/i18n/hu/login.ts index d90bbabc6..36cc07078 100644 --- a/shared/src/i18n/hu/login.ts +++ b/shared/src/i18n/hu/login.ts @@ -25,6 +25,10 @@ const login: TranslationStrings = { 'login.oidc.noEmail': 'Nem érkezett e-mail a szolgáltatótól.', 'login.oidc.tokenFailed': 'Hitelesítés sikertelen.', 'login.oidc.invalidState': 'Érvénytelen munkamenet. Kérjük, próbáld újra.', + 'login.oidc.emailNotVerified': + 'A bejelentkezési szolgáltatód nem erősítette meg ezt az e-mail-címet, ezért nem kapcsolható meglévő fiókhoz.', + 'login.oidc.emailLinkBlocked': + 'Ezt a bejelentkezést nem sikerült összekapcsolni az ehhez az e-mailhez tartozó meglévő fiókkal. Kérd meg az adminisztrátort, hogy erősítse meg a címet.', 'login.demoFailed': 'Demo bejelentkezés sikertelen', 'login.oidcSignIn': 'Bejelentkezés ezzel: {name}', 'login.oidcOnly': 'A jelszavas hitelesítés le van tiltva. Kérjük, jelentkezz be az SSO szolgáltatódon keresztül.', diff --git a/shared/src/i18n/id/login.ts b/shared/src/i18n/id/login.ts index eb4d56d78..df6203f22 100644 --- a/shared/src/i18n/id/login.ts +++ b/shared/src/i18n/id/login.ts @@ -24,6 +24,10 @@ const login: TranslationStrings = { 'login.oidc.noEmail': 'Tidak ada email yang diterima dari penyedia.', 'login.oidc.tokenFailed': 'Autentikasi gagal.', 'login.oidc.invalidState': 'Sesi tidak valid. Coba lagi.', + 'login.oidc.emailNotVerified': + 'Penyedia masuk Anda belum mengonfirmasi alamat email ini, jadi tidak dapat ditautkan ke akun yang sudah ada.', + 'login.oidc.emailLinkBlocked': + 'Proses masuk ini tidak dapat ditautkan ke akun yang sudah ada dengan email ini. Minta administrator untuk mengonfirmasi alamatnya.', 'login.demoFailed': 'Login demo gagal', 'login.oidcSignIn': 'Masuk dengan {name}', 'login.oidcOnly': 'Autentikasi kata sandi dinonaktifkan. Masuk menggunakan penyedia SSO kamu.', diff --git a/shared/src/i18n/it/login.ts b/shared/src/i18n/it/login.ts index cf046ee89..d8cdb4970 100644 --- a/shared/src/i18n/it/login.ts +++ b/shared/src/i18n/it/login.ts @@ -25,6 +25,10 @@ const login: TranslationStrings = { 'login.oidc.noEmail': 'Nessuna email ricevuta dal provider.', 'login.oidc.tokenFailed': 'Autenticazione fallita.', 'login.oidc.invalidState': 'Sessione non valida. Riprova.', + 'login.oidc.emailNotVerified': + 'Il tuo provider di accesso non ha confermato questo indirizzo email, quindi non può essere collegato a un account esistente.', + 'login.oidc.emailLinkBlocked': + "Non è stato possibile collegare questo accesso all'account esistente con questa email. Chiedi all'amministratore di confermare l'indirizzo.", 'login.demoFailed': 'Accesso demo fallito', 'login.oidcSignIn': 'Accedi con {name}', 'login.oidcOnly': "L'autenticazione tramite password è disabilitata. Accedi utilizzando il tuo provider SSO.", diff --git a/shared/src/i18n/ja/login.ts b/shared/src/i18n/ja/login.ts index 68b528b85..ea80084c1 100644 --- a/shared/src/i18n/ja/login.ts +++ b/shared/src/i18n/ja/login.ts @@ -24,6 +24,10 @@ const login: TranslationStrings = { 'login.oidc.noEmail': 'プロバイダーからメールが取得できませんでした。', 'login.oidc.tokenFailed': '認証に失敗しました。', 'login.oidc.invalidState': 'セッションが無効です。もう一度お試しください。', + 'login.oidc.emailNotVerified': + 'サインインプロバイダーがこのメールアドレスを確認していないため、既存のアカウントにリンクできません。', + 'login.oidc.emailLinkBlocked': + 'このサインインを、このメールアドレスの既存アカウントにリンクできませんでした。管理者にアドレスの確認を依頼してください。', 'login.demoFailed': 'デモログインに失敗しました', 'login.oidcSignIn': '{name}でサインイン', 'login.oidcOnly': 'パスワード認証は無効です。SSOプロバイダーでサインインしてください。', diff --git a/shared/src/i18n/ko/login.ts b/shared/src/i18n/ko/login.ts index 0a9cae602..e8ad4bb6b 100644 --- a/shared/src/i18n/ko/login.ts +++ b/shared/src/i18n/ko/login.ts @@ -24,6 +24,9 @@ const login: TranslationStrings = { 'login.oidc.noEmail': '공급자로부터 이메일을 받지 못했습니다.', 'login.oidc.tokenFailed': '인증 실패.', 'login.oidc.invalidState': '유효하지 않은 세션입니다. 다시 시도하세요.', + 'login.oidc.emailNotVerified': '로그인 제공자가 이 이메일 주소를 확인하지 않아 기존 계정에 연결할 수 없습니다.', + 'login.oidc.emailLinkBlocked': + '이 로그인을 이 이메일을 사용하는 기존 계정에 연결할 수 없습니다. 관리자에게 주소 확인을 요청하세요.', 'login.demoFailed': '데모 로그인 실패', 'login.oidcSignIn': '{name}으로 로그인', 'login.oidcOnly': '비밀번호 인증이 비활성화되었습니다. SSO 공급자로 로그인하세요.', diff --git a/shared/src/i18n/nl/login.ts b/shared/src/i18n/nl/login.ts index d3656f4bc..51fc07d39 100644 --- a/shared/src/i18n/nl/login.ts +++ b/shared/src/i18n/nl/login.ts @@ -61,6 +61,10 @@ const login: TranslationStrings = { 'login.resetPasswordFailed': 'Resetten mislukt. De link is mogelijk verlopen.', 'login.oidc.tokenFailed': 'Authenticatie mislukt.', 'login.oidc.invalidState': 'Ongeldige sessie. Probeer het opnieuw.', + 'login.oidc.emailNotVerified': + 'Je inlogprovider heeft dit e-mailadres niet bevestigd, dus het kan niet aan een bestaand account worden gekoppeld.', + 'login.oidc.emailLinkBlocked': + 'Deze aanmelding kon niet worden gekoppeld aan het bestaande account met dit e-mailadres. Vraag je beheerder het adres te bevestigen.', 'login.demoFailed': 'Demo-login mislukt', 'login.oidcSignIn': 'Inloggen met {name}', 'login.oidcOnly': 'Wachtwoordauthenticatie is uitgeschakeld. Log in via je SSO-provider.', diff --git a/shared/src/i18n/pl/login.ts b/shared/src/i18n/pl/login.ts index 70bea165f..8aa9a3533 100644 --- a/shared/src/i18n/pl/login.ts +++ b/shared/src/i18n/pl/login.ts @@ -23,6 +23,10 @@ const login: TranslationStrings = { 'login.oidc.noEmail': 'Nie otrzymano e-maila od dostawcy.', 'login.oidc.tokenFailed': 'Nie udało się uwierzytelnić.', 'login.oidc.invalidState': 'Nieprawidłowa sesja. Spróbuj ponownie.', + 'login.oidc.emailNotVerified': + 'Twój dostawca logowania nie potwierdził tego adresu e-mail, więc nie można go powiązać z istniejącym kontem.', + 'login.oidc.emailLinkBlocked': + 'Nie udało się powiązać tego logowania z istniejącym kontem o tym adresie e-mail. Poproś administratora o potwierdzenie adresu.', 'login.demoFailed': 'Nie udało się zalogować do wersji demonstracyjnej', 'login.oidcSignIn': 'Zaloguj się z {name}', 'login.oidcOnly': 'Uwierzytelnianie hasłem jest wyłączone. Zaloguj się za pomocą swojego dostawcy SSO.', diff --git a/shared/src/i18n/ru/login.ts b/shared/src/i18n/ru/login.ts index 78ee3cf4a..550b82657 100644 --- a/shared/src/i18n/ru/login.ts +++ b/shared/src/i18n/ru/login.ts @@ -62,6 +62,10 @@ const login: TranslationStrings = { 'login.resetPasswordFailed': 'Сброс не удался. Возможно, срок действия ссылки истёк.', 'login.oidc.tokenFailed': 'Аутентификация не удалась.', 'login.oidc.invalidState': 'Недействительная сессия. Попробуйте снова.', + 'login.oidc.emailNotVerified': + 'Ваш провайдер входа не подтвердил этот адрес электронной почты, поэтому его нельзя связать с существующей учётной записью.', + 'login.oidc.emailLinkBlocked': + 'Не удалось связать этот вход с существующей учётной записью с этим адресом. Попросите администратора подтвердить адрес.', 'login.demoFailed': 'Ошибка демо-входа', 'login.oidcSignIn': 'Войти через {name}', 'login.oidcOnly': 'Вход по паролю отключён. Используйте вашего провайдера SSO для входа.', diff --git a/shared/src/i18n/sk/login.ts b/shared/src/i18n/sk/login.ts index d65430495..e511b6b35 100644 --- a/shared/src/i18n/sk/login.ts +++ b/shared/src/i18n/sk/login.ts @@ -24,6 +24,10 @@ const login: TranslationStrings = { 'login.oidc.noEmail': 'Od poskytovateľa nebol prijatý žiadny e-mail.', 'login.oidc.tokenFailed': 'Overenie zlyhalo.', 'login.oidc.invalidState': 'Neplatná relácia. Skúste to prosím znova.', + 'login.oidc.emailNotVerified': + 'Váš poskytovateľ prihlásenia túto e-mailovú adresu nepotvrdil, preto ju nemožno prepojiť s existujúcim účtom.', + 'login.oidc.emailLinkBlocked': + 'Toto prihlásenie sa nepodarilo prepojiť s existujúcim účtom s týmto e-mailom. Požiadajte správcu o potvrdenie adresy.', 'login.demoFailed': 'Prihlásenie do dema zlyhalo', 'login.oidcSignIn': 'Prihlásiť sa cez {name}', 'login.oidcOnly': 'Overovanie heslom je zakázané. Prihláste sa prosím cez svojho SSO poskytovateľa.', diff --git a/shared/src/i18n/sv/login.ts b/shared/src/i18n/sv/login.ts index 4f944a5ac..bf1e6ef14 100644 --- a/shared/src/i18n/sv/login.ts +++ b/shared/src/i18n/sv/login.ts @@ -25,6 +25,10 @@ const login: TranslationStrings = { 'login.oidc.noEmail': 'Inget e-postmeddelande har mottagits från leverantören.', 'login.oidc.tokenFailed': 'Autentiseringen misslyckades.', 'login.oidc.invalidState': 'Ogiltig session. Försök igen.', + 'login.oidc.emailNotVerified': + 'Din inloggningsleverantör har inte bekräftat den här e-postadressen, så den kan inte kopplas till ett befintligt konto.', + 'login.oidc.emailLinkBlocked': + 'Inloggningen kunde inte kopplas till det befintliga kontot med den här e-postadressen. Be administratören bekräfta adressen.', 'login.demoFailed': 'Inloggningen för demoversionen misslyckades', 'login.oidcSignIn': 'Logga in med {name}', 'login.oidcOnly': 'Lösenordsautentisering är inaktiverad. Logga in via din SSO-leverantör.', diff --git a/shared/src/i18n/th/login.ts b/shared/src/i18n/th/login.ts index 33b57bf12..4891b433a 100644 --- a/shared/src/i18n/th/login.ts +++ b/shared/src/i18n/th/login.ts @@ -24,6 +24,10 @@ const login: TranslationStrings = { 'login.oidc.noEmail': 'ไม่ได้รับอีเมลจากผู้ให้บริการ', 'login.oidc.tokenFailed': 'การตรวจสอบสิทธิ์ล้มเหลว', 'login.oidc.invalidState': 'เซสชันไม่ถูกต้อง โปรดลองอีกครั้ง', + 'login.oidc.emailNotVerified': + 'ผู้ให้บริการลงชื่อเข้าใช้ยังไม่ได้ยืนยันที่อยู่อีเมลนี้ จึงไม่สามารถเชื่อมโยงกับบัญชีที่มีอยู่ได้', + 'login.oidc.emailLinkBlocked': + 'ไม่สามารถเชื่อมโยงการลงชื่อเข้าใช้นี้กับบัญชีที่มีอยู่ซึ่งใช้อีเมลนี้ได้ โปรดขอให้ผู้ดูแลระบบยืนยันที่อยู่อีเมล', 'login.demoFailed': 'การเข้าสู่ระบบสาธิตล้มเหลว', 'login.oidcSignIn': 'ลงชื่อเข้าใช้ด้วย {name}', 'login.oidcOnly': 'การตรวจสอบรหัสผ่านถูกปิดใช้งาน โปรดลงชื่อเข้าใช้โดยใช้ผู้ให้บริการ SSO ของคุณ', diff --git a/shared/src/i18n/tr/login.ts b/shared/src/i18n/tr/login.ts index d76e11595..821e2aee8 100644 --- a/shared/src/i18n/tr/login.ts +++ b/shared/src/i18n/tr/login.ts @@ -25,6 +25,10 @@ const login: TranslationStrings = { 'login.oidc.noEmail': 'Sağlayıcıdan e-posta alınmadı.', 'login.oidc.tokenFailed': 'Kimlik doğrulama başarısız oldu.', 'login.oidc.invalidState': 'Geçersiz oturum. Lütfen tekrar deneyin.', + 'login.oidc.emailNotVerified': + 'Oturum açma sağlayıcınız bu e-posta adresini doğrulamadı, bu yüzden mevcut bir hesaba bağlanamaz.', + 'login.oidc.emailLinkBlocked': + 'Bu oturum açma, bu e-postaya sahip mevcut hesaba bağlanamadı. Adresi doğrulaması için yöneticinize başvurun.', 'login.demoFailed': 'Demo girişi başarısız oldu', 'login.oidcSignIn': '{name} ile oturum açın', 'login.oidcOnly': 'Parola kimlik doğrulaması devre dışı. Lütfen SSO sağlayıcınızı kullanarak oturum açın.', diff --git a/shared/src/i18n/uk/login.ts b/shared/src/i18n/uk/login.ts index d38af02fd..04a752b42 100644 --- a/shared/src/i18n/uk/login.ts +++ b/shared/src/i18n/uk/login.ts @@ -61,6 +61,10 @@ const login: TranslationStrings = { 'login.resetPasswordFailed': 'Скидання не вдалося. Можливо, термін дії посилання минув.', 'login.oidc.tokenFailed': 'Автентифікація не вдалася.', 'login.oidc.invalidState': 'Недійсна сесія. Спробуйте знову.', + 'login.oidc.emailNotVerified': + 'Ваш провайдер входу не підтвердив цю адресу електронної пошти, тому її не можна пов’язати з наявним обліковим записом.', + 'login.oidc.emailLinkBlocked': + 'Не вдалося пов’язати цей вхід із наявним обліковим записом з цією адресою. Попросіть адміністратора підтвердити адресу.', 'login.demoFailed': 'Помилка демо-входу', 'login.oidcSignIn': 'Увійти через {name}', 'login.oidcOnly': 'Вхід за паролем вимкнено. Використайте вашого SSO-провайдера для входу.', diff --git a/shared/src/i18n/vi/login.ts b/shared/src/i18n/vi/login.ts index a21e2f9f1..a1dc0965f 100644 --- a/shared/src/i18n/vi/login.ts +++ b/shared/src/i18n/vi/login.ts @@ -24,6 +24,10 @@ const login: TranslationStrings = { 'login.oidc.noEmail': 'Không nhận được email từ nhà cung cấp.', 'login.oidc.tokenFailed': 'Xác thực không thành công.', 'login.oidc.invalidState': 'Phiên không hợp lệ. Vui lòng thử lại.', + 'login.oidc.emailNotVerified': + 'Nhà cung cấp đăng nhập của bạn chưa xác nhận địa chỉ email này, nên không thể liên kết với tài khoản hiện có.', + 'login.oidc.emailLinkBlocked': + 'Không thể liên kết lần đăng nhập này với tài khoản hiện có dùng email này. Hãy nhờ quản trị viên xác nhận địa chỉ.', 'login.demoFailed': 'Đăng nhập demo không thành công', 'login.oidcSignIn': 'Đăng nhập bằng {name}', 'login.oidcOnly': 'Xác thực mật khẩu bị vô hiệu hóa. Vui lòng đăng nhập bằng nhà cung cấp SSO của bạn.', diff --git a/shared/src/i18n/zh-TW/login.ts b/shared/src/i18n/zh-TW/login.ts index bfb3d2e28..f32224373 100644 --- a/shared/src/i18n/zh-TW/login.ts +++ b/shared/src/i18n/zh-TW/login.ts @@ -58,6 +58,8 @@ const login: TranslationStrings = { 'login.resetPasswordFailed': '重設失敗。連結可能已過期。', 'login.oidc.tokenFailed': '認證失敗。', 'login.oidc.invalidState': '會話無效,請重試。', + 'login.oidc.emailNotVerified': '你的登入提供者尚未確認此電子郵件地址,因此無法連結到現有帳號。', + 'login.oidc.emailLinkBlocked': '無法將此次登入連結到使用此電子郵件的現有帳號。請管理員確認該地址。', 'login.demoFailed': '演示登入失敗', 'login.oidcSignIn': '透過 {name} 登入', 'login.oidcOnly': '密碼登入已關閉。請透過 SSO 提供商登入。', diff --git a/shared/src/i18n/zh/login.ts b/shared/src/i18n/zh/login.ts index 6622ca7be..be6481256 100644 --- a/shared/src/i18n/zh/login.ts +++ b/shared/src/i18n/zh/login.ts @@ -58,6 +58,8 @@ const login: TranslationStrings = { 'login.resetPasswordFailed': '重置失败。链接可能已过期。', 'login.oidc.tokenFailed': '认证失败。', 'login.oidc.invalidState': '会话无效,请重试。', + 'login.oidc.emailNotVerified': '你的登录提供方尚未确认此邮箱地址,因此无法关联到现有账户。', + 'login.oidc.emailLinkBlocked': '无法将此次登录关联到使用此邮箱的现有账户。请让管理员确认该地址。', 'login.demoFailed': '演示登录失败', 'login.oidcSignIn': '通过 {name} 登录', 'login.oidcOnly': '密码登录已关闭。请通过 SSO 提供商登录。',