settings and avatar
This commit is contained in:
parent
5e178a6a19
commit
4a07389cca
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"editor.formatOnSave": true
|
||||
}
|
277
package-lock.json
generated
277
package-lock.json
generated
@ -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",
|
||||
|
@ -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"
|
||||
|
25
src/app.css
25
src/app.css
@ -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);
|
||||
}
|
||||
|
||||
|
36
src/lib/components/Alert.svelte
Normal file
36
src/lib/components/Alert.svelte
Normal 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>
|
@ -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 {
|
||||
@ -13,24 +16,24 @@
|
||||
background: transparent;
|
||||
color: var(--in-text-color);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus-visible {
|
||||
outline: var(--in-focus-outline);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: var(--in-focus-outline);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.btn-link {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.btn-default,
|
||||
.btn-primary {
|
||||
background-color: #fff;
|
||||
color: #000;
|
||||
border: 2px solid #ddd;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.btn-default,
|
||||
.btn-primary {
|
||||
background-color: #fff;
|
||||
color: #000;
|
||||
border: 2px solid #ddd;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
|
11
src/lib/components/ColumnView.svelte
Normal file
11
src/lib/components/ColumnView.svelte
Normal file
@ -0,0 +1,11 @@
|
||||
<div class="column">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
</style>
|
23
src/lib/components/MainContainer.svelte
Normal file
23
src/lib/components/MainContainer.svelte
Normal 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>
|
115
src/lib/components/Modal.svelte
Normal file
115
src/lib/components/Modal.svelte
Normal 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>
|
27
src/lib/components/SideContainer.svelte
Normal file
27
src/lib/components/SideContainer.svelte
Normal 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>
|
19
src/lib/components/SplitView.svelte
Normal file
19
src/lib/components/SplitView.svelte
Normal 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>
|
31
src/lib/components/avatar/AvatarCard.svelte
Normal file
31
src/lib/components/avatar/AvatarCard.svelte
Normal 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>
|
152
src/lib/components/avatar/AvatarModal.svelte
Normal file
152
src/lib/components/avatar/AvatarModal.svelte
Normal 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>
|
@ -1,33 +1,47 @@
|
||||
|
||||
<div class="form-control">
|
||||
<slot></slot>
|
||||
<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);
|
||||
border-radius: 6px;
|
||||
:global(input) {
|
||||
background-color: var(--in-input-background);
|
||||
color: var(--in-input-color);
|
||||
border: 2px solid var(--in-input-border-color);
|
||||
|
||||
&:focus-visible {
|
||||
outline: var(--in-focus-outline);
|
||||
}
|
||||
}
|
||||
}
|
||||
&:not([type]),
|
||||
&[type='text'],
|
||||
&[type='password'],
|
||||
&[type='email'] {
|
||||
padding: 8px;
|
||||
font-size: 1rem;
|
||||
border-radius: 6px;
|
||||
|
||||
:global(label) {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: var(--in-focus-outline);
|
||||
}
|
||||
|
||||
.form-control {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
&[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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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>
|
||||
|
@ -1,4 +1,23 @@
|
||||
<script lang="ts">
|
||||
export let title: string = '';
|
||||
</script>
|
||||
|
||||
<div class="form-section">
|
||||
<slot />
|
||||
{#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>
|
||||
|
@ -1,10 +1,13 @@
|
||||
|
||||
<div class="form-wrapper">
|
||||
<slot />
|
||||
<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
1
src/lib/constants.ts
Normal file
@ -0,0 +1 @@
|
||||
export const allowedImages = ['image/png', 'image/jpg', 'image/jpeg'];
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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 <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 <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"
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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
67
src/lib/server/upload.ts
Normal 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 });
|
||||
}
|
||||
}
|
@ -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()
|
||||
|
@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
3
src/routes/+page.server.ts
Normal file
3
src/routes/+page.server.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const load = () => redirect(302, '/account');
|
@ -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
|
||||
};
|
||||
}
|
||||
|
@ -2,75 +2,185 @@
|
||||
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">
|
||||
<label for="form-username">{$t('account.username')}</label>
|
||||
<input
|
||||
type="text"
|
||||
disabled
|
||||
value={data.user.username}
|
||||
id="form-username"
|
||||
autocomplete="username"
|
||||
/>
|
||||
</div>
|
||||
<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}
|
||||
|
||||
<div class="form-control">
|
||||
<label for="form-displayName">{$t('account.displayName')}</label>
|
||||
<input
|
||||
type="text"
|
||||
name="displayName"
|
||||
value={form?.displayName || data.user.name}
|
||||
id="form-displayName"
|
||||
/>
|
||||
</div>
|
||||
{#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"
|
||||
disabled
|
||||
value={data.user.username}
|
||||
id="form-username"
|
||||
autocomplete="username"
|
||||
bind:this={usernameRef}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<div class="form-subtitle">{$t('account.changeEmail')}</div>
|
||||
<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}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<div class="form-control">
|
||||
<label for="form-currentEmail">{$t('account.currentEmail')}</label>
|
||||
<input type="email" name="currentEmail" id="form-currentEmail" />
|
||||
</div>
|
||||
<FormSection title={$t('account.changeEmail')}>
|
||||
<FormControl>
|
||||
<label for="form-currentEmail">{$t('account.currentEmail')}</label>
|
||||
<input type="email" name="currentEmail" id="form-currentEmail" />
|
||||
</FormControl>
|
||||
|
||||
<div class="form-control">
|
||||
<label for="form-newEmail">{$t('account.newEmail')}</label>
|
||||
<input type="email" name="newEmail" id="form-newEmail" />
|
||||
</div>
|
||||
<FormControl>
|
||||
<label for="form-newEmail">{$t('account.newEmail')}</label>
|
||||
<input type="email" name="newEmail" id="form-newEmail" />
|
||||
</FormControl>
|
||||
</FormSection>
|
||||
|
||||
<div class="form-subtitle">{$t('account.changePassword')}</div>
|
||||
<FormSection title={$t('account.changePassword')}>
|
||||
<FormControl>
|
||||
<label for="form-currentPassword">{$t('account.currentPassword')}</label>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
name="currentPassword"
|
||||
id="form-currentPassword"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<div class="form-control">
|
||||
<label for="form-currentPassword">{$t('account.currentPassword')}</label>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
name="currentPassword"
|
||||
id="form-currentPassword"
|
||||
/>
|
||||
</div>
|
||||
<FormControl>
|
||||
<label for="form-newPassword">{$t('account.newPassword')}</label>
|
||||
<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">
|
||||
<label for="form-newPassword">{$t('account.newPassword')}</label>
|
||||
<input type="password" autocomplete="new-password" name="newPassword" id="form-newPassword" />
|
||||
</div>
|
||||
<FormControl>
|
||||
<label for="form-repeatPassword">{$t('account.repeatPassword')}</label>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
name="repeatPassword"
|
||||
id="form-repeatPassword"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormSection>
|
||||
{/if}
|
||||
|
||||
<div class="form-control">
|
||||
<label for="form-repeatPassword">{$t('account.repeatPassword')}</label>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
name="repeatPassword"
|
||||
id="form-repeatPassword"
|
||||
/>
|
||||
</div>
|
||||
<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} />
|
||||
|
79
src/routes/account/two-factor/+page.server.ts
Normal file
79
src/routes/account/two-factor/+page.server.ts
Normal 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
|
||||
};
|
||||
}
|
58
src/routes/account/two-factor/+page.svelte
Normal file
58
src/routes/account/two-factor/+page.svelte
Normal 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>
|
24
src/routes/api/avatar/[slug]/+server.ts
Normal file
24
src/routes/api/avatar/[slug]/+server.ts
Normal 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
|
||||
}
|
||||
});
|
||||
}
|
@ -1,9 +1,23 @@
|
||||
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
|
||||
// Redirect
|
||||
const redirectUrl = url.searchParams.has('redirectTo')
|
||||
? (url.searchParams.get('redirectTo') as string)
|
||||
: '/';
|
||||
@ -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);
|
||||
|
@ -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">
|
||||
<h1>{$t('common.siteName')}</h1>
|
||||
<SideContainer>
|
||||
<h1>{$t('common.siteName')}</h1>
|
||||
|
||||
<h2>{$t('account.login.title')}</h2>
|
||||
<h2>{$t('account.login.title')}</h2>
|
||||
|
||||
<form action="" method="POST">
|
||||
<FormWrapper>
|
||||
{#if form?.incorrect}<p class="error">{$t('account.errors.invalidLogin')}</p>{/if}
|
||||
<form action="" method="POST" use:enhance>
|
||||
<FormWrapper>
|
||||
{#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,44 +47,23 @@
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</FormControl>
|
||||
{/if}
|
||||
<Button type="submit" variant="primary">{$t('account.login.submit')}</Button>
|
||||
</FormWrapper>
|
||||
</form>
|
||||
|
||||
<Button type="submit" variant="primary">{$t('account.login.submit')}</Button>
|
||||
</FormWrapper>
|
||||
</form>
|
||||
|
||||
<div class="welcome">
|
||||
<p class="text-bold">{$t('common.description')}</p>
|
||||
<p>{@html $t('common.cookieDisclaimer')}</p>
|
||||
</div>
|
||||
<div class="welcome">
|
||||
<p class="text-bold">{$t('common.description')}</p>
|
||||
<p>{@html $t('common.cookieDisclaimer')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</SideContainer>
|
||||
|
||||
<style>
|
||||
.login-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
.welcome {
|
||||
margin-top: auto;
|
||||
|
||||
& .text-bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
& .text-bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
BIN
static/application.png
Normal file
BIN
static/application.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
BIN
static/avatar.png
Normal file
BIN
static/avatar.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.9 KiB |
2
uploads/.gitignore
vendored
Normal file
2
uploads/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
Loading…
Reference in New Issue
Block a user