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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'}>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
type Role = Database['public']['Enums']['account_role'];
|
||||
type Role = string;
|
||||
|
||||
const InviteSchema = z.object({
|
||||
email: z.string().email(),
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
type Role = Database['public']['Enums']['account_role'];
|
||||
type Role = string;
|
||||
|
||||
export const UpdateInvitationSchema = z.object({
|
||||
invitationId: z.number(),
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
type Role = Database['public']['Enums']['account_role'];
|
||||
|
||||
export const UpdateRoleSchema = z.object({
|
||||
role: z.custom<Role>((value) => z.string().parse(value)),
|
||||
role: z.string().min(1),
|
||||
});
|
||||
|
||||
@@ -31,7 +31,7 @@ export async function removeMemberFromAccountAction(params: {
|
||||
export async function updateMemberRoleAction(params: {
|
||||
accountId: string;
|
||||
userId: string;
|
||||
role: Database['public']['Enums']['account_role'];
|
||||
role: string;
|
||||
}) {
|
||||
const client = getSupabaseServerActionClient();
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ export class AccountMembersService {
|
||||
async updateMemberRole(params: {
|
||||
accountId: string;
|
||||
userId: string;
|
||||
role: Database['public']['Enums']['account_role'];
|
||||
role: string;
|
||||
}) {
|
||||
const { data, error } = await this.client
|
||||
.from('accounts_memberships')
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import 'server-only';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
import { LeaveTeamAccountSchema } from '../../schema/leave-team-account.schema';
|
||||
|
||||
export class LeaveAccountService {
|
||||
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||
|
||||
async leaveTeamAccount(params: { accountId: string; userId: string }) {
|
||||
async leaveTeamAccount(params: z.infer<typeof LeaveTeamAccountSchema>) {
|
||||
await Promise.resolve();
|
||||
|
||||
console.log(params);
|
||||
// TODO
|
||||
// implement this method
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user