From a8691c6fb2222c0e527002aabf291f25b8f7fced Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bla=C5=BE=20Aristovnik?= <125830696+barisgit@users.noreply.github.com> Date: Thu, 17 Apr 2025 03:24:39 +0200 Subject: [PATCH 01/10] Feature: Granular Permissions (#5) * Add granular permission control, setup backend and framework, prepare to use permissions elsewhere * Refactor permission handling and improve permission logic across the application --- .gitignore | 3 + package.json | 8 +- pnpm-lock.yaml | 233 +++++++++ scripts/README.md | 11 +- scripts/create-admin.ts | 102 ---- scripts/setup-db.js | 201 -------- scripts/setup-db.ts | 295 ++++++++++++ src/app/[...path]/not-found.tsx | 4 +- src/app/admin/[404]/page.tsx | 7 + src/app/admin/assets/page.tsx | 312 ++++++------ src/app/admin/dashboard/page.tsx | 197 ++++++++ src/app/admin/groups/[id]/edit/page.tsx | 81 ++++ .../groups/[id]/users/add-users-modal.tsx | 215 +++++++++ src/app/admin/groups/[id]/users/page.tsx | 98 ++++ .../groups/[id]/users/remove-user-modal.tsx | 104 ++++ src/app/admin/groups/group-form.tsx | 428 +++++++++++++++++ src/app/admin/groups/groups-list.tsx | 115 +++++ src/app/admin/groups/new/page.tsx | 41 ++ src/app/admin/groups/page.tsx | 58 +++ src/app/admin/layout.tsx | 5 + src/app/admin/not-found.tsx | 15 + src/app/admin/page.tsx | 4 +- src/app/admin/permissions/page.tsx | 43 ++ .../admin/permissions/permissions-table.tsx | 112 +++++ src/app/admin/users/page.tsx | 369 +++++++++++++++ src/app/layout.tsx | 66 ++- src/app/login/page.tsx | 2 +- src/app/register/page.tsx | 6 +- src/components/auth/LoginForm.tsx | 46 +- src/components/auth/RegisterForm.tsx | 14 +- src/components/layout/AdminButton.tsx | 35 ++ src/components/layout/AdminLayout.tsx | 354 ++++++++++---- src/components/layout/Header.tsx | 2 + src/components/ui/avatar.tsx | 3 +- src/components/ui/badge.tsx | 166 ++----- src/components/ui/input.tsx | 22 + src/components/ui/label.tsx | 26 + src/components/ui/select.tsx | 159 +++++++ src/components/ui/table.tsx | 120 +++++ src/components/ui/textarea.tsx | 22 + src/components/ui/tooltip.tsx | 32 ++ src/components/wiki/CreatePageButton.tsx | 15 +- src/components/wiki/WikiEditor.tsx | 2 +- src/components/wiki/WikiFolderTree.tsx | 2 +- src/components/wiki/WikiPage.tsx | 60 ++- src/lib/auth.ts | 39 +- src/lib/db/migrate.ts | 135 ++++-- src/lib/db/schema.ts | 232 ++++++++- src/lib/db/seed.ts | 45 ++ src/lib/db/seeds/.gitignore | 2 + src/lib/db/seeds/custom-seeds.example.ts | 21 + src/lib/db/seeds/permissions.ts | 218 +++++++++ src/lib/hooks/usePermissions.tsx | 145 ++++++ src/lib/permissions/client.ts | 43 ++ src/lib/permissions/index.ts | 23 + src/lib/permissions/registry.ts | 212 +++++++++ src/lib/permissions/server.ts | 14 + src/lib/permissions/types.ts | 40 ++ src/lib/permissions/validation.ts | 144 ++++++ src/lib/services/authorization.ts | 278 +++++++++++ src/lib/services/groups.ts | 443 ++++++++++++++++++ src/lib/services/index.ts | 21 + src/lib/services/permissions.ts | 110 +++++ src/lib/services/users.ts | 62 ++- src/lib/trpc/index.ts | 44 ++ src/lib/trpc/routers/assets.ts | 16 +- src/lib/trpc/routers/auth.ts | 76 +++ src/lib/trpc/routers/groups.ts | 282 +++++++++++ src/lib/trpc/routers/index.ts | 8 + src/lib/trpc/routers/permissions.ts | 98 ++++ src/lib/trpc/routers/user.ts | 167 +++++-- src/lib/trpc/routers/users.ts | 34 ++ src/lib/trpc/routers/wiki.ts | 62 +-- src/providers/index.tsx | 39 ++ src/styles/globals.css | 82 ++-- src/styles/markdown.css | 29 +- 76 files changed, 6408 insertions(+), 971 deletions(-) delete mode 100644 scripts/create-admin.ts delete mode 100755 scripts/setup-db.js create mode 100644 scripts/setup-db.ts create mode 100644 src/app/admin/[404]/page.tsx create mode 100644 src/app/admin/dashboard/page.tsx create mode 100644 src/app/admin/groups/[id]/edit/page.tsx create mode 100644 src/app/admin/groups/[id]/users/add-users-modal.tsx create mode 100644 src/app/admin/groups/[id]/users/page.tsx create mode 100644 src/app/admin/groups/[id]/users/remove-user-modal.tsx create mode 100644 src/app/admin/groups/group-form.tsx create mode 100644 src/app/admin/groups/groups-list.tsx create mode 100644 src/app/admin/groups/new/page.tsx create mode 100644 src/app/admin/groups/page.tsx create mode 100644 src/app/admin/layout.tsx create mode 100644 src/app/admin/not-found.tsx create mode 100644 src/app/admin/permissions/page.tsx create mode 100644 src/app/admin/permissions/permissions-table.tsx create mode 100644 src/app/admin/users/page.tsx create mode 100644 src/components/layout/AdminButton.tsx create mode 100644 src/components/ui/input.tsx create mode 100644 src/components/ui/label.tsx create mode 100644 src/components/ui/select.tsx create mode 100644 src/components/ui/table.tsx create mode 100644 src/components/ui/textarea.tsx create mode 100644 src/components/ui/tooltip.tsx create mode 100644 src/lib/db/seed.ts create mode 100644 src/lib/db/seeds/.gitignore create mode 100644 src/lib/db/seeds/custom-seeds.example.ts create mode 100644 src/lib/db/seeds/permissions.ts create mode 100644 src/lib/hooks/usePermissions.tsx create mode 100644 src/lib/permissions/client.ts create mode 100644 src/lib/permissions/index.ts create mode 100644 src/lib/permissions/registry.ts create mode 100644 src/lib/permissions/server.ts create mode 100644 src/lib/permissions/types.ts create mode 100644 src/lib/permissions/validation.ts create mode 100644 src/lib/services/authorization.ts create mode 100644 src/lib/services/groups.ts create mode 100644 src/lib/services/permissions.ts create mode 100644 src/lib/trpc/routers/auth.ts create mode 100644 src/lib/trpc/routers/groups.ts create mode 100644 src/lib/trpc/routers/permissions.ts create mode 100644 src/lib/trpc/routers/users.ts create mode 100644 src/providers/index.tsx diff --git a/.gitignore b/.gitignore index 7b8da95..67d2bbe 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# drizzle +/drizzle \ No newline at end of file diff --git a/package.json b/package.json index 34be7f7..09edfc3 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "db:generate": "drizzle-kit generate", "db:push": "drizzle-kit push", "db:migrate": "tsx src/lib/db/migrate.ts", - "db:setup": "node scripts/setup-db.js", + "db:setup": "tsx scripts/setup-db.ts", + "db:seed": "env-cmd -f .env tsx src/lib/db/seed.ts", "watch-css": "tailwindcss -i ./src/styles/globals.css -o ./public/output.css --watch" }, "dependencies": { @@ -19,8 +20,11 @@ "@codemirror/lang-markdown": "^6.3.2", "@codemirror/state": "^6.5.2", "@neondatabase/serverless": "^0.10.4", + "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-popover": "^1.1.6", + "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-tabs": "^1.1.3", + "@radix-ui/react-tooltip": "^1.1.8", "@tailwindcss/cli": "^4.0.15", "@tailwindcss/typography": "^0.5.16", "@tanstack/react-query": "^5.69.0", @@ -49,6 +53,7 @@ "remark-directive-rehype": "^0.4.2", "remark-emoji": "^5.0.1", "remark-gfm": "^4.0.1", + "server-only": "^0.0.1", "sonner": "^2.0.1", "trpc": "^0.10.4", "unist": "^0.0.1", @@ -72,6 +77,7 @@ "env-cmd": "^10.1.0", "eslint": "^9", "eslint-config-next": "15.2.3", + "gray-matter": "^4.0.3", "lucide-react": "^0.483.0", "postcss": "^8.5.3", "tailwind-merge": "^3.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4ae242..41142a9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,12 +20,21 @@ importers: '@neondatabase/serverless': specifier: ^0.10.4 version: 0.10.4 + '@radix-ui/react-label': + specifier: ^2.1.2 + version: 2.1.2(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@radix-ui/react-popover': specifier: ^1.1.6 version: 1.1.6(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-select': + specifier: ^2.1.6 + version: 2.1.6(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@radix-ui/react-tabs': specifier: ^1.1.3 version: 1.1.3(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-tooltip': + specifier: ^1.1.8 + version: 1.1.8(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@tailwindcss/cli': specifier: ^4.0.15 version: 4.0.15 @@ -110,6 +119,9 @@ importers: remark-gfm: specifier: ^4.0.1 version: 4.0.1 + server-only: + specifier: ^0.0.1 + version: 0.0.1 sonner: specifier: ^2.0.1 version: 2.0.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -174,6 +186,9 @@ importers: eslint-config-next: specifier: 15.2.3 version: 15.2.3(eslint@9.23.0(jiti@2.4.2))(typescript@5.8.2) + gray-matter: + specifier: ^4.0.3 + version: 4.0.3 lucide-react: specifier: ^0.483.0 version: 0.483.0(react@19.0.0) @@ -1066,6 +1081,9 @@ packages: '@petamoriken/float16@3.9.2': resolution: {integrity: sha512-VgffxawQde93xKxT3qap3OH+meZf7VaSB5Sqd4Rqc+FP5alWbpOyan/7tRbOAvynjpG3GpdtAuGU/NdhQpmrog==} + '@radix-ui/number@1.1.0': + resolution: {integrity: sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==} + '@radix-ui/primitive@1.1.1': resolution: {integrity: sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==} @@ -1166,6 +1184,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-label@2.1.2': + resolution: {integrity: sha512-zo1uGMTaNlHehDyFQcDZXRJhUPDuukcnHz0/jnrup0JA6qL+AFpAnty+7VKa9esuU5xTblAZzTGYJKSKaBxBhw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-popover@1.1.6': resolution: {integrity: sha512-NQouW0x4/GnkFJ/pRqsIS3rM/k97VzKnVb2jB7Gq7VEGPy5g7uNV1ykySFt7eWSp3i2uSGFwaJcvIRJBAHmmFg==} peerDependencies: @@ -1244,6 +1275,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-select@2.1.6': + resolution: {integrity: sha512-T6ajELxRvTuAMWH0YmRJ1qez+x4/7Nq7QIx7zJ0VK3qaEWdnWpNbEDnmWldG1zBDwqrLy5aLMUWcoGirVj5kMg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-slot@1.1.2': resolution: {integrity: sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==} peerDependencies: @@ -1266,6 +1310,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-tooltip@1.1.8': + resolution: {integrity: sha512-YAA2cu48EkJZdAMHC0dqo9kialOcRStbtiY4nJPaht7Ptrhcvpo+eDChaM6BIs8kL6a8Z5l5poiqLnXcNduOkA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-use-callback-ref@1.1.0': resolution: {integrity: sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==} peerDependencies: @@ -1302,6 +1359,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-previous@1.1.0': + resolution: {integrity: sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-rect@1.1.0': resolution: {integrity: sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==} peerDependencies: @@ -1320,6 +1386,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-visually-hidden@1.1.2': + resolution: {integrity: sha512-1SzA4ns2M1aRlvxErqhLHsBHoS5eI5UUcI2awAMgGUp4LoaoWOKYmvqDY2s/tltuPkh3Yk77YF/r3IRj+Amx4Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/rect@1.1.0': resolution: {integrity: sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==} @@ -1707,6 +1786,9 @@ packages: engines: {node: '>=10'} deprecated: This package is no longer supported. + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -2333,6 +2415,11 @@ packages: resolution: {integrity: sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + esquery@1.6.0: resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} engines: {node: '>=0.10'} @@ -2356,6 +2443,10 @@ packages: resolution: {integrity: sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA==} engines: {node: ^14.18.0 || ^16.14.0 || >=18.0.0} + extend-shallow@2.0.1: + resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} + engines: {node: '>=0.10.0'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -2514,6 +2605,10 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + gray-matter@4.0.3: + resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} + engines: {node: '>=6.0'} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -2653,6 +2748,10 @@ packages: is-decimal@2.0.1: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-extendable@0.1.1: + resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} + engines: {node: '>=0.10.0'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -2767,6 +2866,10 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-yaml@3.14.1: + resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} + hasBin: true + js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true @@ -2794,6 +2897,10 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + kleur@3.0.3: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} @@ -3608,6 +3715,10 @@ packages: scheduler@0.25.0: resolution: {integrity: sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==} + section-matter@1.0.0: + resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} + engines: {node: '>=4'} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -3617,6 +3728,9 @@ packages: engines: {node: '>=10'} hasBin: true + server-only@0.0.1: + resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} @@ -3701,6 +3815,9 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} @@ -3753,6 +3870,10 @@ packages: resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} engines: {node: '>=12'} + strip-bom-string@1.0.0: + resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==} + engines: {node: '>=0.10.0'} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -4697,6 +4818,8 @@ snapshots: '@petamoriken/float16@3.9.2': {} + '@radix-ui/number@1.1.0': {} + '@radix-ui/primitive@1.1.1': {} '@radix-ui/react-arrow@1.1.2(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': @@ -4775,6 +4898,15 @@ snapshots: optionalDependencies: '@types/react': 19.0.12 + '@radix-ui/react-label@2.1.2(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.12 + '@types/react-dom': 19.0.4(@types/react@19.0.12) + '@radix-ui/react-popover@1.1.6(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@radix-ui/primitive': 1.1.1 @@ -4862,6 +4994,35 @@ snapshots: '@types/react': 19.0.12 '@types/react-dom': 19.0.4(@types/react@19.0.12) + '@radix-ui/react-select@2.1.6(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/number': 1.1.0 + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-collection': 1.1.2(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.12)(react@19.0.0) + '@radix-ui/react-context': 1.1.1(@types/react@19.0.12)(react@19.0.0) + '@radix-ui/react-direction': 1.1.0(@types/react@19.0.12)(react@19.0.0) + '@radix-ui/react-dismissable-layer': 1.1.5(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-focus-guards': 1.1.1(@types/react@19.0.12)(react@19.0.0) + '@radix-ui/react-focus-scope': 1.1.2(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-id': 1.1.0(@types/react@19.0.12)(react@19.0.0) + '@radix-ui/react-popper': 1.2.2(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-portal': 1.1.4(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-slot': 1.1.2(@types/react@19.0.12)(react@19.0.0) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@19.0.12)(react@19.0.0) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@19.0.12)(react@19.0.0) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@19.0.12)(react@19.0.0) + '@radix-ui/react-use-previous': 1.1.0(@types/react@19.0.12)(react@19.0.0) + '@radix-ui/react-visually-hidden': 1.1.2(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + aria-hidden: 1.2.4 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + react-remove-scroll: 2.6.3(@types/react@19.0.12)(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.12 + '@types/react-dom': 19.0.4(@types/react@19.0.12) + '@radix-ui/react-slot@1.1.2(@types/react@19.0.12)(react@19.0.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.12)(react@19.0.0) @@ -4885,6 +5046,26 @@ snapshots: '@types/react': 19.0.12 '@types/react-dom': 19.0.4(@types/react@19.0.12) + '@radix-ui/react-tooltip@1.1.8(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.12)(react@19.0.0) + '@radix-ui/react-context': 1.1.1(@types/react@19.0.12)(react@19.0.0) + '@radix-ui/react-dismissable-layer': 1.1.5(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-id': 1.1.0(@types/react@19.0.12)(react@19.0.0) + '@radix-ui/react-popper': 1.2.2(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-portal': 1.1.4(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-presence': 1.1.2(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-slot': 1.1.2(@types/react@19.0.12)(react@19.0.0) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@19.0.12)(react@19.0.0) + '@radix-ui/react-visually-hidden': 1.1.2(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.12 + '@types/react-dom': 19.0.4(@types/react@19.0.12) + '@radix-ui/react-use-callback-ref@1.1.0(@types/react@19.0.12)(react@19.0.0)': dependencies: react: 19.0.0 @@ -4911,6 +5092,12 @@ snapshots: optionalDependencies: '@types/react': 19.0.12 + '@radix-ui/react-use-previous@1.1.0(@types/react@19.0.12)(react@19.0.0)': + dependencies: + react: 19.0.0 + optionalDependencies: + '@types/react': 19.0.12 + '@radix-ui/react-use-rect@1.1.0(@types/react@19.0.12)(react@19.0.0)': dependencies: '@radix-ui/rect': 1.1.0 @@ -4925,6 +5112,15 @@ snapshots: optionalDependencies: '@types/react': 19.0.12 + '@radix-ui/react-visually-hidden@1.1.2(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.12 + '@types/react-dom': 19.0.4(@types/react@19.0.12) + '@radix-ui/rect@1.1.0': {} '@rtsao/scc@1.1.0': {} @@ -5324,6 +5520,10 @@ snapshots: delegates: 1.0.0 readable-stream: 3.6.2 + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + argparse@2.0.1: {} aria-hidden@1.2.4: @@ -6086,6 +6286,8 @@ snapshots: acorn-jsx: 5.3.2(acorn@8.14.1) eslint-visitor-keys: 4.2.0 + esprima@4.0.1: {} + esquery@1.6.0: dependencies: estraverse: 5.3.0 @@ -6112,6 +6314,10 @@ snapshots: signal-exit: 3.0.7 strip-final-newline: 3.0.0 + extend-shallow@2.0.1: + dependencies: + is-extendable: 0.1.1 + extend@3.0.2: {} fast-deep-equal@3.1.3: {} @@ -6298,6 +6504,13 @@ snapshots: graphemer@1.4.0: {} + gray-matter@4.0.3: + dependencies: + js-yaml: 3.14.1 + kind-of: 6.0.3 + section-matter: 1.0.0 + strip-bom-string: 1.0.0 + has-bigints@1.1.0: {} has-flag@4.0.0: {} @@ -6464,6 +6677,8 @@ snapshots: is-decimal@2.0.1: {} + is-extendable@0.1.1: {} + is-extglob@2.1.1: {} is-finalizationregistry@1.1.1: @@ -6564,6 +6779,11 @@ snapshots: js-tokens@4.0.0: {} + js-yaml@3.14.1: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + js-yaml@4.1.0: dependencies: argparse: 2.0.1 @@ -6595,6 +6815,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + kind-of@6.0.3: {} + kleur@3.0.3: {} language-subtag-registry@0.3.23: {} @@ -7679,10 +7901,17 @@ snapshots: scheduler@0.25.0: {} + section-matter@1.0.0: + dependencies: + extend-shallow: 2.0.1 + kind-of: 6.0.3 + semver@6.3.1: {} semver@7.7.1: {} + server-only@0.0.1: {} + set-blocking@2.0.0: {} set-function-length@1.2.2: @@ -7801,6 +8030,8 @@ snapshots: split2@4.2.0: {} + sprintf-js@1.0.3: {} + stable-hash@0.0.5: {} stdin-discarder@0.1.0: @@ -7882,6 +8113,8 @@ snapshots: dependencies: ansi-regex: 6.1.0 + strip-bom-string@1.0.0: {} + strip-bom@3.0.0: {} strip-final-newline@3.0.0: {} diff --git a/scripts/README.md b/scripts/README.md index 110fc34..c560936 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -4,21 +4,22 @@ This directory contains utility scripts for setting up and managing your NextWik ## Database Setup Scripts -### `setup-db.js` (Node.js) +### `setup-db.ts` (TypeScript) -A Node.js script that creates a PostgreSQL database in Docker and configures your `.env` file with the connection string. +A TypeScript script that creates a PostgreSQL database in Docker and configures your `.env` file with the connection string. ```bash # Run with npm script -npm run db:setup +pnpm run db:setup # Or directly -node scripts/setup-db.js +tsx scripts/setup-db.ts ``` ### `setup-db.sh` (Bash) -A bash script that does the same as the Node.js version but for users who prefer shell scripts. +A bash script that does the same as the TypeScript version but for users who prefer shell scripts. +Note: This script as of now does not migrate or seed the database. ```bash # Make sure it's executable first (only needed once) diff --git a/scripts/create-admin.ts b/scripts/create-admin.ts deleted file mode 100644 index 3b748c4..0000000 --- a/scripts/create-admin.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { db, users, accounts } from "~/lib/db"; -import { eq } from "drizzle-orm"; -import { hash } from "bcrypt"; -import readline from "readline"; - -// Instead of trying to locate and load .env file here, -// we'll use env-cmd to run this script: -// npx env-cmd -f .env tsx scripts/create-admin.ts - -const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, -}); - -async function promptQuestion(question: string): Promise { - return new Promise((resolve) => { - rl.question(question, (answer) => { - resolve(answer); - }); - }); -} - -async function main() { - console.log("NextWiki Admin User Creation"); - console.log("==========================="); - - // Check if DATABASE_URL is set - if (!process.env.DATABASE_URL) { - console.error("ERROR: DATABASE_URL environment variable is not set."); - console.error("Please run this script with env-cmd:"); - console.error("npx env-cmd -f .env tsx scripts/create-admin.ts"); - process.exit(1); - } - - const email = await promptQuestion("Email: "); - - // Check if user already exists - const existingUser = await db.query.users.findFirst({ - where: eq(users.email, email), - }); - - if (existingUser) { - const confirm = await promptQuestion( - `User ${email} already exists. Promote to admin? (y/n): ` - ); - if (confirm.toLowerCase() === "y") { - await db - .update(users) - .set({ isAdmin: true }) - .where(eq(users.email, email)); - - console.log(`User ${email} has been promoted to admin.`); - } else { - console.log("Operation cancelled."); - } - } else { - const name = await promptQuestion("Name: "); - const password = await promptQuestion("Password: "); - - // Hash the password for secure storage - const hashedPassword = await hash(password, 10); - - // Create the user with admin privileges and password - const [newUser] = await db - .insert(users) - .values({ - name, - email, - password: hashedPassword, // Store hashed password in the users table - isAdmin: true, - }) - .returning(); - - if (!newUser) { - throw new Error("Failed to create user"); - } - - // Create credentials entry for NextAuth - await db.insert(accounts).values({ - userId: newUser.id, - type: "credentials", - provider: "credentials", - providerAccountId: newUser.id.toString(), // Use the user ID as the provider account ID - }); - - console.log(`Admin user ${email} created successfully.`); - console.log( - `You can now log in using the email and password you provided.` - ); - } - - rl.close(); -} - -main() - .catch((err) => { - console.error("Error creating admin user:", err); - process.exit(1); - }) - .finally(() => { - process.exit(0); - }); diff --git a/scripts/setup-db.js b/scripts/setup-db.js deleted file mode 100755 index 0adf40d..0000000 --- a/scripts/setup-db.js +++ /dev/null @@ -1,201 +0,0 @@ -#!/usr/bin/env node - -const { execSync } = require('child_process'); -const fs = require('fs'); -const path = require('path'); -const readline = require('readline'); - -// Colors for terminal output -const colors = { - green: '\x1b[32m', - blue: '\x1b[34m', - red: '\x1b[31m', - reset: '\x1b[0m', -}; - -// Docker container configuration -const config = { - containerName: 'nextwiki-postgres', - dbUser: 'nextwiki', - dbPassword: 'nextwiki_password', - dbName: 'nextwiki', - dbPort: '5432', - hostPort: '5432', -}; - -// Utility to print colored messages -const print = { - info: (message) => console.log(`${colors.blue}${message}${colors.reset}`), - success: (message) => console.log(`${colors.green}${message}${colors.reset}`), - error: (message) => console.log(`${colors.red}${message}${colors.reset}`), - normal: (message) => console.log(message), -}; - -// Utility to run shell commands -function runCommand(command) { - try { - return execSync(command, { encoding: 'utf8' }); - } catch (error) { - print.error(`Command failed: ${command}`); - print.error(error.message); - throw error; - } -} - -// Check if a command exists in the PATH -function commandExists(command) { - try { - execSync(`which ${command}`, { stdio: 'ignore' }); - return true; - } catch (error) { - return false; - } -} - -// Check if Docker container exists -function containerExists(name) { - try { - const containers = runCommand('docker ps -a --format "{{.Names}}"'); - return containers.split('\n').includes(name); - } catch (error) { - return false; - } -} - -// Check if Docker container is running -function containerRunning(name) { - try { - const containers = runCommand('docker ps --format "{{.Names}}"'); - return containers.split('\n').includes(name); - } catch (error) { - return false; - } -} - -// Update .env file with database connection string -function updateEnvFile(connectionString) { - const rootDir = path.resolve(__dirname, '..'); - const envPath = path.join(rootDir, '.env'); - const envExamplePath = path.join(rootDir, '.env.example'); - - if (fs.existsSync(envPath)) { - print.normal('Updating existing .env file'); - - let envContent = fs.readFileSync(envPath, 'utf8'); - - if (envContent.includes('DATABASE_URL=')) { - // Replace existing DATABASE_URL - envContent = envContent.replace( - /^DATABASE_URL=.*/m, - `DATABASE_URL=${connectionString}` - ); - } else { - // Add DATABASE_URL to .env - envContent += `\nDATABASE_URL=${connectionString}\n`; - } - - fs.writeFileSync(envPath, envContent); - } else { - print.normal('Creating new .env file'); - - if (fs.existsSync(envExamplePath)) { - // Copy from example and update - let envContent = fs.readFileSync(envExamplePath, 'utf8'); - envContent = envContent.replace( - /^DATABASE_URL=.*/m, - `DATABASE_URL=${connectionString}` - ); - fs.writeFileSync(envPath, envContent); - } else { - // Create minimal .env file - fs.writeFileSync(envPath, `DATABASE_URL=${connectionString}\n`); - } - } -} - -// Main function -async function main() { - print.info('NextWiki Docker Database Setup'); - print.normal('This script will:'); - print.normal('1. Create a PostgreSQL database in a Docker container'); - print.normal('2. Set up the database with required credentials'); - print.normal('3. Update your .env file with the connection string'); - print.normal(''); - - // Check if Docker is installed - if (!commandExists('docker')) { - print.error('Error: Docker is not installed or not in PATH'); - print.normal('Please install Docker and try again'); - process.exit(1); - } - - // Check if Docker is running - try { - runCommand('docker info > /dev/null 2>&1'); - } catch (error) { - print.error('Error: Docker is not running'); - print.normal('Please start Docker and try again'); - process.exit(1); - } - - // Check if container already exists - if (containerExists(config.containerName)) { - print.info(`Container ${config.containerName} already exists`); - - // Check if container is running - if (containerRunning(config.containerName)) { - print.normal('Container is already running'); - } else { - print.normal('Starting existing container...'); - runCommand(`docker start ${config.containerName}`); - print.success('Container started successfully'); - } - } else { - print.info('Creating new PostgreSQL container...'); - - // Run PostgreSQL container - runCommand(`docker run --name ${config.containerName} \ - -e POSTGRES_USER=${config.dbUser} \ - -e POSTGRES_PASSWORD=${config.dbPassword} \ - -e POSTGRES_DB=${config.dbName} \ - -p ${config.hostPort}:${config.dbPort} \ - -d postgres:15`); - - print.normal('Waiting for PostgreSQL to start up...'); - await new Promise(resolve => setTimeout(resolve, 5000)); - - // Check if container is running - if (!containerRunning(config.containerName)) { - print.error('Container failed to start properly'); - print.normal(`Check Docker logs with: docker logs ${config.containerName}`); - process.exit(1); - } - - print.success('PostgreSQL container created and running'); - } - - // Create connection string - const connectionString = `postgresql://${config.dbUser}:${config.dbPassword}@localhost:${config.hostPort}/${config.dbName}`; - - // Update .env file - updateEnvFile(connectionString); - - print.success('Database setup completed successfully!'); - print.normal(`Connection string: ${connectionString}`); - print.normal(''); - print.normal('Your .env file has been updated with the database connection string.'); - print.normal(''); - print.info('Next steps:'); - print.normal('1. Run migrations: npm run db:migrate'); - print.normal('2. Start the application: npm run dev'); - print.normal(''); - print.normal(`To stop the database container: docker stop ${config.containerName}`); - print.normal(`To start it again: docker start ${config.containerName}`); -} - -// Run the main function -main().catch(error => { - print.error('An error occurred:'); - print.error(error.message); - process.exit(1); -}); \ No newline at end of file diff --git a/scripts/setup-db.ts b/scripts/setup-db.ts new file mode 100644 index 0000000..7c3b3b2 --- /dev/null +++ b/scripts/setup-db.ts @@ -0,0 +1,295 @@ +#!/usr/bin/env node + +import { execSync } from "child_process"; +import * as fs from "fs"; +import * as path from "path"; +import { Pool } from "pg"; // Import Pool for DB connection check + +// Colors for terminal output +const colors = { + green: "\x1b[32m", + blue: "\x1b[34m", + red: "\x1b[31m", + reset: "\x1b[0m", +}; + +// Docker container configuration +const config = { + containerName: "nextwiki-postgres", + dbUser: "nextwiki", + dbPassword: "nextwiki_password", + dbName: "nextwiki", + dbPort: "5432", + hostPort: "5432", // Use the same port for host and container for simplicity +}; + +// Utility to print colored messages +const print = { + info: (message: string) => + console.log(`${colors.blue}${message}${colors.reset}`), + success: (message: string) => + console.log(`${colors.green}${message}${colors.reset}`), + error: (message: string) => + console.log(`${colors.red}${message}${colors.reset}`), + normal: (message: string) => console.log(message), +}; + +// Utility to run shell commands +function runCommand(command: string, ignoreError = false): string | null { + try { + print.normal(`Executing: ${command}`); + const output = execSync(command, { encoding: "utf8", stdio: "pipe" }); // Use pipe to capture output, inherit for logs if needed + // print.normal(output); // Optionally log command output + return output; + } catch (error: unknown) { + print.error(`Command failed: ${command}`); + if (error instanceof Error && "stderr" in error) { + print.error(`Stderr: ${error.stderr}`); + } + if (error instanceof Error && "stdout" in error) { + print.error(`Stdout: ${error.stdout}`); // Log stdout on error too + } + if (!ignoreError) { + throw error; + } + return null; + } +} + +// Check if a command exists in the PATH +function commandExists(command: string): boolean { + try { + // Use 'command -v' which is more portable than 'which' + execSync(`command -v ${command}`, { stdio: "ignore" }); + return true; + } catch (error) { + void error; + return false; + } +} + +// Check if Docker container exists +function containerExists(name: string): boolean { + try { + const output = runCommand('docker ps -a --format "{{.Names}}"'); + return !!output && output.split("\n").includes(name); + } catch (error) { + void error; + // If docker command fails (e.g., docker not running), treat as container not existing + return false; + } +} + +// Check if Docker container is running +function containerRunning(name: string): boolean { + try { + const output = runCommand('docker ps --format "{{.Names}}"'); + return !!output && output.split("\n").includes(name); + } catch (error) { + void error; + // If docker command fails, treat as container not running + return false; + } +} + +// Update .env file with database connection string +function updateEnvFile(connectionString: string): void { + const rootDir = path.resolve(__dirname, ".."); + const envPath = path.join(rootDir, ".env"); + const envExamplePath = path.join(rootDir, ".env.example"); + const dbUrlKey = "DATABASE_URL"; + const dbUrlLine = `${dbUrlKey}=${connectionString}`; + + print.normal(`Ensuring ${dbUrlKey} is set in ${envPath}`); + + if (fs.existsSync(envPath)) { + let envContent = fs.readFileSync(envPath, "utf8"); + const dbUrlRegex = new RegExp(`^${dbUrlKey}=.*`, "m"); + + if (dbUrlRegex.test(envContent)) { + // Replace existing DATABASE_URL + envContent = envContent.replace(dbUrlRegex, dbUrlLine); + print.normal(`Updated existing ${dbUrlKey}.`); + } else { + // Add DATABASE_URL to .env + envContent += `\n${dbUrlLine}\n`; + print.normal(`Added ${dbUrlKey}.`); + } + fs.writeFileSync(envPath, envContent); + } else { + print.normal(`Creating ${envPath} from scratch or example.`); + let envContent = ""; + if (fs.existsSync(envExamplePath)) { + envContent = fs.readFileSync(envExamplePath, "utf8"); + const dbUrlRegex = new RegExp(`^${dbUrlKey}=.*`, "m"); + if (dbUrlRegex.test(envContent)) { + envContent = envContent.replace(dbUrlRegex, dbUrlLine); + } else { + envContent += `\n${dbUrlLine}\n`; + } + print.normal(`Used ${envExamplePath} as template.`); + } else { + // Create minimal .env file + envContent = `${dbUrlLine}\n`; + print.normal(`Created minimal ${envPath}.`); + } + fs.writeFileSync(envPath, envContent); + } +} + +// Function to wait for DB connection +async function waitForDB( + connectionString: string, + retries = 15, + delay = 3000 +): Promise { + print.info(`Attempting to connect to database... (up to ${retries} retries)`); + for (let i = 0; i < retries; i++) { + try { + const pool = new Pool({ connectionString }); + await pool.query("SELECT 1"); + await pool.end(); + print.success("Database connection successful!"); + return; + } catch (error: unknown) { + print.normal( + `Attempt ${i + 1} failed. Retrying in ${delay / 1000}s... (${ + error instanceof Error ? error.message : String(error) + })` + ); + if (i === retries - 1) { + print.error("Database connection failed after multiple retries."); + throw error; + } + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } +} + +// Main function +async function main(): Promise { + print.info("🚀 NextWiki Docker Database Setup 🚀"); + print.normal("This script will:"); + print.normal("1. Check for Docker and required commands (pnpm)."); + print.normal("2. Ensure a PostgreSQL container is running."); + print.normal("3. Update your .env file with the connection string."); + print.normal("4. Wait for the database to be ready."); + print.normal("5. Generate Drizzle migrations."); + print.normal("6. Apply Drizzle migrations."); + print.normal("7. Seed the database."); + print.normal(""); + + // --- Prerequisites Check --- + if (!commandExists("docker")) { + print.error("❌ Error: Docker is not installed or not in PATH."); + print.normal("Please install Docker: https://docs.docker.com/get-docker/"); + process.exit(1); + } + if (!commandExists("pnpm")) { + print.error("❌ Error: pnpm is not installed or not in PATH."); + print.normal("Please install pnpm: https://pnpm.io/installation"); + process.exit(1); + } + + try { + runCommand("docker info", true); // Check if Docker daemon is running + } catch (error) { + void error; + print.error("❌ Error: Docker daemon is not running."); + print.normal("Please start Docker and try again."); + process.exit(1); + } + print.success("✅ Prerequisites met (Docker, pnpm)."); + + // --- Docker Container Setup --- + if (containerExists(config.containerName)) { + print.info(`➡️ Container "${config.containerName}" already exists.`); + if (!containerRunning(config.containerName)) { + print.normal("Starting existing container..."); + runCommand(`docker start ${config.containerName}`); + print.success("Container started."); + } else { + print.normal("Container is already running."); + } + } else { + print.info( + `✨ Creating new PostgreSQL container "${config.containerName}"...` + ); + const dockerCommand = `docker run --name ${config.containerName} \ + -e POSTGRES_USER=${config.dbUser} \ + -e POSTGRES_PASSWORD=${config.dbPassword} \ + -e POSTGRES_DB=${config.dbName} \ + -p ${config.hostPort}:${config.dbPort} \ + --health-cmd="pg_isready -U ${config.dbUser} -d ${config.dbName}" \ + --health-interval=5s \ + --health-timeout=5s \ + --health-retries=5 \ + -d postgres:17`; // Using postgres:17, consider locking version or making configurable + runCommand(dockerCommand); + + // Wait briefly for container to initialize before checking health/connection + print.normal("Waiting a few seconds for container to initialize..."); + await new Promise((resolve) => setTimeout(resolve, 5000)); + + if (!containerRunning(config.containerName)) { + print.error("❌ Container failed to start properly."); + print.normal(`Check Docker logs: docker logs ${config.containerName}`); + process.exit(1); + } + print.success("✅ PostgreSQL container created and running."); + } + + // --- Environment Setup --- + const connectionString = `postgresql://${config.dbUser}:${config.dbPassword}@localhost:${config.hostPort}/${config.dbName}`; + updateEnvFile(connectionString); + print.success("✅ .env file updated successfully."); + print.normal( + `Connection string: ${connectionString.replace(config.dbPassword, "****")}` + ); // Hide password in log + + // --- Wait for DB --- + await waitForDB(connectionString); + + // --- Database Schema and Data --- + try { + print.info("🔄 Generating database migrations..."); + runCommand("pnpm db:generate"); + print.success("✅ Migrations generated successfully."); + + print.info("🔄 Applying database migrations..."); + runCommand("pnpm db:migrate"); // Assumes 'db:migrate' script uses tsx + print.success("✅ Migrations applied successfully."); + + print.info("🌱 Seeding database..."); + runCommand("pnpm db:seed"); // Assumes 'db:seed' script uses tsx + print.success("✅ Database seeded successfully."); + } catch (error) { + void error; + print.error("❌ An error occurred during migration or seeding."); + // Error details are already printed by runCommand + process.exit(1); + } + + // --- Completion --- + print.success("🎉 All setup steps completed successfully! 🎉"); + print.normal(""); + print.info("Next steps:"); + print.normal(" Start the application: pnpm dev"); + print.normal(""); + print.info("Container management:"); + print.normal(` Stop the database: docker stop ${config.containerName}`); + print.normal(` Start the database: docker start ${config.containerName}`); + print.normal(` View logs: docker logs ${config.containerName}`); + print.normal(` Remove the container: docker rm -f ${config.containerName}`); + print.normal( + ` Remove container and data: docker rm -f -v ${config.containerName}` + ); +} + +// Run the main function +main().catch((error) => { + void error; + // Error details should have been printed already by internal functions + print.error("❌ Setup script failed."); + process.exit(1); +}); diff --git a/src/app/[...path]/not-found.tsx b/src/app/[...path]/not-found.tsx index a8fe562..f783bfd 100644 --- a/src/app/[...path]/not-found.tsx +++ b/src/app/[...path]/not-found.tsx @@ -6,9 +6,9 @@ export default function WikiNotFound() { return (
-
404
+
404

Wiki Page Not Found

-

+

The wiki page you're looking for doesn't exist or has been moved.

diff --git a/src/app/admin/[404]/page.tsx b/src/app/admin/[404]/page.tsx new file mode 100644 index 0000000..7ea5bc3 --- /dev/null +++ b/src/app/admin/[404]/page.tsx @@ -0,0 +1,7 @@ +import { notFound } from "next/navigation"; + +// This is a page that is used to trigger a 404 error, because we have a catch-all route for all pages in +// app/[...path]/page.tsx, and we want to trigger a 404 error for all admin pages. +export default function NotFoundTrigger() { + notFound(); +} diff --git a/src/app/admin/assets/page.tsx b/src/app/admin/assets/page.tsx index 86b06f1..76f34c0 100644 --- a/src/app/admin/assets/page.tsx +++ b/src/app/admin/assets/page.tsx @@ -3,7 +3,15 @@ import { useState } from "react"; import { trpc } from "~/lib/trpc/client"; import { useNotification } from "~/lib/hooks/useNotification"; -import { AdminLayout } from "~/components/layout/AdminLayout"; +import { Input } from "~/components/ui/input"; +import Image from "next/image"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; export default function AssetsAdminPage() { const notification = useNotification(); @@ -52,165 +60,169 @@ export default function AssetsAdminPage() { : []; return ( - -
-

Asset Management

+
+

Asset Management

- {/* Search and filter controls */} -
-
- setSearchTerm(e.target.value)} - /> -
-
- setSearchTerm(e.target.value)} + /> +
+
+ -
+ +
+
- {/* Loading state */} - {assetsQuery.isLoading && ( -
-
- Loading assets... -
- )} + {/* Loading state */} + {assetsQuery.isLoading && ( +
+
+ Loading assets... +
+ )} - {/* Empty state */} - {!assetsQuery.isLoading && filteredAssets.length === 0 && ( -
- - - -

- No assets found -

-

- {searchTerm || selectedFileType !== "all" - ? "Try adjusting your search or filter criteria" - : "Upload some files through the wiki editor to get started"} -

-
- )} + {/* Empty state */} + {!assetsQuery.isLoading && filteredAssets.length === 0 && ( +
+ + + +

+ No assets found +

+

+ {searchTerm || selectedFileType !== "all" + ? "Try adjusting your search or filter criteria" + : "Upload some files through the wiki editor to get started"} +

+
+ )} - {/* Asset table */} - {!assetsQuery.isLoading && filteredAssets.length > 0 && ( -
- - - - - - - - - - - - - - - {filteredAssets.map((asset) => ( - - - - - - - + + + + + + + + + ))} + +
PreviewFile NameTypeSizeUploaded ByPageDateActions
- {asset.fileType.startsWith("image/") ? ( - {asset.fileName} - ) : ( -
- - - -
- )} -
{asset.fileName}{asset.fileType} - {formatFileSize(asset.fileSize)} - - {asset.uploadedBy?.name || "Unknown"} - - {asset.pageId ? ( - 0 && ( +
+ + + + + + + + + + + + + + + {filteredAssets.map((asset) => ( + + - - - - ))} - -
PreviewFile NameTypeSizeUploaded ByPageDateActions
+ {asset.fileType.startsWith("image/") ? ( + {asset.fileName} + ) : ( +
+ - View Page - - ) : ( - "Not attached" - )} -
{formatDate(asset.createdAt)} -
- - View - - + +
-
-
- )} - - + )} +
{asset.fileName}{asset.fileType} + {formatFileSize(asset.fileSize)} + + {asset.uploadedBy?.name || "Unknown"} + + {asset.pageId ? ( + + View Page + + ) : ( + "Not attached" + )} + {formatDate(asset.createdAt)} +
+ + View + + +
+
+
+ )} +
); } diff --git a/src/app/admin/dashboard/page.tsx b/src/app/admin/dashboard/page.tsx new file mode 100644 index 0000000..209b9a4 --- /dev/null +++ b/src/app/admin/dashboard/page.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card } from "~/components/ui/card"; + +interface StatsItem { + title: string; + value: number | string; + icon: React.ReactNode; +} + +export default function AdminDashboardPage() { + const [isLoading, setIsLoading] = useState(true); + + // In a real implementation, you would fetch these values from your API + const [stats, setStats] = useState([ + { + title: "Total Pages", + value: "...", + icon: ( + + + + ), + }, + { + title: "Total Users", + value: "...", + icon: ( + + + + ), + }, + { + title: "Total Assets", + value: "...", + icon: ( + + + + ), + }, + { + title: "User Groups", + value: "...", + icon: ( + + + + ), + }, + ]); + + // Simulate loading data + useEffect(() => { + const timer = setTimeout(() => { + setStats([ + { ...stats[0], value: 42 }, + { ...stats[1], value: 15 }, + { ...stats[2], value: 87 }, + { ...stats[3], value: 5 }, + ]); + setIsLoading(false); + }, 1000); + + return () => clearTimeout(timer); + }); + + return ( +
+
+

Admin Dashboard

+

Overview of your NextWiki system

+
+ +
+ {stats.map((stat, index) => ( + +
+
+ {stat.icon} +
+
+

+ {stat.title} +

+

+ {isLoading ? ( + + ) : ( + stat.value + )} +

+
+
+
+ ))} +
+ +
+ +

Recent Pages

+ {isLoading ? ( +
+ {[...Array(3)].map((_, i) => ( +
+ ))} +
+ ) : ( +
+

+ Recent page activity will be shown here +

+
+ )} +
+ + +

System Health

+ {isLoading ? ( +
+ {[...Array(3)].map((_, i) => ( +
+ ))} +
+ ) : ( +
+
+ Database + Healthy +
+
+ Storage + Healthy +
+
+ Cache + Healthy +
+
+ )} +
+
+
+ ); +} diff --git a/src/app/admin/groups/[id]/edit/page.tsx b/src/app/admin/groups/[id]/edit/page.tsx new file mode 100644 index 0000000..9873736 --- /dev/null +++ b/src/app/admin/groups/[id]/edit/page.tsx @@ -0,0 +1,81 @@ +import { Metadata } from "next"; +import { getServerAuthSession } from "~/lib/auth"; +import { redirect } from "next/navigation"; +import { dbService } from "~/lib/services"; +import GroupForm from "../../group-form"; +import { notFound } from "next/navigation"; + +interface EditGroupPageProps { + params: { + id: string; + }; +} + +export async function generateMetadata({ + params, +}: EditGroupPageProps): Promise { + const group = await dbService.groups.getById(parseInt(params.id)); + return { + title: `Edit ${group?.name ?? "Group"} | NextWiki`, + description: "Edit user group settings and permissions", + }; +} + +export default async function EditGroupPage({ params }: EditGroupPageProps) { + const session = await getServerAuthSession(); + + // Redirect if not logged in + if (!session?.user) { + redirect("/login"); + } + + // Redirect if not admin + if (!session.user.isAdmin) { + redirect("/"); + } + + const group = await dbService.groups.getById(parseInt(params.id)); + if (!group) { + notFound(); + } + + // Get all permissions + const permissions = await dbService.permissions.getAll(); + + // Get group permissions + const groupPermissions = await dbService.groups.getGroupPermissions(group.id); + const groupPermissionIds = groupPermissions.map((p) => p.id); + + // Get module permissions + const modulePermissions = await dbService.groups.getModulePermissions( + group.id + ); + const modulePermissionModules = modulePermissions.map((p) => p.module); + + // Get action permissions + const actionPermissions = await dbService.groups.getActionPermissions( + group.id + ); + const actionPermissionActions = actionPermissions.map((p) => p.action); + + return ( +
+

Edit Group

+ +
+

+ Edit group settings and configure its permissions. Changes will affect + all users in this group. +

+ + +
+
+ ); +} diff --git a/src/app/admin/groups/[id]/users/add-users-modal.tsx b/src/app/admin/groups/[id]/users/add-users-modal.tsx new file mode 100644 index 0000000..f48294d --- /dev/null +++ b/src/app/admin/groups/[id]/users/add-users-modal.tsx @@ -0,0 +1,215 @@ +"use client"; + +import { useState } from "react"; +import { UserPlus, Loader2, Search } from "lucide-react"; +import { Button } from "~/components/ui/button"; +import Modal from "~/components/ui/modal"; +import { Input } from "~/components/ui/input"; +import { Checkbox } from "~/components/ui/checkbox"; +import { api } from "~/lib/trpc/providers"; +import { toast } from "sonner"; + +interface AddUsersModalProps { + groupId: number; + groupName: string; +} + +export default function AddUsersModal({ + groupId, + groupName, +}: AddUsersModalProps) { + const [isModalOpen, setIsModalOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [selectedUsers, setSelectedUsers] = useState([]); + const [isAdding, setIsAdding] = useState(false); + + // Get all users + const { data: allUsers, isLoading: loadingUsers } = api.users.getAll.useQuery( + undefined, + { + enabled: isModalOpen, + } + ); + + // Get existing group users to exclude them + const { data: groupUsers } = api.groups.getGroupUsers.useQuery( + { + groupId, + }, + { + enabled: isModalOpen, + } + ); + + // Filter users that are not in the group already and match the search query + const filteredUsers = allUsers?.filter((user) => { + // Filter out users already in the group + const alreadyInGroup = groupUsers?.some( + (groupUser) => groupUser.id === user.id + ); + if (alreadyInGroup) return false; + + // Filter by search query + if (!searchQuery) return true; + + const query = searchQuery.toLowerCase(); + return ( + user.name?.toLowerCase().includes(query) || + false || + user.email.toLowerCase().includes(query) + ); + }); + + const addUsersMutation = api.groups.addUsers.useMutation({ + onSuccess: () => { + toast.success("Users added to group successfully"); + setIsModalOpen(false); + // Refresh the page to update the user list + window.location.reload(); + }, + onError: (error) => { + toast.error(error.message || "Failed to add users to group"); + setIsAdding(false); + }, + }); + + const handleAddUsers = async () => { + if (selectedUsers.length === 0) { + toast.error("Please select at least one user"); + return; + } + + setIsAdding(true); + try { + await addUsersMutation.mutate({ + groupId, + userIds: selectedUsers, + }); + } catch { + setIsAdding(false); + } + }; + + const toggleUserSelection = (userId: number) => { + setSelectedUsers((prev) => + prev.includes(userId) + ? prev.filter((id) => id !== userId) + : [...prev, userId] + ); + }; + + const handleCloseModal = () => { + if (!isAdding) { + setIsModalOpen(false); + setSelectedUsers([]); + setSearchQuery(""); + } + }; + + return ( + <> + + + {isModalOpen && ( + +
+

+ Add Users to {groupName} +

+ +
+ + setSearchQuery(e.target.value)} + /> +
+ +
+ {loadingUsers ? ( +
+ +
+ ) : filteredUsers && filteredUsers.length > 0 ? ( +
+ + + + + + + + + + {filteredUsers.map((user) => ( + + + + + + ))} + +
NameEmail
+ toggleUserSelection(user.id)} + /> + {user.name}{user.email}
+
+ ) : ( +

+ {searchQuery + ? "No matching users found" + : "No users available to add to this group"} +

+ )} +
+ +
+
+ {selectedUsers.length} users selected +
+
+ + +
+
+
+
+ )} + + ); +} diff --git a/src/app/admin/groups/[id]/users/page.tsx b/src/app/admin/groups/[id]/users/page.tsx new file mode 100644 index 0000000..66cd3e5 --- /dev/null +++ b/src/app/admin/groups/[id]/users/page.tsx @@ -0,0 +1,98 @@ +import { Metadata } from "next"; +import { getServerAuthSession } from "~/lib/auth"; +import { redirect } from "next/navigation"; +import { dbService } from "~/lib/services"; +import Link from "next/link"; +import { Button } from "~/components/ui/button"; +import { ArrowLeft } from "lucide-react"; +import RemoveUserModal from "./remove-user-modal"; +import AddUsersModal from "./add-users-modal"; + +export const metadata: Metadata = { + title: "Admin - Group Users | NextWiki", + description: "Manage users in a group", +}; + +export default async function GroupUsersPage({ + params, +}: { + params: { id: string }; +}) { + const session = await getServerAuthSession(); + + // Redirect if not logged in + if (!session?.user) { + redirect("/login"); + } + + // Redirect if not admin + if (!session.user.isAdmin) { + redirect("/"); + } + + const groupId = parseInt(params.id); + const group = await dbService.groups.getById(groupId); + + if (!group) { + redirect("/admin/groups"); + } + + const users = await dbService.groups.getGroupUsers(groupId); + + return ( +
+
+ + + +

Group Members: {group.name}

+
+ +
+
+

Users in this group

+ +
+

+ Users in this group inherit all permissions assigned to the group. You + can add or remove users from this group. +

+ + {users.length > 0 ? ( +
+ + + + + + + + + + {users.map((user) => ( + + + + + + ))} + +
NameEmailActions
{user.name}{user.email} + +
+
+ ) : ( +

+ No users in this group yet. +

+ )} +
+
+ ); +} diff --git a/src/app/admin/groups/[id]/users/remove-user-modal.tsx b/src/app/admin/groups/[id]/users/remove-user-modal.tsx new file mode 100644 index 0000000..c55e38f --- /dev/null +++ b/src/app/admin/groups/[id]/users/remove-user-modal.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { useState } from "react"; +import { Trash2, Loader2 } from "lucide-react"; +import { Button } from "~/components/ui/button"; +import Modal from "~/components/ui/modal"; +import { api } from "~/lib/trpc/providers"; +import { toast } from "sonner"; + +interface RemoveUserModalProps { + groupId: number; + userId: number; + userName: string; +} + +export default function RemoveUserModal({ + groupId, + userId, + userName, +}: RemoveUserModalProps) { + const [isModalOpen, setIsModalOpen] = useState(false); + const [isRemoving, setIsRemoving] = useState(false); + + const removeUserMutation = api.groups.removeUsers.useMutation({ + onSuccess: () => { + toast.success("User removed from group successfully"); + setIsModalOpen(false); + // Refresh the page to update the user list + window.location.reload(); + }, + onError: (error) => { + toast.error(error.message || "Failed to remove user from group"); + setIsRemoving(false); + }, + }); + + const handleRemove = async () => { + setIsRemoving(true); + try { + await removeUserMutation.mutate({ + groupId, + userIds: [userId], + }); + } catch { + setIsRemoving(false); + } + }; + + return ( + <> + + + {isModalOpen && ( + !isRemoving && setIsModalOpen(false)} + size="sm" + position="center" + animation="fade" + closeOnEscape={!isRemoving} + showCloseButton={!isRemoving} + > +
+

Remove User from Group

+

+ Are you sure you want to remove{" "} + {userName} from this group? + This action cannot be undone. +

+
+ + +
+
+
+ )} + + ); +} diff --git a/src/app/admin/groups/group-form.tsx b/src/app/admin/groups/group-form.tsx new file mode 100644 index 0000000..87b1254 --- /dev/null +++ b/src/app/admin/groups/group-form.tsx @@ -0,0 +1,428 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { api } from "~/lib/trpc/providers"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { Textarea } from "~/components/ui/textarea"; +import { toast } from "sonner"; +import { Checkbox } from "~/components/ui/checkbox"; +import { Label } from "~/components/ui/label"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "~/components/ui/tooltip"; + +interface GroupFormProps { + group?: { + id: number; + name: string; + description: string | null; + isLocked?: boolean; + }; + permissions: { + id: number; + name: string; + description: string | null; + module: string; + resource: string; + action: string; + }[]; + groupPermissions?: number[]; + groupModulePermissions?: string[]; + groupActionPermissions?: string[]; +} + +export default function GroupForm({ + group, + permissions, + groupPermissions = [], + groupModulePermissions = [], + groupActionPermissions = [], +}: GroupFormProps) { + const router = useRouter(); + const [name, setName] = useState(group?.name ?? ""); + const [description, setDescription] = useState(group?.description ?? ""); + const [selectedPermissions, setSelectedPermissions] = + useState(groupPermissions); + const [selectedModules, setSelectedModules] = useState( + groupModulePermissions + ); + const [selectedActions, setSelectedActions] = useState( + groupActionPermissions + ); + + const isLocked = group?.isLocked ?? false; + + // Fetch available modules and actions + const { data: availableModules = [] } = api.permissions.getModules.useQuery(); + const { data: availableActions = [] } = api.permissions.getActions.useQuery(); + + const createGroup = api.groups.create.useMutation({ + onSuccess: () => { + toast.success("Group created successfully"); + router.push("/admin/groups"); + }, + onError: (error) => { + toast.error(error.message); + }, + }); + + const updateGroup = api.groups.update.useMutation({ + onSuccess: () => { + toast.success("Group updated successfully"); + router.push("/admin/groups"); + }, + onError: (error) => { + toast.error(error.message); + }, + }); + + const addPermissions = api.groups.addPermissions.useMutation({ + onError: (error) => { + toast.error(error.message); + }, + }); + + const addModulePermissions = api.groups.addModulePermissions.useMutation({ + onError: (error) => { + toast.error(error.message); + }, + }); + + const addActionPermissions = api.groups.addActionPermissions.useMutation({ + onError: (error) => { + toast.error(error.message); + }, + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + try { + if (group) { + // Update existing group + const updatedGroup = await updateGroup.mutateAsync({ + id: group.id, + name, + description, + }); + + // Update permissions + await addPermissions.mutateAsync({ + groupId: updatedGroup.id, + permissionIds: selectedPermissions, + }); + + // Update module permissions + await addModulePermissions.mutateAsync({ + groupId: updatedGroup.id, + permissions: selectedModules.map((module) => ({ + module, + isAllowed: true, + })), + }); + + // Update action permissions + await addActionPermissions.mutateAsync({ + groupId: updatedGroup.id, + permissions: selectedActions.map((action) => ({ + action, + isAllowed: true, + })), + }); + } else { + // Create new group + const newGroup = await createGroup.mutateAsync({ + name, + description, + }); + + // Add permissions + await addPermissions.mutateAsync({ + groupId: newGroup.id, + permissionIds: selectedPermissions, + }); + + // Add module permissions + await addModulePermissions.mutateAsync({ + groupId: newGroup.id, + permissions: selectedModules.map((module) => ({ + module, + isAllowed: true, + })), + }); + + // Add action permissions + await addActionPermissions.mutateAsync({ + groupId: newGroup.id, + permissions: selectedActions.map((action) => ({ + action, + isAllowed: true, + })), + }); + } + } catch (error) { + console.error("Error saving group:", error); + } + }; + + // Group permissions by module + const permissionsByModule = permissions.reduce((acc, permission) => { + if (!acc[permission.module]) { + acc[permission.module] = []; + } + acc[permission.module].push(permission); + return acc; + }, {} as Record); + + // Check if a permission is allowed based on module and action permissions + const isPermissionAllowed = (permission: (typeof permissions)[0]) => { + // If no modules are selected, all modules are allowed + if (selectedModules.length === 0) { + // If no actions are selected, all actions are allowed + if (selectedActions.length === 0) return true; + // Otherwise, check if the action is allowed + return selectedActions.includes(permission.action); + } + // If modules are selected, check if the module is allowed + if (!selectedModules.includes(permission.module)) return false; + // If actions are selected, check if the action is allowed + if ( + selectedActions.length > 0 && + !selectedActions.includes(permission.action) + ) + return false; + return true; + }; + + return ( +
+
+
+ + setName(e.target.value)} + required + disabled={isLocked} + /> +
+
+ +