@ossy/users 3.11.0 → 3.12.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/users",
3
3
  "description": "User domain - aggregate, events, and validators for the Ossy user model",
4
- "version": "3.11.0",
4
+ "version": "3.12.0",
5
5
  "private": false,
6
6
  "type": "module",
7
7
  "main": "./src/index.js",
@@ -11,7 +11,8 @@
11
11
  "./server": "./src/server.js",
12
12
  "./update-email.flow.js": "./src/update-email.flow.js",
13
13
  "./update-profile-name.flow.js": "./src/update-profile-name.flow.js",
14
- "./update-profile-details.schema.js": "./src/update-profile-details.schema.js"
14
+ "./update-profile-details.schema.js": "./src/update-profile-details.schema.js",
15
+ "./complete-user-name.schema.js": "./src/complete-user-name.schema.js"
15
16
  },
16
17
  "scripts": {
17
18
  "test": "NODE_OPTIONS=--experimental-vm-modules jest --verbose",
@@ -49,5 +50,5 @@
49
50
  "/src",
50
51
  "README.md"
51
52
  ],
52
- "gitHead": "53ff8456920e09151c9d88df4a6c3ee958d811eb"
53
+ "gitHead": "c2650803219ad1831559b512848358d9550ffad2"
53
54
  }
@@ -0,0 +1,99 @@
1
+ import React, { useMemo, useState } from 'react'
2
+ import { View, Text, Button, Form, FieldFactory, FormStatus, useLocale } from '@ossy/design-system'
3
+ import { useRouter } from '@ossy/router-react'
4
+ import { Schema, contentValidator } from '@ossy/schema'
5
+ import { useSdk } from '@ossy/sdk-react'
6
+ import { SignOut } from '@ossy/authentication'
7
+ import { metadata as UpdateUserDetails } from './update-details.action.js'
8
+ import { metadata as CompleteUserNameAction } from './complete-user-name.action.js'
9
+ import { metadata as CompleteUserNameForm } from './complete-user-name.form.js'
10
+ import completeUserNameTemplate from './complete-user-name.schema.js'
11
+
12
+ /**
13
+ * Blocks product UI until a signed-in user has a first and last name.
14
+ */
15
+ export function CompleteUserName ({ onCompleted }) {
16
+ const { t } = useLocale()
17
+ const router = useRouter()
18
+ const sdk = useSdk()
19
+ const [saving, setSaving] = useState(false)
20
+ const [saveError, setSaveError] = useState(false)
21
+ const validate = useMemo(
22
+ () => contentValidator(
23
+ Schema.of({ schemas: [completeUserNameTemplate] }),
24
+ completeUserNameTemplate,
25
+ ),
26
+ [],
27
+ )
28
+
29
+ const signOff = () => {
30
+ const home = router.getHref('@home') || '/'
31
+ const redirect = encodeURIComponent(home)
32
+ window.location.href = `/api/v0/users/sign-off?redirect=${redirect}`
33
+ }
34
+
35
+ const saveName = (data) => {
36
+ setSaving(true)
37
+ setSaveError(false)
38
+ sdk.invoke(UpdateUserDetails, {
39
+ firstName: data.firstName,
40
+ lastName: data.lastName,
41
+ })
42
+ .then(() => onCompleted?.())
43
+ .catch(() => setSaveError(true))
44
+ .finally(() => setSaving(false))
45
+ }
46
+
47
+ return (
48
+ <View
49
+ layout="off-center-s"
50
+ inset="s"
51
+ surface="base"
52
+ style={{ height: '100%', minHeight: '100%' }}
53
+ data-flow-stage="complete-user-name"
54
+ >
55
+ <View inset="s" roundness="l" surface="primary" data-region="content" gap="l">
56
+ <Form
57
+ id={CompleteUserNameForm.id}
58
+ schemaId={CompleteUserNameForm.schemaId}
59
+ fields={completeUserNameTemplate.fields}
60
+ defaultData={{}}
61
+ onSubmit={saveName}
62
+ validate={validate}
63
+ status={saving ? [FormStatus.Submitting] : []}
64
+ gap="l"
65
+ >
66
+ <View gap="s">
67
+ <Text as="h1" variant="heading-tertiary" text="users.completeName.title" />
68
+ <Text text="users.completeName.description" />
69
+ </View>
70
+ {saveError && (
71
+ <View gap="xs" data-flow-stage="complete-user-name-error">
72
+ <Text as="h2" variant="heading-quaternary">
73
+ {t('users.completeName.errorTitle')}
74
+ </Text>
75
+ <Text>
76
+ {t('users.completeName.errorBody')}
77
+ </Text>
78
+ </View>
79
+ )}
80
+ <FieldFactory />
81
+ <View layout="row" gap="s" justifyContent="flex-end">
82
+ <Button
83
+ {...SignOut}
84
+ variant="link"
85
+ disabled={saving}
86
+ onClick={signOff}
87
+ />
88
+ <Button
89
+ {...CompleteUserNameAction}
90
+ type="submit"
91
+ variant="cta"
92
+ disabled={saving}
93
+ />
94
+ </View>
95
+ </Form>
96
+ </View>
97
+ </View>
98
+ )
99
+ }
@@ -0,0 +1,7 @@
1
+ /** Client-only — submits the signed-in display-name completion form. */
2
+ export const metadata = {
3
+ id: '@ossy/users/actions/complete-user-name',
4
+ access: 'authenticated',
5
+ icon: 'check',
6
+ label: '@ossy/users/actions/complete-user-name.label',
7
+ }
@@ -0,0 +1,5 @@
1
+ export const metadata = {
2
+ id: '@ossy/users/form/complete-user-name',
3
+ schemaId: '@ossy/users/schema/complete-user-name',
4
+ label: '@ossy/users/form/complete-user-name.label',
5
+ }
@@ -0,0 +1,10 @@
1
+ /** Required first/last name for signed-in users who were created email-only. */
2
+ export default {
3
+ id: '@ossy/users/schema/complete-user-name',
4
+ name: 'Complete user name',
5
+ icon: 'user',
6
+ fields: [
7
+ { name: 'firstName', type: 'text', required: true },
8
+ { name: 'lastName', type: 'text', required: true },
9
+ ],
10
+ }
@@ -37,5 +37,14 @@
37
37
  "users.verifyEmailChange.profileButton": "Back to profile",
