feat: dashboard Second Brain, essai 7 jours et vérification e-mail
Rendre le dashboard actionnable (inbox, peek, carte mentale), aligner la facturation sur l’essai 7 jours, et bloquer le login e-mail tant que l’adresse n’est pas confirmée. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -46,6 +46,152 @@ function assertValidTier(tier: string): asserts tier is TierType {
|
||||
}
|
||||
}
|
||||
|
||||
async function getStripeHealth(billingConfig: Record<string, string>) {
|
||||
const secret = process.env.STRIPE_SECRET_KEY ?? ''
|
||||
const publishable = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? ''
|
||||
const webhook = process.env.STRIPE_WEBHOOK_SECRET ?? ''
|
||||
|
||||
let secretMode: 'test' | 'live' | 'missing' | 'placeholder' = 'missing'
|
||||
if (!secret) secretMode = 'missing'
|
||||
else if (secret === 'sk_test_placeholder' || secret.includes('placeholder')) secretMode = 'placeholder'
|
||||
else if (secret.startsWith('sk_live_')) secretMode = 'live'
|
||||
else if (secret.startsWith('sk_test_')) secretMode = 'test'
|
||||
else secretMode = 'placeholder'
|
||||
|
||||
const { isBillingEnabled } = await import('@/lib/billing/stripe-prices')
|
||||
const { SUBSCRIPTION_TRIAL_DAYS } = await import('@/lib/billing/trial-constants')
|
||||
const billingEnabled = await isBillingEnabled()
|
||||
|
||||
const priceKeys = [
|
||||
...BILLING_CONFIG_KEYS.filter((k) => k.startsWith('STRIPE_PRICE_')),
|
||||
] as string[]
|
||||
|
||||
const canCallStripe = secretMode === 'test' || secretMode === 'live'
|
||||
let stripe: Awaited<ReturnType<typeof import('@/lib/stripe').getStripe>> | null = null
|
||||
if (canCallStripe) {
|
||||
try {
|
||||
const { getStripe } = await import('@/lib/stripe')
|
||||
stripe = getStripe()
|
||||
} catch {
|
||||
stripe = null
|
||||
}
|
||||
}
|
||||
|
||||
const prices = await Promise.all(
|
||||
priceKeys.map(async (key) => {
|
||||
const priceId = (billingConfig[key] || process.env[key] || '').trim()
|
||||
const configured = Boolean(priceId) && !priceId.startsWith('price_mock_')
|
||||
let stripeAmount: number | null = null
|
||||
let stripeCurrency: string | null = null
|
||||
let stripeActive: boolean | null = null
|
||||
let error: string | null = null
|
||||
|
||||
if (configured && stripe) {
|
||||
try {
|
||||
const price = await stripe.prices.retrieve(priceId)
|
||||
stripeAmount = price.unit_amount != null ? price.unit_amount / 100 : null
|
||||
stripeCurrency = price.currency?.toUpperCase() ?? null
|
||||
stripeActive = price.active
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Stripe price lookup failed'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
key,
|
||||
priceId: configured ? priceId : '',
|
||||
configured,
|
||||
source: billingConfig[key] ? 'db' : process.env[key] ? 'env' : 'missing',
|
||||
stripeAmount,
|
||||
stripeCurrency,
|
||||
stripeActive,
|
||||
error,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
secretConfigured: secretMode === 'test' || secretMode === 'live',
|
||||
secretMode,
|
||||
publishableConfigured: Boolean(publishable) && publishable.startsWith('pk_'),
|
||||
webhookSecretConfigured: Boolean(webhook) && webhook.startsWith('whsec_'),
|
||||
billingEnabled,
|
||||
trialDays: SUBSCRIPTION_TRIAL_DAYS,
|
||||
prices,
|
||||
}
|
||||
}
|
||||
|
||||
async function getSubscriptionStats() {
|
||||
const [byTierRaw, byStatusRaw, cancelAtPeriodEnd, recent] = await Promise.all([
|
||||
prisma.subscription.groupBy({
|
||||
by: ['tier'],
|
||||
_count: { _all: true },
|
||||
}),
|
||||
prisma.subscription.groupBy({
|
||||
by: ['status'],
|
||||
_count: { _all: true },
|
||||
}),
|
||||
prisma.subscription.count({ where: { cancelAtPeriodEnd: true } }),
|
||||
prisma.subscription.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ tier: { in: ['PRO', 'BUSINESS', 'ENTERPRISE'] } },
|
||||
{ status: { in: ['TRIALING', 'PAST_DUE', 'ACTIVE'] } },
|
||||
],
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 15,
|
||||
select: {
|
||||
tier: true,
|
||||
status: true,
|
||||
trialEndsAt: true,
|
||||
currentPeriodEnd: true,
|
||||
cancelAtPeriodEnd: true,
|
||||
updatedAt: true,
|
||||
stripeSubscriptionId: true,
|
||||
user: { select: { email: true, name: true } },
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
const byTier: Record<string, number> = Object.fromEntries(TIERS.map((t) => [t, 0]))
|
||||
for (const row of byTierRaw) {
|
||||
byTier[row.tier] = row._count._all
|
||||
}
|
||||
|
||||
const byStatus: Record<string, number> = {}
|
||||
for (const row of byStatusRaw) {
|
||||
byStatus[row.status] = row._count._all
|
||||
}
|
||||
|
||||
const usersWithoutSub = await prisma.user.count({
|
||||
where: { subscription: null },
|
||||
})
|
||||
|
||||
return {
|
||||
byTier,
|
||||
byStatus,
|
||||
cancelAtPeriodEnd,
|
||||
usersWithoutSub,
|
||||
trialing: byStatus.TRIALING ?? 0,
|
||||
pastDue: byStatus.PAST_DUE ?? 0,
|
||||
paidActive:
|
||||
(byStatus.ACTIVE ?? 0) +
|
||||
(byStatus.TRIALING ?? 0),
|
||||
recent: recent.map((s) => ({
|
||||
email: s.user.email,
|
||||
name: s.user.name,
|
||||
tier: s.tier,
|
||||
status: s.status,
|
||||
trialEndsAt: s.trialEndsAt?.toISOString() ?? null,
|
||||
currentPeriodEnd: s.currentPeriodEnd?.toISOString() ?? null,
|
||||
cancelAtPeriodEnd: s.cancelAtPeriodEnd,
|
||||
hasStripeSub: Boolean(s.stripeSubscriptionId),
|
||||
updatedAt: s.updatedAt.toISOString(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBillingAdminData() {
|
||||
await checkAdmin()
|
||||
const { getSystemConfig } = await import('@/lib/config')
|
||||
@@ -83,6 +229,11 @@ export async function getBillingAdminData() {
|
||||
}
|
||||
})
|
||||
|
||||
const [stripeHealth, subscriptionStats] = await Promise.all([
|
||||
getStripeHealth(billingConfig),
|
||||
getSubscriptionStats(),
|
||||
])
|
||||
|
||||
return {
|
||||
entitlements,
|
||||
billingConfig,
|
||||
@@ -92,6 +243,8 @@ export async function getBillingAdminData() {
|
||||
creditAllocations,
|
||||
creditCosts,
|
||||
creditPacks,
|
||||
stripeHealth,
|
||||
subscriptionStats,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
11
memento-note/app/actions/auth-verify.ts
Normal file
11
memento-note/app/actions/auth-verify.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
'use server'
|
||||
|
||||
import { verifyEmailToken, resendVerificationEmail } from '@/lib/auth/email-verification'
|
||||
|
||||
export async function confirmEmail(token: string) {
|
||||
return verifyEmailToken(token)
|
||||
}
|
||||
|
||||
export async function resendSignupVerification(email: string, locale?: string) {
|
||||
return resendVerificationEmail(email, locale)
|
||||
}
|
||||
@@ -2,20 +2,49 @@
|
||||
|
||||
import { signIn } from '@/auth';
|
||||
import { AuthError } from 'next-auth';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import prisma from '@/lib/prisma';
|
||||
|
||||
export async function authenticate(
|
||||
prevState: string | undefined,
|
||||
formData: FormData,
|
||||
) {
|
||||
const emailRaw = formData.get('email');
|
||||
const passwordRaw = formData.get('password');
|
||||
const email = typeof emailRaw === 'string' ? emailRaw.toLowerCase().trim() : '';
|
||||
const password = typeof passwordRaw === 'string' ? passwordRaw : '';
|
||||
|
||||
// Surface a clear message when credentials are valid but email is unverified.
|
||||
if (email && password.length >= 6) {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
if (user?.password) {
|
||||
const match = await bcrypt.compare(password, user.password);
|
||||
if (match && !user.emailVerified) {
|
||||
return 'EMAIL_NOT_VERIFIED';
|
||||
}
|
||||
}
|
||||
} catch (preCheckErr) {
|
||||
console.error('[authenticate] emailVerified pre-check failed:', preCheckErr);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await signIn('credentials', {
|
||||
email: formData.get('email'),
|
||||
password: formData.get('password'),
|
||||
email,
|
||||
password,
|
||||
redirectTo: '/home',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
console.error('AuthError details:', error.type, error.message);
|
||||
if (
|
||||
error.type === 'CredentialsSignin' &&
|
||||
(error.message?.includes('EMAIL_NOT_VERIFIED') ||
|
||||
(error.cause as { err?: Error } | undefined)?.err?.message === 'EMAIL_NOT_VERIFIED')
|
||||
) {
|
||||
return 'EMAIL_NOT_VERIFIED';
|
||||
}
|
||||
switch (error.type) {
|
||||
case 'CredentialsSignin':
|
||||
return 'Invalid credentials.';
|
||||
|
||||
@@ -5,6 +5,7 @@ import prisma from '@/lib/prisma';
|
||||
import { z } from 'zod';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getSystemConfig } from '@/lib/config';
|
||||
import { sendVerificationEmail } from '@/lib/auth/email-verification';
|
||||
|
||||
const RegisterSchema = z.object({
|
||||
email: z.string().email(),
|
||||
@@ -17,9 +18,8 @@ const RegisterSchema = z.object({
|
||||
});
|
||||
|
||||
export async function register(prevState: string | undefined, formData: FormData) {
|
||||
// Check if registration is allowed
|
||||
const config = await getSystemConfig();
|
||||
const allowRegister = config.ALLOW_REGISTRATION !== 'false' || process.env.ALLOW_REGISTRATION !== 'false';
|
||||
const allowRegister = config.ALLOW_REGISTRATION !== 'false' && process.env.ALLOW_REGISTRATION !== 'false';
|
||||
|
||||
if (!allowRegister) {
|
||||
return 'Registration is currently disabled by the administrator.';
|
||||
@@ -37,36 +37,48 @@ export async function register(prevState: string | undefined, formData: FormData
|
||||
}
|
||||
|
||||
const { email, password, name } = validatedFields.data;
|
||||
const normalizedEmail = email.toLowerCase();
|
||||
const adminEmail = process.env.ADMIN_EMAIL?.toLowerCase();
|
||||
const isAdmin = Boolean(adminEmail && normalizedEmail === adminEmail);
|
||||
|
||||
try {
|
||||
const existingUser = await prisma.user.findUnique({ where: { email: email.toLowerCase() } });
|
||||
const existingUser = await prisma.user.findUnique({ where: { email: normalizedEmail } });
|
||||
if (existingUser) {
|
||||
return 'User already exists.';
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
const adminEmail = process.env.ADMIN_EMAIL?.toLowerCase();
|
||||
const role = adminEmail && email.toLowerCase() === adminEmail ? 'ADMIN' : 'USER';
|
||||
const role = isAdmin ? 'ADMIN' : 'USER';
|
||||
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
email: email.toLowerCase(),
|
||||
email: normalizedEmail,
|
||||
password: hashedPassword,
|
||||
name,
|
||||
role,
|
||||
// Admin bootstrap + Google OAuth are trusted; everyone else must verify.
|
||||
emailVerified: isAdmin ? new Date() : null,
|
||||
},
|
||||
});
|
||||
|
||||
// Attempt to sign in immediately after registration
|
||||
// We cannot import signIn here directly if it causes circular deps or issues,
|
||||
// but usually it works. If not, redirecting to login is fine.
|
||||
// Let's stick to redirecting to login but with a clear success message?
|
||||
// Or better: lowercase the email to fix the potential bug.
|
||||
|
||||
if (!isAdmin) {
|
||||
const mailResult = await sendVerificationEmail({
|
||||
email: normalizedEmail,
|
||||
name,
|
||||
});
|
||||
|
||||
if (!mailResult.success) {
|
||||
console.error('[register] verification email failed:', mailResult.error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Registration Error:', error);
|
||||
return 'Database Error: Failed to create user.';
|
||||
}
|
||||
|
||||
redirect('/login');
|
||||
if (isAdmin) {
|
||||
redirect('/login?verified=1');
|
||||
}
|
||||
|
||||
redirect(`/check-email?email=${encodeURIComponent(normalizedEmail)}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user