├── .npmrc ├── src ├── domain │ ├── roles │ │ ├── api │ │ │ ├── index.ts │ │ │ ├── role.keys.ts │ │ │ └── getRoles.ts │ │ ├── index.ts │ │ └── features │ │ │ ├── index.ts │ │ │ ├── AssignedRolesList.tsx │ │ │ └── RolesForm.tsx │ ├── auth │ │ ├── hooks │ │ │ ├── index.ts │ │ │ └── useAuthUser.tsx │ │ ├── index.ts │ │ └── components │ │ │ ├── index.ts │ │ │ ├── Forbidden.tsx │ │ │ └── login.tsx │ ├── permissions │ │ ├── utils │ │ │ ├── index.ts │ │ │ └── permissions.ts │ │ ├── hooks │ │ │ ├── index.ts │ │ │ └── usePermissionGuards.tsx │ │ ├── index.ts │ │ └── api │ │ │ ├── index.ts │ │ │ ├── permission.keys.ts │ │ │ ├── getPermissionsList.ts │ │ │ └── getCurrentUserPermissions.ts │ ├── users │ │ ├── features │ │ │ ├── index.ts │ │ │ ├── UserListTable.tsx │ │ │ └── UserForm.tsx │ │ ├── index.ts │ │ ├── api │ │ │ ├── index.ts │ │ │ ├── user.keys.ts │ │ │ ├── getUser.ts │ │ │ ├── deleteUser.ts │ │ │ ├── addUser.ts │ │ │ ├── removeUserRole.ts │ │ │ ├── updateUser.ts │ │ │ ├── addUserRole.ts │ │ │ └── getUserList.ts │ │ ├── validation.tsx │ │ └── types │ │ │ └── index.ts │ └── rolePermissions │ │ ├── features │ │ ├── index.ts │ │ ├── RolePermissionListTable.tsx │ │ └── RolePermissionForm.tsx │ │ ├── index.ts │ │ ├── validation.tsx │ │ ├── api │ │ ├── index.ts │ │ ├── rolePermission.keys.ts │ │ ├── getRolePermission.tsx │ │ ├── deleteRolePermission.tsx │ │ ├── addRolePermission.tsx │ │ ├── updateRolePermission.tsx │ │ └── getRolePermissionList.tsx │ │ └── types │ │ └── index.ts ├── components │ ├── notifications │ │ ├── index.ts │ │ └── Notifications.tsx │ ├── settings │ │ ├── index.ts │ │ ├── UsersTab.tsx │ │ └── RolePermissionsTab.tsx │ ├── index.ts │ ├── forms │ │ ├── index.ts │ │ ├── DebouncedInput.tsx │ │ ├── TrashButton.tsx │ │ ├── TextInput.tsx │ │ ├── Tabs.tsx │ │ ├── TextArea.tsx │ │ ├── Button.tsx │ │ ├── Checkbox.tsx │ │ ├── Autocomplete.tsx │ │ ├── NumberInput.tsx │ │ ├── Combobox.tsx │ │ ├── DatePicker.tsx │ │ └── PaginatedTable.tsx │ ├── PrivateLayout.tsx │ ├── SearchInput.tsx │ ├── modal │ │ └── ConfirmDeleteModal.tsx │ ├── PrivateHeader.tsx │ ├── PrivateSideNav.tsx │ └── ThemeToggle.tsx ├── types │ ├── index.ts │ ├── forms.ts │ ├── react-table.d.ts │ ├── next.d.ts │ └── apis.ts ├── utils │ ├── index.ts │ ├── forms.ts │ ├── sorting.ts │ ├── testing.ts │ └── Autosave │ │ ├── AutosaveMachine.typegen.ts │ │ ├── useAutosave.tsx │ │ └── AutosaveMachine.tsx ├── hooks │ ├── useIsomorphicLayoutEffect.tsx │ ├── useAutofocus.tsx │ └── useTailwindConfig.tsx ├── pages │ ├── token.tsx │ ├── _document.tsx │ ├── settings │ │ ├── users │ │ │ ├── new.tsx │ │ │ └── [userId].tsx │ │ └── index.tsx │ ├── _app.tsx │ ├── api │ │ └── auth │ │ │ └── [...nextauth].ts │ └── index.tsx ├── config │ └── index.ts ├── images │ ├── favicon.svg │ └── logo.svg ├── lib │ └── axios.tsx └── styles │ └── globals.css ├── .eslintrc.json ├── postcss.config.js ├── .env.example ├── next.config.js ├── .gitignore ├── tsconfig.json ├── package.json ├── public └── favicon.svg ├── tailwind.config.js └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers=true -------------------------------------------------------------------------------- /src/domain/roles/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./getRoles"; 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /src/domain/auth/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./useAuthUser"; 2 | -------------------------------------------------------------------------------- /src/components/notifications/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Notifications'; -------------------------------------------------------------------------------- /src/domain/permissions/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./permissions"; 2 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./apis"; 2 | export * from "./forms"; 3 | -------------------------------------------------------------------------------- /src/domain/permissions/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./usePermissionGuards"; 2 | -------------------------------------------------------------------------------- /src/domain/auth/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./components"; 2 | export * from "./hooks"; 3 | -------------------------------------------------------------------------------- /src/domain/roles/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./api"; 2 | export * from "./features"; 3 | -------------------------------------------------------------------------------- /src/domain/auth/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Forbidden"; 2 | export * from "./Login"; 3 | -------------------------------------------------------------------------------- /src/domain/users/features/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./UserForm"; 2 | export * from "./UserListTable"; 3 | -------------------------------------------------------------------------------- /src/components/settings/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./RolePermissionsTab"; 2 | export * from "./UsersTab"; 3 | -------------------------------------------------------------------------------- /src/domain/roles/features/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./AssignedRolesList"; 2 | export * from "./RolesForm"; 3 | -------------------------------------------------------------------------------- /src/domain/permissions/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./api"; 2 | export * from "./hooks"; 3 | export * from "./utils"; 4 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /src/domain/permissions/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./getCurrentUserPermissions"; 2 | export * from "./getPermissionsList"; 3 | -------------------------------------------------------------------------------- /src/domain/roles/api/role.keys.ts: -------------------------------------------------------------------------------- 1 | const RoleKeys = { 2 | all: ["Roles"] as const, 3 | }; 4 | 5 | export { RoleKeys }; 6 | -------------------------------------------------------------------------------- /src/domain/rolePermissions/features/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./RolePermissionForm"; 2 | export * from "./RolePermissionListTable"; -------------------------------------------------------------------------------- /src/domain/auth/components/Forbidden.tsx: -------------------------------------------------------------------------------- 1 | function Forbidden() { 2 | return

Forbidden

