@tuturuuu/ui 0.26.1 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/biome.json +1 -1
  3. package/package.json +48 -48
  4. package/src/components/ui/custom/combobox.tsx +4 -0
  5. package/src/components/ui/custom/workspace-access/adapters.test.ts +89 -1
  6. package/src/components/ui/custom/workspace-access/adapters.ts +67 -2
  7. package/src/components/ui/custom/workspace-access/types.ts +13 -0
  8. package/src/components/ui/custom/workspace-access/workspace-access-invitation-role-menu.test.tsx +79 -0
  9. package/src/components/ui/custom/workspace-access/workspace-access-invitation-role-menu.tsx +125 -0
  10. package/src/components/ui/custom/workspace-access/workspace-access-invite-access-picker.tsx +108 -0
  11. package/src/components/ui/custom/workspace-access/workspace-access-invite-dialog.test.tsx +134 -0
  12. package/src/components/ui/custom/workspace-access/workspace-access-invite-dialog.tsx +94 -113
  13. package/src/components/ui/custom/workspace-access/workspace-access-invite-pos-panel.tsx +75 -0
  14. package/src/components/ui/custom/workspace-access/workspace-access-invite-role-picker.tsx +151 -0
  15. package/src/components/ui/custom/workspace-access/workspace-access-labels.ts +1 -1
  16. package/src/components/ui/custom/workspace-access/workspace-access-member-row.tsx +27 -2
  17. package/src/components/ui/custom/workspace-access/workspace-access-members.tsx +10 -0
  18. package/src/components/ui/custom/workspace-access/workspace-access-page.tsx +102 -3
  19. package/src/components/ui/custom/workspace-access/workspace-access-role-options.test.ts +35 -0
  20. package/src/components/ui/custom/workspace-access/workspace-access-role-options.ts +21 -0
  21. package/src/components/ui/custom/workspace-select-invitations.tsx +2 -1
  22. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +34 -1
  23. package/src/hooks/use-board-actions.test.ts +23 -0
  24. package/src/hooks/use-board-actions.ts +48 -35
  25. package/src/hooks/use-calendar-sync.tsx +12 -12
  26. package/src/hooks/use-notifications.ts +4 -8
