@xeplr/auth 1.0.0 → 1.0.1

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.
Files changed (59) hide show
  1. package/bin/migrate.js +43 -24
  2. package/bin/server.js +12 -30
  3. package/index.js +135 -51
  4. package/lib/adminRouter.js +20 -92
  5. package/lib/authHelper.js +26 -3
  6. package/lib/authMiddleware.js +39 -13
  7. package/lib/authRouter.js +140 -11
  8. package/lib/authService.js +327 -9
  9. package/lib/hooks.js +50 -0
  10. package/lib/mtMembershipMiddleware.js +61 -0
  11. package/lib/seed.js +74 -0
  12. package/lib/sessionService.js +54 -6
  13. package/lib/ticketService.js +88 -0
  14. package/lib/tokenDecision.js +45 -0
  15. package/migrations/0001_extensions.sql +9 -0
  16. package/migrations/0002_users.sql +31 -0
  17. package/migrations/0003_catalog_tables.sql +82 -0
  18. package/migrations/0004_role_mappings.sql +78 -0
  19. package/migrations/0005_user_tenants_mapping.sql +39 -0
  20. package/migrations/0006_seed_catalog.sql +120 -0
  21. package/models/Api.js +3 -0
  22. package/models/ApisRolesMapping.js +3 -0
  23. package/models/Menu.js +3 -0
  24. package/models/MenuRolesMapping.js +3 -0
  25. package/models/Role.js +8 -0
  26. package/models/UiElement.js +3 -0
  27. package/models/UiElementsRolesMapping.js +3 -0
  28. package/models/UiPage.js +3 -0
  29. package/models/UiPagesRolesMapping.js +3 -0
  30. package/models/User.js +3 -13
  31. package/models/UserRolesMapping.js +3 -0
  32. package/models/UserTenantsMapping.js +20 -2
  33. package/models/index.js +0 -2
  34. package/package.json +26 -5
  35. package/migrations/0001_users.js +0 -22
  36. package/migrations/0002_menus.js +0 -16
  37. package/migrations/0003_apis.js +0 -16
  38. package/migrations/0004_uiPages.js +0 -16
  39. package/migrations/0005_uiElements.js +0 -16
  40. package/migrations/0006_roles.js +0 -15
  41. package/migrations/0007_apisRolesMapping.js +0 -16
  42. package/migrations/0008_uiPagesRolesMapping.js +0 -16
  43. package/migrations/0009_uiElementsRolesMapping.js +0 -16
  44. package/migrations/0010_menuRolesMapping.js +0 -16
  45. package/migrations/0011_userRolesMapping.js +0 -16
  46. package/migrations/0012_users_add_reset_token.js +0 -13
  47. package/migrations/0013_add_isPublic.js +0 -27
  48. package/migrations/0014_users_add_activation_token.js +0 -13
  49. package/migrations/0015_add_mt_columns.js +0 -41
  50. package/migrations/0016_tenants.js +0 -23
  51. package/migrations/0017_userTenantsMapping.js +0 -20
  52. package/models/Tenant.js +0 -63
  53. package/seeds/001_roles.js +0 -26
  54. package/seeds/002_apis.js +0 -70
  55. package/seeds/003_pages.js +0 -41
  56. package/seeds/004_elements.js +0 -46
  57. package/seeds/005_menus.js +0 -35
  58. package/seeds/006_default_tenant.js +0 -50
  59. package/seeds/zzz_admin_access.js +0 -49
@@ -1,27 +1,53 @@
1
- const { verifyToken } = require('./authHelper');
2
- const { validateAccessToken } = require('./sessionService');
1
+ const { decodeVerified, getToleranceSeconds } = require('./authHelper');
2
+ const { validateAccessToken, slideAccessToken } = require('./sessionService');
3
+ const { decide } = require('./tokenDecision');
3
4
 
