Add opt-in TOTP 2FA for local accounts
New card in Настройки → Аккаунт: scan a QR code (otplib + qrcode), confirm with a live code, get 8 one-time recovery codes shown once. Only offered for authSource="local" — LDAP accounts already have their own MFA story at the directory level and are turned away with a clear message if they somehow hit the setup endpoint directly. Login flow: a 2FA account's password check now creates a short-lived "login challenge" (separate table from `sessions`, 5-minute TTL, capped at 6 verify attempts) instead of a real session, and the login page swaps to a second screen asking for a code — TOTP or a recovery code, either works. The LDAP branch of the login route is untouched; it returns before ever reaching the 2FA check. totpSecret is encrypted at rest via the existing lib/crypto/credentials.ts helper (same one already used for mailbox/LDAP bind passwords) rather than adding a second encryption scheme. Recovery codes are hashed, not stored plaintext, and each is single-use (marked usedAt, not deleted). Verified against the real deployment end-to-end with Playwright against a throwaway test account (created via the real admin-users API, fully deleted after): setup → confirm → recovery-code issuance → second login correctly prompted for a code → wrong code rejected → correct code and a recovery code both worked → a reused recovery code was correctly rejected → disable (password-gated) → subsequent login went straight through again. Also added unit tests for the TOTP/recovery-code helpers. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GteWhnWKTmnXcsd5jx6H7u
This commit is contained in:
1 parent
95522fdedd
commit
4ecb0e7698
17 files changed
+2285
-55
No files matched your search
Generated
+333
-5
@@ -20,6 +20,8 @@
|
|||||||
"mailparser": "^3.9.14",
|
"mailparser": "^3.9.14",
|
||||||
"next": "16.2.12",
|
"next": "16.2.12",
|
||||||
"nodemailer": "^9.0.3",
|
"nodemailer": "^9.0.3",
|
||||||
|
"otplib": "^13.4.1",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"sanitize-html": "^2.13.1",
|
"sanitize-html": "^2.13.1",
|
||||||
@@ -34,6 +36,7 @@
|
|||||||
"@types/mailparser": "^3.4.6",
|
"@types/mailparser": "^3.4.6",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/nodemailer": "^8.0.1",
|
"@types/nodemailer": "^8.0.1",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@types/sanitize-html": "^2.16.1",
|
"@types/sanitize-html": "^2.16.1",
|
||||||
@@ -2090,6 +2093,17 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@noble/hashes": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nodelib/fs.scandir": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
@@ -2134,6 +2148,56 @@
|
|||||||
"node": ">=12.4.0"
|
"node": ">=12.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@otplib/core": {
|
||||||
|
"version": "13.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@otplib/core/-/core-13.4.1.tgz",
|
||||||
|
"integrity": "sha512-KIXgK1hNtWJEBMTastbe1bpmuais+3f+ATeO8TkMs2rNkfGO1FbQy8+/UWVEu3TR/iTJerU0idkPudaPmLP2BA=="
|
||||||
|
},
|
||||||
|
"node_modules/@otplib/hotp": {
|
||||||
|
"version": "13.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@otplib/hotp/-/hotp-13.4.1.tgz",
|
||||||
|
"integrity": "sha512-g9q04SwpG5ZtMnVkUcgcoAlwCH4YLROZN1qhyBwgkBzqYYVSYhpP6gSGaxGHwePLt1c+e6NqDlgIZN+e1/XPuA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@otplib/core": "13.4.1",
|
||||||
|
"@otplib/uri": "13.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@otplib/plugin-base32-scure": {
|
||||||
|
"version": "13.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.4.1.tgz",
|
||||||
|
"integrity": "sha512-Fs/r5qisC05SRhT6xWXaypB6PVC0vgWf6zztmi0J5RnQ09OJiPDWCJFH6cDm6ANsrdvB9di7X+Jb7L13BoEbUA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@otplib/core": "13.4.1",
|
||||||
|
"@scure/base": "^2.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@otplib/plugin-crypto-noble": {
|
||||||
|
"version": "13.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.4.1.tgz",
|
||||||
|
"integrity": "sha512-PJfVW8/1hdS6CfxLheKPZSLTwDq4TijZbN4yRjxlv0ODdzmxpM+wGwWr1JXMdy0xJPxLziydQD5gdVqrR4/gAg==",
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/hashes": "^2.2.0",
|
||||||
|
"@otplib/core": "13.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@otplib/totp": {
|
||||||
|
"version": "13.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@otplib/totp/-/totp-13.4.1.tgz",
|
||||||
|
"integrity": "sha512-QOkBVPrf6AM4qZaReZPSk9/I8ATVdZpIISJz115MqeVtcrbcr5llPZ0J7804tpnjnp1vCRkI5Qjd47HhgVteBQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@otplib/core": "13.4.1",
|
||||||
|
"@otplib/hotp": "13.4.1",
|
||||||
|
"@otplib/uri": "13.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@otplib/uri": {
|
||||||
|
"version": "13.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@otplib/uri/-/uri-13.4.1.tgz",
|
||||||
|
"integrity": "sha512-xaIm7bvICMhoB2rZIR5luiaMdssWR5nY5nXnR1fdezUgZuEO58D6zrGzLp7pQuBmlpmL0HagnscDQFoskp9yiA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@otplib/core": "13.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@phc/format": {
|
"node_modules/@phc/format": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
|
||||||
@@ -2478,6 +2542,14 @@
|
|||||||
"integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==",
|
"integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@scure/base": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-NsG6Y03tY6R5BUis4FdVtHVkur0U6FOzskgs9ZXNl78CUc9fkZ78HmENUle1nSOkCasDmbubmWD9qwB7mm4PZA==",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@selderee/plugin-htmlparser2": {
|
"node_modules/@selderee/plugin-htmlparser2": {
|
||||||
"version": "0.12.0",
|
"version": "0.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.12.0.tgz",
|
||||||
@@ -2864,6 +2936,15 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/qrcode": {
|
||||||
|
"version": "1.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||||
|
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.17",
|
"version": "19.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||||
@@ -3659,11 +3740,18 @@
|
|||||||
"url": "https://github.com/sponsors/epoberezkin"
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ansi-regex": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ansi-styles": {
|
"node_modules/ansi-styles": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-convert": "^2.0.1"
|
"color-convert": "^2.0.1"
|
||||||
},
|
},
|
||||||
@@ -4201,6 +4289,14 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/camelcase": {
|
||||||
|
"version": "5.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||||
|
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001806",
|
"version": "1.0.30001806",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
|
||||||
@@ -4311,11 +4407,20 @@
|
|||||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="
|
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/cliui": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"strip-ansi": "^6.0.0",
|
||||||
|
"wrap-ansi": "^6.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-name": "~1.1.4"
|
"color-name": "~1.1.4"
|
||||||
},
|
},
|
||||||
@@ -4326,8 +4431,7 @@
|
|||||||
"node_modules/color-name": {
|
"node_modules/color-name": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/concat-map": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
@@ -4480,6 +4584,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decamelize": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/decompress-response": {
|
"node_modules/decompress-response": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||||
@@ -4575,6 +4687,11 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dijkstrajs": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="
|
||||||
|
},
|
||||||
"node_modules/doctrine": {
|
"node_modules/doctrine": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
||||||
@@ -5797,6 +5914,14 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-caller-file": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
|
"engines": {
|
||||||
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/get-intrinsic": {
|
"node_modules/get-intrinsic": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
@@ -6406,6 +6531,14 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-fullwidth-code-point": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-generator-function": {
|
"node_modules/is-generator-function": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
|
||||||
@@ -7684,6 +7817,19 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/otplib": {
|
||||||
|
"version": "13.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/otplib/-/otplib-13.4.1.tgz",
|
||||||
|
"integrity": "sha512-o5CxfDw6bh7hoDv0NUUIcc0RqzJ9ipfUrzeKheKJ+vs4rXZnDlA9n4a/7R1cDjpmLjKLix4BgNVRmoDkm5rLSQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@otplib/core": "13.4.1",
|
||||||
|
"@otplib/hotp": "13.4.1",
|
||||||
|
"@otplib/plugin-base32-scure": "13.4.1",
|
||||||
|
"@otplib/plugin-crypto-noble": "13.4.1",
|
||||||
|
"@otplib/totp": "13.4.1",
|
||||||
|
"@otplib/uri": "13.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/own-keys": {
|
"node_modules/own-keys": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz",
|
||||||
@@ -7740,6 +7886,14 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/p-try": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/parent-module": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
@@ -7818,7 +7972,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
@@ -7911,6 +8064,14 @@
|
|||||||
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
|
||||||
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="
|
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="
|
||||||
},
|
},
|
||||||
|
"node_modules/pngjs": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/possible-typed-array-names": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||||
@@ -8042,6 +8203,22 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/qrcode": {
|
||||||
|
"version": "1.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||||
|
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||||
|
"dependencies": {
|
||||||
|
"dijkstrajs": "^1.0.1",
|
||||||
|
"pngjs": "^5.0.0",
|
||||||
|
"yargs": "^15.3.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"qrcode": "bin/qrcode"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/queue-microtask": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
@@ -8177,6 +8354,19 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-directory": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/require-main-filename": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "2.0.0-next.7",
|
"version": "2.0.0-next.7",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
|
||||||
@@ -8451,6 +8641,11 @@
|
|||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-blocking": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="
|
||||||
|
},
|
||||||
"node_modules/set-function-length": {
|
"node_modules/set-function-length": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||||
@@ -8797,6 +8992,24 @@
|
|||||||
"safe-buffer": "~5.2.0"
|
"safe-buffer": "~5.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string-width": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/string-width/node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
|
||||||
|
},
|
||||||
"node_modules/string.prototype.includes": {
|
"node_modules/string.prototype.includes": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
|
||||||
@@ -8905,6 +9118,17 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/strip-ansi": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/strip-bom": {
|
"node_modules/strip-bom": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
|
||||||
@@ -10335,6 +10559,11 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/which-module": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="
|
||||||
|
},
|
||||||
"node_modules/which-typed-array": {
|
"node_modules/which-typed-array": {
|
||||||
"version": "1.1.22",
|
"version": "1.1.22",
|
||||||
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz",
|
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz",
|
||||||
@@ -10381,17 +10610,116 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wrap-ansi": {
|
||||||
|
"version": "6.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||||
|
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.0.0",
|
||||||
|
"string-width": "^4.1.0",
|
||||||
|
"strip-ansi": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/wrappy": {
|
"node_modules/wrappy": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
|
||||||
},
|
},
|
||||||
|
"node_modules/y18n": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/yargs": {
|
||||||
|
"version": "15.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||||
|
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||||
|
"dependencies": {
|
||||||
|
"cliui": "^6.0.0",
|
||||||
|
"decamelize": "^1.2.0",
|
||||||
|
"find-up": "^4.1.0",
|
||||||
|
"get-caller-file": "^2.0.1",
|
||||||
|
"require-directory": "^2.1.1",
|
||||||
|
"require-main-filename": "^2.0.0",
|
||||||
|
"set-blocking": "^2.0.0",
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"which-module": "^2.0.0",
|
||||||
|
"y18n": "^4.0.0",
|
||||||
|
"yargs-parser": "^18.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs-parser": {
|
||||||
|
"version": "18.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||||
|
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"camelcase": "^5.0.0",
|
||||||
|
"decamelize": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs/node_modules/find-up": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||||
|
"dependencies": {
|
||||||
|
"locate-path": "^5.0.0",
|
||||||
|
"path-exists": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs/node_modules/locate-path": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||||
|
"dependencies": {
|
||||||
|
"p-locate": "^4.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs/node_modules/p-limit": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||||
|
"dependencies": {
|
||||||
|
"p-try": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs/node_modules/p-locate": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||||
|
"dependencies": {
|
||||||
|
"p-limit": "^2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yocto-queue": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
|
|||||||
@@ -26,6 +26,8 @@
|
|||||||
"mailparser": "^3.9.14",
|
"mailparser": "^3.9.14",
|
||||||
"next": "16.2.12",
|
"next": "16.2.12",
|
||||||
"nodemailer": "^9.0.3",
|
"nodemailer": "^9.0.3",
|
||||||
|
"otplib": "^13.4.1",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"sanitize-html": "^2.13.1",
|
"sanitize-html": "^2.13.1",
|
||||||
@@ -40,6 +42,7 @@
|
|||||||
"@types/mailparser": "^3.4.6",
|
"@types/mailparser": "^3.4.6",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/nodemailer": "^8.0.1",
|
"@types/nodemailer": "^8.0.1",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@types/sanitize-html": "^2.16.1",
|
"@types/sanitize-html": "^2.16.1",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getCurrentSession } from "@/lib/auth/session";
|
import { getCurrentSession } from "@/lib/auth/session";
|
||||||
import { ChangePasswordForm } from "./change-password-form";
|
import { ChangePasswordForm } from "./change-password-form";
|
||||||
|
import { TwoFactorSettings } from "./two-factor-settings";
|
||||||
|
|
||||||
export default async function AccountSettingsPage() {
|
export default async function AccountSettingsPage() {
|
||||||
const session = await getCurrentSession();
|
const session = await getCurrentSession();
|
||||||
@@ -9,6 +10,15 @@ export default async function AccountSettingsPage() {
|
|||||||
<h1 className="mb-1 font-display text-xl font-bold tracking-tight">Аккаунт</h1>
|
<h1 className="mb-1 font-display text-xl font-bold tracking-tight">Аккаунт</h1>
|
||||||
<p className="mb-6 text-sm text-text-muted">{session?.user.email}</p>
|
<p className="mb-6 text-sm text-text-muted">{session?.user.email}</p>
|
||||||
<ChangePasswordForm />
|
<ChangePasswordForm />
|
||||||
|
{session?.user.authSource === "local" ? (
|
||||||
|
<div className="mt-4">
|
||||||
|
<TwoFactorSettings initialEnabled={session.user.totpEnabled} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mt-4 rounded-md border border-border bg-surface-hover px-3 py-2 text-sm text-text-muted">
|
||||||
|
Двухфакторная аутентификация недоступна для LDAP-аккаунтов — вход защищён на уровне домена.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ShieldCheck, ShieldOff, Copy, Check } from "lucide-react";
|
||||||
|
|
||||||
|
type Stage = "idle" | "setting-up" | "recovery-codes" | "disabling";
|
||||||
|
|
||||||
|
export function TwoFactorSettings({ initialEnabled }: { initialEnabled: boolean }) {
|
||||||
|
const [enabled, setEnabled] = useState(initialEnabled);
|
||||||
|
const [stage, setStage] = useState<Stage>("idle");
|
||||||
|
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||||
|
const [secret, setSecret] = useState<string | null>(null);
|
||||||
|
const [code, setCode] = useState("");
|
||||||
|
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
setStage("idle");
|
||||||
|
setQrDataUrl(null);
|
||||||
|
setSecret(null);
|
||||||
|
setCode("");
|
||||||
|
setPassword("");
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startSetup() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const res = await fetch("/api/auth/totp/setup", { method: "POST" });
|
||||||
|
setLoading(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
setError(data?.error ?? "Не удалось начать настройку 2FA");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
setQrDataUrl(data.qrDataUrl);
|
||||||
|
setSecret(data.secret);
|
||||||
|
setStage("setting-up");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmSetup(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const res = await fetch("/api/auth/totp/confirm", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ code }),
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
setError(data?.error ?? "Не удалось подтвердить код");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
setRecoveryCodes(data.recoveryCodes);
|
||||||
|
setEnabled(true);
|
||||||
|
setStage("recovery-codes");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disable2fa(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const res = await fetch("/api/auth/totp/disable", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
setError(data?.error ?? "Не удалось отключить 2FA");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEnabled(false);
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyRecoveryCodes() {
|
||||||
|
if (!recoveryCodes) return;
|
||||||
|
navigator.clipboard.writeText(recoveryCodes.join("\n"));
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stage === "recovery-codes" && recoveryCodes) {
|
||||||
|
return (
|
||||||
|
<div className="card p-4">
|
||||||
|
<p className="mb-1 flex items-center gap-1.5 text-sm font-semibold text-success-soft-text">
|
||||||
|
<ShieldCheck size={15} />
|
||||||
|
2FA включена
|
||||||
|
</p>
|
||||||
|
<p className="mb-3 text-sm text-text-muted">
|
||||||
|
Сохраните эти резервные коды в надёжном месте — каждый работает один раз, если телефон с приложением
|
||||||
|
потеряется. Второй раз их показать не получится.
|
||||||
|
</p>
|
||||||
|
<pre className="mb-3 whitespace-pre-wrap rounded-md border border-border bg-surface p-3 text-sm">
|
||||||
|
{recoveryCodes.join("\n")}
|
||||||
|
</pre>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="button" onClick={copyRecoveryCodes} className="btn btn-ghost">
|
||||||
|
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||||
|
{copied ? "Скопировано" : "Скопировать"}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={reset} className="btn btn-primary flex-1 justify-center">
|
||||||
|
Готово
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stage === "setting-up") {
|
||||||
|
return (
|
||||||
|
<form onSubmit={confirmSetup} className="card p-4">
|
||||||
|
<p className="mb-3 text-sm font-semibold">Настройка 2FA</p>
|
||||||
|
<p className="mb-3 text-sm text-text-muted">
|
||||||
|
Отсканируйте QR-код в приложении-аутентификаторе (Google Authenticator, Authy и т.п.) и введите код, чтобы
|
||||||
|
подтвердить.
|
||||||
|
</p>
|
||||||
|
{qrDataUrl && (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element -- a locally generated data: URI QR code, not worth next/image config
|
||||||
|
<img src={qrDataUrl} alt="QR-код для настройки 2FA" className="mb-3 h-40 w-40" />
|
||||||
|
)}
|
||||||
|
{secret && (
|
||||||
|
<p className="mb-3 break-all rounded-md border border-border bg-surface px-2 py-1.5 font-mono text-xs text-text-muted">
|
||||||
|
{secret}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<label className="mb-3 block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Код из приложения</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="\d{6}"
|
||||||
|
maxLength={6}
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error && <p className="mb-3 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="button" onClick={reset} className="btn btn-ghost">
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={loading} className="btn btn-primary flex-1 justify-center">
|
||||||
|
Подтвердить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stage === "disabling") {
|
||||||
|
return (
|
||||||
|
<form onSubmit={disable2fa} className="card p-4">
|
||||||
|
<p className="mb-3 text-sm font-semibold">Отключить 2FA</p>
|
||||||
|
<label className="mb-3 block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Текущий пароль</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error && <p className="mb-3 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="button" onClick={reset} className="btn btn-ghost">
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={loading} className="btn bg-danger text-white hover:brightness-95 flex-1 justify-center">
|
||||||
|
Отключить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card p-4">
|
||||||
|
<p className="mb-1 flex items-center gap-1.5 text-sm font-semibold">
|
||||||
|
{enabled ? <ShieldCheck size={15} className="text-success-soft-text" /> : <ShieldOff size={15} className="text-text-faint" />}
|
||||||
|
Двухфакторная аутентификация
|
||||||
|
</p>
|
||||||
|
<p className="mb-3 text-sm text-text-muted">
|
||||||
|
{enabled ? "Включена — при входе потребуется код из приложения." : "Не включена."}
|
||||||
|
</p>
|
||||||
|
{error && <p className="mb-3 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
|
||||||
|
{enabled ? (
|
||||||
|
<button type="button" onClick={() => setStage("disabling")} className="btn btn-ghost w-full justify-center">
|
||||||
|
Отключить
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button type="button" onClick={startSetup} disabled={loading} className="btn btn-primary w-full justify-center">
|
||||||
|
Включить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+128
-50
@@ -3,12 +3,14 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { Ticket, LogIn } from "lucide-react";
|
import { Ticket, LogIn, ShieldCheck } from "lucide-react";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const [totpRequired, setTotpRequired] = useState(false);
|
||||||
|
const [code, setCode] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
@@ -30,6 +32,36 @@ export default function LoginPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
|
if (data.totpRequired) {
|
||||||
|
setTotpRequired(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push("/dashboard");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleVerifyTotp(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const res = await fetch("/api/auth/verify-totp", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ code }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
setError(data?.error ?? "Не удалось войти");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
router.push("/dashboard");
|
router.push("/dashboard");
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}
|
}
|
||||||
@@ -41,61 +73,107 @@ export default function LoginPage() {
|
|||||||
background: "radial-gradient(ellipse 60% 50% at 50% -10%, var(--accent-soft), var(--bg) 70%)",
|
background: "radial-gradient(ellipse 60% 50% at 50% -10%, var(--accent-soft), var(--bg) 70%)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<motion.form
|
{totpRequired ? (
|
||||||
onSubmit={handleSubmit}
|
<motion.form
|
||||||
initial={{ opacity: 0, y: 12 }}
|
onSubmit={handleVerifyTotp}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
transition={{ duration: 0.35, ease: "easeOut" }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className="card w-full max-w-sm p-8"
|
transition={{ duration: 0.35, ease: "easeOut" }}
|
||||||
>
|
className="card w-full max-w-sm p-8"
|
||||||
<div className="mb-6 flex items-center gap-2">
|
>
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-accent text-white">
|
<div className="mb-6 flex items-center gap-2">
|
||||||
<Ticket size={18} strokeWidth={2.5} />
|
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-accent text-white">
|
||||||
|
<ShieldCheck size={18} strokeWidth={2.5} />
|
||||||
|
</div>
|
||||||
|
<span className="font-display text-lg font-bold tracking-tight">top-tickets</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-display text-lg font-bold tracking-tight">top-tickets</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 className="mb-1 font-display text-xl font-semibold">Вход для агентов</h1>
|
<h1 className="mb-1 font-display text-xl font-semibold">Двухфакторная аутентификация</h1>
|
||||||
<p className="mb-6 text-sm text-text-muted">Войдите, чтобы открыть дашборд заявок.</p>
|
<p className="mb-6 text-sm text-text-muted">Введите код из приложения-аутентификатора или резервный код.</p>
|
||||||
|
|
||||||
<label className="mb-3 block text-sm">
|
<label className="mb-5 block text-sm">
|
||||||
<span className="mb-1 block font-medium text-text-muted">Email</span>
|
<span className="mb-1 block font-medium text-text-muted">Код</span>
|
||||||
<input
|
<input
|
||||||
type="email"
|
required
|
||||||
required
|
value={code}
|
||||||
value={email}
|
onChange={(e) => setCode(e.target.value)}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
autoFocus
|
||||||
autoFocus
|
/>
|
||||||
/>
|
</label>
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="mb-5 block text-sm">
|
{error && (
|
||||||
<span className="mb-1 block font-medium text-text-muted">Пароль</span>
|
<motion.p
|
||||||
<input
|
initial={{ opacity: 0 }}
|
||||||
type="password"
|
animate={{ opacity: 1 }}
|
||||||
required
|
className="mb-4 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text"
|
||||||
value={password}
|
>
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
{error}
|
||||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
</motion.p>
|
||||||
/>
|
)}
|
||||||
</label>
|
|
||||||
|
|
||||||
{error && (
|
<button type="submit" disabled={loading} className="btn btn-primary w-full justify-center">
|
||||||
<motion.p
|
<LogIn size={16} />
|
||||||
initial={{ opacity: 0 }}
|
{loading ? "Проверяем…" : "Войти"}
|
||||||
animate={{ opacity: 1 }}
|
</button>
|
||||||
className="mb-4 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text"
|
</motion.form>
|
||||||
>
|
) : (
|
||||||
{error}
|
<motion.form
|
||||||
</motion.p>
|
onSubmit={handleSubmit}
|
||||||
)}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.35, ease: "easeOut" }}
|
||||||
|
className="card w-full max-w-sm p-8"
|
||||||
|
>
|
||||||
|
<div className="mb-6 flex items-center gap-2">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-accent text-white">
|
||||||
|
<Ticket size={18} strokeWidth={2.5} />
|
||||||
|
</div>
|
||||||
|
<span className="font-display text-lg font-bold tracking-tight">top-tickets</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button type="submit" disabled={loading} className="btn btn-primary w-full justify-center">
|
<h1 className="mb-1 font-display text-xl font-semibold">Вход для агентов</h1>
|
||||||
<LogIn size={16} />
|
<p className="mb-6 text-sm text-text-muted">Войдите, чтобы открыть дашборд заявок.</p>
|
||||||
{loading ? "Входим…" : "Войти"}
|
|
||||||
</button>
|
<label className="mb-3 block text-sm">
|
||||||
</motion.form>
|
<span className="mb-1 block font-medium text-text-muted">Email</span>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="mb-5 block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Пароль</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="mb-4 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</motion.p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="submit" disabled={loading} className="btn btn-primary w-full justify-center">
|
||||||
|
<LogIn size={16} />
|
||||||
|
{loading ? "Входим…" : "Войти"}
|
||||||
|
</button>
|
||||||
|
</motion.form>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ import { z } from "zod";
|
|||||||
import { db } from "@/lib/db/client";
|
import { db } from "@/lib/db/client";
|
||||||
import { verifyPassword } from "@/lib/auth/password";
|
import { verifyPassword } from "@/lib/auth/password";
|
||||||
import { createSession, setSessionCookie } from "@/lib/auth/session";
|
import { createSession, setSessionCookie } from "@/lib/auth/session";
|
||||||
|
import { createLoginChallenge, setLoginChallengeCookie } from "@/lib/auth/login-challenge";
|
||||||
import { authenticateLdapUser } from "@/lib/ldap/client";
|
import { authenticateLdapUser } from "@/lib/ldap/client";
|
||||||
import { findOrCreateUserFromLdap } from "@/lib/auth/users";
|
import { findOrCreateUserFromLdap } from "@/lib/auth/users";
|
||||||
import { getLdapSettings } from "@/lib/auth/ldap-config";
|
import { getLdapSettings } from "@/lib/auth/ldap-config";
|
||||||
@@ -78,6 +79,14 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: "Invalid email or password" }, { status: 401 });
|
return NextResponse.json({ error: "Invalid email or password" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2FA only ever applies to local password accounts (see api/auth/totp/setup) —
|
||||||
|
// an LDAP login never reaches this branch at all, it returns above.
|
||||||
|
if (user.totpEnabled) {
|
||||||
|
const challengeToken = await createLoginChallenge(user.id);
|
||||||
|
await setLoginChallengeCookie(challengeToken);
|
||||||
|
return NextResponse.json({ ok: true, totpRequired: true });
|
||||||
|
}
|
||||||
|
|
||||||
const token = await createSession(user.id);
|
const token = await createSession(user.id);
|
||||||
await setSessionCookie(token);
|
await setSessionCookie(token);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { requireSession } from "@/lib/auth/require";
|
||||||
|
import { db } from "@/lib/db/client";
|
||||||
|
import { users, totpRecoveryCodes } from "@/lib/db/schema";
|
||||||
|
import { decryptTotpSecret, verifyTotpCode, generateRecoveryCodes, hashRecoveryCode } from "@/lib/auth/totp";
|
||||||
|
|
||||||
|
const schema = z.object({ code: z.string().min(6).max(6) });
|
||||||
|
|
||||||
|
/** Flips totpEnabled on after the user proves they can generate a live code, and issues recovery codes — shown once, never retrievable again. */
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const { session, response } = await requireSession();
|
||||||
|
if (!session) return response;
|
||||||
|
|
||||||
|
const parsed = schema.safeParse(await request.json().catch(() => null));
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "Введите 6-значный код" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await db.query.users.findFirst({ where: eq(users.id, session.user.id) });
|
||||||
|
if (!user?.totpSecret) {
|
||||||
|
return NextResponse.json({ error: "Сначала начните настройку 2FA" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await verifyTotpCode(decryptTotpSecret(user.totpSecret), parsed.data.code);
|
||||||
|
if (!ok) {
|
||||||
|
return NextResponse.json({ error: "Неверный код" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.update(users).set({ totpEnabled: true }).where(eq(users.id, user.id));
|
||||||
|
|
||||||
|
// Replace any codes from a previous enable/disable cycle.
|
||||||
|
await db.delete(totpRecoveryCodes).where(eq(totpRecoveryCodes.userId, user.id));
|
||||||
|
const recoveryCodes = generateRecoveryCodes();
|
||||||
|
await db.insert(totpRecoveryCodes).values(recoveryCodes.map((code) => ({ userId: user.id, codeHash: hashRecoveryCode(code) })));
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, recoveryCodes });
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { requireSession } from "@/lib/auth/require";
|
||||||
|
import { verifyPassword } from "@/lib/auth/password";
|
||||||
|
import { db } from "@/lib/db/client";
|
||||||
|
import { users, totpRecoveryCodes } from "@/lib/db/schema";
|
||||||
|
|
||||||
|
const schema = z.object({ password: z.string().min(1) });
|
||||||
|
|
||||||
|
/** Requires the current password (not a TOTP code) — matches change-password's confirmation pattern, and means a stolen live session alone still can't turn off 2FA. */
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const { session, response } = await requireSession();
|
||||||
|
if (!session) return response;
|
||||||
|
|
||||||
|
const parsed = schema.safeParse(await request.json().catch(() => null));
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await db.query.users.findFirst({ where: eq(users.id, session.user.id) });
|
||||||
|
if (!user?.passwordHash) {
|
||||||
|
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await verifyPassword(user.passwordHash, parsed.data.password);
|
||||||
|
if (!ok) {
|
||||||
|
return NextResponse.json({ error: "Неверный пароль" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.update(users).set({ totpSecret: null, totpEnabled: false }).where(eq(users.id, user.id));
|
||||||
|
await db.delete(totpRecoveryCodes).where(eq(totpRecoveryCodes.userId, user.id));
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { requireSession } from "@/lib/auth/require";
|
||||||
|
import { db } from "@/lib/db/client";
|
||||||
|
import { users } from "@/lib/db/schema";
|
||||||
|
import { generateTotpSecret, encryptTotpSecret, buildTotpQrCode } from "@/lib/auth/totp";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts (or restarts) 2FA setup — generates a fresh secret and stores it
|
||||||
|
* encrypted, but leaves totpEnabled false until /confirm proves the user
|
||||||
|
* actually scanned it (see users.totpEnabled comment in schema.ts). Safe to
|
||||||
|
* call again if the user abandons setup partway — it just overwrites the
|
||||||
|
* unconfirmed secret with a new one.
|
||||||
|
*/
|
||||||
|
export async function POST() {
|
||||||
|
const { session, response } = await requireSession();
|
||||||
|
if (!session) return response;
|
||||||
|
|
||||||
|
const user = await db.query.users.findFirst({ where: eq(users.id, session.user.id) });
|
||||||
|
if (!user) return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||||
|
if (user.authSource !== "local") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "2FA доступна только для локальных аккаунтов — вход через LDAP защищён на уровне домена" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = generateTotpSecret();
|
||||||
|
await db.update(users).set({ totpSecret: encryptTotpSecret(secret), totpEnabled: false }).where(eq(users.id, user.id));
|
||||||
|
|
||||||
|
const qrDataUrl = await buildTotpQrCode(secret, user.email);
|
||||||
|
return NextResponse.json({ secret, qrDataUrl });
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { eq, and, isNull } from "drizzle-orm";
|
||||||
|
import { db } from "@/lib/db/client";
|
||||||
|
import { users, totpRecoveryCodes } from "@/lib/db/schema";
|
||||||
|
import { createSession, setSessionCookie } from "@/lib/auth/session";
|
||||||
|
import { consumeLoginChallengeAttempt, deleteLoginChallenge, clearLoginChallengeCookie } from "@/lib/auth/login-challenge";
|
||||||
|
import { decryptTotpSecret, verifyTotpCode, hashRecoveryCode } from "@/lib/auth/totp";
|
||||||
|
|
||||||
|
const schema = z.object({ code: z.string().min(6).max(16) });
|
||||||
|
|
||||||
|
// Second step of login for a 2FA account — reads the pending-login cookie
|
||||||
|
// set by /api/auth/login, so the client never has to carry the challenge
|
||||||
|
// token itself. Accepts either a live 6-digit TOTP code or a recovery code.
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const parsed = schema.safeParse(await request.json().catch(() => null));
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Burns one attempt regardless of whether the code below turns out to be
|
||||||
|
// right — the whole point is capping brute-force *tries*, not just wrong ones.
|
||||||
|
const challenge = await consumeLoginChallengeAttempt();
|
||||||
|
if (!challenge) {
|
||||||
|
return NextResponse.json({ error: "Сессия входа истекла — войдите заново" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await db.query.users.findFirst({ where: eq(users.id, challenge.userId) });
|
||||||
|
if (!user || !user.totpEnabled || !user.totpSecret) {
|
||||||
|
await deleteLoginChallenge(challenge.tokenHash);
|
||||||
|
return NextResponse.json({ error: "2FA не настроена для этого аккаунта" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = parsed.data.code.trim();
|
||||||
|
let verified = /^\d{6}$/.test(code) && (await verifyTotpCode(decryptTotpSecret(user.totpSecret), code));
|
||||||
|
|
||||||
|
if (!verified) {
|
||||||
|
const codeHash = hashRecoveryCode(code);
|
||||||
|
const match = await db.query.totpRecoveryCodes.findFirst({
|
||||||
|
where: and(eq(totpRecoveryCodes.userId, user.id), eq(totpRecoveryCodes.codeHash, codeHash), isNull(totpRecoveryCodes.usedAt)),
|
||||||
|
});
|
||||||
|
if (match) {
|
||||||
|
await db.update(totpRecoveryCodes).set({ usedAt: new Date() }).where(eq(totpRecoveryCodes.id, match.id));
|
||||||
|
verified = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verified) {
|
||||||
|
return NextResponse.json({ error: "Неверный код" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteLoginChallenge(challenge.tokenHash);
|
||||||
|
await clearLoginChallengeCookie();
|
||||||
|
|
||||||
|
const token = await createSession(user.id);
|
||||||
|
await setSessionCookie(token);
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, user: { id: user.id, name: user.name, role: user.role } });
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db } from "@/lib/db/client";
|
||||||
|
import { loginChallenges } from "@/lib/db/schema";
|
||||||
|
|
||||||
|
// The "you passed the password check, now prove the TOTP code" state for a
|
||||||
|
// 2FA account — separate from the real `sessions` table (see schema.ts) so
|
||||||
|
// nothing can mistake a pending challenge for an authenticated session.
|
||||||
|
const CHALLENGE_COOKIE = "pending_login";
|
||||||
|
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
|
||||||
|
const MAX_ATTEMPTS = 6;
|
||||||
|
|
||||||
|
function hashToken(token: string): string {
|
||||||
|
return crypto.createHash("sha256").update(token).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createLoginChallenge(userId: string): Promise<string> {
|
||||||
|
const token = crypto.randomBytes(32).toString("base64url");
|
||||||
|
const tokenHash = hashToken(token);
|
||||||
|
const expiresAt = new Date(Date.now() + CHALLENGE_TTL_MS);
|
||||||
|
await db.insert(loginChallenges).values({ tokenHash, userId, expiresAt });
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setLoginChallengeCookie(token: string): Promise<void> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.set(CHALLENGE_COOKIE, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production" && process.env.COOKIE_ALLOW_INSECURE !== "true",
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
maxAge: CHALLENGE_TTL_MS / 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearLoginChallengeCookie(): Promise<void> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.delete(CHALLENGE_COOKIE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the pending-login cookie and burns one attempt against it —
|
||||||
|
* called once per verify-totp POST, before the code itself is even checked,
|
||||||
|
* so a guessing script can't rack up unlimited tries by never calling this.
|
||||||
|
* Returns null (nothing to resume) if the cookie is missing, the challenge
|
||||||
|
* doesn't exist, it's expired, or attempts are exhausted — the last two
|
||||||
|
* also delete the row so it can't be retried after the fact.
|
||||||
|
*/
|
||||||
|
export async function consumeLoginChallengeAttempt(): Promise<{ userId: string; tokenHash: string } | null> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get(CHALLENGE_COOKIE)?.value;
|
||||||
|
if (!token) return null;
|
||||||
|
const tokenHash = hashToken(token);
|
||||||
|
|
||||||
|
const challenge = await db.query.loginChallenges.findFirst({ where: eq(loginChallenges.tokenHash, tokenHash) });
|
||||||
|
if (!challenge) return null;
|
||||||
|
|
||||||
|
if (challenge.expiresAt.getTime() < Date.now() || challenge.attempts >= MAX_ATTEMPTS) {
|
||||||
|
await db.delete(loginChallenges).where(eq(loginChallenges.tokenHash, tokenHash));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.update(loginChallenges).set({ attempts: challenge.attempts + 1 }).where(eq(loginChallenges.tokenHash, tokenHash));
|
||||||
|
return { userId: challenge.userId, tokenHash };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteLoginChallenge(tokenHash: string): Promise<void> {
|
||||||
|
await db.delete(loginChallenges).where(eq(loginChallenges.tokenHash, tokenHash));
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, it, beforeAll } from "vitest";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
// encryptTotpSecret/decryptTotpSecret delegate to lib/crypto/credentials.ts,
|
||||||
|
// which reads this lazily (only when actually encrypting/decrypting) — a
|
||||||
|
// throwaway key here keeps the test independent of .env.
|
||||||
|
process.env.CREDENTIALS_ENCRYPTION_KEY = crypto.randomBytes(32).toString("base64");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("totp", () => {
|
||||||
|
it("generates a code that verifies against its own secret", async () => {
|
||||||
|
const { generateTotpSecret, verifyTotpCode } = await import("./totp");
|
||||||
|
const { generate } = await import("otplib");
|
||||||
|
|
||||||
|
const secret = generateTotpSecret();
|
||||||
|
const code = await generate({ secret });
|
||||||
|
expect(await verifyTotpCode(secret, code)).toBe(true);
|
||||||
|
expect(await verifyTotpCode(secret, "000000")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips a secret through encryption", async () => {
|
||||||
|
const { generateTotpSecret, encryptTotpSecret, decryptTotpSecret } = await import("./totp");
|
||||||
|
const secret = generateTotpSecret();
|
||||||
|
const encrypted = encryptTotpSecret(secret);
|
||||||
|
expect(encrypted).not.toContain(secret);
|
||||||
|
expect(decryptTotpSecret(encrypted)).toBe(secret);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("generates unique, human-typeable recovery codes and hashes deterministically", async () => {
|
||||||
|
const { generateRecoveryCodes, hashRecoveryCode } = await import("./totp");
|
||||||
|
const codes = generateRecoveryCodes(8);
|
||||||
|
expect(codes).toHaveLength(8);
|
||||||
|
expect(new Set(codes).size).toBe(8);
|
||||||
|
for (const code of codes) expect(code).toMatch(/^[0-9a-f]{5}-[0-9a-f]{5}$/);
|
||||||
|
|
||||||
|
expect(hashRecoveryCode(codes[0])).toBe(hashRecoveryCode(codes[0]));
|
||||||
|
expect(hashRecoveryCode(codes[0])).toBe(hashRecoveryCode(codes[0].toUpperCase()));
|
||||||
|
expect(hashRecoveryCode(codes[0])).not.toBe(hashRecoveryCode(codes[1]));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import { generateSecret, verify, generateURI } from "otplib";
|
||||||
|
import QRCode from "qrcode";
|
||||||
|
import { encryptCredential, decryptCredential } from "@/lib/crypto/credentials";
|
||||||
|
|
||||||
|
const ISSUER = "top-tickets";
|
||||||
|
const RECOVERY_CODE_COUNT = 8;
|
||||||
|
|
||||||
|
export function generateTotpSecret(): string {
|
||||||
|
return generateSecret();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encryptTotpSecret(secret: string): string {
|
||||||
|
return encryptCredential(secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decryptTotpSecret(encrypted: string): string {
|
||||||
|
return decryptCredential(encrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildTotpQrCode(secret: string, email: string): Promise<string> {
|
||||||
|
const uri = generateURI({ issuer: ISSUER, label: email, secret });
|
||||||
|
return QRCode.toDataURL(uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyTotpCode(secret: string, code: string): Promise<boolean> {
|
||||||
|
const result = await verify({ secret, token: code.trim() });
|
||||||
|
return result.valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-typeable: groups of 5 lowercase hex chars, e.g. "a1b2c-d3e4f". */
|
||||||
|
function formatRecoveryCode(): string {
|
||||||
|
const raw = crypto.randomBytes(5).toString("hex");
|
||||||
|
return `${raw.slice(0, 5)}-${raw.slice(5, 10)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateRecoveryCodes(count = RECOVERY_CODE_COUNT): string[] {
|
||||||
|
return Array.from({ length: count }, formatRecoveryCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashRecoveryCode(code: string): string {
|
||||||
|
return crypto.createHash("sha256").update(code.trim().toLowerCase()).digest("hex");
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
CREATE TABLE `login_challenges` (
|
||||||
|
`token_hash` text PRIMARY KEY NOT NULL,
|
||||||
|
`user_id` text NOT NULL,
|
||||||
|
`attempts` integer DEFAULT 0 NOT NULL,
|
||||||
|
`expires_at` integer NOT NULL,
|
||||||
|
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `totp_recovery_codes` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`user_id` text NOT NULL,
|
||||||
|
`code_hash` text NOT NULL,
|
||||||
|
`used_at` integer,
|
||||||
|
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `users` ADD `totp_secret` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `users` ADD `totp_enabled` integer DEFAULT false NOT NULL;
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -71,6 +71,13 @@
|
|||||||
"when": 1787129904542,
|
"when": 1787129904542,
|
||||||
"tag": "0009_little_natasha_romanoff",
|
"tag": "0009_little_natasha_romanoff",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 10,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1787213219014,
|
||||||
|
"tag": "0010_curved_jack_murdock",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -30,6 +30,13 @@ export const users = sqliteTable("users", {
|
|||||||
authSource: text("auth_source", { enum: ["local", "ldap"] })
|
authSource: text("auth_source", { enum: ["local", "ldap"] })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("local"),
|
.default("local"),
|
||||||
|
// Opt-in TOTP 2FA — local accounts only (see api/auth/totp/setup). The
|
||||||
|
// secret is AES-256-GCM encrypted at rest via lib/crypto/credentials.ts,
|
||||||
|
// same as every other stored credential in this app. Set as soon as
|
||||||
|
// "setup" generates a secret, but totpEnabled stays false until the user
|
||||||
|
// proves they scanned it right by confirming one live code.
|
||||||
|
totpSecret: text("totp_secret"),
|
||||||
|
totpEnabled: integer("totp_enabled", { mode: "boolean" }).notNull().default(false),
|
||||||
createdAt: timestamps.createdAt,
|
createdAt: timestamps.createdAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -44,6 +51,41 @@ export const sessions = sqliteTable("sessions", {
|
|||||||
createdAt: timestamps.createdAt,
|
createdAt: timestamps.createdAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time recovery codes, issued when 2FA is confirmed — SHA-256 hashed
|
||||||
|
* (they're low-entropy compared to a password, but only ever checked
|
||||||
|
* against a rate-limited login-challenge attempt counter, same as a TOTP
|
||||||
|
* code guess would be). usedAt marks a code as spent, not deleted, so the
|
||||||
|
* user can see in principle how many they've burned through — nothing
|
||||||
|
* currently surfaces that, but there's no reason to throw the row away.
|
||||||
|
*/
|
||||||
|
export const totpRecoveryCodes = sqliteTable("totp_recovery_codes", {
|
||||||
|
id: id(),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
codeHash: text("code_hash").notNull(),
|
||||||
|
usedAt: integer("used_at", { mode: "timestamp_ms" }),
|
||||||
|
createdAt: timestamps.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "you passed step 1 (password), now prove step 2 (TOTP)" state for a
|
||||||
|
* 2FA-enabled account — deliberately not the same table as `sessions`,
|
||||||
|
* since a login challenge must never be usable as a real session no matter
|
||||||
|
* what bug might otherwise conflate the two. Short TTL (5 min) and a capped
|
||||||
|
* attempt counter so it can't be brute-forced; see lib/auth/login-challenge.ts.
|
||||||
|
*/
|
||||||
|
export const loginChallenges = sqliteTable("login_challenges", {
|
||||||
|
tokenHash: text("token_hash").primaryKey(),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
attempts: integer("attempts").notNull().default(0),
|
||||||
|
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
|
||||||
|
createdAt: timestamps.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
/** People who file tickets — identified by whichever channel they came in on. */
|
/** People who file tickets — identified by whichever channel they came in on. */
|
||||||
export const customers = sqliteTable("customers", {
|
export const customers = sqliteTable("customers", {
|
||||||
id: id(),
|
id: id(),
|
||||||
|
|||||||
Reference in new issue
Block a user