From 00c094242e66c949bf296741ecaa1949eefa6cc9 Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Thu, 25 Jun 2026 21:07:51 +0300 Subject: [PATCH 1/4] feat(team): integrate CASL for team member permissions and abilities --- src/entities/team/index.ts | 8 +++++ src/entities/team/model/ability.ts | 31 +++++++++++++++++++ src/entities/team/model/permissions.ts | 32 ++++++++++++++++++++ src/pages/team/ui/members/MemberCard.tsx | 19 ++++++++---- src/pages/team/ui/members/MembersPage.tsx | 37 +++++++++++++++++++---- 5 files changed, 115 insertions(+), 12 deletions(-) create mode 100644 src/entities/team/model/ability.ts create mode 100644 src/entities/team/model/permissions.ts diff --git a/src/entities/team/index.ts b/src/entities/team/index.ts index 2777269..90417f0 100644 --- a/src/entities/team/index.ts +++ b/src/entities/team/index.ts @@ -7,3 +7,11 @@ export { ROLE_LABELS, INVITATION_ROLES } from './config/roles'; export { STATUS_LABELS, MEMBER_STATUSES } from './config/statuses'; export { useTeamStore } from './model/store'; export { TeamAvatar } from './ui/TeamAvatar'; +export { + defineTeamMemberAbility, + teamSubject, + type TeamMemberAbility, + type TeamMemberActions, + type TeamMemberSubjects, + type WithSubjectType, +} from './model/ability'; diff --git a/src/entities/team/model/ability.ts b/src/entities/team/model/ability.ts new file mode 100644 index 0000000..c4887c4 --- /dev/null +++ b/src/entities/team/model/ability.ts @@ -0,0 +1,31 @@ +import { + createMongoAbility, + AbilityBuilder, + subject, + InferSubjects, + MongoAbility, +} from '@casl/ability'; +import { TeamMemberResponse } from './types'; +import { defineTeamMemberRules, UserContext } from './permissions'; +import { TeamRole } from '../model/types'; + +export type WithSubjectType = T & { + readonly __caslSubjectType__: S; +}; + +type TeamMember = WithSubjectType; + +export type TeamMemberActions = 'invite' | 'delete' | 'changeRole' | 'changeStatus'; +export type TeamMemberSubjects = InferSubjects; + +export type TeamMemberAbility = MongoAbility<[TeamMemberActions, TeamMemberSubjects]>; + +const teamSubject = (member: NonNullable) => subject('TeamMember', member); + +const defineTeamMemberAbility = (user: UserContext | null, teamRole: TeamRole | null) => { + const builder = new AbilityBuilder(createMongoAbility); + defineTeamMemberRules(user, teamRole, builder); + return builder.build(); +}; + +export { defineTeamMemberAbility, teamSubject }; diff --git a/src/entities/team/model/permissions.ts b/src/entities/team/model/permissions.ts new file mode 100644 index 0000000..242b315 --- /dev/null +++ b/src/entities/team/model/permissions.ts @@ -0,0 +1,32 @@ +import { AbilityBuilder } from '@casl/ability'; +import { TeamMemberAbility } from './ability'; +import { TeamRole } from './types'; + +export type UserContext = { id: string }; + +export function defineTeamMemberRules( + user: UserContext | null, + teamRole: TeamRole | null, + { can, cannot }: AbilityBuilder +) { + cannot('delete', 'TeamMember', { role: 'owner' }); + + if (teamRole === 'admin') { + can(['changeRole', 'changeStatus', 'delete', 'invite'], 'TeamMember', { + role: { $in: ['member', 'viewer'] }, + }); + } + if (teamRole === 'owner') { + can(['changeRole', 'changeStatus', 'delete', 'invite'], 'TeamMember', { + role: { $in: ['admin', 'member', 'viewer'] }, + }); + } + + cannot(['changeRole', 'changeStatus', 'delete'], 'TeamMember', { + id: user?.id, + }); +} + +// - Владелец может управлять всеми участниками +// - Администратор может управлять всеми участниками, кроме владельца +// - Участник и гость ничего не могут diff --git a/src/pages/team/ui/members/MemberCard.tsx b/src/pages/team/ui/members/MemberCard.tsx index ce311df..9bef3b8 100644 --- a/src/pages/team/ui/members/MemberCard.tsx +++ b/src/pages/team/ui/members/MemberCard.tsx @@ -27,11 +27,20 @@ const backOn = '2026-05-10'; //todo: mock interface MemberCardProps extends Omit, 'children'> { member: TTeam.TeamMemberResponse; + permissions: Permissions; } -export function MemberCard({ className, member, ...props }: MemberCardProps) { +type Permissions = { + canChangeRole: boolean; + canChangeStatus: boolean; + canDelete: boolean; +}; + +export function MemberCard({ className, member, permissions, ...props }: MemberCardProps) { const wl = cfg.workloadLabel(workload); + const { canChangeRole, canChangeStatus, canDelete } = permissions; + return (

{member.fullName}