; 3 | } 4 | 5 | export { Forbidden }; 6 | -------------------------------------------------------------------------------- /src/domain/users/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./api"; 2 | export * from "./features"; 3 | export * from "./types"; 4 | export * from "./validation"; 5 | -------------------------------------------------------------------------------- /src/types/forms.ts: -------------------------------------------------------------------------------- 1 | export const FormControlState = ["valid", "invalid", "disabled"] as const; 2 | export const FormMode = ["Add", "Edit"] as const; 3 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Autosave/useAutosave"; 2 | export * from "./forms"; 3 | export * from "./sorting"; 4 | export * from "./testing"; 5 | -------------------------------------------------------------------------------- /src/domain/permissions/api/permission.keys.ts: -------------------------------------------------------------------------------- 1 | const PermissionKeys = { 2 | all: ["Permissions"] as const, 3 | }; 4 | 5 | export { PermissionKeys }; 6 | -------------------------------------------------------------------------------- /src/domain/rolePermissions/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./api"; 2 | export * from "./features"; 3 | export * from "./types"; 4 | export * from "./validation"; 5 | -------------------------------------------------------------------------------- /src/types/react-table.d.ts: -------------------------------------------------------------------------------- 1 | import "@tanstack/react-table"; 2 | 3 | declare module "@tanstack/react-table" { 4 | interface ColumnMeta { 5 | thClassName?: string; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NEXTAUTH_URL=http://localhost:8582 2 | NEXTAUTH_SECRET=your-secret-here 3 | AUTH_AUTHORITY=http://localhost:3255/auth/realms/DevRealm 4 | AUTH_CLIENT_ID=recipe_management.next 5 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./PrivateHeader"; 2 | export * from "./PrivateLayout"; 3 | export * from "./PrivateSideNav"; 4 | export * from "./SearchInput"; 5 | export * from "./ThemeToggle"; 6 | -------------------------------------------------------------------------------- /src/hooks/useIsomorphicLayoutEffect.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useLayoutEffect } from "react"; 2 | 3 | export const useIsomorphicLayoutEffect = 4 | typeof window !== "undefined" ? useLayoutEffect : useEffect; 5 | -------------------------------------------------------------------------------- /src/domain/users/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./addUser"; 2 | export * from "./deleteUser"; 3 | export * from "./getUser"; 4 | export * from "./getUserList"; 5 | export * from "./updateUser"; 6 | export * from "./user.keys"; 7 | -------------------------------------------------------------------------------- /src/domain/rolePermissions/validation.tsx: -------------------------------------------------------------------------------- 1 | import * as yup from "yup"; 2 | 3 | export const rolePermissionValidationSchema = yup.object({ 4 | role: yup.string().required(), 5 | permission: yup.string().required(), 6 | }); 7 | -------------------------------------------------------------------------------- /src/types/next.d.ts: -------------------------------------------------------------------------------- 1 | NextComponentType; 2 | import "next/types"; 3 | 4 | declare module "next/types" { 5 | interface NextComponentType { 6 | isPublic: boolean; 7 | } 8 | interface NextPage { 9 | isPublic: boolean; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/forms.ts: -------------------------------------------------------------------------------- 1 | import { FieldNamesMarkedBoolean, FieldValues } from "react-hook-form"; 2 | 3 | export function getSimpleDirtyFields( 4 | dirtyFields: FieldNamesMarkedBoolean 5 | ) { 6 | return !!Object.keys(dirtyFields).length; 7 | } 8 | -------------------------------------------------------------------------------- /src/domain/rolePermissions/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from './rolePermission.keys'; 2 | export * from "./addRolePermission"; 3 | export * from "./getRolePermissionList"; 4 | export * from "./getRolePermission"; 5 | export * from "./deleteRolePermission"; 6 | export * from "./updateRolePermission"; -------------------------------------------------------------------------------- /src/domain/users/validation.tsx: -------------------------------------------------------------------------------- 1 | import * as yup from "yup"; 2 | 3 | export const userValidationSchema = yup.object({ 4 | identifier: yup.string().required(), 5 | firstName: yup.string(), 6 | lastName: yup.string(), 7 | email: yup.string().email(), 8 | username: yup.string(), 9 | }); 10 | -------------------------------------------------------------------------------- /src/hooks/useAutofocus.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export function useAutofocus(): (node: any) => void { 4 | const autofocusRef = React.useCallback((node: any) => { 5 | if (node !== null) { 6 | node?.focus(); 7 | } 8 | }, []); 9 | return autofocusRef; 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/sorting.ts: -------------------------------------------------------------------------------- 1 | import { SortingState } from "@tanstack/react-table"; 2 | 3 | // TODO: add tests 4 | export const generateSieveSortOrder = (sortOrder: SortingState | undefined) => sortOrder && sortOrder.length > 0 5 | ? sortOrder?.map((s) => (s.desc ? `-${s.id}` : s.id)).join(",") 6 | : undefined; -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | experimental: { 5 | newNextLinkBehavior: true, 6 | scrollRestoration: true, 7 | images: { 8 | allowFutureImage: true, 9 | }, 10 | }, 11 | } 12 | 13 | module.exports = nextConfig 14 | -------------------------------------------------------------------------------- /src/hooks/useTailwindConfig.tsx: -------------------------------------------------------------------------------- 1 | import resolveConfig from "tailwindcss/resolveConfig"; 2 | import tailwindConfig from "../../tailwind.config.js"; 3 | 4 | const fullConfig = resolveConfig(tailwindConfig); 5 | 6 | export function useTailwindColors() { 7 | return fullConfig.theme?.colors; 8 | } 9 | 10 | export function useTailwindConfig() { 11 | return fullConfig; 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/testing.ts: -------------------------------------------------------------------------------- 1 | function formatTestSelectorString(string: string) { 2 | return string 3 | .replace(/[^\w\s-]/g, "") 4 | .replace(/\s+/g, "-") 5 | .toLowerCase(); 6 | } 7 | 8 | export function getTestSelector(field?: string, prefix = "") { 9 | return field 10 | ? formatTestSelectorString(`${prefix ? `${prefix}-` : ""}${field}`) 11 | : ""; 12 | } 13 | -------------------------------------------------------------------------------- /src/domain/users/api/user.keys.ts: -------------------------------------------------------------------------------- 1 | const UserKeys = { 2 | all: ["Users"] as const, 3 | lists: () => [...UserKeys.all, "list"] as const, 4 | list: (queryParams: string) => 5 | [...UserKeys.lists(), { queryParams }] as const, 6 | details: () => [...UserKeys.all, "detail"] as const, 7 | detail: (id: string) => [...UserKeys.details(), id] as const, 8 | }; 9 | 10 | export { UserKeys }; 11 | -------------------------------------------------------------------------------- /src/types/apis.ts: -------------------------------------------------------------------------------- 1 | export interface PagedResponse { 2 | pagination: Pagination; 3 | data: T[]; 4 | } 5 | 6 | export interface Pagination { 7 | currentEndIndex: number; 8 | currentPageSize: number; 9 | currentStartIndex: number; 10 | hasNext: boolean; 11 | hasPrevious: boolean; 12 | pageNumber: number; 13 | pageSize: number; 14 | totalCount: number; 15 | totalPages: number; 16 | } 17 | -------------------------------------------------------------------------------- /src/components/forms/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Autocomplete"; 2 | export * from "./Button"; 3 | export * from "./Checkbox"; 4 | export * from "./Combobox"; 5 | export * from "./DatePicker"; 6 | export * from "./DebouncedInput"; 7 | export * from "./NumberInput"; 8 | export * from "./PaginatedTable"; 9 | export * from "./Tabs"; 10 | export * from "./TextArea"; 11 | export * from "./TextInput"; 12 | export * from "./TrashButton"; 13 | -------------------------------------------------------------------------------- /src/domain/rolePermissions/api/rolePermission.keys.ts: -------------------------------------------------------------------------------- 1 | const RolePermissionKeys = { 2 | all: ["RolePermissions"] as const, 3 | lists: () => [...RolePermissionKeys.all, "list"] as const, 4 | list: (queryParams: string) => 5 | [...RolePermissionKeys.lists(), { queryParams }] as const, 6 | details: () => [...RolePermissionKeys.all, "detail"] as const, 7 | detail: (id: string) => [...RolePermissionKeys.details(), id] as const, 8 | } 9 | 10 | export { RolePermissionKeys }; 11 | -------------------------------------------------------------------------------- /src/domain/permissions/utils/permissions.ts: -------------------------------------------------------------------------------- 1 | export type Permission = typeof permissions[number]; 2 | export const permissions = [ 3 | // permissions marker - do not delete if you want to use craftsman 4 | "CanDeleteUsers", 5 | "CanUpdateUsers", 6 | "CanAddUsers", 7 | "CanReadUsers", 8 | "CanDeleteRolePermissions", 9 | "CanUpdateRolePermissions", 10 | "CanAddRolePermissions", 11 | "CanReadRolePermissions", 12 | "CanRemoveUserRoles", 13 | "CanAddUserRoles", 14 | "CanGetRoles", 15 | "CanGetPermissions", 16 | ] as const; 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /src/domain/roles/api/getRoles.ts: -------------------------------------------------------------------------------- 1 | import { clients } from "@/lib/axios"; 2 | import { AxiosResponse } from "axios"; 3 | import { useQuery } from "react-query"; 4 | import { RoleKeys } from "./role.keys"; 5 | 6 | export const getRoles = async () => { 7 | const axios = await clients.recipeManagement(); 8 | 9 | return axios 10 | .get(`/roles`) 11 | .then((response: AxiosResponse) => response.data); 12 | }; 13 | 14 | export const useGetRoles = () => { 15 | return useQuery(RoleKeys.all, () => getRoles(), { 16 | staleTime: Infinity, 17 | cacheTime: Infinity, 18 | }); 19 | }; 20 | -------------------------------------------------------------------------------- /src/domain/permissions/api/getPermissionsList.ts: -------------------------------------------------------------------------------- 1 | import { clients } from "@/lib/axios"; 2 | import { AxiosResponse } from "axios"; 3 | import { useQuery } from "react-query"; 4 | import { PermissionKeys } from "./permission.keys"; 5 | 6 | export const getPermissions = async () => { 7 | const axios = await clients.recipeManagement(); 8 | 9 | return axios 10 | .get(`/permissions`) 11 | .then((response: AxiosResponse) => response.data); 12 | }; 13 | 14 | export const useGetPermissions = () => { 15 | return useQuery(PermissionKeys.all, () => getPermissions(), { 16 | staleTime: Infinity, 17 | cacheTime: Infinity, 18 | }); 19 | }; 20 | -------------------------------------------------------------------------------- /src/pages/token.tsx: -------------------------------------------------------------------------------- 1 | import { PrivateLayout } from "@/components"; 2 | import { useAuthUser } from "@/domain/auth"; 3 | import Head from "next/head"; 4 | 5 | // Protected.isPublic = false; 6 | export default function Protected() { 7 | const { session } = useAuthUser(); 8 | 9 | return ( 10 | <> 11 | 12 | Token Info 13 | 14 | 15 |
16 |

Token Info

17 |

{JSON.stringify(session, null, 2)}

18 |
19 |
20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/domain/users/api/getUser.ts: -------------------------------------------------------------------------------- 1 | import { clients } from "@/lib/axios"; 2 | import { AxiosResponse } from "axios"; 3 | import { useQuery } from "react-query"; 4 | import { UserDto } from "../types"; 5 | import { UserKeys } from "./user.keys"; 6 | 7 | export const getUser = async (id: string) => { 8 | const axios = await clients.recipeManagement(); 9 | 10 | return axios 11 | .get(`/users/${id}`) 12 | .then((response: AxiosResponse) => response.data); 13 | }; 14 | 15 | export const useGetUser = (id: string | null | undefined) => { 16 | return useQuery(UserKeys.detail(id!), () => getUser(id!), { 17 | enabled: id !== null && id !== undefined, 18 | }); 19 | }; 20 | -------------------------------------------------------------------------------- /src/components/PrivateLayout.tsx: -------------------------------------------------------------------------------- 1 | import { PrivateHeader, PrivateSideNav } from "@/components"; 2 | 3 | interface Props { 4 | children: React.ReactNode; 5 | } 6 | 7 | export function PrivateLayout({ children }: Props) { 8 | return ( 9 |
10 | 11 |
12 | 13 |
14 | {children} 15 |
16 |
17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/domain/permissions/api/getCurrentUserPermissions.ts: -------------------------------------------------------------------------------- 1 | import { clients } from "@/lib/axios"; 2 | import { AxiosResponse } from "axios"; 3 | import { useQuery } from "react-query"; 4 | import { PermissionKeys } from "./permission.keys"; 5 | 6 | export const getCurrentUserPermissions = async () => { 7 | const axios = await clients.recipeManagement(); 8 | 9 | return axios 10 | .get(`/permissions/mine`) 11 | .then((response: AxiosResponse) => response.data); 12 | }; 13 | 14 | export const useGetCurrentUserPermissions = () => { 15 | return useQuery(PermissionKeys.all, () => getCurrentUserPermissions(), { 16 | staleTime: Infinity, 17 | cacheTime: Infinity, 18 | }); 19 | }; 20 | -------------------------------------------------------------------------------- /src/domain/auth/hooks/useAuthUser.tsx: -------------------------------------------------------------------------------- 1 | import { env } from "@/config"; 2 | import { signIn, useSession } from "next-auth/react"; 3 | import { useEffect, useState } from "react"; 4 | 5 | export function useAuthUser() { 6 | const { data: session, status } = useSession(); 7 | 8 | const [isLoggedIn, setIsLoggedIn] = useState(false); 9 | useEffect(() => { 10 | setIsLoggedIn(!!session); 11 | 12 | if (session?.error === "RefreshAccessTokenError") 13 | signIn(env.auth.nextAuthId); // Force sign in to hopefully resolve error 14 | }, [session]); 15 | 16 | return { 17 | user: session?.user ?? {}, 18 | isLoggedIn, 19 | session, 20 | isLoading: status === "loading", 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /src/domain/rolePermissions/api/getRolePermission.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | RolePermissionDto, 3 | RolePermissionKeys, 4 | } from "@/domain/rolePermissions"; 5 | import { clients } from "@/lib/axios"; 6 | import { AxiosResponse } from "axios"; 7 | import { useQuery } from "react-query"; 8 | 9 | const getRolePermission = async (id: string) => { 10 | const axios = await clients.recipeManagement(); 11 | 12 | return axios 13 | .get(`/rolepermissions/${id}`) 14 | .then((response: AxiosResponse) => response.data); 15 | }; 16 | 17 | export const useGetRolePermission = (id: string | null | undefined) => { 18 | return useQuery( 19 | RolePermissionKeys.detail(id!), 20 | () => getRolePermission(id!), 21 | { 22 | enabled: id !== null && id !== undefined, 23 | } 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /src/domain/users/api/deleteUser.ts: -------------------------------------------------------------------------------- 1 | import { clients } from "@/lib/axios"; 2 | import { AxiosError } from "axios"; 3 | import { useMutation, UseMutationOptions, useQueryClient } from "react-query"; 4 | import { UserKeys } from "./user.keys"; 5 | 6 | async function deleteUser(id: string) { 7 | const axios = await clients.recipeManagement(); 8 | return axios.delete(`/users/${id}`).then(() => {}); 9 | } 10 | 11 | export function useDeleteUser( 12 | options?: UseMutationOptions 13 | ) { 14 | const queryClient = useQueryClient(); 15 | 16 | return useMutation((id: string) => deleteUser(id), { 17 | onSuccess: () => { 18 | queryClient.invalidateQueries(UserKeys.lists()); 19 | queryClient.invalidateQueries(UserKeys.details()); 20 | }, 21 | ...options, 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /src/domain/rolePermissions/types/index.ts: -------------------------------------------------------------------------------- 1 | import { SortingState } from "@tanstack/react-table"; 2 | 3 | export interface QueryParams { 4 | pageNumber?: number; 5 | pageSize?: number; 6 | filters?: string; 7 | sortOrder?: SortingState; 8 | } 9 | 10 | export interface RolePermissionDto { 11 | id: string; 12 | role: string; 13 | permission: string; 14 | } 15 | 16 | export interface RolePermissionForManipulationDto { 17 | role: string; 18 | permission: string; 19 | } 20 | 21 | export interface RolePermissionForCreationDto extends RolePermissionForManipulationDto { } 22 | export interface RolePermissionForUpdateDto extends RolePermissionForManipulationDto { } 23 | 24 | // need a string enum list? 25 | // const StatusList = ['Status1', 'Status2', null] as const; 26 | // export type Status = typeof StatusList[number]; 27 | // Then use as --> status: Status; 28 | -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | const _env = process.env.NODE_ENV; 2 | export const env = { 3 | environment: _env, 4 | isDevelopment: _env === "development", 5 | auth: { 6 | nextAuthId: "oidc", 7 | secret: process.env.NEXTAUTH_SECRET, 8 | authority: process.env.AUTH_AUTHORITY, 9 | clientId: process.env.AUTH_CLIENT_ID, 10 | }, 11 | clientUrls: { 12 | recipeManagement: () => { 13 | switch (_env) { 14 | case "development": 15 | return "https://localhost:5375"; 16 | default: 17 | throw "Environment variable not set for 'recipeManagement'"; 18 | } 19 | }, 20 | authServer: () => { 21 | switch (_env) { 22 | case "development": 23 | return env.auth.authority; 24 | default: 25 | throw "Environment variable not set for 'authServer'"; 26 | } 27 | }, 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /src/domain/permissions/hooks/usePermissionGuards.tsx: -------------------------------------------------------------------------------- 1 | import { useGetCurrentUserPermissions } from "@/domain/permissions"; 2 | import { Permission } from "@/domain/permissions/utils"; 3 | 4 | function useHasPermission(permission: Permission) { 5 | let { data: permissions, isLoading } = useGetCurrentUserPermissions(); 6 | permissions ??= []; 7 | return { hasPermission: permissions?.includes(permission), isLoading }; 8 | } 9 | 10 | function useCanAccessSettings() { 11 | const canReadUsers = useHasPermission("CanReadUsers"); 12 | const canReadRolePermissions = useHasPermission("CanReadRolePermissions"); 13 | 14 | return { 15 | hasPermission: 16 | canReadUsers.hasPermission || canReadRolePermissions.hasPermission, 17 | isLoading: canReadUsers.isLoading || canReadRolePermissions.isLoading, 18 | }; 19 | } 20 | 21 | export { useHasPermission, useCanAccessSettings }; 22 | -------------------------------------------------------------------------------- /src/domain/users/api/addUser.ts: -------------------------------------------------------------------------------- 1 | import { clients } from "@/lib/axios"; 2 | import { AxiosError } from "axios"; 3 | import { useMutation, UseMutationOptions, useQueryClient } from "react-query"; 4 | import { UserDto, UserForCreationDto } from "../types"; 5 | import { UserKeys } from "./user.keys"; 6 | 7 | const addUser = async (data: UserForCreationDto) => { 8 | const axios = await clients.recipeManagement(); 9 | 10 | return axios 11 | .post("/users", data) 12 | .then((response) => response.data as UserDto); 13 | }; 14 | 15 | export function useAddUser( 16 | options?: UseMutationOptions 17 | ) { 18 | const queryClient = useQueryClient(); 19 | 20 | return useMutation((newUser: UserForCreationDto) => addUser(newUser), { 21 | onSuccess: () => { 22 | queryClient.invalidateQueries(UserKeys.lists()); 23 | }, 24 | ...options, 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /src/domain/rolePermissions/api/deleteRolePermission.tsx: -------------------------------------------------------------------------------- 1 | import { RolePermissionKeys } from "@/domain/rolePermissions"; 2 | import { clients } from "@/lib/axios"; 3 | import { AxiosError } from "axios"; 4 | import { useMutation, UseMutationOptions, useQueryClient } from "react-query"; 5 | 6 | async function deleteRolePermission(id: string) { 7 | const axios = await clients.recipeManagement(); 8 | return axios.delete(`/rolepermissions/${id}`).then(() => {}); 9 | } 10 | 11 | export function useDeleteRolePermission( 12 | options?: UseMutationOptions 13 | ) { 14 | const queryClient = useQueryClient(); 15 | 16 | return useMutation((id: string) => deleteRolePermission(id), { 17 | onSuccess: () => { 18 | queryClient.invalidateQueries(RolePermissionKeys.lists()); 19 | queryClient.invalidateQueries(RolePermissionKeys.details()); 20 | }, 21 | ...options, 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /src/components/forms/DebouncedInput.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | function DebouncedInput({ 4 | value: initialValue, 5 | onChange, 6 | debounce = 500, 7 | ...props 8 | }: { 9 | value: string | number; 10 | onChange: (value: string | number) => void; 11 | debounce?: number; 12 | } & Omit, "onChange">) { 13 | const [value, setValue] = React.useState(initialValue); 14 | 15 | React.useEffect(() => { 16 | setValue(initialValue); 17 | }, [initialValue]); 18 | 19 | React.useEffect(() => { 20 | const timeout = setTimeout(() => { 21 | onChange(value); 22 | }, debounce); 23 | 24 | return () => clearTimeout(timeout); 25 | }, [debounce, onChange, value]); 26 | 27 | return ( 28 | setValue(e.target.value)} 32 | /> 33 | ); 34 | } 35 | 36 | export { DebouncedInput }; 37 | -------------------------------------------------------------------------------- /src/domain/users/types/index.ts: -------------------------------------------------------------------------------- 1 | import { SortingState } from "@tanstack/react-table"; 2 | 3 | export interface QueryParams { 4 | pageNumber?: number; 5 | pageSize?: number; 6 | filters?: string; 7 | sortOrder?: SortingState; 8 | } 9 | 10 | export type UserDto = { 11 | id: string; 12 | identifier: string; 13 | firstName: string; 14 | lastName: string; 15 | email: string; 16 | username: string; 17 | roles: string[]; 18 | }; 19 | 20 | export interface UserForManipulationDto { 21 | identifier: string; 22 | firstName: string; 23 | lastName: string; 24 | email: string; 25 | username: string; 26 | roles: string[]; 27 | } 28 | 29 | export interface UserForCreationDto extends UserForManipulationDto {} 30 | export interface UserForUpdateDto extends UserForManipulationDto {} 31 | 32 | // need a string enum list? 33 | // const StatusList = ['Status1', 'Status2', null] as const; 34 | // export type Status = typeof StatusList[number]; 35 | // Then use as --> status: Status; 36 | -------------------------------------------------------------------------------- /src/domain/users/api/removeUserRole.ts: -------------------------------------------------------------------------------- 1 | import { clients } from "@/lib/axios"; 2 | import { AxiosError } from "axios"; 3 | import { useMutation, UseMutationOptions, useQueryClient } from "react-query"; 4 | import { UserKeys } from "./user.keys"; 5 | 6 | export const removeUserRole = async (userId: string, role: string) => { 7 | const axios = await clients.recipeManagement(); 8 | return axios 9 | .put(`/users/${userId}/removeRole`, role, { 10 | headers: { "Content-Type": "application/json" }, 11 | }) 12 | .then((response) => response.data); 13 | }; 14 | 15 | export interface UpdateProps { 16 | userId: string; 17 | role: string; 18 | } 19 | 20 | export function useRemoveUserRole( 21 | options?: UseMutationOptions 22 | ) { 23 | const queryClient = useQueryClient(); 24 | 25 | return useMutation( 26 | ({ userId, role }: UpdateProps) => removeUserRole(userId, role), 27 | { 28 | onSuccess: () => { 29 | queryClient.invalidateQueries(UserKeys.details()); 30 | }, 31 | ...options, 32 | } 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/domain/rolePermissions/api/addRolePermission.tsx: -------------------------------------------------------------------------------- 1 | import { clients } from "@/lib/axios"; 2 | import { AxiosError } from "axios"; 3 | import { useMutation, UseMutationOptions, useQueryClient } from "react-query"; 4 | import { RolePermissionDto, RolePermissionForCreationDto, RolePermissionKeys } from "@/domain/rolePermissions"; 5 | 6 | const addRolePermission = async (data: RolePermissionForCreationDto) => { 7 | const axios = await clients.recipeManagement(); 8 | 9 | return axios 10 | .post("/rolepermissions", data) 11 | .then((response) => response.data as RolePermissionDto); 12 | }; 13 | 14 | export function useAddRolePermission( 15 | options?: UseMutationOptions 16 | ) { 17 | const queryClient = useQueryClient(); 18 | 19 | return useMutation( 20 | (newRolePermission: RolePermissionForCreationDto) => addRolePermission(newRolePermission), 21 | { 22 | onSuccess: () => { 23 | queryClient.invalidateQueries(RolePermissionKeys.lists()); 24 | }, 25 | ...options, 26 | } 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/domain/users/api/updateUser.ts: -------------------------------------------------------------------------------- 1 | import { clients } from "@/lib/axios"; 2 | import { AxiosError } from "axios"; 3 | import { useMutation, UseMutationOptions, useQueryClient } from "react-query"; 4 | import { UserForUpdateDto } from "../types"; 5 | import { UserKeys } from "./user.keys"; 6 | 7 | export const updateUser = async (id: string, data: UserForUpdateDto) => { 8 | const axios = await clients.recipeManagement(); 9 | return axios.put(`/users/${id}`, data).then((response) => response.data); 10 | }; 11 | 12 | export interface UpdateProps { 13 | id: string; 14 | data: UserForUpdateDto; 15 | } 16 | 17 | export function useUpdateUser( 18 | options?: UseMutationOptions 19 | ) { 20 | const queryClient = useQueryClient(); 21 | 22 | return useMutation( 23 | ({ id, data: updatedUser }: UpdateProps) => updateUser(id, updatedUser), 24 | { 25 | onSuccess: () => { 26 | queryClient.invalidateQueries(UserKeys.lists()); 27 | queryClient.invalidateQueries(UserKeys.details()); 28 | }, 29 | ...options, 30 | } 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/components/forms/TrashButton.tsx: -------------------------------------------------------------------------------- 1 | import { IconTrash } from "@tabler/icons"; 2 | import clsx from "clsx"; 3 | import { MouseEvent } from "react"; 4 | 5 | interface TrashButtonProps { 6 | onClick: (e: MouseEvent) => void; 7 | hideInGroup?: boolean; 8 | } 9 | 10 | function TrashButton({ onClick, hideInGroup = true }: TrashButtonProps) { 11 | return ( 12 | 24 | ); 25 | } 26 | 27 | export { TrashButton }; 28 | -------------------------------------------------------------------------------- /src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { createGetInitialProps } from "@mantine/next"; 2 | import Document, { Head, Html, Main, NextScript } from "next/document"; 3 | 4 | const getInitialProps = createGetInitialProps(); 5 | class MyDocument extends Document { 6 | static getInitialProps = getInitialProps; 7 | 8 | render() { 9 | return ( 10 | 14 | 15 | 16 | 17 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | ); 29 | } 30 | } 31 | 32 | export default MyDocument; 33 | -------------------------------------------------------------------------------- /src/domain/users/api/addUserRole.ts: -------------------------------------------------------------------------------- 1 | import { clients } from "@/lib/axios"; 2 | import { AxiosError } from "axios"; 3 | import { useMutation, UseMutationOptions, useQueryClient } from "react-query"; 4 | import { UserKeys } from "./user.keys"; 5 | 6 | export const addUserRole = async (userId: string, role: string) => { 7 | const axios = await clients.recipeManagement(); 8 | return axios 9 | .put(`/users/${userId}/addRole`, role, { 10 | headers: { "Content-Type": "application/json" }, 11 | }) 12 | .then((response) => response.data); 13 | }; 14 | 15 | export interface UpdateProps { 16 | userId: string; 17 | role: string; 18 | } 19 | 20 | export function useAddUserRole( 21 | options?: UseMutationOptions 22 | ) { 23 | const queryClient = useQueryClient(); 24 | 25 | return useMutation( 26 | ({ userId, role }: UpdateProps) => addUserRole(userId, role), 27 | { 28 | onSuccess: () => { 29 | queryClient.invalidateQueries(UserKeys.details()); 30 | // queryClient.invalidateQueries(UserKeys.detail(userId)); 31 | }, 32 | ...options, 33 | } 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/pages/settings/users/new.tsx: -------------------------------------------------------------------------------- 1 | import { PrivateLayout } from "@/components"; 2 | import { Button } from "@/components/forms"; 3 | import { Forbidden } from "@/domain/auth"; 4 | import { useHasPermission } from "@/domain/permissions"; 5 | import { UserForm } from "@/domain/users"; 6 | import Head from "next/head"; 7 | 8 | export default function NewUser() { 9 | const canAddUser = useHasPermission("CanAddUsers"); 10 | 11 | return ( 12 | <> 13 | 14 | Add User 15 | 16 | 17 | 18 | {canAddUser.hasPermission ? ( 19 |
20 |
21 | 24 |
25 |
26 |

Add a User

27 |
28 | 29 |
30 |
31 |
32 | ) : ( 33 | 34 | )} 35 |
36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/domain/rolePermissions/api/updateRolePermission.tsx: -------------------------------------------------------------------------------- 1 | import { clients } from "@/lib/axios"; 2 | import { AxiosError } from "axios"; 3 | import { useMutation, UseMutationOptions, useQueryClient } from "react-query"; 4 | import { RolePermissionForUpdateDto, RolePermissionKeys } from "@/domain/rolePermissions"; 5 | 6 | const updateRolePermission = async (id: string, data: RolePermissionForUpdateDto) => { 7 | const axios = await clients.recipeManagement(); 8 | return axios.put(`/rolepermissions/${id}`, data).then((response) => response.data); 9 | }; 10 | 11 | export interface UpdateProps { 12 | id: string; 13 | data: RolePermissionForUpdateDto; 14 | } 15 | 16 | export function useUpdateRolePermission( 17 | options?: UseMutationOptions 18 | ) { 19 | const queryClient = useQueryClient(); 20 | 21 | return useMutation( 22 | ({ id, data: updatedRolePermission }: UpdateProps) => 23 | updateRolePermission(id, updatedRolePermission), 24 | { 25 | onSuccess: () => { 26 | queryClient.invalidateQueries(RolePermissionKeys.lists()); 27 | queryClient.invalidateQueries(RolePermissionKeys.details()); 28 | }, 29 | ...options, 30 | } 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "resolveJsonModule": true, 7 | "removeComments": true, 8 | "preserveConstEnums": true, 9 | "strict": true, 10 | "alwaysStrict": true, 11 | "strictNullChecks": true, 12 | "noUncheckedIndexedAccess": true, 13 | 14 | "noImplicitAny": true, 15 | "noImplicitReturns": true, 16 | "noImplicitThis": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "allowUnreachableCode": false, 20 | "noFallthroughCasesInSwitch": true, 21 | 22 | "target": "es5", 23 | "outDir": "out", 24 | "declaration": true, 25 | "sourceMap": true, 26 | 27 | "esModuleInterop": true, 28 | "allowSyntheticDefaultImports": true, 29 | "allowJs": true, 30 | "skipLibCheck": true, 31 | "forceConsistentCasingInFileNames": true, 32 | 33 | "jsx": "preserve", 34 | "noEmit": true, 35 | "isolatedModules": true, 36 | "incremental": true, 37 | 38 | "baseUrl": ".", 39 | "paths": { 40 | "@/*": ["src/*"] 41 | } 42 | }, 43 | "exclude": ["./out/**/*", "./node_modules/**/*", "**/*.cy.ts"], 44 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"] 45 | } 46 | -------------------------------------------------------------------------------- /src/components/notifications/Notifications.tsx: -------------------------------------------------------------------------------- 1 | import toast, { Toaster, ToastOptions } from "react-hot-toast"; 2 | 3 | const Notifications = () => { 4 | return ( 5 | 29 | ); 30 | }; 31 | 32 | Notifications.success = (message: string, options?: ToastOptions) => { 33 | toast.success(message); 34 | // toast.custom( 35 | // (t) => ( 36 | // // TODO framer motion 37 | //
38 | // Hello TailwindCSS! 👋 39 | //
40 | // ), 41 | // { 42 | // duration: 1500, 43 | // } 44 | // ); 45 | }; 46 | Notifications.error = (message: string, options?: ToastOptions) => { 47 | toast.error(message); 48 | }; 49 | 50 | export { Notifications }; 51 | -------------------------------------------------------------------------------- /src/components/SearchInput.tsx: -------------------------------------------------------------------------------- 1 | import { DebouncedInput } from "@/components/forms"; 2 | import { IconSearch } from "@tabler/icons"; 3 | import "@tanstack/react-table"; 4 | import clsx from "clsx"; 5 | 6 | function SearchInput({ 7 | value, 8 | onChange, 9 | debounce = 500, 10 | placeholder, 11 | ...props 12 | }: { 13 | value: string | number; 14 | onChange: (value: string | number) => void; 15 | debounce?: number; 16 | placeholder?: string; 17 | } & Omit, "onChange">) { 18 | return ( 19 |
20 |
21 | 22 |
23 | 24 | 36 |
37 | ); 38 | } 39 | 40 | export { SearchInput }; 41 | -------------------------------------------------------------------------------- /src/utils/Autosave/AutosaveMachine.typegen.ts: -------------------------------------------------------------------------------- 1 | // This file was automatically generated. Edits will be overwritten 2 | 3 | export interface Typegen0 { 4 | "@@xstate/typegen": true; 5 | internalEvents: { 6 | "done.invoke.Autosave": { 7 | type: "done.invoke.Autosave"; 8 | data: unknown; 9 | __tip: "See the XState TS docs to learn how to strongly type this."; 10 | }; 11 | "error.platform.Autosave": { 12 | type: "error.platform.Autosave"; 13 | data: unknown; 14 | }; 15 | "xstate.init": { type: "xstate.init" }; 16 | "xstate.stop": { type: "xstate.stop" }; 17 | }; 18 | invokeSrcNameMap: { 19 | autosave: "done.invoke.Autosave"; 20 | }; 21 | missingImplementations: { 22 | actions: never; 23 | services: "autosave"; 24 | guards: never; 25 | delays: never; 26 | }; 27 | eventsCausingActions: { 28 | cancelSave: "DEBOUNCE_SAVE" | "SAVE" | "xstate.stop"; 29 | saveAfterDelay: "CHECK_IF_FORM_IS_VALID"; 30 | }; 31 | eventsCausingServices: { 32 | autosave: "RETRY" | "SAVE"; 33 | }; 34 | eventsCausingGuards: { 35 | isDirty: "CHECK_FOR_CHANGES"; 36 | isValid: "CHECK_IF_FORM_IS_VALID"; 37 | }; 38 | eventsCausingDelays: {}; 39 | matchesStates: 40 | | "autosaveFailed" 41 | | "autosaveSuccessful" 42 | | "autosaving" 43 | | "cleanForm" 44 | | "dirtyForm" 45 | | "invalidForm" 46 | | "unknown" 47 | | "validForm"; 48 | tags: never; 49 | } 50 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "PORT=8582 next dev", 5 | "build": "next build", 6 | "start": "next start", 7 | "lint": "next lint" 8 | }, 9 | "dependencies": { 10 | "@emotion/server": "^11.10.0", 11 | "@headlessui/react": "^1.7.2", 12 | "@hookform/resolvers": "^2.9.8", 13 | "@mantine/core": "^5.3.2", 14 | "@mantine/dates": "^5.3.2", 15 | "@mantine/hooks": "^5.3.2", 16 | "@mantine/modals": "^5.3.2", 17 | "@mantine/next": "^5.3.2", 18 | "@tabler/icons": "^1.96.0", 19 | "@tanstack/react-table": "^8.5.13", 20 | "@xstate/react": "^3.0.1", 21 | "axios": "^0.27.2", 22 | "clsx": "^1.2.1", 23 | "dayjs": "^1.11.5", 24 | "eslint": "^8.23.1", 25 | "eslint-config-next": "^12.3.0", 26 | "eslint-plugin-react": "^7.31.8", 27 | "next": "^12.3.0", 28 | "next-auth": "^4.10.3", 29 | "query-string": "^7.1.1", 30 | "react": "18.2.0", 31 | "react-aria": "^3.19.0", 32 | "react-dom": "18.2.0", 33 | "react-hook-form": "^7.35.0", 34 | "react-hot-toast": "^2.4.0", 35 | "react-query": "^3.39.2", 36 | "react-stately": "^3.17.0", 37 | "react-toastify": "^9.0.8", 38 | "xstate": "^4.33.6", 39 | "yup": "^0.32.11", 40 | "zustand": "^4.1.1" 41 | }, 42 | "devDependencies": { 43 | "@hookform/devtools": "^4.2.2", 44 | "@tailwindcss/forms": "^0.5.3", 45 | "@types/node": "18.7.18", 46 | "@types/react": "18.0.20", 47 | "@types/react-dom": "18.0.6", 48 | "autoprefixer": "^10.4.11", 49 | "npm-check-updates": "^16.1.3", 50 | "postcss": "^8.4.16", 51 | "tailwindcss": "^3.1.8", 52 | "typescript": "4.8.3" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/images/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/components/settings/UsersTab.tsx: -------------------------------------------------------------------------------- 1 | import { SearchInput } from "@/components"; 2 | import { 3 | Button, 4 | PaginatedTableProvider, 5 | useGlobalFilter, 6 | } from "@/components/forms"; 7 | import { useHasPermission } from "@/domain/permissions"; 8 | import { UserListTable } from "@/domain/users"; 9 | import { IconCirclePlus } from "@tabler/icons"; 10 | import "@tanstack/react-table"; 11 | 12 | function UsersTab() { 13 | const canAddUser = useHasPermission("CanAddUsers"); 14 | const { globalFilter, queryFilter, calculateAndSetQueryFilter } = 15 | useGlobalFilter( 16 | (value) => `(firstName|lastName|identifier|username)@=*${value}` 17 | ); 18 | // TODO add email filter separately due to Value Object 19 | 20 | return ( 21 | <> 22 |

Users

23 |
24 | 25 |
26 |
27 | calculateAndSetQueryFilter(String(value))} 30 | placeholder="Search all columns..." 31 | /> 32 |
33 | {canAddUser.hasPermission && ( 34 | 40 | )} 41 |
42 | 43 |
44 | 45 |
46 |
47 |
48 | 49 | ); 50 | } 51 | 52 | export { UsersTab }; 53 | -------------------------------------------------------------------------------- /src/components/modal/ConfirmDeleteModal.tsx: -------------------------------------------------------------------------------- 1 | import { createStyles } from "@mantine/core"; 2 | import { openConfirmModal } from "@mantine/modals"; 3 | import React from "react"; 4 | 5 | interface DeleteModalProps { 6 | title?: string; 7 | onCancel?(): void; 8 | onConfirm?(): void; 9 | children?: React.ReactNode; 10 | } 11 | 12 | function useDeleteModal() { 13 | const useStyles = createStyles({}); 14 | const { cx } = useStyles(); 15 | 16 | const openDeleteModal = ({ 17 | onConfirm, 18 | onCancel, 19 | title, 20 | children, 21 | }: DeleteModalProps) => 22 | openConfirmModal({ 23 | title: title ?? "Please Confirm", 24 | centered: true, 25 | children: children ?? ( 26 |

27 | Are you sure you want to delete this record? 28 |

29 | ), 30 | labels: { confirm: "Delete", cancel: "Cancel" }, 31 | confirmProps: { 32 | classNames: { 33 | root: cx("bg-red-400 hover:bg-red-500"), 34 | }, 35 | }, 36 | //TODO make secondary button style?? 37 | cancelProps: { 38 | classNames: { 39 | root: cx( 40 | "bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-900 dark:text-white dark:hover:bg-slate-800" 41 | ), 42 | }, 43 | }, 44 | classNames: { 45 | modal: cx("bg-white dark:bg-slate-700"), 46 | title: cx("text-slate-900 dark:text-white text-lg font-medium"), 47 | close: cx( 48 | "bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-900 dark:text-white dark:hover:bg-slate-800" 49 | ), 50 | }, 51 | onCancel, 52 | onConfirm, 53 | }); 54 | return openDeleteModal; 55 | } 56 | 57 | export default useDeleteModal; 58 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { Notifications } from "@/components/notifications"; 2 | import { useAuthUser } from "@/domain/auth"; 3 | import Login from "@/domain/auth/components/Login"; 4 | import "@/styles/globals.css"; 5 | import { MantineProvider } from "@mantine/core"; 6 | import { ModalsProvider } from "@mantine/modals"; 7 | import { SessionProvider } from "next-auth/react"; 8 | import type { AppProps } from "next/app"; 9 | import { QueryClient, QueryClientProvider } from "react-query"; 10 | import { ReactQueryDevtools } from "react-query/devtools"; 11 | 12 | function MyApp({ Component, pageProps: { session, ...pageProps } }: AppProps) { 13 | return ( 14 | <> 15 | New Wrapt App in Next 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | ); 32 | } 33 | 34 | interface RouteGuardProps { 35 | children: React.ReactNode; 36 | isPublic: boolean; 37 | } 38 | function RouteGuard({ children, isPublic }: RouteGuardProps) { 39 | const { isLoggedIn, isLoading } = useAuthUser(); 40 | 41 | if (isPublic) return <>{children}; 42 | 43 | if (typeof window !== undefined && isLoading) return null; 44 | if (!isLoggedIn) return ; 45 | 46 | // TODO try when loading from server 47 | // if (!isLoggedIn) signIn(env.auth.nextAuthId, { callbackUrl: "/" }); 48 | 49 | return <>{children}; 50 | } 51 | 52 | export default MyApp; 53 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const defaultTheme = require('tailwindcss/defaultTheme') 2 | const plugin = require('tailwindcss/plugin') 3 | 4 | /** @type {import('tailwindcss').Config} */ 5 | module.exports = { 6 | darkMode: 'class', 7 | content: ['./src/**/*.{js,ts,jsx,tsx}'], 8 | theme: { 9 | fontSize: { 10 | xs: ['0.75rem', { lineHeight: '1rem' }], 11 | sm: ['0.875rem', { lineHeight: '1.5rem' }], 12 | base: ['1rem', { lineHeight: '1.75rem' }], 13 | lg: ['1.125rem', { lineHeight: '2rem' }], 14 | xl: ['1.25rem', { lineHeight: '2rem' }], 15 | '2xl': ['1.5rem', { lineHeight: '2rem' }], 16 | '3xl': ['2rem', { lineHeight: '2.5rem' }], 17 | '4xl': ['2.5rem', { lineHeight: '3.5rem' }], 18 | '5xl': ['3rem', { lineHeight: '3.5rem' }], 19 | '6xl': ['3.75rem', { lineHeight: '1' }], 20 | '7xl': ['4.5rem', { lineHeight: '1.1' }], 21 | '8xl': ['6rem', { lineHeight: '1' }], 22 | '9xl': ['8rem', { lineHeight: '1' }], 23 | }, 24 | extend: { 25 | borderRadius: { 26 | '4xl': '2rem', 27 | }, 28 | fontFamily: { 29 | sans: ['Lexend', 'Inter', ...defaultTheme.fontFamily.sans], 30 | display: ['Lexend', ...defaultTheme.fontFamily.sans], 31 | }, 32 | height: { 33 | "screen-minus-private-header": 'calc(100vh - var(--private-header-height))', 34 | "private-header": 'var(--private-header-height)', 35 | }, 36 | maxWidth: { 37 | '8xl': '88rem', 38 | '9xl': '96rem', 39 | '10xl': '104rem', 40 | }, 41 | }, 42 | }, 43 | plugins: [ 44 | require('@tailwindcss/forms'), 45 | plugin(function({ addVariant }) { 46 | addVariant('data-active', '&[data-active]') 47 | addVariant('data-selected', '&[data-selected]') 48 | addVariant('data-hovered', '&[data-hovered]') 49 | addVariant('disabled', '&:disabled') 50 | }) 51 | ], 52 | } 53 | -------------------------------------------------------------------------------- /src/domain/roles/features/AssignedRolesList.tsx: -------------------------------------------------------------------------------- 1 | import { TrashButton } from "@/components/forms"; 2 | import { useHasPermission } from "@/domain/permissions"; 3 | import { useRemoveUserRole } from "@/domain/users/api/removeUserRole"; 4 | import toast from "react-hot-toast"; 5 | 6 | interface AssignedRolesListProps { 7 | userId: string; 8 | assignedRoles?: string[]; 9 | } 10 | 11 | function AssignedRolesList({ userId, assignedRoles }: AssignedRolesListProps) { 12 | const canRemoveUserRole = useHasPermission("CanRemoveUserRoles"); 13 | 14 | const removeRoleApi = useRemoveUserRole(); 15 | function removeRole(role: string) { 16 | removeRoleApi 17 | .mutateAsync({ userId, role }) 18 | .then(() => { 19 | toast.success("Role removed successfully"); 20 | }) 21 | .catch((e) => { 22 | toast.error("There was an error removing the role"); 23 | console.error(e); 24 | }); 25 | } 26 | 27 | return ( 28 | <> 29 |
30 | { 31 | <> 32 | {assignedRoles && assignedRoles?.length > 0 ? ( 33 | assignedRoles?.map((role) => ( 34 |
38 |

{role}

39 | {canRemoveUserRole.hasPermission && ( 40 |
41 | { 43 | removeRole(role); 44 | }} 45 | /> 46 |
47 | )} 48 |
49 | )) 50 | ) : ( 51 | <>No roles assigned 52 | )} 53 | 54 | } 55 |
56 | 57 | ); 58 | } 59 | 60 | export { AssignedRolesList }; 61 | -------------------------------------------------------------------------------- /src/components/settings/RolePermissionsTab.tsx: -------------------------------------------------------------------------------- 1 | import { SearchInput } from "@/components"; 2 | import { PaginatedTableProvider, useGlobalFilter } from "@/components/forms"; 3 | import { useHasPermission } from "@/domain/permissions"; 4 | import { 5 | RolePermissionForm, 6 | RolePermissionListTable, 7 | } from "@/domain/rolePermissions"; 8 | import "@tanstack/react-table"; 9 | 10 | function RolePermissionsTab() { 11 | const canAddRolePermission = useHasPermission("CanAddRolePermissions"); 12 | const { globalFilter, queryFilter, calculateAndSetQueryFilter } = 13 | useGlobalFilter((value) => `(role|permission)@=*${value}`); 14 | 15 | return ( 16 | <> 17 |
18 | {canAddRolePermission.hasPermission && ( 19 |
20 |

Add a Role Permission

21 |
22 | 23 |
24 |
25 | 26 |
27 |
28 | )} 29 |
30 | 31 |
32 |

Role Permissions

33 |
34 | 37 | calculateAndSetQueryFilter(String(value)) 38 | } 39 | placeholder="Search all columns..." 40 | className="block" 41 | /> 42 |
43 |
44 | 45 |
46 | 47 |
48 |
49 |
50 |
51 | 52 | ); 53 | } 54 | 55 | export { RolePermissionsTab }; 56 | -------------------------------------------------------------------------------- /src/lib/axios.tsx: -------------------------------------------------------------------------------- 1 | import { env } from "@/config"; 2 | import Axios from "axios"; 3 | import { getSession, signIn } from "next-auth/react"; 4 | 5 | export const clients = { 6 | recipeManagement: (headers?: { [key: string]: string }) => 7 | buildApiClient({ 8 | baseURL: `${env.clientUrls.recipeManagement()}/api`, 9 | customHeaders: headers, 10 | }), 11 | authServer: Axios.create({ 12 | baseURL: env.clientUrls.authServer(), 13 | }), 14 | }; 15 | 16 | interface ApiClientProps { 17 | baseURL?: string; 18 | customHeaders?: { 19 | [key: string]: string; 20 | }; 21 | } 22 | 23 | async function buildApiClient({ baseURL, customHeaders }: ApiClientProps) { 24 | const session = await getSession(); 25 | const token = session?.accessToken; 26 | 27 | const client = Axios.create({ 28 | baseURL, 29 | withCredentials: true, 30 | timeout: 30_000, // If you want to increase this, do it for a specific call, not the global app API. 31 | headers: { 32 | "X-CSRF": "1", 33 | Authorization: `Bearer ${token}`, 34 | ...customHeaders, 35 | }, 36 | }); 37 | 38 | client.interceptors.response.use( 39 | (response) => response, 40 | async (error) => { 41 | if (error.response) { 42 | // The request was made and the server responded with a status code 43 | // that falls out of the range of 2xx 44 | console.error( 45 | error.response.status, 46 | error.response.data, 47 | error.response.headers 48 | ); 49 | } else if (error.request) { 50 | // The request was made but no response was received 51 | // `error.request` is an instance of XMLHttpRequest in the browser and an instance of 52 | // http.ClientRequest in node.js 53 | console.error(error.request); 54 | } 55 | 56 | if (error && error.response && error.response.status === 401) { 57 | signIn(env.auth.nextAuthId, { callbackUrl: "/" }); 58 | } 59 | console.log((error && error.toJSON && error.toJSON()) || undefined); 60 | 61 | return Promise.reject(error); 62 | } 63 | ); 64 | 65 | return client; 66 | } 67 | -------------------------------------------------------------------------------- /src/components/forms/TextInput.tsx: -------------------------------------------------------------------------------- 1 | import { FormControlState } from "@/types"; 2 | import { getTestSelector } from "@/utils/testing"; 3 | import { 4 | createStyles, 5 | TextInput as MantineTextInput, 6 | TextInputProps as MantineTextInputProps, 7 | } from "@mantine/core"; 8 | import { IconAlertCircle } from "@tabler/icons"; 9 | import clsx from "clsx"; 10 | import { forwardRef } from "react"; 11 | 12 | interface TextInputProps extends MantineTextInputProps { 13 | testSelector: string; 14 | errorSrOnly?: boolean; 15 | } 16 | 17 | const TextInput = forwardRef( 18 | ({ testSelector, errorSrOnly, ...rest }, ref) => { 19 | const useStyles = createStyles({}); 20 | const { cx } = useStyles(); 21 | const { error, icon, disabled } = rest; 22 | 23 | let inputState = "valid" as typeof FormControlState[number]; 24 | if (error) inputState = "invalid"; 25 | if (disabled) inputState = "disabled"; 26 | 27 | return ( 28 | 55 | ) 56 | } 57 | /> 58 | ); 59 | } 60 | ); 61 | 62 | TextInput.displayName = "TextInput"; 63 | export { TextInput }; 64 | -------------------------------------------------------------------------------- /src/domain/users/api/getUserList.ts: -------------------------------------------------------------------------------- 1 | import { clients } from "@/lib/axios"; 2 | import { PagedResponse, Pagination } from "@/types/apis"; 3 | import { generateSieveSortOrder } from "@/utils/sorting"; 4 | import { AxiosResponse } from "axios"; 5 | import queryString from "query-string"; 6 | import { useQuery } from "react-query"; 7 | import { QueryParams, UserDto } from "../types"; 8 | import { UserKeys } from "./user.keys"; 9 | 10 | interface delayProps { 11 | hasArtificialDelay?: boolean; 12 | delayInMs?: number; 13 | } 14 | 15 | interface userListApiProps extends delayProps { 16 | queryString: string; 17 | } 18 | const getUsers = async ({ 19 | queryString, 20 | hasArtificialDelay, 21 | delayInMs, 22 | }: userListApiProps) => { 23 | queryString = queryString == "" ? queryString : `?${queryString}`; 24 | 25 | delayInMs = hasArtificialDelay ? delayInMs : 0; 26 | 27 | const [json] = await Promise.all([ 28 | clients.recipeManagement().then((axios) => 29 | axios 30 | .get(`/users${queryString}`) 31 | .then((response: AxiosResponse) => { 32 | return { 33 | data: response.data as UserDto[], 34 | pagination: JSON.parse( 35 | response.headers["x-pagination"] ?? "" 36 | ) as Pagination, 37 | } as PagedResponse; 38 | }) 39 | ), 40 | new Promise((resolve) => setTimeout(resolve, delayInMs)), 41 | ]); 42 | return json; 43 | }; 44 | 45 | interface userListHookProps extends QueryParams, delayProps {} 46 | export const useUsers = ({ 47 | pageNumber, 48 | pageSize, 49 | filters, 50 | sortOrder, 51 | hasArtificialDelay = false, 52 | delayInMs = 500, 53 | }: userListHookProps) => { 54 | let sortOrderString = generateSieveSortOrder(sortOrder); 55 | let queryParams = queryString.stringify({ 56 | pageNumber, 57 | pageSize, 58 | filters, 59 | sortOrder: sortOrderString, 60 | }); 61 | // var temp = useSession(); 62 | // console.log(temp.data.accessToken); 63 | 64 | return useQuery(UserKeys.list(queryParams ?? ""), () => 65 | getUsers({ queryString: queryParams, hasArtificialDelay, delayInMs }) 66 | ); 67 | }; 68 | -------------------------------------------------------------------------------- /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | #__next { 6 | min-height: 100%; 7 | } 8 | 9 | @layer components { 10 | .h1 { 11 | @apply text-2xl font-medium tracking-tight font-display text-slate-900 dark:text-gray-50 sm:text-4xl 12 | } 13 | 14 | .h2 { 15 | @apply text-xl font-medium tracking-tight font-display text-slate-900 dark:text-gray-50 sm:text-3xl 16 | } 17 | 18 | .h3 { 19 | @apply text-lg font-medium tracking-tight font-display text-slate-900 dark:text-gray-50 sm:text-2xl 20 | } 21 | 22 | 23 | /* Mantine */ 24 | .input-root { 25 | @apply w-full 26 | } 27 | .input { 28 | @apply block w-full p-2 text-sm rounded-md outline-none font-sans 29 | } 30 | .input-valid { 31 | @apply text-slate-900 bg-slate-50 dark:bg-slate-700 dark:text-white dark:placeholder-slate-400 border-slate-300 focus:border-violet-500 focus:ring-violet-500 dark:focus:border-violet-500 dark:focus:ring-violet-500 dark:border-slate-600 border 32 | } 33 | .input-invalid { 34 | @apply text-slate-900 bg-slate-50 dark:bg-slate-700 dark:text-white dark:placeholder-slate-400 border-red-400 border focus:border-red-400 focus:ring-red-400 dark:focus:border-red-400 dark:focus:ring-red-400 focus:ring-1 35 | } 36 | .input-disabled { 37 | @apply cursor-not-allowed border bg-slate-200/60 text-slate-300 dark:bg-slate-700 dark:text-slate-50 38 | } 39 | .input-dropdown { 40 | @apply py-1 bg-slate-50 border border-slate-300 text-slate-500 text-sm rounded-r-lg border-l-slate-100 dark:border-l-slate-700 border-l-2 focus:ring-violet-500 focus:border-violet-500 block dark:bg-slate-700 dark:border-slate-600 dark:placeholder-slate-400 dark:text-white dark:focus:ring-violet-500 dark:focus:border-violet-500 41 | } 42 | .input-items-wrapper { 43 | @apply p-0 text-sm text-slate-700 dark:text-slate-200 w-full 44 | } 45 | .input-item { 46 | @apply py-2 w-full text-sm transition-colors duration-100 ease-in 47 | } 48 | .form-label { 49 | @apply text-slate-900 dark:text-white pb-1 font-sans 50 | } 51 | .form-error { 52 | @apply text-sm -mt-1 53 | } 54 | .text-error { 55 | @apply text-red-400 56 | } 57 | } -------------------------------------------------------------------------------- /src/pages/settings/users/[userId].tsx: -------------------------------------------------------------------------------- 1 | import { PrivateLayout } from "@/components"; 2 | import { Button } from "@/components/forms"; 3 | import { Forbidden } from "@/domain/auth"; 4 | import { useHasPermission } from "@/domain/permissions"; 5 | import { AssignedRolesList, RolesForm } from "@/domain/roles"; 6 | import { useGetUser, UserForm } from "@/domain/users"; 7 | import Head from "next/head"; 8 | import { useRouter } from "next/router"; 9 | 10 | export default function EditUser() { 11 | const router = useRouter(); 12 | const { userId } = router.query; 13 | const { data: userData } = useGetUser(userId?.toString() ?? ""); 14 | const canUpdateUser = useHasPermission("CanUpdateUsers"); 15 | const canAddUserRole = useHasPermission("CanAddUserRoles"); 16 | 17 | return ( 18 | <> 19 | 20 | Edit User 21 | 22 | 23 | 24 | {canUpdateUser.hasPermission ? ( 25 |
26 |
27 | 30 |
31 |
32 |

Edit User

33 |
34 | 35 | 36 |
37 |

Manage Roles

38 | {canAddUserRole.hasPermission && ( 39 | 43 | )} 44 | 45 |

Assigned Roles

46 | 50 |
51 |
52 |
53 |
54 | ) : ( 55 | 56 | )} 57 |
58 | 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /src/domain/rolePermissions/api/getRolePermissionList.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | QueryParams, 3 | RolePermissionDto, 4 | RolePermissionKeys, 5 | } from "@/domain/rolePermissions"; 6 | import { clients } from "@/lib/axios"; 7 | import { PagedResponse, Pagination } from "@/types/apis"; 8 | import { generateSieveSortOrder } from "@/utils/sorting"; 9 | import { AxiosResponse } from "axios"; 10 | import queryString from "query-string"; 11 | import { useQuery } from "react-query"; 12 | 13 | interface delayProps { 14 | hasArtificialDelay?: boolean; 15 | delayInMs?: number; 16 | } 17 | 18 | interface rolepermissionListApiProps extends delayProps { 19 | queryString: string; 20 | } 21 | const getRolePermissions = async ({ 22 | queryString, 23 | hasArtificialDelay, 24 | delayInMs, 25 | }: rolepermissionListApiProps) => { 26 | queryString = queryString == "" ? queryString : `?${queryString}`; 27 | 28 | delayInMs = hasArtificialDelay ? delayInMs : 0; 29 | 30 | const [json] = await Promise.all([ 31 | clients.recipeManagement().then((axios) => 32 | axios 33 | .get(`/rolepermissions${queryString}`) 34 | .then((response: AxiosResponse) => { 35 | return { 36 | data: response.data as RolePermissionDto[], 37 | pagination: JSON.parse( 38 | response.headers["x-pagination"] ?? "" 39 | ) as Pagination, 40 | } as PagedResponse; 41 | }) 42 | ), 43 | new Promise((resolve) => setTimeout(resolve, delayInMs)), 44 | ]); 45 | return json; 46 | }; 47 | 48 | interface rolepermissionListHookProps extends QueryParams, delayProps {} 49 | export const useRolePermissions = ({ 50 | pageNumber, 51 | pageSize, 52 | filters, 53 | sortOrder, 54 | hasArtificialDelay = false, 55 | delayInMs = 500, 56 | }: rolepermissionListHookProps) => { 57 | let sortOrderString = generateSieveSortOrder(sortOrder); 58 | let queryParams = queryString.stringify({ 59 | pageNumber, 60 | pageSize, 61 | filters, 62 | sortOrder: sortOrderString, 63 | }); 64 | 65 | return useQuery(RolePermissionKeys.list(queryParams ?? ""), () => 66 | getRolePermissions({ 67 | queryString: queryParams, 68 | hasArtificialDelay, 69 | delayInMs, 70 | }) 71 | ); 72 | }; 73 | -------------------------------------------------------------------------------- /src/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/components/forms/Tabs.tsx: -------------------------------------------------------------------------------- 1 | import { ComboBox } from "@/components/forms"; 2 | import { 3 | createStyles, 4 | Tabs as MantineTabs, 5 | TabsProps as MantineTabsProps, 6 | } from "@mantine/core"; 7 | import clsx from "clsx"; 8 | import { Dispatch, ReactNode, SetStateAction } from "react"; 9 | 10 | interface TabsProps extends MantineTabsProps {} 11 | interface TabsListProps { 12 | activeTab: string | null; 13 | setActiveTab: Dispatch>; 14 | tabs: Tab[]; 15 | } 16 | 17 | interface Tab { 18 | value: string; 19 | label: string; 20 | icon?: ReactNode; 21 | rightSection?: ReactNode; 22 | } 23 | 24 | function Tabs({ ...rest }: TabsProps) { 25 | const useStyles = createStyles({}); 26 | const { cx } = useStyles(); 27 | 28 | return ( 29 | 42 | {rest.children} 43 | 44 | ); 45 | } 46 | 47 | function TabsList({ 48 | tabs = [], 49 | activeTab, 50 | setActiveTab, 51 | ...rest 52 | }: TabsListProps) { 53 | return ( 54 | <> 55 | ({ 59 | value: tab.value, 60 | label: tab.label, 61 | }))} 62 | value={activeTab} 63 | onChange={(e) => setActiveTab(e)} 64 | className="block w-full sm:hidden" 65 | /> 66 | 67 | 68 | {tabs.map((tab) => ( 69 | 75 | {tab.label} 76 | 77 | ))} 78 | 79 | 80 | ); 81 | } 82 | TabsList.displayName = "TabsList"; 83 | 84 | Tabs.List = TabsList; 85 | Tabs.Panel = MantineTabs.Panel; 86 | 87 | export { Tabs }; 88 | -------------------------------------------------------------------------------- /src/components/forms/TextArea.tsx: -------------------------------------------------------------------------------- 1 | import { FormControlState } from "@/types"; 2 | import { getTestSelector } from "@/utils/testing"; 3 | import { 4 | createStyles, 5 | Textarea as MantineTextarea, 6 | TextareaProps as MantineTextareaProps, 7 | } from "@mantine/core"; 8 | import { IconAlertCircle } from "@tabler/icons"; 9 | import clsx from "clsx"; 10 | import { forwardRef } from "react"; 11 | 12 | interface TextareaProps extends MantineTextareaProps { 13 | testSelector: string; 14 | resize?: "none" | "y" | "x" | "both"; 15 | asInputHeight?: boolean; 16 | errorSrOnly?: boolean; 17 | } 18 | 19 | const TextArea = forwardRef( 20 | ( 21 | { 22 | resize = "none", 23 | testSelector, 24 | asInputHeight = false, 25 | errorSrOnly, 26 | ...rest 27 | }, 28 | ref 29 | ) => { 30 | const useStyles = createStyles({}); 31 | const { cx } = useStyles(); 32 | const { error, disabled, maxRows, minRows } = rest; 33 | 34 | let inputState = "valid" as typeof FormControlState[number]; 35 | if (error) inputState = "invalid"; 36 | if (disabled) inputState = "disabled"; 37 | 38 | let resizeClass = "resize-none"; 39 | if (resize === "x") resizeClass = "resize-x"; 40 | if (resize === "y") resizeClass = "resize-y"; 41 | if (resize === "both") resizeClass = "resize"; 42 | 43 | return ( 44 | 71 | ) 72 | } 73 | maxRows={asInputHeight ? 1 : maxRows} 74 | minRows={asInputHeight ? 1 : minRows} 75 | /> 76 | ); 77 | } 78 | ); 79 | 80 | TextArea.displayName = "TextArea"; 81 | export { TextArea }; 82 | -------------------------------------------------------------------------------- /src/pages/settings/index.tsx: -------------------------------------------------------------------------------- 1 | import { PrivateLayout } from "@/components"; 2 | import { Tabs } from "@/components/forms"; 3 | import { RolePermissionsTab, UsersTab } from "@/components/settings"; 4 | import { Forbidden } from "@/domain/auth"; 5 | import { useCanAccessSettings, useHasPermission } from "@/domain/permissions"; 6 | import { IconShieldLock, IconUser } from "@tabler/icons"; 7 | import "@tanstack/react-table"; 8 | import Head from "next/head"; 9 | import { useState } from "react"; 10 | 11 | Settings.isPublic = false; 12 | export default function Settings() { 13 | const [activeTab, setActiveTab] = useState("users"); 14 | const canAccessSettings = useCanAccessSettings(); 15 | const canReadUsers = useHasPermission("CanReadUsers"); 16 | const canReadRolePermissions = useHasPermission("CanReadRolePermissions"); 17 | 18 | const tabs = []; 19 | if (canReadUsers) 20 | tabs.push({ 21 | value: "users", 22 | label: "Users", 23 | icon: , 24 | }); 25 | if (canReadRolePermissions) 26 | tabs.push({ 27 | value: "rolepermissions", 28 | label: "Role Permissions", 29 | icon: , 30 | }); 31 | 32 | return ( 33 | <> 34 | 35 | Settings 36 | 37 | 38 | 39 | <> 40 | {canAccessSettings.isLoading ? null : ( 41 | <> 42 | {canAccessSettings.hasPermission ? ( 43 |
44 |
45 |

46 | Settings 47 |

48 | 49 |
50 | 51 | 56 | 57 | 58 | {canReadUsers.hasPermission && } 59 | 60 | 61 | 62 | {canReadRolePermissions.hasPermission && ( 63 | 64 | )} 65 | 66 | 67 |
68 |
69 |
70 | ) : ( 71 | 72 | )} 73 | 74 | )} 75 | 76 |
77 | 78 | ); 79 | } 80 | -------------------------------------------------------------------------------- /src/components/forms/Button.tsx: -------------------------------------------------------------------------------- 1 | import { getTestSelector } from "@/utils"; 2 | import clsx from "clsx"; 3 | import Link from "next/link"; 4 | import { ReactNode } from "react"; 5 | 6 | export type ButtonStyle = "primary" | "secondary" | "warning"; 7 | 8 | interface ButtonProps { 9 | children: ReactNode; 10 | className?: string; 11 | type?: "button" | "submit" | "reset"; 12 | buttonStyle?: ButtonStyle; 13 | disabled?: boolean; 14 | id?: string; 15 | onClick?: (event?: any) => void; 16 | testSelector?: string; 17 | icon?: ReactNode; 18 | href?: string; 19 | } 20 | 21 | function Button({ 22 | className = "", 23 | children, 24 | onClick, 25 | disabled, 26 | id, 27 | type = "button", 28 | buttonStyle = "primary", 29 | testSelector = getTestSelector( 30 | id || (typeof children === "string" ? children : "not-identified"), 31 | "button" 32 | ), 33 | icon, 34 | href, 35 | ...rest 36 | }: ButtonProps) { 37 | const baseStyles = clsx( 38 | "px-3 py-2 border rounded-md shadow transition-all", 39 | buttonStyle === "primary" && 40 | "text-white bg-violet-500 border-violet-700 dark:bg-violet-500/50 dark:border-violet-500/50 ", 41 | buttonStyle === "primary" && 42 | !disabled && 43 | "hover:bg-violet-600 dark:hover:bg-violet-600/50", 44 | buttonStyle === "secondary" && 45 | "bg-slate-100 text-slate-900 dark:bg-slate-900 dark:text-white ", 46 | buttonStyle === "secondary" && 47 | !disabled && 48 | "hover:bg-slate-200 dark:hover:bg-slate-700", 49 | buttonStyle === "warning" && "text-white bg-red-400 border-red-400 ", 50 | buttonStyle === "warning" && 51 | !disabled && 52 | "hover:bg-red-500 hover:border-red-500", 53 | icon && "flex items-center" 54 | ); 55 | 56 | let renderAs = "button" as "button" | "link"; 57 | if (href) renderAs = "link"; 58 | 59 | return ( 60 | <> 61 | {renderAs === "link" ? ( 62 | 76 | {icon && {icon}} 77 | {children} 78 | 79 | ) : ( 80 | 96 | )} 97 | 98 | ); 99 | } 100 | 101 | export { Button }; 102 | -------------------------------------------------------------------------------- /src/components/PrivateHeader.tsx: -------------------------------------------------------------------------------- 1 | import { ThemeToggle } from "@/components/ThemeToggle"; 2 | import { useAuthUser } from "@/domain/auth"; 3 | import { Menu, Transition } from "@headlessui/react"; 4 | import { Avatar } from "@mantine/core"; 5 | import clsx from "clsx"; 6 | import { signOut } from "next-auth/react"; 7 | import { Fragment } from "react"; 8 | 9 | function PrivateHeader() { 10 | const { user } = useAuthUser(); 11 | 12 | return ( 13 | 65 | ); 66 | } 67 | 68 | export { PrivateHeader }; 69 | -------------------------------------------------------------------------------- /src/utils/Autosave/useAutosave.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { interpret } from "xstate"; 3 | import { autosaveMachine } from "./AutosaveMachine"; 4 | 5 | interface AutosaveProps { 6 | handleSubmission: any; 7 | isDirty: boolean; 8 | isValid?: boolean; 9 | formFields: any; 10 | debounceDelayMs?: number; 11 | isActive?: boolean; 12 | } 13 | 14 | // TODO add proper typescript 15 | // TODO build debounce into the machine 16 | // export function useAutosave({ 17 | // handleSubmission, 18 | // isDirty, 19 | // isValid = true, 20 | // formFields, 21 | // debounceDelayMs = 1500, 22 | // isActive = true, 23 | // }: AutosaveProps) { 24 | // const configuredAutosaveMachine = autosaveMachine.withConfig({ 25 | // services: { 26 | // autosave: () => handleSubmission, 27 | // }, 28 | // }); 29 | 30 | // const [state, send] = useMachine(configuredAutosaveMachine); 31 | 32 | // // const autosaveService = interpret(configuredAutosaveMachine) 33 | // // // .onTransition((state) => console.log(state.value)) 34 | // // .start(); 35 | 36 | // useEffect(() => { 37 | // if (isDirty && isActive) 38 | // send({ 39 | // type: "CHECK_FOR_CHANGES", 40 | // query: isDirty, 41 | // }); 42 | // }, [isActive, isDirty, send]); 43 | 44 | // useEffect(() => { 45 | // let timeout = setTimeout(() => {}); 46 | 47 | // if (isValid && isActive) 48 | // timeout = setTimeout(() => { 49 | // send({ 50 | // type: "CHECK_IF_FORM_IS_VALID", 51 | // query: isValid, 52 | // }); 53 | // }, debounceDelayMs); 54 | 55 | // return () => clearTimeout(timeout); 56 | // }, [isValid, formFields, debounceDelayMs, send, isActive]); 57 | // } 58 | 59 | // TODO add proper typescript 60 | // TODO build debounce into the machine 61 | export function useAutosave({ 62 | handleSubmission, 63 | isDirty, 64 | isValid = true, 65 | formFields, 66 | debounceDelayMs = 1500, 67 | isActive = true, 68 | }: AutosaveProps) { 69 | const configuredAutosaveMachine = autosaveMachine.withConfig({ 70 | services: { 71 | autosave: () => handleSubmission, 72 | }, 73 | }); 74 | 75 | // const [state, send] = useMachine(configuredAutosaveMachine); 76 | 77 | const autosaveService = interpret(configuredAutosaveMachine) 78 | // .onTransition((state) => console.log(state.value)) 79 | .start(); 80 | 81 | useEffect(() => { 82 | if (isDirty && isActive) 83 | autosaveService.send({ 84 | type: "CHECK_FOR_CHANGES", 85 | query: isDirty, 86 | }); 87 | }, [isActive, isDirty, autosaveService]); 88 | 89 | useEffect(() => { 90 | let timeout = setTimeout(() => {}); 91 | 92 | if (isValid && isActive) 93 | timeout = setTimeout(() => { 94 | autosaveService.send({ 95 | type: "CHECK_IF_FORM_IS_VALID", 96 | query: isValid, 97 | }); 98 | }, debounceDelayMs); 99 | 100 | return () => clearTimeout(timeout); 101 | }, [isValid, formFields, debounceDelayMs, isActive, autosaveService]); 102 | } 103 | -------------------------------------------------------------------------------- /src/domain/roles/features/RolesForm.tsx: -------------------------------------------------------------------------------- 1 | import { Button, ComboBox } from "@/components/forms"; 2 | import { useAddUserRole } from "@/domain/users/api/addUserRole"; 3 | import { DevTool } from "@hookform/devtools"; 4 | import { useEffect } from "react"; 5 | import { Controller, SubmitHandler, useForm } from "react-hook-form"; 6 | import toast from "react-hot-toast"; 7 | import { useGetRoles } from "../api/getRoles"; 8 | 9 | interface RolesFormProps { 10 | userId: string; 11 | assignedRoles?: string[]; 12 | shouldSetFocus?: boolean; 13 | } 14 | 15 | interface RoleToSubmit { 16 | role: string; 17 | } 18 | 19 | function RolesForm({ 20 | userId, 21 | assignedRoles, 22 | shouldSetFocus = false, 23 | }: RolesFormProps) { 24 | const focusField = "role"; 25 | const { handleSubmit, reset, control, setFocus } = useForm({ 26 | defaultValues: { 27 | role: "", 28 | }, 29 | }); 30 | 31 | useEffect(() => { 32 | shouldSetFocus && setFocus(focusField); 33 | }, [setFocus, shouldSetFocus]); 34 | 35 | const onSubmit: SubmitHandler = (data) => { 36 | addRole(data.role); 37 | if (shouldSetFocus) setFocus(focusField); 38 | }; 39 | 40 | const { data: rolesList } = useGetRoles(); 41 | const addRoleApi = useAddUserRole(); 42 | function addRole(role: string) { 43 | addRoleApi 44 | .mutateAsync({ userId, role }) 45 | .then(() => { 46 | toast.success("Role added successfully"); 47 | }) 48 | .then(() => { 49 | reset(); 50 | }) 51 | .catch((e) => { 52 | toast.error("There was an error adding the role"); 53 | console.error(e); 54 | }); 55 | } 56 | 57 | function getRolesList() { 58 | return ( 59 | rolesList 60 | ?.filter((item) => !assignedRoles?.includes(item)) 61 | ?.map((role) => ({ value: role, label: role })) ?? [] 62 | ); 63 | } 64 | 65 | return ( 66 | <> 67 | {/* Need `noValidate` to allow RHF validation to trump browser validation when field is required */} 68 |
73 |
74 | ( 79 | 91 | )} 92 | /> 93 |
94 | 97 |
98 | 99 | 100 | ); 101 | } 102 | 103 | export { RolesForm }; 104 | -------------------------------------------------------------------------------- /src/domain/rolePermissions/features/RolePermissionListTable.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | PaginatedTable, 3 | TrashButton, 4 | usePaginatedTableContext, 5 | } from "@/components/forms"; 6 | import useDeleteModal from "@/components/modal/ConfirmDeleteModal"; 7 | import { Notifications } from "@/components/notifications"; 8 | import { useHasPermission } from "@/domain/permissions"; 9 | import { 10 | RolePermissionDto, 11 | useDeleteRolePermission, 12 | useRolePermissions, 13 | } from "@/domain/rolePermissions"; 14 | import "@tanstack/react-table"; 15 | import { createColumnHelper, SortingState } from "@tanstack/react-table"; 16 | 17 | interface RolePermissionListTableProps { 18 | queryFilter?: string | undefined; 19 | } 20 | 21 | export function RolePermissionListTable({ 22 | queryFilter, 23 | }: RolePermissionListTableProps) { 24 | const { sorting, pageSize, pageNumber } = usePaginatedTableContext(); 25 | const canDeleteRolePermission = useHasPermission("CanDeleteRolePermissions"); 26 | 27 | const openDeleteModal = useDeleteModal(); 28 | const deleteRolePermissionApi = useDeleteRolePermission(); 29 | function deleteRolePermission(id: string) { 30 | deleteRolePermissionApi 31 | .mutateAsync(id) 32 | .then(() => { 33 | Notifications.success("RolePermission deleted successfully"); 34 | }) 35 | .catch((e) => { 36 | Notifications.error("There was an error deleting the rolePermission"); 37 | console.error(e); 38 | }); 39 | } 40 | 41 | const { data: rolePermissionResponse, isLoading } = useRolePermissions({ 42 | sortOrder: sorting as SortingState, 43 | pageSize, 44 | pageNumber, 45 | filters: queryFilter, 46 | hasArtificialDelay: true, 47 | }); 48 | const rolePermissionData = rolePermissionResponse?.data; 49 | const rolePermissionPagination = rolePermissionResponse?.pagination; 50 | 51 | const columnHelper = createColumnHelper(); 52 | const columns = [ 53 | columnHelper.accessor((row) => row.role, { 54 | id: "role", 55 | cell: (info) =>

{info.getValue()}

, 56 | header: () => Role, 57 | }), 58 | columnHelper.accessor((row) => row.permission, { 59 | id: "permission", 60 | cell: (info) =>

{info.getValue()}

, 61 | header: () => Permission, 62 | }), 63 | columnHelper.accessor("id", { 64 | enableSorting: false, 65 | meta: { thClassName: "w-10" }, 66 | cell: (row) => ( 67 |
68 | {canDeleteRolePermission.hasPermission && ( 69 | { 71 | openDeleteModal({ 72 | onConfirm: () => deleteRolePermission(row.getValue()), 73 | }); 74 | e.stopPropagation(); 75 | }} 76 | /> 77 | )} 78 |
79 | ), 80 | header: () => , 81 | }), 82 | ]; 83 | 84 | return ( 85 | 92 | ); 93 | } 94 | -------------------------------------------------------------------------------- /src/components/forms/Checkbox.tsx: -------------------------------------------------------------------------------- 1 | import { getTestSelector } from "@/utils/testing"; 2 | import { useCheckbox } from "@react-aria/checkbox"; 3 | import { useFocusRing } from "@react-aria/focus"; 4 | import { mergeProps } from "@react-aria/utils"; 5 | import { VisuallyHidden } from "@react-aria/visually-hidden"; 6 | import { useToggleState } from "@react-stately/toggle"; 7 | import type { AriaCheckboxProps } from "@react-types/checkbox"; 8 | import { clsx } from "clsx"; 9 | import { forwardRef, useRef } from "react"; 10 | 11 | interface CheckboxProps extends AriaCheckboxProps { 12 | label: string; 13 | required?: boolean; 14 | error?: string | undefined; 15 | testSelector: string; 16 | } 17 | 18 | const Checkbox = forwardRef( 19 | ({ label, required = false, error, testSelector, ...props }, ref) => { 20 | let state = useToggleState(props); 21 | let fallbackRef = useRef(null); 22 | // @ts-ignore 23 | let { inputProps } = useCheckbox(props, state, ref || fallbackRef); 24 | let { focusProps, isFocusVisible } = useFocusRing(); 25 | 26 | let checkboxClassName = clsx( 27 | state.isSelected 28 | ? "bg-violet-500 group-active:bg-violet-600" 29 | : "input input-valid", 30 | "text-white", 31 | "border-2", 32 | "rounded", 33 | props.isDisabled 34 | ? "border-slate-300" 35 | : isFocusVisible || state.isSelected 36 | ? "border-violet-500 group-active:border-violet-600" 37 | : "border-slate-500 group-active:border-slate-600", 38 | "w-5", 39 | "h-5", 40 | "flex", 41 | "flex-shrink-0", 42 | "justify-center", 43 | "items-center", 44 | "mr-2", 45 | isFocusVisible ? "shadow-outline" : "", 46 | "transition", 47 | "ease-in-out", 48 | "duration-150" 49 | ); 50 | 51 | let labelClassName = clsx( 52 | props.isDisabled 53 | ? "input-disabled" 54 | : clsx( 55 | "text-slate-700 dark:text-white" 56 | // "group-active:text-slate-800 dark:group-active:text-slate-300" 57 | ), 58 | "select-none" 59 | ); 60 | 61 | return ( 62 | <> 63 | 91 | {error &&

{error}

} 92 | 93 | ); 94 | } 95 | ); 96 | 97 | Checkbox.displayName = "Checkbox"; 98 | export { Checkbox }; 99 | -------------------------------------------------------------------------------- /src/components/forms/Autocomplete.tsx: -------------------------------------------------------------------------------- 1 | import { FormControlState } from "@/types"; 2 | import { getTestSelector } from "@/utils/testing"; 3 | import { 4 | Autocomplete as MantineAutocomplete, 5 | AutocompleteProps as MantineAutocompleteProps, 6 | createStyles, 7 | } from "@mantine/core"; 8 | import { IconAlertCircle } from "@tabler/icons"; 9 | import clsx from "clsx"; 10 | import { useTailwindColors } from "../../hooks/useTailwindConfig"; 11 | import { useSetting } from "../ThemeToggle"; 12 | 13 | interface AutocompleteProps extends MantineAutocompleteProps { 14 | testSelector: string; 15 | errorSrOnly?: boolean; 16 | } 17 | 18 | function Autocomplete({ 19 | testSelector, 20 | errorSrOnly, 21 | ...rest 22 | }: AutocompleteProps) { 23 | const themeSetting = useSetting((state) => state.setting); 24 | const twColors = useTailwindColors(); 25 | const useStyles = createStyles({ 26 | item: { 27 | color: 28 | themeSetting === "dark" 29 | ? twColors?.slate["400"] 30 | : twColors?.slate["700"], 31 | "&[data-hovered]": { 32 | color: 33 | themeSetting === "dark" 34 | ? twColors?.slate["100"] 35 | : twColors?.slate["600"], 36 | backgroundColor: 37 | themeSetting === "dark" 38 | ? twColors?.slate["600"] 39 | : twColors?.slate["200"], 40 | }, 41 | 42 | "&[data-selected]": { 43 | color: 44 | themeSetting === "dark" 45 | ? twColors?.violet["100"] 46 | : twColors?.violet["600"], 47 | backgroundColor: 48 | themeSetting === "dark" 49 | ? twColors?.violet["600"] 50 | : twColors?.violet["200"], 51 | }, 52 | 53 | "&[data-selected]&:hover": { 54 | color: 55 | themeSetting === "dark" 56 | ? twColors?.slate["100"] 57 | : twColors?.slate["600"], 58 | backgroundColor: 59 | themeSetting === "dark" 60 | ? twColors?.slate["600"] 61 | : twColors?.slate["200"], 62 | }, 63 | }, 64 | }); 65 | const { classes, cx } = useStyles(); 66 | const { error, disabled } = rest; 67 | 68 | let inputState = "valid" as typeof FormControlState[number]; 69 | if (error) inputState = "invalid"; 70 | if (disabled) inputState = "disabled"; 71 | 72 | return ( 73 | 101 | {inputState === "invalid" && ( 102 | 103 | )} 104 | 105 | } 106 | /> 107 | ); 108 | } 109 | 110 | Autocomplete.displayName = "Autocomplete"; 111 | export { Autocomplete }; 112 | -------------------------------------------------------------------------------- /src/components/forms/NumberInput.tsx: -------------------------------------------------------------------------------- 1 | import { FormControlState } from "@/types"; 2 | import { getTestSelector } from "@/utils/testing"; 3 | import { 4 | createStyles, 5 | NumberInput as MantineNumberInput, 6 | NumberInputHandlers, 7 | NumberInputProps as MantineNumberInputProps, 8 | } from "@mantine/core"; 9 | import { IconAlertCircle, IconMinus, IconPlus } from "@tabler/icons"; 10 | import clsx from "clsx"; 11 | import { forwardRef, useRef } from "react"; 12 | 13 | interface NumberInputProps extends MantineNumberInputProps { 14 | testSelector: string; 15 | errorSrOnly?: boolean; 16 | } 17 | 18 | const NumberInput = forwardRef( 19 | ({ testSelector, errorSrOnly, ...rest }, ref) => { 20 | const useStyles = createStyles({}); 21 | const { cx } = useStyles(); 22 | const handlers = useRef(); 23 | const { error, disabled, value, min, max } = rest; 24 | 25 | let inputState = "valid" as typeof FormControlState[number]; 26 | if (error) inputState = "invalid"; 27 | if (disabled) inputState = "disabled"; 28 | 29 | return ( 30 | 53 |
54 | {inputState === "invalid" && ( 55 | 56 | )} 57 |
58 | 73 | 88 | 89 | } 90 | rightSectionWidth={inputState === "invalid" ? 100 : 80} 91 | {...rest} 92 | /> 93 | ); 94 | } 95 | ); 96 | 97 | NumberInput.displayName = "NumberInput"; 98 | export { NumberInput }; 99 | -------------------------------------------------------------------------------- /src/domain/users/features/UserListTable.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | PaginatedTable, 3 | TrashButton, 4 | usePaginatedTableContext, 5 | } from "@/components/forms"; 6 | import useDeleteModal from "@/components/modal/ConfirmDeleteModal"; 7 | import { Notifications } from "@/components/notifications"; 8 | import { useHasPermission } from "@/domain/permissions"; 9 | import { useDeleteUser, UserDto, useUsers } from "@/domain/users"; 10 | import "@tanstack/react-table"; 11 | import { createColumnHelper, Row, SortingState } from "@tanstack/react-table"; 12 | import { useRouter } from "next/router"; 13 | 14 | interface UserListTableProps { 15 | queryFilter?: string | undefined; 16 | } 17 | 18 | export function UserListTable({ queryFilter }: UserListTableProps) { 19 | const router = useRouter(); 20 | const { sorting, pageSize, pageNumber } = usePaginatedTableContext(); 21 | const canUpdateUser = useHasPermission("CanUpdateUsers"); 22 | const canDeleteUser = useHasPermission("CanDeleteUsers"); 23 | 24 | const onRowClick = canUpdateUser.hasPermission 25 | ? (row: Row) => router.push(`/settings/users/${row.id}`) 26 | : undefined; 27 | 28 | const openDeleteModal = useDeleteModal(); 29 | const deleteUserApi = useDeleteUser(); 30 | function deleteUser(id: string) { 31 | deleteUserApi 32 | .mutateAsync(id) 33 | .then(() => { 34 | // TODO are you sure modal ***************************************** 35 | Notifications.success("User deleted successfully"); 36 | }) 37 | .catch((e) => { 38 | Notifications.error("There was an error deleting the User"); 39 | console.error(e); 40 | }); 41 | } 42 | 43 | const { data: UserResponse, isLoading } = useUsers({ 44 | sortOrder: sorting as SortingState, 45 | pageSize, 46 | pageNumber, 47 | filters: queryFilter, 48 | hasArtificialDelay: true, 49 | }); 50 | const UserData = UserResponse?.data; 51 | const UserPagination = UserResponse?.pagination; 52 | 53 | const columnHelper = createColumnHelper(); 54 | const columns = [ 55 | columnHelper.accessor((row) => row.identifier, { 56 | id: "identifier", 57 | cell: (info) =>

{info.getValue()}

, 58 | header: () => Identifier, 59 | }), 60 | columnHelper.accessor((row) => row.firstName, { 61 | id: "firstName", 62 | cell: (info) =>

{info.getValue()}

, 63 | header: () => First Name, 64 | }), 65 | columnHelper.accessor((row) => row.lastName, { 66 | id: "lastName", 67 | cell: (info) =>

{info.getValue()}

, 68 | header: () => Last Name, 69 | }), 70 | columnHelper.accessor((row) => row.email, { 71 | id: "email", 72 | cell: (info) =>

{info.getValue()}

, 73 | header: () => Email, 74 | }), 75 | columnHelper.accessor((row) => row.username, { 76 | id: "username", 77 | cell: (info) =>

{info.getValue()?.toLocaleString()}

, 78 | header: () => Username, 79 | }), 80 | columnHelper.accessor("id", { 81 | enableSorting: false, 82 | meta: { thClassName: "w-10" }, 83 | cell: (row) => ( 84 |
85 | {canDeleteUser.hasPermission && ( 86 | { 88 | openDeleteModal({ 89 | onConfirm: () => deleteUser(row.getValue()), 90 | }); 91 | e.stopPropagation(); 92 | }} 93 | /> 94 | )} 95 |
96 | ), 97 | header: () => , 98 | }), 99 | ]; 100 | 101 | return ( 102 | 110 | ); 111 | } 112 | -------------------------------------------------------------------------------- /src/utils/Autosave/AutosaveMachine.tsx: -------------------------------------------------------------------------------- 1 | import { actions, createMachine, send } from "xstate"; 2 | 3 | export const autosaveMachine = 4 | /** @xstate-layout N4IgpgJg5mDOIC5QEECuAXA9rAhgNzADpUA7AaxMwHcSBiAYQAkBRegaQH0AxAeQCUOTZADkA4swDKiUAAdsAS3TzMJaSAAeiAIwBmAOx7CABgCsADnNa9+s1oAsdgDQgAnoju7CANjtm7AJjMvHVNgrz0AXwjnNCxcAkIANRwAG3kIAAIuTAAnAFtaCWRE5jU5WEVlVSQNRB0TO0IDHS8TI38ATjb-LSM9ZzcEOyMjbx1Ajv8dHUm-fzsomIxsfCJktMzs-NoAEWYAIR4AVWF6Zg4ikrKFJRU1TQQdX0IzJ70urT9JrS0BxHMTC89CYOqCTFpgvZFiBYisEgBJEh4VLpLK5ArXCq3aqgB4-Ow6QhdHQ-IL+EwmHSvP5Dd5EqbvLx+Lz+axmaGw+JETn4eQkKC0CAqIh8vCYMjc5ZczGVO41B4GQENCGQvSBIx+GkeLRE+rgvReLT+Ux2YEcqWrQg8vB8gVgHI5XKEGQpHDoABm6KtFoIMux90QisIyq8qvVmtcdXBhB+Wg+5nqVI6C2iMJ9kriqyyOHkKUgtD4zAAKnwAJp+qoBhBmIwdGMNLojDpMrpOSMIck6g0hTrgjx9SKp60ZuFgDKwVAAY0ncFg7tQKVoFbluO09kJxNJLIpVJ0Wp+uopVkNxoaZqH6cIO3kOXQLjR2yYrE48K43H4AFkOPCJBxEsgABl4R2ZccVqBBdAMYwAVsaw9FsBwaTMOsOjMMxWUmJ4eg6dpzUzBJ6DzHASAfDEanKWUwLxQIdR8CwVRBIwvCMPd2y8DpDHmZMplQuwTDgqJU0oCA4DUYdiHISgaFAqsjXMF5-E6DVbHCT4vC1diY0UqlOiCPQRnZC98LWFFNnRGT5XcfwaXBFCqXeWxk2Ygw8NHQhEWRDZSIs1cEFQrxvA44Y-CY5jPhpT5DA+Gt4K0SkCQ6HRXK5b18NtHzwNNMxCGGFkqVaDpPhadT23qRpk2ZMw9CNH4fGSy1h2zXNIAyvFdDrCEQjQlpG0UmlplGNTJkmAIwS0eqEkaidp1necUlaxBkMBNVGQpGssN+dtwVon5UL0XwjF8PwJqIa9b3vLY8gWiDgn8YNUOTew2hBCkNJ1LoDXY+Daw4rwTsIQiwGI7zyJuStLIg8EAsCfj6l2z55iQlimmNUwenY41dBO67wvbKwiVBB6oYhZM+kEiIgA */ 5 | createMachine( 6 | { 7 | tsTypes: {} as import("./AutosaveMachine.typegen").Typegen0, 8 | // schema: { 9 | // context: {} as {}, 10 | // events: {} as {}, 11 | // }, 12 | predictableActionArguments: true, 13 | id: "Autosave Machine", 14 | initial: "unknown", 15 | states: { 16 | unknown: { 17 | on: { 18 | CHECK_FOR_CHANGES: [ 19 | { 20 | cond: "isDirty", 21 | target: "dirtyForm", 22 | }, 23 | { 24 | target: "cleanForm", 25 | }, 26 | ], 27 | }, 28 | }, 29 | validForm: { 30 | entry: "saveAfterDelay", 31 | exit: "cancelSave", 32 | on: { 33 | SAVE: { 34 | target: "autosaving", 35 | }, 36 | DEBOUNCE_SAVE: { 37 | actions: "cancelSave", 38 | target: "unknown", 39 | }, 40 | }, 41 | }, 42 | invalidForm: { 43 | always: { 44 | target: "unknown", 45 | }, 46 | }, 47 | autosaving: { 48 | invoke: { 49 | id: "Autosave", 50 | src: "autosave", 51 | onDone: [ 52 | { 53 | target: "autosaveSuccessful", 54 | }, 55 | ], 56 | onError: [ 57 | { 58 | target: "autosaveFailed", 59 | }, 60 | ], 61 | }, 62 | }, 63 | autosaveFailed: { 64 | on: { 65 | RETRY: { 66 | target: "autosaving", 67 | }, 68 | }, 69 | }, 70 | autosaveSuccessful: { 71 | always: { 72 | target: "unknown", 73 | }, 74 | }, 75 | dirtyForm: { 76 | on: { 77 | CHECK_IF_FORM_IS_VALID: [ 78 | { 79 | cond: "isValid", 80 | target: "validForm", 81 | }, 82 | { 83 | target: "invalidForm", 84 | }, 85 | ], 86 | }, 87 | }, 88 | cleanForm: { 89 | always: { 90 | target: "unknown", 91 | }, 92 | }, 93 | }, 94 | }, 95 | { 96 | services: { 97 | // autosave: () => alert("autosaving!"), 98 | }, 99 | guards: { 100 | isDirty: (context, event) => Boolean(event.query), 101 | isValid: (context, event) => Boolean(event.query), 102 | }, 103 | actions: { 104 | cancelSave: actions.cancel("saveDelay"), 105 | saveAfterDelay: send({ type: "SAVE" }, { delay: 0, id: "saveDelay" }), 106 | }, 107 | } 108 | ); 109 | -------------------------------------------------------------------------------- /src/components/forms/Combobox.tsx: -------------------------------------------------------------------------------- 1 | import { FormControlState } from "@/types"; 2 | import { getTestSelector } from "@/utils/testing"; 3 | import { createStyles, Select, SelectProps } from "@mantine/core"; 4 | import { IconAlertCircle, IconCheck, IconChevronDown } from "@tabler/icons"; 5 | import clsx from "clsx"; 6 | import { forwardRef } from "react"; 7 | 8 | interface ComboBoxProps extends SelectProps { 9 | testSelector: string; 10 | errorSrOnly?: boolean; 11 | } 12 | 13 | const ComboBox = forwardRef( 14 | ({ testSelector, errorSrOnly, ...rest }, ref) => { 15 | const useStyles = createStyles({}); 16 | const { classes, cx } = useStyles(); 17 | const { error, disabled, value, clearable } = rest; 18 | 19 | let inputState = "valid" as typeof FormControlState[number]; 20 | if (error) inputState = "invalid"; 21 | if (disabled) inputState = "disabled"; 22 | 23 | const showClearable = clearable && value !== null && value !== undefined; 24 | 25 | interface ItemProps extends React.ComponentPropsWithoutRef<"div"> { 26 | label: string; 27 | value: string; 28 | "data-selected": boolean; 29 | "data-hovered": boolean; 30 | } 31 | 32 | const SelectItem = forwardRef( 33 | ( 34 | { 35 | label, 36 | value, 37 | "data-selected": isSelected, 38 | "data-hovered": isHovered, 39 | ...others 40 | }: ItemProps, 41 | ref 42 | ) => { 43 | return ( 44 |
55 | 61 | {isSelected ? ( 62 | 67 | 77 | ) : null} 78 |

{label}

79 |
80 |
81 | ); 82 | } 83 | ); 84 | SelectItem.displayName = "SelectItem"; 85 | 86 | return ( 87 |