@cedarjs/cli 8.0.0-canary.3547 → 8.0.0-canary.3550
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/dist/commands/setup/tenancy/schemaPrisma.js +14 -12
- package/dist/commands/setup/tenancy/templates/organizations.scenarios.ts.template +15 -1
- package/dist/commands/setup/tenancy/templates/organizations.sdl.ts.template +4 -1
- package/dist/commands/setup/tenancy/templates/organizations.test.ts.template +49 -0
- package/dist/commands/setup/tenancy/templates/organizations.ts.template +45 -6
- package/package.json +13 -13
|
@@ -7,19 +7,21 @@ const ORGANIZATION_MODEL = `model Organization {
|
|
|
7
7
|
memberships Membership[]
|
|
8
8
|
}`;
|
|
9
9
|
const MEMBERSHIP_MODEL = `model Membership {
|
|
10
|
-
id
|
|
11
|
-
role
|
|
10
|
+
id String @id @default(cuid())
|
|
11
|
+
role String
|
|
12
12
|
// Null while the membership is a pending invitation.
|
|
13
|
-
userId
|
|
14
|
-
organizationId
|
|
15
|
-
invitedById
|
|
16
|
-
invitationToken
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
13
|
+
userId String?
|
|
14
|
+
organizationId String
|
|
15
|
+
invitedById String?
|
|
16
|
+
invitationToken String? @unique
|
|
17
|
+
// When a pending invitation stops being claimable.
|
|
18
|
+
invitationExpiresAt DateTime?
|
|
19
|
+
createdAt DateTime @default(now())
|
|
20
|
+
updatedAt DateTime @updatedAt
|
|
21
|
+
user User? @relation(fields: [userId], references: [id])
|
|
22
|
+
organization Organization @relation(fields: [organizationId], references: [id])
|
|
23
|
+
invitedBy Membership? @relation("Inviter", fields: [invitedById], references: [id])
|
|
24
|
+
invitees Membership[] @relation("Inviter")
|
|
23
25
|
|
|
24
26
|
@@unique([userId, organizationId])
|
|
25
27
|
@@index([organizationId])
|
|
@@ -48,6 +48,16 @@ export const standard = defineScenario<
|
|
|
48
48
|
data: {
|
|
49
49
|
role: 'admin',
|
|
50
50
|
invitationToken: 'invite-token',
|
|
51
|
+
invitationExpiresAt: new Date('2999-01-01T00:00:00Z'),
|
|
52
|
+
organization: { connect: { slug: 'acme-inc' } },
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
// A pending invitation whose expiry has passed.
|
|
56
|
+
expired: {
|
|
57
|
+
data: {
|
|
58
|
+
role: 'member',
|
|
59
|
+
invitationToken: 'expired-token',
|
|
60
|
+
invitationExpiresAt: new Date('2000-01-01T00:00:00Z'),
|
|
51
61
|
organization: { connect: { slug: 'acme-inc' } },
|
|
52
62
|
},
|
|
53
63
|
},
|
|
@@ -55,4 +65,8 @@ export const standard = defineScenario<
|
|
|
55
65
|
})
|
|
56
66
|
|
|
57
67
|
export type StandardScenario = ScenarioData<Organization, 'organization', 'acme'> &
|
|
58
|
-
ScenarioData<
|
|
68
|
+
ScenarioData<
|
|
69
|
+
Membership,
|
|
70
|
+
'membership',
|
|
71
|
+
'owner' | 'admin' | 'pending' | 'expired'
|
|
72
|
+
>
|
|
@@ -30,6 +30,8 @@ export const schema = gql`
|
|
|
30
30
|
type Invitation {
|
|
31
31
|
membership: Membership!
|
|
32
32
|
invitationToken: String!
|
|
33
|
+
"When the invitation stops being claimable."
|
|
34
|
+
invitationExpiresAt: DateTime!
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
input CreateOrganizationInput {
|
|
@@ -61,6 +63,7 @@ export const schema = gql`
|
|
|
61
63
|
organizations.ts) -- an admin can invite an admin or a member, but not an
|
|
62
64
|
owner. The app is responsible for emailing input.email a link built from
|
|
63
65
|
the returned invitationToken; Membership has no email column of its own.
|
|
66
|
+
The invitation can be claimed until the returned invitationExpiresAt.
|
|
64
67
|
Web callers must call reauthenticate() from useAuth() in onCompleted so
|
|
65
68
|
the invite shows up if the inviter is also the invitee.
|
|
66
69
|
"""
|
|
@@ -70,7 +73,7 @@ export const schema = gql`
|
|
|
70
73
|
"""
|
|
71
74
|
Attaches the pending membership matching token to the signed-in user, or
|
|
72
75
|
is a no-op if it is already attached to them. Throws if the token is
|
|
73
|
-
invalid or was already claimed by someone else.
|
|
76
|
+
invalid, expired or was already claimed by someone else.
|
|
74
77
|
Web callers must call reauthenticate() from useAuth() in onCompleted so
|
|
75
78
|
the new membership is reflected in currentUser.memberships.
|
|
76
79
|
"""
|
|
@@ -99,6 +99,27 @@ describe('organizations service', () => {
|
|
|
99
99
|
},
|
|
100
100
|
)
|
|
101
101
|
|
|
102
|
+
scenario(
|
|
103
|
+
'sets an expiry in the future',
|
|
104
|
+
async (scenario: StandardScenario) => {
|
|
105
|
+
asMember(scenario, scenario.membership.owner)
|
|
106
|
+
|
|
107
|
+
const result = await inviteMember({
|
|
108
|
+
input: { email: 'new-member@example.com', role: 'member' },
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
const created = await db.membership.findFirst({
|
|
112
|
+
where: { invitationToken: result.invitationToken },
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
expect(created?.invitationExpiresAt).toBeInstanceOf(Date)
|
|
116
|
+
expect(created!.invitationExpiresAt!.getTime()).toBeGreaterThan(
|
|
117
|
+
Date.now(),
|
|
118
|
+
)
|
|
119
|
+
expect(result.invitationExpiresAt).toEqual(created?.invitationExpiresAt)
|
|
120
|
+
},
|
|
121
|
+
)
|
|
122
|
+
|
|
102
123
|
scenario(
|
|
103
124
|
'an admin cannot invite an owner',
|
|
104
125
|
async (scenario: StandardScenario) => {
|
|
@@ -316,6 +337,34 @@ describe('organizations service', () => {
|
|
|
316
337
|
},
|
|
317
338
|
)
|
|
318
339
|
|
|
340
|
+
scenario(
|
|
341
|
+
'rejects an expired invitation and leaves it unclaimed',
|
|
342
|
+
async (scenario: StandardScenario) => {
|
|
343
|
+
const invitee = await db.user.create({
|
|
344
|
+
data: {
|
|
345
|
+
email: 'invitee@example.com',
|
|
346
|
+
hashedPassword: 'x',
|
|
347
|
+
salt: 'x',
|
|
348
|
+
},
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
const token = scenario.membership.expired.invitationToken!
|
|
352
|
+
|
|
353
|
+
mockCurrentUser({ id: invitee.id })
|
|
354
|
+
|
|
355
|
+
await expect(acceptInvitation({ token })).rejects.toThrow(
|
|
356
|
+
/has expired/,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
const row = await db.membership.findUnique({
|
|
360
|
+
where: { id: scenario.membership.expired.id },
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
expect(row?.userId).toBeNull()
|
|
364
|
+
expect(row?.invitationToken).toEqual(token)
|
|
365
|
+
},
|
|
366
|
+
)
|
|
367
|
+
|
|
319
368
|
it('rejects a token that never matched a pending invitation', async () => {
|
|
320
369
|
mockCurrentUser({ id: 'some-user-id' })
|
|
321
370
|
|
|
@@ -11,6 +11,12 @@ import { requireCurrentOrg } from '@cedarjs/tenancy'
|
|
|
11
11
|
|
|
12
12
|
import { Prisma, db } from 'src/lib/db'
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* How long an invitation stays claimable after `inviteMember` creates it.
|
|
16
|
+
* Change it if seven days does not suit the app.
|
|
17
|
+
*/
|
|
18
|
+
const INVITATION_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000
|
|
19
|
+
|
|
14
20
|
interface MembershipSummary {
|
|
15
21
|
id: string
|
|
16
22
|
organizationId: string
|
|
@@ -143,7 +149,9 @@ export const createOrganization: MutationResolvers['createOrganization'] =
|
|
|
143
149
|
* `Membership` has no email column: the caller is responsible for emailing
|
|
144
150
|
* `input.email` a link built from the returned `invitationToken`, with its
|
|
145
151
|
* own mailer. The token is returned here and nowhere else -- see the SDL
|
|
146
|
-
* doc comment on `Invitation` for why.
|
|
152
|
+
* doc comment on `Invitation` for why. The invitation can be claimed until
|
|
153
|
+
* `invitationExpiresAt`, which is returned with the token so the email can
|
|
154
|
+
* say when the link stops working.
|
|
147
155
|
*/
|
|
148
156
|
export const inviteMember: MutationResolvers['inviteMember'] = async ({
|
|
149
157
|
input,
|
|
@@ -164,6 +172,7 @@ export const inviteMember: MutationResolvers['inviteMember'] = async ({
|
|
|
164
172
|
}
|
|
165
173
|
|
|
166
174
|
const invitationToken = crypto.randomUUID()
|
|
175
|
+
const invitationExpiresAt = new Date(Date.now() + INVITATION_LIFETIME_MS)
|
|
167
176
|
|
|
168
177
|
const membership = await db.membership.create({
|
|
169
178
|
data: {
|
|
@@ -171,10 +180,11 @@ export const inviteMember: MutationResolvers['inviteMember'] = async ({
|
|
|
171
180
|
organizationId: currentOrg.id,
|
|
172
181
|
invitedById: currentOrg.membershipId,
|
|
173
182
|
invitationToken,
|
|
183
|
+
invitationExpiresAt,
|
|
174
184
|
},
|
|
175
185
|
})
|
|
176
186
|
|
|
177
|
-
return { membership, invitationToken }
|
|
187
|
+
return { membership, invitationToken, invitationExpiresAt }
|
|
178
188
|
}
|
|
179
189
|
|
|
180
190
|
/**
|
|
@@ -203,16 +213,23 @@ interface ClaimedInvitation {
|
|
|
203
213
|
* `updateManyAndReturn` because Prisma only supports the latter on
|
|
204
214
|
* PostgreSQL, CockroachDB and SQLite.
|
|
205
215
|
*
|
|
216
|
+
* The same guard refuses an expired invitation: it only matches while
|
|
217
|
+
* `invitationExpiresAt` is in the future.
|
|
218
|
+
*
|
|
206
219
|
* Returns the claimed membership's organization id when `userId` holds the
|
|
207
220
|
* membership, whether this call claimed it or an earlier one did, or `null`
|
|
208
|
-
* when the token is invalid or was claimed by a different user.
|
|
221
|
+
* when the token is invalid, expired, or was claimed by a different user.
|
|
209
222
|
*/
|
|
210
223
|
const claimInvitation = async (
|
|
211
224
|
token: string,
|
|
212
225
|
userId: string,
|
|
213
226
|
): Promise<ClaimedInvitation | null> => {
|
|
214
227
|
await db.membership.updateMany({
|
|
215
|
-
where: {
|
|
228
|
+
where: {
|
|
229
|
+
invitationToken: token,
|
|
230
|
+
userId: null,
|
|
231
|
+
invitationExpiresAt: { gt: new Date() },
|
|
232
|
+
},
|
|
216
233
|
data: { userId, invitationToken: spentInvitationToken(token) },
|
|
217
234
|
})
|
|
218
235
|
|
|
@@ -223,11 +240,29 @@ const claimInvitation = async (
|
|
|
223
240
|
return claimed ? { organizationId: claimed.organizationId } : null
|
|
224
241
|
}
|
|
225
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Whether `token` belongs to an invitation that is still unclaimed but past
|
|
245
|
+
* its expiry. Only used to pick the error message after a failed claim; the
|
|
246
|
+
* guard in `claimInvitation` is what decides.
|
|
247
|
+
*/
|
|
248
|
+
const isExpiredInvitation = async (token: string): Promise<boolean> => {
|
|
249
|
+
const pending = await db.membership.findFirst({
|
|
250
|
+
where: {
|
|
251
|
+
invitationToken: token,
|
|
252
|
+
userId: null,
|
|
253
|
+
invitationExpiresAt: { lte: new Date() },
|
|
254
|
+
},
|
|
255
|
+
select: { id: true },
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
return pending !== null
|
|
259
|
+
}
|
|
260
|
+
|
|
226
261
|
/**
|
|
227
262
|
* Attaches the pending membership matching `token` to the signed-in user.
|
|
228
263
|
* Idempotent when it is already attached to them; throws when the token
|
|
229
264
|
* does not match a pending (unclaimed) membership, including when someone
|
|
230
|
-
* else has already claimed it.
|
|
265
|
+
* else has already claimed it or the invitation has expired.
|
|
231
266
|
*/
|
|
232
267
|
export const acceptInvitation: MutationResolvers['acceptInvitation'] = async ({
|
|
233
268
|
token,
|
|
@@ -238,7 +273,11 @@ export const acceptInvitation: MutationResolvers['acceptInvitation'] = async ({
|
|
|
238
273
|
const claim = await claimInvitation(token, userId)
|
|
239
274
|
|
|
240
275
|
if (!claim) {
|
|
241
|
-
throw new ValidationError(
|
|
276
|
+
throw new ValidationError(
|
|
277
|
+
(await isExpiredInvitation(token))
|
|
278
|
+
? 'This invitation has expired. Ask for a new one.'
|
|
279
|
+
: 'This invitation is invalid or has already been used.',
|
|
280
|
+
)
|
|
242
281
|
}
|
|
243
282
|
|
|
244
283
|
return db.organization.findUniqueOrThrow({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cedarjs/cli",
|
|
3
|
-
"version": "8.0.0-canary.
|
|
3
|
+
"version": "8.0.0-canary.3550",
|
|
4
4
|
"description": "The CedarJS Command Line",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -36,17 +36,17 @@
|
|
|
36
36
|
"@babel/preset-typescript": "7.29.7",
|
|
37
37
|
"@babel/traverse": "7.29.10",
|
|
38
38
|
"@babel/types": "7.29.11",
|
|
39
|
-
"@cedarjs/api-server": "8.0.0-canary.
|
|
40
|
-
"@cedarjs/babel-config": "8.0.0-canary.
|
|
41
|
-
"@cedarjs/cli-helpers": "8.0.0-canary.
|
|
42
|
-
"@cedarjs/internal": "8.0.0-canary.
|
|
43
|
-
"@cedarjs/prerender": "8.0.0-canary.
|
|
44
|
-
"@cedarjs/project-config": "8.0.0-canary.
|
|
45
|
-
"@cedarjs/structure": "8.0.0-canary.
|
|
46
|
-
"@cedarjs/telemetry": "8.0.0-canary.
|
|
47
|
-
"@cedarjs/utils": "8.0.0-canary.
|
|
48
|
-
"@cedarjs/vite": "8.0.0-canary.
|
|
49
|
-
"@cedarjs/web-server": "8.0.0-canary.
|
|
39
|
+
"@cedarjs/api-server": "8.0.0-canary.3550",
|
|
40
|
+
"@cedarjs/babel-config": "8.0.0-canary.3550",
|
|
41
|
+
"@cedarjs/cli-helpers": "8.0.0-canary.3550",
|
|
42
|
+
"@cedarjs/internal": "8.0.0-canary.3550",
|
|
43
|
+
"@cedarjs/prerender": "8.0.0-canary.3550",
|
|
44
|
+
"@cedarjs/project-config": "8.0.0-canary.3550",
|
|
45
|
+
"@cedarjs/structure": "8.0.0-canary.3550",
|
|
46
|
+
"@cedarjs/telemetry": "8.0.0-canary.3550",
|
|
47
|
+
"@cedarjs/utils": "8.0.0-canary.3550",
|
|
48
|
+
"@cedarjs/vite": "8.0.0-canary.3550",
|
|
49
|
+
"@cedarjs/web-server": "8.0.0-canary.3550",
|
|
50
50
|
"@listr2/prompt-adapter-enquirer": "4.4.3",
|
|
51
51
|
"@opentelemetry/api": "1.9.1",
|
|
52
52
|
"@opentelemetry/core": "2.12.0",
|
|
@@ -93,7 +93,7 @@
|
|
|
93
93
|
"yargs": "18.2.0"
|
|
94
94
|
},
|
|
95
95
|
"devDependencies": {
|
|
96
|
-
"@cedarjs/framework-tools": "8.0.0-canary.
|
|
96
|
+
"@cedarjs/framework-tools": "8.0.0-canary.3550",
|
|
97
97
|
"@prisma/dmmf": "7.10.1",
|
|
98
98
|
"@types/archiver": "^7.0.0",
|
|
99
99
|
"memfs": "4.80.0",
|