Compare commits

...

16 Commits

Author SHA1 Message Date
Ravi 8326133a70
Update README.md 2026-05-29 00:26:12 +05:30
Ravi 586a7e1d8b
Merge pull request #79 from Chukwuebuka-2003/add-password-requirements-ui
Add visible password requirements with live validation checklist
2026-05-29 00:17:54 +05:30
Chukuwebuka-2003 f2d72d8825 Add visible password requirements with live validation checklist
- Add PasswordRequirements component showing rules with live checkmarks
- Add PASSWORD_RULES and PASSWORD_VALIDATION constants
- Update sign-up and reset-password forms to show requirements
2026-05-26 14:01:37 +01:00
Ravi e06802738c
Merge pull request #71 from keshav-005/fix-66-password-reset
Add password reset flow
2026-05-03 01:11:43 +05:30
Ravi b3406bb9b1
Merge pull request #72 from keshav-005/fix-35-tradingview-timezone
Fix stock chart timezone display
2026-05-03 01:09:32 +05:30
Ravi a114b007e7
Merge pull request #74 from keshav-005/fix-39-turbopack-root
Set the Turbopack project root
2026-04-30 02:59:02 +05:30
Ravi 346c6bebd5
Merge pull request #73 from keshav-005/fix-55-search-ticker-metadata
Fix search ticker metadata
2026-04-30 02:58:02 +05:30
keshav-005 80454b9141 Refine search exchange label fallback 2026-04-23 00:48:24 +05:30
keshav-005 2c193bfacb Tighten search exchange suffix fallback 2026-04-22 23:51:04 +05:30
keshav-005 723aed4c9d Set the Turbopack project root 2026-04-22 23:43:24 +05:30
keshav-005 6bc464acce Fix search ticker metadata 2026-04-22 23:37:34 +05:30
keshav-005 50b999de71 Tighten reset email escaping test 2026-04-22 23:29:49 +05:30
keshav-005 eae1dafb01 Use exchange timezone for stock charts 2026-04-22 23:22:48 +05:30
keshav-005 6362526168 Add password reset email tests 2026-04-22 23:16:01 +05:30
keshav-005 0440110da9 Address password reset review feedback 2026-04-22 23:07:48 +05:30
keshav-005 0b40dcdb14 Add password reset flow 2026-04-22 21:17:57 +05:30
16 changed files with 527 additions and 31 deletions

View File

@ -9,6 +9,7 @@
style="width: auto; height: 54px;"
/>
</a>
<a href="https://www.star-history.com/open-dev-society/openstock"><img src="https://api.star-history.com/badge?repo=Open-Dev-Society/OpenStock" alt="Star History Rank" /></a>
<div align="center">
<br />
<a href="#" target="_blank">

View File

@ -0,0 +1,58 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/lib/nodemailer', () => ({
transporter: {
sendMail: vi.fn(),
},
}));
import { transporter } from '@/lib/nodemailer';
import { sendPasswordResetEmail } from '@/lib/nodemailer/reset-password';
describe('sendPasswordResetEmail', () => {
const originalEnv = { ...process.env };
const sendMailMock = vi.mocked(transporter.sendMail);
beforeEach(() => {
process.env = {
...originalEnv,
NODEMAILER_EMAIL: 'sender@example.com',
NODEMAILER_PASSWORD: 'secret',
};
sendMailMock.mockReset();
sendMailMock.mockResolvedValue({ messageId: 'msg-123' } as never);
});
afterEach(() => {
process.env = { ...originalEnv };
});
it('escapes interpolated values before building the HTML email', async () => {
await sendPasswordResetEmail({
email: 'user@example.com',
name: '<Admin&Co.>',
resetUrl: 'https://example.com/reset-password?token=a b&next=<script>',
});
expect(sendMailMock).toHaveBeenCalledTimes(1);
const [mailOptions] = sendMailMock.mock.calls[0];
expect(mailOptions.html).toContain('Hi &lt;Admin&amp;Co.&gt;,');
expect(mailOptions.html).toContain('href="https://example.com/reset-password?token=a%20b&amp;next=%3Cscript%3E"');
expect(mailOptions.html).not.toContain('<script>');
expect(mailOptions.text).toContain('https://example.com/reset-password?token=a%20b&next=%3Cscript%3E');
});
it('throws when reset email credentials are missing', async () => {
delete process.env.NODEMAILER_EMAIL;
delete process.env.NODEMAILER_PASSWORD;
await expect(
sendPasswordResetEmail({
email: 'user@example.com',
name: 'User',
resetUrl: 'https://example.com/reset-password?token=test',
})
).rejects.toThrow('Email credentials not configured');
});
});

