@shipfox/client-workspace-settings 5.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.
Files changed (65) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +41 -0
  3. package/dist/components/members/form-errors.d.ts +1 -0
  4. package/dist/components/members/form-errors.d.ts.map +1 -1
  5. package/dist/components/members/form-errors.js +26 -13
  6. package/dist/components/members/form-errors.js.map +1 -1
  7. package/dist/components/members/workspace-members-section.d.ts.map +1 -1
  8. package/dist/components/members/workspace-members-section.js +31 -28
  9. package/dist/components/members/workspace-members-section.js.map +1 -1
  10. package/dist/core/membership.d.ts +41 -0
  11. package/dist/core/membership.d.ts.map +1 -0
  12. package/dist/core/membership.js +16 -0
  13. package/dist/core/membership.js.map +1 -0
  14. package/dist/feature.d.ts +15 -2
  15. package/dist/feature.d.ts.map +1 -1
  16. package/dist/feature.js +20 -18
  17. package/dist/feature.js.map +1 -1
  18. package/dist/hooks/api/create-invitation.d.ts +4 -2
  19. package/dist/hooks/api/create-invitation.d.ts.map +1 -1
  20. package/dist/hooks/api/create-invitation.js +6 -3
  21. package/dist/hooks/api/create-invitation.js.map +1 -1
  22. package/dist/hooks/api/invitation-mapper.d.ts +2 -2
  23. package/dist/hooks/api/invitation-mapper.d.ts.map +1 -1
  24. package/dist/hooks/api/invitation-mapper.js.map +1 -1
  25. package/dist/hooks/api/list-invitations.d.ts +5 -5
  26. package/dist/hooks/api/list-invitations.d.ts.map +1 -1
  27. package/dist/hooks/api/list-invitations.js.map +1 -1
  28. package/dist/hooks/api/list-members.d.ts +10 -11
  29. package/dist/hooks/api/list-members.d.ts.map +1 -1
  30. package/dist/hooks/api/list-members.js +11 -5
  31. package/dist/hooks/api/list-members.js.map +1 -1
  32. package/dist/hooks/api/membership-mapper.d.ts +4 -0
  33. package/dist/hooks/api/membership-mapper.d.ts.map +1 -0
  34. package/dist/hooks/api/membership-mapper.js +14 -0
  35. package/dist/hooks/api/membership-mapper.js.map +1 -0
  36. package/dist/hooks/api/remove-member.d.ts +2 -1
  37. package/dist/hooks/api/remove-member.d.ts.map +1 -1
  38. package/dist/hooks/api/remove-member.js +6 -8
  39. package/dist/hooks/api/remove-member.js.map +1 -1
  40. package/dist/hooks/api/revoke-invitation.d.ts +2 -1
  41. package/dist/hooks/api/revoke-invitation.d.ts.map +1 -1
  42. package/dist/hooks/api/revoke-invitation.js +4 -4
  43. package/dist/hooks/api/revoke-invitation.js.map +1 -1
  44. package/dist/tsconfig.test.tsbuildinfo +1 -1
  45. package/package.json +10 -11
  46. package/src/components/members/form-errors.test.ts +57 -4
  47. package/src/components/members/form-errors.ts +21 -10
  48. package/src/components/members/workspace-members-section.tsx +38 -35
  49. package/src/core/membership.test.ts +69 -0
  50. package/src/core/membership.ts +64 -0
  51. package/src/feature.ts +27 -20
  52. package/src/hooks/api/create-invitation.ts +8 -4
  53. package/src/hooks/api/invitation-mapper.ts +2 -2
  54. package/src/hooks/api/list-invitations.ts +2 -2
  55. package/src/hooks/api/list-members.ts +17 -7
  56. package/src/hooks/api/membership-mapper.test.ts +24 -0
  57. package/src/hooks/api/membership-mapper.ts +15 -0
  58. package/src/hooks/api/remove-member.ts +11 -8
  59. package/src/hooks/api/revoke-invitation.ts +11 -6
  60. package/tsconfig.build.tsbuildinfo +1 -1
  61. package/dist/core/invitation.d.ts +0 -15
  62. package/dist/core/invitation.d.ts.map +0 -1
  63. package/dist/core/invitation.js +0 -3
  64. package/dist/core/invitation.js.map +0 -1
  65. package/src/core/invitation.ts +0 -15
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/client-workspace-settings",
3
3
  "license": "MIT",
