@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.
- package/CHANGELOG.md +39 -0
- package/biome.json +1 -1
- package/package.json +56 -48
- package/src/components/ui/badge.tsx +2 -0
- package/src/components/ui/button.tsx +2 -0
- package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +48 -4
- package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +22 -0
- package/src/components/ui/custom/animated-slot-text.tsx +20 -0
- package/src/components/ui/custom/common-footer.tsx +262 -237
- package/src/components/ui/custom/language-dropdown-item.tsx +3 -10
- package/src/components/ui/custom/language-toggle.test.tsx +30 -0
- package/src/components/ui/custom/language-toggle.tsx +11 -10
- package/src/components/ui/custom/locale-preference.ts +32 -0
- package/src/components/ui/custom/settings/appearance-settings.tsx +5 -13
- package/src/components/ui/custom/settings-dialog-shell.tsx +40 -9
- package/src/components/ui/custom/structure.tsx +12 -0
- package/src/components/ui/custom/system-language-dropdown-item.tsx +3 -6
- package/src/components/ui/custom/workspace-access/adapters.test.ts +10 -1
- package/src/components/ui/custom/workspace-access/adapters.ts +36 -4
- package/src/components/ui/custom/workspace-access/member-filter-utils.test.ts +14 -0
- package/src/components/ui/custom/workspace-access/member-filter-utils.ts +8 -0
- package/src/components/ui/custom/workspace-access/types.ts +20 -0
- package/src/components/ui/custom/workspace-access/workspace-access-context.test.tsx +111 -0
- package/src/components/ui/custom/workspace-access/workspace-access-default-role-card.tsx +15 -5
- package/src/components/ui/custom/workspace-access/workspace-access-invite-dialog.tsx +155 -48
- package/src/components/ui/custom/workspace-access/workspace-access-member-profile-dialog.tsx +92 -0
- package/src/components/ui/custom/workspace-access/workspace-access-member-row.tsx +171 -76
- package/src/components/ui/custom/workspace-access/workspace-access-members.tsx +11 -2
- package/src/components/ui/custom/workspace-access/workspace-access-page-header.tsx +11 -11
- package/src/components/ui/custom/workspace-access/workspace-access-page.tsx +180 -22
- package/src/components/ui/custom/workspace-access/workspace-access-people-filters.tsx +29 -15
- package/src/components/ui/custom/workspace-access/workspace-access-permission-checklist.tsx +56 -10
- package/src/components/ui/custom/workspace-access/workspace-access-permission-preview.test.ts +33 -0
- package/src/components/ui/custom/workspace-access/workspace-access-permission-preview.tsx +24 -1
- package/src/components/ui/custom/workspace-access/workspace-access-responsive.test.ts +64 -0
- package/src/components/ui/custom/workspace-access/workspace-access-role-editor-dialog.tsx +28 -16
- package/src/components/ui/custom/workspace-access/workspace-access-roles.tsx +35 -11
- package/src/components/ui/custom/workspace-access/workspace-access-tabs-toolbar.tsx +23 -11
- package/src/components/ui/custom/workspace-select-helpers.ts +5 -3
- package/src/components/ui/custom/workspace-select.tsx +8 -2
- package/src/components/ui/finance/shared/charts/monthly-total-chart-client.tsx +1 -1
- package/src/components/ui/finance/shared/charts/monthly-total-chart.tsx +1 -1
- package/src/components/ui/storefront/cart-summary.tsx +12 -2
- package/src/components/ui/storefront/storefront-surface.test.tsx +21 -0
- package/src/components/ui/storefront/storefront-surface.tsx +6 -0
- package/src/components/ui/text-editor/__tests__/collaboration-binding.test.tsx +161 -0
- package/src/components/ui/text-editor/editor.tsx +267 -235
- package/src/globals.css +166 -0
- package/src/hooks/__tests__/use-workspace-identity-mutation.test.tsx +181 -0
- 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 =
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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)
|
|
256
|
+
if (targetItem) selectActiveTab(targetItem.name);
|
|
228
257
|
},
|
|
229
|
-
[filteredEnabledItems,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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="
|
|
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>
|