From dd3c321350bfafe60ca9e880b4e380cf728cc1fc Mon Sep 17 00:00:00 2001 From: shrt Date: Sat, 8 Mar 2025 10:29:44 +0100 Subject: [PATCH] admin dashboard user managment ui; set users permission actions --- package.json | 4 + pnpm-lock.yaml | 165 ++++++++++++++++++ src/app/admin/_components/admin-sidebar.tsx | 8 +- .../_components/user-permissions-form.tsx | 112 ++++++++++++ .../_components/user-permissions-popover.tsx | 35 ++++ src/app/admin/benutzer/columns.tsx | 113 ++++++++++++ src/app/admin/{team => benutzer}/page.tsx | 2 +- src/app/admin/team/columns.tsx | 50 ------ src/app/layout.tsx | 6 +- src/components/copy-to-clip.tsx | 44 +++++ src/components/data-table.tsx | 80 --------- src/components/data-table/index.tsx | 142 +++++++++++++++ src/components/data-table/pagination.tsx | 99 +++++++++++ src/components/ui/checkbox.tsx | 30 ++++ src/components/ui/select.tsx | 159 +++++++++++++++++ src/components/ui/sonner.tsx | 31 ++++ src/lib/validation/permissions.tsx | 14 +- src/server/actions/user.ts | 11 ++ src/server/api/routers/users.ts | 23 ++- 19 files changed, 988 insertions(+), 140 deletions(-) create mode 100644 src/app/admin/_components/user-permissions-form.tsx create mode 100644 src/app/admin/_components/user-permissions-popover.tsx create mode 100644 src/app/admin/benutzer/columns.tsx rename src/app/admin/{team => benutzer}/page.tsx (88%) delete mode 100644 src/app/admin/team/columns.tsx create mode 100644 src/components/copy-to-clip.tsx delete mode 100644 src/components/data-table.tsx create mode 100644 src/components/data-table/index.tsx create mode 100644 src/components/data-table/pagination.tsx create mode 100644 src/components/ui/checkbox.tsx create mode 100644 src/components/ui/select.tsx create mode 100644 src/components/ui/sonner.tsx create mode 100644 src/server/actions/user.ts diff --git a/package.json b/package.json index c5d77a6..794f04a 100644 --- a/package.json +++ b/package.json @@ -24,9 +24,11 @@ "@hookform/resolvers": "^4.1.3", "@paralleldrive/cuid2": "^2.2.2", "@radix-ui/react-avatar": "^1.1.3", + "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-popover": "^1.1.6", + "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.8", @@ -50,12 +52,14 @@ "lucide-react": "^0.477.0", "next": "^15.0.1", "next-auth": "5.0.0-beta.25", + "next-themes": "^0.4.4", "postgres": "^3.4.4", "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.54.2", "react-textarea-autosize": "^8.5.7", "server-only": "^0.0.1", + "sonner": "^2.0.1", "superjson": "^2.2.1", "tailwind-merge": "^3.0.2", "tailwindcss-animate": "^1.0.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 440415b..bf1f4d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@radix-ui/react-avatar': specifier: ^1.1.3 version: 1.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-checkbox': + specifier: ^1.1.4 + version: 1.1.4(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-dialog': specifier: ^1.1.6 version: 1.1.6(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -29,6 +32,9 @@ importers: '@radix-ui/react-popover': specifier: ^1.1.6 version: 1.1.6(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-select': + specifier: ^2.1.6 + version: 2.1.6(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-separator': specifier: ^1.1.2 version: 1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -98,6 +104,9 @@ importers: next-auth: specifier: 5.0.0-beta.25 version: 5.0.0-beta.25(next@15.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + next-themes: + specifier: ^0.4.4 + version: 0.4.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) postgres: specifier: ^3.4.4 version: 3.4.5 @@ -116,6 +125,9 @@ importers: server-only: specifier: ^0.0.1 version: 0.0.1 + sonner: + specifier: ^2.0.1 + version: 2.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) superjson: specifier: ^2.2.1 version: 2.2.2 @@ -931,6 +943,9 @@ packages: '@popperjs/core@2.11.8': resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} + '@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==} @@ -960,6 +975,32 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-checkbox@1.1.4': + resolution: {integrity: sha512-wP0CPAHq+P5I4INKe3hJrIa1WoNqqrejzW+zoU0rOvo1b9gDEJJFl2rYfO1PYJUQCc2H1WZxIJmyv9BS8i5fLw==} + 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-collection@1.1.2': + resolution: {integrity: sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw==} + 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-compose-refs@1.1.1': resolution: {integrity: sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==} peerDependencies: @@ -991,6 +1032,15 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-direction@1.1.0': + resolution: {integrity: sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-dismissable-layer@1.1.5': resolution: {integrity: sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==} peerDependencies: @@ -1113,6 +1163,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-separator@1.1.2': resolution: {integrity: sha512-oZfHcaAp2Y6KFBX6I5P1u7CQoy4lheCGiYj+pGFrHy8E/VNRb5E39TkTr3JrV520csPBTZjkuKFdEsjS5EUNKQ==} peerDependencies: @@ -1184,6 +1247,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: @@ -2573,6 +2645,12 @@ packages: nodemailer: optional: true + next-themes@0.4.4: + resolution: {integrity: sha512-LDQ2qIOJF0VnuVrrMSMLrWGjRMkq+0mpgl6e0juCLqdJ+oo8Q84JRWT6Wh11VDQKkMMe+dVzDKLWs5n87T+PkQ==} + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + next@15.2.1: resolution: {integrity: sha512-zxbsdQv3OqWXybK5tMkPCBKyhIz63RstJ+NvlfkaLMc/m5MwXgz2e92k+hSKcyBpyADhMk2C31RIiaDjUZae7g==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} @@ -3089,6 +3167,12 @@ packages: simple-swizzle@0.2.2: resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} + sonner@2.0.1: + resolution: {integrity: sha512-FRBphaehZ5tLdLcQ8g2WOIRE+Y7BCfWi5Zyd8bCvBjiW8TxxAyoWZIxS661Yz6TGPqFQ4VLzOF89WEYhfynSFQ==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -3896,6 +3980,8 @@ snapshots: '@popperjs/core@2.11.8': {} + '@radix-ui/number@1.1.0': {} + '@radix-ui/primitive@1.1.1': {} '@radix-ui/react-arrow@1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': @@ -3919,6 +4005,34 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-checkbox@1.1.4(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-presence': 1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.0(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + + '@radix-ui/react-collection@1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.1.2(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-compose-refs@1.1.1(@types/react@18.3.18)(react@18.3.1)': dependencies: react: 18.3.1 @@ -3953,6 +4067,12 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-direction@1.1.0(@types/react@18.3.18)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.18 + '@radix-ui/react-dismissable-layer@1.1.5(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.1 @@ -4069,6 +4189,35 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-select@2.1.6(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/number': 1.1.0 + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-collection': 1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-direction': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.5(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-popper': 1.2.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.4(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.1.2(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-visually-hidden': 1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + aria-hidden: 1.2.4 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.6.3(@types/react@18.3.18)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-separator@1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-primitive': 2.0.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -4131,6 +4280,12 @@ snapshots: optionalDependencies: '@types/react': 18.3.18 + '@radix-ui/react-use-previous@1.1.0(@types/react@18.3.18)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.18 + '@radix-ui/react-use-rect@1.1.0(@types/react@18.3.18)(react@18.3.1)': dependencies: '@radix-ui/rect': 1.1.0 @@ -5683,6 +5838,11 @@ snapshots: next: 15.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 + next-themes@0.4.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + next@15.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@next/env': 15.2.1 @@ -6228,6 +6388,11 @@ snapshots: is-arrayish: 0.3.2 optional: true + sonner@2.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + source-map-js@1.2.1: {} source-map-support@0.5.21: diff --git a/src/app/admin/_components/admin-sidebar.tsx b/src/app/admin/_components/admin-sidebar.tsx index d43509d..dac20dd 100644 --- a/src/app/admin/_components/admin-sidebar.tsx +++ b/src/app/admin/_components/admin-sidebar.tsx @@ -22,10 +22,10 @@ const data = { title: "Benutzer", url: "/admin", items: [ - { - title: "Team Mitglieder", - url: "/admin/team", - }, + // { + // title: "Team Mitglieder", + // url: "/admin/team", + // }, { title: "Alle Benutzer", url: "/admin/benutzer", diff --git a/src/app/admin/_components/user-permissions-form.tsx b/src/app/admin/_components/user-permissions-form.tsx new file mode 100644 index 0000000..698af7a --- /dev/null +++ b/src/app/admin/_components/user-permissions-form.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { z } from "zod"; +import { Checkbox } from "@/components/ui/checkbox"; +import { cn } from "@/lib/utils"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; + +import { + hasPermission, + roleDefinitionsArray, +} from "@/lib/validation/permissions"; +import React from "react"; +import { setUserPermissions } from "@/server/actions/user"; +import { toast } from "sonner"; + +const formSchema = z.object({ + permission: z.number().min(1), +}); +const dynamicRoles = roleDefinitionsArray.filter( + ({ role: { name } }) => name !== "User", +); + +export type UserPermissionsFormProps = { + server_permissions: number; + userId: string; +}; + +function UserPermissionsForm({ + server_permissions, + userId, + cb, +}: UserPermissionsFormProps & { + cb?: () => void; +}) { + // const userRoles = getUserPermissions(permissions); + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + permission: server_permissions, + }, + }); + + // 2. Define a submit handler. + async function onSubmit(values: z.infer) { + const result = await setUserPermissions(userId, values.permission); + if (result.success) toast.success("Benutzerrechte gespeichert"); + else toast.error("Fehler beim Speichern der Benutzerrechte"); + cb?.(); + } + return ( +
+ + ( + + Benutzerrechte + +
    + {dynamicRoles.map(({ key, role }) => { + const hasRole = hasPermission(field.value, Number(key)); + return ( +
  • + {role.name} + { + const newPermissionFlag = hasRole + ? field.value - Number(key) + : field.value + Number(key); + field.onChange(newPermissionFlag); + }} + /> +
  • + ); + })} +