5
+ // The ONE place token logic lives. Order (from tokenDecision): signature →
6
+ // revocation → expiry. Tolerance (ACCESS_TOKEN_TOLERANCE_SECONDS, default 0)
7
+ // turns on the sliding refresh: an expired-but-recent token is served AND a
8
+ // fresh token is attached to the response (X-New-Token), set BEFORE the handler
9
+ // runs so it rides every response path. With tolerance 0 this is exactly the
10
+ // old behavior (reject on expiry).
4
11
  async function authMiddleware(req, res, next) {
5
12
  const authHeader = req.headers.authorization;
6
-
7
13
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
8
14
  return res.status(401).json({ error: 'No token provided' });
9
15
  }
10
-
11
16
  const token = authHeader.split(' ')[1];
12
17
 
13
- // Step 1: Verify JWT signature + expiry
14
- let decoded;
15
- try {
16
- decoded = verifyToken(token);
17
- } catch (err) {
18
+ // Verify signature, ignore expiry (we decide on expiry ourselves).
19
+ const { decoded, signatureValid } = decodeVerified(token);
20
+
21
+ // Session liveness = the whitelist entry still exists (a logout deletes it).
22
+ // The entry outlives the token by `tolerance`, so it's present during grace.
23
+ const session = signatureValid ? await validateAccessToken(token) : null;
24
+
25
+ const decision = decide({
26
+ exp: decoded && decoded.exp,
27
+ now: Math.floor(Date.now() / 1000),
28
+ toleranceSeconds: getToleranceSeconds(),
29
+ sessionLive: !!session,
30
+ signatureValid: signatureValid
31
+ });
32
+
33
+ if (decision.action === 'reject') {
34
+ if (req.log) req.log.debug('auth reject: ' + decision.reason);
18
35
  return res.status(401).json({ error: 'Invalid or expired token' });
19
36
  }
20
37
 
21
- // Step 2: Check Redis whitelist (token not revoked)
22
- const session = await validateAccessToken(token);
23
- if (!session) {
24
- return res.status(401).json({ error: 'Session expired or revoked' });
38
+ if (decision.action === 'slide') {
39
+ // Serve the request but hand back a fresh token. Header set BEFORE next()
40
+ // present on every response. A slide failure must not block a live
41
+ // session fall through and serve with the (still-tolerated) old token.
42
+ try {
43
+ const newToken = await slideAccessToken(token, decoded);
44
+ if (newToken) {
45
+ res.setHeader('X-New-Token', newToken);
46
+ res.setHeader('Access-Control-Expose-Headers', 'X-New-Token');
47
+ }
48
+ } catch (err) {
49
+ if (req.log) req.log.error('slide failed: ' + err.message);
50
+ }
25
51
  }
26
52
 
27
53
  req.user = decoded;
package/lib/authRouter.js CHANGED
@@ -1,10 +1,19 @@
1
1
  const express = require('express');
2
- const { register, activate, login, forgotPassword, resetPassword } = require('./authService');
2
+ const FileUploader = require('@xeplr/utils/lib/fileUploader');
3
+ const { register, activate, previewInvite, acceptInvite, login, forgotPassword, resetPassword, requestPhoneOtp, verifyPhoneOtp, getProfile, updateProfile, setProfilePic, changePassword } = require('./authService');
3
4
  const { getUserAccess } = require('./accessService');
4
5
  const { getSession, rotateSession, destroySession } = require('./sessionService');
6
+ const { issueTicket } = require('./ticketService');
5
7
  const authMiddleware = require('./authMiddleware');
6
8
  const User = require('../models/User');
7
9
 
10
+ // Avatar uploads — local disk by default (FileUploader's own default:
11
+ // process.env.UPLOAD_DIR or ./uploads). Auth only persists the resulting
12
+ // path on the user; serving it back over HTTP is the consuming app's concern
13
+ // (same as any other uploaded asset), so wire a static route to the same
14
+ // directory wherever it makes sense for your deployment.
15
+ const avatarUploader = new FileUploader({ allowedTypes: ['image/*'], maxSize: 2 * 1024 * 1024 });
16
+
8
17
  function logError(req, err) {
9
18
  if (req.log) req.log.error(err.message, { stack: err.stack });
10
19
  }
@@ -47,6 +56,32 @@ function createAuthRouter(options = {}) {
47
56
  }
48
57
  });
49
58
 