38
38
  "users.emailChanged.subject": "Your Ossy email was changed",
39
39
  "users.emailChanged.title": "Email address updated",
40
- "users.emailChanged.body": "The email address on your Ossy account was changed. If you did not make this change, contact support."
40
+ "users.emailChanged.body": "The email address on your Ossy account was changed. If you did not make this change, contact support.",
41
+ "@ossy/users/actions/complete-user-name.label": "Continue",
42
+ "@ossy/users/actions/complete-user-name.description": "Save first and last name for the signed-in user",
43
+ "@ossy/users/form/complete-user-name.label": "Your name",
44
+ "@ossy/users/schema/complete-user-name.firstName.label": "First name",
45
+ "@ossy/users/schema/complete-user-name.lastName.label": "Last name",
46
+ "users.completeName.title": "What's your name?",
47
+ "users.completeName.description": "We use this on your profile and when you work with others.",
48
+ "users.completeName.errorTitle": "Could not save name",
49
+ "users.completeName.errorBody": "Something went wrong. Try again in a moment."
41
50
  }
package/src/index.js CHANGED
@@ -7,7 +7,11 @@ export { metadata as ConfirmEmailChange } from './confirm-email-change.action.js
7
7
  export { metadata as EditProfileDetails } from './edit-profile-details.action.js'
8
8
  export { metadata as SaveProfileDetails } from './save-profile-details.action.js'
9
9
  export { metadata as UpdateProfileDetailsForm } from './update-profile-details.form.js'
10
+ export { metadata as CompleteUserName } from './complete-user-name.action.js'
11
+ export { metadata as CompleteUserNameForm } from './complete-user-name.form.js'
10
12
  export { metadata as GetCurrentUserHistory } from './get-current-user-history.action.js'
11
13
  export { metadata as JoinWorkspace } from './join-workspace.action.js'
12
14
  export { metadata as LeaveWorkspace } from './leave-workspace.action.js'
13
15
  export { VerifyEmailChange } from './VerifyEmailChange.jsx'
16
+ export { CompleteUserName as CompleteUserNamePrompt } from './CompleteUserName.jsx'
17
+ export { outstandingUserRequirements, userNeedsDisplayName } from './user-requirements.js'
@@ -37,5 +37,14 @@
37
37
  "users.verifyEmailChange.profileButton": "Tillbaka till profil",
38
38
  "users.emailChanged.subject": "Din Ossy-e-post har ändrats",
39
39
  "users.emailChanged.title": "E-postadress uppdaterad",
