@mars-stack/cli 3.0.1 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,262 @@
1
+ 'use client';
2
+
3
+ import { Suspense, useEffect, useState, useCallback } from 'react';
4
+ import { useSearchParams } from 'next/navigation';
5
+ import { Card, Badge, Button, LinkButton, Spinner } from '@mars-stack/ui';
6
+ import { appConfig } from '@/config/app.config';
7
+ import { routes } from '@/config/routes';
8
+ import type { SubscriptionRecord } from '@/features/billing/types';
9
+ import { SUBSCRIPTION_STATUS } from '@/features/billing/types';
10
+
11
+ type BadgeVariant = 'success' | 'warning' | 'error' | 'neutral' | 'info';
12
+
13
+ function getStatusBadge(status: string): { label: string; variant: BadgeVariant } {
14
+ switch (status) {
15
+ case SUBSCRIPTION_STATUS.active:
16
+ return { label: 'Active', variant: 'success' };
17
+ case SUBSCRIPTION_STATUS.trialing:
18
+ return { label: 'Trial', variant: 'info' };
19
+ case SUBSCRIPTION_STATUS.pastDue:
20
+ return { label: 'Past due', variant: 'warning' };
21
+ case SUBSCRIPTION_STATUS.canceled:
22
+ return { label: 'Cancelled', variant: 'error' };
23
+ case SUBSCRIPTION_STATUS.unpaid:
24
+ return { label: 'Unpaid', variant: 'error' };
25
+ case SUBSCRIPTION_STATUS.paused:
26
+ return { label: 'Paused', variant: 'neutral' };
27
+ case SUBSCRIPTION_STATUS.inactive:
28
+ return { label: 'Inactive', variant: 'neutral' };
29
+ default:
30
+ return { label: status, variant: 'neutral' };
31
+ }
32
+ }
33
+
34
+ function formatDate(date: Date | string | null): string {
35
+ if (!date) return 'N/A';
36
+ return new Date(date).toLocaleDateString(undefined, {
37
+ year: 'numeric',
38
+ month: 'long',
39
+ day: 'numeric',
40
+ });
41
+ }
42
+
43
+ function SubscriptionDetails({ subscription }: { subscription: SubscriptionRecord }) {
44
+ const statusBadge = getStatusBadge(subscription.status);
45
+ const isActive = subscription.status === SUBSCRIPTION_STATUS.active
46
+ || subscription.status === SUBSCRIPTION_STATUS.trialing;
47
+
48
+ return (
49
+ <Card>
50
+ <div className="flex items-start justify-between gap-4">
51
+ <div>
52
+ <h3 className="text-lg font-semibold text-text-primary">Current Plan</h3>
53
+ <p className="mt-1 text-sm text-text-secondary">
54
+ {subscription.stripePriceId
55
+ ? `Price: ${subscription.stripePriceId}`
56
+ : 'No active price'}
57
+ </p>
58
+ </div>
59
+ <Badge variant={statusBadge.variant}>{statusBadge.label}</Badge>
60
+ </div>
61
+
62
+ <dl className="mt-6 grid gap-4 sm:grid-cols-2">
63
+ <div>
64
+ <dt className="text-xs font-medium uppercase tracking-wider text-text-muted">Status</dt>
65
+ <dd className="mt-1 text-sm text-text-primary">{statusBadge.label}</dd>
66
+ </div>
67
+ <div>
68
+ <dt className="text-xs font-medium uppercase tracking-wider text-text-muted">
69
+ {subscription.cancelAtPeriodEnd ? 'Cancels on' : 'Renews on'}
70
+ </dt>
71
+ <dd className="mt-1 text-sm text-text-primary">
72
+ {formatDate(subscription.currentPeriodEnd)}
73
+ </dd>
74
+ </div>
75
+ </dl>
76
+
77
+ {subscription.cancelAtPeriodEnd && isActive && (
78
+ <div className="mt-4 rounded-lg border border-border-default bg-warning-muted p-3">
79
+ <p className="text-sm text-text-warning">
80
+ Your subscription will be cancelled at the end of the current period on{' '}
81
+ {formatDate(subscription.currentPeriodEnd)}.
82
+ </p>
83
+ </div>
84
+ )}
85
+ </Card>
86
+ );
87
+ }
88
+
89
+ function NoSubscription() {
90
+ return (
91
+ <Card>
92
+ <div className="py-4 text-center">
93
+ <svg
94
+ className="mx-auto h-12 w-12 text-text-muted"
95
+ fill="none"
96
+ viewBox="0 0 24 24"
97
+ strokeWidth={1}
98
+ stroke="currentColor"
99
+ >
100
+ <path
101
+ strokeLinecap="round"
102
+ strokeLinejoin="round"
103
+ d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z"
104
+ />
105
+ </svg>
106
+ <h3 className="mt-3 text-lg font-semibold text-text-primary">No active subscription</h3>
107
+ <p className="mt-1 text-sm text-text-secondary">
108
+ Choose a plan to unlock premium features.
109
+ </p>
110
+ <div className="mt-4">
111
+ <LinkButton href={routes.pricing} variant="primary" size="md">
112
+ View plans
113
+ </LinkButton>
114
+ </div>
115
+ </div>
116
+ </Card>
117
+ );
118
+ }
119
+
120
+ function StripeNotConfigured() {
121
+ return (
122
+ <Card>
123
+ <div className="py-4 text-center">
124
+ <h3 className="text-lg font-semibold text-text-primary">Billing not configured</h3>
125
+ <p className="mt-1 text-sm text-text-secondary">
126
+ The payments provider is not set up. Configure Stripe in your environment to enable
127
+ billing.
128
+ </p>
129
+ </div>
130
+ </Card>
131
+ );
132
+ }
133
+
134
+ function BillingSettingsContent() {
135
+ const searchParams = useSearchParams();
136
+ const [subscription, setSubscription] = useState<SubscriptionRecord | null>(null);
137
+ const [loading, setLoading] = useState(true);
138
+ const [portalLoading, setPortalLoading] = useState(false);
139
+ const [successMessage, setSuccessMessage] = useState<string | null>(null);
140
+
141
+ const hasStripe = (appConfig.services.payments.provider as string) === 'stripe';
142
+
143
+ const billingStatus = searchParams.get('billing');
144
+
145
+ useEffect(() => {
146
+ if (billingStatus === 'success') {
147
+ setSuccessMessage('Payment successful! Your subscription is now active.');
148
+ } else if (billingStatus === 'cancelled') {
149
+ setSuccessMessage('Checkout was cancelled. No charges were made.');
150
+ }
151
+ }, [billingStatus]);
152
+
153
+ const fetchSubscription = useCallback(async () => {
154
+ try {
155
+ const response = await fetch('/api/protected/billing/subscription', {
156
+ credentials: 'include',
157
+ });
158
+
159
+ if (response.ok) {
160
+ const data = (await response.json()) as { subscription: SubscriptionRecord | null };
161
+ setSubscription(data.subscription);
162
+ }
163
+ } catch (error) {
164
+ console.error('Failed to fetch subscription:', error);
165
+ } finally {
166
+ setLoading(false);
167
+ }
168
+ }, []);
169
+
170
+ useEffect(() => {
171
+ if (hasStripe) {
172
+ fetchSubscription();
173
+ } else {
174
+ setLoading(false);
175
+ }
176
+ }, [hasStripe, fetchSubscription]);
177
+
178
+ const handleManageBilling = async () => {
179
+ setPortalLoading(true);
180
+
181
+ try {
182
+ const response = await fetch('/api/protected/billing/portal', {
183
+ method: 'POST',
184
+ credentials: 'include',
185
+ });
186
+
187
+ if (!response.ok) {
188
+ const data: { error?: string } = await response.json();
189
+ throw new Error(data.error ?? 'Failed to create portal session');
190
+ }
191
+
192
+ const { url } = (await response.json()) as { url: string };
193
+ window.location.href = url;
194
+ } catch (error) {
195
+ console.error('Portal error:', error);
196
+ setPortalLoading(false);
197
+ }
198
+ };
199
+
200
+ return (
201
+ <div className="space-y-6">
202
+ <div>
203
+ <h1 className="text-2xl font-bold text-text-primary">Billing</h1>
204
+ <p className="mt-1 text-text-secondary">Manage your subscription and billing details.</p>
205
+ </div>
206
+
207
+ {successMessage && (
208
+ <div className="rounded-lg border border-border-default bg-success-muted p-4">
209
+ <p className="text-sm text-text-success">{successMessage}</p>
210
+ </div>
211
+ )}
212
+
213
+ {!hasStripe ? (
214
+ <StripeNotConfigured />
215
+ ) : loading ? (
216
+ <Card>
217
+ <div className="flex items-center justify-center py-8">
218
+ <Spinner size="lg" />
219
+ </div>
220
+ </Card>
221
+ ) : subscription && subscription.status !== SUBSCRIPTION_STATUS.inactive ? (
222
+ <>
223
+ <SubscriptionDetails subscription={subscription} />
224
+ <Card>
225
+ <h3 className="text-sm font-semibold text-text-primary">Manage Subscription</h3>
226
+ <p className="mt-1 text-sm text-text-secondary">
227
+ Update payment method, change plan, or cancel via the Stripe Customer Portal.
228
+ </p>
229
+ <div className="mt-4">
230
+ <Button
231
+ variant="secondary"
232
+ size="md"
233
+ onClick={handleManageBilling}
234
+ disabled={portalLoading}
235
+ >
236
+ {portalLoading ? 'Opening portal...' : 'Manage billing'}
237
+ </Button>
238
+ </div>
239
+ </Card>
240
+ </>
241
+ ) : (
242
+ <NoSubscription />
243
+ )}
244
+ </div>
245
+ );
246
+ }
247
+
248
+ export default function BillingSettingsPage() {
249
+ return (
250
+ <Suspense
251
+ fallback={
252
+ <Card>
253
+ <div className="flex items-center justify-center py-8">
254
+ <Spinner size="lg" />
255
+ </div>
256
+ </Card>
257
+ }
258
+ >
259
+ <BillingSettingsContent />
260
+ </Suspense>
261
+ );
262
+ }
@@ -0,0 +1,118 @@
1
+ // @vitest-environment node
2
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
3
+
4
+ vi.mock('server-only', () => ({}));
5
+ vi.mock('@/lib/prisma', () => ({ prisma: {} }));
6
+ vi.mock('@/lib/mars', () => ({
7
+ sendEmail: vi.fn(),
8
+ handleApiError: vi.fn(),
9
+ getBaseUrl: vi.fn(),
10
+ authLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
11
+ }));
12
+ vi.mock('@mars-stack/core/rate-limit', () => ({
13
+ checkRateLimit: vi.fn(),
14
+ getClientIP: vi.fn(),
15
+ RATE_LIMITS: { signup: {} },
16
+ rateLimitResponse: vi.fn(),
17
+ }));
18
+ vi.mock('@mars-stack/core/auth/password', () => ({ hashPassword: vi.fn() }));
19
+ vi.mock('@mars-stack/core/auth/crypto-utils', () => ({ hashToken: vi.fn() }));
20
+ vi.mock('@/features/auth/server/user', () => ({ findUserByEmailPublic: vi.fn() }));
21
+ vi.mock('@mars-stack/core/auth/link-utils', () => ({ buildEmailVerificationUrl: vi.fn() }));
22
+ vi.mock('@mars-stack/core/auth/validation', () => ({ apiSchemas: { signup: { parse: vi.fn() } } }));
23
+ vi.mock('@/lib/core/email/templates', () => ({ verificationEmailHtml: vi.fn() }));
24
+ vi.mock('@/config/app.config', () => ({ appConfig: { name: 'TestApp', services: { email: { provider: 'console' } } } }));
25
+
26
+ import { shouldAutoVerifySignupEmail } from './route';
27
+ import { authLogger } from '@/lib/mars';
28
+
29
+ describe('shouldAutoVerifySignupEmail', () => {
30
+ const originalEnv = process.env;
31
+
32
+ beforeEach(() => {
33
+ process.env = { ...originalEnv };
34
+ vi.clearAllMocks();
35
+ });
36
+
37
+ afterEach(() => {
38
+ process.env = originalEnv;
39
+ });
40
+
41
+ it('returns false when AUTO_VERIFY_EMAIL is not set', () => {
42
+ delete process.env.AUTO_VERIFY_EMAIL;
43
+ expect(shouldAutoVerifySignupEmail()).toBe(false);
44
+ });
45
+
46
+ it('returns false when AUTO_VERIFY_EMAIL is an unrecognised value', () => {
47
+ process.env.AUTO_VERIFY_EMAIL = 'yes';
48
+ expect(shouldAutoVerifySignupEmail()).toBe(false);
49
+ });
50
+
51
+ it('returns false when AUTO_VERIFY_EMAIL=true but VERCEL_ENV=production', () => {
52
+ process.env.AUTO_VERIFY_EMAIL = 'true';
53
+ process.env.VERCEL_ENV = 'production';
54
+ delete process.env.NODE_ENV;
55
+ expect(shouldAutoVerifySignupEmail()).toBe(false);
56
+ });
57
+
58
+ it('returns true when AUTO_VERIFY_EMAIL=true and NODE_ENV=development', () => {
59
+ process.env.AUTO_VERIFY_EMAIL = 'true';
60
+ delete process.env.VERCEL_ENV;
61
+ process.env.NODE_ENV = 'development';
62
+ expect(shouldAutoVerifySignupEmail()).toBe(true);
63
+ });
64
+
65
+ it('returns true when AUTO_VERIFY_EMAIL=1 and NODE_ENV=development', () => {
66
+ process.env.AUTO_VERIFY_EMAIL = '1';
67
+ delete process.env.VERCEL_ENV;
68
+ process.env.NODE_ENV = 'development';
69
+ expect(shouldAutoVerifySignupEmail()).toBe(true);
70
+ });
71
+
72
+ it('returns true when APP_URL points to localhost', () => {
73
+ process.env.AUTO_VERIFY_EMAIL = 'true';
74
+ delete process.env.VERCEL_ENV;
75
+ process.env.NODE_ENV = 'test';
76
+ process.env.APP_URL = 'http://localhost:3000';
77
+ delete process.env.NEXT_PUBLIC_APP_URL;
78
+ expect(shouldAutoVerifySignupEmail()).toBe(true);
79
+ });
80
+
81
+ it('returns true when NEXT_PUBLIC_APP_URL points to 127.0.0.1', () => {
82
+ process.env.AUTO_VERIFY_EMAIL = 'true';
83
+ delete process.env.VERCEL_ENV;
84
+ process.env.NODE_ENV = 'test';
85
+ delete process.env.APP_URL;
86
+ process.env.NEXT_PUBLIC_APP_URL = 'http://127.0.0.1:3000';
87
+ expect(shouldAutoVerifySignupEmail()).toBe(true);
88
+ });
89
+
90
+ it('returns false and logs a warning when APP_URL is not localhost', () => {
91
+ process.env.AUTO_VERIFY_EMAIL = 'true';
92
+ delete process.env.VERCEL_ENV;
93
+ process.env.NODE_ENV = 'test';
94
+ process.env.APP_URL = 'https://example.com';
95
+ delete process.env.NEXT_PUBLIC_APP_URL;
96
+ expect(shouldAutoVerifySignupEmail()).toBe(false);
97
+ expect(authLogger.warn).toHaveBeenCalledOnce();
98
+ });
99
+
100
+ it('returns false and logs a warning when no APP_URL env vars are set', () => {
101
+ process.env.AUTO_VERIFY_EMAIL = 'true';
102
+ delete process.env.VERCEL_ENV;
103
+ process.env.NODE_ENV = 'test';
104
+ delete process.env.APP_URL;
105
+ delete process.env.NEXT_PUBLIC_APP_URL;
106
+ expect(shouldAutoVerifySignupEmail()).toBe(false);
107
+ expect(authLogger.warn).toHaveBeenCalledOnce();
108
+ });
109
+
110
+ it('skips invalid URL values gracefully and continues checking other keys', () => {
111
+ process.env.AUTO_VERIFY_EMAIL = 'true';
112
+ delete process.env.VERCEL_ENV;
113
+ process.env.NODE_ENV = 'test';
114
+ process.env.APP_URL = 'not-a-valid-url';
115
+ process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000';
116
+ expect(shouldAutoVerifySignupEmail()).toBe(true);
117
+ });
118
+ });
@@ -1,7 +1,7 @@
1
1
  import 'server-only';