View File

@ -0,0 +1,81 @@
'use client';
import React from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import InputField from '@/components/forms/InputField';
import FooterLink from '@/components/forms/FooterLink';
import OpenDevSocietyBranding from '@/components/OpenDevSocietyBranding';
import { requestPasswordResetEmail } from '@/lib/actions/auth.actions';
type ForgotPasswordFormData = {
email: string;
};
const ForgotPasswordPage = () => {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<ForgotPasswordFormData>({
defaultValues: {
email: '',
},
mode: 'onBlur',
});
const onSubmit = async (data: ForgotPasswordFormData) => {
try {
const result = await requestPasswordResetEmail(data);
if (result.success) {
toast.success('If an account exists for that email, a reset link has been sent.');
return;
}
toast.error('Password reset unavailable', {
description: result.error ?? 'Unable to start password reset.',
});
} catch (error) {
toast.error('Password reset unavailable', {
description: error instanceof Error ? error.message : 'Unable to start password reset.',
});
}
};
return (
<>
<h1 className="form-title">Forgot your password?</h1>
<p className="text-sm text-gray-400 mb-6">
Enter your email address and we&apos;ll send you a password reset link.
</p>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<InputField
name="email"
label="Email"
placeholder="opendevsociety@cc.cc"
register={register}
error={errors.email}
validation={{
required: 'Email is required',
pattern: {
value: /^[\w-.]+@([\w-]+\.)+[\w-]{2,}$/,
message: 'Please enter a valid email address',
},
}}
/>
<Button type="submit" disabled={isSubmitting} className="yellow-btn w-full mt-5">
{isSubmitting ? 'Sending reset link' : 'Send reset link'}
</Button>
<FooterLink text="Remembered it?" linkText="Sign in" href="/sign-in" />
<OpenDevSocietyBranding outerClassName="mt-10 flex justify-center" />
</form>
</>
);
};
export default ForgotPasswordPage;

View File