40
- "users.emailChanged.body": "E-postadressen på ditt Ossy-konto har ändrats. Om du inte gjorde ändringen, kontakta support."
40
+ "users.emailChanged.body": "E-postadressen på ditt Ossy-konto har ändrats. Om du inte gjorde ändringen, kontakta support.",
41
+ "@ossy/users/actions/complete-user-name.label": "Fortsätt",
42
+ "@ossy/users/actions/complete-user-name.description": "Spara för- och efternamn för den inloggade användaren",
43
+ "@ossy/users/form/complete-user-name.label": "Ditt namn",
44
+ "@ossy/users/schema/complete-user-name.firstName.label": "Förnamn",
45
+ "@ossy/users/schema/complete-user-name.lastName.label": "Efternamn",
46
+ "users.completeName.title": "Vad heter du?",
47
+ "users.completeName.description": "Vi använder namnet på din profil och när du arbetar med andra.",
48
+ "users.completeName.errorTitle": "Kunde inte spara namnet",
49
+ "users.completeName.errorBody": "Något gick fel. Försök igen om en stund."
41
50
  }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Ordered product gates for a signed-in user. Name first; later entries can
3
+ * include a required user-agreement version without changing the shell.
4
+ *
5
+ * @param {{ firstName?: string, lastName?: string } | null | undefined} user
6
+ * @returns {string[]}
7
+ */
8
+ export function outstandingUserRequirements (user) {
9
+ if (!user) return []
10
+ const requirements = []
11
+ if (userNeedsDisplayName(user)) requirements.push('name')
12
+ return requirements
13
+ }
14
+
15
+ /**
16
+ * True when a signed-in user is missing a trimmed first or last name.
17
+ *
18
+ * @param {{ firstName?: string, lastName?: string } | null | undefined} user
19
+ * @returns {boolean}
20
+ */
21
+ export function userNeedsDisplayName (user) {
22
+ if (!user) return false
23
+ const firstName = typeof user.firstName === 'string' ? user.firstName.trim() : ''
24
+ const lastName = typeof user.lastName === 'string' ? user.lastName.trim() : ''
25
+ return !firstName || !lastName
26
+ }
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from '@jest/globals'
2
+ import { outstandingUserRequirements, userNeedsDisplayName } from './user-requirements.js'
3
+
4
+ describe('userNeedsDisplayName', () => {
5
+ it('is false for anonymous or missing user', () => {
6
+ expect(userNeedsDisplayName(undefined)).toBe(false)
7
+ expect(userNeedsDisplayName(null)).toBe(false)
8
+ })
9
+
10
+ it('requires both trimmed first and last name', () => {
11
+ expect(userNeedsDisplayName({})).toBe(true)
12
+ expect(userNeedsDisplayName({ firstName: 'Ada' })).toBe(true)
13
+ expect(userNeedsDisplayName({ lastName: 'Lovelace' })).toBe(true)
14
+ expect(userNeedsDisplayName({ firstName: ' ', lastName: 'Lovelace' })).toBe(true)
15
+ expect(userNeedsDisplayName({ firstName: 'Ada', lastName: ' ' })).toBe(true)
16
+ expect(userNeedsDisplayName({ firstName: 'Ada', lastName: 'Lovelace' })).toBe(false)
17
+ })
18
+ })
19
+
20
+ describe('outstandingUserRequirements', () => {
21
+ it('returns no gates for anonymous users', () => {
22
+ expect(outstandingUserRequirements(undefined)).toEqual([])
23
+ })
24
+
25
+ it('lists name first when the display name is missing', () => {
26
+ expect(outstandingUserRequirements({ firstName: '', lastName: '' })).toEqual(['name'])
27
+ expect(outstandingUserRequirements({ firstName: 'Ada', lastName: 'Lovelace' })).toEqual([])
28
+ })
29
+ })
@@ -221,6 +221,130 @@ describe('[authentication/actions/request-sign-in]', () => {
221
221
 
222
222
  })
223
223
 
