This commit is contained in:
Evert Prants 2025-02-23 12:21:09 +02:00
parent 796066ce42
commit 7cd513457c
Signed by: evert
GPG Key ID: 0960A17F9F40237D
38 changed files with 758 additions and 140 deletions

View File

@ -51,3 +51,6 @@ AUTO_MIGRATE=true
# hCaptcha keys, leave empty if not using it # hCaptcha keys, leave empty if not using it
PUBLIC_HCAPTCHA_KEY= PUBLIC_HCAPTCHA_KEY=
HCAPTCHA_SECRET= HCAPTCHA_SECRET=
# Use Valkey for cache, leave empty to use in-memory cache
VALKEY_URI=

213
package-lock.json generated
View File

@ -8,14 +8,17 @@
"name": "icysso", "name": "icysso",
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"@keyv/valkey": "^1.0.3",
"@sveltejs/adapter-node": "^5.2.12", "@sveltejs/adapter-node": "^5.2.12",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"cache-manager": "^6.4.0",
"cacheable": "^1.8.8",
"chalk": "^5.4.1", "chalk": "^5.4.1",
"cropperjs": "^1.6.2", "cropperjs": "^1.6.2",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"drizzle-orm": "^0.39.3", "drizzle-orm": "^0.39.3",
"image-size": "^1.2.0", "image-size": "^1.2.0",
"jose": "^5.10.0", "jose": "^6.0.4",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"mysql2": "^3.12.0", "mysql2": "^3.12.0",
"nodemailer": "^6.10.0", "nodemailer": "^6.10.0",
@ -1135,6 +1138,12 @@
"url": "https://github.com/sponsors/nzakas" "url": "https://github.com/sponsors/nzakas"
} }
}, },
"node_modules/@iovalkey/commands": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
"integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
"license": "MIT"
},
"node_modules/@isaacs/ttlcache": { "node_modules/@isaacs/ttlcache": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz", "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz",
@ -1187,6 +1196,27 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@keyv/serialize": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.0.3.tgz",
"integrity": "sha512-qnEovoOp5Np2JDGonIDL6Ayihw0RhnRh6vxPuHo4RDn1UOzwEo4AeIfpL6UGIrsceWrCMiVPgwRjbHu4vYFc3g==",
"license": "MIT",
"dependencies": {
"buffer": "^6.0.3"
}
},
"node_modules/@keyv/valkey": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@keyv/valkey/-/valkey-1.0.3.tgz",
"integrity": "sha512-QCC6OzI3K1PjAvcj9n5CB+hy8M+hl8PxLkJQWs0DAOP+4XXuJPPRdQqf/jPm7zunrZl5+G+JL8WGsv4Kq0TeoQ==",
"license": "MIT",
"dependencies": {
"iovalkey": "^0.3.1"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/@noble/ciphers": { "node_modules/@noble/ciphers": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.2.0.tgz", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.2.0.tgz",
@ -2305,6 +2335,26 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true "dev": true
}, },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/bcryptjs": { "node_modules/bcryptjs": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz",
@ -2343,12 +2393,73 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/buffer-from": { "node_modules/buffer-from": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true "dev": true
}, },
"node_modules/cache-manager": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-6.4.0.tgz",
"integrity": "sha512-eUmPyVqQYzWCt7hx1QrYzQ7oC3MGKM1etxxe8zuq1o7IB4NzdBeWcUGDSWYahaI8fkd538SEZRGadyZWQfvOzQ==",
"license": "MIT",
"dependencies": {
"keyv": "^5.2.3"
}
},
"node_modules/cache-manager/node_modules/keyv": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-5.2.3.tgz",
"integrity": "sha512-AGKecUfzrowabUv0bH1RIR5Vf7w+l4S3xtQAypKaUpTdIR1EbrAcTxHCrpo9Q+IWeUlFE2palRtgIQcgm+PQJw==",
"license": "MIT",
"dependencies": {
"@keyv/serialize": "^1.0.2"
}
},
"node_modules/cacheable": {
"version": "1.8.8",
"resolved": "https://registry.npmjs.org/cacheable/-/cacheable-1.8.8.tgz",
"integrity": "sha512-OE1/jlarWxROUIpd0qGBSKFLkNsotY8pt4GeiVErUYh/NUeTNrT+SBksUgllQv4m6a0W/VZsLuiHb88maavqEw==",
"license": "MIT",
"dependencies": {
"hookified": "^1.7.0",
"keyv": "^5.2.3"
}
},
"node_modules/cacheable/node_modules/keyv": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-5.2.3.tgz",
"integrity": "sha512-AGKecUfzrowabUv0bH1RIR5Vf7w+l4S3xtQAypKaUpTdIR1EbrAcTxHCrpo9Q+IWeUlFE2palRtgIQcgm+PQJw==",
"license": "MIT",
"dependencies": {
"@keyv/serialize": "^1.0.2"
}
},
"node_modules/callsites": { "node_modules/callsites": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@ -2414,6 +2525,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -3767,6 +3887,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/hookified": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/hookified/-/hookified-1.7.1.tgz",
"integrity": "sha512-OXcdHsXeOiD7OJ5zvWj8Oy/6RCdLwntAX+wUrfemNcMGn6sux4xbEHi2QXwqePYhjQ/yvxxq2MvCRirdlHscBw==",
"license": "MIT"
},
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@ -3778,6 +3904,26 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.1", "version": "5.3.1",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz",
@ -3842,6 +3988,26 @@
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
}, },
"node_modules/iovalkey": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.3.1.tgz",
"integrity": "sha512-pSmFj/ZDFLP8AzqIAMpNJArhHYNeyqIwfcUULwZv8g6y3eaUGrlnlT7QXLrJAp0yiGCAqe1hPA33x/h5opNP9w==",
"license": "MIT",
"dependencies": {
"@iovalkey/commands": "^0.1.0",
"cluster-key-slot": "^1.1.0",
"debug": "^4.3.4",
"denque": "^2.1.0",
"lodash.defaults": "^4.2.0",
"lodash.isarguments": "^3.1.0",
"redis-errors": "^1.2.0",
"redis-parser": "^3.0.0",
"standard-as-callback": "^2.1.0"
},
"engines": {
"node": ">=18.12.0"
}
},
"node_modules/is-core-module": { "node_modules/is-core-module": {
"version": "2.16.1", "version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
@ -3924,9 +4090,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/jose": { "node_modules/jose": {
"version": "5.10.0", "version": "6.0.4",
"resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", "resolved": "https://registry.npmjs.org/jose/-/jose-6.0.4.tgz",
"integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", "integrity": "sha512-GTWKmlIq0xs50pnwa7q+8eG0ZLR1DVcHxa75s2VArJcriBd+YEC18uK/kbTObF3gMXgqBeZXAoZA9eMLhPjSpQ==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/panva" "url": "https://github.com/sponsors/panva"
@ -4032,6 +4198,18 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
"license": "MIT"
},
"node_modules/lodash.isarguments": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
"license": "MIT"
},
"node_modules/lodash.merge": { "node_modules/lodash.merge": {
"version": "4.6.2", "version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@ -4592,6 +4770,27 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/redis-errors": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/redis-parser": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
"license": "MIT",
"dependencies": {
"redis-errors": "^1.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/require-directory": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@ -4833,6 +5032,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/standard-as-callback": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
"license": "MIT"
},
"node_modules/string-width": { "node_modules/string-width": {
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",

View File

@ -37,14 +37,17 @@
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@keyv/valkey": "^1.0.3",
"@sveltejs/adapter-node": "^5.2.12", "@sveltejs/adapter-node": "^5.2.12",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"cache-manager": "^6.4.0",
"cacheable": "^1.8.8",
"chalk": "^5.4.1", "chalk": "^5.4.1",
"cropperjs": "^1.6.2", "cropperjs": "^1.6.2",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"drizzle-orm": "^0.39.3", "drizzle-orm": "^0.39.3",
"image-size": "^1.2.0", "image-size": "^1.2.0",
"jose": "^5.10.0", "jose": "^6.0.4",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"mysql2": "^3.12.0", "mysql2": "^3.12.0",
"nodemailer": "^6.10.0", "nodemailer": "^6.10.0",

View File

@ -6,7 +6,9 @@
--in-background-image: none; --in-background-image: none;
--in-normalized-background: #cceaff; --in-normalized-background: #cceaff;
--in-container-background: #f1faff; --in-container-background: #f1faff;
--in-inner-container-background: #b7e5ff;
--in-container-shadow: 0px 0px 16px 0px rgba(0, 0, 0, 0.15); --in-container-shadow: 0px 0px 16px 0px rgba(0, 0, 0, 0.15);
--in-alert-shadow: 0px 5px 10px 0px rgba(0, 0, 0, 0.45);
--in-text-color: #000000; --in-text-color: #000000;
--in-link-color: #000000; --in-link-color: #000000;
@ -71,6 +73,7 @@ html[theme-base='dark'] {
--in-background-image: url('/background.webp'); --in-background-image: url('/background.webp');
--in-normalized-background: #000; --in-normalized-background: #000;
--in-container-background: transparent; --in-container-background: transparent;
--in-inner-container-background: #ffffff15;
--in-text-color: #fff; --in-text-color: #fff;
--in-link-color: #fff; --in-link-color: #fff;

View File

@ -1,7 +1,9 @@
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
import { CacheBackend } from '$lib/server/cache-backend';
import { csrf } from '$lib/server/csrf'; import { csrf } from '$lib/server/csrf';
import { DB } from '$lib/server/drizzle'; import { DB } from '$lib/server/drizzle';
import { runSeeds } from '$lib/server/drizzle/seeds'; import { runSeeds } from '$lib/server/drizzle/seeds';
import { FileBackend } from '$lib/server/file-backend';
import { JWT } from '$lib/server/jwt'; import { JWT } from '$lib/server/jwt';
import { Logger, LogLevel } from '$lib/server/logger'; import { Logger, LogLevel } from '$lib/server/logger';
import type { ThemeModeType } from '$lib/theme-mode'; import type { ThemeModeType } from '$lib/theme-mode';
@ -12,6 +14,9 @@ import { handleSession } from 'svelte-kit-cookie-session';
const { AUTO_MIGRATE, SESSION_SECRET, SESSION_SECURE, NODE_ENV } = env; const { AUTO_MIGRATE, SESSION_SECRET, SESSION_SECURE, NODE_ENV } = env;
await FileBackend.init();
await CacheBackend.init();
// Set logger to debug mode // Set logger to debug mode
if (NODE_ENV === 'development') { if (NODE_ENV === 'development') {
Logger.setLogLevel(LogLevel.DEBUG); Logger.setLogLevel(LogLevel.DEBUG);

View File

@ -1,21 +1,19 @@
<script lang="ts"> <script lang="ts">
import { createEventDispatcher } from 'svelte';
import Button from './Button.svelte'; import Button from './Button.svelte';
interface Props { interface Props {
type?: 'default' | 'error' | 'success'; type?: 'default' | 'error' | 'success';
dismissable?: boolean; dismissable?: boolean;
children?: import('svelte').Snippet; children?: import('svelte').Snippet;
onDismiss?: () => void;
} }
let { type = 'default', dismissable = false, children }: Props = $props(); let { type = 'default', dismissable = false, children, onDismiss }: Props = $props();
const dispatch = createEventDispatcher();
</script> </script>
<div class="alert alert-{type}" role="alert"> <div class="alert alert-{type}" role="alert">
<p>{@render children?.()}</p> <p>{@render children?.()}</p>
{#if dismissable}<Button variant="link" onclick={() => dispatch('dismiss')}>x</Button>{/if} {#if dismissable}<Button variant="link" onclick={() => onDismiss?.()}>x</Button>{/if}
</div> </div>
<style> <style>

View File

@ -0,0 +1,122 @@
<script lang="ts">
import { t } from '$lib/i18n';
import { dismissMessage, messages } from '$lib/stores/messages.store';
import Icon from './icons/Icon.svelte';
</script>
<div class="messages-wrapper" aria-live="assertive">
{#each $messages as msg}
<div class="appear message message--{msg.type}" role="alertdialog">
<div class="message-title">
<span>{$t(`common.${msg.type}`)}!</span>
<button onclick={() => dismissMessage(msg)} class="message-close">
<Icon icon="Close" />
<span class="visually-hidden">{$t('common.close')}</span>
</button>
</div>
<span>{msg.text}</span>
{#if msg.actions}
<div class="message-actions">
{#each msg.actions as action}
<button onclick={action.action}>{action.label}</button>
{/each}
</div>
{/if}
</div>
{/each}
</div>
<style>
.messages-wrapper {
position: fixed;
z-index: 30;
left: auto;
width: 100%;
right: auto;
bottom: 0;
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
pointer-events: none;
}
.message {
pointer-events: all;
background-color: var(--in-alert-color);
box-shadow: var(--in-alert-shadow);
padding: 16px;
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 8px;
overflow-wrap: anywhere;
max-width: 28rem;
margin: auto;
& > span {
font-size: 1.25rem;
}
}
.message-title {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
& > span {
font-weight: bold;
font-size: 1.5rem;
}
}
.message-close,
.message-actions > button {
appearance: none;
border: 0;
padding: 0;
background: transparent;
color: var(--in-link-color);
font-size: 1rem;
width: 2rem;
height: 2rem;
margin-top: -0.5rem;
margin-right: -0.5rem;
cursor: pointer;
&:focus-visible {
outline: var(--in-focus-outline);
}
}
.message-actions > button {
text-decoration: underline;
}
.message-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.message--error {
background-color: var(--in-error-color);
}
.message--success {
background-color: var(--in-success-color);
}
.appear {
animation: popup 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes popup {
from {
transform: scale(0.6);
}
to {
transform: scale(1);
}
}
</style>

View File

@ -85,7 +85,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 2rem 2rem 1rem 2rem; padding: 1.5rem 2rem;
border-bottom: 1px solid var(--in-modal-divider-color); border-bottom: 1px solid var(--in-modal-divider-color);
} }
@ -93,7 +93,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 1rem 2rem 2rem 2rem; padding: 1.5rem 2rem;
border-top: 1px solid var(--in-modal-divider-color); border-top: 1px solid var(--in-modal-divider-color);
} }

View File

@ -1,16 +1,28 @@
<script lang="ts"> <script lang="ts">
import { env } from '$env/dynamic/public'; import { env } from '$env/dynamic/public';
import { t } from '$lib/i18n';
import type { UserSession } from '$lib/types'; import type { UserSession } from '$lib/types';
import Button from '../Button.svelte';
import Icon from '../icons/Icon.svelte';
import ThemeButton from '../ThemeButton.svelte'; import ThemeButton from '../ThemeButton.svelte';
interface Props { interface Props {
user: UserSession; user: UserSession;
mobileOpen?: boolean;
} }
let { user }: Props = $props(); let { user, mobileOpen = $bindable() }: Props = $props();
</script> </script>
<header class="admin-header"> <header class="admin-header">
<div class="menu-toggle-wrapper">
<Button variant="link" onclick={() => (mobileOpen = !mobileOpen)}>
<Icon icon={mobileOpen ? 'Close' : 'Menu'} />
<span class="visually-hidden">{$t('admin.menu.' + (mobileOpen ? 'close' : 'open'))}</span>
</Button>
<a class="site-name" href="/">{env.PUBLIC_SITE_NAME}</a> <a class="site-name" href="/">{env.PUBLIC_SITE_NAME}</a>
</div>
<div class="aside"> <div class="aside">
<div class="admin-user"> <div class="admin-user">
<img class="admin-user-avatar" src={`/api/avatar/${user.uuid}`} alt={user.name} /> <img class="admin-user-avatar" src={`/api/avatar/${user.uuid}`} alt={user.name} />
@ -33,10 +45,27 @@
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
} }
& :global(button) {
color: var(--ina-header-link-color);
} }
.admin-header .aside :global(button) { .menu-toggle-wrapper {
color: var(--ina-header-link-color); display: flex;
align-items: center;
gap: 8px;
& > :global(button) {
width: 2rem;
height: 2rem;
flex-shrink: 0;
margin-left: -8px;
@media screen and (min-width: 769px) {
display: none;
}
}
}
} }
.admin-user { .admin-user {
@ -54,6 +83,14 @@
border-radius: 100%; border-radius: 100%;
object-fit: contain; object-fit: contain;
} }
@media screen and (max-width: 768px) {
padding: 4px;
& .admin-user-name {
display: none;
}
}
} }
a { a {

View File

@ -3,12 +3,14 @@
import { hasPrivileges } from '$lib/utils'; import { hasPrivileges } from '$lib/utils';
import { page } from '$app/state'; import { page } from '$app/state';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { onNavigate } from '$app/navigation';
interface Props { interface Props {
user: UserSession; user: UserSession;
mobileOpen?: boolean;
} }
let { user }: Props = $props(); let { user, mobileOpen = $bindable() }: Props = $props();
const links = [ const links = [
{ {
@ -31,9 +33,13 @@
let entries = $derived( let entries = $derived(
links.filter((link) => hasPrivileges(user.privileges || [], link.privileges)) links.filter((link) => hasPrivileges(user.privileges || [], link.privileges))
); );
onNavigate(() => {
mobileOpen = false;
});
</script> </script>
<aside class="admin-sidebar"> <aside class="admin-sidebar{mobileOpen ? ' mobile-open' : ''}">
<nav> <nav>
<ul> <ul>
{#each entries as link} {#each entries as link}
@ -59,6 +65,21 @@
width: 100%; width: 100%;
border-right: 2px solid var(--ina-sidebar-border-color); border-right: 2px solid var(--ina-sidebar-border-color);
@media screen and (max-width: 768px) {
position: absolute;
z-index: 111;
transition: width 250ms linear;
overflow: hidden;
white-space: nowrap;
box-shadow: var(--in-alert-shadow);
&:not(.mobile-open) {
width: 0;
visibility: hidden;
border: none;
}
}
& > nav { & > nav {
margin-top: 16px; margin-top: 16px;

View File

@ -2,14 +2,15 @@
interface Props { interface Props {
src?: string; src?: string;
alt?: string; alt?: string;
variant?: 'normal' | 'small';
children?: import('svelte').Snippet; children?: import('svelte').Snippet;
} }
let { src = '', alt = '', children }: Props = $props(); let { src = '', alt = '', variant = 'normal', children }: Props = $props();
</script> </script>
<div class="avatar-wrapper{children ? ' with-actions' : ''}"> <div class="avatar-wrapper{children ? ' with-actions' : ''}">
<div class="image-wrapper"> <div class="image-wrapper{variant ? ` ${variant}` : ''}">
<img {src} {alt} /> <img {src} {alt} />
</div> </div>
@ -37,5 +38,11 @@
width: 120px; width: 120px;
flex: 0 0 120px; flex: 0 0 120px;
background-color: var(--in-normalized-background); background-color: var(--in-normalized-background);
&.small {
height: 60px;
width: 60px;
flex: 0 0 60px;
}
} }
</style> </style>

View File

@ -1,13 +1,14 @@
<script lang="ts"> <script lang="ts">
interface Props { interface Props {
mobileBreak?: boolean;
children?: import('svelte').Snippet; children?: import('svelte').Snippet;
action?: import('svelte').Snippet; action?: import('svelte').Snippet;
} }
let { children, action }: Props = $props(); let { children, action, mobileBreak = true }: Props = $props();
</script> </script>
<div class="title-row"> <div class="title-row{mobileBreak ? ' mobile-break' : ''}">
{@render children?.()} {@render children?.()}
{@render action?.()} {@render action?.()}
</div> </div>
@ -19,14 +20,20 @@
justify-content: space-between; justify-content: space-between;
margin-bottom: 1.3rem; margin-bottom: 1.3rem;
@media screen and (max-width: 768px) { & > :global(*):first-child {
flex-direction: column;
gap: 1rem;
}
}
.title-row > :global(*):first-child {
align-self: flex-start; align-self: flex-start;
margin: 0; margin: 0;
} }
&.mobile-break {
@media screen and (max-width: 768px) {
flex-direction: column;
gap: 1rem;
& > :global(*):first-child {
align-self: center;
}
}
}
}
</style> </style>

View File

@ -1,17 +0,0 @@
<script lang="ts">
import { t } from '$lib/i18n';
import Alert from '../Alert.svelte';
interface Props {
prefix: string;
errors?: string[];
}
let { prefix, errors = [] }: Props = $props();
</script>
{#if errors.length}
{#each errors as error}
<Alert type="error">{$t(`${prefix}.${error}`)}</Alert>
{/each}
{/if}

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
><path
d="M13.46,12L19,17.54V19H17.54L12,13.46L6.46,19H5V17.54L10.54,12L5,6.46V5H6.46L12,10.54L17.54,5H19V6.46L13.46,12Z"
/></svg
>

After

Width:  |  Height:  |  Size: 196 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
><path d="M3,6H21V8H3V6M3,11H21V13H3V11M3,16H21V18H3V16Z" /></svg
>

After

Width:  |  Height:  |  Size: 129 B

View File

@ -0,0 +1,56 @@
<script lang="ts">
import { t } from '$lib/i18n';
import type { OAuth2ClientInfo } from '$lib/types';
import AvatarCard from '../avatar/AvatarCard.svelte';
let { client }: { client: OAuth2ClientInfo } = $props();
</script>
<div class="client-wrapper">
<div class="card">
<AvatarCard src={`/api/avatar/client/${client.client_id}`} alt={client.title} variant="small">
<div class="card-inner">
<span class="card-display-name">{client.title}</span>
<span class="card-user-name">{client.description}</span>
<div class="card-links">
{#each client.links as link}
<a href={link.url} target="_blank" rel="nofollow noreferrer"
>{$t(`oauth2.authorize.link.${link.type}`)}</a
>
{/each}
</div>
</div>
</AvatarCard>
</div>
</div>
<style>
.client-wrapper {
display: flex;
flex-direction: row;
justify-content: space-evenly;
width: calc(100% + 80px);
margin: 0 -40px;
background-color: var(--in-inner-container-background);
& .card {
width: 100%;
padding: 16px 40px;
}
}
.card-inner {
display: flex;
flex-direction: column;
height: 100%;
}
.card-display-name {
font-size: 1.25rem;
font-weight: 700;
}
.card-links {
display: flex;
flex-direction: column;
margin-top: auto;
}
</style>

13
src/lib/form-errors.ts Normal file
View File

@ -0,0 +1,13 @@
import { get } from 'svelte/store';
import { displayMessage, translateError } from './stores/messages.store';
export const popupFormErrors = (form?: { errors?: string[] } | null, prefix?: string) => {
if (form?.errors) {
form.errors.forEach((error) =>
displayMessage({
text: get(translateError)(error, prefix),
type: 'error'
})
);
}
};

View File

@ -1,8 +1,11 @@
{ {
"title": "Admin", "title": "Admin",
"success": "Changes saved successfully.",
"menu": { "menu": {
"open": "Open menu",
"close": "Close menu",
"users": "Users", "users": "Users",
"oauth2": "OAuth2 applications", "oauth2": "OAuth 2.0 applications",
"audit": "Audit logs" "audit": "Audit logs"
}, },
"users": { "users": {
@ -29,7 +32,7 @@
} }
}, },
"oauth2": { "oauth2": {
"title": "OAuth2 applications", "title": "OAuth 2.0 applications",
"new": "Create a new application", "new": "Create a new application",
"clientTitle": "Application name", "clientTitle": "Application name",
"clientId": "Client ID", "clientId": "Client ID",
@ -42,7 +45,7 @@
"scopes": "Available scopes", "scopes": "Available scopes",
"scopesHint": "The level of access to information you will be needing for this application.", "scopesHint": "The level of access to information you will be needing for this application.",
"grants": "Available grant types", "grants": "Available grant types",
"grantsHint": "The OAuth2 authorization flows you will be using with this application.", "grantsHint": "The OAuth 2.0 authorization flows you will be using with this application.",
"confidential": "Confidential", "confidential": "Confidential",
"confidentialHint": "Uncheck this checkbox if you cannot reasonably guarantee the secrecy of your Client secret, such as in the case of your application being a native (desktop, mobile, etc.) application. Unchecking this will allow you to get tokens without client authentication. Some functionality may be limited in public applications.", "confidentialHint": "Uncheck this checkbox if you cannot reasonably guarantee the secrecy of your Client secret, such as in the case of your application being a native (desktop, mobile, etc.) application. Unchecking this will allow you to get tokens without client authentication. Some functionality may be limited in public applications.",
"created": "Created at", "created": "Created at",
@ -59,7 +62,6 @@
"authorizationsHint": "These users have authorized this application at least once. You may assign application privileges to each user individually.", "authorizationsHint": "These users have authorized this application at least once. You may assign application privileges to each user individually.",
"revoked": "This authorization has been revoked by the user", "revoked": "This authorization has been revoked by the user",
"noAuthorizations": "There are no authorizations on record for this application.", "noAuthorizations": "There are no authorizations on record for this application.",
"success": "Success!",
"privileges": { "privileges": {
"title": "Application privileges", "title": "Application privileges",
"name": "Privilege name", "name": "Privilege name",

View File

@ -1,7 +1,7 @@
{ {
"description": "{{siteName}} is a Single-Sign-On service used by other applications.", "description": "{{siteName}} is a Single-Sign-On service used by other applications.",
"metaDescription": "{{siteName}} - Single-Sign-On service", "metaDescription": "{{siteName}} - Single-Sign-On service",
"cookieDisclaimer": "The website may use temporary cookies for storing your login session and ensuring your security. This web service is&nbsp;<a href=\"https://git.icynet.eu/IcyNetwork/sso-core\" target=\"_blank\">completely open source</a> and can be audited by anyone.", "cookieDisclaimer": "The website may use temporary cookies for storing your login session, theme preference and ensuring your account security. This web service is&nbsp;<a href=\"https://git.icynet.eu/IcyNetwork/sso-core\" target=\"_blank\">completely open source</a> and can be audited by anyone.",
"submit": "Submit", "submit": "Submit",
"cancel": "Cancel", "cancel": "Cancel",
"manage": "Manage", "manage": "Manage",
@ -15,6 +15,9 @@
"true": "Yes", "true": "Yes",
"false": "No" "false": "No"
}, },
"success": "Success",
"error": "Error",
"close": "Close",
"available": "Available", "available": "Available",
"current": "Current", "current": "Current",
"remove": "Remove", "remove": "Remove",

View File

@ -1,4 +1,4 @@
import { SQL, count, desc, eq, inArray, lt, or, sql } from 'drizzle-orm'; import { SQL, count, desc, eq, inArray, or, sql } from 'drizzle-orm';
import { DB, auditLog, user, type AuditLog, type NewAuditLog, type User } from '../drizzle'; import { DB, auditLog, user, type AuditLog, type NewAuditLog, type User } from '../drizzle';
import { import {
AuditAction, AuditAction,
@ -11,6 +11,7 @@ import { Logger } from '../logger';
import { AdminNotificationEmail, Emails } from '../email'; import { AdminNotificationEmail, Emails } from '../email';
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
import { env as publicEnv } from '$env/dynamic/public'; import { env as publicEnv } from '$env/dynamic/public';
import { CacheBackend } from '../cache-backend';
const FLAG_EMAIL_COOLDOWN = 1 * 60 * 1000; const FLAG_EMAIL_COOLDOWN = 1 * 60 * 1000;
const FLAG_TRESHOLD_COOLDOWN = 30 * 60 * 1000; const FLAG_TRESHOLD_COOLDOWN = 30 * 60 * 1000;
@ -20,9 +21,6 @@ const AUTOFLAG = [AuditAction.MALICIOUS_REQUEST, AuditAction.THROTTLE];
export class Audit { export class Audit {
protected static logger = new Logger('AUDIT'); protected static logger = new Logger('AUDIT');
protected static flagEmailTimeout: ReturnType<typeof setTimeout> | undefined = undefined; protected static flagEmailTimeout: ReturnType<typeof setTimeout> | undefined = undefined;
protected static flagTresholdTimeout: ReturnType<typeof setTimeout> | undefined = undefined;
protected static flagTriggerCount = 0;
protected static flagEmailCount = 0;
public static async insert( public static async insert(
action: AuditAction, action: AuditAction,
@ -176,28 +174,22 @@ export class Audit {
} }
private static async auditFlagTrigger() { private static async auditFlagTrigger() {
Audit.flagTriggerCount += 1; let flagTriggerCount = (await CacheBackend.get<number>('auditFlagTriggerCount')) ?? 0;
let flagEmailCount = (await CacheBackend.get<number>('auditFlagEmailCount')) ?? 0;
flagTriggerCount += 1;
clearTimeout(Audit.flagTresholdTimeout); await CacheBackend.set('auditFlagTriggerCount', flagTriggerCount, FLAG_TRESHOLD_COOLDOWN);
Audit.flagTresholdTimeout = setTimeout(() => {
Audit.flagTriggerCount = 0;
Audit.flagEmailCount = 0;
}, FLAG_TRESHOLD_COOLDOWN);
if ( if (flagTriggerCount < FLAG_EMAIL_TRESHOLD || Audit.flagEmailTimeout || flagEmailCount > 1) {
Audit.flagTriggerCount < FLAG_EMAIL_TRESHOLD ||
Audit.flagEmailTimeout ||
Audit.flagEmailCount > 1
) {
return; return;
} }
Logger.verbose('Flag treshold reached, sending out audit email to administrator'); Logger.verbose('Flag treshold reached, sending out audit email to administrator');
Audit.flagEmailTimeout = setTimeout(() => { Audit.flagEmailTimeout = setTimeout(() => {
void Audit.sendAuditEmail(Audit.flagTriggerCount); void Audit.sendAuditEmail(flagTriggerCount);
Audit.flagTriggerCount = 0;
Audit.flagEmailCount += 1;
Audit.flagEmailTimeout = undefined; Audit.flagEmailTimeout = undefined;
void CacheBackend.set('auditFlagTriggerCount', 0, FLAG_TRESHOLD_COOLDOWN);
void CacheBackend.set('auditFlagEmailCount', flagEmailCount + 1, FLAG_TRESHOLD_COOLDOWN);
}, FLAG_EMAIL_COOLDOWN); }, FLAG_EMAIL_COOLDOWN);
} }

View File

@ -0,0 +1,51 @@
import { env } from '$env/dynamic/private';
import KeyvValkey from '@keyv/valkey';
import { createCache, type Cache } from 'cache-manager';
import { CacheableMemory, Keyv } from 'cacheable';
export class CacheBackend {
private static cache: Cache;
/**
* Set a cached value
* @param key Key to set
* @param data Data to set
* @param ttl Time-to-live in milliseconds
*/
public static async set<T = unknown>(key: string, data: T, ttl?: number): Promise<T> {
return CacheBackend.cache.set(key, data, ttl);
}
/**
* Get a cached value
* @param key Key to retrieve from cache
* @returns Value
*/
public static async get<T = unknown>(key: string): Promise<T | undefined> {
return CacheBackend.cache.get(key) as T;
}
/**
* Delete a cached value
* @param key Key to delete from cache
* @returns Success
*/
public static async del(key: string) {
return CacheBackend.cache.del(key);
}
/**
* Initialize configured cache backend
*/
public static async init() {
CacheBackend.cache = createCache({
stores: [
new Keyv({
store: env.VALKEY_URI
? new KeyvValkey(env.VALKEY_URI)
: new CacheableMemory({ ttl: 3600000, lruSize: 5000 })
})
]
});
}
}

View File

@ -1,6 +1,8 @@
import { mkdir, readFile, rm, stat, unlink, writeFile } from 'fs/promises'; import { mkdir, readFile, rm, stat, unlink, writeFile } from 'fs/promises';
import { dirname, join, resolve } from 'path'; import { dirname, join, resolve } from 'path';
// TODO: implement S3
export class FileBackend { export class FileBackend {
static async fileExists(path: string | string[]) { static async fileExists(path: string | string[]) {
try { try {
@ -44,4 +46,8 @@ export class FileBackend {
private static filePath(path: string | string[]) { private static filePath(path: string | string[]) {
return resolve(Array.isArray(path) ? join(...path) : path); return resolve(Array.isArray(path) ? join(...path) : path);
} }
public static async init() {
// stub
}
} }

View File

@ -1,7 +1,7 @@
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
import { import {
type JWK, type JWK,
type KeyLike, type CryptoKey,
importPKCS8, importPKCS8,
importSPKI, importSPKI,
SignJWT, SignJWT,
@ -23,8 +23,8 @@ const ISSUER_EXPIRY = 365 * 24 * 60 * 60 * 1000;
const ISSUER_ROTATE = ISSUER_EXPIRY / 2; const ISSUER_ROTATE = ISSUER_EXPIRY / 2;
interface AvailableWebKey extends JsonKey { interface AvailableWebKey extends JsonKey {
privateKey: KeyLike; privateKey: CryptoKey;
publicKey: KeyLike; publicKey: CryptoKey;
publicKeyJWK: JWK; publicKeyJWK: JWK;
} }

View File

@ -17,7 +17,7 @@ import {
import { Emails, OAuth2InvitationEmail } from '$lib/server/email'; import { Emails, OAuth2InvitationEmail } from '$lib/server/email';
import { Uploads } from '$lib/server/upload'; import { Uploads } from '$lib/server/upload';
import { UserTokens, Users } from '$lib/server/users'; import { UserTokens, Users } from '$lib/server/users';
import type { PaginationMeta } from '$lib/types'; import type { OAuth2ClientInfo, PaginationMeta } from '$lib/types';
import { and, count, eq, like, or, sql } from 'drizzle-orm'; import { and, count, eq, like, or, sql } from 'drizzle-orm';
import { createLocalJWKSet, exportJWK, importJWK, jwtVerify, type JWK } from 'jose'; import { createLocalJWKSet, exportJWK, importJWK, jwtVerify, type JWK } from 'jose';
@ -409,9 +409,9 @@ export class OAuth2Clients {
// This prevents entering of arbitrary JSON data while also validating the key // This prevents entering of arbitrary JSON data while also validating the key
parsedKeys = await Promise.all( parsedKeys = await Promise.all(
preParseList.map(async (entry: JWK) => { preParseList.map(async (entry: JWK) => {
const imported = await importJWK(entry); const imported = await importJWK(entry, privateEnv.JWT_ALGORITHM);
const exported = await exportJWK(imported); const exported = await exportJWK(imported);
(['use', 'kid'] as (keyof JWK)[]).forEach((nKey) => { (['use', 'kid', 'alg', 'ext', 'key_ops'] as (keyof JWK)[]).forEach((nKey) => {
exported[nKey] = (entry[nKey] || undefined) as never; exported[nKey] = (entry[nKey] || undefined) as never;
}); });
return exported; return exported;
@ -423,7 +423,7 @@ export class OAuth2Clients {
(entry, index, array) => (entry, index, array) =>
array.findIndex((item) => JSON.stringify(item) === JSON.stringify(entry)) === index array.findIndex((item) => JSON.stringify(item) === JSON.stringify(entry)) === index
); );
} catch { } catch (e) {
throw new Error('Failed to parse JWKs'); throw new Error('Failed to parse JWKs');
} }
@ -491,7 +491,10 @@ export class OAuth2Clients {
} }
} }
static async authorizeClientInfo(client: OAuth2Client, scope: string[]) { static async authorizeClientInfo(
client: OAuth2Client,
scope: string[]
): Promise<OAuth2ClientInfo> {
const links = await OAuth2Clients.getClientUrls(client); const links = await OAuth2Clients.getClientUrls(client);
const filteredLinks = links const filteredLinks = links
.filter((link) => link.type !== 'redirect_uri') .filter((link) => link.type !== 'redirect_uri')

View File

@ -236,7 +236,7 @@ export class OAuth2AccessTokens {
) { ) {
const client = await OAuth2Clients.fetchById(clientId); const client = await OAuth2Clients.fetchById(clientId);
const user = userId != null ? await Users.getById(userId) : undefined; const user = userId != null ? await Users.getById(userId) : undefined;
const accessToken = CryptoUtils.generateString(64); const accessToken = CryptoUtils.generateString(128);
const scopes = (!Array.isArray(scope) ? OAuth2Clients.splitScope(scope) : scope).join(' '); const scopes = (!Array.isArray(scope) ? OAuth2Clients.splitScope(scope) : scope).join(' ');

View File

@ -0,0 +1,36 @@
import { browser } from '$app/environment';
import { locale, t } from '$lib/i18n';
import { derived, writable } from 'svelte/store';
export interface Message {
text: string;
type: 'success' | 'error';
actions?: {
label: string;
action: () => void;
}[];
}
export const messages = writable<Message[]>([]);
export const dismissMessage = (msg: Message) =>
messages.update((current) => current.filter((entry) => entry !== msg));
export const displayMessage = (msg: Message) =>
browser && messages.update((current) => [...current, msg]);
export const clearMessages = () => messages.set([]);
export const translateError = {
...derived([t, locale], ([tFn]) => (error: string, prefix = 'common.errors') => {
const [keyword, args] = error.split('(');
const argList = args
?.substring(0, args.length - 1)
.split(',')
.reduce<Record<string, string>>(
(data, value, i) => ({ ...data, [`arg${i}`]: value.trim() }),
{}
);
return tFn(`${prefix}.${keyword}`, argList);
})
};

View File

@ -3,7 +3,7 @@ export type RequiredPrivileges = (string | string[])[];
/** /**
* Check a list of privileges against a list of required privileges. * Check a list of privileges against a list of required privileges.
* @param list List of privileges * @param list List of privileges
* @param privileges OR logic, AND's arrays * @param privileges OR logic, use a nested array for AND
* @returns Satisfies privileges * @returns Satisfies privileges
*/ */
export const hasPrivileges = (list: string[], privileges: RequiredPrivileges) => export const hasPrivileges = (list: string[], privileges: RequiredPrivileges) =>

View File

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { env } from '$env/dynamic/public'; import { env } from '$env/dynamic/public';
import Messages from '$lib/components/Messages.svelte';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { forwardInitialTheme } from '$lib/theme-mode'; import { forwardInitialTheme } from '$lib/theme-mode';
import '../app.css'; import '../app.css';
@ -21,3 +22,5 @@
</svelte:head> </svelte:head>
{@render children?.()} {@render children?.()}
<Messages />

View File

@ -6,7 +6,6 @@
import FormSection from '$lib/components/form/FormSection.svelte'; import FormSection from '$lib/components/form/FormSection.svelte';
import FormWrapper from '$lib/components/form/FormWrapper.svelte'; import FormWrapper from '$lib/components/form/FormWrapper.svelte';
import Button from '$lib/components/Button.svelte'; import Button from '$lib/components/Button.svelte';
import Alert from '$lib/components/Alert.svelte';
import ViewColumn from '$lib/components/container/ColumnView.svelte'; import ViewColumn from '$lib/components/container/ColumnView.svelte';
import MainContainer from '$lib/components/container/MainContainer.svelte'; import MainContainer from '$lib/components/container/MainContainer.svelte';
import SplitView from '$lib/components/container/SplitView.svelte'; import SplitView from '$lib/components/container/SplitView.svelte';
@ -14,14 +13,16 @@
import type { SubmitFunction } from '@sveltejs/kit'; import type { SubmitFunction } from '@sveltejs/kit';
import AvatarCard from '$lib/components/avatar/AvatarCard.svelte'; import AvatarCard from '$lib/components/avatar/AvatarCard.svelte';
import AvatarModal from '$lib/components/avatar/AvatarModal.svelte'; import AvatarModal from '$lib/components/avatar/AvatarModal.svelte';
import { writable } from 'svelte/store'; import { get, writable } from 'svelte/store';
import { env } from '$env/dynamic/public'; import { env } from '$env/dynamic/public';
import FormErrors from '$lib/components/form/FormErrors.svelte';
import TitleRow from '$lib/components/container/TitleRow.svelte'; import TitleRow from '$lib/components/container/TitleRow.svelte';
import ActionButton from '$lib/components/ActionButton.svelte'; import ActionButton from '$lib/components/ActionButton.svelte';
import ButtonRow from '$lib/components/container/ButtonRow.svelte'; import ButtonRow from '$lib/components/container/ButtonRow.svelte';
import ThemeButton from '$lib/components/ThemeButton.svelte'; import ThemeButton from '$lib/components/ThemeButton.svelte';
import { hasPrivileges } from '$lib/utils'; import { hasPrivileges } from '$lib/utils';
import { clearMessages, displayMessage } from '$lib/stores/messages.store';
import { popupFormErrors } from '$lib/form-errors';
import { onNavigate } from '$app/navigation';
interface Props { interface Props {
data: PageData; data: PageData;
@ -30,8 +31,6 @@
let { data, form }: Props = $props(); let { data, form }: Props = $props();
let internalErrors: string[] = $state([]);
let errors = $derived([...internalErrors, ...(form?.errors?.length ? form.errors : [])]);
let adminButton = $derived(hasPrivileges(data.privileges, ['admin', 'self:oauth2'])); let adminButton = $derived(hasPrivileges(data.privileges, ['admin', 'self:oauth2']));
let usernameRef: HTMLInputElement = $state()!; let usernameRef: HTMLInputElement = $state()!;
@ -39,12 +38,15 @@
let showAvatarModal = writable(false); let showAvatarModal = writable(false);
const enhanceFn: SubmitFunction = ({ formData, cancel }) => { const enhanceFn: SubmitFunction = ({ formData, cancel }) => {
internalErrors.length = 0; clearMessages();
const pwd = formData.get('newPassword') as string; const pwd = formData.get('newPassword') as string;
const repeat = formData.get('repeatPassword') as string; const repeat = formData.get('repeatPassword') as string;
if (pwd && pwd !== repeat) { if (pwd && pwd !== repeat) {
internalErrors.push('passwordMismatch'); displayMessage({
type: 'error',
text: $t('account.errors.passwordMismatch')
});
return cancel(); return cancel();
} }
@ -61,6 +63,14 @@
} }
}; };
}; };
$effect(() => popupFormErrors(form, 'account.errors'));
$effect(() => {
if (form?.success) {
displayMessage({ type: 'success', text: get(t)('account.changeSuccess') });
}
});
onNavigate(() => clearMessages());
</script> </script>
<svelte:head> <svelte:head>
@ -87,9 +97,6 @@
<ViewColumn> <ViewColumn>
<form action="?/update" method="POST" use:enhance={enhanceFn}> <form action="?/update" method="POST" use:enhance={enhanceFn}>
<FormWrapper> <FormWrapper>
{#if form?.success}<Alert type="success">{$t('account.changeSuccess')}</Alert>{/if}
<FormErrors {errors} prefix="account.errors" />
{#if form?.otpRequired} {#if form?.otpRequired}
<!-- Two-factor code request --> <!-- Two-factor code request -->
<FormSection title={$t('account.login.otp')}> <FormSection title={$t('account.login.otp')}>

View File

@ -10,7 +10,9 @@
import OAuth2ScopesCard from '$lib/components/oauth2/OAuth2ScopesCard.svelte'; import OAuth2ScopesCard from '$lib/components/oauth2/OAuth2ScopesCard.svelte';
import FormWrapper from '$lib/components/form/FormWrapper.svelte'; import FormWrapper from '$lib/components/form/FormWrapper.svelte';
import FormControl from '$lib/components/form/FormControl.svelte'; import FormControl from '$lib/components/form/FormControl.svelte';
import FormErrors from '$lib/components/form/FormErrors.svelte'; import { onNavigate } from '$app/navigation';
import { popupFormErrors } from '$lib/form-errors';
import { clearMessages } from '$lib/stores/messages.store';
interface Props { interface Props {
data: PageData; data: PageData;
@ -18,6 +20,9 @@
} }
let { data, form }: Props = $props(); let { data, form }: Props = $props();
$effect(() => popupFormErrors(form, 'oauth2.errors'));
onNavigate(() => clearMessages());
</script> </script>
<svelte:head> <svelte:head>
@ -61,7 +66,6 @@
<form action="" method="POST" use:enhance> <form action="" method="POST" use:enhance>
<FormWrapper> <FormWrapper>
<FormErrors errors={form?.errors || []} prefix="oauth2.errors" />
<FormControl> <FormControl>
<label for="form-code">{$t('oauth2.device.deviceCode')}</label> <label for="form-code">{$t('oauth2.device.deviceCode')}</label>
<input type="text" autocomplete="off" name="code" id="form-code" /> <input type="text" autocomplete="off" name="code" id="form-code" />

View File

@ -1,19 +1,15 @@
import { env } from '$env/dynamic/public';
import { Audit } from '$lib/server/audit/audit.js'; import { Audit } from '$lib/server/audit/audit.js';
import { AuditAction } from '$lib/server/audit/types.js'; import { AuditAction } from '$lib/server/audit/types.js';
import { Challenge } from '$lib/server/challenge.js'; import { Challenge } from '$lib/server/challenge.js';
import { Changesets } from '$lib/server/changesets.js'; import { Changesets } from '$lib/server/changesets.js';
import { OAuth2Clients } from '$lib/server/oauth2/index.js';
import { Users } from '$lib/server/users/index.js'; import { Users } from '$lib/server/users/index.js';
import { TimeOTP } from '$lib/server/users/totp.js'; import { TimeOTP } from '$lib/server/users/totp.js';
import type { OAuth2ClientInfo } from '$lib/types';
import { error, fail, redirect, type Actions } from '@sveltejs/kit'; import { error, fail, redirect, type Actions } from '@sveltejs/kit';
import { RateLimiter } from 'sveltekit-rate-limiter/server'; import { RateLimiter } from 'sveltekit-rate-limiter/server';
interface LoginParams {
email: string;
password: string;
challenge: string;
otpCode: string;
}
interface LoginChallenge { interface LoginChallenge {
email: string; email: string;
} }
@ -132,6 +128,8 @@ export const actions = {
} as Actions; } as Actions;
export const load = async ({ locals, url, ...event }) => { export const load = async ({ locals, url, ...event }) => {
let clientInfo: OAuth2ClientInfo | undefined;
if (url.searchParams.has('redirectTo')) { if (url.searchParams.has('redirectTo')) {
// Check that the redirect URL is a local path // Check that the redirect URL is a local path
if (!url.searchParams.get('redirectTo')?.startsWith('/')) { if (!url.searchParams.get('redirectTo')?.startsWith('/')) {
@ -142,6 +140,19 @@ export const load = async ({ locals, url, ...event }) => {
if (locals.session.data?.user) { if (locals.session.data?.user) {
return redirect(301, url.searchParams.get('redirectTo') || '/'); return redirect(301, url.searchParams.get('redirectTo') || '/');
} }
// Take client ID from the redirect if applicable
const parsedParams = new URL(url.searchParams.get('redirectTo') as string, env.PUBLIC_URL);
const clientId = parsedParams.searchParams.get('client_id') as string;
if (clientId) {
const client = await OAuth2Clients.fetchById(clientId);
if (!client) {
return redirect(301, '/login');
}
// Displays client info on the login page to make the redirect less confusing
clientInfo = await OAuth2Clients.authorizeClientInfo(client, []);
}
} }
// Activation routine // Activation routine
@ -165,6 +176,7 @@ export const load = async ({ locals, url, ...event }) => {
} }
return { return {
activated: null activated: null,
clientInfo
}; };
}; };

View File

@ -12,6 +12,7 @@
import ButtonRow from '$lib/components/container/ButtonRow.svelte'; import ButtonRow from '$lib/components/container/ButtonRow.svelte';
import TitleRow from '$lib/components/container/TitleRow.svelte'; import TitleRow from '$lib/components/container/TitleRow.svelte';
import ThemeButton from '$lib/components/ThemeButton.svelte'; import ThemeButton from '$lib/components/ThemeButton.svelte';
import OAuth2LoginClientCard from '$lib/components/oauth2/OAuth2LoginClientCard.svelte';
interface Props { interface Props {
data: PageData; data: PageData;
@ -26,7 +27,7 @@
</svelte:head> </svelte:head>
<SideContainer> <SideContainer>
<TitleRow> <TitleRow mobileBreak={false}>
<h1>{env.PUBLIC_SITE_NAME}</h1> <h1>{env.PUBLIC_SITE_NAME}</h1>
<ThemeButton /> <ThemeButton />
</TitleRow> </TitleRow>
@ -56,6 +57,10 @@
</FormSection> </FormSection>
{:else} {:else}
<!-- Normal login --> <!-- Normal login -->
{#if data.clientInfo}
<OAuth2LoginClientCard client={data.clientInfo} />
{/if}
<FormControl> <FormControl>
<label for="login-email">{$t('account.login.email')}</label> <label for="login-email">{$t('account.login.email')}</label>
<input id="login-email" name="email" value={form?.email ?? ''} autocomplete="username" /> <input id="login-email" name="email" value={form?.email ?? ''} autocomplete="username" />

View File

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { onNavigate } from '$app/navigation';
import { page } from '$app/state'; import { page } from '$app/state';
import { env } from '$env/dynamic/public'; import { env } from '$env/dynamic/public';
import Alert from '$lib/components/Alert.svelte'; import Alert from '$lib/components/Alert.svelte';
@ -8,9 +9,10 @@
import ColumnView from '$lib/components/container/ColumnView.svelte'; import ColumnView from '$lib/components/container/ColumnView.svelte';
import SideContainer from '$lib/components/container/SideContainer.svelte'; import SideContainer from '$lib/components/container/SideContainer.svelte';
import FormControl from '$lib/components/form/FormControl.svelte'; import FormControl from '$lib/components/form/FormControl.svelte';
import FormErrors from '$lib/components/form/FormErrors.svelte';
import FormWrapper from '$lib/components/form/FormWrapper.svelte'; import FormWrapper from '$lib/components/form/FormWrapper.svelte';
import { popupFormErrors } from '$lib/form-errors';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { clearMessages, displayMessage } from '$lib/stores/messages.store';
import type { ActionData, PageData, SubmitFunction } from './$types'; import type { ActionData, PageData, SubmitFunction } from './$types';
interface Props { interface Props {
@ -20,21 +22,22 @@
let { data, form }: Props = $props(); let { data, form }: Props = $props();
let internalErrors: string[] = $state([]);
let submitted = $state(false); let submitted = $state(false);
let errors = $derived([...internalErrors, ...(form?.errors?.length ? form.errors : [])]);
let actionUrl = $derived( let actionUrl = $derived(
data.setter ? `?/setPassword&token=${page.url.searchParams.get('token')}` : '?/sendEmail' data.setter ? `?/setPassword&token=${page.url.searchParams.get('token')}` : '?/sendEmail'
); );
let pageTitle = $derived(data.setter ? 'setNewPassword' : 'resetPassword'); let pageTitle = $derived(data.setter ? 'setNewPassword' : 'resetPassword');
const enhanceFn: SubmitFunction = ({ formData, cancel }) => { const enhanceFn: SubmitFunction = ({ formData, cancel }) => {
internalErrors.length = 0; clearMessages();
const pwd = formData.get('newPassword') as string; const pwd = formData.get('newPassword') as string;
const repeat = formData.get('repeatPassword') as string; const repeat = formData.get('repeatPassword') as string;
if (pwd && pwd !== repeat) { if (pwd && pwd !== repeat) {
internalErrors.push('passwordMismatch'); displayMessage({
type: 'error',
text: $t('account.errors.passwordMismatch')
});
return cancel(); return cancel();
} }
@ -45,6 +48,9 @@
submitted = false; submitted = false;
}; };
}; };
$effect(() => popupFormErrors(form, 'account.errors'));
onNavigate(() => clearMessages());
</script> </script>
<svelte:head> <svelte:head>
@ -65,8 +71,6 @@
{:else} {:else}
<form action={actionUrl} method="POST" use:enhance={enhanceFn}> <form action={actionUrl} method="POST" use:enhance={enhanceFn}>
<FormWrapper> <FormWrapper>
<FormErrors {errors} prefix="account.errors" />
{#if data.setter} {#if data.setter}
<FormControl> <FormControl>
<label for="password-newPassword">{$t('account.newPassword')}</label> <label for="password-newPassword">{$t('account.newPassword')}</label>

View File

@ -11,9 +11,11 @@
import Button from '$lib/components/Button.svelte'; import Button from '$lib/components/Button.svelte';
import FormSection from '$lib/components/form/FormSection.svelte'; import FormSection from '$lib/components/form/FormSection.svelte';
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import FormErrors from '$lib/components/form/FormErrors.svelte';
import ButtonRow from '$lib/components/container/ButtonRow.svelte'; import ButtonRow from '$lib/components/container/ButtonRow.svelte';
import HCaptcha from '$lib/components/form/HCaptcha.svelte'; import HCaptcha from '$lib/components/form/HCaptcha.svelte';
import { onNavigate } from '$app/navigation';
import { popupFormErrors } from '$lib/form-errors';
import { clearMessages, displayMessage } from '$lib/stores/messages.store';
interface Props { interface Props {
data: PageData; data: PageData;
@ -22,17 +24,18 @@
let { data, form }: Props = $props(); let { data, form }: Props = $props();
let internalErrors: string[] = $state([]);
let submitted = $state(false); let submitted = $state(false);
let errors = $derived([...internalErrors, ...(form?.errors?.length ? form.errors : [])]);
const enhanceFn: SubmitFunction = ({ formData, cancel }) => { const enhanceFn: SubmitFunction = ({ formData, cancel }) => {
internalErrors.length = 0; clearMessages();
const pwd = formData.get('newPassword') as string; const pwd = formData.get('newPassword') as string;
const repeat = formData.get('repeatPassword') as string; const repeat = formData.get('repeatPassword') as string;
if (pwd && pwd !== repeat) { if (pwd && pwd !== repeat) {
internalErrors.push('passwordMismatch'); displayMessage({
type: 'error',
text: $t('account.errors.passwordMismatch')
});
return cancel(); return cancel();
} }
@ -43,6 +46,9 @@
submitted = false; submitted = false;
}; };
}; };
$effect(() => popupFormErrors(form, 'account.errors'));
onNavigate(() => clearMessages());
</script> </script>
<svelte:head> <svelte:head>
@ -63,8 +69,6 @@
{:else} {:else}
<form action="" method="POST" use:enhance={enhanceFn}> <form action="" method="POST" use:enhance={enhanceFn}>
<FormWrapper> <FormWrapper>
<FormErrors {errors} prefix="account.errors" />
<FormSection required> <FormSection required>
<FormControl> <FormControl>
<label for="register-username">{$t('account.username')}</label> <label for="register-username">{$t('account.username')}</label>

View File

@ -11,7 +11,8 @@
let { data, children }: Props = $props(); let { data, children }: Props = $props();
let container: HTMLElement = $state(); let container: HTMLElement | undefined = $state();
let mobileOpen = $state(false);
// Reset container scroll. // Reset container scroll.
afterNavigate(async ({ type }) => { afterNavigate(async ({ type }) => {
@ -22,10 +23,10 @@
</script> </script>
<div class="admin-wrapper"> <div class="admin-wrapper">
<AdminHeader user={data.user} /> <AdminHeader user={data.user} bind:mobileOpen />
<div class="sidebar-wrapper"> <div class="sidebar-wrapper">
<AdminSidebar user={data.user} /> <AdminSidebar user={data.user} bind:mobileOpen />
<main bind:this={container}> <main bind:this={container}>
{@render children?.()} {@render children?.()}

View File

@ -1,4 +1,13 @@
<script lang="ts"> <script lang="ts">
import { t } from '$lib/i18n';
import { page } from '$app/state';
import { get, writable } from 'svelte/store';
import { env } from '$env/dynamic/public';
import { onNavigate } from '$app/navigation';
import { popupFormErrors } from '$lib/form-errors';
import type { ActionData, PageData } from './$types';
import { OAUTH2_MAX_REDIRECTS, OAUTH2_MAX_URLS } from '$lib/constants';
import { displayMessage, clearMessages } from '$lib/stores/messages.store';
import Alert from '$lib/components/Alert.svelte'; import Alert from '$lib/components/Alert.svelte';
import Button from '$lib/components/Button.svelte'; import Button from '$lib/components/Button.svelte';
import AvatarCard from '$lib/components/avatar/AvatarCard.svelte'; import AvatarCard from '$lib/components/avatar/AvatarCard.svelte';
@ -9,14 +18,7 @@
import FormControl from '$lib/components/form/FormControl.svelte'; import FormControl from '$lib/components/form/FormControl.svelte';
import FormSection from '$lib/components/form/FormSection.svelte'; import FormSection from '$lib/components/form/FormSection.svelte';
import FormWrapper from '$lib/components/form/FormWrapper.svelte'; import FormWrapper from '$lib/components/form/FormWrapper.svelte';
import FormErrors from '$lib/components/form/FormErrors.svelte';
import ActionButton from '$lib/components/ActionButton.svelte'; import ActionButton from '$lib/components/ActionButton.svelte';
import type { ActionData, PageData } from './$types';
import { t } from '$lib/i18n';
import { page } from '$app/state';
import { writable } from 'svelte/store';
import { env } from '$env/dynamic/public';
import { OAUTH2_MAX_REDIRECTS, OAUTH2_MAX_URLS } from '$lib/constants';
interface Props { interface Props {
data: PageData; data: PageData;
@ -47,6 +49,14 @@
let uuidPrefix = $derived(data.details.client_id.split('-')[0] + ':'); let uuidPrefix = $derived(data.details.client_id.split('-')[0] + ':');
const jwkPlaceholder = JSON.stringify([{ kty: 'RSA', n: '...', e: 'AQAB' }]); const jwkPlaceholder = JSON.stringify([{ kty: 'RSA', n: '...', e: 'AQAB' }]);
$effect(() => popupFormErrors(form, 'admin.oauth2.errors'));
$effect(() => {
if (form?.errors && !form.errors.length) {
displayMessage({ type: 'success', text: get(t)('admin.success') });
}
});
onNavigate(() => clearMessages());
</script> </script>
<svelte:head> <svelte:head>
@ -76,10 +86,6 @@
<form action="?/update" method="POST"> <form action="?/update" method="POST">
<FormWrapper> <FormWrapper>
{#if form?.errors && !form.errors.length}
<Alert type="success">{$t('admin.oauth2.success')}</Alert>
{/if}
<FormErrors errors={form?.errors || []} prefix="admin.oauth2.errors" />
<FormSection> <FormSection>
<FormControl> <FormControl>
<label for="client-title">{$t('admin.oauth2.clientTitle')}</label> <label for="client-title">{$t('admin.oauth2.clientTitle')}</label>

View File

@ -2,18 +2,23 @@
import FormControl from '$lib/components/form/FormControl.svelte'; import FormControl from '$lib/components/form/FormControl.svelte';
import FormSection from '$lib/components/form/FormSection.svelte'; import FormSection from '$lib/components/form/FormSection.svelte';
import FormWrapper from '$lib/components/form/FormWrapper.svelte'; import FormWrapper from '$lib/components/form/FormWrapper.svelte';
import FormErrors from '$lib/components/form/FormErrors.svelte';
import Button from '$lib/components/Button.svelte'; import Button from '$lib/components/Button.svelte';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import type { ActionData } from './$types'; import type { ActionData } from './$types';
import { env } from '$env/dynamic/public'; import { env } from '$env/dynamic/public';
import SplitView from '$lib/components/container/SplitView.svelte'; import SplitView from '$lib/components/container/SplitView.svelte';
import { onNavigate } from '$app/navigation';
import { popupFormErrors } from '$lib/form-errors';
import { clearMessages } from '$lib/stores/messages.store';
interface Props { interface Props {
form: ActionData; form: ActionData;
} }
let { form }: Props = $props(); let { form }: Props = $props();
$effect(() => popupFormErrors(form, 'admin.oauth2.errors'));
onNavigate(() => clearMessages());
</script> </script>
<svelte:head> <svelte:head>
@ -25,8 +30,6 @@
<SplitView> <SplitView>
<form action="" method="POST"> <form action="" method="POST">
<FormWrapper> <FormWrapper>
<FormErrors errors={form?.errors || []} prefix="admin.oauth2.errors" />
<FormSection required> <FormSection required>
<FormControl> <FormControl>
<label for="form-title">{$t('admin.oauth2.clientTitle')}</label> <label for="form-title">{$t('admin.oauth2.clientTitle')}</label>

View File

@ -9,10 +9,12 @@
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import type { ActionData, PageData } from './$types'; import type { ActionData, PageData } from './$types';
import AdminPrivilegesSelect from '$lib/components/admin/AdminPrivilegesSelect.svelte'; import AdminPrivilegesSelect from '$lib/components/admin/AdminPrivilegesSelect.svelte';
import FormErrors from '$lib/components/form/FormErrors.svelte';
import { env } from '$env/dynamic/public'; import { env } from '$env/dynamic/public';
import ActionButton from '$lib/components/ActionButton.svelte'; import ActionButton from '$lib/components/ActionButton.svelte';
import Alert from '$lib/components/Alert.svelte'; import { onNavigate } from '$app/navigation';
import { popupFormErrors } from '$lib/form-errors';
import { displayMessage, clearMessages } from '$lib/stores/messages.store';
import { get } from 'svelte/store';
interface Props { interface Props {
data: PageData; data: PageData;
@ -20,6 +22,14 @@
} }
let { data, form }: Props = $props(); let { data, form }: Props = $props();
$effect(() => popupFormErrors(form, 'admin.users.errors'));
$effect(() => {
if (form?.errors && !form.errors.length) {
displayMessage({ type: 'success', text: get(t)('admin.success') });
}
});
onNavigate(() => clearMessages());
</script> </script>
<svelte:head> <svelte:head>
@ -41,11 +51,6 @@
</ColumnView> </ColumnView>
</AvatarCard> </AvatarCard>
{#if form?.errors && !form.errors.length}
<Alert type="success">{$t('admin.oauth2.success')}</Alert>
{/if}
<FormErrors errors={form?.errors || []} prefix="admin.users.errors" />
<form action="?/update" method="POST"> <form action="?/update" method="POST">
<FormWrapper> <FormWrapper>
<FormSection> <FormSection>