@@ -0,0 +1,75 @@
1
+ 'use client';
2
+
3
+ import { CreditCard, ShieldCheck, TriangleAlert } from '@tuturuuu/icons';
4
+ import { Checkbox } from '@tuturuuu/ui/checkbox';
5
+ import { useTranslations } from 'next-intl';
6
+
7
+ export function WorkspaceAccessInvitePosPanel({
8
+ confirmDefaultAdminMigration,
9
+ defaultAdminEnabled,
10
+ joinedMemberCount,
11
+ onConfirmDefaultAdminMigrationChange,
12
+ }: {
13
+ confirmDefaultAdminMigration: boolean;
14
+ defaultAdminEnabled: boolean;
15
+ joinedMemberCount: number;
16
+ onConfirmDefaultAdminMigrationChange: (value: boolean) => void;
17
+ }) {
18
+ const t = useTranslations();
19
+
20
+ return (
21
+ <div className="space-y-3 rounded-xl border border-dynamic-blue/25 bg-dynamic-blue/5 p-3.5 sm:p-4">
22
+ <div className="flex items-start gap-3">
23
+ <div className="mt-0.5 rounded-lg border border-dynamic-blue/20 bg-background p-2 text-dynamic-blue">
24
+ <CreditCard className="size-4" />
25
+ </div>
26
+ <div className="min-w-0 space-y-1">
27
+ <p className="font-medium text-sm">
28
+ {t('ws-members.pos_operator_title')}
29
+ </p>
30
+ <p className="text-muted-foreground text-sm leading-5">
31
+ {t('ws-members.pos_operator_description')}
32
+ </p>
33
+ </div>
34
+ </div>
35
+
36
+ <div className="grid gap-2 sm:grid-cols-2">
37
+ {defaultAdminEnabled ? (
38
+ <div className="flex items-center gap-2 rounded-lg border bg-background/70 p-3 text-sm">
39
+ <ShieldCheck className="size-4 text-dynamic-green" />
40
+ <span>
41
+ {joinedMemberCount}{' '}
42
+ {t('ws-members.pos_operator_existing_members')}
43
+ </span>
44
+ </div>
45
+ ) : null}
46
+ <div className="flex items-center gap-2 rounded-lg border bg-background/70 p-3 text-sm">
47
+ <CreditCard className="size-4 text-dynamic-blue" />
48
+ <span>{t('ws-members.pos_operator_only_permission')}</span>
49
+ </div>
50
+ </div>
51
+
52
+ {defaultAdminEnabled ? (
53
+ <div className="flex items-start gap-2 rounded-lg border border-dynamic-orange/25 bg-dynamic-orange/5 p-3 text-sm">
54
+ <TriangleAlert className="mt-0.5 size-4 shrink-0 text-dynamic-orange" />
55
+ <p className="text-muted-foreground leading-5">
56
+ {t('ws-members.pos_operator_admin_migration_note')}
57
+ </p>
58
+ </div>
59
+ ) : null}
60
+
61
+ <label className="flex cursor-pointer items-start gap-3 rounded-lg border bg-background p-3 transition-colors hover:bg-muted/20 active:scale-[0.99]">
62
+ <Checkbox
63
+ checked={confirmDefaultAdminMigration}
64
+ className="mt-0.5"
65
+ onCheckedChange={(checked) =>
66
+ onConfirmDefaultAdminMigrationChange(checked === true)
67
+ }
68
+ />
69
+ <span className="text-sm leading-5">
70
+ {t('ws-members.pos_operator_confirmation')}
71
+ </span>
72
+ </label>
73
+ </div>
74
+ );
75
+ }
@@ -0,0 +1,151 @@
1
+ 'use client';
2
+
3
+ import { Search, ShieldUser } from '@tuturuuu/icons';
4
+ import {
5
+ Accordion,
6
+ AccordionContent,
7
+ AccordionItem,
8
+ AccordionTrigger,
9
+ } from '@tuturuuu/ui/accordion';
10
+ import { Badge } from '@tuturuuu/ui/badge';
11
+ import { Button } from '@tuturuuu/ui/button';
12
+ import { Checkbox } from '@tuturuuu/ui/checkbox';
13
+ import { Input } from '@tuturuuu/ui/input';
14
+ import { Label } from '@tuturuuu/ui/label';
15
+ import { useTranslations } from 'next-intl';
16
+ import { useState } from 'react';
17
+
18
+ export function WorkspaceAccessInviteRolePicker({
19
+ noRoleLabel,
20
+ onChange,
21
+ roleIds,
22
+ roles,
23
+ }: {
24
+ noRoleLabel: string;
25
+ onChange: (roleIds: string[]) => void;
26
+ roleIds: string[];
27
+ roles: Array<{ id: string; name: string }>;
28
+ }) {
29
+ const t = useTranslations();
30
+ const [query, setQuery] = useState('');
31
+ const selected = new Set(roleIds);
32
+ const normalizedQuery = query.trim().toLocaleLowerCase();
33
+ const visibleRoles = normalizedQuery
34
+ ? roles.filter((role) =>
35
+ role.name.toLocaleLowerCase().includes(normalizedQuery)
36
+ )
37
+ : roles;
38
+
39
+ const toggleRole = (roleId: string) => {
40
+ onChange(
41
+ selected.has(roleId)
42
+ ? roleIds.filter((id) => id !== roleId)
43
+ : [...roleIds, roleId]
44
+ );
45
+ };
46
+
47
+ return (
48
+ <Accordion type="single" collapsible defaultValue="workspace-roles">
49
+ <AccordionItem
50
+ value="workspace-roles"
51
+ className="overflow-hidden rounded-xl border bg-background px-3"
52
+ >
53
+ <AccordionTrigger className="items-center py-3 hover:no-underline">
54
+ <span className="flex min-w-0 items-center gap-3">
55
+ <span className="flex size-9 shrink-0 items-center justify-center rounded-lg border bg-muted/30 text-dynamic-blue">
56
+ <ShieldUser className="size-4" />
57
+ </span>
58
+ <span className="min-w-0 text-left">
59
+ <span className="flex items-center gap-2">
60
+ <span className="truncate">
61
+ {t('ws-members.role-placeholder')}
62
+ </span>
63
+ <Badge
64
+ variant="outline"
65
+ className="rounded-full font-normal text-[10px] uppercase tracking-wide"
66
+ >
67
+ {t('ws-members.invite_roles_optional')}
68
+ </Badge>
69
+ </span>
70
+ <span
71
+ aria-live="polite"
72
+ className="mt-0.5 block text-muted-foreground text-xs"
73
+ >
74
+ {t('ws-members.roles_selected', { count: roleIds.length })}
75
+ </span>
76
+ </span>
77
+ </span>
78
+ </AccordionTrigger>
79
+
80
+ <AccordionContent className="space-y-3 border-t pt-3">
81
+ <p className="text-muted-foreground text-xs leading-5">
82
+ {t('ws-members.invite_roles_description')}
83
+ </p>
84
+
85
+ {roles.length > 0 ? (
86
+ <>
87
+ <div className="flex items-center gap-2">
88
+ <div className="relative min-w-0 flex-1">
89
+ <Label
90
+ htmlFor="workspace-access-invite-role-search"
91
+ className="sr-only"
92
+ >
93
+ {t('ws-members.invite_roles_search')}
94
+ </Label>
95
+ <Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
96
+ <Input
97
+ id="workspace-access-invite-role-search"
98
+ value={query}
99
+ onChange={(event) => setQuery(event.target.value)}
100
+ placeholder={t('ws-members.invite_roles_search')}
101
+ className="h-10 rounded-lg pl-9"
102
+ />
103
+ </div>
104
+ {roleIds.length > 0 ? (
105
+ <Button
106
+ type="button"
107
+ variant="ghost"
108
+ size="sm"
109
+ className="shrink-0"
110
+ onClick={() => onChange([])}
111
+ >
112
+ {t('ws-members.clear_all_roles')}
113
+ </Button>
114
+ ) : null}
115
+ </div>
116
+
117
+ {visibleRoles.length > 0 ? (
118
+ <div className="max-h-52 space-y-1 overflow-y-auto rounded-lg border p-1.5">
119
+ {visibleRoles.map((role) => (
120
+ <label
121
+ key={role.id}
122
+ className={`flex cursor-pointer items-center gap-3 rounded-md px-3 py-2.5 transition-colors active:scale-[0.99] ${selected.has(role.id) ? 'bg-dynamic-blue/10 text-foreground' : 'hover:bg-muted/50'}`}
123
+ >
124
+ <Checkbox
125
+ aria-label={role.name}
126
+ checked={selected.has(role.id)}
127
+ onCheckedChange={() => toggleRole(role.id)}
128
+ />
129
+ <ShieldUser className="size-4 text-dynamic-blue" />
130
+ <span className="min-w-0 flex-1 truncate font-medium text-sm">
131
+ {role.name}
132
+ </span>
133
+ </label>
134
+ ))}
135
+ </div>
136
+ ) : (
137
+ <div className="rounded-lg border border-dashed p-4 text-center text-muted-foreground text-sm">
138
+ {t('ws-members.invite_roles_no_match')}
139
+ </div>
140
+ )}
141
+ </>
142
+ ) : (
143
+ <div className="rounded-lg border border-dashed p-4 text-center text-muted-foreground text-sm">
144
+ {noRoleLabel}
145
+ </div>
146
+ )}
147
+ </AccordionContent>
148
+ </AccordionItem>
149
+ </Accordion>
150
+ );
151
+ }
@@ -32,7 +32,7 @@ export function getWorkspaceAccessLabels(
32
32
  noRolesLabel:
33
33
  mode === 'cms'
34
34
  ? t('external-projects.settings.no_roles_label')
35
- : t('ws-members.no_roles_found'),
35
+ : t('ws-members.no_roles_assigned'),
36
36
  protectedMemberLabel:
37
37
  mode === 'cms'
38
38
  ? t('external-projects.settings.protected_member_label')
@@ -34,6 +34,7 @@ import {
34
34
  shouldShowProtectedMemberStatus,
35
35
  } from './member-filter-utils';
36
36
  import type { WorkspaceAccessLabels, WorkspaceAccessRole } from './types';
37
+ import { WorkspaceAccessInvitationRoleMenu } from './workspace-access-invitation-role-menu';
37
38
 
38
39
  type GuestContext = InternalApiEnhancedWorkspaceMember & {
39
40
  direct_board_guest?: boolean;
@@ -46,6 +47,7 @@ type Props = {
46
47
  canEditProfiles: boolean;
47
48
  canManageMembers: boolean;
48
49
  canManageRoles: boolean;
50
+ canUpdateInvitationRoles: boolean;
49
51
  defaultAdminEnabled: boolean;
50
52
  isMutating: boolean;
51
53
  labels: WorkspaceAccessLabels;
@@ -57,6 +59,11 @@ type Props = {
57
59
  userId?: null | string;
58
60
  }) => void;
59
61
  onRemoveRole: (payload: { roleId: string; userId: string }) => void;
62
+ onUpdateInvitationRole: (payload: {
63
+ email?: null | string;
64
+ roleIds: string[];
65
+ userId?: null | string;
66
+ }) => void;
60
67
  roles: Array<Pick<WorkspaceAccessRole, 'id' | 'name'>>;
61
68
  };
62
69
 
@@ -64,6 +71,7 @@ export function WorkspaceAccessMemberRow({
64
71
  canEditProfiles,
65
72
  canManageMembers,
66
73
  canManageRoles,
74
+ canUpdateInvitationRoles,
67
75
  defaultAdminEnabled,
68
76
  isMutating,
69
77
  labels,
@@ -72,6 +80,7 @@ export function WorkspaceAccessMemberRow({
72
80
  onEditMemberProfile,
73
81
  onRemoveMember,
74
82
  onRemoveRole,
83
+ onUpdateInvitationRole,
75
84
  roles,
76
85
  }: Props) {
77
86
  const t = useTranslations();
@@ -81,6 +90,11 @@ export function WorkspaceAccessMemberRow({
81
90
  const memberName = getMemberDisplayName(member, t('common.unknown'));
82
91
  const guest = member as GuestContext;
83
92
  const canRemoveRoles = canManageRoles && memberId && !member.pending;
93
+ const canUpdateInvitationRole =
94
+ canUpdateInvitationRoles &&
95
+ member.pending &&
96
+ member.workspace_member_type !== 'GUEST' &&
97
+ Boolean(memberId || member.email);
84
98
  const canEditProfile =
85
99
  canEditProfiles &&
86
100
  canManageMembers &&
@@ -281,7 +295,16 @@ export function WorkspaceAccessMemberRow({
281
295
  </div>
282
296
 
283
297
  <div className="mt-2.5 flex flex-wrap gap-1.5 sm:mt-3">
284
- {member.roles.length > 0 ? (
298
+ {canUpdateInvitationRole ? (
299
+ <WorkspaceAccessInvitationRoleMenu
300
+ email={member.email}
301
+ isMutating={isMutating}
302
+ onUpdate={onUpdateInvitationRole}
303
+ assignedRoles={member.roles}
304
+ roles={roles}
305
+ userId={member.id}
306
+ />
307
+ ) : member.roles.length > 0 ? (
285
308
  member.roles.map((role) => (
286
309
  <Badge
287
310
  key={`${member.id ?? member.email}-${role.id}`}
@@ -297,7 +320,9 @@ export function WorkspaceAccessMemberRow({
297
320
  </Badge>
298
321
  ) : (
299
322
  <Badge variant="outline" className="h-5 px-1.5 text-xs">
300
- {labels.noRolesLabel}
323
+ {member.pending
324
+ ? t('ws-members.no_role_assigned')
325
+ : labels.noRolesLabel}
301
326
  </Badge>
302
327
  )}
303
328
  </div>
@@ -11,6 +11,7 @@ type Props = {
11
11
  canEditProfiles: boolean;
12
12
  canManageMembers: boolean;
13
13
  canManageRoles: boolean;
14
+ canUpdateInvitationRoles: boolean;
14
15
  defaultAdminEnabled: boolean;
15
16
  isLoading: boolean;
16
17
  isMutating: boolean;
@@ -23,6 +24,11 @@ type Props = {
23
24
  userId?: null | string;
24
25
  }) => void;
25
26
  onRemoveRole: (payload: { roleId: string; userId: string }) => void;
27
+ onUpdateInvitationRole: (payload: {
28
+ email?: null | string;
29
+ roleIds: string[];
30
+ userId?: null | string;
31
+ }) => void;
26
32
  roles: Array<Pick<WorkspaceAccessRole, 'id' | 'name'>>;
27
33
  searchTerm: string;
28
34
  status: string;
@@ -32,6 +38,7 @@ export function WorkspaceAccessMembers({
32
38
  canEditProfiles,
33
39
  canManageMembers,
34
40
  canManageRoles,
41
+ canUpdateInvitationRoles,
35
42
  defaultAdminEnabled,
36
43
  isLoading,
37
44
  isMutating,
@@ -41,6 +48,7 @@ export function WorkspaceAccessMembers({
41
48
  onEditMemberProfile,
42
49
  onRemoveMember,
43
50
  onRemoveRole,
51
+ onUpdateInvitationRole,
44
52
  roles,
45
53
  searchTerm,
46
54
  status,
@@ -83,6 +91,7 @@ export function WorkspaceAccessMembers({
83
91
  canEditProfiles={canEditProfiles}
84
92
  canManageMembers={canManageMembers}
85
93
  canManageRoles={canManageRoles}
94
+ canUpdateInvitationRoles={canUpdateInvitationRoles}
86
95
  defaultAdminEnabled={defaultAdminEnabled}
87
96
  isMutating={isMutating}
88
97
  labels={labels}
@@ -91,6 +100,7 @@ export function WorkspaceAccessMembers({
91
100
  onEditMemberProfile={onEditMemberProfile}
92
101
  onRemoveMember={onRemoveMember}
93
102
  onRemoveRole={onRemoveRole}
103
+ onUpdateInvitationRole={onUpdateInvitationRole}
94
104
  roles={roles}
95
105
  />
96
106
  ))}
@@ -37,9 +37,16 @@ import { WorkspaceAccessMembers } from './workspace-access-members';
37
37
  import { WorkspaceAccessPageHeader } from './workspace-access-page-header';
38
38
  import { WorkspaceAccessPeopleFilters } from './workspace-access-people-filters';
39
39
  import { WorkspaceAccessRoleEditorDialog } from './workspace-access-role-editor-dialog';
40
+ import { listAllWorkspaceAccessRoles } from './workspace-access-role-options';
40
41
  import { WorkspaceAccessRoles } from './workspace-access-roles';
41
42
  import { WorkspaceAccessTabsToolbar } from './workspace-access-tabs-toolbar';
42
43
 
44
+ type InvitationRoleUpdate = {
45
+ email?: null | string;
46
+ roleIds: string[];
47
+ userId?: null | string;
48
+ };
49
+
43
50
  export function WorkspaceAccessPage({
44
51
  adapter,
45
52
  disableInvite = false,
@@ -57,6 +64,7 @@ export function WorkspaceAccessPage({
57
64
  const [inviteAccessPreset, setInviteAccessPreset] = useState<
58
65
  'guest' | 'member' | 'pos_operator'
59
66
  >('member');
67
+ const [inviteRoleIds, setInviteRoleIds] = useState<string[]>([]);
60
68
  const [confirmDefaultAdminMigration, setConfirmDefaultAdminMigration] =
61
69
  useState(false);
62
70
  const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
@@ -92,6 +100,7 @@ export function WorkspaceAccessPage({
92
100
  const workspaceId = context.workspaceId;
93
101
  const canManageMembers = context.canManageMembers;
94
102
  const canManageRoles = context.canManageRoles;
103
+ const canAssignInviteRoles = canManageRoles && mode === 'workspace';
95
104
  const canInvite = canManageMembers && !disableInvite;
96
105
  const permissionUser = useMemo(
97
106
  () =>
@@ -145,6 +154,12 @@ export function WorkspaceAccessPage({
145
154
  queryKey: ['workspace-access', workspaceId, 'roles', search],
146
155
  staleTime: 30_000,
147
156
  });
157
+ const inviteRolesQuery = useQuery({
158
+ enabled: inviteDialogOpen && canAssignInviteRoles,
159
+ queryFn: () => listAllWorkspaceAccessRoles(adapter, workspaceId),
160
+ queryKey: ['workspace-access', workspaceId, 'invite-roles'],
161
+ staleTime: 30_000,
162
+ });
148
163
  const memberDefaultQuery = useQuery({
149
164
  enabled: canManageRoles,
150
165
  queryFn: () => adapter.getDefaultRole(workspaceId, 'MEMBER'),
@@ -166,6 +181,9 @@ export function WorkspaceAccessPage({
166
181
  queryClient.invalidateQueries({
167
182
  queryKey: ['workspace-access', workspaceId, 'roles'],
168
183
  }),
184
+ queryClient.invalidateQueries({
185
+ queryKey: ['workspace-access', workspaceId, 'invite-roles'],
186
+ }),
169
187
  queryClient.invalidateQueries({
170
188
  queryKey: ['workspace-access', workspaceId, 'defaults'],
171
189
  }),
@@ -179,12 +197,17 @@ export function WorkspaceAccessPage({
179
197
  confirmDefaultAdminMigration,
180
198
  emails: parseInviteEmails(inviteEmails),
181
199
  memberType: inviteAccessPreset === 'guest' ? 'GUEST' : 'MEMBER',
200
+ roleIds:
201
+ canAssignInviteRoles && inviteAccessPreset === 'member'
202
+ ? inviteRoleIds
203
+ : [],
182
204
  }),
183
205
  onError: (error) =>
184
206
  toast.error(error instanceof Error ? error.message : t('common.error')),
185
207
  onSuccess: async (result) => {
186
208
  setInviteEmails('');
187
209
  setInviteAccessPreset('member');
210
+ setInviteRoleIds([]);
188
211
  setConfirmDefaultAdminMigration(false);
189
212
  setInviteDialogOpen(false);
190
213
  toast.success(result.message ?? t('ws-members.invitation-sent'));
@@ -247,6 +270,66 @@ export function WorkspaceAccessPage({
247
270
  await invalidateAccessData();
248
271
  },
249
272
  });
273
+ const invitationRoleMutation = useMutation({
274
+ mutationFn: (payload: InvitationRoleUpdate) => {
275
+ if (!adapter.updateInvitationRole) {
276
+ throw new Error(t('common.error'));
277
+ }
278
+ return adapter.updateInvitationRole(workspaceId, payload);
279
+ },
280
+ onMutate: async (payload) => {
281
+ const queryKey = ['workspace-access', workspaceId, 'members'] as const;
282
+ await queryClient.cancelQueries({ queryKey });
283
+ const previousMembers =
284
+ queryClient.getQueryData<InternalApiEnhancedWorkspaceMember[]>(
285
+ queryKey
286
+ );
287
+ const roleById = new Map(
288
+ (inviteRolesQuery.data?.data ?? rolesQuery.data?.data ?? []).map(
289
+ (role) => [role.id, role]
290
+ )
291
+ );
292
+ const nextRoles = payload.roleIds.flatMap((roleId) => {
293
+ const role = roleById.get(roleId);
294
+ return role ? [{ id: role.id, name: role.name, permissions: [] }] : [];
295
+ });
296
+ const normalizedEmail = payload.email?.trim().toLowerCase() ?? null;
297
+
298
+ queryClient.setQueryData<InternalApiEnhancedWorkspaceMember[]>(
299
+ queryKey,
300
+ (members = []) =>
301
+ members.map((member) => {
302
+ const matchesUser = Boolean(
303
+ payload.userId && member.id === payload.userId
304
+ );
305
+ const matchesEmail = Boolean(
306
+ normalizedEmail &&
307
+ member.email?.trim().toLowerCase() === normalizedEmail
308
+ );
309
+ if (!member.pending || (!matchesUser && !matchesEmail)) {
310
+ return member;
311
+ }
312
+
313
+ return {
314
+ ...member,
315
+ roles: nextRoles,
316
+ };
317
+ })
318
+ );
319
+
320
+ return { previousMembers, queryKey };
321
+ },
322
+ onError: (error, _payload, context) => {
323
+ if (context?.previousMembers) {
324
+ queryClient.setQueryData(context.queryKey, context.previousMembers);
325
+ }
326
+ toast.error(error instanceof Error ? error.message : t('common.error'));
327
+ },
328
+ onSettled: invalidateAccessData,
329
+ onSuccess: () => {
330
+ toast.success(t('ws-members.invitation_role_updated'));
331
+ },
332
+ });
250
333
  const deleteRoleMutation = useMutation({
251
334
  mutationFn: (roleId: string) => adapter.deleteRole(workspaceId, roleId),
252
335
  onError: (error) =>
@@ -394,11 +477,15 @@ export function WorkspaceAccessPage({
394
477
  canEditProfiles={Boolean(adapter.updateMemberProfile)}
395
478
  canManageMembers={canManageMembers}
396
479
  canManageRoles={canManageRoles}
480
+ canUpdateInvitationRoles={
481
+ canManageRoles && Boolean(adapter.updateInvitationRole)
482
+ }
397
483
  defaultAdminEnabled={defaultAdminEnabled}
398
484
  isLoading={membersQuery.isPending}
399
485
  isMutating={
400
486
  removeMemberMutation.isPending ||
401
487
  roleMembershipMutation.isPending ||
488
+ invitationRoleMutation.isPending ||
402
489
  updateMemberProfileMutation.isPending
403
490
  }
404
491
  labels={labels}
@@ -411,7 +498,10 @@ export function WorkspaceAccessPage({
411
498
  onRemoveRole={(payload) =>
412
499
  roleMembershipMutation.mutate({ ...payload, action: 'remove' })
413
500
  }
414
- roles={roles}
501
+ onUpdateInvitationRole={(payload) =>
502
+ invitationRoleMutation.mutate(payload)
503
+ }
504
+ roles={inviteRolesQuery.data?.data ?? roles}
415
505
  searchTerm={search}
416
506
  status={status}
417
507
  />
@@ -475,23 +565,32 @@ export function WorkspaceAccessPage({
475
565
 
476
566
  <WorkspaceAccessInviteDialog
477
567
  accessPreset={inviteAccessPreset}
478
- canManageRoles={canManageRoles}
568
+ canManageRoles={canAssignInviteRoles}
479
569
  confirmDefaultAdminMigration={confirmDefaultAdminMigration}
480
570
  defaultAdminEnabled={defaultAdminEnabled}
481
571
  emails={inviteEmails}
482
572
  isSubmitting={inviteMutation.isPending}
483
573
  joinedMemberCount={joinedCount}
574
+ noRoleLabel={t('ws-members.no_role_assigned')}
484
575
  onAccessPresetChange={(value) => {
485
576
  setInviteAccessPreset(value);
486
- if (value !== 'pos_operator') {
577
+ if (value === 'member') {
487
578
  setConfirmDefaultAdminMigration(false);
579
+ } else {
580
+ setInviteRoleIds([]);
581
+ if (value === 'guest') {
582
+ setConfirmDefaultAdminMigration(false);
583
+ }
488
584
  }
489
585
  }}
490
586
  onConfirmDefaultAdminMigrationChange={setConfirmDefaultAdminMigration}
491
587
  onEmailsChange={setInviteEmails}
492
588
  onOpenChange={setInviteDialogOpen}
589
+ onRoleIdsChange={setInviteRoleIds}
493
590
  onSubmit={() => inviteMutation.mutate()}
494
591
  open={inviteDialogOpen}
592
+ roleIds={inviteRoleIds}
593
+ roles={inviteRolesQuery.data?.data ?? []}
495
594
  />
496
595
 
497
596
  {profileMember ? (
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import type { WorkspaceAccessAdapter, WorkspaceAccessRole } from './types';
3
+ import { listAllWorkspaceAccessRoles } from './workspace-access-role-options';
4
+
5
+ function createRoles(start: number, count: number): WorkspaceAccessRole[] {
6
+ return Array.from({ length: count }, (_, index) => ({
7
+ id: `role-${start + index}`,
8
+ name: `Role ${start + index}`,
9
+ permissions: [],
10
+ }));
11
+ }
12
+
13
+ describe('listAllWorkspaceAccessRoles', () => {
14
+ it('paginates beyond the first 100 assignable roles', async () => {
15
+ const listRoleOptions = vi
16
+ .fn()
17
+ .mockResolvedValueOnce({ count: 125, data: createRoles(0, 100) })
18
+ .mockResolvedValueOnce({ count: 125, data: createRoles(100, 25) });
19
+
20
+ const result = await listAllWorkspaceAccessRoles(
21
+ { listRoleOptions } as unknown as WorkspaceAccessAdapter,
22
+ 'workspace-1'
23
+ );
24
+
25
+ expect(result.data).toHaveLength(125);
26
+ expect(listRoleOptions).toHaveBeenNthCalledWith(1, 'workspace-1', {
27
+ page: '1',
28
+ pageSize: '100',
29
+ });
30
+ expect(listRoleOptions).toHaveBeenNthCalledWith(2, 'workspace-1', {
31
+ page: '2',
32
+ pageSize: '100',
33
+ });
34
+ });
35
+ });
@@ -0,0 +1,21 @@
1
+ import type { WorkspaceAccessAdapter, WorkspaceAccessRole } from './types';
2
+
3
+ export async function listAllWorkspaceAccessRoles(
4
+ adapter: WorkspaceAccessAdapter,
5
+ workspaceId: string
6
+ ) {
7
+ const pageSize = 100;
8
+ const roles: WorkspaceAccessRole[] = [];
9
+
10
+ for (let page = 1; ; page += 1) {
11
+ const result = await adapter.listRoleOptions(workspaceId, {
12
+ page: String(page),
13
+ pageSize: String(pageSize),
14
+ });
15
+ roles.push(...result.data.map((role) => ({ ...role, permissions: [] })));
16
+
17
+ if (roles.length >= result.count || result.data.length < pageSize) {
18
+ return { count: result.count, data: roles };
19
+ }
20
+ }
21
+ }
@@ -31,8 +31,9 @@ export function useWorkspaceInvitations({
31
31
  queryKey: ['workspace-invitations', ...(cacheScope ? [cacheScope] : [])],
32
32
  queryFn: async () => (await listWorkspaceInvitations()).invitations,
33
33
  enabled,
34
+ gcTime: 30 * 60_000,
34
35
  retry: 1,
35
- staleTime: 30_000,
36
+ staleTime: 5 * 60_000,
36
37
  });
37
38
  const invitations = query.data ?? [];
38
39
  const mutation = useMutation({