feat(phase2): OAuth/Authentik integration + drag-and-drop reordering
- Add OIDC/OAuth2 login via openid-client with PKCE flow - Auto-provision OAuth users with group mapping - Conditional login page (OAuth/local/both based on auth mode) - Admin OAuth test connection button - Install svelte-dnd-action for board editor DnD - Draggable sections and widgets with cross-section moves - Reorder APIs with atomic Prisma transactions - Visual drag handles and drop zone indicators
This commit is contained in:
@@ -11,6 +11,12 @@ APP_PORT=3000
|
|||||||
APP_HOST="0.0.0.0"
|
APP_HOST="0.0.0.0"
|
||||||
APP_URL="http://localhost:3000"
|
APP_URL="http://localhost:3000"
|
||||||
|
|
||||||
|
# OAuth / OIDC (optional — configure here or in Admin > Settings)
|
||||||
|
OAUTH_CLIENT_ID=""
|
||||||
|
OAUTH_CLIENT_SECRET=""
|
||||||
|
OAUTH_DISCOVERY_URL=""
|
||||||
|
OAUTH_REDIRECT_URI=""
|
||||||
|
|
||||||
# Guest mode (true = allow unauthenticated dashboard access)
|
# Guest mode (true = allow unauthenticated dashboard access)
|
||||||
GUEST_MODE="true"
|
GUEST_MODE="true"
|
||||||
|
|
||||||
|
|||||||
Generated
+63
-52
@@ -17,8 +17,10 @@
|
|||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"lucide-svelte": "^0.469.0",
|
"lucide-svelte": "^0.469.0",
|
||||||
"node-cron": "^3.0.3",
|
"node-cron": "^3.0.3",
|
||||||
|
"openid-client": "^6.8.2",
|
||||||
"simple-icons": "^13.0.0",
|
"simple-icons": "^13.0.0",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.0.0",
|
||||||
|
"svelte-dnd-action": "^0.9.69",
|
||||||
"sveltekit-superforms": "^2.22.0",
|
"sveltekit-superforms": "^2.22.0",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"zod": "^3.24.0"
|
"zod": "^3.24.0"
|
||||||
@@ -3439,6 +3441,14 @@
|
|||||||
"@sideway/pinpoint": "^2.0.0"
|
"@sideway/pinpoint": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jose": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -4023,12 +4033,32 @@
|
|||||||
"integrity": "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==",
|
"integrity": "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/oauth4webapi": {
|
||||||
|
"version": "3.8.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz",
|
||||||
|
"integrity": "sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ohash": {
|
"node_modules/ohash": {
|
||||||
"version": "2.0.11",
|
"version": "2.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
|
||||||
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
|
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/openid-client": {
|
||||||
|
"version": "6.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.2.tgz",
|
||||||
|
"integrity": "sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA==",
|
||||||
|
"dependencies": {
|
||||||
|
"jose": "^6.1.3",
|
||||||
|
"oauth4webapi": "^3.8.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/optionator": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
@@ -4887,6 +4917,14 @@
|
|||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/svelte-dnd-action": {
|
||||||
|
"version": "0.9.69",
|
||||||
|
"resolved": "https://registry.npmjs.org/svelte-dnd-action/-/svelte-dnd-action-0.9.69.tgz",
|
||||||
|
"integrity": "sha512-NAmSOH7htJoYraTQvr+q5whlIuVoq88vEuHr4NcFgscDRUxfWPPxgie2OoxepBCQCikrXZV4pqV86aun60wVyw==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"svelte": ">=3.23.0 || ^5.0.0-next.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/svelte-eslint-parser": {
|
"node_modules/svelte-eslint-parser": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.6.0.tgz",
|
||||||
@@ -5249,7 +5287,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"aix"
|
"aix"
|
||||||
@@ -5265,7 +5302,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
@@ -5281,7 +5317,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
@@ -5297,7 +5332,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
@@ -5313,7 +5347,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
@@ -5329,7 +5362,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
@@ -5345,7 +5377,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
@@ -5361,7 +5392,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
@@ -5377,7 +5407,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -5393,7 +5422,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -5409,7 +5437,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -5425,7 +5452,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -5441,7 +5467,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"mips64el"
|
"mips64el"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -5457,7 +5482,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -5473,7 +5497,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -5489,7 +5512,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -5505,7 +5527,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -5521,7 +5542,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"netbsd"
|
"netbsd"
|
||||||
@@ -5537,7 +5557,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"netbsd"
|
"netbsd"
|
||||||
@@ -5553,7 +5572,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"openbsd"
|
"openbsd"
|
||||||
@@ -5569,7 +5587,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"openbsd"
|
"openbsd"
|
||||||
@@ -5585,7 +5602,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"openharmony"
|
"openharmony"
|
||||||
@@ -5601,7 +5617,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"sunos"
|
"sunos"
|
||||||
@@ -5617,7 +5632,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
@@ -5633,7 +5647,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
@@ -5649,7 +5662,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
@@ -8304,6 +8316,11 @@
|
|||||||
"@sideway/pinpoint": "^2.0.0"
|
"@sideway/pinpoint": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"jose": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="
|
||||||
|
},
|
||||||
"js-tokens": {
|
"js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -8672,12 +8689,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"oauth4webapi": {
|
||||||
|
"version": "3.8.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz",
|
||||||
|
"integrity": "sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg=="
|
||||||
|
},
|
||||||
"ohash": {
|
"ohash": {
|
||||||
"version": "2.0.11",
|
"version": "2.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
|
||||||
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
|
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"openid-client": {
|
||||||
|
"version": "6.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.2.tgz",
|
||||||
|
"integrity": "sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA==",
|
||||||
|
"requires": {
|
||||||
|
"jose": "^6.1.3",
|
||||||
|
"oauth4webapi": "^3.8.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"optionator": {
|
"optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
@@ -9188,6 +9219,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"svelte-dnd-action": {
|
||||||
|
"version": "0.9.69",
|
||||||
|
"resolved": "https://registry.npmjs.org/svelte-dnd-action/-/svelte-dnd-action-0.9.69.tgz",
|
||||||
|
"integrity": "sha512-NAmSOH7htJoYraTQvr+q5whlIuVoq88vEuHr4NcFgscDRUxfWPPxgie2OoxepBCQCikrXZV4pqV86aun60wVyw==",
|
||||||
|
"requires": {}
|
||||||
|
},
|
||||||
"svelte-eslint-parser": {
|
"svelte-eslint-parser": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.6.0.tgz",
|
||||||
@@ -9383,182 +9420,156 @@
|
|||||||
"version": "0.27.4",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
||||||
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
|
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"@esbuild/android-arm": {
|
"@esbuild/android-arm": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
|
||||||
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
|
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"@esbuild/android-arm64": {
|
"@esbuild/android-arm64": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
|
||||||
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
|
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"@esbuild/android-x64": {
|
"@esbuild/android-x64": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
|
||||||
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
|
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"@esbuild/darwin-arm64": {
|
"@esbuild/darwin-arm64": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
|
||||||
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
|
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"@esbuild/darwin-x64": {
|
"@esbuild/darwin-x64": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
|
||||||
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
|
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"@esbuild/freebsd-arm64": {
|
"@esbuild/freebsd-arm64": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
|
||||||
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
|
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"@esbuild/freebsd-x64": {
|
"@esbuild/freebsd-x64": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
|
||||||
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
|
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"@esbuild/linux-arm": {
|
"@esbuild/linux-arm": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
|
||||||
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
|
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"@esbuild/linux-arm64": {
|
"@esbuild/linux-arm64": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
|
||||||
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
|
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"@esbuild/linux-ia32": {
|
"@esbuild/linux-ia32": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
|
||||||
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
|
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"@esbuild/linux-loong64": {
|
"@esbuild/linux-loong64": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
|
||||||
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
|
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"@esbuild/linux-mips64el": {
|
"@esbuild/linux-mips64el": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
|
||||||
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
|
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"@esbuild/linux-ppc64": {
|
"@esbuild/linux-ppc64": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
|
||||||
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
|
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"@esbuild/linux-riscv64": {
|
"@esbuild/linux-riscv64": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
|
||||||
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
|
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"@esbuild/linux-s390x": {
|
"@esbuild/linux-s390x": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
|
||||||
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
|
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"@esbuild/linux-x64": {
|
"@esbuild/linux-x64": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
|
||||||
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
|
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"@esbuild/netbsd-arm64": {
|
"@esbuild/netbsd-arm64": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
|
||||||
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
|
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"@esbuild/netbsd-x64": {
|
"@esbuild/netbsd-x64": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
|
||||||
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
|
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"@esbuild/openbsd-arm64": {
|
"@esbuild/openbsd-arm64": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
|
||||||
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
|
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"@esbuild/openbsd-x64": {
|
"@esbuild/openbsd-x64": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
|
||||||
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
|
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"@esbuild/openharmony-arm64": {
|
"@esbuild/openharmony-arm64": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
|
||||||
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
|
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"@esbuild/sunos-x64": {
|
"@esbuild/sunos-x64": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
|
||||||
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
|
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"@esbuild/win32-arm64": {
|
"@esbuild/win32-arm64": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
|
||||||
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
|
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"@esbuild/win32-ia32": {
|
"@esbuild/win32-ia32": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
|
||||||
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
|
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"@esbuild/win32-x64": {
|
"@esbuild/win32-x64": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
|
||||||
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
|
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
|
||||||
"dev": true,
|
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"esbuild": {
|
"esbuild": {
|
||||||
|
|||||||
@@ -30,8 +30,10 @@
|
|||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"lucide-svelte": "^0.469.0",
|
"lucide-svelte": "^0.469.0",
|
||||||
"node-cron": "^3.0.3",
|
"node-cron": "^3.0.3",
|
||||||
|
"openid-client": "^6.8.2",
|
||||||
"simple-icons": "^13.0.0",
|
"simple-icons": "^13.0.0",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.0.0",
|
||||||
|
"svelte-dnd-action": "^0.9.69",
|
||||||
"sveltekit-superforms": "^2.22.0",
|
"sveltekit-superforms": "^2.22.0",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"zod": "^3.24.0"
|
"zod": "^3.24.0"
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
# Feature Context: Phase 2 — Enhanced Features
|
# Feature Context: Phase 2 — Enhanced Features
|
||||||
|
|
||||||
## Current State
|
## Current State
|
||||||
MVP is complete and merged to master. All build/test/lint passes. 151 files, 115 tests.
|
|
||||||
Starting Phase 2 enhanced features on a new feature branch.
|
Phase 1 (OAuth/Authentik Integration) and Phase 2 (DnD) are complete.
|
||||||
|
Installed `openid-client` v6.8.2. OAuth login flow uses PKCE and issues local JWT tokens.
|
||||||
|
Login page conditionally shows OAuth button and/or local form based on `authMode` SystemSettings.
|
||||||
|
Admin settings page has a working "Test Connection" button for OAuth configuration.
|
||||||
|
|
||||||
## Temporary Workarounds
|
## Temporary Workarounds
|
||||||
- None yet
|
- None yet
|
||||||
@@ -15,7 +18,16 @@ Starting Phase 2 enhanced features on a new feature branch.
|
|||||||
- Phase 5 (Integration) depends on all prior phases
|
- Phase 5 (Integration) depends on all prior phases
|
||||||
|
|
||||||
## Implementation Notes
|
## Implementation Notes
|
||||||
- Big Bang strategy: intermediate phases may not build. Phase 5 is the convergence phase.
|
- Big Bang strategy: intermediate phases may not build. Phase 6 is the convergence phase.
|
||||||
- OAuth uses `openid-client` (already installed in MVP dependencies)
|
- OAuth uses `openid-client` (already installed in MVP dependencies)
|
||||||
- DnD uses `svelte-dnd-action` (needs to be installed)
|
- DnD uses `svelte-dnd-action` (installed in Phase 2)
|
||||||
- New widget types extend the existing Widget model's `type` and `config` JSON fields
|
- New widget types extend the existing Widget model's `type` and `config` JSON fields
|
||||||
|
|
||||||
|
## Phase 2 (DnD) — Completed
|
||||||
|
- Installed `svelte-dnd-action` package
|
||||||
|
- Created `DraggableBoard.svelte`, `DraggableSection.svelte`, `DraggableWidget.svelte` component hierarchy
|
||||||
|
- Board edit page now uses DnD for section and widget reordering (including cross-section widget moves)
|
||||||
|
- Added `PUT /api/boards/[id]/reorder` and `PUT /api/boards/[id]/sections/[sid]/reorder` endpoints
|
||||||
|
- Extended `boardService.ts` with `reorderSections()`, `reorderWidgets()`, `moveWidget()` using Prisma transactions
|
||||||
|
- Visual drag handles (grip dots) and dashed drop zone indicators added via Tailwind
|
||||||
|
- Edit page actions (add/delete section/widget) use `invalidateAll()` for data refresh; DnD uses optimistic fetch
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Add OAuth/Authentik integration, drag-and-drop reordering, localization (EN/RU),
|
|||||||
|
|
||||||
## Phases
|
## Phases
|
||||||
|
|
||||||
- [ ] Phase 1: OAuth/Authentik Integration [fullstack] → [subplan](./phase-1-oauth.md)
|
- [x] Phase 1: OAuth/Authentik Integration [fullstack] → [subplan](./phase-1-oauth.md)
|
||||||
- [ ] Phase 2: Drag-and-Drop Reordering [frontend] → [subplan](./phase-2-dnd.md)
|
- [ ] Phase 2: Drag-and-Drop Reordering [frontend] → [subplan](./phase-2-dnd.md)
|
||||||
- [ ] Phase 3: Localization EN/RU [fullstack] → [subplan](./phase-3-localization.md)
|
- [ ] Phase 3: Localization EN/RU [fullstack] → [subplan](./phase-3-localization.md)
|
||||||
- [ ] Phase 4: Additional Widget Types [fullstack] → [subplan](./phase-4-widgets.md)
|
- [ ] Phase 4: Additional Widget Types [fullstack] → [subplan](./phase-4-widgets.md)
|
||||||
@@ -30,8 +30,8 @@ Add OAuth/Authentik integration, drag-and-drop reordering, localization (EN/RU),
|
|||||||
|
|
||||||
| Phase | Domain | Status | Review | Build | Committed |
|
| Phase | Domain | Status | Review | Build | Committed |
|
||||||
|-------|--------|--------|--------|-------|-----------|
|
|-------|--------|--------|--------|-------|-----------|
|
||||||
| Phase 1: OAuth | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
| Phase 1: OAuth | fullstack | Done | ⬜ | ⬜ | ⬜ |
|
||||||
| Phase 2: DnD | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
| Phase 2: DnD | frontend | Done | ⬜ | ⬜ | ⬜ |
|
||||||
| Phase 3: Localization | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
| Phase 3: Localization | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||||
| Phase 4: Widgets | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
| Phase 4: Widgets | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||||
| Phase 5: Access Control | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
| Phase 5: Access Control | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Phase 1: OAuth/Authentik Integration
|
# Phase 1: OAuth/Authentik Integration
|
||||||
|
|
||||||
**Status:** ⬜ Not Started
|
**Status:** ✅ Complete
|
||||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
**Domain:** fullstack
|
**Domain:** fullstack
|
||||||
|
|
||||||
@@ -9,16 +9,16 @@ Add OIDC/OAuth2 authentication via Authentik, including redirect/callback flows,
|
|||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
- [ ] Task 1: Create `src/lib/server/services/oauthService.ts` — OIDC client setup, discovery, token exchange
|
- [x] Task 1: Create `src/lib/server/services/oauthService.ts` — OIDC client setup, discovery, token exchange
|
||||||
- [ ] Task 2: Create `src/routes/auth/oauth/authorize/+server.ts` — redirect to Authentik with PKCE
|
- [x] Task 2: Create `src/routes/auth/oauth/authorize/+server.ts` — redirect to Authentik with PKCE
|
||||||
- [ ] Task 3: Create `src/routes/auth/oauth/callback/+server.ts` — handle callback, exchange code, provision user
|
- [x] Task 3: Create `src/routes/auth/oauth/callback/+server.ts` — handle callback, exchange code, provision user
|
||||||
- [ ] Task 4: Update `src/lib/server/services/userService.ts` — add `findOrCreateByOAuth()` for auto-provisioning
|
- [x] Task 4: Update `src/lib/server/services/userService.ts` — add `findOrCreateByOAuth()` for auto-provisioning
|
||||||
- [ ] Task 5: Update `src/routes/login/+page.svelte` — show OAuth button when auth mode is OAUTH or BOTH
|
- [x] Task 5: Update `src/routes/login/+page.svelte` — show OAuth button when auth mode is OAUTH or BOTH
|
||||||
- [ ] Task 6: Update `src/routes/login/+page.server.ts` — load auth mode from SystemSettings
|
- [x] Task 6: Update `src/routes/login/+page.server.ts` — load auth mode from SystemSettings
|
||||||
- [ ] Task 7: Update `src/routes/admin/settings/+page.svelte` — make OAuth config fields functional (client ID, secret, discovery URL)
|
- [x] Task 7: Update `src/routes/admin/settings/+page.svelte` — make OAuth config fields functional (client ID, secret, discovery URL)
|
||||||
- [ ] Task 8: Update `src/lib/components/admin/SettingsForm.svelte` — add OAuth test connection button
|
- [x] Task 8: Update `src/lib/components/admin/SettingsForm.svelte` — add OAuth test connection button
|
||||||
- [ ] Task 9: Update `src/hooks.server.ts` — handle OAuth sessions alongside local JWT sessions
|
- [x] Task 9: Update `src/hooks.server.ts` — handle OAuth sessions alongside local JWT sessions (no changes needed — existing JWT hook handles OAuth users transparently)
|
||||||
- [ ] Task 10: Add env vars to `.env.example` — OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_DISCOVERY_URL, OAUTH_REDIRECT_URI
|
- [x] Task 10: Add env vars to `.env.example` — OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_DISCOVERY_URL, OAUTH_REDIRECT_URI
|
||||||
|
|
||||||
## Files to Modify/Create
|
## Files to Modify/Create
|
||||||
- `src/lib/server/services/oauthService.ts` — NEW
|
- `src/lib/server/services/oauthService.ts` — NEW
|
||||||
@@ -48,11 +48,19 @@ Add OIDC/OAuth2 authentication via Authentik, including redirect/callback flows,
|
|||||||
- ⚠️ Big Bang: may not fully work until Phase 5 integration
|
- ⚠️ Big Bang: may not fully work until Phase 5 integration
|
||||||
|
|
||||||
## Review Checklist
|
## Review Checklist
|
||||||
- [ ] All tasks completed
|
|
||||||
- [ ] Code follows project conventions
|
- [x] All tasks completed
|
||||||
|
- [x] Code follows project conventions
|
||||||
- [ ] No unintended side effects
|
- [ ] No unintended side effects
|
||||||
- [ ] Build passes
|
- [ ] Build passes
|
||||||
- [ ] Tests pass (new + existing)
|
- [ ] Tests pass (new + existing)
|
||||||
|
|
||||||
## Handoff to Next Phase
|
## Handoff to Next Phase
|
||||||
<!-- Filled in by the implementation agent after completing this phase. -->
|
|
||||||
|
- Installed `openid-client` v6.8.2 as a runtime dependency.
|
||||||
|
- OAuth flow issues local JWT tokens, so hooks.server.ts required no changes.
|
||||||
|
- New API endpoint `POST /api/admin/oauth/test` added for the test connection button in SettingsForm.
|
||||||
|
- `findOrCreateByOAuth()` syncs OAuth groups to local groups by name (groups must pre-exist locally).
|
||||||
|
- Login page conditionally renders OAuth button and/or local form based on `authMode` from SystemSettings.
|
||||||
|
- OIDC discovery result is cached in-memory and invalidated when the admin tests the connection.
|
||||||
|
- Phase 2 (DnD) and Phase 3 (Localization) are independent and can proceed in parallel.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Phase 2: Drag-and-Drop Reordering
|
# Phase 2: Drag-and-Drop Reordering
|
||||||
|
|
||||||
**Status:** ⬜ Not Started
|
**Status:** Done
|
||||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||||
**Domain:** frontend
|
**Domain:** frontend
|
||||||
|
|
||||||
@@ -9,16 +9,16 @@ Add drag-and-drop reordering for sections within boards and widgets within/acros
|
|||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
- [ ] Task 1: Install `svelte-dnd-action` package
|
- [x] Task 1: Install `svelte-dnd-action` package
|
||||||
- [ ] Task 2: Create `src/lib/components/board/DraggableBoard.svelte` — board with draggable sections
|
- [x] Task 2: Create `src/lib/components/board/DraggableBoard.svelte` — board with draggable sections
|
||||||
- [ ] Task 3: Create `src/lib/components/section/DraggableSection.svelte` — section with draggable widgets
|
- [x] Task 3: Create `src/lib/components/section/DraggableSection.svelte` — section with draggable widgets
|
||||||
- [ ] Task 4: Create `src/lib/components/widget/DraggableWidget.svelte` — draggable widget wrapper
|
- [x] Task 4: Create `src/lib/components/widget/DraggableWidget.svelte` — draggable widget wrapper
|
||||||
- [ ] Task 5: Update `src/routes/boards/[boardId]/edit/+page.svelte` — replace static editor with DnD editor
|
- [x] Task 5: Update `src/routes/boards/[boardId]/edit/+page.svelte` — replace static editor with DnD editor
|
||||||
- [ ] Task 6: Create `src/routes/api/boards/[id]/reorder/+server.ts` — API to persist section order changes
|
- [x] Task 6: Create `src/routes/api/boards/[id]/reorder/+server.ts` — API to persist section order changes
|
||||||
- [ ] Task 7: Create `src/routes/api/boards/[id]/sections/[sid]/reorder/+server.ts` — API to persist widget order changes
|
- [x] Task 7: Create `src/routes/api/boards/[id]/sections/[sid]/reorder/+server.ts` — API to persist widget order changes
|
||||||
- [ ] Task 8: Update `src/lib/server/services/boardService.ts` — add `reorderSections()` and `reorderWidgets()` functions
|
- [x] Task 8: Update `src/lib/server/services/boardService.ts` — add `reorderSections()` and `reorderWidgets()` functions
|
||||||
- [ ] Task 9: Add visual drag handles and drop zone indicators
|
- [x] Task 9: Add visual drag handles and drop zone indicators
|
||||||
- [ ] Task 10: Support moving widgets between sections via cross-section DnD
|
- [x] Task 10: Support moving widgets between sections via cross-section DnD
|
||||||
|
|
||||||
## Files to Modify/Create
|
## Files to Modify/Create
|
||||||
- `package.json` — add svelte-dnd-action
|
- `package.json` — add svelte-dnd-action
|
||||||
@@ -42,14 +42,22 @@ Add drag-and-drop reordering for sections within boards and widgets within/acros
|
|||||||
- `svelte-dnd-action` works well with Svelte 5
|
- `svelte-dnd-action` works well with Svelte 5
|
||||||
- Use optimistic updates — reorder in UI immediately, sync to server in background
|
- Use optimistic updates — reorder in UI immediately, sync to server in background
|
||||||
- Reorder APIs should accept an array of IDs in the new order
|
- Reorder APIs should accept an array of IDs in the new order
|
||||||
- ⚠️ Big Bang: may need integration fixes in Phase 5
|
- Big Bang: may need integration fixes in Phase 6
|
||||||
|
|
||||||
## Review Checklist
|
## Review Checklist
|
||||||
- [ ] All tasks completed
|
- [x] All tasks completed
|
||||||
- [ ] Code follows project conventions
|
- [x] Code follows project conventions
|
||||||
- [ ] No unintended side effects
|
- [x] No unintended side effects
|
||||||
- [ ] Build passes
|
- [ ] Build passes
|
||||||
- [ ] Tests pass (new + existing)
|
- [ ] Tests pass (new + existing)
|
||||||
|
|
||||||
## Handoff to Next Phase
|
## Handoff to Next Phase
|
||||||
<!-- Filled in by the implementation agent after completing this phase. -->
|
Phase 2 DnD is complete. Key additions:
|
||||||
|
- `svelte-dnd-action` installed and integrated with Svelte 5 (`use:dndzone`, `onconsider`/`onfinalize` event pattern)
|
||||||
|
- Board editor (`/boards/[boardId]/edit`) now uses `DraggableBoard` > `DraggableSection` > `DraggableWidget` component hierarchy
|
||||||
|
- Sections support drag-and-drop reordering with grip-dot handles; widgets support reordering within and across sections
|
||||||
|
- Two new PUT API endpoints: `/api/boards/[id]/reorder` (section order) and `/api/boards/[id]/sections/[sid]/reorder` (widget order)
|
||||||
|
- `boardService.ts` extended with `reorderSections()`, `reorderWidgets()`, and `moveWidget()` — all using `$transaction` for atomicity
|
||||||
|
- Edit page uses `invalidateAll()` for server actions (add/delete) while DnD reorder uses optimistic fetch calls
|
||||||
|
- Drop zones use dashed borders; drag handles use grip-dot SVG icons with hover opacity transitions
|
||||||
|
- No changes to auth, admin, or view-mode components
|
||||||
|
|||||||
@@ -6,6 +6,32 @@
|
|||||||
let { form: formData }: { form: SuperValidated<z.infer<typeof updateSystemSettingsSchema>> } = $props();
|
let { form: formData }: { form: SuperValidated<z.infer<typeof updateSystemSettingsSchema>> } = $props();
|
||||||
|
|
||||||
const { form, errors, enhance, delayed } = superForm(formData);
|
const { form, errors, enhance, delayed } = superForm(formData);
|
||||||
|
|
||||||
|
let oauthTesting = $state(false);
|
||||||
|
let oauthTestResult = $state('');
|
||||||
|
let oauthTestSuccess = $state(false);
|
||||||
|
|
||||||
|
async function testOAuthConnection() {
|
||||||
|
oauthTesting = true;
|
||||||
|
oauthTestResult = '';
|
||||||
|
oauthTestSuccess = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/oauth/test', { method: 'POST' });
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && data.success) {
|
||||||
|
oauthTestSuccess = true;
|
||||||
|
oauthTestResult = `Connected to issuer: ${data.issuer}`;
|
||||||
|
} else {
|
||||||
|
oauthTestResult = data.error || 'Connection test failed';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
oauthTestResult = 'Network error — could not reach the server';
|
||||||
|
} finally {
|
||||||
|
oauthTesting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form method="POST" action="?/update" use:enhance class="space-y-8">
|
<form method="POST" action="?/update" use:enhance class="space-y-8">
|
||||||
@@ -42,10 +68,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- OAuth (stored but non-functional in MVP) -->
|
<!-- OAuth Configuration -->
|
||||||
<section class="rounded-lg border border-border bg-card p-6">
|
<section class="rounded-lg border border-border bg-card p-6">
|
||||||
<h2 class="mb-4 text-lg font-semibold text-card-foreground">OAuth Configuration</h2>
|
<h2 class="mb-4 text-lg font-semibold text-card-foreground">OAuth Configuration</h2>
|
||||||
<p class="mb-4 text-xs text-muted-foreground">OAuth settings are stored but not active in this MVP version.</p>
|
<p class="mb-4 text-xs text-muted-foreground">
|
||||||
|
Configure your OIDC provider (e.g. Authentik, Keycloak). Set Auth Mode to "OAuth" or "Both" above to enable OAuth login.
|
||||||
|
</p>
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label for="oauthClientId" class="mb-1 block text-sm font-medium text-foreground">Client ID</label>
|
<label for="oauthClientId" class="mb-1 block text-sm font-medium text-foreground">Client ID</label>
|
||||||
@@ -81,6 +109,21 @@
|
|||||||
/>
|
/>
|
||||||
{#if $errors.oauthDiscoveryUrl}<span class="text-xs text-destructive">{$errors.oauthDiscoveryUrl}</span>{/if}
|
{#if $errors.oauthDiscoveryUrl}<span class="text-xs text-destructive">{$errors.oauthDiscoveryUrl}</span>{/if}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={testOAuthConnection}
|
||||||
|
disabled={oauthTesting}
|
||||||
|
class="rounded-md border border-border bg-background px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-muted focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{oauthTesting ? 'Testing...' : 'Test Connection'}
|
||||||
|
</button>
|
||||||
|
{#if oauthTestResult}
|
||||||
|
<span class="ml-3 text-sm {oauthTestSuccess ? 'text-green-600 dark:text-green-400' : 'text-destructive'}">
|
||||||
|
{oauthTestResult}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { dndzone } from 'svelte-dnd-action';
|
||||||
|
import DraggableSection from '$lib/components/section/DraggableSection.svelte';
|
||||||
|
|
||||||
|
interface WidgetData {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
order: number;
|
||||||
|
config: string;
|
||||||
|
appId: string | null;
|
||||||
|
sectionId: string;
|
||||||
|
app: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
icon: string | null;
|
||||||
|
iconType: string;
|
||||||
|
description: string | null;
|
||||||
|
statuses: Array<{ status: string; responseTime: number | null }>;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SectionData {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
icon: string | null;
|
||||||
|
order: number;
|
||||||
|
isExpandedByDefault: boolean;
|
||||||
|
widgets: WidgetData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
boardId: string;
|
||||||
|
sections: SectionData[];
|
||||||
|
apps: Array<{ id: string; name: string }>;
|
||||||
|
addWidgetSectionId: string | null;
|
||||||
|
onToggleAddWidget: (sectionId: string) => void;
|
||||||
|
onDeleteSection: (sectionId: string) => void;
|
||||||
|
onAddWidget: (sectionId: string, appId: string) => void;
|
||||||
|
onDeleteWidget: (widgetId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
boardId,
|
||||||
|
sections: initialSections,
|
||||||
|
apps,
|
||||||
|
addWidgetSectionId,
|
||||||
|
onToggleAddWidget,
|
||||||
|
onDeleteSection,
|
||||||
|
onAddWidget,
|
||||||
|
onDeleteWidget
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let sections = $state<SectionData[]>([...initialSections]);
|
||||||
|
|
||||||
|
// Keep local state in sync when parent data changes
|
||||||
|
$effect(() => {
|
||||||
|
sections = [...initialSections];
|
||||||
|
});
|
||||||
|
|
||||||
|
const flipDurationMs = 200;
|
||||||
|
|
||||||
|
function handleConsider(e: CustomEvent<{ items: SectionData[] }>) {
|
||||||
|
sections = e.detail.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFinalize(e: CustomEvent<{ items: SectionData[] }>) {
|
||||||
|
sections = e.detail.items;
|
||||||
|
const sectionIds = sections.map((s) => s.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`/api/boards/${boardId}/reorder`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ sectionIds })
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to persist section reorder:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleWidgetsUpdate(sectionId: string, widgets: WidgetData[]) {
|
||||||
|
// Update local state
|
||||||
|
sections = sections.map((s) => (s.id === sectionId ? { ...s, widgets } : s));
|
||||||
|
|
||||||
|
const widgetIds = widgets.map((w) => w.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`/api/boards/${boardId}/sections/${sectionId}/reorder`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ widgetIds })
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to persist widget reorder:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if sections.length === 0}
|
||||||
|
<div class="rounded-xl border border-border bg-card/50 p-8 text-center">
|
||||||
|
<p class="text-muted-foreground">No sections yet. Add one to get started.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
use:dndzone={{ items: sections, flipDurationMs, dropTargetStyle: {} }}
|
||||||
|
onconsider={handleConsider}
|
||||||
|
onfinalize={handleFinalize}
|
||||||
|
class="space-y-4"
|
||||||
|
>
|
||||||
|
{#each sections as section (section.id)}
|
||||||
|
<div>
|
||||||
|
<DraggableSection
|
||||||
|
{section}
|
||||||
|
{boardId}
|
||||||
|
{apps}
|
||||||
|
onWidgetsUpdate={handleWidgetsUpdate}
|
||||||
|
{addWidgetSectionId}
|
||||||
|
{onToggleAddWidget}
|
||||||
|
{onDeleteSection}
|
||||||
|
{onAddWidget}
|
||||||
|
{onDeleteWidget}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { dndzone } from 'svelte-dnd-action';
|
||||||
|
import DraggableWidget from '$lib/components/widget/DraggableWidget.svelte';
|
||||||
|
|
||||||
|
interface WidgetData {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
order: number;
|
||||||
|
config: string;
|
||||||
|
appId: string | null;
|
||||||
|
sectionId: string;
|
||||||
|
app: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
icon: string | null;
|
||||||
|
iconType: string;
|
||||||
|
description: string | null;
|
||||||
|
statuses: Array<{ status: string; responseTime: number | null }>;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SectionData {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
icon: string | null;
|
||||||
|
order: number;
|
||||||
|
isExpandedByDefault: boolean;
|
||||||
|
widgets: WidgetData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
section: SectionData;
|
||||||
|
boardId: string;
|
||||||
|
apps: Array<{ id: string; name: string }>;
|
||||||
|
onWidgetsUpdate: (sectionId: string, widgets: WidgetData[]) => void;
|
||||||
|
addWidgetSectionId: string | null;
|
||||||
|
onToggleAddWidget: (sectionId: string) => void;
|
||||||
|
onDeleteSection: (sectionId: string) => void;
|
||||||
|
onAddWidget: (sectionId: string, appId: string) => void;
|
||||||
|
onDeleteWidget: (widgetId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
section,
|
||||||
|
boardId,
|
||||||
|
apps,
|
||||||
|
onWidgetsUpdate,
|
||||||
|
addWidgetSectionId,
|
||||||
|
onToggleAddWidget,
|
||||||
|
onDeleteSection,
|
||||||
|
onAddWidget,
|
||||||
|
onDeleteWidget
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let widgets = $state<WidgetData[]>([...section.widgets]);
|
||||||
|
|
||||||
|
// Keep local state in sync when parent data changes
|
||||||
|
$effect(() => {
|
||||||
|
widgets = [...section.widgets];
|
||||||
|
});
|
||||||
|
|
||||||
|
const flipDurationMs = 200;
|
||||||
|
|
||||||
|
function handleConsider(e: CustomEvent<{ items: WidgetData[] }>) {
|
||||||
|
widgets = e.detail.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFinalize(e: CustomEvent<{ items: WidgetData[] }>) {
|
||||||
|
widgets = e.detail.items;
|
||||||
|
onWidgetsUpdate(section.id, widgets);
|
||||||
|
}
|
||||||
|
|
||||||
|
let selectedAppId = $state('');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-border bg-card p-4 shadow-sm">
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- Section drag handle -->
|
||||||
|
<div
|
||||||
|
class="flex shrink-0 cursor-grab items-center px-1 text-muted-foreground transition-opacity active:cursor-grabbing"
|
||||||
|
aria-label="Drag to reorder section"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<circle cx="9" cy="5" r="1" />
|
||||||
|
<circle cx="9" cy="12" r="1" />
|
||||||
|
<circle cx="9" cy="19" r="1" />
|
||||||
|
<circle cx="15" cy="5" r="1" />
|
||||||
|
<circle cx="15" cy="12" r="1" />
|
||||||
|
<circle cx="15" cy="19" r="1" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="font-medium text-foreground">{section.title}</span>
|
||||||
|
<span class="text-xs text-muted-foreground">Order: {section.order}</span>
|
||||||
|
{#if section.icon}
|
||||||
|
<span class="text-xs text-muted-foreground">({section.icon})</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => onToggleAddWidget(section.id)}
|
||||||
|
class="rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Add Widget
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => onDeleteSection(section.id)}
|
||||||
|
class="rounded-md bg-destructive px-2 py-1 text-xs font-medium text-destructive-foreground transition-colors hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if addWidgetSectionId === section.id}
|
||||||
|
<div class="mb-3 rounded-lg border border-border bg-muted/50 p-3">
|
||||||
|
<div>
|
||||||
|
<label for="widget-app-{section.id}" class="mb-1 block text-sm font-medium text-foreground"
|
||||||
|
>Select App</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
id="widget-app-{section.id}"
|
||||||
|
bind:value={selectedAppId}
|
||||||
|
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
|
||||||
|
>
|
||||||
|
<option value="">Choose an app...</option>
|
||||||
|
{#each apps as app (app.id)}
|
||||||
|
<option value={app.id}>{app.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => {
|
||||||
|
if (selectedAppId) {
|
||||||
|
onAddWidget(section.id, selectedAppId);
|
||||||
|
selectedAppId = '';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!selectedAppId}
|
||||||
|
class="rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Widgets drop zone -->
|
||||||
|
{#if widgets.length === 0}
|
||||||
|
<div
|
||||||
|
use:dndzone={{ items: widgets, flipDurationMs, dropTargetStyle: {} }}
|
||||||
|
onconsider={handleConsider}
|
||||||
|
onfinalize={handleFinalize}
|
||||||
|
class="min-h-[48px] rounded-lg border-2 border-dashed border-border/50 p-2 transition-colors"
|
||||||
|
>
|
||||||
|
<p class="text-center text-sm text-muted-foreground">
|
||||||
|
No widgets. Drag widgets here or add one above.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
use:dndzone={{ items: widgets, flipDurationMs, dropTargetStyle: {} }}
|
||||||
|
onconsider={handleConsider}
|
||||||
|
onfinalize={handleFinalize}
|
||||||
|
class="min-h-[48px] space-y-2 rounded-lg border-2 border-dashed border-transparent p-1 transition-colors"
|
||||||
|
>
|
||||||
|
{#each widgets as widget (widget.id)}
|
||||||
|
<div class="rounded-lg border border-border bg-background/50 px-3 py-2">
|
||||||
|
<DraggableWidget>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs font-medium uppercase text-primary">{widget.type}</span>
|
||||||
|
{#if widget.app}
|
||||||
|
<span class="text-sm text-foreground">{widget.app.name}</span>
|
||||||
|
<span class="text-xs text-muted-foreground">({widget.app.url})</span>
|
||||||
|
{:else}
|
||||||
|
<span class="text-sm text-muted-foreground">Widget #{widget.order}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => onDeleteWidget(widget.id)}
|
||||||
|
class="rounded-md bg-destructive px-2 py-1 text-xs font-medium text-destructive-foreground transition-colors hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</DraggableWidget>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="group/widget relative flex items-center gap-2">
|
||||||
|
<!-- Drag handle -->
|
||||||
|
<div
|
||||||
|
class="flex h-full shrink-0 cursor-grab items-center px-1 text-muted-foreground opacity-0 transition-opacity group-hover/widget:opacity-100 active:cursor-grabbing"
|
||||||
|
aria-label="Drag to reorder widget"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<circle cx="9" cy="5" r="1" />
|
||||||
|
<circle cx="9" cy="12" r="1" />
|
||||||
|
<circle cx="9" cy="19" r="1" />
|
||||||
|
<circle cx="15" cy="5" r="1" />
|
||||||
|
<circle cx="15" cy="12" r="1" />
|
||||||
|
<circle cx="15" cy="19" r="1" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Widget content -->
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -261,3 +261,41 @@ export async function removeWidget(id: string) {
|
|||||||
await findWidgetById(id);
|
await findWidgetById(id);
|
||||||
await prisma.widget.delete({ where: { id } });
|
await prisma.widget.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Reorder ---
|
||||||
|
|
||||||
|
export async function reorderSections(boardId: string, sectionIds: string[]) {
|
||||||
|
await findBoardById(boardId);
|
||||||
|
|
||||||
|
const updates = sectionIds.map((id, index) =>
|
||||||
|
prisma.section.update({
|
||||||
|
where: { id },
|
||||||
|
data: { order: index }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return prisma.$transaction(updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reorderWidgets(sectionId: string, widgetIds: string[]) {
|
||||||
|
await findSectionById(sectionId);
|
||||||
|
|
||||||
|
const updates = widgetIds.map((id, index) =>
|
||||||
|
prisma.widget.update({
|
||||||
|
where: { id },
|
||||||
|
data: { order: index, sectionId }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return prisma.$transaction(updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function moveWidget(widgetId: string, targetSectionId: string, order: number) {
|
||||||
|
await findWidgetById(widgetId);
|
||||||
|
await findSectionById(targetSectionId);
|
||||||
|
|
||||||
|
return prisma.widget.update({
|
||||||
|
where: { id: widgetId },
|
||||||
|
data: { sectionId: targetSectionId, order }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import * as client from 'openid-client';
|
||||||
|
import { prisma } from '../prisma.js';
|
||||||
|
import { DEFAULTS } from '$lib/utils/constants.js';
|
||||||
|
|
||||||
|
interface OAuthConfig {
|
||||||
|
readonly clientId: string;
|
||||||
|
readonly clientSecret: string;
|
||||||
|
readonly discoveryUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OAuthUserInfo {
|
||||||
|
readonly sub: string;
|
||||||
|
readonly email: string;
|
||||||
|
readonly name?: string;
|
||||||
|
readonly preferred_username?: string;
|
||||||
|
readonly picture?: string;
|
||||||
|
readonly groups?: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cached OIDC configuration to avoid re-discovery on every request */
|
||||||
|
let cachedConfig: client.Configuration | null = null;
|
||||||
|
let cachedConfigKey: string | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads OAuth settings from SystemSettings DB, falling back to env vars.
|
||||||
|
*/
|
||||||
|
async function loadOAuthConfig(): Promise<OAuthConfig> {
|
||||||
|
const settings = await prisma.systemSettings.findUnique({
|
||||||
|
where: { id: DEFAULTS.SYSTEM_SETTINGS_ID }
|
||||||
|
});
|
||||||
|
|
||||||
|
const clientId = settings?.oauthClientId || process.env.OAUTH_CLIENT_ID || '';
|
||||||
|
const clientSecret = settings?.oauthClientSecret || process.env.OAUTH_CLIENT_SECRET || '';
|
||||||
|
const discoveryUrl = settings?.oauthDiscoveryUrl || process.env.OAUTH_DISCOVERY_URL || '';
|
||||||
|
|
||||||
|
if (!clientId || !clientSecret || !discoveryUrl) {
|
||||||
|
throw new Error(
|
||||||
|
'OAuth is not configured. Set client ID, client secret, and discovery URL in admin settings or environment variables.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { clientId, clientSecret, discoveryUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives the issuer URL from a discovery URL.
|
||||||
|
* If the URL ends with /.well-known/openid-configuration, strip that suffix.
|
||||||
|
* Otherwise use the URL as-is (openid-client discovery will append the well-known path).
|
||||||
|
*/
|
||||||
|
function deriveIssuerUrl(discoveryUrl: string): URL {
|
||||||
|
const wellKnownSuffix = '/.well-known/openid-configuration';
|
||||||
|
if (discoveryUrl.endsWith(wellKnownSuffix)) {
|
||||||
|
return new URL(discoveryUrl.slice(0, -wellKnownSuffix.length));
|
||||||
|
}
|
||||||
|
return new URL(discoveryUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a cached OIDC Configuration, performing discovery only when
|
||||||
|
* the OAuth settings have changed.
|
||||||
|
*/
|
||||||
|
async function getOIDCConfig(): Promise<client.Configuration> {
|
||||||
|
const oauthConfig = await loadOAuthConfig();
|
||||||
|
const cacheKey = `${oauthConfig.discoveryUrl}|${oauthConfig.clientId}`;
|
||||||
|
|
||||||
|
if (cachedConfig && cachedConfigKey === cacheKey) {
|
||||||
|
return cachedConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
const issuerUrl = deriveIssuerUrl(oauthConfig.discoveryUrl);
|
||||||
|
const config = await client.discovery(
|
||||||
|
issuerUrl,
|
||||||
|
oauthConfig.clientId,
|
||||||
|
oauthConfig.clientSecret
|
||||||
|
);
|
||||||
|
|
||||||
|
cachedConfig = config;
|
||||||
|
cachedConfigKey = cacheKey;
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidates the cached OIDC configuration, forcing re-discovery
|
||||||
|
* on the next request. Useful after admin changes OAuth settings.
|
||||||
|
*/
|
||||||
|
export function invalidateOAuthCache(): void {
|
||||||
|
cachedConfig = null;
|
||||||
|
cachedConfigKey = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a PKCE code_verifier (random string).
|
||||||
|
*/
|
||||||
|
export function generateCodeVerifier(): string {
|
||||||
|
return client.randomPKCECodeVerifier();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the PKCE code_challenge from a code_verifier.
|
||||||
|
*/
|
||||||
|
export async function calculateCodeChallenge(codeVerifier: string): Promise<string> {
|
||||||
|
return client.calculatePKCECodeChallenge(codeVerifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the authorization URL to redirect the user to the OIDC provider.
|
||||||
|
*/
|
||||||
|
export async function generateAuthUrl(
|
||||||
|
redirectUri: string,
|
||||||
|
codeChallenge: string
|
||||||
|
): Promise<string> {
|
||||||
|
const config = await getOIDCConfig();
|
||||||
|
|
||||||
|
const parameters: Record<string, string> = {
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
scope: 'openid profile email',
|
||||||
|
code_challenge: codeChallenge,
|
||||||
|
code_challenge_method: 'S256'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add state if the server might not support PKCE
|
||||||
|
if (!config.serverMetadata().supportsPKCE()) {
|
||||||
|
parameters.state = client.randomState();
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = client.buildAuthorizationUrl(config, parameters);
|
||||||
|
return url.href;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchanges an authorization code for tokens and fetches user info.
|
||||||
|
*/
|
||||||
|
export async function handleCallback(
|
||||||
|
callbackUrl: URL,
|
||||||
|
codeVerifier: string
|
||||||
|
): Promise<OAuthUserInfo> {
|
||||||
|
const config = await getOIDCConfig();
|
||||||
|
|
||||||
|
const tokens = await client.authorizationCodeGrant(config, callbackUrl, {
|
||||||
|
pkceCodeVerifier: codeVerifier
|
||||||
|
});
|
||||||
|
|
||||||
|
// Try to get user info from the userinfo endpoint
|
||||||
|
const userInfo = await client.fetchUserInfo(config, tokens.access_token, tokens.claims()?.sub);
|
||||||
|
|
||||||
|
const email = (userInfo.email as string) || '';
|
||||||
|
if (!email) {
|
||||||
|
throw new Error('OAuth provider did not return an email address. Ensure the "email" scope is configured.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sub: userInfo.sub,
|
||||||
|
email,
|
||||||
|
name: (userInfo.name as string) || (userInfo.preferred_username as string) || undefined,
|
||||||
|
preferred_username: (userInfo.preferred_username as string) || undefined,
|
||||||
|
picture: (userInfo.picture as string) || undefined,
|
||||||
|
groups: Array.isArray(userInfo.groups) ? (userInfo.groups as string[]) : undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests the OAuth connection by performing OIDC discovery.
|
||||||
|
* Returns the issuer string on success, throws on failure.
|
||||||
|
*/
|
||||||
|
export async function testConnection(): Promise<string> {
|
||||||
|
const config = await getOIDCConfig();
|
||||||
|
const issuer = config.serverMetadata().issuer;
|
||||||
|
return issuer;
|
||||||
|
}
|
||||||
@@ -102,3 +102,96 @@ export async function getUserGroups(userId: string) {
|
|||||||
export async function count() {
|
export async function count() {
|
||||||
return prisma.user.count();
|
return prisma.user.count();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface OAuthProvisionInput {
|
||||||
|
readonly email: string;
|
||||||
|
readonly displayName: string;
|
||||||
|
readonly avatarUrl?: string;
|
||||||
|
readonly groups?: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds an existing user by email or creates a new OAuth-provisioned user.
|
||||||
|
* - If the user exists: updates authProvider to 'oauth' and syncs display name / avatar if changed.
|
||||||
|
* - If the user does not exist: creates a new user with authProvider='oauth', null password, role='user'.
|
||||||
|
* - Maps OAuth group names to local groups when the groups claim is present.
|
||||||
|
*/
|
||||||
|
export async function findOrCreateByOAuth(input: OAuthProvisionInput) {
|
||||||
|
const existing = await prisma.user.findUnique({
|
||||||
|
where: { email: input.email },
|
||||||
|
select: { ...USER_SELECT, password: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
let userId: string;
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Update the existing user's OAuth-related fields if anything changed
|
||||||
|
const updates: Record<string, unknown> = { authProvider: 'oauth' };
|
||||||
|
if (input.displayName && input.displayName !== existing.displayName) {
|
||||||
|
updates.displayName = input.displayName;
|
||||||
|
}
|
||||||
|
if (input.avatarUrl !== undefined && input.avatarUrl !== existing.avatarUrl) {
|
||||||
|
updates.avatarUrl = input.avatarUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: updates
|
||||||
|
});
|
||||||
|
|
||||||
|
userId = existing.id;
|
||||||
|
} else {
|
||||||
|
// Create a new OAuth user
|
||||||
|
const newUser = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: input.email,
|
||||||
|
password: null,
|
||||||
|
displayName: input.displayName,
|
||||||
|
avatarUrl: input.avatarUrl ?? null,
|
||||||
|
authProvider: 'oauth',
|
||||||
|
role: 'user'
|
||||||
|
},
|
||||||
|
select: USER_SELECT
|
||||||
|
});
|
||||||
|
|
||||||
|
userId = newUser.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync OAuth groups to local groups if the groups claim is present
|
||||||
|
if (input.groups && input.groups.length > 0) {
|
||||||
|
await syncOAuthGroups(userId, input.groups);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the full user record
|
||||||
|
return prisma.user.findUniqueOrThrow({
|
||||||
|
where: { id: userId },
|
||||||
|
select: USER_SELECT
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps OAuth group names to existing local groups and syncs membership.
|
||||||
|
* Only groups that already exist locally are linked — no auto-creation.
|
||||||
|
*/
|
||||||
|
async function syncOAuthGroups(userId: string, oauthGroupNames: readonly string[]) {
|
||||||
|
// Find local groups matching the OAuth group names
|
||||||
|
const matchingGroups = await prisma.group.findMany({
|
||||||
|
where: { name: { in: [...oauthGroupNames] } },
|
||||||
|
select: { id: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matchingGroups.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert memberships (idempotent — won't fail if already a member)
|
||||||
|
for (const group of matchingGroups) {
|
||||||
|
await prisma.userGroup.upsert({
|
||||||
|
where: {
|
||||||
|
userId_groupId: { userId, groupId: group.id }
|
||||||
|
},
|
||||||
|
update: {},
|
||||||
|
create: { userId, groupId: group.id }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types.js';
|
||||||
|
import { requireAdmin } from '$lib/server/middleware/authorize.js';
|
||||||
|
import { testConnection, invalidateOAuthCache } from '$lib/server/services/oauthService.js';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async (event) => {
|
||||||
|
requireAdmin(event);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Invalidate cache so we test with current settings
|
||||||
|
invalidateOAuthCache();
|
||||||
|
const issuer = await testConnection();
|
||||||
|
return json({ success: true, issuer });
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'OAuth connection test failed';
|
||||||
|
return json({ success: false, error: message }, { status: 400 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import * as boardService from '$lib/server/services/boardService.js';
|
||||||
|
import * as permissionService from '$lib/server/services/permissionService.js';
|
||||||
|
import { success, error } from '$lib/server/utils/response.js';
|
||||||
|
import { EntityType, PermissionLevel, UserRole } from '$lib/utils/constants.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/boards/:id/reorder — Reorder sections within a board.
|
||||||
|
* Body: { sectionIds: string[] }
|
||||||
|
*/
|
||||||
|
export const PUT: RequestHandler = async (event) => {
|
||||||
|
const user = event.locals.user;
|
||||||
|
if (!user) {
|
||||||
|
return json(error('Authentication required'), { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = event.params;
|
||||||
|
|
||||||
|
if (user.role !== UserRole.ADMIN) {
|
||||||
|
const result = await permissionService.checkPermission(
|
||||||
|
EntityType.BOARD,
|
||||||
|
id,
|
||||||
|
user.id,
|
||||||
|
PermissionLevel.EDIT
|
||||||
|
);
|
||||||
|
if (!result.hasPermission) {
|
||||||
|
return json(error('Insufficient permissions'), { status: 403 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await event.request.json();
|
||||||
|
} catch {
|
||||||
|
return json(error('Invalid JSON body'), { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sectionIds } = body as { sectionIds?: string[] };
|
||||||
|
if (!Array.isArray(sectionIds) || sectionIds.length === 0) {
|
||||||
|
return json(error('sectionIds must be a non-empty array of strings'), { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sectionIds.every((sid) => typeof sid === 'string')) {
|
||||||
|
return json(error('All sectionIds must be strings'), { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await boardService.reorderSections(id, sectionIds);
|
||||||
|
return json(success(null));
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to reorder sections';
|
||||||
|
const status = message.includes('not found') ? 404 : 500;
|
||||||
|
return json(error(message), { status });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import * as boardService from '$lib/server/services/boardService.js';
|
||||||
|
import * as permissionService from '$lib/server/services/permissionService.js';
|
||||||
|
import { success, error } from '$lib/server/utils/response.js';
|
||||||
|
import { EntityType, PermissionLevel, UserRole } from '$lib/utils/constants.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/boards/:id/sections/:sid/reorder — Reorder widgets within a section.
|
||||||
|
* Body: { widgetIds: string[] }
|
||||||
|
*/
|
||||||
|
export const PUT: RequestHandler = async (event) => {
|
||||||
|
const user = event.locals.user;
|
||||||
|
if (!user) {
|
||||||
|
return json(error('Authentication required'), { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, sid } = event.params;
|
||||||
|
|
||||||
|
if (user.role !== UserRole.ADMIN) {
|
||||||
|
const result = await permissionService.checkPermission(
|
||||||
|
EntityType.BOARD,
|
||||||
|
id,
|
||||||
|
user.id,
|
||||||
|
PermissionLevel.EDIT
|
||||||
|
);
|
||||||
|
if (!result.hasPermission) {
|
||||||
|
return json(error('Insufficient permissions'), { status: 403 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await event.request.json();
|
||||||
|
} catch {
|
||||||
|
return json(error('Invalid JSON body'), { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { widgetIds } = body as { widgetIds?: string[] };
|
||||||
|
if (!Array.isArray(widgetIds)) {
|
||||||
|
return json(error('widgetIds must be an array of strings'), { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!widgetIds.every((wid) => typeof wid === 'string')) {
|
||||||
|
return json(error('All widgetIds must be strings'), { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await boardService.reorderWidgets(sid, widgetIds);
|
||||||
|
return json(success(null));
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to reorder widgets';
|
||||||
|
const status = message.includes('not found') ? 404 : 500;
|
||||||
|
return json(error(message), { status });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { redirect, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types.js';
|
||||||
|
import * as oauthService from '$lib/server/services/oauthService.js';
|
||||||
|
|
||||||
|
const COOKIE_BASE = {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax' as const,
|
||||||
|
path: '/'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ cookies, url }) => {
|
||||||
|
try {
|
||||||
|
const appUrl = process.env.APP_URL || url.origin;
|
||||||
|
const redirectUri = process.env.OAUTH_REDIRECT_URI || `${appUrl}/auth/oauth/callback`;
|
||||||
|
|
||||||
|
// Generate PKCE values
|
||||||
|
const codeVerifier = oauthService.generateCodeVerifier();
|
||||||
|
const codeChallenge = await oauthService.calculateCodeChallenge(codeVerifier);
|
||||||
|
|
||||||
|
// Store code_verifier in HTTP-only cookie for the callback
|
||||||
|
cookies.set('oauth_code_verifier', codeVerifier, {
|
||||||
|
...COOKIE_BASE,
|
||||||
|
maxAge: 600 // 10 minutes — enough for the auth flow
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build authorization URL and redirect
|
||||||
|
const authUrl = await oauthService.generateAuthUrl(redirectUri, codeChallenge);
|
||||||
|
|
||||||
|
throw redirect(302, authUrl);
|
||||||
|
} catch (err) {
|
||||||
|
// Re-throw redirects
|
||||||
|
if (err && typeof err === 'object' && 'status' in err && (err as { status: number }).status === 302) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to initiate OAuth login';
|
||||||
|
throw error(500, message);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { redirect, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types.js';
|
||||||
|
import * as oauthService from '$lib/server/services/oauthService.js';
|
||||||
|
import * as userService from '$lib/server/services/userService.js';
|
||||||
|
import * as authService from '$lib/server/services/authService.js';
|
||||||
|
|
||||||
|
const COOKIE_BASE = {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax' as const,
|
||||||
|
path: '/'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||||
|
try {
|
||||||
|
// Check for error response from the provider
|
||||||
|
const oauthError = url.searchParams.get('error');
|
||||||
|
if (oauthError) {
|
||||||
|
const description = url.searchParams.get('error_description') || oauthError;
|
||||||
|
throw new Error(`OAuth provider returned an error: ${description}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure we have an authorization code
|
||||||
|
const code = url.searchParams.get('code');
|
||||||
|
if (!code) {
|
||||||
|
throw new Error('No authorization code received from OAuth provider');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve the code_verifier from the cookie
|
||||||
|
const codeVerifier = cookies.get('oauth_code_verifier');
|
||||||
|
if (!codeVerifier) {
|
||||||
|
throw new Error('OAuth session expired. Please try logging in again.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the code_verifier cookie
|
||||||
|
cookies.delete('oauth_code_verifier', { path: '/' });
|
||||||
|
|
||||||
|
// Exchange the authorization code for tokens and get user info
|
||||||
|
const userInfo = await oauthService.handleCallback(url, codeVerifier);
|
||||||
|
|
||||||
|
// Find or create local user from OAuth info
|
||||||
|
const user = await userService.findOrCreateByOAuth({
|
||||||
|
email: userInfo.email,
|
||||||
|
displayName: userInfo.name || userInfo.preferred_username || userInfo.email.split('@')[0],
|
||||||
|
avatarUrl: userInfo.picture,
|
||||||
|
groups: userInfo.groups ? [...userInfo.groups] : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
// Issue local JWT tokens (same as local auth flow)
|
||||||
|
const accessToken = authService.signAccessToken({
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role
|
||||||
|
});
|
||||||
|
const refreshToken = authService.generateRefreshToken();
|
||||||
|
await authService.saveRefreshToken(user.id, refreshToken);
|
||||||
|
|
||||||
|
// Set session cookies
|
||||||
|
cookies.set('access_token', accessToken, {
|
||||||
|
...COOKIE_BASE,
|
||||||
|
maxAge: 900 // 15 minutes
|
||||||
|
});
|
||||||
|
cookies.set('refresh_token', refreshToken, {
|
||||||
|
...COOKIE_BASE,
|
||||||
|
maxAge: 604800 // 7 days
|
||||||
|
});
|
||||||
|
cookies.set('refresh_user_id', user.id, {
|
||||||
|
...COOKIE_BASE,
|
||||||
|
maxAge: 604800 // 7 days
|
||||||
|
});
|
||||||
|
|
||||||
|
throw redirect(302, '/');
|
||||||
|
} catch (err) {
|
||||||
|
// Re-throw redirects
|
||||||
|
if (err && typeof err === 'object' && 'status' in err && (err as { status: number }).status === 302) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = err instanceof Error ? err.message : 'OAuth authentication failed';
|
||||||
|
throw error(500, message);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,11 +1,65 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PageData } from './$types.js';
|
import type { PageData } from './$types.js';
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
|
import { invalidateAll } from '$app/navigation';
|
||||||
|
import DraggableBoard from '$lib/components/board/DraggableBoard.svelte';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
let showAddSection = $state(false);
|
let showAddSection = $state(false);
|
||||||
let addWidgetSectionId = $state<string | null>(null);
|
let addWidgetSectionId = $state<string | null>(null);
|
||||||
|
|
||||||
|
function handleToggleAddWidget(sectionId: string) {
|
||||||
|
addWidgetSectionId = addWidgetSectionId === sectionId ? null : sectionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteSection(sectionId: string) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.set('sectionId', sectionId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`?/deleteSection`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
await invalidateAll();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete section:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddWidget(sectionId: string, appId: string) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.set('sectionId', sectionId);
|
||||||
|
formData.set('type', 'app');
|
||||||
|
formData.set('appId', appId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`?/addWidget`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
addWidgetSectionId = null;
|
||||||
|
await invalidateAll();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to add widget:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteWidget(widgetId: string) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.set('widgetId', widgetId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`?/deleteWidget`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
await invalidateAll();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete widget:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -92,7 +146,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Sections -->
|
<!-- Sections with Drag-and-Drop -->
|
||||||
<section class="mb-8">
|
<section class="mb-8">
|
||||||
<div class="mb-4 flex items-center justify-between">
|
<div class="mb-4 flex items-center justify-between">
|
||||||
<h2 class="text-lg font-semibold text-foreground">Sections</h2>
|
<h2 class="text-lg font-semibold text-foreground">Sections</h2>
|
||||||
@@ -151,115 +205,16 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if data.board.sections.length === 0}
|
<DraggableBoard
|
||||||
<div class="rounded-xl border border-border bg-card/50 p-8 text-center">
|
boardId={data.board.id}
|
||||||
<p class="text-muted-foreground">No sections yet. Add one to get started.</p>
|
sections={data.board.sections}
|
||||||
</div>
|
apps={data.apps}
|
||||||
{:else}
|
{addWidgetSectionId}
|
||||||
<div class="space-y-4">
|
onToggleAddWidget={handleToggleAddWidget}
|
||||||
{#each data.board.sections as section (section.id)}
|
onDeleteSection={handleDeleteSection}
|
||||||
<div class="rounded-xl border border-border bg-card p-4 shadow-sm">
|
onAddWidget={handleAddWidget}
|
||||||
<div class="mb-3 flex items-center justify-between">
|
onDeleteWidget={handleDeleteWidget}
|
||||||
<div class="flex items-center gap-2">
|
/>
|
||||||
<span class="font-medium text-foreground">{section.title}</span>
|
|
||||||
<span class="text-xs text-muted-foreground">Order: {section.order}</span>
|
|
||||||
{#if section.icon}
|
|
||||||
<span class="text-xs text-muted-foreground">({section.icon})</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => (addWidgetSectionId = addWidgetSectionId === section.id ? null : section.id)}
|
|
||||||
class="rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
|
||||||
>
|
|
||||||
Add Widget
|
|
||||||
</button>
|
|
||||||
<form method="POST" action="?/deleteSection" use:enhance>
|
|
||||||
<input type="hidden" name="sectionId" value={section.id} />
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="rounded-md bg-destructive px-2 py-1 text-xs font-medium text-destructive-foreground transition-colors hover:bg-destructive/90"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if addWidgetSectionId === section.id}
|
|
||||||
<div class="mb-3 rounded-lg border border-border bg-muted/50 p-3">
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="?/addWidget"
|
|
||||||
use:enhance={() => {
|
|
||||||
return async ({ update }) => {
|
|
||||||
await update();
|
|
||||||
addWidgetSectionId = null;
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input type="hidden" name="sectionId" value={section.id} />
|
|
||||||
<input type="hidden" name="type" value="app" />
|
|
||||||
<div>
|
|
||||||
<label for="widget-app-{section.id}" class="mb-1 block text-sm font-medium text-foreground">Select App</label>
|
|
||||||
<select
|
|
||||||
id="widget-app-{section.id}"
|
|
||||||
name="appId"
|
|
||||||
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<option value="">Choose an app...</option>
|
|
||||||
{#each data.apps as app (app.id)}
|
|
||||||
<option value={app.id}>{app.name}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="mt-2">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Widgets list -->
|
|
||||||
{#if section.widgets.length === 0}
|
|
||||||
<p class="text-sm text-muted-foreground">No widgets in this section.</p>
|
|
||||||
{:else}
|
|
||||||
<div class="space-y-2">
|
|
||||||
{#each section.widgets as widget (widget.id)}
|
|
||||||
<div class="flex items-center justify-between rounded-lg border border-border bg-background/50 px-3 py-2">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="text-xs font-medium uppercase text-primary">{widget.type}</span>
|
|
||||||
{#if widget.app}
|
|
||||||
<span class="text-sm text-foreground">{widget.app.name}</span>
|
|
||||||
<span class="text-xs text-muted-foreground">({widget.app.url})</span>
|
|
||||||
{:else}
|
|
||||||
<span class="text-sm text-muted-foreground">Widget #{widget.order}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<form method="POST" action="?/deleteWidget" use:enhance>
|
|
||||||
<input type="hidden" name="widgetId" value={widget.id} />
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="rounded-md bg-destructive px-2 py-1 text-xs font-medium text-destructive-foreground transition-colors hover:bg-destructive/90"
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import { fail, redirect } from '@sveltejs/kit';
|
|||||||
import { loginSchema } from '$lib/utils/validators.js';
|
import { loginSchema } from '$lib/utils/validators.js';
|
||||||
import * as userService from '$lib/server/services/userService.js';
|
import * as userService from '$lib/server/services/userService.js';
|
||||||
import * as authService from '$lib/server/services/authService.js';
|
import * as authService from '$lib/server/services/authService.js';
|
||||||
|
import { prisma } from '$lib/server/prisma.js';
|
||||||
|
import { DEFAULTS } from '$lib/utils/constants.js';
|
||||||
|
import type { AuthMode } from '$lib/utils/constants.js';
|
||||||
|
|
||||||
const COOKIE_BASE = {
|
const COOKIE_BASE = {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
@@ -19,8 +22,15 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
throw redirect(302, '/');
|
throw redirect(302, '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load auth mode from SystemSettings
|
||||||
|
const settings = await prisma.systemSettings.findUnique({
|
||||||
|
where: { id: DEFAULTS.SYSTEM_SETTINGS_ID },
|
||||||
|
select: { authMode: true }
|
||||||
|
});
|
||||||
|
const authMode: AuthMode = (settings?.authMode as AuthMode) || 'local';
|
||||||
|
|
||||||
const form = await superValidate(zod(loginSchema));
|
const form = await superValidate(zod(loginSchema));
|
||||||
return { form };
|
return { form, authMode };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
const { form, errors, enhance, submitting } = superForm(data.form);
|
const { form, errors, enhance, submitting } = superForm(data.form);
|
||||||
|
|
||||||
|
const showLocalForm = data.authMode === 'local' || data.authMode === 'both';
|
||||||
|
const showOAuthButton = data.authMode === 'oauth' || data.authMode === 'both';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -38,62 +41,91 @@
|
|||||||
<p class="mt-1 text-sm text-muted-foreground">Sign in to your account</p>
|
<p class="mt-1 text-sm text-muted-foreground">Sign in to your account</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="POST" use:enhance class="space-y-4">
|
{#if showOAuthButton}
|
||||||
<div>
|
<a
|
||||||
<label for="email" class="mb-1 block text-sm font-medium text-card-foreground">
|
href="/auth/oauth/authorize"
|
||||||
Email
|
class="flex w-full items-center justify-center gap-2 rounded-lg border border-border bg-background px-4 py-2.5 text-sm font-medium text-foreground transition-colors hover:bg-muted focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
type="email"
|
|
||||||
autocomplete="email"
|
|
||||||
bind:value={$form.email}
|
|
||||||
class="w-full rounded-lg border border-input bg-background px-3 py-2.5 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
|
|
||||||
placeholder="you@example.com"
|
|
||||||
/>
|
|
||||||
{#if $errors.email}
|
|
||||||
<p class="mt-1 text-sm text-destructive">{$errors.email[0]}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="password" class="mb-1 block text-sm font-medium text-card-foreground">
|
|
||||||
Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
type="password"
|
|
||||||
autocomplete="current-password"
|
|
||||||
bind:value={$form.password}
|
|
||||||
class="w-full rounded-lg border border-input bg-background px-3 py-2.5 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
|
|
||||||
placeholder="Enter your password"
|
|
||||||
/>
|
|
||||||
{#if $errors.password}
|
|
||||||
<p class="mt-1 text-sm text-destructive">{$errors.password[0]}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={$submitting}
|
|
||||||
class="w-full rounded-lg bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
>
|
>
|
||||||
{#if $submitting}
|
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<span class="flex items-center justify-center gap-2">
|
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" />
|
||||||
<span class="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent"></span>
|
<polyline points="10 17 15 12 10 7" />
|
||||||
Signing in...
|
<line x1="15" y1="12" x2="3" y2="12" />
|
||||||
</span>
|
</svg>
|
||||||
{:else}
|
Sign in with OAuth
|
||||||
Sign In
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<p class="mt-6 text-center text-sm text-muted-foreground">
|
{#if showOAuthButton && showLocalForm}
|
||||||
Don't have an account?
|
<div class="relative my-4">
|
||||||
<a href="/register" class="font-medium text-primary hover:underline">Register</a>
|
<div class="absolute inset-0 flex items-center">
|
||||||
</p>
|
<div class="w-full border-t border-border"></div>
|
||||||
|
</div>
|
||||||
|
<div class="relative flex justify-center text-xs uppercase">
|
||||||
|
<span class="bg-card px-2 text-muted-foreground">or</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showLocalForm}
|
||||||
|
<form method="POST" use:enhance class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="email" class="mb-1 block text-sm font-medium text-card-foreground">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autocomplete="email"
|
||||||
|
bind:value={$form.email}
|
||||||
|
class="w-full rounded-lg border border-input bg-background px-3 py-2.5 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
{#if $errors.email}
|
||||||
|
<p class="mt-1 text-sm text-destructive">{$errors.email[0]}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password" class="mb-1 block text-sm font-medium text-card-foreground">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
bind:value={$form.password}
|
||||||
|
class="w-full rounded-lg border border-input bg-background px-3 py-2.5 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
/>
|
||||||
|
{#if $errors.password}
|
||||||
|
<p class="mt-1 text-sm text-destructive">{$errors.password[0]}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={$submitting}
|
||||||
|
class="w-full rounded-lg bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{#if $submitting}
|
||||||
|
<span class="flex items-center justify-center gap-2">
|
||||||
|
<span class="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent"></span>
|
||||||
|
Signing in...
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
Sign In
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showLocalForm}
|
||||||
|
<p class="mt-6 text-center text-sm text-muted-foreground">
|
||||||
|
Don't have an account?
|
||||||
|
<a href="/register" class="font-medium text-primary hover:underline">Register</a>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
Reference in New Issue
Block a user