- {member.role !== 'owner' && ( + {canDelete && ( - + {canInvite && ( + + + + )}
@@ -38,7 +53,17 @@ export function MembersPage() {
{isPending ? Array.from({ length: 8 }).map((_, i) => ) - : filtered.map((m) => )} + : filtered.map((m) => ( + + ))}
); From a2621505bc23f07e2683032d426dd1ec4a9140f6 Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Fri, 26 Jun 2026 19:00:46 +0300 Subject: [PATCH 2/4] feat(ability): integrate CASL for project and team permissions management --- src/app/providers/AppProviders.tsx | 7 +- src/entities/team/index.ts | 8 - src/entities/team/model/ability.ts | 31 ---- src/entities/team/model/permissions.ts | 32 ---- src/features/ability/index.ts | 5 + src/features/ability/lib/create-builder.ts | 6 + src/features/ability/lib/useAbility.ts | 13 ++ src/features/ability/lib/with-subject-type.ts | 3 + src/features/ability/model/project.ability.ts | 20 +++ src/features/ability/model/store.ts | 24 +++ src/features/ability/model/team.ability.ts | 53 ++++++ src/features/ability/model/types.ts | 2 + src/features/ability/ui/AbilityProvider.tsx | 39 +++++ src/features/ability/ui/Can.tsx | 8 + src/features/ability/ui/SyncAbilityStore.tsx | 30 ++++ src/pages/team/ui/members/MemberCard.tsx | 2 +- src/pages/team/ui/members/MembersPage.tsx | 38 ++--- src/pages/team/ui/projects/ProjectCard.tsx | 153 ++++++++++-------- src/pages/team/ui/projects/ProjectsEmpty.tsx | 9 +- src/pages/team/ui/projects/ProjectsPage.tsx | 31 +++- src/pages/team/ui/settings/TeamCover.tsx | 29 ++-- src/pages/team/ui/settings/TeamIdentity.tsx | 9 +- .../team/ui/settings/TeamIdentityForm.tsx | 8 +- .../ui/projects/ProjectActions.tsx | 10 +- .../ui/projects/ProjectsContent.tsx | 43 +++-- 25 files changed, 413 insertions(+), 200 deletions(-) delete mode 100644 src/entities/team/model/ability.ts delete mode 100644 src/entities/team/model/permissions.ts create mode 100644 src/features/ability/index.ts create mode 100644 src/features/ability/lib/create-builder.ts create mode 100644 src/features/ability/lib/useAbility.ts create mode 100644 src/features/ability/lib/with-subject-type.ts create mode 100644 src/features/ability/model/project.ability.ts create mode 100644 src/features/ability/model/store.ts create mode 100644 src/features/ability/model/team.ability.ts create mode 100644 src/features/ability/model/types.ts create mode 100644 src/features/ability/ui/AbilityProvider.tsx create mode 100644 src/features/ability/ui/Can.tsx create mode 100644 src/features/ability/ui/SyncAbilityStore.tsx diff --git a/src/app/providers/AppProviders.tsx b/src/app/providers/AppProviders.tsx index ed3d1e5..f37f407 100644 --- a/src/app/providers/AppProviders.tsx +++ b/src/app/providers/AppProviders.tsx @@ -2,12 +2,17 @@ import { PropsWithChildren } from 'react'; import { QueryProvider } from './QueryProvider'; import { Toaster, TooltipProvider } from 'shared/ui'; import { FrontendObservability } from 'shared/config/'; +import { AbilityProvider, SyncAbilityStore } from 'features/ability'; + export function AppProviders({ children }: PropsWithChildren) { return ( <> - {children} + + {children} + + diff --git a/src/entities/team/index.ts b/src/entities/team/index.ts index 90417f0..2777269 100644 --- a/src/entities/team/index.ts +++ b/src/entities/team/index.ts @@ -7,11 +7,3 @@ export { ROLE_LABELS, INVITATION_ROLES } from './config/roles'; export { STATUS_LABELS, MEMBER_STATUSES } from './config/statuses'; export { useTeamStore } from './model/store'; export { TeamAvatar } from './ui/TeamAvatar'; -export { - defineTeamMemberAbility, - teamSubject, - type TeamMemberAbility, - type TeamMemberActions, - type TeamMemberSubjects, - type WithSubjectType, -} from './model/ability'; diff --git a/src/entities/team/model/ability.ts b/src/entities/team/model/ability.ts deleted file mode 100644 index c4887c4..0000000 --- a/src/entities/team/model/ability.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { - createMongoAbility, - AbilityBuilder, - subject, - InferSubjects, - MongoAbility, -} from '@casl/ability'; -import { TeamMemberResponse } from './types'; -import { defineTeamMemberRules, UserContext } from './permissions'; -import { TeamRole } from '../model/types'; - -export type WithSubjectType = T & { - readonly __caslSubjectType__: S; -}; - -type TeamMember = WithSubjectType; - -export type TeamMemberActions = 'invite' | 'delete' | 'changeRole' | 'changeStatus'; -export type TeamMemberSubjects = InferSubjects; - -export type TeamMemberAbility = MongoAbility<[TeamMemberActions, TeamMemberSubjects]>; - -const teamSubject = (member: NonNullable) => subject('TeamMember', member); - -const defineTeamMemberAbility = (user: UserContext | null, teamRole: TeamRole | null) => { - const builder = new AbilityBuilder(createMongoAbility); - defineTeamMemberRules(user, teamRole, builder); - return builder.build(); -}; - -export { defineTeamMemberAbility, teamSubject }; diff --git a/src/entities/team/model/permissions.ts b/src/entities/team/model/permissions.ts deleted file mode 100644 index 242b315..0000000 --- a/src/entities/team/model/permissions.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { AbilityBuilder } from '@casl/ability'; -import { TeamMemberAbility } from './ability'; -import { TeamRole } from './types'; - -export type UserContext = { id: string }; - -export function defineTeamMemberRules( - user: UserContext | null, - teamRole: TeamRole | null, - { can, cannot }: AbilityBuilder -) { - cannot('delete', 'TeamMember', { role: 'owner' }); - - if (teamRole === 'admin') { - can(['changeRole', 'changeStatus', 'delete', 'invite'], 'TeamMember', { - role: { $in: ['member', 'viewer'] }, - }); - } - if (teamRole === 'owner') { - can(['changeRole', 'changeStatus', 'delete', 'invite'], 'TeamMember', { - role: { $in: ['admin', 'member', 'viewer'] }, - }); - } - - cannot(['changeRole', 'changeStatus', 'delete'], 'TeamMember', { - id: user?.id, - }); -} - -// - Владелец может управлять всеми участниками -// - Администратор может управлять всеми участниками, кроме владельца -// - Участник и гость ничего не могут diff --git a/src/features/ability/index.ts b/src/features/ability/index.ts new file mode 100644 index 0000000..089b19f --- /dev/null +++ b/src/features/ability/index.ts @@ -0,0 +1,5 @@ +export { useAbility } from './lib/useAbility'; +export { teamSubject, teamSettingsSubject } from './model/team.ability'; +export { Can } from './ui/Can'; +export { AbilityProvider } from './ui/AbilityProvider'; +export { SyncAbilityStore } from './ui/SyncAbilityStore'; diff --git a/src/features/ability/lib/create-builder.ts b/src/features/ability/lib/create-builder.ts new file mode 100644 index 0000000..9431f17 --- /dev/null +++ b/src/features/ability/lib/create-builder.ts @@ -0,0 +1,6 @@ +import { AbilityBuilder, createMongoAbility } from '@casl/ability'; +import { AppAbility } from '../ui/AbilityProvider'; + +export function createBuilder() { + return new AbilityBuilder(createMongoAbility); +} diff --git a/src/features/ability/lib/useAbility.ts b/src/features/ability/lib/useAbility.ts new file mode 100644 index 0000000..2d36323 --- /dev/null +++ b/src/features/ability/lib/useAbility.ts @@ -0,0 +1,13 @@ +'use client'; +import { useAbility as useCaslAbility } from '@casl/react'; +import { TeamAbility } from '../model/team.ability'; +import { ProjectAbility } from '../model/project.ability'; + +type SubjectMap = { + Team: TeamAbility; + Project: ProjectAbility; +}; + +export function useAbility(_subject: T) { + return useCaslAbility(); +} diff --git a/src/features/ability/lib/with-subject-type.ts b/src/features/ability/lib/with-subject-type.ts new file mode 100644 index 0000000..ad0725f --- /dev/null +++ b/src/features/ability/lib/with-subject-type.ts @@ -0,0 +1,3 @@ +export type WithSubjectType = T & { + readonly __caslSubjectType__: S; +}; diff --git a/src/features/ability/model/project.ability.ts b/src/features/ability/model/project.ability.ts new file mode 100644 index 0000000..61d82a3 --- /dev/null +++ b/src/features/ability/model/project.ability.ts @@ -0,0 +1,20 @@ +import { AbilityBuilder, MongoAbility } from '@casl/ability'; +import { TProject } from 'entities/project'; +import { UseAbilityStates as AbilityContext } from './store'; +import { Action } from './types'; + +export type Project = Pick; + +export type ProjectSubject = 'Project'; +export type ProjectAction = 'publish' | 'archive' | 'share' | Action; + +export type ProjectAbility = MongoAbility<[ProjectAction, ProjectSubject]>; + +export function defineProjectRules( + user: AbilityContext['user'], + { can }: AbilityBuilder +) { + if (user?.teamRole === 'admin' || user?.teamRole === 'owner') { + can('manage', 'Project'); + } +} diff --git a/src/features/ability/model/store.ts b/src/features/ability/model/store.ts new file mode 100644 index 0000000..c4efac8 --- /dev/null +++ b/src/features/ability/model/store.ts @@ -0,0 +1,24 @@ +import { type TProject } from 'entities/project/'; +import { type TTeam } from 'entities/team/'; +import { createStore } from 'shared/lib/store'; + +export type UseAbilityStates = { + user: { + userId: string | null; + projectRole: TProject.ProjectMemberRole | null; + teamRole: TTeam.TeamRole | null; + } | null; +}; + +export type UserAbilityActions = { + setUser: (user: UseAbilityStates['user']) => void; + clearUser: () => void; +}; + +export type UseAbilityStore = UseAbilityStates & UserAbilityActions; + +export const useAbilityStore = createStore((set, g) => ({ + user: null, + setUser: (user) => set({ user }), + clearUser: () => set({ user: null }), +})); diff --git a/src/features/ability/model/team.ability.ts b/src/features/ability/model/team.ability.ts new file mode 100644 index 0000000..7d63246 --- /dev/null +++ b/src/features/ability/model/team.ability.ts @@ -0,0 +1,53 @@ +import { AbilityBuilder, type MongoAbility, subject } from '@casl/ability'; +import { type UseAbilityStates as AbilityContext } from './store'; +import type { InferSubjects } from '@casl/ability'; +import { TTeam } from 'entities/team'; +import { Action } from './types'; +import { WithSubjectType } from '../lib/with-subject-type'; + +export const SUBJECTS = { + 'team.settings': 'TeamSettings', + 'team.member': 'TeamMember', +} as const; + +export type SubjectsType = typeof SUBJECTS; + +export type TeamMember = TTeam.TeamMemberResponse; +export type TeamSettings = TTeam.UpdateTeamBody; + +export type TeamAction = 'invite' | Action; + +export type TeamSubject = InferSubjects< + | WithSubjectType + | WithSubjectType +>; + +export type TeamAbility = MongoAbility<[TeamAction, TeamSubject]>; + +export const teamSubject = (member: NonNullable) => subject('TeamMember', member); +export const teamSettingsSubject = (settings: NonNullable) => + subject('TeamSettings', settings); + +export function defineTeamRules( + user: AbilityContext['user'], + { can }: AbilityBuilder +) { + if (user?.teamRole === 'admin') { + can('manage', 'TeamMember', { + role: { $in: ['member', 'viewer'] }, + }); + } + if (user?.teamRole === 'owner') { + can('manage', 'TeamMember', { + role: { $in: ['admin', 'member', 'viewer'] }, + }); + } + + if (user?.teamRole === 'admin' || user?.teamRole === 'owner') { + can('update', 'TeamSettings'); + } +} + +// - Владелец может управлять всеми участниками +// - Администратор может управлять всеми участниками, кроме владельца +// - Участник и гость ничего не могут diff --git a/src/features/ability/model/types.ts b/src/features/ability/model/types.ts new file mode 100644 index 0000000..cd97b39 --- /dev/null +++ b/src/features/ability/model/types.ts @@ -0,0 +1,2 @@ +export type Action = 'manage' | 'read' | 'create' | 'update' | 'delete'; +export type Subject = 'all'; diff --git a/src/features/ability/ui/AbilityProvider.tsx b/src/features/ability/ui/AbilityProvider.tsx new file mode 100644 index 0000000..84474c9 --- /dev/null +++ b/src/features/ability/ui/AbilityProvider.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { AbilityProvider as CaslAbilityProvider } from '@casl/react'; +import { AbilityBuilder, MongoAbility } from '@casl/ability'; +import { PropsWithChildren, useEffect, useRef } from 'react'; +import { defineTeamRules, TeamAbility, TeamAction, TeamSubject } from '../model/team.ability'; +import { useAbilityStore } from '../model/store'; +import { + defineProjectRules, + ProjectAbility, + ProjectAction, + ProjectSubject, +} from '../model/project.ability'; +import { createBuilder } from '../lib/create-builder'; + +export type AppAbility = MongoAbility<[TeamAction, TeamSubject] | [ProjectAction, ProjectSubject]>; + +const builder = createBuilder(); + +export function AbilityProvider({ children }: PropsWithChildren) { + const mounted = useRef(false); + const user = useAbilityStore((s) => s.user); + + defineProjectRules(user, builder as AbilityBuilder); + defineTeamRules(user, builder as AbilityBuilder); + + const ability = builder.build(); + + useEffect(() => { + if (!mounted.current) { + mounted.current = true; + return; + } + ability.update(builder.rules); + console.log('updated'); + }, [ability, user]); + + return {children}; +} diff --git a/src/features/ability/ui/Can.tsx b/src/features/ability/ui/Can.tsx new file mode 100644 index 0000000..8c68b1d --- /dev/null +++ b/src/features/ability/ui/Can.tsx @@ -0,0 +1,8 @@ +'use client'; + +import { Can as CaslCan, type CanProps } from '@casl/react'; +import { AppAbility } from './AbilityProvider'; + +export function Can({ children, ...props }: CanProps) { + return {children}; +} diff --git a/src/features/ability/ui/SyncAbilityStore.tsx b/src/features/ability/ui/SyncAbilityStore.tsx new file mode 100644 index 0000000..f9d8ec8 --- /dev/null +++ b/src/features/ability/ui/SyncAbilityStore.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { TeamQueries, useTeamStore } from 'entities/team'; +import { UserQueries } from 'entities/user'; +import { useAbilityStore } from '../model/store'; +import { useEffect } from 'react'; + +export function SyncAbilityStore() { + const teamId = useTeamStore((s) => s.teamId); + const setAbilityData = useAbilityStore((s) => s.setUser); + const { data: user } = useQuery({ ...UserQueries.getMe(), select: (data) => ({ id: data.id }) }); + const { data: teamRole } = useQuery({ + ...TeamQueries.getMembers(teamId!), + enabled: !!teamId && !!user, + select: (data) => { + return data.items.find((v) => v.id === user?.id)?.role; + }, + }); + + useEffect(() => { + setAbilityData({ + userId: user?.id ?? null, + teamRole: teamRole ?? null, + projectRole: teamRole ?? null, + }); + }, [setAbilityData, teamRole, user]); + + return null; +} diff --git a/src/pages/team/ui/members/MemberCard.tsx b/src/pages/team/ui/members/MemberCard.tsx index 9bef3b8..829b72a 100644 --- a/src/pages/team/ui/members/MemberCard.tsx +++ b/src/pages/team/ui/members/MemberCard.tsx @@ -68,7 +68,7 @@ export function MemberCard({ className, member, permissions, ...props }: MemberC
{canDelete && ( - + diff --git a/src/pages/team/ui/members/MembersPage.tsx b/src/pages/team/ui/members/MembersPage.tsx index d9cbdfb..5ec3586 100644 --- a/src/pages/team/ui/members/MembersPage.tsx +++ b/src/pages/team/ui/members/MembersPage.tsx @@ -6,21 +6,12 @@ import { Button, Search } from 'shared/ui'; import { MemberCard } from './MemberCard'; import { MemberCardSkeleton } from './MemberCard.skeleton'; import { useMembersPage } from '../../model/useMembersPage'; -import { UserQueries } from 'entities/user'; -import { useQuery } from '@tanstack/react-query'; -import { useMemo } from 'react'; -import { teamSubject, defineTeamMemberAbility } from 'entities/team'; +import { useAbility, teamSubject } from 'features/ability'; export function MembersPage() { const { search, onChange, filtered, total, isPending } = useMembersPage(); - const user = useQuery(UserQueries.getMe()); - const teamRole = filtered.find((v) => v.id === user.data?.id)?.role; - - const ability = useMemo( - () => defineTeamMemberAbility(user.data ? { id: user.data.id } : null, teamRole ?? null), - [user.data, teamRole] - ); + const ability = useAbility('Team'); const canInvite = ability.can('invite', 'TeamMember'); return ( @@ -53,17 +44,20 @@ export function MembersPage() {
{isPending ? Array.from({ length: 8 }).map((_, i) => ) - : filtered.map((m) => ( - - ))} + : filtered.map((m) => { + const canUpdate = ability.can('update', teamSubject(m)); + return ( + + ); + })}
); diff --git a/src/pages/team/ui/projects/ProjectCard.tsx b/src/pages/team/ui/projects/ProjectCard.tsx index 41a5ab9..40d0891 100644 --- a/src/pages/team/ui/projects/ProjectCard.tsx +++ b/src/pages/team/ui/projects/ProjectCard.tsx @@ -34,6 +34,14 @@ export type ProjectCardProps = ComponentProps & { name?: string; description?: string; statusLabel?: string; + permissions: Permissions; +}; + +type Permissions = { + canArchive: boolean; + canDelete: boolean; + canShare: boolean; + canRead: boolean; }; const statusLabels: Record = { @@ -49,8 +57,12 @@ export function ProjectCard({ name: nameProp, description: descriptionProp, statusLabel: statusLabelProp, + permissions, ...props }: ProjectCardProps) { + const { canArchive, canDelete, canRead, canShare } = permissions; + const canManage = Object.values(permissions).every(Boolean); + const teamId = useTeamStore.use.teamId(); const name = nameProp ?? project?.name ?? 'Atlas Platform'; const description = @@ -74,7 +86,7 @@ export function ProjectCard({ )} {...props} > - {projectHref && ( + {canRead && projectHref && ( -
e.stopPropagation()} - > - - - - - - - e.preventDefault()}>Поделиться - - {project?.status === 'archived' ? ( - e.stopPropagation()} + > + + + + + + {canShare && ( + e.preventDefault()}> - Архивировать + Поделиться - - ) - )} - - e.preventDefault()}> - Удалить - - - - -
+ + )} + + {canArchive && ( + <> + {project?.status === 'archived' ? ( + + e.preventDefault()}> + Восстановить + + + ) : ( + project?.status !== 'template' && ( + + e.preventDefault()}> + Архивировать + + + ) + )} + + )} + {canDelete && ( + + e.preventDefault()}> + Удалить + + + )} + + + + )} diff --git a/src/pages/team/ui/projects/ProjectsEmpty.tsx b/src/pages/team/ui/projects/ProjectsEmpty.tsx index d5647d4..1d3d7fe 100644 --- a/src/pages/team/ui/projects/ProjectsEmpty.tsx +++ b/src/pages/team/ui/projects/ProjectsEmpty.tsx @@ -1,3 +1,4 @@ +import { Can } from 'features/ability'; import { CreateProjectDialog } from 'features/projects/create'; import { FolderKanban } from 'lucide-react'; import { @@ -23,9 +24,11 @@ export function ProjectsEmpty() { - - - + + + + + ); diff --git a/src/pages/team/ui/projects/ProjectsPage.tsx b/src/pages/team/ui/projects/ProjectsPage.tsx index a8b7a39..f8535ed 100644 --- a/src/pages/team/ui/projects/ProjectsPage.tsx +++ b/src/pages/team/ui/projects/ProjectsPage.tsx @@ -9,6 +9,7 @@ import { Button } from 'shared/ui'; import { ProjectCard } from './ProjectCard'; import { ProjectCardSkeleton } from './ProjectCard.skeleton'; import { ProjectsEmpty } from './ProjectsEmpty'; +import { Can, useAbility } from 'features/ability'; export function ProjectsPage() { const teamId = useTeamStore.use.teamId(); @@ -17,25 +18,39 @@ export function ProjectsPage() { enabled: !!teamId, }); + const ability = useAbility('Project'); + if (!isPending && !data?.items.length) { return ; } return ( <> -
- - - -
+ +
+ + + +
+
{isPending ? Array.from({ length: 8 }).map((_, i) => ) : data?.items.map((project) => ( - + ))}
diff --git a/src/pages/team/ui/settings/TeamCover.tsx b/src/pages/team/ui/settings/TeamCover.tsx index da0627b..8afbf66 100644 --- a/src/pages/team/ui/settings/TeamCover.tsx +++ b/src/pages/team/ui/settings/TeamCover.tsx @@ -7,9 +7,10 @@ import { useUploadCover, type UseUploadFileOptions } from '../../api/useUploadCo interface TeamCoverProps { mutationOptions?: UseUploadFileOptions; coverUrl: string; + canUpdate: boolean; } -export function TeamCover({ mutationOptions, coverUrl }: TeamCoverProps) { +export function TeamCover({ mutationOptions, coverUrl, canUpdate }: TeamCoverProps) { const fileInputRef = useRef(null); const uploadCoverMutation = useUploadCover(mutationOptions); @@ -48,18 +49,20 @@ export function TeamCover({ mutationOptions, coverUrl }: TeamCoverProps) { )}
- + {canUpdate && ( + + )}
, @@ -16,6 +17,10 @@ interface TeamIdentityProps extends Omit< } export function TeamIdentity({ team, ...props }: TeamIdentityProps) { + const ability = useAbility('Team'); + + const canUpdate = ability.can('update', 'TeamSettings'); + return (
- +
} /> - +
diff --git a/src/pages/team/ui/settings/TeamIdentityForm.tsx b/src/pages/team/ui/settings/TeamIdentityForm.tsx index 284c77c..56c11e0 100644 --- a/src/pages/team/ui/settings/TeamIdentityForm.tsx +++ b/src/pages/team/ui/settings/TeamIdentityForm.tsx @@ -4,18 +4,23 @@ import { ComponentProps, useId } from 'react'; import { Controller, useFormContext, useFormState } from 'react-hook-form'; import { Field, FieldError, FieldLabel, Input, Textarea } from 'shared/ui'; -export function TeamIdentityForm(props: Omit, 'children'>) { +export function TeamIdentityForm( + props: Omit, 'children'> & { canUpdate: boolean } +) { const idName = useId(); const idDescription = useId(); const form = useFormContext(); const { isSubmitting } = useFormState({ control: form.control }); + const canUpdate = props.canUpdate; + return (
( @@ -34,6 +39,7 @@ export function TeamIdentityForm(props: Omit, 'children'>) /> ( diff --git a/src/widgets/app-sidebar/ui/projects/ProjectActions.tsx b/src/widgets/app-sidebar/ui/projects/ProjectActions.tsx index 5143651..32e0a78 100644 --- a/src/widgets/app-sidebar/ui/projects/ProjectActions.tsx +++ b/src/widgets/app-sidebar/ui/projects/ProjectActions.tsx @@ -1,6 +1,7 @@ 'use client'; import { type TProject } from 'entities/project'; +import { Can, useAbility } from 'features/ability'; import { ArchiveProjectDialog, RestoreProjectDialog } from 'features/projects/archive'; import { ShareProjectDialog } from 'features/projects/share'; import { Archive, Link2 } from 'lucide-react'; @@ -24,7 +25,12 @@ export function ProjectActions({ project, teamId, ...props }: ProjectActionsProp const [shareOpen, setShareOpen] = useState(false); const [archiveOpen, setArchiveOpen] = useState(false); const [restoreOpen, setRestoreOpen] = useState(false); - const canManage = Boolean(teamId && project.canEdit); + + const ability = useAbility('Project'); + const canArchive = ability.can('archive', 'Project'); + const canPublish = ability.can('publish', 'Project'); + + const canManage = Boolean(teamId) && canArchive && canPublish; const openDialog = (setDialogOpen: (open: boolean) => void) => (event: Event) => { event.preventDefault(); @@ -41,7 +47,7 @@ export function ProjectActions({ project, teamId, ...props }: ProjectActionsProp side={isMobile ? 'bottom' : 'right'} align={isMobile ? 'end' : 'start'} > - + Опубликовать diff --git a/src/widgets/app-sidebar/ui/projects/ProjectsContent.tsx b/src/widgets/app-sidebar/ui/projects/ProjectsContent.tsx index c08eb73..2bdf776 100644 --- a/src/widgets/app-sidebar/ui/projects/ProjectsContent.tsx +++ b/src/widgets/app-sidebar/ui/projects/ProjectsContent.tsx @@ -21,13 +21,22 @@ import { useSidebar, } from 'shared/ui'; import { ProjectActions } from './ProjectActions'; +import { useAbility } from 'features/ability'; export function ProjectsContent() { const teamId = useTeamStore.use.teamId(); const router = useRouter(); const pathname = usePathname(); const { open, isMobile } = useSidebar(); - const projects = useQuery({ ...ProjectQueries.getProjects(teamId!), enabled: !!teamId }); + + const ability = useAbility('Project'); + const canReadProjects = ability.can('read', 'Project'); + const canCreate = ability.can('create', 'Project'); + + const projects = useQuery({ + ...ProjectQueries.getProjects(teamId!), + enabled: !!teamId, + }); if (!projects.data) { return null; @@ -64,12 +73,19 @@ export function ProjectsContent() { - - {projectIconCodeToEmoji(project.icon)} {project.name} - + {canReadProjects ? ( + + {projectIconCodeToEmoji(project.icon)} {project.name} + + ) : ( + <> + {projectIconCodeToEmoji(project.icon)} {project.name} + + )} + @@ -77,13 +93,16 @@ export function ProjectsContent() { ))} - - - - Новый проект - - - + {canCreate && ( + + + + Новый проект + + + + )} + {totalProjects > 0 ? ( From 339d06598123b75571fd8b5f42bcbccbe6adbb2c Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Fri, 26 Jun 2026 19:08:03 +0300 Subject: [PATCH 3/4] chore: lint fix --- src/features/ability/model/store.ts | 2 +- src/features/task/create/model/useCreateTask.tsx | 1 - .../profile/ui/notifications-page/NotificationsPageFallback.tsx | 2 +- src/widgets/app-sidebar/ui/projects/ProjectActions.tsx | 2 +- 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/features/ability/model/store.ts b/src/features/ability/model/store.ts index c4efac8..4b06e9e 100644 --- a/src/features/ability/model/store.ts +++ b/src/features/ability/model/store.ts @@ -17,7 +17,7 @@ export type UserAbilityActions = { export type UseAbilityStore = UseAbilityStates & UserAbilityActions; -export const useAbilityStore = createStore((set, g) => ({ +export const useAbilityStore = createStore((set) => ({ user: null, setUser: (user) => set({ user }), clearUser: () => set({ user: null }), diff --git a/src/features/task/create/model/useCreateTask.tsx b/src/features/task/create/model/useCreateTask.tsx index 257654c..5b6bc38 100644 --- a/src/features/task/create/model/useCreateTask.tsx +++ b/src/features/task/create/model/useCreateTask.tsx @@ -17,7 +17,6 @@ export function useCreateTask({ onSuccess, ...rest }: UseCreateProjectOptions = return useMutation({ ...rest, mutationFn: ({ body }) => TaskHttp.createTask(body), - onMutate: (data, ctx) => {}, onSuccess: async (res, variables, _r, context) => { onSuccess?.(res, variables, _r, context); }, diff --git a/src/pages/profile/ui/notifications-page/NotificationsPageFallback.tsx b/src/pages/profile/ui/notifications-page/NotificationsPageFallback.tsx index 339c750..e45f3c7 100644 --- a/src/pages/profile/ui/notifications-page/NotificationsPageFallback.tsx +++ b/src/pages/profile/ui/notifications-page/NotificationsPageFallback.tsx @@ -1,4 +1,4 @@ -import { CardSection, OptionGroup, Skeleton } from 'shared/ui'; +import { CardSection, Skeleton } from 'shared/ui'; export function NotificationsPageFallback() { return ( diff --git a/src/widgets/app-sidebar/ui/projects/ProjectActions.tsx b/src/widgets/app-sidebar/ui/projects/ProjectActions.tsx index 32e0a78..2dc501d 100644 --- a/src/widgets/app-sidebar/ui/projects/ProjectActions.tsx +++ b/src/widgets/app-sidebar/ui/projects/ProjectActions.tsx @@ -1,7 +1,7 @@ 'use client'; import { type TProject } from 'entities/project'; -import { Can, useAbility } from 'features/ability'; +import { useAbility } from 'features/ability'; import { ArchiveProjectDialog, RestoreProjectDialog } from 'features/projects/archive'; import { ShareProjectDialog } from 'features/projects/share'; import { Archive, Link2 } from 'lucide-react'; From e11e1118a52671d067b3aca89e293859ff59f044 Mon Sep 17 00:00:00 2001 From: Alexandr Nelyubov Date: Fri, 26 Jun 2026 22:35:04 +0300 Subject: [PATCH 4/4] refactor(ability): add CASL dependencies and update ability management logic - store: simplified storage fields, rename types - stable reference for `ability` has been implemented; - new rules for the existing `ability` instance are now created within `useEffect` based on the new user. --- package.json | 2 + pnpm-lock.yaml | 52 +++++++++++++++++++ src/features/ability/lib/create-builder.ts | 2 +- src/features/ability/lib/useAbility.ts | 5 +- src/features/ability/model/project.ability.ts | 13 ++--- src/features/ability/model/store.ts | 30 ++++++----- src/features/ability/model/team.ability.ts | 30 ++++++----- src/features/ability/ui/AbilityProvider.tsx | 51 ++++++++++-------- src/features/ability/ui/Can.tsx | 2 +- src/features/ability/ui/SyncAbilityStore.tsx | 2 +- 10 files changed, 129 insertions(+), 60 deletions(-) diff --git a/package.json b/package.json index 5b92ae7..045b3cb 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,8 @@ "prepare": "husky" }, "dependencies": { + "@casl/ability": "^7.0.0", + "@casl/react": "^7.0.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9abf844..b0bb143 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,12 @@ importers: .: dependencies: + '@casl/ability': + specifier: ^7.0.0 + version: 7.0.0 + '@casl/react': + specifier: ^7.0.0 + version: 7.0.0(@casl/ability@7.0.0)(react@19.2.5) '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -359,6 +365,15 @@ packages: resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true + '@casl/ability@7.0.0': + resolution: {integrity: sha512-QhwRflkTucpdS2uw1XScrzLWbgLYJGvPoq2Xm5OjeRci3dwtPixxnjUKJ04Ss1ivNS9tZQ8y4sjWeelWsrwo4g==} + + '@casl/react@7.0.0': + resolution: {integrity: sha512-TfSujWNo4IosHoYZpkGdKXwlKdUBeJCm5CxaApjsOeyNFRfXcGWhHkikK4b6D0L+HgiRiizE7PxHhpc7lv3Z/g==} + peerDependencies: + '@casl/ability': ^4.0.0 || ^5.1.0 || ^6.0.0 || ^7.0.0 + react: ^18.0.0 || ^19.0.0 + '@clack/core@0.4.1': resolution: {integrity: sha512-Pxhij4UXg8KSr7rPek6Zowm+5M22rbd2g1nfojHJkxp5YkFqiZ2+YLEM/XGVIzvGOcM0nqjIFxrpDwWRZYWYjA==} @@ -2352,6 +2367,18 @@ packages: resolution: {integrity: sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ucast/core@2.0.0': + resolution: {integrity: sha512-4XVx6LzPXZGvnZO5jp39cm/G4UvuwvEdtmg+9+4+zl6uFkCcB7UJacvtMYeBE56GJVT99Zqy6Pii7dGJq3Kz9Q==} + + '@ucast/js@4.0.1': + resolution: {integrity: sha512-9O5xPBvwEWQk2WvO69Eh2WJB8QljVZ2vRVdFvfnKjlZwWXcYxp1lqLBhwXBU1AtuSgCvKhJPkXdjKJggUmAmQQ==} + + '@ucast/mongo2js@2.0.0': + resolution: {integrity: sha512-vNBZzRnsfLr/TSxEoxz6W6hHQ5tmWsfEeC0nCq5z8RezC1AqIRy3cfHm8AGvlGtcn+cTSFQcZremfqnz6wm+nQ==} + + '@ucast/mongo@3.0.0': + resolution: {integrity: sha512-kwuSH+kdB4GCR0LGhy/PEDm4PCflur89AlK82kNiYD0FvsA8A/p+0sx7m+/R8mMFAlmlkAd3VXp7sM/cLLYWYg==} + '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} cpu: [arm] @@ -5770,6 +5797,15 @@ snapshots: dependencies: css-tree: 3.2.1 + '@casl/ability@7.0.0': + dependencies: + '@ucast/mongo2js': 2.0.0 + + '@casl/react@7.0.0(@casl/ability@7.0.0)(react@19.2.5)': + dependencies: + '@casl/ability': 7.0.0 + react: 19.2.5 + '@clack/core@0.4.1': dependencies: picocolors: 1.1.1 @@ -7701,6 +7737,22 @@ snapshots: '@typescript-eslint/types': 8.58.1 eslint-visitor-keys: 5.0.1 + '@ucast/core@2.0.0': {} + + '@ucast/js@4.0.1': + dependencies: + '@ucast/core': 2.0.0 + + '@ucast/mongo2js@2.0.0': + dependencies: + '@ucast/core': 2.0.0 + '@ucast/js': 4.0.1 + '@ucast/mongo': 3.0.0 + + '@ucast/mongo@3.0.0': + dependencies: + '@ucast/core': 2.0.0 + '@unrs/resolver-binding-android-arm-eabi@1.11.1': optional: true diff --git a/src/features/ability/lib/create-builder.ts b/src/features/ability/lib/create-builder.ts index 9431f17..a1a6fad 100644 --- a/src/features/ability/lib/create-builder.ts +++ b/src/features/ability/lib/create-builder.ts @@ -1,5 +1,5 @@ import { AbilityBuilder, createMongoAbility } from '@casl/ability'; -import { AppAbility } from '../ui/AbilityProvider'; +import { type AppAbility } from '../ui/AbilityProvider'; export function createBuilder() { return new AbilityBuilder(createMongoAbility); diff --git a/src/features/ability/lib/useAbility.ts b/src/features/ability/lib/useAbility.ts index 2d36323..1487586 100644 --- a/src/features/ability/lib/useAbility.ts +++ b/src/features/ability/lib/useAbility.ts @@ -1,13 +1,14 @@ 'use client'; import { useAbility as useCaslAbility } from '@casl/react'; -import { TeamAbility } from '../model/team.ability'; -import { ProjectAbility } from '../model/project.ability'; +import { type TeamAbility } from '../model/team.ability'; +import { type ProjectAbility } from '../model/project.ability'; type SubjectMap = { Team: TeamAbility; Project: ProjectAbility; }; +// eslint-disable-next-line @typescript-eslint/no-unused-vars export function useAbility(_subject: T) { return useCaslAbility(); } diff --git a/src/features/ability/model/project.ability.ts b/src/features/ability/model/project.ability.ts index 61d82a3..9b1b5c2 100644 --- a/src/features/ability/model/project.ability.ts +++ b/src/features/ability/model/project.ability.ts @@ -1,7 +1,7 @@ -import { AbilityBuilder, MongoAbility } from '@casl/ability'; -import { TProject } from 'entities/project'; -import { UseAbilityStates as AbilityContext } from './store'; -import { Action } from './types'; +import type { AbilityBuilder, MongoAbility } from '@casl/ability'; +import { type TProject } from 'entities/project'; +import { type AbilityState } from './store'; +import { type Action } from './types'; export type Project = Pick; @@ -11,10 +11,11 @@ export type ProjectAction = 'publish' | 'archive' | 'share' | Action; export type ProjectAbility = MongoAbility<[ProjectAction, ProjectSubject]>; export function defineProjectRules( - user: AbilityContext['user'], + { teamRole }: AbilityState, { can }: AbilityBuilder ) { - if (user?.teamRole === 'admin' || user?.teamRole === 'owner') { + if (teamRole === 'admin' || teamRole === 'owner') { + // Пользователи с ролями администратор и владелец могут управлять любыми проектами can('manage', 'Project'); } } diff --git a/src/features/ability/model/store.ts b/src/features/ability/model/store.ts index 4b06e9e..188c60b 100644 --- a/src/features/ability/model/store.ts +++ b/src/features/ability/model/store.ts @@ -2,23 +2,27 @@ import { type TProject } from 'entities/project/'; import { type TTeam } from 'entities/team/'; import { createStore } from 'shared/lib/store'; -export type UseAbilityStates = { - user: { - userId: string | null; - projectRole: TProject.ProjectMemberRole | null; - teamRole: TTeam.TeamRole | null; - } | null; +export type AbilityState = { + userId: string | null; + projectRole: TProject.ProjectMemberRole | null; + teamRole: TTeam.TeamRole | null; }; -export type UserAbilityActions = { - setUser: (user: UseAbilityStates['user']) => void; - clearUser: () => void; +export type AbilityActions = { + setAbility: (data: AbilityState) => void; + clearAbility: () => void; }; -export type UseAbilityStore = UseAbilityStates & UserAbilityActions; +export type UseAbilityStore = AbilityState & AbilityActions; + +const initialState: AbilityState = { + userId: null, + projectRole: null, + teamRole: null, +}; export const useAbilityStore = createStore((set) => ({ - user: null, - setUser: (user) => set({ user }), - clearUser: () => set({ user: null }), + ...initialState, + setAbility: (data) => set(data), + clearAbility: () => set(initialState), })); diff --git a/src/features/ability/model/team.ability.ts b/src/features/ability/model/team.ability.ts index 7d63246..0b8d98c 100644 --- a/src/features/ability/model/team.ability.ts +++ b/src/features/ability/model/team.ability.ts @@ -1,9 +1,10 @@ -import { AbilityBuilder, type MongoAbility, subject } from '@casl/ability'; -import { type UseAbilityStates as AbilityContext } from './store'; -import type { InferSubjects } from '@casl/ability'; -import { TTeam } from 'entities/team'; -import { Action } from './types'; -import { WithSubjectType } from '../lib/with-subject-type'; +import type { AbilityBuilder } from '@casl/ability'; +import { type MongoAbility, subject } from '@casl/ability'; +import { type AbilityState } from './store'; +import { type InferSubjects } from '@casl/ability'; +import { type TTeam } from 'entities/team'; +import { type Action } from './types'; +import { type WithSubjectType } from '../lib/with-subject-type'; export const SUBJECTS = { 'team.settings': 'TeamSettings', @@ -28,22 +29,25 @@ export const teamSubject = (member: NonNullable) => subject('TeamMem export const teamSettingsSubject = (settings: NonNullable) => subject('TeamSettings', settings); -export function defineTeamRules( - user: AbilityContext['user'], - { can }: AbilityBuilder -) { - if (user?.teamRole === 'admin') { +export function defineTeamRules({ teamRole }: AbilityState, { can }: AbilityBuilder) { + const isAdmin = teamRole === 'admin'; + const isOwner = teamRole === 'owner'; + + if (isAdmin) { + // Если пользователь администратор, он может (can) управлять (manage) участниками с ролями member и viewer (role: { $in: ['member', 'viewer'] }) can('manage', 'TeamMember', { role: { $in: ['member', 'viewer'] }, }); } - if (user?.teamRole === 'owner') { + if (isOwner) { + // Если пользователь владелец, он может управлять участниками с ролями member, viewer и admin can('manage', 'TeamMember', { role: { $in: ['admin', 'member', 'viewer'] }, }); } - if (user?.teamRole === 'admin' || user?.teamRole === 'owner') { + if (isAdmin || isOwner) { + // Пользователь с ролью администратор и владелец могут изменять настройки любой команды can('update', 'TeamSettings'); } } diff --git a/src/features/ability/ui/AbilityProvider.tsx b/src/features/ability/ui/AbilityProvider.tsx index 84474c9..96e940a 100644 --- a/src/features/ability/ui/AbilityProvider.tsx +++ b/src/features/ability/ui/AbilityProvider.tsx @@ -1,38 +1,43 @@ 'use client'; import { AbilityProvider as CaslAbilityProvider } from '@casl/react'; -import { AbilityBuilder, MongoAbility } from '@casl/ability'; -import { PropsWithChildren, useEffect, useRef } from 'react'; -import { defineTeamRules, TeamAbility, TeamAction, TeamSubject } from '../model/team.ability'; -import { useAbilityStore } from '../model/store'; +import { type PropsWithChildren, useEffect, useState } from 'react'; import { - defineProjectRules, - ProjectAbility, - ProjectAction, - ProjectSubject, -} from '../model/project.ability'; + defineTeamRules, + type TeamAbility, + type TeamAction, + type TeamSubject, +} from '../model/team.ability'; +import type { AbilityState } from '../model/store'; +import { useAbilityStore } from '../model/store'; +import type { ProjectAbility, ProjectAction, ProjectSubject } from '../model/project.ability'; +import { defineProjectRules } from '../model/project.ability'; import { createBuilder } from '../lib/create-builder'; +import type { AbilityBuilder } from '@casl/ability'; +import { type MongoAbility } from '@casl/ability'; +import { useShallow } from 'zustand/shallow'; export type AppAbility = MongoAbility<[TeamAction, TeamSubject] | [ProjectAction, ProjectSubject]>; -const builder = createBuilder(); - export function AbilityProvider({ children }: PropsWithChildren) { - const mounted = useRef(false); - const user = useAbilityStore((s) => s.user); - - defineProjectRules(user, builder as AbilityBuilder); - defineTeamRules(user, builder as AbilityBuilder); - - const ability = builder.build(); + const user = useAbilityStore( + useShallow( + ({ userId, teamRole, projectRole }): AbilityState => ({ userId, teamRole, projectRole }) + ) + ); + + const [ability] = useState(() => { + const builder = createBuilder(); + defineProjectRules(user, builder as AbilityBuilder); + defineTeamRules(user, builder as AbilityBuilder); + return builder.build(); + }); useEffect(() => { - if (!mounted.current) { - mounted.current = true; - return; - } + const builder = createBuilder(); + defineProjectRules(user, builder as AbilityBuilder); + defineTeamRules(user, builder as AbilityBuilder); ability.update(builder.rules); - console.log('updated'); }, [ability, user]); return {children}; diff --git a/src/features/ability/ui/Can.tsx b/src/features/ability/ui/Can.tsx index 8c68b1d..0a76a0f 100644 --- a/src/features/ability/ui/Can.tsx +++ b/src/features/ability/ui/Can.tsx @@ -1,7 +1,7 @@ 'use client'; import { Can as CaslCan, type CanProps } from '@casl/react'; -import { AppAbility } from './AbilityProvider'; +import type { AppAbility } from './AbilityProvider'; export function Can({ children, ...props }: CanProps) { return {children}; diff --git a/src/features/ability/ui/SyncAbilityStore.tsx b/src/features/ability/ui/SyncAbilityStore.tsx index f9d8ec8..6c5e1f1 100644 --- a/src/features/ability/ui/SyncAbilityStore.tsx +++ b/src/features/ability/ui/SyncAbilityStore.tsx @@ -8,7 +8,7 @@ import { useEffect } from 'react'; export function SyncAbilityStore() { const teamId = useTeamStore((s) => s.teamId); - const setAbilityData = useAbilityStore((s) => s.setUser); + const setAbilityData = useAbilityStore((s) => s.setAbility); const { data: user } = useQuery({ ...UserQueries.getMe(), select: (data) => ({ id: data.id }) }); const { data: teamRole } = useQuery({ ...TeamQueries.getMembers(teamId!),