@ossy/users 3.0.8 → 3.2.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 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.8",
4
+ "version": "3.2.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
  },
@@ -29,9 +34,9 @@
29
34
  "registry": "https://registry.npmjs.org"
30
35
  },
31
36
  "dependencies": {
32
- "@ossy/config": "^3.0.8",
33
- "@ossy/observability": "^3.0.8",
34
- "@ossy/schema": "^3.0.8",
37
+ "@ossy/config": "^3.0.9",
38
+ "@ossy/observability": "^3.0.9",
39
+ "@ossy/schema": "^3.0.9",
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": "980b380c9dee0de78ef3ebabe86efcec1c223a9a"
51
+ "gitHead": "b045f1fd7f2fe00c776b9ea96f76083b93fd8556"
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,4 @@
1
+ export const metadata = {
2
+ id: '@ossy/users/actions/confirm-email-change',
3
+ access: 'public',
4
+ }
@@ -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,7 @@
1
+ /** Client-only — opens the profile details editor. */
2
+ export const metadata = {
3
+ id: '@ossy/users/actions/edit-profile-details',
4
+ access: 'authenticated',
5
+ icon: 'pen',
6
+ label: '@ossy/users/actions/edit-profile-details.label',
7
+ }
@@ -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
+ }
@@ -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,4 @@
1
+ export const metadata = {
2
+ id: '@ossy/users/actions/request-email-change',
3
+ access: 'authenticated',
4
+ }
@@ -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
+ }
@@ -0,0 +1,6 @@
1
+ /** Client-only — submits the profile details form (name and/or email change). */
2
+ export const metadata = {
3
+ id: '@ossy/users/actions/save-profile-details',
4
+ access: 'authenticated',
5
+ label: '@ossy/users/actions/save-profile-details.label',
6
+ }
@@ -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,5 @@
1
+ export const metadata = {
2
+ id: '@ossy/users/form/update-profile-details',
3
+ schemaId: '@ossy/users/schema/update-profile-details',
4
+ label: '@ossy/users/form/update-profile-details.label',
5
+ }
@@ -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
+ }
@@ -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,39 @@
1
+ import { describe, expect, it } from '@jest/globals'
2
+ import { User } from './user.aggregate.js'
3
+ import { UsersEvents } from './users.events.js'
4
+
5
+ describe('User.View email change', () => {
6
+ it('sets pendingEmail on EmailChangeRequested and updates email on EmailUpdated', () => {
7
+ const created = UsersEvents.Created({
8
+ email: 'old@example.com',
9
+ firstName: 'Ada',
10
+ lastName: 'Lovelace',
11
+ userId: 'user-1',
12
+ })
13
+ const requested = {
14
+ ...UsersEvents.EmailChangeRequested({
15
+ email: 'new@example.com',
16
+ createdBy: 'user-1',
17
+ }),
18
+ resourceId: 'user-1',
19
+ created: Date.now(),
20
+ }
21
+ const updated = {
22
+ ...UsersEvents.EmailUpdated({
23
+ email: 'new@example.com',
24
+ previousEmail: 'old@example.com',
25
+ createdBy: 'user-1',
26
+ }),
27
+ resourceId: 'user-1',
28
+ created: Date.now() + 1,
29
+ }
30
+
31
+ const afterRequest = User.View([created, requested])
32
+ expect(afterRequest.email).toBe('old@example.com')
33
+ expect(afterRequest.pendingEmail).toBe('new@example.com')
34
+
35
+ const afterConfirm = User.View([created, requested, updated])
36
+ expect(afterConfirm.email).toBe('new@example.com')
37
+ expect(afterConfirm.pendingEmail).toBeUndefined()
38
+ })
39
+ })
@@ -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({
@@ -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