@tuturuuu/ui 0.26.1 → 0.28.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 (43) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/biome.json +1 -1
  3. package/package.json +49 -48
  4. package/src/components/ui/button.tsx +12 -8
  5. package/src/components/ui/color-picker.test.tsx +23 -0
  6. package/src/components/ui/color-picker.tsx +1 -1
  7. package/src/components/ui/custom/combobox.tsx +4 -0
  8. package/src/components/ui/custom/notification-popover-client.tsx +6 -19
  9. package/src/components/ui/custom/notification-popover-trigger.test.tsx +37 -0
  10. package/src/components/ui/custom/notification-popover-trigger.tsx +44 -0
  11. package/src/components/ui/custom/workspace-access/adapters.test.ts +89 -1
  12. package/src/components/ui/custom/workspace-access/adapters.ts +67 -2
  13. package/src/components/ui/custom/workspace-access/types.ts +13 -0
  14. package/src/components/ui/custom/workspace-access/workspace-access-invitation-role-menu.test.tsx +79 -0
  15. package/src/components/ui/custom/workspace-access/workspace-access-invitation-role-menu.tsx +125 -0
  16. package/src/components/ui/custom/workspace-access/workspace-access-invite-access-picker.tsx +108 -0
  17. package/src/components/ui/custom/workspace-access/workspace-access-invite-dialog.test.tsx +134 -0
  18. package/src/components/ui/custom/workspace-access/workspace-access-invite-dialog.tsx +94 -113
  19. package/src/components/ui/custom/workspace-access/workspace-access-invite-pos-panel.tsx +75 -0
  20. package/src/components/ui/custom/workspace-access/workspace-access-invite-role-picker.tsx +151 -0
  21. package/src/components/ui/custom/workspace-access/workspace-access-labels.ts +1 -1
  22. package/src/components/ui/custom/workspace-access/workspace-access-member-row.tsx +27 -2
  23. package/src/components/ui/custom/workspace-access/workspace-access-members.tsx +10 -0
  24. package/src/components/ui/custom/workspace-access/workspace-access-page.tsx +102 -3
  25. package/src/components/ui/custom/workspace-access/workspace-access-role-options.test.ts +35 -0
  26. package/src/components/ui/custom/workspace-access/workspace-access-role-options.ts +21 -0
  27. package/src/components/ui/custom/workspace-select-invitations.tsx +2 -1
  28. package/src/components/ui/text-editor/__tests__/editor-classes.test.ts +36 -0
  29. package/src/components/ui/text-editor/__tests__/inline-task-conversion.test.tsx +30 -0
  30. package/src/components/ui/text-editor/__tests__/keyboard.test.ts +173 -3
  31. package/src/components/ui/text-editor/__tests__/toggle-block-extension.test.ts +251 -0
  32. package/src/components/ui/text-editor/editor-classes.ts +100 -0
  33. package/src/components/ui/text-editor/editor.tsx +11 -153
  34. package/src/components/ui/text-editor/extensions.ts +19 -0
  35. package/src/components/ui/text-editor/keyboard.ts +47 -0
  36. package/src/components/ui/text-editor/toggle-block-extension.ts +153 -0
  37. package/src/components/ui/text-editor/tool-bar.tsx +24 -73
  38. package/src/components/ui/text-editor/toolbar-config.ts +67 -0
  39. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +34 -1
  40. package/src/hooks/use-board-actions.test.ts +23 -0
  41. package/src/hooks/use-board-actions.ts +48 -35
  42. package/src/hooks/use-calendar-sync.tsx +12 -12
  43. package/src/hooks/use-notifications.ts +4 -8
@@ -1,15 +1,8 @@
1
1
  'use client';
2
2
 
3
- import {
4
- CreditCard,
5
- MailPlus,
6
- ShieldCheck,
7
- TriangleAlert,
8
- UserPlus,
9
- } from '@tuturuuu/icons';
3
+ import { MailPlus, UserPlus } from '@tuturuuu/icons';
10
4
  import { Badge } from '@tuturuuu/ui/badge';
11
5
  import { Button } from '@tuturuuu/ui/button';
