settings and avatar

This commit is contained in:
Evert Prants 2024-05-17 17:31:14 +03:00
parent 5e178a6a19
commit 4a07389cca
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
35 changed files with 1397 additions and 182 deletions

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"editor.formatOnSave": true
}

277
package-lock.json generated
View File

@ -10,9 +10,12 @@
"dependencies": {
"@sveltejs/adapter-node": "^5.0.1",
"bcryptjs": "^2.4.3",
"cropperjs": "^1.6.2",
"drizzle-orm": "^0.30.10",
"mime-types": "^2.1.35",
"mysql2": "^3.9.7",
"otplib": "^12.0.1",
"qrcode": "^1.5.3",
"svelte-kit-cookie-session": "^4.0.0",
"sveltekit-i18n": "^2.4.2",
"uuid": "^9.0.1"
@ -23,7 +26,9 @@
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"@types/bcryptjs": "^2.4.6",
"@types/eslint": "^8.56.0",
"@types/mime-types": "^2.1.4",
"@types/node": "^20.12.12",
"@types/qrcode": "^1.5.5",
"@types/uuid": "^9.0.8",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
@ -1538,6 +1543,12 @@
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"dev": true
},
"node_modules/@types/mime-types": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.4.tgz",
"integrity": "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==",
"dev": true
},
"node_modules/@types/node": {
"version": "20.12.12",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.12.tgz",
@ -1553,6 +1564,15 @@
"integrity": "sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==",
"dev": true
},
"node_modules/@types/qrcode": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.5.tgz",
"integrity": "sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/resolve": {
"version": "1.20.2",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
@ -1795,7 +1815,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"engines": {
"node": ">=8"
}
@ -1804,7 +1823,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"dependencies": {
"color-convert": "^2.0.1"
},
@ -1936,6 +1954,14 @@
"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/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@ -2004,6 +2030,16 @@
"node": ">=0.10"
}
},
"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/code-red": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/code-red/-/code-red-1.0.4.tgz",
@ -2020,7 +2056,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"dependencies": {
"color-name": "~1.1.4"
},
@ -2031,8 +2066,7 @@
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"node_modules/commander": {
"version": "9.5.0",
@ -2062,6 +2096,11 @@
"node": ">= 0.6"
}
},
"node_modules/cropperjs": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.6.2.tgz",
"integrity": "sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA=="
},
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@ -2129,6 +2168,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/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@ -2185,6 +2232,11 @@
"node": "*"
}
},
"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/dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
@ -2787,6 +2839,16 @@
}
}
},
"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/encode-utf8": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/encode-utf8/-/encode-utf8-1.0.3.tgz",
"integrity": "sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw=="
},
"node_modules/env-paths": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz",
@ -3340,6 +3402,14 @@
"is-property": "^1.0.2"
}
},
"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-tsconfig": {
"version": "4.7.5",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.7.5.tgz",
@ -3613,6 +3683,14 @@
"node": ">=0.10.0"
}
},
"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-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@ -3870,6 +3948,25 @@
"node": ">=8.6"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/min-indent": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
@ -4076,6 +4173,14 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"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": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@ -4092,7 +4197,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"engines": {
"node": ">=8"
}
@ -4155,6 +4259,14 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"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/postcss": {
"version": "8.4.38",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz",
@ -4309,6 +4421,23 @@
"node": ">=6"
}
},
"node_modules/qrcode": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.3.tgz",
"integrity": "sha512-puyri6ApkEHYiVl4CFzo1tDkAZ+ATcnbJrJ6RiBM1Fhctdn/ix9MTE3hRph33omisEbC/2fcfemsseiKgBPKZg==",
"dependencies": {
"dijkstrajs": "^1.0.1",
"encode-utf8": "^1.0.3",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@ -4341,6 +4470,19 @@
"node": ">=8.10.0"
}
},
"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": {
"version": "1.22.8",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz",
@ -4514,6 +4656,11 @@
"resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
"integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="
},
"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-cookie-parser": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz",
@ -4618,11 +4765,23 @@
"node": ">= 0.6"
}
},
"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/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"dependencies": {
"ansi-regex": "^5.0.1"
},
@ -5072,6 +5231,11 @@
"node": ">= 8"
}
},
"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/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@ -5087,11 +5251,29 @@
"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
"dev": true
},
"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": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"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/yaml": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
@ -5101,6 +5283,87 @@
"node": ">= 6"
}
},
"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": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@ -17,7 +17,9 @@
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"@types/bcryptjs": "^2.4.6",
"@types/eslint": "^8.56.0",
"@types/mime-types": "^2.1.4",
"@types/node": "^20.12.12",
"@types/qrcode": "^1.5.5",
"@types/uuid": "^9.0.8",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
@ -37,9 +39,12 @@
"dependencies": {
"@sveltejs/adapter-node": "^5.0.1",
"bcryptjs": "^2.4.3",
"cropperjs": "^1.6.2",
"drizzle-orm": "^0.30.10",
"mime-types": "^2.1.35",
"mysql2": "^3.9.7",
"otplib": "^12.0.1",
"qrcode": "^1.5.3",
"svelte-kit-cookie-session": "^4.0.0",
"sveltekit-i18n": "^2.4.2",
"uuid": "^9.0.1"

View File

@ -10,25 +10,26 @@
--in-outline-color: #00aaff;
--in-normalized-background: #000;
--in-input-background: #fff;
--in-input-background-disabled: #c2c2c2;
--in-input-color: #000;
--in-input-color-disabled: #414141;
--in-input-border-color: #ddd;
--in-input-border-color-disabled: #a0a0a0;
--in-alert-color: #006597;
--in-error-color: #b52e2e;
--in-success-color: #1e7f27;
--in-modal-background: #fff;
--in-modal-backdrop: rgba(0, 0, 0, 0.3);
--in-modal-divider-color: #ddd;
--in-focus-outline: 3px solid var(--in-outline-color);
}
:root {
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Open Sans',
'Helvetica Neue',
sans-serif;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell,
'Open Sans', 'Helvetica Neue', sans-serif;
color: var(--in-text-color);
}

