Compare commits
16 Commits
fe59884827
...
8326133a70
| Author | SHA1 | Date |
|---|---|---|
|
|
8326133a70 | |
|
|
586a7e1d8b | |
|
|
f2d72d8825 | |
|
|
e06802738c | |
|
|
b3406bb9b1 | |
|
|
a114b007e7 | |
|
|
346c6bebd5 | |
|
|
80454b9141 | |
|
|
2c193bfacb | |
|
|
723aed4c9d | |
|
|
6bc464acce | |
|
|
50b999de71 | |
|
|
eae1dafb01 | |
|
|
6362526168 | |
|
|
0440110da9 | |
|
|
0b40dcdb14 |
|
|
@ -9,6 +9,7 @@
|
||||||
style="width: auto; height: 54px;"
|
style="width: auto; height: 54px;"
|
||||||
/>
|
/>
|
||||||
</a>
|
</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">
|
<div align="center">
|
||||||
<br />
|
<br />
|
||||||
<a href="#" target="_blank">
|
<a href="#" target="_blank">
|
||||||
|
|
|
||||||
|
|
@ -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 <Admin&Co.>,');
|
||||||
|
expect(mailOptions.html).toContain('href="https://example.com/reset-password?token=a%20b&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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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'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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -4,9 +4,9 @@ import { useForm } from 'react-hook-form';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import InputField from '@/components/forms/InputField';
|
import InputField from '@/components/forms/InputField';
|
||||||
import FooterLink from '@/components/forms/FooterLink';
|
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 { toast } from "sonner";
|
||||||
import { signInEmail } from "better-auth/api";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import OpenDevSocietyBranding from "@/components/OpenDevSocietyBranding";
|
import OpenDevSocietyBranding from "@/components/OpenDevSocietyBranding";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
@ -73,6 +73,12 @@ const SignIn = () => {
|
||||||
validation={{ required: 'Password is required', minLength: 8 }}
|
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">
|
<Button type="submit" disabled={isSubmitting} className="yellow-btn w-full mt-5">
|
||||||
{isSubmitting ? 'Signing In' : 'Sign In'}
|
{isSubmitting ? 'Signing In' : 'Sign In'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@ import { useForm } from "react-hook-form";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import InputField from "@/components/forms/InputField";
|
import InputField from "@/components/forms/InputField";
|
||||||
import SelectField from "@/components/forms/SelectField";
|
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 { CountrySelectField } from "@/components/forms/CountrySelectField";
|
||||||
import FooterLink from "@/components/forms/FooterLink";
|
import FooterLink from "@/components/forms/FooterLink";
|
||||||
import { signUpWithEmail } from "@/lib/actions/auth.actions";
|
import { signUpWithEmail } from "@/lib/actions/auth.actions";
|
||||||
|
|
@ -19,6 +20,7 @@ const SignUp = () => {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
control,
|
control,
|
||||||
|
watch,
|
||||||
formState: { errors, isSubmitting },
|
formState: { errors, isSubmitting },
|
||||||
} = useForm<SignUpFormData>({
|
} = useForm<SignUpFormData>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
|
|
@ -33,6 +35,8 @@ const SignUp = () => {
|
||||||
mode: 'onBlur'
|
mode: 'onBlur'
|
||||||
},);
|
},);
|
||||||
|
|
||||||
|
const passwordValue = watch('password');
|
||||||
|
|
||||||
const onSubmit = async (data: SignUpFormData) => {
|
const onSubmit = async (data: SignUpFormData) => {
|
||||||
try {
|
try {
|
||||||
const result = await signUpWithEmail(data);
|
const result = await signUpWithEmail(data);
|
||||||
|
|
@ -87,8 +91,9 @@ const SignUp = () => {
|
||||||
type="password"
|
type="password"
|
||||||
register={register}
|
register={register}
|
||||||
error={errors.password}
|
error={errors.password}
|
||||||
validation={{ required: 'Password is required', minLength: 8 }}
|
validation={PASSWORD_VALIDATION}
|
||||||
/>
|
/>
|
||||||
|
<PasswordRequirements password={passwordValue ?? ''} />
|
||||||
|
|
||||||
<CountrySelectField
|
<CountrySelectField
|
||||||
name="country"
|
name="country"
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ export default function SearchCommand({ renderAs = 'button', label = 'Add stock'
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
debouncedSearch();
|
debouncedSearch();
|
||||||
}, [searchTerm]);
|
}, [debouncedSearch, searchTerm]);
|
||||||
|
|
||||||
const handleSelectStock = () => {
|
const handleSelectStock = () => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
|
@ -87,7 +87,7 @@ export default function SearchCommand({ renderAs = 'button', label = 'Add stock'
|
||||||
{isSearchMode ? 'Search results' : 'Popular stocks'}
|
{isSearchMode ? 'Search results' : 'Popular stocks'}
|
||||||
{` `}({displayStocks?.length || 0})
|
{` `}({displayStocks?.length || 0})
|
||||||
</div>
|
</div>
|
||||||
{displayStocks?.map((stock, i) => (
|
{displayStocks?.map((stock) => (
|
||||||
<li key={stock.symbol} className="search-item">
|
<li key={stock.symbol} className="search-item">
|
||||||
<Link
|
<Link
|
||||||
href={`/stocks/${stock.symbol}`}
|
href={`/stocks/${stock.symbol}`}
|
||||||
|
|
@ -100,7 +100,7 @@ export default function SearchCommand({ renderAs = 'button', label = 'Add stock'
|
||||||
{stock.name}
|
{stock.name}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-500">
|
<div className="text-sm text-gray-500">
|
||||||
{stock.symbol} | {stock.exchange } | {stock.type}
|
{[stock.symbol, stock.exchange, stock.type].filter(Boolean).join(' | ')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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 () => {
|
export const signOut = async () => {
|
||||||
try {
|
try {
|
||||||
await auth.api.signOut({ headers: await headers() });
|
await auth.api.signOut({ headers: await headers() });
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,32 @@ import { cache } from 'react';
|
||||||
const FINNHUB_BASE_URL = 'https://finnhub.io/api/v1';
|
const FINNHUB_BASE_URL = 'https://finnhub.io/api/v1';
|
||||||
const NEXT_PUBLIC_FINNHUB_API_KEY = process.env.NEXT_PUBLIC_FINNHUB_API_KEY ?? '';
|
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> {
|
async function fetchJSON<T>(url: string, revalidateSeconds?: number): Promise<T> {
|
||||||
const options: RequestInit & { next?: { revalidate?: number } } = revalidateSeconds
|
const options: RequestInit & { next?: { revalidate?: number } } = revalidateSeconds
|
||||||
? { cache: 'force-cache', next: { revalidate: revalidateSeconds } }
|
? { cache: 'force-cache', next: { revalidate: revalidateSeconds } }
|
||||||
|
|
@ -22,12 +48,27 @@ async function fetchJSON<T>(url: string, revalidateSeconds?: number): Promise<T>
|
||||||
|
|
||||||
export { fetchJSON };
|
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) {
|
export async function getQuote(symbol: string) {
|
||||||
try {
|
try {
|
||||||
const token = NEXT_PUBLIC_FINNHUB_API_KEY;
|
const token = NEXT_PUBLIC_FINNHUB_API_KEY;
|
||||||
const url = `${FINNHUB_BASE_URL}/quote?symbol=${encodeURIComponent(symbol)}&token=${token}`;
|
const url = `${FINNHUB_BASE_URL}/quote?symbol=${encodeURIComponent(symbol)}&token=${token}`;
|
||||||
// No caching for real-time price
|
// No caching for real-time price
|
||||||
return await fetchJSON<any>(url, 0);
|
return await fetchJSON<FinnhubQuote>(url, 0);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error fetching quote for', symbol, e);
|
console.error('Error fetching quote for', symbol, e);
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -39,7 +80,7 @@ export async function getCompanyProfile(symbol: string) {
|
||||||
const token = NEXT_PUBLIC_FINNHUB_API_KEY;
|
const token = NEXT_PUBLIC_FINNHUB_API_KEY;
|
||||||
const url = `${FINNHUB_BASE_URL}/stock/profile2?symbol=${encodeURIComponent(symbol)}&token=${token}`;
|
const url = `${FINNHUB_BASE_URL}/stock/profile2?symbol=${encodeURIComponent(symbol)}&token=${token}`;
|
||||||
// Cache profile for 24 hours
|
// Cache profile for 24 hours
|
||||||
return await fetchJSON<any>(url, 86400);
|
return await fetchJSON<FinnhubCompanyProfile>(url, 86400);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error fetching profile for', symbol, e);
|
console.error('Error fetching profile for', symbol, e);
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -160,7 +201,7 @@ export const searchStocks = cache(async (query?: string): Promise<StockWithWatch
|
||||||
|
|
||||||
const trimmed = typeof query === 'string' ? query.trim() : '';
|
const trimmed = typeof query === 'string' ? query.trim() : '';
|
||||||
|
|
||||||
let results: FinnhubSearchResult[] = [];
|
let results: SearchStockCandidate[] = [];
|
||||||
|
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
// Fetch top 10 popular symbols' profiles
|
// Fetch top 10 popular symbols' profiles
|
||||||
|
|
@ -170,11 +211,11 @@ export const searchStocks = cache(async (query?: string): Promise<StockWithWatch
|
||||||
try {
|
try {
|
||||||
const url = `${FINNHUB_BASE_URL}/stock/profile2?symbol=${encodeURIComponent(sym)}&token=${token}`;
|
const url = `${FINNHUB_BASE_URL}/stock/profile2?symbol=${encodeURIComponent(sym)}&token=${token}`;
|
||||||
// Revalidate every hour
|
// Revalidate every hour
|
||||||
const profile = await fetchJSON<any>(url, 3600);
|
const profile = await fetchJSON<FinnhubCompanyProfile>(url, 3600);
|
||||||
return { sym, profile } as { sym: string; profile: any };
|
return { sym, profile } as { sym: string; profile: FinnhubCompanyProfile | null };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error fetching profile2 for', sym, 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 name: string | undefined = profile?.name || profile?.ticker || undefined;
|
||||||
const exchange: string | undefined = profile?.exchange || undefined;
|
const exchange: string | undefined = profile?.exchange || undefined;
|
||||||
if (!name) return undefined;
|
if (!name) return undefined;
|
||||||
const r: FinnhubSearchResult = {
|
const r: SearchStockCandidate = {
|
||||||
symbol,
|
symbol,
|
||||||
description: name,
|
description: name,
|
||||||
displaySymbol: symbol,
|
displaySymbol: symbol,
|
||||||
type: 'Common Stock',
|
type: 'Common Stock',
|
||||||
};
|
};
|
||||||
// We don't include exchange in FinnhubSearchResult type, so carry via mapping later using profile
|
r.__exchange = exchange;
|
||||||
// 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
|
|
||||||
return r;
|
return r;
|
||||||
})
|
})
|
||||||
.filter((x): x is FinnhubSearchResult => Boolean(x));
|
.filter((x): x is SearchStockCandidate => Boolean(x));
|
||||||
} else {
|
} else {
|
||||||
const url = `${FINNHUB_BASE_URL}/search?q=${encodeURIComponent(trimmed)}&token=${token}`;
|
const url = `${FINNHUB_BASE_URL}/search?q=${encodeURIComponent(trimmed)}&token=${token}`;
|
||||||
const data = await fetchJSON<FinnhubSearchResponse>(url, 1800);
|
const data = await fetchJSON<FinnhubSearchResponse>(url, 1800);
|
||||||
|
|
@ -208,9 +246,8 @@ export const searchStocks = cache(async (query?: string): Promise<StockWithWatch
|
||||||
.map((r) => {
|
.map((r) => {
|
||||||
const upper = (r.symbol || '').toUpperCase();
|
const upper = (r.symbol || '').toUpperCase();
|
||||||
const name = r.description || upper;
|
const name = r.description || upper;
|
||||||
const exchangeFromDisplay = (r.displaySymbol as string | undefined) || undefined;
|
const exchangeFromProfile = r.__exchange;
|
||||||
const exchangeFromProfile = (r as any).__exchange as string | undefined;
|
const exchange = getExchangeLabel(upper, exchangeFromProfile);
|
||||||
const exchange = exchangeFromDisplay || exchangeFromProfile || 'US';
|
|
||||||
const type = r.type || 'Stock';
|
const type = r.type || 'Stock';
|
||||||
const item: StockWithWatchlistStatus = {
|
const item: StockWithWatchlistStatus = {
|
||||||
symbol: upper,
|
symbol: upper,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { betterAuth } from "better-auth";
|
||||||
import {mongodbAdapter} from "better-auth/adapters/mongodb";
|
import {mongodbAdapter} from "better-auth/adapters/mongodb";
|
||||||
import {connectToDatabase} from "@/database/mongoose";
|
import {connectToDatabase} from "@/database/mongoose";
|
||||||
import {nextCookies} from "better-auth/next-js";
|
import {nextCookies} from "better-auth/next-js";
|
||||||
|
import { sendPasswordResetEmail } from "@/lib/nodemailer/reset-password";
|
||||||
|
|
||||||
|
|
||||||
let authInstance: ReturnType<typeof betterAuth> | null = null;
|
let authInstance: ReturnType<typeof betterAuth> | null = null;
|
||||||
|
|
@ -14,13 +15,14 @@ export const getAuth = async () => {
|
||||||
|
|
||||||
const mongoose = await connectToDatabase();
|
const mongoose = await connectToDatabase();
|
||||||
const db = mongoose.connection;
|
const db = mongoose.connection;
|
||||||
|
const database = db.db;
|
||||||
|
|
||||||
if (!db) {
|
if (!db || !database) {
|
||||||
throw new Error("MongoDB connection not found!");
|
throw new Error("MongoDB connection not found!");
|
||||||
}
|
}
|
||||||
|
|
||||||
authInstance = betterAuth({
|
authInstance = betterAuth({
|
||||||
database: mongodbAdapter(db as any),
|
database: mongodbAdapter(database),
|
||||||
secret: process.env.BETTER_AUTH_SECRET,
|
secret: process.env.BETTER_AUTH_SECRET,
|
||||||
baseURL: process.env.BETTER_AUTH_URL,
|
baseURL: process.env.BETTER_AUTH_URL,
|
||||||
emailAndPassword: {
|
emailAndPassword: {
|
||||||
|
|
@ -30,6 +32,15 @@ export const getAuth = async () => {
|
||||||
minPasswordLength: 8,
|
minPasswordLength: 8,
|
||||||
maxPasswordLength: 128,
|
maxPasswordLength: 128,
|
||||||
autoSignIn: true,
|
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()],
|
plugins: [nextCookies()],
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -195,7 +195,7 @@ export const CANDLE_CHART_WIDGET_CONFIG = (symbol: string) => ({
|
||||||
style: 1,
|
style: 1,
|
||||||
symbol: symbol.toUpperCase(),
|
symbol: symbol.toUpperCase(),
|
||||||
theme: 'dark',
|
theme: 'dark',
|
||||||
timezone: 'Etc/UTC',
|
timezone: 'exchange',
|
||||||
backgroundColor: '#141414',
|
backgroundColor: '#141414',
|
||||||
gridColor: '#141414',
|
gridColor: '#141414',
|
||||||
watchlist: [],
|
watchlist: [],
|
||||||
|
|
@ -221,7 +221,7 @@ export const BASELINE_WIDGET_CONFIG = (symbol: string) => ({
|
||||||
style: 10,
|
style: 10,
|
||||||
symbol: symbol.toUpperCase(),
|
symbol: symbol.toUpperCase(),
|
||||||
theme: 'dark',
|
theme: 'dark',
|
||||||
timezone: 'Etc/UTC',
|
timezone: 'exchange',
|
||||||
backgroundColor: '#141414',
|
backgroundColor: '#141414',
|
||||||
gridColor: '#141414',
|
gridColor: '#141414',
|
||||||
watchlist: [],
|
watchlist: [],
|
||||||
|
|
@ -338,3 +338,19 @@ export const WATCHLIST_TABLE_HEADER = [
|
||||||
'Alert',
|
'Alert',
|
||||||
'Action',
|
'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',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { transporter } from "@/lib/nodemailer";
|
||||||
|
|
||||||
|
const escapeHtml = (value: string) =>
|
||||||
|
value
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -14,6 +14,6 @@ export async function middleware(request: NextRequest) {
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
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).*)',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
@ -2,6 +2,9 @@ import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
devIndicators: false,
|
devIndicators: false,
|
||||||
|
turbopack: {
|
||||||
|
root: process.cwd(),
|
||||||
|
},
|
||||||
/* config options here */
|
/* config options here */
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue