@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,20 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
|
|
3
|
+
import { prisma } from '@/lib/prisma';
|
|
4
|
+
import { buildCredentialTag } from '@mars-stack/core/auth/credential-tag';
|
|
5
|
+
import { constantTimeEqual } from '@mars-stack/core/auth/crypto-utils';
|
|
6
|
+
|
|
7
|
+
export async function isSessionCredentialValid(
|
|
8
|
+
userId: string,
|
|
9
|
+
credentialTag: string,
|
|
10
|
+
): Promise<boolean> {
|
|
11
|
+
const user = await prisma.user.findUnique({
|
|
12
|
+
where: { id: userId },
|
|
13
|
+
select: { password: true },
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
if (!user) return false;
|
|
17
|
+
|
|
18
|
+
const currentCredentialTag = await buildCredentialTag(user.password);
|
|
19
|
+
return constantTimeEqual(credentialTag, currentCredentialTag);
|
|
20
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
|
|
3
|
+
import { prisma } from '@/lib/prisma';
|
|
4
|
+
|
|
5
|
+
const SESSION_DURATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
6
|
+
|
|
7
|
+
interface CreateDbSessionInput {
|
|
8
|
+
userId: string;
|
|
9
|
+
ipAddress: string;
|
|
10
|
+
userAgent: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function createDbSession({ userId, ipAddress, userAgent }: CreateDbSessionInput) {
|
|
14
|
+
return prisma.session.create({
|
|
15
|
+
data: {
|
|
16
|
+
userId,
|
|
17
|
+
token: crypto.randomUUID(),
|
|
18
|
+
expiresAt: new Date(Date.now() + SESSION_DURATION_MS),
|
|
19
|
+
ipAddress,
|
|
20
|
+
userAgent,
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function listSessionsForUser(userId: string) {
|
|
26
|
+
return prisma.session.findMany({
|
|
27
|
+
where: {
|
|
28
|
+
userId,
|
|
29
|
+
expiresAt: { gt: new Date() },
|
|
30
|
+
},
|
|
31
|
+
select: {
|
|
32
|
+
id: true,
|
|
33
|
+
ipAddress: true,
|
|
34
|
+
userAgent: true,
|
|
35
|
+
createdAt: true,
|
|
36
|
+
expiresAt: true,
|
|
37
|
+
},
|
|
38
|
+
orderBy: { createdAt: 'desc' },
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function findSessionById(sessionId: string) {
|
|
43
|
+
return prisma.session.findUnique({
|
|
44
|
+
where: { id: sessionId },
|
|
45
|
+
select: { id: true, userId: true },
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function revokeSession(sessionId: string) {
|
|
50
|
+
return prisma.session.delete({ where: { id: sessionId } });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function revokeAllSessionsForUser(userId: string, excludeSessionId?: string) {
|
|
54
|
+
return prisma.session.deleteMany({
|
|
55
|
+
where: {
|
|
56
|
+
userId,
|
|
57
|
+
...(excludeSessionId ? { id: { not: excludeSessionId } } : {}),
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function deleteExpiredSessions() {
|
|
63
|
+
return prisma.session.deleteMany({
|
|
64
|
+
where: { expiresAt: { lte: new Date() } },
|
|
65
|
+
});
|
|
66
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
|
|
3
|
+
import { prisma } from '@/lib/prisma';
|
|
4
|
+
import { verifySession } from '@/lib/mars';
|
|
5
|
+
import type { SessionPayload } from '@mars-stack/core/auth/session';
|
|
6
|
+
import type { User } from '@db';
|
|
7
|
+
import { cache } from 'react';
|
|
8
|
+
|
|
9
|
+
export const getUserByEmail = cache(async (email: string): Promise<User | null> => {
|
|
10
|
+
await verifySession();
|
|
11
|
+
return prisma.user.findUnique({ where: { email } });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export const getUserById = cache(async (id: string): Promise<User | null> => {
|
|
15
|
+
await verifySession();
|
|
16
|
+
return prisma.user.findUnique({ where: { id } });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export const getCurrentUserFromDB = cache(async (): Promise<User | null> => {
|
|
20
|
+
const session = await verifySession();
|
|
21
|
+
return prisma.user.findUnique({ where: { id: session.userId } });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export async function updateUser(
|
|
25
|
+
id: string,
|
|
26
|
+
data: Partial<Pick<User, 'name' | 'email' | 'emailVerified' | 'image'>>,
|
|
27
|
+
): Promise<User> {
|
|
28
|
+
const session = await verifySession();
|
|
29
|
+
|
|
30
|
+
if (session.userId !== id && session.role !== 'admin') {
|
|
31
|
+
throw new Error("Unauthorized: Cannot update other user's data");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return prisma.user.update({ where: { id }, data });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function deleteUser(id: string): Promise<void> {
|
|
38
|
+
const session = await verifySession();
|
|
39
|
+
|
|
40
|
+
if (session.userId !== id && session.role !== 'admin') {
|
|
41
|
+
throw new Error("Unauthorized: Cannot delete other user's account");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
await prisma.user.delete({ where: { id } });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function verifyAdminFromDB(): Promise<SessionPayload> {
|
|
48
|
+
const session = await verifySession();
|
|
49
|
+
const dbUser = await prisma.user.findUnique({
|
|
50
|
+
where: { id: session.userId },
|
|
51
|
+
select: { role: true },
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (!dbUser || dbUser.role !== 'admin') {
|
|
55
|
+
throw new Error('Unauthorized: Admin access required');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return session;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const getAllUsers = cache(async (): Promise<User[]> => {
|
|
62
|
+
await verifyAdminFromDB();
|
|
63
|
+
return prisma.user.findMany({ orderBy: { createdAt: 'desc' } });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
export async function updateUserRole(id: string, role: string): Promise<User> {
|
|
67
|
+
await verifyAdminFromDB();
|
|
68
|
+
return prisma.user.update({ where: { id }, data: { role } });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function createUser(data: {
|
|
72
|
+
email: string;
|
|
73
|
+
password: string;
|
|
74
|
+
name?: string;
|
|
75
|
+
termsAccepted?: boolean;
|
|
76
|
+
marketingOptIn?: boolean;
|
|
77
|
+
}): Promise<User> {
|
|
78
|
+
const now = new Date();
|
|
79
|
+
|
|
80
|
+
return prisma.user.create({
|
|
81
|
+
data: {
|
|
82
|
+
email: data.email,
|
|
83
|
+
password: data.password,
|
|
84
|
+
name: data.name,
|
|
85
|
+
role: 'user',
|
|
86
|
+
termsAcceptedAt: data.termsAccepted ? now : null,
|
|
87
|
+
privacyAcceptedAt: data.termsAccepted ? now : null,
|
|
88
|
+
marketingOptIn: data.marketingOptIn || false,
|
|
89
|
+
marketingOptInAt: data.marketingOptIn ? now : null,
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function findUserByEmailPublic(
|
|
95
|
+
email: string,
|
|
96
|
+
): Promise<Omit<User, 'password'> | null> {
|
|
97
|
+
return prisma.user.findUnique({
|
|
98
|
+
where: { email },
|
|
99
|
+
select: {
|
|
100
|
+
id: true,
|
|
101
|
+
email: true,
|
|
102
|
+
name: true,
|
|
103
|
+
role: true,
|
|
104
|
+
emailVerified: true,
|
|
105
|
+
image: true,
|
|
106
|
+
createdAt: true,
|
|
107
|
+
updatedAt: true,
|
|
108
|
+
failedLoginAttempts: true,
|
|
109
|
+
lastFailedLogin: true,
|
|
110
|
+
lockedUntil: true,
|
|
111
|
+
termsAcceptedAt: true,
|
|
112
|
+
privacyAcceptedAt: true,
|
|
113
|
+
marketingOptIn: true,
|
|
114
|
+
marketingOptInAt: true,
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function findUserByEmailForAuth(email: string): Promise<User | null> {
|
|
120
|
+
return prisma.user.findUnique({ where: { email } });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function updateUserPassword(id: string, hashedPassword: string): Promise<User> {
|
|
124
|
+
return prisma.user.update({
|
|
125
|
+
where: { id },
|
|
126
|
+
data: {
|
|
127
|
+
password: hashedPassword,
|
|
128
|
+
failedLoginAttempts: 0,
|
|
129
|
+
lastFailedLogin: null,
|
|
130
|
+
lockedUntil: null,
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function incrementFailedLoginAttempts(id: string): Promise<User> {
|
|
136
|
+
const user = await prisma.user.findUnique({
|
|
137
|
+
where: { id },
|
|
138
|
+
select: { failedLoginAttempts: true },
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const attempts = (user?.failedLoginAttempts || 0) + 1;
|
|
142
|
+
const shouldLock = attempts >= 5;
|
|
143
|
+
|
|
144
|
+
return prisma.user.update({
|
|
145
|
+
where: { id },
|
|
146
|
+
data: {
|
|
147
|
+
failedLoginAttempts: attempts,
|
|
148
|
+
lastFailedLogin: new Date(),
|
|
149
|
+
lockedUntil: shouldLock ? new Date(Date.now() + 15 * 60 * 1000) : null,
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function resetFailedLoginAttempts(id: string): Promise<User> {
|
|
155
|
+
return prisma.user.update({
|
|
156
|
+
where: { id },
|
|
157
|
+
data: { failedLoginAttempts: 0, lastFailedLogin: null, lockedUntil: null },
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function verifyUserEmail(email: string): Promise<User> {
|
|
162
|
+
return prisma.user.update({
|
|
163
|
+
where: { email },
|
|
164
|
+
data: { emailVerified: new Date() },
|
|
165
|
+
});
|
|
166
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface AuthError {
|
|
2
|
+
code: string;
|
|
3
|
+
message: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface AuthUser {
|
|
7
|
+
id: string;
|
|
8
|
+
email: string;
|
|
9
|
+
name?: string;
|
|
10
|
+
role: string;
|
|
11
|
+
emailVerified: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export enum AuthErrorCode {
|
|
15
|
+
INVALID_CREDENTIALS = 'invalid_credentials',
|
|
16
|
+
EMAIL_NOT_VERIFIED = 'email_not_verified',
|
|
17
|
+
ACCOUNT_LOCKED = 'account_locked',
|
|
18
|
+
SERVER_ERROR = 'server_error',
|
|
19
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { passwordSchema } from '@mars-stack/core/auth/password';
|
|
2
|
+
|
|
3
|
+
export function validateEmail(value: string): string | undefined {
|
|
4
|
+
if (!value) return 'Email is required';
|
|
5
|
+
|
|
6
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
7
|
+
if (!emailRegex.test(value)) return 'Please enter a valid email address';
|
|
8
|
+
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function validatePassword(value: string): string | undefined {
|
|
13
|
+
if (!value) return 'Password is required';
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
passwordSchema.parse(value);
|
|
17
|
+
return undefined;
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if (error instanceof Error) return error.message;
|
|
20
|
+
return 'Password validation failed';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function validateRequired(fieldName: string) {
|
|
25
|
+
return (value: string): string | undefined => {
|
|
26
|
+
if (!value) return `${fieldName} is required`;
|
|
27
|
+
return undefined;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
|
|
3
|
+
import { prisma } from '@/lib/prisma';
|
|
4
|
+
import type {
|
|
5
|
+
SubscriptionRecord,
|
|
6
|
+
UpsertSubscriptionData,
|
|
7
|
+
UpdateSubscriptionStatusData,
|
|
8
|
+
} from '../types';
|
|
9
|
+
|
|
10
|
+
export async function findSubscriptionByUserId(
|
|
11
|
+
userId: string,
|
|
12
|
+
): Promise<SubscriptionRecord | null> {
|
|
13
|
+
return prisma.subscription.findUnique({ where: { userId } });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function findSubscriptionByStripeCustomerId(
|
|
17
|
+
stripeCustomerId: string,
|
|
18
|
+
): Promise<SubscriptionRecord | null> {
|
|
19
|
+
return prisma.subscription.findUnique({ where: { stripeCustomerId } });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function findSubscriptionByStripeSubscriptionId(
|
|
23
|
+
stripeSubscriptionId: string,
|
|
24
|
+
): Promise<SubscriptionRecord | null> {
|
|
25
|
+
return prisma.subscription.findUnique({ where: { stripeSubscriptionId } });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function upsertSubscription(
|
|
29
|
+
data: UpsertSubscriptionData,
|
|
30
|
+
): Promise<SubscriptionRecord> {
|
|
31
|
+
return prisma.subscription.upsert({
|
|
32
|
+
where: { userId: data.userId },
|
|
33
|
+
create: {
|
|
34
|
+
userId: data.userId,
|
|
35
|
+
stripeCustomerId: data.stripeCustomerId,
|
|
36
|
+
stripePriceId: data.stripePriceId ?? null,
|
|
37
|
+
stripeSubscriptionId: data.stripeSubscriptionId ?? null,
|
|
38
|
+
status: data.status,
|
|
39
|
+
currentPeriodEnd: data.currentPeriodEnd ?? null,
|
|
40
|
+
cancelAtPeriodEnd: data.cancelAtPeriodEnd ?? false,
|
|
41
|
+
},
|
|
42
|
+
update: {
|
|
43
|
+
stripeCustomerId: data.stripeCustomerId,
|
|
44
|
+
stripePriceId: data.stripePriceId,
|
|
45
|
+
stripeSubscriptionId: data.stripeSubscriptionId,
|
|
46
|
+
status: data.status,
|
|
47
|
+
currentPeriodEnd: data.currentPeriodEnd,
|
|
48
|
+
cancelAtPeriodEnd: data.cancelAtPeriodEnd,
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function updateSubscriptionStatus(
|
|
54
|
+
stripeSubscriptionId: string,
|
|
55
|
+
data: UpdateSubscriptionStatusData,
|
|
56
|
+
): Promise<SubscriptionRecord> {
|
|
57
|
+
return prisma.subscription.update({
|
|
58
|
+
where: { stripeSubscriptionId },
|
|
59
|
+
data: {
|
|
60
|
+
status: data.status,
|
|
61
|
+
stripePriceId: data.stripePriceId,
|
|
62
|
+
currentPeriodEnd: data.currentPeriodEnd,
|
|
63
|
+
cancelAtPeriodEnd: data.cancelAtPeriodEnd,
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export const SUBSCRIPTION_STATUS = {
|
|
2
|
+
active: 'active',
|
|
3
|
+
canceled: 'canceled',
|
|
4
|
+
incomplete: 'incomplete',
|
|
5
|
+
incompleteExpired: 'incomplete_expired',
|
|
6
|
+
pastDue: 'past_due',
|
|
7
|
+
trialing: 'trialing',
|
|
8
|
+
unpaid: 'unpaid',
|
|
9
|
+
paused: 'paused',
|
|
10
|
+
inactive: 'inactive',
|
|
11
|
+
} as const;
|
|
12
|
+
|
|
13
|
+
export type SubscriptionStatus = (typeof SUBSCRIPTION_STATUS)[keyof typeof SUBSCRIPTION_STATUS];
|
|
14
|
+
|
|
15
|
+
export interface SubscriptionRecord {
|
|
16
|
+
id: string;
|
|
17
|
+
userId: string;
|
|
18
|
+
stripeCustomerId: string;
|
|
19
|
+
stripePriceId: string | null;
|
|
20
|
+
stripeSubscriptionId: string | null;
|
|
21
|
+
status: string;
|
|
22
|
+
currentPeriodEnd: Date | null;
|
|
23
|
+
cancelAtPeriodEnd: boolean;
|
|
24
|
+
createdAt: Date;
|
|
25
|
+
updatedAt: Date;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface UpsertSubscriptionData {
|
|
29
|
+
userId: string;
|
|
30
|
+
stripeCustomerId: string;
|
|
31
|
+
stripePriceId?: string;
|
|
32
|
+
stripeSubscriptionId?: string;
|
|
33
|
+
status: string;
|
|
34
|
+
currentPeriodEnd?: Date;
|
|
35
|
+
cancelAtPeriodEnd?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface UpdateSubscriptionStatusData {
|
|
39
|
+
status: string;
|
|
40
|
+
stripePriceId?: string;
|
|
41
|
+
currentPeriodEnd?: Date;
|
|
42
|
+
cancelAtPeriodEnd?: boolean;
|
|
43
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
|
|
3
|
+
import { prisma } from '@/lib/prisma';
|
|
4
|
+
import type { FileRecord, CreateFileData, FileListParams } from '../types';
|
|
5
|
+
|
|
6
|
+
export async function createFileRecord(data: CreateFileData): Promise<FileRecord> {
|
|
7
|
+
return prisma.file.create({
|
|
8
|
+
data: {
|
|
9
|
+
userId: data.userId,
|
|
10
|
+
filename: data.filename,
|
|
11
|
+
url: data.url,
|
|
12
|
+
contentType: data.contentType,
|
|
13
|
+
size: data.size,
|
|
14
|
+
access: data.access ?? 'private',
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function getFileRecord(
|
|
20
|
+
fileId: string,
|
|
21
|
+
userId: string,
|
|
22
|
+
): Promise<FileRecord | null> {
|
|
23
|
+
return prisma.file.findFirst({
|
|
24
|
+
where: { id: fileId, userId },
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function deleteFileRecord(
|
|
29
|
+
fileId: string,
|
|
30
|
+
userId: string,
|
|
31
|
+
): Promise<FileRecord | null> {
|
|
32
|
+
const file = await prisma.file.findFirst({
|
|
33
|
+
where: { id: fileId, userId },
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
if (!file) return null;
|
|
37
|
+
|
|
38
|
+
await prisma.file.delete({ where: { id: fileId } });
|
|
39
|
+
return file;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function listUserFiles(params: FileListParams): Promise<FileRecord[]> {
|
|
43
|
+
return prisma.file.findMany({
|
|
44
|
+
where: { userId: params.userId },
|
|
45
|
+
orderBy: { createdAt: 'desc' },
|
|
46
|
+
take: params.limit ?? 50,
|
|
47
|
+
skip: params.offset ?? 0,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface FileRecord {
|
|
2
|
+
id: string;
|
|
3
|
+
userId: string;
|
|
4
|
+
filename: string;
|
|
5
|
+
url: string;
|
|
6
|
+
contentType: string;
|
|
7
|
+
size: number;
|
|
8
|
+
access: string;
|
|
9
|
+
createdAt: Date;
|
|
10
|
+
updatedAt: Date;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface CreateFileData {
|
|
14
|
+
userId: string;
|
|
15
|
+
filename: string;
|
|
16
|
+
url: string;
|
|
17
|
+
contentType: string;
|
|
18
|
+
size: number;
|
|
19
|
+
access?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface FileListParams {
|
|
23
|
+
userId: string;
|
|
24
|
+
limit?: number;
|
|
25
|
+
offset?: number;
|
|
26
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
const BRAND_PRIMARY = '#6366f1';
|
|
2
|
+
const TEXT_PRIMARY = '#111827';
|
|
3
|
+
const TEXT_SECONDARY = '#6b7280';
|
|
4
|
+
const BORDER_COLOR = '#e5e7eb';
|
|
5
|
+
const BG_COLOR = '#f9fafb';
|
|
6
|
+
|
|
7
|
+
interface EmailLayoutOptions {
|
|
8
|
+
appName: string;
|
|
9
|
+
previewText: string;
|
|
10
|
+
content: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function emailLayout({ appName, previewText, content }: EmailLayoutOptions): string {
|
|
14
|
+
return `<!DOCTYPE html>
|
|
15
|
+
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
|
|
16
|
+
<head>
|
|
17
|
+
<meta charset="utf-8" />
|
|
18
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
19
|
+
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
|
20
|
+
<title>${appName}</title>
|
|
21
|
+
<!--[if mso]>
|
|
22
|
+
<noscript>
|
|
23
|
+
<xml>
|
|
24
|
+
<o:OfficeDocumentSettings>
|
|
25
|
+
<o:PixelsPerInch>96</o:PixelsPerInch>
|
|
26
|
+
</o:OfficeDocumentSettings>
|
|
27
|
+
</xml>
|
|
28
|
+
</noscript>
|
|
29
|
+
<![endif]-->
|
|
30
|
+
<style>
|
|
31
|
+
body, table, td, a { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
|
|
32
|
+
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
|
|
33
|
+
img { -ms-interpolation-mode: bicubic; border: 0; height: auto; line-height: 100%; outline: none; text-decoration: none; }
|
|
34
|
+
body { margin: 0; padding: 0; width: 100% !important; }
|
|
35
|
+
</style>
|
|
36
|
+
</head>
|
|
37
|
+
<body style="margin: 0; padding: 0; background-color: ${BG_COLOR}; font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;">
|
|
38
|
+
<!-- Preview text (hidden) -->
|
|
39
|
+
<div style="display: none; max-height: 0; overflow: hidden; mso-hide: all;">
|
|
40
|
+
${previewText}
|
|
41
|
+
${' '.repeat(40)}
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<!-- Outer wrapper table -->
|
|
45
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color: ${BG_COLOR};">
|
|
46
|
+
<tr>
|
|
47
|
+
<td align="center" style="padding: 40px 16px;">
|
|
48
|
+
<!-- Inner content table -->
|
|
49
|
+
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width: 600px; width: 100%; background-color: #ffffff; border-radius: 8px; border: 1px solid ${BORDER_COLOR};">
|
|
50
|
+
<!-- Header -->
|
|
51
|
+
<tr>
|
|
52
|
+
<td style="padding: 32px 40px 0 40px;">
|
|
53
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
|
54
|
+
<tr>
|
|
55
|
+
<td style="font-size: 20px; font-weight: 700; color: ${TEXT_PRIMARY};">
|
|
56
|
+
${appName}
|
|
57
|
+
</td>
|
|
58
|
+
</tr>
|
|
59
|
+
</table>
|
|
60
|
+
</td>
|
|
61
|
+
</tr>
|
|
62
|
+
|
|
63
|
+
<!-- Divider -->
|
|
64
|
+
<tr>
|
|
65
|
+
<td style="padding: 16px 40px 0 40px;">
|
|
66
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
|
67
|
+
<tr>
|
|
68
|
+
<td style="border-top: 1px solid ${BORDER_COLOR};"></td>
|
|
69
|
+
</tr>
|
|
70
|
+
</table>
|
|
71
|
+
</td>
|
|
72
|
+
</tr>
|
|
73
|
+
|
|
74
|
+
<!-- Body content -->
|
|
75
|
+
<tr>
|
|
76
|
+
<td style="padding: 32px 40px; color: ${TEXT_PRIMARY}; font-size: 16px; line-height: 24px;">
|
|
77
|
+
${content}
|
|
78
|
+
</td>
|
|
79
|
+
</tr>
|
|
80
|
+
|
|
81
|
+
<!-- Footer -->
|
|
82
|
+
<tr>
|
|
83
|
+
<td style="padding: 0 40px 32px 40px;">
|
|
84
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
|
85
|
+
<tr>
|
|
86
|
+
<td style="border-top: 1px solid ${BORDER_COLOR}; padding-top: 20px; color: ${TEXT_SECONDARY}; font-size: 13px; line-height: 20px;">
|
|
87
|
+
This is an automated message, please do not reply.
|
|
88
|
+
</td>
|
|
89
|
+
</tr>
|
|
90
|
+
<tr>
|
|
91
|
+
<td style="color: ${TEXT_SECONDARY}; font-size: 13px; line-height: 20px; padding-top: 4px;">
|
|
92
|
+
© ${new Date().getFullYear()} ${appName}. All rights reserved.
|
|
93
|
+
</td>
|
|
94
|
+
</tr>
|
|
95
|
+
</table>
|
|
96
|
+
</td>
|
|
97
|
+
</tr>
|
|
98
|
+
</table>
|
|
99
|
+
</td>
|
|
100
|
+
</tr>
|
|
101
|
+
</table>
|
|
102
|
+
</body>
|
|
103
|
+
</html>`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function emailButton(href: string, label: string): string {
|
|
107
|
+
return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
|
108
|
+
<tr>
|
|
109
|
+
<td align="center" style="padding: 24px 0;">
|
|
110
|
+
<!--[if mso]>
|
|
111
|
+
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="${href}" style="height:48px;v-text-anchor:middle;width:220px;" arcsize="13%" fillcolor="${BRAND_PRIMARY}">
|
|
112
|
+
<w:anchorlock/>
|
|
113
|
+
<center style="color:#ffffff;font-family:system-ui,sans-serif;font-size:16px;font-weight:600;">${label}</center>
|
|
114
|
+
</v:roundrect>
|
|
115
|
+
<![endif]-->
|
|
116
|
+
<!--[if !mso]><!-->
|
|
117
|
+
<a href="${href}" target="_blank" style="display: inline-block; background-color: ${BRAND_PRIMARY}; color: #ffffff; font-size: 16px; font-weight: 600; text-decoration: none; padding: 12px 32px; border-radius: 6px; mso-hide: all;">${label}</a>
|
|
118
|
+
<!--<![endif]-->
|
|
119
|
+
</td>
|
|
120
|
+
</tr>
|
|
121
|
+
</table>`;
|
|
122
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { emailLayout, emailButton } from './base-layout';
|
|
2
|
+
|
|
3
|
+
interface PasswordResetEmailOptions {
|
|
4
|
+
appName: string;
|
|
5
|
+
resetUrl: string;
|
|
6
|
+
userName?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function passwordResetEmailHtml({ appName, resetUrl, userName }: PasswordResetEmailOptions): { html: string; text: string } {
|
|
10
|
+
const greeting = userName ? `Hi ${userName},` : 'Hi there,';
|
|
11
|
+
|
|
12
|
+
const content = `
|
|
13
|
+
<h1 style="margin: 0 0 16px 0; font-size: 24px; font-weight: 700; color: #111827;">Reset your password</h1>
|
|
14
|
+
<p style="margin: 0 0 8px 0;">${greeting}</p>
|
|
15
|
+
<p style="margin: 0 0 8px 0;">We received a request to reset the password for your ${appName} account.</p>
|
|
16
|
+
${emailButton(resetUrl, 'Reset Password')}
|
|
17
|
+
<p style="margin: 0 0 8px 0; font-size: 14px; color: #6b7280;">If the button doesn't work, copy and paste this link into your browser:</p>
|
|
18
|
+
<p style="margin: 0 0 16px 0; font-size: 14px; word-break: break-all; color: #6366f1;">${resetUrl}</p>
|
|
19
|
+
<p style="margin: 0 0 8px 0; font-size: 14px; color: #6b7280;">This link expires in 1 hour.</p>
|
|
20
|
+
<p style="margin: 0; font-size: 14px; color: #6b7280; font-style: italic;">If you didn't request this, you can safely ignore this email. Your password will not be changed.</p>`;
|
|
21
|
+
|
|
22
|
+
const html = emailLayout({
|
|
23
|
+
appName,
|
|
24
|
+
previewText: `Reset your ${appName} password`,
|
|
25
|
+
content,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const text = [
|
|
29
|
+
`Reset your password`,
|
|
30
|
+
``,
|
|
31
|
+
`${greeting}`,
|
|
32
|
+
``,
|
|
33
|
+
`We received a request to reset the password for your ${appName} account. Open this link to reset your password:`,
|
|
34
|
+
``,
|
|
35
|
+
resetUrl,
|
|
36
|
+
``,
|
|
37
|
+
`This link expires in 1 hour.`,
|
|
38
|
+
`If you didn't request this, you can safely ignore this email. Your password will not be changed.`,
|
|
39
|
+
].join('\n');
|
|
40
|
+
|
|
41
|
+
return { html, text };
|
|
42
|
+
}
|