@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,109 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCSRF, usePasswordStrength } from '@mars-stack/core/auth/hooks';
|
|
4
|
+
import { formSchemas } from '@mars-stack/core/auth/validation';
|
|
5
|
+
import { routes } from '@/config/routes';
|
|
6
|
+
import { useZodForm } from '@mars-stack/ui/hooks';
|
|
7
|
+
import { Button, Input, Spinner, Text, PasswordStrengthIndicator } from '@mars-stack/ui';
|
|
8
|
+
import Link from 'next/link';
|
|
9
|
+
import { useRouter, useSearchParams } from 'next/navigation';
|
|
10
|
+
import { Suspense, useState } from 'react';
|
|
11
|
+
|
|
12
|
+
function ResetPasswordForm() {
|
|
13
|
+
const searchParams = useSearchParams();
|
|
14
|
+
const router = useRouter();
|
|
15
|
+
const { getCSRFHeaders } = useCSRF();
|
|
16
|
+
const [serverError, setServerError] = useState('');
|
|
17
|
+
|
|
18
|
+
const token = searchParams?.get('token') || '';
|
|
19
|
+
const email = searchParams?.get('email') || '';
|
|
20
|
+
|
|
21
|
+
const form = useZodForm({
|
|
22
|
+
schema: formSchemas.resetPassword,
|
|
23
|
+
initialValues: { token, email, password: '', confirmPassword: '' },
|
|
24
|
+
mode: 'onBlur',
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const passwordStrength = usePasswordStrength(form.values.password);
|
|
28
|
+
|
|
29
|
+
const handleSubmit = form.handleSubmit(async (values) => {
|
|
30
|
+
setServerError('');
|
|
31
|
+
try {
|
|
32
|
+
const response = await fetch('/api/auth/reset', {
|
|
33
|
+
method: 'POST',
|
|
34
|
+
headers: { 'Content-Type': 'application/json', ...getCSRFHeaders() },
|
|
35
|
+
body: JSON.stringify(values),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const data = await response.json();
|
|
39
|
+
|
|
40
|
+
if (!response.ok) {
|
|
41
|
+
setServerError(data.error || 'Failed to reset password');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
router.push(`${routes.signIn}?message=password-reset`);
|
|
46
|
+
} catch {
|
|
47
|
+
setServerError('Network error. Please try again.');
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<>
|
|
53
|
+
<div>
|
|
54
|
+
<Text.H3 as="h2">Set new password</Text.H3>
|
|
55
|
+
<Text.Paragraph className="text-sm">Enter your new password below.</Text.Paragraph>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
|
59
|
+
{serverError && (
|
|
60
|
+
<div className="rounded-md bg-error-muted p-3 text-sm text-text-error">{serverError}</div>
|
|
61
|
+
)}
|
|
62
|
+
|
|
63
|
+
<div className="space-y-4">
|
|
64
|
+
<Input
|
|
65
|
+
{...form.getFieldProps('password')}
|
|
66
|
+
label="New Password"
|
|
67
|
+
type="password"
|
|
68
|
+
placeholder="Create a strong password"
|
|
69
|
+
showPasswordToggle
|
|
70
|
+
fullWidth
|
|
71
|
+
required
|
|
72
|
+
/>
|
|
73
|
+
{form.values.password && (
|
|
74
|
+
<PasswordStrengthIndicator
|
|
75
|
+
strength={passwordStrength.strength}
|
|
76
|
+
requirements={passwordStrength.requirements}
|
|
77
|
+
/>
|
|
78
|
+
)}
|
|
79
|
+
<Input
|
|
80
|
+
{...form.getFieldProps('confirmPassword')}
|
|
81
|
+
label="Confirm New Password"
|
|
82
|
+
type="password"
|
|
83
|
+
placeholder="Re-enter your password"
|
|
84
|
+
fullWidth
|
|
85
|
+
required
|
|
86
|
+
/>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<Button type="submit" disabled={form.isSubmitting || !form.isValid} fullWidth loading={form.isSubmitting}>
|
|
90
|
+
{form.isSubmitting ? 'Resetting...' : 'Reset password'}
|
|
91
|
+
</Button>
|
|
92
|
+
</form>
|
|
93
|
+
|
|
94
|
+
<div className="mt-4 text-center">
|
|
95
|
+
<Link href={routes.signIn} className="text-sm text-text-link hover:text-text-link-hover hover:underline">
|
|
96
|
+
Back to sign in
|
|
97
|
+
</Link>
|
|
98
|
+
</div>
|
|
99
|
+
</>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export default function ResetPassword() {
|
|
104
|
+
return (
|
|
105
|
+
<Suspense fallback={<Spinner size="md" />}>
|
|
106
|
+
<ResetPasswordForm />
|
|
107
|
+
</Suspense>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useAuth } from '@/features/auth/context/AuthContext';
|
|
4
|
+
import { useCSRF } from '@mars-stack/core/auth/hooks';
|
|
5
|
+
import { formSchemas, type LoginFormData } from '@mars-stack/core/auth/validation';
|
|
6
|
+
import { routes } from '@/config/routes';
|
|
7
|
+
import { useZodForm } from '@mars-stack/ui/hooks';
|
|
8
|
+
import { Button, Input, Spinner, Text } from '@mars-stack/ui';
|
|
9
|
+
import Link from 'next/link';
|
|
10
|
+
import { useRouter, useSearchParams } from 'next/navigation';
|
|
11
|
+
import { Suspense, useEffect, useState } from 'react';
|
|
12
|
+
|
|
13
|
+
function SignInForm() {
|
|
14
|
+
const searchParams = useSearchParams();
|
|
15
|
+
const router = useRouter();
|
|
16
|
+
const { login, isAuthenticated, isLoading } = useAuth();
|
|
17
|
+
const callbackUrl = searchParams?.get('callbackUrl') || routes.dashboard;
|
|
18
|
+
const resetMessage = searchParams?.get('message');
|
|
19
|
+
const { getCSRFHeaders, getCSRFFormData } = useCSRF();
|
|
20
|
+
const [serverError, setServerError] = useState('');
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (!isLoading && isAuthenticated) router.push(callbackUrl);
|
|
24
|
+
}, [isAuthenticated, isLoading, router, callbackUrl]);
|
|
25
|
+
|
|
26
|
+
const form = useZodForm({
|
|
27
|
+
schema: formSchemas.login,
|
|
28
|
+
initialValues: { email: '', password: '' },
|
|
29
|
+
mode: 'onBlur',
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const handleSubmit = form.handleSubmit(async (values: LoginFormData) => {
|
|
33
|
+
setServerError('');
|
|
34
|
+
try {
|
|
35
|
+
const response = await fetch('/api/auth/login', {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers: { 'Content-Type': 'application/json', ...getCSRFHeaders() },
|
|
38
|
+
body: JSON.stringify({ ...values, ...getCSRFFormData() }),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const data = await response.json();
|
|
42
|
+
|
|
43
|
+
if (!response.ok) {
|
|
44
|
+
setServerError(data.error || 'Failed to sign in');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (data.user) login(data.user);
|
|
49
|
+
router.push(callbackUrl);
|
|
50
|
+
} catch {
|
|
51
|
+
setServerError('Network error. Please try again.');
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
if (isLoading) return <Spinner size="md" />;
|
|
56
|
+
if (isAuthenticated) return <Text.Paragraph>Redirecting...</Text.Paragraph>;
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<>
|
|
60
|
+
<div>
|
|
61
|
+
<Text.H3 as="h2">Sign in to your account</Text.H3>
|
|
62
|
+
<Text.Paragraph className="text-sm">
|
|
63
|
+
Need to create an account?{' '}
|
|
64
|
+
<Link href="/register" className="text-text-link hover:text-text-link-hover hover:underline">
|
|
65
|
+
Register
|
|
66
|
+
</Link>
|
|
67
|
+
</Text.Paragraph>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
|
71
|
+
{resetMessage === 'password-reset' && (
|
|
72
|
+
<div className="rounded-md bg-success-muted p-3 text-sm text-text-success">
|
|
73
|
+
Password reset successful. Please sign in with your new password.
|
|
74
|
+
</div>
|
|
75
|
+
)}
|
|
76
|
+
{serverError && (
|
|
77
|
+
<div className="rounded-md bg-error-muted p-3 text-sm text-text-error">{serverError}</div>
|
|
78
|
+
)}
|
|
79
|
+
|
|
80
|
+
<div className="space-y-4">
|
|
81
|
+
<Input
|
|
82
|
+
{...form.getFieldProps('email')}
|
|
83
|
+
label="Email Address"
|
|
84
|
+
type="email"
|
|
85
|
+
placeholder="you@example.com"
|
|
86
|
+
error={form.touched.email ? form.errors.email : undefined}
|
|
87
|
+
fullWidth
|
|
88
|
+
required
|
|
89
|
+
/>
|
|
90
|
+
<Input
|
|
91
|
+
{...form.getFieldProps('password')}
|
|
92
|
+
label="Password"
|
|
93
|
+
type="password"
|
|
94
|
+
placeholder="Enter your password"
|
|
95
|
+
showPasswordToggle
|
|
96
|
+
error={form.touched.password ? form.errors.password : undefined}
|
|
97
|
+
fullWidth
|
|
98
|
+
required
|
|
99
|
+
/>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<Button type="submit" disabled={form.isSubmitting || !form.isValid} fullWidth loading={form.isSubmitting}>
|
|
103
|
+
{form.isSubmitting ? 'Signing in...' : 'Sign in'}
|
|
104
|
+
</Button>
|
|
105
|
+
</form>
|
|
106
|
+
|
|
107
|
+
<div className="mt-4 text-center">
|
|
108
|
+
<Link href="/forgotten-password" className="text-sm text-text-link hover:text-text-link-hover hover:underline">
|
|
109
|
+
Forgot your password?
|
|
110
|
+
</Link>
|
|
111
|
+
</div>
|
|
112
|
+
</>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export default function SignIn() {
|
|
117
|
+
return (
|
|
118
|
+
<Suspense fallback={<Spinner size="md" />}>
|
|
119
|
+
<SignInForm />
|
|
120
|
+
</Suspense>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { routes } from '@/config/routes';
|
|
4
|
+
import { LinkButton, Spinner, Text } from '@mars-stack/ui';
|
|
5
|
+
import Link from 'next/link';
|
|
6
|
+
import { useParams } from 'next/navigation';
|
|
7
|
+
import { Suspense, useEffect, useState } from 'react';
|
|
8
|
+
|
|
9
|
+
function VerifyTokenContent() {
|
|
10
|
+
const params = useParams();
|
|
11
|
+
const token = params?.token as string;
|
|
12
|
+
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
|
|
13
|
+
const [message, setMessage] = useState('');
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (!token) {
|
|
17
|
+
setStatus('error');
|
|
18
|
+
setMessage('Invalid verification link');
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const verify = async () => {
|
|
23
|
+
try {
|
|
24
|
+
const response = await fetch('/api/auth/verify', {
|
|
25
|
+
method: 'POST',
|
|
26
|
+
headers: { 'Content-Type': 'application/json' },
|
|
27
|
+
body: JSON.stringify({ token }),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const data = await response.json();
|
|
31
|
+
|
|
32
|
+
if (response.ok) {
|
|
33
|
+
setStatus('success');
|
|
34
|
+
setMessage(data.message || 'Email verified successfully');
|
|
35
|
+
} else {
|
|
36
|
+
setStatus('error');
|
|
37
|
+
setMessage(data.error || 'Verification failed');
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
setStatus('error');
|
|
41
|
+
setMessage('Network error. Please try again.');
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
verify();
|
|
46
|
+
}, [token]);
|
|
47
|
+
|
|
48
|
+
if (status === 'loading') {
|
|
49
|
+
return (
|
|
50
|
+
<div className="text-center">
|
|
51
|
+
<Text.H3 as="h2">Verifying your email...</Text.H3>
|
|
52
|
+
<Text.Paragraph>Please wait while we verify your email address.</Text.Paragraph>
|
|
53
|
+
<Spinner size="md" className="mt-4" />
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (status === 'success') {
|
|
59
|
+
return (
|
|
60
|
+
<div className="text-center">
|
|
61
|
+
<Text.H3 as="h2">Email verified</Text.H3>
|
|
62
|
+
<Text.Paragraph>{message}</Text.Paragraph>
|
|
63
|
+
<LinkButton href={routes.signIn} className="mt-6">
|
|
64
|
+
Sign in
|
|
65
|
+
</LinkButton>
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div className="text-center">
|
|
72
|
+
<Text.H3 as="h2">Verification failed</Text.H3>
|
|
73
|
+
<Text.Paragraph className="text-text-error">{message}</Text.Paragraph>
|
|
74
|
+
<Link href={routes.signIn} className="mt-6 inline-block text-sm text-text-link hover:text-text-link-hover hover:underline">
|
|
75
|
+
Back to sign in
|
|
76
|
+
</Link>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export default function VerifyToken() {
|
|
82
|
+
return (
|
|
83
|
+
<Suspense fallback={<Spinner size="md" />}>
|
|
84
|
+
<VerifyTokenContent />
|
|
85
|
+
</Suspense>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { routes } from '@/config/routes';
|
|
4
|
+
import { Spinner, Text } from '@mars-stack/ui';
|
|
5
|
+
import Link from 'next/link';
|
|
6
|
+
import { Suspense, useEffect, useState } from 'react';
|
|
7
|
+
|
|
8
|
+
function VerifyContent() {
|
|
9
|
+
const [email, setEmail] = useState<string | null>(null);
|
|
10
|
+
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
const stored = sessionStorage.getItem('verify-email');
|
|
13
|
+
if (stored) {
|
|
14
|
+
setEmail(stored);
|
|
15
|
+
sessionStorage.removeItem('verify-email');
|
|
16
|
+
}
|
|
17
|
+
}, []);
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div className="text-center">
|
|
21
|
+
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-success-muted">
|
|
22
|
+
<svg className="h-8 w-8 text-text-success" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
|
23
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
|
|
24
|
+
</svg>
|
|
25
|
+
</div>
|
|
26
|
+
<Text.H3 as="h2">Check your email</Text.H3>
|
|
27
|
+
<Text.Paragraph className="mt-2">
|
|
28
|
+
{email ? (
|
|
29
|
+
<>We've sent a verification link to <strong>{email}</strong>.</>
|
|
30
|
+
) : (
|
|
31
|
+
<>We've sent a verification link to your email address.</>
|
|
32
|
+
)}
|
|
33
|
+
</Text.Paragraph>
|
|
34
|
+
<Text.Paragraph className="mt-4 text-sm text-text-muted">
|
|
35
|
+
Click the link in the email to verify your account. The link expires in 24 hours.
|
|
36
|
+
</Text.Paragraph>
|
|
37
|
+
<Text.Paragraph className="mt-1 text-sm text-text-muted">
|
|
38
|
+
Didn't receive the email? Check your spam folder.
|
|
39
|
+
</Text.Paragraph>
|
|
40
|
+
<Link
|
|
41
|
+
href={routes.signIn}
|
|
42
|
+
className="mt-8 inline-block text-sm text-text-link hover:text-text-link-hover hover:underline"
|
|
43
|
+
>
|
|
44
|
+
Back to sign in
|
|
45
|
+
</Link>
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export default function Verify() {
|
|
51
|
+
return (
|
|
52
|
+
<Suspense fallback={<Spinner size="md" />}>
|
|
53
|
+
<VerifyContent />
|
|
54
|
+
</Suspense>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { Table, TableHead, TableBody, TableRow, TableHeaderCell, TableCell } from '@mars-stack/ui';
|
|
5
|
+
import { Badge } from '@mars-stack/ui';
|
|
6
|
+
import { Spinner } from '@mars-stack/ui';
|
|
7
|
+
|
|
8
|
+
interface AdminUser {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string | null;
|
|
11
|
+
email: string;
|
|
12
|
+
role: string;
|
|
13
|
+
emailVerified: string | null;
|
|
14
|
+
createdAt: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default function AdminPage() {
|
|
18
|
+
const [users, setUsers] = useState<AdminUser[]>([]);
|
|
19
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
20
|
+
const [error, setError] = useState<string | null>(null);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
async function fetchUsers() {
|
|
24
|
+
try {
|
|
25
|
+
const response = await fetch('/api/protected/admin/users', {
|
|
26
|
+
credentials: 'include',
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (!response.ok) {
|
|
30
|
+
if (response.status === 403) {
|
|
31
|
+
setError('You do not have permission to view this page.');
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
throw new Error('Failed to fetch users');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const data: { users: AdminUser[] } = await response.json();
|
|
38
|
+
setUsers(data.users);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
setError(err instanceof Error ? err.message : 'An unexpected error occurred');
|
|
41
|
+
} finally {
|
|
42
|
+
setIsLoading(false);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
fetchUsers();
|
|
47
|
+
}, []);
|
|
48
|
+
|
|
49
|
+
if (isLoading) {
|
|
50
|
+
return (
|
|
51
|
+
<div className="flex min-h-[400px] items-center justify-center">
|
|
52
|
+
<Spinner size="lg" />
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (error) {
|
|
58
|
+
return (
|
|
59
|
+
<div className="mx-auto max-w-5xl px-4 py-8 sm:px-6 lg:px-8">
|
|
60
|
+
<div className="rounded-lg border border-border-error bg-error-muted p-4">
|
|
61
|
+
<p className="text-text-error">{error}</p>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div className="mx-auto max-w-5xl px-4 py-8 sm:px-6 lg:px-8">
|
|
69
|
+
<div className="mb-6">
|
|
70
|
+
<h1 className="text-3xl font-bold text-text-primary">Admin</h1>
|
|
71
|
+
<p className="mt-2 text-text-secondary">
|
|
72
|
+
{users.length} {users.length === 1 ? 'user' : 'users'} registered
|
|
73
|
+
</p>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<Table>
|
|
77
|
+
<TableHead>
|
|
78
|
+
<TableHeaderCell>Name</TableHeaderCell>
|
|
79
|
+
<TableHeaderCell>Email</TableHeaderCell>
|
|
80
|
+
<TableHeaderCell>Role</TableHeaderCell>
|
|
81
|
+
<TableHeaderCell>Status</TableHeaderCell>
|
|
82
|
+
<TableHeaderCell>Created</TableHeaderCell>
|
|
83
|
+
</TableHead>
|
|
84
|
+
<TableBody>
|
|
85
|
+
{users.map((user) => (
|
|
86
|
+
<TableRow key={user.id}>
|
|
87
|
+
<TableCell>{user.name || '—'}</TableCell>
|
|
88
|
+
<TableCell>{user.email}</TableCell>
|
|
89
|
+
<TableCell>
|
|
90
|
+
<Badge variant={user.role === 'admin' ? 'warning' : 'neutral'}>
|
|
91
|
+
{user.role}
|
|
92
|
+
</Badge>
|
|
93
|
+
</TableCell>
|
|
94
|
+
<TableCell>
|
|
95
|
+
<Badge variant={user.emailVerified ? 'success' : 'error'}>
|
|
96
|
+
{user.emailVerified ? 'Verified' : 'Unverified'}
|
|
97
|
+
</Badge>
|
|
98
|
+
</TableCell>
|
|
99
|
+
<TableCell className="text-text-muted">
|
|
100
|
+
{new Date(user.createdAt).toLocaleDateString()}
|
|
101
|
+
</TableCell>
|
|
102
|
+
</TableRow>
|
|
103
|
+
))}
|
|
104
|
+
</TableBody>
|
|
105
|
+
</Table>
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export default function DashboardLoading() {
|
|
2
|
+
return (
|
|
3
|
+
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
|
4
|
+
<div className="h-8 w-48 animate-pulse rounded bg-surface-skeleton" />
|
|
5
|
+
<div className="mt-2 h-5 w-64 animate-pulse rounded bg-surface-skeleton" />
|
|
6
|
+
|
|
7
|
+
<div className="mt-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
|
8
|
+
{[1, 2, 3].map((i) => (
|
|
9
|
+
<div key={i} className="rounded-xl border border-border-default bg-surface-card p-6 shadow-xs">
|
|
10
|
+
<div className="h-6 w-32 animate-pulse rounded bg-surface-skeleton" />
|
|
11
|
+
<div className="mt-4 space-y-2">
|
|
12
|
+
<div className="h-4 w-full animate-pulse rounded bg-surface-skeleton" />
|
|
13
|
+
<div className="h-4 w-3/4 animate-pulse rounded bg-surface-skeleton" />
|
|
14
|
+
</div>
|
|
15
|
+
</div>
|
|
16
|
+
))}
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { verifySession } from '@/lib/mars';
|
|
2
|
+
import { Card } from '@mars-stack/ui';
|
|
3
|
+
|
|
4
|
+
export default async function Dashboard() {
|
|
5
|
+
const session = await verifySession();
|
|
6
|
+
|
|
7
|
+
return (
|
|
8
|
+
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
|
9
|
+
<h1 className="text-3xl font-bold text-text-primary">Dashboard</h1>
|
|
10
|
+
<p className="mt-2 text-text-secondary">Welcome back, {session.name}.</p>
|
|
11
|
+
|
|
12
|
+
<div className="mt-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
|
13
|
+
<Card>
|
|
14
|
+
<h3 className="text-lg font-semibold text-text-primary">Getting Started</h3>
|
|
15
|
+
<p className="mt-2 text-sm text-text-secondary">
|
|
16
|
+
This is a placeholder dashboard. Replace this with your app's content.
|
|
17
|
+
</p>
|
|
18
|
+
</Card>
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
);
|
|
22
|
+
}
|