4
- "version": "5.0.0",
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,12 +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": "6.0.0",
35
- "@shipfox/client-api": "4.0.0",
36
- "@shipfox/client-auth": "5.0.0",
37
- "@shipfox/client-shell": "5.0.0",
38
- "@shipfox/client-ui": "4.0.0",
39
- "@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"
40
36
  },
41
37
  "peerDependencies": {
42
38
  "@tanstack/react-query": "^5.101.0",
@@ -44,6 +40,9 @@
44
40
  "react": "^19.0.0",
45
41
  "react-dom": "^19.0.0"
46
42
  },
43
+ "imports": {
44
+ "#*": "./dist/*"
45
+ },
47
46
  "scripts": {
48
47
  "build": "shipfox-swc",
49
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('routes other ApiError codes to a form-level alert with the server message', () => {
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({kind: 'form', message: 'Try later'});
26
+ expect(result).toEqual({
27
+ kind: 'form',
28
+ message: 'Too many invitations were sent. Try again shortly.',
29
+ });
27
30
  });
28
31
 
29
- test('routes non-ApiError to a form-level alert with the error message', () => {
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, type MembershipWithUserDto} from '@shipfox/api-workspaces-dto';
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 type {Invitation} from '#core/invitation.js';
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?.members ?? [];
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.length} ${members.length === 1 ? 'member' : '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
- isSelf={member.user_id === auth.user?.id}
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
- isSelf,
120
+ members,
121
+ currentUserId,
117
122
  workspaceId,
118
123
  workspaceName,
119
124
  }: {
120
- member: MembershipWithUserDto;
121
- isSelf: boolean;
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.user_id);
131
- toast.success(`Removed ${member.user_email} from ${workspaceName}.`);
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(removeMemberErrorMessage(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.user_name ?? '—'}</TableCell>
147
+ <TableCell className="font-medium">{member.name ?? '—'}</TableCell>
141
148
  <TableCell>
142
- <Code variant="paragraph">{member.user_email}</Code>
149
+ <Code variant="paragraph">{member.email}</Code>
143
150
  </TableCell>
144
- <TableCell>{formatDate(member.created_at)}</TableCell>
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={isSelf}
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.user_name ?? member.user_email} from {workspaceName}?
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({invitation, workspaceId}: {invitation: Invitation; workspaceId: string}) {
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 expiresAt = new Date(invitation.expiresAt);
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
- {expiresSoon ? <Badge variant="warning">Soon</Badge> : null}
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 {defineClientFeature} from '@shipfox/client-shell';
2
- import type {IconName} from '@shipfox/react-ui/icon';
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',
@@ -15,22 +38,6 @@ export const workspaceSettingsFeature = defineClientFeature({
15
38
  impl: '@shipfox/client-workspace-settings/routes/members',
16
39
  },
17
40
  ],
18
- navigation: [
19
- {
20
- id: 'nav.settings',
21
- scope: 'workspace',
22
- label: 'Settings',
23
- to: '/workspaces/$wid/settings',
24
- order: 200,
25
- },
26
- ],
27
- settingsSections: [
28
- {
29
- id: 'settings.members',
30
- pathSegment: 'members',
31
- label: 'Members',
32
- icon: 'userLine' as IconName,
33
- order: 100,
34
- },
35
- ],
41
+ navigation: workspaceSettingsNavigation,
42
+ settingsSections: workspaceSettingsSections,
36
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, Invitation} from '#core/invitation.js';
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<Invitation> {
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: {email: params.command.email},
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 {Invitation} from '#core/invitation.js';
2
+ import type {PendingInvitation} from '#core/membership.js';
3
3
 
4
- export function toInvitation(dto: InvitationDto): Invitation {
4
+ export function toInvitation(dto: InvitationDto): PendingInvitation {
5
5
  return {
6
6
  id: dto.id,
7
7
  workspaceId: dto.workspace_id,
@@ -1,13 +1,13 @@
1
1
  import {listInvitationsResponseSchema} from '@shipfox/api-workspaces-dto';
2
2
  import {checkedApiRequest} from '@shipfox/client-api';
3
3
  import {queryOptions, useQuery} from '@tanstack/react-query';
4
- import type {Invitation} from '#core/invitation.js';
4
+ import type {PendingInvitation} from '#core/membership.js';
5
5
  import {toInvitation} from './invitation-mapper.js';
6
6
 
7
7
  export const listInvitationsQueryKey = (workspaceId: string) =>
8
8
  ['workspaces', workspaceId, 'invitations'] as const;
9
9
 
10
- async function listInvitations(workspaceId: string): Promise<Invitation[]> {
10
+ async function listInvitations(workspaceId: string): Promise<PendingInvitation[]> {
11
11
  const response = await checkedApiRequest(
12
12
  listInvitationsResponseSchema,
13
13
  `/workspaces/${workspaceId}/invitations`,
@@ -1,17 +1,27 @@
1
- import type {ListMembersResponseDto} from '@shipfox/api-workspaces-dto';
2
- import {apiRequest} from '@shipfox/client-api';
3
- import {useQuery} from '@tanstack/react-query';
1
+ import {listMembersResponseSchema} from '@shipfox/api-workspaces-dto';
2
+ import {checkedApiRequest} from '@shipfox/client-api';
3
+ import {queryOptions, useQuery} from '@tanstack/react-query';
4
+ import type {WorkspaceMember} from '#core/membership.js';
5
+ import {toWorkspaceMember} from './membership-mapper.js';
4
6
 
5
7
  export const listMembersQueryKey = (workspaceId: string) =>
6
8
  ['workspaces', workspaceId, 'members'] as const;
7
9
 
8
- async function listMembers(workspaceId: string): Promise<ListMembersResponseDto> {
9
- return await apiRequest<ListMembersResponseDto>(`/workspaces/${workspaceId}/members`);
10
+ async function listMembers(workspaceId: string): Promise<WorkspaceMember[]> {
11
+ const response = await checkedApiRequest(
12
+ listMembersResponseSchema,
13
+ `/workspaces/${workspaceId}/members`,
14
+ );
15
+ return response.members.map(toWorkspaceMember);
10
16
  }
11
17
 
12
- export function useListMembers(workspaceId: string) {
13
- return useQuery({
18
+ export function listMembersQueryOptions(workspaceId: string) {
19
+ return queryOptions({
14
20
  queryKey: listMembersQueryKey(workspaceId),
15
21
  queryFn: () => listMembers(workspaceId),
16
22
  });
17
23
  }
24
+
25
+ export function useListMembers(workspaceId: string) {
26
+ return useQuery(listMembersQueryOptions(workspaceId));
27
+ }
@@ -0,0 +1,24 @@
1
+ import {toWorkspaceMember} from './membership-mapper.js';
2
+
3
+ test('maps the member transport response to the package domain model', () => {
4
+ const member = toWorkspaceMember({
5
+ id: 'f87901d8-4a7e-43e7-84f5-a9c73456657f',
6
+ workspace_id: 'ab03cfd0-1845-453b-867c-a661c8e62f13',
7
+ user_id: 'b8d2ab85-9e38-4449-af50-8beaabf058e5',
8
+ user_email: 'member@example.com',
9
+ user_name: 'Noé',
10
+ created_at: '2026-07-22T12:00:00.000Z',
11
+ updated_at: '2026-07-22T12:00:00.000Z',
12
+ });
13
+
14
+ expect(member).toEqual({
15
+ id: 'f87901d8-4a7e-43e7-84f5-a9c73456657f',
16
+ workspaceId: 'ab03cfd0-1845-453b-867c-a661c8e62f13',
17
+ userId: 'b8d2ab85-9e38-4449-af50-8beaabf058e5',
18
+ email: 'member@example.com',
19
+ name: 'Noé',
20
+ role: 'admin',
21
+ joinedAt: '2026-07-22T12:00:00.000Z',
22
+ updatedAt: '2026-07-22T12:00:00.000Z',
23
+ });
24
+ });
@@ -0,0 +1,15 @@
1
+ import type {MembershipWithUserDto} from '@shipfox/api-workspaces-dto';
2
+ import type {WorkspaceMember} from '#core/membership.js';
3
+
4
+ export function toWorkspaceMember(dto: MembershipWithUserDto): WorkspaceMember {
5
+ return {
6
+ id: dto.id,
7
+ userId: dto.user_id,
8
+ workspaceId: dto.workspace_id,
9
+ email: dto.user_email,
10
+ name: dto.user_name,
11
+ role: 'admin',
12
+ joinedAt: dto.created_at,
13
+ updatedAt: dto.updated_at,
14
+ };
15
+ }