@tuturuuu/ui 0.19.0 → 0.20.1

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 (55) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/biome.json +1 -1
  3. package/package.json +62 -50
  4. package/src/components/ui/badge.tsx +2 -0
  5. package/src/components/ui/button.tsx +2 -0
  6. package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +48 -4
  7. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +22 -0
  8. package/src/components/ui/custom/animated-slot-text.tsx +20 -0
  9. package/src/components/ui/custom/common-footer.tsx +262 -237
  10. package/src/components/ui/custom/language-dropdown-item.tsx +3 -10
  11. package/src/components/ui/custom/language-toggle.test.tsx +30 -0
  12. package/src/components/ui/custom/language-toggle.tsx +11 -10
  13. package/src/components/ui/custom/locale-preference.ts +32 -0
  14. package/src/components/ui/custom/settings/appearance-settings.tsx +5 -13
  15. package/src/components/ui/custom/settings-dialog-shell.tsx +40 -9
  16. package/src/components/ui/custom/structure.tsx +12 -0
  17. package/src/components/ui/custom/system-language-dropdown-item.tsx +3 -6
  18. package/src/components/ui/custom/workspace-access/adapters.test.ts +10 -1
  19. package/src/components/ui/custom/workspace-access/adapters.ts +36 -4
  20. package/src/components/ui/custom/workspace-access/member-filter-utils.test.ts +14 -0
  21. package/src/components/ui/custom/workspace-access/member-filter-utils.ts +8 -0
  22. package/src/components/ui/custom/workspace-access/types.ts +20 -0
  23. package/src/components/ui/custom/workspace-access/workspace-access-context.test.tsx +111 -0
  24. package/src/components/ui/custom/workspace-access/workspace-access-default-role-card.tsx +15 -5
  25. package/src/components/ui/custom/workspace-access/workspace-access-invite-dialog.tsx +155 -48
  26. package/src/components/ui/custom/workspace-access/workspace-access-member-profile-dialog.tsx +92 -0
  27. package/src/components/ui/custom/workspace-access/workspace-access-member-row.tsx +171 -76
  28. package/src/components/ui/custom/workspace-access/workspace-access-members.tsx +11 -2
  29. package/src/components/ui/custom/workspace-access/workspace-access-page-header.tsx +11 -11
  30. package/src/components/ui/custom/workspace-access/workspace-access-page.tsx +180 -22
  31. package/src/components/ui/custom/workspace-access/workspace-access-people-filters.tsx +29 -15
  32. package/src/components/ui/custom/workspace-access/workspace-access-permission-checklist.tsx +56 -10
  33. package/src/components/ui/custom/workspace-access/workspace-access-permission-preview.test.ts +33 -0
  34. package/src/components/ui/custom/workspace-access/workspace-access-permission-preview.tsx +24 -1
  35. package/src/components/ui/custom/workspace-access/workspace-access-responsive.test.ts +64 -0
  36. package/src/components/ui/custom/workspace-access/workspace-access-role-editor-dialog.tsx +28 -16
  37. package/src/components/ui/custom/workspace-access/workspace-access-roles.tsx +35 -11
  38. package/src/components/ui/custom/workspace-access/workspace-access-tabs-toolbar.tsx +23 -11
  39. package/src/components/ui/custom/workspace-select-helpers.ts +5 -3
  40. package/src/components/ui/custom/workspace-select.tsx +8 -2
  41. package/src/components/ui/finance/shared/charts/monthly-total-chart-client.tsx +1 -1
  42. package/src/components/ui/finance/shared/charts/monthly-total-chart.tsx +1 -1
  43. package/src/components/ui/storefront/cart-summary.tsx +12 -2
  44. package/src/components/ui/storefront/storefront-surface.test.tsx +21 -0
  45. package/src/components/ui/storefront/storefront-surface.tsx +6 -0
  46. package/src/components/ui/text-editor/__tests__/collaboration-binding.test.tsx +161 -0
  47. package/src/components/ui/text-editor/editor.tsx +267 -235
  48. package/src/globals.css +166 -0
  49. package/src/hooks/__tests__/use-workspace-identity-mutation.test.tsx +181 -0
  50. package/src/hooks/use-workspace-identity-mutation.ts +136 -0
  51. package/vendor/xlsx/LICENSE +201 -0
  52. package/vendor/xlsx/types/index.d.ts +1065 -0
  53. package/vendor/xlsx/xlsx.js +28103 -0
  54. package/vendor/xlsx/xlsx.mjs +28228 -0
  55. package/vendor/xlsx-0.20.3.tgz +0 -0
