diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 91aac58fa..0ac0d1f24 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -565,6 +565,7 @@ export const adminApi = { deleteUser: (id: number) => apiClient.delete(`/admin/users/${id}`).then(r => r.data), resetUserPasskeys: (id: number) => apiClient.delete(`/admin/users/${id}/passkeys`).then(r => r.data), resetUserMfa: (id: number) => apiClient.delete(`/admin/users/${id}/mfa`).then(r => r.data), + unlinkUserSso: (id: number) => apiClient.delete(`/admin/users/${id}/sso`).then(r => r.data), stats: () => apiClient.get('/admin/stats').then(r => r.data), saveDemoBaseline: () => apiClient.post('/admin/save-demo-baseline').then(r => r.data), getOidc: () => apiClient.get('/admin/oidc').then(r => r.data), diff --git a/client/src/components/Admin/useAdminUserActions.test.ts b/client/src/components/Admin/useAdminUserActions.test.ts index d3e936bdc..c34f4df7a 100644 --- a/client/src/components/Admin/useAdminUserActions.test.ts +++ b/client/src/components/Admin/useAdminUserActions.test.ts @@ -3,9 +3,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useAdminUserActions } from './useAdminUserActions'; -// FE-HOOK-ADMUSERACT-001 to FE-HOOK-ADMUSERACT-007 +// FE-HOOK-ADMUSERACT-001 to FE-HOOK-ADMUSERACT-009 -const api = vi.hoisted(() => ({ rotateJwtSecret: vi.fn(), resetUserPasskeys: vi.fn(), resetUserMfa: vi.fn() })); +const api = vi.hoisted(() => ({ + rotateJwtSecret: vi.fn(), + resetUserPasskeys: vi.fn(), + resetUserMfa: vi.fn(), + unlinkUserSso: vi.fn(), +})); vi.mock('../../api/client', () => ({ adminApi: api })); const t = (key: string, params?: Record) => (params ? `${key} ${JSON.stringify(params)}` : key); @@ -28,6 +33,7 @@ beforeEach(() => { api.rotateJwtSecret.mockReset().mockResolvedValue({}); api.resetUserPasskeys.mockReset().mockResolvedValue({ deleted: 2 }); api.resetUserMfa.mockReset().mockResolvedValue({ success: true }); + api.unlinkUserSso.mockReset().mockResolvedValue({ success: true, unlinked: true }); }); describe('useAdminUserActions', () => { @@ -101,4 +107,26 @@ describe('useAdminUserActions', () => { expect(admin.toast.error).toHaveBeenCalledWith('common.error'); expect(ok).toBe(false); }); + + it('FE-HOOK-ADMUSERACT-008: removing the SSO link calls the API, toasts and reports success', async () => { + const { admin, actions } = mount(); + let ok: boolean | undefined; + await act(async () => { + ok = await actions.unlinkSso({ id: 11 }); + }); + expect(api.unlinkUserSso).toHaveBeenCalledWith(11); + expect(admin.toast.success).toHaveBeenCalledWith('admin.sso.unlinkDone'); + expect(ok).toBe(true); + }); + + it('FE-HOOK-ADMUSERACT-009: a failed SSO unlink toasts and reports failure', async () => { + api.unlinkUserSso.mockRejectedValue(new Error('x')); + const { admin, actions } = mount(); + let ok: boolean | undefined; + await act(async () => { + ok = await actions.unlinkSso({ id: 11 }); + }); + expect(admin.toast.error).toHaveBeenCalledWith('common.error'); + expect(ok).toBe(false); + }); }); diff --git a/client/src/components/Admin/useAdminUserActions.ts b/client/src/components/Admin/useAdminUserActions.ts index 19ce26a89..3d45d691c 100644 --- a/client/src/components/Admin/useAdminUserActions.ts +++ b/client/src/components/Admin/useAdminUserActions.ts @@ -12,8 +12,8 @@ interface UserActionsHost { /** * The irreversible user actions behind the admin dialogs and their phone sheets: * rotating the JWT secret, which signs everyone out including the admin, removing a - * user's passkeys and switching off a user's two-factor login. Each shell keeps its own - * confirm step and busy state around them. + * user's passkeys, switching off a user's two-factor login and removing a user's SSO + * link. Each shell keeps its own confirm step and busy state around them. */ export function useAdminUserActions(admin: UserActionsHost, t: TranslationFn) { const { toast, setRotatingJwt, setShowRotateJwtModal, logout, navigate } = admin; @@ -55,5 +55,17 @@ export function useAdminUserActions(admin: UserActionsHost, t: TranslationFn) { } }; - return { rotateJwt, resetPasskeys, resetMfa }; + /** Forgets the user's SSO identity so the next SSO sign-in links again (#3065); true when it worked. */ + const unlinkSso = async (user: { id: number }): Promise => { + try { + await adminApi.unlinkUserSso(user.id); + toast.success(t('admin.sso.unlinkDone')); + return true; + } catch { + toast.error(t('common.error')); + return false; + } + }; + + return { rotateJwt, resetPasskeys, resetMfa, unlinkSso }; } diff --git a/client/src/pages/admin/AdminUserModals.tsx b/client/src/pages/admin/AdminUserModals.tsx index 87a52b278..27860cc91 100644 --- a/client/src/pages/admin/AdminUserModals.tsx +++ b/client/src/pages/admin/AdminUserModals.tsx @@ -8,6 +8,7 @@ import { Fingerprint, RefreshCw, ShieldOff, + Unlink, UserCog, UserPlus, } from 'lucide-react'; @@ -114,14 +115,17 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea rotatingJwt, handleCreateUser, handleSaveUser, + oidcConfigured, } = admin; - const { rotateJwt, resetPasskeys, resetMfa } = useAdminUserActions(admin, t); + const { rotateJwt, resetPasskeys, resetMfa, unlinkSso } = useAdminUserActions(admin, t); const [showCreatePw, setShowCreatePw] = React.useState(false); const [showEditPw, setShowEditPw] = React.useState(false); // The user whose passkeys wait for the admin's answer in the confirm dialog. const [passkeyResetUser, setPasskeyResetUser] = React.useState(null); // Same for the two-factor reset; an admin turns off their own 2FA in Settings, not here. const [mfaResetUser, setMfaResetUser] = React.useState(null); + // Same for removing the SSO link, offered only while SSO is set up (#3065). + const [ssoUnlinkUser, setSsoUnlinkUser] = React.useState(null); const uid = useId(); const createId = `${uid}-create`; const editId = `${uid}-edit`; @@ -318,6 +322,23 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea )} + {oidcConfigured && editingUser && editingUser.id !== currentUser?.id && ( + +
+

+ {t('admin.sso.unlinkHint')} +