@ -0,0 +1,120 @@
'use client';
import React, { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { useRouter, useSearchParams } from 'next/navigation';
import { toast } from 'sonner';
import FooterLink from '@/components/forms/FooterLink';
import InputField from '@/components/forms/InputField';
import PasswordRequirements from '@/components/forms/PasswordRequirements';
import OpenDevSocietyBranding from '@/components/OpenDevSocietyBranding';
import { Button } from '@/components/ui/button';
import { resetPasswordWithToken } from '@/lib/actions/auth.actions';
import { PASSWORD_VALIDATION } from '@/lib/constants';
type ResetPasswordFormData = {
newPassword: string;
confirmPassword: string;
};
const ResetPasswordForm = () => {
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get('token') ?? '';
const error = searchParams.get('error');
const {
register,
watch,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<ResetPasswordFormData>({
defaultValues: {
newPassword: '',
confirmPassword: '',
},
mode: 'onBlur',
});
const newPassword = watch('newPassword');
useEffect(() => {
if (error === 'INVALID_TOKEN') {
toast.error('Reset link is invalid or expired.');
}
}, [error]);
const onSubmit = async (data: ResetPasswordFormData) => {
if (!token) {
toast.error('Reset link is invalid or expired.');
return;
}
try {
const result = await resetPasswordWithToken({
token,
newPassword: data.newPassword,
});
if (result.success) {
toast.success('Password updated. You can sign in now.');
router.push('/sign-in');
return;
}
toast.error('Password reset failed', {
description: result.error ?? 'Unable to reset your password.',
});
} catch (error) {
toast.error('Password reset failed', {
description: error instanceof Error ? error.message : 'Unable to reset your password.',
});
}
};
return (
<>
<h1 className="form-title">Choose a new password</h1>
<p className="text-sm text-gray-400 mb-6">
Enter a new password for your account.
</p>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<InputField
name="newPassword"
label="New Password"
placeholder="Enter a new password"
type="password"
register={register}
error={errors.newPassword}
validation={PASSWORD_VALIDATION}
/>
<PasswordRequirements password={newPassword ?? ''} />
<InputField
name="confirmPassword"
label="Confirm Password"
placeholder="Confirm your new password"
type="password"
register={register}
error={errors.confirmPassword}
validation={{
required: 'Please confirm your new password',
validate: (value: string) =>
value === newPassword || 'Passwords do not match',
}}
/>
<Button type="submit" disabled={isSubmitting} className="yellow-btn w-full mt-5">
{isSubmitting ? 'Resetting password' : 'Reset password'}
</Button>
<FooterLink text="Need a fresh link?" linkText="Request another one" href="/forgot-password" />
<OpenDevSocietyBranding outerClassName="mt-10 flex justify-center" />
</form>
</>
);
};
export default ResetPasswordForm;

View File

@ -0,0 +1,13 @@
import { Suspense } from 'react';
import ResetPasswordForm from './ResetPasswordForm';
const ResetPasswordPage = () => {
return (
<Suspense fallback={<div className="text-sm text-gray-400">Loading reset form...</div>}>
<ResetPasswordForm />
</Suspense>
);
};
export default ResetPasswordPage;

View File

@ -4,9 +4,9 @@ import { useForm } from 'react-hook-form';
import { Button } from '@/components/ui/button';
import InputField from '@/components/forms/InputField';
import FooterLink from '@/components/forms/FooterLink';
import { signInWithEmail, signUpWithEmail } from "@/lib/actions/auth.actions";
import { signInWithEmail } from "@/lib/actions/auth.actions";
import { toast } from "sonner";
import { signInEmail } from "better-auth/api";
import Link from "next/link";
import { useRouter } from "next/navigation";
import OpenDevSocietyBranding from "@/components/OpenDevSocietyBranding";
import React from "react";
@ -73,6 +73,12 @@ const SignIn = () => {
validation={{ required: 'Password is required', minLength: 8 }}
/>
<div className="flex justify-end">
<Link href="/forgot-password" className="footer-link text-sm">
Forgot password?
</Link>
</div>
<Button type="submit" disabled={isSubmitting} className="yellow-btn w-full mt-5">
{isSubmitting ? 'Signing In' : 'Sign In'}
</Button>

View File

@ -4,7 +4,8 @@ import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import InputField from "@/components/forms/InputField";
import SelectField from "@/components/forms/SelectField";
import { INVESTMENT_GOALS, PREFERRED_INDUSTRIES, RISK_TOLERANCE_OPTIONS } from "@/lib/constants";
import PasswordRequirements from "@/components/forms/PasswordRequirements";
import { INVESTMENT_GOALS, PASSWORD_VALIDATION, PREFERRED_INDUSTRIES, RISK_TOLERANCE_OPTIONS } from "@/lib/constants";
import { CountrySelectField } from "@/components/forms/CountrySelectField";
import FooterLink from "@/components/forms/FooterLink";
import { signUpWithEmail } from "@/lib/actions/auth.actions";
@ -19,6 +20,7 @@ const SignUp = () => {
register,
handleSubmit,
control,
watch,
formState: { errors, isSubmitting },
} = useForm<SignUpFormData>({
defaultValues: {
@ -33,6 +35,8 @@ const SignUp = () => {
mode: 'onBlur'
},);
const passwordValue = watch('password');
const onSubmit = async (data: SignUpFormData) => {
try {
const result = await signUpWithEmail(data);
@ -87,8 +91,9 @@ const SignUp = () => {
type="password"
register={register}
error={errors.password}
validation={{ required: 'Password is required', minLength: 8 }}
validation={PASSWORD_VALIDATION}
/>
<PasswordRequirements password={passwordValue ?? ''} />
<CountrySelectField
name="country"

View File

@ -46,7 +46,7 @@ export default function SearchCommand({ renderAs = 'button', label = 'Add stock'
useEffect(() => {
debouncedSearch();
}, [searchTerm]);
}, [debouncedSearch, searchTerm]);
const handleSelectStock = () => {
setOpen(false);
@ -87,7 +87,7 @@ export default function SearchCommand({ renderAs = 'button', label = 'Add stock'
{isSearchMode ? 'Search results' : 'Popular stocks'}
{` `}({displayStocks?.length || 0})
</div>
{displayStocks?.map((stock, i) => (
{displayStocks?.map((stock) => (
<li key={stock.symbol} className="search-item">
<Link
href={`/stocks/${stock.symbol}`}
@ -100,7 +100,7 @@ export default function SearchCommand({ renderAs = 'button', label = 'Add stock'
{stock.name}
</div>
<div className="text-sm text-gray-500">
{stock.symbol} | {stock.exchange } | {stock.type}
{[stock.symbol, stock.exchange, stock.type].filter(Boolean).join(' | ')}
</div>
</div>

View File

@ -0,0 +1,38 @@
'use client';
import React from 'react';
import { PASSWORD_RULES } from '@/lib/constants';
import { cn } from '@/lib/utils';
import { Check, X } from 'lucide-react';
const PasswordRequirements = ({ password }: { password: string }) => {
return (
<ul className="space-y-1.5 mt-2">
{PASSWORD_RULES.map((rule) => {
const passed = rule.test(password);
return (
<li key={rule.label} className="flex items-center gap-2 text-xs">
{password.length === 0 ? (
<span className="size-3.5 rounded-full border border-gray-500" />
) : passed ? (
<Check className="size-3.5 text-green-500" />
) : (
<X className="size-3.5 text-red-500" />
)}
<span
className={cn(
'transition-colors',
password.length === 0 && 'text-gray-500',
passed ? 'text-green-500' : password.length > 0 && 'text-red-500',
)}
>
{rule.label}
</span>
</li>
);
})}
</ul>
);
};
export default PasswordRequirements;

View File

@ -58,6 +58,56 @@ export const signInWithEmail = async ({ email, password }: SignInFormData) => {
}
}
export const requestPasswordResetEmail = async ({ email }: { email: string }) => {
if (!process.env.NODEMAILER_EMAIL || !process.env.NODEMAILER_PASSWORD) {
return { success: false, error: 'Password reset email is not configured.' }
}
try {
const configuredBaseUrl = process.env.BETTER_AUTH_URL;
const baseUrl = configuredBaseUrl || (
process.env.NODE_ENV !== 'production' ? 'http://localhost:3000' : null
);
if (!baseUrl) {
return {
success: false,
error: 'BETTER_AUTH_URL must be configured before password reset emails can be sent.',
}
}
await auth.api.requestPasswordReset({
body: {
email,
redirectTo: `${baseUrl}/reset-password`,
},
});
return { success: true }
} catch (e) {
console.log('Password reset request failed', e)
return { success: false, error: 'Unable to send password reset email.' }
}
}
export const resetPasswordWithToken = async (
{ token, newPassword }: { token: string; newPassword: string }
) => {
try {
await auth.api.resetPassword({
body: {
token,
newPassword,
},
});
return { success: true }
} catch (e) {
console.log('Password reset failed', e)
return { success: false, error: 'Reset link is invalid or expired.' }
}
}
export const signOut = async () => {
try {
await auth.api.signOut({ headers: await headers() });

View File

@ -7,6 +7,32 @@ import { cache } from 'react';
const FINNHUB_BASE_URL = 'https://finnhub.io/api/v1';
const NEXT_PUBLIC_FINNHUB_API_KEY = process.env.NEXT_PUBLIC_FINNHUB_API_KEY ?? '';
type FinnhubQuote = {
c?: number;
d?: number;
dp?: number;
};
type FinnhubCompanyProfile = {
currency?: string;
exchange?: string;
logo?: string;
marketCapitalization?: number;
name?: string;
ticker?: string;
};
type SearchStockCandidate = FinnhubSearchResult & {
__exchange?: string;
};
const FINNHUB_EXCHANGE_SUFFIXES = new Set([
'AS', 'AT', 'AX', 'BA', 'BK', 'BO', 'BR', 'CO', 'DE', 'F', 'HE', 'HK',
'IL', 'IS', 'JK', 'JO', 'KL', 'KQ', 'KS', 'L', 'LS', 'MC', 'MI', 'MX',
'NS', 'NZ', 'OL', 'PA', 'PR', 'SA', 'SI', 'SS', 'ST', 'SW', 'SZ', 'T',
'TA', 'TO', 'TW', 'TWO', 'V', 'VI', 'WA',
]);
async function fetchJSON<T>(url: string, revalidateSeconds?: number): Promise<T> {
const options: RequestInit & { next?: { revalidate?: number } } = revalidateSeconds
? { cache: 'force-cache', next: { revalidate: revalidateSeconds } }
@ -22,12 +48,27 @@ async function fetchJSON<T>(url: string, revalidateSeconds?: number): Promise<T>
export { fetchJSON };
function getExchangeLabel(symbol: string, exchange?: string) {
if (exchange?.trim()) {
return exchange.trim();
}
const parts = symbol.split('.');
const suffix = parts.length > 1 ? parts[parts.length - 1].toUpperCase() : '';
if (!suffix) {
return 'US';
}
return FINNHUB_EXCHANGE_SUFFIXES.has(suffix) ? suffix : 'US';
}
export async function getQuote(symbol: string) {
try {
const token = NEXT_PUBLIC_FINNHUB_API_KEY;
const url = `${FINNHUB_BASE_URL}/quote?symbol=${encodeURIComponent(symbol)}&token=${token}`;
// No caching for real-time price
return await fetchJSON<any>(url, 0);
return await fetchJSON<FinnhubQuote>(url, 0);
} catch (e) {
console.error('Error fetching quote for', symbol, e);
return null;
@ -39,7 +80,7 @@ export async function getCompanyProfile(symbol: string) {
const token = NEXT_PUBLIC_FINNHUB_API_KEY;
const url = `${FINNHUB_BASE_URL}/stock/profile2?symbol=${encodeURIComponent(symbol)}&token=${token}`;
// Cache profile for 24 hours
return await fetchJSON<any>(url, 86400);
return await fetchJSON<FinnhubCompanyProfile>(url, 86400);
} catch (e) {
console.error('Error fetching profile for', symbol, e);
return null;
@ -160,7 +201,7 @@ export const searchStocks = cache(async (query?: string): Promise<StockWithWatch
const trimmed = typeof query === 'string' ? query.trim() : '';
let results: FinnhubSearchResult[] = [];
let results: SearchStockCandidate[] = [];
if (!trimmed) {
// Fetch top 10 popular symbols' profiles
@ -170,11 +211,11 @@ export const searchStocks = cache(async (query?: string): Promise<StockWithWatch
try {
const url = `${FINNHUB_BASE_URL}/stock/profile2?symbol=${encodeURIComponent(sym)}&token=${token}`;
// Revalidate every hour
const profile = await fetchJSON<any>(url, 3600);
return { sym, profile } as { sym: string; profile: any };
const profile = await fetchJSON<FinnhubCompanyProfile>(url, 3600);
return { sym, profile } as { sym: string; profile: FinnhubCompanyProfile | null };
} catch (e) {
console.error('Error fetching profile2 for', sym, e);
return { sym, profile: null } as { sym: string; profile: any };
return { sym, profile: null } as { sym: string; profile: FinnhubCompanyProfile | null };
}
})
);
@ -185,19 +226,16 @@ export const searchStocks = cache(async (query?: string): Promise<StockWithWatch
const name: string | undefined = profile?.name || profile?.ticker || undefined;
const exchange: string | undefined = profile?.exchange || undefined;
if (!name) return undefined;
const r: FinnhubSearchResult = {
const r: SearchStockCandidate = {
symbol,
description: name,
displaySymbol: symbol,
type: 'Common Stock',
};
// We don't include exchange in FinnhubSearchResult type, so carry via mapping later using profile
// To keep pipeline simple, attach exchange via closure map stage
// We'll reconstruct exchange when mapping to final type
(r as any).__exchange = exchange; // internal only
r.__exchange = exchange;
return r;
})
.filter((x): x is FinnhubSearchResult => Boolean(x));
.filter((x): x is SearchStockCandidate => Boolean(x));
} else {
const url = `${FINNHUB_BASE_URL}/search?q=${encodeURIComponent(trimmed)}&token=${token}`;
const data = await fetchJSON<FinnhubSearchResponse>(url, 1800);
@ -208,9 +246,8 @@ export const searchStocks = cache(async (query?: string): Promise<StockWithWatch
.map((r) => {
const upper = (r.symbol || '').toUpperCase();
const name = r.description || upper;
const exchangeFromDisplay = (r.displaySymbol as string | undefined) || undefined;
const exchangeFromProfile = (r as any).__exchange as string | undefined;
const exchange = exchangeFromDisplay || exchangeFromProfile || 'US';
const exchangeFromProfile = r.__exchange;
const exchange = getExchangeLabel(upper, exchangeFromProfile);
const type = r.type || 'Stock';
const item: StockWithWatchlistStatus = {
symbol: upper,

View File

@ -2,6 +2,7 @@ import { betterAuth } from "better-auth";
import {mongodbAdapter} from "better-auth/adapters/mongodb";
import {connectToDatabase} from "@/database/mongoose";
import {nextCookies} from "better-auth/next-js";
import { sendPasswordResetEmail } from "@/lib/nodemailer/reset-password";
let authInstance: ReturnType<typeof betterAuth> | null = null;
@ -14,13 +15,14 @@ export const getAuth = async () => {
const mongoose = await connectToDatabase();
const db = mongoose.connection;
const database = db.db;
if (!db) {
if (!db || !database) {
throw new Error("MongoDB connection not found!");
}
authInstance = betterAuth({
database: mongodbAdapter(db as any),
database: mongodbAdapter(database),
secret: process.env.BETTER_AUTH_SECRET,
baseURL: process.env.BETTER_AUTH_URL,
emailAndPassword: {
@ -30,6 +32,15 @@ export const getAuth = async () => {
minPasswordLength: 8,
maxPasswordLength: 128,
autoSignIn: true,
sendResetPassword: async ({ user, url }) => {
void sendPasswordResetEmail({
email: user.email,
name: user.name,
resetUrl: url,
}).catch((error) => {
console.error('Failed to queue password reset email:', error);
});
},
},
plugins: [nextCookies()],

View File

@ -195,7 +195,7 @@ export const CANDLE_CHART_WIDGET_CONFIG = (symbol: string) => ({
style: 1,
symbol: symbol.toUpperCase(),
theme: 'dark',
timezone: 'Etc/UTC',
timezone: 'exchange',
backgroundColor: '#141414',
gridColor: '#141414',
watchlist: [],
@ -221,7 +221,7 @@ export const BASELINE_WIDGET_CONFIG = (symbol: string) => ({
style: 10,
symbol: symbol.toUpperCase(),
theme: 'dark',
timezone: 'Etc/UTC',
timezone: 'exchange',
backgroundColor: '#141414',
gridColor: '#141414',
watchlist: [],
@ -338,3 +338,19 @@ export const WATCHLIST_TABLE_HEADER = [
'Alert',
'Action',
];
export const PASSWORD_RULES = [
{ label: 'At least 8 characters', test: (pw: string) => pw.length >= 8 },
{ label: 'At least 1 uppercase letter', test: (pw: string) => /[A-Z]/.test(pw) },
{ label: 'At least 1 lowercase letter', test: (pw: string) => /[a-z]/.test(pw) },
{ label: 'At least 1 number', test: (pw: string) => /[0-9]/.test(pw) },
] as const;
export const PASSWORD_VALIDATION = {
required: 'Password is required',
minLength: { value: 8, message: 'Password must be at least 8 characters' },
pattern: {
value: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/,
message: 'Password must include uppercase, lowercase, and a number',
},
};

View File

@ -0,0 +1,57 @@
import { transporter } from "@/lib/nodemailer";
const escapeHtml = (value: string) =>
value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
export const sendPasswordResetEmail = async (
{ email, name, resetUrl }: { email: string; name?: string | null; resetUrl: string }
) => {
try {
if (!process.env.NODEMAILER_EMAIL || !process.env.NODEMAILER_PASSWORD) {
throw new Error('Email credentials not configured');
}
const firstName = name?.trim().split(' ')[0] || 'there';
const escapedFirstName = escapeHtml(firstName);
const escapedResetUrl = escapeHtml(encodeURI(resetUrl));
const html = `
<div style="background:#000;padding:32px;font-family:Arial,sans-serif;color:#fff;">
<div style="max-width:560px;margin:0 auto;border:1px solid #333;border-radius:12px;padding:32px;background:#111;">
<h1 style="margin:0 0 16px;font-size:28px;">Reset your password</h1>
<p style="margin:0 0 16px;color:#d4d4d8;">Hi ${escapedFirstName},</p>
<p style="margin:0 0 24px;color:#d4d4d8;line-height:1.6;">
We received a request to reset your Openstock password. Use the button below to choose a new one.
</p>
<a
href="${escapedResetUrl}"
style="display:inline-block;background:#facc15;color:#111827;padding:12px 20px;border-radius:9999px;text-decoration:none;font-weight:700;"
>
Reset password
</a>
<p style="margin:24px 0 0;color:#a1a1aa;line-height:1.6;">
If you did not request this, you can safely ignore this email.
</p>
</div>
</div>
`;
const info = await transporter.sendMail({
from: `"Openstock" <${process.env.NODEMAILER_EMAIL}>`,
to: email,
subject: 'Reset your Openstock password',
text: `Reset your password: ${encodeURI(resetUrl)}`,
html,
});
console.log('Password reset email sent successfully:', info.messageId);
return info;
} catch (error) {
console.error('Failed to send password reset email:', error);
throw error;
}
};

View File

@ -14,6 +14,6 @@ export async function middleware(request: NextRequest) {
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|favicon.ico|sign-in|sign-up|assets).*)',
'/((?!api|_next/static|_next/image|favicon.ico|sign-in|sign-up|forgot-password|reset-password|assets).*)',
],
};

View File

@ -2,6 +2,9 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
devIndicators: false,
turbopack: {
root: process.cwd(),
},
/* config options here */
images: {
remotePatterns: [