59
+ router.get('/invite-preview', async function(req, res) {
60
+ try {
61
+ var token = req.query.token;
62
+ if (!token) return res.status(400).json({ error: 'Token is required' });
63
+ var info = await previewInvite(token);
64
+ res.json(info);
65
+ } catch (err) {
66
+ logError(req, err);
67
+ res.status(400).json({ error: err.message });
68
+ }
69
+ });
70
+
71
+ router.post('/invite-accept', async function(req, res) {
72
+ try {
73
+ const { token, name, password } = req.body;
74
+ if (!token || !password) {
75
+ return res.status(400).json({ error: 'Token and password are required' });
76
+ }
77
+ const result = await acceptInvite({ token, name, password });
78
+ res.json({ message: 'Invite accepted', user: result });
79
+ } catch (err) {
80
+ logError(req, err);
81
+ res.status(400).json({ error: err.message });
82
+ }
83
+ });
84
+
50
85
  router.post('/login', async function(req, res) {
51
86
  try {
52
87
  const { email, password } = req.body;
@@ -115,12 +150,20 @@ function createAuthRouter(options = {}) {
115
150
  return res.status(401).json({ error: 'Invalid or expired refresh token' });
116
151
  }
117
152
 
118
- const user = await User.query().findById(session.userId);
153
+ const user = await User.query().findById(session.userId).withGraphFetched('roles');
119
154
  if (!user || !user.isActive) {
120
155
  return res.status(401).json({ error: 'Account not found or deactivated' });
121
156
  }
122
157
 
123
- const result = await rotateSession(refreshToken, user);
158
+ // Pack role names so refreshed tokens carry the same role claims as login.
159
+ const tokenUser = {
160
+ id: user.id,
161
+ email: user.email,
162
+ name: user.name,
163
+ roles: (user.roles || []).map(function(r) { return r.name; })
164
+ };
165
+
166
+ const result = await rotateSession(refreshToken, tokenUser);
124
167
  if (!result) {
125
168
  return res.status(401).json({ error: 'Invalid or expired refresh token' });
126
169
  }
@@ -172,17 +215,103 @@ function createAuthRouter(options = {}) {
172
215
  }
173
216
  });
174
217
 
175
- // Get tenants for the current logged-in user
176
- router.get('/my-tenants', authMiddleware, async function(req, res) {
218
+ router.get('/profile', authMiddleware, async function(req, res) {
177
219
  try {
178
- var user = await User.query().findById(req.user.id).withGraphFetched('tenants');
179
- var tenants = (user && user.tenants) ? user.tenants : [];
180
- res.json(tenants.map(function(t) {
181
- return { id: t.id, name: t.name, code: t.code, description: t.description };
182
- }));
220
+ const profile = await getProfile(req.user.id);
221
+ res.json(profile);
183
222
  } catch (err) {
184
223
  logError(req, err);
185
- res.status(500).json({ error: 'Something went wrong' });
224
+ res.status(400).json({ error: err.message });
225
+ }
226
+ });
227
+
228
+ router.put('/profile', authMiddleware, async function(req, res) {
229
+ try {
230
+ const profile = await updateProfile(req.user.id, req.body);
231
+ res.json(profile);
232
+ } catch (err) {
233
+ logError(req, err);
234
+ res.status(400).json({ error: err.message });
235
+ }
236
+ });
237
+
238
+ router.post('/profile/avatar', authMiddleware, ...avatarUploader.single('avatar'), async function(req, res) {
239
+ try {
240
+ if (!req.file) return res.status(400).json({ error: 'avatar file is required' });
241
+ // Store just the generated filename — never the server's filesystem
242
+ // path (leaks server layout, and isn't meaningful once you swap disk
243
+ // storage for S3/etc). The app knows its own UPLOAD_DIR and however
244
+ // it serves it; it builds the actual URL from this filename.
245
+ const profile = await setProfilePic(req.user.id, req.file.filename);
246
+ res.json(profile);
247
+ } catch (err) {
248
+ logError(req, err);
249
+ res.status(400).json({ error: err.message });
250
+ }
251
+ });
252
+
253
+ router.post('/change-password', authMiddleware, async function(req, res) {
254
+ try {
255
+ const { oldPassword, newPassword } = req.body;
256
+ if (!oldPassword || !newPassword) {
257
+ return res.status(400).json({ error: 'oldPassword and newPassword are required' });
258
+ }
259
+ await changePassword(req.user.id, { oldPassword, newPassword });
260
+ res.json({ message: 'Password changed. Please log in again.' });
261
+ } catch (err) {
262
+ logError(req, err);
263
+ res.status(400).json({ error: err.message });
264
+ }
265
+ });
266
+
267
+ // Additive phone verification — like email activation, but for a phone
268
+ // number attached to an already-authenticated account. Body: { phoneNumber }
269
+ // optional (omit to re-send to the number already on file).
270
+ router.post('/request-phone-otp', authMiddleware, async function(req, res) {
271
+ try {
272
+ const result = await requestPhoneOtp(req.user.id, req.body.phoneNumber);
273
+ res.json(result);
274
+ } catch (err) {
275
+ logError(req, err);
276
+ res.status(400).json({ error: err.message });
277
+ }
278
+ });
279
+
280
+ router.post('/verify-phone-otp', authMiddleware, async function(req, res) {
281
+ try {
282
+ if (!req.body.code) return res.status(400).json({ error: 'code is required' });
283
+ const result = await verifyPhoneOtp(req.user.id, req.body.code);
284
+ res.json(result);
285
+ } catch (err) {
286
+ logError(req, err);
287
+ res.status(400).json({ error: err.message });
288
+ }
289
+ });
290
+
291
+ // ── SSE ticket ────────────────────────────────────────────────────────
292
+ //
293
+ // Mints a short-lived, single-use ticket so a browser can open an
294
+ // EventSource without leaking the primary session token in the URL.
295
+ //
296
+ // Body (optional):
297
+ // { scopes: [...] } — client-requested scopes. If omitted, defaults
298
+ // to the user's own namespace (['user:me:*']).
299
+ // Whatever is minted here is what the SSE server
300
+ // will enforce at subscribe time.
301
+ //
302
+ // NOTE the scopes are advisory intent — the SSE handler owns the final
303
+ // authorization check (see xeplr-base-apis/lib/sse.js#createHandler).
304
+ router.post('/sse-ticket', authMiddleware, async function(req, res) {
305
+ try {
306
+ var requested = (req.body && Array.isArray(req.body.scopes)) ? req.body.scopes : ['user:me:*'];
307
+ var { ticket, expiresIn } = await issueTicket({
308
+ userId: req.user.id,
309
+ scopes: requested
310
+ });
311
+ res.json({ ticket: ticket, expiresIn: expiresIn });
312
+ } catch (err) {
313
+ logError(req, err);
314
+ res.status(500).json({ error: 'Failed to mint SSE ticket' });
186
315
  }
187
316
  });
188
317
 
@@ -1,10 +1,11 @@
1
1
  const crypto = require('crypto');
2
- const { sendEmail, configureEmail, formatDbDateTime } = require('@xeplr/utils');
2
+ const { sendEmail, configureEmail, formatDbDateTime, otp } = require('@xeplr/utils');
3
3
  const { normalizeEmail } = require('@xeplr/utils/isomorphic');
4
4
  const { generateId, hashPassword, comparePassword } = require('./authHelper');
5
5
  const User = require('../models/User');
6
6
  const { getUserAccess, clearUserAccess } = require('./accessService');
7
7
  const { createSession, destroyAllUserSessions } = require('./sessionService');
8
+ const hooks = require('./hooks');
8
9
 
9
10
  let _activationBaseUrl = null;
10
11
 
@@ -12,10 +13,11 @@ function configureActivation(baseUrl) {
12
13
  _activationBaseUrl = baseUrl;
13
14
  }
14
15
 
15
- async function register({ email, phoneNumber, name, password }) {
16
+ async function register({ email, phoneNumber, name, password, knex }) {
17
+ const UserM = knex ? User.bindKnex(knex) : User;
16
18
  const normalized = normalizeEmail(email);
17
19
 
18
- const existing = await User.query().findOne({ normalizedEmail: normalized });
20
+ const existing = await UserM.query().findOne({ normalizedEmail: normalized });
19
21
  if (existing) {
20
22
  throw new Error('Email already registered');
21
23
  }
@@ -25,7 +27,7 @@ async function register({ email, phoneNumber, name, password }) {
25
27
  const now = formatDbDateTime();
26
28
  const activationToken = crypto.randomBytes(32).toString('hex');
27
29
 
28
- const user = await User.query().insert({
30
+ const user = await UserM.query().insert({
29
31
  id,
30
32
  email: email.trim().toLowerCase(),
31
33
  normalizedEmail: normalized,
@@ -45,7 +47,7 @@ async function register({ email, phoneNumber, name, password }) {
45
47
  });
46
48
 
47
49
  // Send activation email
48
- const baseUrl = _activationBaseUrl || process.env.ACTIVATION_BASE_URL;
50
+ const baseUrl = _activationBaseUrl || process.env.AUTH_ACTIVATION_BASE_URL;
49
51
  if (!baseUrl) {
50
52
  throw new Error('ACTIVATION_BASE_URL is required. Cannot register without email activation.');
51
53
  }
@@ -58,6 +60,8 @@ async function register({ email, phoneNumber, name, password }) {
58
60
 
59
61
  await sendEmail(email, 'Activate your account', html);
60
62
 
63
+ await hooks.fire('user', 'create', user.id);
64
+
61
65
  return {
62
66
  id: user.id,
63
67
  email: user.email,
@@ -86,12 +90,16 @@ async function activate(token) {
86
90
  recordModifiedDate: now
87
91
  });
88
92
 
93
+ await hooks.fire('user', 'update', user.id);
94
+
89
95
  return { id: user.id, email: user.email, name: user.name };
90
96
  }
91
97
 
92
98
  async function login({ email, password }) {
93
99
  const normalized = normalizeEmail(email);
94
- const user = await User.query().findOne({ normalizedEmail: normalized });
100
+ const user = await User.query()
101
+ .findOne({ normalizedEmail: normalized })
102
+ .withGraphFetched('roles');
95
103
  if (!user) {
96
104
  throw new Error('Invalid email or password');
97
105
  }
@@ -109,11 +117,22 @@ async function login({ email, password }) {
109
117
  throw new Error('NOT_ACTIVATED');
110
118
  }
111
119
 
112
- const { accessToken, refreshToken } = await createSession(user);
120
+ // Pack role names into the JWT so middleware (e.g. requireSuperAdmin)
121
+ // can gate without a per-request DB lookup.
122
+ const tokenUser = {
123
+ id: user.id,
124
+ email: user.email,
125
+ name: user.name,
126
+ roles: (user.roles || []).map(function(r) { return r.name; })
127
+ };
128
+
129
+ const { accessToken, refreshToken } = await createSession(tokenUser);
113
130
 
114
131
  await clearUserAccess(user.id);
115
132
  const access = await getUserAccess(user.id);
116
133
 
134
+ await hooks.fire('user', 'login', user.id);
135
+
117
136
  return {
118
137
  accessToken,
119
138
  refreshToken,
@@ -127,6 +146,124 @@ async function login({ email, password }) {
127
146
  };
128
147
  }
129
148
 
149
+ /**
150
+ * Invite a user. Creates a User row with no password, isActivated=false, and an
151
+ * activation token. Sends an "accept invite" email pointing the user to a page
152
+ * where they set their own password and activate.
153
+ *
154
+ * Idempotent on email: if the user already exists, returns the existing user
155
+ * (no email sent, no password reset). The caller can then layer additional
156
+ * roles/mappings on top.
157
+ */
158
+ async function invite({ email, name, phoneNumber, invitedBy, inviterName, appName, knex }) {
159
+ const UserM = knex ? User.bindKnex(knex) : User;
160
+ const normalized = normalizeEmail(email);
161
+
162
+ const existing = await UserM.query().findOne({ normalizedEmail: normalized });
163
+ if (existing) {
164
+ return {
165
+ id: existing.id,
166
+ email: existing.email,
167
+ name: existing.name,
168
+ isNew: false,
169
+ isActivated: existing.isActivated
170
+ };
171
+ }
172
+
173
+ const id = generateId();
174
+ const now = formatDbDateTime();
175
+ const activationToken = crypto.randomBytes(32).toString('hex');
176
+ const inviter = invitedBy || id;
177
+
178
+ const user = await UserM.query().insert({
179
+ id,
180
+ email: email.trim().toLowerCase(),
181
+ normalizedEmail: normalized,
182
+ phoneNumber: phoneNumber || null,
183
+ name: name || null,
184
+ pwd: null,
185
+ pwdSalt: null,
186
+ isActive: true,
187
+ isActivated: false,
188
+ activationToken,
189
+ activatedOn: null,
190
+ activatedBy: null,
191
+ recordCreatedDate: now,
192
+ recordModifiedDate: now,
193
+ recordCreatedBy: inviter,
194
+ recordModifiedBy: inviter
195
+ });
196
+
197
+ const baseUrl = _activationBaseUrl || process.env.AUTH_ACTIVATION_BASE_URL;
198
+ if (!baseUrl) {
199
+ throw new Error('ACTIVATION_BASE_URL is required to send invite emails.');
200
+ }
201
+
202
+ const acceptLink = baseUrl + '/auth/accept-invite?token=' + activationToken;
203
+ const app = appName || 'the app';
204
+ const subject = 'You have been invited to ' + app;
205
+ const html = '<h2>You have been invited</h2>'
206
+ + '<p>Hi ' + (name || 'there') + ',</p>'
207
+ + (inviterName ? '<p>' + inviterName + ' has invited you to join ' + app + '.</p>' : '<p>You have been invited to join ' + app + '.</p>')
208
+ + '<p>Click below to set your password and accept the invite:</p>'
209
+ + '<p><a href="' + acceptLink + '">' + acceptLink + '</a></p>';
210
+
211
+ await sendEmail(email, subject, html);
212
+
213
+ await hooks.fire('user', 'create', user.id);
214
+
215
+ return {
216
+ id: user.id,
217
+ email: user.email,
218
+ name: user.name,
219
+ isNew: true,
220
+ isActivated: false
221
+ };
222
+ }
223
+
224
+ /**
225
+ * Preview an invite — returns invitee info if the token matches a pending
226
+ * invite (user exists, is not activated, and has no password set yet).
227
+ */
228
+ async function previewInvite(token, options = {}) {
229
+ const UserM = options.knex ? User.bindKnex(options.knex) : User;
230
+ const user = await UserM.query().findOne({ activationToken: token });
231
+ if (!user) throw new Error('Invalid or expired invite');
232
+ if (user.isActivated) throw new Error('Invite already accepted');
233
+ if (user.pwd) throw new Error('This account already has a password — use the activation link instead');
234
+
235
+ return { email: user.email, name: user.name };
236
+ }
237
+
238
+ /**
239
+ * Accept an invite — sets the user's password and name, then activates.
240
+ */
241
+ async function acceptInvite({ token, name, password, knex }) {
242
+ if (!password) throw new Error('Password is required');
243
+
244
+ const UserM = knex ? User.bindKnex(knex) : User;
245
+ const user = await UserM.query().findOne({ activationToken: token });
246
+ if (!user) throw new Error('Invalid or expired invite');
247
+ if (user.isActivated) throw new Error('Invite already accepted');
248
+
249
+ const { hash, salt } = await hashPassword(password);
250
+ const now = formatDbDateTime();
251
+
252
+ await UserM.query().findById(user.id).patch({
253
+ name: name || user.name,
254
+ pwd: hash,
255
+ pwdSalt: salt,
256
+ isActivated: true,
257
+ activatedOn: now,
258
+ activationToken: null,
259
+ recordModifiedDate: now
260
+ });
261
+
262
+ await hooks.fire('user', 'update', user.id);
263
+
264
+ return { id: user.id, email: user.email, name: name || user.name };
265
+ }
266
+
130
267
  async function forgotPassword({ email }, resetBaseUrl) {
131
268
  const normalized = normalizeEmail(email);
132
269
  const user = await User.query().findOne({ normalizedEmail: normalized });
@@ -135,7 +272,7 @@ async function forgotPassword({ email }, resetBaseUrl) {
135
272
  }
136
273
 
137
274
  const resetToken = crypto.randomBytes(32).toString('hex');
138
- const expiryMinutes = parseInt(process.env.RESET_TOKEN_EXPIRY_MINUTES) || 30;
275
+ const expiryMinutes = parseInt(process.env.AUTH_RESET_TOKEN_EXPIRY_MINUTES) || 30;
139
276
  const resetTokenExpiry = formatDbDateTime(new Date(Date.now() + expiryMinutes * 60 * 1000));
140
277
 
141
278
  await User.query().findById(user.id).patch({
@@ -156,6 +293,8 @@ async function forgotPassword({ email }, resetBaseUrl) {
156
293
  `;
157
294
 
158
295
  await sendEmail(user.email, 'Password Reset', html);
296
+
297
+ await hooks.fire('user', 'update', user.id);
159
298
  }
160
299
 
161
300
  async function resetPassword({ token, newPassword }) {
@@ -181,6 +320,176 @@ async function resetPassword({ token, newPassword }) {
181
320
 
182
321
  await destroyAllUserSessions(user.id);
183
322
  await clearUserAccess(user.id);
323
+
324
+ await hooks.fire('user', 'update', user.id);
325
+ }
326
+
327
+ /**
328
+ * Send an OTP to verify a phone number on an already-authenticated user's
329
+ * account (additive — like email activation, not a replacement for password
330
+ * login). Delivery + code storage are @xeplr/utils's otp module — register an
331
+ * sms provider (see @xeplr/utils sms.register()) from your own boot script
332
+ * before calling boot(), otherwise this throws "provider not registered".
333
+ *
334
+ * @param {string} userId
335
+ * @param {string} [phoneNumber] - set/replace the number on file; omit to
336
+ * re-send a code to the number already on the account.
337
+ */
338
+ async function requestPhoneOtp(userId, phoneNumber) {
339
+ const user = await User.query().findById(userId);
340
+ if (!user) throw new Error('User not found');
341
+
342
+ const targetPhone = phoneNumber || user.phoneNumber;
343
+ if (!targetPhone) throw new Error('phoneNumber is required');
344
+
345
+ if (phoneNumber && phoneNumber !== user.phoneNumber) {
346
+ // Changing the number on file invalidates any prior verification.
347
+ await User.query().findById(userId).patch({
348
+ phoneNumber: targetPhone,
349
+ phoneVerified: false,
350
+ recordModifiedDate: formatDbDateTime()
351
+ });
352
+ await hooks.fire('user', 'update', userId);
353
+ }
354
+
355
+ await otp.requestOtp(targetPhone);
356
+ return { sent: true };
357
+ }
358
+
359
+ /**
360
+ * Verify a code against the phone number currently on file for this user.
361
+ */
362
+ async function verifyPhoneOtp(userId, code) {
363
+ const user = await User.query().findById(userId);
364
+ if (!user) throw new Error('User not found');
365
+ if (!user.phoneNumber) throw new Error('No phone number on file — request a code first');
366
+
367
+ const result = await otp.verifyOtp(user.phoneNumber, code);
368
+ if (!result.success) {
369
+ const messages = {
370
+ expired: 'Code expired — request a new one',
371
+ too_many_attempts: 'Too many attempts — request a new code',
372
+ invalid: 'Invalid code'
373
+ };
374
+ throw new Error(messages[result.reason] || 'Invalid code');
375
+ }
376
+
377
+ const now = formatDbDateTime();
378
+ await User.query().findById(userId).patch({
379
+ phoneVerified: true,
380
+ phoneVerifiedOn: now,
381
+ recordModifiedDate: now
382
+ });
383
+
384
+ await hooks.fire('user', 'update', userId);
385
+
386
+ return { phoneVerified: true };
387
+ }
388
+
389
+ function shapeProfile(user) {
390
+ return {
391
+ id: user.id,
392
+ email: user.email,
393
+ name: user.name,
394
+ phoneNumber: user.phoneNumber,
395
+ phoneVerified: user.phoneVerified,
396
+ profilePicUrl: user.profilePicUrl,
397
+ isActivated: user.isActivated
398
+ };
399
+ }
400
+
401
+ async function getProfile(userId) {
402
+ const user = await User.query().findById(userId);
403
+ if (!user) throw new Error('User not found');
404
+ return shapeProfile(user);
405
+ }
406
+
407
+ /**
408
+ * Update the current user's own name/phoneNumber/email. Changing phoneNumber
409
+ * clears phoneVerified (same rule as requestPhoneOtp — a new number needs its
410
+ * own verification). Changing email re-checks uniqueness (same as register())
411
+ * but does NOT require re-activation — that's a bigger flow this doesn't touch.
412
+ */
413
+ async function updateProfile(userId, fields) {
414
+ const user = await User.query().findById(userId);
415
+ if (!user) throw new Error('User not found');
416
+
417
+ const data = {};
418
+
419
+ if (fields.name !== undefined) {
420
+ data.name = fields.name;
421
+ }
422
+
423
+ if (fields.phoneNumber !== undefined && fields.phoneNumber !== user.phoneNumber) {
424
+ data.phoneNumber = fields.phoneNumber;
425
+ data.phoneVerified = false;
426
+ }
427
+
428
+ if (fields.email !== undefined) {
429
+ const email = fields.email.trim().toLowerCase();
430
+ if (email !== user.email) {
431
+ const normalized = normalizeEmail(email);
432
+ const existing = await User.query().findOne({ normalizedEmail: normalized });
433
+ if (existing && existing.id !== userId) {
434
+ throw new Error('Email already in use');
435
+ }
436
+ data.email = email;
437
+ data.normalizedEmail = normalized;
438
+ }
439
+ }
440
+
441
+ data.recordModifiedDate = formatDbDateTime();
442
+
443
+ await User.query().findById(userId).patch(data);
444
+ await hooks.fire('user', 'update', userId);
445
+
446
+ const updated = await User.query().findById(userId);
447
+ return shapeProfile(updated);
448
+ }
449
+
450
+ /**
451
+ * Store an already-uploaded avatar's path/URL on the user's profile. The
452
+ * upload itself happens in the router via @xeplr/utils's FileUploader — this
453
+ * just persists the result, same as any other profile field.
454
+ */
455
+ async function setProfilePic(userId, profilePicUrl) {
456
+ await User.query().findById(userId).patch({
457
+ profilePicUrl: profilePicUrl,
458
+ recordModifiedDate: formatDbDateTime()
459
+ });
460
+ await hooks.fire('user', 'update', userId);
461
+ const updated = await User.query().findById(userId);
462
+ return shapeProfile(updated);
463
+ }
464
+
465
+ /**
466
+ * Self-service password change for an already-authenticated user (distinct
467
+ * from resetPassword, which is for a user who's locked out and uses an
468
+ * emailed token instead of their current session). Requires the current
469
+ * password. Destroys all other sessions afterward — same as resetPassword —
470
+ * so the caller should expect to need to log back in.
471
+ */
472
+ async function changePassword(userId, { oldPassword, newPassword }) {
473
+ if (!oldPassword || !newPassword) throw new Error('oldPassword and newPassword are required');
474
+
475
+ const user = await User.query().findById(userId);
476
+ if (!user) throw new Error('User not found');
477
+
478
+ const isMatch = await comparePassword(oldPassword, user.pwd);
479
+ if (!isMatch) throw new Error('Current password is incorrect');
480
+
481
+ const { hash, salt } = await hashPassword(newPassword);
482
+
483
+ await User.query().findById(userId).patch({
484
+ pwd: hash,
485
+ pwdSalt: salt,
486
+ recordModifiedDate: formatDbDateTime()
487
+ });
488
+
489
+ await destroyAllUserSessions(userId);
490
+ await clearUserAccess(userId);
491
+
492
+ await hooks.fire('user', 'update', userId);
184
493
  }
185
494
 
186
495
  module.exports = {
@@ -188,7 +497,16 @@ module.exports = {
188
497
  configureEmail,
189
498
  register,
190
499
  activate,
500
+ invite,
501
+ previewInvite,
502
+ acceptInvite,
191
503
  login,
192
504
  forgotPassword,
193
- resetPassword
505
+ resetPassword,
506
+ requestPhoneOtp,
507
+ verifyPhoneOtp,
508
+ getProfile,
509
+ updateProfile,
510
+ setProfilePic,
511
+ changePassword
194
512
  };