@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,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'} · 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
|
+
}
|