12
- import { Checkbox } from '@tuturuuu/ui/checkbox';
13
6
  import {
14
7
  Dialog,
15
8
  DialogContent,
@@ -19,16 +12,12 @@ import {
19
12
  DialogTitle,
20
13
  } from '@tuturuuu/ui/dialog';
21
14
  import { Label } from '@tuturuuu/ui/label';
22
- import {
23
- Select,
24
- SelectContent,
25
- SelectItem,
26
- SelectTrigger,
27
- SelectValue,
28
- } from '@tuturuuu/ui/select';
29
15
  import { Textarea } from '@tuturuuu/ui/textarea';
30
16
  import { useTranslations } from 'next-intl';
31
17
  import { parseInviteEmails } from './member-filter-utils';
18
+ import { WorkspaceAccessInviteAccessPicker } from './workspace-access-invite-access-picker';
19
+ import { WorkspaceAccessInvitePosPanel } from './workspace-access-invite-pos-panel';
20
+ import { WorkspaceAccessInviteRolePicker } from './workspace-access-invite-role-picker';
32
21
 
33
22
  type Props = {
34
23
  accessPreset: 'guest' | 'member' | 'pos_operator';
@@ -38,12 +27,16 @@ type Props = {
38
27
  emails: string;
39
28
  isSubmitting: boolean;
40
29
  joinedMemberCount: number;
30
+ noRoleLabel: string;
41
31
  onAccessPresetChange: (value: 'guest' | 'member' | 'pos_operator') => void;
42
32
  onConfirmDefaultAdminMigrationChange: (value: boolean) => void;
43
33
  onEmailsChange: (value: string) => void;
44
34
  onOpenChange: (open: boolean) => void;
35
+ onRoleIdsChange: (roleIds: string[]) => void;
45
36
  onSubmit: () => void;
46
37
  open: boolean;
38
+ roleIds: string[];
39
+ roles: Array<{ id: string; name: string }>;
47
40
  };
48
41
 
49
42
  export function WorkspaceAccessInviteDialog({
@@ -54,19 +47,32 @@ export function WorkspaceAccessInviteDialog({
54
47
  emails,
55
48
  isSubmitting,
56
49
  joinedMemberCount,
50
+ noRoleLabel,
57
51
  onAccessPresetChange,
58
52
  onConfirmDefaultAdminMigrationChange,
59
53
  onEmailsChange,
60
54
  onOpenChange,
55
+ onRoleIdsChange,
61
56
  onSubmit,
62
57
  open,
58
+ roleIds,
59
+ roles,
63
60
  }: Props) {
64
- const t = useTranslations() as (key: string) => string;
61
+ const t = useTranslations();
65
62
  const count = parseInviteEmails(emails).length;
63
+ const selectedRoleNames = roles
64
+ .filter((role) => roleIds.includes(role.id))
65
+ .map((role) => role.name);
66
+ const accessSummary =
67
+ accessPreset === 'member'
68
+ ? selectedRoleNames.length > 0
69
+ ? `${t('ws-members.invite_access_member_tab')} · ${selectedRoleNames.join(', ')}`
70
+ : `${t('ws-members.invite_access_member_tab')} · ${t('ws-members.no_roles_assigned')}`
71
+ : t(`ws-members.invite_membership_${accessPreset}`);
66
72
 
67
73
  return (
68
74
  <Dialog open={open} onOpenChange={onOpenChange}>
69
- <DialogContent className="flex max-h-[calc(100dvh-1rem)] w-[calc(100%-1rem)] max-w-none flex-col gap-0 overflow-hidden rounded-b-none p-0 max-sm:top-auto max-sm:bottom-0 max-sm:left-0 max-sm:translate-x-0 max-sm:translate-y-0 sm:max-h-[min(88dvh,52rem)] sm:max-w-xl sm:rounded-lg">
75
+ <DialogContent className="flex max-h-[calc(100dvh-1rem)] w-[calc(100%-1rem)] max-w-none flex-col gap-0 overflow-hidden rounded-b-none p-0 max-sm:top-auto max-sm:bottom-0 max-sm:left-0 max-sm:translate-x-0 max-sm:translate-y-0 sm:max-h-[min(90dvh,54rem)] sm:max-w-4xl sm:rounded-2xl">
70
76
  <DialogHeader className="shrink-0 gap-0 border-b p-4 pr-12 text-left sm:p-6 sm:pr-12">
71
77
  <div className="flex items-start gap-3">
72
78
  <div className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-dynamic-blue/25 bg-dynamic-blue/10 text-dynamic-blue">
@@ -81,114 +87,89 @@ export function WorkspaceAccessInviteDialog({
81
87
  </div>
82
88
  </DialogHeader>
83
89
 
84
- <div className="min-h-0 flex-1 space-y-4 overflow-y-auto overscroll-contain p-4 sm:p-6">
85
- <div className="grid gap-2">
86
- <Label htmlFor="workspace-access-invite-emails">
87
- {t('ws-members.invite_member')}
88
- </Label>
89
- <Textarea
90
- id="workspace-access-invite-emails"
91
- rows={5}
92
- value={emails}
93
- onChange={(event) => onEmailsChange(event.target.value)}
94
- placeholder={'one@example.com\ntwo@example.com'}
95
- className="min-h-28 resize-y"
96
- />
97
- </div>
98
-
99
- <div className="grid gap-2">
100
- <Label htmlFor="workspace-access-invite-membership">
101
- {t('ws-members.invite_membership_label')}
102
- </Label>
103
- <Select
104
- value={accessPreset}
105
- onValueChange={(value) =>
106
- onAccessPresetChange(
107
- value as 'guest' | 'member' | 'pos_operator'
108
- )
109
- }
110
- >
111
- <SelectTrigger
112
- id="workspace-access-invite-membership"
113
- className="w-full"
114
- >
115
- <SelectValue />
116
- </SelectTrigger>
117
- <SelectContent>
118
- <SelectItem value="member">
119
- {t('ws-members.invite_membership_member')}
120
- </SelectItem>
121
- <SelectItem value="guest">
122
- {t('ws-members.invite_membership_guest')}
123
- </SelectItem>
124
- <SelectItem value="pos_operator" disabled={!canManageRoles}>
125
- {t('ws-members.invite_membership_pos_operator')}
126
- </SelectItem>
127
- </SelectContent>
128
- </Select>
129
- </div>
130
-
131
- {accessPreset === 'pos_operator' ? (
132
- <div className="space-y-3 rounded-xl border border-dynamic-blue/25 bg-dynamic-blue/5 p-3.5 sm:p-4">
133
- <div className="flex items-start gap-3">
134
- <div className="mt-0.5 rounded-lg border border-dynamic-blue/20 bg-background p-2 text-dynamic-blue">
135
- <CreditCard className="h-4 w-4" />
136
- </div>
137
- <div className="min-w-0 space-y-1">
138
- <p className="font-medium text-sm">
139
- {t('ws-members.pos_operator_title')}
140
- </p>
141
- <p className="text-muted-foreground text-sm leading-5">
142
- {t('ws-members.pos_operator_description')}
143
- </p>
90
+ <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
91
+ <div className="grid lg:grid-cols-[minmax(0,1fr)_minmax(22rem,1fr)] lg:divide-x">
92
+ <section className="space-y-4 p-4 sm:p-6">
93
+ <div className="flex items-center gap-2">
94
+ <span className="flex size-6 items-center justify-center rounded-full bg-foreground font-semibold text-background text-xs">
95
+ 1
96
+ </span>
97
+ <h3 className="font-semibold text-sm">
98
+ {t('ws-members.invite_recipients_title')}
99
+ </h3>
100
+ </div>
101
+ <div className="grid gap-2">
102
+ <Label htmlFor="workspace-access-invite-emails">
103
+ {t('ws-members.invite_emails_label')}
104
+ </Label>
105
+ <Textarea
106
+ id="workspace-access-invite-emails"
107
+ rows={7}
108
+ value={emails}
109
+ onChange={(event) => onEmailsChange(event.target.value)}
110
+ placeholder={'one@example.com\ntwo@example.com'}
111
+ className="min-h-40 resize-y rounded-xl bg-muted/20 font-mono text-sm leading-6"
112
+ />
113
+ <div className="flex items-center justify-between gap-3 text-muted-foreground text-xs">
114
+ <span>{t('ws-members.invite_emails_helper')}</span>
115
+ <Badge variant="outline" className="rounded-full">
116
+ {t('ws-members.recipients_count', { count })}
117
+ </Badge>
144
118
  </div>
145
119
  </div>
120
+ </section>
146
121
 
147
- <div className="grid gap-2 sm:grid-cols-2">
148
- {defaultAdminEnabled ? (
149
- <div className="flex items-center gap-2 rounded-lg border bg-background/70 p-3 text-sm">
150
- <ShieldCheck className="h-4 w-4 text-dynamic-green" />
151
- <span>
152
- {joinedMemberCount}{' '}
153
- {t('ws-members.pos_operator_existing_members')}
154
- </span>
155
- </div>
156
- ) : null}
157
- <div className="flex items-center gap-2 rounded-lg border bg-background/70 p-3 text-sm">
158
- <CreditCard className="h-4 w-4 text-dynamic-blue" />
159
- <span>{t('ws-members.pos_operator_only_permission')}</span>
160
- </div>
122
+ <section className="space-y-5 bg-muted/10 p-4 sm:p-6">
123
+ <div className="flex items-center gap-2">
124
+ <span className="flex size-6 items-center justify-center rounded-full bg-foreground font-semibold text-background text-xs">
125
+ 2
126
+ </span>
127
+ <h3 className="font-semibold text-sm">
128
+ {t('ws-members.invite_access_title')}
129
+ </h3>
161
130
  </div>
162
131
 
163
- {defaultAdminEnabled ? (
164
- <div className="flex items-start gap-2 rounded-lg border border-dynamic-orange/25 bg-dynamic-orange/5 p-3 text-sm">
165
- <TriangleAlert className="mt-0.5 h-4 w-4 shrink-0 text-dynamic-orange" />
166
- <p className="text-muted-foreground leading-5">
167
- {t('ws-members.pos_operator_admin_migration_note')}
168
- </p>
169
- </div>
132
+ <WorkspaceAccessInviteAccessPicker
133
+ canManageRoles={canManageRoles}
134
+ onChange={onAccessPresetChange}
135
+ value={accessPreset}
136
+ />
137
+
138
+ {canManageRoles && accessPreset === 'member' ? (
139
+ <WorkspaceAccessInviteRolePicker
140
+ noRoleLabel={noRoleLabel}
141
+ onChange={onRoleIdsChange}
142
+ roleIds={roleIds}
143
+ roles={roles}
144
+ />
170
145
  ) : null}
171
146
 
172
- <label className="flex cursor-pointer items-start gap-3 rounded-lg border bg-background p-3">
173
- <Checkbox
174
- checked={confirmDefaultAdminMigration}
175
- className="mt-0.5"
176
- onCheckedChange={(checked) =>
177
- onConfirmDefaultAdminMigrationChange(checked === true)
147
+ {accessPreset === 'pos_operator' ? (
148
+ <WorkspaceAccessInvitePosPanel
149
+ confirmDefaultAdminMigration={confirmDefaultAdminMigration}
150
+ defaultAdminEnabled={defaultAdminEnabled}
151
+ joinedMemberCount={joinedMemberCount}
152
+ onConfirmDefaultAdminMigrationChange={
153
+ onConfirmDefaultAdminMigrationChange
178
154
  }
179
155
  />
180
- <span className="text-sm leading-5">
181
- {t('ws-members.pos_operator_confirmation')}
182
- </span>
183
- </label>
184
- </div>
185
- ) : null}
156
+ ) : null}
157
+ </section>
158
+ </div>
186
159
  </div>
187
160
 
188
161
  <DialogFooter className="grid shrink-0 grid-cols-[auto_1fr] items-center gap-3 border-t bg-muted/20 p-3 sm:flex sm:p-4">
189
- <Badge variant="secondary" className="rounded-full">
190
- {count} {t('ws-members.pending_invitations').toLowerCase()}
191
- </Badge>
162
+ <div className="min-w-0">
163
+ <p className="font-medium text-sm">
164
+ {t('ws-members.recipients_count', { count })}
165
+ </p>
166
+ <p
167
+ className="truncate text-muted-foreground text-xs"
168
+ title={accessSummary}
169
+ >
170
+ {accessSummary}
171
+ </p>
172
+ </div>
192
173
  <Button
193
174
  className="min-w-0"
194
175
  disabled={
@@ -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
  ))}