View File

@ -0,0 +1,36 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import Button from './Button.svelte';
export let type: 'default' | 'error' | 'success' = 'default';
export let dismissable = false;
const dispatch = createEventDispatcher();
</script>
<div class="alert alert-{type}" role="alert">
<p><slot /></p>
{#if dismissable}<Button variant="link" on:click={() => dispatch('dismiss')}>x</Button>{/if}
</div>
<style>
.alert {
background-color: var(--in-alert-color);
width: 100%;
border-radius: 8px;
padding: 24px 28px;
font-size: 1.15rem;
& p {
margin: 0;
}
&.alert-error {
background-color: var(--in-error-color);
}
&.alert-success {
background-color: var(--in-success-color);
}
}
</style>

View File

@ -1,9 +1,12 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
export let type: 'button' | 'submit' = 'button';
export let variant: 'default' | 'primary' | 'link' = 'default';
const dispath = createEventDispatcher();
</script>
<button {type} class="btn btn-{variant}"><slot /></button>
<button {type} class="btn btn-{variant}" on:click={(e) => dispath('click', e)}><slot /></button>
<style>
.btn {

View File

@ -0,0 +1,11 @@
<div class="column">
<slot />
</div>
<style>
.column {
display: flex;
flex-direction: column;
gap: 1rem;
}
</style>

View File

@ -0,0 +1,23 @@
<section class="page-wrapper">
<div class="page-inner">
<slot />
</div>
</section>
<style>
.page-wrapper {
display: flex;
justify-content: center;
width: 100%;
}
.page-inner {
display: flex;
flex-direction: column;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
padding: 40px;
max-width: 1080px;
width: 100%;
}
</style>

View File

@ -0,0 +1,115 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
export let showModal: boolean;
let dialog: HTMLDialogElement;
const dispatch = createEventDispatcher();
$: if (dialog && showModal) dialog.showModal();
$: if (dialog?.open && !showModal) dialog.close();
</script>
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-noninteractive-element-interactions -->
<dialog
bind:this={dialog}
on:close={() => {
showModal = false;
dispatch('close');
}}
on:click|self={() => dialog.close()}
>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:click|stopPropagation>
{#if $$slots.header}
<div class="dialog-header">
<slot name="header" />
</div>
{/if}
<div class="dialog-body">
<slot />
</div>
{#if $$slots.footer}
<div class="dialog-footer">
<slot name="footer" />
</div>
{/if}
</div>
</dialog>
<style>
dialog {
max-width: 32em;
border-radius: 0.2em;
border: none;
padding: 0;
background-color: var(--in-modal-background);
}
dialog::backdrop {
background: var(--in-modal-backdrop);
}
dialog[open] {
animation: zoom 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
dialog[open]::backdrop {
animation: fade 0.2s ease-out;
}
.dialog-header {
padding: auto;
}
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 2rem 2rem 1rem 2rem;
border-bottom: 1px solid var(--in-modal-divider-color);
}
.dialog-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 2rem 2rem 2rem;
border-top: 1px solid var(--in-modal-divider-color);
}
.dialog-body {
padding: 1rem 2rem;
}
.dialog-header > :global(span) {
font-weight: 700;
}
.dialog-header > :global(.btn) {
font-weight: 700;
font-size: 1.5rem;
}
.dialog-footer :global(.btn-link) {
color: var(--in-input-color);
}
@keyframes zoom {
from {
transform: scale(0.9);
}
to {
transform: scale(1);
}
}
@keyframes fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>

View File

@ -0,0 +1,27 @@
<div class="aside-wrapper">
<div class="aside-inner">
<slot />
</div>
</div>
<style>
.aside-wrapper {
display: flex;
justify-content: flex-end;
}
.aside-wrapper,
.aside-inner {
height: 100%;
}
.aside-inner {
display: flex;
flex-direction: column;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
padding: 40px;
max-width: 600px;
width: 100%;
}
</style>

View File

@ -0,0 +1,19 @@
<div class="split">
<slot />
<slot name="side" />
</div>
<style>
.split {
display: flex;
gap: 24px;
@media screen and (max-width: 768px) {
flex-direction: column;
}
}
.split > :global(*) {
flex-basis: calc(50% - 24px);
}
</style>

View File

@ -0,0 +1,31 @@
<script lang="ts">
export let user: { uuid: string; username: string };
export let cacheBust: number;
$: avatarSource = `/api/avatar/${user.uuid}?t=${cacheBust}`;
</script>
<div class="avatar-wrapper">
<div class="image-wrapper">
<img src={avatarSource} alt={user.username} />
</div>
<div class="actions-wrapper">
<slot />
</div>
</div>
<style>
.avatar-wrapper {
display: flex;
gap: 16px;
}
.image-wrapper {
display: flex;
height: 120px;
width: 120px;
flex: 0 0 120px;
background-color: var(--in-normalized-background);
}
</style>

View File

@ -0,0 +1,152 @@
<script lang="ts">
import type { Writable } from 'svelte/store';
import Modal from '../Modal.svelte';
import { t } from '$lib/i18n';
import Button from '../Button.svelte';
import Cropper from 'cropperjs';
import 'cropperjs/dist/cropper.css';
import { invalidateAll } from '$app/navigation';
import FormControl from '../form/FormControl.svelte';
import { allowedImages } from '$lib/constants';
export let show: Writable<boolean>;
let cropper: Cropper;
let image: HTMLImageElement;
let resultImage: HTMLImageElement;
let resultBlob: Blob | null = null;
let picker = true;
let ready = false;
const resetCropper = () => {
cropper?.destroy();
image.src = '';
};
const reset = () => {
resultImage.src = '';
picker = true;
ready = false;
resultBlob = null;
resetCropper();
};
const createCrop = () => {
cropper = new Cropper(image, {
aspectRatio: 1,
viewMode: 1,
modal: false
});
};
const createCropResult = (): void => {
const cropCanvas = cropper.getCroppedCanvas({
maxHeight: 1024,
maxWidth: 1024,
imageSmoothingEnabled: true,
imageSmoothingQuality: 'high'
});
resultImage.src = cropCanvas.toDataURL();
cropCanvas.toBlob((blob) => {
resultBlob = blob;
ready = true;
resetCropper();
});
};
const readFile = (target: EventTarget | null) => {
const input = target as HTMLInputElement;
const file = input?.files?.item(0);
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
picker = false;
image.src = reader.result as string;
createCrop();
};
reader.readAsDataURL(file);
input.value = '';
};
const upload = async () => {
if (!resultBlob) return;
const data = new FormData();
data.append('file', resultBlob);
await fetch(`/account?/avatar`, {
method: 'POST',
body: data,
credentials: 'include',
headers: {
'x-sveltekit-action': 'true'
}
});
$show = false;
await invalidateAll();
};
$: if (!$show && image?.src) reset();
</script>
<Modal showModal={$show} on:close={() => ($show = false)}>
<span slot="header">{$t('account.avatar.change')}</span>
<div>
{#if picker}
<FormControl>
<label for="avatar-file">{$t('account.avatar.uploadLabel')}</label>
<input type="file" on:change={(e) => readFile(e.target)} accept={allowedImages.join(',')} />
<span>{$t('account.avatar.hint')}</span>
</FormControl>
{/if}
<div class="crop-wrapper">
<img bind:this={image} alt="" class="preview" />
</div>
<div class="crop-result">
<img bind:this={resultImage} alt="" class="result" />
</div>
</div>
<div class="actions" slot="footer">
<Button variant="link" on:click={() => ($show = false)}>{$t('common.cancel')}</Button>
{#if !picker}
<Button variant="primary" on:click={() => reset()}>{$t('account.avatar.restart')}</Button>
{#if !ready}
<Button variant="primary" on:click={() => createCropResult()}
>{$t('account.avatar.done')}</Button
>
{:else}
<Button variant="primary" on:click={() => upload()}>{$t('account.avatar.upload')}</Button>
{/if}
{/if}
</div>
</Modal>
<style>
.crop-wrapper,
.crop-result {
display: flex;
flex-direction: column;
max-height: 400px;
& > img {
overflow: auto;
object-fit: contain;
}
}
.actions {
width: 100%;
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
}
</style>

View File

@ -1,33 +1,47 @@
<div class="form-control">
<slot></slot>
</div>
<style>
:global(input) {
&:not([type]),
&[type=text],
&[type=password],
&[type=email] {
padding: 8px;
font-size: 1rem;
background-color: var(--in-input-background);
color: var(--in-input-color);
border: 2px solid var(--in-input-border-color);
&:not([type]),
&[type='text'],
&[type='password'],
&[type='email'] {
padding: 8px;
font-size: 1rem;
border-radius: 6px;
&:focus-visible {
outline: var(--in-focus-outline);
}
&[disabled] {
background-color: var(--in-input-background-disabled);
border-color: var(--in-input-border-color-disabled);
color: var(--in-input-color-disabled);
cursor: not-allowed;
}
}
}
:global(label) {
margin-bottom: 2px;
.form-control > :global(label) {
margin-bottom: 4px;
font-size: 1.1rem;
}
.form-control > :global(span) {
margin-top: 4px;
font-size: 0.9rem;
}
.form-control {
display: flex;
flex-direction: column;
width: 100%;
}
</style>

View File

@ -1,4 +1,23 @@
<script lang="ts">
export let title: string = '';
</script>
<div class="form-section">
{#if title}<div class="form-subtitle">{title}</div>{/if}
<slot />
</div>
<style>
.form-section {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
align-items: flex-start;
}
.form-subtitle {
font-size: 1.2rem;
font-weight: 700;
}
</style>

View File

@ -1,10 +1,13 @@
<div class="form-wrapper">
<slot />
</div>
<style>
:global(.form-control) {
margin-bottom: 16px;
.form-wrapper {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
align-items: flex-start;
}
</style>

1
src/lib/constants.ts Normal file
View File

@ -0,0 +1 @@
export const allowedImages = ['image/png', 'image/jpg', 'image/jpeg'];

View File

@ -1,4 +1,5 @@
{
"title": "Manage your account",
"username": "Username",
"displayName": "Display Name",
"changeEmail": "Change email address",
@ -8,12 +9,26 @@
"currentPassword": "Current password",
"newPassword": "New password",
"repeatPassword": "Repeat new password",
"passwordHint": "At least 8 characters, a capital letter and a number required.",
"submit": "Submit",
"changeSuccess": "Account settings changed successfully!",
"avatar": {
"title": "Profile avatar",
"change": "Change avatar",
"remove": "Remove avatar",
"done": "Done",
"restart": "Restart",
"upload": "Upload",
"uploadLabel": "Upload image file",
"hint": "Allowed image formats: .png, .jpg, .jpeg. Image must be less than 10 MB in size."
},
"login": {
"title": "Log in",
"email": "Email",
"password": "Password",
"submit": "Log in"
"submit": "Log in",
"otp": "Two-factor authentication is enabled",
"otpCode": "Enter the code displayed on the authenticator app"
},
"errors": {
"invalidLogin": "Invalid email or password!",
@ -23,6 +38,19 @@
"passwordRequired": "The password is required.",
"passwordMismatch": "The passwords do not match!",
"invalidPassword": "The provided password is invalid.",
"invalidDisplayName": "The provided display name is invalid."
"invalidDisplayName": "The provided display name is invalid.",
"otpFailed": "The code you entered was invalid. Please note that you will be given a new QR code for subsequent retries."
},
"otp": {
"title": "Two-factor authentication",
"enabled": "Two-factor authentication is enabled",
"disabled": "Your account does not have two-factor authentication enabled.",
"activated": "Two-factor authentication has been activated successfully!",
"scan": "Scan this QR code with the authenticator app of your choice",
"code": "Enter the code displayed on the authenticator app",
"return": "Return to account management",
"retry": "Try again",
"activate": "Set up two factor authentication",
"deactivate": "Deactivate"
}
}

View File

@ -1,5 +1,8 @@
{
"siteName": "Icy Network",
"description": "Icy Network is a Single-Sign-On service used by other applications.",
"cookieDisclaimer": "The website may use temporary cookies for storing your login session and ensuring your security. This web service is&nbsp;<a href=\"https://git.icynet.eu/IcyNetwork/icynet-auth-server\" target=\"_blank\">completely open source</a> and can be audited by anyone."
"cookieDisclaimer": "The website may use temporary cookies for storing your login session and ensuring your security. This web service is&nbsp;<a href=\"https://git.icynet.eu/IcyNetwork/icynet-auth-server\" target=\"_blank\">completely open source</a> and can be audited by anyone.",
"submit": "Submit",
"cancel": "Cancel",
"manage": "Manage"
}

View File

@ -66,7 +66,7 @@ export class Challenge {
const userOtp = await TimeOTP.getUserOtp(subject);
if (!userOtp) {
throw new Error('Invalid request');
throw new Error('User has no OTP');
}
const data = await Challenge.challengeFromBody<TRes>(body, subject.uuid, userOtp.token);

View File

@ -9,7 +9,7 @@ import {
timestamp,
mysqlEnum,
index,
type AnyMySqlColumn,
type AnyMySqlColumn
} from 'drizzle-orm/mysql-core';
export const auditLog = mysqlTable('audit_log', {
@ -131,6 +131,8 @@ export const upload = mysqlTable('upload', {
.notNull()
});
export type Upload = typeof upload.$inferSelect;
export const user = mysqlTable(
'user',
{

67
src/lib/server/upload.ts Normal file
View File

@ -0,0 +1,67 @@
import { eq } from 'drizzle-orm';
import { db, upload, user, type Upload, type User } from './drizzle';
import { Users } from './users';
import { readFile, unlink, writeFile } from 'fs/promises';
import { join } from 'path';
import * as mime from 'mime-types';
const fallbackImage = await readFile(join('static', 'avatar.png'));
export class Uploads {
static fallbackImage = fallbackImage;
static uploads = join('uploads');
static async getAvatarByUuid(
uuid: string
): Promise<{ file: string; mimetype: string } | undefined> {
const user = await Users.getByUuid(uuid);
if (!user?.pictureId) {
return undefined;
}
const [picture] = await db
.select({ mimetype: upload.mimetype, file: upload.file })
.from(upload)
.where(eq(upload.id, user.pictureId));
return picture;
}
static async removeUpload(subject: Upload) {
try {
unlink(join(Uploads.uploads, subject.file));
} catch {
// ignore unlink error
}
await db.delete(upload).where(eq(upload.id, subject.id));
}
static async removeAvatar(subject: User) {
if (!subject.pictureId) return;
const [fileinfo] = await db.select().from(upload).where(eq(upload.id, subject.pictureId));
if (fileinfo) {
await Uploads.removeUpload(fileinfo);
}
await db.update(user).set({ pictureId: null }).where(eq(user.id, subject.id));
}
static async saveAvatar(subject: User, file: File) {
const ext = mime.extension(file.type);
const newName = `user-${subject.uuid.split('-')[0]}-${Math.floor(Date.now() / 1000)}.${ext}`;
const arrayBuffer = await file.arrayBuffer();
// Write to filesystem
await writeFile(join(Uploads.uploads, newName), Buffer.from(arrayBuffer));
// Remove old
await Uploads.removeAvatar(subject);
// Update DB
const [retval] = await db.insert(upload).values({
original_name: file.name,
mimetype: file.type,
file: newName,
uploaderId: subject.id
});
await db.update(user).set({ pictureId: retval.insertId });
}
}

View File

@ -4,6 +4,15 @@ import { db, user, type User } from '../drizzle';
import type { UserSession } from './types';
export class Users {
static async getByUuid(uuid: string): Promise<User | undefined> {
const [result] = await db
.select()
.from(user)
.where(and(eq(user.uuid, uuid), eq(user.activated, 1)))
.limit(1);
return result;
}
static async getByLogin(login: string): Promise<User | undefined> {
const [result] = await db
.select()

View File

@ -47,4 +47,12 @@ export class TimeOTP {
.limit(1);
return token;
}
public static async saveUserOtp(subject: User, secret: string) {
await db.insert(userToken).values({
type: 'totp',
token: secret,
userId: subject.id
});
}
}

View File

@ -0,0 +1,3 @@
import { redirect } from '@sveltejs/kit';
export const load = () => redirect(302, '/account');

View File

@ -1,7 +1,9 @@
import { Challenge } from '$lib/server/challenge.js';
import type { User } from '$lib/server/drizzle';
import { Uploads } from '$lib/server/upload.js';
import { Users, type UserSession } from '$lib/server/users/index.js';
import { TimeOTP } from '$lib/server/users/totp.js';
import { passwordRegex } from '$lib/validators.js';
import { fail, redirect } from '@sveltejs/kit';
interface AccountUpdate {
@ -23,7 +25,7 @@ export const actions = {
const currentUser = await Users.getBySession(locals.session.data?.user);
if (!currentUser) {
await locals.session.destroy();
return redirect(301, '/login');
return redirect(303, '/login');
}
const body = await request.formData();
@ -86,6 +88,14 @@ export const actions = {
});
}
if (data.newPassword && !passwordRegex.test(data.newPassword)) {
return fail(400, {
displayName: data.displayName,
errors: ['invalidPassword'],
fields: ['newPassword']
});
}
// Invalid display name
if (data.displayName && (data.displayName.length < 3 || data.displayName.length > 32)) {
return fail(400, {
@ -97,7 +107,7 @@ export const actions = {
// When updating email or password, we check if OTP has been enabled.
// If it is, we need to ask for the OTP code.
if (data.newEmail || data.newPassword) {
if (!body.has('challenge') && (data.newEmail || data.newPassword)) {
const isOtp = await TimeOTP.isUserOtp(currentUser);
if (isOtp) {
const challenge = await Challenge.issueChallenge(data, currentUser.uuid);
@ -136,6 +146,39 @@ export const actions = {
fields: <string[]>[],
displayName: data.displayName || currentUser.display_name
};
},
avatar: async ({ request, locals }) => {
const currentUser = await Users.getBySession(locals.session.data?.user);
if (!currentUser) {
await locals.session.destroy();
return redirect(303, '/login');
}
const formData = Object.fromEntries(await request.formData());
if (!(formData.file as File).name || (formData.file as File).name === 'undefined') {
return fail(400, {
error: true,
message: 'You must provide a file to upload'
});
}
const { file } = formData as { file: File };
await Uploads.saveAvatar(currentUser, file);
return { avatarChanged: true };
},
removeAvatar: async ({ locals }) => {
const currentUser = await Users.getBySession(locals.session.data?.user);
if (!currentUser) {
await locals.session.destroy();
return redirect(303, '/login');
}
await Uploads.removeAvatar(currentUser);
return { avatarChanged: true };
}
};
@ -148,9 +191,12 @@ export async function load({ locals, url }) {
}
const otpEnabled = await TimeOTP.isUserOtp(currentUser);
const updateRef = Date.now();
return {
user: userInfo,
otpEnabled
otpEnabled,
hasAvatar: !!currentUser.pictureId,
updateRef
};
}

View File

@ -2,15 +2,82 @@
import { t } from '$lib/i18n';
import LogoutButton from '$lib/components/LogoutButton.svelte';
import type { ActionData, PageData } from './$types';
import FormControl from '$lib/components/form/FormControl.svelte';
import FormSection from '$lib/components/form/FormSection.svelte';
import FormWrapper from '$lib/components/form/FormWrapper.svelte';
import Button from '$lib/components/Button.svelte';
import Alert from '$lib/components/Alert.svelte';
import ViewColumn from '$lib/components/ColumnView.svelte';
import MainContainer from '$lib/components/MainContainer.svelte';
import SplitView from '$lib/components/SplitView.svelte';
import { enhance } from '$app/forms';
import type { SubmitFunction } from '@sveltejs/kit';
import AvatarCard from '$lib/components/avatar/AvatarCard.svelte';
import AvatarModal from '$lib/components/avatar/AvatarModal.svelte';
import { writable } from 'svelte/store';
export let data: PageData;
export let form: ActionData;
let internalErrors: string[] = [];
$: errors = [...internalErrors, ...(form?.errors?.length ? form.errors : [])];
let usernameRef: HTMLInputElement;
let displayRef: HTMLInputElement;
let showAvatarModal = writable(false);
const enhanceFn: SubmitFunction = ({ formData, cancel }) => {
internalErrors.length = 0;
const pwd = formData.get('newPassword') as string;
const repeat = formData.get('repeatPassword') as string;
if (pwd && pwd !== repeat) {
internalErrors.push('passwordMismatch');
return cancel();
}
return async ({ update }) => {
await update();
// Reset these values, as they are cleared but we don't want that.
if (usernameRef) {
usernameRef.value = data.user.username;
}
if (displayRef) {
displayRef.value = form?.displayName || data.user.name;
}
};
};
</script>
<span>{data.user.name}</span>
<MainContainer>
<h1>{$t('common.siteName')}</h1>
<SplitView>
<div>
<h2>{$t('account.title')}</h2>
<form action="?/update" method="POST">
<div class="form-control">
<form action="?/update" method="POST" use:enhance={enhanceFn}>
<FormWrapper>
{#if form?.success}<Alert type="success">{$t('account.changeSuccess')}</Alert>{/if}
{#if errors.length}
{#each errors as error}
<Alert type="error">{$t(`account.errors.${error}`)}</Alert>
{/each}
{/if}
{#if form?.otpRequired}
<!-- Two-factor code request -->
<FormSection title={$t('account.login.otp')}>
<input name="challenge" value={form.otpRequired} type="hidden" />
<FormControl>
<label for="form-otpCode">{$t('account.login.otpCode')}</label>
<!-- svelte-ignore a11y-autofocus -->
<input id="form-otpCode" name="otpCode" autocomplete="off" autofocus />
</FormControl>
</FormSection>
{:else}
<FormControl>
<label for="form-username">{$t('account.username')}</label>
<input
type="text"
@ -18,34 +85,35 @@
value={data.user.username}
id="form-username"
autocomplete="username"
bind:this={usernameRef}
/>
</div>
</FormControl>
<div class="form-control">
<FormControl>
<label for="form-displayName">{$t('account.displayName')}</label>
<input
type="text"
name="displayName"
value={form?.displayName || data.user.name}
id="form-displayName"
bind:this={displayRef}
/>
</div>
</FormControl>
<div class="form-subtitle">{$t('account.changeEmail')}</div>
<div class="form-control">
<FormSection title={$t('account.changeEmail')}>
<FormControl>
<label for="form-currentEmail">{$t('account.currentEmail')}</label>
<input type="email" name="currentEmail" id="form-currentEmail" />
</div>
</FormControl>
<div class="form-control">
<FormControl>
<label for="form-newEmail">{$t('account.newEmail')}</label>
<input type="email" name="newEmail" id="form-newEmail" />
</div>
</FormControl>
</FormSection>
<div class="form-subtitle">{$t('account.changePassword')}</div>
<div class="form-control">
<FormSection title={$t('account.changePassword')}>
<FormControl>
<label for="form-currentPassword">{$t('account.currentPassword')}</label>
<input
type="password"
@ -53,14 +121,21 @@
name="currentPassword"
id="form-currentPassword"
/>
</div>
</FormControl>
<div class="form-control">
<FormControl>
<label for="form-newPassword">{$t('account.newPassword')}</label>
<input type="password" autocomplete="new-password" name="newPassword" id="form-newPassword" />
</div>
<input
type="password"
autocomplete="new-password"
name="newPassword"
id="form-newPassword"
aria-describedby="new-password-hint"
/>
<span id="new-password-hint">{$t('account.passwordHint')}</span>
</FormControl>
<div class="form-control">
<FormControl>
<label for="form-repeatPassword">{$t('account.repeatPassword')}</label>
<input
type="password"
@ -68,9 +143,44 @@
name="repeatPassword"
id="form-repeatPassword"
/>
</FormControl>
</FormSection>
{/if}
<Button variant="primary" type="submit">{$t('account.submit')}</Button>
</FormWrapper>
</form>
</div>
<button class="btn">{$t('account.submit')}</button>
</form>
<ViewColumn slot="side">
<div>
<h3>{$t('account.avatar.title')}</h3>
<AvatarCard user={data.user} cacheBust={data.updateRef}>
<ViewColumn>
<Button variant="primary" on:click={() => ($showAvatarModal = true)}
>{$t('account.avatar.change')}</Button
>
{#if data.hasAvatar}
<form action="?/removeAvatar" method="POST" use:enhance>
<Button variant="link" type="submit">{$t('account.avatar.remove')}</Button>
</form>
{/if}
</ViewColumn>
</AvatarCard>
</div>
<LogoutButton />
<div>
<h3>{$t('account.otp.title')}</h3>
{#if data.otpEnabled}
<p>{$t('account.otp.enabled')}</p>
{:else}
<p>{$t('account.otp.disabled')}</p>
{/if}
<a href="/account/two-factor">{$t('common.manage')}</a>
</div>
</ViewColumn>
</SplitView>
<LogoutButton />
</MainContainer>
<AvatarModal show={showAvatarModal} />

View File

@ -0,0 +1,79 @@
import { Challenge, type ChallengeBody } from '$lib/server/challenge.js';
import { Changesets } from '$lib/server/changesets.js';
import { CryptoUtils } from '$lib/server/crypto-utils';
import type { User } from '$lib/server/drizzle';
import { Users } from '$lib/server/users';
import { TimeOTP } from '$lib/server/users/totp.js';
import { fail, redirect } from '@sveltejs/kit';
import * as QRCode from 'qrcode';
interface ActivateChallenge {
secret: string;
}
interface ActivateRequest {
challenge: string;
otpCode?: string;
}
const issueActivateChallenge = async (subject: User) => {
const secret = TimeOTP.createSecret();
const challenge = await Challenge.issueChallenge<ActivateChallenge>({ secret }, subject.uuid);
const uri = TimeOTP.getUri(secret, subject.username);
const qr = await QRCode.toDataURL(uri);
return { challenge, qr, uri };
};
export const actions = {
activate: async ({ locals, request }) => {
const currentUser = await Users.getBySession(locals.session.data?.user);
if (!currentUser) {
await locals.session.destroy();
return redirect(303, '/login');
}
const body = await request.formData();
const { challenge, otpCode } = Changesets.take<ActivateRequest>(['challenge', 'otpCode'], body);
if (!challenge) {
return issueActivateChallenge(currentUser);
}
const decoded = await CryptoUtils.decryptChallenge<ChallengeBody<ActivateChallenge>>(challenge);
if (
!otpCode ||
decoded?.aud !== currentUser.uuid ||
!decoded?.data?.secret ||
!TimeOTP.validate(decoded.data.secret, otpCode)
) {
return fail(400, { invalid: true });
}
await TimeOTP.saveUserOtp(currentUser, decoded.data.secret);
// TODO: audit log
return { success: true };
},
deactivate: async ({ locals }) => {
const currentUser = await Users.getBySession(locals.session.data?.user);
if (!currentUser) {
await locals.session.destroy();
return redirect(303, '/login');
}
}
};
export async function load({ locals }) {
const currentUser = await Users.getBySession(locals.session.data?.user);
if (!currentUser) {
await locals.session.destroy();
return redirect(301, '/login');
}
const otpEnabled = await TimeOTP.isUserOtp(currentUser);
return {
otpEnabled
};
}

View File

@ -0,0 +1,58 @@
<script lang="ts">
import Alert from '$lib/components/Alert.svelte';
import Button from '$lib/components/Button.svelte';
import MainContainer from '$lib/components/MainContainer.svelte';
import FormControl from '$lib/components/form/FormControl.svelte';
import FormSection from '$lib/components/form/FormSection.svelte';
import FormWrapper from '$lib/components/form/FormWrapper.svelte';
import { t } from '$lib/i18n';
import type { ActionData, PageData } from './$types';
export let data: PageData;
export let form: ActionData;
</script>
<MainContainer>
<h1>{$t('common.siteName')}</h1>
<h2>{$t('account.otp.title')}</h2>
{#if form?.success}
<Alert type="success">{$t('account.otp.activated')}</Alert>
<br />
<a href="/account">{$t('account.otp.return')}</a>
{:else if form?.challenge}
<form action="?/activate" method="POST">
<FormWrapper>
<FormSection title={$t('account.otp.scan')}>
<div class="qr-wrapper">
<img src={form?.qr} alt={form?.uri} />
</div>
<input value={form.challenge} type="hidden" name="challenge" />
</FormSection>
<FormControl>
<label for="form-otpCode">{$t('account.otp.code')}</label>
<input type="text" name="otpCode" id="form-otpCode" autocomplete="off" />
</FormControl>
<Button type="submit" variant="primary">{$t('account.submit')}</Button>
</FormWrapper>
</form>
{:else if form?.invalid}
<Alert type="error">{$t('account.errors.otpFailed')}</Alert>
<br />
<form action="?/activate" method="POST">
<Button type="submit" variant="link">{$t('account.otp.retry')}</Button>
</form>
{:else if data?.otpEnabled}
<Alert type="success">{$t('account.otp.enabled')}</Alert>
<br />
<form action="?/deactivate" method="POST">
<Button type="submit" variant="link">{$t('account.otp.deactivate')}</Button>
</form>
{:else}
<Alert type="default">{$t('account.otp.disabled')}</Alert>
<br />
<form action="?/activate" method="POST">
<Button type="submit" variant="link">{$t('account.otp.activate')}</Button>
</form>
{/if}
</MainContainer>

View File

@ -0,0 +1,24 @@
import { Uploads } from '$lib/server/upload.js';
import { readFile } from 'fs/promises';
import { join } from 'path';
export async function GET({ params }) {
const uuid = params.slug;
const uploadFile = await Uploads.getAvatarByUuid(uuid);
if (!uploadFile) {
return new Response(Uploads.fallbackImage, {
status: 200,
headers: {
'Content-Type': 'image/png'
}
});
}
const readUpload = await readFile(join(Uploads.uploads, uploadFile.file));
return new Response(readUpload, {
status: 200,
headers: {
'Content-Type': uploadFile.mimetype
}
});
}

View File

@ -1,6 +1,20 @@
import { Challenge } from '$lib/server/challenge.js';
import { Changesets } from '$lib/server/changesets.js';
import { Users } from '$lib/server/users/index.js';
import { TimeOTP } from '$lib/server/users/totp.js';
import { fail, redirect, type Actions } from '@sveltejs/kit';
interface LoginParams {
email: string;
password: string;
challenge: string;
otpCode: string;
}
interface LoginChallenge {
email: string;
}
export const actions = {
default: async ({ request, locals, url }) => {
// Redirect
@ -13,23 +27,64 @@ export const actions = {
return redirect(303, redirectUrl);
}
const data = await request.formData();
const email = data.get('email') as string;
const password = data.get('password') as string;
// TODO: Audit log failed attempts
if (!email?.trim() || !password?.trim()) {
const body = await request.formData();
const { email, password, challenge, otpCode } = Changesets.take<LoginParams>(
['email', 'password', 'challenge', 'otpCode'],
body
);
if (!email) {
return fail(400, { incorrect: true });
}
if (!password && (!challenge || !otpCode)) {
return fail(400, { incorrect: true });
}
// Find existing active user
const loginUser = await Users.getByLogin(email);
// Compare user password
if (!loginUser || !(await Users.validatePassword(loginUser, password))) {
if (!loginUser) {
return fail(400, { email, incorrect: true });
}
// TODO: check two-factor
// Check OTP challenge
if (challenge && otpCode) {
const userOtp = await TimeOTP.getUserOtp(loginUser);
if (!userOtp) {
return fail(400, { email, incorrect: true });
}
try {
const valid = await Challenge.verifyChallenge<LoginChallenge>(
challenge,
loginUser.uuid,
otpCode,
userOtp.token
);
if (email !== valid.email) {
return fail(400, { email, incorrect: true });
}
} catch {
return fail(400, { email, incorrect: true });
}
} else {
// Compare user password
if (!loginUser || !password || !(await Users.validatePassword(loginUser, password))) {
return fail(400, { email, incorrect: true });
}
}
// Issue two-factor challenge
if (!challenge) {
const isOtp = await TimeOTP.isUserOtp(loginUser);
if (isOtp) {
const issued = await Challenge.issueChallenge<LoginChallenge>({ email }, loginUser.uuid);
return { otpRequired: issued, email };
}
}
// Create session data for user
const sessionUser = await Users.toSession(loginUser);

View File

@ -1,6 +1,10 @@
<script lang="ts">
import { enhance } from '$app/forms';
import Alert from '$lib/components/Alert.svelte';
import Button from '$lib/components/Button.svelte';
import SideContainer from '$lib/components/SideContainer.svelte';
import FormControl from '$lib/components/form/FormControl.svelte';
import FormSection from '$lib/components/form/FormSection.svelte';
import FormWrapper from '$lib/components/form/FormWrapper.svelte';
import { t } from '$lib/i18n';
import type { ActionData } from './$types';
@ -8,15 +12,27 @@
export let form: ActionData;
</script>
<div class="login-wrapper">
<div class="login-inner">
<SideContainer>
<h1>{$t('common.siteName')}</h1>
<h2>{$t('account.login.title')}</h2>
<form action="" method="POST">
<form action="" method="POST" use:enhance>
<FormWrapper>
{#if form?.incorrect}<p class="error">{$t('account.errors.invalidLogin')}</p>{/if}
{#if form?.incorrect}<Alert type="error">{$t('account.errors.invalidLogin')}</Alert>{/if}
{#if form?.otpRequired}
<!-- Two-factor code request -->
<FormSection title={$t('account.login.otp')}>
<input name="email" value={form.email} type="hidden" />
<input name="challenge" value={form.otpRequired} type="hidden" />
<FormControl>
<label for="login-otpCode">{$t('account.login.otpCode')}</label>
<!-- svelte-ignore a11y-autofocus -->
<input id="login-otpCode" name="otpCode" autocomplete="off" autofocus />
</FormControl>
</FormSection>
{:else}
<!-- Normal login -->
<FormControl>
<label for="login-email">{$t('account.login.email')}</label>
<input id="login-email" name="email" value={form?.email ?? ''} autocomplete="username" />
@ -31,7 +47,7 @@
autocomplete="current-password"
/>
</FormControl>
{/if}
<Button type="submit" variant="primary">{$t('account.login.submit')}</Button>
</FormWrapper>
</form>
@ -40,30 +56,9 @@
<p class="text-bold">{$t('common.description')}</p>
<p>{@html $t('common.cookieDisclaimer')}</p>
</div>
</div>
</div>
</SideContainer>
<style>
.login-wrapper {
display: flex;
justify-content: flex-end;
}
.login-wrapper,
.login-inner {
height: 100%;
}
.login-inner {
display: flex;
flex-direction: column;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
padding: 40px;
max-width: 600px;
width: 100%;
}
.welcome {
margin-top: auto;

BIN
static/application.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
static/avatar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

2
uploads/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore