diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..ad92582
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,3 @@
+{
+ "editor.formatOnSave": true
+}
diff --git a/package-lock.json b/package-lock.json
index b2b04b8..007548a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index dfe5550..9571a49 100644
--- a/package.json
+++ b/package.json
@@ -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"
diff --git a/src/app.css b/src/app.css
index 2082fc5..7398b29 100644
--- a/src/app.css
+++ b/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);
}
diff --git a/src/lib/components/Alert.svelte b/src/lib/components/Alert.svelte
new file mode 100644
index 0000000..a35f7ca
--- /dev/null
+++ b/src/lib/components/Alert.svelte
@@ -0,0 +1,36 @@
+
+
+
+
+ {#if dismissable}
{/if}
+
+
+
diff --git a/src/lib/components/Button.svelte b/src/lib/components/Button.svelte
index 1447963..a2a04bc 100644
--- a/src/lib/components/Button.svelte
+++ b/src/lib/components/Button.svelte
@@ -1,9 +1,12 @@
-
+
diff --git a/src/lib/components/ColumnView.svelte b/src/lib/components/ColumnView.svelte
new file mode 100644
index 0000000..1b23c77
--- /dev/null
+++ b/src/lib/components/ColumnView.svelte
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/src/lib/components/MainContainer.svelte b/src/lib/components/MainContainer.svelte
new file mode 100644
index 0000000..3656d0b
--- /dev/null
+++ b/src/lib/components/MainContainer.svelte
@@ -0,0 +1,23 @@
+
+
+
diff --git a/src/lib/components/Modal.svelte b/src/lib/components/Modal.svelte
new file mode 100644
index 0000000..147bc2d
--- /dev/null
+++ b/src/lib/components/Modal.svelte
@@ -0,0 +1,115 @@
+
+
+
+
+
+
diff --git a/src/lib/components/SideContainer.svelte b/src/lib/components/SideContainer.svelte
new file mode 100644
index 0000000..6ac26a2
--- /dev/null
+++ b/src/lib/components/SideContainer.svelte
@@ -0,0 +1,27 @@
+
+
+
diff --git a/src/lib/components/SplitView.svelte b/src/lib/components/SplitView.svelte
new file mode 100644
index 0000000..eeebc90
--- /dev/null
+++ b/src/lib/components/SplitView.svelte
@@ -0,0 +1,19 @@
+
+
+
+
+
+
diff --git a/src/lib/components/avatar/AvatarCard.svelte b/src/lib/components/avatar/AvatarCard.svelte
new file mode 100644
index 0000000..c99d313
--- /dev/null
+++ b/src/lib/components/avatar/AvatarCard.svelte
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/lib/components/avatar/AvatarModal.svelte b/src/lib/components/avatar/AvatarModal.svelte
new file mode 100644
index 0000000..6a87d52
--- /dev/null
+++ b/src/lib/components/avatar/AvatarModal.svelte
@@ -0,0 +1,152 @@
+
+
+ ($show = false)}>
+ {$t('account.avatar.change')}
+
+
+ {#if picker}
+
+
+ readFile(e.target)} accept={allowedImages.join(',')} />
+ {$t('account.avatar.hint')}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if !picker}
+
+
+ {#if !ready}
+
+ {:else}
+
+ {/if}
+ {/if}
+
+
+
+
diff --git a/src/lib/components/form/FormControl.svelte b/src/lib/components/form/FormControl.svelte
index 48e87f5..675efce 100644
--- a/src/lib/components/form/FormControl.svelte
+++ b/src/lib/components/form/FormControl.svelte
@@ -1,33 +1,47 @@
-
-
+
diff --git a/src/lib/components/form/FormSection.svelte b/src/lib/components/form/FormSection.svelte
index 5c184a3..3b82774 100644
--- a/src/lib/components/form/FormSection.svelte
+++ b/src/lib/components/form/FormSection.svelte
@@ -1,4 +1,23 @@
+
+
+
diff --git a/src/lib/components/form/FormWrapper.svelte b/src/lib/components/form/FormWrapper.svelte
index 41d7244..07bf0e3 100644
--- a/src/lib/components/form/FormWrapper.svelte
+++ b/src/lib/components/form/FormWrapper.svelte
@@ -1,10 +1,13 @@
-
-
+
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
new file mode 100644
index 0000000..6846fac
--- /dev/null
+++ b/src/lib/constants.ts
@@ -0,0 +1 @@
+export const allowedImages = ['image/png', 'image/jpg', 'image/jpeg'];
diff --git a/src/lib/i18n/en/account.json b/src/lib/i18n/en/account.json
index 2fec6dd..0caace5 100644
--- a/src/lib/i18n/en/account.json
+++ b/src/lib/i18n/en/account.json
@@ -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"
}
}
diff --git a/src/lib/i18n/en/common.json b/src/lib/i18n/en/common.json
index 76ae907..f25a630 100644
--- a/src/lib/i18n/en/common.json
+++ b/src/lib/i18n/en/common.json
@@ -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 completely open source 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 completely open source and can be audited by anyone.",
+ "submit": "Submit",
+ "cancel": "Cancel",
+ "manage": "Manage"
}
diff --git a/src/lib/server/challenge.ts b/src/lib/server/challenge.ts
index f34b5da..3c8fb7f 100644
--- a/src/lib/server/challenge.ts
+++ b/src/lib/server/challenge.ts
@@ -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(body, subject.uuid, userOtp.token);
diff --git a/src/lib/server/drizzle/schema.ts b/src/lib/server/drizzle/schema.ts
index 9552f46..e78c937 100644
--- a/src/lib/server/drizzle/schema.ts
+++ b/src/lib/server/drizzle/schema.ts
@@ -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',
{
diff --git a/src/lib/server/upload.ts b/src/lib/server/upload.ts
new file mode 100644
index 0000000..59fbe05
--- /dev/null
+++ b/src/lib/server/upload.ts
@@ -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 });
+ }
+}
diff --git a/src/lib/server/users/index.ts b/src/lib/server/users/index.ts
index af42284..465818e 100644
--- a/src/lib/server/users/index.ts
+++ b/src/lib/server/users/index.ts
@@ -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 {
+ 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 {
const [result] = await db
.select()
diff --git a/src/lib/server/users/totp.ts b/src/lib/server/users/totp.ts
index 644b213..08d43b6 100644
--- a/src/lib/server/users/totp.ts
+++ b/src/lib/server/users/totp.ts
@@ -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
+ });
+ }
}
diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts
new file mode 100644
index 0000000..336c438
--- /dev/null
+++ b/src/routes/+page.server.ts
@@ -0,0 +1,3 @@
+import { redirect } from '@sveltejs/kit';
+
+export const load = () => redirect(302, '/account');
diff --git a/src/routes/account/+page.server.ts b/src/routes/account/+page.server.ts
index 0e9a5cc..749e16c 100644
--- a/src/routes/account/+page.server.ts
+++ b/src/routes/account/+page.server.ts
@@ -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: [],
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
};
}
diff --git a/src/routes/account/+page.svelte b/src/routes/account/+page.svelte
index b63562c..9f9e33b 100644
--- a/src/routes/account/+page.svelte
+++ b/src/routes/account/+page.svelte
@@ -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;
+ }
+ };
+ };
-{data.user.name}
+
+ {$t('common.siteName')}
+
+
+
{$t('account.title')}
-
+
-
-
+
+
+
{$t('account.avatar.title')}
+
+
+
+ {#if data.hasAvatar}
+
+ {/if}
+
+
+
-
+
+
{$t('account.otp.title')}
+ {#if data.otpEnabled}
+
{$t('account.otp.enabled')}
+ {:else}
+
{$t('account.otp.disabled')}
+ {/if}
+
{$t('common.manage')}
+
+
+
+
+
+
+
diff --git a/src/routes/account/two-factor/+page.server.ts b/src/routes/account/two-factor/+page.server.ts
new file mode 100644
index 0000000..1ea3816
--- /dev/null
+++ b/src/routes/account/two-factor/+page.server.ts
@@ -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({ 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(['challenge', 'otpCode'], body);
+
+ if (!challenge) {
+ return issueActivateChallenge(currentUser);
+ }
+
+ const decoded = await CryptoUtils.decryptChallenge>(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
+ };
+}
diff --git a/src/routes/account/two-factor/+page.svelte b/src/routes/account/two-factor/+page.svelte
new file mode 100644
index 0000000..a523695
--- /dev/null
+++ b/src/routes/account/two-factor/+page.svelte
@@ -0,0 +1,58 @@
+
+
+
+ {$t('common.siteName')}
+ {$t('account.otp.title')}
+
+ {#if form?.success}
+ {$t('account.otp.activated')}
+
+ {$t('account.otp.return')}
+ {:else if form?.challenge}
+
+ {:else if form?.invalid}
+ {$t('account.errors.otpFailed')}
+
+
+ {:else if data?.otpEnabled}
+ {$t('account.otp.enabled')}
+
+
+ {:else}
+ {$t('account.otp.disabled')}
+
+
+ {/if}
+
diff --git a/src/routes/api/avatar/[slug]/+server.ts b/src/routes/api/avatar/[slug]/+server.ts
new file mode 100644
index 0000000..23d2615
--- /dev/null
+++ b/src/routes/api/avatar/[slug]/+server.ts
@@ -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
+ }
+ });
+}
diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts
index 7c87e94..ff8c544 100644
--- a/src/routes/login/+page.server.ts
+++ b/src/routes/login/+page.server.ts
@@ -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(
+ ['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(
+ 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({ email }, loginUser.uuid);
+ return { otpRequired: issued, email };
+ }
+ }
// Create session data for user
const sessionUser = await Users.toSession(loginUser);
diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte
index 43b6aa1..0dcc1fa 100644
--- a/src/routes/login/+page.svelte
+++ b/src/routes/login/+page.svelte
@@ -1,6 +1,10 @@
-
-
-
{$t('common.siteName')}
+
+ {$t('common.siteName')}
- {$t('account.login.title')}
+ {$t('account.login.title')}
-
-
-
-
{$t('common.description')}
-
{@html $t('common.cookieDisclaimer')}
-
+
+
{$t('common.description')}
+
{@html $t('common.cookieDisclaimer')}
-
+
diff --git a/static/application.png b/static/application.png
new file mode 100644
index 0000000..6ad5ce6
Binary files /dev/null and b/static/application.png differ
diff --git a/static/avatar.png b/static/avatar.png
new file mode 100644
index 0000000..25ce30e
Binary files /dev/null and b/static/avatar.png differ
diff --git a/uploads/.gitignore b/uploads/.gitignore
new file mode 100644
index 0000000..d6b7ef3
--- /dev/null
+++ b/uploads/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore