diff --git a/.env.example b/.env.example index 0056c89..3bf0f40 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,12 @@ APP_PORT=3000 APP_HOST="0.0.0.0" 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" diff --git a/package-lock.json b/package-lock.json index b89909e..c340b87 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,8 +17,10 @@ "jsonwebtoken": "^9.0.2", "lucide-svelte": "^0.469.0", "node-cron": "^3.0.3", + "openid-client": "^6.8.2", "simple-icons": "^13.0.0", "svelte": "^5.0.0", + "svelte-dnd-action": "^0.9.69", "sveltekit-superforms": "^2.22.0", "tailwind-merge": "^2.6.0", "zod": "^3.24.0" @@ -3439,6 +3441,14 @@ "@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": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4023,12 +4033,32 @@ "integrity": "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==", "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": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", "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": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4887,6 +4917,14 @@ "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": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.6.0.tgz", @@ -5249,7 +5287,6 @@ "cpu": [ "ppc64" ], - "dev": true, "optional": true, "os": [ "aix" @@ -5265,7 +5302,6 @@ "cpu": [ "arm" ], - "dev": true, "optional": true, "os": [ "android" @@ -5281,7 +5317,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "android" @@ -5297,7 +5332,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "android" @@ -5313,7 +5347,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "darwin" @@ -5329,7 +5362,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "darwin" @@ -5345,7 +5377,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "freebsd" @@ -5361,7 +5392,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "freebsd" @@ -5377,7 +5407,6 @@ "cpu": [ "arm" ], - "dev": true, "optional": true, "os": [ "linux" @@ -5393,7 +5422,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -5409,7 +5437,6 @@ "cpu": [ "ia32" ], - "dev": true, "optional": true, "os": [ "linux" @@ -5425,7 +5452,6 @@ "cpu": [ "loong64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -5441,7 +5467,6 @@ "cpu": [ "mips64el" ], - "dev": true, "optional": true, "os": [ "linux" @@ -5457,7 +5482,6 @@ "cpu": [ "ppc64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -5473,7 +5497,6 @@ "cpu": [ "riscv64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -5489,7 +5512,6 @@ "cpu": [ "s390x" ], - "dev": true, "optional": true, "os": [ "linux" @@ -5505,7 +5527,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -5521,7 +5542,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "netbsd" @@ -5537,7 +5557,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "netbsd" @@ -5553,7 +5572,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "openbsd" @@ -5569,7 +5587,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "openbsd" @@ -5585,7 +5602,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "openharmony" @@ -5601,7 +5617,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "sunos" @@ -5617,7 +5632,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "win32" @@ -5633,7 +5647,6 @@ "cpu": [ "ia32" ], - "dev": true, "optional": true, "os": [ "win32" @@ -5649,7 +5662,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "win32" @@ -8304,6 +8316,11 @@ "@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": { "version": "4.0.0", "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": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", "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": { "version": "0.9.4", "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": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.6.0.tgz", @@ -9383,182 +9420,156 @@ "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", - "dev": true, "optional": true }, "@esbuild/android-arm": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", - "dev": true, "optional": true }, "@esbuild/android-arm64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", - "dev": true, "optional": true }, "@esbuild/android-x64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", - "dev": true, "optional": true }, "@esbuild/darwin-arm64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", - "dev": true, "optional": true }, "@esbuild/darwin-x64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", - "dev": true, "optional": true }, "@esbuild/freebsd-arm64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", - "dev": true, "optional": true }, "@esbuild/freebsd-x64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", - "dev": true, "optional": true }, "@esbuild/linux-arm": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", - "dev": true, "optional": true }, "@esbuild/linux-arm64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", - "dev": true, "optional": true }, "@esbuild/linux-ia32": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", - "dev": true, "optional": true }, "@esbuild/linux-loong64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", - "dev": true, "optional": true }, "@esbuild/linux-mips64el": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", - "dev": true, "optional": true }, "@esbuild/linux-ppc64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", - "dev": true, "optional": true }, "@esbuild/linux-riscv64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", - "dev": true, "optional": true }, "@esbuild/linux-s390x": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", - "dev": true, "optional": true }, "@esbuild/linux-x64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", - "dev": true, "optional": true }, "@esbuild/netbsd-arm64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", - "dev": true, "optional": true }, "@esbuild/netbsd-x64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", - "dev": true, "optional": true }, "@esbuild/openbsd-arm64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", - "dev": true, "optional": true }, "@esbuild/openbsd-x64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", - "dev": true, "optional": true }, "@esbuild/openharmony-arm64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", - "dev": true, "optional": true }, "@esbuild/sunos-x64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", - "dev": true, "optional": true }, "@esbuild/win32-arm64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", - "dev": true, "optional": true }, "@esbuild/win32-ia32": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", - "dev": true, "optional": true }, "@esbuild/win32-x64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", - "dev": true, "optional": true }, "esbuild": { diff --git a/package.json b/package.json index 39725ed..5315f7a 100644 --- a/package.json +++ b/package.json @@ -30,8 +30,10 @@ "jsonwebtoken": "^9.0.2", "lucide-svelte": "^0.469.0", "node-cron": "^3.0.3", + "openid-client": "^6.8.2", "simple-icons": "^13.0.0", "svelte": "^5.0.0", + "svelte-dnd-action": "^0.9.69", "sveltekit-superforms": "^2.22.0", "tailwind-merge": "^2.6.0", "zod": "^3.24.0" diff --git a/plans/phase-2-enhanced-features/CONTEXT.md b/plans/phase-2-enhanced-features/CONTEXT.md index f5cd083..7898aa1 100644 --- a/plans/phase-2-enhanced-features/CONTEXT.md +++ b/plans/phase-2-enhanced-features/CONTEXT.md @@ -1,8 +1,11 @@ # Feature Context: Phase 2 — Enhanced Features ## 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 - None yet @@ -15,7 +18,16 @@ Starting Phase 2 enhanced features on a new feature branch. - Phase 5 (Integration) depends on all prior phases ## 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) -- 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 + +## 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 diff --git a/plans/phase-2-enhanced-features/PLAN.md b/plans/phase-2-enhanced-features/PLAN.md index 27f6a0e..66a5d97 100644 --- a/plans/phase-2-enhanced-features/PLAN.md +++ b/plans/phase-2-enhanced-features/PLAN.md @@ -19,7 +19,7 @@ Add OAuth/Authentik integration, drag-and-drop reordering, localization (EN/RU), ## 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 3: Localization EN/RU [fullstack] → [subplan](./phase-3-localization.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 1: OAuth | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | -| Phase 2: DnD | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 1: OAuth | fullstack | Done | ⬜ | ⬜ | ⬜ | +| Phase 2: DnD | frontend | Done | ⬜ | ⬜ | ⬜ | | Phase 3: Localization | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | | Phase 4: Widgets | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | | Phase 5: Access Control | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | diff --git a/plans/phase-2-enhanced-features/phase-1-oauth.md b/plans/phase-2-enhanced-features/phase-1-oauth.md index 63c7927..1e44aa8 100644 --- a/plans/phase-2-enhanced-features/phase-1-oauth.md +++ b/plans/phase-2-enhanced-features/phase-1-oauth.md @@ -1,6 +1,6 @@ # Phase 1: OAuth/Authentik Integration -**Status:** ⬜ Not Started +**Status:** ✅ Complete **Parent plan:** [PLAN.md](./PLAN.md) **Domain:** fullstack @@ -9,16 +9,16 @@ Add OIDC/OAuth2 authentication via Authentik, including redirect/callback flows, ## Tasks -- [ ] 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 -- [ ] 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 -- [ ] 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 -- [ ] 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 -- [ ] Task 9: Update `src/hooks.server.ts` — handle OAuth sessions alongside local JWT sessions -- [ ] Task 10: Add env vars to `.env.example` — OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_DISCOVERY_URL, OAUTH_REDIRECT_URI +- [x] Task 1: Create `src/lib/server/services/oauthService.ts` — OIDC client setup, discovery, token exchange +- [x] Task 2: Create `src/routes/auth/oauth/authorize/+server.ts` — redirect to Authentik with PKCE +- [x] Task 3: Create `src/routes/auth/oauth/callback/+server.ts` — handle callback, exchange code, provision user +- [x] Task 4: Update `src/lib/server/services/userService.ts` — add `findOrCreateByOAuth()` for auto-provisioning +- [x] Task 5: Update `src/routes/login/+page.svelte` — show OAuth button when auth mode is OAUTH or BOTH +- [x] Task 6: Update `src/routes/login/+page.server.ts` — load auth mode from SystemSettings +- [x] Task 7: Update `src/routes/admin/settings/+page.svelte` — make OAuth config fields functional (client ID, secret, discovery URL) +- [x] Task 8: Update `src/lib/components/admin/SettingsForm.svelte` — add OAuth test connection button +- [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) +- [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 - `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 ## Review Checklist -- [ ] All tasks completed -- [ ] Code follows project conventions + +- [x] All tasks completed +- [x] Code follows project conventions - [ ] No unintended side effects - [ ] Build passes - [ ] Tests pass (new + existing) ## Handoff to Next 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. diff --git a/plans/phase-2-enhanced-features/phase-2-dnd.md b/plans/phase-2-enhanced-features/phase-2-dnd.md index 07186b3..3abbbd1 100644 --- a/plans/phase-2-enhanced-features/phase-2-dnd.md +++ b/plans/phase-2-enhanced-features/phase-2-dnd.md @@ -1,6 +1,6 @@ # Phase 2: Drag-and-Drop Reordering -**Status:** ⬜ Not Started +**Status:** Done **Parent plan:** [PLAN.md](./PLAN.md) **Domain:** frontend @@ -9,16 +9,16 @@ Add drag-and-drop reordering for sections within boards and widgets within/acros ## Tasks -- [ ] Task 1: Install `svelte-dnd-action` package -- [ ] 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 -- [ ] 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 -- [ ] 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 -- [ ] Task 8: Update `src/lib/server/services/boardService.ts` — add `reorderSections()` and `reorderWidgets()` functions -- [ ] Task 9: Add visual drag handles and drop zone indicators -- [ ] Task 10: Support moving widgets between sections via cross-section DnD +- [x] Task 1: Install `svelte-dnd-action` package +- [x] Task 2: Create `src/lib/components/board/DraggableBoard.svelte` — board with draggable sections +- [x] Task 3: Create `src/lib/components/section/DraggableSection.svelte` — section with draggable widgets +- [x] Task 4: Create `src/lib/components/widget/DraggableWidget.svelte` — draggable widget wrapper +- [x] Task 5: Update `src/routes/boards/[boardId]/edit/+page.svelte` — replace static editor with DnD editor +- [x] Task 6: Create `src/routes/api/boards/[id]/reorder/+server.ts` — API to persist section order changes +- [x] Task 7: Create `src/routes/api/boards/[id]/sections/[sid]/reorder/+server.ts` — API to persist widget order changes +- [x] Task 8: Update `src/lib/server/services/boardService.ts` — add `reorderSections()` and `reorderWidgets()` functions +- [x] Task 9: Add visual drag handles and drop zone indicators +- [x] Task 10: Support moving widgets between sections via cross-section DnD ## Files to Modify/Create - `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 - Use optimistic updates — reorder in UI immediately, sync to server in background - 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 -- [ ] All tasks completed -- [ ] Code follows project conventions -- [ ] No unintended side effects +- [x] All tasks completed +- [x] Code follows project conventions +- [x] No unintended side effects - [ ] Build passes - [ ] Tests pass (new + existing) ## Handoff to Next 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 diff --git a/src/lib/components/admin/SettingsForm.svelte b/src/lib/components/admin/SettingsForm.svelte index b4847af..aa1cee5 100644 --- a/src/lib/components/admin/SettingsForm.svelte +++ b/src/lib/components/admin/SettingsForm.svelte @@ -6,6 +6,32 @@ let { form: formData }: { form: SuperValidated> } = $props(); 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; + } + }
@@ -42,10 +68,12 @@ - +

OAuth Configuration

-

OAuth settings are stored but not active in this MVP version.

+

+ Configure your OIDC provider (e.g. Authentik, Keycloak). Set Auth Mode to "OAuth" or "Both" above to enable OAuth login. +

@@ -81,6 +109,21 @@ /> {#if $errors.oauthDiscoveryUrl}{$errors.oauthDiscoveryUrl}{/if}
+
+ + {#if oauthTestResult} + + {oauthTestResult} + + {/if} +
diff --git a/src/lib/components/board/DraggableBoard.svelte b/src/lib/components/board/DraggableBoard.svelte new file mode 100644 index 0000000..d658e46 --- /dev/null +++ b/src/lib/components/board/DraggableBoard.svelte @@ -0,0 +1,127 @@ + + +{#if sections.length === 0} +
+

No sections yet. Add one to get started.

+
+{:else} +
+ {#each sections as section (section.id)} +
+ +
+ {/each} +
+{/if} diff --git a/src/lib/components/section/DraggableSection.svelte b/src/lib/components/section/DraggableSection.svelte new file mode 100644 index 0000000..a6c7e1e --- /dev/null +++ b/src/lib/components/section/DraggableSection.svelte @@ -0,0 +1,208 @@ + + +
+
+
+ +
+ + + + + + + + +
+ {section.title} + Order: {section.order} + {#if section.icon} + ({section.icon}) + {/if} +
+
+ + +
+
+ + {#if addWidgetSectionId === section.id} +
+
+ + +
+
+ +
+
+ {/if} + + + {#if widgets.length === 0} +
+

+ No widgets. Drag widgets here or add one above. +

+
+ {:else} +
+ {#each widgets as widget (widget.id)} +
+ +
+
+ {widget.type} + {#if widget.app} + {widget.app.name} + ({widget.app.url}) + {:else} + Widget #{widget.order} + {/if} +
+ +
+
+
+ {/each} +
+ {/if} +
diff --git a/src/lib/components/widget/DraggableWidget.svelte b/src/lib/components/widget/DraggableWidget.svelte new file mode 100644 index 0000000..e9ca014 --- /dev/null +++ b/src/lib/components/widget/DraggableWidget.svelte @@ -0,0 +1,41 @@ + + +
+ +
+ + + + + + + + +
+ + +
+ {@render children()} +
+
diff --git a/src/lib/server/services/boardService.ts b/src/lib/server/services/boardService.ts index c76e662..2fee693 100644 --- a/src/lib/server/services/boardService.ts +++ b/src/lib/server/services/boardService.ts @@ -261,3 +261,41 @@ export async function removeWidget(id: string) { await findWidgetById(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 } + }); +} diff --git a/src/lib/server/services/oauthService.ts b/src/lib/server/services/oauthService.ts new file mode 100644 index 0000000..98ef30f --- /dev/null +++ b/src/lib/server/services/oauthService.ts @@ -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 { + 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 { + 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 { + 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 { + const config = await getOIDCConfig(); + + const parameters: Record = { + 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 { + 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 { + const config = await getOIDCConfig(); + const issuer = config.serverMetadata().issuer; + return issuer; +} diff --git a/src/lib/server/services/userService.ts b/src/lib/server/services/userService.ts index f46c467..7946667 100644 --- a/src/lib/server/services/userService.ts +++ b/src/lib/server/services/userService.ts @@ -102,3 +102,96 @@ export async function getUserGroups(userId: string) { export async function 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 = { 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 } + }); + } +} diff --git a/src/routes/api/admin/oauth/test/+server.ts b/src/routes/api/admin/oauth/test/+server.ts new file mode 100644 index 0000000..3c50fa6 --- /dev/null +++ b/src/routes/api/admin/oauth/test/+server.ts @@ -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 }); + } +}; diff --git a/src/routes/api/boards/[id]/reorder/+server.ts b/src/routes/api/boards/[id]/reorder/+server.ts new file mode 100644 index 0000000..8b3e3c5 --- /dev/null +++ b/src/routes/api/boards/[id]/reorder/+server.ts @@ -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 }); + } +}; diff --git a/src/routes/api/boards/[id]/sections/[sid]/reorder/+server.ts b/src/routes/api/boards/[id]/sections/[sid]/reorder/+server.ts new file mode 100644 index 0000000..c44568f --- /dev/null +++ b/src/routes/api/boards/[id]/sections/[sid]/reorder/+server.ts @@ -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 }); + } +}; diff --git a/src/routes/auth/oauth/authorize/+server.ts b/src/routes/auth/oauth/authorize/+server.ts new file mode 100644 index 0000000..22071a6 --- /dev/null +++ b/src/routes/auth/oauth/authorize/+server.ts @@ -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); + } +}; diff --git a/src/routes/auth/oauth/callback/+server.ts b/src/routes/auth/oauth/callback/+server.ts new file mode 100644 index 0000000..489e9de --- /dev/null +++ b/src/routes/auth/oauth/callback/+server.ts @@ -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); + } +}; diff --git a/src/routes/boards/[boardId]/edit/+page.svelte b/src/routes/boards/[boardId]/edit/+page.svelte index 39d42c6..6aea0b9 100644 --- a/src/routes/boards/[boardId]/edit/+page.svelte +++ b/src/routes/boards/[boardId]/edit/+page.svelte @@ -1,11 +1,65 @@ @@ -92,7 +146,7 @@ - +

Sections

@@ -151,115 +205,16 @@
{/if} - {#if data.board.sections.length === 0} -
-

No sections yet. Add one to get started.

-
- {:else} -
- {#each data.board.sections as section (section.id)} -
-
-
- {section.title} - Order: {section.order} - {#if section.icon} - ({section.icon}) - {/if} -
-
- -
- - -
-
-
- - {#if addWidgetSectionId === section.id} -
-
{ - return async ({ update }) => { - await update(); - addWidgetSectionId = null; - }; - }} - > - - -
- - -
-
- -
-
-
- {/if} - - - {#if section.widgets.length === 0} -

No widgets in this section.

- {:else} -
- {#each section.widgets as widget (widget.id)} -
-
- {widget.type} - {#if widget.app} - {widget.app.name} - ({widget.app.url}) - {:else} - Widget #{widget.order} - {/if} -
-
- - -
-
- {/each} -
- {/if} -
- {/each} -
- {/if} +
diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts index 7bef095..096f5c8 100644 --- a/src/routes/login/+page.server.ts +++ b/src/routes/login/+page.server.ts @@ -5,6 +5,9 @@ import { fail, redirect } from '@sveltejs/kit'; import { loginSchema } from '$lib/utils/validators.js'; import * as userService from '$lib/server/services/userService.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 = { httpOnly: true, @@ -19,8 +22,15 @@ export const load: PageServerLoad = async ({ locals }) => { 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)); - return { form }; + return { form, authMode }; }; export const actions: Actions = { diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte index 158518a..dd29d2a 100644 --- a/src/routes/login/+page.svelte +++ b/src/routes/login/+page.svelte @@ -6,6 +6,9 @@ let { data }: { data: PageData } = $props(); const { form, errors, enhance, submitting } = superForm(data.form); + + const showLocalForm = data.authMode === 'local' || data.authMode === 'both'; + const showOAuthButton = data.authMode === 'oauth' || data.authMode === 'both'; @@ -38,62 +41,91 @@

Sign in to your account

-
-
- - - {#if $errors.email} -

{$errors.email[0]}

- {/if} -
- -
- - - {#if $errors.password} -

{$errors.password[0]}

- {/if} -
- - -
+ + + + + + Sign in with OAuth + + {/if} -

- Don't have an account? - Register -

+ {#if showOAuthButton && showLocalForm} +
+
+
+
+
+ or +
+
+ {/if} + + {#if showLocalForm} +
+
+ + + {#if $errors.email} +

{$errors.email[0]}

+ {/if} +
+ +
+ + + {#if $errors.password} +

{$errors.password[0]}

+ {/if} +
+ + +
+ {/if} + + {#if showLocalForm} +

+ Don't have an account? + Register +

+ {/if}