@ossy/workspaces 3.2.0 → 3.5.0
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/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossy/workspaces",
|
|
3
3
|
"description": "Workspaces feature package - create, select, and manage workspaces, users, and invitations",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.5.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"module": "./src/index.js",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@ossy/config": "^3.0.9",
|
|
27
|
-
"@ossy/schema": "^3.0
|
|
27
|
+
"@ossy/schema": "^3.5.0"
|
|
28
28
|
},
|
|
29
29
|
"peerDependencies": {
|
|
30
30
|
"@ossy/design-system": ">=1.0.0",
|
|
@@ -45,5 +45,5 @@
|
|
|
45
45
|
"/src",
|
|
46
46
|
"README.md"
|
|
47
47
|
],
|
|
48
|
-
"gitHead": "
|
|
48
|
+
"gitHead": "23167600f991596f122765a53f2b9df08314e115"
|
|
49
49
|
}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import React, { useEffect } from 'react'
|
|
2
2
|
import { AsyncStatus, cacheKey, useReadCacheStore } from '@ossy/sdk-react'
|
|
3
3
|
import { metadata as GetSchemas } from './get-schemas.action.js'
|
|
4
|
+
import { mergeWorkspaceSchemas } from './merge-workspace-schemas.js'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Seed the GetSchemas read cache from SSR props so schema lookups work on first paint.
|
|
8
|
+
* If a client get-schemas response already landed but omitted system schemas (empty
|
|
9
|
+
* inlined registry), merge SSR schemas back in so create forms keep their fields.
|
|
7
10
|
*/
|
|
8
11
|
export function SchemasReadBootstrap ({ schemas }) {
|
|
9
12
|
const store = useReadCacheStore()
|
|
@@ -12,7 +15,17 @@ export function SchemasReadBootstrap ({ schemas }) {
|
|
|
12
15
|
if (!Array.isArray(schemas) || schemas.length === 0) return
|
|
13
16
|
const key = cacheKey(GetSchemas)
|
|
14
17
|
const entry = store.getEntry(key)
|
|
15
|
-
if (entry.status === AsyncStatus.Success)
|
|
18
|
+
if (entry.status === AsyncStatus.Success && Array.isArray(entry.data)) {
|
|
19
|
+
const cachedIds = new Set(entry.data.map((s) => s?.id).filter(Boolean))
|
|
20
|
+
const missing = schemas.some((s) => s?.id && !cachedIds.has(s.id))
|
|
21
|
+
if (!missing) return
|
|
22
|
+
store.setEntry(key, {
|
|
23
|
+
status: AsyncStatus.Success,
|
|
24
|
+
data: mergeWorkspaceSchemas(schemas, entry.data),
|
|
25
|
+
error: null,
|
|
26
|
+
})
|
|
27
|
+
return
|
|
28
|
+
}
|
|
16
29
|
store.setEntry(key, { status: AsyncStatus.Success, data: schemas, error: null })
|
|
17
30
|
}, [schemas, store])
|
|
18
31
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
1
2
|
import jwt from 'jsonwebtoken'
|
|
2
3
|
import { Aggregate } from '@ossy/event-store'
|
|
3
4
|
import { UserSchema } from '@ossy/users/server'
|
|
@@ -21,6 +22,26 @@ function verifyToken(token) {
|
|
|
21
22
|
})
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
function createWebAuthSession (userId) {
|
|
26
|
+
const resourceId = randomUUID()
|
|
27
|
+
const signInExpiresIn = ConfigService.TokenValidity
|
|
28
|
+
const signInExpiresAt = Date.now() + signInExpiresIn * 1000
|
|
29
|
+
const signInToken = jwt.sign(
|
|
30
|
+
{ sub: userId, type: 'WebAuth', jti: resourceId },
|
|
31
|
+
ConfigService.TokenSecret,
|
|
32
|
+
{ expiresIn: signInExpiresIn },
|
|
33
|
+
)
|
|
34
|
+
const signInEvent = TokenEvents.Created({
|
|
35
|
+
resourceId,
|
|
36
|
+
type: 'WebAuth',
|
|
37
|
+
subject: userId,
|
|
38
|
+
createdBy: userId,
|
|
39
|
+
token: signInToken,
|
|
40
|
+
expiresAt: signInExpiresAt,
|
|
41
|
+
})
|
|
42
|
+
return { signInToken, signInExpiresAt, signInEvent }
|
|
43
|
+
}
|
|
44
|
+
|
|
24
45
|
export async function run({ payload, req }) {
|
|
25
46
|
const workspaceId = payload?.workspaceId ?? req?.workspaceId
|
|
26
47
|
const verificationToken = payload?.token ?? req?.query?.token
|
|
@@ -45,41 +66,28 @@ export async function run({ payload, req }) {
|
|
|
45
66
|
|
|
46
67
|
if (existingUser) {
|
|
47
68
|
userId = existingUser.id
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const signInEvent = TokenEvents.Created({
|
|
52
|
-
type: 'WebAuth',
|
|
53
|
-
subject: userId,
|
|
54
|
-
createdBy: userId,
|
|
55
|
-
token: signInToken,
|
|
56
|
-
expiresAt: signInExpiresAt,
|
|
57
|
-
})
|
|
69
|
+
const session = createWebAuthSession(userId)
|
|
70
|
+
signInToken = session.signInToken
|
|
71
|
+
signInExpiresAt = session.signInExpiresAt
|
|
58
72
|
const userInvitationAccepted = WorkspacesEvents.UserInvitationAccepted({ email, createdBy: userId })
|
|
59
73
|
await Promise.all([
|
|
60
|
-
Aggregate.Of(Token, signInEvent),
|
|
74
|
+
Aggregate.Of(Token, session.signInEvent),
|
|
61
75
|
Aggregate.Of(Workspace, resolvedWorkspaceId).then(Aggregate.Add(userInvitationAccepted)),
|
|
62
76
|
])
|
|
63
77
|
} else {
|
|
64
78
|
const signUpEvent = UsersEvents.Created({ email })
|
|
65
79
|
userId = signUpEvent.createdBy
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const signInEvent = TokenEvents.Created({
|
|
70
|
-
type: 'WebAuth',
|
|
71
|
-
subject: userId,
|
|
72
|
-
createdBy: userId,
|
|
73
|
-
token: signInToken,
|
|
74
|
-
expiresAt: signInExpiresAt,
|
|
75
|
-
})
|
|
80
|
+
const session = createWebAuthSession(userId)
|
|
81
|
+
signInToken = session.signInToken
|
|
82
|
+
signInExpiresAt = session.signInExpiresAt
|
|
76
83
|
const userInvitationAccepted = WorkspacesEvents.UserInvitationAccepted({ email, createdBy: userId })
|
|
77
84
|
await Promise.all([
|
|
78
85
|
Aggregate.Of(User, signUpEvent),
|
|
79
|
-
Aggregate.Of(Token, signInEvent),
|
|
86
|
+
Aggregate.Of(Token, session.signInEvent),
|
|
80
87
|
Aggregate.Of(Workspace, resolvedWorkspaceId).then(Aggregate.Add(userInvitationAccepted)),
|
|
81
88
|
])
|
|
82
89
|
}
|
|
83
90
|
|
|
91
|
+
log.info('Invitation accepted; WebAuth session issued', { userId, workspaceId: resolvedWorkspaceId })
|
|
84
92
|
return { authToken: signInToken, authExpiresAt: signInExpiresAt, workspaceId: resolvedWorkspaceId }
|
|
85
93
|
}
|
package/src/get-schemas.task.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { Aggregate } from '@ossy/event-store'
|
|
2
2
|
import { Workspace } from '@ossy/workspaces/server'
|
|
3
|
-
|
|
3
|
+
// Use the system-schemas subpath so Rollup can externalize the registry singleton
|
|
4
|
+
// (same module `registerSchema` fills at server boot). A bare `@ossy/schema` import
|
|
5
|
+
// gets inlined with an empty `systemSchemas` array and new manifest schemas vanish.
|
|
6
|
+
import { getSystemSchemas } from '@ossy/schema/system-schemas'
|
|
4
7
|
import { mergeWorkspaceSchemas } from './merge-workspace-schemas.js'
|
|
5
8
|
|
|
6
9
|
export const metadata = { id: '@ossy/workspaces/tasks/get-schemas' }
|
package/src/invite-user.task.js
CHANGED
|
@@ -5,12 +5,11 @@ import { Workspace, WorkspacesEvents } from '@ossy/workspaces/server'
|
|
|
5
5
|
import { Token, TokenEvents } from '@ossy/tokens/server'
|
|
6
6
|
import { EmailRenderer } from '@ossy/email'
|
|
7
7
|
import { ConfigService } from '@ossy/config'
|
|
8
|
+
import { isEmailLike } from '@ossy/schema'
|
|
8
9
|
import WorkspaceInvitationEmail, { subject as workspaceInvitationSubject } from './workspace-invitation.email.jsx'
|
|
9
10
|
|
|
10
11
|
export const metadata = { id: '@ossy/workspaces/tasks/invite-user' }
|
|
11
12
|
|
|
12
|
-
const isEmailLike = maybeEmail => /.@.+\.../.test(maybeEmail)
|
|
13
|
-
|
|
14
13
|
function createVerificationToken(workspaceId) {
|
|
15
14
|
const expiresIn = ConfigService.TokenValidity
|
|
16
15
|
return jwt.sign({ aud: workspaceId }, ConfigService.TokenSecret, { expiresIn })
|
package/src/server.js
CHANGED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals'
|
|
2
|
+
import { Workspace } from './workspace.aggregate.js'
|
|
3
|
+
import { WorkspacesEvents } from './workspaces.events.js'
|
|
4
|
+
import { WorkspaceSchema } from './schema-ids.js'
|
|
5
|
+
|
|
6
|
+
const NOW = 1_700_000_000_000
|
|
7
|
+
const DAY = 1000 * 60 * 60 * 24
|
|
8
|
+
const WEEK = DAY * 7
|
|
9
|
+
|
|
10
|
+
function createdEvent (overrides = {}) {
|
|
11
|
+
return {
|
|
12
|
+
...WorkspacesEvents.Created({
|
|
13
|
+
resourceId: 'ws-1',
|
|
14
|
+
createdBy: 'user-1',
|
|
15
|
+
name: 'Acme',
|
|
16
|
+
}),
|
|
17
|
+
created: NOW,
|
|
18
|
+
...overrides,
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function withMeta (event, overrides = {}) {
|
|
23
|
+
return {
|
|
24
|
+
...event,
|
|
25
|
+
created: NOW + 1000,
|
|
26
|
+
...overrides,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('Workspace.View', () => {
|
|
31
|
+
|
|
32
|
+
describe('empty event list', () => {
|
|
33
|
+
it('returns savedState with normalized empty services when no events are given', () => {
|
|
34
|
+
expect(Workspace.View([], { id: 'ws-1', extra: 'data' })).toEqual({
|
|
35
|
+
id: 'ws-1',
|
|
36
|
+
extra: 'data',
|
|
37
|
+
services: {},
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('uses {} as the default savedState', () => {
|
|
42
|
+
expect(Workspace.View([])).toEqual({ services: {} })
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('Created event', () => {
|
|
47
|
+
it('sets id from resourceId', () => {
|
|
48
|
+
expect(Workspace.View([createdEvent()]).id).toBe('ws-1')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('sets type from the workspace schema', () => {
|
|
52
|
+
expect(Workspace.View([createdEvent()]).type).toBe(WorkspaceSchema.workspace)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('sets name from payload', () => {
|
|
56
|
+
expect(Workspace.View([createdEvent()]).name).toBe('Acme')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('sets createdBy and created from the event', () => {
|
|
60
|
+
const result = Workspace.View([createdEvent()])
|
|
61
|
+
expect(result.createdBy).toBe('user-1')
|
|
62
|
+
expect(result.created).toBe(NOW)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('initializes users with the creator and empty collections', () => {
|
|
66
|
+
const result = Workspace.View([createdEvent()])
|
|
67
|
+
expect(result.users).toEqual(['user-1'])
|
|
68
|
+
expect(result.invitations).toEqual([])
|
|
69
|
+
expect(result.apiTokens).toEqual([])
|
|
70
|
+
expect(result.schemas).toEqual([])
|
|
71
|
+
expect(result.services).toEqual({})
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe('SchemasImported', () => {
|
|
76
|
+
it('replaces schemas from the payload', () => {
|
|
77
|
+
const schemas = [
|
|
78
|
+
{ id: '@ossy/booking/schema/booking', label: 'Booking' },
|
|
79
|
+
]
|
|
80
|
+
const result = Workspace.View([
|
|
81
|
+
createdEvent(),
|
|
82
|
+
withMeta(WorkspacesEvents.SchemasImported({
|
|
83
|
+
createdBy: 'user-1',
|
|
84
|
+
schemas,
|
|
85
|
+
})),
|
|
86
|
+
])
|
|
87
|
+
expect(result.schemas).toEqual(schemas)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('treats missing schemas as an empty list', () => {
|
|
91
|
+
const result = Workspace.View([
|
|
92
|
+
createdEvent(),
|
|
93
|
+
withMeta({
|
|
94
|
+
event: 'SchemasImported',
|
|
95
|
+
createdBy: 'user-1',
|
|
96
|
+
payload: {},
|
|
97
|
+
}),
|
|
98
|
+
])
|
|
99
|
+
expect(result.schemas).toEqual([])
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('UserInvited', () => {
|
|
104
|
+
it('appends a pending invitation that has not expired', () => {
|
|
105
|
+
const expiresAt = Date.now() + DAY
|
|
106
|
+
const result = Workspace.View([
|
|
107
|
+
createdEvent(),
|
|
108
|
+
withMeta(WorkspacesEvents.UserInvited({
|
|
109
|
+
createdBy: 'user-1',
|
|
110
|
+
email: 'new@example.com',
|
|
111
|
+
expiresAt,
|
|
112
|
+
}), { created: NOW + 2000 }),
|
|
113
|
+
])
|
|
114
|
+
expect(result.invitations).toEqual([{
|
|
115
|
+
email: 'new@example.com',
|
|
116
|
+
invitedAt: NOW + 2000,
|
|
117
|
+
expiresAt,
|
|
118
|
+
expired: false,
|
|
119
|
+
}])
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('keeps recently expired invitations marked as expired', () => {
|
|
123
|
+
const expiresAt = Date.now() - DAY
|
|
124
|
+
const result = Workspace.View([
|
|
125
|
+
createdEvent(),
|
|
126
|
+
withMeta(WorkspacesEvents.UserInvited({
|
|
127
|
+
createdBy: 'user-1',
|
|
128
|
+
email: 'late@example.com',
|
|
129
|
+
expiresAt,
|
|
130
|
+
}), { created: NOW + 2000 }),
|
|
131
|
+
])
|
|
132
|
+
expect(result.invitations).toHaveLength(1)
|
|
133
|
+
expect(result.invitations[0]).toMatchObject({
|
|
134
|
+
email: 'late@example.com',
|
|
135
|
+
expired: true,
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('hides invitations expired more than one week ago', () => {
|
|
140
|
+
const expiresAt = Date.now() - WEEK - DAY
|
|
141
|
+
const result = Workspace.View([
|
|
142
|
+
createdEvent(),
|
|
143
|
+
withMeta(WorkspacesEvents.UserInvited({
|
|
144
|
+
createdBy: 'user-1',
|
|
145
|
+
email: 'gone@example.com',
|
|
146
|
+
expiresAt,
|
|
147
|
+
})),
|
|
148
|
+
])
|
|
149
|
+
expect(result.invitations).toEqual([])
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
describe('UserInvitationAccepted', () => {
|
|
154
|
+
it('adds the accepting user and removes their invitation', () => {
|
|
155
|
+
const expiresAt = Date.now() + DAY
|
|
156
|
+
const result = Workspace.View([
|
|
157
|
+
createdEvent(),
|
|
158
|
+
withMeta(WorkspacesEvents.UserInvited({
|
|
159
|
+
createdBy: 'user-1',
|
|
160
|
+
email: 'new@example.com',
|
|
161
|
+
expiresAt,
|
|
162
|
+
})),
|
|
163
|
+
withMeta(WorkspacesEvents.UserInvitationAccepted({
|
|
164
|
+
createdBy: 'user-2',
|
|
165
|
+
email: 'new@example.com',
|
|
166
|
+
}), { created: NOW + 3000 }),
|
|
167
|
+
])
|
|
168
|
+
expect(result.users).toEqual(['user-1', 'user-2'])
|
|
169
|
+
expect(result.invitations).toEqual([])
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
describe('ApiTokenCreated', () => {
|
|
174
|
+
it('appends a token using the event id and description', () => {
|
|
175
|
+
const result = Workspace.View([
|
|
176
|
+
createdEvent(),
|
|
177
|
+
withMeta(WorkspacesEvents.ApiTokenCreated({
|
|
178
|
+
createdBy: 'user-1',
|
|
179
|
+
description: 'CI token',
|
|
180
|
+
token: 'secret',
|
|
181
|
+
}), { id: 'token-1' }),
|
|
182
|
+
])
|
|
183
|
+
expect(result.apiTokens).toEqual([
|
|
184
|
+
{ id: 'token-1', description: 'CI token' },
|
|
185
|
+
])
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
describe('ServiceEnabled and ServiceDisabled', () => {
|
|
190
|
+
it('enables a service under its npm package key', () => {
|
|
191
|
+
const result = Workspace.View([
|
|
192
|
+
createdEvent(),
|
|
193
|
+
withMeta(WorkspacesEvents.ServiceEnabled({
|
|
194
|
+
createdBy: 'user-1',
|
|
195
|
+
service: '@ossy/booking',
|
|
196
|
+
})),
|
|
197
|
+
])
|
|
198
|
+
expect(result.services).toEqual({
|
|
199
|
+
'@ossy/booking': { enabled: true },
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('disables a previously enabled service', () => {
|
|
204
|
+
const result = Workspace.View([
|
|
205
|
+
createdEvent(),
|
|
206
|
+
withMeta(WorkspacesEvents.ServiceEnabled({
|
|
207
|
+
createdBy: 'user-1',
|
|
208
|
+
service: '@ossy/booking',
|
|
209
|
+
})),
|
|
210
|
+
withMeta(WorkspacesEvents.ServiceDisabled({
|
|
211
|
+
createdBy: 'user-1',
|
|
212
|
+
service: '@ossy/booking',
|
|
213
|
+
}), { created: NOW + 4000 }),
|
|
214
|
+
])
|
|
215
|
+
expect(result.services).toEqual({
|
|
216
|
+
'@ossy/booking': { enabled: false },
|
|
217
|
+
})
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('drops non-scoped service keys via normalizeWorkspaceServices', () => {
|
|
221
|
+
const result = Workspace.View([
|
|
222
|
+
createdEvent(),
|
|
223
|
+
withMeta(WorkspacesEvents.ServiceEnabled({
|
|
224
|
+
createdBy: 'user-1',
|
|
225
|
+
service: 'booking',
|
|
226
|
+
})),
|
|
227
|
+
])
|
|
228
|
+
expect(result.services).toEqual({})
|
|
229
|
+
})
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
describe('UserRemoved', () => {
|
|
233
|
+
it('removes the user from the workspace members list', () => {
|
|
234
|
+
const result = Workspace.View([
|
|
235
|
+
createdEvent(),
|
|
236
|
+
withMeta(WorkspacesEvents.UserInvitationAccepted({
|
|
237
|
+
createdBy: 'user-2',
|
|
238
|
+
email: 'new@example.com',
|
|
239
|
+
})),
|
|
240
|
+
withMeta(WorkspacesEvents.UserRemoved({
|
|
241
|
+
createdBy: 'user-1',
|
|
242
|
+
userId: 'user-2',
|
|
243
|
+
}), { created: NOW + 5000 }),
|
|
244
|
+
])
|
|
245
|
+
expect(result.users).toEqual(['user-1'])
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
describe('unknown event type', () => {
|
|
250
|
+
it('ignores unknown events and returns state unchanged aside from services normalization', () => {
|
|
251
|
+
const unknown = { event: 'SomethingElse', payload: {} }
|
|
252
|
+
const result = Workspace.View([createdEvent(), unknown])
|
|
253
|
+
expect(result.name).toBe('Acme')
|
|
254
|
+
expect(result.users).toEqual(['user-1'])
|
|
255
|
+
})
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
describe('savedState merging', () => {
|
|
259
|
+
it('merges events on top of a provided savedState', () => {
|
|
260
|
+
const savedState = {
|
|
261
|
+
id: 'ws-1',
|
|
262
|
+
name: 'Acme',
|
|
263
|
+
users: ['user-1'],
|
|
264
|
+
invitations: [],
|
|
265
|
+
services: { '@ossy/booking': { enabled: true } },
|
|
266
|
+
}
|
|
267
|
+
const result = Workspace.View([
|
|
268
|
+
withMeta(WorkspacesEvents.UserRemoved({
|
|
269
|
+
createdBy: 'user-1',
|
|
270
|
+
userId: 'user-1',
|
|
271
|
+
})),
|
|
272
|
+
], savedState)
|
|
273
|
+
expect(result.users).toEqual([])
|
|
274
|
+
expect(result.services).toEqual({
|
|
275
|
+
'@ossy/booking': { enabled: true },
|
|
276
|
+
})
|
|
277
|
+
expect(result.name).toBe('Acme')
|
|
278
|
+
})
|
|
279
|
+
})
|
|
280
|
+
})
|