dev
All checks were successful
ci / docker_image (push) Successful in 1m38s
ci / deploy (push) Successful in 18s

This commit is contained in:
Drake Marino 2025-01-29 22:38:07 -06:00
parent be83b7570d
commit fa14fe0496
65 changed files with 3318 additions and 708 deletions

382
package-lock.json generated
View File

@ -16,6 +16,7 @@
"js-cookie": "^3.0.5",
"jsonwebtoken": "^9.0.2",
"postgres": "^3.4.5",
"svelte-preprocess": "^6.0.3",
"vitest": "^2.0.4"
},
"devDependencies": {
@ -592,9 +593,9 @@
}
},
"node_modules/@eslint/js": {
"version": "9.18.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.18.0.tgz",
"integrity": "sha512-fK6L7rxcq6/z+AaQMtiFTkvbHkBLNlwyRxHpKawP0x3u9+NC6MQTnFW+AdpwC6gfHTW0051cokQgtTN2FqlxQA==",
"version": "9.19.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.19.0.tgz",
"integrity": "sha512-rbq9/g38qjfqFLOVPvwjIvFFdNziEC5S65jmjPw5r6A//QH+W91akh9irMwjDN8zKUTak6W9EsAv4m/7Wnw0UQ==",
"dev": true,
"license": "MIT",
"engines": {
@ -970,9 +971,9 @@
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.31.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.31.0.tgz",
"integrity": "sha512-9NrR4033uCbUBRgvLcBrJofa2KY9DzxL2UKZ1/4xA/mnTNyhZCWBuD8X3tPm1n4KxcgaraOYgrFKSgwjASfmlA==",
"version": "4.32.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.32.1.tgz",
"integrity": "sha512-/pqA4DmqyCm8u5YIDzIdlLcEmuvxb0v8fZdFhVMszSpDTgbQKdw3/mB3eMUHIbubtJ6F9j+LtmyCnHTEqIHyzA==",
"cpu": [
"arm"
],
@ -983,9 +984,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.31.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.31.0.tgz",
"integrity": "sha512-iBbODqT86YBFHajxxF8ebj2hwKm1k8PTBQSojSt3d1FFt1gN+xf4CowE47iN0vOSdnd+5ierMHBbu/rHc7nq5g==",
"version": "4.32.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.32.1.tgz",
"integrity": "sha512-If3PDskT77q7zgqVqYuj7WG3WC08G1kwXGVFi9Jr8nY6eHucREHkfpX79c0ACAjLj3QIWKPJR7w4i+f5EdLH5Q==",
"cpu": [
"arm64"
],
@ -996,9 +997,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.31.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.31.0.tgz",
"integrity": "sha512-WHIZfXgVBX30SWuTMhlHPXTyN20AXrLH4TEeH/D0Bolvx9PjgZnn4H677PlSGvU6MKNsjCQJYczkpvBbrBnG6g==",
"version": "4.32.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.32.1.tgz",
"integrity": "sha512-zCpKHioQ9KgZToFp5Wvz6zaWbMzYQ2LJHQ+QixDKq52KKrF65ueu6Af4hLlLWHjX1Wf/0G5kSJM9PySW9IrvHA==",
"cpu": [
"arm64"
],
@ -1009,9 +1010,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.31.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.31.0.tgz",
"integrity": "sha512-hrWL7uQacTEF8gdrQAqcDy9xllQ0w0zuL1wk1HV8wKGSGbKPVjVUv/DEwT2+Asabf8Dh/As+IvfdU+H8hhzrQQ==",
"version": "4.32.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.32.1.tgz",
"integrity": "sha512-sFvF+t2+TyUo/ZQqUcifrJIgznx58oFZbdHS9TvHq3xhPVL9nOp+yZ6LKrO9GWTP+6DbFtoyLDbjTpR62Mbr3Q==",
"cpu": [
"x64"
],
@ -1022,9 +1023,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.31.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.31.0.tgz",
"integrity": "sha512-S2oCsZ4hJviG1QjPY1h6sVJLBI6ekBeAEssYKad1soRFv3SocsQCzX6cwnk6fID6UQQACTjeIMB+hyYrFacRew==",
"version": "4.32.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.32.1.tgz",
"integrity": "sha512-NbOa+7InvMWRcY9RG+B6kKIMD/FsnQPH0MWUvDlQB1iXnF/UcKSudCXZtv4lW+C276g3w5AxPbfry5rSYvyeYA==",
"cpu": [
"arm64"
],
@ -1035,9 +1036,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.31.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.31.0.tgz",
"integrity": "sha512-pCANqpynRS4Jirn4IKZH4tnm2+2CqCNLKD7gAdEjzdLGbH1iO0zouHz4mxqg0uEMpO030ejJ0aA6e1PJo2xrPA==",
"version": "4.32.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.32.1.tgz",
"integrity": "sha512-JRBRmwvHPXR881j2xjry8HZ86wIPK2CcDw0EXchE1UgU0ubWp9nvlT7cZYKc6bkypBt745b4bglf3+xJ7hXWWw==",
"cpu": [
"x64"
],
@ -1048,9 +1049,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.31.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.31.0.tgz",
"integrity": "sha512-0O8ViX+QcBd3ZmGlcFTnYXZKGbFu09EhgD27tgTdGnkcYXLat4KIsBBQeKLR2xZDCXdIBAlWLkiXE1+rJpCxFw==",
"version": "4.32.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.32.1.tgz",
"integrity": "sha512-PKvszb+9o/vVdUzCCjL0sKHukEQV39tD3fepXxYrHE3sTKrRdCydI7uldRLbjLmDA3TFDmh418XH19NOsDRH8g==",
"cpu": [
"arm"
],
@ -1061,9 +1062,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.31.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.31.0.tgz",
"integrity": "sha512-w5IzG0wTVv7B0/SwDnMYmbr2uERQp999q8FMkKG1I+j8hpPX2BYFjWe69xbhbP6J9h2gId/7ogesl9hwblFwwg==",
"version": "4.32.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.32.1.tgz",
"integrity": "sha512-9WHEMV6Y89eL606ReYowXuGF1Yb2vwfKWKdD1A5h+OYnPZSJvxbEjxTRKPgi7tkP2DSnW0YLab1ooy+i/FQp/Q==",
"cpu": [
"arm"
],
@ -1074,9 +1075,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.31.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.31.0.tgz",
"integrity": "sha512-JyFFshbN5xwy6fulZ8B/8qOqENRmDdEkcIMF0Zz+RsfamEW+Zabl5jAb0IozP/8UKnJ7g2FtZZPEUIAlUSX8cA==",
"version": "4.32.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.32.1.tgz",
"integrity": "sha512-tZWc9iEt5fGJ1CL2LRPw8OttkCBDs+D8D3oEM8mH8S1ICZCtFJhD7DZ3XMGM8kpqHvhGUTvNUYVDnmkj4BDXnw==",
"cpu": [
"arm64"
],
@ -1087,9 +1088,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.31.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.31.0.tgz",
"integrity": "sha512-kpQXQ0UPFeMPmPYksiBL9WS/BDiQEjRGMfklVIsA0Sng347H8W2iexch+IEwaR7OVSKtr2ZFxggt11zVIlZ25g==",
"version": "4.32.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.32.1.tgz",
"integrity": "sha512-FTYc2YoTWUsBz5GTTgGkRYYJ5NGJIi/rCY4oK/I8aKowx1ToXeoVVbIE4LGAjsauvlhjfl0MYacxClLld1VrOw==",
"cpu": [
"arm64"
],
@ -1100,9 +1101,9 @@
]
},
"node_modules/@rollup/rollup-linux-loongarch64-gnu": {
"version": "4.31.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.31.0.tgz",
"integrity": "sha512-pMlxLjt60iQTzt9iBb3jZphFIl55a70wexvo8p+vVFK+7ifTRookdoXX3bOsRdmfD+OKnMozKO6XM4zR0sHRrQ==",
"version": "4.32.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.32.1.tgz",
"integrity": "sha512-F51qLdOtpS6P1zJVRzYM0v6MrBNypyPEN1GfMiz0gPu9jN8ScGaEFIZQwteSsGKg799oR5EaP7+B2jHgL+d+Kw==",
"cpu": [
"loong64"
],
@ -1113,9 +1114,9 @@
]
},
"node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
"version": "4.31.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.31.0.tgz",
"integrity": "sha512-D7TXT7I/uKEuWiRkEFbed1UUYZwcJDU4vZQdPTcepK7ecPhzKOYk4Er2YR4uHKme4qDeIh6N3XrLfpuM7vzRWQ==",
"version": "4.32.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.32.1.tgz",
"integrity": "sha512-wO0WkfSppfX4YFm5KhdCCpnpGbtgQNj/tgvYzrVYFKDpven8w2N6Gg5nB6w+wAMO3AIfSTWeTjfVe+uZ23zAlg==",
"cpu": [
"ppc64"
],
@ -1126,9 +1127,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.31.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.31.0.tgz",
"integrity": "sha512-wal2Tc8O5lMBtoePLBYRKj2CImUCJ4UNGJlLwspx7QApYny7K1cUYlzQ/4IGQBLmm+y0RS7dwc3TDO/pmcneTw==",
"version": "4.32.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.32.1.tgz",
"integrity": "sha512-iWswS9cIXfJO1MFYtI/4jjlrGb/V58oMu4dYJIKnR5UIwbkzR0PJ09O0PDZT0oJ3LYWXBSWahNf/Mjo6i1E5/g==",
"cpu": [
"riscv64"
],
@ -1139,9 +1140,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.31.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.31.0.tgz",
"integrity": "sha512-O1o5EUI0+RRMkK9wiTVpk2tyzXdXefHtRTIjBbmFREmNMy7pFeYXCFGbhKFwISA3UOExlo5GGUuuj3oMKdK6JQ==",
"version": "4.32.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.32.1.tgz",
"integrity": "sha512-RKt8NI9tebzmEthMnfVgG3i/XeECkMPS+ibVZjZ6mNekpbbUmkNWuIN2yHsb/mBPyZke4nlI4YqIdFPgKuoyQQ==",
"cpu": [
"s390x"
],
@ -1152,9 +1153,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.31.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.31.0.tgz",
"integrity": "sha512-zSoHl356vKnNxwOWnLd60ixHNPRBglxpv2g7q0Cd3Pmr561gf0HiAcUBRL3S1vPqRC17Zo2CX/9cPkqTIiai1g==",
"version": "4.32.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.32.1.tgz",
"integrity": "sha512-WQFLZ9c42ECqEjwg/GHHsouij3pzLXkFdz0UxHa/0OM12LzvX7DzedlY0SIEly2v18YZLRhCRoHZDxbBSWoGYg==",
"cpu": [
"x64"
],
@ -1165,9 +1166,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.31.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.31.0.tgz",
"integrity": "sha512-ypB/HMtcSGhKUQNiFwqgdclWNRrAYDH8iMYH4etw/ZlGwiTVxBz2tDrGRrPlfZu6QjXwtd+C3Zib5pFqID97ZA==",
"version": "4.32.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.32.1.tgz",
"integrity": "sha512-BLoiyHDOWoS3uccNSADMza6V6vCNiphi94tQlVIL5de+r6r/CCQuNnerf+1g2mnk2b6edp5dk0nhdZ7aEjOBsA==",
"cpu": [
"x64"
],
@ -1178,9 +1179,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.31.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.31.0.tgz",
"integrity": "sha512-JuhN2xdI/m8Hr+aVO3vspO7OQfUFO6bKLIRTAy0U15vmWjnZDLrEgCZ2s6+scAYaQVpYSh9tZtRijApw9IXyMw==",
"version": "4.32.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.32.1.tgz",
"integrity": "sha512-w2l3UnlgYTNNU+Z6wOR8YdaioqfEnwPjIsJ66KxKAf0p+AuL2FHeTX6qvM+p/Ue3XPBVNyVSfCrfZiQh7vZHLQ==",
"cpu": [
"arm64"
],
@ -1191,9 +1192,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.31.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.31.0.tgz",
"integrity": "sha512-U1xZZXYkvdf5MIWmftU8wrM5PPXzyaY1nGCI4KI4BFfoZxHamsIe+BtnPLIvvPykvQWlVbqUXdLa4aJUuilwLQ==",
"version": "4.32.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.32.1.tgz",
"integrity": "sha512-Am9H+TGLomPGkBnaPWie4F3x+yQ2rr4Bk2jpwy+iV+Gel9jLAu/KqT8k3X4jxFPW6Zf8OMnehyutsd+eHoq1WQ==",
"cpu": [
"ia32"
],
@ -1204,9 +1205,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.31.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.31.0.tgz",
"integrity": "sha512-ul8rnCsUumNln5YWwz0ted2ZHFhzhRRnkpBZ+YRuHoRAlUji9KChpOUOndY7uykrPEPXVbHLlsdo6v5yXo/TXw==",
"version": "4.32.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.32.1.tgz",
"integrity": "sha512-ar80GhdZb4DgmW3myIS9nRFYcpJRSME8iqWgzH2i44u+IdrzmiXVxeFnExQ5v4JYUSpg94bWjevMG8JHf1Da5Q==",
"cpu": [
"x64"
],
@ -1354,19 +1355,27 @@
"license": "MIT"
},
"node_modules/@types/jsonwebtoken": {
"version": "9.0.7",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.7.tgz",
"integrity": "sha512-ugo316mmTYBl2g81zDFnZ7cfxlut3o+/EQdaP7J8QN2kY6lJ22hmQYCK5EHcJHbrW+dkCGSCPgbG8JtYj6qSrg==",
"version": "9.0.8",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.8.tgz",
"integrity": "sha512-7fx54m60nLFUVYlxAB1xpe9CBWX2vSrk50Y6ogRJ1v5xxtba7qXTg5BgYDN5dq+yuQQ9HaVlHJyAAt1/mxryFg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/ms": "*",
"@types/node": "*"
}
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.10.10",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.10.tgz",
"integrity": "sha512-X47y/mPNzxviAGY5TcYPtYL8JsY3kAq2n8fMmKoRCxq/c4v4pyGNCzM2R6+M5/umG4ZfHuT+sgqDYqWc9rJ6ww==",
"version": "22.12.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.12.0.tgz",
"integrity": "sha512-Fll2FZ1riMjNmlmJOdAyY5pUbkftXslB5DgEzlIuNaiWhXd00FhWxVC/r4yV/4wBb9JfImTu+jiSvXTkJ7F/gA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
@ -1391,17 +1400,17 @@
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.21.0.tgz",
"integrity": "sha512-eTH+UOR4I7WbdQnG4Z48ebIA6Bgi7WO8HvFEneeYBxG8qCOYgTOFPSg6ek9ITIDvGjDQzWHcoWHCDO2biByNzA==",
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.22.0.tgz",
"integrity": "sha512-4Uta6REnz/xEJMvwf72wdUnC3rr4jAQf5jnTkeRQ9b6soxLxhDEbS/pfMPoJLDfFPNVRdryqWUIV/2GZzDJFZw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.21.0",
"@typescript-eslint/type-utils": "8.21.0",
"@typescript-eslint/utils": "8.21.0",
"@typescript-eslint/visitor-keys": "8.21.0",
"@typescript-eslint/scope-manager": "8.22.0",
"@typescript-eslint/type-utils": "8.22.0",
"@typescript-eslint/utils": "8.22.0",
"@typescript-eslint/visitor-keys": "8.22.0",
"graphemer": "^1.4.0",
"ignore": "^5.3.1",
"natural-compare": "^1.4.0",
@ -1421,16 +1430,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.21.0.tgz",
"integrity": "sha512-Wy+/sdEH9kI3w9civgACwabHbKl+qIOu0uFZ9IMKzX3Jpv9og0ZBJrZExGrPpFAY7rWsXuxs5e7CPPP17A4eYA==",
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.22.0.tgz",
"integrity": "sha512-MqtmbdNEdoNxTPzpWiWnqNac54h8JDAmkWtJExBVVnSrSmi9z+sZUt0LfKqk9rjqmKOIeRhO4fHHJ1nQIjduIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.21.0",
"@typescript-eslint/types": "8.21.0",
"@typescript-eslint/typescript-estree": "8.21.0",
"@typescript-eslint/visitor-keys": "8.21.0",
"@typescript-eslint/scope-manager": "8.22.0",
"@typescript-eslint/types": "8.22.0",
"@typescript-eslint/typescript-estree": "8.22.0",
"@typescript-eslint/visitor-keys": "8.22.0",
"debug": "^4.3.4"
},
"engines": {
@ -1446,14 +1455,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.21.0.tgz",
"integrity": "sha512-G3IBKz0/0IPfdeGRMbp+4rbjfSSdnGkXsM/pFZA8zM9t9klXDnB/YnKOBQ0GoPmoROa4bCq2NeHgJa5ydsQ4mA==",
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.22.0.tgz",
"integrity": "sha512-/lwVV0UYgkj7wPSw0o8URy6YI64QmcOdwHuGuxWIYznO6d45ER0wXUbksr9pYdViAofpUCNJx/tAzNukgvaaiQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.21.0",
"@typescript-eslint/visitor-keys": "8.21.0"
"@typescript-eslint/types": "8.22.0",
"@typescript-eslint/visitor-keys": "8.22.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -1464,14 +1473,14 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.21.0.tgz",
"integrity": "sha512-95OsL6J2BtzoBxHicoXHxgk3z+9P3BEcQTpBKriqiYzLKnM2DeSqs+sndMKdamU8FosiadQFT3D+BSL9EKnAJQ==",
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.22.0.tgz",
"integrity": "sha512-NzE3aB62fDEaGjaAYZE4LH7I1MUwHooQ98Byq0G0y3kkibPJQIXVUspzlFOmOfHhiDLwKzMlWxaNv+/qcZurJA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/typescript-estree": "8.21.0",
"@typescript-eslint/utils": "8.21.0",
"@typescript-eslint/typescript-estree": "8.22.0",
"@typescript-eslint/utils": "8.22.0",
"debug": "^4.3.4",
"ts-api-utils": "^2.0.0"
},
@ -1488,9 +1497,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.21.0.tgz",
"integrity": "sha512-PAL6LUuQwotLW2a8VsySDBwYMm129vFm4tMVlylzdoTybTHaAi0oBp7Ac6LhSrHHOdLM3efH+nAR6hAWoMF89A==",
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.22.0.tgz",
"integrity": "sha512-0S4M4baNzp612zwpD4YOieP3VowOARgK2EkN/GBn95hpyF8E2fbMT55sRHWBq+Huaqk3b3XK+rxxlM8sPgGM6A==",
"dev": true,
"license": "MIT",
"engines": {
@ -1502,14 +1511,14 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.21.0.tgz",
"integrity": "sha512-x+aeKh/AjAArSauz0GiQZsjT8ciadNMHdkUSwBB9Z6PrKc/4knM4g3UfHml6oDJmKC88a6//cdxnO/+P2LkMcg==",
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.22.0.tgz",
"integrity": "sha512-SJX99NAS2ugGOzpyhMza/tX+zDwjvwAtQFLsBo3GQxiGcvaKlqGBkmZ+Y1IdiSi9h4Q0Lr5ey+Cp9CGWNY/F/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.21.0",
"@typescript-eslint/visitor-keys": "8.21.0",
"@typescript-eslint/types": "8.22.0",
"@typescript-eslint/visitor-keys": "8.22.0",
"debug": "^4.3.4",
"fast-glob": "^3.3.2",
"is-glob": "^4.0.3",
@ -1555,16 +1564,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.21.0.tgz",
"integrity": "sha512-xcXBfcq0Kaxgj7dwejMbFyq7IOHgpNMtVuDveK7w3ZGwG9owKzhALVwKpTF2yrZmEwl9SWdetf3fxNzJQaVuxw==",
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.22.0.tgz",
"integrity": "sha512-T8oc1MbF8L+Bk2msAvCUzjxVB2Z2f+vXYfcucE2wOmYs7ZUwco5Ep0fYZw8quNwOiw9K8GYVL+Kgc2pETNTLOg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"@typescript-eslint/scope-manager": "8.21.0",
"@typescript-eslint/types": "8.21.0",
"@typescript-eslint/typescript-estree": "8.21.0"
"@typescript-eslint/scope-manager": "8.22.0",
"@typescript-eslint/types": "8.22.0",
"@typescript-eslint/typescript-estree": "8.22.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -1579,13 +1588,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.21.0.tgz",
"integrity": "sha512-BkLMNpdV6prozk8LlyK/SOoWLmUFi+ZD+pcqti9ILCbVvHGk1ui1g4jJOc2WDLaeExz2qWwojxlPce5PljcT3w==",
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.22.0.tgz",
"integrity": "sha512-AWpYAXnUgvLNabGTy3uBylkgZoosva/miNd1I8Bz3SjotmQPbVqhO4Cczo8AsZ44XVErEBPr/CRSgaj8sG7g0w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.21.0",
"@typescript-eslint/types": "8.22.0",
"eslint-visitor-keys": "^4.2.0"
},
"engines": {
@ -2022,9 +2031,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001695",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001695.tgz",
"integrity": "sha512-vHyLade6wTgI2u1ec3WQBxv+2BrTERV28UXQu9LO6lZ9pYeMk34vjXFLOxo1A4UBA8XTL4njRQZdno/yYaSmWw==",
"version": "1.0.30001696",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001696.tgz",
"integrity": "sha512-pDCPkvzfa39ehJtJ+OwGT/2yvT2SbjfHhiIW2LWOAcMQ7BzwxT/XuyUp4OTOd0XFWA6BKw0JalnBHgSi5DGJBQ==",
"funding": [
{
"type": "opencollective",
@ -2332,9 +2341,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.87",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.87.tgz",
"integrity": "sha512-mPFwmEWmRivw2F8x3w3l2m6htAUN97Gy0kwpO++2m9iT1Gt8RCFVUfv9U/sIbHJ6rY4P6/ooqFL/eL7ock+pPg==",
"version": "1.5.88",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.88.tgz",
"integrity": "sha512-K3C2qf1o+bGzbilTDCTBhTQcMS9KW60yTAaTeeXsfvQuTDDwlokLam/AdqlqcSy9u4UainDgsHV23ksXAOgamw==",
"license": "ISC"
},
"node_modules/emoji-regex": {
@ -2412,9 +2421,9 @@
}
},
"node_modules/eslint": {
"version": "9.18.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.18.0.tgz",
"integrity": "sha512-+waTfRWQlSbpt3KWE+CjrPPYnbq9kfZIYUqapc0uBXyjTp8aYXZDsUH16m39Ryq3NjAVP4tjuF7KaukeqoCoaA==",
"version": "9.19.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.19.0.tgz",
"integrity": "sha512-ug92j0LepKlbbEv6hD911THhoRHmbdXt2gX+VDABAW/Ir7D3nqKdv5Pf5vtlyY6HQMTEP2skXY43ueqTCWssEA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -2423,7 +2432,7 @@
"@eslint/config-array": "^0.19.0",
"@eslint/core": "^0.10.0",
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "9.18.0",
"@eslint/js": "9.19.0",
"@eslint/plugin-kit": "^0.2.5",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
@ -3323,7 +3332,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
"integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=10"
@ -3407,9 +3416,9 @@
"license": "MIT"
},
"node_modules/loupe": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz",
"integrity": "sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==",
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz",
"integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==",
"license": "MIT"
},
"node_modules/lru-cache": {
@ -3978,7 +3987,7 @@
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz",
"integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"lilconfig": "^2.0.5",
@ -4008,7 +4017,7 @@
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
"dev": true,
"devOptional": true,
"license": "ISC",
"engines": {
"node": ">= 6"
@ -4355,9 +4364,9 @@
}
},
"node_modules/rollup": {
"version": "4.31.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.31.0.tgz",
"integrity": "sha512-9cCE8P4rZLx9+PjoyqHLs31V9a9Vpvfo4qNcs6JCiGWYhw2gijSetFbH6SSy1whnkgcefnUwr8sad7tgqsGvnw==",
"version": "4.32.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.32.1.tgz",
"integrity": "sha512-z+aeEsOeEa3mEbS1Tjl6sAZ8NE3+AalQz1RJGj81M+fizusbdDMoEJwdJNHfaB40Scr4qNu+welOfes7maKonA==",
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.6"
@ -4370,25 +4379,25 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.31.0",
"@rollup/rollup-android-arm64": "4.31.0",
"@rollup/rollup-darwin-arm64": "4.31.0",
"@rollup/rollup-darwin-x64": "4.31.0",
"@rollup/rollup-freebsd-arm64": "4.31.0",
"@rollup/rollup-freebsd-x64": "4.31.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.31.0",
"@rollup/rollup-linux-arm-musleabihf": "4.31.0",
"@rollup/rollup-linux-arm64-gnu": "4.31.0",
"@rollup/rollup-linux-arm64-musl": "4.31.0",
"@rollup/rollup-linux-loongarch64-gnu": "4.31.0",
"@rollup/rollup-linux-powerpc64le-gnu": "4.31.0",
"@rollup/rollup-linux-riscv64-gnu": "4.31.0",
"@rollup/rollup-linux-s390x-gnu": "4.31.0",
"@rollup/rollup-linux-x64-gnu": "4.31.0",
"@rollup/rollup-linux-x64-musl": "4.31.0",
"@rollup/rollup-win32-arm64-msvc": "4.31.0",
"@rollup/rollup-win32-ia32-msvc": "4.31.0",
"@rollup/rollup-win32-x64-msvc": "4.31.0",
"@rollup/rollup-android-arm-eabi": "4.32.1",
"@rollup/rollup-android-arm64": "4.32.1",
"@rollup/rollup-darwin-arm64": "4.32.1",
"@rollup/rollup-darwin-x64": "4.32.1",
"@rollup/rollup-freebsd-arm64": "4.32.1",
"@rollup/rollup-freebsd-x64": "4.32.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.32.1",
"@rollup/rollup-linux-arm-musleabihf": "4.32.1",
"@rollup/rollup-linux-arm64-gnu": "4.32.1",
"@rollup/rollup-linux-arm64-musl": "4.32.1",
"@rollup/rollup-linux-loongarch64-gnu": "4.32.1",
"@rollup/rollup-linux-powerpc64le-gnu": "4.32.1",
"@rollup/rollup-linux-riscv64-gnu": "4.32.1",
"@rollup/rollup-linux-s390x-gnu": "4.32.1",
"@rollup/rollup-linux-x64-gnu": "4.32.1",
"@rollup/rollup-linux-x64-musl": "4.32.1",
"@rollup/rollup-win32-arm64-msvc": "4.32.1",
"@rollup/rollup-win32-ia32-msvc": "4.32.1",
"@rollup/rollup-win32-x64-msvc": "4.32.1",
"fsevents": "~2.3.2"
}
},
@ -4448,9 +4457,9 @@
"license": "MIT"
},
"node_modules/semver": {
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
"version": "7.7.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.0.tgz",
"integrity": "sha512-DrfFnPzblFmNrIZzg5RzHegbiRWg7KMR7btwi2yjHwx06zsUbO5g613sVwEV7FTwmzJu+Io0lJe2GJ3LxqpvBQ==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@ -4716,9 +4725,9 @@
}
},
"node_modules/svelte": {
"version": "5.19.2",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.19.2.tgz",
"integrity": "sha512-Ww1uLgdX5MdQrAO5zfU1dWUh6zqiPR6uIbwqm8a+4eQ+tNEYHRPgypvKKfHh9lmTkmJ30PWZ2O5qX8aS+PblRQ==",
"version": "5.19.5",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.19.5.tgz",
"integrity": "sha512-vVAntseegJX80sgbY8CxQISSE/VoDSfP7VZHoQaf2+z+2XOPOz/N+k455HJmO9O0g8oxTtuE0TBhC/5LAP4lPg==",
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.3.0",
@ -4840,6 +4849,61 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/svelte-preprocess": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-6.0.3.tgz",
"integrity": "sha512-PLG2k05qHdhmRG7zR/dyo5qKvakhm8IJ+hD2eFRQmMLHp7X3eJnjeupUtvuRpbNiF31RjVw45W+abDwHEmP5OA==",
"hasInstallScript": true,
"license": "MIT",
"engines": {
"node": ">= 18.0.0"
},
"peerDependencies": {
"@babel/core": "^7.10.2",
"coffeescript": "^2.5.1",
"less": "^3.11.3 || ^4.0.0",
"postcss": "^7 || ^8",
"postcss-load-config": ">=3",
"pug": "^3.0.0",
"sass": "^1.26.8",
"stylus": ">=0.55",
"sugarss": "^2.0.0 || ^3.0.0 || ^4.0.0",
"svelte": "^4.0.0 || ^5.0.0-next.100 || ^5.0.0",
"typescript": "^5.0.0"
},
"peerDependenciesMeta": {
"@babel/core": {
"optional": true
},
"coffeescript": {
"optional": true
},
"less": {
"optional": true
},
"postcss": {
"optional": true
},
"postcss-load-config": {
"optional": true
},
"pug": {
"optional": true
},
"sass": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/svelte/node_modules/is-reference": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
@ -5133,7 +5197,7 @@
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@ -5144,15 +5208,15 @@
}
},
"node_modules/typescript-eslint": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.21.0.tgz",
"integrity": "sha512-txEKYY4XMKwPXxNkN8+AxAdX6iIJAPiJbHE/FpQccs/sxw8Lf26kqwC3cn0xkHlW8kEbLhkhCsjWuMveaY9Rxw==",
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.22.0.tgz",
"integrity": "sha512-Y2rj210FW1Wb6TWXzQc5+P+EWI9/zdS57hLEc0gnyuvdzWo8+Y8brKlbj0muejonhMI/xAZCnZZwjbIfv1CkOw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.21.0",
"@typescript-eslint/parser": "8.21.0",
"@typescript-eslint/utils": "8.21.0"
"@typescript-eslint/eslint-plugin": "8.22.0",
"@typescript-eslint/parser": "8.22.0",
"@typescript-eslint/utils": "8.22.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"

