@xeplr/auth 1.0.0 → 1.0.2
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/bin/migrate.js +58 -25
- package/bin/server.js +12 -30
- package/index.js +188 -48
- package/lib/adminRouter.js +20 -92
- package/lib/authHelper.js +26 -3
- package/lib/authMiddleware.js +39 -13
- package/lib/authRouter.js +144 -12
- package/lib/authService.js +448 -16
- package/lib/hooks.js +50 -0
- package/lib/mtMembershipMiddleware.js +61 -0
- package/lib/seed.js +74 -0
- package/lib/sessionService.js +54 -6
- package/lib/ticketService.js +88 -0
- package/lib/tokenDecision.js +45 -0
- package/migrations/0001_extensions.sql +9 -0
- package/migrations/0002_users.sql +31 -0
- package/migrations/0003_catalog_tables.sql +82 -0
- package/migrations/0004_role_mappings.sql +78 -0
- package/migrations/0005_user_tenants_mapping.sql +39 -0
- package/migrations/0006_seed_catalog.sql +120 -0
- package/models/Api.js +3 -0
- package/models/ApisRolesMapping.js +3 -0
- package/models/Menu.js +3 -0
- package/models/MenuRolesMapping.js +3 -0
- package/models/Role.js +8 -0
- package/models/UiElement.js +3 -0
- package/models/UiElementsRolesMapping.js +3 -0
- package/models/UiPage.js +3 -0
- package/models/UiPagesRolesMapping.js +3 -0
- package/models/User.js +3 -13
- package/models/UserRolesMapping.js +3 -0
- package/models/UserTenantsMapping.js +20 -2
- package/models/index.js +0 -2
- package/package.json +26 -5
- package/migrations/0001_users.js +0 -22
- package/migrations/0002_menus.js +0 -16
- package/migrations/0003_apis.js +0 -16
- package/migrations/0004_uiPages.js +0 -16
- package/migrations/0005_uiElements.js +0 -16
- package/migrations/0006_roles.js +0 -15
- package/migrations/0007_apisRolesMapping.js +0 -16
- package/migrations/0008_uiPagesRolesMapping.js +0 -16
- package/migrations/0009_uiElementsRolesMapping.js +0 -16
- package/migrations/0010_menuRolesMapping.js +0 -16
- package/migrations/0011_userRolesMapping.js +0 -16
- package/migrations/0012_users_add_reset_token.js +0 -13
- package/migrations/0013_add_isPublic.js +0 -27
- package/migrations/0014_users_add_activation_token.js +0 -13
- package/migrations/0015_add_mt_columns.js +0 -41
- package/migrations/0016_tenants.js +0 -23
- package/migrations/0017_userTenantsMapping.js +0 -20
- package/models/Tenant.js +0 -63
- package/seeds/001_roles.js +0 -26
- package/seeds/002_apis.js +0 -70
- package/seeds/003_pages.js +0 -41
- package/seeds/004_elements.js +0 -46
- package/seeds/005_menus.js +0 -35
- package/seeds/006_default_tenant.js +0 -50
- package/seeds/zzz_admin_access.js +0 -49
package/lib/adminRouter.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
var express = require('express');
|
|
2
2
|
var { generateId } = require('./authHelper');
|
|
3
3
|
var { clearUserAccess, clearAccessRules } = require('./accessService');
|
|
4
|
+
var authMiddleware = require('./authMiddleware');
|
|
5
|
+
var hooks = require('./hooks');
|
|
4
6
|
var User = require('../models/User');
|
|
5
7
|
var Role = require('../models/Role');
|
|
6
8
|
var Api = require('../models/Api');
|
|
@@ -12,8 +14,6 @@ var ApisRolesMapping = require('../models/ApisRolesMapping');
|
|
|
12
14
|
var UiPagesRolesMapping = require('../models/UiPagesRolesMapping');
|
|
13
15
|
var UiElementsRolesMapping = require('../models/UiElementsRolesMapping');
|
|
14
16
|
var MenuRolesMapping = require('../models/MenuRolesMapping');
|
|
15
|
-
var Tenant = require('../models/Tenant');
|
|
16
|
-
var UserTenantsMapping = require('../models/UserTenantsMapping');
|
|
17
17
|
|
|
18
18
|
// Group field names per type
|
|
19
19
|
var GROUP_FIELDS = {
|
|
@@ -33,12 +33,15 @@ function isModuleGroup(group) {
|
|
|
33
33
|
function createAdminRouter() {
|
|
34
34
|
var router = express.Router();
|
|
35
35
|
|
|
36
|
+
// Every admin route requires a valid JWT — populates req.user (with roles).
|
|
37
|
+
router.use(authMiddleware);
|
|
38
|
+
|
|
36
39
|
// ─── Users with their roles ───
|
|
37
40
|
router.get('/users', async function(req, res) {
|
|
38
41
|
try {
|
|
39
42
|
var users = await User.query()
|
|
40
43
|
.select('id', 'email', 'name', 'isActive', 'isActivated')
|
|
41
|
-
.withGraphFetched('
|
|
44
|
+
.withGraphFetched('roles');
|
|
42
45
|
res.json(users);
|
|
43
46
|
} catch (err) {
|
|
44
47
|
res.status(500).json({ error: 'Something went wrong' });
|
|
@@ -97,6 +100,7 @@ function createAdminRouter() {
|
|
|
97
100
|
}
|
|
98
101
|
|
|
99
102
|
await clearUserAccess(userId);
|
|
103
|
+
await hooks.fire('userRolesMapping', assign ? 'create' : 'delete', userId);
|
|
100
104
|
res.json({ ok: true });
|
|
101
105
|
} catch (err) {
|
|
102
106
|
res.status(500).json({ error: 'Something went wrong' });
|
|
@@ -143,6 +147,7 @@ function createAdminRouter() {
|
|
|
143
147
|
}
|
|
144
148
|
|
|
145
149
|
await clearAccessRules();
|
|
150
|
+
await hooks.fire(type + 'RolesMapping', assign ? 'create' : 'delete', itemId);
|
|
146
151
|
res.json({ ok: true });
|
|
147
152
|
} catch (err) {
|
|
148
153
|
res.status(500).json({ error: 'Something went wrong' });
|
|
@@ -162,10 +167,10 @@ function createAdminRouter() {
|
|
|
162
167
|
var createdBy = req.user ? req.user.id : null;
|
|
163
168
|
|
|
164
169
|
var types = [
|
|
165
|
-
{ Model: Api, MappingModel: ApisRolesMapping, fk: 'apiId', groupField: 'apiGroup' },
|
|
166
|
-
{ Model: UiPage, MappingModel: UiPagesRolesMapping, fk: 'uiPageId', groupField: 'uiPagesGroup' },
|
|
167
|
-
{ Model: UiElement, MappingModel: UiElementsRolesMapping, fk: 'uiElementId', groupField: 'uiElementsGroup' },
|
|
168
|
-
{ Model: Menu, MappingModel: MenuRolesMapping, fk: 'menuId', groupField: 'menuGroup' }
|
|
170
|
+
{ type: 'apis', Model: Api, MappingModel: ApisRolesMapping, fk: 'apiId', groupField: 'apiGroup' },
|
|
171
|
+
{ type: 'pages', Model: UiPage, MappingModel: UiPagesRolesMapping, fk: 'uiPageId', groupField: 'uiPagesGroup' },
|
|
172
|
+
{ type: 'elements', Model: UiElement, MappingModel: UiElementsRolesMapping, fk: 'uiElementId', groupField: 'uiElementsGroup' },
|
|
173
|
+
{ type: 'menus', Model: Menu, MappingModel: MenuRolesMapping, fk: 'menuId', groupField: 'menuGroup' }
|
|
169
174
|
];
|
|
170
175
|
|
|
171
176
|
var promises = types.map(async function(cfg) {
|
|
@@ -187,6 +192,8 @@ function createAdminRouter() {
|
|
|
187
192
|
} else {
|
|
188
193
|
await cfg.MappingModel.query().delete().where(itemWhere);
|
|
189
194
|
}
|
|
195
|
+
|
|
196
|
+
await hooks.fire(cfg.type + 'RolesMapping', assign ? 'create' : 'delete', items[i].id);
|
|
190
197
|
}
|
|
191
198
|
});
|
|
192
199
|
|
|
@@ -215,10 +222,12 @@ function createAdminRouter() {
|
|
|
215
222
|
|
|
216
223
|
if (id) {
|
|
217
224
|
await Role.query().findById(id).patch({ name: name });
|
|
225
|
+
await hooks.fire('roles', 'update', id);
|
|
218
226
|
res.json({ id: id });
|
|
219
227
|
} else {
|
|
220
228
|
var newId = generateId();
|
|
221
229
|
await Role.query().insert({ id: newId, name: name });
|
|
230
|
+
await hooks.fire('roles', 'create', newId);
|
|
222
231
|
res.json({ id: newId });
|
|
223
232
|
}
|
|
224
233
|
} catch (err) {
|
|
@@ -232,6 +241,7 @@ function createAdminRouter() {
|
|
|
232
241
|
if (!id) return res.status(400).json({ error: 'id is required' });
|
|
233
242
|
await Role.query().deleteById(id);
|
|
234
243
|
await clearAccessRules();
|
|
244
|
+
await hooks.fire('roles', 'delete', id);
|
|
235
245
|
res.json({ ok: true });
|
|
236
246
|
} catch (err) {
|
|
237
247
|
res.status(500).json({ error: 'Something went wrong' });
|
|
@@ -271,11 +281,13 @@ function createAdminRouter() {
|
|
|
271
281
|
|
|
272
282
|
if (id) {
|
|
273
283
|
await cfg.Model.query().findById(id).patch(data);
|
|
284
|
+
await hooks.fire(typeKey, 'update', id);
|
|
274
285
|
res.json({ id: id });
|
|
275
286
|
} else {
|
|
276
287
|
var newId = generateId();
|
|
277
288
|
data.id = newId;
|
|
278
289
|
await cfg.Model.query().insert(data);
|
|
290
|
+
await hooks.fire(typeKey, 'create', newId);
|
|
279
291
|
res.json({ id: newId });
|
|
280
292
|
}
|
|
281
293
|
} catch (err) {
|
|
@@ -289,6 +301,7 @@ function createAdminRouter() {
|
|
|
289
301
|
if (!id) return res.status(400).json({ error: 'id is required' });
|
|
290
302
|
await cfg.Model.query().deleteById(id);
|
|
291
303
|
await clearAccessRules();
|
|
304
|
+
await hooks.fire(typeKey, 'delete', id);
|
|
292
305
|
res.json({ ok: true });
|
|
293
306
|
} catch (err) {
|
|
294
307
|
res.status(500).json({ error: 'Something went wrong' });
|
|
@@ -296,91 +309,6 @@ function createAdminRouter() {
|
|
|
296
309
|
});
|
|
297
310
|
});
|
|
298
311
|
|
|
299
|
-
// ─── Tenants (Super Admin only) ───
|
|
300
|
-
|
|
301
|
-
function requireSuperAdmin(req, res, next) {
|
|
302
|
-
if (!req.user || !req.user.roles || req.user.roles.indexOf('Super Admin') === -1) {
|
|
303
|
-
return res.status(403).json({ error: 'Super Admin access required' });
|
|
304
|
-
}
|
|
305
|
-
next();
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
router.get('/tenants', requireSuperAdmin, async function(req, res) {
|
|
309
|
-
try {
|
|
310
|
-
var level = req.query.level ? parseInt(req.query.level) : null;
|
|
311
|
-
var query = Tenant.query().select('id', 'name', 'code', 'level', 'parentId', 'description');
|
|
312
|
-
if (level) query = query.where('level', level);
|
|
313
|
-
var tenants = await query;
|
|
314
|
-
res.json(tenants);
|
|
315
|
-
} catch (err) {
|
|
316
|
-
res.status(500).json({ error: 'Something went wrong' });
|
|
317
|
-
}
|
|
318
|
-
});
|
|
319
|
-
|
|
320
|
-
router.post('/tenants', requireSuperAdmin, async function(req, res) {
|
|
321
|
-
try {
|
|
322
|
-
var { id, name, code, level, parentId, description } = req.body;
|
|
323
|
-
if (!name) return res.status(400).json({ error: 'name is required' });
|
|
324
|
-
|
|
325
|
-
var data = { name: name };
|
|
326
|
-
if (code !== undefined) data.code = code;
|
|
327
|
-
if (level !== undefined) data.level = level;
|
|
328
|
-
if (parentId !== undefined) data.parentId = parentId;
|
|
329
|
-
if (description !== undefined) data.description = description;
|
|
330
|
-
|
|
331
|
-
if (id) {
|
|
332
|
-
await Tenant.query().findById(id).patch(data);
|
|
333
|
-
res.json({ id: id });
|
|
334
|
-
} else {
|
|
335
|
-
var newId = generateId();
|
|
336
|
-
data.id = newId;
|
|
337
|
-
await Tenant.query().insert(data);
|
|
338
|
-
res.json({ id: newId });
|
|
339
|
-
}
|
|
340
|
-
} catch (err) {
|
|
341
|
-
res.status(500).json({ error: 'Something went wrong' });
|
|
342
|
-
}
|
|
343
|
-
});
|
|
344
|
-
|
|
345
|
-
router.post('/tenants/delete', requireSuperAdmin, async function(req, res) {
|
|
346
|
-
try {
|
|
347
|
-
var { id } = req.body;
|
|
348
|
-
if (!id) return res.status(400).json({ error: 'id is required' });
|
|
349
|
-
await Tenant.query().findById(id).patch({ isActive: false });
|
|
350
|
-
res.json({ ok: true });
|
|
351
|
-
} catch (err) {
|
|
352
|
-
res.status(500).json({ error: 'Something went wrong' });
|
|
353
|
-
}
|
|
354
|
-
});
|
|
355
|
-
|
|
356
|
-
// Assign/unassign tenant to a user (via mapping table)
|
|
357
|
-
// Access controlled via Access Matrix (tenants:edit group), not hardcoded to Super Admin
|
|
358
|
-
router.post('/tenants/assign-user', async function(req, res) {
|
|
359
|
-
try {
|
|
360
|
-
var { userId, tenantId, assign } = req.body;
|
|
361
|
-
if (!userId || !tenantId) return res.status(400).json({ error: 'userId and tenantId are required' });
|
|
362
|
-
|
|
363
|
-
if (assign === false) {
|
|
364
|
-
await UserTenantsMapping.query().delete().where({ userId: userId, tenantId: tenantId });
|
|
365
|
-
} else {
|
|
366
|
-
var existing = await UserTenantsMapping.query().findOne({ userId: userId, tenantId: tenantId });
|
|
367
|
-
if (!existing) {
|
|
368
|
-
await UserTenantsMapping.query().insert({
|
|
369
|
-
id: generateId(),
|
|
370
|
-
userId: userId,
|
|
371
|
-
tenantId: tenantId,
|
|
372
|
-
recordCreatedBy: req.user ? req.user.id : null
|
|
373
|
-
});
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
await clearUserAccess(userId);
|
|
378
|
-
res.json({ ok: true });
|
|
379
|
-
} catch (err) {
|
|
380
|
-
res.status(500).json({ error: 'Something went wrong' });
|
|
381
|
-
}
|
|
382
|
-
});
|
|
383
|
-
|
|
384
312
|
return router;
|
|
385
313
|
}
|
|
386
314
|
|
package/lib/authHelper.js
CHANGED
|
@@ -16,7 +16,7 @@ function configure(config) {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
function getSecret() {
|
|
19
|
-
return _config.jwtSecret || process.env.
|
|
19
|
+
return _config.jwtSecret || process.env.AUTH_JWT_SECRET || 'change_me_in_production';
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
function generateId() {
|
|
@@ -35,10 +35,13 @@ async function comparePassword(password, hash) {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
function generateAccessToken(user) {
|
|
38
|
+
// Caller is responsible for providing `roles` as an array of role names
|
|
39
|
+
// (see authService.login → withGraphFetched('roles'))
|
|
38
40
|
const payload = {
|
|
39
41
|
id: user.id,
|
|
40
42
|
email: user.email,
|
|
41
|
-
name: user.name
|
|
43
|
+
name: user.name,
|
|
44
|
+
roles: Array.isArray(user.roles) ? user.roles : []
|
|
42
45
|
};
|
|
43
46
|
const expiresIn = _config.accessTokenExpiresIn || '15m';
|
|
44
47
|
return jwt.sign(payload, getSecret(), { expiresIn });
|
|
@@ -52,6 +55,24 @@ function verifyToken(token) {
|
|
|
52
55
|
return jwt.verify(token, getSecret());
|
|
53
56
|
}
|
|
54
57
|
|
|
58
|
+
// Verify the SIGNATURE but ignore expiry — the middleware decides on expiry
|
|
59
|
+
// itself (so it can apply the tolerance window). Returns { decoded, signatureValid }.
|
|
60
|
+
function decodeVerified(token) {
|
|
61
|
+
try {
|
|
62
|
+
return { decoded: jwt.verify(token, getSecret(), { ignoreExpiration: true }), signatureValid: true };
|
|
63
|
+
} catch (err) {
|
|
64
|
+
return { decoded: null, signatureValid: false };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Grace window (seconds) during which an expired access token is still honored
|
|
69
|
+
// and slid to a fresh one. 0 = strict (reject on expiry) = default / back-compat.
|
|
70
|
+
function getToleranceSeconds() {
|
|
71
|
+
var t = _config.accessTokenToleranceSeconds;
|
|
72
|
+
if (t === undefined || t === null) t = parseInt(process.env.AUTH_ACCESS_TOKEN_TOLERANCE_SECONDS || '0', 10);
|
|
73
|
+
return (Number.isFinite(t) && t > 0) ? t : 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
55
76
|
module.exports = {
|
|
56
77
|
configure,
|
|
57
78
|
generateId,
|
|
@@ -59,5 +80,7 @@ module.exports = {
|
|
|
59
80
|
comparePassword,
|
|
60
81
|
generateAccessToken,
|
|
61
82
|
generateRefreshToken,
|
|
62
|
-
verifyToken
|
|
83
|
+
verifyToken,
|
|
84
|
+
decodeVerified,
|
|
85
|
+
getToleranceSeconds
|
|
63
86
|
};
|
package/lib/authMiddleware.js
CHANGED
|
@@ -1,27 +1,53 @@
|
|
|
1
|
-
const {
|
|
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
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
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
|
}
|
|
@@ -39,7 +48,10 @@ function createAuthRouter(options = {}) {
|
|
|
39
48
|
return res.status(400).json({ error: 'Activation token is required' });
|
|
40
49
|
}
|
|
41
50
|
|
|
42
|
-
|
|
51
|
+
// Carried in the activation LINK and handed straight back — see
|
|
52
|
+
// authService.register. If a workflow is waiting on this person, this is
|
|
53
|
+
// the key that releases it.
|
|
54
|
+
var result = await activate(token, { workflowKey: req.query.workflowKey });
|
|
43
55
|
res.json({ message: 'Account activated successfully', user: result });
|
|
44
56
|
} catch (err) {
|
|
45
57
|
logError(req, err);
|
|
@@ -47,6 +59,32 @@ function createAuthRouter(options = {}) {
|
|
|
47
59
|
}
|
|
48
60
|
});
|
|
49
61
|
|
|
62
|
+
router.get('/invite-preview', async function(req, res) {
|
|
63
|
+
try {
|
|
64
|
+
var token = req.query.token;
|
|
65
|
+
if (!token) return res.status(400).json({ error: 'Token is required' });
|
|
66
|
+
var info = await previewInvite(token);
|
|
67
|
+
res.json(info);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
logError(req, err);
|
|
70
|
+
res.status(400).json({ error: err.message });
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
router.post('/invite-accept', async function(req, res) {
|
|
75
|
+
try {
|
|
76
|
+
const { token, name, password } = req.body;
|
|
77
|
+
if (!token || !password) {
|
|
78
|
+
return res.status(400).json({ error: 'Token and password are required' });
|
|
79
|
+
}
|
|
80
|
+
const result = await acceptInvite({ token, name, password });
|
|
81
|
+
res.json({ message: 'Invite accepted', user: result });
|
|
82
|
+
} catch (err) {
|
|
83
|
+
logError(req, err);
|
|
84
|
+
res.status(400).json({ error: err.message });
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
50
88
|
router.post('/login', async function(req, res) {
|
|
51
89
|
try {
|
|
52
90
|
const { email, password } = req.body;
|
|
@@ -115,12 +153,20 @@ function createAuthRouter(options = {}) {
|
|
|
115
153
|
return res.status(401).json({ error: 'Invalid or expired refresh token' });
|
|
116
154
|
}
|
|
117
155
|
|
|
118
|
-
const user = await User.query().findById(session.userId);
|
|
156
|
+
const user = await User.query().findById(session.userId).withGraphFetched('roles');
|
|
119
157
|
if (!user || !user.isActive) {
|
|
120
158
|
return res.status(401).json({ error: 'Account not found or deactivated' });
|
|
121
159
|
}
|
|
122
160
|
|
|
123
|
-
|
|
161
|
+
// Pack role names so refreshed tokens carry the same role claims as login.
|
|
162
|
+
const tokenUser = {
|
|
163
|
+
id: user.id,
|
|
164
|
+
email: user.email,
|
|
165
|
+
name: user.name,
|
|
166
|
+
roles: (user.roles || []).map(function(r) { return r.name; })
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const result = await rotateSession(refreshToken, tokenUser);
|
|
124
170
|
if (!result) {
|
|
125
171
|
return res.status(401).json({ error: 'Invalid or expired refresh token' });
|
|
126
172
|
}
|
|
@@ -172,17 +218,103 @@ function createAuthRouter(options = {}) {
|
|
|
172
218
|
}
|
|
173
219
|
});
|
|
174
220
|
|
|
175
|
-
|
|
176
|
-
router.get('/my-tenants', authMiddleware, async function(req, res) {
|
|
221
|
+
router.get('/profile', authMiddleware, async function(req, res) {
|
|
177
222
|
try {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
res.json(tenants.map(function(t) {
|
|
181
|
-
return { id: t.id, name: t.name, code: t.code, description: t.description };
|
|
182
|
-
}));
|
|
223
|
+
const profile = await getProfile(req.user.id);
|
|
224
|
+
res.json(profile);
|
|
183
225
|
} catch (err) {
|
|
184
226
|
logError(req, err);
|
|
185
|
-
res.status(
|
|
227
|
+
res.status(400).json({ error: err.message });
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
router.put('/profile', authMiddleware, async function(req, res) {
|
|
232
|
+
try {
|
|
233
|
+
const profile = await updateProfile(req.user.id, req.body);
|
|
234
|
+
res.json(profile);
|
|
235
|
+
} catch (err) {
|
|
236
|
+
logError(req, err);
|
|
237
|
+
res.status(400).json({ error: err.message });
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
router.post('/profile/avatar', authMiddleware, ...avatarUploader.single('avatar'), async function(req, res) {
|
|
242
|
+
try {
|
|
243
|
+
if (!req.file) return res.status(400).json({ error: 'avatar file is required' });
|
|
244
|
+
// Store just the generated filename — never the server's filesystem
|
|
245
|
+
// path (leaks server layout, and isn't meaningful once you swap disk
|
|
246
|
+
// storage for S3/etc). The app knows its own UPLOAD_DIR and however
|
|
247
|
+
// it serves it; it builds the actual URL from this filename.
|
|
248
|
+
const profile = await setProfilePic(req.user.id, req.file.filename);
|
|
249
|
+
res.json(profile);
|
|
250
|
+
} catch (err) {
|
|
251
|
+
logError(req, err);
|
|
252
|
+
res.status(400).json({ error: err.message });
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
router.post('/change-password', authMiddleware, async function(req, res) {
|
|
257
|
+
try {
|
|
258
|
+
const { oldPassword, newPassword } = req.body;
|
|
259
|
+
if (!oldPassword || !newPassword) {
|
|
260
|
+
return res.status(400).json({ error: 'oldPassword and newPassword are required' });
|
|
261
|
+
}
|
|
262
|
+
await changePassword(req.user.id, { oldPassword, newPassword });
|
|
263
|
+
res.json({ message: 'Password changed. Please log in again.' });
|
|
264
|
+
} catch (err) {
|
|
265
|
+
logError(req, err);
|
|
266
|
+
res.status(400).json({ error: err.message });
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// Additive phone verification — like email activation, but for a phone
|
|
271
|
+
// number attached to an already-authenticated account. Body: { phoneNumber }
|
|
272
|
+
// optional (omit to re-send to the number already on file).
|
|
273
|
+
router.post('/request-phone-otp', authMiddleware, async function(req, res) {
|
|
274
|
+
try {
|
|
275
|
+
const result = await requestPhoneOtp(req.user.id, req.body.phoneNumber);
|
|
276
|
+
res.json(result);
|
|
277
|
+
} catch (err) {
|
|
278
|
+
logError(req, err);
|
|
279
|
+
res.status(400).json({ error: err.message });
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
router.post('/verify-phone-otp', authMiddleware, async function(req, res) {
|
|
284
|
+
try {
|
|
285
|
+
if (!req.body.code) return res.status(400).json({ error: 'code is required' });
|
|
286
|
+
const result = await verifyPhoneOtp(req.user.id, req.body.code);
|
|
287
|
+
res.json(result);
|
|
288
|
+
} catch (err) {
|
|
289
|
+
logError(req, err);
|
|
290
|
+
res.status(400).json({ error: err.message });
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// ── SSE ticket ────────────────────────────────────────────────────────
|
|
295
|
+
//
|
|
296
|
+
// Mints a short-lived, single-use ticket so a browser can open an
|
|
297
|
+
// EventSource without leaking the primary session token in the URL.
|
|
298
|
+
//
|
|
299
|
+
// Body (optional):
|
|
300
|
+
// { scopes: [...] } — client-requested scopes. If omitted, defaults
|
|
301
|
+
// to the user's own namespace (['user:me:*']).
|
|
302
|
+
// Whatever is minted here is what the SSE server
|
|
303
|
+
// will enforce at subscribe time.
|
|
304
|
+
//
|
|
305
|
+
// NOTE the scopes are advisory intent — the SSE handler owns the final
|
|
306
|
+
// authorization check (see xeplr-base-apis/lib/sse.js#createHandler).
|
|
307
|
+
router.post('/sse-ticket', authMiddleware, async function(req, res) {
|
|
308
|
+
try {
|
|
309
|
+
var requested = (req.body && Array.isArray(req.body.scopes)) ? req.body.scopes : ['user:me:*'];
|
|
310
|
+
var { ticket, expiresIn } = await issueTicket({
|
|
311
|
+
userId: req.user.id,
|
|
312
|
+
scopes: requested
|
|
313
|
+
});
|
|
314
|
+
res.json({ ticket: ticket, expiresIn: expiresIn });
|
|
315
|
+
} catch (err) {
|
|
316
|
+
logError(req, err);
|
|
317
|
+
res.status(500).json({ error: 'Failed to mint SSE ticket' });
|
|
186
318
|
}
|
|
187
319
|
});
|
|
188
320
|
|