@mars-stack/cli 0.2.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.
Files changed (175) hide show
  1. package/dist/index.js +137 -12
  2. package/dist/index.js.map +1 -1
  3. package/package.json +4 -3
  4. package/template/.cursor/rules/composition-patterns.mdc +186 -0
  5. package/template/.cursor/rules/data-access.mdc +29 -0
  6. package/template/.cursor/rules/project-structure.mdc +34 -0
  7. package/template/.cursor/rules/security.mdc +25 -0
  8. package/template/.cursor/rules/testing.mdc +24 -0
  9. package/template/.cursor/rules/ui-conventions.mdc +29 -0
  10. package/template/.cursor/skills/add-api-route/SKILL.md +122 -0
  11. package/template/.cursor/skills/add-audit-log/SKILL.md +375 -0
  12. package/template/.cursor/skills/add-blog/SKILL.md +447 -0
  13. package/template/.cursor/skills/add-command-palette/SKILL.md +438 -0
  14. package/template/.cursor/skills/add-component/SKILL.md +158 -0
  15. package/template/.cursor/skills/add-crud-routes/SKILL.md +221 -0
  16. package/template/.cursor/skills/add-e2e-test/SKILL.md +227 -0
  17. package/template/.cursor/skills/add-error-boundary/SKILL.md +472 -0
  18. package/template/.cursor/skills/add-feature/SKILL.md +174 -0
  19. package/template/.cursor/skills/add-middleware/SKILL.md +135 -0
  20. package/template/.cursor/skills/add-page/SKILL.md +151 -0
  21. package/template/.cursor/skills/add-prisma-model/SKILL.md +148 -0
  22. package/template/.cursor/skills/add-protected-resource/SKILL.md +192 -0
  23. package/template/.cursor/skills/add-role/SKILL.md +156 -0
  24. package/template/.cursor/skills/add-server-action/SKILL.md +167 -0
  25. package/template/.cursor/skills/add-webhook/SKILL.md +192 -0
  26. package/template/.cursor/skills/build-complete-feature/SKILL.md +227 -0
  27. package/template/.cursor/skills/build-dashboard/SKILL.md +211 -0
  28. package/template/.cursor/skills/build-data-table/SKILL.md +283 -0
  29. package/template/.cursor/skills/build-form/SKILL.md +231 -0
  30. package/template/.cursor/skills/build-landing-page/SKILL.md +248 -0
  31. package/template/.cursor/skills/configure-ai/SKILL.md +617 -0
  32. package/template/.cursor/skills/configure-analytics/SKILL.md +413 -0
  33. package/template/.cursor/skills/configure-dark-mode/SKILL.md +309 -0
  34. package/template/.cursor/skills/configure-email/SKILL.md +170 -0
  35. package/template/.cursor/skills/configure-email-verification/SKILL.md +333 -0
  36. package/template/.cursor/skills/configure-feature-flags/SKILL.md +361 -0
  37. package/template/.cursor/skills/configure-i18n/SKILL.md +518 -0
  38. package/template/.cursor/skills/configure-jobs/SKILL.md +500 -0
  39. package/template/.cursor/skills/configure-magic-links/SKILL.md +385 -0
  40. package/template/.cursor/skills/configure-multi-tenancy/SKILL.md +611 -0
  41. package/template/.cursor/skills/configure-notifications/SKILL.md +569 -0
  42. package/template/.cursor/skills/configure-oauth/SKILL.md +217 -0
  43. package/template/.cursor/skills/configure-onboarding/SKILL.md +483 -0
  44. package/template/.cursor/skills/configure-payments/SKILL.md +243 -0
  45. package/template/.cursor/skills/configure-realtime/SKILL.md +733 -0
  46. package/template/.cursor/skills/configure-search/SKILL.md +581 -0
  47. package/template/.cursor/skills/configure-storage/SKILL.md +273 -0
  48. package/template/.cursor/skills/configure-two-factor/SKILL.md +518 -0
  49. package/template/.cursor/skills/create-execution-plan/SKILL.md +204 -0
  50. package/template/.cursor/skills/create-seed/SKILL.md +191 -0
  51. package/template/.cursor/skills/deploy-to-vercel/SKILL.md +300 -0
  52. package/template/.cursor/skills/design-tokens/SKILL.md +138 -0
  53. package/template/.cursor/skills/mars-capture-conversation-context/SKILL.md +119 -0
  54. package/template/.cursor/skills/setup-billing/SKILL.md +322 -0
  55. package/template/.cursor/skills/setup-project/SKILL.md +104 -0
  56. package/template/.cursor/skills/setup-teams/SKILL.md +682 -0
  57. package/template/.cursor/skills/test-api-route/SKILL.md +219 -0
  58. package/template/.cursor/skills/update-architecture-docs/SKILL.md +99 -0
  59. package/template/AGENTS.md +104 -0
  60. package/template/ARCHITECTURE.md +102 -0
  61. package/template/docs/QUALITY_SCORE.md +20 -0
  62. package/template/docs/design-docs/conversation-as-system-record.md +70 -0
  63. package/template/docs/design-docs/core-beliefs.md +43 -0
  64. package/template/docs/design-docs/index.md +8 -0
  65. package/template/docs/exec-plans/active/.gitkeep +0 -0
  66. package/template/docs/exec-plans/completed/.gitkeep +0 -0
  67. package/template/docs/exec-plans/tech-debt.md +7 -0
  68. package/template/docs/generated/.gitkeep +0 -0
  69. package/template/docs/product-specs/index.md +7 -0
  70. package/template/docs/references/index.md +18 -0
  71. package/template/e2e/api.spec.ts +20 -0
  72. package/template/e2e/auth.spec.ts +24 -0
  73. package/template/e2e/public.spec.ts +25 -0
  74. package/template/eslint.config.mjs +24 -0
  75. package/template/next-env.d.ts +6 -0
  76. package/template/next.config.ts +45 -0
  77. package/template/package.json +80 -0
  78. package/template/playwright.config.ts +31 -0
  79. package/template/postcss.config.mjs +8 -0
  80. package/template/prisma/generated/prisma/browser.ts +49 -0
  81. package/template/prisma/generated/prisma/client.ts +73 -0
  82. package/template/prisma/generated/prisma/commonInputTypes.ts +406 -0
  83. package/template/prisma/generated/prisma/enums.ts +15 -0
  84. package/template/prisma/generated/prisma/internal/class.ts +254 -0
  85. package/template/prisma/generated/prisma/internal/prismaNamespace.ts +1240 -0
  86. package/template/prisma/generated/prisma/internal/prismaNamespaceBrowser.ts +190 -0
  87. package/template/prisma/generated/prisma/models/Account.ts +1543 -0
  88. package/template/prisma/generated/prisma/models/File.ts +1529 -0
  89. package/template/prisma/generated/prisma/models/Session.ts +1415 -0
  90. package/template/prisma/generated/prisma/models/Subscription.ts +1455 -0
  91. package/template/prisma/generated/prisma/models/User.ts +2235 -0
  92. package/template/prisma/generated/prisma/models/VerificationToken.ts +1099 -0
  93. package/template/prisma/generated/prisma/models.ts +17 -0
  94. package/template/prisma/schema/auth.prisma +69 -0
  95. package/template/prisma/schema/base.prisma +8 -0
  96. package/template/prisma/schema/file.prisma +15 -0
  97. package/template/prisma/schema/subscription.prisma +17 -0
  98. package/template/prisma.config.ts +13 -0
  99. package/template/scripts/check-architecture.ts +221 -0
  100. package/template/scripts/check-doc-freshness.ts +242 -0
  101. package/template/scripts/ensure-db.mjs +291 -0
  102. package/template/scripts/generate-docs.ts +143 -0
  103. package/template/scripts/generate-env-example.ts +89 -0
  104. package/template/scripts/seed.ts +56 -0
  105. package/template/scripts/update-quality-score.ts +263 -0
  106. package/template/src/__tests__/architecture.test.ts +114 -0
  107. package/template/src/app/(auth)/forgotten-password/page.tsx +92 -0
  108. package/template/src/app/(auth)/layout.tsx +11 -0
  109. package/template/src/app/(auth)/register/page.tsx +162 -0
  110. package/template/src/app/(auth)/reset-password/page.tsx +109 -0
  111. package/template/src/app/(auth)/sign-in/page.tsx +122 -0
  112. package/template/src/app/(auth)/verify/[token]/page.tsx +87 -0
  113. package/template/src/app/(auth)/verify/page.tsx +56 -0
  114. package/template/src/app/(protected)/admin/page.tsx +108 -0
  115. package/template/src/app/(protected)/dashboard/loading.tsx +20 -0
  116. package/template/src/app/(protected)/dashboard/page.tsx +22 -0
  117. package/template/src/app/(protected)/layout.tsx +262 -0
  118. package/template/src/app/(protected)/settings/page.tsx +370 -0
  119. package/template/src/app/api/auth/forgot/route.ts +63 -0
  120. package/template/src/app/api/auth/login/route.ts +121 -0
  121. package/template/src/app/api/auth/logout/route.ts +19 -0
  122. package/template/src/app/api/auth/me/route.ts +30 -0
  123. package/template/src/app/api/auth/reset/route.ts +45 -0
  124. package/template/src/app/api/auth/signup/route.ts +85 -0
  125. package/template/src/app/api/auth/verify/route.ts +46 -0
  126. package/template/src/app/api/csrf/route.ts +12 -0
  127. package/template/src/app/api/health/route.ts +10 -0
  128. package/template/src/app/api/protected/admin/users/route.ts +24 -0
  129. package/template/src/app/api/protected/billing/checkout/route.ts +83 -0
  130. package/template/src/app/api/protected/billing/portal/route.ts +39 -0
  131. package/template/src/app/api/protected/files/[fileId]/route.ts +86 -0
  132. package/template/src/app/api/protected/files/upload/route.ts +64 -0
  133. package/template/src/app/api/protected/user/password/route.ts +63 -0
  134. package/template/src/app/api/protected/user/profile/route.ts +35 -0
  135. package/template/src/app/api/protected/user/sessions/[sessionId]/route.ts +33 -0
  136. package/template/src/app/api/protected/user/sessions/route.ts +22 -0
  137. package/template/src/app/api/readiness/route.ts +15 -0
  138. package/template/src/app/api/webhooks/stripe/route.ts +166 -0
  139. package/template/src/app/error.tsx +33 -0
  140. package/template/src/app/layout.tsx +29 -0
  141. package/template/src/app/not-found.tsx +20 -0
  142. package/template/src/app/page.tsx +136 -0
  143. package/template/src/app/privacy/page.tsx +178 -0
  144. package/template/src/app/providers.tsx +8 -0
  145. package/template/src/app/terms/page.tsx +139 -0
  146. package/template/src/config/app.config.ts +70 -0
  147. package/template/src/config/routes.ts +17 -0
  148. package/template/src/features/admin/index.ts +11 -0
  149. package/template/src/features/admin/permissions.ts +64 -0
  150. package/template/src/features/auth/context/AuthContext.tsx +96 -0
  151. package/template/src/features/auth/context/index.ts +2 -0
  152. package/template/src/features/auth/index.ts +3 -0
  153. package/template/src/features/auth/server/consent.ts +66 -0
  154. package/template/src/features/auth/server/session-revocation.ts +20 -0
  155. package/template/src/features/auth/server/sessions.ts +66 -0
  156. package/template/src/features/auth/server/user.ts +166 -0
  157. package/template/src/features/auth/types.ts +19 -0
  158. package/template/src/features/auth/validators.ts +29 -0
  159. package/template/src/features/billing/server/index.ts +66 -0
  160. package/template/src/features/billing/types.ts +43 -0
  161. package/template/src/features/uploads/server/index.ts +49 -0
  162. package/template/src/features/uploads/types.ts +26 -0
  163. package/template/src/lib/core/email/templates/base-layout.ts +122 -0
  164. package/template/src/lib/core/email/templates/index.ts +4 -0
  165. package/template/src/lib/core/email/templates/password-reset-email.ts +42 -0
  166. package/template/src/lib/core/email/templates/verification-email.ts +41 -0
  167. package/template/src/lib/core/email/templates/welcome-email.ts +40 -0
  168. package/template/src/lib/mars.ts +56 -0
  169. package/template/src/lib/prisma.ts +19 -0
  170. package/template/src/proxy.ts +92 -0
  171. package/template/src/styles/brand.css +15 -0
  172. package/template/src/styles/globals.css +7 -0
  173. package/template/tsconfig.json +59 -0
  174. package/template/vitest.config.ts +41 -0
  175. package/template/vitest.setup.ts +24 -0