View File

@ -44,6 +44,7 @@
"js-cookie": "^3.0.5",
"jsonwebtoken": "^9.0.2",
"postgres": "^3.4.5",
"svelte-preprocess": "^6.0.3",
"vitest": "^2.0.4"
}
}

View File

@ -14,6 +14,7 @@
--elevated-bg-color: #ffffff;
--bg-accent-color: #f4f4f4;
--danger-color: #ff2d2f;
--hyperlink-color: #3b82f6;
}
[data-theme='dark'] {
@ -27,6 +28,7 @@
--elevated-bg-color: #101011;
--bg-accent-color: #202020;
--danger-color: #ff1d1f;
--hyperlink-color: #3b82f6;
}
body {
@ -71,6 +73,14 @@ h1 {
border-bottom: 1px solid var(--separator-line-color);
}
.left-border {
border-left: 1px solid var(--separator-line-color);
}
.right-border {
border-right: 1px solid var(--separator-line-color);
}
.icon-16 {
font-size: 16px !important;
font-variation-settings:
@ -89,21 +99,24 @@ h1 {
'opsz' 20
}
input[type='search'], input[type='text'], input[type='password'], input[type='email'], input[type='tel'] {
input[type='search'], input[type='text'], input[type='password'], input[type='email'], input[type='tel'], input[type='number'], textarea, select {
background-color: var(--bg-color);
color: var(--text-color);
border: 1px solid var(--separator-line-color);
caret-color: var(--text-color);
}
input[type='search']:focus, input[type='text']:focus, input[type='password']:focus, input[type='email']:focus, input[type='tel']:focus {
input[type='search']:focus, input[type='text']:focus, input[type='password']:focus, input[type='email']:focus, input[type='tel']:focus, input[type='number']:focus, textarea:focus, select:focus {
outline: 0 solid var(--text-color);
border: 1px solid var(--primary-color);
box-shadow: 0 0 0 0 var(--primary-color);
caret-color: var(--text-color);
}
input[type='search']:-webkit-autofill, input[type='text']:-webkit-autofill, input[type='password']:-webkit-autofill, input[type='email']:-webkit-autofill, input[type='tel']:-webkit-autofill {
input[type='search']:-webkit-autofill, input[type='text']:-webkit-autofill, input[type='password']:-webkit-autofill, input[type='email']:-webkit-autofill, input[type='tel']:-webkit-autofill, input[type='number']:-webkit-autofill, textarea:-webkit-autofill, select:-webkit-autofill {
-webkit-text-fill-color: var(--text-color) !important;
background-clip: text !important;
caret-color: var(--text-color);
}
.input-field {
@ -155,6 +168,12 @@ input[type='search']:-webkit-autofill, input[type='text']:-webkit-autofill, inpu
margin: 0 auto;
}
.base-container-small {
width: 100%;
max-width: 960px;
margin: 0 auto;
}
.signin-container {
width: 100%;
max-width: 420px;
@ -167,6 +186,7 @@ input[type='search']:-webkit-autofill, input[type='text']:-webkit-autofill, inpu
@apply px-6;
}
.search-cancel::-webkit-search-cancel-button {
-webkit-appearance: none;
background-color: var(--text-color);
@ -200,10 +220,18 @@ th.left, td.left {
@apply shadow-lg
}
.elevated-bg {
background-color: var(--elevated-bg-color);
}
.primary-bg-color {
background-color: var(--primary-color);
}
.accent-bg-color {
background-color: var(--bg-accent-color);
}
.dull-primary-bg-color {
background-color: var(--dull-primary-color);
}
@ -241,6 +269,10 @@ input[type='checkbox']:focus {
background-color: var(--danger-color);
}
.danger-color {
color: var(--danger-color);
}
.danger-border-color {
border-color: var(--danger-color);
}
@ -259,6 +291,18 @@ input[type='checkbox']:focus {
background-color: rgba(0,0,0,0.6); /* Black w/ opacity */
}
.modal-always-display {
position: fixed; /* Stay in place */
z-index: 1; /* Sit on top */
left: 0;
top: 0;
width: 100%; /* Full width */
height: 100%; /* Full height */
overflow: auto; /* Enable scroll if needed */
background-color: rgb(0,0,0); /* Fallback color */
background-color: rgba(0,0,0,0.6); /* Black w/ opacity */
}
/* Modal Content/Box */
.modal-content {
background-color: var(--bg-color);
@ -269,33 +313,18 @@ input[type='checkbox']:focus {
border-radius: 4px;
}
/* The Close Button */
.close {
color: #aaa;
float: right;
font-size: 28px;
font-weight: bold;
}
.close:hover,
.close:focus {
color: black;
text-decoration: none;
cursor: pointer;
}
h2 {
color: var(--text-color);
@apply text-2xl
}
.tooltip {
position: relative;
display: inline-block;
}
/*.tooltip {*/
/* position: relative;*/
/* display: inline-block;*/
/*}*/
/* Tooltip text */
.tooltip .tooltip-text {
.tooltip-text {
visibility: hidden;
background-color: var(--bg-accent-color);
color: var(--text-color);
@ -306,16 +335,16 @@ h2 {
/* Position the tooltip text - see examples below! */
position: absolute;
z-index: 1;
width: 100px;
width: 120px;
bottom: 120%;
left: 50%;
margin-left: -50px; /* Use half of the width (120/2 = 60), to center the tooltip */
margin-left: -60px; /* Use half of the width (120/2 = 60), to center the tooltip */
opacity: 0;
transition: opacity 0s linear 0.5s;
}
.tooltip .tooltip-text::after {
.tooltip-text::after {
content: " ";
position: absolute;
top: 100%; /* At the bottom of the tooltip */
@ -338,8 +367,16 @@ h2 {
}
.hover-hyperlink:hover {
color: var(--dull-primary-color);
text-decoration: underline var(--dull-primary-color);;
color: var(--hyperlink-color);
text-decoration: underline var(--hyperlink-color);
}
.hyperlink-color {
color: var(--hyperlink-color);
}
.hyperlink-underline {
text-decoration: underline var(--hyperlink-color);
}
.max-char-length {
@ -347,3 +384,35 @@ h2 {
overflow: hidden;
text-overflow: ellipsis;
}
::-webkit-scrollbar-track {
background: var(--bg-color);
}
::-webkit-scrollbar {
width: 12px;
}
::-webkit-scrollbar-thumb {
background: var(--bg-accent-color);
}
::-webkit-scrollbar-thumb:hover {
background: var(--separator-line-color);
}
.scrollbar-on-elevated::-webkit-scrollbar-track {
background: var(--elevated-bg-color);
}
.-webkit-scrollbar-thumb::-webkit-scrollbar-track {
background: var(--elevated-bg-color);
}
.details-height {
max-height: calc(100vh - 200px);
}
.top-with-navbar {
top: 72px;
}

View File

@ -2,6 +2,14 @@ import bcrypt from 'bcrypt';
import sql from '$lib/db/db.server';
import { error } from '@sveltejs/kit';
import { saveAvatar } from '$lib/index.server';
import {
EmploymentType,
type User,
type Company,
type Tag,
type Posting,
type Application
} from '$lib/types';
export async function createUser(user: User): Promise<number> {
const password_hash: string = await bcrypt.hash(user.password!, 12);
@ -48,7 +56,7 @@ export async function updateUser(user: User): Promise<number> {
export async function checkUserCreds(username: string, password: string): Promise<User | null> {
const [user] = await sql`
SELECT id, password_hash, perms, active
SELECT id, username, password_hash, perms, active, company_id AS "companyId"
FROM users
WHERE username = ${username}
`;
@ -57,7 +65,8 @@ export async function checkUserCreds(username: string, password: string): Promis
return null;
}
if (await bcrypt.compare(password, user.password_hash)) {
return <User>{ id: user.id, perms: user.perms, active: user.active };
delete user.password_hash;
return <User>user;
}
return null;
}
@ -87,6 +96,19 @@ export async function getUsers(searchQuery: string | null = null): Promise<User[
return <User[]>(<unknown>users);
}
export async function getCompanies(searchQuery: string | null = null): Promise<Company[]> {
return sql<Company[]>`
SELECT id,
name,
description,
website,
company_code AS "companyCode",
created_at AT TIME ZONE 'UTC' AS "createdAt"
FROM companies
WHERE name ILIKE ${searchQuery ? `%${searchQuery}%` : '%'};
`;
}
// should require MANAGE_USERS permission
export async function getUser(id: number): Promise<User> {
const [user] = await sql`
@ -152,9 +174,84 @@ export async function getUserWithCompany(id: number): Promise<User> {
return <User>user;
}
export async function getUserWithCompanyAndApplications(
id: number
): Promise<{ user: User; applications: Application[] }> {
const data = await sql`
WITH company_data AS (
SELECT
id,
name,
description,
website,
created_at AT TIME ZONE 'UTC' AS "createdAt"
FROM companies
WHERE id = (SELECT company_id FROM users WHERE id = ${id})
),
user_data AS (
SELECT
username,
perms,
email,
phone,
full_name AS "fullName",
created_at AT TIME ZONE 'UTC' AS "createdAt",
last_signin AT TIME ZONE 'UTC' AS "lastSignIn",
active
FROM users
WHERE "id" = ${id}
),
application_data AS (
SELECT
id,
posting_id AS "postingId",
(SELECT title FROM postings WHERE id = posting_id) AS "postingTitle",
created_at AT TIME ZONE 'UTC' AS "createdAt"
FROM applications
WHERE "user_id" = ${id}
)
SELECT
(
SELECT row_to_json(company_data)
FROM company_data
) AS company,
(
SELECT row_to_json(user_data)
FROM user_data
) AS user,
(
SELECT json_agg(row_to_json(application_data))
FROM application_data
) AS applications;
`;
if (!data) {
error(404, 'User not found');
}
let user = data[0].user;
user.company = data[0].company;
user.createdAt = new Date(user.createdAt);
user.lastSignIn = new Date(user.lastSignIn);
if (user.company) {
user.company.createdAt = new Date(user.company.createdAt);
}
let applications = data[0].applications;
if (applications) {
applications.forEach((application: { createdAt: string | number | Date }) => {
application.createdAt = new Date(application.createdAt);
});
}
return {
user: <User>user,
applications: <Application[]>applications
};
}
// should require MANAGE_USERS permission
export async function deleteUser(id: number): Promise<void> {
const response = await sql`
await sql`
DELETE FROM users
WHERE id = ${id};
`;
@ -177,16 +274,456 @@ export async function updateLastSignin(username: string): Promise<void> {
`;
}
export async function createCompany(
name: string,
description: string,
website: string
): Promise<number> {
export async function createCompany(company: Company): Promise<number> {
const response = await sql`
INSERT INTO companies (name, description, website, created_at, company_code)
VALUES (${name}, ${description}, ${website}, NOW(), generate_company_code(CAST(CURRVAL('companies_id_seq') AS INT)))
VALUES (${company.name}, ${company.description}, ${company.website}, NOW(), generate_company_code(CAST(CURRVAL('companies_id_seq') AS INT)))
RETURNING id;
`;
return response[0].id;
}
export async function editCompany(company: Company): Promise<number> {
const response = await sql`
UPDATE companies
SET name = ${company.name}, description = ${company.description}, website = ${company.website}
WHERE id = ${company.id}
RETURNING id;
`;
return response[0].id;
}
export async function deleteCompany(id: number): Promise<void> {
await sql`
DELETE FROM companies
WHERE id = ${id};
`;
}
export async function getCompany(id: number): Promise<Company> {
const [company] = await sql`
SELECT id, name, description, website, created_at AS "createdAt"
FROM companies
WHERE id = ${id};
`;
if (!company) {
error(404, 'Company not found');
}
return <Company>company;
}
export async function getCompanyFullData(
id: number
): Promise<{ company: Company; users: User[]; postings: Posting[] }> {
const data = await sql`
WITH company_data AS (
SELECT
id,
name,
description,
website,
created_at AT TIME ZONE 'UTC' AS "createdAt"
FROM companies
WHERE id = ${id}
),
user_data AS (
SELECT
username,
email,
phone,
full_name AS "fullName"
FROM users
WHERE "company_id" = ${id}
),
posting_data AS (
SELECT
id,
title,
description,
employer_id AS "employerId",
address,
employment_type AS "employmentType",
wage,
link,
tag_ids AS "tagIds",
created_at AT TIME ZONE 'UTC' AS "createdAt",
updated_at AT TIME ZONE 'UTC' AS "updatedAt",
flyer_link AS "flyerLink"
FROM postings
WHERE "company_id" = ${id}
)
SELECT
(
SELECT row_to_json(company_data)
FROM company_data
) AS company,
(
SELECT json_agg(row_to_json(user_data))
FROM user_data
) AS users,
(
SELECT json_agg(row_to_json(posting_data))
FROM posting_data
) AS postings;
`;
if (!data) {
error(404, 'Company not found');
}
if (data[0].company) {
data[0].company.createdAt = new Date(data[0].company.createdAt);
}
if (data[0].postings) {
data[0].postings.forEach(
(posting: {
createdAt: string | number | Date;
updatedAt: string | number | Date;
tagIds: number[] | undefined;
tags: { id: number; displayName: null; createdAt: null }[];
}) => {
posting.createdAt = new Date(posting.createdAt);
posting.updatedAt = new Date(posting.updatedAt);
if (posting.tagIds) {
posting.tagIds?.forEach((tagId: number) => {
posting.tags.push({ id: tagId, displayName: null, createdAt: null });
});
}
delete posting.tagIds;
}
);
}
return {
company: <Company>data[0].company,
users: <User[]>data[0].users,
postings: <Posting[]>data[0].postings
};
}
export async function createPosting(posting: Posting): Promise<number> {
if (posting.tagIds === null || posting.tagIds === undefined) {
posting.tagIds = [];
}
posting.tags?.forEach((tag) => {
posting.tagIds?.push(tag.id);
});
if (posting.companyId === null || posting.companyId === undefined) {
if (posting.company) {
posting.companyId = posting.company.id;
} else {
posting.companyId = null;
}
}
const response = await sql`
INSERT INTO postings (title, description, employer_id, address, employment_type, wage, link, tag_ids, created_at, updated_at, flyer_link, company_id)
VALUES (${posting.title}, ${posting.description}, ${posting.employerId}, ${posting.address}, ${posting.employmentType}, ${posting.wage}, ${posting.link}, ${posting.tagIds}, NOW(), NOW(), ${posting.flyerLink}, ${posting.companyId})
RETURNING id;
`;
return response[0].id;
}
export async function editPosting(posting: Posting): Promise<number> {
if (posting.tagIds === null || posting.tagIds === undefined) {
posting.tagIds = [];
}
posting.tags?.forEach((tag) => {
posting.tagIds?.push(tag.id);
});
if (posting.companyId === null || posting.companyId === undefined) {
if (posting.company) {
posting.companyId = posting.company.id;
} else {
posting.companyId = null;
}
}
const response = await sql`
UPDATE postings
SET title = ${posting.title}, description = ${posting.description}, employer_id = ${posting.employerId}, address = ${posting.address}, employment_type = ${posting.employmentType}, wage = ${posting.wage}, link = ${posting.link}, tag_ids = ${posting.tagIds}, updated_at = NOW(), flyer_link = ${posting.flyerLink}, company_id = ${posting.companyId}
WHERE id = ${posting.id}
RETURNING id;
`;
return response[0].id;
}
export async function deletePosting(id: number): Promise<void> {
await sql`
DELETE FROM postings
WHERE id = ${id};
`;
}
export async function getCompanyEmployers(
id: number
): Promise<{ company: Company; users: User[] }> {
const data = await sql`
WITH company_data AS (
SELECT
id,
name,
description,
website,
created_at AT TIME ZONE 'UTC' AS "createdAt",
company_code AS "companyCode"
FROM companies
WHERE id = ${id}
),
user_data AS (SELECT id,
username,
email,
phone,
full_name AS "fullName",
created_at AT TIME ZONE 'UTC' AS "createdAt",
last_signin AT TIME ZONE 'UTC' AS "lastSignIn",
company_id as "companyId"
FROM users
WHERE "company_code" = (SELECT company_code FROM companies WHERE id = ${id}))
SELECT
(
SELECT row_to_json(company_data)
FROM company_data
) AS company,
(
SELECT json_agg(row_to_json(user_data))
FROM user_data
) AS users;
`;
if (!data) {
error(404, 'Company not found');
}
if (data[0].users) {
data[0].users.forEach(
(user: {
company: { id: any };
companyId: any;
createdAt: string | number | Date;
lastSignIn: string | number | Date;
}) => {
user.company = {
id: user.companyId
};
user.createdAt = new Date(user.createdAt);
user.lastSignIn = new Date(user.lastSignIn);
delete user.companyId;
}
);
}
return {
company: <Company>data[0].company,
users: <User[]>data[0].users
};
}
export async function removeEmployerFromCompany(companyId: number, userId: number): Promise<void> {
await sql`
UPDATE users
SET company_id = NULL,
company_code = NULL
WHERE id = ${userId};
`;
}
export async function addEmployerToCompany(companyId: number, userId: number): Promise<void> {
await sql`
UPDATE users
SET company_id = ${companyId}
WHERE id = ${userId};
`;
}
export async function getPostings(searchQuery: string | null = null): Promise<Posting[]> {
const postings = await sql<Posting[]>`
SELECT p.id,
p.title,
p.description,
p.employer_id AS "employerId",
p.address,
p.employment_type AS "employmentType",
p.wage,
p.link,
p.tag_ids AS "tagIds",
p.created_at AT TIME ZONE 'UTC' AS "createdAt",
p.updated_at AT TIME ZONE 'UTC' AS "updatedAt",
p.flyer_link AS "flyerLink",
p.company_id AS "companyId",
c.name AS "companyName"
FROM postings p
LEFT JOIN companies c ON p.company_id = c.id
WHERE title ILIKE ${searchQuery ? `%${searchQuery}%` : '%'};
`;
postings.forEach((posting) => {
posting.company = <Company>{};
if (posting.companyName) {
posting.company.name = posting.companyName;
}
delete posting.companyName;
posting.tags = [];
posting.employmentType = EmploymentType[posting.employmentType as keyof typeof EmploymentType];
if (posting.tagIds) {
posting.tagIds?.forEach((tagId: number) => {
posting.tags.push({ id: tagId, displayName: null, createdAt: null });
});
}
delete posting.tagIds;
});
return <Posting[]>(<unknown>postings);
}
export async function getPosting(id: number): Promise<Posting> {
const data = await sql<Posting[]>`
SELECT id,
title,
description,
employer_id AS "employerId",
address,
employment_type AS "employmentType",
wage,
link,
tag_ids AS "tagIds",
created_at AT TIME ZONE 'UTC' AS "createdAt",
updated_at AT TIME ZONE 'UTC' AS "updatedAt",
flyer_link AS "flyerLink",
company_id AS "companyId"
FROM postings
WHERE id = ${id};
`;
const posting = data[0];
posting.tags = [];
posting.employmentType = EmploymentType[posting.employmentType as keyof typeof EmploymentType];
if (posting.tagIds) {
posting.tagIds?.forEach((tagId: number) => {
posting.tags.push({ id: tagId, displayName: null, createdAt: null });
});
}
delete posting.tagIds;
if (posting.createdAt) {
posting.createdAt = new Date(posting.createdAt);
}
if (posting.updatedAt) {
posting.updatedAt = new Date(posting.updatedAt);
}
return posting;
}
export async function getPostingFullData(id: number): Promise<Posting> {
const data = await sql`
WITH company_data AS (
SELECT
id,
name,
description,
website,
created_at AS "createdAt"
FROM companies
WHERE id = (SELECT company_id FROM postings WHERE id = ${id})
),
user_data AS (
SELECT
username,
email,
phone,
full_name AS "fullName"
FROM users
WHERE "company_id" = (SELECT company_id FROM postings WHERE id = ${id})
),
posting_data AS (
SELECT
id,
title,
description,
employer_id AS "employerId",
address,
employment_type AS "employmentType",
wage,
link,
tag_ids AS "tagIds",
created_at AT TIME ZONE 'UTC' AS "createdAt",
updated_at AT TIME ZONE 'UTC' AS "updatedAt",
flyer_link AS "flyerLink"
FROM postings
WHERE id = ${id}
)
SELECT
(
SELECT row_to_json(company_data)
FROM company_data
) AS company,
(
SELECT row_to_json(user_data)
FROM user_data
) AS user,
(
SELECT row_to_json(posting_data)
FROM posting_data
) AS posting;
`;
if (!data) {
error(404, 'Posting not found');
}
let posting = <Posting>data[0].posting;
posting.company = <Company>data[0].company;
posting.employer = <User>data[0].user;
if (posting.createdAt) {
posting.createdAt = new Date(posting.createdAt);
}
if (posting.updatedAt) {
posting.updatedAt = new Date(posting.updatedAt);
}
return posting;
}
export async function createApplication(application: Application): Promise<number> {
const response = await sql`
INSERT INTO applications (posting_id, user_id, candidate_statement, created_at)
VALUES (${application.postingId}, ${application.userId}, ${application.candidateStatement}, NOW())
RETURNING id;
`;
return response[0].id;
}
export async function deleteApplication(id: number): Promise<void> {
const response = await sql`
DELETE FROM applications
WHERE id = ${id};
`;
}
export async function deleteApplicationWithUser(
applicationId: number,
userId: number
): Promise<void> {
console.log(applicationId, userId);
const response = await sql`
DELETE FROM applications
WHERE id = ${applicationId} AND user_id = ${userId};
`;
}
export async function getApplications(postingId: number): Promise<Application[]> {
const applications = await sql<Application[]>`
SELECT id, posting_id AS "postingId", user_id AS "userId", candidate_statement AS "candidateStatement", created_at AS "createdAt"
FROM applications
WHERE posting_id = ${postingId};
`;
applications.forEach((application) => {
application.createdAt = new Date(application.createdAt);
});
return applications;
}

View File

@ -3,7 +3,7 @@ import path from 'path';
import fetch from 'node-fetch';
import { type Cookies, error } from '@sveltejs/kit';
import jwt from 'jsonwebtoken';
import { getUserWithCompany } from '$lib/db/index.server';
import type { User } from '$lib/types';
// TODO: Handle saving custom avatar uploads
export async function saveAvatar(user: User): Promise<void> {
@ -14,6 +14,7 @@ export async function saveAvatar(user: User): Promise<void> {
fs.writeFileSync(filePath, avatar);
}
// TODO: change to return null instead of -1
export function getUserPerms(cookies: Cookies): number {
if (process.env.JWT_SECRET === undefined) {
throw new Error('JWT_SECRET not defined');
@ -51,3 +52,22 @@ export function getUserId(cookies: Cookies): number {
}
error(403, 'Unauthorized');
}
export function getUserCompanyId(cookies: Cookies): number | null {
if (process.env.JWT_SECRET === undefined) {
throw new Error('JWT_SECRET not defined');
}
const JWT = cookies.get('jwt');
if (JWT) {
try {
const decoded = jwt.verify(JWT, process.env.JWT_SECRET);
if (typeof decoded === 'object' && 'companyId' in decoded) {
return decoded['companyId'];
}
} catch (err) {
return null;
}
}
error(403, 'Unauthorized');
}

20
src/lib/shared.server.ts Normal file
View File

@ -0,0 +1,20 @@
import type { Cookies } from '@sveltejs/kit';
import jwt from 'jsonwebtoken';
import type { User } from '$lib/types';
export function setJWT(cookies: Cookies, user: User) {
const payload = {
username: user.username,
perms: user.perms,
id: user.id,
companyId: user.companyId
};
if (process.env.JWT_SECRET === undefined) {
throw new Error('JWT_SECRET not defined');
}
const maxAge = 60 * 60 * 24 * 30; // 30 days
const JWT = jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '30d' });
cookies.set('jwt', JWT, { maxAge, path: '/', httpOnly: false });
}

View File

@ -1,6 +1,12 @@
import { PERMISSIONS } from '$lib/consts';
import type { EmploymentType } from '$lib/types';
export let userState = $state({ perms: PERMISSIONS.VIEW, username: null, id: null });
export let userState = $state({
perms: PERMISSIONS.VIEW,
username: null,
id: null,
companyId: null
});
export const userPerms = PERMISSIONS.VIEW | PERMISSIONS.APPLY_FOR_JOBS;
export const employerPerms = PERMISSIONS.SUBMIT_POSTINGS | PERMISSIONS.MANAGE_EMPLOYERS;
@ -19,3 +25,28 @@ export function telFormatter(initial: string) {
(num.length > 6 ? '-' + num.substring(6, 10) : '');
return initial;
}
export const getCookieValue = (name: String) =>
document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)')?.pop() || '';
export function updateUserState() {
const JWT = getCookieValue('jwt');
if (JWT !== '') {
const state = JSON.parse(atob(JWT.split('.')[1]));
userState.perms = state.perms;
userState.username = state.username;
userState.id = state.id;
userState.companyId = state.companyId;
}
}
export function employmentTypeDisplayName(type: EmploymentType) {
switch (type) {
case 'full_time':
return 'Full Time';
case 'part_time':
return 'Part Time';
case 'internship':
return 'Internship';
}
}

View File

@ -1,4 +1,4 @@
interface User {
export interface User {
id: number | null;
username: string;
password: string | null;
@ -11,18 +11,58 @@ interface User {
fullName: string | null;
company: Company | null;
companyCode: string | null;
companyId: number | null | undefined;
}
interface Tag {
export interface Company {
id: number;
displayName: string;
name: string | null;
description: string | null;
website: string | null;
createdAt: Date | null;
companyCode: string | null;
}
interface Company {
export interface Posting {
id: number;
name: string;
title: string;
description: string;
website: string;
employerId: number;
address: string;
employmentType: EmploymentType;
wage: string;
link: string;
tags: Tag[];
tagIds: number[] | null | undefined;
createdAt: Date | null;
updatedAt: Date | null;
flyerLink: string | null;
company: Company;
companyId: number | null | undefined;
companyName: string | null | undefined;
employer: User | null | undefined;
}
export interface Tag {
id: number;
displayName: string | null;
createdAt: Date | null;
}
export enum EmploymentType {
full_time = 'full_time',
part_time = 'part_time',
internship = 'internship'
// contract = 'Contract',
// temporary = 'Temporary',
// seasonal = 'Seasonal'
}
export interface Application {
id: number;
userId: number;
postingId: number;
postingTitle: string | null;
candidateStatement: string;
createdAt: Date;
}

View File

@ -1,9 +1,8 @@
<script lang="ts">
import '../app.css';
import { onMount } from 'svelte';
import { userState } from '$lib/shared.svelte';
import { updateUserState, userState } from '$lib/shared.svelte';
import { PERMISSIONS } from '$lib/consts';
import { updateUserState } from './utils.client';
let currentTheme: string = $state('');
@ -32,25 +31,15 @@
updateUserState();
});
if ((userState.perms & PERMISSIONS.MANAGE_POSTINGS) !== 0) {
('/admin/postings');
} else if ((userState.perms & PERMISSIONS.MANAGE_USERS) !== 0) {
('/admin/employers');
} else if ((userState.perms & PERMISSIONS.MANAGE_TAGS) !== 0) {
('/admin/tags');
} else if ((userState.perms & PERMISSIONS.MANAGE_COMPANIES) !== 0) {
('/admin/companies');
}
let { children } = $props();
</script>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..40,400,0,0&display=block&icon_names=account_circle,arrow_drop_down,arrow_drop_up,calendar_today,call,check,close,dark_mode,edit,group,info,light_mode,login,mail,person,search,sell,store,visibility,visibility_off,work"
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..40,400,0,0&display=block&icon_names=account_circle,arrow_drop_down,arrow_drop_up,calendar_today,call,check,close,dark_mode,delete,description,edit,group,info,light_mode,login,mail,person,search,sell,store,visibility,visibility_off,work"
/>
<div class="bottom-border flex h-14 justify-between p-3 align-middle">
<div class="bottom-border bg-color sticky top-0 flex h-14 justify-between p-3 align-middle">
<nav class="pt-1">
<a href="/" class="hover-bg-color mr-1 rounded-md px-2 pb-2 pt-1.5">
<img
@ -61,22 +50,23 @@
width="24"
/>
</a>
<a href="/about" class="hover-bg-color mr-1 rounded px-3 py-2 text-sm">About</a>
{#if (userState.perms & PERMISSIONS.VIEW) !== 0}
<a href="/listings" class="hover-bg-color mr-1 rounded px-3 py-2 text-sm">Listings</a>
<!-- <a href="/about" class="hover-bg-color mr-1 rounded px-3 py-2 text-sm">About</a>-->
{#if (userState.perms & PERMISSIONS.VIEW) > 0}
<a href="/postings" class="hover-bg-color mr-1 rounded px-3 py-2 text-sm">Postings</a>
{/if}
{#if (userState.perms & PERMISSIONS.VIEW) !== 0}
{#if (userState.perms & PERMISSIONS.VIEW) > 0}
<a href="/companies" class="hover-bg-color mr-1 rounded px-3 py-2 text-sm">Companies</a>
{/if}
{#if (userState.perms & (PERMISSIONS.MANAGE_POSTINGS | PERMISSIONS.MANAGE_TAGS | PERMISSIONS.MANAGE_USERS)) !== 0}
{#if (userState.perms & (PERMISSIONS.MANAGE_POSTINGS | PERMISSIONS.MANAGE_TAGS | PERMISSIONS.MANAGE_USERS)) > 0}
<a
href={(userState.perms & PERMISSIONS.MANAGE_POSTINGS) !== 0
href={(userState.perms & PERMISSIONS.MANAGE_POSTINGS) > 0
? '/admin/postings'
: (userState.perms & PERMISSIONS.MANAGE_USERS) !== 0
: (userState.perms & PERMISSIONS.MANAGE_USERS) > 0
? '/admin/users'
: (userState.perms & PERMISSIONS.MANAGE_TAGS) !== 0
? '/admin/tags'
: (userState.perms & PERMISSIONS.MANAGE_COMPANIES) !== 0
: // TODO: Implement tags
// : (userState.perms & PERMISSIONS.MANAGE_TAGS) > 0
// ? '/admin/tags'
(userState.perms & PERMISSIONS.MANAGE_COMPANIES) > 0
? '/admin/companies'
: '/admin'}
class="hover-bg-color mr-1 rounded px-3 py-2 text-sm">Administration</a
@ -89,11 +79,11 @@
{currentTheme === 'light' ? 'light_mode' : 'dark_mode'}
</span>
</button>
<button onclick={() => (window.location.href = userState.id !== null ? '/account' : '/signin')}>
<a href={userState.id !== null ? '/account' : '/signin'}>
<span class="material-symbols-outlined hover-bg-color rounded-full p-1">
{userState.id !== null ? 'account_circle' : 'login'}
</span>
</button>
</a>
</div>
</div>

View File

@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { updateUserState } from './utils.client';
import { updateUserState } from '$lib/shared.svelte';
onMount(() => {
updateUserState();

View File

@ -1,10 +1,25 @@
import type { PageServerLoad } from './$types';
import { getUserWithCompany } from '$lib/db/index.server';
import { error } from '@sveltejs/kit';
import jwt from 'jsonwebtoken';
import { getUserId } from '$lib/index.server';
import {
deleteApplication,
deleteApplicationWithUser,
getUserWithCompanyAndApplications
} from '$lib/db/index.server';
import { getUserId, getUserPerms } from '$lib/index.server';
import { type Actions, fail } from '@sveltejs/kit';
import { PERMISSIONS } from '$lib/consts';
export const load: PageServerLoad = async ({ cookies }) => {
const id = getUserId(cookies);
return { user: await getUserWithCompany(id) };
return await getUserWithCompanyAndApplications(id);
};
export const actions: Actions = {
delete: async ({ url, cookies }) => {
const id = parseInt(url.searchParams.get('id')!);
try {
await deleteApplicationWithUser(id, getUserId(cookies));
} catch (err) {
return fail(500, { errorMessage: `Internal Server Error: ${err}` });
}
}
};

View File

@ -2,6 +2,8 @@
import { onMount } from 'svelte';
import type { PageProps } from './$types';
let applicationToDelete: number = $state(0);
onMount(() => {
if (!document.cookie.includes('jwt=')) {
window.location.href = '/signin';
@ -29,6 +31,14 @@
window.location.href = '/signin';
}
function openConfirm() {
document.getElementById('deleteConfirmModal')!.style.display = 'block';
}
function closeConfirm() {
document.getElementById('deleteConfirmModal')!.style.display = 'none';
}
let { data, form }: PageProps = $props();
</script>
@ -39,7 +49,9 @@
<img
id="avatar"
class="mb-2 inline-block rounded-lg"
src="/uploads/avatars/{data.user.id}.svg?timestamp=${Date.now()}"
src="/uploads/avatars/{data.user.id
? data.user.id
: 'default'}.svg?timestamp=${Date.now()}"
onerror={avatarFallback}
alt="User avatar"
height="240"
@ -72,7 +84,8 @@
</div>
{/if}
</div>
<div class="elevated separator-borders m-2 inline-block h-min w-full rounded">
<div class="inline-block w-full">
<div class="elevated separator-borders m-2 h-min w-full rounded">
<div class="bottom-border flex place-content-between">
<div class="p-3 font-semibold">User Details</div>
<div class="flex">
@ -128,5 +141,57 @@
</div>
</div>
</div>
{#if data.applications}
<div class="elevated separator-borders m-2 inline-block h-min w-full rounded">
<div class="p-3 font-semibold">Pending applications</div>
{#each data.applications as application}
<div class="top-border flex justify-between p-3">
<div class="inline-block">
<div class="font-semibold">
Applied to: <span class="font-normal">{application.postingTitle}</span>
</div>
<div class="font-semibold">
Applied on: <span class="font-normal"
>{data.applications[0].createdAt.toLocaleDateString(
'en-US',
dateFormatOptions
)}</span
>
</div>
</div>
<button
class="material-symbols-outlined danger-color inline-block"
onclick={() => {
applicationToDelete = application.id;
openConfirm();
}}>delete</button
>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
<form id="deleteConfirmModal" method="POST" class="modal">
<div class="modal-content">
<div class="mb-2 inline-flex w-full justify-between">
<h2 class="font-semibold">Are you sure?</h2>
<button class="material-symbols-outlined" onclick={closeConfirm}>close</button>
</div>
<p>This will permanently delete this application. This action cannot be undone.</p>
<div class="mt-4 flex justify-between">
<button
class="danger-bg-color rounded px-2 py-1"
type="submit"
formaction="?/delete&id={applicationToDelete}">Delete application</button
>
<button
class="separator-borders bg-color rounded px-2 py-1"
type="button"
onclick={closeConfirm}>Cancel</button
>
</div>
</div>
</form>

View File

@ -3,6 +3,7 @@ import { deleteUser, getUser, getUserWithCompany, updateUser } from '$lib/db/ind
import { type Actions, fail, redirect } from '@sveltejs/kit';
import { PERMISSIONS } from '$lib/consts';
import { getUserId } from '$lib/index.server';
import type { User } from '$lib/types';
export const load: PageServerLoad = async ({ cookies }) => {
const id = getUserId(cookies);
@ -23,9 +24,7 @@ export const actions: Actions = {
.toUpperCase()
.trim();
if (email === '' || email == undefined) email = null;
if (phone === '' || phone == undefined) phone = null;
if (fullName === '' || fullName == undefined) fullName = null;
if (companyCode === '' || companyCode == undefined) companyCode = null;
if (email && !email.includes('@')) {
@ -36,7 +35,7 @@ export const actions: Actions = {
return fail(400, { errorMessage: 'Invalid phone number' });
}
if (username && username !== '') {
if (username && username !== '' && fullName && fullName !== '' && email && email !== '') {
try {
await updateUser(<User>{
id: id,

View File

@ -6,6 +6,10 @@
let permsAccordions: boolean[] = [false, false, false];
onMount(() => {
if (!document.cookie.includes('jwt=')) {
window.location.href = '/signin';
}
let acc = document.getElementsByClassName('accordion');
for (let i = 0; i < acc.length; i++) {
acc[i].addEventListener('click', function (this: HTMLElement, event: Event) {
@ -89,7 +93,7 @@
</div>
<form method="POST" class="px-4" autocomplete="off" use:enhance>
<div class="mt-4 text-sm font-semibold">
Username
Username <span class="danger-color">*</span>
<input
type="text"
name="username"
@ -100,7 +104,19 @@
/>
</div>
<div class="mt-4 text-sm font-semibold">
Email (optional)
Full name <span class="danger-color">*</span>
<input
type="text"
name="fullName"
id="fullName"
value={data.user?.fullName}
placeholder="Full name"
class="w-full rounded font-normal"
required
/>
</div>
<div class="mt-4 text-sm font-semibold">
Email <span class="danger-color">*</span>
<input
type="email"
name="email"
@ -108,6 +124,7 @@
value={data.user?.email}
placeholder="Email"
class="w-full rounded font-normal"
required
/>
</div>
<div class="mt-4 text-sm font-semibold">
@ -122,17 +139,6 @@
pattern="([0-9]\{3}) [0-9]\{3}-[0-9]\{3}"
/>
</div>
<div class="mt-4 text-sm font-semibold">
Full name (optional)
<input
type="text"
name="fullName"
id="fullName"
value={data.user?.fullName}
placeholder="Full name"
class="w-full rounded font-normal"
/>
</div>
<div class="mt-4 text-sm font-semibold">
Company code (optional)
<input

View File

@ -1,5 +1,4 @@
<script lang="ts">
import '../../app.css';
import { page } from '$app/state';
import { userState } from '$lib/shared.svelte';
import { PERMISSIONS } from '$lib/consts';
@ -8,7 +7,7 @@
</script>
<div class="bottom-border h-10 pt-2 text-center">
{#if (userState.perms & PERMISSIONS.MANAGE_POSTINGS) !== 0}
{#if (userState.perms & PERMISSIONS.MANAGE_POSTINGS) > 0}
<a
href="/admin/postings"
class="p-2 {page.url.pathname.startsWith('/admin/postings')
@ -17,7 +16,7 @@
><span class="material-symbols-outlined align-bottom">work</span> Postings</a
>
{/if}
{#if (userState.perms & PERMISSIONS.MANAGE_USERS) !== 0}
{#if (userState.perms & PERMISSIONS.MANAGE_USERS) > 0}
<a
href="/admin/users"
class="p-2 {page.url.pathname.startsWith('/admin/users')
@ -26,16 +25,17 @@
><span class="material-symbols-outlined align-bottom">group</span> Users</a
>
{/if}
{#if (userState.perms & PERMISSIONS.MANAGE_TAGS) !== 0}
<a
href="/admin/tags"
class="{page.url.pathname.startsWith('/admin/tags')
? 'primary-underline font-bold'
: 'low-emphasis-text low-emphasis-text-button'} p-2"
><span class="material-symbols-outlined align-bottom">sell</span> Tags</a
>
{/if}
{#if (userState.perms & PERMISSIONS.MANAGE_COMPANIES) !== 0}
<!--TODO-->
<!--{#if (userState.perms & PERMISSIONS.MANAGE_TAGS) > 0}-->
<!-- <a-->
<!-- href="/admin/tags"-->
<!-- class="{page.url.pathname.startsWith('/admin/tags')-->
<!-- ? 'primary-underline font-bold'-->
<!-- : 'low-emphasis-text low-emphasis-text-button'} p-2"-->
<!-- ><span class="material-symbols-outlined align-bottom">sell</span> Tags</a-->
<!-- >-->
<!--{/if}-->
{#if (userState.perms & PERMISSIONS.MANAGE_COMPANIES) > 0}
<a
href="/admin/companies"
class="{page.url.pathname.startsWith('/admin/companies')

View File

@ -4,13 +4,13 @@
import { PERMISSIONS } from '$lib/consts';
onMount(() => {
if ((userState.perms & PERMISSIONS.MANAGE_POSTINGS) !== 0) {
if ((userState.perms & PERMISSIONS.MANAGE_POSTINGS) > 0) {
window.location.href = '/admin/postings';
} else if ((userState.perms & PERMISSIONS.MANAGE_USERS) !== 0) {
window.location.href = '/admin/employers';
} else if ((userState.perms & PERMISSIONS.MANAGE_TAGS) !== 0) {
} else if ((userState.perms & PERMISSIONS.MANAGE_USERS) > 0) {
window.location.href = '/admin/users';
} else if ((userState.perms & PERMISSIONS.MANAGE_TAGS) > 0) {
window.location.href = '/admin/tags';
} else if ((userState.perms & PERMISSIONS.MANAGE_COMPANIES) !== 0) {
} else if ((userState.perms & PERMISSIONS.MANAGE_COMPANIES) > 0) {
window.location.href = '/admin/companies';
}
});

View File

@ -0,0 +1,16 @@
import type { PageServerLoad } from './$types';
import { getCompanies, getUsers } from '$lib/db/index.server';
import { PERMISSIONS } from '$lib/consts';
import { error } from '@sveltejs/kit';
import { getUserPerms } from '$lib/index.server';
export const load: PageServerLoad = async ({ cookies, url }) => {
const search = url.searchParams.get('searchCompanies');
const perms = getUserPerms(cookies);
if (perms >= 0 && (perms & PERMISSIONS.MANAGE_COMPANIES) > 0) {
return {
companies: await getCompanies(search)
};
}
error(403, 'Unauthorized');
};

View File

@ -0,0 +1,84 @@
<script lang="ts">
let { data } = $props();
const dateFormatOptions: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'short',
day: 'numeric'
};
</script>
<div class="content">
<div class="elevated separator-borders m-4 rounded">
<div class="bottom-border flex place-content-between">
<div class="p-3 font-semibold">
Company Management (Total: {data.companies?.length || 0})
</div>
<a class="dull-primary-bg-color m-2 rounded-md px-2.5 py-1" href="/companies/create"
>Create new company</a
>
</div>
<form action="" class="px-4">
<div class="flex py-4">
<div class="search-bar">
<input
type="search"
name="searchCompanies"
id="searchCompanies"
placeholder="Search Companies"
class="search-cancel"
/>
<button><span class="material-symbols-outlined">search</span></button>
</div>
<button class="hover-bg-color mx-2 rounded py-2 pl-3 pr-2 text-sm"
>Filter<span class="material-symbols-outlined icon-20 align-middle">arrow_drop_down</span
></button
>
<button class="hover-bg-color rounded py-2 pl-3 pr-2 text-sm"
>Sort<span class="material-symbols-outlined icon-20 align-middle">arrow_drop_down</span
></button
>
</div>
</form>
<div class="table">
<table>
<thead>
<tr>
<th class="left w-16 py-1">ID</th>
<th class="py-1">Name</th>
<th class="py-1">Website</th>
<th class="py-1">Created</th>
<th class="w-28 py-1"></th>
</tr>
</thead>
<tbody>
{#if data.companies !== undefined}
{#each data.companies as company}
<tr>
<td class="left">{company.id}</td>
<td>{company.name}</td>
<td>{company.website}</td>
<td
>{company.createdAt?.toLocaleDateString('en-US', dateFormatOptions) ||
'unknown'}</td
>
<td class="w-28 pr-1 text-end">
<a
class="hover-bg-color material-symbols-outlined hyperlink-color icon-20 tooltip relative my-1 rounded p-1"
href="/companies/{company.id}"
>store<span class="tooltip-text font-sans text-sm">View company</span></a
>
<a
class="hover-bg-color material-symbols-outlined icon-20 hyperlink-color tooltip relative my-1 ml-1 mr-8 rounded p-1"
href="/companies/{company.id}/edit"
>edit<span class="tooltip-text font-sans text-sm">Edit company</span></a
>
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
</div>
</div>

View File

@ -0,0 +1,16 @@
import type { PageServerLoad } from './$types';
import { getPostings, getUsers } from '$lib/db/index.server';
import { PERMISSIONS } from '$lib/consts';
import { error } from '@sveltejs/kit';
import { getUserPerms } from '$lib/index.server';
export const load: PageServerLoad = async ({ cookies, url }) => {
const search = url.searchParams.get('searchUsers');
const perms = getUserPerms(cookies);
if (perms >= 0 && (perms & PERMISSIONS.MANAGE_POSTINGS) > 0) {
return {
postings: await getPostings(search)
};
}
error(403, 'Unauthorized');
};

View File

@ -0,0 +1,93 @@
<script lang="ts">
import { employmentTypeDisplayName } from '$lib/shared.svelte';
const dateFormatOptions: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'short',
day: 'numeric'
};
let { data } = $props();
</script>
<div class="content">
<div class="elevated separator-borders m-4 rounded">
<div class="bottom-border flex place-content-between">
<div class="p-3 font-semibold">
Posting Management (Total: {data.postings?.length || 0})
</div>
<a class="dull-primary-bg-color m-2 rounded-md px-2.5 py-1" href="/postings/create"
>Create new posting</a
>
</div>
<form action="" class="px-4">
<div class="flex py-4">
<div class="search-bar">
<input
type="search"
name="searchUsers"
id="searchUsers"
placeholder="Search Users"
class="search-cancel"
/>
<button><span class="material-symbols-outlined">search</span></button>
</div>
<button class="hover-bg-color mx-2 rounded py-2 pl-3 pr-2 text-sm"
>Filter<span class="material-symbols-outlined icon-20 align-middle">arrow_drop_down</span
></button
>
<button class="hover-bg-color rounded py-2 pl-3 pr-2 text-sm"
>Sort<span class="material-symbols-outlined icon-20 align-middle">arrow_drop_down</span
></button
>
</div>
</form>
<div class="table">
<table>
<thead>
<tr>
<th class="left w-16 py-1">ID</th>
<th class="py-1">Title</th>
<th class="py-1">Company</th>
<th class="py-1">Created</th>
<th class="py-1">Updated</th>
<th class="py-1">Employment Type</th>
<th class="w-28 py-1"></th>
</tr>
</thead>
<tbody>
{#if data.postings !== undefined}
{#each data.postings as posting}
<tr>
<td class="left">{posting.id}</td>
<td class="left">{posting.title}</td>
<td>{posting.company.name || 'unknown'}</td>
<td
>{posting.createdAt?.toLocaleDateString('en-US', dateFormatOptions) ||
'unknown'}</td
>
<td
>{posting.updatedAt?.toLocaleDateString('en-US', dateFormatOptions) ||
'unknown'}</td
>
<td>{employmentTypeDisplayName(posting.employmentType)}</td>
<td class="w-28 pr-1 text-end">
<a
class="hover-bg-color material-symbols-outlined hyperlink-color icon-20 tooltip relative my-1 rounded p-1"
href="/postings/{posting.id}"
>work<span class="tooltip-text font-sans text-sm">View posting</span></a
>
<a
class="hover-bg-color material-symbols-outlined icon-20 hyperlink-color tooltip relative my-1 ml-1 mr-8 rounded p-1"
href="/postings/{posting.id}/manage/edit"
>edit<span class="tooltip-text font-sans text-sm">Edit posting</span></a
>
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
</div>
</div>

View File

@ -59,11 +59,11 @@
<td>{tag.createdAt?.toLocaleDateString('en-US', dateFormatOptions)}</td>
<td class="w-28 pr-1 text-end">
<a
class="hover-bg-color material-symbols-outlined icon-20 my-1 rounded p-1"
class="hover-bg-color material-symbols-outlined hyperlink-color icon-20 my-1 rounded p-1"
href="/admin/tags/{tag.id}">info</a
>
<a
class="hover-bg-color material-symbols-outlined icon-20 my-1 ml-1 mr-8 rounded p-1"
class="hover-bg-color material-symbols-outlined hyperlink-color icon-20 my-1 ml-1 mr-8 rounded p-1"
href="/admin/tags/{tag.id}/edit">edit</a
>
</td>

View File

@ -75,17 +75,17 @@
>{user.lastSignIn?.toLocaleDateString('en-US', dateFormatOptions) ||
'unknown'}</td
>
<td class="material-symbols-outlined py-2">
<td class="material-symbols-outlined py-1.5">
{user.active ? 'check' : 'close'}
</td>
<td class="w-28 pr-1 text-end">
<a
class="hover-bg-color material-symbols-outlined dull-primary-text-color icon-20 tooltip my-1 rounded p-1"
class="hover-bg-color material-symbols-outlined hyperlink-color icon-20 tooltip relative my-1 rounded p-1"
href="/admin/users/{user.id}"
>person<span class="tooltip-text font-sans text-sm">View account</span></a
>
<a
class="hover-bg-color material-symbols-outlined icon-20 dull-primary-text-color tooltip my-1 ml-1 mr-8 rounded p-1"
class="hover-bg-color material-symbols-outlined icon-20 hyperlink-color tooltip relative my-1 ml-1 mr-8 rounded p-1"
href="/admin/users/{user.id}/edit"
>edit<span class="tooltip-text font-sans text-sm">Edit account</span></a
>

View File

@ -110,36 +110,36 @@
<div class="top-border pt-2 font-semibold">
Permissions: <span class="font-normal">{data.user.perms}</span>
<div class="font-normal">
{#if (data.user.perms & userPerms) !== 0}
{#if (data.user.perms & userPerms) > 0}
<p class="font-semibold">User permissions:</p>
{#if (data.user.perms & PERMISSIONS.VIEW) !== 0}
{#if (data.user.perms & PERMISSIONS.VIEW) > 0}
<p class="pl-4">View access</p>
{/if}
{#if (data.user.perms & PERMISSIONS.APPLY_FOR_JOBS) !== 0}
{#if (data.user.perms & PERMISSIONS.APPLY_FOR_JOBS) > 0}
<p class="pl-4">Apply for jobs</p>
{/if}
{/if}
{#if (data.user.perms & employerPerms) !== 0}
{#if (data.user.perms & employerPerms) > 0}
<p class="font-semibold">Employer permissions:</p>
{#if (data.user.perms & PERMISSIONS.SUBMIT_POSTINGS) !== 0}
{#if (data.user.perms & PERMISSIONS.SUBMIT_POSTINGS) > 0}
<p class="pl-4">Submit postings</p>
{/if}
{#if (data.user.perms & PERMISSIONS.MANAGE_EMPLOYERS) !== 0}
{#if (data.user.perms & PERMISSIONS.MANAGE_EMPLOYERS) > 0}
<p class="pl-4">Manage employers (of their company)</p>
{/if}
{/if}
{#if (data.user.perms & adminPerms) !== 0}
{#if (data.user.perms & adminPerms) > 0}
<p class="font-semibold">Admin permissions:</p>
{#if (data.user.perms & PERMISSIONS.MANAGE_POSTINGS) !== 0}
{#if (data.user.perms & PERMISSIONS.MANAGE_POSTINGS) > 0}
<p class="pl-4">Manage postings</p>
{/if}
{#if (data.user.perms & PERMISSIONS.MANAGE_USERS) !== 0}
{#if (data.user.perms & PERMISSIONS.MANAGE_USERS) > 0}
<p class="pl-4">Manage users</p>
{/if}
{#if (data.user.perms & PERMISSIONS.MANAGE_TAGS) !== 0}
{#if (data.user.perms & PERMISSIONS.MANAGE_TAGS) > 0}
<p class="pl-4">Manage tags</p>
{/if}
{#if (data.user.perms & PERMISSIONS.MANAGE_COMPANIES) !== 0}
{#if (data.user.perms & PERMISSIONS.MANAGE_COMPANIES) > 0}
<p class="pl-4">Manage companies</p>
{/if}
{/if}

View File

@ -3,7 +3,8 @@ import { deleteUser, getUser, updateUser } from '$lib/db/index.server';
import { PERMISSIONS } from '$lib/consts';
import type { PageServerLoad } from './$types';
import { getUserPerms } from '$lib/index.server';
import { userPerms } from '$lib/shared.svelte';
import { userPerms, employerPerms } from '$lib/shared.svelte';
import type { User } from '$lib/types';
export const load: PageServerLoad = async ({ cookies, params }) => {
const id = parseInt(params.user);
@ -66,13 +67,22 @@ export const actions: Actions = {
const requestPerms = getUserPerms(cookies);
if (!(requestPerms >= 0 && (requestPerms & PERMISSIONS.MANAGE_USERS) > 0)) {
return fail(403, { errorMessage: 'Unauthorized' });
} else {
if (((requestPerms | userPerms) & newUserPerms) !== newUserPerms) {
}
if (((requestPerms | userPerms | employerPerms) & newUserPerms) !== newUserPerms) {
return fail(403, {
errorMessage: 'Cannot give a user higher permissions than yourself!'
});
} else {
if (username && username !== '') {
}
if (!username) {
return fail(400, { errorMessage: 'Missing username' });
}
if (password && password.length < 8) {
return fail(400, { errorMessage: 'Password must be at least 8 characters' });
}
if (username.length < 4) {
return fail(400, { errorMessage: 'Username must be at least 4 characters' });
}
try {
await updateUser(<User>{
id: id,
@ -89,11 +99,6 @@ export const actions: Actions = {
return fail(500, { errorMessage: `Internal Server Error: ${err}` });
}
return redirect(301, `/admin/users/${id}`);
} else {
return fail(400, { errorMessage: 'Missing username or password' });
}
}
}
},
delete: async ({ cookies, params }) => {
const id = parseInt(params.user!);

View File

@ -100,7 +100,9 @@
<div class="content">
<div class="elevated separator-borders m-4 rounded">
<div class="bottom-border flex place-content-between">
<div class="p-3 font-semibold">Update User</div>
<div class="p-3 font-semibold">
Edit User {data.user.username}{data.user.fullName ? ` (${data.user.fullName})` : ''}
</div>
</div>
<form method="POST" class="px-4" autocomplete="off" use:enhance>
<div class="mt-4 text-sm font-semibold">
@ -112,10 +114,11 @@
value={data.user?.username}
placeholder="Username"
class="w-full rounded font-normal"
required
/>
</div>
<div class="relative pt-4 text-sm font-semibold">
Password
New password (optional)
<input
type="password"
name="password"
@ -134,7 +137,19 @@
</button>
</div>
<div class="mt-4 text-sm font-semibold">
Email (optional)
Full name <span class="danger-color">*</span>
<input
type="text"
name="fullName"
id="fullName"
value={data.user?.fullName}
placeholder="Full name"
class="w-full rounded font-normal"
required
/>
</div>
<div class="mt-4 text-sm font-semibold">
Email <span class="danger-color">*</span>
<input
type="email"
name="email"
@ -142,6 +157,7 @@
value={data.user?.email}
placeholder="Email"
class="w-full rounded font-normal"
required
/>
</div>
<div class="mt-4 text-sm font-semibold">
@ -156,17 +172,6 @@
pattern="([0-9]\{3}) [0-9]\{3}-[0-9]\{3}"
/>
</div>
<div class="mt-4 text-sm font-semibold">
Full name (optional)
<input
type="text"
name="fullName"
id="fullName"
value={data.user?.fullName}
placeholder="Full name"
class="w-full rounded font-normal"
/>
</div>
<div class="mt-4 text-sm font-semibold">
Company code (optional)
<input
@ -195,7 +200,7 @@
id="userPerms"
class="select-all"
checked={(perms & userPerms) === userPerms}
indeterminate={(perms & userPerms) !== userPerms && (perms & userPerms) !== 0}
indeterminate={(perms & userPerms) !== userPerms && (perms & userPerms) > 0}
/></span
>User Permissions
</span>
@ -212,7 +217,7 @@
name="view"
id="view"
class="permCheckbox mx-1"
checked={(perms & PERMISSIONS.VIEW) !== 0}
checked={(perms & PERMISSIONS.VIEW) > 0}
/>
<span class="ml-2">View access</span></label
>
@ -224,7 +229,7 @@
name="apply"
id="apply"
class="permCheckbox mx-1"
checked={(perms & PERMISSIONS.APPLY_FOR_JOBS) !== 0}
checked={(perms & PERMISSIONS.APPLY_FOR_JOBS) > 0}
/>
<span class="ml-2">Apply for jobs</span></label
>
@ -246,7 +251,7 @@
class="select-all"
checked={(perms & employerPerms) === employerPerms}
indeterminate={(perms & employerPerms) !== employerPerms &&
(perms & employerPerms) !== 0}
(perms & employerPerms) > 0}
/></span
>Company Permissions
</span>
@ -262,7 +267,7 @@
type="checkbox"
name="submitPostings"
id="submitPostings"
checked={(perms & PERMISSIONS.SUBMIT_POSTINGS) !== 0}
checked={(perms & PERMISSIONS.SUBMIT_POSTINGS) >= 0}
class="permCheckbox mx-1"
/>
<span class="ml-2">Submit postings</span></label
@ -274,7 +279,7 @@
type="checkbox"
name="manageEmployers"
id="manageEmployers"
checked={(perms & PERMISSIONS.MANAGE_EMPLOYERS) !== 0}
checked={(perms & PERMISSIONS.MANAGE_EMPLOYERS) > 0}
class="permCheckbox mx-1"
/>
<span class="ml-2">Manage employers (within their company)</span></label
@ -296,7 +301,7 @@
id="adminPerms"
class="select-all"
checked={(perms & adminPerms) === adminPerms}
indeterminate={(perms & adminPerms) !== adminPerms && (perms & adminPerms) !== 0}
indeterminate={(perms & adminPerms) !== adminPerms && (perms & adminPerms) > 0}
/></span
>Admin Permissions
</span>
@ -312,7 +317,7 @@
type="checkbox"
name="manageTags"
id="manageTags"
checked={(perms & PERMISSIONS.MANAGE_TAGS) !== 0}
checked={(perms & PERMISSIONS.MANAGE_TAGS) > 0}
class="permCheckbox mx-1"
/>
<span class="ml-2">Manage tags</span></label
@ -324,7 +329,7 @@
type="checkbox"
name="managePostings"
id="managePostings"
checked={(perms & PERMISSIONS.MANAGE_POSTINGS) !== 0}
checked={(perms & PERMISSIONS.MANAGE_POSTINGS) > 0}
class="permCheckbox mx-1"
/>
<span class="ml-2">Manage postings</span></label
@ -336,7 +341,7 @@
type="checkbox"
name="manageUsers"
id="manageUsers"
checked={(perms & PERMISSIONS.MANAGE_USERS) !== 0}
checked={(perms & PERMISSIONS.MANAGE_USERS) > 0}
class="permCheckbox mx-1"
/>
<span class="ml-2">Manage users</span></label
@ -348,7 +353,7 @@
type="checkbox"
name="manageCompanies"
id="manageCompanies"
checked={(perms & PERMISSIONS.MANAGE_COMPANIES) !== 0}
checked={(perms & PERMISSIONS.MANAGE_COMPANIES) > 0}
class="permCheckbox mx-1"
/>
<span class="ml-2">Manage companies</span></label
@ -375,7 +380,7 @@
<button
class="dull-primary-bg-color mb-4 mt-2 rounded px-2 py-1"
type="submit"
formaction="?/submit">Update user</button
formaction="?/submit">Save user</button
>
<button
class="danger-bg-color mb-4 mt-2 rounded px-2 py-1"

View File

@ -2,6 +2,8 @@ import { type Actions, fail, redirect } from '@sveltejs/kit';
import { createUser } from '$lib/db/index.server';
import { PERMISSIONS } from '$lib/consts';
import { getUserPerms } from '$lib/index.server';
import type { User } from '$lib/types';
import { employerPerms, userPerms } from '$lib/shared.svelte';
export const actions: Actions = {
submit: async ({ request, cookies }) => {
@ -25,9 +27,7 @@ export const actions: Actions = {
.toUpperCase()
.trim();
if (email === '' || email == undefined) email = null;
if (phone === '' || phone == undefined) phone = null;
if (fullName === '' || fullName == undefined) fullName = null;
if (companyCode === '' || companyCode == undefined) companyCode = null;
if (email && !email.includes('@')) {
@ -51,16 +51,29 @@ export const actions: Actions = {
const requestPerms = getUserPerms(cookies);
if (!(requestPerms >= 0 && (requestPerms & PERMISSIONS.MANAGE_USERS) > 0)) {
return fail(403, { errorMessage: 'You cannot preform this action!' });
} else {
if ((requestPerms & newUserPerms) !== newUserPerms) {
}
if (((requestPerms | userPerms | employerPerms) & newUserPerms) !== newUserPerms) {
return fail(403, {
errorMessage: 'Cannot create a user with higher permissions than yourself!'
});
} else {
if (username && password && username !== '' && password !== '') {
}
if (
!username ||
username === '' ||
!password ||
!email ||
email === '' ||
!fullName ||
fullName === ''
) {
return fail(400, { errorMessage: 'Please fill out all required fields' });
}
if (password.length < 8) {
return fail(400, { errorMessage: 'Password must be at least 8 characters' });
}
if (username.length < 4) {
return fail(400, { errorMessage: 'Username must be at least 4 characters' });
}
let id = -1;
try {
id = await createUser(<User>{
@ -79,10 +92,5 @@ export const actions: Actions = {
if (id !== -1) {
return redirect(301, `/admin/users/${id}`);
}
} else {
return fail(400, { errorMessage: 'Missing username or password' });
}
}
}
}
};

View File

@ -118,13 +118,25 @@
</button>
</div>
<div class="mt-4 text-sm font-semibold">
Email (optional)
Full name <span class="danger-color">*</span>
<input
type="text"
name="fullName"
id="fullName"
placeholder="Full Name"
class="w-full rounded font-normal"
required
/>
</div>
<div class="mt-4 text-sm font-semibold">
Email <span class="danger-color">*</span>
<input
type="email"
name="email"
id="email"
placeholder="Email"
class="w-full rounded font-normal"
required
/>
</div>
<div class="mt-4 text-sm font-semibold">
@ -138,16 +150,6 @@
pattern="[0-9]{3}-[0-9]{3}-[0-9]{4}"
/>
</div>
<div class="mt-4 text-sm font-semibold">
Full name (optional)
<input
type="text"
name="fullName"
id="fullName"
placeholder="Full Name"
class="w-full rounded font-normal"
/>
</div>
<div class="mt-4 text-sm font-semibold">
Company code (optional)
<input

View File

@ -0,0 +1,8 @@
import { getPosting, getPostingFullData } from '$lib/db/index.server';
import { error, json } from '@sveltejs/kit';
export async function GET({ url }) {
const id = url.searchParams.get('id');
if (!id) return new Response(error(400, 'No id provided'));
return json(await getPostingFullData(parseInt(id)));
}

View File

@ -0,0 +1,6 @@
import type { PageServerLoad } from './$types';
import { getCompanies } from '$lib/db/index.server';
export const load: PageServerLoad = async () => {
return { companies: await getCompanies() };
};

View File

@ -0,0 +1,35 @@
<script lang="ts">
import type { PageProps } from './$types';
import type { Company, User } from '$lib/types';
function logoFallback(e: Event, company: Company) {
(e.target as HTMLImageElement).src =
`https://ui-avatars.com/api/?background=random&format=svg&name=${encodeURIComponent(company.name!)}`;
}
let { data }: PageProps = $props();
</script>
<div class="base-container-small">
<div class="content">
<div class="elevated separator-borders mb-4 mt-4 rounded">
<div class="p-3 font-semibold">Companies</div>
{#each data.companies as company}
<a class="top-border hover-bg-color inline-block w-full p-3" href="/companies/{company.id}">
<img
class="mb-2 inline-block rounded-lg"
src="/uploads/logos/{company.id}.svg?timestamp=${Date.now()}"
alt="Company logo"
onerror={(e) => logoFallback(e, company)}
height="64"
width="64"
/>
<div class="inline-block h-min pl-4 align-top">
<h2 class="font-bold">{company.name}</h2>
<p class="max-char-length">{company.description}</p>
</div>
</a>
{/each}
</div>
</div>
</div>

View File

@ -0,0 +1,7 @@
import type { PageServerLoad } from './$types';
import { getCompanyFullData } from '$lib/db/index.server';
export const load: PageServerLoad = async ({ params }) => {
const id = parseInt(params.company!);
return await getCompanyFullData(id);
};

View File

@ -0,0 +1,109 @@
<script lang="ts">
import type { PageProps } from './$types';
import { userState } from '$lib/shared.svelte';
import { PERMISSIONS } from '$lib/consts';
import type { User } from '$lib/types';
function avatarFallback(e: Event, user: User): null {
(e.target as HTMLImageElement).src =
`https://ui-avatars.com/api/?background=random&format=svg&name=${user.fullName ? encodeURIComponent(user.fullName) : encodeURIComponent(user.username)}`;
return null;
}
function logoFallback(e: Event) {
(e.target as HTMLImageElement).src =
`https://ui-avatars.com/api/?background=random&format=svg&name=${encodeURIComponent(data.company.name!)}`;
}
let { data, form }: PageProps = $props();
</script>
<div class="base-container">
<div class="content py-4">
<div class="bottom-border mb-4 flex justify-between">
<div class="inline-block">
<img
class="mb-2 inline-block rounded-lg"
src="/uploads/logos/{data.company.id}.svg?timestamp=${Date.now()}"
alt="User avatar"
onerror={logoFallback}
height="120"
width="120"
/>
<div class="inline-block h-min pl-4">
<h1 class="font-bold">{data.company.name}</h1>
<p>{data.company.description}</p>
</div>
</div>
{#if (userState.perms & PERMISSIONS.MANAGE_COMPANIES) > 0 || ((userState.perms & PERMISSIONS.MANAGE_EMPLOYERS) !== 0 && userState.companyId === data.company.id)}
<div class="inline-block">
<a
class="dull-primary-bg-color rounded px-3 py-1.5"
href="/companies/{data.company.id}/edit">Edit company</a
>
</div>
{/if}
</div>
<div class="flex">
<div class="elevated separator-borders mr-4 inline-block h-min w-3/4 rounded">
<div class="p-3 font-semibold">
{data.postings
? data.company.name + "'s Postings"
: data.company.name + ' has no current postings!'}
</div>
{#each data.postings as posting}
<a class="top-border hover-bg-color block p-2" href="/postings/{posting.id}">
<img
class="inline-block rounded"
src="/uploads/logos/{posting.companyId ? posting.companyId : 'default'}.svg"
alt="Company Logo"
height="48"
width="48"
onerror={logoFallback}
/>
<div class="inline-block pl-2 align-top">
<h2 class="font-semibold">{posting.title}</h2>
<h3 class="max-char-length">{posting.description}</h3>
</div>
</a>
{/each}
</div>
<div class="elevated separator-borders inline-block h-min w-1/4 rounded">
<div class="flex place-content-between">
<div class="p-3 font-semibold">Employers</div>
</div>
{#each data.users as user}
<div class="top-border px-3 pb-2 pt-3">
<div class="flex">
<img
class="mb-2 inline-block h-min rounded"
src="/uploads/avatars/{data.company.id}.svg?timestamp=${Date.now()}"
alt="User avatar"
onerror={(e) => avatarFallback(e, user)}
height="32"
width="32"
/>
<div class="pl-2">
<div class="pb-1 font-semibold">
{user.username}{user.fullName ? ` (${user.fullName})` : ''}
</div>
{#if user.email}
<div class="pb-1">
<span class="material-symbols-outlined align-middle">mail</span>
<a class="hover-hyperlink" href="mailto:{user.email}">{user.email}</a>
</div>
{/if}
{#if user.phone}
<div class="pb-1">
<span class="material-symbols-outlined align-middle">call</span>
<a class="hover-hyperlink" href="tel:{user.phone}">{user.phone}</a>
</div>
{/if}
</div>
</div>
</div>
{/each}
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,27 @@
<script lang="ts">
import { page } from '$app/state';
import type { LayoutProps } from './$types';
let { data, children }: LayoutProps = $props();
</script>
<div class="bottom-border h-10 pt-2 text-center">
<a
href={page.url.pathname.endsWith('employers') ? '.' : ''}
class="p-2 {page.url.pathname.endsWith('edit')
? 'primary-underline font-bold'
: 'low-emphasis-text low-emphasis-text-button'}"
><span class="material-symbols-outlined align-bottom">store</span> Details</a
>
<a
href={page.url.pathname.endsWith('edit') ? 'edit/employers' : ''}
class="p-2 {page.url.pathname.endsWith('employers')
? 'primary-underline font-bold'
: 'low-emphasis-text low-emphasis-text-button'}"
><span class="material-symbols-outlined align-bottom">group</span> Employers</a
>
</div>
<div class="base-container">
{@render children()}
</div>

View File

@ -0,0 +1,5 @@
import type { LayoutLoad } from './$types';
export const load: LayoutLoad = async ({ params }) => {
return { id: parseInt(params.company) };
};

View File

@ -0,0 +1,70 @@
import { type Actions, error, fail, redirect } from '@sveltejs/kit';
import { deleteCompany, editCompany, getCompany } from '$lib/db/index.server';
import { PERMISSIONS } from '$lib/consts';
import { getUserCompanyId, getUserPerms } from '$lib/index.server';
import type { PageServerLoad } from './$types';
import type { Company } from '$lib/types';
export const load: PageServerLoad = async ({ cookies, params }) => {
const id = parseInt(params.company);
const perms = getUserPerms(cookies);
if (perms >= 0 && (perms & PERMISSIONS.MANAGE_COMPANIES) > 0) {
return {
company: await getCompany(id)
};
}
error(403, 'Unauthorized');
};
export const actions: Actions = {
submit: async ({ request, cookies, params }) => {
const id = parseInt(params.company!);
const data = await request.formData();
const name = data.get('name')?.toString().trim();
let website = data.get('website')?.toString().trim();
const description = data.get('description')?.toString().trim();
const requestPerms = getUserPerms(cookies);
if (
!(
requestPerms >= 0 &&
((requestPerms & PERMISSIONS.MANAGE_COMPANIES) > 0 ||
((requestPerms & PERMISSIONS.SUBMIT_POSTINGS) > 0 && getUserCompanyId(cookies) === id))
)
) {
return fail(403, { errorMessage: 'You cannot preform this action!' });
}
if (!name || name === '' || !website || website === '' || !description || description === '') {
return fail(400, { errorMessage: 'All fields are required' });
}
if (!website.includes('.')) {
return fail(400, { errorMessage: 'Invalid website' });
}
if (!website.startsWith('http://') && !website.startsWith('https://'))
website = `https://${website}`;
try {
await editCompany(<Company>{
id: id,
name: name,
website: website,
description: description
});
} catch (err) {
return fail(500, { errorMessage: `Internal Server Error: ${err}` });
}
redirect(301, `/companies/${id}`);
},
delete: async ({ cookies, params }) => {
const id = parseInt(params.company!);
const requestPerms = getUserPerms(cookies);
if (!(requestPerms >= 0 && (requestPerms & PERMISSIONS.MANAGE_COMPANIES) > 0)) {
return fail(403, { errorMessage: 'You cannot preform this action!' });
}
try {
await deleteCompany(id);
} catch (err) {
return fail(500, { errorMessage: `Internal Server Error: ${err}` });
}
}
};

View File

@ -0,0 +1,108 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageProps } from './$types';
function openConfirm() {
document.getElementById('deleteConfirmModal')!.style.display = 'block';
}
function closeConfirm() {
document.getElementById('deleteConfirmModal')!.style.display = 'none';
}
let { data, form }: PageProps = $props();
</script>
<div class="base-container">
<div class="content">
<div class="elevated separator-borders m-4 rounded">
<div class="bottom-border flex place-content-between">
<div class="p-3 font-semibold">Edit Company {data.company.name}</div>
</div>
<form method="POST" class="px-4" autocomplete="off" use:enhance>
<div class="mt-4 text-sm font-semibold">
Name <span class="text-red-500">*</span>
<input
type="text"
name="name"
id="name"
placeholder="Name"
value={data.company?.name}
class="w-full rounded font-normal"
required
/>
</div>
<div class="mt-4 text-sm font-semibold">
Description <span class="text-red-500">*</span>
<textarea
name="description"
id="description"
rows="4"
placeholder="Description"
class="w-full rounded font-normal">{data.company?.description}</textarea
>
</div>
<div class="mt-4 text-sm font-semibold">
Website <span class="text-red-500">*</span>
<input
type="text"
name="website"
id="website"
placeholder="Website"
value={data.company?.website}
class="w-full rounded font-normal"
/>
</div>
{#if form?.errorMessage}
<div class="mb-2 text-red-500">{form.errorMessage}</div>
{/if}
<div class="mt-4 flex justify-between">
<button
class="dull-primary-bg-color mb-4 mt-2 rounded px-2 py-1"
type="submit"
formaction="?/submit">Save company</button
>
<button
class="danger-bg-color mb-4 mt-2 rounded px-2 py-1"
type="button"
onclick={openConfirm}>Delete company</button
>
</div>
</form>
<form id="deleteConfirmModal" class="modal" method="POST" use:enhance>
<div class="modal-content">
<div class="mb-2 inline-flex w-full justify-between">
<h2 class="font-semibold">Are you sure?</h2>
<button class="material-symbols-outlined" onclick={closeConfirm}>close</button>
</div>
<p>
This will permanently delete company <span class="font-semibold"
>{data.company?.name}.</span
>
</p>
<p>Please type "I understand" into the box below to confirm</p>
<input
type="text"
name="confirm"
id="confirm"
placeholder="I understand"
class="w-full rounded font-normal"
pattern="I understand"
required
/>
<div class="mt-4 flex justify-between">
<button class="danger-bg-color rounded px-2 py-1" type="submit" formaction="?/delete"
>Delete company</button
>
<button
class="separator-borders bg-color rounded px-2 py-1"
type="button"
onclick={closeConfirm}>Cancel</button
>
</div>
</div>
</form>
</div>
</div>
</div>

View File

@ -0,0 +1,56 @@
import { type Actions, error, fail, redirect } from '@sveltejs/kit';
import {
addEmployerToCompany,
deleteCompany,
editCompany,
getCompany,
getCompanyEmployers,
removeEmployerFromCompany
} from '$lib/db/index.server';
import { PERMISSIONS } from '$lib/consts';
import { getUserCompanyId, getUserPerms } from '$lib/index.server';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies, params }) => {
const id = parseInt(params.company);
const perms = getUserPerms(cookies);
if (perms >= 0 && (perms & PERMISSIONS.MANAGE_COMPANIES) > 0) {
return await getCompanyEmployers(id);
}
error(403, 'Unauthorized');
};
export const actions: Actions = {
removeEmployer: async ({ params, url, cookies }) => {
const id = parseInt(params.company!);
const data = url.searchParams;
const requestPerms = getUserPerms(cookies);
if (
!(
(requestPerms >= 0 && (requestPerms & PERMISSIONS.MANAGE_POSTINGS) > 0) ||
((requestPerms & PERMISSIONS.SUBMIT_POSTINGS) > 0 && getUserCompanyId(cookies) === id)
)
) {
return fail(403, { errorMessage: 'You cannot preform this action!' });
}
await removeEmployerFromCompany(id, parseInt(data.get('userId')!));
},
addEmployer: async ({ params, url, cookies }) => {
const id = parseInt(params.company!);
const data = url.searchParams;
const requestPerms = getUserPerms(cookies);
if (
!(
(requestPerms >= 0 && (requestPerms & PERMISSIONS.MANAGE_POSTINGS) > 0) ||
((requestPerms & PERMISSIONS.SUBMIT_POSTINGS) > 0 && getUserCompanyId(cookies) === id)
)
) {
return fail(403, { errorMessage: 'You cannot preform this action!' });
}
await addEmployerToCompany(id, parseInt(data.get('userId')!));
}
};

View File

@ -0,0 +1,174 @@
<script lang="ts">
import type { PageProps } from './$types';
const dateFormatOptions: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'short',
day: 'numeric'
};
function logoFallback(e: Event) {
(e.target as HTMLImageElement).src =
`https://ui-avatars.com/api/?background=random&format=svg&name=${encodeURIComponent(data.company.name!)}`;
}
let idToRemove: number | null = $state(null);
let { data, form }: PageProps = $props();
</script>
<div class="content">
<div class="m-4">
<img
class="mb-2 inline-block rounded-lg"
src="/uploads/logos/{data.company.id}.svg?timestamp=${Date.now()}"
alt="User avatar"
onerror={logoFallback}
height="80"
width="80"
/>
<div class="inline-block pl-4 align-top">
<h1 class="font-bold">{data.company.name}</h1>
<h2>Company code: <span class="font-semibold">{data.company.companyCode}</span></h2>
</div>
</div>
<div class="elevated separator-borders m-4 rounded">
<div class="bottom-border flex place-content-between">
<div class="p-3 font-semibold">Current Employers</div>
</div>
<div class="table">
<table>
<thead>
<tr>
<th class="left w-16 py-1">ID</th>
<th class="py-1">Username</th>
<th class="py-1">Full Name</th>
<th class="py-1">Email</th>
<th class="py-1">Created</th>
<th class="py-1">Last Sign-In</th>
<th class="py-1">Remove</th>
</tr>
</thead>
<tbody>
{#if data.users !== undefined}
{#each data.users as user}
{#if user.company?.id === data.id}
<tr class="h-8">
<td class="left">{user.id}</td>
<td>{user.username}</td>
<td>{user.fullName || 'N/A'}</td>
<td>{user.email || 'N/A'}</td>
<td
>{user.createdAt?.toLocaleDateString('en-US', dateFormatOptions) ||
'unknown'}</td
>
<td
>{user.lastSignIn?.toLocaleDateString('en-US', dateFormatOptions) ||
'unknown'}</td
>
<td class="material-symbols-outlined hover-bg-color danger-color m-1 rounded"
><button
onclick={() => {
idToRemove = user.id;
}}>close</button
></td
>
</tr>
{/if}
{/each}
{/if}
</tbody>
</table>
</div>
</div>
</div>
{#if idToRemove !== null}
<form method="POST" id="deleteConfirmModal" class="modal-always-display">
<div class="modal-content">
<div class="mb-2 inline-flex w-full justify-between">
<h2 class="font-semibold">Are you sure?</h2>
<button
class="material-symbols-outlined"
onclick={() => {
idToRemove = null;
}}>close</button
>
</div>
<p>This will remove this employer from the company.</p>
<div class="mt-4 flex justify-between">
<button
class="danger-bg-color rounded px-2 py-1"
type="submit"
formaction="?/removeEmployer&userId={idToRemove}">Remove</button
>
<button
class="separator-borders bg-color rounded px-2 py-1"
type="button"
onclick={() => {
idToRemove = null;
}}>Cancel</button
>
</div>
</div>
</form>
{/if}
{#if data.users && data.users.some((user) => {
return user.company?.id !== data.id;
})}
<div class="content">
<div class="elevated separator-borders m-4 rounded">
<div class="bottom-border flex place-content-between">
<div class="p-3 font-semibold">Pending requests</div>
</div>
<div class="table">
<table>
<thead>
<tr>
<th class="left w-16 py-1">ID</th>
<th class="py-1">Username</th>
<th class="py-1">Full Name</th>
<th class="py-1">Email</th>
<th class="py-1">Created</th>
<th class="py-1">Last Sign-In</th>
<th class="py-1">Approve</th>
</tr>
</thead>
<tbody>
{#if data.users !== undefined}
{#each data.users as user}
{#if user.company?.id !== data.id}
<tr class="h-8">
<td class="left">{user.id}</td>
<td>{user.username}</td>
<td>{user.fullName || 'N/A'}</td>
<td>{user.email || 'N/A'}</td>
<td
>{user.createdAt?.toLocaleDateString('en-US', dateFormatOptions) ||
'unknown'}</td
>
<td
>{user.lastSignIn?.toLocaleDateString('en-US', dateFormatOptions) ||
'unknown'}</td
>
<td class="material-symbols-outlined"
><form method="POST" class="flex">
<button
class="hover-bg-color m-1 rounded text-green-600"
formaction="?/addEmployer&userId={user.id}">check</button
>
<button
class="hover-bg-color danger-color m-1 rounded"
formaction="?/removeEmployer&userId={user.id}">close</button
>
</form></td
>
</tr>
{/if}
{/each}
{/if}
</tbody>
</table>
</div>
</div>
</div>
{/if}

View File

@ -0,0 +1,45 @@
import { type Actions, fail, redirect } from '@sveltejs/kit';
import { createCompany } from '$lib/db/index.server';
import { PERMISSIONS } from '$lib/consts';
import { getUserCompanyId, getUserPerms } from '$lib/index.server';
import type { Company } from '$lib/types';
export const actions: Actions = {
submit: async ({ request, cookies }) => {
const data = await request.formData();
const name = data.get('name')?.toString().trim();
let website = data.get('website')?.toString().trim();
const description = data.get('description')?.toString().trim();
const requestPerms = getUserPerms(cookies);
if (
!(
requestPerms >= 0 &&
((requestPerms & PERMISSIONS.MANAGE_COMPANIES) > 0 ||
((requestPerms & PERMISSIONS.SUBMIT_POSTINGS) > 0 && getUserCompanyId(cookies)))
)
) {
return fail(403, { errorMessage: 'You cannot preform this action!' });
}
if (!name || name === '' || !website || website === '' || !description || description === '') {
return fail(400, { errorMessage: 'All fields are required' });
}
if (!website.includes('.')) {
return fail(400, { errorMessage: 'Invalid website' });
}
if (!website.startsWith('http://') && !website.startsWith('https://'))
website = `https://${website}`;
let id: number;
try {
id = await createCompany(<Company>{
name: name,
website: website,
description: description
});
} catch (err) {
return fail(500, { errorMessage: `Internal Server Error: ${err}` });
}
redirect(301, `/companies/${id}`);
}
};

View File

@ -0,0 +1,60 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
</script>
<div class="base-container">
<div class="content">
<div class="elevated separator-borders m-4 rounded">
<div class="bottom-border flex place-content-between">
<div class="p-3 font-semibold">Create new company</div>
</div>
<form method="POST" class="px-4" autocomplete="off" use:enhance>
<div class="mt-4 text-sm font-semibold">
Name <span class="text-red-500">*</span>
<input
type="text"
name="name"
id="name"
placeholder="Name"
class="w-full rounded font-normal"
required
/>
</div>
<div class="mt-4 text-sm font-semibold">
Description <span class="text-red-500">*</span>
<textarea
name="description"
id="description"
rows="4"
placeholder="Description"
class="w-full rounded font-normal"
required
></textarea>
</div>
<div class="mt-4 text-sm font-semibold">
Website <span class="text-red-500">*</span>
<input
type="text"
name="website"
id="website"
placeholder="Website"
class="w-full rounded font-normal"
required
/>
</div>
{#if form?.errorMessage}
<div class="mb-2 text-red-500">{form.errorMessage}</div>
{/if}
<button
class="dull-primary-bg-color mb-4 mt-6 rounded px-2 py-1"
type="submit"
formaction="?/submit">Create company</button
>
</form>
</div>
</div>
</div>

View File

@ -0,0 +1,23 @@
<div class="base-container">
<div class="content elevated separator-borders m-2 rounded p-2">
<h1 class="font-bold">Info</h1>
<p>This page contains additional info about different parts of the app</p>
<h2 class="pt-2 font-semibold" id="company-codes">Company Codes</h2>
<p>
Company codes are unique identifiers of companies in order for an employer to associate
themselves to a company.
</p>
<p>If you are an applicant, you can safely ignore them.</p>
<p>
If you are an employer, get a code from your company admin, and input it into your account
page to request access to your company. Once approved by your administrator, you will have
access to create job postings.
</p>
<p>
If you are your company admin, first create your account (without inputting a code). Then, go
to the company page, and use the button in the top right to create a new company. Once created
and approved by a CareerConnect admin, you will be able to see the company code, which you can
then give to your employees.
</p>
</div>
</div>

View File

@ -0,0 +1,8 @@
import type { PageServerLoad } from './$types';
import { getPostings } from '$lib/db/index.server';
export const load: PageServerLoad = async ({ url }) => {
return {
postings: await getPostings(url.searchParams.get('searchQuery') as string)
};
};

View File

@ -0,0 +1,140 @@
<script lang="ts">
import type { PageProps } from './$types';
import type { Posting } from '$lib/types';
import { onMount } from 'svelte';
import { employmentTypeDisplayName, userState } from '$lib/shared.svelte';
import { PERMISSIONS } from '$lib/consts';
let details: Posting | undefined = $state<Posting>();
const dateFormatOptions: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'short',
day: 'numeric'
};
function logoFallback(e: Event, posting: Posting) {
(e.target as HTMLImageElement).src =
`https://ui-avatars.com/api/?background=random&format=svg&name=${encodeURIComponent(posting.company.name || 'COMPANY')}`;
}
async function fetchDetails(id: number) {
const response = await fetch(`/api/posting?id=${id}`);
details = await response.json();
if (details?.createdAt) {
details.createdAt = new Date(details.createdAt);
}
if (details?.updatedAt) {
details.updatedAt = new Date(details.updatedAt);
}
}
let { data }: PageProps = $props();
onMount(async () => {
await fetchDetails(data.postings[0].id);
});
</script>
<div class="base-container">
<div class="content flex">
<div class="right-border inline-block w-1/3">
{#each data.postings as posting}
<button
class="bottom-border block w-full p-4 text-left {details?.id === posting.id
? 'accent-bg-color'
: ''}"
onclick={() => {
fetchDetails(posting.id);
}}
>
<img
class="inline-block rounded"
src="/uploads/logos/{posting.companyId}.svg"
alt="Company Logo"
height="48"
width="48"
onerror={(e) => logoFallback(e, posting)}
/>
<div class="inline-block pl-2 align-top">
<h2 class="font-semibold">{posting.title}</h2>
<h3>{posting.company.name}</h3>
</div>
</button>
{/each}
</div>
{#if details !== undefined}
<div
class="elevated separator-borders top-with-navbar sticky ml-4 mt-4 inline-block h-min w-2/3 rounded p-4"
>
<div class="bottom-border flex justify-between pb-2">
<div class="inline-block">
<img
class="inline-block rounded"
src="/uploads/logos/{details.companyId || 'default'}.svg"
alt="Company Logo"
height="64"
width="64"
onerror={(e) => logoFallback(e, details)}
/>
<div class="inline-block pl-2 align-top">
<h1>{details.title}</h1>
<h2>Company: {details.company.name}</h2>
</div>
</div>
{#if userState.perms >= 0 && ((userState.perms & PERMISSIONS.MANAGE_POSTINGS) > 0 || ((userState.perms & PERMISSIONS.SUBMIT_POSTINGS) > 0 && userState.companyId === details.company.id))}
<a
class="dull-primary-bg-color inline-block h-min rounded-md px-2.5 py-1 align-top"
href="/postings/{details.id}/manage">Manage posting</a
>
{:else if (userState.perms & PERMISSIONS.APPLY_FOR_JOBS) > 0}
<a
class="dull-primary-bg-color inline-block h-min rounded-md px-2.5 py-1 align-top"
href="/postings/{details.id}/apply">Apply</a
>
{/if}
</div>
<div class="scrollbar-on-elevated details-height overflow-y-scroll">
<h2 class="pt-2 font-semibold">Contact</h2>
<p>{details.employer?.fullName} ({details.employer?.username})</p>
<a class="hover-hyperlink" href="mailto:{details.employer?.email}"
>{details.employer?.email}</a
>
<a class="hover-hyperlink" href="tel:{details.employer?.phone}"
>{details.employer?.phone}</a
>
<h2 class="pt-2 font-semibold">Details</h2>
{#if details.employmentType}
<p>{employmentTypeDisplayName(details.employmentType)}</p>
{/if}
{#if details.address}
<a
href="https://www.google.com/maps/search/?api=1&query={details.address}"
class="block w-max">Address: <span class="hover-hyperlink">{details.address}</span></a
>
{/if}
{#if details.wage}
<p>Wage: {details.wage}</p>
{/if}
{#if details.createdAt}
<p>Posted: {details.createdAt.toLocaleDateString('en-US', dateFormatOptions)}</p>
{/if}
{#if details.link}
<a href={details.link} class="block w-max"
>More information: <span class="hyperlink-color hyperlink-underline"
>{details.link}</span
></a
>
{/if}
{#if details.flyerLink}
<a href={details.flyerLink} class="block w-max"
>Flyer: <span class="hyperlink-color hyperlink-underline">{details.flyerLink}</span
></a
>
{/if}
<h2 class="pt-2 font-semibold">Job Description</h2>
<p class="whitespace-pre-wrap">{details.description}</p>
</div>
</div>
{/if}
</div>
</div>

View File

@ -0,0 +1,8 @@
import type { PageServerLoad } from './$types';
import { getPostingFullData, getPostings } from '$lib/db/index.server';
export const load: PageServerLoad = async ({ params }) => {
return {
posting: await getPostingFullData(parseInt(params.posting))
};
};

View File

@ -0,0 +1,95 @@
<script lang="ts">
import type { PageProps } from './$types';
import type { Posting } from '$lib/types';
import { employmentTypeDisplayName, userState } from '$lib/shared.svelte';
import { PERMISSIONS } from '$lib/consts';
const dateFormatOptions: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'short',
day: 'numeric'
};
function logoFallback(e: Event, posting: Posting) {
(e.target as HTMLImageElement).src =
`https://ui-avatars.com/api/?background=random&format=svg&name=${encodeURIComponent(posting.company.name || 'COMPANY')}`;
}
let { data }: PageProps = $props();
</script>
<div class="base-container-small">
<div class="content">
<div class="elevated separator-borders ml-4 mt-4 inline-block h-min w-full rounded p-4">
<div class="bottom-border elevated-bg flex justify-between pb-2">
<div class="inline-block">
<img
class="inline-block rounded"
src="/uploads/logos/{data.posting.companyId || 'default'}.svg"
alt="Company Logo"
height="64"
width="64"
onerror={(e) => logoFallback(e, data.posting)}
/>
<div class="inline-block pl-2 align-top">
<h1>{data.posting.title}</h1>
<h2>Company: {data.posting.company.name}</h2>
</div>
</div>
{#if userState.perms >= 0 && ((userState.perms & PERMISSIONS.MANAGE_POSTINGS) > 0 || ((userState.perms & PERMISSIONS.SUBMIT_POSTINGS) > 0 && userState.companyId === details.company.id))}
<a
class="dull-primary-bg-color inline-block h-min rounded-md px-2.5 py-1 align-top"
href="/postings/{data.posting.id}/manage">Manage posting</a
>
{:else if (userState.perms & PERMISSIONS.APPLY_FOR_JOBS) > 0}
<a
class="dull-primary-bg-color inline-block h-min rounded-md px-2.5 py-1 align-top"
href="/postings/{data.posting.id}/apply">Apply</a
>
{/if}
</div>
<div class="scrollbar-on-elevated details-height overflow-y-scroll">
<h2 class="pt-2 font-semibold">Contact</h2>
<p>{data.posting.employer?.fullName} ({data.posting.employer?.username})</p>
<a class="hover-hyperlink" href="mailto:{data.posting.employer?.email}"
>{data.posting.employer?.email}</a
>
<a class="hover-hyperlink" href="tel:{data.posting.employer?.phone}"
>{data.posting.employer?.phone}</a
>
<h2 class="pt-2 font-semibold">Details</h2>
{#if data.posting.employmentType}
<p>{employmentTypeDisplayName(data.posting.employmentType)}</p>
{/if}
{#if data.posting.address}
<a
href="https://www.google.com/maps/search/?api=1&query={data.posting.address}"
class="block w-max"
>Address: <span class="hover-hyperlink">{data.posting.address}</span></a
>
{/if}
{#if data.posting.wage}
<p>Wage: {data.posting.wage}</p>
{/if}
{#if data.posting.createdAt}
<p>Posted: {data.posting.createdAt.toLocaleDateString('en-US', dateFormatOptions)}</p>
{/if}
{#if data.posting.link}
<a href={data.posting.link} class="block w-max"
>More information: <span class="hyperlink-color hyperlink-underline"
>{data.posting.link}</span
></a
>
{/if}
{#if data.posting.flyerLink}
<a href={data.posting.flyerLink} class="block w-max"
>Flyer: <span class="hyperlink-color hyperlink-underline">{data.posting.flyerLink}</span
></a
>
{/if}
<h2 class="pt-2 font-semibold">Job Description</h2>
<p class="whitespace-pre-wrap">{data.posting.description}</p>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,37 @@
import { type Actions, error, fail, redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { createApplication, getPostingFullData, getUserWithCompany } from '$lib/db/index.server';
import { getUserId, getUserPerms } from '$lib/index.server';
import { PERMISSIONS } from '$lib/consts';
import type { Application } from '$lib/types';
export const load: PageServerLoad = async ({ params, cookies }) => {
const id = parseInt(params.posting);
const perms = getUserPerms(cookies);
if (perms >= 0 && (perms & PERMISSIONS.MANAGE_USERS) > 0) {
return {
posting: await getPostingFullData(id)
};
}
error(403, 'Unauthorized');
};
export const actions: Actions = {
submit: async ({ request, cookies, params }) => {
if (!((getUserPerms(cookies) & PERMISSIONS.APPLY_FOR_JOBS) > 0)) {
return fail(403, { errorMessage: 'Unauthorized' });
}
const data = await request.formData();
const candidateStatement = data.get('candidateStatement')?.toString().trim();
const postingId = parseInt(params.posting!);
const userId = getUserId(cookies);
if (!candidateStatement || candidateStatement === '') {
return fail(400, { errorMessage: 'Candidate statement is required' });
}
await createApplication(<Application>{ userId, postingId, candidateStatement });
redirect(301, `/postings`);
}
};

View File

@ -0,0 +1,116 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageProps } from './$types';
import { employmentTypeDisplayName, userState } from '$lib/shared.svelte';
import type { Posting } from '$lib/types';
import { PERMISSIONS } from '$lib/consts';
const dateFormatOptions: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'short',
day: 'numeric'
};
function logoFallback(e: Event, posting: Posting) {
(e.target as HTMLImageElement).src =
`https://ui-avatars.com/api/?background=random&format=svg&name=${encodeURIComponent(posting.company.name || 'COMPANY')}`;
}
let { data, form }: PageProps = $props();
</script>
<div class="base-container">
<div class="content flex">
<div class="elevated separator-borders ml-4 mt-4 inline-block h-min w-1/2 rounded p-4">
<div class="bottom-border elevated-bg flex justify-between pb-2">
<div class="inline-block">
<img
class="inline-block rounded"
src="/uploads/logos/{data.posting?.companyId || 'default'}.svg"
alt="Company Logo"
height="64"
width="64"
onerror={(e) => logoFallback(e, data.posting)}
/>
<div class="inline-block pl-2 align-top">
<h1>{data.posting.title}</h1>
<h2>Company: {data.posting.company.name}</h2>
</div>
</div>
</div>
<div class="scrollbar-on-elevated details-height overflow-y-scroll">
<h2 class="pt-2 font-semibold">Contact</h2>
<p>{data.posting.employer?.fullName} ({data.posting.employer?.username})</p>
<a class="hover-hyperlink" href="mailto:{data.posting.employer?.email}"
>{data.posting.employer?.email}</a
>
<a class="hover-hyperlink" href="tel:{data.posting.employer?.phone}"
>{data.posting.employer?.phone}</a
>
<h2 class="pt-2 font-semibold">Details</h2>
{#if data.posting.employmentType}
<p>{employmentTypeDisplayName(data.posting.employmentType)}</p>
{/if}
{#if data.posting.address}
<a
href="https://www.google.com/maps/search/?api=1&query={data.posting.address}"
class="block w-max"
>Address: <span class="hover-hyperlink">{data.posting.address}</span></a
>
{/if}
{#if data.posting.wage}
<p>Wage: {data.posting.wage}</p>
{/if}
{#if data.posting.createdAt}
<p>Posted: {data.posting.createdAt.toLocaleDateString('en-US', dateFormatOptions)}</p>
{/if}
{#if data.posting.link}
<a href={data.posting.link} class="block w-max"
>More information: <span class="hyperlink-color hyperlink-underline"
>{data.posting.link}</span
></a
>
{/if}
{#if data.posting.flyerLink}
<a href={data.posting.flyerLink} class="block w-max"
>Flyer: <span class="hyperlink-color hyperlink-underline">{data.posting.flyerLink}</span
></a
>
{/if}
<h2 class="pt-2 font-semibold">Job Description</h2>
<p class="whitespace-pre-wrap">{data.posting.description}</p>
</div>
</div>
<div class="elevated separator-borders m-4 inline-block h-min w-1/2 rounded">
<div class="bottom-border flex place-content-between">
<div class="p-3 font-semibold">Apply</div>
</div>
<form method="POST" class="px-4" autocomplete="off" use:enhance>
<div class="mt-4 text-sm font-semibold">
Why do you believe you are the best fit for this role? <span class="text-red-500">*</span>
<textarea
name="candidateStatement"
id="candidateStatement"
rows="4"
placeholder="Answer here"
class="w-full rounded font-normal"
required
></textarea>
</div>
<p>
Your account information will be submitted along with this application. If there is any
other information you would like the employer to know, please add it in the box above.
</p>
{#if form?.errorMessage}
<div class="mb-2 text-red-500">{form.errorMessage}</div>
{/if}
<button
class="dull-primary-bg-color mb-4 mt-6 rounded px-2 py-1"
type="submit"
formaction="?/submit">Submit application</button
>
</form>
</div>
</div>
</div>

View File

@ -0,0 +1,27 @@
<script lang="ts">
import { page } from '$app/state';
import type { LayoutProps } from './$types';
let { children }: LayoutProps = $props();
</script>
<div class="bottom-border h-10 pt-2 text-center">
<a
href="applications"
class="p-2 {page.url.pathname.endsWith('applications')
? 'primary-underline font-bold'
: 'low-emphasis-text low-emphasis-text-button'}"
><span class="material-symbols-outlined align-bottom">description</span> Applications</a
>
<a
href="edit"
class="p-2 {page.url.pathname.endsWith('edit')
? 'primary-underline font-bold'
: 'low-emphasis-text low-emphasis-text-button'}"
><span class="material-symbols-outlined align-bottom">work</span> Details</a
>
</div>
<div class="base-container">
{@render children()}
</div>

View File

@ -0,0 +1,7 @@
<script>
import { onMount } from 'svelte';
onMount(() => {
window.location.href = 'manage/applications';
});
</script>

View File

@ -0,0 +1,123 @@
import { type Actions, error, fail, redirect } from '@sveltejs/kit';
import { deletePosting, editPosting, getPosting, getPostings, getUser } from '$lib/db/index.server';
import { PERMISSIONS } from '$lib/consts';
import { getUserCompanyId, getUserId, getUserPerms } from '$lib/index.server';
import type { Posting } from '$lib/types';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies, params }) => {
const id = parseInt(params.posting);
const perms = getUserPerms(cookies);
if (
perms >= 0 &&
((perms & PERMISSIONS.MANAGE_POSTINGS) > 0 ||
((perms & PERMISSIONS.SUBMIT_POSTINGS) > 0 && getUserCompanyId(cookies) === id))
) {
return {
posting: await getPosting(id)
};
}
error(403, 'Unauthorized');
};
export const actions: Actions = {
submit: async ({ request, cookies, params }) => {
const id = parseInt(params.posting!);
const employerId = getUserId(cookies);
const data = await request.formData();
let companyId: number | null | undefined = getUserCompanyId(cookies);
if (!companyId) {
if (
data.get('companyId')?.toString().trim() === undefined ||
data.get('companyId')?.toString().trim() === null ||
data.get('companyId')?.toString().trim() === ''
) {
return fail(400, { errorMessage: 'Company ID is required' });
} else {
companyId = parseInt(data.get('companyId')?.toString().trim()!);
}
}
const title = data.get('title')?.toString().trim();
const description = data.get('description')?.toString().trim();
const address = data.get('address')?.toString().trim();
const employmentType = data.get('employmentType')?.toString().trim();
const wage = data.get('wage')?.toString().trim();
let link = data.get('link')?.toString().trim();
const tagIds = data.get('tagIds')?.toString().trim() || null;
let flyerLink = data.get('flyerLink')?.toString().trim();
if (link && !link?.includes('.')) {
return fail(400, { errorMessage: 'Invalid link' });
}
if (flyerLink && !flyerLink?.includes('.')) {
return fail(400, { errorMessage: 'Invalid flyer link' });
}
if (link && !link.startsWith('http://') && !link.startsWith('https://'))
link = `https://${link}`;
if (flyerLink && !flyerLink.startsWith('http://') && !flyerLink.startsWith('https://'))
flyerLink = `https://${flyerLink}`;
const requestPerms = getUserPerms(cookies);
if (
!(
requestPerms >= 0 &&
((requestPerms & PERMISSIONS.MANAGE_POSTINGS) > 0 ||
((requestPerms & PERMISSIONS.SUBMIT_POSTINGS) > 0 &&
getUserCompanyId(cookies) === employerId))
)
) {
return fail(403, { errorMessage: 'You cannot preform this action!' });
}
if (
!title ||
title === '' ||
!description ||
description === '' ||
!address ||
address === '' ||
!employmentType ||
employmentType === ''
) {
return fail(400, { errorMessage: 'All fields are required' });
}
try {
await editPosting(<Posting>{
id,
title,
description,
employerId,
address,
employmentType,
wage,
link,
tagIds: tagIds?.split(',').map((tag) => parseInt(tag)),
companyId,
flyerLink
});
} catch (err) {
return fail(500, { errorMessage: `Internal Server Error: ${err}` });
}
redirect(301, `/postings/${id}`);
},
delete: async ({ cookies, params }) => {
const id = parseInt(params.posting!);
const perms = getUserPerms(cookies);
if (
perms >= 0 &&
((perms & PERMISSIONS.MANAGE_POSTINGS) > 0 ||
((perms & PERMISSIONS.SUBMIT_POSTINGS) > 0 && getUserCompanyId(cookies) === id))
) {
try {
await deletePosting(id);
} catch (err) {
return fail(500, { errorMessage: `Internal Server Error: ${err}` });
}
redirect(301, '/postings');
}
error(403, 'Unauthorized');
}
};

View File

@ -0,0 +1,160 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageProps } from './$types';
import { userState } from '$lib/shared.svelte.js';
function openConfirm() {
document.getElementById('deleteConfirmModal')!.style.display = 'block';
}
function closeConfirm() {
document.getElementById('deleteConfirmModal')!.style.display = 'none';
}
let { data, form }: PageProps = $props();
</script>
<div class="base-container">
<div class="content">
<div class="elevated separator-borders m-4 rounded">
<div class="bottom-border flex place-content-between">
<div class="p-3 font-semibold">Edit {data.posting.title}</div>
</div>
<form method="POST" class="px-4" autocomplete="off" use:enhance>
{#if !userState.companyId}
<div class="mt-4 text-sm font-semibold">
Company ID <span class="text-red-500">*</span>
<input
type="number"
name="companyId"
id="companyId"
placeholder="Company ID"
value={data.posting.companyId}
class="w-full rounded font-normal"
required
/>
</div>
{/if}
<div class="mt-4 text-sm font-semibold">
Title <span class="text-red-500">*</span>
<input
type="text"
name="title"
id="title"
placeholder="Title"
value={data.posting.title}
class="w-full rounded font-normal"
required
/>
</div>
<div class="mt-4 text-sm font-semibold">
Description <span class="text-red-500">*</span>
<textarea
name="description"
id="description"
rows="4"
placeholder="Description"
class="w-full rounded font-normal"
required>{data.posting.description}</textarea
>
</div>
<div class="mt-4 text-sm font-semibold">
Address <span class="text-red-500">*</span>
<input
type="text"
name="address"
id="address"
placeholder="Address"
value={data.posting.address}
class="w-full rounded font-normal"
required
/>
</div>
<div class="mt-4 text-sm font-semibold">
<label for="employmentType">Employment type <span class="text-red-500">*</span></label>
<select
name="employmentType"
id="employmentType"
value={data.posting.employmentType}
class="w-full rounded font-normal"
>
<option value="full_time">Full time</option>
<option value="part_time">Part time</option>
<option value="internship">Internship</option>
</select>
</div>
<div class="mt-4 text-sm font-semibold">
Wage (optional)
<input
type="text"
name="wage"
id="wage"
placeholder="Wage"
value={data.posting.wage}
class="w-full rounded font-normal"
/>
</div>
<div class="mt-4 text-sm font-semibold">
Link to external posting information (optional)
<input
type="text"
name="link"
id="link"
placeholder="Link"
value={data.posting.link}
class="w-full rounded font-normal"
/>
</div>
<div class="mt-4 text-sm font-semibold">
Link to flyer (optional)
<input
type="text"
name="flyerLink"
id="flyerLink"
placeholder="Flyer link"
value={data.posting.flyerLink}
class="w-full rounded font-normal"
/>
</div>
{#if form?.errorMessage}
<div class="mb-2 text-red-500">{form.errorMessage}</div>
{/if}
<div class="mt-4 flex justify-between">
<button
class="dull-primary-bg-color mb-4 mt-2 rounded px-2 py-1"
type="submit"
formaction="?/submit">Save posting</button
>
<button
class="danger-bg-color mb-4 mt-2 rounded px-2 py-1"
type="button"
onclick={openConfirm}>Delete posting</button
>
</div>
</form>
</div>
</div>
</div>
<form id="deleteConfirmModal" method="POST" class="modal">
<div class="modal-content">
<div class="mb-2 inline-flex w-full justify-between">
<h2 class="font-semibold">Are you sure?</h2>
<button class="material-symbols-outlined" onclick={closeConfirm}>close</button>
</div>
<p>This will permanently delete this posting. This action cannot be undone.</p>
<div class="mt-4 flex justify-between">
<button
class="danger-bg-color rounded px-2 py-1"
type="submit"
formaction="?/delete&id={data.posting.id}">Delete posting</button
>
<button
class="separator-borders bg-color rounded px-2 py-1"
type="button"
onclick={closeConfirm}>Cancel</button
>
</div>
</div>
</form>

View File

@ -0,0 +1,90 @@
import { type Actions, fail, redirect } from '@sveltejs/kit';
import { createPosting } from '$lib/db/index.server';
import { PERMISSIONS } from '$lib/consts';
import { getUserCompanyId, getUserId, getUserPerms } from '$lib/index.server';
import type { Posting } from '$lib/types';
export const actions: Actions = {
submit: async ({ request, cookies }) => {
const employerId = getUserId(cookies);
const data = await request.formData();
let companyId: number | null | undefined = getUserCompanyId(cookies);
if (!companyId) {
if (
data.get('companyId')?.toString().trim() === undefined ||
data.get('companyId')?.toString().trim() === null ||
data.get('companyId')?.toString().trim() === ''
) {
return fail(400, { errorMessage: 'Company ID is required' });
} else {
companyId = parseInt(data.get('companyId')?.toString().trim()!);
}
}
const title = data.get('title')?.toString().trim();
const description = data.get('description')?.toString().trim();
const address = data.get('address')?.toString().trim();
const employmentType = data.get('employmentType')?.toString().trim();
const wage = data.get('wage')?.toString().trim();
let link = data.get('link')?.toString().trim();
const tagIds = data.get('tagIds')?.toString().trim() || null;
let flyerLink = data.get('flyerLink')?.toString().trim();
if (link && !link?.includes('.')) {
return fail(400, { errorMessage: 'Invalid link' });
}
if (flyerLink && !flyerLink?.includes('.')) {
return fail(400, { errorMessage: 'Invalid flyer link' });
}
if (link && !link.startsWith('http://') && !link.startsWith('https://'))
link = `https://${link}`;
if (flyerLink && !flyerLink.startsWith('http://') && !flyerLink.startsWith('https://'))
flyerLink = `https://${flyerLink}`;
const requestPerms = getUserPerms(cookies);
if (
!(
requestPerms >= 0 &&
((requestPerms & PERMISSIONS.MANAGE_POSTINGS) > 0 ||
((requestPerms & PERMISSIONS.SUBMIT_POSTINGS) > 0 &&
getUserCompanyId(cookies) === companyId))
)
) {
return fail(403, { errorMessage: 'You cannot preform this action!' });
}
if (
!title ||
title === '' ||
!address ||
address === '' ||
!description ||
description === '' ||
!employmentType ||
employmentType === '' ||
!address ||
address === ''
) {
return fail(400, { errorMessage: 'All fields are required' });
}
let id: number;
try {
id = await createPosting(<Posting>{
title,
description,
employerId,
address,
employmentType,
wage,
link,
tagIds: tagIds?.split(',').map((tag) => parseInt(tag)),
companyId,
flyerLink
});
} catch (err) {
return fail(500, { errorMessage: `Internal Server Error: ${err}` });
}
redirect(301, `/postings/${id}`);
}
};

View File

@ -0,0 +1,112 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageProps } from './$types';
import { userState } from '$lib/shared.svelte';
let { data, form }: PageProps = $props();
</script>
<div class="base-container">
<div class="content">
<div class="elevated separator-borders m-4 rounded">
<div class="bottom-border flex place-content-between">
<div class="p-3 font-semibold">Create new posting</div>
</div>
<form method="POST" class="px-4" autocomplete="off" use:enhance>
{#if !userState.companyId}
<div class="mt-4 text-sm font-semibold">
Company ID <span class="text-red-500">*</span>
<input
type="number"
name="companyId"
id="companyId"
placeholder="Company ID"
class="w-full rounded font-normal"
required
/>
</div>
{/if}
<div class="mt-4 text-sm font-semibold">
Title <span class="text-red-500">*</span>
<input
type="text"
name="title"
id="title"
placeholder="Title"
class="w-full rounded font-normal"
required
/>
</div>
<div class="mt-4 text-sm font-semibold">
Description <span class="text-red-500">*</span>
<textarea
name="description"
id="description"
rows="4"
placeholder="Description"
class="w-full rounded font-normal"
required
></textarea>
</div>
<div class="mt-4 text-sm font-semibold">
Address <span class="text-red-500">*</span>
<input
type="text"
name="address"
id="address"
placeholder="Address"
class="w-full rounded font-normal"
required
/>
</div>
<div class="mt-4 text-sm font-semibold">
<label for="employmentType">Employment type <span class="text-red-500">*</span></label>
<select name="employmentType" id="employmentType" class="w-full rounded font-normal">
<option value="full_time">Full time</option>
<option value="part_time">Part time</option>
<option value="internship">Internship</option>
</select>
</div>
<div class="mt-4 text-sm font-semibold">
Wage (optional)
<input
type="text"
name="wage"
id="wage"
placeholder="Wage"
class="w-full rounded font-normal"
/>
</div>
<div class="mt-4 text-sm font-semibold">
Link to external posting information (optional)
<input
type="text"
name="link"
id="link"
placeholder="Link"
class="w-full rounded font-normal"
/>
</div>
<div class="mt-4 text-sm font-semibold">
Link to flyer (optional)
<input
type="text"
name="flyerLink"
id="flyerLink"
placeholder="Flyer link"
class="w-full rounded font-normal"
/>
</div>
{#if form?.errorMessage}
<div class="mb-2 text-red-500">{form.errorMessage}</div>
{/if}
<button
class="dull-primary-bg-color mb-4 mt-6 rounded px-2 py-1"
type="submit"
formaction="?/submit">Create posting</button
>
</form>
</div>
</div>
</div>

View File

@ -3,24 +3,11 @@ import { type Actions, type Cookies, fail, redirect } from '@sveltejs/kit';
import jwt from 'jsonwebtoken';
import { createUser } from '$lib/db/index.server';
import { setJWT } from '$lib/shared.server';
import type { User } from '$lib/types';
dotenv.config({ path: '.env' });
function setJWT(cookies: Cookies, username: string, perms: number) {
const payload = {
username: username,
perms: perms
};
if (process.env.JWT_SECRET === undefined) {
throw new Error('JWT_SECRET not defined');
}
const maxAge = 60 * 60 * 24 * 30; // 30 days
const JWT = jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '30d' });
cookies.set('jwt', JWT, { maxAge, path: '/', httpOnly: false });
console.log(cookies.get('jwt'));
}
export const actions: Actions = {
register: async ({ request, cookies }) => {
const data = await request.formData();
@ -28,9 +15,11 @@ export const actions: Actions = {
const password = data.get('password')?.toString().trim();
const confirmPassword = data.get('confirmPassword')?.toString().trim();
let email: string | undefined | null = data.get('email')?.toString().trim();
let phone: string | undefined | null = data.get('phone')?.toString().trim();
let companyCode: string | undefined | null = data.get('companyCode')?.toString().trim();
let fullName: string | undefined | null = data.get('fullName')?.toString().trim();
if (email === '') email = null;
if (fullName === '') fullName = null;
if (phone === '') phone = null;
if (companyCode === '') companyCode = null;
if (email && !email.includes('@')) {
return fail(400, { errorMessage: 'Invalid email' });
@ -40,33 +29,39 @@ export const actions: Actions = {
username &&
password &&
confirmPassword &&
email &&
fullName &&
username !== '' &&
password !== '' &&
confirmPassword !== ''
confirmPassword !== '' &&
email !== '' &&
fullName !== ''
) {
if (password.length < 8) {
return fail(400, { errorMessage: 'Password must be at least 8 characters' });
}
if (password === confirmPassword) {
try {
await createUser(<User>{
const user: User = <User>{
username: username,
password: password,
perms: 3,
active: true,
email: email,
phone: phone,
fullName: fullName
});
};
if (password === confirmPassword) {
try {
await createUser(user);
} catch (err) {
return fail(400, { errorMessage: `Internal Server Error: ${err}` });
}
setJWT(cookies, username, 1);
setJWT(cookies, user);
throw redirect(303, '/');
} else {
return fail(400, { errorMessage: 'Passwords do not match' });
}
} else {
return fail(400, { errorMessage: 'Missing username or password' });
return fail(400, { errorMessage: 'Please fill out all required fields' });
}
}
};

View File

@ -1,27 +1,123 @@
<script lang="ts">
import { onMount } from 'svelte';
import { enhance } from '$app/forms';
import type { PageProps } from './$types';
import { telFormatter } from '$lib/shared.svelte';
onMount(() => {
if (document.cookie.includes('jwt=')) {
window.location.href = '/account';
}
document.getElementById('phone')?.addEventListener('input', function (this: HTMLInputElement) {
this.value = telFormatter(this.value);
});
});
// receive form data from server
let { data, form }: PageProps = $props();
</script>
<div class="signin-container place-items-center pt-8">
<div class="elevated separator-borders bg content rounded-md p-8">
<h1 class=" text-weight-semibold mb-4 text-center">Register</h1>
<h3>Are you an applicant or an employer?</h3>
<h1 class="text-weight-semibold mb-4 text-center">Register</h1>
<p>Create your account. Its free and only takes a minute!</p>
<form method="POST" class="arrange-vertically" use:enhance>
<div class="mt-4 text-sm font-semibold">
Username <span class="text-red-500">*</span>
<input
type="text"
name="username"
id="username"
placeholder="Username"
class="input-field w-full font-normal"
required
/>
</div>
<div class="relative mt-4 text-sm font-semibold">
Password <span class="text-red-500">*</span>
<input
type="password"
class="input-field w-full font-normal"
placeholder="Password"
name="password"
required
/>
</div>
<div class="relative mt-4 text-sm font-semibold">
Confirm password <span class="text-red-500">*</span>
<input
type="password"
class="input-field w-full font-normal"
placeholder="Password"
name="confirmPassword"
required
/>
</div>
<div class="mt-4 text-sm font-semibold">
Full name <span class="text-red-500">*</span>
<input
type="text"
name="fullName"
id="fullName"
placeholder="Full name"
class="input-field w-full font-normal"
required
/>
</div>
<div class="mt-4 text-sm font-semibold">
Email <span class="text-red-500">*</span>
<input
type="text"
name="email"
id="email"
placeholder="Email"
class="input-field w-full font-normal"
required
/>
</div>
<div class="mt-4 text-sm font-semibold">
Phone (optional)
<input
type="tel"
name="phone"
id="phone"
placeholder="Phone"
class="w-full rounded font-normal"
pattern="[0-9]{3}-[0-9]{3}-[0-9]{4}"
/>
</div>
<div class="relative mt-4 text-sm font-semibold">
<label for="companyCode"> Company code (optional) </label>
<div class="relative">
<input
type="text"
name="companyCode"
id="companyCode"
placeholder="Company code"
class="input-field w-full pr-10 font-normal"
/>
<a
class="primary-bg-color mb-4 mt-6 block w-full rounded px-2 py-2 text-center"
href="/register/user">Applicant</a
type="button"
href="/info#company-codes"
class="hyperlink-color tooltip absolute inset-y-0 right-2 flex items-center"
>
<div class="low-emphasis-text text-center text-3xl">OR</div>
<a
href="/register/employer"
class="primary-bg-color mb-2 mt-4 block w-full rounded px-2 py-2 text-center">Employer</a
<span class="material-symbols-outlined">info</span><span
class="tooltip-text font-sans text-sm font-normal">About company codes</span
>
</a>
</div>
</div>
<a href="/signin" class="low-emphasis-text-button">I already have an account.</a>
{#if form?.errorMessage}
<div class="my-2 text-red-500">{form.errorMessage}</div>
{/if}
<button
class="primary-bg-color mt-8 w-full rounded px-2 py-2"
type="submit"
formaction="?/register">Create account</button
>
<a href="/signin" class="low-emphasis-text-button mt-2">I already have an account.</a>
</form>
</div>
</div>

View File

@ -1,57 +0,0 @@
import * as dotenv from 'dotenv';
import { type Actions, type Cookies, fail, redirect } from '@sveltejs/kit';
import jwt from 'jsonwebtoken';
import { createUser } from '$lib/db/index.server';
dotenv.config({ path: '.env' });
function setJWT(cookies: Cookies, username: string, perms: number) {
const payload = {
username: username,
perms: perms
};
if (process.env.JWT_SECRET === undefined) {
throw new Error('JWT_SECRET not defined');
}
const maxAge = 60 * 60 * 24 * 30; // 30 days
const JWT = jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '30d' });
cookies.set('jwt', JWT, { maxAge, path: '/', httpOnly: false });
console.log(cookies.get('jwt'));
}
export const actions: Actions = {
register: async ({ request, cookies }) => {
const data = await request.formData();
const username = data.get('username')?.toString().trim();
const password = data.get('password')?.toString().trim();
const confirmPassword = data.get('confirmPassword')?.toString().trim();
if (
username &&
password &&
confirmPassword &&
username !== '' &&
password !== '' &&
confirmPassword !== ''
) {
if (password.length < 8) {
return fail(400, { errorMessage: 'Password must be at least 8 characters' });
}
if (password === confirmPassword) {
try {
await createUser(<User>{ username: username, password: password, perms: 3 });
} catch (err) {
return fail(400, { errorMessage: `Internal Server Error: ${err}` });
}
setJWT(cookies, username, 1);
throw redirect(303, '/');
} else {
return fail(400, { errorMessage: 'Passwords do not match' });
}
} else {
return fail(400, { errorMessage: 'Missing username or password' });
}
}
};

View File

@ -1,55 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { enhance } from '$app/forms';
import type { PageProps } from './$types';
onMount(() => {
if (document.cookie.includes('jwt=')) {
window.location.href = '/account';
}
});
// receive form data from server
let { data, form }: PageProps = $props();
</script>
<div class="signin-container place-items-center pt-8">
<div class="elevated separator-borders bg content rounded-md p-8">
<h1 class="text-weight-semibold mb-4 text-center">Register</h1>
<p>This is for employers only!</p>
<form method="POST" class="arrange-vertically" use:enhance>
<input
class="input-field my-4 w-full"
type="text"
placeholder="Username"
name="username"
required
/>
<input
type="password"
class="input-field my-4 w-full"
placeholder="Password"
name="password"
required
/>
<input
type="password"
class="input-field mt-4 w-full"
placeholder="Confirm password"
name="confirmPassword"
required
/>
{#if form?.errorMessage}
<div class="my-2 text-red-500">{form.errorMessage}</div>
{/if}
<button
class="primary-bg-color mt-8 w-full rounded px-2 py-2"
type="submit"
formaction="?/register">Create account</button
>
<a href="/signin" class="low-emphasis-text-button mt-2">I already have an account.</a>
</form>
</div>
</div>

View File

@ -1,85 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { enhance } from '$app/forms';
import type { PageProps } from './$types';
onMount(() => {
if (document.cookie.includes('jwt=')) {
window.location.href = '/account';
}
});
// receive form data from server
let { data, form }: PageProps = $props();
</script>
<div class="signin-container place-items-center pt-8">
<div class="elevated separator-borders bg content rounded-md p-8">
<h1 class="text-weight-semibold mb-4 text-center">Register</h1>
<p>Create your account. Its free and only takes a minute!</p>
<form method="POST" class="arrange-vertically" use:enhance>
<div class="mt-4 text-sm font-semibold">
Username <span class="text-red-500">*</span>
<input
type="text"
name="username"
id="username"
placeholder="Username"
class="input-field w-full font-normal"
required
/>
</div>
<div class="mt-4 text-sm font-semibold">
Email (optional)
<input
type="text"
name="email"
id="email"
placeholder="Email"
class="input-field w-full font-normal"
/>
</div>
<div class="mt-4 text-sm font-semibold">
Full name (optional)
<input
type="text"
name="fullName"
id="fullName"
placeholder="Full name"
class="input-field w-full font-normal"
/>
</div>
<div class="relative mt-4 text-sm font-semibold">
Password <span class="text-red-500">*</span>
<input
type="password"
class="input-field w-full font-normal"
placeholder="Password"
name="password"
required
/>
</div>
<div class="relative mt-4 text-sm font-semibold">
Confirm password <span class="text-red-500">*</span>
<input
type="password"
class="input-field w-full font-normal"
placeholder="Password"
name="confirmPassword"
required
/>
</div>
{#if form?.errorMessage}
<div class="my-2 text-red-500">{form.errorMessage}</div>
{/if}
<button
class="primary-bg-color mt-8 w-full rounded px-2 py-2"
type="submit"
formaction="?/register">Create account</button
>
<a href="/signin" class="low-emphasis-text-button mt-2">I already have an account.</a>
</form>
</div>
</div>

View File

@ -1,26 +1,12 @@
import { checkUserCreds, createUser, updateLastSignin } from '$lib/db/index.server';
import { fail, redirect, type Actions, type Cookies } from '@sveltejs/kit';
import jwt from 'jsonwebtoken';
import { checkUserCreds, updateLastSignin } from '$lib/db/index.server';
import { fail, redirect, type Actions } from '@sveltejs/kit';
import * as dotenv from 'dotenv';
import { setJWT } from '$lib/shared.server';
import type { User } from '$lib/types';
dotenv.config({ path: '.env' });
function setJWT(cookies: Cookies, user: User) {
const payload = {
username: user.username,
perms: user.perms,
id: user.id
};
if (process.env.JWT_SECRET === undefined) {
throw new Error('JWT_SECRET not defined');
}
const maxAge = 60 * 60 * 24 * 30; // 30 days
const JWT = jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '30d' });
cookies.set('jwt', JWT, { maxAge, path: '/', httpOnly: false });
}
export const actions: Actions = {
signin: async ({ request, cookies }) => {
const data = await request.formData();

View File

@ -1,14 +0,0 @@
import { userState } from '$lib/shared.svelte';
export const getCookieValue = (name: String) =>
document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)')?.pop() || '';
export function updateUserState() {
const JWT = getCookieValue('jwt');
if (JWT !== '') {
const state = JSON.parse(atob(JWT.split('.')[1]));
userState.perms = state.perms;
userState.username = state.username;
userState.id = state.id;
}
}

View File

@ -1,9 +1,10 @@
import adapter from '@sveltejs/adapter-node';
import { sveltePreprocess } from 'svelte-preprocess';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
preprocess: sveltePreprocess(),
kit: {
adapter: adapter()