@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 +4 -3
- package/src/CompleteUserName.jsx +99 -0
- package/src/complete-user-name.action.js +7 -0
- package/src/complete-user-name.form.js +5 -0
- package/src/complete-user-name.schema.js +10 -0
- package/src/en.translations.json +10 -1
- package/src/index.js +4 -0
- package/src/sv.translations.json +10 -1
- package/src/user-requirements.js +26 -0
- package/src/user-requirements.spec.js +29 -0
- package/src/users.integration.spec.js +131 -0
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.
|
|
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": "
|
|
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,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
|
+
}
|
package/src/en.translations.json
CHANGED
|
@@ -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'
|
package/src/sv.translations.json
CHANGED
|
@@ -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
|
})
|