2
2
 
3
3
  import { prisma } from '@/lib/prisma';
4
- import { sendEmail, handleApiError, getBaseUrl } from '@/lib/mars';
4
+ import { sendEmail, handleApiError, getBaseUrl, authLogger } from '@/lib/mars';
5
5
  import { checkRateLimit, getClientIP, RATE_LIMITS, rateLimitResponse } from '@mars-stack/core/rate-limit';
6
6
  import { hashPassword } from '@mars-stack/core/auth/password';
7
7
  import { hashToken } from '@mars-stack/core/auth/crypto-utils';
@@ -13,6 +13,32 @@ import { appConfig } from '@/config/app.config';
13
13
  import { randomBytes } from 'crypto';
14
14
  import { NextResponse } from 'next/server';
15
15
 
16
+ /**
17
+ * When AUTO_VERIFY_EMAIL is true, new users skip the verification email flow.
18
+ * Active only in safe contexts: Next dev server, or local next start (APP_URL → localhost).
19
+ * Ignored on Vercel production even if the flag is mistakenly set.
20
+ */
21
+ export function shouldAutoVerifySignupEmail(): boolean {
22
+ const raw = (process.env.AUTO_VERIFY_EMAIL ?? '').trim().toLowerCase();
23
+ if (raw !== 'true' && raw !== '1') return false;
24
+ if (process.env.VERCEL_ENV === 'production') return false;
25
+ if (process.env.NODE_ENV === 'development') return true;
26
+ for (const key of ['APP_URL', 'NEXT_PUBLIC_APP_URL'] as const) {
27
+ const base = process.env[key];
28
+ if (!base) continue;
29
+ try {
30
+ const host = new URL(base).hostname.toLowerCase();
31
+ if (host === 'localhost' || host === '127.0.0.1') return true;
32
+ } catch {
33
+ /* invalid URL */
34
+ }
35
+ }
36
+ authLogger.warn(
37
+ '[mars:auth] AUTO_VERIFY_EMAIL is set but not applied: run `yarn dev`, or set APP_URL=http://localhost:PORT (needed for `next start`).',
38
+ );
39
+ return false;
40
+ }
41
+
16
42
  export async function POST(request: Request) {
17
43
  const ip = getClientIP(request);
18
44
  const rateLimit = await checkRateLimit(ip, RATE_LIMITS.signup);
@@ -34,9 +60,7 @@ export async function POST(request: Request) {
34
60
  const tokenHash = await hashToken(token, 'email-verification-v1');
35
61
  const expires = new Date(Date.now() + 24 * 60 * 60 * 1000);
36
62
 
37
- const autoVerify =
38
- process.env.NODE_ENV === 'development' &&
39
- process.env.AUTO_VERIFY_EMAIL === 'true';
63
+ const autoVerify = shouldAutoVerifySignupEmail();
40
64
 
41
65
  await prisma.$transaction(async (tx) => {
42
66
  await tx.user.create({
@@ -60,7 +84,7 @@ export async function POST(request: Request) {
60
84
  });
61
85
 
62
86
  if (autoVerify) {
63
- console.log(`[mars:auth] AUTO_VERIFY_EMAIL is enabled ${email} verified immediately`);
87
+ authLogger.info({ email }, 'AUTO_VERIFY_EMAIL: signup marked verified immediately');
64
88
  } else {
65
89
  const baseUrl = getBaseUrl();
66
90
  const verifyUrl = buildEmailVerificationUrl({ baseUrl, token });
@@ -71,8 +71,8 @@ export const POST = withAuthNoParams(async (request: AuthenticatedRequest) => {
71
71
  const { url } = await payments.createCheckoutSession({
72
72
  customerId: stripeCustomerId as string,
73
73
  priceId,
74
- successUrl: `${baseUrl}/settings?billing=success`,
75
- cancelUrl: `${baseUrl}/settings?billing=cancelled`,
74
+ successUrl: `${baseUrl}/settings/billing?billing=success`,
75
+ cancelUrl: `${baseUrl}/settings/billing?billing=cancelled`,
76
76
  metadata: { userId },
77
77
  });
78
78
 
@@ -29,7 +29,7 @@ export const POST = withAuthNoParams(async (request: AuthenticatedRequest) => {
29
29
 
30
30
  const { url } = await payments.createPortalSession({
31
31
  customerId: subscription.stripeCustomerId,
32
- returnUrl: `${baseUrl}/settings`,
32
+ returnUrl: `${baseUrl}/settings/billing`,
33
33
  });
34
34
 
35
35
  return NextResponse.json({ url });
@@ -0,0 +1,13 @@
1
+ import { withAuthNoParams, handleApiError } from '@/lib/mars';
2
+ import { findSubscriptionByUserId } from '@/features/billing/server';
3
+ import type { AuthenticatedRequest } from '@mars-stack/core/auth/middleware';
4
+ import { NextResponse } from 'next/server';
5
+
6
+ export const GET = withAuthNoParams(async (request: AuthenticatedRequest) => {
7
+ try {
8
+ const subscription = await findSubscriptionByUserId(request.session.userId);
9
+ return NextResponse.json({ subscription });
10
+ } catch (error) {
11
+ return handleApiError(error, { endpoint: '/api/protected/billing/subscription' });
12
+ }
13
+ });
@@ -0,0 +1,22 @@
1
+ import { withAuthNoParams, handleApiError } from '@/lib/mars';
2
+ import { listUserFiles } from '@/features/uploads/server';
3
+ import type { AuthenticatedRequest } from '@mars-stack/core/auth/middleware';
4
+ import { NextResponse } from 'next/server';
5
+
6
+ export const GET = withAuthNoParams(async (request: AuthenticatedRequest) => {
7
+ try {
8
+ const { searchParams } = new URL(request.url);
9
+ const limit = Math.min(parseInt(searchParams.get('limit') ?? '50', 10), 100);
10
+ const offset = parseInt(searchParams.get('offset') ?? '0', 10);
11
+
12
+ const files = await listUserFiles({
13
+ userId: request.session.userId,
14
+ limit,
15
+ offset,
16
+ });
17
+
18
+ return NextResponse.json({ files });
19
+ } catch (error) {
20
+ return handleApiError(error, { endpoint: '/api/protected/files/list' });
21
+ }
22
+ });