feat: SaaS onboarding, Stripe billing, MFA, SSO, passkeys, refresh tokens
Complete SaaS self-service onboarding sprint: - Stripe-powered signup flow: pricing page → checkout → provisioning → activation - Refresh token infrastructure: 1h access tokens + 30-day httpOnly cookie refresh - TOTP MFA with QR setup, recovery codes, and login challenge flow - Google + Azure AD SSO (conditional on env vars) with account linking - WebAuthn passkey registration and passwordless login - Guided onboarding checklist with server-side progress tracking - Stubbed email service (console + DB logging, ready for real provider) - Settings page with tabbed security settings (MFA, passkeys, linked accounts) - Login page enhanced with MFA verification, SSO buttons, passkey login - Database migration 015 with all new tables and columns - Version bump to 2026.03.17 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
79
backend/src/modules/onboarding/onboarding.service.ts
Normal file
79
backend/src/modules/onboarding/onboarding.service.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
const REQUIRED_STEPS = ['profile', 'workspace', 'invite_member', 'first_workflow'];
|
||||
|
||||
@Injectable()
|
||||
export class OnboardingService {
|
||||
private readonly logger = new Logger(OnboardingService.name);
|
||||
|
||||
constructor(private dataSource: DataSource) {}
|
||||
|
||||
async getProgress(orgId: string) {
|
||||
const rows = await this.dataSource.query(
|
||||
`SELECT completed_steps, completed_at, updated_at
|
||||
FROM shared.onboarding_progress
|
||||
WHERE organization_id = $1`,
|
||||
[orgId],
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
// Create a fresh record
|
||||
await this.dataSource.query(
|
||||
`INSERT INTO shared.onboarding_progress (organization_id)
|
||||
VALUES ($1) ON CONFLICT DO NOTHING`,
|
||||
[orgId],
|
||||
);
|
||||
return { completedSteps: [], completedAt: null, requiredSteps: REQUIRED_STEPS };
|
||||
}
|
||||
|
||||
return {
|
||||
completedSteps: rows[0].completed_steps || [],
|
||||
completedAt: rows[0].completed_at,
|
||||
requiredSteps: REQUIRED_STEPS,
|
||||
};
|
||||
}
|
||||
|
||||
async markStepComplete(orgId: string, step: string) {
|
||||
// Add step to array (using array_append with dedup)
|
||||
await this.dataSource.query(
|
||||
`INSERT INTO shared.onboarding_progress (organization_id, completed_steps, updated_at)
|
||||
VALUES ($1, ARRAY[$2::text], NOW())
|
||||
ON CONFLICT (organization_id)
|
||||
DO UPDATE SET
|
||||
completed_steps = CASE
|
||||
WHEN $2 = ANY(onboarding_progress.completed_steps) THEN onboarding_progress.completed_steps
|
||||
ELSE array_append(onboarding_progress.completed_steps, $2::text)
|
||||
END,
|
||||
updated_at = NOW()`,
|
||||
[orgId, step],
|
||||
);
|
||||
|
||||
// Check if all required steps are done
|
||||
const rows = await this.dataSource.query(
|
||||
`SELECT completed_steps FROM shared.onboarding_progress WHERE organization_id = $1`,
|
||||
[orgId],
|
||||
);
|
||||
|
||||
const completedSteps = rows[0]?.completed_steps || [];
|
||||
const allDone = REQUIRED_STEPS.every((s) => completedSteps.includes(s));
|
||||
|
||||
if (allDone) {
|
||||
await this.dataSource.query(
|
||||
`UPDATE shared.onboarding_progress SET completed_at = NOW() WHERE organization_id = $1 AND completed_at IS NULL`,
|
||||
[orgId],
|
||||
);
|
||||
}
|
||||
|
||||
return this.getProgress(orgId);
|
||||
}
|
||||
|
||||
async resetProgress(orgId: string) {
|
||||
await this.dataSource.query(
|
||||
`UPDATE shared.onboarding_progress SET completed_steps = '{}', completed_at = NULL, updated_at = NOW()
|
||||
WHERE organization_id = $1`,
|
||||
[orgId],
|
||||
);
|
||||
return this.getProgress(orgId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user