Skip to content

Commit 7e2b1b7

Browse files
KushagraJaiswar02yash-pouranikgithub-advanced-security[bot]
authored
Feat/onboarding (#314)
* onboarding fixes * onboarding * onboarding finsihing and flow concerns * dashboard fixes * fix: coderabbit review comments * Fix merge conflict issues: missing imports in routes * feat(ui): redesign onboarding and fix test api blocked issue * feat(ui): refine onboarding colors and fix step 3 failure skip * refactor(ui): apply flat minimalist design to onboarding and fix logic loops * style(onboarding): restore hyperspeed as lightweight bg and force pure black/green theme * fix(onboarding): do not skip collection step on new project and preserve collection name * fix(onboarding): users collection interference, unique field, and button color * fix(onboarding): clarify GET request returns empty list in Step 3 * fix(onboarding): perform POST on api test and explicitly mark firstApiCall * fix(onboarding): revert to GET, always enable launch button, mark firstApiCall in DB on success * fix(onboarding): auto-satisfy firstApiCall prereqs, allow skip completion * Potential fix for pull request finding 'CodeQL / Database query built from user-controlled sources' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * Potential fix for pull request finding 'CodeQL / Prototype-polluting function' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * feat(dashboard): remove Activation Status, fix secret key revealed state text * fix(dashboard): remove pk copy, sk reveal once, unused state, and emoji tooltips * fix(tests): add missing loadProjectForAdmin mock, remove verifyEmail from createCollection route * fix(csrf): persist cookie 24h, auto-refresh and retry on EBADCSRFTOKEN * fix(analytics): fix docs link to correct external URL --------- Co-authored-by: yash-pouranik <yashpouranik124@gmail.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
1 parent 3c90adc commit 7e2b1b7

64 files changed

Lines changed: 3271 additions & 818 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/consumer/src/index.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,14 @@ app.get('/', (_req, res) => {
1818
res.status(200).send('consumer worker running');
1919
});
2020

21-
const port = Number(process.env.PORT) || 3000;
21+
let port = process.env.NODE_ENV === 'production'
22+
? (Number(process.env.PORT) || 3000)
23+
: (Number(process.env.CONSUMER_PORT) || 1237);
24+
25+
if (port < 1 || port > 65535 || isNaN(port)) {
26+
console.warn(`[CONSUMER] Invalid port ${port} detected, defaulting to 1237`);
27+
port = 1237;
28+
}
2229
let worker;
2330
let server;
2431

apps/dashboard-api/src/__tests__/auth.controller.test.js

Lines changed: 225 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,18 @@ jest.mock('@urbackend/common', () => {
8484
}
8585
},
8686
sendOtp: jest.fn().mockResolvedValue(undefined),
87+
normalizeOnboarding: jest.fn((onboarding = {}) => {
88+
const steps = onboarding.steps || {};
89+
return {
90+
completed: Boolean(onboarding.completed),
91+
steps: {
92+
projectCreated: Boolean(steps.projectCreated),
93+
collectionCreated: Boolean(steps.collectionCreated),
94+
firstApiCall: Boolean(steps.firstApiCall),
95+
},
96+
activationAt: onboarding.activationAt || null,
97+
};
98+
}),
8799
// Use real zod shapes so validation logic is exercised.
88100
loginSchema: z.object({
89101
email: z.string().email(),
@@ -108,6 +120,26 @@ jest.mock('@urbackend/common', () => {
108120
otp: z.string(),
109121
newPassword: z.string().min(6),
110122
}),
123+
updateOnboardingSchema: z.object({
124+
completed: z.boolean().optional(),
125+
steps: z.object({
126+
projectCreated: z.boolean().optional(),
127+
collectionCreated: z.boolean().optional(),
128+
firstApiCall: z.boolean().optional(),
129+
}).strict().optional(),
130+
}).strict().refine(
131+
(data) => data.completed !== undefined || data.steps !== undefined,
132+
{ message: 'At least one onboarding field must be provided.' }
133+
),
134+
updateDeveloperOnboarding: jest.fn().mockResolvedValue({
135+
completed: false,
136+
steps: {
137+
projectCreated: true,
138+
collectionCreated: false,
139+
firstApiCall: false,
140+
},
141+
activationAt: null,
142+
}),
111143
};
112144
});
113145

@@ -117,7 +149,7 @@ jest.mock('@urbackend/common', () => {
117149

118150
const jwt = require('jsonwebtoken');
119151
const bcrypt = require('bcryptjs');
120-
const { Developer, Otp, Project, sendOtp, AppError } = require('@urbackend/common');
152+
const { Developer, Otp, Project, sendOtp, AppError, updateDeveloperOnboarding } = require('@urbackend/common');
121153
const authController = require('../controllers/auth.controller');
122154

123155
// ---------------------------------------------------------------------------
@@ -159,22 +191,43 @@ describe('auth.controller', () => {
159191

160192
// -----------------------------------------------------------------------
161193
describe('register', () => {
162-
test('returns 201 and success message on valid new-user registration', async () => {
194+
test('returns 201 with session payload on valid new-user registration', async () => {
163195
Developer.findOne.mockResolvedValue(null);
164196
bcrypt.genSalt.mockResolvedValue('salt');
165197
bcrypt.hash.mockResolvedValue('hashed_password');
198+
jwt.sign.mockReturnValue('signed_token');
166199

200+
const createdUser = {
201+
_id: 'new_dev_1',
202+
email: 'new@example.com',
203+
isVerified: false,
204+
maxProjects: 1,
205+
password: 'hashed_password',
206+
save: jest.fn().mockResolvedValue(undefined),
207+
};
167208
const mockSave = jest.fn().mockResolvedValue(undefined);
168-
Developer.mockImplementation(() => ({ save: mockSave }));
209+
Developer.mockImplementation((data) => ({ ...createdUser, ...data, save: mockSave }));
169210

170211
const req = makeReq({ email: 'new@example.com', password: 'password123' });
171212
const res = makeRes();
172213

173214
await authController.register(req, res, next);
174215

216+
175217
expect(Developer.findOne).toHaveBeenCalledWith({ email: 'new@example.com' });
176218
expect(res.status).toHaveBeenCalledWith(201);
177-
expect(res.json).toHaveBeenCalledWith({ success: true, data: {}, message: 'Registered successfully' });
219+
expect(res.cookie).toHaveBeenCalledWith('accessToken', expect.any(String), expect.any(Object));
220+
expect(res.cookie).toHaveBeenCalledWith('refreshToken', expect.any(String), expect.any(Object));
221+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
222+
success: true,
223+
data: expect.objectContaining({
224+
redirectTo: '/onboarding',
225+
user: expect.objectContaining({
226+
email: 'new@example.com',
227+
isVerified: false,
228+
}),
229+
}),
230+
}));
178231
});
179232

180233
test('returns 400 when email already exists', async () => {
@@ -202,14 +255,15 @@ describe('auth.controller', () => {
202255

203256
// -----------------------------------------------------------------------
204257
describe('login', () => {
205-
const mockUser = () => ({
258+
const mockUser = (overrides = {}) => ({
206259
_id: 'dev_id_1',
207260
email: 'test@example.com',
208261
isVerified: true,
209262
maxProjects: 3,
210263
refreshToken: null,
211264
password: 'hashed_password',
212265
save: jest.fn().mockResolvedValue(undefined),
266+
...overrides,
213267
});
214268

215269
test('returns 200 with cookie tokens on valid credentials', async () => {
@@ -232,6 +286,79 @@ describe('auth.controller', () => {
232286
);
233287
});
234288

289+
test('returns onboarding redirect for authenticated users with incomplete onboarding', async () => {
290+
const user = mockUser();
291+
Developer.findOne.mockReturnValue(Developer.__mockQuery(user));
292+
bcrypt.compare.mockResolvedValue(true);
293+
jwt.sign.mockReturnValue('signed_token');
294+
295+
const req = makeReq({ email: 'test@example.com', password: 'correctpass' });
296+
const res = makeRes();
297+
298+
await authController.login(req, res, next);
299+
300+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
301+
data: expect.objectContaining({
302+
redirectTo: '/onboarding',
303+
user: expect.objectContaining({
304+
onboarding: expect.objectContaining({ completed: false }),
305+
}),
306+
}),
307+
}));
308+
});
309+
310+
test('returns onboarding redirect for unverified users', async () => {
311+
const user = mockUser({ isVerified: false });
312+
Developer.findOne.mockReturnValue(Developer.__mockQuery(user));
313+
bcrypt.compare.mockResolvedValue(true);
314+
jwt.sign.mockReturnValue('signed_token');
315+
316+
const req = makeReq({ email: 'test@example.com', password: 'correctpass' });
317+
const res = makeRes();
318+
319+
await authController.login(req, res, next);
320+
321+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
322+
data: expect.objectContaining({
323+
redirectTo: '/onboarding',
324+
user: expect.objectContaining({
325+
isVerified: false,
326+
}),
327+
}),
328+
}));
329+
});
330+
331+
test('returns dashboard redirect for authenticated users with completed onboarding', async () => {
332+
const user = mockUser({
333+
onboarding: {
334+
completed: true,
335+
steps: {
336+
projectCreated: true,
337+
collectionCreated: true,
338+
firstApiCall: true,
339+
},
340+
activationAt: new Date('2026-06-08T00:00:00.000Z'),
341+
},
342+
});
343+
Developer.findOne.mockReturnValue(Developer.__mockQuery(user));
344+
bcrypt.compare.mockResolvedValue(true);
345+
jwt.sign.mockReturnValue('signed_token');
346+
347+
const req = makeReq({ email: 'test@example.com', password: 'correctpass' });
348+
const res = makeRes();
349+
350+
await authController.login(req, res, next);
351+
352+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
353+
data: expect.objectContaining({
354+
redirectTo: '/dashboard',
355+
user: expect.objectContaining({
356+
onboarding: expect.objectContaining({ completed: true }),
357+
}),
358+
}),
359+
}));
360+
});
361+
235362
test('returns 400 when user is not found', async () => {
236363
Developer.findOne.mockReturnValue(Developer.__mockQuery(null));
237364

@@ -395,6 +522,37 @@ describe('auth.controller', () => {
395522
);
396523
});
397524

525+
test('returns default onboarding state for users without stored onboarding', async () => {
526+
const mockSelect = jest.fn().mockResolvedValue({
527+
_id: 'dev_id_1',
528+
email: 'test@example.com',
529+
});
530+
Developer.findById.mockReturnValue({ select: mockSelect });
531+
532+
const req = makeReq({}, { _id: 'dev_id_1' });
533+
const res = makeRes();
534+
535+
await authController.getMe(req, res, next);
536+
537+
expect(res.json).toHaveBeenCalledWith({
538+
success: true,
539+
data: {
540+
user: expect.objectContaining({
541+
onboarding: {
542+
completed: false,
543+
steps: {
544+
projectCreated: false,
545+
collectionCreated: false,
546+
firstApiCall: false,
547+
},
548+
activationAt: null,
549+
},
550+
}),
551+
},
552+
message: 'Success',
553+
});
554+
});
555+
398556
test('returns 404 when user does not exist', async () => {
399557
Developer.findById.mockReturnValue({
400558
select: jest.fn().mockResolvedValue(null),
@@ -411,6 +569,68 @@ describe('auth.controller', () => {
411569
});
412570
});
413571

572+
// -----------------------------------------------------------------------
573+
describe('updateOnboarding', () => {
574+
test('updates only the authenticated developer onboarding state', async () => {
575+
const req = makeReq(
576+
{ completed: false, steps: { projectCreated: true } },
577+
{ _id: 'dev_id_1' }
578+
);
579+
const res = makeRes();
580+
581+
await authController.updateOnboarding(req, res, next);
582+
583+
expect(updateDeveloperOnboarding).toHaveBeenCalledWith('dev_id_1', {
584+
completed: false,
585+
steps: { projectCreated: true },
586+
});
587+
expect(res.status).toHaveBeenCalledWith(200);
588+
expect(res.json).toHaveBeenCalledWith({
589+
success: true,
590+
data: {
591+
onboarding: {
592+
completed: false,
593+
steps: {
594+
projectCreated: true,
595+
collectionCreated: false,
596+
firstApiCall: false,
597+
},
598+
activationAt: null,
599+
},
600+
},
601+
message: 'Onboarding updated successfully',
602+
});
603+
});
604+
605+
test('rejects activationAt updates', async () => {
606+
const req = makeReq(
607+
{ activationAt: '2026-06-08T00:00:00.000Z' },
608+
{ _id: 'dev_id_1' }
609+
);
610+
const res = makeRes();
611+
612+
await authController.updateOnboarding(req, res, next);
613+
614+
expect(updateDeveloperOnboarding).not.toHaveBeenCalled();
615+
expect(next).toHaveBeenCalledWith(expect.any(AppError));
616+
expect(next.mock.calls[0][0].statusCode).toBe(400);
617+
});
618+
619+
test('rejects malformed nested step objects', async () => {
620+
const req = makeReq(
621+
{ steps: { firstApiCall: 'yes' } },
622+
{ _id: 'dev_id_1' }
623+
);
624+
const res = makeRes();
625+
626+
await authController.updateOnboarding(req, res, next);
627+
628+
expect(updateDeveloperOnboarding).not.toHaveBeenCalled();
629+
expect(next).toHaveBeenCalledWith(expect.any(AppError));
630+
expect(next.mock.calls[0][0].statusCode).toBe(400);
631+
});
632+
});
633+
414634
// -----------------------------------------------------------------------
415635
describe('changePassword', () => {
416636
test('returns 200 on successful password change', async () => {

0 commit comments

Comments
 (0)