+
+ + +
+ )} + /> + {form.formState.isDirty && ( + + )} + + + ); +} + +export default UserPermissionsForm; diff --git a/src/app/admin/_components/user-permissions-popover.tsx b/src/app/admin/_components/user-permissions-popover.tsx new file mode 100644 index 0000000..5d25a22 --- /dev/null +++ b/src/app/admin/_components/user-permissions-popover.tsx @@ -0,0 +1,35 @@ +"use client"; + +import React from "react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Settings } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import UserPermissionsForm, { + UserPermissionsFormProps, +} from "./user-permissions-form"; + +function UserPermissionsPopover(props: UserPermissionsFormProps) { + const [open, setOpen] = React.useState(false); + return ( + + + + + + setOpen(false)} /> + + + ); +} + +export default UserPermissionsPopover; diff --git a/src/app/admin/benutzer/columns.tsx b/src/app/admin/benutzer/columns.tsx new file mode 100644 index 0000000..41681dc --- /dev/null +++ b/src/app/admin/benutzer/columns.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { getUserPermissions } from "@/lib/validation/permissions"; +import { ColumnDef } from "@tanstack/react-table"; +import { ArrowUpDown, Copy, Mail } from "lucide-react"; +import { User } from "next-auth"; + +import UserPermissionsPopover from "../_components/user-permissions-popover"; +import CopyToClip from "@/components/copy-to-clip"; + +export const columns: ColumnDef[] = [ + { + accessorKey: "name", + + header: ({ column }) => { + return ( + + ); + }, + }, + + { + accessorKey: "email", + cell: ({ row }) => { + const email = String(row.getValue("email")); + return ( +
+ + {email} +
+ ); + }, + header: ({ column }) => { + return ( + + ); + }, + }, + { + accessorKey: "role", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const roles = getUserPermissions(Number(row.getValue("role"))); + const userId = String(row.getValue("id")); + + return ( +
+ + {roles + .filter(({ name }) => name !== "User") + .map((role) => ( + + {role.name} + + ))} +
+ ); + }, + }, + { + accessorKey: "id", + header: () =>
Benutzer ID
, + cell: ({ row }) => { + return ( +
+ + + Kopieren + +
+ ); + }, + }, +]; diff --git a/src/app/admin/team/page.tsx b/src/app/admin/benutzer/page.tsx similarity index 88% rename from src/app/admin/team/page.tsx rename to src/app/admin/benutzer/page.tsx index 51cac84..672233c 100644 --- a/src/app/admin/team/page.tsx +++ b/src/app/admin/benutzer/page.tsx @@ -10,7 +10,7 @@ async function Page() { return ( <>
-

Team Mitglieder

+

Benutzer Manager

diff --git a/src/app/admin/team/columns.tsx b/src/app/admin/team/columns.tsx deleted file mode 100644 index 9817334..0000000 --- a/src/app/admin/team/columns.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"use client"; - -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { getUserPermissions } from "@/lib/validation/permissions"; -import { ColumnDef } from "@tanstack/react-table"; -import { Plus } from "lucide-react"; -import { User } from "next-auth"; - -export const columns: ColumnDef[] = [ - { - accessorKey: "name", - header: "Name", - }, - { - accessorKey: "email", - header: "Email", - }, - - { - accessorKey: "role", - header: "Rollen", - cell: ({ row }) => { - const roles = getUserPermissions(Number(row.getValue("role"))); - - return ( -
- {roles - .filter(({ name }) => name !== "User") - .map((role) => ( - - {role.name} - - ))} - -
- ); - }, - }, -]; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 24f2f27..55c5ce2 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,7 @@ import { GeistSans } from "geist/font/sans"; import { type Metadata } from "next"; import { TRPCReactProvider } from "@/trpc/react"; +import { Toaster } from "@/components/ui/sonner"; export const metadata: Metadata = { title: "Create T3 App", @@ -17,7 +18,10 @@ export default function RootLayout({ return ( - {children} + + + {children} + ); diff --git a/src/components/copy-to-clip.tsx b/src/components/copy-to-clip.tsx new file mode 100644 index 0000000..344b9d3 --- /dev/null +++ b/src/components/copy-to-clip.tsx @@ -0,0 +1,44 @@ +"use client"; +import React from "react"; +import { Button, ButtonProps } from "./ui/button"; +import { cn } from "@/lib/utils"; +import { Copy } from "lucide-react"; +import { toast } from "sonner"; + +function CopyToClip({ + text, + children, + buttonProps, + targetName, +}: { + text: string; + children?: React.ReactNode; + buttonProps?: ButtonProps; + targetName?: string; +}) { + const handleClick = () => { + navigator.clipboard.writeText(text); + toast.success(`${targetName ?? "Text"} in die Zwischenablage kopiert`); + }; + + return ( + + ); +} + +export default CopyToClip; diff --git a/src/components/data-table.tsx b/src/components/data-table.tsx deleted file mode 100644 index 447378d..0000000 --- a/src/components/data-table.tsx +++ /dev/null @@ -1,80 +0,0 @@ -"use client"; - -import { - ColumnDef, - flexRender, - getCoreRowModel, - useReactTable, -} from "@tanstack/react-table"; - -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; - -interface DataTableProps { - columns: ColumnDef[]; - data: TData[]; -} - -export function DataTable({ - columns, - data, -}: DataTableProps) { - const table = useReactTable({ - data, - columns, - getCoreRowModel: getCoreRowModel(), - }); - - return ( -
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - return ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext(), - )} - - ); - })} - - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - )) - ) : ( - - - No results. - - - )} - -
-
- ); -} diff --git a/src/components/data-table/index.tsx b/src/components/data-table/index.tsx new file mode 100644 index 0000000..03a2343 --- /dev/null +++ b/src/components/data-table/index.tsx @@ -0,0 +1,142 @@ +"use client"; + +import * as React from "react"; +import { + type PaginationState, + type ColumnSizingState, + type ColumnDef, + type SortingState, + type ColumnFiltersState, + flexRender, + getCoreRowModel, + useReactTable, + getSortedRowModel, + getFilteredRowModel, + getPaginationRowModel, +} from "@tanstack/react-table"; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Input } from "../ui/input"; + +import { DataTablePagination } from "./pagination"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; +} + +export function DataTable({ + columns, + data, +}: DataTableProps) { + const [pagination, setPagination] = React.useState({ + pageSize: 25, + pageIndex: 0, + }); + const [sorting, setSorting] = React.useState([]); + const [columnFilters, setColumnFilters] = React.useState( + [], + ); + const [columnSizing, setColumnSizing] = React.useState({}); + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onPaginationChange: setPagination, + onColumnFiltersChange: setColumnFilters, + onSortingChange: setSorting, + onColumnSizingChange: setColumnSizing, + state: { + sorting, + columnFilters, + columnSizing, + pagination, + }, + }); + return ( +
+
+ + table.getColumn("email")?.setFilterValue(event.target.value) + } + className="max-w-sm" + /> +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+ + +
+ ); +} diff --git a/src/components/data-table/pagination.tsx b/src/components/data-table/pagination.tsx new file mode 100644 index 0000000..ca265b9 --- /dev/null +++ b/src/components/data-table/pagination.tsx @@ -0,0 +1,99 @@ +import { Table } from "@tanstack/react-table"; +import { + ChevronLeft, + ChevronRight, + ChevronsLeft, + ChevronsRight, +} from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +interface DataTablePaginationProps { + table: Table; +} + +export function DataTablePagination({ + table, +}: DataTablePaginationProps) { + const paginationOptions = [10, 25, 50, 100]; + return ( +
+
+ {/* {table.getFilteredSelectedRowModel().rows.length} of{" "} + {table.getFilteredRowModel().rows.length} row(s) selected. */} +
+
+
+

Reihen pro Seite

+ +
+ +
+ + +
+ Seite {table.getState().pagination.pageIndex + 1} von{" "} + {table.getPageCount()} +
+ + +
+
+
+ ); +} diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..c6fdd07 --- /dev/null +++ b/src/components/ui/checkbox.tsx @@ -0,0 +1,30 @@ +"use client" + +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { Check } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx new file mode 100644 index 0000000..cd3c745 --- /dev/null +++ b/src/components/ui/select.tsx @@ -0,0 +1,159 @@ +"use client" + +import * as React from "react" +import * as SelectPrimitive from "@radix-ui/react-select" +import { Check, ChevronDown, ChevronUp } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Select = SelectPrimitive.Root + +const SelectGroup = SelectPrimitive.Group + +const SelectValue = SelectPrimitive.Value + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className + )} + {...props} + > + {children} + + + + +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectLabel.displayName = SelectPrimitive.Label.displayName + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +SelectItem.displayName = SelectPrimitive.Item.displayName + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectSeparator.displayName = SelectPrimitive.Separator.displayName + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +} diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx new file mode 100644 index 0000000..452f4d9 --- /dev/null +++ b/src/components/ui/sonner.tsx @@ -0,0 +1,31 @@ +"use client" + +import { useTheme } from "next-themes" +import { Toaster as Sonner } from "sonner" + +type ToasterProps = React.ComponentProps + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme() + + return ( + + ) +} + +export { Toaster } diff --git a/src/lib/validation/permissions.tsx b/src/lib/validation/permissions.tsx index e443a2c..2d894db 100644 --- a/src/lib/validation/permissions.tsx +++ b/src/lib/validation/permissions.tsx @@ -24,7 +24,7 @@ type RoleDefinition = { }; }; -const roles: { [key: number]: RoleDefinition } = { +export const roleDefinitions: { [key: number]: RoleDefinition } = { [Role.USER]: { name: "User", color: { @@ -50,9 +50,17 @@ const roles: { [key: number]: RoleDefinition } = { }, }, }; + +export const roleDefinitionsArray = Object.entries(roleDefinitions).map( + ([key, role]) => ({ + key, + role, + }), +); + export function getUserPermissions(userRole: number): RoleDefinition[] { - return Object.keys(roles) + return Object.keys(roleDefinitions) .map(Number) // Convert keys to numbers .filter((role) => (userRole & role) !== 0) // Check if role is set - .map((role) => roles[role]!); // Convert back to role name + .map((role) => roleDefinitions[role]!); // Convert back to role name } diff --git a/src/server/actions/user.ts b/src/server/actions/user.ts new file mode 100644 index 0000000..f8f43cf --- /dev/null +++ b/src/server/actions/user.ts @@ -0,0 +1,11 @@ +"use server"; + +import { api } from "@/trpc/server"; +import { revalidatePath } from "next/cache"; + +export async function setUserPermissions(userId: string, permission: number) { + const result = await api.users.setPermission({ userId, permission }); + if (!result[0]?.id) return { success: false }; + revalidatePath("/admin/benutzer"); + return { success: true }; +} diff --git a/src/server/api/routers/users.ts b/src/server/api/routers/users.ts index e9aec79..71f8087 100644 --- a/src/server/api/routers/users.ts +++ b/src/server/api/routers/users.ts @@ -1,10 +1,31 @@ import { hasPermission, Role } from "@/lib/validation/permissions"; import { createTRPCRouter, protectedProcedure } from "../trpc"; +import { z } from "zod"; +import { permission } from "process"; +import { users } from "@/server/db/schema"; +import { desc, eq } from "drizzle-orm"; export const usersRouter = createTRPCRouter({ getAll: protectedProcedure.query(async ({ ctx }) => { const isAdmin = hasPermission(ctx.session.user.role, Role.ADMIN); if (!isAdmin) throw new Error("You are not allowed to get all users"); - return await ctx.db.query.users.findMany(); + return await ctx.db.query.users.findMany({ + orderBy: desc(users.role), + }); }), + + setPermission: protectedProcedure + .input(z.object({ userId: z.string(), permission: z.number() })) + .mutation(async ({ ctx, input }) => { + const isAdmin = hasPermission(ctx.session.user.role, Role.ADMIN); + if (!isAdmin) + throw new Error("You are not allowed to set user permissions"); + return await ctx.db + .update(users) + .set({ + role: input.permission, + }) + .where(eq(users.id, input.userId)) + .returning({ id: users.id }); + }), });