Implement custom roles and improve permissions logic

The commit refactors the handling of account roles and enhances permissions checks. The account role has been shifted to use a string type, providing the ability to define custom roles. It also introduces the RolesDataProvider component, which stipulates role-related data for different forms and tables. The modification goes further to consider user role hierarchy in permissions checks, offering a more granular access control.
This commit is contained in:
giancarlo
2024-03-29 14:48:45 +08:00
parent f1967a686c
commit 99db8f4ca4
41 changed files with 498 additions and 228 deletions

View File

@@ -35,6 +35,7 @@ type AccountInvitationsTableProps = {
permissions: {
canUpdateInvitation: boolean;
canRemoveInvitation: boolean;
currentUserRoleHierarchy: number;
};
};
@@ -72,6 +73,7 @@ export function AccountInvitationsTable({
function useGetColumns(permissions: {
canUpdateInvitation: boolean;
canRemoveInvitation: boolean;
currentUserRoleHierarchy: number;
}): ColumnDef<Invitations[0]>[] {
const { t } = useTranslation('teams');
@@ -197,6 +199,7 @@ function ActionsDropdown({
setIsOpen={setIsUpdatingRole}
invitationId={invitation.id}
userRole={invitation.role}
userRoleHierarchy={permissions.currentUserRoleHierarchy}
/>
</If>

View File

@@ -4,7 +4,6 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { Database } from '@kit/supabase/database';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { Button } from '@kit/ui/button';
import {
@@ -29,15 +28,17 @@ import { Trans } from '@kit/ui/trans';
import { UpdateRoleSchema } from '../../schema/update-role-schema';
import { updateInvitationAction } from '../../server/actions/team-invitations-server-actions';
import { MembershipRoleSelector } from '../members/membership-role-selector';
import { RolesDataProvider } from '../members/roles-data-provider';
type Role = Database['public']['Enums']['account_role'];
type Role = string;
export const UpdateInvitationDialog: React.FC<{
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
invitationId: number;
userRole: Role;
}> = ({ isOpen, setIsOpen, invitationId, userRole }) => {
userRoleHierarchy: number;
}> = ({ isOpen, setIsOpen, invitationId, userRole, userRoleHierarchy }) => {
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent>
@@ -51,11 +52,16 @@ export const UpdateInvitationDialog: React.FC<{
</DialogDescription>
</DialogHeader>
<UpdateInvitationForm
setIsOpen={setIsOpen}
invitationId={invitationId}
userRole={userRole}
/>
<RolesDataProvider maxRoleHierarchy={userRoleHierarchy}>
{(roles) => (
<UpdateInvitationForm
invitationId={invitationId}
userRole={userRole}
userRoleHierarchy={roles.length}
setIsOpen={setIsOpen}
/>
)}
</RolesDataProvider>
</DialogContent>
</Dialog>
);
@@ -64,10 +70,12 @@ export const UpdateInvitationDialog: React.FC<{
function UpdateInvitationForm({
invitationId,
userRole,
userRoleHierarchy,
setIsOpen,
}: React.PropsWithChildren<{
invitationId: number;
userRole: Role;
userRoleHierarchy: number;
setIsOpen: (isOpen: boolean) => void;
}>) {
const { t } = useTranslation('teams');
@@ -128,11 +136,18 @@ function UpdateInvitationForm({
</FormLabel>
<FormControl>
<MembershipRoleSelector
currentUserRole={userRole}
value={field.value}
onChange={(newRole) => form.setValue('role', newRole)}
/>
<RolesDataProvider maxRoleHierarchy={userRoleHierarchy}>
{(roles) => (
<MembershipRoleSelector
roles={roles}
currentUserRole={userRole}
value={field.value}
onChange={(newRole) =>
form.setValue(field.name, newRole)
}
/>
)}
</RolesDataProvider>
</FormControl>
<FormDescription>

View File

@@ -28,25 +28,38 @@ import { UpdateMemberRoleDialog } from './update-member-role-dialog';
type Members =
Database['public']['Functions']['get_account_members']['Returns'];
interface Permissions {
canUpdateRole: (roleHierarchy: number) => boolean;
canRemoveFromAccount: (roleHierarchy: number) => boolean;
canTransferOwnership: boolean;
}
type AccountMembersTableProps = {
members: Members;
currentUserId: string;
permissions: {
canUpdateRole: boolean;
canTransferOwnership: boolean;
canRemoveFromAccount: boolean;
};
userRoleHierarchy: number;
isPrimaryOwner: boolean;
canManageRoles: boolean;
};
export function AccountMembersTable({
members,
permissions,
currentUserId,
isPrimaryOwner,
userRoleHierarchy,
canManageRoles,
}: AccountMembersTableProps) {
const [search, setSearch] = useState('');
const { t } = useTranslation('teams');
const permissions = {
canUpdateRole: (targetRole: number) =>
canManageRoles && targetRole < userRoleHierarchy,
canRemoveFromAccount: (targetRole: number) =>
canManageRoles && targetRole < userRoleHierarchy,
canTransferOwnership: isPrimaryOwner,
};
const columns = useGetColumns(permissions, currentUserId);
const filteredMembers = members.filter((member) => {
@@ -73,11 +86,7 @@ export function AccountMembersTable({
}
function useGetColumns(
permissions: {
canUpdateRole: boolean;
canTransferOwnership: boolean;
canRemoveFromAccount: boolean;
},
permissions: Permissions,
currentUserId: string,
): ColumnDef<Members[0]>[] {
const { t } = useTranslation('teams');
@@ -173,7 +182,7 @@ function ActionsDropdown({
member,
currentUserId,
}: {
permissions: AccountMembersTableProps['permissions'];
permissions: Permissions;
member: Members[0];
currentUserId: string;
}) {
@@ -188,6 +197,22 @@ function ActionsDropdown({
return null;
}
const memberRoleHierarchy = member.role_hierarchy_level;
const canUpdateRole = permissions.canUpdateRole(memberRoleHierarchy);
const canRemoveFromAccount =
permissions.canRemoveFromAccount(memberRoleHierarchy);
// if has no permission to update role, transfer ownership or remove from account
// do not render the dropdown menu
if (
!canUpdateRole &&
!permissions.canTransferOwnership &&
!canRemoveFromAccount
) {
return null;
}
return (
<>
<DropdownMenu>
@@ -198,7 +223,7 @@ function ActionsDropdown({
</DropdownMenuTrigger>
<DropdownMenuContent>
<If condition={permissions.canUpdateRole}>
<If condition={canUpdateRole}>
<DropdownMenuItem onClick={() => setIsUpdatingRole(true)}>
<Trans i18nKey={'teams:updateRole'} />
</DropdownMenuItem>
@@ -210,7 +235,7 @@ function ActionsDropdown({
</DropdownMenuItem>
</If>
<If condition={permissions.canRemoveFromAccount}>
<If condition={canRemoveFromAccount}>
<DropdownMenuItem onClick={() => setIsRemoving(true)}>
<Trans i18nKey={'teams:removeMember'} />
</DropdownMenuItem>
@@ -234,6 +259,7 @@ function ActionsDropdown({
accountId={member.id}
userId={member.user_id}
userRole={member.role}
userRoleHierarchy={memberRoleHierarchy}
/>
</If>

View File

@@ -7,7 +7,6 @@ import { Plus, X } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { Database } from '@kit/supabase/database';
import { Button } from '@kit/ui/button';
import {
Dialog,
@@ -36,16 +35,19 @@ import { Trans } from '@kit/ui/trans';
import { InviteMembersSchema } from '../../schema/invite-members.schema';
import { createInvitationsAction } from '../../server/actions/team-invitations-server-actions';
import { MembershipRoleSelector } from './membership-role-selector';
import { RolesDataProvider } from './roles-data-provider';
type InviteModel = ReturnType<typeof createEmptyInviteModel>;
type Role = Database['public']['Enums']['account_role'];
type Role = string;
export function InviteMembersDialogContainer({
account,
userRoleHierarchy,
children,
}: React.PropsWithChildren<{
account: string;
userRoleHierarchy: number;
}>) {
const [pending, startTransition] = useTransition();
const [isOpen, setIsOpen] = useState(false);
@@ -65,19 +67,24 @@ export function InviteMembersDialogContainer({
</DialogDescription>
</DialogHeader>
<InviteMembersForm
pending={pending}
onSubmit={(data) => {
startTransition(async () => {
await createInvitationsAction({
account,
invitations: data.invitations,
});
<RolesDataProvider maxRoleHierarchy={userRoleHierarchy}>
{(roles) => (
<InviteMembersForm
pending={pending}
roles={roles}
onSubmit={(data) => {
startTransition(async () => {
await createInvitationsAction({
account,
invitations: data.invitations,
});
setIsOpen(false);
});
}}
/>
setIsOpen(false);
});
}}
/>
)}
</RolesDataProvider>
</DialogContent>
</Dialog>
);
@@ -85,10 +92,12 @@ export function InviteMembersDialogContainer({
function InviteMembersForm({
onSubmit,
roles,
pending,
}: {
onSubmit: (data: { invitations: InviteModel[] }) => void;
pending: boolean;
roles: string[];
}) {
const { t } = useTranslation('teams');
@@ -156,6 +165,7 @@ function InviteMembersForm({
<FormControl>
<MembershipRoleSelector
roles={roles}
value={field.value}
onChange={(role) => {
form.setValue(field.name, role);

View File

@@ -1,4 +1,3 @@
import { Database } from '@kit/supabase/database';
import {
Select,
SelectContent,
@@ -8,15 +7,14 @@ import {
} from '@kit/ui/select';
import { Trans } from '@kit/ui/trans';
type Role = Database['public']['Enums']['account_role'];
type Role = string;
export const MembershipRoleSelector: React.FC<{
roles: Role[];
value: Role;
currentUserRole?: Role;
onChange: (role: Role) => unknown;
}> = ({ value, currentUserRole, onChange }) => {
const rolesList: Role[] = ['owner', 'member'];
}> = ({ roles, value, currentUserRole, onChange }) => {
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger data-test={'role-selector-trigger'}>
@@ -24,12 +22,12 @@ export const MembershipRoleSelector: React.FC<{
</SelectTrigger>
<SelectContent>
{rolesList.map((role) => {
{roles.map((role) => {
return (
<SelectItem
key={role}
data-test={`role-item-${role}`}
disabled={currentUserRole && currentUserRole === role}
disabled={currentUserRole === role}
value={role}
>
<span className={'text-sm capitalize'}>

View File

@@ -1,10 +1,9 @@
import { cva } from 'class-variance-authority';
import { Database } from '@kit/supabase/database';
import { Badge } from '@kit/ui/badge';
import { Trans } from '@kit/ui/trans';
type Role = Database['public']['Enums']['account_role'];
type Role = string;
const roleClassNameBuilder = cva('font-medium capitalize', {
variants: {
@@ -19,6 +18,7 @@ const roleClassNameBuilder = cva('font-medium capitalize', {
export const RoleBadge: React.FC<{
role: Role;
}> = ({ role }) => {
// @ts-expect-error: hard to type this since users can add custom roles
const className = roleClassNameBuilder({ role });
return (

View File

@@ -0,0 +1,45 @@
import { useQuery } from '@tanstack/react-query';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
import { LoadingOverlay } from '@kit/ui/loading-overlay';
export function RolesDataProvider(props: {
maxRoleHierarchy: number;
children: (roles: string[]) => React.ReactNode;
}) {
const rolesQuery = useFetchRoles({
maxRoleHierarchy: props.maxRoleHierarchy,
});
if (rolesQuery.isLoading) {
return <LoadingOverlay fullPage={false} />;
}
// TODO handle error
if (rolesQuery.isError) {
return null;
}
return <>{props.children(rolesQuery.data ?? [])}</>;
}
function useFetchRoles(props: { maxRoleHierarchy: number }) {
const supabase = useSupabase();
return useQuery({
queryKey: ['roles', props.maxRoleHierarchy],
queryFn: async () => {
const { error, data } = await supabase
.from('roles')
.select('name')
.gte('hierarchy_level', props.maxRoleHierarchy)
.order('hierarchy_level', { ascending: true });
if (error) {
throw error;
}
return data.map((item) => item.name);
},
});
}

View File

@@ -4,7 +4,6 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { Database } from '@kit/supabase/database';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { Button } from '@kit/ui/button';
import {
@@ -29,8 +28,9 @@ import { Trans } from '@kit/ui/trans';
import { UpdateRoleSchema } from '../../schema/update-role-schema';
import { updateMemberRoleAction } from '../../server/actions/team-members-server-actions';
import { MembershipRoleSelector } from './membership-role-selector';
import { RolesDataProvider } from './roles-data-provider';
type Role = Database['public']['Enums']['account_role'];
type Role = string;
export const UpdateMemberRoleDialog: React.FC<{
isOpen: boolean;
@@ -38,7 +38,15 @@ export const UpdateMemberRoleDialog: React.FC<{
userId: string;
accountId: string;
userRole: Role;
}> = ({ isOpen, setIsOpen, userId, accountId, userRole }) => {
userRoleHierarchy: number;
}> = ({
isOpen,
setIsOpen,
userId,
accountId,
userRole,
userRoleHierarchy,
}) => {
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent>
@@ -52,12 +60,17 @@ export const UpdateMemberRoleDialog: React.FC<{
</DialogDescription>
</DialogHeader>
<UpdateMemberForm
setIsOpen={setIsOpen}
userId={userId}
accountId={accountId}
userRole={userRole}
/>
<RolesDataProvider maxRoleHierarchy={userRoleHierarchy}>
{(data) => (
<UpdateMemberForm
setIsOpen={setIsOpen}
userId={userId}
accountId={accountId}
userRole={userRole}
roles={data}
/>
)}
</RolesDataProvider>
</DialogContent>
</Dialog>
);
@@ -68,11 +81,13 @@ function UpdateMemberForm({
userRole,
accountId,
setIsOpen,
roles,
}: React.PropsWithChildren<{
userId: string;
userRole: Role;
accountId: string;
setIsOpen: (isOpen: boolean) => void;
roles: Role[];
}>) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<boolean>();
@@ -128,6 +143,7 @@ function UpdateMemberForm({
<FormControl>
<MembershipRoleSelector
roles={roles}
currentUserRole={userRole}
value={field.value}
onChange={(newRole) => form.setValue('role', newRole)}