From 39bebea5f77e01f5a7e3d2af2b0d7f9aa014a71c Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 8 Jan 2026 03:33:03 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20create=20&=20update=20role=20with?= =?UTF-8?q?=20device=20approval?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 5 + server/routers/external.ts | 8 + server/routers/integration.ts | 8 + server/routers/role/createRole.ts | 10 +- server/routers/role/updateRole.ts | 22 +- .../[orgId]/settings/access/roles/page.tsx | 2 +- src/components/CreateRoleForm.tsx | 101 ++++---- src/components/Credenza.tsx | 8 +- src/components/EditRoleForm.tsx | 241 ++++++++++++++++++ src/components/MachineClientsTable.tsx | 11 +- src/components/RolesTable.tsx | 149 +++++++---- src/components/ui/checkbox.tsx | 12 +- 12 files changed, 449 insertions(+), 128 deletions(-) create mode 100644 src/components/EditRoleForm.tsx diff --git a/messages/en-US.json b/messages/en-US.json index a805c2f0..dbccd6d6 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -734,6 +734,11 @@ "accessRoleCreatedDescription": "The role has been successfully created.", "accessRoleErrorCreate": "Failed to create role", "accessRoleErrorCreateDescription": "An error occurred while creating the role.", + "accessRoleUpdateSubmit": "Update Role", + "accessRoleUpdated": "Role updated", + "accessRoleUpdatedDescription": "The role has been successfully updated.", + "accessRoleErrorUpdate": "Failed to update role", + "accessRoleErrorUpdateDescription": "An error occurred while updating the role.", "accessRoleErrorNewRequired": "New role is required", "accessRoleErrorRemove": "Failed to remove role", "accessRoleErrorRemoveDescription": "An error occurred while removing the role.", diff --git a/server/routers/external.ts b/server/routers/external.ts index cb5328ab..bdea5ae9 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -554,6 +554,14 @@ authenticated.get( verifyUserHasAction(ActionsEnum.listRoles), role.listRoles ); + +authenticated.post( + "/org/:orgId/role/:roleId", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.updateRole), + logActionAudit(ActionsEnum.updateRole), + role.updateRole +); // authenticated.get( // "/role/:roleId", // verifyRoleAccess, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 4250458a..7d5a43dd 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -467,6 +467,14 @@ authenticated.put( role.createRole ); +authenticated.post( + "/org/:orgId/role/:roleId", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.updateRole), + logActionAudit(ActionsEnum.updateRole), + role.updateRole +); + authenticated.get( "/org/:orgId/roles", verifyApiKeyOrgAccess, diff --git a/server/routers/role/createRole.ts b/server/routers/role/createRole.ts index 16696af4..a1e21d7a 100644 --- a/server/routers/role/createRole.ts +++ b/server/routers/role/createRole.ts @@ -10,6 +10,8 @@ import { fromError } from "zod-validation-error"; import { ActionsEnum } from "@server/auth/actions"; import { eq, and } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; +import { build } from "@server/build"; +import { isLicensedOrSubscribed } from "@server/lib/isLicencedOrSubscribed"; const createRoleParamsSchema = z.strictObject({ orgId: z.string() @@ -17,7 +19,8 @@ const createRoleParamsSchema = z.strictObject({ const createRoleSchema = z.strictObject({ name: z.string().min(1).max(255), - description: z.string().optional() + description: z.string().optional(), + requireDeviceApproval: z.boolean().optional() }); export const defaultRoleAllowedActions: ActionsEnum[] = [ @@ -97,6 +100,11 @@ export async function createRole( ); } + const isLicensed = await isLicensedOrSubscribed(orgId); + if (build === "oss" || !isLicensed) { + roleData.requireDeviceApproval = undefined; + } + await db.transaction(async (trx) => { const newRole = await trx .insert(roles) diff --git a/server/routers/role/updateRole.ts b/server/routers/role/updateRole.ts index c9f63a7b..0eeef100 100644 --- a/server/routers/role/updateRole.ts +++ b/server/routers/role/updateRole.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; +import { db, orgs, type Role } from "@server/db"; import { roles } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; @@ -8,20 +8,28 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; +import { build } from "@server/build"; +import { isLicensedOrSubscribed } from "@server/lib/isLicencedOrSubscribed"; const updateRoleParamsSchema = z.strictObject({ + orgId: z.string(), roleId: z.string().transform(Number).pipe(z.int().positive()) }); const updateRoleBodySchema = z .strictObject({ name: z.string().min(1).max(255).optional(), - description: z.string().optional() + description: z.string().optional(), + requireDeviceApproval: z.boolean().optional() }) .refine((data) => Object.keys(data).length > 0, { error: "At least one field must be provided for update" }); +export type UpdateRoleBody = z.infer; + +export type UpdateRoleResponse = Role; + export async function updateRole( req: Request, res: Response, @@ -48,13 +56,14 @@ export async function updateRole( ); } - const { roleId } = parsedParams.data; + const { roleId, orgId } = parsedParams.data; const updateData = parsedBody.data; const role = await db .select() .from(roles) .where(eq(roles.roleId, roleId)) + .innerJoin(orgs, eq(roles.orgId, orgs.orgId)) .limit(1); if (role.length === 0) { @@ -66,7 +75,7 @@ export async function updateRole( ); } - if (role[0].isAdmin) { + if (role[0].roles.isAdmin) { return next( createHttpError( HttpCode.FORBIDDEN, @@ -75,6 +84,11 @@ export async function updateRole( ); } + const isLicensed = await isLicensedOrSubscribed(orgId); + if (build === "oss" || !isLicensed) { + updateData.requireDeviceApproval = undefined; + } + const updatedRole = await db .update(roles) .set(updateData) diff --git a/src/app/[orgId]/settings/access/roles/page.tsx b/src/app/[orgId]/settings/access/roles/page.tsx index c4818abe..93649816 100644 --- a/src/app/[orgId]/settings/access/roles/page.tsx +++ b/src/app/[orgId]/settings/access/roles/page.tsx @@ -5,7 +5,7 @@ import { GetOrgResponse } from "@server/routers/org"; import { cache } from "react"; import OrgProvider from "@app/providers/OrgProvider"; import { ListRolesResponse } from "@server/routers/role"; -import RolesTable, { RoleRow } from "../../../../../components/RolesTable"; +import RolesTable, { type RoleRow } from "@app/components/RolesTable"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { getTranslations } from "next-intl/server"; diff --git a/src/components/CreateRoleForm.tsx b/src/components/CreateRoleForm.tsx index 1f6914fe..8108461d 100644 --- a/src/components/CreateRoleForm.tsx +++ b/src/components/CreateRoleForm.tsx @@ -1,5 +1,15 @@ "use client"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; import { Button } from "@app/components/ui/button"; import { Form, @@ -11,37 +21,26 @@ import { FormMessage } from "@app/components/ui/form"; import { Input } from "@app/components/ui/input"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useOrgContext } from "@app/hooks/useOrgContext"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; import { zodResolver } from "@hookform/resolvers/zod"; +import { build } from "@server/build"; +import type { CreateRoleBody, CreateRoleResponse } from "@server/routers/role"; import { AxiosResponse } from "axios"; -import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { useTransition } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; -import { - Credenza, - CredenzaBody, - CredenzaClose, - CredenzaContent, - CredenzaDescription, - CredenzaFooter, - CredenzaHeader, - CredenzaTitle -} from "@app/components/Credenza"; -import { useOrgContext } from "@app/hooks/useOrgContext"; -import type { CreateRoleBody, CreateRoleResponse } from "@server/routers/role"; -import { formatAxiosError } from "@app/lib/api"; -import { createApiClient } from "@app/lib/api"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { useTranslations } from "next-intl"; -import { CheckboxWithLabel } from "./ui/checkbox"; -import { usePaidStatus } from "@app/hooks/usePaidStatus"; -import { build } from "@server/build"; import { PaidFeaturesAlert } from "./PaidFeaturesAlert"; +import { CheckboxWithLabel } from "./ui/checkbox"; type CreateRoleFormProps = { open: boolean; setOpen: (open: boolean) => void; - afterCreate?: (res: CreateRoleResponse) => Promise; + afterCreate?: (res: CreateRoleResponse) => void; }; export default function CreateRoleForm({ @@ -54,13 +53,14 @@ export default function CreateRoleForm({ const { isPaidUser } = usePaidStatus(); const formSchema = z.object({ - name: z.string({ message: t("nameRequired") }).max(32), + name: z + .string({ message: t("nameRequired") }) + .min(1) + .max(32), description: z.string().max(255).optional(), requireDeviceApproval: z.boolean().optional() }); - const [loading, setLoading] = useState(false); - const api = createApiClient(useEnvContext()); const form = useForm>({ @@ -72,17 +72,13 @@ export default function CreateRoleForm({ } }); - async function onSubmit(values: z.infer) { - setLoading(true); + const [loading, startTransition] = useTransition(); + async function onSubmit(values: z.infer) { const res = await api - .put>( - `/org/${org?.org.orgId}/role`, - { - name: values.name, - description: values.description - } as CreateRoleBody - ) + .put< + AxiosResponse + >(`/org/${org?.org.orgId}/role`, values satisfies CreateRoleBody) .catch((e) => { toast({ variant: "destructive", @@ -105,12 +101,8 @@ export default function CreateRoleForm({ setOpen(false); } - if (afterCreate) { - afterCreate(res.data.data); - } + afterCreate?.(res.data.data); } - - setLoading(false); } return ( @@ -119,7 +111,6 @@ export default function CreateRoleForm({ open={open} onOpenChange={(val) => { setOpen(val); - setLoading(false); form.reset(); }} > @@ -133,7 +124,9 @@ export default function CreateRoleForm({
+ startTransition(() => onSubmit(values)) + )} className="space-y-4" id="create-role-form" > @@ -168,23 +161,37 @@ export default function CreateRoleForm({ )} /> {build !== "oss" && ( - <> +
+ ( - + { + if ( + checked !== + "indeterminate" + ) { + form.setValue( + "requireDeviceApproval", + checked + ); + } + }} label={t( "requireDeviceApproval" )} @@ -201,7 +208,7 @@ export default function CreateRoleForm({ )} /> - +
)}
diff --git a/src/components/Credenza.tsx b/src/components/Credenza.tsx index 0446500c..bda0e7b4 100644 --- a/src/components/Credenza.tsx +++ b/src/components/Credenza.tsx @@ -78,10 +78,7 @@ const CredenzaClose = ({ className, children, ...props }: CredenzaProps) => { const CredenzaClose = isDesktop ? DialogClose : DrawerClose; return ( - + {children} ); @@ -172,14 +169,13 @@ const CredenzaBody = ({ className, children, ...props }: CredenzaProps) => { const CredenzaFooter = ({ className, children, ...props }: CredenzaProps) => { const isDesktop = useMediaQuery(desktop); - // const isDesktop = true; const CredenzaFooter = isDesktop ? DialogFooter : SheetFooter; return ( void; + onSuccess?: (res: CreateRoleResponse) => void; +}; + +export default function EditRoleForm({ + open, + role, + setOpen, + onSuccess +}: CreateRoleFormProps) { + const { org } = useOrgContext(); + const t = useTranslations(); + const { isPaidUser } = usePaidStatus(); + + const formSchema = z.object({ + name: z + .string({ message: t("nameRequired") }) + .min(1) + .max(32), + description: z.string().max(255).optional(), + requireDeviceApproval: z.boolean().optional() + }); + + const api = createApiClient(useEnvContext()); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + name: role.name, + description: role.description ?? "", + requireDeviceApproval: role.requireDeviceApproval ?? false + } + }); + + const [loading, startTransition] = useTransition(); + + async function onSubmit(values: z.infer) { + const res = await api + .post< + AxiosResponse + >(`/org/${org?.org.orgId}/role/${role.roleId}`, values satisfies UpdateRoleBody) + .catch((e) => { + toast({ + variant: "destructive", + title: t("accessRoleErrorUpdate"), + description: formatAxiosError( + e, + t("accessRoleErrorUpdateDescription") + ) + }); + }); + + if (res && res.status === 200) { + toast({ + variant: "default", + title: t("accessRoleUpdated"), + description: t("accessRoleUpdatedDescription") + }); + + if (open) { + setOpen(false); + } + + onSuccess?.(res.data.data); + } + } + + return ( + <> + { + setOpen(val); + form.reset(); + }} + > + + + {t("accessRoleCreate")} + + {t("accessRoleCreateDescription")} + + + +
+ + startTransition(() => onSubmit(values)) + )} + className="space-y-4" + id="create-role-form" + > + ( + + + {t("accessRoleName")} + + + + + + + )} + /> + ( + + + {t("description")} + + + + + + + )} + /> + {build !== "oss" && ( +
+ + + ( + + + { + if ( + checked !== + "indeterminate" + ) { + form.setValue( + "requireDeviceApproval", + checked + ); + } + }} + label={t( + "requireDeviceApproval" + )} + /> + + + + {t( + "requireDeviceApprovalDescription" + )} + + + + + )} + /> +
+ )} + + +
+ + + + + + +
+
+ + ); +} diff --git a/src/components/MachineClientsTable.tsx b/src/components/MachineClientsTable.tsx index 67ed2e08..89f20b80 100644 --- a/src/components/MachineClientsTable.tsx +++ b/src/components/MachineClientsTable.tsx @@ -1,9 +1,8 @@ "use client"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; -import { DataTable } from "@app/components/ui/data-table"; -import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { Button } from "@app/components/ui/button"; +import { DataTable, ExtendedColumnDef } from "@app/components/ui/data-table"; import { DropdownMenu, DropdownMenuContent, @@ -13,18 +12,12 @@ import { import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { - ArrowRight, - ArrowUpDown, - ArrowUpRight, - MoreHorizontal -} from "lucide-react"; +import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useMemo, useState, useTransition } from "react"; import { Badge } from "./ui/badge"; -import { InfoPopup } from "./ui/info-popup"; export type ClientRow = { id: number; diff --git a/src/components/RolesTable.tsx b/src/components/RolesTable.tsx index 2fd3353d..1ef103f3 100644 --- a/src/components/RolesTable.tsx +++ b/src/components/RolesTable.tsx @@ -10,12 +10,18 @@ import { useOrgContext } from "@app/hooks/useOrgContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient } from "@app/lib/api"; import { Role } from "@server/db"; -import { ArrowUpDown } from "lucide-react"; +import { ArrowRight, ArrowUpDown, Link, MoreHorizontal } from "lucide-react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; -import { useState } from "react"; -import { Switch } from "./ui/switch"; +import { useState, useTransition } from "react"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem +} from "./ui/dropdown-menu"; +import EditRoleForm from "./EditRoleForm"; export type RoleRow = Role; @@ -23,13 +29,13 @@ type RolesTableProps = { roles: RoleRow[]; }; -export default function UsersTable({ roles: r }: RolesTableProps) { +export default function UsersTable({ roles }: RolesTableProps) { const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [editingRole, setEditingRole] = useState(null); + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); const router = useRouter(); - const [roles, setRoles] = useState(r); - const [roleToRemove, setUserToRemove] = useState(null); const api = createApiClient(useEnvContext()); @@ -38,13 +44,11 @@ export default function UsersTable({ roles: r }: RolesTableProps) { const { isPaidUser } = usePaidStatus(); const t = useTranslations(); - const [isRefreshing, setIsRefreshing] = useState(false); + const [isRefreshing, startTransition] = useTransition(); const refreshData = async () => { console.log("Data refreshed"); - setIsRefreshing(true); try { - await new Promise((resolve) => setTimeout(resolve, 200)); router.refresh(); } catch (error) { toast({ @@ -52,8 +56,6 @@ export default function UsersTable({ roles: r }: RolesTableProps) { description: t("refreshError"), variant: "destructive" }); - } finally { - setIsRefreshing(false); } }; @@ -81,52 +83,76 @@ export default function UsersTable({ roles: r }: RolesTableProps) { friendlyName: t("description"), header: () => {t("description")} }, + // { + // id: "actions", + // enableHiding: false, + // header: () => , + // cell: ({ row }) => { + // const roleRow = row.original; - ...(isPaidUser - ? ([ - { - accessorKey: "requireDeviceApproval", - friendlyName: t("requireDeviceApproval"), - header: () => ( - - {t("requireDeviceApproval")} - - ), - cell: ({ row }) => ( - { - // ... - }} - /> - ) - } - ] as ExtendedColumnDef[]) - : []), - + // return ( + //
+ // + //
+ // ); + // } + // }, { id: "actions", enableHiding: false, header: () => , cell: ({ row }) => { const roleRow = row.original; - return ( -
- -
+ !roleRow.isAdmin && ( +
+ + + + + + { + // setSelectedInternalResource( + // resourceRow + // ); + setIsDeleteModalOpen(true); + }} + > + + {t("delete")} + + + + + +
+ ) ); } } @@ -134,11 +160,26 @@ export default function UsersTable({ roles: r }: RolesTableProps) { return ( <> + {editingRole && ( + { + // Delay refresh to allow modal to close smoothly + setTimeout(() => { + router.refresh(); + setEditingRole(null); + }, 150); + }} + /> + )} { - setRoles((prev) => [...prev, role]); + afterCreate={() => { + startTransition(refreshData); }} /> @@ -148,9 +189,7 @@ export default function UsersTable({ roles: r }: RolesTableProps) { setOpen={setIsDeleteModalOpen} roleToDelete={roleToRemove} afterDelete={() => { - setRoles((prev) => - prev.filter((r) => r.roleId !== roleToRemove.roleId) - ); + startTransition(refreshData); setUserToRemove(null); }} /> @@ -162,7 +201,7 @@ export default function UsersTable({ roles: r }: RolesTableProps) { createRole={() => { setIsCreateModalOpen(true); }} - onRefresh={refreshData} + onRefresh={() => startTransition(refreshData)} isRefreshing={isRefreshing} /> diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx index 85825dc1..261655bb 100644 --- a/src/components/ui/checkbox.tsx +++ b/src/components/ui/checkbox.tsx @@ -30,7 +30,8 @@ const checkboxVariants = cva( ); interface CheckboxProps - extends React.ComponentPropsWithoutRef, + extends + React.ComponentPropsWithoutRef, VariantProps {} const Checkbox = React.forwardRef< @@ -49,17 +50,18 @@ const Checkbox = React.forwardRef< )); Checkbox.displayName = CheckboxPrimitive.Root.displayName; -interface CheckboxWithLabelProps - extends React.ComponentPropsWithoutRef { +interface CheckboxWithLabelProps extends React.ComponentPropsWithoutRef< + typeof Checkbox +> { label: string; } const CheckboxWithLabel = React.forwardRef< - React.ElementRef, + React.ComponentRef, CheckboxWithLabelProps >(({ className, label, id, ...props }, ref) => { return ( -
+