@@ -3,7 +3,8 @@
3
3
  import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
4
4
  import { TriangleAlert } from '@tuturuuu/icons';
5
5
  import type { SupabaseUser } from '@tuturuuu/supabase/next/user';
6
- import type { WorkspaceDefaultPermissionMemberType } from '@tuturuuu/types';
6
+ import type { InternalApiEnhancedWorkspaceMember } from '@tuturuuu/types';
7
+ import { Button } from '@tuturuuu/ui/button';
7
8
  import { Skeleton } from '@tuturuuu/ui/skeleton';
8
9
  import { toast } from '@tuturuuu/ui/sonner';
9
10
  import { Tabs, TabsContent } from '@tuturuuu/ui/tabs';
@@ -31,6 +32,7 @@ import type {
31
32
  import { WorkspaceAccessDefaultRoleCard } from './workspace-access-default-role-card';
32
33
  import { WorkspaceAccessInviteDialog } from './workspace-access-invite-dialog';
33
34
  import { getWorkspaceAccessLabels } from './workspace-access-labels';
35
+ import { WorkspaceAccessMemberProfileDialog } from './workspace-access-member-profile-dialog';
34
36
  import { WorkspaceAccessMembers } from './workspace-access-members';
35
37
  import { WorkspaceAccessPageHeader } from './workspace-access-page-header';
36
38
  import { WorkspaceAccessPeopleFilters } from './workspace-access-people-filters';
@@ -44,6 +46,7 @@ export function WorkspaceAccessPage({
44
46
  initialContext,
45
47
  initialTab = 'people',
46
48
  mode = 'workspace',
49
+ showHeader = true,
47
50
  }: WorkspaceAccessPageProps) {
48
51
  const t = useTranslations() as (key: string) => string;
49
52
  const queryClient = useQueryClient();
@@ -51,8 +54,11 @@ export function WorkspaceAccessPage({
51
54
  const [status, setStatus] = useState<WorkspaceAccessMemberStatus>('all');
52
55
  const [search, setSearch] = useState('');
53
56
  const [inviteEmails, setInviteEmails] = useState('');
54
- const [inviteMemberType, setInviteMemberType] =
55
- useState<WorkspaceDefaultPermissionMemberType>('MEMBER');
57
+ const [inviteAccessPreset, setInviteAccessPreset] = useState<
58
+ 'guest' | 'member' | 'pos_operator'
59
+ >('member');
60
+ const [confirmDefaultAdminMigration, setConfirmDefaultAdminMigration] =
61
+ useState(false);
56
62
  const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
57
63
  const [filters, setFilters] = useState<MemberFiltersState>({
58
64
  permissionIds: [],
@@ -60,9 +66,12 @@ export function WorkspaceAccessPage({
60
66
  });
61
67
  const [roleEditorState, setRoleEditorState] =
62
68
  useState<WorkspaceAccessRoleEditorState | null>(null);
69
+ const [profileMember, setProfileMember] =
70
+ useState<InternalApiEnhancedWorkspaceMember | null>(null);
63
71
 
72
+ const fetchesOwnContext = Boolean(adapter.getContext);
64
73
  const contextQuery = useQuery({
65
- enabled: Boolean(adapter.getContext),
74
+ enabled: fetchesOwnContext,
66
75
  initialData: initialContext,
67
76
  queryFn: async () =>
68
77
  adapter.getContext
@@ -71,7 +80,15 @@ export function WorkspaceAccessPage({
71
80
  queryKey: ['workspace-access', initialContext.workspaceId, 'context'],
72
81
  staleTime: 30_000,
73
82
  });
74
- const context = contextQuery.data ?? initialContext;
83
+ // Only trust the query when the adapter actually fetches a context. Otherwise
84
+ // `initialData` seeds a *disabled* query, which then serves that first snapshot
85
+ // forever — and callers resolve their permissions asynchronously, so the first
86
+ // snapshot is the one taken before they arrived. That is what left admins
87
+ // looking at a permanently disabled Roles tab: the permission turned true a
88
+ // moment later and this component never saw it.
89
+ const context = fetchesOwnContext
90
+ ? (contextQuery.data ?? initialContext)
91
+ : initialContext;
75
92
  const workspaceId = context.workspaceId;
76
93
  const canManageMembers = context.canManageMembers;
77
94
  const canManageRoles = context.canManageRoles;
@@ -158,13 +175,17 @@ export function WorkspaceAccessPage({
158
175
  const inviteMutation = useMutation({
159
176
  mutationFn: () =>
160
177
  adapter.inviteMembers(workspaceId, {
178
+ accessPreset: inviteAccessPreset,
179
+ confirmDefaultAdminMigration,
161
180
  emails: parseInviteEmails(inviteEmails),
162
- memberType: inviteMemberType,
181
+ memberType: inviteAccessPreset === 'guest' ? 'GUEST' : 'MEMBER',
163
182
  }),
164
183
  onError: (error) =>
165
184
  toast.error(error instanceof Error ? error.message : t('common.error')),
166
185
  onSuccess: async (result) => {
167
186
  setInviteEmails('');
187
+ setInviteAccessPreset('member');
188
+ setConfirmDefaultAdminMigration(false);
168
189
  setInviteDialogOpen(false);
169
190
  toast.success(result.message ?? t('ws-members.invitation-sent'));
170
191
  await invalidateAccessData();
@@ -180,6 +201,36 @@ export function WorkspaceAccessPage({
180
201
  await invalidateAccessData();
181
202
  },
182
203
  });
204
+ const updateMemberProfileMutation = useMutation({
205
+ mutationFn: ({
206
+ displayName,
207
+ member,
208
+ }: {
209
+ displayName: null | string;
210
+ member: InternalApiEnhancedWorkspaceMember;
211
+ }) => {
212
+ if (!adapter.updateMemberProfile) {
213
+ throw new Error(t('common.error'));
214
+ }
215
+
216
+ return adapter.updateMemberProfile(workspaceId, {
217
+ displayName,
218
+ email: member.id ? null : member.email,
219
+ userId: member.id,
220
+ });
221
+ },
222
+ onError: (error) =>
223
+ toast.error(
224
+ error instanceof Error
225
+ ? error.message
226
+ : t('ws-members.profile_display_name_update_error')
227
+ ),
228
+ onSuccess: async () => {
229
+ setProfileMember(null);
230
+ toast.success(t('ws-members.profile_display_name_updated'));
231
+ await invalidateAccessData();
232
+ },
233
+ });
183
234
  const roleMembershipMutation = useMutation({
184
235
  mutationFn: (payload: {
185
236
  action: 'add' | 'remove';
@@ -205,6 +256,46 @@ export function WorkspaceAccessPage({
205
256
  await invalidateAccessData();
206
257
  },
207
258
  });
259
+ const hardenDefaultAdminMutation = useMutation({
260
+ mutationFn: async () => {
261
+ if (!adapter.hardenDefaultAdmin) {
262
+ throw new Error(t('common.error'));
263
+ }
264
+
265
+ const existingAdminRole = (rolesQuery.data?.data ?? []).find((role) =>
266
+ role.permissions.some(
267
+ (permission) => permission.id === 'admin' && permission.enabled
268
+ )
269
+ );
270
+ const alreadyAssigned = new Set(
271
+ existingAdminRole?.members?.map((member) => member.id) ?? []
272
+ );
273
+ const memberIds = (membersQuery.data ?? [])
274
+ .filter(
275
+ (member) =>
276
+ !member.pending &&
277
+ member.id &&
278
+ member.workspace_member_type !== 'GUEST' &&
279
+ !alreadyAssigned.has(member.id)
280
+ )
281
+ .map((member) => member.id as string);
282
+
283
+ return adapter.hardenDefaultAdmin(workspaceId, {
284
+ memberIds,
285
+ permissions: [{ enabled: true, id: 'admin' }],
286
+ roleId: existingAdminRole?.id,
287
+ roleName: t('ws-roles.admin'),
288
+ });
289
+ },
290
+ onError: async (error) => {
291
+ toast.error(error instanceof Error ? error.message : t('common.error'));
292
+ await invalidateAccessData();
293
+ },
294
+ onSuccess: async () => {
295
+ toast.success(t('common.saved'));
296
+ await invalidateAccessData();
297
+ },
298
+ });
208
299
 
209
300
  const roles = rolesQuery.data?.data ?? [];
210
301
  const sortedMembers = sortMembers(membersQuery.data ?? []);
@@ -215,6 +306,11 @@ export function WorkspaceAccessPage({
215
306
  });
216
307
  const joinedCount = sortedMembers.filter((member) => !member.pending).length;
217
308
  const invitedCount = sortedMembers.filter((member) => member.pending).length;
309
+ const defaultAdminEnabled = Boolean(
310
+ memberDefaultQuery.data?.permissions.some(
311
+ (permission) => permission.id === 'admin' && permission.enabled
312
+ )
313
+ );
218
314
  const filterOptions = getMemberFilterOptions(
219
315
  sortedMembers,
220
316
  permissionDefinitions
@@ -222,7 +318,7 @@ export function WorkspaceAccessPage({
222
318
 
223
319
  if (contextQuery.isPending) {
224
320
  return (
225
- <div className="space-y-6">
321
+ <div className="space-y-4 sm:space-y-6">
226
322
  <Skeleton className="h-32 rounded-xl" />
227
323
  <Skeleton className="h-10 rounded-xl" />
228
324
  <Skeleton className="h-96 rounded-xl" />
@@ -231,14 +327,43 @@ export function WorkspaceAccessPage({
231
327
  }
232
328
 
233
329
  return (
234
- <div className="space-y-6">
235
- <WorkspaceAccessPageHeader
236
- context={context}
237
- invitedCount={invitedCount}
238
- joinedCount={joinedCount}
239
- mode={mode}
240
- totalCount={sortedMembers.length}
241
- />
330
+ <div className="space-y-4 sm:space-y-6">
331
+ {showHeader ? (
332
+ <WorkspaceAccessPageHeader
333
+ context={context}
334
+ invitedCount={invitedCount}
335
+ joinedCount={joinedCount}
336
+ mode={mode}
337
+ totalCount={sortedMembers.length}
338
+ />
339
+ ) : null}
340
+
341
+ {mode === 'workspace' &&
342
+ canManageRoles &&
343
+ defaultAdminEnabled &&
344
+ adapter.hardenDefaultAdmin ? (
345
+ <div className="flex flex-col gap-3 rounded-xl border border-dynamic-orange/25 bg-dynamic-orange/5 p-4 sm:flex-row sm:items-center sm:justify-between">
346
+ <div className="flex min-w-0 items-start gap-3">
347
+ <TriangleAlert className="mt-0.5 size-4 shrink-0 text-dynamic-orange" />
348
+ <div>
349
+ <div className="font-medium text-sm">
350
+ {t('ws-roles.admin_enabled_title')}
351
+ </div>
352
+ <p className="mt-1 text-muted-foreground text-sm">
353
+ {t('ws-members.pos_operator_admin_migration_note')}
354
+ </p>
355
+ </div>
356
+ </div>
357
+ <Button
358
+ size="sm"
359
+ className="shrink-0"
360
+ disabled={hardenDefaultAdminMutation.isPending}
361
+ onClick={() => hardenDefaultAdminMutation.mutate()}
362
+ >
363
+ {t('ws-roles.create')}: {t('ws-roles.admin')}
364
+ </Button>
365
+ </div>
366
+ ) : null}
242
367
 
243
368
  <Tabs
244
369
  value={activeTab}
@@ -255,7 +380,7 @@ export function WorkspaceAccessPage({
255
380
  search={search}
256
381
  />
257
382
 
258
- <TabsContent value="people" className="mt-6 space-y-4">
383
+ <TabsContent value="people" className="mt-4 space-y-3 sm:mt-6">
259
384
  <WorkspaceAccessPeopleFilters
260
385
  filterOptions={filterOptions}
261
386
  filters={filters}
@@ -266,17 +391,22 @@ export function WorkspaceAccessPage({
266
391
  />
267
392
 
268
393
  <WorkspaceAccessMembers
394
+ canEditProfiles={Boolean(adapter.updateMemberProfile)}
269
395
  canManageMembers={canManageMembers}
270
396
  canManageRoles={canManageRoles}
397
+ defaultAdminEnabled={defaultAdminEnabled}
271
398
  isLoading={membersQuery.isPending}
272
399
  isMutating={
273
- removeMemberMutation.isPending || roleMembershipMutation.isPending
400
+ removeMemberMutation.isPending ||
401
+ roleMembershipMutation.isPending ||
402
+ updateMemberProfileMutation.isPending
274
403
  }
275
404
  labels={labels}
276
405
  members={visibleMembers}
277
406
  onAssignRole={(payload) =>
278
407
  roleMembershipMutation.mutate({ ...payload, action: 'add' })
279
408
  }
409
+ onEditMemberProfile={setProfileMember}
280
410
  onRemoveMember={(payload) => removeMemberMutation.mutate(payload)}
281
411
  onRemoveRole={(payload) =>
282
412
  roleMembershipMutation.mutate({ ...payload, action: 'remove' })
@@ -287,7 +417,7 @@ export function WorkspaceAccessPage({
287
417
  />
288
418
  </TabsContent>
289
419
 
290
- <TabsContent value="roles" className="mt-6">
420
+ <TabsContent value="roles" className="mt-4 sm:mt-6">
291
421
  <WorkspaceAccessRoles
292
422
  canManageRoles={canManageRoles}
293
423
  isLoading={rolesQuery.isPending}
@@ -306,7 +436,7 @@ export function WorkspaceAccessPage({
306
436
  />
307
437
  </TabsContent>
308
438
 
309
- <TabsContent value="defaults-member" className="mt-6">
439
+ <TabsContent value="defaults-member" className="mt-4 sm:mt-6">
310
440
  <WorkspaceAccessDefaultRoleCard
311
441
  canManageRoles={canManageRoles}
312
442
  isLoading={memberDefaultQuery.isPending}
@@ -324,7 +454,7 @@ export function WorkspaceAccessPage({
324
454
  />
325
455
  </TabsContent>
326
456
 
327
- <TabsContent value="defaults-guest" className="mt-6">
457
+ <TabsContent value="defaults-guest" className="mt-4 sm:mt-6">
328
458
  <WorkspaceAccessDefaultRoleCard
329
459
  canManageRoles={canManageRoles}
330
460
  isLoading={guestDefaultQuery.isPending}
@@ -344,16 +474,44 @@ export function WorkspaceAccessPage({
344
474
  </Tabs>
345
475
 
346
476
  <WorkspaceAccessInviteDialog
477
+ accessPreset={inviteAccessPreset}
478
+ canManageRoles={canManageRoles}
479
+ confirmDefaultAdminMigration={confirmDefaultAdminMigration}
480
+ defaultAdminEnabled={defaultAdminEnabled}
347
481
  emails={inviteEmails}
348
482
  isSubmitting={inviteMutation.isPending}
349
- memberType={inviteMemberType}
483
+ joinedMemberCount={joinedCount}
484
+ onAccessPresetChange={(value) => {
485
+ setInviteAccessPreset(value);
486
+ if (value !== 'pos_operator') {
487
+ setConfirmDefaultAdminMigration(false);
488
+ }
489
+ }}
490
+ onConfirmDefaultAdminMigrationChange={setConfirmDefaultAdminMigration}
350
491
  onEmailsChange={setInviteEmails}
351
- onMemberTypeChange={setInviteMemberType}
352
492
  onOpenChange={setInviteDialogOpen}
353
493
  onSubmit={() => inviteMutation.mutate()}
354
494
  open={inviteDialogOpen}
355
495
  />
356
496
 
497
+ {profileMember ? (
498
+ <WorkspaceAccessMemberProfileDialog
499
+ key={`${profileMember.id ?? profileMember.email}-profile`}
500
+ isSubmitting={updateMemberProfileMutation.isPending}
501
+ member={profileMember}
502
+ onOpenChange={(open) => {
503
+ if (!open) setProfileMember(null);
504
+ }}
505
+ onSubmit={(displayName) =>
506
+ updateMemberProfileMutation.mutate({
507
+ displayName,
508
+ member: profileMember,
509
+ })
510
+ }
511
+ open
512
+ />
513
+ ) : null}
514
+
357
515
  {roleEditorState ? (
358
516
  <WorkspaceAccessRoleEditorDialog
359
517
  key={
@@ -52,38 +52,47 @@ export function WorkspaceAccessPeopleFilters({
52
52
  filters.permissionIds.length + filters.roleIds.length;
53
53
 
54
54
  return (
55
- <div className="flex flex-col gap-3 rounded-xl border border-border bg-foreground/[0.02] p-3 sm:flex-row sm:items-center sm:justify-between">
56
- <div className="flex flex-wrap items-center gap-2">
57
- <div className="mr-1 flex items-center gap-2 text-muted-foreground text-sm">
55
+ <div className="grid gap-2 rounded-xl border border-border bg-foreground/[0.02] p-2.5 sm:p-3">
56
+ <div className="grid min-w-0 gap-2 sm:grid-cols-[auto_1fr] sm:items-center">
57
+ <div className="hidden items-center gap-2 text-muted-foreground text-sm sm:flex">
58
58
  <ListFilter className="h-4 w-4" />
59
59
  <span className="font-medium">{t('ws-members.filters')}</span>
60
60
  </div>
61
61
 
62
62
  <Tabs
63
+ className="w-full sm:w-auto"
63
64
  value={status}
64
65
  onValueChange={(value) =>
65
66
  onStatusChange(value as WorkspaceAccessMemberStatus)
66
67
  }
67
68
  >
68
- <TabsList className="h-8">
69
- <TabsTrigger value="all">{t('ws-members.all')}</TabsTrigger>
70
- <TabsTrigger value="joined">{t('ws-members.joined')}</TabsTrigger>
71
- <TabsTrigger value="invited">{t('ws-members.invited')}</TabsTrigger>
69
+ <TabsList className="grid h-9 w-full grid-cols-3 p-1 sm:w-auto">
70
+ <TabsTrigger className="h-full min-h-0" value="all">
71
+ {t('ws-members.all')}
72
+ </TabsTrigger>
73
+ <TabsTrigger className="h-full min-h-0" value="joined">
74
+ {t('ws-members.joined')}
75
+ </TabsTrigger>
76
+ <TabsTrigger className="h-full min-h-0" value="invited">
77
+ {t('ws-members.invited')}
78
+ </TabsTrigger>
72
79
  </TabsList>
73
80
  </Tabs>
74
81
  </div>
75
82
 
76
- <div className="flex flex-wrap items-center gap-2">
83
+ <div className="flex min-w-0 items-center justify-end gap-2">
77
84
  <DropdownMenu>
78
85
  <DropdownMenuTrigger asChild>
79
86
  <Button
80
87
  variant="outline"
81
88
  size="sm"
82
- className="h-8 gap-1.5 border-dashed bg-background/60"
89
+ className="h-8 min-w-0 flex-1 gap-1.5 border-dashed bg-background/60 px-2 sm:flex-none sm:px-3"
83
90
  disabled={filterOptions.roles.length === 0}
84
91
  >
85
92
  <ShieldCheck className="h-4 w-4 text-dynamic-purple" />
86
- {labels.filterByRole}
93
+ <span className="truncate sm:max-w-36">
94
+ {labels.filterByRole}
95
+ </span>
87
96
  {filters.roleIds.length > 0 ? (
88
97
  <>
89
98
  <Separator className="mx-1 h-4" orientation="vertical" />
@@ -132,11 +141,13 @@ export function WorkspaceAccessPeopleFilters({
132
141
  <Button
133
142
  variant="outline"
134
143
  size="sm"
135
- className="h-8 gap-1.5 border-dashed bg-background/60"
144
+ className="h-8 min-w-0 flex-1 gap-1.5 border-dashed bg-background/60 px-2 sm:flex-none sm:px-3"
136
145
  disabled={filterOptions.permissions.length === 0}
137
146
  >
138
147
  <KeyRound className="h-4 w-4 text-dynamic-blue" />
139
- {labels.filterByPermission}
148
+ <span className="truncate sm:max-w-44">
149
+ {labels.filterByPermission}
150
+ </span>
140
151
  {filters.permissionIds.length > 0 ? (
141
152
  <>
142
153
  <Separator className="mx-1 h-4" orientation="vertical" />
@@ -181,16 +192,19 @@ export function WorkspaceAccessPeopleFilters({
181
192
  <Button
182
193
  variant="ghost"
183
194
  size="sm"
184
- className="h-8 gap-1.5 px-2 text-muted-foreground hover:text-foreground"
195
+ className="size-8 shrink-0 px-0 text-muted-foreground hover:text-foreground"
185
196
  onClick={() => onFiltersChange({ permissionIds: [], roleIds: [] })}
197
+ aria-label={labels.clearFiltersAction}
186
198
  >
187
199
  <X className="h-3.5 w-3.5" />
188
- {labels.clearFiltersAction}
189
200
  </Button>
190
201
  ) : null}
191
202
 
192
203
  {activeFilterCount > 0 ? (
193
- <Badge variant="outline" className="rounded-full">
204
+ <Badge
205
+ variant="outline"
206
+ className="hidden rounded-full sm:inline-flex"
207
+ >
194
208
  {activeFilterCount}
195
209
  </Badge>
196
210
  ) : null}
@@ -3,6 +3,7 @@ import { Checkbox } from '@tuturuuu/ui/checkbox';
3
3
  import { ScrollArea } from '@tuturuuu/ui/scroll-area';
4
4
  import { cn } from '@tuturuuu/utils/format';
5
5
  import type { permissionGroups } from '@tuturuuu/utils/permissions';
6
+ import { useTranslations } from 'next-intl';
6
7
 
7
8
  type PermissionGroups = ReturnType<typeof permissionGroups>;
8
9
 
@@ -17,11 +18,55 @@ export function WorkspaceAccessPermissionChecklist({
17
18
  onSelectedPermissionsChange,
18
19
  selectedPermissions,
19
20
  }: Props) {
21
+ const t = useTranslations() as (key: string) => string;
22
+ const adminPermission = groups
23
+ .flatMap((group) => group.permissions)
24
+ .find((permission) => permission.id === 'admin');
25
+ const regularGroups = groups
26
+ .map((group) => ({
27
+ ...group,
28
+ permissions: group.permissions.filter(
29
+ (permission) => permission.id !== 'admin'
30
+ ),
31
+ }))
32
+ .filter((group) => group.permissions.length > 0);
33
+ const adminEnabled = selectedPermissions.has('admin');
34
+
20
35
  return (
21
- <ScrollArea className="h-[52vh] rounded-lg border">
22
- <div className="space-y-4 p-4">
23
- {groups.map((group) => (
24
- <section key={group.id} className="rounded-lg border p-4">
36
+ <ScrollArea className="h-auto rounded-lg border sm:h-[52vh]">
37
+ <div className="space-y-3 p-3 sm:space-y-4 sm:p-4">
38
+ {adminPermission ? (
39
+ <section className="rounded-lg border border-dynamic-green/30 bg-dynamic-green/5 p-3 sm:p-4">
40
+ <label className="flex items-start gap-3">
41
+ <Checkbox
42
+ checked={adminEnabled}
43
+ onCheckedChange={(checked) => {
44
+ const next = new Set(selectedPermissions);
45
+ if (checked) next.add('admin');
46
+ else next.delete('admin');
47
+ onSelectedPermissionsChange(next);
48
+ }}
49
+ />
50
+ <div className="min-w-0 flex-1">
51
+ <div className="flex items-center gap-2 font-semibold">
52
+ <ShieldCheck className="size-4 text-dynamic-green" />
53
+ {adminPermission.title}
54
+ </div>
55
+ <p className="mt-1 text-muted-foreground text-sm">
56
+ {adminPermission.description}
57
+ </p>
58
+ {adminEnabled ? (
59
+ <p className="mt-2 rounded-md bg-dynamic-green/10 px-2.5 py-2 text-dynamic-green text-sm">
60
+ {t('ws-roles.admin_enabled_description')}
61
+ </p>
62
+ ) : null}
63
+ </div>
64
+ </label>
65
+ </section>
66
+ ) : null}
67
+
68
+ {regularGroups.map((group) => (
69
+ <section key={group.id} className="rounded-lg border p-3 sm:p-4">
25
70
  <div className="flex items-start gap-3">
26
71
  <span className="text-muted-foreground">{group.icon}</span>
27
72
  <div>
@@ -33,17 +78,16 @@ export function WorkspaceAccessPermissionChecklist({
33
78
  ) : null}
34
79
  </div>
35
80
  </div>
36
- <div className="mt-4 grid gap-2">
81
+ <div className="mt-3 grid gap-2 sm:mt-4">
37
82
  {group.permissions.map((permission) => {
38
83
  const checked = selectedPermissions.has(permission.id);
39
- const adminLocked =
40
- selectedPermissions.has('admin') && permission.id !== 'admin';
84
+ const adminLocked = adminEnabled;
41
85
 
42
86
  return (
43
87
  <label
44
88
  key={permission.id}
45
89
  className={cn(
46
- 'flex items-start gap-3 rounded-lg border p-3',
90
+ 'flex items-start gap-3 rounded-lg border p-2.5 sm:p-3',
47
91
  checked || adminLocked
48
92
  ? 'border-dynamic-green/30 bg-dynamic-green/10'
49
93
  : 'bg-background'
@@ -67,8 +111,10 @@ export function WorkspaceAccessPermissionChecklist({
67
111
  {permission.icon}
68
112
  </span>
69
113
  <span>{permission.title}</span>
70
- {permission.id === 'admin' ? (
71
- <ShieldCheck className="h-4 w-4 text-dynamic-green" />
114
+ {adminLocked ? (
115
+ <span className="ml-auto text-dynamic-green text-xs">
116
+ {t('ws-roles.granted_via_admin')}
117
+ </span>
72
118
  ) : null}
73
119
  </div>
74
120
  <p className="mt-1 text-muted-foreground text-sm">
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { enabledPermissionCount } from './workspace-access-permission-preview';
3
+
4
+ describe('enabledPermissionCount', () => {
5
+ it('treats administrator access as granting the full permission catalog', () => {
6
+ expect(
7
+ enabledPermissionCount(
8
+ {
9
+ id: 'admin-role',
10
+ name: 'Administrator',
11
+ permissions: [{ enabled: true, id: 'admin' }],
12
+ },
13
+ 42
14
+ )
15
+ ).toBe(42);
16
+ });
17
+
18
+ it('counts explicit permissions for non-administrator roles', () => {
19
+ expect(
20
+ enabledPermissionCount(
21
+ {
22
+ id: 'operator-role',
23
+ name: 'Operator',
24
+ permissions: [
25
+ { enabled: true, id: 'initiate_pos_checkout' },
26
+ { enabled: false, id: 'update_inventory' },
27
+ ],
28
+ },
29
+ 42
30
+ )
31
+ ).toBe(1);
32
+ });
33
+ });
@@ -1,7 +1,19 @@
1
1
  import { Badge } from '@tuturuuu/ui/badge';
2
2
  import type { WorkspaceAccessRole } from './types';
3
3
 
4
- export function enabledPermissionCount(role?: WorkspaceAccessRole | null) {
4
+ export function enabledPermissionCount(
5
+ role?: WorkspaceAccessRole | null,
6
+ permissionCount?: number
7
+ ) {
8
+ if (
9
+ permissionCount !== undefined &&
10
+ role?.permissions.some(
11
+ (permission) => permission.id === 'admin' && permission.enabled
12
+ )
13
+ ) {
14
+ return permissionCount;
15
+ }
16
+
5
17
  return (
6
18
  role?.permissions.filter((permission) => permission.enabled).length ?? 0
7
19
  );
@@ -18,6 +30,9 @@ export function WorkspaceAccessPermissionPreview({
18
30
  }) {
19
31
  const enabledPermissions =
20
32
  role?.permissions.filter((permission) => permission.enabled) ?? [];
33
+ const adminEnabled = enabledPermissions.some(
34
+ (permission) => permission.id === 'admin'
35
+ );
21
36
 
22
37
  if (enabledPermissions.length === 0) {
23
38
  return (
@@ -27,6 +42,14 @@ export function WorkspaceAccessPermissionPreview({
27
42
  );
28
43
  }
29
44
 
45
+ if (adminEnabled) {
46
+ return (
47
+ <Badge className="rounded-full border-dynamic-green/30 bg-dynamic-green/10 text-dynamic-green">
48
+ {permissionTitles.get('admin') ?? 'Administrator'}
49
+ </Badge>
50
+ );
51
+ }
52
+
30
53
  return enabledPermissions.slice(0, 5).map((permission) => (
31
54
  <Badge key={permission.id} variant="secondary" className="rounded-full">
32
55
  {permissionTitles.get(permission.id) ?? permission.id}