@shipfox/client-workspace-settings 4.0.0 → 6.0.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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +51 -0
- package/dist/components/members/form-errors.d.ts +1 -0
- package/dist/components/members/form-errors.d.ts.map +1 -1
- package/dist/components/members/form-errors.js +26 -13
- package/dist/components/members/form-errors.js.map +1 -1
- package/dist/components/members/workspace-members-section.d.ts.map +1 -1
- package/dist/components/members/workspace-members-section.js +31 -28
- package/dist/components/members/workspace-members-section.js.map +1 -1
- package/dist/core/membership.d.ts +41 -0
- package/dist/core/membership.d.ts.map +1 -0
- package/dist/core/membership.js +16 -0
- package/dist/core/membership.js.map +1 -0
- package/dist/feature.d.ts +15 -72
- package/dist/feature.d.ts.map +1 -1
- package/dist/feature.js +20 -102
- package/dist/feature.js.map +1 -1
- package/dist/hooks/api/create-invitation.d.ts +4 -2
- package/dist/hooks/api/create-invitation.d.ts.map +1 -1
- package/dist/hooks/api/create-invitation.js +6 -3
- package/dist/hooks/api/create-invitation.js.map +1 -1
- package/dist/hooks/api/invitation-mapper.d.ts +2 -2
- package/dist/hooks/api/invitation-mapper.d.ts.map +1 -1
- package/dist/hooks/api/invitation-mapper.js.map +1 -1
- package/dist/hooks/api/list-invitations.d.ts +5 -5
- package/dist/hooks/api/list-invitations.d.ts.map +1 -1
- package/dist/hooks/api/list-invitations.js.map +1 -1
- package/dist/hooks/api/list-members.d.ts +10 -11
- package/dist/hooks/api/list-members.d.ts.map +1 -1
- package/dist/hooks/api/list-members.js +11 -5
- package/dist/hooks/api/list-members.js.map +1 -1
- package/dist/hooks/api/membership-mapper.d.ts +4 -0
- package/dist/hooks/api/membership-mapper.d.ts.map +1 -0
- package/dist/hooks/api/membership-mapper.js +14 -0
- package/dist/hooks/api/membership-mapper.js.map +1 -0
- package/dist/hooks/api/remove-member.d.ts +2 -1
- package/dist/hooks/api/remove-member.d.ts.map +1 -1
- package/dist/hooks/api/remove-member.js +6 -8
- package/dist/hooks/api/remove-member.js.map +1 -1
- package/dist/hooks/api/revoke-invitation.d.ts +2 -1
- package/dist/hooks/api/revoke-invitation.d.ts.map +1 -1
- package/dist/hooks/api/revoke-invitation.js +4 -4
- package/dist/hooks/api/revoke-invitation.js.map +1 -1
- package/dist/index.d.ts +0 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -7
- package/dist/index.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +10 -16
- package/src/components/members/form-errors.test.ts +57 -4
- package/src/components/members/form-errors.ts +21 -10
- package/src/components/members/workspace-members-section.tsx +38 -35
- package/src/core/membership.test.ts +69 -0
- package/src/core/membership.ts +64 -0
- package/src/feature.ts +27 -104
- package/src/hooks/api/create-invitation.ts +8 -4
- package/src/hooks/api/invitation-mapper.ts +2 -2
- package/src/hooks/api/list-invitations.ts +2 -2
- package/src/hooks/api/list-members.ts +17 -7
- package/src/hooks/api/membership-mapper.test.ts +24 -0
- package/src/hooks/api/membership-mapper.ts +15 -0
- package/src/hooks/api/remove-member.ts +11 -8
- package/src/hooks/api/revoke-invitation.ts +11 -6
- package/src/index.ts +0 -7
- package/test/pages.tsx +1 -5
- package/tsconfig.build.tsbuildinfo +1 -1
- package/dist/core/invitation.d.ts +0 -15
- package/dist/core/invitation.d.ts.map +0 -1
- package/dist/core/invitation.js +0 -3
- package/dist/core/invitation.js.map +0 -1
- package/dist/pages/events-settings-page.d.ts +0 -2
- package/dist/pages/events-settings-page.d.ts.map +0 -1
- package/dist/pages/events-settings-page.js +0 -30
- package/dist/pages/events-settings-page.js.map +0 -1
- package/dist/pages/integrations-settings-page.d.ts +0 -2
- package/dist/pages/integrations-settings-page.d.ts.map +0 -1
- package/dist/pages/integrations-settings-page.js +0 -10
- package/dist/pages/integrations-settings-page.js.map +0 -1
- package/dist/pages/model-providers-settings-page.d.ts +0 -2
- package/dist/pages/model-providers-settings-page.d.ts.map +0 -1
- package/dist/pages/model-providers-settings-page.js +0 -20
- package/dist/pages/model-providers-settings-page.js.map +0 -1
- package/dist/pages/provisioners-settings-page.d.ts +0 -2
- package/dist/pages/provisioners-settings-page.d.ts.map +0 -1
- package/dist/pages/provisioners-settings-page.js +0 -14
- package/dist/pages/provisioners-settings-page.js.map +0 -1
- package/dist/pages/runners-settings-page.d.ts +0 -2
- package/dist/pages/runners-settings-page.d.ts.map +0 -1
- package/dist/pages/runners-settings-page.js +0 -14
- package/dist/pages/runners-settings-page.js.map +0 -1
- package/dist/pages/secrets-settings-page.d.ts +0 -2
- package/dist/pages/secrets-settings-page.d.ts.map +0 -1
- package/dist/pages/secrets-settings-page.js +0 -14
- package/dist/pages/secrets-settings-page.js.map +0 -1
- package/dist/pages/variables-settings-page.d.ts +0 -2
- package/dist/pages/variables-settings-page.d.ts.map +0 -1
- package/dist/pages/variables-settings-page.js +0 -14
- package/dist/pages/variables-settings-page.js.map +0 -1
- package/dist/routes/agents.d.ts +0 -6
- package/dist/routes/agents.d.ts.map +0 -1
- package/dist/routes/agents.js +0 -7
- package/dist/routes/agents.js.map +0 -1
- package/dist/routes/events.d.ts +0 -8
- package/dist/routes/events.d.ts.map +0 -1
- package/dist/routes/events.js +0 -9
- package/dist/routes/events.js.map +0 -1
- package/dist/routes/integrations.d.ts +0 -6
- package/dist/routes/integrations.d.ts.map +0 -1
- package/dist/routes/integrations.js +0 -7
- package/dist/routes/integrations.js.map +0 -1
- package/dist/routes/provisioners.d.ts +0 -6
- package/dist/routes/provisioners.d.ts.map +0 -1
- package/dist/routes/provisioners.js +0 -7
- package/dist/routes/provisioners.js.map +0 -1
- package/dist/routes/runners.d.ts +0 -6
- package/dist/routes/runners.d.ts.map +0 -1
- package/dist/routes/runners.js +0 -7
- package/dist/routes/runners.js.map +0 -1
- package/dist/routes/secrets.d.ts +0 -6
- package/dist/routes/secrets.d.ts.map +0 -1
- package/dist/routes/secrets.js +0 -7
- package/dist/routes/secrets.js.map +0 -1
- package/dist/routes/variables.d.ts +0 -6
- package/dist/routes/variables.d.ts.map +0 -1
- package/dist/routes/variables.js +0 -7
- package/dist/routes/variables.js.map +0 -1
- package/src/core/invitation.ts +0 -15
- package/src/pages/events-settings-page.tsx +0 -25
- package/src/pages/integrations-settings-page.test.tsx +0 -72
- package/src/pages/integrations-settings-page.tsx +0 -6
- package/src/pages/model-providers-settings-page.test.tsx +0 -48
- package/src/pages/model-providers-settings-page.tsx +0 -15
- package/src/pages/provisioners-settings-page.test.tsx +0 -29
- package/src/pages/provisioners-settings-page.tsx +0 -14
- package/src/pages/runners-settings-page.test.tsx +0 -23
- package/src/pages/runners-settings-page.tsx +0 -14
- package/src/pages/secrets-settings-page.tsx +0 -14
- package/src/pages/variables-settings-page.tsx +0 -14
- package/src/routes/agents.tsx +0 -3
- package/src/routes/events.tsx +0 -7
- package/src/routes/integrations.tsx +0 -3
- package/src/routes/provisioners.tsx +0 -3
- package/src/routes/runners.tsx +0 -3
- package/src/routes/secrets.tsx +0 -3
- package/src/routes/variables.tsx +0 -3
- package/test/typecheck/route-ids.tsx +0 -7
- package/test/typecheck/settings-composition.tsx +0 -95
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/client-workspace-settings",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "6.0.1",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -10,10 +10,6 @@
|
|
|
10
10
|
"type": "module",
|
|
11
11
|
"main": "dist/index.js",
|
|
12
12
|
"types": "dist/index.d.ts",
|
|
13
|
-
"imports": {
|
|
14
|
-
"#*": "./dist/*",
|
|
15
|
-
"#test/*": "./test/*"
|
|
16
|
-
},
|
|
17
13
|
"exports": {
|
|
18
14
|
".": {
|
|
19
15
|
"types": "./dist/index.d.ts",
|
|
@@ -31,17 +27,12 @@
|
|
|
31
27
|
"dependencies": {
|
|
32
28
|
"@swc/helpers": "^0.5.17",
|
|
33
29
|
"@tanstack/react-form": "^1.32.0",
|
|
34
|
-
"@shipfox/api-workspaces-dto": "
|
|
35
|
-
"@shipfox/client-
|
|
36
|
-
"@shipfox/client-
|
|
37
|
-
"@shipfox/client-
|
|
38
|
-
"@shipfox/client-
|
|
39
|
-
"@shipfox/
|
|
40
|
-
"@shipfox/client-shell": "4.0.0",
|
|
41
|
-
"@shipfox/client-secrets": "4.0.0",
|
|
42
|
-
"@shipfox/client-triggers": "4.0.0",
|
|
43
|
-
"@shipfox/client-ui": "4.0.0",
|
|
44
|
-
"@shipfox/react-ui": "0.3.5"
|
|
30
|
+
"@shipfox/api-workspaces-dto": "9.0.1",
|
|
31
|
+
"@shipfox/client-auth": "6.0.1",
|
|
32
|
+
"@shipfox/client-ui": "6.0.1",
|
|
33
|
+
"@shipfox/client-shell": "6.0.1",
|
|
34
|
+
"@shipfox/client-api": "6.0.1",
|
|
35
|
+
"@shipfox/react-ui": "0.3.6"
|
|
45
36
|
},
|
|
46
37
|
"peerDependencies": {
|
|
47
38
|
"@tanstack/react-query": "^5.101.0",
|
|
@@ -49,6 +40,9 @@
|
|
|
49
40
|
"react": "^19.0.0",
|
|
50
41
|
"react-dom": "^19.0.0"
|
|
51
42
|
},
|
|
43
|
+
"imports": {
|
|
44
|
+
"#*": "./dist/*"
|
|
45
|
+
},
|
|
52
46
|
"scripts": {
|
|
53
47
|
"build": "shipfox-swc",
|
|
54
48
|
"check": "shipfox-biome-check",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import {ApiError} from '@shipfox/client-api';
|
|
2
|
-
import {invitationErrorToFormError} from './form-errors.js';
|
|
2
|
+
import {invitationErrorToFormError, memberRemovalErrorMessage} from './form-errors.js';
|
|
3
3
|
|
|
4
4
|
describe('invitationErrorToFormError', () => {
|
|
5
5
|
test('routes open-invitation-exists to the email field', () => {
|
|
@@ -18,23 +18,76 @@ describe('invitationErrorToFormError', () => {
|
|
|
18
18
|
});
|
|
19
19
|
});
|
|
20
20
|
|
|
21
|
-
test('
|
|
21
|
+
test('maps rate-limited errors to feature-owned form copy', () => {
|
|
22
22
|
const error = new ApiError({code: 'rate-limited', message: 'Try later', status: 429});
|
|
23
23
|
|
|
24
24
|
const result = invitationErrorToFormError(error);
|
|
25
25
|
|
|
26
|
-
expect(result).toEqual({
|
|
26
|
+
expect(result).toEqual({
|
|
27
|
+
kind: 'form',
|
|
28
|
+
message: 'Too many invitations were sent. Try again shortly.',
|
|
29
|
+
});
|
|
27
30
|
});
|
|
28
31
|
|
|
29
|
-
test('
|
|
32
|
+
test('uses the fallback message for unexpected errors', () => {
|
|
30
33
|
const result = invitationErrorToFormError(new Error('boom'));
|
|
31
34
|
|
|
32
35
|
expect(result).toEqual({kind: 'form', message: 'boom'});
|
|
33
36
|
});
|
|
34
37
|
|
|
38
|
+
test('uses an unclassified API error message as the fallback', () => {
|
|
39
|
+
const result = invitationErrorToFormError(
|
|
40
|
+
new ApiError({code: 'forbidden', message: 'You need the workspace admin role.', status: 403}),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
expect(result).toEqual({kind: 'form', message: 'You need the workspace admin role.'});
|
|
44
|
+
});
|
|
45
|
+
|
|
35
46
|
test('falls back to generic copy for non-Error throwables', () => {
|
|
36
47
|
const result = invitationErrorToFormError('weird');
|
|
37
48
|
|
|
38
49
|
expect(result).toEqual({kind: 'form', message: 'Could not send invitation.'});
|
|
39
50
|
});
|
|
40
51
|
});
|
|
52
|
+
|
|
53
|
+
describe('memberRemovalErrorMessage', () => {
|
|
54
|
+
test('maps self-removal-not-allowed to feature-owned copy', () => {
|
|
55
|
+
const error = new ApiError({code: 'self-removal-not-allowed', message: 'ignored', status: 403});
|
|
56
|
+
|
|
57
|
+
const result = memberRemovalErrorMessage(error);
|
|
58
|
+
|
|
59
|
+
expect(result).toBe("You can't remove yourself.");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('maps last-member to feature-owned copy', () => {
|
|
63
|
+
const error = new ApiError({code: 'last-member', message: 'ignored', status: 409});
|
|
64
|
+
|
|
65
|
+
const result = memberRemovalErrorMessage(error);
|
|
66
|
+
|
|
67
|
+
expect(result).toBe('Cannot remove the last member.');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('uses the fallback message for unexpected errors', () => {
|
|
71
|
+
const result = memberRemovalErrorMessage(new Error('boom'));
|
|
72
|
+
|
|
73
|
+
expect(result).toBe('boom');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('uses an unclassified API error message as the fallback', () => {
|
|
77
|
+
const result = memberRemovalErrorMessage(
|
|
78
|
+
new ApiError({
|
|
79
|
+
code: 'workspace-inactive',
|
|
80
|
+
message: 'This workspace is not active.',
|
|
81
|
+
status: 409,
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
expect(result).toBe('This workspace is not active.');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('falls back to generic copy for non-Error throwables', () => {
|
|
89
|
+
const result = memberRemovalErrorMessage('weird');
|
|
90
|
+
|
|
91
|
+
expect(result).toBe('Could not remove member.');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -7,18 +7,29 @@ export type InvitationFormErrorMapping =
|
|
|
7
7
|
| {kind: 'form'; message: string};
|
|
8
8
|
|
|
9
9
|
export function invitationErrorToFormError(error: unknown): InvitationFormErrorMapping {
|
|
10
|
-
if (error instanceof ApiError && error.code === 'open-invitation-exists') {
|
|
11
|
-
return {
|
|
12
|
-
kind: 'field',
|
|
13
|
-
field: 'email',
|
|
14
|
-
message: 'An open invitation already exists for this email.',
|
|
15
|
-
};
|
|
16
|
-
}
|
|
17
10
|
if (error instanceof ApiError) {
|
|
11
|
+
if (error.code === 'open-invitation-exists') {
|
|
12
|
+
return {
|
|
13
|
+
kind: 'field',
|
|
14
|
+
field: 'email',
|
|
15
|
+
message: 'An open invitation already exists for this email.',
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
if (error.code === 'rate-limited') {
|
|
19
|
+
return {kind: 'form', message: 'Too many invitations were sent. Try again shortly.'};
|
|
20
|
+
}
|
|
18
21
|
return {kind: 'form', message: error.message};
|
|
19
22
|
}
|
|
20
|
-
if (error instanceof Error) {
|
|
21
|
-
return {kind: 'form', message: error.message};
|
|
22
|
-
}
|
|
23
|
+
if (error instanceof Error) return {kind: 'form', message: error.message};
|
|
23
24
|
return {kind: 'form', message: 'Could not send invitation.'};
|
|
24
25
|
}
|
|
26
|
+
|
|
27
|
+
export function memberRemovalErrorMessage(error: unknown): string {
|
|
28
|
+
if (error instanceof ApiError) {
|
|
29
|
+
if (error.code === 'self-removal-not-allowed') return "You can't remove yourself.";
|
|
30
|
+
if (error.code === 'last-member') return 'Cannot remove the last member.';
|
|
31
|
+
return error.message;
|
|
32
|
+
}
|
|
33
|
+
if (error instanceof Error) return error.message;
|
|
34
|
+
return 'Could not remove member.';
|
|
35
|
+
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {createInvitationBodySchema
|
|
2
|
-
import {ApiError} from '@shipfox/client-api';
|
|
1
|
+
import {createInvitationBodySchema} from '@shipfox/api-workspaces-dto';
|
|
3
2
|
import {useAuthState} from '@shipfox/client-auth';
|
|
4
3
|
import {QueryLoadError} from '@shipfox/client-ui';
|
|
5
4
|
import {Badge} from '@shipfox/react-ui/badge';
|
|
@@ -31,15 +30,19 @@ import {Code, Header, Text} from '@shipfox/react-ui/typography';
|
|
|
31
30
|
import {formatDate} from '@shipfox/react-ui/utils';
|
|
32
31
|
import {useForm} from '@tanstack/react-form';
|
|
33
32
|
import {useState} from 'react';
|
|
34
|
-
import
|
|
33
|
+
import {
|
|
34
|
+
getInvitationExpiry,
|
|
35
|
+
getMemberRemovalRestriction,
|
|
36
|
+
memberCount,
|
|
37
|
+
type PendingInvitation,
|
|
38
|
+
type WorkspaceMember,
|
|
39
|
+
} from '#core/membership.js';
|
|
35
40
|
import {useCreateInvitation} from '#hooks/api/create-invitation.js';
|
|
36
41
|
import {useListInvitations} from '#hooks/api/list-invitations.js';
|
|
37
42
|
import {useListMembers} from '#hooks/api/list-members.js';
|
|
38
43
|
import {useRemoveMember} from '#hooks/api/remove-member.js';
|
|
39
44
|
import {useRevokeInvitation} from '#hooks/api/revoke-invitation.js';
|
|
40
|
-
import {invitationErrorToFormError} from './form-errors.js';
|
|
41
|
-
|
|
42
|
-
const EXPIRES_SOON_MS = 24 * 60 * 60 * 1000;
|
|
45
|
+
import {invitationErrorToFormError, memberRemovalErrorMessage} from './form-errors.js';
|
|
43
46
|
|
|
44
47
|
export function WorkspaceMembersSettingsSection({
|
|
45
48
|
workspaceId,
|
|
@@ -65,7 +68,7 @@ function MembersSection({
|
|
|
65
68
|
}) {
|
|
66
69
|
const auth = useAuthState();
|
|
67
70
|
const query = useListMembers(workspaceId);
|
|
68
|
-
const members = query.data
|
|
71
|
+
const members = query.data ?? [];
|
|
69
72
|
|
|
70
73
|
return (
|
|
71
74
|
<section className="flex flex-col gap-16">
|
|
@@ -74,7 +77,7 @@ function MembersSection({
|
|
|
74
77
|
<Text size="sm" className="text-foreground-neutral-muted">
|
|
75
78
|
{query.isPending
|
|
76
79
|
? 'Loading members…'
|
|
77
|
-
: `${members
|
|
80
|
+
: `${memberCount(members)} ${memberCount(members) === 1 ? 'member' : 'members'}`}
|
|
78
81
|
</Text>
|
|
79
82
|
</div>
|
|
80
83
|
|
|
@@ -99,7 +102,8 @@ function MembersSection({
|
|
|
99
102
|
<MemberRow
|
|
100
103
|
key={member.id}
|
|
101
104
|
member={member}
|
|
102
|
-
|
|
105
|
+
members={members}
|
|
106
|
+
currentUserId={auth.user?.id}
|
|
103
107
|
workspaceId={workspaceId}
|
|
104
108
|
workspaceName={workspaceName}
|
|
105
109
|
/>
|
|
@@ -113,35 +117,38 @@ function MembersSection({
|
|
|
113
117
|
|
|
114
118
|
function MemberRow({
|
|
115
119
|
member,
|
|
116
|
-
|
|
120
|
+
members,
|
|
121
|
+
currentUserId,
|
|
117
122
|
workspaceId,
|
|
118
123
|
workspaceName,
|
|
119
124
|
}: {
|
|
120
|
-
member:
|
|
121
|
-
|
|
125
|
+
member: WorkspaceMember;
|
|
126
|
+
members: readonly WorkspaceMember[];
|
|
127
|
+
currentUserId: string | undefined;
|
|
122
128
|
workspaceId: string;
|
|
123
129
|
workspaceName: string;
|
|
124
130
|
}) {
|
|
125
131
|
const [open, setOpen] = useState(false);
|
|
126
132
|
const remove = useRemoveMember(workspaceId);
|
|
133
|
+
const restriction = getMemberRemovalRestriction({member, members, currentUserId});
|
|
127
134
|
|
|
128
135
|
async function handleRemove() {
|
|
129
136
|
try {
|
|
130
|
-
await remove.mutateAsync(member.
|
|
131
|
-
toast.success(`Removed ${member.
|
|
137
|
+
await remove.mutateAsync({userId: member.userId});
|
|
138
|
+
toast.success(`Removed ${member.email} from ${workspaceName}.`);
|
|
132
139
|
setOpen(false);
|
|
133
140
|
} catch (error) {
|
|
134
|
-
toast.error(
|
|
141
|
+
toast.error(memberRemovalErrorMessage(error));
|
|
135
142
|
}
|
|
136
143
|
}
|
|
137
144
|
|
|
138
145
|
return (
|
|
139
146
|
<TableRow className={remove.isPending ? 'opacity-60' : undefined}>
|
|
140
|
-
<TableCell className="font-medium">{member.
|
|
147
|
+
<TableCell className="font-medium">{member.name ?? '—'}</TableCell>
|
|
141
148
|
<TableCell>
|
|
142
|
-
<Code variant="paragraph">{member.
|
|
149
|
+
<Code variant="paragraph">{member.email}</Code>
|
|
143
150
|
</TableCell>
|
|
144
|
-
<TableCell>{formatDate(member.
|
|
151
|
+
<TableCell>{formatDate(member.joinedAt)}</TableCell>
|
|
145
152
|
<TableCell className="text-right">
|
|
146
153
|
<Modal open={open} onOpenChange={setOpen}>
|
|
147
154
|
<ModalTrigger asChild>
|
|
@@ -149,7 +156,7 @@ function MemberRow({
|
|
|
149
156
|
size="sm"
|
|
150
157
|
variant="transparentMuted"
|
|
151
158
|
aria-label="Remove member"
|
|
152
|
-
disabled={
|
|
159
|
+
disabled={restriction !== undefined}
|
|
153
160
|
isLoading={remove.isPending}
|
|
154
161
|
className={
|
|
155
162
|
remove.isPending
|
|
@@ -164,7 +171,7 @@ function MemberRow({
|
|
|
164
171
|
<ModalTitle className="sr-only">Remove member</ModalTitle>
|
|
165
172
|
<ModalHeader>
|
|
166
173
|
<Text size="lg">
|
|
167
|
-
Remove {member.
|
|
174
|
+
Remove {member.name ?? member.email} from {workspaceName}?
|
|
168
175
|
</Text>
|
|
169
176
|
</ModalHeader>
|
|
170
177
|
<ModalBody>
|
|
@@ -250,15 +257,20 @@ function PendingInvitationsSection({
|
|
|
250
257
|
);
|
|
251
258
|
}
|
|
252
259
|
|
|
253
|
-
function InvitationRow({
|
|
260
|
+
function InvitationRow({
|
|
261
|
+
invitation,
|
|
262
|
+
workspaceId,
|
|
263
|
+
}: {
|
|
264
|
+
invitation: PendingInvitation;
|
|
265
|
+
workspaceId: string;
|
|
266
|
+
}) {
|
|
254
267
|
const [open, setOpen] = useState(false);
|
|
255
268
|
const revoke = useRevokeInvitation(workspaceId);
|
|
256
|
-
const
|
|
257
|
-
const expiresSoon = expiresAt.getTime() - Date.now() < EXPIRES_SOON_MS;
|
|
269
|
+
const expiry = getInvitationExpiry(invitation);
|
|
258
270
|
|
|
259
271
|
async function handleRevoke() {
|
|
260
272
|
try {
|
|
261
|
-
await revoke.mutateAsync(invitation.id);
|
|
273
|
+
await revoke.mutateAsync({invitationId: invitation.id});
|
|
262
274
|
toast.success(`Invitation to ${invitation.email} revoked.`);
|
|
263
275
|
setOpen(false);
|
|
264
276
|
} catch {
|
|
@@ -275,7 +287,8 @@ function InvitationRow({invitation, workspaceId}: {invitation: Invitation; works
|
|
|
275
287
|
<TableCell>
|
|
276
288
|
<div className="flex items-center gap-8">
|
|
277
289
|
<Text size="sm">{formatDate(invitation.expiresAt)}</Text>
|
|
278
|
-
{
|
|
290
|
+
{expiry === 'expires-soon' ? <Badge variant="warning">Soon</Badge> : null}
|
|
291
|
+
{expiry === 'expired' ? <Badge variant="error">Expired</Badge> : null}
|
|
279
292
|
</div>
|
|
280
293
|
</TableCell>
|
|
281
294
|
<TableCell className="text-right">
|
|
@@ -459,13 +472,3 @@ function TableSkeleton({rows, cols}: {rows: number; cols: number}) {
|
|
|
459
472
|
</div>
|
|
460
473
|
);
|
|
461
474
|
}
|
|
462
|
-
|
|
463
|
-
function removeMemberErrorMessage(error: unknown): string {
|
|
464
|
-
if (error instanceof ApiError) {
|
|
465
|
-
if (error.code === 'self-removal-not-allowed') return "You can't remove yourself.";
|
|
466
|
-
if (error.code === 'last-member') return 'Cannot remove the last member.';
|
|
467
|
-
return error.message;
|
|
468
|
-
}
|
|
469
|
-
if (error instanceof Error) return error.message;
|
|
470
|
-
return 'Could not remove member.';
|
|
471
|
-
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getInvitationExpiry,
|
|
3
|
+
getMemberRemovalRestriction,
|
|
4
|
+
memberCount,
|
|
5
|
+
type WorkspaceMember,
|
|
6
|
+
} from './membership.js';
|
|
7
|
+
|
|
8
|
+
const member: WorkspaceMember = {
|
|
9
|
+
id: 'membership-1',
|
|
10
|
+
userId: 'user-1',
|
|
11
|
+
workspaceId: 'workspace-1',
|
|
12
|
+
email: 'member@example.com',
|
|
13
|
+
name: 'Member',
|
|
14
|
+
role: 'admin',
|
|
15
|
+
joinedAt: '2026-07-22T12:00:00.000Z',
|
|
16
|
+
updatedAt: '2026-07-22T12:00:00.000Z',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe('getInvitationExpiry', () => {
|
|
20
|
+
const now = Date.parse('2026-07-22T12:00:00.000Z');
|
|
21
|
+
|
|
22
|
+
it.each([
|
|
23
|
+
['active', '2026-07-24T12:00:00.000Z'],
|
|
24
|
+
['expires-soon', '2026-07-23T11:59:59.999Z'],
|
|
25
|
+
['expired', '2026-07-22T12:00:00.000Z'],
|
|
26
|
+
] as const)('classifies a %s invitation', (expected, expiresAt) => {
|
|
27
|
+
const result = getInvitationExpiry({expiresAt}, now);
|
|
28
|
+
|
|
29
|
+
expect(result).toBe(expected);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('getMemberRemovalRestriction', () => {
|
|
34
|
+
it('forbids self-removal before evaluating the member count', () => {
|
|
35
|
+
const result = getMemberRemovalRestriction({
|
|
36
|
+
member,
|
|
37
|
+
currentUserId: member.userId,
|
|
38
|
+
members: [member],
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(result).toBe('self');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('forbids removing the final member', () => {
|
|
45
|
+
const result = getMemberRemovalRestriction({
|
|
46
|
+
member,
|
|
47
|
+
currentUserId: 'another-user',
|
|
48
|
+
members: [member],
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
expect(result).toBe('last-member');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('allows removal when another member remains', () => {
|
|
55
|
+
const result = getMemberRemovalRestriction({
|
|
56
|
+
member,
|
|
57
|
+
currentUserId: 'another-user',
|
|
58
|
+
members: [member, {...member, id: 'membership-2', userId: 'user-2'}],
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(result).toBeUndefined();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('counts workspace members', () => {
|
|
66
|
+
const result = memberCount([member, {...member, id: 'membership-2'}]);
|
|
67
|
+
|
|
68
|
+
expect(result).toBe(2);
|
|
69
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export type WorkspaceMemberRole = 'admin';
|
|
2
|
+
|
|
3
|
+
export interface WorkspaceMember {
|
|
4
|
+
id: string;
|
|
5
|
+
userId: string;
|
|
6
|
+
workspaceId: string;
|
|
7
|
+
email: string;
|
|
8
|
+
name: string | null;
|
|
9
|
+
role: WorkspaceMemberRole;
|
|
10
|
+
joinedAt: string;
|
|
11
|
+
updatedAt: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface PendingInvitation {
|
|
15
|
+
id: string;
|
|
16
|
+
workspaceId: string;
|
|
17
|
+
email: string;
|
|
18
|
+
expiresAt: string;
|
|
19
|
+
acceptedAt: string | null;
|
|
20
|
+
invitedByUserId: string;
|
|
21
|
+
invitedByDisplay: string | null;
|
|
22
|
+
createdAt: string;
|
|
23
|
+
updatedAt: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CreateInvitationCommand {
|
|
27
|
+
email: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface RevokeInvitationCommand {
|
|
31
|
+
invitationId: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RemoveWorkspaceMemberCommand {
|
|
35
|
+
userId: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type InvitationExpiry = 'active' | 'expires-soon' | 'expired';
|
|
39
|
+
|
|
40
|
+
export function getInvitationExpiry(
|
|
41
|
+
invitation: Pick<PendingInvitation, 'expiresAt'>,
|
|
42
|
+
now = Date.now(),
|
|
43
|
+
): InvitationExpiry {
|
|
44
|
+
const expiresAt = Date.parse(invitation.expiresAt);
|
|
45
|
+
if (expiresAt <= now) return 'expired';
|
|
46
|
+
if (expiresAt - now < 24 * 60 * 60 * 1000) return 'expires-soon';
|
|
47
|
+
return 'active';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function memberCount(members: readonly WorkspaceMember[]): number {
|
|
51
|
+
return members.length;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type MemberRemovalRestriction = 'self' | 'last-member';
|
|
55
|
+
|
|
56
|
+
export function getMemberRemovalRestriction(params: {
|
|
57
|
+
member: Pick<WorkspaceMember, 'userId'>;
|
|
58
|
+
currentUserId: string | undefined;
|
|
59
|
+
members: readonly WorkspaceMember[];
|
|
60
|
+
}): MemberRemovalRestriction | undefined {
|
|
61
|
+
if (params.member.userId === params.currentUserId) return 'self';
|
|
62
|
+
if (memberCount(params.members) <= 1) return 'last-member';
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
package/src/feature.ts
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
defineClientFeature,
|
|
3
|
+
type NavTabEntry,
|
|
4
|
+
type SettingsSectionEntry,
|
|
5
|
+
} from '@shipfox/client-shell';
|
|
6
|
+
|
|
7
|
+
export const workspaceSettingsNavigation = [
|
|
8
|
+
{
|
|
9
|
+
id: 'nav.settings',
|
|
10
|
+
scope: 'workspace',
|
|
11
|
+
label: 'Settings',
|
|
12
|
+
to: '/workspaces/$wid/settings',
|
|
13
|
+
order: 200,
|
|
14
|
+
},
|
|
15
|
+
] as const satisfies readonly NavTabEntry[];
|
|
16
|
+
|
|
17
|
+
export const workspaceSettingsSections = [
|
|
18
|
+
{
|
|
19
|
+
id: 'settings.members',
|
|
20
|
+
pathSegment: 'members',
|
|
21
|
+
label: 'Members',
|
|
22
|
+
icon: 'userLine',
|
|
23
|
+
order: 100,
|
|
24
|
+
},
|
|
25
|
+
] as const satisfies readonly SettingsSectionEntry[];
|
|
3
26
|
|
|
4
27
|
export const workspaceSettingsFeature = defineClientFeature({
|
|
5
28
|
id: 'shipfox.workspace-settings',
|
|
@@ -14,107 +37,7 @@ export const workspaceSettingsFeature = defineClientFeature({
|
|
|
14
37
|
parent: 'workspaceSettings',
|
|
15
38
|
impl: '@shipfox/client-workspace-settings/routes/members',
|
|
16
39
|
},
|
|
17
|
-
{
|
|
18
|
-
path: '/workspaces/$wid/settings/runners',
|
|
19
|
-
parent: 'workspaceSettings',
|
|
20
|
-
impl: '@shipfox/client-workspace-settings/routes/runners',
|
|
21
|
-
},
|
|
22
|
-
{
|
|
23
|
-
path: '/workspaces/$wid/settings/provisioners',
|
|
24
|
-
parent: 'workspaceSettings',
|
|
25
|
-
impl: '@shipfox/client-workspace-settings/routes/provisioners',
|
|
26
|
-
},
|
|
27
|
-
{
|
|
28
|
-
path: '/workspaces/$wid/settings/agents',
|
|
29
|
-
parent: 'workspaceSettings',
|
|
30
|
-
impl: '@shipfox/client-workspace-settings/routes/agents',
|
|
31
|
-
},
|
|
32
|
-
{
|
|
33
|
-
path: '/workspaces/$wid/settings/secrets',
|
|
34
|
-
parent: 'workspaceSettings',
|
|
35
|
-
impl: '@shipfox/client-workspace-settings/routes/secrets',
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
path: '/workspaces/$wid/settings/variables',
|
|
39
|
-
parent: 'workspaceSettings',
|
|
40
|
-
impl: '@shipfox/client-workspace-settings/routes/variables',
|
|
41
|
-
},
|
|
42
|
-
{
|
|
43
|
-
path: '/workspaces/$wid/settings/integrations',
|
|
44
|
-
parent: 'workspaceSettings',
|
|
45
|
-
impl: '@shipfox/client-workspace-settings/routes/integrations',
|
|
46
|
-
},
|
|
47
|
-
{
|
|
48
|
-
path: '/workspaces/$wid/settings/events',
|
|
49
|
-
parent: 'workspaceSettings',
|
|
50
|
-
impl: '@shipfox/client-workspace-settings/routes/events',
|
|
51
|
-
},
|
|
52
|
-
],
|
|
53
|
-
navigation: [
|
|
54
|
-
{
|
|
55
|
-
id: 'nav.settings',
|
|
56
|
-
scope: 'workspace',
|
|
57
|
-
label: 'Settings',
|
|
58
|
-
to: '/workspaces/$wid/settings',
|
|
59
|
-
order: 200,
|
|
60
|
-
},
|
|
61
|
-
],
|
|
62
|
-
settingsSections: [
|
|
63
|
-
{
|
|
64
|
-
id: 'settings.members',
|
|
65
|
-
pathSegment: 'members',
|
|
66
|
-
label: 'Members',
|
|
67
|
-
icon: 'userLine' as IconName,
|
|
68
|
-
order: 100,
|
|
69
|
-
},
|
|
70
|
-
{
|
|
71
|
-
id: 'settings.runners',
|
|
72
|
-
pathSegment: 'runners',
|
|
73
|
-
label: 'Runners',
|
|
74
|
-
icon: 'settings3Line' as IconName,
|
|
75
|
-
order: 200,
|
|
76
|
-
},
|
|
77
|
-
{
|
|
78
|
-
id: 'settings.provisioners',
|
|
79
|
-
pathSegment: 'provisioners',
|
|
80
|
-
label: 'Runner provisioners',
|
|
81
|
-
icon: 'serverLine' as IconName,
|
|
82
|
-
order: 300,
|
|
83
|
-
},
|
|
84
|
-
{
|
|
85
|
-
id: 'settings.agents',
|
|
86
|
-
pathSegment: 'agents',
|
|
87
|
-
label: 'Agents',
|
|
88
|
-
icon: 'robot2Line' as IconName,
|
|
89
|
-
order: 400,
|
|
90
|
-
},
|
|
91
|
-
{
|
|
92
|
-
id: 'settings.secrets',
|
|
93
|
-
pathSegment: 'secrets',
|
|
94
|
-
label: 'Secrets',
|
|
95
|
-
icon: 'keyLine' as IconName,
|
|
96
|
-
order: 500,
|
|
97
|
-
},
|
|
98
|
-
{
|
|
99
|
-
id: 'settings.variables',
|
|
100
|
-
pathSegment: 'variables',
|
|
101
|
-
label: 'Variables',
|
|
102
|
-
icon: 'bracesLine' as IconName,
|
|
103
|
-
order: 600,
|
|
104
|
-
},
|
|
105
|
-
{
|
|
106
|
-
id: 'settings.integrations',
|
|
107
|
-
pathSegment: 'integrations',
|
|
108
|
-
label: 'Integrations',
|
|
109
|
-
icon: 'plugLine' as IconName,
|
|
110
|
-
order: 700,
|
|
111
|
-
},
|
|
112
|
-
{
|
|
113
|
-
id: 'settings.events',
|
|
114
|
-
pathSegment: 'events',
|
|
115
|
-
label: 'Events',
|
|
116
|
-
icon: 'pulseLine' as IconName,
|
|
117
|
-
order: 800,
|
|
118
|
-
},
|
|
119
40
|
],
|
|
41
|
+
navigation: workspaceSettingsNavigation,
|
|
42
|
+
settingsSections: workspaceSettingsSections,
|
|
120
43
|
});
|
|
@@ -1,25 +1,29 @@
|
|
|
1
|
-
import {invitationDtoSchema} from '@shipfox/api-workspaces-dto';
|
|
1
|
+
import {type CreateInvitationBodyDto, invitationDtoSchema} from '@shipfox/api-workspaces-dto';
|
|
2
2
|
import {checkedApiRequest} from '@shipfox/client-api';
|
|
3
3
|
import {useMutation, useQueryClient} from '@tanstack/react-query';
|
|
4
|
-
import type {CreateInvitationCommand,
|
|
4
|
+
import type {CreateInvitationCommand, PendingInvitation} from '#core/membership.js';
|
|
5
5
|
import {toInvitation} from './invitation-mapper.js';
|
|
6
6
|
import {listInvitationsQueryOptions} from './list-invitations.js';
|
|
7
7
|
|
|
8
8
|
async function createInvitation(params: {
|
|
9
9
|
workspaceId: string;
|
|
10
10
|
command: CreateInvitationCommand;
|
|
11
|
-
}): Promise<
|
|
11
|
+
}): Promise<PendingInvitation> {
|
|
12
12
|
const response = await checkedApiRequest(
|
|
13
13
|
invitationDtoSchema,
|
|
14
14
|
`/workspaces/${params.workspaceId}/invitations`,
|
|
15
15
|
{
|
|
16
16
|
method: 'POST',
|
|
17
|
-
body:
|
|
17
|
+
body: toCreateInvitationBody(params.command),
|
|
18
18
|
},
|
|
19
19
|
);
|
|
20
20
|
return toInvitation(response);
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
export function toCreateInvitationBody(command: CreateInvitationCommand): CreateInvitationBodyDto {
|
|
24
|
+
return {email: command.email};
|
|
25
|
+
}
|
|
26
|
+
|
|
23
27
|
export function useCreateInvitation(workspaceId: string) {
|
|
24
28
|
const queryClient = useQueryClient();
|
|
25
29
|
return useMutation({
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type {InvitationDto} from '@shipfox/api-workspaces-dto';
|
|
2
|
-
import type {
|
|
2
|
+
import type {PendingInvitation} from '#core/membership.js';
|
|
3
3
|
|
|
4
|
-
export function toInvitation(dto: InvitationDto):
|
|
4
|
+
export function toInvitation(dto: InvitationDto): PendingInvitation {
|
|
5
5
|
return {
|
|
6
6
|
id: dto.id,
|
|
7
7
|
workspaceId: dto.workspace_id,
|