@tuturuuu/ui 0.19.1 → 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 (50) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/biome.json +1 -1
  3. package/package.json +56 -48
  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
@@ -0,0 +1,32 @@
1
+ import {
2
+ getSharedAndHostOnlyCookieDeleteOptions,
3
+ getTuturuuuBrowserSharedCookieOptions,
4
+ } from '@tuturuuu/utils/shared-cookie';
5
+ import { deleteCookie, setCookie } from 'cookies-next';
6
+
7
+ export const DEFAULT_LOCALE_COOKIE_NAME = 'NEXT_LOCALE';
8
+
9
+ const LOCALE_COOKIE_OPTIONS = {
10
+ maxAge: 365 * 24 * 60 * 60,
11
+ path: '/',
12
+ sameSite: 'lax',
13
+ } as const;
14
+
15
+ export function persistLocalePreference(
16
+ locale: string,
17
+ cookieName = DEFAULT_LOCALE_COOKIE_NAME
18
+ ) {
19
+ setCookie(
20
+ cookieName,
21
+ locale,
22
+ getTuturuuuBrowserSharedCookieOptions(LOCALE_COOKIE_OPTIONS)
23
+ );
24
+ }
25
+
26
+ export function clearLocalePreference(cookieName = DEFAULT_LOCALE_COOKIE_NAME) {
27
+ for (const options of getSharedAndHostOnlyCookieDeleteOptions(
28
+ LOCALE_COOKIE_OPTIONS
29
+ )) {
30
+ deleteCookie(cookieName, options);
31
+ }
32
+ }
@@ -15,6 +15,7 @@ import { useRouter } from 'next/navigation';
15
15
  import { useLocale, useTranslations } from 'next-intl';
16
16
  import { useTheme } from 'next-themes';
17
17
  import { useTransition } from 'react';
18
+ import { persistLocalePreference } from '../locale-preference';
18
19
  import { VersionBadgeSetting } from '../version-badge';
19
20
 
20
21
  /**
@@ -33,20 +34,11 @@ export function AppearanceSettings({
33
34
  const router = useRouter();
34
35
  const [isPending, startTransition] = useTransition();
35
36
 
36
- const handleLocaleChange = async (newLocale: string) => {
37
- const res = await fetch('/api/v1/infrastructure/languages', {
38
- method: 'POST',
39
- body: JSON.stringify({ locale: newLocale }),
40
- headers: {
41
- 'Content-Type': 'application/json',
42
- },
37
+ const handleLocaleChange = (newLocale: string) => {
38
+ persistLocalePreference(newLocale);
39
+ startTransition(() => {
40
+ router.refresh();
43
41
  });
44
-
45
- if (res.ok) {
46
- startTransition(() => {
47
- router.refresh();
48
- });
49
- }
50
42
  };
51
43
 
52
44
  return (
@@ -60,8 +60,9 @@ import {
60
60
  } from '@tuturuuu/ui/sidebar';
61
61
  import { cn } from '@tuturuuu/utils/format';
62
62
  import { useTranslations } from 'next-intl';
63
+ import { parseAsString, useQueryState } from 'nuqs';
63
64
  import type { ComponentType, KeyboardEvent, ReactNode } from 'react';
64
- import { useCallback, useMemo, useRef, useState } from 'react';
65
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
65
66
  import type { createSettingsSearchEngine } from './settings-dialog-search';
66
67
  import { loadSettingsSearchEngine } from './settings-dialog-search-loader';
67
68
 
@@ -99,6 +100,8 @@ export interface SettingsDialogShellProps {
99
100
  expandAllAccordions?: boolean;
100
101
  /** Enable dialog-scoped keyboard shortcuts for search and tab navigation */
101
102
  keyboardNavigation?: boolean;
103
+ /** Optional context control that replaces the active group breadcrumb */
104
+ activeGroupBreadcrumb?: ReactNode;
102
105
  /** Content to render in the main area */
103
106
  children: ReactNode;
104
107
  }