+ +
+
+ )} )} @@ -480,6 +501,19 @@ docker run -d --name trek \\ confirmLabel={t('admin.mfa.reset')} danger /> + + setSsoUnlinkUser(null)} + onConfirm={async () => { + if (!ssoUnlinkUser) return; + await unlinkSso(ssoUnlinkUser); + }} + title={t('admin.sso.unlink')} + message={ssoUnlinkUser ? t('admin.sso.unlinkConfirm', { name: ssoUnlinkUser.username }) : ''} + confirmLabel={t('admin.sso.unlink')} + danger + /> ); } diff --git a/server/src/db/repositories/Users.repository.ts b/server/src/db/repositories/Users.repository.ts index 5a0a2dab4..5588fc3e6 100644 --- a/server/src/db/repositories/Users.repository.ts +++ b/server/src/db/repositories/Users.repository.ts @@ -823,6 +823,14 @@ export class UsersRepository extends TrekRepository { await this.nativeUpdate({ id }, { oidc_sub: sub, oidc_issuer: issuer }); } + /** + * `UPDATE users SET oidc_sub = NULL, oidc_issuer = NULL WHERE id = ?`: the + * admin's "Remove SSO link" (#3065). No `updated_at`, like the link it undoes. + */ + async clearOidcIdentity(id: number): Promise { + await this.nativeUpdate({ id }, { oidc_sub: null, oidc_issuer: null }); + } + // --------------------------------------------------------------------- // O8 — claims-based role change // --------------------------------------------------------------------- diff --git a/server/src/nest/admin/admin.controller.ts b/server/src/nest/admin/admin.controller.ts index ab5aa0969..ef44c34b3 100644 --- a/server/src/nest/admin/admin.controller.ts +++ b/server/src/nest/admin/admin.controller.ts @@ -156,6 +156,22 @@ export class AdminController { return { success: true }; } + @Delete('users/:id/sso') + async unlinkUserSso(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { + const result = await this.admin.unlinkUserSso(id, user.id); + if (result.unlinked) { + await this.audit.writeAudit({ + userId: user.id, + action: 'admin.user_sso_unlink', + resource: String(id), + ip: getClientIp(req), + details: { targetUser: result.email }, + }); + logInfo(`Admin ${user.email} removed the SSO link of user ${result.email}`); + } + return { success: true, unlinked: result.unlinked }; + } + // ── Stats / permissions / audit ── @Get('stats') stats() { diff --git a/server/src/nest/admin/admin.service.ts b/server/src/nest/admin/admin.service.ts index dcdc7ddfc..dc8295681 100644 --- a/server/src/nest/admin/admin.service.ts +++ b/server/src/nest/admin/admin.service.ts @@ -398,6 +398,35 @@ export class AdminService { return { success: true, email: target.email }; } + /** + * Forget the SSO identity stored on another account (#3065). + * + * A sign-in that arrives with a different subject of the same provider is + * refused, because that is another person at the provider as far as TREK can + * tell. When the provider itself re-issued the subject (a migrated or rebuilt + * IdP, a changed `sub` format), the account owner is locked out and editing the + * email does not help. Clearing the link hands the decision to the admin: the + * next SSO sign-in links again under the same rules as a first one, so it still + * needs a verified email and an address the account did not set itself. + * + * Never for the caller themselves, for the same reason as resetUserMfa: a + * stolen admin session must not be able to loosen the login of the account it + * came from. + */ + async unlinkUserSso(id: string, actingUserId: number): Promise<{ email: string; unlinked: boolean }> { + const targetId = Number(id); + // SECURITY: plain JS comparison, kept here like resetUserMfa's (R4). + if (targetId === actingUserId) { + throw new DomainError(400, 'You cannot remove the SSO link of your own account'); + } + const target = await this.users.findById(targetId); + if (!target || target.is_guest) throw new DomainError(404, 'User not found'); + if (!target.oidc_sub && !target.oidc_issuer) return { email: target.email, unlinked: false }; + + await this.users.clearOidcIdentity(targetId); + return { email: target.email, unlinked: true }; + } + // ── Stats ────────────────────────────────────────────────────────────────── async getStats() { diff --git a/server/src/nest/oidc/oidc.service.ts b/server/src/nest/oidc/oidc.service.ts index f5000bc38..ffeaaaac0 100644 --- a/server/src/nest/oidc/oidc.service.ts +++ b/server/src/nest/oidc/oidc.service.ts @@ -686,7 +686,7 @@ export class OidcService implements OnModuleDestroy { // provider, never this account under a new name. if (user.oidc_sub && user.oidc_issuer === config.issuer) { logWarn( - `[OIDC] Refused to link a sign-in to user ${user.id} by email: it is linked to another subject of this provider.`, + `[OIDC] Refused to link a sign-in to user ${user.id} by email: it is linked to another subject of this provider. If the provider changed the subject of the same person, an admin can remove the SSO link of this user in the admin panel.`, ); return { error: 'email_link_blocked' }; } diff --git a/server/tests/e2e/admin.e2e.test.ts b/server/tests/e2e/admin.e2e.test.ts index 8cf6a86ee..45d2039ee 100644 --- a/server/tests/e2e/admin.e2e.test.ts +++ b/server/tests/e2e/admin.e2e.test.ts @@ -24,7 +24,7 @@ import { NotificationsModule } from '../../src/nest/notifications/notifications. import { OidcModule } from '../../src/nest/oidc/oidc.module'; import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; import { SettingsModule } from '../../src/nest/settings/settings.module'; -import { countRows, findRow, insertRow } from '../helpers/factories/rows'; +import { countRows, findRow, insertRow, updateRows } from '../helpers/factories/rows'; import { readAppSetting } from '../helpers/factories/settings'; import { makeTrip } from '../helpers/factories/trips'; import { makeAdmin, makeUser } from '../helpers/factories/users'; @@ -262,6 +262,27 @@ describe('Admin e2e (real auth + admin guard + temp SQLite)', () => { }); }); + it('DELETE /users/:id/sso clears the stored SSO identity, audits it, and is admin-only (#3065)', async () => { + const { user } = await makeUser(orm, { email: 'sso-moved@example.test', username: 'sso-moved' }); + await updateRows(orm, Users, { id: user.id }, { oidc_sub: 'sub-old', oidc_issuer: 'https://idp.example.test' }); + + const denied = await request(server).delete(`/api/admin/users/${user.id}/sso`).set('Cookie', sessionCookie(2)); + expect(denied.status).toBe(403); + + const res = await request(server).delete(`/api/admin/users/${user.id}/sso`).set('Cookie', sessionCookie(1)); + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true, unlinked: true }); + const row = await findRow(orm, Users, { id: user.id }); + expect(row && { oidc_sub: row.oidc_sub ?? null, oidc_issuer: row.oidc_issuer ?? null }).toEqual({ + oidc_sub: null, + oidc_issuer: null, + }); + expect(await findRow(orm, AuditLog, { action: 'admin.user_sso_unlink', resource: String(user.id) })).not.toBeNull(); + + const self = await request(server).delete('/api/admin/users/1/sso').set('Cookie', sessionCookie(1)); + expect(self.status).toBe(400); + }); + it('404 on the dev-only test-notification outside development', async () => { const res = await request(server).post('/api/admin/dev/test-notification').set('Cookie', sessionCookie(1)).send({}); expect(res.status).toBe(404); diff --git a/server/tests/unit/db/repositories/Users.repository.test.ts b/server/tests/unit/db/repositories/Users.repository.test.ts index 2e38dd756..df0f54d46 100644 --- a/server/tests/unit/db/repositories/Users.repository.test.ts +++ b/server/tests/unit/db/repositories/Users.repository.test.ts @@ -669,6 +669,25 @@ describe('UsersRepository', () => { expect(row.updated_at).toBe('2020-01-01 00:00:00'); }); + it('USERSREPO-088: clearOidcIdentity nulls sub+issuer WITHOUT touching updated_at (#3065)', async () => { + const { user } = createUser(testDb); + await updateRows( + t, + Users, + { id: user.id }, + { oidc_sub: 'sub-1', oidc_issuer: 'https://idp.example.com', updated_at: '2020-01-01 00:00:00' }, + ); + await users.clearOidcIdentity(user.id); + const row = (await findRow(t, Users, { id: user.id })) as { + oidc_sub: string | null; + oidc_issuer: string | null; + updated_at: string; + }; + expect(row.oidc_sub).toBeNull(); + expect(row.oidc_issuer).toBeNull(); + expect(row.updated_at).toBe('2020-01-01 00:00:00'); + }); + it('USERSREPO-042: setRole (O8) writes role WITHOUT touching updated_at', async () => { const { user } = createUser(testDb); await updateRows(t, Users, { id: user.id }, { updated_at: '2020-01-01 00:00:00' }); diff --git a/server/tests/unit/nest/admin.controller.test.ts b/server/tests/unit/nest/admin.controller.test.ts index a8804d74d..5072cbc4e 100644 --- a/server/tests/unit/nest/admin.controller.test.ts +++ b/server/tests/unit/nest/admin.controller.test.ts @@ -357,6 +357,42 @@ describe('AdminController addons + sessions + jwt + defaults', () => { }); }); +describe('AdminController SSO unlink (#3065)', () => { + it('ADMIN-CTRL-SSO-001: audits a removed link and answers unlinked: true', async () => { + writeAudit.mockClear(); + const unlinkUserSso = vi.fn().mockResolvedValue({ email: 'a@b.c', unlinked: true }); + expect(await adminCtl(svc({ unlinkUserSso } as Partial)).unlinkUserSso(user, '4', req)).toEqual({ + success: true, + unlinked: true, + }); + expect(unlinkUserSso).toHaveBeenCalledWith('4', user.id); + expect(writeAudit).toHaveBeenCalledWith( + expect.objectContaining({ action: 'admin.user_sso_unlink', resource: '4', details: { targetUser: 'a@b.c' } }), + ); + }); + + it('ADMIN-CTRL-SSO-002: writes no audit row when there was no link, and maps the self refusal', async () => { + writeAudit.mockClear(); + expect( + await adminCtl( + svc({ unlinkUserSso: vi.fn().mockResolvedValue({ email: 'a@b.c', unlinked: false }) } as Partial), + ).unlinkUserSso(user, '4', req), + ).toEqual({ success: true, unlinked: false }); + expect(writeAudit).not.toHaveBeenCalled(); + expect( + await thrown(() => + adminCtl( + svc({ + unlinkUserSso: vi + .fn() + .mockRejectedValue(new DomainError(400, 'You cannot remove the SSO link of your own account')), + } as Partial), + ).unlinkUserSso(user, '1', req), + ), + ).toEqual({ status: 400, body: { error: 'You cannot remove the SSO link of your own account' } }); + }); +}); + describe('AdminController read-only getters', () => { it('return service values verbatim', async () => { expect( diff --git a/server/tests/unit/nest/admin.service.test.ts b/server/tests/unit/nest/admin.service.test.ts index f8b541c93..cf01c6628 100644 --- a/server/tests/unit/nest/admin.service.test.ts +++ b/server/tests/unit/nest/admin.service.test.ts @@ -998,6 +998,61 @@ describe('resetUserMfa', () => { }); }); +// #3065: the provider re-issued a user's subject, so every sign-in hits +// "linked to another subject of this provider" and editing the email cannot help. +describe('unlinkUserSso', () => { + it('ADMIN-SVC-097 — clears sub and issuer so the next SSO sign-in links like a first one', async () => { + const admin = createAdmin(testDb); + const { user } = createUser(testDb); + await updateRows(orm, Users, { id: user.id }, { oidc_sub: 'sub-old', oidc_issuer: 'https://idp.example.com' }); + + const result = await svc.unlinkUserSso(String(user.id), admin.user.id); + + expect(result).toEqual({ email: user.email, unlinked: true }); + const row = await readUser(orm, user.id); + expect({ oidc_sub: row.oidc_sub, oidc_issuer: row.oidc_issuer }).toEqual({ oidc_sub: null, oidc_issuer: null }); + }); + + it('ADMIN-SVC-098 — an account without an SSO link answers unlinked: false and stays as it is', async () => { + const admin = createAdmin(testDb); + const { user } = createUser(testDb); + + expect(await svc.unlinkUserSso(String(user.id), admin.user.id)).toEqual({ email: user.email, unlinked: false }); + }); + + it('ADMIN-SVC-099 — refuses the callers own account and keeps its link', async () => { + const admin = createAdmin(testDb); + await updateRows( + orm, + Users, + { id: admin.user.id }, + { oidc_sub: 'sub-admin', oidc_issuer: 'https://idp.example.com' }, + ); + + const result = (await asLegacyResult(svc.unlinkUserSso(String(admin.user.id), admin.user.id))) as { + error?: string; + status?: number; + }; + + expect(result.status).toBe(400); + expect(result.error).toMatch(/your own/i); + expect((await readUser(orm, admin.user.id)).oidc_sub).toBe('sub-admin'); + }); + + it('ADMIN-SVC-100 — 404 for a user that is not there, and for a guest', async () => { + const admin = createAdmin(testDb); + const { user: guest } = createUser(testDb); + await updateRows(orm, Users, { id: guest.id }, { is_guest: 1 }); + + expect((await asLegacyResult(svc.unlinkUserSso('99999', admin.user.id))) as { status?: number }).toMatchObject({ + status: 404, + }); + expect( + (await asLegacyResult(svc.unlinkUserSso(String(guest.id), admin.user.id))) as { status?: number }, + ).toMatchObject({ status: 404 }); + }); +}); + describe('checkVersion on a centrally administered install', () => { afterEach(() => { vi.unstubAllEnvs(); diff --git a/shared/src/i18n/ar/admin.ts b/shared/src/i18n/ar/admin.ts index 9eef0ece6..fb6c21712 100644 --- a/shared/src/i18n/ar/admin.ts +++ b/shared/src/i18n/ar/admin.ts @@ -1052,6 +1052,11 @@ const admin: TranslationStrings = { 'يعطّل المصادقة الثنائية لهذا المستخدم، مثلاً بعد فقدان هاتفه. يتم تسجيل خروجه من كل مكان، ويمكنه إعدادها من جديد بعد تسجيل الدخول بكلمة مروره.', 'admin.mfa.resetConfirm': 'إيقاف المصادقة الثنائية لـ {name}؟', 'admin.mfa.resetDone': 'تمت إعادة تعيين المصادقة الثنائية', + 'admin.sso.unlink': 'إزالة ربط SSO', + 'admin.sso.unlinkHint': + 'ينسى أي تسجيل دخول لدى مزوّد SSO ينتمي إلى هذا الحساب، مثلاً بعد أن غيّر المزوّد معرّفات المستخدمين. أول تسجيل دخول عبر SSO بعنوان بريد مؤكَّد يربط الحساب من جديد.', + 'admin.sso.unlinkConfirm': 'إزالة ربط SSO لـ {name}؟', + 'admin.sso.unlinkDone': 'تمت إزالة ربط SSO', 'admin.danger.title': 'منطقة الخطر', 'admin.rotateJwt.title': 'تدوير سر JWT', 'admin.rotateJwt.hint': 'أنشئ سرًّا جديدًا لتوقيع JWT. ستُلغى جميع الجلسات النشطة فورًا.', diff --git a/shared/src/i18n/az/admin.ts b/shared/src/i18n/az/admin.ts index 23665fa76..b67dd30c6 100644 --- a/shared/src/i18n/az/admin.ts +++ b/shared/src/i18n/az/admin.ts @@ -1030,6 +1030,11 @@ const admin: TranslationStrings = { 'Bu istifadəçi üçün iki faktorlu doğrulamanı söndürür, məsələn telefon itirildikdən sonra. İstifadəçi hər yerdən çıxarılır və parolu ilə daxil olduqdan sonra onu yenidən quraşdıra bilər.', 'admin.mfa.resetConfirm': '{name} üçün iki faktorlu doğrulama söndürülsün?', 'admin.mfa.resetDone': 'İki faktorlu doğrulama sıfırlandı', + 'admin.sso.unlink': 'SSO bağlantısını sil', + 'admin.sso.unlinkHint': + 'SSO provayderindəki hansı girişin bu hesaba aid olduğunu unudur, məsələn provayder istifadəçi ID-lərini dəyişdikdən sonra. Təsdiqlənmiş e-poçt ünvanı ilə növbəti SSO girişi hesabı yenidən bağlayır.', + 'admin.sso.unlinkConfirm': '{name} üçün SSO bağlantısı silinsin?', + 'admin.sso.unlinkDone': 'SSO bağlantısı silindi', 'admin.danger.title': 'Təhlükəli zona', 'admin.rotateJwt.title': 'JWT sirrini yenilə', 'admin.rotateJwt.hint': 'Yeni JWT imza sirri yaradın. Bütün aktiv sessiyalar dərhal etibarsız olacaq.', diff --git a/shared/src/i18n/br/admin.ts b/shared/src/i18n/br/admin.ts index 1641dbd01..a07f3b32a 100644 --- a/shared/src/i18n/br/admin.ts +++ b/shared/src/i18n/br/admin.ts @@ -1004,6 +1004,11 @@ const admin: TranslationStrings = { 'Desativa a autenticação em dois fatores deste usuário, por exemplo após a perda do celular. Ele é desconectado de todos os lugares e pode configurá-la novamente depois de entrar com a senha.', 'admin.mfa.resetConfirm': 'Desativar a autenticação em dois fatores de {name}?', 'admin.mfa.resetDone': 'Autenticação em dois fatores redefinida', + 'admin.sso.unlink': 'Remover vínculo SSO', + 'admin.sso.unlinkHint': + 'Esquece qual login no seu provedor SSO pertence a esta conta, por exemplo depois que o provedor mudou os IDs de usuário. O próximo login via SSO com um e-mail confirmado vincula a conta novamente.', + 'admin.sso.unlinkConfirm': 'Remover o vínculo SSO de {name}?', + 'admin.sso.unlinkDone': 'Vínculo SSO removido', 'admin.danger.title': 'Zona de perigo', 'admin.rotateJwt.title': 'Rotacionar segredo JWT', 'admin.rotateJwt.hint': diff --git a/shared/src/i18n/ca/admin.ts b/shared/src/i18n/ca/admin.ts index 9c6900a2c..4f9b678e0 100644 --- a/shared/src/i18n/ca/admin.ts +++ b/shared/src/i18n/ca/admin.ts @@ -1018,6 +1018,11 @@ const admin: TranslationStrings = { "Desactiva l'autenticació de dos factors d'aquest usuari, per exemple després de perdre el telèfon. Es tanca la seva sessió a tot arreu i podrà tornar-la a configurar després d'iniciar sessió amb la contrasenya.", 'admin.mfa.resetConfirm': "Vols desactivar l'autenticació de dos factors de {name}?", 'admin.mfa.resetDone': 'Autenticació de dos factors restablerta', + 'admin.sso.unlink': "Elimina l'enllaç SSO", + 'admin.sso.unlinkHint': + "Oblida quin inici de sessió del teu proveïdor SSO pertany a aquest compte, per exemple després que el proveïdor hagi canviat els identificadors d'usuari. El següent inici de sessió SSO amb una adreça confirmada torna a enllaçar el compte.", + 'admin.sso.unlinkConfirm': "Vols eliminar l'enllaç SSO de {name}?", + 'admin.sso.unlinkDone': 'Enllaç SSO eliminat', 'admin.danger.title': 'Zona de perill', 'admin.rotateJwt.title': 'Rota el secret JWT', 'admin.rotateJwt.hint': diff --git a/shared/src/i18n/cs/admin.ts b/shared/src/i18n/cs/admin.ts index ba2fdca9a..de39dbca8 100644 --- a/shared/src/i18n/cs/admin.ts +++ b/shared/src/i18n/cs/admin.ts @@ -1012,6 +1012,11 @@ const admin: TranslationStrings = { 'Vypne pro tohoto uživatele dvoufázové ověření, například po ztrátě telefonu. Bude odhlášen všude a po přihlášení heslem si ho může nastavit znovu.', 'admin.mfa.resetConfirm': 'Vypnout dvoufázové ověření uživatele {name}?', 'admin.mfa.resetDone': 'Dvoufázové ověření bylo resetováno', + 'admin.sso.unlink': 'Odebrat propojení SSO', + 'admin.sso.unlinkHint': + 'Zapomene, které přihlášení u vašeho poskytovatele SSO patří k tomuto účtu, například poté, co poskytovatel změnil ID uživatelů. Další přihlášení přes SSO s potvrzenou e-mailovou adresou účet znovu propojí.', + 'admin.sso.unlinkConfirm': 'Odebrat propojení SSO uživatele {name}?', + 'admin.sso.unlinkDone': 'Propojení SSO bylo odebráno', 'admin.danger.title': 'Nebezpečná zóna', 'admin.rotateJwt.title': 'Obnovit tajný klíč JWT', 'admin.rotateJwt.hint': 'Vygeneruje nový podpisový klíč JWT. Všechny aktivní relace budou okamžitě zneplatněny.', diff --git a/shared/src/i18n/de/admin.ts b/shared/src/i18n/de/admin.ts index 0f0a8f9c4..a6b529c42 100644 --- a/shared/src/i18n/de/admin.ts +++ b/shared/src/i18n/de/admin.ts @@ -1014,6 +1014,11 @@ const admin: TranslationStrings = { 'Schaltet die Zwei-Faktor-Authentifizierung für diesen Benutzer ab, zum Beispiel nach dem Verlust des Handys. Die Person wird überall abgemeldet und kann sie nach der Anmeldung mit dem Passwort neu einrichten.', 'admin.mfa.resetConfirm': 'Zwei-Faktor-Authentifizierung für {name} abschalten?', 'admin.mfa.resetDone': 'Zwei-Faktor-Authentifizierung zurückgesetzt', + 'admin.sso.unlink': 'SSO-Verknüpfung entfernen', + 'admin.sso.unlinkHint': + 'Vergisst, welche Anmeldung bei deinem SSO-Anbieter zu diesem Konto gehört, zum Beispiel nachdem der Anbieter seine Benutzer-IDs geändert hat. Die nächste SSO-Anmeldung mit bestätigter E-Mail-Adresse verknüpft das Konto wieder.', + 'admin.sso.unlinkConfirm': 'SSO-Verknüpfung von {name} entfernen?', + 'admin.sso.unlinkDone': 'SSO-Verknüpfung entfernt', 'admin.danger.title': 'Gefahrenzone', 'admin.rotateJwt.title': 'JWT-Secret erneuern', 'admin.rotateJwt.hint': 'Erzeugt ein neues JWT-Signatur-Secret. Alle aktiven Sitzungen werden sofort ungültig.', diff --git a/shared/src/i18n/en/admin.ts b/shared/src/i18n/en/admin.ts index 4d435acc0..c8cdbdbc7 100644 --- a/shared/src/i18n/en/admin.ts +++ b/shared/src/i18n/en/admin.ts @@ -982,6 +982,11 @@ const admin: TranslationStrings = { 'Switches off two-factor authentication for this user, for example after a lost phone. They are signed out everywhere and can set it up again after logging in with their password.', 'admin.mfa.resetConfirm': 'Turn off two-factor authentication for {name}?', 'admin.mfa.resetDone': 'Two-factor authentication reset', + 'admin.sso.unlink': 'Remove SSO link', + 'admin.sso.unlinkHint': + 'Forgets which sign-in at your SSO provider belongs to this account, for example after the provider changed its user IDs. The next SSO sign-in with a confirmed email address links the account again.', + 'admin.sso.unlinkConfirm': 'Remove the SSO link of {name}?', + 'admin.sso.unlinkDone': 'SSO link removed', 'admin.danger.title': 'Danger Zone', 'admin.rotateJwt.title': 'Rotate JWT Secret', 'admin.rotateJwt.hint': 'Generate a new JWT signing secret. All active sessions will be invalidated immediately.', diff --git a/shared/src/i18n/es/admin.ts b/shared/src/i18n/es/admin.ts index 08574f3ec..bf8cbe36b 100644 --- a/shared/src/i18n/es/admin.ts +++ b/shared/src/i18n/es/admin.ts @@ -1022,6 +1022,11 @@ const admin: TranslationStrings = { 'Desactiva la autenticación en dos factores de este usuario, por ejemplo tras perder el móvil. Se cierra su sesión en todas partes y podrá volver a configurarla después de iniciar sesión con su contraseña.', 'admin.mfa.resetConfirm': '¿Desactivar la autenticación en dos factores de {name}?', 'admin.mfa.resetDone': 'Autenticación en dos factores restablecida', + 'admin.sso.unlink': 'Quitar vínculo SSO', + 'admin.sso.unlinkHint': + 'Olvida qué inicio de sesión de tu proveedor SSO pertenece a esta cuenta, por ejemplo después de que el proveedor cambiara los ID de usuario. El siguiente inicio de sesión SSO con un correo confirmado vuelve a vincular la cuenta.', + 'admin.sso.unlinkConfirm': '¿Quitar el vínculo SSO de {name}?', + 'admin.sso.unlinkDone': 'Vínculo SSO quitado', 'admin.danger.title': 'Zona de peligro', 'admin.rotateJwt.title': 'Rotar secreto JWT', 'admin.rotateJwt.hint': diff --git a/shared/src/i18n/et/admin.ts b/shared/src/i18n/et/admin.ts index 0fa80b1f3..c9270e916 100644 --- a/shared/src/i18n/et/admin.ts +++ b/shared/src/i18n/et/admin.ts @@ -1004,6 +1004,11 @@ const admin: TranslationStrings = { 'Lülitab selle kasutaja kaheastmelise autentimise välja, näiteks pärast telefoni kaotamist. Kasutaja logitakse kõikjalt välja ja ta saab selle pärast parooliga sisselogimist uuesti seadistada.', 'admin.mfa.resetConfirm': 'Kas lülitada kasutaja {name} kaheastmeline autentimine välja?', 'admin.mfa.resetDone': 'Kaheastmeline autentimine lähtestatud', + 'admin.sso.unlink': 'Eemalda SSO-seos', + 'admin.sso.unlinkHint': + 'Unustab, milline sisselogimine teie SSO-teenusepakkuja juures sellele kontole kuulub, näiteks pärast seda, kui teenusepakkuja muutis kasutajate ID-sid. Järgmine kinnitatud e-posti aadressiga SSO-sisselogimine seob konto uuesti.', + 'admin.sso.unlinkConfirm': 'Kas eemaldada kasutaja {name} SSO-seos?', + 'admin.sso.unlinkDone': 'SSO-seos eemaldatud', 'admin.danger.title': 'Ohutsoon', 'admin.rotateJwt.title': 'Roteeri JWT saladus', 'admin.rotateJwt.hint': 'Loo uus JWT allkirjastamise saladus. Kõik aktiivsed seansid muutuvad kohe kehtetuks.', diff --git a/shared/src/i18n/fr/admin.ts b/shared/src/i18n/fr/admin.ts index e9d2a822f..caee006fb 100644 --- a/shared/src/i18n/fr/admin.ts +++ b/shared/src/i18n/fr/admin.ts @@ -1023,6 +1023,11 @@ const admin: TranslationStrings = { "Désactive l'authentification à deux facteurs de cet utilisateur, par exemple après la perte d'un téléphone. Il est déconnecté partout et pourra la reconfigurer après s'être connecté avec son mot de passe.", 'admin.mfa.resetConfirm': "Désactiver l'authentification à deux facteurs de {name} ?", 'admin.mfa.resetDone': 'Authentification à deux facteurs réinitialisée', + 'admin.sso.unlink': 'Supprimer la liaison SSO', + 'admin.sso.unlinkHint': + 'Oublie quelle connexion chez votre fournisseur SSO appartient à ce compte, par exemple après un changement des identifiants utilisateur chez le fournisseur. La prochaine connexion SSO avec une adresse e-mail confirmée relie à nouveau le compte.', + 'admin.sso.unlinkConfirm': 'Supprimer la liaison SSO de {name} ?', + 'admin.sso.unlinkDone': 'Liaison SSO supprimée', 'admin.danger.title': 'Zone de danger', 'admin.rotateJwt.title': 'Renouveler le secret JWT', 'admin.rotateJwt.hint': diff --git a/shared/src/i18n/gr/admin.ts b/shared/src/i18n/gr/admin.ts index afa69cee8..7da615e80 100644 --- a/shared/src/i18n/gr/admin.ts +++ b/shared/src/i18n/gr/admin.ts @@ -1032,6 +1032,11 @@ const admin: TranslationStrings = { 'Απενεργοποιεί τον έλεγχο ταυτότητας δύο παραγόντων για αυτόν τον χρήστη, για παράδειγμα μετά από απώλεια του τηλεφώνου. Αποσυνδέεται παντού και μπορεί να τον ρυθμίσει ξανά αφού συνδεθεί με τον κωδικό του.', 'admin.mfa.resetConfirm': 'Απενεργοποίηση του ελέγχου ταυτότητας δύο παραγόντων για τον/την {name};', 'admin.mfa.resetDone': 'Ο έλεγχος ταυτότητας δύο παραγόντων επαναφέρθηκε', + 'admin.sso.unlink': 'Αφαίρεση σύνδεσης SSO', + 'admin.sso.unlinkHint': + 'Ξεχνά ποια σύνδεση στον πάροχο SSO ανήκει σε αυτόν τον λογαριασμό, για παράδειγμα αφού ο πάροχος άλλαξε τα αναγνωριστικά χρηστών. Η επόμενη σύνδεση SSO με επιβεβαιωμένο email συνδέει ξανά τον λογαριασμό.', + 'admin.sso.unlinkConfirm': 'Αφαίρεση της σύνδεσης SSO του/της {name};', + 'admin.sso.unlinkDone': 'Η σύνδεση SSO αφαιρέθηκε', 'admin.danger.title': 'Επικίνδυνη ζώνη', 'admin.rotateJwt.title': 'Εναλλαγή μυστικού JWT', 'admin.rotateJwt.hint': 'Δημιουργεί νέο μυστικό υπογραφής JWT. Όλες οι ενεργές συνεδρίες θα ακυρωθούν αμέσως.', diff --git a/shared/src/i18n/he/admin.ts b/shared/src/i18n/he/admin.ts index 99c3211a2..554d135de 100644 --- a/shared/src/i18n/he/admin.ts +++ b/shared/src/i18n/he/admin.ts @@ -970,6 +970,11 @@ const admin: TranslationStrings = { 'מכבה את האימות הדו-שלבי של המשתמש הזה, למשל אחרי שאבד לו הטלפון. הוא ינותק מכל המכשירים ויוכל להגדיר אותו מחדש אחרי התחברות עם הסיסמה.', 'admin.mfa.resetConfirm': 'לכבות את האימות הדו-שלבי של {name}?', 'admin.mfa.resetDone': 'האימות הדו-שלבי אופס', + 'admin.sso.unlink': 'הסרת קישור SSO', + 'admin.sso.unlinkHint': + 'שוכח איזו כניסה אצל ספק ה-SSO שייכת לחשבון הזה, למשל אחרי שהספק שינה את מזהי המשתמשים. הכניסה הבאה דרך SSO עם כתובת דוא״ל מאומתת מקשרת את החשבון מחדש.', + 'admin.sso.unlinkConfirm': 'להסיר את קישור ה-SSO של {name}?', + 'admin.sso.unlinkDone': 'קישור ה-SSO הוסר', 'admin.danger.title': 'אזור מסוכן', 'admin.rotateJwt.title': 'החלפת סוד ה-JWT', 'admin.rotateJwt.hint': 'יצירת סוד חתימה חדש ל-JWT. כל ההפעלות הפעילות יבוטלו מיד.', diff --git a/shared/src/i18n/hu/admin.ts b/shared/src/i18n/hu/admin.ts index 3ed660c55..b11988954 100644 --- a/shared/src/i18n/hu/admin.ts +++ b/shared/src/i18n/hu/admin.ts @@ -1021,6 +1021,11 @@ const admin: TranslationStrings = { 'Kikapcsolja a felhasználó kétlépcsős hitelesítését, például elveszett telefon esetén. Mindenhonnan kijelentkezik, és a jelszavával való belépés után újra beállíthatja.', 'admin.mfa.resetConfirm': 'Kikapcsolod {name} kétlépcsős hitelesítését?', 'admin.mfa.resetDone': 'A kétlépcsős hitelesítés visszaállítva', + 'admin.sso.unlink': 'SSO-kapcsolat eltávolítása', + 'admin.sso.unlinkHint': + 'Elfelejti, hogy az SSO-szolgáltatónál melyik bejelentkezés tartozik ehhez a fiókhoz, például miután a szolgáltató megváltoztatta a felhasználói azonosítókat. A következő SSO-bejelentkezés megerősített e-mail-címmel újra összekapcsolja a fiókot.', + 'admin.sso.unlinkConfirm': 'Eltávolítod {name} SSO-kapcsolatát?', + 'admin.sso.unlinkDone': 'Az SSO-kapcsolat eltávolítva', 'admin.danger.title': 'Veszélyzóna', 'admin.rotateJwt.title': 'JWT-titok cseréje', 'admin.rotateJwt.hint': 'Új JWT-aláírási titkot hoz létre. Az összes aktív munkamenet azonnal érvénytelenné válik.', diff --git a/shared/src/i18n/id/admin.ts b/shared/src/i18n/id/admin.ts index 9258ba4a6..b92c7c3d9 100644 --- a/shared/src/i18n/id/admin.ts +++ b/shared/src/i18n/id/admin.ts @@ -992,6 +992,11 @@ const admin: TranslationStrings = { 'Menonaktifkan autentikasi dua faktor untuk pengguna ini, misalnya setelah ponsel hilang. Pengguna dikeluarkan dari semua sesi dan dapat mengaturnya lagi setelah masuk dengan kata sandinya.', 'admin.mfa.resetConfirm': 'Nonaktifkan autentikasi dua faktor untuk {name}?', 'admin.mfa.resetDone': 'Autentikasi dua faktor telah diatur ulang', + 'admin.sso.unlink': 'Hapus tautan SSO', + 'admin.sso.unlinkHint': + 'Melupakan login mana di penyedia SSO yang dimiliki akun ini, misalnya setelah penyedia mengubah ID pengguna. Login SSO berikutnya dengan alamat email yang terkonfirmasi menautkan akun ini lagi.', + 'admin.sso.unlinkConfirm': 'Hapus tautan SSO untuk {name}?', + 'admin.sso.unlinkDone': 'Tautan SSO dihapus', 'admin.danger.title': 'Zona Berbahaya', 'admin.rotateJwt.title': 'Putar Secret JWT', 'admin.rotateJwt.hint': 'Buat secret penandatanganan JWT baru. Semua sesi aktif akan langsung tidak berlaku.', diff --git a/shared/src/i18n/it/admin.ts b/shared/src/i18n/it/admin.ts index e79744486..53436e4d3 100644 --- a/shared/src/i18n/it/admin.ts +++ b/shared/src/i18n/it/admin.ts @@ -1015,6 +1015,11 @@ const admin: TranslationStrings = { "Disattiva l'autenticazione a due fattori per questo utente, ad esempio dopo la perdita del telefono. L'utente viene disconnesso ovunque e può configurarla di nuovo dopo aver effettuato l'accesso con la password.", 'admin.mfa.resetConfirm': "Disattivare l'autenticazione a due fattori per {name}?", 'admin.mfa.resetDone': 'Autenticazione a due fattori reimpostata', + 'admin.sso.unlink': 'Rimuovi collegamento SSO', + 'admin.sso.unlinkHint': + "Dimentica quale accesso presso il provider SSO appartiene a questo account, ad esempio dopo che il provider ha cambiato gli ID utente. Il prossimo accesso SSO con un indirizzo email confermato collega di nuovo l'account.", + 'admin.sso.unlinkConfirm': 'Rimuovere il collegamento SSO di {name}?', + 'admin.sso.unlinkDone': 'Collegamento SSO rimosso', 'admin.danger.title': 'Zona pericolosa', 'admin.rotateJwt.title': 'Ruota il secret JWT', 'admin.rotateJwt.hint': diff --git a/shared/src/i18n/ja/admin.ts b/shared/src/i18n/ja/admin.ts index f538d4f16..aca6d62cc 100644 --- a/shared/src/i18n/ja/admin.ts +++ b/shared/src/i18n/ja/admin.ts @@ -951,6 +951,11 @@ const admin: TranslationStrings = { 'このユーザーの二要素認証をオフにします(スマートフォンを紛失した場合など)。すべての場所からサインアウトされ、パスワードでサインインした後に再設定できます。', 'admin.mfa.resetConfirm': '{name} の二要素認証をオフにしますか?', 'admin.mfa.resetDone': '二要素認証をリセットしました', + 'admin.sso.unlink': 'SSO連携を解除', + 'admin.sso.unlinkHint': + 'SSOプロバイダーのどのサインインがこのアカウントに属するかを忘れます(プロバイダーがユーザーIDを変更した場合など)。確認済みのメールアドレスで次にSSOサインインすると、アカウントが再び連携されます。', + 'admin.sso.unlinkConfirm': '{name} のSSO連携を解除しますか?', + 'admin.sso.unlinkDone': 'SSO連携を解除しました', 'admin.danger.title': '危険ゾーン', 'admin.rotateJwt.title': 'JWTシークレットをローテーション', 'admin.rotateJwt.hint': diff --git a/shared/src/i18n/ko/admin.ts b/shared/src/i18n/ko/admin.ts index 790791814..f73731312 100644 --- a/shared/src/i18n/ko/admin.ts +++ b/shared/src/i18n/ko/admin.ts @@ -943,6 +943,11 @@ const admin: TranslationStrings = { '이 사용자의 2단계 인증을 해제합니다. 예를 들어 휴대폰을 분실한 경우에 사용합니다. 모든 기기에서 로그아웃되며, 비밀번호로 로그인한 뒤 다시 설정할 수 있습니다.', 'admin.mfa.resetConfirm': '{name}의 2단계 인증을 해제할까요?', 'admin.mfa.resetDone': '2단계 인증이 초기화되었습니다', + 'admin.sso.unlink': 'SSO 연결 해제', + 'admin.sso.unlinkHint': + 'SSO 공급자의 어떤 로그인이 이 계정에 속하는지 잊습니다. 예를 들어 공급자가 사용자 ID를 변경한 경우에 사용합니다. 확인된 이메일 주소로 다음에 SSO 로그인하면 계정이 다시 연결됩니다.', + 'admin.sso.unlinkConfirm': '{name}의 SSO 연결을 해제할까요?', + 'admin.sso.unlinkDone': 'SSO 연결이 해제되었습니다', 'admin.danger.title': '위험 구역', 'admin.rotateJwt.title': 'JWT 시크릿 교체', 'admin.rotateJwt.hint': '새 JWT 서명 시크릿을 생성합니다. 활성 세션이 모두 즉시 무효화됩니다.', diff --git a/shared/src/i18n/nl/admin.ts b/shared/src/i18n/nl/admin.ts index 1417ba91d..1d1793dbe 100644 --- a/shared/src/i18n/nl/admin.ts +++ b/shared/src/i18n/nl/admin.ts @@ -1013,6 +1013,11 @@ const admin: TranslationStrings = { 'Schakelt tweestapsverificatie voor deze gebruiker uit, bijvoorbeeld na verlies van de telefoon. De gebruiker wordt overal afgemeld en kan het na inloggen met het wachtwoord opnieuw instellen.', 'admin.mfa.resetConfirm': 'Tweestapsverificatie voor {name} uitschakelen?', 'admin.mfa.resetDone': 'Tweestapsverificatie is gereset', + 'admin.sso.unlink': 'SSO-koppeling verwijderen', + 'admin.sso.unlinkHint': + 'Vergeet welke aanmelding bij je SSO-provider bij dit account hoort, bijvoorbeeld nadat de provider zijn gebruikers-ID’s heeft gewijzigd. De volgende SSO-aanmelding met een bevestigd e-mailadres koppelt het account opnieuw.', + 'admin.sso.unlinkConfirm': 'SSO-koppeling van {name} verwijderen?', + 'admin.sso.unlinkDone': 'SSO-koppeling verwijderd', 'admin.danger.title': 'Gevarenzone', 'admin.rotateJwt.title': 'JWT-geheim roteren', 'admin.rotateJwt.hint': 'Genereert een nieuw JWT-ondertekeningsgeheim. Alle actieve sessies worden direct ongeldig.', diff --git a/shared/src/i18n/pl/admin.ts b/shared/src/i18n/pl/admin.ts index af7ad9c6a..4b16ef05b 100644 --- a/shared/src/i18n/pl/admin.ts +++ b/shared/src/i18n/pl/admin.ts @@ -1059,6 +1059,11 @@ const admin: TranslationStrings = { 'Wyłącza uwierzytelnianie dwuskładnikowe tego użytkownika, na przykład po zgubieniu telefonu. Użytkownik zostaje wylogowany ze wszystkich urządzeń i po zalogowaniu hasłem może skonfigurować je ponownie.', 'admin.mfa.resetConfirm': 'Wyłączyć uwierzytelnianie dwuskładnikowe dla {name}?', 'admin.mfa.resetDone': 'Uwierzytelnianie dwuskładnikowe zostało zresetowane', + 'admin.sso.unlink': 'Usuń powiązanie SSO', + 'admin.sso.unlinkHint': + 'Zapomina, które logowanie u dostawcy SSO należy do tego konta, na przykład po zmianie identyfikatorów użytkowników przez dostawcę. Następne logowanie przez SSO z potwierdzonym adresem e-mail ponownie powiąże konto.', + 'admin.sso.unlinkConfirm': 'Usunąć powiązanie SSO dla {name}?', + 'admin.sso.unlinkDone': 'Powiązanie SSO zostało usunięte', 'admin.danger.title': 'Strefa zagrożenia', 'admin.rotateJwt.title': 'Zmień sekret JWT', 'admin.rotateJwt.hint': 'Generuje nowy sekret podpisu JWT. Wszystkie aktywne sesje zostaną natychmiast unieważnione.', diff --git a/shared/src/i18n/ru/admin.ts b/shared/src/i18n/ru/admin.ts index 6edcee7cd..c11d76061 100644 --- a/shared/src/i18n/ru/admin.ts +++ b/shared/src/i18n/ru/admin.ts @@ -1050,6 +1050,11 @@ const admin: TranslationStrings = { 'Отключает двухфакторную аутентификацию пользователя, например после потери телефона. Он выйдет из аккаунта везде и сможет настроить её заново после входа с паролем.', 'admin.mfa.resetConfirm': 'Отключить двухфакторную аутентификацию для {name}?', 'admin.mfa.resetDone': 'Двухфакторная аутентификация сброшена', + 'admin.sso.unlink': 'Удалить привязку SSO', + 'admin.sso.unlinkHint': + 'Забывает, какой вход у провайдера SSO принадлежит этому аккаунту, например после того как провайдер сменил идентификаторы пользователей. Следующий вход через SSO с подтверждённым адресом почты снова привяжет аккаунт.', + 'admin.sso.unlinkConfirm': 'Удалить привязку SSO для {name}?', + 'admin.sso.unlinkDone': 'Привязка SSO удалена', 'admin.danger.title': 'Опасная зона', 'admin.rotateJwt.title': 'Сменить секрет JWT', 'admin.rotateJwt.hint': 'Создаёт новый секрет подписи JWT. Все активные сеансы будут немедленно аннулированы.', diff --git a/shared/src/i18n/sk/admin.ts b/shared/src/i18n/sk/admin.ts index e43da8791..cbd539885 100644 --- a/shared/src/i18n/sk/admin.ts +++ b/shared/src/i18n/sk/admin.ts @@ -1022,6 +1022,11 @@ const admin: TranslationStrings = { 'Vypne pre tohto používateľa dvojfaktorové overenie, napríklad po strate telefónu. Bude odhlásený všade a po prihlásení heslom si ho môže nastaviť znova.', 'admin.mfa.resetConfirm': 'Vypnúť dvojfaktorové overenie pre používateľa {name}?', 'admin.mfa.resetDone': 'Dvojfaktorové overenie bolo resetované', + 'admin.sso.unlink': 'Odstrániť prepojenie SSO', + 'admin.sso.unlinkHint': + 'Zabudne, ktoré prihlásenie u poskytovateľa SSO patrí k tomuto účtu, napríklad po tom, ako poskytovateľ zmenil ID používateľov. Ďalšie prihlásenie cez SSO s potvrdenou e-mailovou adresou účet znova prepojí.', + 'admin.sso.unlinkConfirm': 'Odstrániť prepojenie SSO používateľa {name}?', + 'admin.sso.unlinkDone': 'Prepojenie SSO bolo odstránené', 'admin.danger.title': 'Nebezpečná zóna', 'admin.rotateJwt.title': 'Obnoviť tajný kľúč JWT', 'admin.rotateJwt.hint': 'Vygeneruje nový podpisový kľúč JWT. Všetky aktívne relácie budú okamžite zneplatnené.', diff --git a/shared/src/i18n/sv/admin.ts b/shared/src/i18n/sv/admin.ts index dda5932e3..5f2fdc7d7 100644 --- a/shared/src/i18n/sv/admin.ts +++ b/shared/src/i18n/sv/admin.ts @@ -1007,6 +1007,11 @@ const admin: TranslationStrings = { 'Stänger av tvåfaktorsautentiseringen för den här användaren, till exempel efter en förlorad telefon. Användaren loggas ut överallt och kan ställa in den igen efter inloggning med lösenordet.', 'admin.mfa.resetConfirm': 'Stänga av tvåfaktorsautentiseringen för {name}?', 'admin.mfa.resetDone': 'Tvåfaktorsautentiseringen har återställts', + 'admin.sso.unlink': 'Ta bort SSO-koppling', + 'admin.sso.unlinkHint': + 'Glömmer vilken inloggning hos din SSO-leverantör som hör till det här kontot, till exempel efter att leverantören bytt användar-ID:n. Nästa SSO-inloggning med en bekräftad e-postadress kopplar kontot igen.', + 'admin.sso.unlinkConfirm': 'Ta bort SSO-kopplingen för {name}?', + 'admin.sso.unlinkDone': 'SSO-kopplingen har tagits bort', 'admin.danger.title': 'Farozon', 'admin.rotateJwt.title': 'Rotera JWT-hemlighet', 'admin.rotateJwt.hint': 'Skapar en ny JWT-signeringshemlighet. Alla aktiva sessioner ogiltigförklaras omedelbart.', diff --git a/shared/src/i18n/th/admin.ts b/shared/src/i18n/th/admin.ts index 56f7389c6..93f3f43d7 100644 --- a/shared/src/i18n/th/admin.ts +++ b/shared/src/i18n/th/admin.ts @@ -953,6 +953,11 @@ const admin: TranslationStrings = { 'ปิดการรับรองความถูกต้องด้วยสองปัจจัยของผู้ใช้รายนี้ เช่น หลังโทรศัพท์หาย ผู้ใช้จะถูกออกจากระบบทุกที่ และตั้งค่าใหม่ได้หลังเข้าสู่ระบบด้วยรหัสผ่าน', 'admin.mfa.resetConfirm': 'ปิดการรับรองความถูกต้องด้วยสองปัจจัยของ {name} หรือไม่', 'admin.mfa.resetDone': 'รีเซ็ตการรับรองความถูกต้องด้วยสองปัจจัยแล้ว', + 'admin.sso.unlink': 'ยกเลิกการเชื่อมโยง SSO', + 'admin.sso.unlinkHint': + 'ล้างข้อมูลว่าการเข้าสู่ระบบใดที่ผู้ให้บริการ SSO เป็นของบัญชีนี้ เช่น หลังผู้ให้บริการเปลี่ยนรหัสผู้ใช้ การเข้าสู่ระบบ SSO ครั้งถัดไปด้วยอีเมลที่ยืนยันแล้วจะเชื่อมโยงบัญชีอีกครั้ง', + 'admin.sso.unlinkConfirm': 'ยกเลิกการเชื่อมโยง SSO ของ {name} หรือไม่', + 'admin.sso.unlinkDone': 'ยกเลิกการเชื่อมโยง SSO แล้ว', 'admin.danger.title': 'โซนอันตราย', 'admin.rotateJwt.title': 'หมุนเวียนรหัสลับ JWT', 'admin.rotateJwt.hint': 'สร้างรหัสลับสำหรับลงนาม JWT ใหม่ เซสชันที่ใช้งานอยู่ทั้งหมดจะถูกยกเลิกทันที', diff --git a/shared/src/i18n/tr/admin.ts b/shared/src/i18n/tr/admin.ts index 2f8132997..4e5a24e89 100644 --- a/shared/src/i18n/tr/admin.ts +++ b/shared/src/i18n/tr/admin.ts @@ -1013,6 +1013,11 @@ const admin: TranslationStrings = { 'Bu kullanıcının iki faktörlü kimlik doğrulamasını kapatır, örneğin telefon kaybedildiğinde. Kullanıcının tüm oturumları kapatılır ve parolasıyla giriş yaptıktan sonra yeniden kurabilir.', 'admin.mfa.resetConfirm': '{name} için iki faktörlü kimlik doğrulama kapatılsın mı?', 'admin.mfa.resetDone': 'İki faktörlü kimlik doğrulama sıfırlandı', + 'admin.sso.unlink': 'SSO bağlantısını kaldır', + 'admin.sso.unlinkHint': + 'SSO sağlayıcınızdaki hangi girişin bu hesaba ait olduğunu unutur, örneğin sağlayıcı kullanıcı kimliklerini değiştirdiğinde. Onaylanmış bir e-posta adresiyle yapılan sonraki SSO girişi hesabı yeniden bağlar.', + 'admin.sso.unlinkConfirm': '{name} için SSO bağlantısı kaldırılsın mı?', + 'admin.sso.unlinkDone': 'SSO bağlantısı kaldırıldı', 'admin.danger.title': 'Tehlikeli Bölge', 'admin.rotateJwt.title': 'JWT sırrını yenile', 'admin.rotateJwt.hint': 'Yeni bir JWT imzalama sırrı oluşturur. Tüm etkin oturumlar hemen geçersiz kılınır.', diff --git a/shared/src/i18n/uk/admin.ts b/shared/src/i18n/uk/admin.ts index 6e43f4bb3..e182bc165 100644 --- a/shared/src/i18n/uk/admin.ts +++ b/shared/src/i18n/uk/admin.ts @@ -1046,6 +1046,11 @@ const admin: TranslationStrings = { 'Вимикає двофакторну автентифікацію користувача, наприклад після втрати телефона. Його буде виведено з акаунта всюди, а після входу з паролем він зможе налаштувати її знову.', 'admin.mfa.resetConfirm': 'Вимкнути двофакторну автентифікацію для {name}?', 'admin.mfa.resetDone': 'Двофакторну автентифікацію скинуто', + 'admin.sso.unlink': 'Видалити прив’язку SSO', + 'admin.sso.unlinkHint': + 'Забуває, який вхід у провайдера SSO належить цьому акаунту, наприклад після того як провайдер змінив ідентифікатори користувачів. Наступний вхід через SSO з підтвердженою адресою пошти знову прив’яже акаунт.', + 'admin.sso.unlinkConfirm': 'Видалити прив’язку SSO для {name}?', + 'admin.sso.unlinkDone': 'Прив’язку SSO видалено', 'admin.danger.title': 'Небезпечна зона', 'admin.rotateJwt.title': 'Змінити секрет JWT', 'admin.rotateJwt.hint': 'Створює новий секрет підпису JWT. Усі активні сеанси буде негайно анульовано.', diff --git a/shared/src/i18n/vi/admin.ts b/shared/src/i18n/vi/admin.ts index 02646db9f..a4671d01b 100644 --- a/shared/src/i18n/vi/admin.ts +++ b/shared/src/i18n/vi/admin.ts @@ -991,6 +991,11 @@ const admin: TranslationStrings = { 'Tắt xác thực hai yếu tố của người dùng này, ví dụ khi mất điện thoại. Họ bị đăng xuất ở mọi nơi và có thể thiết lập lại sau khi đăng nhập bằng mật khẩu.', 'admin.mfa.resetConfirm': 'Tắt xác thực hai yếu tố của {name}?', 'admin.mfa.resetDone': 'Đã đặt lại xác thực hai yếu tố', + 'admin.sso.unlink': 'Gỡ liên kết SSO', + 'admin.sso.unlinkHint': + 'Quên lần đăng nhập nào ở nhà cung cấp SSO thuộc về tài khoản này, ví dụ sau khi nhà cung cấp thay đổi ID người dùng. Lần đăng nhập SSO tiếp theo bằng địa chỉ email đã xác nhận sẽ liên kết lại tài khoản.', + 'admin.sso.unlinkConfirm': 'Gỡ liên kết SSO của {name}?', + 'admin.sso.unlinkDone': 'Đã gỡ liên kết SSO', 'admin.danger.title': 'Vùng nguy hiểm', 'admin.rotateJwt.title': 'Xoay vòng khóa bí mật JWT', 'admin.rotateJwt.hint': 'Tạo khóa bí mật ký JWT mới. Tất cả phiên đang hoạt động sẽ bị vô hiệu hóa ngay lập tức.', diff --git a/shared/src/i18n/zh-TW/admin.ts b/shared/src/i18n/zh-TW/admin.ts index a7274d72f..2408cc919 100644 --- a/shared/src/i18n/zh-TW/admin.ts +++ b/shared/src/i18n/zh-TW/admin.ts @@ -907,6 +907,11 @@ const admin: TranslationStrings = { '為該使用者關閉雙因素驗證,例如手機遺失後。該使用者會在所有裝置上登出,使用密碼登入後可重新設定。', 'admin.mfa.resetConfirm': '要關閉 {name} 的雙因素驗證嗎?', 'admin.mfa.resetDone': '雙因素驗證已重設', + 'admin.sso.unlink': '解除 SSO 連結', + 'admin.sso.unlinkHint': + '忘記 SSO 提供者中哪個登入屬於該帳號,例如提供者變更了使用者 ID 之後。下一次使用已驗證電子郵件地址的 SSO 登入會重新連結該帳號。', + 'admin.sso.unlinkConfirm': '要解除 {name} 的 SSO 連結嗎?', + 'admin.sso.unlinkDone': 'SSO 連結已解除', 'admin.danger.title': '危險區域', 'admin.rotateJwt.title': '輪替 JWT 密鑰', 'admin.rotateJwt.hint': '產生新的 JWT 簽署密鑰。所有進行中的工作階段將立即失效。', diff --git a/shared/src/i18n/zh/admin.ts b/shared/src/i18n/zh/admin.ts index ee22251ff..f7e88a543 100644 --- a/shared/src/i18n/zh/admin.ts +++ b/shared/src/i18n/zh/admin.ts @@ -906,6 +906,11 @@ const admin: TranslationStrings = { '为该用户关闭双因素验证,例如手机丢失后。该用户会在所有设备上退出登录,使用密码登录后可重新设置。', 'admin.mfa.resetConfirm': '要关闭 {name} 的双因素验证吗?', 'admin.mfa.resetDone': '双因素验证已重置', + 'admin.sso.unlink': '解除 SSO 关联', + 'admin.sso.unlinkHint': + '忘记 SSO 提供商中哪个登录属于该账户,例如提供商更改了用户 ID 之后。下一次使用已验证邮箱地址的 SSO 登录会重新关联该账户。', + 'admin.sso.unlinkConfirm': '要解除 {name} 的 SSO 关联吗?', + 'admin.sso.unlinkDone': 'SSO 关联已解除', 'admin.danger.title': '危险区域', 'admin.rotateJwt.title': '轮换 JWT 密钥', 'admin.rotateJwt.hint': '生成新的 JWT 签名密钥。所有活动会话将立即失效。', diff --git a/wiki/Admin-Users-and-Invites.md b/wiki/Admin-Users-and-Invites.md index 1e986a200..3569889d2 100644 --- a/wiki/Admin-Users-and-Invites.md +++ b/wiki/Admin-Users-and-Invites.md @@ -42,6 +42,8 @@ Below the fields sits **Reset passkeys**, which removes every passkey that user For every other account the dialog also has **Reset 2FA**, for example after a lost phone. After you confirm (**Turn off two-factor authentication for {name}?**) it switches 2FA off for that user and signs them out everywhere; they can set it up again after logging in with their password. It is not offered for your own account, and it is recorded in the [Audit-Log](Audit-Log). See [Two-Factor-Authentication](Two-Factor-Authentication). +While SSO is set up, the dialog of every other account also has **Remove SSO link**. It forgets which sign-in at the provider belongs to the account, for when the provider changed the user's subject and their login now ends with *"This sign-in could not be linked to the existing account with this email"*. After you confirm, the next SSO sign-in with a verified email links the account again. It takes effect immediately, is not offered for your own account and is recorded in the [Audit-Log](Audit-Log). See [OIDC-SSO](OIDC-SSO#how-an-existing-account-gets-linked). + ### Delete a user Click the trash icon and confirm in the **Delete user** dialog (**Delete user "name"? All trips will be permanently deleted.**). Deletion is permanent. The user's account is removed from the database along with their data, including the trips they own (cascade behavior is enforced at the database level). diff --git a/wiki/Audit-Log.md b/wiki/Audit-Log.md index 05b71b10b..c6c507951 100644 --- a/wiki/Audit-Log.md +++ b/wiki/Audit-Log.md @@ -80,6 +80,7 @@ Logging out writes no row, and neither do the sessions a password change, a rese | `admin.user_delete` | User deleted by admin | | `admin.user_mfa_reset` | A user's MFA reset by admin | | `admin.user_passkeys_reset` | All of a user's passkeys removed by admin | +| `admin.user_sso_unlink` | A user's SSO link removed by admin | | `admin.invite_create` | Invite link created | | `admin.invite_delete` | Invite link deleted | | `admin.permissions_update` | Instance permissions updated | diff --git a/wiki/OIDC-SSO.md b/wiki/OIDC-SSO.md index 898cf794d..57ff20e40 100644 --- a/wiki/OIDC-SSO.md +++ b/wiki/OIDC-SSO.md @@ -106,7 +106,11 @@ An SSO login that finds no account by its subject falls back to the email addres - **The address did not come from the account itself.** An address typed into the signup form or changed under **Settings** proves nothing about who owns it, so a login with that address ends with `email_link_blocked` and the user sees a message to ask the admin. Addresses an admin entered (an account created in **Admin → Users**, or an email changed there), the first account of the instance and accounts the provider created are linked as before. - **The account is not linked to another subject of the same provider.** The same issuer with another `sub` is another person at that provider, so this also ends with `email_link_blocked`. Pointing the instance at a different provider (a new issuer) still moves an account over on its first verified login there. -To let a blocked account link, confirm its address as admin: in **Admin → Users**, edit the user, change the email to any other address and save, then set it back to the real address and save again. Changing only upper and lower case does not count. If the account is not needed, deleting it lets the SSO login create a fresh one instead. The server log names the account a login was refused for (`[OIDC] Refused to link a sign-in to user by email`), and every email change a user makes in their own settings appears in the audit log as `user.email_change`. +The server log says which of the two blocks a login hit. `[OIDC] Refused to link a sign-in to user by email: the account set that address itself` is the second one, `… it is linked to another subject of this provider` the third. + +When the account is linked to another subject of the same provider and you know it is the same person (the provider was migrated or rebuilt, or it changed how it forms the `sub`), remove the stored link: in **Admin → Users**, edit the user and use **Remove SSO link**. The next SSO sign-in then links the account like a first one, under the rules above. Changing the email does not help in this case. The action is not offered for your own account and is recorded in the [Audit-Log](Audit-Log) as `admin.user_sso_unlink`. Only remove a link when you are sure: if the address now belongs to somebody else at the provider, that person receives the account. + +When the account set its address itself, confirm the address as admin: in **Admin → Users**, edit the user, change the email to any other address and save, then set it back to the real address and save again. Changing only upper and lower case does not count. If the account is not needed, deleting it lets the SSO login create a fresh one instead. The server log names the account a login was refused for (`[OIDC] Refused to link a sign-in to user by email`) and the reason, and every email change a user makes in their own settings appears in the audit log as `user.email_change`. Accounts that existed before this check keep linking as they did, except those whose address provably changed since they registered without an admin editing it.