@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.
- package/CHANGELOG.md +23 -0
- package/biome.json +1 -1
- package/package.json +48 -48
- package/src/components/ui/custom/combobox.tsx +4 -0
- package/src/components/ui/custom/workspace-access/adapters.test.ts +89 -1
- package/src/components/ui/custom/workspace-access/adapters.ts +67 -2
- package/src/components/ui/custom/workspace-access/types.ts +13 -0
- package/src/components/ui/custom/workspace-access/workspace-access-invitation-role-menu.test.tsx +79 -0
- package/src/components/ui/custom/workspace-access/workspace-access-invitation-role-menu.tsx +125 -0
- package/src/components/ui/custom/workspace-access/workspace-access-invite-access-picker.tsx +108 -0
- package/src/components/ui/custom/workspace-access/workspace-access-invite-dialog.test.tsx +134 -0
- package/src/components/ui/custom/workspace-access/workspace-access-invite-dialog.tsx +94 -113
- package/src/components/ui/custom/workspace-access/workspace-access-invite-pos-panel.tsx +75 -0
- package/src/components/ui/custom/workspace-access/workspace-access-invite-role-picker.tsx +151 -0
- package/src/components/ui/custom/workspace-access/workspace-access-labels.ts +1 -1
- package/src/components/ui/custom/workspace-access/workspace-access-member-row.tsx +27 -2
- package/src/components/ui/custom/workspace-access/workspace-access-members.tsx +10 -0
- package/src/components/ui/custom/workspace-access/workspace-access-page.tsx +102 -3
- package/src/components/ui/custom/workspace-access/workspace-access-role-options.test.ts +35 -0
- package/src/components/ui/custom/workspace-access/workspace-access-role-options.ts +21 -0
- package/src/components/ui/custom/workspace-select-invitations.tsx +2 -1
- package/src/hooks/__tests__/use-notifications-subscription.test.tsx +34 -1
- package/src/hooks/use-board-actions.test.ts +23 -0
- package/src/hooks/use-board-actions.ts +48 -35
- package/src/hooks/use-calendar-sync.tsx +12 -12
- 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.
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
|
|
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={
|
|
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
|
|
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:
|
|
36
|
+
staleTime: 5 * 60_000,
|
|
36
37
|
});
|
|
37
38
|
const invitations = query.data ?? [];
|
|
38
39
|
const mutation = useMutation({
|