@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.
Files changed (173) hide show
  1. package/package.json +2 -2
  2. package/template/.cursor/rules/composition-patterns.mdc +186 -0
  3. package/template/.cursor/rules/data-access.mdc +29 -0
  4. package/template/.cursor/rules/project-structure.mdc +34 -0
  5. package/template/.cursor/rules/security.mdc +25 -0
  6. package/template/.cursor/rules/testing.mdc +24 -0
  7. package/template/.cursor/rules/ui-conventions.mdc +29 -0
  8. package/template/.cursor/skills/add-api-route/SKILL.md +122 -0
  9. package/template/.cursor/skills/add-audit-log/SKILL.md +373 -0
  10. package/template/.cursor/skills/add-blog/SKILL.md +447 -0
  11. package/template/.cursor/skills/add-command-palette/SKILL.md +438 -0
  12. package/template/.cursor/skills/add-component/SKILL.md +158 -0
  13. package/template/.cursor/skills/add-crud-routes/SKILL.md +221 -0
  14. package/template/.cursor/skills/add-e2e-test/SKILL.md +227 -0
  15. package/template/.cursor/skills/add-error-boundary/SKILL.md +472 -0
  16. package/template/.cursor/skills/add-feature/SKILL.md +174 -0
  17. package/template/.cursor/skills/add-middleware/SKILL.md +135 -0
  18. package/template/.cursor/skills/add-page/SKILL.md +151 -0
  19. package/template/.cursor/skills/add-prisma-model/SKILL.md +148 -0
  20. package/template/.cursor/skills/add-protected-resource/SKILL.md +192 -0
  21. package/template/.cursor/skills/add-role/SKILL.md +156 -0
  22. package/template/.cursor/skills/add-server-action/SKILL.md +167 -0
  23. package/template/.cursor/skills/add-webhook/SKILL.md +192 -0
  24. package/template/.cursor/skills/build-complete-feature/SKILL.md +227 -0
  25. package/template/.cursor/skills/build-dashboard/SKILL.md +211 -0
  26. package/template/.cursor/skills/build-data-table/SKILL.md +283 -0
  27. package/template/.cursor/skills/build-form/SKILL.md +231 -0
  28. package/template/.cursor/skills/build-landing-page/SKILL.md +248 -0
  29. package/template/.cursor/skills/configure-ai/SKILL.md +617 -0
  30. package/template/.cursor/skills/configure-analytics/SKILL.md +413 -0
  31. package/template/.cursor/skills/configure-dark-mode/SKILL.md +309 -0
  32. package/template/.cursor/skills/configure-email/SKILL.md +170 -0
  33. package/template/.cursor/skills/configure-email-verification/SKILL.md +333 -0
  34. package/template/.cursor/skills/configure-feature-flags/SKILL.md +361 -0
  35. package/template/.cursor/skills/configure-i18n/SKILL.md +518 -0
  36. package/template/.cursor/skills/configure-jobs/SKILL.md +500 -0
  37. package/template/.cursor/skills/configure-magic-links/SKILL.md +385 -0
  38. package/template/.cursor/skills/configure-multi-tenancy/SKILL.md +611 -0
  39. package/template/.cursor/skills/configure-notifications/SKILL.md +569 -0
  40. package/template/.cursor/skills/configure-oauth/SKILL.md +217 -0
  41. package/template/.cursor/skills/configure-onboarding/SKILL.md +483 -0
  42. package/template/.cursor/skills/configure-payments/SKILL.md +243 -0
  43. package/template/.cursor/skills/configure-realtime/SKILL.md +733 -0
  44. package/template/.cursor/skills/configure-search/SKILL.md +581 -0
  45. package/template/.cursor/skills/configure-storage/SKILL.md +273 -0
  46. package/template/.cursor/skills/configure-two-factor/SKILL.md +518 -0
  47. package/template/.cursor/skills/create-execution-plan/SKILL.md +204 -0
  48. package/template/.cursor/skills/create-seed/SKILL.md +191 -0
  49. package/template/.cursor/skills/deploy-to-vercel/SKILL.md +300 -0
  50. package/template/.cursor/skills/design-tokens/SKILL.md +138 -0
  51. package/template/.cursor/skills/mars-capture-conversation-context/SKILL.md +119 -0
  52. package/template/.cursor/skills/setup-billing/SKILL.md +322 -0
  53. package/template/.cursor/skills/setup-project/SKILL.md +104 -0
  54. package/template/.cursor/skills/setup-teams/SKILL.md +682 -0
  55. package/template/.cursor/skills/test-api-route/SKILL.md +219 -0
  56. package/template/.cursor/skills/update-architecture-docs/SKILL.md +99 -0
  57. package/template/AGENTS.md +104 -0
  58. package/template/ARCHITECTURE.md +102 -0
  59. package/template/docs/QUALITY_SCORE.md +20 -0
  60. package/template/docs/design-docs/conversation-as-system-record.md +70 -0
  61. package/template/docs/design-docs/core-beliefs.md +43 -0
  62. package/template/docs/design-docs/index.md +8 -0
  63. package/template/docs/exec-plans/active/.gitkeep +0 -0
  64. package/template/docs/exec-plans/completed/.gitkeep +0 -0
  65. package/template/docs/exec-plans/tech-debt.md +7 -0
  66. package/template/docs/generated/.gitkeep +0 -0
  67. package/template/docs/product-specs/index.md +7 -0
  68. package/template/docs/references/index.md +18 -0
  69. package/template/e2e/api.spec.ts +20 -0
  70. package/template/e2e/auth.spec.ts +24 -0
  71. package/template/e2e/public.spec.ts +25 -0
  72. package/template/eslint.config.mjs +24 -0
  73. package/template/next-env.d.ts +6 -0
  74. package/template/next.config.ts +45 -0
  75. package/template/package.json +80 -0
  76. package/template/playwright.config.ts +31 -0
  77. package/template/postcss.config.mjs +8 -0
  78. package/template/prisma/generated/prisma/browser.ts +49 -0
  79. package/template/prisma/generated/prisma/client.ts +73 -0
  80. package/template/prisma/generated/prisma/commonInputTypes.ts +406 -0
  81. package/template/prisma/generated/prisma/enums.ts +15 -0
  82. package/template/prisma/generated/prisma/internal/class.ts +254 -0
  83. package/template/prisma/generated/prisma/internal/prismaNamespace.ts +1240 -0
  84. package/template/prisma/generated/prisma/internal/prismaNamespaceBrowser.ts +190 -0
  85. package/template/prisma/generated/prisma/models/Account.ts +1543 -0
  86. package/template/prisma/generated/prisma/models/File.ts +1529 -0
  87. package/template/prisma/generated/prisma/models/Session.ts +1415 -0
  88. package/template/prisma/generated/prisma/models/Subscription.ts +1455 -0
  89. package/template/prisma/generated/prisma/models/User.ts +2235 -0
  90. package/template/prisma/generated/prisma/models/VerificationToken.ts +1099 -0
  91. package/template/prisma/generated/prisma/models.ts +17 -0
  92. package/template/prisma/schema/auth.prisma +69 -0
  93. package/template/prisma/schema/base.prisma +8 -0
  94. package/template/prisma/schema/file.prisma +15 -0
  95. package/template/prisma/schema/subscription.prisma +17 -0
  96. package/template/prisma.config.ts +13 -0
  97. package/template/scripts/check-architecture.ts +221 -0
  98. package/template/scripts/check-doc-freshness.ts +242 -0
  99. package/template/scripts/ensure-db.mjs +291 -0
  100. package/template/scripts/generate-docs.ts +143 -0
  101. package/template/scripts/generate-env-example.ts +89 -0
  102. package/template/scripts/seed.ts +56 -0
  103. package/template/scripts/update-quality-score.ts +263 -0
  104. package/template/src/__tests__/architecture.test.ts +114 -0
  105. package/template/src/app/(auth)/forgotten-password/page.tsx +92 -0
  106. package/template/src/app/(auth)/layout.tsx +11 -0
  107. package/template/src/app/(auth)/register/page.tsx +162 -0
  108. package/template/src/app/(auth)/reset-password/page.tsx +109 -0
  109. package/template/src/app/(auth)/sign-in/page.tsx +122 -0
  110. package/template/src/app/(auth)/verify/[token]/page.tsx +87 -0
  111. package/template/src/app/(auth)/verify/page.tsx +56 -0
  112. package/template/src/app/(protected)/admin/page.tsx +108 -0
  113. package/template/src/app/(protected)/dashboard/loading.tsx +20 -0
  114. package/template/src/app/(protected)/dashboard/page.tsx +22 -0
  115. package/template/src/app/(protected)/layout.tsx +262 -0
  116. package/template/src/app/(protected)/settings/page.tsx +370 -0
  117. package/template/src/app/api/auth/forgot/route.ts +63 -0
  118. package/template/src/app/api/auth/login/route.ts +121 -0
  119. package/template/src/app/api/auth/logout/route.ts +19 -0
  120. package/template/src/app/api/auth/me/route.ts +30 -0
  121. package/template/src/app/api/auth/reset/route.ts +45 -0
  122. package/template/src/app/api/auth/signup/route.ts +85 -0
  123. package/template/src/app/api/auth/verify/route.ts +46 -0
  124. package/template/src/app/api/csrf/route.ts +12 -0
  125. package/template/src/app/api/health/route.ts +10 -0
  126. package/template/src/app/api/protected/admin/users/route.ts +24 -0
  127. package/template/src/app/api/protected/billing/checkout/route.ts +83 -0
  128. package/template/src/app/api/protected/billing/portal/route.ts +39 -0
  129. package/template/src/app/api/protected/files/[fileId]/route.ts +86 -0
  130. package/template/src/app/api/protected/files/upload/route.ts +64 -0
  131. package/template/src/app/api/protected/user/password/route.ts +63 -0
  132. package/template/src/app/api/protected/user/profile/route.ts +35 -0
  133. package/template/src/app/api/protected/user/sessions/[sessionId]/route.ts +33 -0
  134. package/template/src/app/api/protected/user/sessions/route.ts +22 -0
  135. package/template/src/app/api/readiness/route.ts +15 -0
  136. package/template/src/app/api/webhooks/stripe/route.ts +166 -0
  137. package/template/src/app/error.tsx +33 -0
  138. package/template/src/app/layout.tsx +29 -0
  139. package/template/src/app/not-found.tsx +20 -0
  140. package/template/src/app/page.tsx +136 -0
  141. package/template/src/app/privacy/page.tsx +178 -0
  142. package/template/src/app/providers.tsx +8 -0
  143. package/template/src/app/terms/page.tsx +139 -0
  144. package/template/src/config/app.config.ts +70 -0
  145. package/template/src/config/routes.ts +17 -0
  146. package/template/src/features/admin/index.ts +11 -0
  147. package/template/src/features/admin/permissions.ts +64 -0
  148. package/template/src/features/auth/context/AuthContext.tsx +96 -0
  149. package/template/src/features/auth/context/index.ts +2 -0
  150. package/template/src/features/auth/index.ts +3 -0
  151. package/template/src/features/auth/server/consent.ts +66 -0
  152. package/template/src/features/auth/server/session-revocation.ts +20 -0
  153. package/template/src/features/auth/server/sessions.ts +66 -0
  154. package/template/src/features/auth/server/user.ts +166 -0
  155. package/template/src/features/auth/types.ts +19 -0
  156. package/template/src/features/auth/validators.ts +29 -0
  157. package/template/src/features/billing/server/index.ts +66 -0
  158. package/template/src/features/billing/types.ts +43 -0
  159. package/template/src/features/uploads/server/index.ts +49 -0
  160. package/template/src/features/uploads/types.ts +26 -0
  161. package/template/src/lib/core/email/templates/base-layout.ts +122 -0
  162. package/template/src/lib/core/email/templates/index.ts +4 -0
  163. package/template/src/lib/core/email/templates/password-reset-email.ts +42 -0
  164. package/template/src/lib/core/email/templates/verification-email.ts +41 -0
  165. package/template/src/lib/core/email/templates/welcome-email.ts +40 -0
  166. package/template/src/lib/mars.ts +56 -0
  167. package/template/src/lib/prisma.ts +19 -0
  168. package/template/src/proxy.ts +92 -0
  169. package/template/src/styles/brand.css +17 -0
  170. package/template/src/styles/globals.css +6 -0
  171. package/template/tsconfig.json +59 -0
  172. package/template/vitest.config.ts +41 -0
  173. package/template/vitest.setup.ts +24 -0
