@mars-stack/cli 0.2.0 → 0.2.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/package.json +2 -2
- package/template/.cursor/rules/composition-patterns.mdc +186 -0
- package/template/.cursor/rules/data-access.mdc +29 -0
- package/template/.cursor/rules/project-structure.mdc +34 -0
- package/template/.cursor/rules/security.mdc +25 -0
- package/template/.cursor/rules/testing.mdc +24 -0
- package/template/.cursor/rules/ui-conventions.mdc +29 -0
- package/template/.cursor/skills/add-api-route/SKILL.md +122 -0
- package/template/.cursor/skills/add-audit-log/SKILL.md +373 -0
- package/template/.cursor/skills/add-blog/SKILL.md +447 -0
- package/template/.cursor/skills/add-command-palette/SKILL.md +438 -0
- package/template/.cursor/skills/add-component/SKILL.md +158 -0
- package/template/.cursor/skills/add-crud-routes/SKILL.md +221 -0
- package/template/.cursor/skills/add-e2e-test/SKILL.md +227 -0
- package/template/.cursor/skills/add-error-boundary/SKILL.md +472 -0
- package/template/.cursor/skills/add-feature/SKILL.md +174 -0
- package/template/.cursor/skills/add-middleware/SKILL.md +135 -0
- package/template/.cursor/skills/add-page/SKILL.md +151 -0
- package/template/.cursor/skills/add-prisma-model/SKILL.md +148 -0
- package/template/.cursor/skills/add-protected-resource/SKILL.md +192 -0
- package/template/.cursor/skills/add-role/SKILL.md +156 -0
- package/template/.cursor/skills/add-server-action/SKILL.md +167 -0
- package/template/.cursor/skills/add-webhook/SKILL.md +192 -0
- package/template/.cursor/skills/build-complete-feature/SKILL.md +227 -0
- package/template/.cursor/skills/build-dashboard/SKILL.md +211 -0
- package/template/.cursor/skills/build-data-table/SKILL.md +283 -0
- package/template/.cursor/skills/build-form/SKILL.md +231 -0
- package/template/.cursor/skills/build-landing-page/SKILL.md +248 -0
- package/template/.cursor/skills/configure-ai/SKILL.md +617 -0
- package/template/.cursor/skills/configure-analytics/SKILL.md +413 -0
- package/template/.cursor/skills/configure-dark-mode/SKILL.md +309 -0
- package/template/.cursor/skills/configure-email/SKILL.md +170 -0
- package/template/.cursor/skills/configure-email-verification/SKILL.md +333 -0
- package/template/.cursor/skills/configure-feature-flags/SKILL.md +361 -0
- package/template/.cursor/skills/configure-i18n/SKILL.md +518 -0
- package/template/.cursor/skills/configure-jobs/SKILL.md +500 -0
- package/template/.cursor/skills/configure-magic-links/SKILL.md +385 -0
- package/template/.cursor/skills/configure-multi-tenancy/SKILL.md +611 -0
- package/template/.cursor/skills/configure-notifications/SKILL.md +569 -0
- package/template/.cursor/skills/configure-oauth/SKILL.md +217 -0
- package/template/.cursor/skills/configure-onboarding/SKILL.md +483 -0
- package/template/.cursor/skills/configure-payments/SKILL.md +243 -0
- package/template/.cursor/skills/configure-realtime/SKILL.md +733 -0
- package/template/.cursor/skills/configure-search/SKILL.md +581 -0
- package/template/.cursor/skills/configure-storage/SKILL.md +273 -0
- package/template/.cursor/skills/configure-two-factor/SKILL.md +518 -0
- package/template/.cursor/skills/create-execution-plan/SKILL.md +204 -0
- package/template/.cursor/skills/create-seed/SKILL.md +191 -0
- package/template/.cursor/skills/deploy-to-vercel/SKILL.md +300 -0
- package/template/.cursor/skills/design-tokens/SKILL.md +138 -0
- package/template/.cursor/skills/mars-capture-conversation-context/SKILL.md +119 -0
- package/template/.cursor/skills/setup-billing/SKILL.md +322 -0
- package/template/.cursor/skills/setup-project/SKILL.md +104 -0
- package/template/.cursor/skills/setup-teams/SKILL.md +682 -0
- package/template/.cursor/skills/test-api-route/SKILL.md +219 -0
- package/template/.cursor/skills/update-architecture-docs/SKILL.md +99 -0
- package/template/AGENTS.md +104 -0
- package/template/ARCHITECTURE.md +102 -0
- package/template/docs/QUALITY_SCORE.md +20 -0
- package/template/docs/design-docs/conversation-as-system-record.md +70 -0
- package/template/docs/design-docs/core-beliefs.md +43 -0
- package/template/docs/design-docs/index.md +8 -0
- package/template/docs/exec-plans/active/.gitkeep +0 -0
- package/template/docs/exec-plans/completed/.gitkeep +0 -0
- package/template/docs/exec-plans/tech-debt.md +7 -0
- package/template/docs/generated/.gitkeep +0 -0
- package/template/docs/product-specs/index.md +7 -0
- package/template/docs/references/index.md +18 -0
- package/template/e2e/api.spec.ts +20 -0
- package/template/e2e/auth.spec.ts +24 -0
- package/template/e2e/public.spec.ts +25 -0
- package/template/eslint.config.mjs +24 -0
- package/template/next-env.d.ts +6 -0
- package/template/next.config.ts +45 -0
- package/template/package.json +80 -0
- package/template/playwright.config.ts +31 -0
- package/template/postcss.config.mjs +8 -0
- package/template/prisma/generated/prisma/browser.ts +49 -0
- package/template/prisma/generated/prisma/client.ts +73 -0
- package/template/prisma/generated/prisma/commonInputTypes.ts +406 -0
- package/template/prisma/generated/prisma/enums.ts +15 -0
- package/template/prisma/generated/prisma/internal/class.ts +254 -0
- package/template/prisma/generated/prisma/internal/prismaNamespace.ts +1240 -0
- package/template/prisma/generated/prisma/internal/prismaNamespaceBrowser.ts +190 -0
- package/template/prisma/generated/prisma/models/Account.ts +1543 -0
- package/template/prisma/generated/prisma/models/File.ts +1529 -0
- package/template/prisma/generated/prisma/models/Session.ts +1415 -0
- package/template/prisma/generated/prisma/models/Subscription.ts +1455 -0
- package/template/prisma/generated/prisma/models/User.ts +2235 -0
- package/template/prisma/generated/prisma/models/VerificationToken.ts +1099 -0
- package/template/prisma/generated/prisma/models.ts +17 -0
- package/template/prisma/schema/auth.prisma +69 -0
- package/template/prisma/schema/base.prisma +8 -0
- package/template/prisma/schema/file.prisma +15 -0
- package/template/prisma/schema/subscription.prisma +17 -0
- package/template/prisma.config.ts +13 -0
- package/template/scripts/check-architecture.ts +221 -0
- package/template/scripts/check-doc-freshness.ts +242 -0
- package/template/scripts/ensure-db.mjs +291 -0
- package/template/scripts/generate-docs.ts +143 -0
- package/template/scripts/generate-env-example.ts +89 -0
- package/template/scripts/seed.ts +56 -0
- package/template/scripts/update-quality-score.ts +263 -0
- package/template/src/__tests__/architecture.test.ts +114 -0
- package/template/src/app/(auth)/forgotten-password/page.tsx +92 -0
- package/template/src/app/(auth)/layout.tsx +11 -0
- package/template/src/app/(auth)/register/page.tsx +162 -0
- package/template/src/app/(auth)/reset-password/page.tsx +109 -0
- package/template/src/app/(auth)/sign-in/page.tsx +122 -0
- package/template/src/app/(auth)/verify/[token]/page.tsx +87 -0
- package/template/src/app/(auth)/verify/page.tsx +56 -0
- package/template/src/app/(protected)/admin/page.tsx +108 -0
- package/template/src/app/(protected)/dashboard/loading.tsx +20 -0
- package/template/src/app/(protected)/dashboard/page.tsx +22 -0
- package/template/src/app/(protected)/layout.tsx +262 -0
- package/template/src/app/(protected)/settings/page.tsx +370 -0
- package/template/src/app/api/auth/forgot/route.ts +63 -0
- package/template/src/app/api/auth/login/route.ts +121 -0
- package/template/src/app/api/auth/logout/route.ts +19 -0
- package/template/src/app/api/auth/me/route.ts +30 -0
- package/template/src/app/api/auth/reset/route.ts +45 -0
- package/template/src/app/api/auth/signup/route.ts +85 -0
- package/template/src/app/api/auth/verify/route.ts +46 -0
- package/template/src/app/api/csrf/route.ts +12 -0
- package/template/src/app/api/health/route.ts +10 -0
- package/template/src/app/api/protected/admin/users/route.ts +24 -0
- package/template/src/app/api/protected/billing/checkout/route.ts +83 -0
- package/template/src/app/api/protected/billing/portal/route.ts +39 -0
- package/template/src/app/api/protected/files/[fileId]/route.ts +86 -0
- package/template/src/app/api/protected/files/upload/route.ts +64 -0
- package/template/src/app/api/protected/user/password/route.ts +63 -0
- package/template/src/app/api/protected/user/profile/route.ts +35 -0
- package/template/src/app/api/protected/user/sessions/[sessionId]/route.ts +33 -0
- package/template/src/app/api/protected/user/sessions/route.ts +22 -0
- package/template/src/app/api/readiness/route.ts +15 -0
- package/template/src/app/api/webhooks/stripe/route.ts +166 -0
- package/template/src/app/error.tsx +33 -0
- package/template/src/app/layout.tsx +29 -0
- package/template/src/app/not-found.tsx +20 -0
- package/template/src/app/page.tsx +136 -0
- package/template/src/app/privacy/page.tsx +178 -0
- package/template/src/app/providers.tsx +8 -0
- package/template/src/app/terms/page.tsx +139 -0
- package/template/src/config/app.config.ts +70 -0
- package/template/src/config/routes.ts +17 -0
- package/template/src/features/admin/index.ts +11 -0
- package/template/src/features/admin/permissions.ts +64 -0
- package/template/src/features/auth/context/AuthContext.tsx +96 -0
- package/template/src/features/auth/context/index.ts +2 -0
- package/template/src/features/auth/index.ts +3 -0
- package/template/src/features/auth/server/consent.ts +66 -0
- package/template/src/features/auth/server/session-revocation.ts +20 -0
- package/template/src/features/auth/server/sessions.ts +66 -0
- package/template/src/features/auth/server/user.ts +166 -0
- package/template/src/features/auth/types.ts +19 -0
- package/template/src/features/auth/validators.ts +29 -0
- package/template/src/features/billing/server/index.ts +66 -0
- package/template/src/features/billing/types.ts +43 -0
- package/template/src/features/uploads/server/index.ts +49 -0
- package/template/src/features/uploads/types.ts +26 -0
- package/template/src/lib/core/email/templates/base-layout.ts +122 -0
- package/template/src/lib/core/email/templates/index.ts +4 -0
- package/template/src/lib/core/email/templates/password-reset-email.ts +42 -0
- package/template/src/lib/core/email/templates/verification-email.ts +41 -0
- package/template/src/lib/core/email/templates/welcome-email.ts +40 -0
- package/template/src/lib/mars.ts +56 -0
- package/template/src/lib/prisma.ts +19 -0
- package/template/src/proxy.ts +92 -0
- package/template/src/styles/brand.css +17 -0
- package/template/src/styles/globals.css +6 -0
- package/template/tsconfig.json +59 -0
- package/template/vitest.config.ts +41 -0
- 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
|