@@ -0,0 +1,217 @@
1
+ # Skill: Configure OAuth Provider
2
+
3
+ Add Google OAuth (or other OAuth providers) to the MARS authentication system.
4
+
5
+ ## When to Use
6
+
7
+ Use this skill when the user asks to add Google sign-in, social login, OAuth, or third-party authentication.
8
+
9
+ ## Prerequisites
10
+
11
+ - `appConfig.features.auth` is `true`
12
+ - `appConfig.features.googleOAuth` should be set to `true`
13
+
14
+ ## Step 1: Get OAuth Credentials
15
+
16
+ ### Google
17
+
18
+ 1. Go to [Google Cloud Console](https://console.cloud.google.com/)
19
+ 2. Create a project (or use existing)
20
+ 3. Enable the Google+ API
21
+ 4. Go to Credentials → Create Credentials → OAuth 2.0 Client ID
22
+ 5. Set authorized redirect URIs: `http://localhost:3000/api/auth/callback/google` and your production URL
23
+ 6. Copy Client ID and Client Secret
24
+
25
+ ### Environment Variables
26
+
27
+ Add to `.env`:
28
+
29
+ ```bash
30
+ GOOGLE_CLIENT_ID="your-client-id.apps.googleusercontent.com"
31
+ GOOGLE_CLIENT_SECRET="your-client-secret"
32
+ ```
33
+
34
+ Update `src/core/env/index.ts` to validate these:
35
+
36
+ ```typescript
37
+ if (appConfig.features.googleOAuth) {
38
+ base.GOOGLE_CLIENT_ID = z.string().min(1);
39
+ base.GOOGLE_CLIENT_SECRET = z.string().min(1);
40
+ }
41
+ ```
42
+
43
+ ## Step 2: Create the OAuth API Routes
44
+
45
+ ### Initiate flow: `src/app/api/auth/oauth/google/route.ts`
46
+
47
+ ```typescript
48
+ import { NextResponse } from 'next/server';
49
+
50
+ export async function GET() {
51
+ const clientId = process.env.GOOGLE_CLIENT_ID;
52
+ const redirectUri = `${process.env.APP_URL || 'http://localhost:3000'}/api/auth/callback/google`;
53
+
54
+ const params = new URLSearchParams({
55
+ client_id: clientId!,
56
+ redirect_uri: redirectUri,
57
+ response_type: 'code',
58
+ scope: 'openid email profile',
59
+ access_type: 'offline',
60
+ prompt: 'consent',
61
+ });
62
+
63
+ return NextResponse.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`);
64
+ }
65
+ ```
66
+
67
+ ### Callback: `src/app/api/auth/callback/google/route.ts`
68
+
69
+ ```typescript
70
+ import { handleApiError, createSession } from '@/lib/mars';
71
+ import { prisma } from '@/lib/prisma';
72
+ import { NextResponse, type NextRequest } from 'next/server';
73
+
74
+ export async function GET(request: NextRequest) {
75
+ try {
76
+ const code = request.nextUrl.searchParams.get('code');
77
+ if (!code) {
78
+ return NextResponse.redirect(new URL('/sign-in?error=no_code', request.url));
79
+ }
80
+
81
+ // Exchange code for tokens
82
+ const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
83
+ method: 'POST',
84
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
85
+ body: new URLSearchParams({
86
+ code,
87
+ client_id: process.env.GOOGLE_CLIENT_ID!,
88
+ client_secret: process.env.GOOGLE_CLIENT_SECRET!,
89
+ redirect_uri: `${process.env.APP_URL || 'http://localhost:3000'}/api/auth/callback/google`,
90
+ grant_type: 'authorization_code',
91
+ }),
92
+ });
93
+
94
+ const tokens = await tokenResponse.json();
95
+ if (!tokens.access_token) {
96
+ return NextResponse.redirect(new URL('/sign-in?error=token_exchange', request.url));
97
+ }
98
+
99
+ // Get user info
100
+ const userInfoResponse = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
101
+ headers: { Authorization: `Bearer ${tokens.access_token}` },
102
+ });
103
+ const profile = await userInfoResponse.json();
104
+
105
+ // Find or create user
106
+ let user = await prisma.user.findUnique({ where: { email: profile.email } });
107
+
108
+ if (!user) {
109
+ user = await prisma.user.create({
110
+ data: {
111
+ email: profile.email,
112
+ name: profile.name,
113
+ image: profile.picture,
114
+ emailVerified: new Date(),
115
+ termsAcceptedAt: new Date(),
116
+ privacyAcceptedAt: new Date(),
117
+ },
118
+ });
119
+ }
120
+
121
+ // Link the OAuth account
122
+ await prisma.account.upsert({
123
+ where: {
124
+ provider_providerAccountId: {
125
+ provider: 'google',
126
+ providerAccountId: profile.id,
127
+ },
128
+ },
129
+ update: {
130
+ accessToken: tokens.access_token,
131
+ refreshToken: tokens.refresh_token,
132
+ expiresAt: tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : null,
133
+ },
134
+ create: {
135
+ userId: user.id,
136
+ provider: 'google',
137
+ providerAccountId: profile.id,
138
+ accessToken: tokens.access_token,
139
+ refreshToken: tokens.refresh_token,
140
+ expiresAt: tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : null,
141
+ },
142
+ });
143
+
144
+ // Create session
145
+ await createSession({
146
+ id: user.id,
147
+ email: user.email,
148
+ name: user.name || user.email,
149
+ role: user.role,
150
+ emailVerified: !!user.emailVerified,
151
+ passwordHash: user.password || '',
152
+ });
153
+
154
+ return NextResponse.redirect(new URL('/dashboard', request.url));
155
+ } catch (error) {
156
+ return handleApiError(error, { endpoint: '/api/auth/callback/google' });
157
+ }
158
+ }
159
+ ```
160
+
161
+ ## Step 3: Add Google Sign-In Button
162
+
163
+ ```tsx
164
+ import { Button } from '@mars-stack/ui';
165
+
166
+ export function GoogleSignInButton() {
167
+ return (
168
+ <a href="/api/auth/oauth/google" className="block">
169
+ <Button type="button" variant="secondary" className="w-full">
170
+ <svg className="mr-2 h-5 w-5" viewBox="0 0 24 24">
171
+ {/* Google G icon SVG path */}
172
+ </svg>
173
+ Continue with Google
174
+ </Button>
175
+ </a>
176
+ );
177
+ }
178
+ ```
179
+
180
+ Add to the sign-in and register pages:
181
+
182
+ ```tsx
183
+ {appConfig.features.googleOAuth && (
184
+ <>
185
+ <GoogleSignInButton />
186
+ <Divider label="or" />
187
+ </>
188
+ )}
189
+ ```
190
+
191
+ ## Step 4: Middleware Update
192
+
193
+ Add the OAuth callback routes to bypass CSRF in `src/middleware.ts`:
194
+
195
+ ```typescript
196
+ if (pathname.startsWith('/api/auth/callback/')) {
197
+ return NextResponse.next();
198
+ }
199
+ ```
200
+
201
+ ## Adding Other Providers
202
+
203
+ The pattern is identical for GitHub, Discord, etc. Create:
204
+ 1. `/api/auth/oauth/<provider>/route.ts` -- initiate
205
+ 2. `/api/auth/callback/<provider>/route.ts` -- handle callback
206
+ 3. Provider-specific button component
207
+
208
+ ## Checklist
209
+
210
+ - [ ] OAuth credentials obtained and added to `.env`
211
+ - [ ] Env variables added to validation schema
212
+ - [ ] OAuth initiation route created
213
+ - [ ] Callback route with user creation/linking
214
+ - [ ] Google Sign-In button added to auth pages
215
+ - [ ] Feature flag checked (`appConfig.features.googleOAuth`)
216
+ - [ ] Callback route excluded from CSRF in middleware
217
+ - [ ] Account model used for provider linking (supports multiple providers)
@@ -0,0 +1,483 @@
1
+ # Skill: Configure Onboarding
2
+
3
+ Set up a multi-step onboarding flow for first-time users in a MARS application.
4
+
5
+ ## When to Use
6
+
7
+ Use this skill when the user asks to add user onboarding, a setup wizard, first-time user experience, welcome flow, profile completion, or getting-started steps.
8
+
9
+ ## Prerequisites
10
+
11
+ - Auth system configured with session management
12
+ - User model exists in Prisma schema
13
+
14
+ ## Architecture
15
+
16
+ The onboarding system consists of:
17
+ 1. **Prisma model** — tracks which steps a user has completed
18
+ 2. **Step components** — each step is an isolated component with its own validation
19
+ 3. **Progress indicator** — visual stepper showing current position
20
+ 4. **Redirect logic** — middleware or layout check redirects incomplete users to onboarding
21
+ 5. **Skip/complete** — users can skip optional steps or mark onboarding as complete
22
+
23
+ ## Step 1: Prisma Schema
24
+
25
+ ```prisma
26
+ // prisma/schema/onboarding.prisma
27
+ model OnboardingProgress {
28
+ id String @id @default(cuid())
29
+ userId String @unique
30
+ completedAt DateTime?
31
+ currentStep Int @default(0)
32
+ stepsData String @default("{}") // JSON for step-specific data
33
+ skippedSteps String @default("[]") // JSON array of skipped step indices
34
+ createdAt DateTime @default(now())
35
+ updatedAt DateTime @updatedAt
36
+
37
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
38
+ }
39
+ ```
40
+
41
+ Add the relation to the User model and run `yarn db:push`.
42
+
43
+ ## Step 2: Define Onboarding Steps
44
+
45
+ ```typescript
46
+ // src/features/onboarding/config.ts
47
+ export interface OnboardingStep {
48
+ id: string;
49
+ title: string;
50
+ description: string;
51
+ required: boolean;
52
+ }
53
+
54
+ export const ONBOARDING_STEPS: OnboardingStep[] = [
55
+ {
56
+ id: 'profile',
57
+ title: 'Complete Your Profile',
58
+ description: 'Add your name and avatar',
59
+ required: true,
60
+ },
61
+ {
62
+ id: 'preferences',
63
+ title: 'Set Preferences',
64
+ description: 'Choose your notification and display settings',
65
+ required: false,
66
+ },
67
+ {
68
+ id: 'workspace',
69
+ title: 'Create Your First Workspace',
70
+ description: 'Set up a workspace to get started',
71
+ required: true,
72
+ },
73
+ {
74
+ id: 'invite',
75
+ title: 'Invite Your Team',
76
+ description: 'Invite colleagues to collaborate',
77
+ required: false,
78
+ },
79
+ ];
80
+ ```
81
+
82
+ ## Step 3: Server Logic
83
+
84
+ ```typescript
85
+ // src/features/onboarding/server/index.ts
86
+ import 'server-only';
87
+
88
+ import { prisma } from '@/lib/prisma';
89
+ import { ONBOARDING_STEPS } from '../config';
90
+
91
+ export async function getOnboardingProgress(userId: string) {
92
+ let progress = await prisma.onboardingProgress.findUnique({
93
+ where: { userId },
94
+ });
95
+
96
+ if (!progress) {
97
+ progress = await prisma.onboardingProgress.create({
98
+ data: { userId },
99
+ });
100
+ }
101
+
102
+ return {
103
+ ...progress,
104
+ stepsData: JSON.parse(progress.stepsData) as Record<string, unknown>,
105
+ skippedSteps: JSON.parse(progress.skippedSteps) as number[],
106
+ totalSteps: ONBOARDING_STEPS.length,
107
+ isComplete: progress.completedAt !== null,
108
+ };
109
+ }
110
+
111
+ export async function updateOnboardingStep(
112
+ userId: string,
113
+ stepIndex: number,
114
+ data?: Record<string, unknown>,
115
+ ) {
116
+ const progress = await prisma.onboardingProgress.findUnique({
117
+ where: { userId },
118
+ });
119
+
120
+ if (!progress) {
121
+ throw new Error('Onboarding progress not found');
122
+ }
123
+
124
+ const stepsData = JSON.parse(progress.stepsData);
125
+ if (data) {
126
+ stepsData[ONBOARDING_STEPS[stepIndex].id] = data;
127
+ }
128
+
129
+ const nextStep = Math.min(stepIndex + 1, ONBOARDING_STEPS.length);
130
+ const isLastStep = nextStep === ONBOARDING_STEPS.length;
131
+
132
+ await prisma.onboardingProgress.update({
133
+ where: { userId },
134
+ data: {
135
+ currentStep: nextStep,
136
+ stepsData: JSON.stringify(stepsData),
137
+ completedAt: isLastStep ? new Date() : null,
138
+ },
139
+ });
140
+
141
+ return { nextStep, isComplete: isLastStep };
142
+ }
143
+
144
+ export async function skipOnboardingStep(userId: string, stepIndex: number) {
145
+ const step = ONBOARDING_STEPS[stepIndex];
146
+ if (step.required) {
147
+ throw new Error('Cannot skip a required step');
148
+ }
149
+
150
+ const progress = await prisma.onboardingProgress.findUnique({
151
+ where: { userId },
152
+ });
153
+
154
+ if (!progress) {
155
+ throw new Error('Onboarding progress not found');
156
+ }
157
+
158
+ const skippedSteps = JSON.parse(progress.skippedSteps);
159
+ if (!skippedSteps.includes(stepIndex)) {
160
+ skippedSteps.push(stepIndex);
161
+ }
162
+
163
+ const nextStep = Math.min(stepIndex + 1, ONBOARDING_STEPS.length);
164
+ const isLastStep = nextStep === ONBOARDING_STEPS.length;
165
+
166
+ await prisma.onboardingProgress.update({
167
+ where: { userId },
168
+ data: {
169
+ currentStep: nextStep,
170
+ skippedSteps: JSON.stringify(skippedSteps),
171
+ completedAt: isLastStep ? new Date() : null,
172
+ },
173
+ });
174
+
175
+ return { nextStep, isComplete: isLastStep };
176
+ }
177
+
178
+ export async function completeOnboarding(userId: string) {
179
+ await prisma.onboardingProgress.update({
180
+ where: { userId },
181
+ data: { completedAt: new Date() },
182
+ });
183
+ }
184
+
185
+ export async function isOnboardingComplete(userId: string): Promise<boolean> {
186
+ const progress = await prisma.onboardingProgress.findUnique({
187
+ where: { userId },
188
+ select: { completedAt: true },
189
+ });
190
+ return progress?.completedAt !== null;
191
+ }
192
+ ```
193
+
194
+ ## Step 4: API Routes
195
+
196
+ ```typescript
197
+ // src/app/api/protected/onboarding/route.ts
198
+ import { handleApiError, withAuthNoParams, type AuthenticatedRequest } from '@/lib/mars';
199
+ import { getOnboardingProgress } from '@/features/onboarding/server';
200
+ import { NextResponse } from 'next/server';
201
+
202
+ export const GET = withAuthNoParams(async (request: AuthenticatedRequest) => {
203
+ try {
204
+ const progress = await getOnboardingProgress(request.session.userId);
205
+ return NextResponse.json(progress);
206
+ } catch (error) {
207
+ return handleApiError(error, { endpoint: '/api/protected/onboarding' });
208
+ }
209
+ });
210
+ ```
211
+
212
+ ```typescript
213
+ // src/app/api/protected/onboarding/step/route.ts
214
+ import { handleApiError, withAuthNoParams, type AuthenticatedRequest } from '@/lib/mars';
215
+ import { updateOnboardingStep, skipOnboardingStep } from '@/features/onboarding/server';
216
+ import { NextResponse } from 'next/server';
217
+ import { z } from 'zod';
218
+
219
+ const stepSchema = z.object({
220
+ stepIndex: z.number().min(0),
221
+ action: z.enum(['complete', 'skip']),
222
+ data: z.record(z.unknown()).optional(),
223
+ });
224
+
225
+ export const POST = withAuthNoParams(async (request: AuthenticatedRequest) => {
226
+ try {
227
+ const { stepIndex, action, data } = stepSchema.parse(await request.json());
228
+
229
+ const result =
230
+ action === 'skip'
231
+ ? await skipOnboardingStep(request.session.userId, stepIndex)
232
+ : await updateOnboardingStep(request.session.userId, stepIndex, data);
233
+
234
+ return NextResponse.json(result);
235
+ } catch (error) {
236
+ return handleApiError(error, { endpoint: '/api/protected/onboarding/step' });
237
+ }
238
+ });
239
+ ```
240
+
241
+ ## Step 5: Progress Indicator Component
242
+
243
+ ```typescript
244
+ // src/features/onboarding/components/OnboardingProgress.tsx
245
+ 'use client';
246
+
247
+ import { ONBOARDING_STEPS } from '../config';
248
+
249
+ interface OnboardingProgressProps {
250
+ currentStep: number;
251
+ skippedSteps: number[];
252
+ }
253
+
254
+ export function OnboardingProgressBar({ currentStep, skippedSteps }: OnboardingProgressProps) {
255
+ return (
256
+ <div className="flex items-center gap-2">
257
+ {ONBOARDING_STEPS.map((step, index) => {
258
+ const isComplete = index < currentStep;
259
+ const isCurrent = index === currentStep;
260
+ const isSkipped = skippedSteps.includes(index);
261
+
262
+ return (
263
+ <div key={step.id} className="flex items-center gap-2">
264
+ <div className="flex flex-col items-center">
265
+ <div
266
+ className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium transition-colors ${
267
+ isComplete
268
+ ? 'bg-interactive-primary text-white'
269
+ : isCurrent
270
+ ? 'border-2 border-interactive-primary text-interactive-primary'
271
+ : isSkipped
272
+ ? 'bg-surface-tertiary text-content-tertiary line-through'
273
+ : 'border-2 border-border-primary text-content-tertiary'
274
+ }`}
275
+ >
276
+ {isComplete ? '✓' : index + 1}
277
+ </div>
278
+ <span
279
+ className={`mt-1 text-xs ${
280
+ isCurrent ? 'font-medium text-content-primary' : 'text-content-tertiary'
281
+ }`}
282
+ >
283
+ {step.title}
284
+ </span>
285
+ </div>
286
+ {index < ONBOARDING_STEPS.length - 1 && (
287
+ <div
288
+ className={`h-0.5 w-8 ${isComplete ? 'bg-interactive-primary' : 'bg-border-primary'}`}
289
+ />
290
+ )}
291
+ </div>
292
+ );
293
+ })}
294
+ </div>
295
+ );
296
+ }
297
+ ```
298
+
299
+ ## Step 6: Onboarding Page
300
+
301
+ ```typescript
302
+ // src/app/(protected)/onboarding/page.tsx
303
+ import { redirect } from 'next/navigation';
304
+ import { getOnboardingProgress } from '@/features/onboarding/server';
305
+ import { getSession } from '@/features/auth/server/sessions';
306
+ import { OnboardingFlow } from '@/features/onboarding/components/OnboardingFlow';
307
+
308
+ export default async function OnboardingPage() {
309
+ const session = await getSession();
310
+ if (!session) redirect('/auth/login');
311
+
312
+ const progress = await getOnboardingProgress(session.userId);
313
+ if (progress.isComplete) redirect('/dashboard');
314
+
315
+ return (
316
+ <div className="mx-auto max-w-2xl px-4 py-12">
317
+ <OnboardingFlow
318
+ currentStep={progress.currentStep}
319
+ stepsData={progress.stepsData}
320
+ skippedSteps={progress.skippedSteps}
321
+ />
322
+ </div>
323
+ );
324
+ }
325
+ ```
326
+
327
+ ## Step 7: Onboarding Flow Client Component
328
+
329
+ ```typescript
330
+ // src/features/onboarding/components/OnboardingFlow.tsx
331
+ 'use client';
332
+
333
+ import { useState } from 'react';
334
+ import { useRouter } from 'next/navigation';
335
+ import { ONBOARDING_STEPS } from '../config';
336
+ import { OnboardingProgressBar } from './OnboardingProgress';
337
+
338
+ interface OnboardingFlowProps {
339
+ currentStep: number;
340
+ stepsData: Record<string, unknown>;
341
+ skippedSteps: number[];
342
+ }
343
+
344
+ export function OnboardingFlow({ currentStep: initialStep, stepsData, skippedSteps: initialSkipped }: OnboardingFlowProps) {
345
+ const router = useRouter();
346
+ const [step, setStep] = useState(initialStep);
347
+ const [skippedSteps, setSkippedSteps] = useState(initialSkipped);
348
+ const [loading, setLoading] = useState(false);
349
+
350
+ const currentStepConfig = ONBOARDING_STEPS[step];
351
+
352
+ async function handleComplete(data?: Record<string, unknown>) {
353
+ setLoading(true);
354
+ try {
355
+ const res = await fetch('/api/protected/onboarding/step', {
356
+ method: 'POST',
357
+ headers: { 'Content-Type': 'application/json' },
358
+ body: JSON.stringify({ stepIndex: step, action: 'complete', data }),
359
+ });
360
+ const result = await res.json();
361
+
362
+ if (result.isComplete) {
363
+ router.push('/dashboard');
364
+ } else {
365
+ setStep(result.nextStep);
366
+ }
367
+ } finally {
368
+ setLoading(false);
369
+ }
370
+ }
371
+
372
+ async function handleSkip() {
373
+ setLoading(true);
374
+ try {
375
+ const res = await fetch('/api/protected/onboarding/step', {
376
+ method: 'POST',
377
+ headers: { 'Content-Type': 'application/json' },
378
+ body: JSON.stringify({ stepIndex: step, action: 'skip' }),
379
+ });
380
+ const result = await res.json();
381
+ setSkippedSteps([...skippedSteps, step]);
382
+
383
+ if (result.isComplete) {
384
+ router.push('/dashboard');
385
+ } else {
386
+ setStep(result.nextStep);
387
+ }
388
+ } finally {
389
+ setLoading(false);
390
+ }
391
+ }
392
+
393
+ if (!currentStepConfig) {
394
+ router.push('/dashboard');
395
+ return null;
396
+ }
397
+
398
+ return (
399
+ <div className="space-y-8">
400
+ <OnboardingProgressBar currentStep={step} skippedSteps={skippedSteps} />
401
+
402
+ <div className="rounded-lg border border-border-primary bg-surface-primary p-6">
403
+ <h2 className="text-xl font-semibold text-content-primary">{currentStepConfig.title}</h2>
404
+ <p className="mt-1 text-content-secondary">{currentStepConfig.description}</p>
405
+
406
+ <div className="mt-6">
407
+ {/* Render step-specific content here based on currentStepConfig.id */}
408
+ {/* Each step should call handleComplete(data) when done */}
409
+ </div>
410
+
411
+ <div className="mt-8 flex items-center justify-between">
412
+ {!currentStepConfig.required && (
413
+ <button
414
+ onClick={handleSkip}
415
+ disabled={loading}
416
+ className="text-sm text-content-tertiary hover:text-content-secondary"
417
+ >
418
+ Skip this step
419
+ </button>
420
+ )}
421
+ <div className="ml-auto">
422
+ <button
423
+ onClick={() => handleComplete()}
424
+ disabled={loading}
425
+ className="rounded-md bg-interactive-primary px-6 py-2 text-white hover:bg-interactive-primary-hover disabled:opacity-50"
426
+ >
427
+ {step === ONBOARDING_STEPS.length - 1 ? 'Finish' : 'Continue'}
428
+ </button>
429
+ </div>
430
+ </div>
431
+ </div>
432
+ </div>
433
+ );
434
+ }
435
+ ```
436
+
437
+ ## Step 8: Redirect Incomplete Users
438
+
439
+ In the protected layout, check onboarding status:
440
+
441
+ ```typescript
442
+ // src/app/(protected)/layout.tsx
443
+ import { redirect } from 'next/navigation';
444
+ import { getSession } from '@/features/auth/server/sessions';
445
+ import { isOnboardingComplete } from '@/features/onboarding/server';
446
+
447
+ export default async function ProtectedLayout({ children }: { children: React.ReactNode }) {
448
+ const session = await getSession();
449
+ if (!session) redirect('/auth/login');
450
+
451
+ const pathname = /* get current pathname */;
452
+ const onboardingComplete = await isOnboardingComplete(session.userId);
453
+
454
+ if (!onboardingComplete && !pathname.startsWith('/onboarding')) {
455
+ redirect('/onboarding');
456
+ }
457
+
458
+ return <>{children}</>;
459
+ }
460
+ ```
461
+
462
+ ## Testing
463
+
464
+ 1. Create a new user — verify they are redirected to `/onboarding`.
465
+ 2. Complete the first step — verify progress updates and next step is shown.
466
+ 3. Skip an optional step — verify it is marked as skipped and the flow advances.
467
+ 4. Try to skip a required step — verify it is not allowed.
468
+ 5. Complete all steps — verify redirect to `/dashboard`.
469
+ 6. Return to `/onboarding` after completion — verify redirect to `/dashboard`.
470
+ 7. Refresh during onboarding — verify progress is preserved.
471
+
472
+ ## Checklist
473
+
474
+ - [ ] `OnboardingProgress` model added to Prisma schema
475
+ - [ ] Onboarding steps configured in `src/features/onboarding/config.ts`
476
+ - [ ] Server functions for get, update, skip, and complete
477
+ - [ ] API routes for progress and step actions
478
+ - [ ] Progress indicator component shows current position
479
+ - [ ] Onboarding flow handles step navigation, skip, and complete
480
+ - [ ] Protected layout redirects incomplete users to `/onboarding`
481
+ - [ ] Onboarding page redirects completed users to `/dashboard`
482
+ - [ ] Step-specific data stored as JSON
483
+ - [ ] `db:push` run after schema changes