@@ -0,0 +1,262 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react';
4
+ import { usePathname, useRouter } from 'next/navigation';
5
+ import Link from 'next/link';
6
+ import { Avatar, Text } from '@mars-stack/ui';
7
+ import { useAuth } from '@/features/auth/context/AuthContext';
8
+ import { routes } from '@/config/routes';
9
+ import { appConfig } from '@/config/app.config';
10
+
11
+ const NAV_ITEMS = [
12
+ { label: 'Dashboard', href: routes.dashboard },
13
+ { label: 'Settings', href: routes.settings },
14
+ ] as const;
15
+
16
+ function NavLink({ href, label, active }: { href: string; label: string; active: boolean }) {
17
+ return (
18
+ <Link
19
+ href={href}
20
+ className={
21
+ active
22
+ ? 'text-sm font-semibold text-text-primary border-b-2 border-brand-primary pb-0.5'
23
+ : 'text-sm font-medium text-text-secondary hover:text-text-primary transition-colors duration-150'
24
+ }
25
+ >
26
+ {label}
27
+ </Link>
28
+ );
29
+ }
30
+
31
+ function MobileNavLink({
32
+ href,
33
+ label,
34
+ active,
35
+ onClick,
36
+ }: {
37
+ href: string;
38
+ label: string;
39
+ active: boolean;
40
+ onClick: () => void;
41
+ }) {
42
+ return (
43
+ <Link
44
+ href={href}
45
+ onClick={onClick}
46
+ className={
47
+ active
48
+ ? 'block px-3 py-2 text-sm font-semibold text-text-primary bg-ghost-active rounded-lg'
49
+ : 'block px-3 py-2 text-sm font-medium text-text-secondary hover:text-text-primary hover:bg-ghost-hover rounded-lg transition-colors duration-150'
50
+ }
51
+ >
52
+ {label}
53
+ </Link>
54
+ );
55
+ }
56
+
57
+ function UserMenu() {
58
+ const { user, logout } = useAuth();
59
+ const router = useRouter();
60
+ const [open, setOpen] = useState(false);
61
+ const menuRef = useRef<HTMLDivElement>(null);
62
+
63
+ const handleClickOutside = useCallback((event: MouseEvent) => {
64
+ if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
65
+ setOpen(false);
66
+ }
67
+ }, []);
68
+
69
+ useEffect(() => {
70
+ if (open) {
71
+ document.addEventListener('mousedown', handleClickOutside);
72
+ }
73
+ return () => document.removeEventListener('mousedown', handleClickOutside);
74
+ }, [open, handleClickOutside]);
75
+
76
+ const handleLogout = async () => {
77
+ setOpen(false);
78
+ await logout();
79
+ router.push(routes.signIn);
80
+ };
81
+
82
+ if (!user) return null;
83
+
84
+ return (
85
+ <div ref={menuRef} className="relative">
86
+ <button
87
+ type="button"
88
+ onClick={() => setOpen((prev) => !prev)}
89
+ className="flex items-center gap-2 rounded-full p-1 hover:bg-ghost-hover transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-ring-focus"
90
+ aria-expanded={open}
91
+ aria-haspopup="true"
92
+ >
93
+ <Avatar name={user.name} size="sm" />
94
+ <span className="hidden sm:block text-sm font-medium text-text-primary max-w-[120px] truncate">
95
+ {user.name}
96
+ </span>
97
+ <ChevronIcon open={open} />
98
+ </button>
99
+
100
+ {open && (
101
+ <div className="absolute right-0 mt-2 w-56 rounded-xl border border-border-default bg-surface-card shadow-lg py-1 z-50">
102
+ <div className="px-4 py-3 border-b border-border-default">
103
+ <Text.Paragraph noMargin className="text-sm! font-semibold! text-text-primary! mb-0!">
104
+ {user.name}
105
+ </Text.Paragraph>
106
+ <Text.Paragraph noMargin className="text-xs! text-text-muted! mb-0!">
107
+ {user.role}
108
+ </Text.Paragraph>
109
+ </div>
110
+
111
+ <div className="py-1">
112
+ <Link
113
+ href={routes.settings}
114
+ onClick={() => setOpen(false)}
115
+ className="flex items-center gap-2 px-4 py-2 text-sm text-text-secondary hover:bg-ghost-hover hover:text-text-primary transition-colors duration-150"
116
+ >
117
+ <SettingsIcon />
118
+ Settings
119
+ </Link>
120
+ </div>
121
+
122
+ <div className="border-t border-border-default py-1">
123
+ <button
124
+ type="button"
125
+ onClick={handleLogout}
126
+ className="flex w-full items-center gap-2 px-4 py-2 text-sm text-text-error hover:bg-ghost-hover transition-colors duration-150"
127
+ >
128
+ <LogoutIcon />
129
+ Log out
130
+ </button>
131
+ </div>
132
+ </div>
133
+ )}
134
+ </div>
135
+ );
136
+ }
137
+
138
+ function HamburgerIcon({ open }: { open: boolean }) {
139
+ return (
140
+ <svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
141
+ {open ? (
142
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
143
+ ) : (
144
+ <path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
145
+ )}
146
+ </svg>
147
+ );
148
+ }
149
+
150
+ function ChevronIcon({ open }: { open: boolean }) {
151
+ return (
152
+ <svg
153
+ className={`hidden sm:block h-4 w-4 text-text-muted transition-transform duration-150 ${open ? 'rotate-180' : ''}`}
154
+ fill="none"
155
+ viewBox="0 0 24 24"
156
+ strokeWidth={2}
157
+ stroke="currentColor"
158
+ >
159
+ <path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
160
+ </svg>
161
+ );
162
+ }
163
+
164
+ function SettingsIcon() {
165
+ return (
166
+ <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
167
+ <path
168
+ strokeLinecap="round"
169
+ strokeLinejoin="round"
170
+ d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 010 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 010-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28z"
171
+ />
172
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
173
+ </svg>
174
+ );
175
+ }
176
+
177
+ function LogoutIcon() {
178
+ return (
179
+ <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
180
+ <path
181
+ strokeLinecap="round"
182
+ strokeLinejoin="round"
183
+ d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9"
184
+ />
185
+ </svg>
186
+ );
187
+ }
188
+
189
+ export default function ProtectedLayout({ children }: Readonly<{ children: React.ReactNode }>) {
190
+ const pathname = usePathname();
191
+ const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
192
+
193
+ useEffect(() => {
194
+ setMobileMenuOpen(false);
195
+ }, [pathname]);
196
+
197
+ return (
198
+ <div className="min-h-screen bg-surface-background">
199
+ <nav className="sticky top-0 z-40 border-b border-border-default bg-surface-primary/80 backdrop-blur-lg">
200
+ <div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
201
+ <div className="flex h-16 items-center justify-between">
202
+ {/* Logo */}
203
+ <div className="flex items-center gap-8">
204
+ <Link href={routes.dashboard} className="flex items-center gap-2">
205
+ <Text.H4 noMargin className="mb-0!">
206
+ {appConfig.name}
207
+ </Text.H4>
208
+ </Link>
209
+
210
+ {/* Desktop nav */}
211
+ <div className="hidden md:flex items-center gap-6">
212
+ {NAV_ITEMS.map((item) => (
213
+ <NavLink key={item.href} href={item.href} label={item.label} active={pathname === item.href} />
214
+ ))}
215
+ </div>
216
+ </div>
217
+
218
+ {/* Right side */}
219
+ <div className="flex items-center gap-3">
220
+ <div className="hidden md:block">
221
+ <UserMenu />
222
+ </div>
223
+
224
+ {/* Mobile hamburger */}
225
+ <button
226
+ type="button"
227
+ onClick={() => setMobileMenuOpen((prev) => !prev)}
228
+ className="md:hidden inline-flex items-center justify-center rounded-lg p-2 text-text-secondary hover:bg-ghost-hover hover:text-text-primary transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-ring-focus"
229
+ aria-expanded={mobileMenuOpen}
230
+ aria-label="Toggle navigation menu"
231
+ >
232
+ <HamburgerIcon open={mobileMenuOpen} />
233
+ </button>
234
+ </div>
235
+ </div>
236
+ </div>
237
+
238
+ {/* Mobile menu */}
239
+ {mobileMenuOpen && (
240
+ <div className="md:hidden border-t border-border-default bg-surface-primary">
241
+ <div className="space-y-1 px-4 py-3">
242
+ {NAV_ITEMS.map((item) => (
243
+ <MobileNavLink
244
+ key={item.href}
245
+ href={item.href}
246
+ label={item.label}
247
+ active={pathname === item.href}
248
+ onClick={() => setMobileMenuOpen(false)}
249
+ />
250
+ ))}
251
+ </div>
252
+ <div className="border-t border-border-default px-4 py-3">
253
+ <UserMenu />
254
+ </div>
255
+ </div>
256
+ )}
257
+ </nav>
258
+
259
+ <main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">{children}</main>
260
+ </div>
261
+ );
262
+ }
@@ -0,0 +1,370 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback, type FormEvent } from 'react';
4
+ import { useAuth } from '@/features/auth/context/AuthContext';
5
+ import { Card } from '@mars-stack/ui';
6
+ import { FormField, Input, Button, Spinner } from '@mars-stack/ui';
7
+
8
+ type FormStatus = { type: 'success' | 'error'; message: string } | null;
9
+
10
+ interface SessionEntry {
11
+ id: string;
12
+ ipAddress: string | null;
13
+ userAgent: string | null;
14
+ createdAt: string;
15
+ expiresAt: string;
16
+ }
17
+
18
+ function parseUserAgent(ua: string | null): string {
19
+ if (!ua || ua === 'unknown') return 'Unknown device';
20
+
21
+ const browser =
22
+ ua.match(/(?:Chrome|Firefox|Safari|Edge|Opera|Brave)\/[\d.]+/)?.[0] ??
23
+ 'Unknown browser';
24
+
25
+ let os = 'Unknown OS';
26
+ if (ua.includes('Windows')) os = 'Windows';
27
+ else if (ua.includes('Mac OS')) os = 'macOS';
28
+ else if (ua.includes('Linux')) os = 'Linux';
29
+ else if (ua.includes('Android')) os = 'Android';
30
+ else if (ua.includes('iPhone') || ua.includes('iPad')) os = 'iOS';
31
+
32
+ return `${browser} on ${os}`;
33
+ }
34
+
35
+ export default function Settings() {
36
+ const { user, isLoading: authLoading, updateUser } = useAuth();
37
+
38
+ const [name, setName] = useState('');
39
+ const [nameInitialized, setNameInitialized] = useState(false);
40
+ const [nameStatus, setNameStatus] = useState<FormStatus>(null);
41
+ const [nameSaving, setNameSaving] = useState(false);
42
+
43
+ const [currentPassword, setCurrentPassword] = useState('');
44
+ const [newPassword, setNewPassword] = useState('');
45
+ const [confirmPassword, setConfirmPassword] = useState('');
46
+ const [passwordStatus, setPasswordStatus] = useState<FormStatus>(null);
47
+ const [passwordSaving, setPasswordSaving] = useState(false);
48
+
49
+ const [sessions, setSessions] = useState<SessionEntry[]>([]);
50
+ const [sessionsLoading, setSessionsLoading] = useState(true);
51
+ const [sessionsStatus, setSessionsStatus] = useState<FormStatus>(null);
52
+ const [revokingId, setRevokingId] = useState<string | null>(null);
53
+ const [revokingAll, setRevokingAll] = useState(false);
54
+
55
+ const fetchSessions = useCallback(async () => {
56
+ try {
57
+ const response = await fetch('/api/protected/user/sessions', {
58
+ credentials: 'include',
59
+ });
60
+ if (response.ok) {
61
+ const data = await response.json();
62
+ setSessions(data.sessions);
63
+ }
64
+ } catch {
65
+ setSessionsStatus({ type: 'error', message: 'Failed to load sessions.' });
66
+ } finally {
67
+ setSessionsLoading(false);
68
+ }
69
+ }, []);
70
+
71
+ useEffect(() => {
72
+ if (user) {
73
+ fetchSessions();
74
+ }
75
+ }, [user, fetchSessions]);
76
+
77
+ if (!nameInitialized && user?.name) {
78
+ setName(user.name);
79
+ setNameInitialized(true);
80
+ }
81
+
82
+ if (authLoading) {
83
+ return (
84
+ <div className="flex min-h-[400px] items-center justify-center">
85
+ <Spinner size="lg" />
86
+ </div>
87
+ );
88
+ }
89
+
90
+ if (!user) {
91
+ return null;
92
+ }
93
+
94
+ async function handleNameSubmit(event: FormEvent<HTMLFormElement>) {
95
+ event.preventDefault();
96
+ setNameStatus(null);
97
+ setNameSaving(true);
98
+
99
+ try {
100
+ const response = await fetch('/api/protected/user/profile', {
101
+ method: 'PATCH',
102
+ headers: { 'Content-Type': 'application/json' },
103
+ credentials: 'include',
104
+ body: JSON.stringify({ name: name.trim() }),
105
+ });
106
+
107
+ const data = await response.json();
108
+
109
+ if (!response.ok) {
110
+ setNameStatus({ type: 'error', message: data.error || 'Failed to update name' });
111
+ return;
112
+ }
113
+
114
+ updateUser({ name: data.user.name });
115
+ setNameStatus({ type: 'success', message: 'Display name updated.' });
116
+ } catch {
117
+ setNameStatus({ type: 'error', message: 'An unexpected error occurred.' });
118
+ } finally {
119
+ setNameSaving(false);
120
+ }
121
+ }
122
+
123
+ async function handleRevokeSession(sessionId: string) {
124
+ setRevokingId(sessionId);
125
+ setSessionsStatus(null);
126
+ try {
127
+ const response = await fetch(`/api/protected/user/sessions/${sessionId}`, {
128
+ method: 'DELETE',
129
+ credentials: 'include',
130
+ });
131
+ if (!response.ok) {
132
+ const data = await response.json();
133
+ setSessionsStatus({ type: 'error', message: data.error || 'Failed to revoke session.' });
134
+ return;
135
+ }
136
+ setSessions((prev) => prev.filter((s) => s.id !== sessionId));
137
+ setSessionsStatus({ type: 'success', message: 'Session revoked.' });
138
+ } catch {
139
+ setSessionsStatus({ type: 'error', message: 'An unexpected error occurred.' });
140
+ } finally {
141
+ setRevokingId(null);
142
+ }
143
+ }
144
+
145
+ async function handleRevokeAllSessions() {
146
+ setRevokingAll(true);
147
+ setSessionsStatus(null);
148
+ try {
149
+ const response = await fetch('/api/protected/user/sessions', {
150
+ method: 'DELETE',
151
+ credentials: 'include',
152
+ });
153
+ if (!response.ok) {
154
+ const data = await response.json();
155
+ setSessionsStatus({ type: 'error', message: data.error || 'Failed to revoke sessions.' });
156
+ return;
157
+ }
158
+ setSessions([]);
159
+ setSessionsStatus({ type: 'success', message: 'All sessions revoked.' });
160
+ } catch {
161
+ setSessionsStatus({ type: 'error', message: 'An unexpected error occurred.' });
162
+ } finally {
163
+ setRevokingAll(false);
164
+ }
165
+ }
166
+
167
+ async function handlePasswordSubmit(event: FormEvent<HTMLFormElement>) {
168
+ event.preventDefault();
169
+ setPasswordStatus(null);
170
+
171
+ if (newPassword !== confirmPassword) {
172
+ setPasswordStatus({ type: 'error', message: 'New passwords do not match.' });
173
+ return;
174
+ }
175
+
176
+ setPasswordSaving(true);
177
+
178
+ try {
179
+ const response = await fetch('/api/protected/user/password', {
180
+ method: 'POST',
181
+ headers: { 'Content-Type': 'application/json' },
182
+ credentials: 'include',
183
+ body: JSON.stringify({ currentPassword, newPassword }),
184
+ });
185
+
186
+ const data = await response.json();
187
+
188
+ if (!response.ok) {
189
+ setPasswordStatus({ type: 'error', message: data.error || 'Failed to change password' });
190
+ return;
191
+ }
192
+
193
+ setCurrentPassword('');
194
+ setNewPassword('');
195
+ setConfirmPassword('');
196
+ setPasswordStatus({ type: 'success', message: 'Password changed successfully.' });
197
+ } catch {
198
+ setPasswordStatus({ type: 'error', message: 'An unexpected error occurred.' });
199
+ } finally {
200
+ setPasswordSaving(false);
201
+ }
202
+ }
203
+
204
+ return (
205
+ <div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8">
206
+ <h1 className="text-3xl font-bold text-text-primary">Settings</h1>
207
+ <p className="mt-2 text-text-secondary">Manage your account settings.</p>
208
+
209
+ <div className="mt-8 space-y-6">
210
+ <Card>
211
+ <h3 className="text-lg font-semibold text-text-primary">Display Name</h3>
212
+ <form onSubmit={handleNameSubmit} className="mt-4 space-y-4">
213
+ <FormField label="Name" htmlFor="display-name">
214
+ <Input
215
+ id="display-name"
216
+ type="text"
217
+ value={name}
218
+ onChange={(e) => setName(e.target.value)}
219
+ placeholder="Your display name"
220
+ required
221
+ fullWidth
222
+ />
223
+ </FormField>
224
+
225
+ {nameStatus && (
226
+ <p
227
+ className={
228
+ nameStatus.type === 'success' ? 'text-sm text-text-success' : 'text-sm text-text-error'
229
+ }
230
+ >
231
+ {nameStatus.message}
232
+ </p>
233
+ )}
234
+
235
+ <Button type="submit" loading={nameSaving} disabled={!name.trim()}>
236
+ Save Name
237
+ </Button>
238
+ </form>
239
+ </Card>
240
+
241
+ <Card>
242
+ <h3 className="text-lg font-semibold text-text-primary">Change Password</h3>
243
+ <form onSubmit={handlePasswordSubmit} className="mt-4 space-y-4">
244
+ <FormField label="Current Password" htmlFor="current-password">
245
+ <Input
246
+ id="current-password"
247
+ type="password"
248
+ value={currentPassword}
249
+ onChange={(e) => setCurrentPassword(e.target.value)}
250
+ placeholder="Enter current password"
251
+ showPasswordToggle
252
+ required
253
+ fullWidth
254
+ />
255
+ </FormField>
256
+
257
+ <FormField label="New Password" htmlFor="new-password">
258
+ <Input
259
+ id="new-password"
260
+ type="password"
261
+ value={newPassword}
262
+ onChange={(e) => setNewPassword(e.target.value)}
263
+ placeholder="Enter new password"
264
+ showPasswordToggle
265
+ required
266
+ fullWidth
267
+ />
268
+ </FormField>
269
+
270
+ <FormField label="Confirm New Password" htmlFor="confirm-password">
271
+ <Input
272
+ id="confirm-password"
273
+ type="password"
274
+ value={confirmPassword}
275
+ onChange={(e) => setConfirmPassword(e.target.value)}
276
+ placeholder="Re-enter new password"
277
+ showPasswordToggle
278
+ required
279
+ fullWidth
280
+ />
281
+ </FormField>
282
+
283
+ {passwordStatus && (
284
+ <p
285
+ className={
286
+ passwordStatus.type === 'success'
287
+ ? 'text-sm text-text-success'
288
+ : 'text-sm text-text-error'
289
+ }
290
+ >
291
+ {passwordStatus.message}
292
+ </p>
293
+ )}
294
+
295
+ <Button
296
+ type="submit"
297
+ loading={passwordSaving}
298
+ disabled={!currentPassword || !newPassword || !confirmPassword}
299
+ >
300
+ Change Password
301
+ </Button>
302
+ </form>
303
+ </Card>
304
+
305
+ <Card>
306
+ <div className="flex items-center justify-between">
307
+ <h3 className="text-lg font-semibold text-text-primary">Active Sessions</h3>
308
+ {sessions.length > 1 && (
309
+ <Button
310
+ variant="subtle"
311
+ size="sm"
312
+ loading={revokingAll}
313
+ onClick={handleRevokeAllSessions}
314
+ >
315
+ Revoke All
316
+ </Button>
317
+ )}
318
+ </div>
319
+
320
+ {sessionsStatus && (
321
+ <p
322
+ className={
323
+ sessionsStatus.type === 'success'
324
+ ? 'mt-2 text-sm text-text-success'
325
+ : 'mt-2 text-sm text-text-error'
326
+ }
327
+ >
328
+ {sessionsStatus.message}
329
+ </p>
330
+ )}
331
+
332
+ <div className="mt-4 space-y-3">
333
+ {sessionsLoading ? (
334
+ <div className="flex justify-center py-4">
335
+ <Spinner size="md" />
336
+ </div>
337
+ ) : sessions.length === 0 ? (
338
+ <p className="text-sm text-text-secondary">No active sessions.</p>
339
+ ) : (
340
+ sessions.map((session) => (
341
+ <div
342
+ key={session.id}
343
+ className="flex items-center justify-between rounded-lg border border-border-default p-3"
344
+ >
345
+ <div className="min-w-0 flex-1">
346
+ <p className="truncate text-sm font-medium text-text-primary">
347
+ {parseUserAgent(session.userAgent)}
348
+ </p>
349
+ <p className="text-xs text-text-secondary">
350
+ IP: {session.ipAddress ?? 'Unknown'} &middot; Created{' '}
351
+ {new Date(session.createdAt).toLocaleDateString()}
352
+ </p>
353
+ </div>
354
+ <Button
355
+ variant="subtle"
356
+ size="sm"
357
+ loading={revokingId === session.id}
358
+ onClick={() => handleRevokeSession(session.id)}
359
+ >
360
+ Revoke
361
+ </Button>
362
+ </div>
363
+ ))
364
+ )}
365
+ </div>
366
+ </Card>
367
+ </div>
368
+ </div>
369
+ );
370
+ }
@@ -0,0 +1,63 @@
1
+ import { prisma } from '@/lib/prisma';
2
+ import { sendEmail, handleApiError, getBaseUrl } from '@/lib/mars';
3
+ import { checkRateLimit, getClientIP, RATE_LIMITS, rateLimitResponse } from '@mars-stack/core/rate-limit';
4
+ import { hashPasswordResetToken } from '@mars-stack/core/auth/reset-token';
5
+ import { findUserByEmailPublic } from '@/features/auth/server/user';
6
+ import { buildPasswordResetUrl } from '@mars-stack/core/auth/link-utils';
7
+ import { apiSchemas } from '@mars-stack/core/auth/validation';
8
+ import { passwordResetEmailHtml } from '@/lib/core/email/templates';
9
+ import { appConfig } from '@/config/app.config';
10
+ import { randomBytes } from 'crypto';
11
+ import { NextResponse } from 'next/server';
12
+
13
+ export async function POST(request: Request) {
14
+ const ip = getClientIP(request);
15
+ const rateLimit = await checkRateLimit(ip, RATE_LIMITS.forgotPassword);
16
+ if (!rateLimit.success) return rateLimitResponse(rateLimit.resetAt);
17
+
18
+ try {
19
+ const body = await request.json();
20
+ const { email } = apiSchemas.forgotPassword.parse(body);
21
+
22
+ const user = await findUserByEmailPublic(email);
23
+
24
+ if (!user) {
25
+ return NextResponse.json(
26
+ { message: 'If an account exists, a reset email will be sent' },
27
+ { status: 200 },
28
+ );
29
+ }
30
+
31
+ await prisma.verificationToken.deleteMany({ where: { identifier: email } });
32
+
33
+ const rawToken = randomBytes(32).toString('hex');
34
+ const tokenHash = await hashPasswordResetToken(rawToken);
35
+ const expires = new Date(Date.now() + 3600000);
36
+
37
+ await prisma.verificationToken.create({
38
+ data: { identifier: email, token: tokenHash, expires },
39
+ });
40
+
41
+ const baseUrl = getBaseUrl();
42
+ const resetUrl = buildPasswordResetUrl({ baseUrl, token: rawToken, email });
43
+ const { html, text } = passwordResetEmailHtml({
44
+ appName: appConfig.name,
45
+ resetUrl,
46
+ userName: user.name || undefined,
47
+ });
48
+
49
+ await sendEmail({
50
+ to: email,
51
+ subject: `Reset Your Password - ${appConfig.name}`,
52
+ html,
53
+ text,
54
+ });
55
+
56
+ return NextResponse.json(
57
+ { message: 'If an account exists, a reset email will be sent' },
58
+ { status: 200 },
59
+ );
60
+ } catch (error) {
61
+ return handleApiError(error, { endpoint: '/api/auth/forgot', fallbackMessage: 'Failed to process password reset request' });
62
+ }
63
+ }