224
+ describe('[authentication/actions/verify-sign-in]', () => {
225
+
226
+ describe('given email + code from request-sign-in', () => {
227
+ it('must return a WebAuth session and set an auth cookie via POST /actions', async () => {
228
+ const email = casual.email
229
+
230
+ await TestUtil.AssertActionResponse({
231
+ actionId: '@ossy/authentication/actions/sign-up',
232
+ body: TestUtil.signUpBody({ email }),
233
+ expectedResponseStatus: 200,
234
+ expectedResponseBody: { ok: true },
235
+ })
236
+
237
+ await TestUtil.AssertActionResponse({
238
+ actionId: '@ossy/authentication/actions/request-sign-in',
239
+ payload: { email },
240
+ expectedResponseStatus: 200,
241
+ expectedResponseBody: { ok: true },
242
+ })
243
+
244
+ const createdEvent = await TestUtil.GetEvent({
245
+ type: USER_SCHEMA,
246
+ event: 'Created',
247
+ 'payload.email': email,
248
+ })
249
+
250
+ const code = await TestUtil.getLatestVerificationCodeForSubject(createdEvent.resourceId)
251
+
252
+ const response = await TestUtil.InvokeAction({
253
+ actionId: '@ossy/authentication/actions/verify-sign-in',
254
+ payload: { email, code },
255
+ })
256
+
257
+ const body = await response.json()
258
+ const signInVerifiedEvent = await TestUtil.GetEvent({
259
+ type: USER_SCHEMA,
260
+ resourceId: createdEvent.resourceId,
261
+ event: 'SignInVerified',
262
+ })
263
+
264
+ expect(response.status).toBe(200)
265
+ expect(body).toMatchObject({ sub: createdEvent.resourceId, token: expect.any(String) })
266
+ expect(!!signInVerifiedEvent).toBe(true)
267
+ expect(getSetCookieHeader(response).includes(signInVerifiedEvent.payload.token)).toEqual(true)
268
+ })
269
+
270
+ it('must reject reuse of the same code with 401', async () => {
271
+ const email = casual.email
272
+
273
+ await TestUtil.AssertActionResponse({
274
+ actionId: '@ossy/authentication/actions/sign-up',
275
+ body: TestUtil.signUpBody({ email }),
276
+ expectedResponseStatus: 200,
277
+ expectedResponseBody: { ok: true },
278
+ })
279
+
280
+ await TestUtil.AssertActionResponse({
281
+ actionId: '@ossy/authentication/actions/request-sign-in',
282
+ payload: { email },
283
+ expectedResponseStatus: 200,
284
+ expectedResponseBody: { ok: true },
285
+ })
286
+
287
+ const createdEvent = await TestUtil.GetEvent({
288
+ type: USER_SCHEMA,
289
+ event: 'Created',
290
+ 'payload.email': email,
291
+ })
292
+
293
+ const code = await TestUtil.getLatestVerificationCodeForSubject(createdEvent.resourceId)
294
+
295
+ const first = await TestUtil.InvokeAction({
296
+ actionId: '@ossy/authentication/actions/verify-sign-in',
297
+ payload: { email, code },
298
+ })
299
+ expect(first.status).toBe(200)
300
+
301
+ const second = await TestUtil.InvokeAction({
302
+ actionId: '@ossy/authentication/actions/verify-sign-in',
303
+ payload: { email, code },
304
+ })
305
+ expect(second.status).toBe(401)
306
+ })
307
+
308
+ it('must reject the magic link JWT after sign-in via code with 401', async () => {
309
+ const email = casual.email
310
+
311
+ await TestUtil.AssertActionResponse({
312
+ actionId: '@ossy/authentication/actions/sign-up',
313
+ body: TestUtil.signUpBody({ email }),
314
+ expectedResponseStatus: 200,
315
+ expectedResponseBody: { ok: true },
316
+ })
317
+
318
+ await TestUtil.AssertActionResponse({
319
+ actionId: '@ossy/authentication/actions/request-sign-in',
320
+ payload: { email },
321
+ expectedResponseStatus: 200,
322
+ expectedResponseBody: { ok: true },
323
+ })
324
+
325
+ const createdEvent = await TestUtil.GetEvent({
326
+ type: USER_SCHEMA,
327
+ event: 'Created',
328
+ 'payload.email': email,
329
+ })
330
+
331
+ const code = await TestUtil.getLatestVerificationCodeForSubject(createdEvent.resourceId)
332
+ const verificationJwt = await TestUtil.getLatestVerificationJwtForSubject(createdEvent.resourceId)
333
+
334
+ const codeResponse = await TestUtil.InvokeAction({
335
+ actionId: '@ossy/authentication/actions/verify-sign-in',
336
+ payload: { email, code },
337
+ })
338
+ expect(codeResponse.status).toBe(200)
339
+
340
+ const linkResponse = await fetch(
341
+ `${getApiTestBaseUrl()}/users/verify-sign-in?token=${verificationJwt}`,
342
+ { method: 'GET' },
343
+ )
344
+ expect(linkResponse.status).toBe(401)
345
+ })
346
+ })
347
+
224
348
  describe('[/users/verify-sign-in][GET]', () => {
225
349
 
226
350
  describe('given a valid token created from a Created event', () => {
@@ -333,6 +457,13 @@ describe('[/users/verify-sign-in][GET]', () => {
333
457
  }))
334
458
  })
335
459
 
460
+ it('must return 400 when email+code are provided without a token (codes are POST-only)', () => TestUtil.AssertResponse({
461
+ endpoint: '/users/verify-sign-in?email=test@example.com&code=123456',
462
+ method: 'GET',
463
+ expectedResponseStatus: 400,
464
+ expectedResponseBody: '',
465
+ }))
466
+
336
467
  })
337
468
 
338
469
  })