@@ -134,6 +137,7 @@ export function SettingsDialogShell({
134
137
  primaryGroupLabels,
135
138
  expandAllAccordions = true,
136
139
  keyboardNavigation = false,
140
+ activeGroupBreadcrumb,
137
141
  children,
138
142
  }: SettingsDialogShellProps) {
139
143
  const t = useTranslations();
@@ -145,6 +149,14 @@ export function SettingsDialogShell({
145
149
  const [searchEngineFactory, setSearchEngineFactory] =
146
150
  useState<SettingsSearchEngineFactory | null>(null);
147
151
  const [mobileNavOpen, setMobileNavOpen] = useState(false);
152
+ const [persistedTab, setPersistedTab] = useQueryState(
153
+ 'settingsTab',
154
+ parseAsString.withOptions({
155
+ history: 'replace',
156
+ shallow: true,
157
+ scroll: false,
158
+ })
159
+ );
148
160
 
149
161
  const searchEngine = useMemo<SettingsSearchEngine | null>(
150
162
  () => searchEngineFactory?.(navItems) ?? null,
@@ -165,6 +177,23 @@ export function SettingsDialogShell({
165
177
  allNavItems.find((item) => !item.disabled) ||
166
178
  allNavItems[0];
167
179
  const showContentHeader = !activeItem?.hideContentHeader;
180
+ const persistedItem = allNavItems.find(
181
+ (item) => !item.disabled && item.name === persistedTab
182
+ );
183
+
184
+ useEffect(() => {
185
+ if (persistedItem && persistedItem.name !== activeTab) {
186
+ onActiveTabChange(persistedItem.name);
187
+ }
188
+ }, [activeTab, onActiveTabChange, persistedItem]);
189
+
190
+ const selectActiveTab = useCallback(
191
+ (tab: string) => {
192
+ onActiveTabChange(tab);
193
+ void setPersistedTab(tab);
194
+ },
195
+ [onActiveTabChange, setPersistedTab]
196
+ );
168
197
 
169
198
  const ensureSearchEngine = useCallback(() => {
170
199
  if (searchEngineFactory || searchEngineLoadRef.current) return;
@@ -224,9 +253,9 @@ export function SettingsDialogShell({
224
253
  const changeActiveItem = useCallback(
225
254
  (targetIndex: number) => {
226
255
  const targetItem = filteredEnabledItems[targetIndex];
227
- if (targetItem) onActiveTabChange(targetItem.name);
256
+ if (targetItem) selectActiveTab(targetItem.name);
228
257
  },
229
- [filteredEnabledItems, onActiveTabChange]
258
+ [filteredEnabledItems, selectActiveTab]
230
259
  );
231
260
 
232
261
  const moveActiveItem = useCallback(
@@ -375,7 +404,7 @@ export function SettingsDialogShell({
375
404
  isActive={activeTab === item.name}
376
405
  onClick={() => {
377
406
  if (!item.disabled) {
378
- onActiveTabChange(item.name);
407
+ selectActiveTab(item.name);
379
408
  }
380
409
  }}
381
410
  className={cn(
@@ -454,7 +483,7 @@ export function SettingsDialogShell({
454
483
  value={`${group.label} ${item.label} ${item.description || ''} ${item.keywords?.join(' ') || ''} ${item.aliases?.join(' ') || ''} ${item.searchLabels?.join(' ') || ''}`}
455
484
  onSelect={() => {
456
485
  if (item.disabled) return;
457
- onActiveTabChange(item.name);
486
+ selectActiveTab(item.name);
458
487
  setMobileNavOpen(false);
459
488
  }}
460
489
  className={cn(
@@ -491,10 +520,12 @@ export function SettingsDialogShell({
491
520
  <BreadcrumbSeparator />
492
521
  {activeGroup && (
493
522
  <>
494
- <BreadcrumbItem>
495
- <BreadcrumbPage className="text-muted-foreground">
496
- {activeGroup.label}
497
- </BreadcrumbPage>
523
+ <BreadcrumbItem data-testid="settings-active-group-breadcrumb">
524
+ {activeGroupBreadcrumb ?? (
525
+ <BreadcrumbPage className="text-muted-foreground">
526
+ {activeGroup.label}
527
+ </BreadcrumbPage>
528
+ )}
498
529
  </BreadcrumbItem>
499
530
  <BreadcrumbSeparator />
500
531
  </>
@@ -17,6 +17,7 @@ interface StructureProps {
17
17
  sidebarContent?: ReactNode;
18
18
  actions?: ReactNode;
19
19
  userPopover?: ReactNode;
20
+ sidebarUtility?: ReactNode;
20
21
  feedbackButton?: ReactNode;
21
22
  children: ReactNode;
22
23
  onMouseEnter?: () => void;
@@ -39,6 +40,7 @@ export function Structure({
39
40
  sidebarContent,
40
41
  actions,
41
42
  userPopover,
43
+ sidebarUtility,
42
44
  feedbackButton,
43
45
  children,
44
46
  onMouseEnter,
@@ -163,6 +165,16 @@ export function Structure({
163
165
  <div className="scrollbar-none flex flex-1 flex-col gap-y-1 overflow-y-auto overflow-x-hidden overscroll-contain">
164
166
  {sidebarContent}
165
167
  </div>
168
+ {sidebarUtility && (
169
+ <div
170
+ className={cn(
171
+ 'flex border-foreground/10 border-t p-2',
172
+ isCollapsed ? 'justify-center' : ''
173
+ )}
174
+ >
175
+ {sidebarUtility}
176
+ </div>
177
+ )}
166
178
  {feedbackButton && (
167
179
  <div
168
180
  className={cn(
@@ -4,6 +4,7 @@ import { Check, Monitor } from '@tuturuuu/icons';
4
4
  import { useRouter } from 'next/navigation';
5
5
  import { useTranslations } from 'next-intl';
6
6
  import { DropdownMenuItem } from '../dropdown-menu';
7
+ import { clearLocalePreference } from './locale-preference';
7
8
 
8
9
  interface Props {
9
10
  selected?: boolean;
@@ -21,12 +22,8 @@ export function SystemLanguageDropdownItem({ selected, onResetLocale }: Props) {
21
22
  return;
22
23
  }
23
24
 
24
- // Default: API-based locale reset (web pattern)
25
- const res = await fetch('/api/v1/infrastructure/languages', {
26
- method: 'DELETE',
27
- });
28
-
29
- if (res.ok) router.refresh();
25
+ clearLocalePreference();
26
+ router.refresh();
30
27
  };
31
28
 
32
29
  return (
@@ -1,5 +1,8 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { normalizeWorkspaceAccessRole } from './adapters';
2
+ import {
3
+ createStandardWorkspaceAccessAdapter,
4
+ normalizeWorkspaceAccessRole,
5
+ } from './adapters';
3
6
 
4
7
  describe('workspace access adapters', () => {
5
8
  it('normalizes role permissions into the shared access role shape', () => {
@@ -28,4 +31,10 @@ describe('workspace access adapters', () => {
28
31
  ws_id: 'ws_123',
29
32
  });
30
33
  });
34
+
35
+ it('exposes linked workspace profile updates to every standard satellite app', () => {
36
+ expect(
37
+ createStandardWorkspaceAccessAdapter().updateMemberProfile
38
+ ).toBeTypeOf('function');
39
+ });
31
40
  });
@@ -25,6 +25,7 @@ import {
25
25
  inviteWorkspaceMember,
26
26
  listEnhancedWorkspaceMembers,
27
27
  removeWorkspaceMember,
28
+ updateWorkspaceMemberProfile,
28
29
  } from '@tuturuuu/internal-api/workspaces';
29
30
  import type { WorkspaceRole } from '@tuturuuu/types';
30
31
  import type {
@@ -64,12 +65,18 @@ async function inviteStandardWorkspaceMembers(
64
65
  payload: WorkspaceAccessInvitePayload
65
66
  ) {
66
67
  const results = await Promise.allSettled(
67
- payload.emails.map((email) =>
68
- inviteWorkspaceMember(workspaceId, {
68
+ payload.emails.map((email) => {
69
+ const invitePayload: Parameters<typeof inviteWorkspaceMember>[1] & {
70
+ accessPreset?: 'guest' | 'member' | 'pos_operator';
71
+ confirmDefaultAdminMigration?: boolean;
72
+ } = {
73
+ accessPreset: payload.accessPreset,
74
+ confirmDefaultAdminMigration: payload.confirmDefaultAdminMigration,
69
75
  email,
70
76
  memberType: payload.memberType,
71
- })
72
- )
77
+ };
78
+ return inviteWorkspaceMember(workspaceId, invitePayload);
79
+ })
73
80
  );
74
81
  const successCount = results.filter(
75
82
  (result) => result.status === 'fulfilled'
@@ -100,6 +107,30 @@ export function createStandardWorkspaceAccessAdapter(): WorkspaceAccessAdapter {
100
107
  normalizeWorkspaceAccessRole(
101
108
  await getWorkspaceDefaultPermissions(workspaceId, memberType)
102
109
  ),
110
+ hardenDefaultAdmin: async (
111
+ workspaceId,
112
+ { memberIds, permissions, roleId, roleName }
113
+ ) => {
114
+ const role = roleId
115
+ ? { id: roleId, message: 'existing' }
116
+ : await createWorkspaceRole(workspaceId, {
117
+ name: roleName,
118
+ permissions,
119
+ } as WorkspaceRole);
120
+
121
+ if (memberIds.length > 0) {
122
+ await addRoleMembers(workspaceId, role.id, memberIds);
123
+ }
124
+
125
+ await updateWorkspaceDefaultPermissions(workspaceId, 'MEMBER', {
126
+ permissions: permissions.map((permission) => ({
127
+ ...permission,
128
+ enabled: false,
129
+ })) as WorkspaceRole['permissions'],
130
+ });
131
+
132
+ return role;
133
+ },
103
134
  inviteMembers: inviteStandardWorkspaceMembers,
104
135
  listMembers: listEnhancedWorkspaceMembers,
105
136
  listRoles: async (workspaceId, query) => {
@@ -116,6 +147,7 @@ export function createStandardWorkspaceAccessAdapter(): WorkspaceAccessAdapter {
116
147
  },
117
148
  removeMember: removeWorkspaceMember,
118
149
  removeRoleMember,
150
+ updateMemberProfile: updateWorkspaceMemberProfile,
119
151
  updateDefaultRole: (workspaceId, memberType, payload) =>
120
152
  updateWorkspaceDefaultPermissions(workspaceId, memberType, {
121
153
  permissions: payload.permissions as WorkspaceRole['permissions'],
@@ -5,6 +5,7 @@ import {
5
5
  getEffectiveMemberPermissionIds,
6
6
  getMemberFilterOptions,
7
7
  parseInviteEmails,
8
+ shouldShowProtectedMemberStatus,
8
9
  } from './member-filter-utils';
9
10
 
10
11
  function member(
@@ -172,4 +173,17 @@ describe('workspace access member filter utilities', () => {
172
173
 
173
174
  expect([...getEffectiveMemberPermissionIds(disabled)]).toEqual([]);
174
175
  });
176
+
177
+ it('only labels the actual creator as a protected member', () => {
178
+ expect(
179
+ shouldShowProtectedMemberStatus({
180
+ isCreator: true,
181
+ })
182
+ ).toBe(true);
183
+ expect(
184
+ shouldShowProtectedMemberStatus({
185
+ isCreator: false,
186
+ })
187
+ ).toBe(false);
188
+ });
175
189
  });
@@ -60,6 +60,14 @@ export function getAvailableRolesForMember(
60
60
  );
61
61
  }
62
62
 
63
+ export function shouldShowProtectedMemberStatus({
64
+ isCreator,
65
+ }: {
66
+ isCreator: boolean;
67
+ }) {
68
+ return isCreator;
69
+ }
70
+
63
71
  export function sortMembers(
64
72
  members: InternalApiEnhancedWorkspaceMember[]
65
73
  ): InternalApiEnhancedWorkspaceMember[] {
@@ -52,6 +52,8 @@ export type WorkspaceAccessRolePayload = {
52
52
  };
53
53
 
54
54
  export type WorkspaceAccessInvitePayload = {
55
+ accessPreset: 'guest' | 'member' | 'pos_operator';
56
+ confirmDefaultAdminMigration?: boolean;
55
57
  emails: string[];
56
58
  memberType: WorkspaceDefaultPermissionMemberType;
57
59
  };
@@ -72,6 +74,15 @@ export type WorkspaceAccessAdapter = {
72
74
  workspaceId: string,
73
75
  memberType: WorkspaceDefaultPermissionMemberType
74
76
  ) => Promise<WorkspaceAccessRole>;
77
+ hardenDefaultAdmin?: (
78
+ workspaceId: string,
79
+ payload: {
80
+ memberIds: string[];
81
+ permissions: WorkspaceAccessRolePermission[];
82
+ roleId?: string;
83
+ roleName: string;
84
+ }
85
+ ) => Promise<unknown>;
75
86
  inviteMembers: (
76
87
  workspaceId: string,
77
88
  payload: WorkspaceAccessInvitePayload
@@ -93,6 +104,14 @@ export type WorkspaceAccessAdapter = {
93
104
  roleId: string,
94
105
  userId: string
95
106
  ) => Promise<unknown>;
107
+ updateMemberProfile?: (
108
+ workspaceId: string,
109
+ payload: {
110
+ displayName: string | null;
111
+ email?: null | string;
112
+ userId?: null | string;
113
+ }
114
+ ) => Promise<unknown>;
96
115
  updateDefaultRole: (
97
116
  workspaceId: string,
98
117
  memberType: WorkspaceDefaultPermissionMemberType,
@@ -111,6 +130,7 @@ export type WorkspaceAccessPageProps = {
111
130
  initialContext: WorkspaceAccessContext;
112
131
  initialTab?: WorkspaceAccessTab;
113
132
  mode?: WorkspaceAccessMode;
133
+ showHeader?: boolean;
114
134
  };
115
135
 
116
136
  export type WorkspaceAccessRoleEditorState =
@@ -0,0 +1,111 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
+ import { render, screen, waitFor } from '@testing-library/react';
3
+ import type { ReactNode } from 'react';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+ import type { WorkspaceAccessAdapter } from './types';
6
+ import { WorkspaceAccessPage } from './workspace-access-page';
7
+
8
+ vi.mock('next-intl', () => ({
9
+ useTranslations: () => (key: string) => key,
10
+ }));
11
+
12
+ const WS_ID = 'ws-1';
13
+
14
+ function createAdapter(
15
+ overrides: Partial<WorkspaceAccessAdapter> = {}
16
+ ): WorkspaceAccessAdapter {
17
+ return {
18
+ addRoleMembers: vi.fn(),
19
+ createRole: vi.fn(),
20
+ deleteRole: vi.fn(),
21
+ getDefaultRole: vi.fn().mockResolvedValue({
22
+ id: 'default',
23
+ name: 'default',
24
+ permissions: [],
25
+ }),
26
+ hardenDefaultAdmin: vi.fn(),
27
+ inviteMembers: vi.fn(),
28
+ listMembers: vi.fn().mockResolvedValue([]),
29
+ listRoles: vi.fn().mockResolvedValue({ count: 0, data: [] }),
30
+ removeMember: vi.fn(),
31
+ removeRoleMember: vi.fn(),
32
+ updateDefaultRole: vi.fn(),
33
+ updateMemberProfile: vi.fn(),
34
+ updateRole: vi.fn(),
35
+ ...overrides,
36
+ } as unknown as WorkspaceAccessAdapter;
37
+ }
38
+
39
+ function renderPage(canManageRoles: boolean, adapter: WorkspaceAccessAdapter) {
40
+ const queryClient = new QueryClient({
41
+ defaultOptions: { queries: { retry: false } },
42
+ });
43
+
44
+ const ui = (props: { canManageRoles: boolean }) => (
45
+ <QueryClientProvider client={queryClient}>
46
+ <WorkspaceAccessPage
47
+ adapter={adapter}
48
+ initialContext={{
49
+ canManageMembers: true,
50
+ canManageRoles: props.canManageRoles,
51
+ currentUserEmail: 'admin@example.com',
52
+ workspaceId: WS_ID,
53
+ }}
54
+ initialTab="people"
55
+ />
56
+ </QueryClientProvider>
57
+ );
58
+
59
+ const view = render(ui({ canManageRoles }) as ReactNode);
60
+
61
+ return {
62
+ ...view,
63
+ setCanManageRoles: (next: boolean) =>
64
+ view.rerender(ui({ canManageRoles: next }) as ReactNode),
65
+ };
66
+ }
67
+
68
+ // The tabs are Radix triggers (role="tab"); roles is the second of four, and its
69
+ // label comes from the adapter's label set rather than a fixed string.
70
+ function rolesTab() {
71
+ return screen.getAllByRole('tab')[1];
72
+ }
73
+
74
+ describe('WorkspaceAccessPage permission context', () => {
75
+ // Regression: callers resolve `manage_workspace_roles` asynchronously, so the
76
+ // first render says false. The context used to be seeded into a disabled query
77
+ // and served from there forever, which left admins with a Roles tab that never
78
+ // enabled — no amount of waiting helped, only a full reload.
79
+ it('follows the caller once the roles permission resolves', async () => {
80
+ const { setCanManageRoles } = renderPage(false, createAdapter());
81
+
82
+ await waitFor(() => expect(rolesTab()).toBeDefined());
83
+ expect(rolesTab()).toBeDisabled();
84
+
85
+ setCanManageRoles(true);
86
+
87
+ await waitFor(() => expect(rolesTab()).not.toBeDisabled());
88
+ });
89
+
90
+ // An adapter that fetches its own context (the external-project/CMS one) keeps
91
+ // reading through the query, seeded by the server-resolved context it is handed.
92
+ it('leaves a context-fetching adapter on its own query', async () => {
93
+ const getContext = vi.fn().mockResolvedValue({
94
+ canManageMembers: true,
95
+ canManageRoles: true,
96
+ currentUserEmail: 'admin@example.com',
97
+ workspaceId: WS_ID,
98
+ });
99
+
100
+ const { setCanManageRoles } = renderPage(
101
+ true,
102
+ createAdapter({ getContext })
103
+ );
104
+
105
+ await waitFor(() => expect(rolesTab()).not.toBeDisabled());
106
+
107
+ // Prop churn does not yank control away from the adapter's own context.
108
+ setCanManageRoles(false);
109
+ await waitFor(() => expect(rolesTab()).not.toBeDisabled());
110
+ });
111
+ });
@@ -30,7 +30,10 @@ export function WorkspaceAccessDefaultRoleCard({
30
30
  }) {
31
31
  const t = useTranslations() as (key: string) => string;
32
32
  const isGuest = memberType === 'GUEST';
33
- const enabled = enabledPermissionCount(role);
33
+ const enabled = enabledPermissionCount(role, permissionCount);
34
+ const isAdministrator = role?.permissions.some(
35
+ (permission) => permission.id === 'admin' && permission.enabled
36
+ );
34
37
  const pct =
35
38
  permissionCount > 0 ? Math.round((enabled / permissionCount) * 100) : 0;
36
39
  const accent = isGuest
@@ -39,8 +42,8 @@ export function WorkspaceAccessDefaultRoleCard({
39
42
  const barColor = isGuest ? 'bg-dynamic-blue' : 'bg-dynamic-green';
40
43
 
41
44
  return (
42
- <div className="rounded-xl border border-border bg-background p-5">
43
- <div className="flex flex-wrap items-start justify-between gap-3">
45
+ <div className="rounded-xl border border-border bg-background p-4 sm:p-5">
46
+ <div className="flex items-start justify-between gap-3">
44
47
  <div className="flex min-w-0 gap-3">
45
48
  <div
46
49
  className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border ${accent}`}
@@ -68,10 +71,12 @@ export function WorkspaceAccessDefaultRoleCard({
68
71
  <Button
69
72
  variant="outline"
70
73
  size="sm"
74
+ className="size-9 shrink-0 px-0 sm:w-auto sm:px-3"
71
75
  onClick={() => onEdit(memberType)}
72
76
  >
73
- <Pencil className="mr-2 h-4 w-4" />
74
- {t('common.edit')}
77
+ <Pencil className="size-4 sm:mr-2" />
78
+ <span className="hidden sm:inline">{t('common.edit')}</span>
79
+ <span className="sr-only sm:hidden">{t('common.edit')}</span>
75
80
  </Button>
76
81
  ) : null}
77
82
  </div>
@@ -109,6 +114,11 @@ export function WorkspaceAccessDefaultRoleCard({
109
114
  role={role}
110
115
  />
111
116
  </div>
117
+ {isAdministrator ? (
118
+ <p className="text-dynamic-green text-sm">
119
+ {t('ws-members.admin_has_all_permissions')}
120
+ </p>
121
+ ) : null}
112
122
  </>
113
123
  )}
114
124
  </div>