Two-Factor Authentication (2FA) (#10658)

* Two-Factor Authentication (2FA)

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>

* Fix test

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>

* Fix test

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>

* Fix tests

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>

* More test fixes

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>

* Fix tests

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>

---------

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>
This commit is contained in:
Denis Bykhov
2026-03-21 14:36:32 +07:00
committed by GitHub
parent f44d88e134
commit 4ef554b9d2
46 changed files with 916 additions and 90 deletions
+145
View File
@@ -26436,6 +26436,9 @@ importers:
'@hcengineering/workbench-resources':
specifier: workspace:^0.7.0
version: link:../workbench-resources
qrcode:
specifier: ^1.5.4
version: 1.5.4
svelte:
specifier: ^4.2.20
version: 4.2.20
@@ -26446,6 +26449,9 @@ importers:
'@types/jest':
specifier: ^29.5.5
version: 29.5.14
'@types/qrcode':
specifier: ^1.5.5
version: 1.5.6
'@typescript-eslint/eslint-plugin':
specifier: ^6.21.0
version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)
@@ -35776,6 +35782,9 @@ importers:
otp-generator:
specifier: ^4.0.1
version: 4.0.1
otplib:
specifier: ^12.0.1
version: 12.0.1
postgres:
specifier: ^3.4.7
version: 3.4.7
@@ -43857,6 +43866,24 @@ packages:
peerDependencies:
'@opentelemetry/api': ^1.1.0
'@otplib/core@12.0.1':
resolution: {integrity: sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==}
'@otplib/plugin-crypto@12.0.1':
resolution: {integrity: sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==}
deprecated: Please upgrade to v13 of otplib. Refer to otplib docs for migration paths
'@otplib/plugin-thirty-two@12.0.1':
resolution: {integrity: sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==}
deprecated: Please upgrade to v13 of otplib. Refer to otplib docs for migration paths
'@otplib/preset-default@12.0.1':
resolution: {integrity: sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==}
deprecated: Please upgrade to v13 of otplib. Refer to otplib docs for migration paths
'@otplib/preset-v11@12.0.1':
resolution: {integrity: sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==}
'@paralleldrive/cuid2@2.3.1':
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==}
@@ -44971,6 +44998,9 @@ packages:
'@types/pug@2.0.10':
resolution: {integrity: sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==}
'@types/qrcode@1.5.6':
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
'@types/qs@6.9.18':
resolution: {integrity: sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==}
@@ -45973,6 +46003,9 @@ packages:
cliui@4.1.0:
resolution: {integrity: sha512-4FG+RSG9DL7uEwRUZXZn3SS34DiDPfzP0VOiEwtUWlE+AR2EIg+hSyvrIgUUfhdgR/UkAeW2QHgeP+hWrXs7jQ==}
cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
@@ -46700,6 +46733,9 @@ packages:
resolution: {integrity: sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==}
engines: {node: '>=0.3.1'}
dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
dingbat-to-unicode@1.0.1:
resolution: {integrity: sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==}
@@ -49602,6 +49638,9 @@ packages:
resolution: {integrity: sha512-2TJ52vUftA0+J3eque4wwVtpaL4/NdIXDL0gFWFJFVUAZwAN7+9tltMhL7GCNYaHJtuONoier8Hayyj4HLbSag==}
engines: {node: '>=14.10.0'}
otplib@12.0.1:
resolution: {integrity: sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==}
own-keys@1.0.1:
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
engines: {node: '>= 0.4'}
@@ -49917,6 +49956,10 @@ packages:
resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==}
engines: {node: '>=4.0.0'}
pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
points-on-curve@0.2.0:
resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==}
@@ -50214,6 +50257,11 @@ packages:
pure-rand@6.1.0:
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
hasBin: true
qs@6.11.2:
resolution: {integrity: sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==}
engines: {node: '>=0.6'}
@@ -50355,6 +50403,9 @@ packages:
require-main-filename@1.0.1:
resolution: {integrity: sha512-IqSUtOVP4ksd1C/ej5zeEh/BIP2ajqpn8c5x+q99gvcIG/Qf0cud5raVnE/Dwd0ua9TXYDoDc0RE5hBSdz22Ug==}
require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
requires-port@1.0.0:
resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
@@ -51172,6 +51223,10 @@ packages:
thenify@3.3.1:
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
thirty-two@1.0.2:
resolution: {integrity: sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==}
engines: {node: '>=0.2.6'}
through2@4.0.2:
resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==}
@@ -51819,6 +51874,10 @@ packages:
resolution: {integrity: sha512-vAaEaDM946gbNpH5pLVNR+vX2ht6n0Bt3GXwVB1AuAqZosOvHNF3P7wDnh8KLkSqgUh0uh77le7Owgoz+Z9XBw==}
engines: {node: '>=0.10.0'}
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@@ -51948,6 +52007,10 @@ packages:
yargs-parser@11.1.1:
resolution: {integrity: sha512-C6kB/WJDiaxONLJQnF8ccx9SEeoTTLek8RVbaOIsrAUS8VrBEXfmeSnCZxygc+XC2sNMBIwOOnfcxiynjHsVSQ==}
yargs-parser@18.1.3:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
@@ -51955,6 +52018,10 @@ packages:
yargs@12.0.5:
resolution: {integrity: sha512-Lhz8TLaYnxq/2ObqHDql8dX8CJi97oHxrjUcYtzKbbykPtVW9WB+poxI+NM2UIzsMgNCZTIf0AQwsjK5yMAqZw==}
yargs@15.4.1:
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
engines: {node: '>=8'}
yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
@@ -54795,6 +54862,29 @@ snapshots:
'@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
'@otplib/core@12.0.1': {}
'@otplib/plugin-crypto@12.0.1':
dependencies:
'@otplib/core': 12.0.1
'@otplib/plugin-thirty-two@12.0.1':
dependencies:
'@otplib/core': 12.0.1
thirty-two: 1.0.2
'@otplib/preset-default@12.0.1':
dependencies:
'@otplib/core': 12.0.1
'@otplib/plugin-crypto': 12.0.1
'@otplib/plugin-thirty-two': 12.0.1
'@otplib/preset-v11@12.0.1':
dependencies:
'@otplib/core': 12.0.1
'@otplib/plugin-crypto': 12.0.1
'@otplib/plugin-thirty-two': 12.0.1
'@paralleldrive/cuid2@2.3.1':
dependencies:
'@noble/hashes': 1.8.0
@@ -56120,6 +56210,10 @@ snapshots:
'@types/pug@2.0.10': {}
'@types/qrcode@1.5.6':
dependencies:
'@types/node': 22.19.0
'@types/qs@6.9.18': {}
'@types/querystringify@2.0.2': {}
@@ -57322,6 +57416,12 @@ snapshots:
strip-ansi: 4.0.0
wrap-ansi: 2.1.0
cliui@6.0.0:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
cliui@8.0.1:
dependencies:
string-width: 4.2.3
@@ -58062,6 +58162,8 @@ snapshots:
diff@7.0.0: {}
dijkstrajs@1.0.3: {}
dingbat-to-unicode@1.0.1: {}
dir-compare@4.2.0:
@@ -61660,6 +61762,12 @@ snapshots:
otp-generator@4.0.1: {}
otplib@12.0.1:
dependencies:
'@otplib/core': 12.0.1
'@otplib/preset-default': 12.0.1
'@otplib/preset-v11': 12.0.1
own-keys@1.0.1:
dependencies:
get-intrinsic: 1.3.0
@@ -61960,6 +62068,8 @@ snapshots:
pngjs@3.4.0: {}
pngjs@5.0.0: {}
points-on-curve@0.2.0: {}
points-on-path@0.2.1:
@@ -62319,6 +62429,12 @@ snapshots:
pure-rand@6.1.0: {}
qrcode@1.5.4:
dependencies:
dijkstrajs: 1.0.3
pngjs: 5.0.0
yargs: 15.4.1
qs@6.11.2:
dependencies:
side-channel: 1.1.0
@@ -62478,6 +62594,8 @@ snapshots:
require-main-filename@1.0.1: {}
require-main-filename@2.0.0: {}
requires-port@1.0.0: {}
resedit@1.7.2:
@@ -63505,6 +63623,8 @@ snapshots:
dependencies:
any-promise: 1.3.0
thirty-two@1.0.2: {}
through2@4.0.2:
dependencies:
readable-stream: 3.6.2
@@ -64357,6 +64477,12 @@ snapshots:
string-width: 1.0.2
strip-ansi: 3.0.1
wrap-ansi@6.2.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
@@ -64463,6 +64589,11 @@ snapshots:
camelcase: 5.3.1
decamelize: 1.2.0
yargs-parser@18.1.3:
dependencies:
camelcase: 5.3.1
decamelize: 1.2.0
yargs-parser@21.1.1: {}
yargs@12.0.5:
@@ -64480,6 +64611,20 @@ snapshots:
y18n: 4.0.3
yargs-parser: 11.1.1
yargs@15.4.1:
dependencies:
cliui: 6.0.0
decamelize: 1.2.0
find-up: 4.1.0
get-caller-file: 2.0.5
require-directory: 2.1.1
require-main-filename: 2.0.0
set-blocking: 2.0.0
string-width: 4.2.3
which-module: 2.0.1
y18n: 4.0.3
yargs-parser: 18.1.3
yargs@17.7.2:
dependencies:
cliui: 8.0.1
@@ -254,8 +254,14 @@ export interface AccountClient {
getWorkspacePermissions: (params: { accountId: AccountUuid, permission: string }) => Promise<WorkspaceUuid[]>
getWorkspaceUsersWithPermission: (params: { permission: string }) => Promise<AccountUuid[]>
verify2fa: (code: string) => Promise<LoginInfo>
setCookie: () => Promise<void>
deleteCookie: () => Promise<void>
generate2faSecret: () => Promise<{ secret: string, url: string }>
enable2fa: (secret: string, code: string) => Promise<void>
disable2fa: (code: string) => Promise<void>
}
/** @public */
@@ -1335,6 +1341,42 @@ class AccountClientImpl implements AccountClient {
params
})
}
async verify2fa (code: string): Promise<LoginInfo> {
const request = {
method: 'verify2fa' as const,
params: { code }
}
return await this.rpc(request)
}
async generate2faSecret (): Promise<{ secret: string, url: string }> {
const request = {
method: 'generate2faSecret' as const,
params: {}
}
return await this.rpc(request)
}
async enable2fa (secret: string, code: string): Promise<void> {
const request = {
method: 'enable2fa' as const,
params: { secret, code }
}
await this.rpc(request)
}
async disable2fa (code: string): Promise<void> {
const request = {
method: 'disable2fa' as const,
params: { code }
}
await this.rpc(request)
}
}
function withRetry<T, F extends (...args: any[]) => Promise<T>> (
@@ -19,6 +19,8 @@ export interface LoginInfo {
name?: string
socialId?: PersonId
token?: string
tfaRequired?: boolean
extra?: Record<string, string>
}
export interface EndpointInfo {
@@ -960,6 +960,7 @@ export interface SocialId {
export interface AccountInfo {
timezone?: string
locale?: string
tfaEnabled?: boolean
}
export type SocialKey = Pick<SocialId, 'type' | 'value'>
+16
View File
@@ -209,6 +209,22 @@ export function createModel (builder: Builder): void {
},
setting.ids.Password
)
builder.createDoc(
setting.class.SettingsCategory,
core.space.Model,
{
name: 'security',
label: setting.string.Security,
icon: setting.icon.Password,
component: setting.component.TwoFactorSettings,
group: 'settings-account',
role: AccountRole.Guest,
order: 1200
},
setting.ids.Security
)
builder.createDoc(
setting.class.SettingsCategory,
core.space.Model,
+9 -5
View File
@@ -93,10 +93,14 @@
"SignUpAndJoin": "Registrovat se a připojit",
"CreateNewAccount": "Vytvořit nový účet",
"SignedInAs": "Přihlášen jako {name}",
"SetPassword": "Set a password",
"SSOPasswordDescription": "Your account uses external login. Set up a password via email confirmation.",
"SendSetupLink": "Send setup link",
"SSOPasswordEmailSent": "Check your email for a link to set your password.",
"SSONoEmailLinked": "No email address is linked to your account. Add one in Account Settings → Manage Identities first."
"TwoFactorAuth": "Dvoufaktorové ověření",
"EnterTwoFactorCode": "Zadejte dvoufaktorový ověřovací kód",
"TwoFactorCode": "Dvoufaktorový ověřovací kód",
"Verify": "Ověřit",
"SetPassword": "Nastavit heslo",
"SSOPasswordDescription": "Váš účet používá externí přihlášení. Nastavte si heslo prostřednictvím e-mailové potvrzení.",
"SendSetupLink": "Odeslat odkaz na nastavení",
"SSOPasswordEmailSent": "Zkontrolujte svůj e-mail, zda neobsahuje odkaz pro nastavení hesla.",
"SSONoEmailLinked": "K vašemu účtu není přidružena žádná e-mailová adresa. Nejprve ji přidejte v Nastavení účtu → Spravovat identity."
}
}
+9 -5
View File
@@ -93,10 +93,14 @@
"SignUpAndJoin": "Registrieren & beitreten",
"CreateNewAccount": "Neues Konto erstellen",
"SignedInAs": "Angemeldet als {name}",
"SetPassword": "Set a password",
"SSOPasswordDescription": "Your account uses external login. Set up a password via email confirmation.",
"SendSetupLink": "Send setup link",
"SSOPasswordEmailSent": "Check your email for a link to set your password.",
"SSONoEmailLinked": "No email address is linked to your account. Add one in Account Settings → Manage Identities first."
"TwoFactorAuth": "Zweistufige Authentifizierung",
"EnterTwoFactorCode": "Geben Sie den zweistufigen Authentifizierungscode ein",
"TwoFactorCode": "Zweistufiger Authentifizierungscode",
"Verify": "Überprüfen",
"SetPassword": "Passwort festlegen",
"SSOPasswordDescription": "Ihr Konto verwendet externe Anmeldung. Richten Sie ein Passwort über die E-Mail-Bestätigung ein.",
"SendSetupLink": "Setup-Link senden",
"SSOPasswordEmailSent": "Überprüfen Sie Ihre E-Mail auf einen Link zum Festlegen des Passworts.",
"SSONoEmailLinked": "Keine E-Mail-Adresse mit Ihrem Konto verknüpft. Fügen Sie eine unter Kontoeinstellungen → Identitäten verwalten hinzu."
}
}
+5 -1
View File
@@ -96,6 +96,10 @@
"LogInAndJoin": "Log In & Join",
"SignUpAndJoin": "Sign Up & Join",
"CreateNewAccount": "Create new account",
"SignedInAs": "Signed in as {name}"
"SignedInAs": "Signed in as {name}",
"TwoFactorAuth": "Two-factor authentication",
"EnterTwoFactorCode": "Enter two-factor authentication code",
"TwoFactorCode": "Two-factor authentication code",
"Verify": "Verify"
}
}
+9 -5
View File
@@ -92,10 +92,14 @@
"SignUpAndJoin": "Registrarse y unirse",
"CreateNewAccount": "Crear nueva cuenta",
"SignedInAs": "Conectado como {name}",
"SetPassword": "Set a password",
"SSOPasswordDescription": "Your account uses external login. Set up a password via email confirmation.",
"SendSetupLink": "Send setup link",
"SSOPasswordEmailSent": "Check your email for a link to set your password.",
"SSONoEmailLinked": "No email address is linked to your account. Add one in Account Settings → Manage Identities first."
"TwoFactorAuth": "Autenticación de dos factores",
"EnterTwoFactorCode": "Introduce el código de autenticación de dos factores",
"TwoFactorCode": "Código de autenticación de dos factores",
"Verify": "Verificar",
"SetPassword": "Establecer una contraseña",
"SSOPasswordDescription": "Tu cuenta utiliza inicio de sesión externo. Configura una contraseña mediante confirmación por correo electrónico.",
"SendSetupLink": "Enviar enlace de configuración",
"SSOPasswordEmailSent": "Revisa tu correo electrónico para encontrar un enlace para configurar la contraseña.",
"SSONoEmailLinked": "No hay dirección de correo electrónico vinculada a tu cuenta. Añade una en Configuración de la cuenta → Gestionar identidades primero."
}
}
+9 -5
View File
@@ -92,10 +92,14 @@
"SignUpAndJoin": "S'inscrire et rejoindre",
"CreateNewAccount": "Créer un nouveau compte",
"SignedInAs": "Connecté en tant que {name}",
"SetPassword": "Set a password",
"SSOPasswordDescription": "Your account uses external login. Set up a password via email confirmation.",
"SendSetupLink": "Send setup link",
"SSOPasswordEmailSent": "Check your email for a link to set your password.",
"SSONoEmailLinked": "No email address is linked to your account. Add one in Account Settings → Manage Identities first."
"TwoFactorAuth": "Authentification à deux facteurs",
"EnterTwoFactorCode": "Entrez le code d'authentification à deux facteurs",
"TwoFactorCode": "Code d'authentification à deux facteurs",
"Verify": "Vérifier",
"SetPassword": "Définir un mot de passe",
"SSOPasswordDescription": "Votre compte utilise une connexion externe. Définissez un mot de passe via confirmation par e-mail.",
"SendSetupLink": "Envoyer le lien de configuration",
"SSOPasswordEmailSent": "Vérifiez votre e-mail pour trouver un lien de configuration de mot de passe.",
"SSONoEmailLinked": "Aucune adresse e-mail n'est liée à votre compte. Ajoutez-en une dans Paramètres du compte → Gérer les identités d'abord."
}
}
+9 -5
View File
@@ -92,10 +92,14 @@
"SignUpAndJoin": "Iscriviti e partecipa",
"CreateNewAccount": "Crea nuovo account",
"SignedInAs": "Accesso effettuato come {name}",
"SetPassword": "Set a password",
"SSOPasswordDescription": "Your account uses external login. Set up a password via email confirmation.",
"SendSetupLink": "Send setup link",
"SSOPasswordEmailSent": "Check your email for a link to set your password.",
"SSONoEmailLinked": "No email address is linked to your account. Add one in Account Settings → Manage Identities first."
"TwoFactorAuth": "Autenticazione a due fattori",
"EnterTwoFactorCode": "Inserisci il codice di autenticazione a due fattori",
"TwoFactorCode": "Codice di autenticazione a due fattori",
"Verify": "Verifica",
"SetPassword": "Imposta una password",
"SSOPasswordDescription": "Il tuo account utilizza l'accesso esterno. Imposta una password tramite conferma via email.",
"SendSetupLink": "Invia link di configurazione",
"SSOPasswordEmailSent": "Controlla la tua email per trovare un link per impostare la password.",
"SSONoEmailLinked": "Nessun indirizzo email è collegato al tuo account. Aggiungine uno in Impostazioni account → Gestisci identità prima."
}
}
+9 -5
View File
@@ -92,10 +92,14 @@
"SignUpAndJoin": "サインアップして参加",
"CreateNewAccount": "新規アカウントを作成",
"SignedInAs": "{name} としてサインイン中",
"SetPassword": "Set a password",
"SSOPasswordDescription": "Your account uses external login. Set up a password via email confirmation.",
"SendSetupLink": "Send setup link",
"SSOPasswordEmailSent": "Check your email for a link to set your password.",
"SSONoEmailLinked": "No email address is linked to your account. Add one in Account Settings → Manage Identities first."
"TwoFactorAuth": "2段階認証",
"EnterTwoFactorCode": "2段階認証コードを入力",
"TwoFactorCode": "2段階認証コード",
"Verify": "確認",
"SetPassword": "パスワードを設定",
"SSOPasswordDescription": "アカウントは外部ログインを使用しています。メール確認でパスワードを設定してください。",
"SendSetupLink": "セットアップリンクを送信",
"SSOPasswordEmailSent": "パスワードを設定するためのリンクが記載されたメールを確認してください。",
"SSONoEmailLinked": "アカウントにメールアドレスがリンクされていません。まず、アカウント設定 → IDの管理 で追加してください。"
}
}
+9 -5
View File
@@ -92,10 +92,14 @@
"SignUpAndJoin": "Cadastrar e participar",
"CreateNewAccount": "Criar nova conta",
"SignedInAs": "Conectado como {name}",
"SetPassword": "Set a password",
"SSOPasswordDescription": "Your account uses external login. Set up a password via email confirmation.",
"SendSetupLink": "Send setup link",
"SSOPasswordEmailSent": "Check your email for a link to set your password.",
"SSONoEmailLinked": "No email address is linked to your account. Add one in Account Settings → Manage Identities first."
"TwoFactorAuth": "Autenticação de dois fatores",
"EnterTwoFactorCode": "Digite o código de autenticação de dois fatores",
"TwoFactorCode": "Código de autenticação de dois fatores",
"Verify": "Verificar",
"SetPassword": "Definir senha",
"SSOPasswordDescription": "Sua conta usa login externo. Defina uma senha através da confirmação por e-mail.",
"SendSetupLink": "Enviar link de configuração",
"SSOPasswordEmailSent": "Verifique seu e-mail para encontrar o link de configuração da senha.",
"SSONoEmailLinked": "Nenhum endereço de e-mail está vinculado à sua conta. Adicione um em Configurações da Conta → Gerenciar Identidades primeiro."
}
}
+9 -5
View File
@@ -92,10 +92,14 @@
"SignUpAndJoin": "Registar e participar",
"CreateNewAccount": "Criar nova conta",
"SignedInAs": "Sessão iniciada como {name}",
"SetPassword": "Set a password",
"SSOPasswordDescription": "Your account uses external login. Set up a password via email confirmation.",
"SendSetupLink": "Send setup link",
"SSOPasswordEmailSent": "Check your email for a link to set your password.",
"SSONoEmailLinked": "No email address is linked to your account. Add one in Account Settings → Manage Identities first."
"TwoFactorAuth": "Autenticação de dois fatores",
"EnterTwoFactorCode": "Digite o código de autenticação de dois fatores",
"TwoFactorCode": "Código de autenticação de dois fatores",
"Verify": "Verificar",
"SetPassword": "Definir senha",
"SSOPasswordDescription": "Sua conta usa login externo. Defina uma senha através da confirmação por e-mail.",
"SendSetupLink": "Enviar link de configuração",
"SSOPasswordEmailSent": "Verifique seu e-mail para encontrar o link de configuração da senha.",
"SSONoEmailLinked": "Nenhum endereço de e-mail está vinculado à sua conta. Adicione um em Configurações da Conta → Gerenciar Identidades primeiro."
}
}
+9 -5
View File
@@ -92,10 +92,14 @@
"SignUpAndJoin": "Зарегистрироваться и присоединиться",
"CreateNewAccount": "Создать новый аккаунт",
"SignedInAs": "Вы вошли как {name}",
"SetPassword": "Set a password",
"SSOPasswordDescription": "Your account uses external login. Set up a password via email confirmation.",
"SendSetupLink": "Send setup link",
"SSOPasswordEmailSent": "Check your email for a link to set your password.",
"SSONoEmailLinked": "No email address is linked to your account. Add one in Account Settings → Manage Identities first."
"TwoFactorAuth": "Двухфакторная аутентификация",
"EnterTwoFactorCode": "Введите код двухфакторной аутентификации",
"TwoFactorCode": "Код двухфакторной аутентификации",
"Verify": "Проверить",
"SetPassword": "Установить пароль",
"SSOPasswordDescription": "Ваша учетная запись использует внешний вход. Установите пароль через подтверждение по электронной почте.",
"SendSetupLink": "Отправить ссылку для настройки",
"SSOPasswordEmailSent": "Проверьте свою электронную почту, чтобы найти ссылку для установки пароля.",
"SSONoEmailLinked": "К вашей учетной записи не привязан адрес электронной почты. Сначала добавьте его в Настройки аккаунта → Управление идентификаторами."
}
}
+9 -5
View File
@@ -92,10 +92,14 @@
"SignUpAndJoin": "Kayıt ol ve katıl",
"CreateNewAccount": "Yeni hesap oluştur",
"SignedInAs": "{name} olarak giriş yapıldı",
"SetPassword": "Set a password",
"SSOPasswordDescription": "Your account uses external login. Set up a password via email confirmation.",
"SendSetupLink": "Send setup link",
"SSOPasswordEmailSent": "Check your email for a link to set your password.",
"SSONoEmailLinked": "No email address is linked to your account. Add one in Account Settings → Manage Identities first."
"TwoFactorAuth": "İki faktörlü kimlik doğrulama",
"EnterTwoFactorCode": "İki faktörlü kimlik doğrulama kodunu girin",
"TwoFactorCode": "İki faktörlü kimlik doğrulama kodu",
"Verify": "Doğrula",
"SetPassword": "Şifreyi ayarla",
"SSOPasswordDescription": "Hesabınız harici girişi kullanıyor. E-posta onayı ile bir şifre belirleyin.",
"SendSetupLink": "Kurulum bağlantısı gönder",
"SSOPasswordEmailSent": "Şifrenizi belirlemek için bir bağlantı içeren e-postayı kontrol edin.",
"SSONoEmailLinked": "Hesabınıza bağlı bir e-posta adresi yok. Önce Hesap Ayarları → Kimlikleri Yönet bölümünden bir tane ekleyin."
}
}
+9 -5
View File
@@ -92,10 +92,14 @@
"SignUpAndJoin": "注册并加入",
"CreateNewAccount": "创建新账户",
"SignedInAs": "已以 {name} 身份登录",
"SetPassword": "Set a password",
"SSOPasswordDescription": "Your account uses external login. Set up a password via email confirmation.",
"SendSetupLink": "Send setup link",
"SSOPasswordEmailSent": "Check your email for a link to set your password.",
"SSONoEmailLinked": "No email address is linked to your account. Add one in Account Settings → Manage Identities first."
"TwoFactorAuth": "两步验证",
"EnterTwoFactorCode": "输入两步验证码",
"TwoFactorCode": "两步验证码",
"Verify": "验证",
"SetPassword": "设置密码",
"SSOPasswordDescription": "您的账户使用外部登录。请通过电子邮件确认设置密码。",
"SendSetupLink": "发送设置链接",
"SSOPasswordEmailSent": "请检查您的电子邮件,获取设置密码的链接。",
"SSONoEmailLinked": "您的账户没有关联的电子邮件地址。请先在账户设置 → 管理身份中添加。"
}
}
@@ -41,6 +41,7 @@
import PasswordRestore from './PasswordRestore.svelte'
import SelectWorkspace from './SelectWorkspace.svelte'
import SignupForm from './SignupForm.svelte'
import LoginTfaForm from './LoginTfaForm.svelte'
import LoginIcon from './icons/LoginIcon.svelte'
import { Pages, getAccount, pages } from '..'
import login from '../plugin'
@@ -60,6 +61,7 @@
const localLoginHidden = getMetadata(login.metadata.HideLocalLogin) ?? false
const useOTP = getMetadata(presentation.metadata.MailUrl) != null && getMetadata(presentation.metadata.MailUrl) !== ''
let navigateUrl: string | undefined
let tfaToken: string | undefined = undefined
onDestroy(location.subscribe(updatePageLoc))
@@ -79,7 +81,8 @@
'autoJoin',
'confirm',
'confirmationSend',
'auth'
'auth',
'tfa'
]
if (token === undefined ? !allowedUnauthPages.includes(page) : !pages.includes(page)) {
const account = fetchMetadataLocalStorage(login.metadata.LastAccount)
@@ -87,6 +90,7 @@
}
navigateUrl = loc.query?.navigateUrl ?? undefined
tfaToken = loc.query?.token ?? undefined
}
async function chooseToken (): Promise<void> {
@@ -180,6 +184,8 @@
<Auth />
{:else if page === 'changePassword'}
<ChangePassword />
{:else if page === 'tfa'}
<LoginTfaForm {navigateUrl} token={tfaToken} on:back={() => (page = 'login')} />
{/if}
</div>
</Scroller>
@@ -0,0 +1,65 @@
<!--
// Copyright © 2026 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { OK } from '@hcengineering/platform'
import { doLoginNavigate, verify2fa } from '../utils'
import login from '../plugin'
import Form from './Form.svelte'
export let navigateUrl: string | undefined = undefined
export let token: string | undefined = undefined
const object = {
code: ''
}
let status = OK
let isLoading = false
$: fields = [
{
id: 'two-factor-code',
name: 'code',
i18n: login.string.TwoFactorCode,
rules: [
{
rule: (v: string) => v.length === 6,
notMatch: false,
ruleDescr: login.status.IncorrectValue,
ruleDescrParams: { field: 'code' }
}
]
}
]
const action = {
i18n: login.string.Verify,
func: async () => {
isLoading = true
try {
const [resStatus, loginInfo] = await verify2fa(object.code, token)
status = resStatus
if (resStatus === OK && loginInfo != null) {
await doLoginNavigate(loginInfo, (s) => (status = s), navigateUrl)
}
} finally {
isLoading = false
}
}
}
</script>
<Form caption={login.string.TwoFactorAuth} bind:status {fields} {object} {action} {isLoading} ignoreInitialValidation />
+44
View File
@@ -1025,12 +1025,56 @@ export async function doValidateOtp (
}
}
export async function verify2fa (code: string, token: string | undefined): Promise<[Status, LoginInfo | null]> {
if (token === undefined) {
return [new Status(Severity.ERROR, platform.status.Unauthorized, {}), null]
}
try {
const loginInfo = await getAccountClient(token).verify2fa(code)
Analytics.handleEvent('verify2fa', { ok: true })
return [OK, loginInfo]
} catch (err: any) {
Analytics.handleEvent('verify2fa', { ok: false })
if (err instanceof PlatformError) {
await handleStatusError('Verify 2fa error', err.status)
return [err.status, null]
} else {
console.error('Verify 2fa error', err)
Analytics.handleError(err)
return [unknownError(err), null]
}
}
}
export async function doLoginNavigate (
result: LoginInfo | null,
updateStatus: (status: Status) => void,
navigateUrl?: string
): Promise<void> {
if (result != null) {
if (result.tfaRequired === true) {
const currentLoc = getCurrentLocation()
const loc = getCurrentLocation()
loc.path[1] = 'tfa'
loc.path.length = 2
if (navigateUrl !== undefined || result.token != null) {
loc.query = { ...loc.query, navigateUrl: navigateUrl ?? null, token: result.token ?? null }
}
if (loc.path.length === currentLoc.path.length && isSameSegments(currentLoc, loc, loc.path.length)) {
window.location.reload()
return
}
navigate(loc)
return
}
if (result.token != null) {
await logIn(result)
}
+7 -2
View File
@@ -40,7 +40,8 @@ export const pages = [
'confirmationSend',
'auth',
'login-password',
'changePassword'
'changePassword',
'tfa'
] as const
export type Pages = (typeof pages)[number]
@@ -97,7 +98,11 @@ export default plugin(loginId, {
PasswordExpiredDesc: '' as IntlString,
Email: '' as IntlString,
Password: '' as IntlString,
PasswordRepeat: '' as IntlString
PasswordRepeat: '' as IntlString,
TwoFactorAuth: '' as IntlString,
EnterTwoFactorCode: '' as IntlString,
TwoFactorCode: '' as IntlString,
Verify: '' as IntlString
},
function: {
SendInvite: '' as Resource<(email: string, role: AccountRole) => Promise<void>>,
+10 -1
View File
@@ -217,6 +217,15 @@
"SelectUsers": "Vybrat uživatele",
"ShowInTitle": "Zobrazit v názvu",
"SpaceMembersOnly": "Pouze členové prostoru",
"Reset": "Resetovat"
"Reset": "Resetovat",
"Security": "Zabezpečení",
"TwoFactorAuth": "Dvoufaktorové ověřování",
"TwoFactorAuthDescription": "Dvoufaktorové ověřování přidává další vrstvu zabezpečení k vašemu účtu",
"EnableTwoFactorAuth": "Povolit dvoufaktorové ověřování",
"DisableTwoFactorAuth": "Zakázat dvoufaktorové ověřování",
"TwoFactorAuthEnabled": "Dvoufaktorové ověřování je povoleno",
"TwoFactorAuthDisabled": "Dvoufaktorové ověřování je zakázáno",
"ShowQRCode": "Zobrazit QR kód",
"EnterVerificationCode": "Zadejte ověřovací kód"
}
}
+10 -1
View File
@@ -219,6 +219,15 @@
"SelectUsers": "Benutzer auswählen",
"ShowInTitle": "Im Titel anzeigen",
"SpaceMembersOnly": "Nur Bereichsmitglieder",
"Reset": "Zurücksetzen"
"Reset": "Zurücksetzen",
"Security": "Sicherheit",
"TwoFactorAuth": "Zweistufige Authentifizierung",
"TwoFactorAuthDescription": "Zweistufige Authentifizierung fügt eine zusätzliche Sicherheitsebene zu Ihrem Konto hinzu",
"EnableTwoFactorAuth": "Zweistufige Authentifizierung aktivieren",
"DisableTwoFactorAuth": "Zweistufige Authentifizierung deaktivieren",
"TwoFactorAuthEnabled": "Zweistufige Authentifizierung ist aktiviert",
"TwoFactorAuthDisabled": "Zweistufige Authentifizierung ist deaktiviert",
"ShowQRCode": "QR-Code anzeigen",
"EnterVerificationCode": "Verifizierungscode eingeben"
}
}
+10 -1
View File
@@ -219,6 +219,15 @@
"ImportDocumentDescription": "Grants users ability to import documents into the workspace",
"SelectUsers": "Select users",
"ShowInTitle": "Show in title",
"SpaceMembersOnly": "Space members only"
"SpaceMembersOnly": "Space members only",
"Security": "Security",
"TwoFactorAuth": "Two-factor authentication",
"TwoFactorAuthDescription": "Two-factor authentication adds an extra layer of security to your account",
"EnableTwoFactorAuth": "Enable two-factor authentication",
"DisableTwoFactorAuth": "Disable two-factor authentication",
"TwoFactorAuthEnabled": "Two-factor authentication is enabled",
"TwoFactorAuthDisabled": "Two-factor authentication is disabled",
"ShowQRCode": "Show QR code",
"EnterVerificationCode": "Enter verification code"
}
}
+10 -1
View File
@@ -210,6 +210,15 @@
"SelectUsers": "Seleccionar usuarios",
"ShowInTitle": "Mostrar en el título",
"SpaceMembersOnly": "Solo miembros del espacio",
"Reset": "Reiniciar"
"Reset": "Reiniciar",
"Security": "Seguridad",
"TwoFactorAuth": "Autenticación de dos factores",
"TwoFactorAuthDescription": "La autenticación de dos factores añade una capa adicional de seguridad a tu cuenta",
"EnableTwoFactorAuth": "Habilitar autenticación de dos factores",
"DisableTwoFactorAuth": "Deshabilitar autenticación de dos factores",
"TwoFactorAuthEnabled": "La autenticación de dos factores está habilitada",
"TwoFactorAuthDisabled": "La autenticación de dos factores está deshabilitada",
"ShowQRCode": "Mostrar código QR",
"EnterVerificationCode": "Introducir código de verificación"
}
}
+10 -1
View File
@@ -219,6 +219,15 @@
"SelectUsers": "Sélectionner des utilisateurs",
"ShowInTitle": "Afficher dans le titre",
"SpaceMembersOnly": "Membres de l'espace uniquement",
"Reset": "Réinitialiser"
"Reset": "Réinitialiser",
"Security": "Sécurité",
"TwoFactorAuth": "Authentification à deux facteurs",
"TwoFactorAuthDescription": "L'authentification à deux facteurs ajoute une couche de sécurité supplémentaire à votre compte",
"EnableTwoFactorAuth": "Activer l'authentification à deux facteurs",
"DisableTwoFactorAuth": "Désactiver l'authentification à deux facteurs",
"TwoFactorAuthEnabled": "L'authentification à deux facteurs est activée",
"TwoFactorAuthDisabled": "L'authentification à deux facteurs est désactivée",
"ShowQRCode": "Afficher le code QR",
"EnterVerificationCode": "Entrer le code de vérification"
}
}
+10 -1
View File
@@ -219,6 +219,15 @@
"SelectUsers": "Seleziona utenti",
"ShowInTitle": "Mostra nel titolo",
"SpaceMembersOnly": "Solo membri dello spazio",
"Reset": "Reset"
"Reset": "Reset",
"Security": "Sicurezza",
"TwoFactorAuth": "Autenticazione a due fattori",
"TwoFactorAuthDescription": "L'autenticazione a due fattori aggiunge un ulteriore livello di sicurezza al tuo account",
"EnableTwoFactorAuth": "Abilita autenticazione a due fattori",
"DisableTwoFactorAuth": "Disabilita autenticazione a due fattori",
"TwoFactorAuthEnabled": "L'autenticazione a due fattori è abilitata",
"TwoFactorAuthDisabled": "L'autenticazione a due fattori è disabilitata",
"ShowQRCode": "Mostra codice QR",
"EnterVerificationCode": "Inserisci codice di verifica"
}
}
+10 -1
View File
@@ -219,6 +219,15 @@
"SelectUsers": "ユーザーを選択",
"ShowInTitle": "タイトルに表示",
"SpaceMembersOnly": "スペースメンバーのみ",
"Reset": "リセット"
"Reset": "リセット",
"Security": "セキュリティ",
"TwoFactorAuth": "二要素認証",
"TwoFactorAuthDescription": "二要素認証はアカウントにセキュリティの追加レイヤーを追加します",
"EnableTwoFactorAuth": "二要素認証を有効にする",
"DisableTwoFactorAuth": "二要素認証を無効にする",
"TwoFactorAuthEnabled": "二要素認証は有効です",
"TwoFactorAuthDisabled": "二要素認証は無効です",
"ShowQRCode": "QRコードを表示",
"EnterVerificationCode": "確認コードを入力"
}
}
+10 -1
View File
@@ -210,6 +210,15 @@
"SelectUsers": "Selecionar usuários",
"ShowInTitle": "Mostrar no título",
"SpaceMembersOnly": "Apenas membros do espaço",
"Reset": "Reiniciar"
"Reset": "Reiniciar",
"Security": "Segurança",
"TwoFactorAuth": "Autenticação de dois fatores",
"TwoFactorAuthDescription": "A autenticação de dois fatores adiciona uma camada extra de segurança à sua conta",
"EnableTwoFactorAuth": "Ativar autenticação de dois fatores",
"DisableTwoFactorAuth": "Desativar autenticação de dois fatores",
"TwoFactorAuthEnabled": "Autenticação de dois fatores está ativada",
"TwoFactorAuthDisabled": "Autenticação de dois fatores está desativada",
"ShowQRCode": "Mostrar código QR",
"EnterVerificationCode": "Inserir código de verificação"
}
}
+10 -1
View File
@@ -210,6 +210,15 @@
"SelectUsers": "Selecionar usuários",
"ShowInTitle": "Mostrar no título",
"SpaceMembersOnly": "Apenas membros do espaço",
"Reset": "Reiniciar"
"Reset": "Reiniciar",
"Security": "Segurança",
"TwoFactorAuth": "Autenticação de dois fatores",
"TwoFactorAuthDescription": "A autenticação de dois fatores adiciona uma camada extra de segurança à sua conta",
"EnableTwoFactorAuth": "Ativar autenticação de dois fatores",
"DisableTwoFactorAuth": "Desativar autenticação de dois fatores",
"TwoFactorAuthEnabled": "Autenticação de dois fatores está ativada",
"TwoFactorAuthDisabled": "Autenticação de dois fatores está desativada",
"ShowQRCode": "Mostrar código QR",
"EnterVerificationCode": "Inserir código de verificação"
}
}
+10 -1
View File
@@ -219,6 +219,15 @@
"ImportDocumentDescription": "Предоставляет пользователям возможность импортировать документы в рабочее пространство",
"SelectUsers": "Выбрать пользователей",
"ShowInTitle": "Показывать в заголовке",
"SpaceMembersOnly": "Только участники пространства"
"SpaceMembersOnly": "Только участники пространства",
"Security": "Безопасность",
"TwoFactorAuth": "Двухфакторная аутентификация",
"TwoFactorAuthDescription": "Двухфакторная аутентификация добавляет дополнительный уровень безопасности к вашей учетной записи",
"EnableTwoFactorAuth": "Включить двухфакторную аутентификацию",
"DisableTwoFactorAuth": "Отключить двухфакторную аутентификацию",
"TwoFactorAuthEnabled": "Двухфакторная аутентификация включена",
"TwoFactorAuthDisabled": "Двухфакторная аутентификация отключена",
"ShowQRCode": "Показать QR-код",
"EnterVerificationCode": "Введите код подтверждения"
}
}
+10 -1
View File
@@ -219,6 +219,15 @@
"SelectUsers": "Kullanıcıları seç",
"ShowInTitle": "Başlıkta göster",
"SpaceMembersOnly": "Yalnızca alan üyeleri",
"Reset": "Sıfırla"
"Reset": "Sıfırla",
"Security": "Güvenlik",
"TwoFactorAuth": "İki faktörlü kimlik doğrulama",
"TwoFactorAuthDescription": "İki faktörlü kimlik doğrulama hesabınıza ek bir güvenlik katmanı ekler",
"EnableTwoFactorAuth": "İki faktörlü kimlik doğrulamayı etkinleştir",
"DisableTwoFactorAuth": "İki faktörlü kimlik doğrulamayı devre dışı bırak",
"TwoFactorAuthEnabled": "İki faktörlü kimlik doğrulama etkin",
"TwoFactorAuthDisabled": "İki faktörlü kimlik doğrulama devre dışı",
"ShowQRCode": "QR kodu göster",
"EnterVerificationCode": "Doğrulama kodunu gir"
}
}
+10 -1
View File
@@ -219,6 +219,15 @@
"SelectUsers": "选择用户",
"ShowInTitle": "在标题中显示",
"SpaceMembersOnly": "仅限空间成员",
"Reset": "重置"
"Reset": "重置",
"Security": "安全",
"TwoFactorAuth": "双因素认证",
"TwoFactorAuthDescription": "双因素认证为您的帐户增加额外的安全层",
"EnableTwoFactorAuth": "启用双因素认证",
"DisableTwoFactorAuth": "禁用双因素认证",
"TwoFactorAuthEnabled": "双因素认证已启用",
"TwoFactorAuthDisabled": "双因素认证已禁用",
"ShowQRCode": "显示QR码",
"EnterVerificationCode": "输入验证码"
}
}
+4 -2
View File
@@ -37,7 +37,8 @@
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"@types/jest": "^29.5.5",
"svelte-eslint-parser": "^0.33.1"
"svelte-eslint-parser": "^0.33.1",
"@types/qrcode": "^1.5.5"
},
"dependencies": {
"@hcengineering/platform": "workspace:^0.7.19",
@@ -66,6 +67,7 @@
"@hcengineering/chat": "workspace:^0.7.0",
"@hcengineering/integration-client": "workspace:^0.7.0",
"@hcengineering/rank": "workspace:^0.7.17",
"@hcengineering/rating": "workspace:^0.7.0"
"@hcengineering/rating": "workspace:^0.7.0",
"qrcode": "^1.5.4"
}
}
@@ -0,0 +1,152 @@
<!--
// Copyright © 2026 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import QRCode from 'qrcode'
import { getCurrentAccount } from '@hcengineering/core'
import { OK, unknownError, getEmbeddedLabel } from '@hcengineering/platform'
import setting from '@hcengineering/setting'
import { Breadcrumb, Button, EditBox, Header, Label, Spinner, Status } from '@hcengineering/ui'
import { getAccountClient } from '../utils'
import presentation from '@hcengineering/presentation'
let tfaEnabled: boolean | undefined = undefined
let showSetup = false
let secret = ''
let otpauthUrl = ''
let code = ''
let status = OK
let isLoading = false
const acc = getCurrentAccount()
getAccountClient()
.getAccountInfo(acc.uuid)
.then((account) => {
tfaEnabled = account.tfaEnabled
})
async function toggle2fa () {
if (tfaEnabled) {
// Logic for disabling (needs code)
showSetup = true
} else {
isLoading = true
try {
const result = await getAccountClient().generate2faSecret()
secret = result.secret
otpauthUrl = result.url
showSetup = true
} catch (err: any) {
status = err.status ?? unknownError(err)
} finally {
isLoading = false
}
}
}
async function verifyAndEnable () {
isLoading = true
try {
await getAccountClient().enable2fa(secret, code)
tfaEnabled = true
showSetup = false
code = ''
status = OK
} catch (err: any) {
status = err.status ?? unknownError(err)
} finally {
isLoading = false
}
}
async function verifyAndDisable () {
isLoading = true
try {
await getAccountClient().disable2fa(code)
tfaEnabled = false
showSetup = false
code = ''
status = OK
} catch (err: any) {
status = err.status ?? unknownError(err)
} finally {
isLoading = false
}
}
let qrCodeUrl = ''
$: if (otpauthUrl) {
QRCode.toDataURL(otpauthUrl, { margin: 1, width: 200 }).then((url) => {
qrCodeUrl = url
})
}
</script>
<div class="hulyComponent">
<Header adaptive={'disabled'}>
<Breadcrumb icon={setting.icon.Password} label={setting.string.TwoFactorAuth} size="large" isCurrent />
</Header>
<div class="flex-col p-6 gap-8 max-w-2xl">
<div class="flex flex-between">
<Label label={setting.string.TwoFactorAuthDescription} />
{#if tfaEnabled === undefined}
<Spinner />
{:else}
<Button
label={tfaEnabled ? setting.string.DisableTwoFactorAuth : setting.string.EnableTwoFactorAuth}
kind={tfaEnabled ? 'negative' : 'primary'}
on:click={toggle2fa}
disabled={isLoading || showSetup}
/>
{/if}
</div>
{#if showSetup}
<div class="flex-col flex-gap-6 mt-6">
{#if !tfaEnabled}
<div class="flex-col items-center flex-gap-1">
<div style="width: 200px; height: 200px">
<img src={qrCodeUrl} alt="2FA QR Code" width="200" height="200" />
</div>
<div class="font-mono break-all">
{secret}
</div>
</div>
{/if}
<div class="flex mt-4 items-center flex-gap-4">
<Label label={setting.string.EnterVerificationCode} />:
<EditBox
bind:value={code}
kind={'default-large'}
maxWidth={'80px'}
placeholder={getEmbeddedLabel('000000')}
autoFocus
/>
<Button
label={presentation.string.Save}
kind="primary"
size="large"
on:click={tfaEnabled ? verifyAndDisable : verifyAndEnable}
disabled={isLoading || code.length !== 6}
/>
<Status {status} />
</div>
</div>
{/if}
</div>
</div>
+3 -1
View File
@@ -30,6 +30,7 @@ import Password from './components/Password.svelte'
import Privacy from './components/Privacy.svelte'
import Profile from './components/Profile.svelte'
import Settings from './components/Settings.svelte'
import TwoFactorSettings from './components/TwoFactorSettings.svelte'
import { Analytics } from '@hcengineering/analytics'
import ClassAttributes from './components/ClassAttributes.svelte'
@@ -167,7 +168,8 @@ export default async (): Promise<Resources> => ({
AddSocialId,
AddEmailSocialId,
EmployeeRefEditor,
UserRoleSelect
UserRoleSelect,
TwoFactorSettings
},
actionImpl: {
DeleteMixin
+13 -2
View File
@@ -199,7 +199,8 @@ export default plugin(settingId, {
Export: '' as Ref<Doc>,
OfficeSettings: '' as Ref<Doc>,
DisablePermissionsConfiguration: '' as Ref<Configuration>,
Mailboxes: '' as Ref<Doc>
Mailboxes: '' as Ref<Doc>,
Security: '' as Ref<Doc>
},
mixin: {
Editable: '' as Ref<Mixin<Editable>>,
@@ -244,7 +245,8 @@ export default plugin(settingId, {
Mailboxes: '' as AnyComponent,
AddEmailSocialId: '' as AnyComponent,
OfficeSettings: '' as AnyComponent,
UserRoleSelect: '' as AnyComponent
UserRoleSelect: '' as AnyComponent,
TwoFactorSettings: '' as AnyComponent
},
string: {
Settings: '' as IntlString,
@@ -316,6 +318,15 @@ export default plugin(settingId, {
MailboxErrorMailboxCountLimit: '' as IntlString,
DeleteMailbox: '' as IntlString,
MailboxDeleteConfirmation: '' as IntlString,
Security: '' as IntlString,
TwoFactorAuth: '' as IntlString,
TwoFactorAuthDescription: '' as IntlString,
EnableTwoFactorAuth: '' as IntlString,
DisableTwoFactorAuth: '' as IntlString,
TwoFactorAuthEnabled: '' as IntlString,
TwoFactorAuthDisabled: '' as IntlString,
ShowQRCode: '' as IntlString,
EnterVerificationCode: '' as IntlString,
IntegrationFailed: '' as IntlString,
IntegrationError: '' as IntlString,
EmailIsUsed: '' as IntlString,
@@ -127,6 +127,8 @@ export async function connect (title: string): Promise<Client | undefined> {
break
}
console.log('workspaceLoginInfo', workspaceLoginInfo)
const token = workspaceLoginInfo.token
setMetadata(presentation.metadata.Token, workspaceLoginInfo.token)
+1
View File
@@ -47,6 +47,7 @@
"@hcengineering/analytics": "workspace:^0.7.17",
"@hcengineering/server-storage": "workspace:^0.7.16",
"@hcengineering/server-core": "workspace:^0.7.18",
"otplib": "^12.0.1",
"@hcengineering/server-pipeline": "workspace:^0.7.0"
}
}
@@ -1400,7 +1400,8 @@ describe('account operations', () => {
account: mockAccountId,
token: expect.any(String),
name: 'John Doe',
socialId: 'social-id'
socialId: 'social-id',
tfaRequired: false
})
})
@@ -1439,7 +1440,8 @@ describe('account operations', () => {
account: mockAccountId,
token: undefined,
name: 'John Doe',
socialId: 'social-id'
socialId: 'social-id',
tfaRequired: false
})
})
@@ -1984,7 +1986,8 @@ describe('account operations', () => {
account: mockPersonId,
name: 'John Doe',
socialId: mockSocialId._id,
token: expect.any(String)
token: expect.any(String),
tfaRequired: false
})
expect(mockDb.otp.deleteMany).toHaveBeenCalledWith({ socialId: mockSocialId._id })
@@ -2030,7 +2033,8 @@ describe('account operations', () => {
account: mockPersonId,
name: 'John Doe',
socialId: mockSocialId._id,
token: expect.any(String)
token: expect.any(String),
tfaRequired: false
})
expect(mockDb.otp.deleteMany).toHaveBeenCalledWith({ socialId: mockSocialId._id })
@@ -2071,7 +2075,8 @@ describe('account operations', () => {
account: callerAccountId,
name: 'John Doe',
socialId: mockSocialId._id,
token: expect.any(String)
token: undefined,
tfaRequired: false
})
expect(mockDb.otp.deleteMany).toHaveBeenCalledWith({ socialId: mockSocialId._id })
@@ -2126,7 +2131,8 @@ describe('account operations', () => {
account: callerAccountId,
name: 'John Doe',
socialId: mockSocialId._id,
token: expect.any(String)
token: undefined,
tfaRequired: false
})
expect(mockDb.otp.deleteMany).toHaveBeenCalledWith({ socialId: mockSocialId._id })
@@ -2173,7 +2179,8 @@ describe('account operations', () => {
account: callerAccountId,
name: 'John Doe',
socialId: mockSocialId._id,
token: expect.any(String)
token: undefined,
tfaRequired: false
})
expect(mockDb.otp.deleteMany).toHaveBeenCalledWith({ socialId: mockSocialId._id })
@@ -2476,7 +2483,8 @@ describe('account operations', () => {
account: mockAccountId,
name: 'John Doe',
token: expect.any(String),
socialId: mockSocialId._id
socialId: mockSocialId._id,
tfaRequired: false
})
expect(utils.setPassword).toHaveBeenCalledWith(mockCtx, mockDb, mockBranding, mockAccountId, mockNewPassword)
@@ -337,6 +337,7 @@ describe('AccountPostgresDbCollection', () => {
a.automatic,
a.max_workspaces,
a.failed_login_attempts,
a.tfa_secret,
p.hash,
p.salt
FROM global_account.account as a
@@ -81,7 +81,8 @@ export function getMigrations (ns: string, flavor: DBFlavor): [string, string][]
getV21Migration(ns, flavor),
getV22Migration(ns, flavor),
getV23Migration(ns, flavor),
getV24Migration(ns, flavor)
getV24Migration(ns, flavor),
getV25Migration(ns, flavor)
]
}
@@ -782,3 +783,14 @@ function getV24Migration (ns: string, flavor: DBFlavor): [string, string] {
`
]
}
function getV25Migration (ns: string, flavor: DBFlavor): [string, string] {
const types = dbTypes[flavor]
return [
'account_db_v25_add_2fa_to_account',
`
ALTER TABLE ${ns}.account
ADD COLUMN IF NOT EXISTS tfa_secret ${types.string};
`
]
}
@@ -453,6 +453,7 @@ export class AccountPostgresDbCollection
a.automatic,
a.max_workspaces,
a.failed_login_attempts,
a.tfa_secret,
p.hash,
p.salt
FROM ${this.getTableName()} as a
+142 -5
View File
@@ -123,9 +123,14 @@ import {
recordFailedLoginAttempt,
resetFailedLoginAttempts,
updatePasswordAgingRule,
checkPasswordAging
checkPasswordAging,
generateTotpSecret,
verifyTotpCode,
getTotpUrl
} from './utils'
const NIL_UUID = '00000000-0000-0000-0000-000000000000' as AccountUuid
// Note: it is IMPORTANT to always destructure params passed here to avoid sending extra params
// to the database layer when searching/inserting as they may contain SQL injection
// !!! NEVER PASS "params" DIRECTLY in any DB functions !!!
@@ -229,9 +234,16 @@ export async function login (
return {
account: existingAccount.uuid,
token: isConfirmed ? generateToken(existingAccount.uuid, undefined, extraToken) : undefined,
token: isConfirmed
? generateToken(
existingAccount.tfaSecret != null ? NIL_UUID : existingAccount.uuid,
undefined,
existingAccount.tfaSecret != null ? { ...extraToken, tfaAccount: existingAccount.uuid } : extraToken
)
: undefined,
name: getPersonName(person),
socialId: emailSocialId._id
socialId: emailSocialId._id,
tfaRequired: isConfirmed && existingAccount.tfaSecret != null
}
} catch (err: any) {
Analytics.handleError(err)
@@ -508,15 +520,26 @@ export async function validateOtp (
await resetFailedLoginAttempts(db, emailSocialId.personUuid as AccountUuid)
const isConfirmed = emailSocialId.verifiedOn != null || action !== 'verify'
const extraToken: Record<string, string> = isAdminEmail(normalizedEmail)
? { admin: 'true', authMethod: 'otp' }
: { authMethod: 'otp' }
const _token = isConfirmed
? generateToken(
targetAccount?.tfaSecret != null ? NIL_UUID : emailSocialId.personUuid,
undefined,
targetAccount?.tfaSecret != null ? { ...extraToken, tfaAccount: emailSocialId.personUuid } : extraToken
)
: undefined
return {
account: emailSocialId.personUuid as AccountUuid,
name: getPersonName(person),
socialId: emailSocialId._id,
token: generateToken(emailSocialId.personUuid, undefined, extraToken)
token: _token,
tfaRequired: targetAccount?.tfaSecret != null
}
} catch (err: any) {
Analytics.handleError(err)
@@ -1694,6 +1717,112 @@ export async function deleteWorkspace (
)
}
export async function generate2faSecret (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string
): Promise<{ secret: string, url: string }> {
const { account: accountUuid } = decodeTokenVerbose(ctx, token)
const account = await getAccount(db, accountUuid)
if (account == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: accountUuid }))
}
const emailSocialId = await db.socialId.findOne({ personUuid: accountUuid, type: SocialIdType.EMAIL })
if (emailSocialId == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
const secret = generateTotpSecret()
const app = branding?.title ?? getMetadata(accountPlugin.metadata.ProductName) ?? 'Huly'
const url = getTotpUrl(emailSocialId.value, app, secret)
return { secret, url }
}
export async function enable2fa (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: { secret: string, code: string }
): Promise<void> {
const { secret, code } = params
const { account: accountUuid } = decodeTokenVerbose(ctx, token)
if (!verifyTotpCode(secret, code)) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.InvalidOtp, {}))
}
await db.account.update({ uuid: accountUuid }, { tfaSecret: secret })
ctx.info('2FA enabled', { accountUuid })
}
export async function disable2fa (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: { code: string }
): Promise<void> {
const { code } = params
const { account: accountUuid } = decodeTokenVerbose(ctx, token)
const account = await getAccount(db, accountUuid)
if (account?.tfaSecret == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
if (!verifyTotpCode(account.tfaSecret, code)) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.InvalidOtp, {}))
}
await db.account.update({ uuid: accountUuid }, { tfaSecret: undefined })
ctx.info('2FA disabled', { accountUuid })
}
export async function verify2fa (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: { code: string }
): Promise<LoginInfo> {
const { code } = params
const decoded = decodeTokenVerbose(ctx, token)
const accountUuid =
decoded.account === NIL_UUID || decoded.account == null
? (decoded.extra?.tfaAccount as AccountUuid)
: decoded.account
const extra = decoded.extra
const account = await getAccount(db, accountUuid)
if (account?.tfaSecret == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
if (!verifyTotpCode(account.tfaSecret, code)) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.InvalidOtp, {}))
}
const person = await db.person.findOne({ uuid: accountUuid })
if (person == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.InternalServerError, {}))
}
const socialId = await db.socialId.findOne({ personUuid: accountUuid, verifiedOn: { $gt: 0 } })
const { tfaAccount, ...filteredExtra } = extra ?? {}
return {
account: accountUuid,
token: generateToken(accountUuid, undefined, filteredExtra),
name: getPersonName(person),
socialId: socialId?._id
}
}
/* =================================== */
/* ==========READ OPERATIONS========== */
/* =================================== */
@@ -2274,7 +2403,7 @@ export async function getAccountInfo (
if (account === undefined || account === null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, {}))
}
return { timezone: account?.timezone, locale: account?.locale }
return { timezone: account?.timezone, locale: account?.locale, tfaEnabled: account?.tfaSecret != null }
}
export async function ensurePerson (
@@ -3089,6 +3218,10 @@ export type AccountMethods =
| 'changeUsername'
| 'updateWorkspaceName'
| 'deleteWorkspace'
| 'generate2faSecret'
| 'enable2fa'
| 'disable2fa'
| 'verify2fa'
| 'getRegionInfo'
| 'getUserWorkspaces'
| 'getWorkspaceInfo'
@@ -3167,6 +3300,10 @@ export function getMethods (hasSignUp: boolean = true): Partial<Record<AccountMe
changeUsername: wrap(changeUsername),
updateWorkspaceName: wrap(updateWorkspaceName),
deleteWorkspace: wrap(deleteWorkspace),
generate2faSecret: wrap(generate2faSecret),
enable2fa: wrap(enable2fa),
disable2fa: wrap(disable2fa),
verify2fa: wrap(verify2fa),
updateWorkspaceRole: wrap(updateWorkspaceRole),
updateAllowReadOnlyGuests: wrap(updateAllowReadOnlyGuests),
updateAllowGuestSignUp: wrap(updateAllowGuestSignUp),
+2
View File
@@ -65,6 +65,7 @@ export interface Account {
salt?: Buffer | null
maxWorkspaces?: number
failedLoginAttempts?: number // Number of consecutive failed login attempts
tfaSecret?: string
}
// TODO: type data with generic type
@@ -432,6 +433,7 @@ export interface LoginInfo {
name?: string
socialId?: PersonId
token?: string
tfaRequired?: boolean
}
export interface LoginInfoRequestData {
+13
View File
@@ -39,6 +39,7 @@ import platform, { getMetadata, PlatformError, Severity, Status, translate } fro
import { getDBClient, setDBExtraOptions } from '@hcengineering/postgres'
import { pbkdf2Sync, randomBytes } from 'crypto'
import otpGenerator from 'otp-generator'
import { authenticator } from 'otplib'
import { Analytics } from '@hcengineering/analytics'
import { decodeTokenVerbose, generateToken, type PermissionsGrant, TokenError } from '@hcengineering/server-token'
@@ -2118,3 +2119,15 @@ export async function doMergeAccounts (
await db.setPassword(primaryAccount, secondaryAccountObj.hash, secondaryAccountObj.salt)
}
}
export function generateTotpSecret (): string {
return authenticator.generateSecret()
}
export function verifyTotpCode (secret: string, code: string): boolean {
return authenticator.check(code, secret)
}
export function getTotpUrl (account: string, issuer: string, secret: string): string {
return authenticator.keyuri(account, issuer, secret)
}