@digilogiclabs/create-saas-app 2.5.1 → 2.6.0
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/CHANGELOG.md +23 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/cli/commands/create.d.ts +4 -0
- package/dist/cli/commands/create.d.ts.map +1 -1
- package/dist/cli/commands/create.js +2 -0
- package/dist/cli/commands/create.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +2 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/generators/template-generator.d.ts +2 -0
- package/dist/generators/template-generator.d.ts.map +1 -1
- package/dist/generators/template-generator.js +32 -0
- package/dist/generators/template-generator.js.map +1 -1
- package/dist/templates/shared/design/web/src/config/design.config.ts +51 -0
- package/dist/templates/shared/landing/web/src/components/LandingPage.tsx +97 -0
- package/dist/templates/shared/landing/web/src/components/PricingSection.tsx +80 -0
- package/dist/templates/shared/quality/web/lighthouserc.js +41 -0
- package/dist/templates/shared/quality/web/src/__tests__/accessibility.test.tsx +140 -0
- package/dist/templates/shared/security/web/lib/api-security.ts +76 -243
- package/package.json +1 -1
- package/src/templates/shared/design/web/src/config/design.config.ts +51 -0
- package/src/templates/shared/landing/web/src/components/LandingPage.tsx +97 -0
- package/src/templates/shared/landing/web/src/components/PricingSection.tsx +80 -0
- package/src/templates/shared/quality/web/lighthouserc.js +41 -0
- package/src/templates/shared/quality/web/src/__tests__/accessibility.test.tsx +140 -0
- package/src/templates/shared/security/web/lib/api-security.ts +76 -243
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accessibility Test Suite
|
|
3
|
+
*
|
|
4
|
+
* Uses axe-core to automatically scan pages for WCAG 2.2 AA violations.
|
|
5
|
+
* Run: npm test -- accessibility
|
|
6
|
+
*
|
|
7
|
+
* Setup: npm i -D @axe-core/react axe-core vitest
|
|
8
|
+
*
|
|
9
|
+
* Generated by @digilogiclabs/create-saas-app
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect } from 'vitest';
|
|
12
|
+
import { render } from '@testing-library/react';
|
|
13
|
+
|
|
14
|
+
// Dynamically import axe-core to avoid build errors if not installed
|
|
15
|
+
async function checkAccessibility(container: HTMLElement) {
|
|
16
|
+
try {
|
|
17
|
+
const axe = await import('axe-core');
|
|
18
|
+
const results = await axe.default.run(container);
|
|
19
|
+
return results;
|
|
20
|
+
} catch {
|
|
21
|
+
// axe-core not installed — skip gracefully
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('Accessibility', () => {
|
|
27
|
+
it('should have no critical accessibility violations on a basic page', async () => {
|
|
28
|
+
const { container } = render(
|
|
29
|
+
<main>
|
|
30
|
+
<h1>Test Page</h1>
|
|
31
|
+
<nav aria-label="Main navigation">
|
|
32
|
+
<a href="/">Home</a>
|
|
33
|
+
<a href="/about">About</a>
|
|
34
|
+
</nav>
|
|
35
|
+
<section aria-label="Content">
|
|
36
|
+
<p>Test content</p>
|
|
37
|
+
<button type="button">Action</button>
|
|
38
|
+
</section>
|
|
39
|
+
</main>
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const results = await checkAccessibility(container);
|
|
43
|
+
|
|
44
|
+
if (results) {
|
|
45
|
+
// Filter to only critical and serious violations
|
|
46
|
+
const critical = results.violations.filter(
|
|
47
|
+
(v) => v.impact === 'critical' || v.impact === 'serious'
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
if (critical.length > 0) {
|
|
51
|
+
const summary = critical
|
|
52
|
+
.map((v) => `[${v.impact}] ${v.id}: ${v.description}`)
|
|
53
|
+
.join('\n');
|
|
54
|
+
expect(critical, `Accessibility violations found:\n${summary}`).toHaveLength(0);
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
// axe-core not installed — test passes with a warning
|
|
58
|
+
console.warn(
|
|
59
|
+
'axe-core not installed. Run: npm i -D axe-core\n' +
|
|
60
|
+
'Accessibility testing will be enabled once installed.'
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should have proper heading hierarchy', () => {
|
|
66
|
+
const { container } = render(
|
|
67
|
+
<main>
|
|
68
|
+
<h1>Page Title</h1>
|
|
69
|
+
<section>
|
|
70
|
+
<h2>Section</h2>
|
|
71
|
+
<h3>Subsection</h3>
|
|
72
|
+
</section>
|
|
73
|
+
</main>
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const headings = container.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
|
77
|
+
const levels = Array.from(headings).map((h) =>
|
|
78
|
+
parseInt(h.tagName.replace('H', ''))
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
// Verify no heading level is skipped (e.g., h1 -> h3 without h2)
|
|
82
|
+
for (let i = 1; i < levels.length; i++) {
|
|
83
|
+
const jump = levels[i]! - levels[i - 1]!;
|
|
84
|
+
expect(
|
|
85
|
+
jump,
|
|
86
|
+
`Heading level jumped from h${levels[i - 1]} to h${levels[i]}`
|
|
87
|
+
).toBeLessThanOrEqual(1);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should have accessible form labels', () => {
|
|
92
|
+
const { container } = render(
|
|
93
|
+
<form>
|
|
94
|
+
<label htmlFor="email">Email</label>
|
|
95
|
+
<input id="email" type="email" aria-required="true" />
|
|
96
|
+
<label htmlFor="password">Password</label>
|
|
97
|
+
<input id="password" type="password" aria-required="true" />
|
|
98
|
+
<button type="submit">Submit</button>
|
|
99
|
+
</form>
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const inputs = container.querySelectorAll('input');
|
|
103
|
+
inputs.forEach((input) => {
|
|
104
|
+
const id = input.getAttribute('id');
|
|
105
|
+
if (id) {
|
|
106
|
+
const label = container.querySelector(`label[for="${id}"]`);
|
|
107
|
+
expect(
|
|
108
|
+
label,
|
|
109
|
+
`Input #${id} is missing a label`
|
|
110
|
+
).not.toBeNull();
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('should have sufficient touch targets (44px minimum)', () => {
|
|
116
|
+
const { container } = render(
|
|
117
|
+
<div>
|
|
118
|
+
<button style={{ minHeight: '44px', minWidth: '44px' }}>
|
|
119
|
+
Click me
|
|
120
|
+
</button>
|
|
121
|
+
<a href="/" style={{ display: 'inline-block', minHeight: '44px', padding: '12px' }}>
|
|
122
|
+
Link
|
|
123
|
+
</a>
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const buttons = container.querySelectorAll('button');
|
|
128
|
+
buttons.forEach((button) => {
|
|
129
|
+
const style = window.getComputedStyle(button);
|
|
130
|
+
const height = parseFloat(style.minHeight) || button.offsetHeight;
|
|
131
|
+
// Only check if explicit minHeight is set
|
|
132
|
+
if (style.minHeight && style.minHeight !== 'auto') {
|
|
133
|
+
expect(
|
|
134
|
+
height,
|
|
135
|
+
`Button "${button.textContent}" has touch target ${height}px (minimum 44px)`
|
|
136
|
+
).toBeGreaterThanOrEqual(44);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -1,17 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared API security utilities.
|
|
3
3
|
*
|
|
4
|
-
* Built on platform-core
|
|
5
|
-
*
|
|
4
|
+
* Built on platform-core's createSecureHandlerFactory — apps configure auth,
|
|
5
|
+
* rate limiting, and error handling once, then use composable wrappers per route.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* // Authenticated route
|
|
9
|
+
* export const POST = withAuthenticatedApi({
|
|
10
|
+
* rateLimit: 'authMutation',
|
|
11
|
+
* validate: CreateItemSchema,
|
|
12
|
+
* }, async (request, { session, validated }) => {
|
|
13
|
+
* return Response.json({ created: true })
|
|
14
|
+
* })
|
|
15
|
+
*
|
|
16
|
+
* // Public route (manual composition)
|
|
17
|
+
* export async function GET(request: NextRequest) {
|
|
18
|
+
* const rl = await enforceRateLimit(request, 'list', AppRateLimits.publicRead)
|
|
19
|
+
* if (rl) return rl
|
|
20
|
+
* return NextResponse.json({ items: [] })
|
|
21
|
+
* }
|
|
10
22
|
*/
|
|
11
23
|
import 'server-only';
|
|
12
24
|
import { NextRequest, NextResponse } from 'next/server';
|
|
13
|
-
import { randomUUID } from 'crypto';
|
|
14
25
|
import {
|
|
26
|
+
// Factory for composable wrappers
|
|
27
|
+
createSecureHandlerFactory,
|
|
15
28
|
// Security primitives
|
|
16
29
|
constantTimeEqual,
|
|
17
30
|
// Rate limiting
|
|
@@ -33,18 +46,12 @@ export { escapeHtml } from '@digilogiclabs/platform-core/auth';
|
|
|
33
46
|
export { errorResponse, zodErrorResponse };
|
|
34
47
|
|
|
35
48
|
// ---------------------------------------------------------------------------
|
|
36
|
-
//
|
|
49
|
+
// Rate limiting — Redis-backed with in-memory fallback
|
|
37
50
|
// ---------------------------------------------------------------------------
|
|
38
51
|
|
|
39
|
-
/** Generate or extract a request ID for correlation. */
|
|
40
|
-
export function getRequestId(request: NextRequest): string {
|
|
41
|
-
return request.headers.get('x-request-id') || randomUUID();
|
|
42
|
-
}
|
|
43
|
-
|
|
44
52
|
/**
|
|
45
|
-
* Rate-limit a request
|
|
46
|
-
*
|
|
47
|
-
* automatically inject the store.
|
|
53
|
+
* Rate-limit a request. Wraps platform-core's enforceRateLimit to
|
|
54
|
+
* automatically inject the Redis store.
|
|
48
55
|
*/
|
|
49
56
|
export async function enforceRateLimit(
|
|
50
57
|
request: { headers: { get(name: string): string | null } },
|
|
@@ -65,254 +72,80 @@ export async function enforceRateLimit(
|
|
|
65
72
|
}
|
|
66
73
|
|
|
67
74
|
// ---------------------------------------------------------------------------
|
|
68
|
-
//
|
|
75
|
+
// Rate limit presets — tune for your app's traffic patterns
|
|
69
76
|
// ---------------------------------------------------------------------------
|
|
70
77
|
|
|
71
|
-
/** Extract bearer token from Authorization header. */
|
|
72
|
-
function extractBearerToken(request: NextRequest): string | null {
|
|
73
|
-
const header = request.headers.get('authorization');
|
|
74
|
-
if (!header?.startsWith('Bearer ')) return null;
|
|
75
|
-
return header.slice(7);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** Check if request has a valid admin bearer token (ADMIN_SECRET). Timing-safe. */
|
|
79
|
-
export function isAdminRequest(request: NextRequest): boolean {
|
|
80
|
-
const secret = config.adminSecret;
|
|
81
|
-
if (!secret) return false;
|
|
82
|
-
const token = extractBearerToken(request);
|
|
83
|
-
if (!token) return false;
|
|
84
|
-
return constantTimeEqual(token, secret);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** Check if request has a valid cron bearer token (CRON_SECRET). Timing-safe. */
|
|
88
|
-
export function isCronRequest(request: NextRequest): boolean {
|
|
89
|
-
const secret = config.cronSecret;
|
|
90
|
-
if (!secret) return false;
|
|
91
|
-
const token = extractBearerToken(request);
|
|
92
|
-
if (!token) return false;
|
|
93
|
-
return constantTimeEqual(token, secret);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// ---------------------------------------------------------------------------
|
|
97
|
-
// Rate limiting presets — tune for your app's traffic patterns
|
|
98
|
-
// ---------------------------------------------------------------------------
|
|
99
|
-
|
|
100
|
-
/** App-specific rate limit presets. Extend or override as needed. */
|
|
101
78
|
export const AppRateLimits = {
|
|
102
|
-
/** Public read endpoints */
|
|
103
79
|
publicRead: { limit: 60, windowSeconds: 60 } satisfies RateLimitRule,
|
|
104
|
-
/** Authenticated mutations */
|
|
105
80
|
authMutation: { limit: 30, windowSeconds: 60 } satisfies RateLimitRule,
|
|
106
|
-
/** Admin endpoints */
|
|
107
81
|
admin: CommonRateLimits.adminAction,
|
|
108
|
-
/** Beta code validation */
|
|
109
82
|
betaValidation: CommonRateLimits.betaValidation,
|
|
110
|
-
/** Webhook endpoints (generous — Stripe retries) */
|
|
111
83
|
webhook: { limit: 100, windowSeconds: 60 } satisfies RateLimitRule,
|
|
112
84
|
} as const;
|
|
113
85
|
|
|
86
|
+
type AppRateLimitPreset = keyof typeof AppRateLimits;
|
|
87
|
+
|
|
114
88
|
// ---------------------------------------------------------------------------
|
|
115
|
-
//
|
|
89
|
+
// Rate limit enforcer for the factory
|
|
116
90
|
// ---------------------------------------------------------------------------
|
|
117
91
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
/** The authenticated session */
|
|
127
|
-
session: { user: { id: string; email: string; roles?: string[] } };
|
|
128
|
-
/** Request correlation ID */
|
|
129
|
-
requestId: string;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
interface PublicApiContext {
|
|
133
|
-
/** Request correlation ID */
|
|
134
|
-
requestId: string;
|
|
92
|
+
async function enforcePreset(
|
|
93
|
+
request: Request,
|
|
94
|
+
operation: string,
|
|
95
|
+
preset: AppRateLimitPreset,
|
|
96
|
+
userId?: string,
|
|
97
|
+
): Promise<Response | null> {
|
|
98
|
+
const rule = AppRateLimits[preset];
|
|
99
|
+
return enforceRateLimit(request, operation, rule, userId ? { userId } : undefined);
|
|
135
100
|
}
|
|
136
101
|
|
|
137
|
-
type PublicApiHandler = (request: NextRequest, context: PublicApiContext) => Promise<NextResponse>;
|
|
138
|
-
|
|
139
|
-
type AuthenticatedApiHandler = (
|
|
140
|
-
request: NextRequest,
|
|
141
|
-
context: AuthenticatedApiContext
|
|
142
|
-
) => Promise<NextResponse>;
|
|
143
|
-
|
|
144
|
-
type AdminApiHandler = (request: NextRequest, context: PublicApiContext) => Promise<NextResponse>;
|
|
145
|
-
|
|
146
|
-
type CronApiHandler = (request: NextRequest, context: PublicApiContext) => Promise<NextResponse>;
|
|
147
|
-
|
|
148
102
|
// ---------------------------------------------------------------------------
|
|
149
|
-
// API
|
|
103
|
+
// Composable API security wrappers (via platform-core factory)
|
|
150
104
|
// ---------------------------------------------------------------------------
|
|
151
105
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
if (rateLimited) return rateLimited as NextResponse;
|
|
177
|
-
|
|
178
|
-
const response = await handler(request, { requestId });
|
|
179
|
-
response.headers.set('x-request-id', requestId);
|
|
180
|
-
return response;
|
|
181
|
-
} catch (error) {
|
|
182
|
-
const { status, body } = classifyError(error, process.env.NODE_ENV === 'development');
|
|
183
|
-
return NextResponse.json({ ...body, requestId }, { status });
|
|
184
|
-
}
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* Wrap an authenticated API route with session validation, rate limiting, and error handling.
|
|
190
|
-
* Requires a valid Auth.js session.
|
|
191
|
-
*
|
|
192
|
-
* @example
|
|
193
|
-
* export const POST = withAuthenticatedApi({ operation: 'create-item' }, async (req, ctx) => {
|
|
194
|
-
* const { session, requestId } = ctx;
|
|
195
|
-
* return NextResponse.json({ userId: session.user.id });
|
|
196
|
-
* });
|
|
197
|
-
*/
|
|
198
|
-
export function withAuthenticatedApi(
|
|
199
|
-
handlerConfig: ApiWrapperConfig,
|
|
200
|
-
handler: AuthenticatedApiHandler
|
|
201
|
-
): (request: NextRequest) => Promise<NextResponse> {
|
|
202
|
-
return async (request: NextRequest) => {
|
|
203
|
-
const requestId = getRequestId(request);
|
|
204
|
-
const operation = handlerConfig.operation || 'authenticated';
|
|
205
|
-
|
|
206
|
-
try {
|
|
207
|
-
// Dynamic import to avoid Edge runtime issues
|
|
208
|
-
const { auth } = await import('@/auth');
|
|
209
|
-
const session = await auth();
|
|
210
|
-
|
|
211
|
-
if (!session?.user?.id) {
|
|
212
|
-
return NextResponse.json({ error: 'Unauthorized', requestId }, { status: 401 });
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// Rate limiting (per-user)
|
|
216
|
-
const rateLimited = await enforceRateLimit(
|
|
217
|
-
request,
|
|
218
|
-
operation,
|
|
219
|
-
handlerConfig.rateLimit || AppRateLimits.authMutation,
|
|
220
|
-
{ userId: session.user.id }
|
|
221
|
-
);
|
|
222
|
-
if (rateLimited) return rateLimited as NextResponse;
|
|
223
|
-
|
|
224
|
-
const response = await handler(request, {
|
|
225
|
-
session: session as AuthenticatedApiContext['session'],
|
|
226
|
-
requestId,
|
|
227
|
-
});
|
|
228
|
-
response.headers.set('x-request-id', requestId);
|
|
229
|
-
return response;
|
|
230
|
-
} catch (error) {
|
|
231
|
-
const { status, body } = classifyError(error, process.env.NODE_ENV === 'development');
|
|
232
|
-
return NextResponse.json({ ...body, requestId }, { status });
|
|
233
|
-
}
|
|
234
|
-
};
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* Wrap an admin API route with Bearer token auth, rate limiting, and error handling.
|
|
239
|
-
* Requires ADMIN_SECRET Bearer token.
|
|
240
|
-
*
|
|
241
|
-
* @example
|
|
242
|
-
* export const POST = withAdminApi({ operation: 'admin-action' }, async (req, ctx) => {
|
|
243
|
-
* return NextResponse.json({ success: true });
|
|
244
|
-
* });
|
|
245
|
-
*/
|
|
246
|
-
export function withAdminApi(
|
|
247
|
-
handlerConfig: ApiWrapperConfig,
|
|
248
|
-
handler: AdminApiHandler
|
|
249
|
-
): (request: NextRequest) => Promise<NextResponse> {
|
|
250
|
-
return async (request: NextRequest) => {
|
|
251
|
-
const requestId = getRequestId(request);
|
|
252
|
-
const operation = handlerConfig.operation || 'admin';
|
|
253
|
-
|
|
254
|
-
try {
|
|
255
|
-
if (!isAdminRequest(request)) {
|
|
256
|
-
return NextResponse.json({ error: 'Forbidden', requestId }, { status: 403 });
|
|
257
|
-
}
|
|
106
|
+
export const {
|
|
107
|
+
withPublicApi,
|
|
108
|
+
withAuthenticatedApi,
|
|
109
|
+
withAdminApi,
|
|
110
|
+
withLegacyAdminApi,
|
|
111
|
+
createSecureHandler,
|
|
112
|
+
} = createSecureHandlerFactory<
|
|
113
|
+
{ user?: { id?: string; email?: string | null; name?: string | null; roles?: string[] } },
|
|
114
|
+
AppRateLimitPreset
|
|
115
|
+
>({
|
|
116
|
+
getSession: async () => {
|
|
117
|
+
const { auth } = await import('@/auth');
|
|
118
|
+
return auth();
|
|
119
|
+
},
|
|
120
|
+
isAdmin: (session) => session?.user?.roles?.includes('admin') ?? false,
|
|
121
|
+
rateLimiter: {
|
|
122
|
+
enforce: enforcePreset,
|
|
123
|
+
publicDefault: 'publicRead',
|
|
124
|
+
authDefault: 'authMutation',
|
|
125
|
+
adminDefault: 'admin',
|
|
126
|
+
},
|
|
127
|
+
adminSecret: config.adminSecret,
|
|
128
|
+
classifyError: (error, isDev) => classifyError(error, isDev),
|
|
129
|
+
});
|
|
258
130
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
operation,
|
|
263
|
-
handlerConfig.rateLimit || AppRateLimits.admin
|
|
264
|
-
);
|
|
265
|
-
if (rateLimited) return rateLimited as NextResponse;
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Legacy helpers — for routes that need manual composition
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
266
134
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
};
|
|
135
|
+
/** Check if request has a valid admin bearer token. Timing-safe. */
|
|
136
|
+
export function isAdminRequest(request: NextRequest): boolean {
|
|
137
|
+
const secret = config.adminSecret;
|
|
138
|
+
if (!secret) return false;
|
|
139
|
+
const header = request.headers.get('authorization');
|
|
140
|
+
if (!header?.startsWith('Bearer ')) return false;
|
|
141
|
+
return constantTimeEqual(header.slice(7), secret);
|
|
275
142
|
}
|
|
276
143
|
|
|
277
|
-
/**
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
* // Run scheduled task...
|
|
285
|
-
* return NextResponse.json({ processed: 42 });
|
|
286
|
-
* });
|
|
287
|
-
*/
|
|
288
|
-
export function withCronApi(
|
|
289
|
-
handlerConfig: ApiWrapperConfig,
|
|
290
|
-
handler: CronApiHandler
|
|
291
|
-
): (request: NextRequest) => Promise<NextResponse> {
|
|
292
|
-
return async (request: NextRequest) => {
|
|
293
|
-
const requestId = getRequestId(request);
|
|
294
|
-
const operation = handlerConfig.operation || 'cron';
|
|
295
|
-
|
|
296
|
-
try {
|
|
297
|
-
// Accept CRON_SECRET Bearer token or fall back to ADMIN_SECRET
|
|
298
|
-
if (!isCronRequest(request) && !isAdminRequest(request)) {
|
|
299
|
-
return NextResponse.json({ error: 'Forbidden', requestId }, { status: 403 });
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
// Rate limiting (generous — cron jobs are server-to-server)
|
|
303
|
-
const rateLimited = await enforceRateLimit(
|
|
304
|
-
request,
|
|
305
|
-
operation,
|
|
306
|
-
handlerConfig.rateLimit || AppRateLimits.webhook
|
|
307
|
-
);
|
|
308
|
-
if (rateLimited) return rateLimited as NextResponse;
|
|
309
|
-
|
|
310
|
-
const response = await handler(request, { requestId });
|
|
311
|
-
response.headers.set('x-request-id', requestId);
|
|
312
|
-
return response;
|
|
313
|
-
} catch (error) {
|
|
314
|
-
const { status, body } = classifyError(error, process.env.NODE_ENV === 'development');
|
|
315
|
-
return NextResponse.json({ ...body, requestId }, { status });
|
|
316
|
-
}
|
|
317
|
-
};
|
|
144
|
+
/** Check if request has a valid cron bearer token. Timing-safe. */
|
|
145
|
+
export function isCronRequest(request: NextRequest): boolean {
|
|
146
|
+
const secret = config.cronSecret;
|
|
147
|
+
if (!secret) return false;
|
|
148
|
+
const header = request.headers.get('authorization');
|
|
149
|
+
if (!header?.startsWith('Bearer ')) return false;
|
|
150
|
+
return constantTimeEqual(header.slice(7), secret);
|
|
318
151
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Design System Configuration
|
|
3
|
+
*
|
|
4
|
+
* Central design config for {{projectName}}.
|
|
5
|
+
* Values here drive the CSS custom properties in globals.css.
|
|
6
|
+
* Override tokens per-app — never hardcode colors/spacing in components.
|
|
7
|
+
*
|
|
8
|
+
* Generated by @digilogiclabs/create-saas-app
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const designConfig = {
|
|
12
|
+
/** App theme preset */
|
|
13
|
+
theme: "{{theme}}" as const,
|
|
14
|
+
|
|
15
|
+
/** Primary accent color */
|
|
16
|
+
accent: "{{themeColor}}" as const,
|
|
17
|
+
|
|
18
|
+
/** Default color mode */
|
|
19
|
+
defaultMode: "{{defaultTheme}}" as const,
|
|
20
|
+
|
|
21
|
+
/** Content density */
|
|
22
|
+
density: "comfortable" as const,
|
|
23
|
+
|
|
24
|
+
/** Border radius scale */
|
|
25
|
+
radius: "lg" as const,
|
|
26
|
+
|
|
27
|
+
/** Motion preference */
|
|
28
|
+
motion: "{{motion}}" as const,
|
|
29
|
+
|
|
30
|
+
/** Landing page style */
|
|
31
|
+
landingStyle: "{{landingStyle}}" as const,
|
|
32
|
+
|
|
33
|
+
/** Layout archetype */
|
|
34
|
+
layout: "dashboard" as const,
|
|
35
|
+
} as const;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Color presets mapped to CSS custom property values.
|
|
39
|
+
* Used by ThemeProvider to set --color-accent at runtime.
|
|
40
|
+
*/
|
|
41
|
+
export const accentColors = {
|
|
42
|
+
blue: { light: "#3b82f6", dark: "#60a5fa" },
|
|
43
|
+
green: { light: "#10b981", dark: "#34d399" },
|
|
44
|
+
purple: { light: "#8b5cf6", dark: "#a78bfa" },
|
|
45
|
+
orange: { light: "#f97316", dark: "#fb923c" },
|
|
46
|
+
red: { light: "#ef4444", dark: "#f87171" },
|
|
47
|
+
slate: { light: "#475569", dark: "#94a3b8" },
|
|
48
|
+
} as const;
|
|
49
|
+
|
|
50
|
+
export type AccentColor = keyof typeof accentColors;
|
|
51
|
+
export type DesignConfig = typeof designConfig;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
PageShell,
|
|
5
|
+
PageHeader,
|
|
6
|
+
SectionBlock,
|
|
7
|
+
FeatureGrid,
|
|
8
|
+
CTACluster,
|
|
9
|
+
Hero,
|
|
10
|
+
Button,
|
|
11
|
+
} from '@digilogiclabs/saas-factory-ui'
|
|
12
|
+
import { Zap, Shield, Rocket, BarChart3 } from 'lucide-react'
|
|
13
|
+
import Link from 'next/link'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* LandingPage — Design-system-aligned landing page component.
|
|
17
|
+
*
|
|
18
|
+
* Uses PageShell, FeatureGrid, CTACluster, and SectionBlock from
|
|
19
|
+
* @digilogiclabs/saas-factory-ui for consistent structure.
|
|
20
|
+
*
|
|
21
|
+
* Generated by @digilogiclabs/create-saas-app
|
|
22
|
+
*/
|
|
23
|
+
export function LandingPage() {
|
|
24
|
+
return (
|
|
25
|
+
<PageShell maxWidth="xl" padding="none">
|
|
26
|
+
{/* Hero Section */}
|
|
27
|
+
<Hero
|
|
28
|
+
title="Build Your SaaS, Faster"
|
|
29
|
+
subtitle="Everything you need to launch — authentication, payments, and a beautiful UI. All in one platform."
|
|
30
|
+
variant="centered"
|
|
31
|
+
size="lg"
|
|
32
|
+
primaryAction={{
|
|
33
|
+
text: "Get Started",
|
|
34
|
+
onClick: () => (window.location.href = '/signup'),
|
|
35
|
+
}}
|
|
36
|
+
secondaryAction={{
|
|
37
|
+
text: "View Pricing",
|
|
38
|
+
onClick: () => {
|
|
39
|
+
document.getElementById('pricing')?.scrollIntoView({ behavior: 'smooth' })
|
|
40
|
+
},
|
|
41
|
+
variant: "outline",
|
|
42
|
+
}}
|
|
43
|
+
className="py-20 px-4"
|
|
44
|
+
/>
|
|
45
|
+
|
|
46
|
+
{/* Features Section */}
|
|
47
|
+
<SectionBlock spacing="lg">
|
|
48
|
+
<FeatureGrid
|
|
49
|
+
title="Why Choose Us"
|
|
50
|
+
description="Built on battle-tested infrastructure with modern design patterns."
|
|
51
|
+
columns={4}
|
|
52
|
+
variant="cards"
|
|
53
|
+
features={[
|
|
54
|
+
{
|
|
55
|
+
icon: <Zap className="w-5 h-5" />,
|
|
56
|
+
title: "Lightning Fast",
|
|
57
|
+
description: "Optimized for Core Web Vitals with server-first rendering and smart caching.",
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
icon: <Shield className="w-5 h-5" />,
|
|
61
|
+
title: "Enterprise Security",
|
|
62
|
+
description: "Self-hosted auth via Keycloak, rate limiting, CSRF protection, and audit logging.",
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
icon: <Rocket className="w-5 h-5" />,
|
|
66
|
+
title: "Ship in Days",
|
|
67
|
+
description: "Pre-built auth flows, payment integration, and a complete component library.",
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
icon: <BarChart3 className="w-5 h-5" />,
|
|
71
|
+
title: "Full Observability",
|
|
72
|
+
description: "Health checks, structured logging, metrics, and distributed tracing out of the box.",
|
|
73
|
+
},
|
|
74
|
+
]}
|
|
75
|
+
/>
|
|
76
|
+
</SectionBlock>
|
|
77
|
+
|
|
78
|
+
{/* CTA Section */}
|
|
79
|
+
<SectionBlock spacing="lg">
|
|
80
|
+
<div className="text-center py-16 px-4 rounded-2xl bg-gradient-to-br from-primary/5 to-accent/5">
|
|
81
|
+
<h2 className="text-2xl sm:text-3xl font-bold mb-4">
|
|
82
|
+
Ready to launch?
|
|
83
|
+
</h2>
|
|
84
|
+
<p className="text-muted-foreground mb-8 max-w-lg mx-auto">
|
|
85
|
+
Start building today with a complete SaaS foundation. No vendor lock-in.
|
|
86
|
+
</p>
|
|
87
|
+
<CTACluster
|
|
88
|
+
align="center"
|
|
89
|
+
size="lg"
|
|
90
|
+
primary={{ text: "Start Free Trial", href: "/signup" }}
|
|
91
|
+
secondary={{ text: "View Documentation", href: "/docs", variant: "outline" }}
|
|
92
|
+
/>
|
|
93
|
+
</div>
|
|
94
|
+
</SectionBlock>
|
|
95
|
+
</PageShell>
|
|
96
|
+
)
|
|
97
|
+
}
|