@ossy/users 3.0.9 → 3.4.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/README.md +19 -1
- package/package.json +9 -4
- package/src/VerifyEmailChange.jsx +114 -0
- package/src/confirm-email-change.action.js +4 -0
- package/src/confirm-email-change.api.js +34 -0
- package/src/confirm-email-change.task.js +89 -0
- package/src/edit-profile-details.action.js +7 -0
- package/src/email-change-token.js +41 -0
- package/src/email-change-token.spec.js +36 -0
- package/src/email-changed.email.jsx +30 -0
- package/src/en.translations.json +26 -1
- package/src/index.js +6 -0
- package/src/request-email-change.action.js +4 -0
- package/src/request-email-change.task.js +108 -0
- package/src/save-profile-details.action.js +6 -0
- package/src/sv.translations.json +26 -1
- package/src/update-email.flow.js +55 -0
- package/src/update-profile-details.form.js +5 -0
- package/src/update-profile-details.schema.js +11 -0
- package/src/user.aggregate.js +13 -0
- package/src/user.aggregate.spec.js +214 -0
- package/src/users.events.js +16 -0
- package/src/users.integration.spec.js +146 -0
- package/src/users.queries.js +1 -1
- package/src/verify-email-change.email.jsx +30 -0
- package/src/verify-email-change.page.jsx +31 -0
package/README.md
CHANGED
|
@@ -20,6 +20,10 @@ Client exports (`@ossy/users`) surface action metadata for the SDK. Server expor
|
|
|
20
20
|
| Action | Access | Task |
|
|
21
21
|
|--------|--------|------|
|
|
22
22
|
| `@ossy/users/actions/update-details` | authenticated | `@ossy/users/tasks/update-details` |
|
|
23
|
+
| `@ossy/users/actions/request-email-change` | authenticated | `@ossy/users/tasks/request-email-change` |
|
|
24
|
+
| `@ossy/users/actions/confirm-email-change` | public | `@ossy/users/tasks/confirm-email-change` |
|
|
25
|
+
| `@ossy/users/actions/edit-profile-details` | authenticated | client-only (profile editor) |
|
|
26
|
+
| `@ossy/users/actions/save-profile-details` | authenticated | client-only (profile form submit) |
|
|
23
27
|
| `@ossy/users/actions/get-current-user-history` | authenticated | `@ossy/users/tasks/get-current-user-history` |
|
|
24
28
|
| `@ossy/users/actions/join-workspace` | authenticated | `@ossy/users/tasks/join-workspace` |
|
|
25
29
|
| `@ossy/users/actions/leave-workspace` | authenticated | `@ossy/users/tasks/leave-workspace` |
|
|
@@ -44,9 +48,13 @@ User entity events use the universal envelope: `type` (schema id), `resourceId`,
|
|
|
44
48
|
| `Created` | New user (`type: 'User'` or `'Bot'`) |
|
|
45
49
|
| `SignInVerified` | Sets `verifiedAt` on first verification |
|
|
46
50
|
| `NameUpdated` | Updates `firstName` / `lastName` |
|
|
51
|
+
| `EmailChangeRequested` | Sets `pendingEmail` (verification mail sent to the new address) |
|
|
52
|
+
| `EmailUpdated` | Sets `email`, clears `pendingEmail` (old address is notified) |
|
|
47
53
|
| `WorkspaceJoined` | Adds workspace id to `workspaces[]` |
|
|
48
54
|
| `WorkspaceLeft` | Removes workspace id from `workspaces[]` |
|
|
49
55
|
|
|
56
|
+
Email change is a two-step flow: authenticated `request-email-change` appends `EmailChangeRequested` and emails a confirmation link; public `confirm-email-change` (GET `/api/v0/users/confirm-email-change?token=…` / page `/verify-email-change`) appends `EmailUpdated`. If the new address is already used by another account, request returns `{ ok: true }` without events or mail. Mail is sent only via the `email` integration; if it is missing after the aggregate update, the task still returns `{ ok: true }` and logs a warning so verification / old-address notices are not dropped silently.
|
|
57
|
+
|
|
50
58
|
`UsersEvents.SignedUp` was renamed to `UsersEvents.Created` to match the ADR 0008 lifecycle name.
|
|
51
59
|
|
|
52
60
|
## Server usage
|
|
@@ -62,6 +70,16 @@ UserSchema.user
|
|
|
62
70
|
// => '@ossy/users/schema/user'
|
|
63
71
|
```
|
|
64
72
|
|
|
73
|
+
## Forms & flows
|
|
74
|
+
|
|
75
|
+
| Primitive | Id |
|
|
76
|
+
|---|---|
|
|
77
|
+
| Form | `@ossy/users/form/update-profile-details` |
|
|
78
|
+
| Schema | `@ossy/users/schema/update-profile-details` |
|
|
79
|
+
| Flow | `@ossy/users/flows/update-email` |
|
|
80
|
+
|
|
81
|
+
`update-email.flow.js` composes the sign-up flow, edits email on `@profile`, confirms via `@ossy/users/emails/verify-email-change`, asserts return to profile, and checks `[data-user-email="$email"]` for the new address.
|
|
82
|
+
|
|
65
83
|
## Testing
|
|
66
84
|
|
|
67
85
|
```bash
|
|
@@ -69,4 +87,4 @@ npm test -w @ossy/users
|
|
|
69
87
|
npm run test:integration -w @ossy/users
|
|
70
88
|
```
|
|
71
89
|
|
|
72
|
-
Unit coverage lives in package tests; `users.integration.spec.js` exercises actions against `@ossy/platform`.
|
|
90
|
+
Unit coverage lives in package tests; `users.integration.spec.js` exercises actions against `@ossy/platform`. Product e2e: `npm run test:e2e:flows` in `@ossy/app-test` (includes `update-email` when built into the manifest).
|
package/package.json
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossy/users",
|
|
3
3
|
"description": "User domain - aggregate, events, and validators for the Ossy user model",
|
|
4
|
-
"version": "3.0
|
|
4
|
+
"version": "3.4.0",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./src/index.js",
|
|
8
8
|
"module": "./src/index.js",
|
|
9
9
|
"exports": {
|
|
10
10
|
".": "./src/index.js",
|
|
11
|
-
"./server": "./src/server.js"
|
|
11
|
+
"./server": "./src/server.js",
|
|
12
|
+
"./update-email.flow.js": "./src/update-email.flow.js",
|
|
13
|
+
"./update-profile-details.schema.js": "./src/update-profile-details.schema.js"
|
|
12
14
|
},
|
|
13
15
|
"scripts": {
|
|
14
16
|
"test": "NODE_OPTIONS=--experimental-vm-modules jest --verbose",
|
|
@@ -21,6 +23,9 @@
|
|
|
21
23
|
},
|
|
22
24
|
"peerDependencies": {
|
|
23
25
|
"@ossy/authentication": ">=1.0.0",
|
|
26
|
+
"@ossy/design-system": ">=1.0.0",
|
|
27
|
+
"@ossy/platform": ">=1.0.0",
|
|
28
|
+
"@ossy/router-react": ">=1.0.0",
|
|
24
29
|
"@ossy/sdk-react": ">=1.0.0",
|
|
25
30
|
"react": ">=19.0.0 <20.0.0"
|
|
26
31
|
},
|
|
@@ -31,7 +36,7 @@
|
|
|
31
36
|
"dependencies": {
|
|
32
37
|
"@ossy/config": "^3.0.9",
|
|
33
38
|
"@ossy/observability": "^3.0.9",
|
|
34
|
-
"@ossy/schema": "^3.0
|
|
39
|
+
"@ossy/schema": "^3.4.0",
|
|
35
40
|
"nanoid": "^5.1.11"
|
|
36
41
|
},
|
|
37
42
|
"devDependencies": {
|
|
@@ -43,5 +48,5 @@
|
|
|
43
48
|
"/src",
|
|
44
49
|
"README.md"
|
|
45
50
|
],
|
|
46
|
-
"gitHead": "
|
|
51
|
+
"gitHead": "d36b69444268d172bc3e8e1dc77e67afc63f8650"
|
|
47
52
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react'
|
|
2
|
+
import { View, Text, Switch, Button, useLocale } from '@ossy/design-system'
|
|
3
|
+
import { useRouter } from '@ossy/router-react'
|
|
4
|
+
|
|
5
|
+
const FlowStage = {
|
|
6
|
+
Confirming: 'Confirming',
|
|
7
|
+
Success: 'Success',
|
|
8
|
+
Error: 'Error',
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const SUCCESS_DISPLAY_MS = 1500
|
|
12
|
+
|
|
13
|
+
export const VerifyEmailChange = ({
|
|
14
|
+
surface = 'primary',
|
|
15
|
+
roundness = 'l',
|
|
16
|
+
inset = 'l',
|
|
17
|
+
gap = 'l',
|
|
18
|
+
...props
|
|
19
|
+
}) => {
|
|
20
|
+
const { t } = useLocale()
|
|
21
|
+
const router = useRouter()
|
|
22
|
+
const token = router.searchParams.token
|
|
23
|
+
const [flowStage, setFlowStage] = useState(FlowStage.Confirming)
|
|
24
|
+
const [confirmed, setConfirmed] = useState(false)
|
|
25
|
+
const [successAt, setSuccessAt] = useState(null)
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (!token) {
|
|
29
|
+
setFlowStage(FlowStage.Error)
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let didCancel = false
|
|
34
|
+
setFlowStage(FlowStage.Confirming)
|
|
35
|
+
|
|
36
|
+
fetch(`/api/v0/users/confirm-email-change?token=${encodeURIComponent(token)}`, {
|
|
37
|
+
credentials: 'include',
|
|
38
|
+
})
|
|
39
|
+
.then((res) => {
|
|
40
|
+
if (!res.ok) throw new Error('Confirm failed')
|
|
41
|
+
return res.json()
|
|
42
|
+
})
|
|
43
|
+
.then(() => {
|
|
44
|
+
if (didCancel) return
|
|
45
|
+
setFlowStage(FlowStage.Success)
|
|
46
|
+
setSuccessAt(Date.now())
|
|
47
|
+
setConfirmed(true)
|
|
48
|
+
})
|
|
49
|
+
.catch(() => {
|
|
50
|
+
if (didCancel) return
|
|
51
|
+
setFlowStage(FlowStage.Error)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
return () => {
|
|
55
|
+
didCancel = true
|
|
56
|
+
}
|
|
57
|
+
}, [token])
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (!confirmed) return
|
|
61
|
+
|
|
62
|
+
const elapsed = successAt ? Date.now() - successAt : SUCCESS_DISPLAY_MS
|
|
63
|
+
const remaining = Math.max(0, SUCCESS_DISPLAY_MS - elapsed)
|
|
64
|
+
|
|
65
|
+
const timer = setTimeout(() => {
|
|
66
|
+
window.location.assign(router.getHref('profile') || '/profile')
|
|
67
|
+
}, remaining)
|
|
68
|
+
|
|
69
|
+
return () => clearTimeout(timer)
|
|
70
|
+
}, [confirmed, successAt, router])
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<View
|
|
74
|
+
data-region="content"
|
|
75
|
+
surface={surface}
|
|
76
|
+
inset={inset}
|
|
77
|
+
roundness={roundness}
|
|
78
|
+
gap={gap}
|
|
79
|
+
{...props}
|
|
80
|
+
>
|
|
81
|
+
<Switch on={flowStage}>
|
|
82
|
+
<Switch.Case match={[FlowStage.Confirming]}>
|
|
83
|
+
<Text as="h1" variant="heading-tertiary" text="users.verifyEmailChange.confirming" />
|
|
84
|
+
</Switch.Case>
|
|
85
|
+
|
|
86
|
+
<Switch.Case match={[FlowStage.Success]}>
|
|
87
|
+
<View data-flow-stage="email-change-success">
|
|
88
|
+
<Text as="h1" variant="heading-tertiary" text="users.verifyEmailChange.successRedirect" />
|
|
89
|
+
</View>
|
|
90
|
+
</Switch.Case>
|
|
91
|
+
|
|
92
|
+
<Switch.Case match={[FlowStage.Error]}>
|
|
93
|
+
<View gap="s" style={{ flexGrow: 1 }}>
|
|
94
|
+
<Text as="h1" variant="heading-tertiary">
|
|
95
|
+
{t('users.verifyEmailChange.errorTitle')}
|
|
96
|
+
</Text>
|
|
97
|
+
<Text>
|
|
98
|
+
{t('users.verifyEmailChange.errorBody')}
|
|
99
|
+
</Text>
|
|
100
|
+
</View>
|
|
101
|
+
<View layout="row" justifyContent="flex-end" gap="s">
|
|
102
|
+
<Button
|
|
103
|
+
variant="cta"
|
|
104
|
+
prefix="redo"
|
|
105
|
+
href={router.getHref('profile') || '/profile'}
|
|
106
|
+
>
|
|
107
|
+
{t('users.verifyEmailChange.profileButton')}
|
|
108
|
+
</Button>
|
|
109
|
+
</View>
|
|
110
|
+
</Switch.Case>
|
|
111
|
+
</Switch>
|
|
112
|
+
</View>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { ActionService } from '@ossy/platform'
|
|
2
|
+
|
|
3
|
+
export const metadata = {
|
|
4
|
+
id: 'users.confirm-email-change',
|
|
5
|
+
path: '/api/v0/users/confirm-email-change',
|
|
6
|
+
action: '@ossy/users/actions/confirm-email-change',
|
|
7
|
+
method: 'GET',
|
|
8
|
+
query: ['token'],
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default async function handle (req, res) {
|
|
12
|
+
if (req.method !== 'GET') {
|
|
13
|
+
res.setHeader('Allow', 'GET')
|
|
14
|
+
res.status(405).json({ error: 'Method Not Allowed' })
|
|
15
|
+
return
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const token = req.query?.token
|
|
19
|
+
if (!token || typeof token !== 'string') {
|
|
20
|
+
res.status(400).json({ message: 'No token provided' })
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const result = await ActionService.invoke('@ossy/users/actions/confirm-email-change', {
|
|
26
|
+
payload: { token },
|
|
27
|
+
req,
|
|
28
|
+
})
|
|
29
|
+
res.status(200).json(result)
|
|
30
|
+
} catch (err) {
|
|
31
|
+
const status = err?.status ?? 401
|
|
32
|
+
res.status(status).json({ error: err?.message || 'Unauthorized' })
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { Aggregate } from '@ossy/event-store'
|
|
3
|
+
import { User, UsersEvents, UserSchema } from '@ossy/users/server'
|
|
4
|
+
import { EmailRenderer } from '@ossy/email'
|
|
5
|
+
import { createTranslatorForBuild } from '@ossy/platform/locale'
|
|
6
|
+
import { createLogger } from '@ossy/observability'
|
|
7
|
+
import { verifyEmailChangeToken } from './email-change-token.js'
|
|
8
|
+
import EmailChangedEmail, {
|
|
9
|
+
id as emailChangedEmailId,
|
|
10
|
+
} from './email-changed.email.jsx'
|
|
11
|
+
|
|
12
|
+
export const metadata = { id: '@ossy/users/tasks/confirm-email-change' }
|
|
13
|
+
|
|
14
|
+
const log = createLogger('@ossy/users/tasks/confirm-email-change')
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Confirm a pending email change from a signed EmailChange token.
|
|
18
|
+
* Appends `EmailUpdated`, then notifies the previous address.
|
|
19
|
+
*
|
|
20
|
+
* @param {{ payload: { token?: string }, integrations: unknown, req: unknown }} context
|
|
21
|
+
* @returns {Promise<{ ok: true, email: string }>}
|
|
22
|
+
*/
|
|
23
|
+
export async function run ({ payload, integrations, req }) {
|
|
24
|
+
const { userId, email: newEmail } = await verifyEmailChangeToken(payload?.token)
|
|
25
|
+
|
|
26
|
+
const user = await Aggregate.Of(User, userId).then(Aggregate.View())
|
|
27
|
+
if (!user?.id) {
|
|
28
|
+
throw Object.assign(new Error('Invalid token'), { status: 401 })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const previousEmail = user.email
|
|
32
|
+
if (!previousEmail) {
|
|
33
|
+
throw Object.assign(new Error('Invalid user'), { status: 400 })
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (previousEmail === newEmail) {
|
|
37
|
+
return { ok: true, email: newEmail }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const existingUser = await Aggregate.Collection.findOne({
|
|
41
|
+
'state.email': newEmail,
|
|
42
|
+
type: { $in: [UserSchema.user, User.AggregateType] },
|
|
43
|
+
}).then((agg) => agg?.state)
|
|
44
|
+
|
|
45
|
+
if (existingUser && existingUser.id !== userId) {
|
|
46
|
+
log.warn('Email change confirm blocked; address already in use')
|
|
47
|
+
throw Object.assign(new Error('Email unavailable'), { status: 409 })
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
await Aggregate.Of(User, userId)
|
|
51
|
+
.then(Aggregate.Add(UsersEvents.EmailUpdated({
|
|
52
|
+
email: newEmail,
|
|
53
|
+
previousEmail,
|
|
54
|
+
createdBy: userId,
|
|
55
|
+
})))
|
|
56
|
+
.then(Aggregate.Save())
|
|
57
|
+
|
|
58
|
+
const buildDir = path.resolve(process.cwd(), 'build')
|
|
59
|
+
const t = createTranslatorForBuild(buildDir, undefined, { defaultLanguage: 'en' })
|
|
60
|
+
const subject = t('users.emailChanged.subject')
|
|
61
|
+
const { html, text } = EmailRenderer.render(EmailChangedEmail, {
|
|
62
|
+
previousEmail,
|
|
63
|
+
email: newEmail,
|
|
64
|
+
t,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const emailClient = integrations?.get?.('email')
|
|
68
|
+
if (!emailClient) {
|
|
69
|
+
// Email already updated; surface misconfig so the old-address notice is not silently dropped.
|
|
70
|
+
log.warn('No email integration available; skipping email-changed notification', {
|
|
71
|
+
userId,
|
|
72
|
+
previousEmail,
|
|
73
|
+
email: newEmail,
|
|
74
|
+
})
|
|
75
|
+
return { ok: true, email: newEmail }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
await emailClient.send({
|
|
79
|
+
to: previousEmail,
|
|
80
|
+
from: 'noreply@ossy.se',
|
|
81
|
+
subject,
|
|
82
|
+
html,
|
|
83
|
+
text,
|
|
84
|
+
// Required for flow/dev-inbox matching (`email.id` / template filter).
|
|
85
|
+
templateId: emailChangedEmailId,
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
return { ok: true, email: newEmail }
|
|
89
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import jwt from 'jsonwebtoken'
|
|
2
|
+
import { ConfigService } from '@ossy/config'
|
|
3
|
+
|
|
4
|
+
export const EMAIL_CHANGE_TOKEN_TYPE = 'EmailChange'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {{ userId: string, email: string }} args
|
|
8
|
+
* @returns {string}
|
|
9
|
+
*/
|
|
10
|
+
export function createEmailChangeToken ({ userId, email }) {
|
|
11
|
+
const expiresIn = ConfigService.TokenValidity
|
|
12
|
+
return jwt.sign(
|
|
13
|
+
{ sub: userId, type: EMAIL_CHANGE_TOKEN_TYPE, email },
|
|
14
|
+
ConfigService.TokenSecret,
|
|
15
|
+
{ expiresIn, algorithm: 'HS256' },
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {string} token
|
|
21
|
+
* @returns {Promise<{ userId: string, email: string }>}
|
|
22
|
+
*/
|
|
23
|
+
export function verifyEmailChangeToken (token) {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
if (!token) {
|
|
26
|
+
return reject(Object.assign(new Error('No token provided'), { status: 400 }))
|
|
27
|
+
}
|
|
28
|
+
jwt.verify(token, ConfigService.TokenSecret, { algorithms: ['HS256'] }, (err, payload) => {
|
|
29
|
+
if (err) {
|
|
30
|
+
return reject(Object.assign(new Error('Invalid or expired token'), { status: 401 }))
|
|
31
|
+
}
|
|
32
|
+
if (payload?.type !== EMAIL_CHANGE_TOKEN_TYPE) {
|
|
33
|
+
return reject(Object.assign(new Error('Invalid token type'), { status: 401 }))
|
|
34
|
+
}
|
|
35
|
+
if (!payload?.sub || typeof payload.email !== 'string') {
|
|
36
|
+
return reject(Object.assign(new Error('Invalid token payload'), { status: 401 }))
|
|
37
|
+
}
|
|
38
|
+
resolve({ userId: payload.sub, email: payload.email })
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it, beforeAll } from '@jest/globals'
|
|
2
|
+
import {
|
|
3
|
+
createEmailChangeToken,
|
|
4
|
+
verifyEmailChangeToken,
|
|
5
|
+
EMAIL_CHANGE_TOKEN_TYPE,
|
|
6
|
+
} from './email-change-token.js'
|
|
7
|
+
|
|
8
|
+
describe('email-change-token', () => {
|
|
9
|
+
beforeAll(() => {
|
|
10
|
+
process.env.TOKEN_SECRET = process.env.TOKEN_SECRET || 'test-token-secret-for-email-change'
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('round-trips userId and email with EmailChange type', async () => {
|
|
14
|
+
const token = createEmailChangeToken({
|
|
15
|
+
userId: 'user-42',
|
|
16
|
+
email: 'new@example.com',
|
|
17
|
+
})
|
|
18
|
+
const payload = await verifyEmailChangeToken(token)
|
|
19
|
+
expect(payload).toEqual({ userId: 'user-42', email: 'new@example.com' })
|
|
20
|
+
expect(EMAIL_CHANGE_TOKEN_TYPE).toBe('EmailChange')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('rejects missing tokens', async () => {
|
|
24
|
+
await expect(verifyEmailChangeToken('')).rejects.toMatchObject({
|
|
25
|
+
message: 'No token provided',
|
|
26
|
+
status: 400,
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('rejects garbage tokens', async () => {
|
|
31
|
+
await expect(verifyEmailChangeToken('not-a-jwt')).rejects.toMatchObject({
|
|
32
|
+
message: 'Invalid or expired token',
|
|
33
|
+
status: 401,
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
})
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { EmailLayout, EmailText } from '@ossy/email'
|
|
2
|
+
|
|
3
|
+
export const id = '@ossy/users/emails/email-changed'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Notification to the previous address after a successful email change.
|
|
7
|
+
*
|
|
8
|
+
* @param {{ previousEmail?: string, email?: string, theme?: object, t?: (key: string) => string }} props
|
|
9
|
+
*/
|
|
10
|
+
export default function EmailChangedEmail ({
|
|
11
|
+
previousEmail,
|
|
12
|
+
email,
|
|
13
|
+
theme,
|
|
14
|
+
t = (key) => key,
|
|
15
|
+
}) {
|
|
16
|
+
return (
|
|
17
|
+
<EmailLayout theme={theme}>
|
|
18
|
+
<h1 style={{ color: '#111111', margin: '0 0 16px' }}>{t('users.emailChanged.title')}</h1>
|
|
19
|
+
<EmailText>
|
|
20
|
+
{t('users.emailChanged.body')}
|
|
21
|
+
</EmailText>
|
|
22
|
+
{(previousEmail || email) && (
|
|
23
|
+
<EmailText>
|
|
24
|
+
{previousEmail ? `${previousEmail} → ` : ''}
|
|
25
|
+
{email || ''}
|
|
26
|
+
</EmailText>
|
|
27
|
+
)}
|
|
28
|
+
</EmailLayout>
|
|
29
|
+
)
|
|
30
|
+
}
|
package/src/en.translations.json
CHANGED
|
@@ -12,5 +12,30 @@
|
|
|
12
12
|
"@ossy/users/actions/leave-workspace.label": "Leave workspace",
|
|
13
13
|
"@ossy/users/actions/leave-workspace.description": "Remove the current user from a workspace",
|
|
14
14
|
"@ossy/users/actions/update-details.label": "Update user details",
|
|
15
|
-
"@ossy/users/actions/update-details.description": "Update profile fields for the authenticated user"
|
|
15
|
+
"@ossy/users/actions/update-details.description": "Update profile fields for the authenticated user",
|
|
16
|
+
"@ossy/users/actions/request-email-change.label": "Request email change",
|
|
17
|
+
"@ossy/users/actions/request-email-change.description": "Send a verification link to a new email address for the authenticated user",
|
|
18
|
+
"@ossy/users/actions/confirm-email-change.label": "Confirm email change",
|
|
19
|
+
"@ossy/users/actions/confirm-email-change.description": "Confirm a pending email change from a verification link",
|
|
20
|
+
"@ossy/users/actions/edit-profile-details.label": "Edit",
|
|
21
|
+
"@ossy/users/actions/edit-profile-details.description": "Open the profile details editor",
|
|
22
|
+
"@ossy/users/actions/save-profile-details.label": "Save",
|
|
23
|
+
"@ossy/users/actions/save-profile-details.description": "Save profile name and email changes",
|
|
24
|
+
"@ossy/users/form/update-profile-details.label": "Update profile details",
|
|
25
|
+
"@ossy/users/schema/update-profile-details.firstName.label": "First name",
|
|
26
|
+
"@ossy/users/schema/update-profile-details.lastName.label": "Last name",
|
|
27
|
+
"@ossy/users/schema/update-profile-details.email.label": "Email address",
|
|
28
|
+
"verify-email-change.documentTitle": "Confirm email change",
|
|
29
|
+
"users.verifyEmailChange.subject": "Confirm your new Ossy email",
|
|
30
|
+
"users.verifyEmailChange.title": "Confirm email change",
|
|
31
|
+
"users.verifyEmailChange.body": "Click the button below to confirm this email address for your Ossy account.",
|
|
32
|
+
"users.verifyEmailChange.button": "Confirm email",
|
|
33
|
+
"users.verifyEmailChange.confirming": "Confirming email change...",
|
|
34
|
+
"users.verifyEmailChange.successRedirect": "Email updated, redirecting...",
|
|
35
|
+
"users.verifyEmailChange.errorTitle": "Could not update email",
|
|
36
|
+
"users.verifyEmailChange.errorBody": "This confirmation link is invalid or has expired. Request a new change from your profile.",
|
|
37
|
+
"users.verifyEmailChange.profileButton": "Back to profile",
|
|
38
|
+
"users.emailChanged.subject": "Your Ossy email was changed",
|
|
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."
|
|
16
41
|
}
|
package/src/index.js
CHANGED
|
@@ -2,6 +2,12 @@ export { metadata as GetUserApiTokens } from './get-api-tokens.action.js'
|
|
|
2
2
|
export { metadata as CreateUserApiToken } from './create-api-token.action.js'
|
|
3
3
|
export { metadata as InvalidateApiToken } from './invalidate-api-token.action.js'
|
|
4
4
|
export { metadata as UpdateUserDetails } from './update-details.action.js'
|
|
5
|
+
export { metadata as RequestEmailChange } from './request-email-change.action.js'
|
|
6
|
+
export { metadata as ConfirmEmailChange } from './confirm-email-change.action.js'
|
|
7
|
+
export { metadata as EditProfileDetails } from './edit-profile-details.action.js'
|
|
8
|
+
export { metadata as SaveProfileDetails } from './save-profile-details.action.js'
|
|
9
|
+
export { metadata as UpdateProfileDetailsForm } from './update-profile-details.form.js'
|
|
5
10
|
export { metadata as GetCurrentUserHistory } from './get-current-user-history.action.js'
|
|
6
11
|
export { metadata as JoinWorkspace } from './join-workspace.action.js'
|
|
7
12
|
export { metadata as LeaveWorkspace } from './leave-workspace.action.js'
|
|
13
|
+
export { VerifyEmailChange } from './VerifyEmailChange.jsx'
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { Aggregate } from '@ossy/event-store'
|
|
3
|
+
import { User, UsersEvents, UserSchema } from '@ossy/users/server'
|
|
4
|
+
import { Token, TokenEvents } from '@ossy/tokens/server'
|
|
5
|
+
import { EmailRenderer } from '@ossy/email'
|
|
6
|
+
import { ConfigService } from '@ossy/config'
|
|
7
|
+
import { createTranslatorForBuild } from '@ossy/platform/locale'
|
|
8
|
+
import { createLogger } from '@ossy/observability'
|
|
9
|
+
import { createEmailChangeToken, EMAIL_CHANGE_TOKEN_TYPE } from './email-change-token.js'
|
|
10
|
+
import VerifyEmailChangeEmail, {
|
|
11
|
+
id as verifyEmailChangeEmailId,
|
|
12
|
+
} from './verify-email-change.email.jsx'
|
|
13
|
+
|
|
14
|
+
export const metadata = { id: '@ossy/users/tasks/request-email-change' }
|
|
15
|
+
|
|
16
|
+
const log = createLogger('@ossy/users/tasks/request-email-change')
|
|
17
|
+
|
|
18
|
+
const isEmailLike = (maybeEmail) => /.@.+\.../.test(maybeEmail)
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Request an email change for the authenticated user.
|
|
22
|
+
* If the new email is already used by another account, returns `{ ok: true }`
|
|
23
|
+
* without creating events or sending mail (avoids email enumeration).
|
|
24
|
+
*
|
|
25
|
+
* @param {{ payload: { email?: string }, integrations: unknown, req: unknown }} context
|
|
26
|
+
*/
|
|
27
|
+
export async function run ({ payload, integrations, req }) {
|
|
28
|
+
const createdBy = payload?.userId ?? req?.userId
|
|
29
|
+
const currentUser = payload?.currentUser ?? req?.user
|
|
30
|
+
const email = payload?.email?.trim?.()
|
|
31
|
+
|
|
32
|
+
if (!createdBy) {
|
|
33
|
+
throw Object.assign(new Error('Unauthorized'), { status: 401 })
|
|
34
|
+
}
|
|
35
|
+
if (!isEmailLike(email)) {
|
|
36
|
+
throw Object.assign(new Error('No email provided'), { status: 400 })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const currentEmail = currentUser?.email?.trim?.() ?? currentUser?.email
|
|
40
|
+
|
|
41
|
+
if (email === currentEmail) {
|
|
42
|
+
return { ok: true }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Snapshots may be typed as SchemaId or legacy AggregateType (`User`).
|
|
46
|
+
const existingUser = await Aggregate.Collection.findOne({
|
|
47
|
+
'state.email': email,
|
|
48
|
+
type: { $in: [UserSchema.user, User.AggregateType] },
|
|
49
|
+
}).then((agg) => agg?.state)
|
|
50
|
+
|
|
51
|
+
if (existingUser && existingUser.id !== createdBy) {
|
|
52
|
+
log.info('Email change requested for address already in use; returning silently')
|
|
53
|
+
return { ok: true }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const token = createEmailChangeToken({ userId: createdBy, email })
|
|
57
|
+
const expiresIn = ConfigService.TokenValidity
|
|
58
|
+
const expiresAt = Date.now() + Number(expiresIn) * 1000
|
|
59
|
+
const tokenCreatedEvent = TokenEvents.Created({
|
|
60
|
+
type: EMAIL_CHANGE_TOKEN_TYPE,
|
|
61
|
+
createdBy,
|
|
62
|
+
subject: createdBy,
|
|
63
|
+
token,
|
|
64
|
+
expiresAt,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
await Aggregate.Of(User, createdBy)
|
|
68
|
+
.then(Aggregate.Add(UsersEvents.EmailChangeRequested({
|
|
69
|
+
email,
|
|
70
|
+
createdBy,
|
|
71
|
+
})))
|
|
72
|
+
.then(Aggregate.Save())
|
|
73
|
+
|
|
74
|
+
await Aggregate.Of(Token, tokenCreatedEvent)
|
|
75
|
+
|
|
76
|
+
const baseUrl = ConfigService.getWebClientBaseUrl(req)
|
|
77
|
+
const buildDir = path.resolve(process.cwd(), 'build')
|
|
78
|
+
const t = createTranslatorForBuild(buildDir, undefined, { defaultLanguage: 'en' })
|
|
79
|
+
const subject = t('users.verifyEmailChange.subject')
|
|
80
|
+
const { html, text } = EmailRenderer.render(VerifyEmailChangeEmail, {
|
|
81
|
+
token: tokenCreatedEvent.payload.token,
|
|
82
|
+
baseUrl,
|
|
83
|
+
email,
|
|
84
|
+
t,
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const emailClient = integrations?.get?.('email')
|
|
88
|
+
if (!emailClient) {
|
|
89
|
+
// Pending email already recorded; surface misconfig so verification mail is not silently dropped.
|
|
90
|
+
log.warn('No email integration available; skipping verify-email-change mail', {
|
|
91
|
+
userId: createdBy,
|
|
92
|
+
email,
|
|
93
|
+
})
|
|
94
|
+
return { ok: true }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
await emailClient.send({
|
|
98
|
+
to: email,
|
|
99
|
+
from: 'noreply@ossy.se',
|
|
100
|
+
subject,
|
|
101
|
+
html,
|
|
102
|
+
text,
|
|
103
|
+
// Required for flow/dev-inbox matching (`email.id` / template filter).
|
|
104
|
+
templateId: verifyEmailChangeEmailId,
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
return { ok: true }
|
|
108
|
+
}
|
package/src/sv.translations.json
CHANGED
|
@@ -12,5 +12,30 @@
|
|
|
12
12
|
"@ossy/users/actions/leave-workspace.label": "Lämna workspace",
|
|
13
13
|
"@ossy/users/actions/leave-workspace.description": "Ta bort nuvarande användare från ett workspace",
|
|
14
14
|
"@ossy/users/actions/update-details.label": "Uppdatera användaruppgifter",
|
|
15
|
-
"@ossy/users/actions/update-details.description": "Uppdatera profilfält för den inloggade användaren"
|
|
15
|
+
"@ossy/users/actions/update-details.description": "Uppdatera profilfält för den inloggade användaren",
|
|
16
|
+
"@ossy/users/actions/request-email-change.label": "Begär e-poständring",
|
|
17
|
+
"@ossy/users/actions/request-email-change.description": "Skicka en verifieringslänk till en ny e-postadress för den inloggade användaren",
|
|
18
|
+
"@ossy/users/actions/confirm-email-change.label": "Bekräfta e-poständring",
|
|
19
|
+
"@ossy/users/actions/confirm-email-change.description": "Bekräfta en väntande e-poständring via verifieringslänk",
|
|
20
|
+
"@ossy/users/actions/edit-profile-details.label": "Redigera",
|
|
21
|
+
"@ossy/users/actions/edit-profile-details.description": "Öppna redigeraren för profiluppgifter",
|
|
22
|
+
"@ossy/users/actions/save-profile-details.label": "Spara",
|
|
23
|
+
"@ossy/users/actions/save-profile-details.description": "Spara namn- och e-poständringar",
|
|
24
|
+
"@ossy/users/form/update-profile-details.label": "Uppdatera profiluppgifter",
|
|
25
|
+
"@ossy/users/schema/update-profile-details.firstName.label": "Förnamn",
|
|
26
|
+
"@ossy/users/schema/update-profile-details.lastName.label": "Efternamn",
|
|
27
|
+
"@ossy/users/schema/update-profile-details.email.label": "E-postadress",
|
|
28
|
+
"verify-email-change.documentTitle": "Bekräfta e-poständring",
|
|
29
|
+
"users.verifyEmailChange.subject": "Bekräfta din nya Ossy-e-post",
|
|
30
|
+
"users.verifyEmailChange.title": "Bekräfta e-poständring",
|
|
31
|
+
"users.verifyEmailChange.body": "Klicka på knappen nedan för att bekräfta den här e-postadressen för ditt Ossy-konto.",
|
|
32
|
+
"users.verifyEmailChange.button": "Bekräfta e-post",
|
|
33
|
+
"users.verifyEmailChange.confirming": "Bekräftar e-poständring...",
|
|
34
|
+
"users.verifyEmailChange.successRedirect": "E-post uppdaterad, omdirigerar...",
|
|
35
|
+
"users.verifyEmailChange.errorTitle": "Kunde inte uppdatera e-post",
|
|
36
|
+
"users.verifyEmailChange.errorBody": "Den här bekräftelselänken är ogiltig eller har gått ut. Begär en ny ändring från din profil.",
|
|
37
|
+
"users.verifyEmailChange.profileButton": "Tillbaka till profil",
|
|
38
|
+
"users.emailChanged.subject": "Din Ossy-e-post har ändrats",
|
|
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."
|
|
16
41
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import signUpFlow from '@ossy/authentication/sign-up.flow.js'
|
|
2
|
+
import { metadata as EditProfileDetails } from './edit-profile-details.action.js'
|
|
3
|
+
import { metadata as SaveProfileDetails } from './save-profile-details.action.js'
|
|
4
|
+
import { metadata as UpdateProfileDetailsForm } from './update-profile-details.form.js'
|
|
5
|
+
|
|
6
|
+
export const metadata = {
|
|
7
|
+
id: '@ossy/users/flows/update-email',
|
|
8
|
+
feature: 'users',
|
|
9
|
+
requires: ['server', 'database'],
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Happy path for issue #415 — request email change from profile, confirm via
|
|
14
|
+
* verification mail, land back on profile with the new address active.
|
|
15
|
+
*/
|
|
16
|
+
export default {
|
|
17
|
+
title: 'Update email',
|
|
18
|
+
description: 'Signed-in user changes email from profile, confirms via link, and returns to profile',
|
|
19
|
+
steps: [
|
|
20
|
+
...signUpFlow.steps,
|
|
21
|
+
{ page: '@profile' },
|
|
22
|
+
{ result: { action: EditProfileDetails, timeout: 10000 } },
|
|
23
|
+
{ action: EditProfileDetails },
|
|
24
|
+
// Preserve sign-up address before the profile form overwrites `$email`.
|
|
25
|
+
{ capture: { previousEmail: '$email' } },
|
|
26
|
+
{ form: UpdateProfileDetailsForm },
|
|
27
|
+
{ action: SaveProfileDetails },
|
|
28
|
+
{ result: { selector: '[data-flow-stage="email-change-requested"]', timeout: 10000 } },
|
|
29
|
+
{
|
|
30
|
+
email: {
|
|
31
|
+
to: '$email',
|
|
32
|
+
id: '@ossy/users/emails/verify-email-change',
|
|
33
|
+
click: 'Confirm email',
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
{ result: { selector: '[data-flow-stage="email-change-success"]', timeout: 10000 } },
|
|
37
|
+
{ result: { page: '@profile', timeout: 15000 } },
|
|
38
|
+
// `$email` is the faker address filled into the profile form (new address).
|
|
39
|
+
{ result: { selector: '[data-user-email="$email"]', timeout: 15000 } },
|
|
40
|
+
// Issue #415: previous address receives a change notification (no link to click).
|
|
41
|
+
{
|
|
42
|
+
page: '@dev-inbox',
|
|
43
|
+
search: {
|
|
44
|
+
to: '$previousEmail',
|
|
45
|
+
template: '@ossy/users/emails/email-changed',
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
result: {
|
|
50
|
+
selector: '[data-email-template="@ossy/users/emails/email-changed"][data-email-to="$previousEmail"]',
|
|
51
|
+
timeout: 15000,
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Profile account details — UI fields and flow faker schema. */
|
|
2
|
+
export default {
|
|
3
|
+
id: '@ossy/users/schema/update-profile-details',
|
|
4
|
+
name: 'Update profile details',
|
|
5
|
+
icon: 'profile',
|
|
6
|
+
fields: [
|
|
7
|
+
{ name: 'firstName', type: 'text', required: true },
|
|
8
|
+
{ name: 'lastName', type: 'text', required: true },
|
|
9
|
+
{ name: 'email', type: 'email', required: true },
|
|
10
|
+
],
|
|
11
|
+
}
|
package/src/user.aggregate.js
CHANGED
|
@@ -34,6 +34,19 @@ export class User {
|
|
|
34
34
|
return { ...user, firstName, lastName }
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
case 'EmailChangeRequested':
|
|
38
|
+
return {
|
|
39
|
+
...user,
|
|
40
|
+
pendingEmail: event.payload.email,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
case 'EmailUpdated':
|
|
44
|
+
return {
|
|
45
|
+
...user,
|
|
46
|
+
email: event.payload.email,
|
|
47
|
+
pendingEmail: undefined,
|
|
48
|
+
}
|
|
49
|
+
|
|
37
50
|
case 'WorkspaceJoined':
|
|
38
51
|
return {
|
|
39
52
|
...user,
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals'
|
|
2
|
+
import { User } from './user.aggregate.js'
|
|
3
|
+
import { UsersEvents } from './users.events.js'
|
|
4
|
+
import { UserSchema } from './schema-ids.js'
|
|
5
|
+
|
|
6
|
+
const NOW = 1_700_000_000_000
|
|
7
|
+
|
|
8
|
+
function createdEvent (overrides = {}) {
|
|
9
|
+
return {
|
|
10
|
+
...UsersEvents.Created({
|
|
11
|
+
email: 'ada@example.com',
|
|
12
|
+
firstName: 'Ada',
|
|
13
|
+
lastName: 'Lovelace',
|
|
14
|
+
userId: 'user-1',
|
|
15
|
+
}),
|
|
16
|
+
created: NOW,
|
|
17
|
+
...overrides,
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function withMeta (event, overrides = {}) {
|
|
22
|
+
return {
|
|
23
|
+
...event,
|
|
24
|
+
created: NOW + 1000,
|
|
25
|
+
...overrides,
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('User.View', () => {
|
|
30
|
+
|
|
31
|
+
describe('empty event list', () => {
|
|
32
|
+
it('returns the savedState unchanged when no events are given', () => {
|
|
33
|
+
expect(User.View([], { id: 'user-1' })).toEqual({ id: 'user-1' })
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('uses {} as the default savedState', () => {
|
|
37
|
+
expect(User.View([])).toEqual({})
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('Created event', () => {
|
|
42
|
+
it('sets identity and profile fields from the event', () => {
|
|
43
|
+
const result = User.View([createdEvent()])
|
|
44
|
+
expect(result).toMatchObject({
|
|
45
|
+
id: 'user-1',
|
|
46
|
+
type: 'User',
|
|
47
|
+
created: NOW,
|
|
48
|
+
email: 'ada@example.com',
|
|
49
|
+
firstName: 'Ada',
|
|
50
|
+
lastName: 'Lovelace',
|
|
51
|
+
workspaces: [],
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('preserves existing workspaces from savedState', () => {
|
|
56
|
+
const result = User.View([createdEvent()], { workspaces: ['ws-1'] })
|
|
57
|
+
expect(result.workspaces).toEqual(['ws-1'])
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('uses the user schema type on Created events', () => {
|
|
61
|
+
expect(createdEvent().type).toBe(UserSchema.user)
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe('SignInVerified', () => {
|
|
66
|
+
it('sets verifiedAt from the first verification event', () => {
|
|
67
|
+
const result = User.View([
|
|
68
|
+
createdEvent(),
|
|
69
|
+
withMeta(UsersEvents.SignInVerified({
|
|
70
|
+
createdBy: 'user-1',
|
|
71
|
+
token: 'tok',
|
|
72
|
+
}), { created: NOW + 2000 }),
|
|
73
|
+
])
|
|
74
|
+
expect(result.verifiedAt).toBe(NOW + 2000)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('keeps the earliest verifiedAt when verified again', () => {
|
|
78
|
+
const result = User.View([
|
|
79
|
+
createdEvent(),
|
|
80
|
+
withMeta(UsersEvents.SignInVerified({
|
|
81
|
+
createdBy: 'user-1',
|
|
82
|
+
token: 'tok-1',
|
|
83
|
+
}), { created: NOW + 2000 }),
|
|
84
|
+
withMeta(UsersEvents.SignInVerified({
|
|
85
|
+
createdBy: 'user-1',
|
|
86
|
+
token: 'tok-2',
|
|
87
|
+
}), { created: NOW + 3000 }),
|
|
88
|
+
])
|
|
89
|
+
expect(result.verifiedAt).toBe(NOW + 2000)
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('NameUpdated', () => {
|
|
94
|
+
it('updates first and last name from the payload', () => {
|
|
95
|
+
const result = User.View([
|
|
96
|
+
createdEvent(),
|
|
97
|
+
withMeta(UsersEvents.NameUpdated({
|
|
98
|
+
firstName: 'Augusta',
|
|
99
|
+
lastName: 'Byron',
|
|
100
|
+
createdBy: 'user-1',
|
|
101
|
+
})),
|
|
102
|
+
])
|
|
103
|
+
expect(result.firstName).toBe('Augusta')
|
|
104
|
+
expect(result.lastName).toBe('Byron')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('keeps previous names when payload fields are empty', () => {
|
|
108
|
+
const result = User.View([
|
|
109
|
+
createdEvent(),
|
|
110
|
+
withMeta(UsersEvents.NameUpdated({
|
|
111
|
+
firstName: '',
|
|
112
|
+
lastName: '',
|
|
113
|
+
createdBy: 'user-1',
|
|
114
|
+
})),
|
|
115
|
+
])
|
|
116
|
+
expect(result.firstName).toBe('Ada')
|
|
117
|
+
expect(result.lastName).toBe('Lovelace')
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe('email change', () => {
|
|
122
|
+
it('sets pendingEmail on EmailChangeRequested and updates email on EmailUpdated', () => {
|
|
123
|
+
const requested = withMeta(UsersEvents.EmailChangeRequested({
|
|
124
|
+
email: 'new@example.com',
|
|
125
|
+
createdBy: 'user-1',
|
|
126
|
+
}), { resourceId: 'user-1', created: NOW + 1 })
|
|
127
|
+
const updated = withMeta(UsersEvents.EmailUpdated({
|
|
128
|
+
email: 'new@example.com',
|
|
129
|
+
previousEmail: 'ada@example.com',
|
|
130
|
+
createdBy: 'user-1',
|
|
131
|
+
}), { resourceId: 'user-1', created: NOW + 2 })
|
|
132
|
+
|
|
133
|
+
const afterRequest = User.View([createdEvent(), requested])
|
|
134
|
+
expect(afterRequest.email).toBe('ada@example.com')
|
|
135
|
+
expect(afterRequest.pendingEmail).toBe('new@example.com')
|
|
136
|
+
|
|
137
|
+
const afterConfirm = User.View([createdEvent(), requested, updated])
|
|
138
|
+
expect(afterConfirm.email).toBe('new@example.com')
|
|
139
|
+
expect(afterConfirm.pendingEmail).toBeUndefined()
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
describe('WorkspaceJoined and WorkspaceLeft', () => {
|
|
144
|
+
it('adds a workspace id without duplicates', () => {
|
|
145
|
+
const joined = withMeta(UsersEvents.WorkspaceJoined({
|
|
146
|
+
workspaceId: 'ws-1',
|
|
147
|
+
createdBy: 'user-1',
|
|
148
|
+
}))
|
|
149
|
+
const result = User.View([createdEvent(), joined, joined])
|
|
150
|
+
expect(result.workspaces).toEqual(['ws-1'])
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('removes a workspace id on WorkspaceLeft', () => {
|
|
154
|
+
const result = User.View([
|
|
155
|
+
createdEvent(),
|
|
156
|
+
withMeta(UsersEvents.WorkspaceJoined({
|
|
157
|
+
workspaceId: 'ws-1',
|
|
158
|
+
createdBy: 'user-1',
|
|
159
|
+
})),
|
|
160
|
+
withMeta(UsersEvents.WorkspaceJoined({
|
|
161
|
+
workspaceId: 'ws-2',
|
|
162
|
+
createdBy: 'user-1',
|
|
163
|
+
}), { created: NOW + 2000 }),
|
|
164
|
+
withMeta(UsersEvents.WorkspaceLeft({
|
|
165
|
+
workspaceId: 'ws-1',
|
|
166
|
+
createdBy: 'user-1',
|
|
167
|
+
}), { created: NOW + 3000 }),
|
|
168
|
+
])
|
|
169
|
+
expect(result.workspaces).toEqual(['ws-2'])
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
describe('unknown event type', () => {
|
|
174
|
+
it('ignores unknown events and returns state unchanged', () => {
|
|
175
|
+
const unknown = { event: 'SomethingElse', payload: {} }
|
|
176
|
+
expect(User.View([createdEvent(), unknown]).email).toBe('ada@example.com')
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
describe('User.UserForWorkspace', () => {
|
|
182
|
+
it('projects the workspace-facing user fields', () => {
|
|
183
|
+
const projection = User.UserForWorkspace([createdEvent()])
|
|
184
|
+
expect(projection).toEqual({
|
|
185
|
+
id: 'user-1',
|
|
186
|
+
firstName: 'Ada',
|
|
187
|
+
lastName: 'Lovelace',
|
|
188
|
+
email: 'ada@example.com',
|
|
189
|
+
type: 'User',
|
|
190
|
+
})
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
describe('User.History', () => {
|
|
195
|
+
it('returns event types newest first', () => {
|
|
196
|
+
const history = User.History([
|
|
197
|
+
{ ...createdEvent(), created: NOW },
|
|
198
|
+
withMeta(UsersEvents.NameUpdated({
|
|
199
|
+
firstName: 'Augusta',
|
|
200
|
+
lastName: 'Byron',
|
|
201
|
+
createdBy: 'user-1',
|
|
202
|
+
}), { created: NOW + 5000 }),
|
|
203
|
+
withMeta(UsersEvents.SignInVerified({
|
|
204
|
+
createdBy: 'user-1',
|
|
205
|
+
token: 'tok',
|
|
206
|
+
}), { created: NOW + 2000 }),
|
|
207
|
+
])
|
|
208
|
+
expect(history).toEqual([
|
|
209
|
+
{ type: 'NameUpdated', created: NOW + 5000 },
|
|
210
|
+
{ type: 'SignInVerified', created: NOW + 2000 },
|
|
211
|
+
{ type: 'Created', created: NOW },
|
|
212
|
+
])
|
|
213
|
+
})
|
|
214
|
+
})
|
package/src/users.events.js
CHANGED
|
@@ -39,6 +39,22 @@ export class UsersEvents {
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
static EmailChangeRequested ({ email, createdBy }) {
|
|
43
|
+
return {
|
|
44
|
+
event: 'EmailChangeRequested',
|
|
45
|
+
createdBy,
|
|
46
|
+
payload: { email },
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
static EmailUpdated ({ email, previousEmail, createdBy }) {
|
|
51
|
+
return {
|
|
52
|
+
event: 'EmailUpdated',
|
|
53
|
+
createdBy,
|
|
54
|
+
payload: { email, previousEmail },
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
42
58
|
static WorkspaceJoined ({ workspaceId, createdBy }) {
|
|
43
59
|
return {
|
|
44
60
|
event: 'WorkspaceJoined',
|
|
@@ -402,6 +402,152 @@ describe('[users/actions/get-current-user-history]', () => {
|
|
|
402
402
|
})
|
|
403
403
|
})
|
|
404
404
|
|
|
405
|
+
describe('[users/actions/request-email-change]', () => {
|
|
406
|
+
|
|
407
|
+
TestUtil.AssertActionAuthenticationNeeded({
|
|
408
|
+
actionId: '@ossy/users/actions/request-email-change',
|
|
409
|
+
payload: { email: 'new@example.com' },
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
describe('given an email that is not already in use', () => {
|
|
413
|
+
it('must return OK 200 and produce EmailChangeRequested plus an EmailChange token', async () => {
|
|
414
|
+
const user = await TestUtil.GetAuthenticatedTestUser()
|
|
415
|
+
const newEmail = `new-${Date.now()}-${casual.email}`
|
|
416
|
+
|
|
417
|
+
await TestUtil.AssertActionResponse({
|
|
418
|
+
actionId: '@ossy/users/actions/request-email-change',
|
|
419
|
+
headers: authHeaders(user.token),
|
|
420
|
+
payload: { email: newEmail },
|
|
421
|
+
expectedResponseStatus: 200,
|
|
422
|
+
expectedResponseBody: { ok: true },
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
await TestUtil.AssertEventExist({
|
|
426
|
+
type: USER_SCHEMA,
|
|
427
|
+
resourceId: user.id,
|
|
428
|
+
event: 'EmailChangeRequested',
|
|
429
|
+
'payload.email': newEmail,
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
const jwt = await TestUtil.getLatestEmailChangeJwtForSubject(user.id)
|
|
433
|
+
expect(typeof jwt).toBe('string')
|
|
434
|
+
})
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
describe('given an email that is already in use', () => {
|
|
438
|
+
it('must return OK 200 without EmailChangeRequested or EmailChange token', async () => {
|
|
439
|
+
const takenEmail = `taken-${Date.now()}-${casual.email}`
|
|
440
|
+
await TestUtil.AssertActionResponse({
|
|
441
|
+
actionId: '@ossy/authentication/actions/sign-up',
|
|
442
|
+
payload: { email: takenEmail, firstName: 'Taken', lastName: 'User' },
|
|
443
|
+
expectedResponseStatus: 200,
|
|
444
|
+
expectedResponseBody: { ok: true },
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
const user = await TestUtil.GetAuthenticatedTestUser()
|
|
448
|
+
const tokensBefore = await TestUtil.countEmailChangeTokenEvents()
|
|
449
|
+
|
|
450
|
+
await TestUtil.AssertActionResponse({
|
|
451
|
+
actionId: '@ossy/users/actions/request-email-change',
|
|
452
|
+
headers: authHeaders(user.token),
|
|
453
|
+
payload: { email: takenEmail },
|
|
454
|
+
expectedResponseStatus: 200,
|
|
455
|
+
expectedResponseBody: { ok: true },
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
const changeEvents = await TestUtil.GetEvents({
|
|
459
|
+
type: USER_SCHEMA,
|
|
460
|
+
resourceId: user.id,
|
|
461
|
+
event: 'EmailChangeRequested',
|
|
462
|
+
}).catch(() => [])
|
|
463
|
+
const tokensAfter = await TestUtil.countEmailChangeTokenEvents()
|
|
464
|
+
|
|
465
|
+
expect(changeEvents).toEqual([])
|
|
466
|
+
expect(tokensAfter).toEqual(tokensBefore)
|
|
467
|
+
})
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
describe('given an invalid email', () => {
|
|
471
|
+
invalidEmails.forEach((invalidEmail) => {
|
|
472
|
+
it(
|
|
473
|
+
`must return BAD REQUEST 400 for invalid email: ${invalidEmail}`,
|
|
474
|
+
async () => {
|
|
475
|
+
const user = await TestUtil.GetAuthenticatedTestUser()
|
|
476
|
+
await TestUtil.AssertActionResponse({
|
|
477
|
+
actionId: '@ossy/users/actions/request-email-change',
|
|
478
|
+
headers: authHeaders(user.token),
|
|
479
|
+
payload: { email: invalidEmail },
|
|
480
|
+
expectedResponseStatus: 400,
|
|
481
|
+
expectedResponseBody: { error: 'No email provided' },
|
|
482
|
+
})
|
|
483
|
+
},
|
|
484
|
+
)
|
|
485
|
+
})
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
describe('[/users/confirm-email-change][GET]', () => {
|
|
491
|
+
|
|
492
|
+
describe('given a valid EmailChange token', () => {
|
|
493
|
+
it('must return OK 200, produce EmailUpdated, and update get-current-user email', async () => {
|
|
494
|
+
const user = await TestUtil.GetAuthenticatedTestUser()
|
|
495
|
+
const newEmail = `confirm-${Date.now()}-${casual.email}`
|
|
496
|
+
|
|
497
|
+
await TestUtil.AssertActionResponse({
|
|
498
|
+
actionId: '@ossy/users/actions/request-email-change',
|
|
499
|
+
headers: authHeaders(user.token),
|
|
500
|
+
payload: { email: newEmail },
|
|
501
|
+
expectedResponseStatus: 200,
|
|
502
|
+
expectedResponseBody: { ok: true },
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
const emailChangeJwt = await TestUtil.getLatestEmailChangeJwtForSubject(user.id)
|
|
506
|
+
|
|
507
|
+
const response = await fetch(
|
|
508
|
+
`${getApiTestBaseUrl()}/users/confirm-email-change?token=${emailChangeJwt}`,
|
|
509
|
+
{ method: 'GET' },
|
|
510
|
+
)
|
|
511
|
+
const body = await response.json()
|
|
512
|
+
|
|
513
|
+
expect(response.status).toBe(200)
|
|
514
|
+
expect(body).toEqual({ ok: true, email: newEmail })
|
|
515
|
+
|
|
516
|
+
await TestUtil.AssertEventExist({
|
|
517
|
+
type: USER_SCHEMA,
|
|
518
|
+
resourceId: user.id,
|
|
519
|
+
event: 'EmailUpdated',
|
|
520
|
+
'payload.email': newEmail,
|
|
521
|
+
'payload.previousEmail': user.email,
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
const currentUserResponse = await TestUtil.InvokeAction({
|
|
525
|
+
actionId: '@ossy/authentication/actions/get-current-user',
|
|
526
|
+
headers: authHeaders(user.token),
|
|
527
|
+
})
|
|
528
|
+
const currentUser = await currentUserResponse.json()
|
|
529
|
+
expect(currentUser.email).toBe(newEmail)
|
|
530
|
+
})
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
describe('given an invalid token', () => {
|
|
534
|
+
it('must return 400 when token query param is missing', () => TestUtil.AssertResponse({
|
|
535
|
+
endpoint: '/users/confirm-email-change',
|
|
536
|
+
method: 'GET',
|
|
537
|
+
expectedResponseStatus: 400,
|
|
538
|
+
expectedResponseBody: { message: 'No token provided' },
|
|
539
|
+
}))
|
|
540
|
+
|
|
541
|
+
it('must return 401 for a garbage token', () => TestUtil.AssertResponse({
|
|
542
|
+
endpoint: '/users/confirm-email-change?token=notatoken',
|
|
543
|
+
method: 'GET',
|
|
544
|
+
expectedResponseStatus: 401,
|
|
545
|
+
expectedResponseBody: { error: 'Invalid or expired token' },
|
|
546
|
+
}))
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
})
|
|
550
|
+
|
|
405
551
|
describe('[users/actions/create-api-token]', () => {
|
|
406
552
|
|
|
407
553
|
TestUtil.AssertActionAuthenticationNeeded({
|
package/src/users.queries.js
CHANGED
|
@@ -10,8 +10,8 @@ export class UsersQueries {
|
|
|
10
10
|
log.debug(`[UsersQueries][getByEmail()] Fetch user for ${email}`)
|
|
11
11
|
|
|
12
12
|
return Aggregate.Collection.findOne({
|
|
13
|
-
type: UserSchema.user,
|
|
14
13
|
'state.email': email,
|
|
14
|
+
type: { $in: [UserSchema.user, 'User'] },
|
|
15
15
|
}).then(aggregate => aggregate?.state)
|
|
16
16
|
}
|
|
17
17
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { EmailLayout, EmailButton, EmailText } from '@ossy/email'
|
|
2
|
+
|
|
3
|
+
export const id = '@ossy/users/emails/verify-email-change'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {{ token: string, baseUrl?: string, email?: string, theme?: object, t?: (key: string) => string }} props
|
|
7
|
+
*/
|
|
8
|
+
export default function VerifyEmailChangeEmail ({
|
|
9
|
+
token,
|
|
10
|
+
baseUrl = 'https://app.ossy.se',
|
|
11
|
+
email,
|
|
12
|
+
theme,
|
|
13
|
+
t = (key) => key,
|
|
14
|
+
}) {
|
|
15
|
+
const url = new URL('/verify-email-change', baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`)
|
|
16
|
+
url.searchParams.set('token', token)
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<EmailLayout theme={theme}>
|
|
20
|
+
<h1 style={{ color: '#111111', margin: '0 0 16px' }}>{t('users.verifyEmailChange.title')}</h1>
|
|
21
|
+
<EmailText>
|
|
22
|
+
{t('users.verifyEmailChange.body')}
|
|
23
|
+
{email ? ` (${email})` : ''}
|
|
24
|
+
</EmailText>
|
|
25
|
+
<EmailButton href={url.toString()} theme={theme}>
|
|
26
|
+
{t('users.verifyEmailChange.button')}
|
|
27
|
+
</EmailButton>
|
|
28
|
+
</EmailLayout>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { View } from '@ossy/design-system'
|
|
3
|
+
import { VerifyEmailChange } from './VerifyEmailChange.jsx'
|
|
4
|
+
|
|
5
|
+
export const metadata = {
|
|
6
|
+
id: 'verify-email-change',
|
|
7
|
+
path: '/verify-email-change',
|
|
8
|
+
layout: '@ossy/app/layout/blank',
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const VerifyEmailChangePage = () => (
|
|
12
|
+
<View
|
|
13
|
+
layout="off-center-s"
|
|
14
|
+
inset="s"
|
|
15
|
+
surface="base"
|
|
16
|
+
style={{ height: '100%', minHeight: '100%' }}
|
|
17
|
+
>
|
|
18
|
+
<View inset="s" roundness="l" surface="primary" data-region="content" style={{ height: '100%' }}>
|
|
19
|
+
<VerifyEmailChange
|
|
20
|
+
data-region="content"
|
|
21
|
+
inset="none"
|
|
22
|
+
style={{
|
|
23
|
+
padding: 'var(--space-l) var(--space-l) var(--space-m) var(--space-l)',
|
|
24
|
+
height: '100%',
|
|
25
|
+
}}
|
|
26
|
+
/>
|
|
27
|
+
</View>
|
|
28
|
+
</View>
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
export default VerifyEmailChangePage
|