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 提供商登录。',