diff --git a/.env.example b/.env.example index f6b6747..1eae11a 100644 --- a/.env.example +++ b/.env.example @@ -34,7 +34,7 @@ SESSION_CONNECTION=default BROADCAST_CONNECTION=log -FILESYSTEM_DISK=local +FILESYSTEM_DISK=public QUEUE_CONNECTION=database #JWT_SECRET= @@ -49,12 +49,13 @@ REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null REDIS_PORT=6379 -MAIL_MAILER=log +MAIL_MAILER=smtp MAIL_SCHEME=null MAIL_HOST=127.0.0.1 -MAIL_PORT=2525 +MAIL_PORT=1025 MAIL_USERNAME=null MAIL_PASSWORD=null +MAIL_ENCRYPTION=null MAIL_FROM_ADDRESS="hello@example.com" MAIL_FROM_NAME="${APP_NAME}" diff --git a/api/app/Actions/Fortify/CreateNewUser.php b/api/app/Actions/Fortify/CreateNewUser.php index 7bf18d0..9308d8d 100644 --- a/api/app/Actions/Fortify/CreateNewUser.php +++ b/api/app/Actions/Fortify/CreateNewUser.php @@ -20,7 +20,8 @@ class CreateNewUser implements CreatesNewUsers public function create(array $input): User { Validator::make($input, [ - 'name' => ['required', 'string', 'max:255'], + 'first_name' => ['required', 'string', 'max:255'], + 'last_name' => ['required', 'string', 'max:255'], 'email' => [ 'required', 'string', @@ -32,7 +33,8 @@ public function create(array $input): User ])->validate(); return User::create([ - 'name' => $input['name'], + 'first_name' => $input['first_name'], + 'last_name' => $input['last_name'], 'email' => $input['email'], 'password' => Hash::make($input['password']), ]); diff --git a/api/app/Actions/Fortify/UpdateUserProfileInformation.php b/api/app/Actions/Fortify/UpdateUserProfileInformation.php index 0930ddf..b3bd652 100644 --- a/api/app/Actions/Fortify/UpdateUserProfileInformation.php +++ b/api/app/Actions/Fortify/UpdateUserProfileInformation.php @@ -18,8 +18,8 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation public function update(User $user, array $input): void { Validator::make($input, [ - 'name' => ['required', 'string', 'max:255'], - + 'first_name' => ['required', 'string', 'max:255'], + 'last_name' => ['required', 'string', 'max:255'], 'email' => [ 'required', 'string', @@ -34,7 +34,8 @@ public function update(User $user, array $input): void $this->updateVerifiedUser($user, $input); } else { $user->forceFill([ - 'name' => $input['name'], + 'first_name' => $input['first_name'], + 'last_name' => $input['last_name'], 'email' => $input['email'], ])->save(); } @@ -48,7 +49,8 @@ public function update(User $user, array $input): void protected function updateVerifiedUser(User $user, array $input): void { $user->forceFill([ - 'name' => $input['name'], + 'first_name' => $input['first_name'], + 'last_name' => $input['last_name'], 'email' => $input['email'], 'email_verified_at' => null, ])->save(); diff --git a/api/app/Models/User.php b/api/app/Models/User.php index ff8b262..c1d723d 100644 --- a/api/app/Models/User.php +++ b/api/app/Models/User.php @@ -33,6 +33,8 @@ class User extends Authenticatable implements MustVerifyEmail 'avatar', 'timezone', 'locale', + 'preferredLanguage' + ]; @@ -57,7 +59,7 @@ protected function casts(): array 'email_verified_at' => 'datetime', 'password' => 'hashed', 'is_active' => 'boolean', - 'last_login_at' => 'datetime', + ]; } diff --git a/api/app/Providers/FortifyServiceProvider.php b/api/app/Providers/FortifyServiceProvider.php index 004ced4..6bb9b98 100644 --- a/api/app/Providers/FortifyServiceProvider.php +++ b/api/app/Providers/FortifyServiceProvider.php @@ -44,5 +44,14 @@ public function boot(): void RateLimiter::for('two-factor', function (Request $request) { return Limit::perMinute(5)->by($request->session()->get('login.id')); }); + + Fortify::twoFactorChallengeView(function () { + // For SPAs, return JSON + if (request()->wantsJson()) { + return response()->json(['two_factor' => true], 423); + } + // For web, return view + return view('auth.two-factor-challenge'); +}); } } diff --git a/api/config/cors.php b/api/config/cors.php index 01b14cf..fd87c7c 100644 --- a/api/config/cors.php +++ b/api/config/cors.php @@ -26,7 +26,7 @@ 'http://127.0.0.1:5173', 'http://127.0.0.1:5174', 'http://127.0.0.1:3000', - 'https://localhost', + 'https://localhost', 'https://localhost:443', 'https://notetify.com' ], diff --git a/api/config/fortify.php b/api/config/fortify.php index 306aa94..c0172e7 100644 --- a/api/config/fortify.php +++ b/api/config/fortify.php @@ -86,7 +86,7 @@ | */ - 'prefix' => '', + 'prefix' => 'api', 'domain' => null, diff --git a/api/routes/api.php b/api/routes/api.php index 2ff0273..1c2e6b4 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -6,6 +6,8 @@ use App\Http\Controllers\TagController; use App\Http\Controllers\NotebookController; use Illuminate\Support\Facades\Route; +use Illuminate\Http\Request; + Route::prefix('auth')->group(function () { Route::post('register', [AuthController::class, 'register']) @@ -44,3 +46,8 @@ Route::resource('notebooks', NotebookController::class)->except(['show']); Route::resource('tags', TagController::class)->except(['show']); }); + + +Route::middleware('auth:sanctum')->get('/user', function (Request $request) { + return $request->user(); +}); diff --git a/client/cypress/support/commands.ts b/client/cypress/support/commands.ts index 2c24b53..6a37e83 100644 --- a/client/cypress/support/commands.ts +++ b/client/cypress/support/commands.ts @@ -37,7 +37,7 @@ // } import './commands'; -Cypress.on('uncaught:exception', (err, runnable) => { +Cypress.on('uncaught:exception', (_err, _runnable) => { // returning false here prevents Cypress from failing the test return false; }); diff --git a/client/src/components/nav-user.tsx b/client/src/components/nav-user.tsx index c70fdd8..024c72e 100644 --- a/client/src/components/nav-user.tsx +++ b/client/src/components/nav-user.tsx @@ -20,6 +20,12 @@ export function NavUser() { const { state } = useSidebar(); const isMobile = useIsMobile(); + const user = sharedData?.auth?.user; + + if (!user) { + return null; + } + return ( @@ -29,7 +35,7 @@ export function NavUser() { size="lg" className="group text-sidebar-accent-foreground data-[state=open]:bg-sidebar-accent" > - + @@ -40,7 +46,7 @@ export function NavUser() { isMobile ? 'bottom' : state === 'collapsed' ? 'left' : 'bottom' } > - + diff --git a/client/src/hooks/use-note.ts b/client/src/hooks/use-note.ts index 71a8c9f..16f239c 100644 --- a/client/src/hooks/use-note.ts +++ b/client/src/hooks/use-note.ts @@ -22,6 +22,12 @@ import { } from '../types/index.ts'; import { noteQueryKeys } from '../utils/queryKeys.ts'; +type NotesType = + | UserNote[] + | { results: UserNote[] } + | { pages: { results: UserNote[] }[]; pageParams: unknown[] } + | undefined; + export const notesQueryOptions = ( search: string = '', sortby: SortBy = 'updated_at' @@ -205,7 +211,7 @@ export function useUpdateNote() { console.error('Failed to update note:', error); restoreNotes(queryClient, context?.previous); }, - onSettled: async () => { + onSettled: async () => { await queryClient.invalidateQueries({ queryKey: noteQueryKeys.all }); }, }); @@ -242,7 +248,7 @@ export function useDeleteNote() { Snapshot the current state of the cache so we can rollback if the mutation fails. */ function snapshotNotes(queryClient: ReturnType) { - return queryClient.getQueriesData({ + return queryClient.getQueriesData({ queryKey: noteQueryKeys.all, }); } @@ -252,7 +258,7 @@ function snapshotNotes(queryClient: ReturnType) { */ function restoreNotes( queryClient: ReturnType, - previous: [readonly unknown[], UserNote[] | undefined][] | undefined + previous: [readonly unknown[], NotesType][] | undefined ) { if (previous) { previous.forEach(([queryKey, data]) => { @@ -269,20 +275,14 @@ function updateNotesCaches( updater: (oldNotes: UserNote[], pageIndex?: number) => UserNote[] ) { // Get all matching queries and update them individually - const queries = queryClient.getQueriesData< - | UserNote[] - | { results: UserNote[] } - | { pages: { results: UserNote[] }[]; pageParams: unknown[] } - >({ queryKey: noteQueryKeys.all }); + const queries = queryClient.getQueriesData({ + queryKey: noteQueryKeys.all, + }); for (const [queryKey, oldData] of queries) { if (!oldData) continue; - let newData: - | UserNote[] - | { results: UserNote[] } - | { pages: { results: UserNote[] }[]; pageParams: unknown[] } - | undefined; + let newData: NotesType; // Handle if your API returns an Array directly if (Array.isArray(oldData)) { diff --git a/client/src/layouts/settings/layout.tsx b/client/src/layouts/settings/layout.tsx index 6acf13e..a55cffe 100644 --- a/client/src/layouts/settings/layout.tsx +++ b/client/src/layouts/settings/layout.tsx @@ -14,8 +14,8 @@ const sidebarNavItems: NavItem[] = [ icon: null, }, { - title: 'Account', - href: '/settings/account', + title: 'Authentication', + href: '/settings/authentication', icon: null, }, { @@ -23,11 +23,7 @@ const sidebarNavItems: NavItem[] = [ href: '/settings/billing', icon: null, }, - { - title: 'Authentication', - href: '/settings/authentication', - icon: null, - }, + ]; export default function SettingsLayout() { diff --git a/client/src/pages/auth/confirm-password.tsx b/client/src/pages/auth/confirm-password.tsx index d2b96b9..9f6396e 100644 --- a/client/src/pages/auth/confirm-password.tsx +++ b/client/src/pages/auth/confirm-password.tsx @@ -1,6 +1,6 @@ // Components import { LoaderCircle } from 'lucide-react'; -import { useState, type FormEventHandler } from 'react'; +import { useState, type FormEvent } from 'react'; import InputError from '../../components/input-error'; import { Button } from '../../components/ui/button.tsx'; @@ -18,7 +18,7 @@ export default function ConfirmPassword() { }); const { isLoading, setErrors, errors, ConfirmPassword } = useStore(); - const submit: FormEventHandler = async (e) => { + const submit = async (e: FormEvent) => { e.preventDefault(); setErrors(null); @@ -41,7 +41,11 @@ export default function ConfirmPassword() { title="Confirm your password" description="This is a secure area of the application. Please confirm your password before continuing." > -
+ { + submit(e).catch(console.error); + }} + >
@@ -51,7 +55,7 @@ export default function ConfirmPassword() { name="password" placeholder="Password" autoComplete="current-password" - value={form!.password} + value={form.password} autoFocus onChange={change} /> diff --git a/client/src/pages/auth/forgot-password.tsx b/client/src/pages/auth/forgot-password.tsx index 253281f..ac3d618 100644 --- a/client/src/pages/auth/forgot-password.tsx +++ b/client/src/pages/auth/forgot-password.tsx @@ -1,5 +1,5 @@ import { LoaderCircle } from 'lucide-react'; -import { useState } from 'react'; +import { useState, type ChangeEvent, type FormEvent } from 'react'; import InputError from '../../components/input-error'; import TextLink from '../../components/text-link'; import { Button } from '../../components/ui/button.tsx'; @@ -9,26 +9,22 @@ import AuthLayout from '../../layouts/auth-layout'; import { useStore } from '../../stores/index.ts'; import { forgatPasswordSchema } from '../../utils/validators.ts'; -type ForgotPasswordProps = { - status?: string; -}; - type ForgotPasswordForm = { email: string; }; -export default function ForgotPassword({ status }: ForgotPasswordProps) { +export default function ForgotPassword() { const [form, setForm] = useState({ email: '', }); const { isLoading, errors, setErrors, ForgotPassword } = useStore(); - const change = (e: React.ChangeEvent) => { + const change = (e: ChangeEvent) => { setForm({ ...form, [e.target.name]: e.target.value.trim() }); }; - const submit = async (e: React.FormEvent) => { + const submit = async (e: FormEvent) => { e.preventDefault(); setErrors(null); @@ -49,14 +45,13 @@ export default function ForgotPassword({ status }: ForgotPasswordProps) { >

Forgot password

- {status && ( -
- {status} -
- )} -
- + { + submit(e).catch(console.error); + }} + noValidate + >
{ + const navigate = useNavigate(); + const [form, setForm] = useState({ email: '', password: '', @@ -28,7 +31,7 @@ const Login = () => { setForm({ ...form, [e.target.name]: e.target.value.trim() }); }; - const submit = async (e: React.FormEvent) => { + const submit = async (e: React.FormEvent): Promise => { e.preventDefault(); setErrors(null); @@ -39,15 +42,40 @@ const Login = () => { setErrors(formattedErrors); return; } - await Login(form); + const success = await Login(form); + + if (!success) { + return; + } + + // Get the current state after Login completes + const currentStep = useStore.getState().authenticationStep; + + if (currentStep === 'two-factor') { + await navigate('/two-factor'); + } else if (currentStep === 'recovery') { + await navigate('/recovery'); + } else { + await navigate('/', { + replace: true, + }); + } }; + function handleOnLinkClick() { + setErrors(null); + } return ( - + { + submit(e).catch(console.error); + }} + >
{errors?.general && }
@@ -155,8 +183,8 @@ const Login = () => {
- Don't have an account?{' '} - + {"Don't have an account?"}{' '} + Sign up
diff --git a/client/src/pages/auth/one-time-password.tsx b/client/src/pages/auth/one-time-password.tsx index 5ee03d4..d36926f 100644 --- a/client/src/pages/auth/one-time-password.tsx +++ b/client/src/pages/auth/one-time-password.tsx @@ -33,6 +33,9 @@ export function InputOTPForm() { }, }); + + + function onSubmit(data: z.infer) { toast('You submitted the following values', { description: ( @@ -45,7 +48,12 @@ export function InputOTPForm() { return ( - + { + form.handleSubmit(onSubmit)(e).catch(console.error); + }} + className="w-2/3 space-y-6" + > - Please enter the one-time password sent to your phone or email. + Please enter the one-time password sent to your phone or email + or from your authenticator app. diff --git a/client/src/pages/auth/register.tsx b/client/src/pages/auth/register.tsx index 1b01637..837876f 100644 --- a/client/src/pages/auth/register.tsx +++ b/client/src/pages/auth/register.tsx @@ -48,6 +48,10 @@ const Register = () => { await SignUp(form.first_name, form.last_name, form.email, form.password); } + function handleOnLinkClick (){ + setErrors(null); + } + return ( { > submit(e)} + onSubmit={(e) => {submit(e).catch(console.error)}} noValidate >
@@ -206,7 +210,7 @@ const Register = () => {
Already have an account?{' '} - + Log in
diff --git a/client/src/pages/auth/reset-password.tsx b/client/src/pages/auth/reset-password.tsx index 98c976a..cd4eb0b 100644 --- a/client/src/pages/auth/reset-password.tsx +++ b/client/src/pages/auth/reset-password.tsx @@ -1,5 +1,5 @@ import { LoaderCircle } from 'lucide-react'; -import { type FormEventHandler, useState } from 'react'; +import { type FormEvent, useState } from 'react'; import { useParams } from 'react-router'; import InputError from '../../components/input-error'; import { Button } from '../../components/ui/button.tsx'; @@ -16,6 +16,7 @@ type ResetPasswordForm = { export default function ResetPassword() { const { token } = useParams(); + const { sharedData } = useStore(); const [form, setForm] = useState({ password: '', @@ -24,7 +25,7 @@ export default function ResetPassword() { const { isLoading, errors, PasswordReset, setErrors } = useStore(); - const submit: FormEventHandler = async (e) => { + const submit = async (e: FormEvent) => { e.preventDefault(); setErrors(null); @@ -35,7 +36,7 @@ export default function ResetPassword() { setErrors(formattedErrors); return; } - await PasswordReset(token, form.password); + await PasswordReset(token, form.password, sharedData!.auth.user.email); }; function change(e: React.ChangeEvent) { @@ -46,7 +47,11 @@ export default function ResetPassword() { title="Reset password" description="Please enter your new password below" > - + { + submit(e).catch(console.error); + }} + >
diff --git a/client/src/pages/auth/two-factor-verification.tsx b/client/src/pages/auth/two-factor-verification.tsx index fa4b528..2ffbf7e 100644 --- a/client/src/pages/auth/two-factor-verification.tsx +++ b/client/src/pages/auth/two-factor-verification.tsx @@ -52,7 +52,9 @@ export function TwoFactorVerification() { > { + form.handleSubmit(onSubmit)(e).catch(console.error); + }} className="w-2/3 space-y-6" > { + const { isLoading, VerifyEmail, sharedData } = useStore(); + const submit = async (e: FormEvent) => { e.preventDefault(); - await VerifyEmail(''); + await VerifyEmail(sharedData!.auth.user.email); }; return ( @@ -20,7 +20,12 @@ export default function VerifyEmail() { > {/* Status message handled elsewhere; removed undefined reference */} - + { + submit(e).catch(console.error); + }} + className="space-y-6 text-center" + > -
- - ); -} diff --git a/client/src/pages/settings/general.tsx b/client/src/pages/settings/general.tsx index 4ea6dbc..413e694 100644 --- a/client/src/pages/settings/general.tsx +++ b/client/src/pages/settings/general.tsx @@ -15,16 +15,19 @@ import { Separator } from '../../components/ui/separator'; import { Switch } from '../../components/ui/switch'; import { useStore } from '../../stores/index'; import { type Theme } from '../../types'; +import { useInitials } from '../../hooks/use-initials'; +import { Avatar, AvatarFallback, AvatarImage } from '../../components/ui/avatar'; type NotificationPrefs = { - email: boolean; - push: boolean; - marketing: boolean; + emailNotificationEnabled: boolean; + pushNotificationEnabled: boolean; + marketingNotificationEnabled: boolean; }; -const STORAGE_KEY_NOTIFICATIONS = 'notification_prefs'; - const General = () => { + const getInitials = useInitials(); + + const { sharedData, setSharedData, theme, setTheme, language, setLanguage } = useStore(); const user = sharedData?.auth.user; @@ -36,18 +39,11 @@ const General = () => { // Notification State const [notifPrefs, setNotifPrefs] = useState(() => { - try { - const raw = localStorage.getItem(STORAGE_KEY_NOTIFICATIONS); - if (raw) return JSON.parse(raw); - } catch { - console.error( - 'there was an error adding notification preference to local storage' - ); - } + return { - email: true, - push: false, - marketing: false, + emailNotificationEnabled: true, + pushNotificationEnabled: false, + marketingNotificationEnabled: false, }; }); @@ -64,6 +60,11 @@ const General = () => { ...user, first_name: firstName.trim(), last_name: lastName.trim(), + avatar: user.avatar, + emailNotificationEnabled: notifPrefs.emailNotificationEnabled, + pushNotificationEnabled: notifPrefs.pushNotificationEnabled, + marketingNotificationEnabled: notifPrefs.marketingNotificationEnabled, + preferredLanguage: language, }, }, }; @@ -79,7 +80,6 @@ const General = () => { const updateNotifPref = (key: keyof NotificationPrefs, value: boolean) => { const next = { ...notifPrefs, [key]: value }; setNotifPrefs(next); - localStorage.setItem(STORAGE_KEY_NOTIFICATIONS, JSON.stringify(next)); toast.success('Notification preferences updated'); }; @@ -95,6 +95,19 @@ const General = () => {
+
+
+ + + + {getInitials(`${user!.first_name} ${user!.last_name}`)} + + {' '} +
+
@@ -190,8 +203,10 @@ const General = () => {

updateNotifPref('email', v)} + checked={notifPrefs.emailNotificationEnabled} + onCheckedChange={(v) => + updateNotifPref('emailNotificationEnabled', v) + } />
@@ -202,8 +217,10 @@ const General = () => {

updateNotifPref('push', v)} + checked={notifPrefs.pushNotificationEnabled} + onCheckedChange={(v) => + updateNotifPref('pushNotificationEnabled', v) + } />
@@ -214,8 +231,10 @@ const General = () => {

updateNotifPref('marketing', v)} + checked={notifPrefs.marketingNotificationEnabled} + onCheckedChange={(v) => + updateNotifPref('marketingNotificationEnabled', v) + } />
diff --git a/client/src/pages/settings/settings.tsx b/client/src/pages/settings/settings.tsx deleted file mode 100644 index 970dc37..0000000 --- a/client/src/pages/settings/settings.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import { Navigate } from 'react-router'; - -const Settings = () => { - // Redirect bare /settings route to General settings - return ; -}; - -export default Settings; diff --git a/client/src/routes/app-routes.tsx b/client/src/routes/app-routes.tsx index f0d8eb4..dc2c450 100644 --- a/client/src/routes/app-routes.tsx +++ b/client/src/routes/app-routes.tsx @@ -21,9 +21,8 @@ import ResetPassword from '../pages/auth/reset-password'; import { TwoFactorVerification } from '../pages/auth/two-factor-verification'; import VerifyEmail from '../pages/auth/verify-email'; import Landing from '../pages/landing'; -import Account from '../pages/settings/account'; import Authentication from '../pages/settings/authentication'; -import Billing from '../pages/settings/billing'; +import Billing from '../pages/settings/billing.tsx'; import General from '../pages/settings/general'; import { useStore } from '../stores/index.ts'; @@ -157,7 +156,6 @@ function AppRoutes() { ), }, { path: 'general', Component: General }, - { path: 'account', Component: Account }, { path: 'authentication', Component: Authentication }, { path: 'billing', Component: Billing }, ], diff --git a/client/src/services/auth-service.ts b/client/src/services/auth-service.ts index e0f107a..b5afea2 100644 --- a/client/src/services/auth-service.ts +++ b/client/src/services/auth-service.ts @@ -1,3 +1,4 @@ +import axios from 'axios'; import axiosInstance, { ensureCSRFToken } from '../lib/axios'; import type { SharedData, User } from '../types'; @@ -13,8 +14,13 @@ type SignUpParams = { type PasswordResetParams = { token: string; password: string; + email: string; }; +interface TwoFactorRequired { + requiresTwoFactor: true; +} + export function buildSharedData(apiResponse: any): SharedData { const user: User = { id: apiResponse.id, @@ -32,50 +38,98 @@ export function buildSharedData(apiResponse: any): SharedData { export async function signUp(params: SignUpParams): Promise { await ensureCSRFToken(); - const response = await axiosInstance.post('auth/register/', { + const response = await axiosInstance.post('register', { first_name: params.first_name.trim(), last_name: params.last_name.trim(), email: params.email.trim().toLowerCase(), password: params.password, + password_confirmation: params.password, }); return buildSharedData(response.data); } -export async function login(params: LoginParams): Promise { +export async function login( + params: LoginParams +): Promise { await ensureCSRFToken(); - const response = await axiosInstance.post('auth/login/', { - email: params.email.trim().toLowerCase(), - password: params.password, - remember: params.remember, - }); - return buildSharedData(response.data); + + try { + const response = await axiosInstance.post('login', { + email: params.email.trim().toLowerCase(), + password: params.password, + remember: params.remember, + }); + + if (response.data.two_factor === true) { + return { requiresTwoFactor: true }; + } + + const userResponse = await axiosInstance.get('user'); + return buildSharedData(userResponse.data); + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 423) { + return { requiresTwoFactor: true }; + } + throw error; + } } export async function logout(): Promise { - await axiosInstance.post('auth/logout/'); + await axiosInstance.post('logout'); } export async function confirmPassword(password: string): Promise { await ensureCSRFToken(); - await axiosInstance.post('auth/confirm-password/', { password }); + await axiosInstance.post('user/confirm-password', { password }); } export async function passwordReset( params: PasswordResetParams -): Promise { +): Promise { await ensureCSRFToken(); - await axiosInstance.post('auth/password-reset/confirm/', { - password: params.password, + const response =await axiosInstance.post('reset-password', { token: params.token, + email: params.email, + password: params.password, + password_confirmation: params.password, }); + + return response.status } -export async function forgotPassword(email: string): Promise { +export async function forgotPassword(email: string): Promise { await ensureCSRFToken(); - const response = await axiosInstance.post('auth/password-reset/', { + await axiosInstance.post('forgot-password', { email: email.trim().toLowerCase(), }); - return response.data.token || 'success'; +} + +export async function updatePassword( + current: string, + newPassword: string +): Promise { + await axiosInstance.put('user/password', { + current_password: current, + password: newPassword, + password_confirmation: newPassword, + }); +} + +export async function getTwoFactorQrCode(): Promise { + const response = await axiosInstance.get('user/two-factor-qr-code'); + return response.data.svg; +} + + +export async function updateProfile(params): Promise { + await axiosInstance.put('user/profile-information', params); +} + + +// Resend verification email +export async function resendVerificationEmail(): Promise { + await ensureCSRFToken(); + await axiosInstance.post('email/verification-notification'); } export async function verifyEmail(email: string): Promise { @@ -87,7 +141,53 @@ export async function verifyEmail(email: string): Promise { } export async function getMe(): Promise { + const response = await axiosInstance.get('user'); + return buildSharedData(response.data); +} + +export async function submitTwoFactorCode(code: string): Promise { await ensureCSRFToken(); - const response = await axiosInstance.get('auth/me/'); + const response = await axiosInstance.post('two-factor-challenge', { code }); return buildSharedData(response.data); } + +export async function submitRecoveryCode( + recoveryCode: string +): Promise { + await ensureCSRFToken(); + const response = await axiosInstance.post('two-factor-challenge', { + recovery_code: recoveryCode, + }); + return buildSharedData(response.data); +} + +export async function getRecoveryCodes(): Promise { + const response = await axiosInstance.get('user/two-factor-recovery-codes'); + return response.data; +} + +export async function regenerateRecoveryCodes(): Promise { + await ensureCSRFToken(); + const response = await axiosInstance.post('user/two-factor-recovery-codes'); + return response.data; +} +export async function enableTwoFactor(): Promise { + await ensureCSRFToken(); + const response = await axiosInstance.post('user/two-factor-authentication'); + return response.status; +} + +export async function disableTwoFactor(): Promise { + await ensureCSRFToken(); + const response = await axiosInstance.delete('user/two-factor-authentication'); + return response.status; +} + +export async function confirmTwoFactor(code: string): Promise { + await ensureCSRFToken(); + const response =await axiosInstance.post('user/confirmed-two-factor-authentication', { + code, + }); + + return response.status; +} diff --git a/client/src/stores/index.ts b/client/src/stores/index.ts index 676e6f4..97cb102 100644 --- a/client/src/stores/index.ts +++ b/client/src/stores/index.ts @@ -40,11 +40,11 @@ export const useStore = create()( { name: 'notetify-store', storage: createJSONStorage(() => localStorage), - onRehydrateStorage: () => (state) => { + onRehydrateStorage: () => async (state) => { // After rehydrate, start an auth confirmation in background const t = state?.theme ?? 'system'; applyTheme(t); - state?.confirmAuth?.(); + await state?.confirmAuth?.(); }, partialize: (state) => ({ selectedNoteId: state.selectedNoteId, diff --git a/client/src/stores/slices/auth-slice.ts b/client/src/stores/slices/auth-slice.ts index 9310e91..6e32375 100644 --- a/client/src/stores/slices/auth-slice.ts +++ b/client/src/stores/slices/auth-slice.ts @@ -6,8 +6,10 @@ import type { FormErrors } from '../../utils/helpers'; import { mapAxiosErrorToFieldErrors } from '../../utils/helpers'; type LoginParams = { email: string; password: string; remember?: boolean }; +type AuthenticationStepType = 'credentials' | 'two-factor' | 'recovery'; type AuthSliceState = { + authenticationStep: AuthenticationStepType; isLoading: boolean; isAuthenticated: boolean; checkingAuth: boolean; @@ -17,10 +19,11 @@ type AuthSliceState = { }; export type AuthSliceActions = { + setAuthenticationStep: (step: AuthenticationStepType) => void; setErrors: (e: FormErrors | null) => void; setSharedData: (s: SharedData | null) => void; clearErrors: () => void; - + updatePassword: (current: string, newPassword: string) => Promise; SignUp: ( first_name: string, last_name: string, @@ -31,9 +34,18 @@ export type AuthSliceActions = { Logout: () => Promise; PasswordReset: ( token: string | undefined, - password: string + password: string, + email: string ) => Promise; - ForgotPassword: (email: string) => Promise; + getTwoFactorQrCode: () => Promise; + regenerateRecoveryCodes: () => Promise; + getRecoveryCodes: () => Promise; + confirmTwoFactor: (code: string) => Promise; + disableTwoFactor: () => Promise; + enableTwoFactor: () => Promise; + submitRecoveryCode: (recoveryCode: string) => Promise; + submitTwoFactorCode: (code: string) => Promise; + ForgotPassword: (email: string) => Promise; VerifyEmail: (email: string) => Promise; ConfirmPassword: (password: string) => Promise; confirmAuth: () => Promise; @@ -44,19 +56,140 @@ export type AuthSlice = AuthSliceState & AuthSliceActions; export const createAuthSlice: StateCreator = ( set, - get + _get ) => ({ + authenticationStep: 'credentials', isLoading: false, isAuthenticated: false, checkingAuth: true, errors: null, sharedData: null, url: '', + setAuthenticationStep: (step) => set({ authenticationStep: step }), setUrl: (url) => set({ url }), setErrors: (e) => set({ errors: e }), clearErrors: () => set({ errors: null }), setSharedData: (s) => set({ sharedData: s }), + async getTwoFactorQrCode() { + set({ isLoading: true, errors: null }); + try { + const svg = await authService.getTwoFactorQrCode(); + return svg; + } catch (error: any) { + set({ errors: mapAxiosErrorToFieldErrors(error) }); + return undefined; + } finally { + set({ isLoading: false }); + } + }, + + async getRecoveryCodes() { + set({ isLoading: true, errors: null }); + try { + const codes = await authService.getRecoveryCodes(); + return codes; + } catch (error: any) { + set({ errors: mapAxiosErrorToFieldErrors(error) }); + return undefined; + } finally { + set({ isLoading: false }); + } + }, + + async regenerateRecoveryCodes() { + set({ isLoading: true, errors: null }); + try { + const codes = await authService.regenerateRecoveryCodes(); + return codes; + } catch (error: any) { + set({ errors: mapAxiosErrorToFieldErrors(error) }); + return undefined; + } finally { + set({ isLoading: false }); + } + }, + + async confirmTwoFactor(code) { + set({ isLoading: true, errors: null }); + try { + const statuCode = await authService.confirmTwoFactor(code); + } catch (error: any) { + set({ errors: mapAxiosErrorToFieldErrors(error) }); + } finally { + set({ isLoading: false }); + } + }, + + async disableTwoFactor() { + set({ isLoading: true, errors: null }); + try { + const statuCode = await authService.disableTwoFactor(); + } catch (error: any) { + set({ errors: mapAxiosErrorToFieldErrors(error) }); + } finally { + set({ isLoading: false }); + } + }, + + async enableTwoFactor() { + set({ isLoading: true, errors: null }); + try { + const statuCode = await authService.enableTwoFactor(); + } catch (error: any) { + set({ errors: mapAxiosErrorToFieldErrors(error) }); + } finally { + set({ isLoading: false }); + } + }, + + async submitRecoveryCode(recoveryCode) { + set({ isLoading: true, errors: null }); + try { + const shared = await authService.submitRecoveryCode(recoveryCode); + set({ + isAuthenticated: true, + sharedData: shared, + authenticationStep: 'credentials', + }); + return shared; + } catch (error: any) { + set({ errors: mapAxiosErrorToFieldErrors(error) }); + throw error; + } finally { + set({ isLoading: false }); + } + }, + + async submitTwoFactorCode(code) { + set({ isLoading: true, errors: null }); + try { + const shared = await authService.submitTwoFactorCode(code); + set({ + isAuthenticated: true, + sharedData: shared, + authenticationStep: 'credentials', + }); + return shared; + } catch (error: any) { + set({ errors: mapAxiosErrorToFieldErrors(error) }); + throw error; + } finally { + set({ isLoading: false }); + } + }, + async updatePassword(current, newPassword) { + set({ isLoading: true, errors: null }); + try { + await authService.updatePassword(current, newPassword); + return true; + } catch (error: any) { + set({ errors: mapAxiosErrorToFieldErrors(error) }); + return false; + } finally { + set({ isLoading: false }); + } + }, async SignUp(first_name, last_name, email, password) { set({ isLoading: true, errors: null }); try { @@ -102,7 +235,13 @@ export const createAuthSlice: StateCreator = ( } const shared = await authService.login({ email, password, remember }); - set({ isAuthenticated: true, sharedData: shared }); + if ('requiresTwoFactor' in shared) { + set({ authenticationStep: 'two-factor' }); + } else if ('requiresRecovery' in shared) { + set({ authenticationStep: 'recovery' }); + } else { + set({ isAuthenticated: true, sharedData: shared }); + } return true; } catch (error: any) { set({ errors: mapAxiosErrorToFieldErrors(error) }); @@ -130,7 +269,7 @@ export const createAuthSlice: StateCreator = ( } }, - async PasswordReset(token, password) { + async PasswordReset(token, password, email) { set({ isLoading: true, errors: null }); try { if (!token || !password?.trim()) { @@ -146,7 +285,11 @@ export const createAuthSlice: StateCreator = ( return false; } - await authService.passwordReset({ token, password }); + const statusCode = await authService.passwordReset({ + token, + password, + email, + }); return true; } catch (error: any) { set({ errors: mapAxiosErrorToFieldErrors(error) }); @@ -205,11 +348,7 @@ export const createAuthSlice: StateCreator = ( searchNotebooks: '', }); - try { - await authService.logout(); - } catch (e) { - // server logout may fail; local state already cleared - } + await authService.logout(); } finally { set({ isLoading: false }); } diff --git a/client/src/types/index.ts b/client/src/types/index.ts index 2d6fe2a..5e0e73a 100644 --- a/client/src/types/index.ts +++ b/client/src/types/index.ts @@ -45,6 +45,9 @@ export interface User extends Omit { remember?: boolean; avatar?: string; is_active: boolean; + emailNotificationEnabled?: boolean; + pushNotificationEnabled?: boolean; + marketingNotificationEnabled?: boolean; } export interface SharedData { name: string; diff --git a/client/tests/auth/login.test.tsx b/client/tests/auth/login.test.tsx index 798cf67..def22df 100644 --- a/client/tests/auth/login.test.tsx +++ b/client/tests/auth/login.test.tsx @@ -60,8 +60,10 @@ describe('Login page', () => { const passwordInput = screen.getByLabelText(/^Password$/i); const submitButton = screen.getByRole('button', { name: /Log in/i }); + // Use a valid email format to pass native HTML validation, + // but a short password to fail Zod schema validation fireEvent.change(emailInput, { - target: { name: 'email', value: 'invalid' }, + target: { name: 'email', value: 'user@example.com' }, }); fireEvent.change(passwordInput, { target: { name: 'password', value: 'short' }, @@ -71,7 +73,6 @@ describe('Login page', () => { expect(mockSetErrors).toHaveBeenNthCalledWith(1, null); expect(mockSetErrors).toHaveBeenNthCalledWith(2, { - email: ['Please enter a valid email address.'], password: ['Password must be at least 8 characters long.'], }); expect(mockLogin).not.toHaveBeenCalled(); diff --git a/client/tests/auth/reset-password.test.tsx b/client/tests/auth/reset-password.test.tsx index b5cce66..187003c 100644 --- a/client/tests/auth/reset-password.test.tsx +++ b/client/tests/auth/reset-password.test.tsx @@ -12,6 +12,13 @@ vi.mock('../../src/stores/index.ts', () => ({ isLoading: false, errors: null, setErrors: mockSetErrors, + sharedData: { + auth: { + user: { + email: 'test@example.com', + }, + }, + }, }), })); @@ -44,7 +51,11 @@ describe('ResetPassword page', () => { fireEvent.click(screen.getByRole('button', { name: /Reset password/i })); expect(mockSetErrors).toHaveBeenCalledWith(null); - expect(mockPasswordReset).toHaveBeenCalledWith('abc123', 'password123'); + expect(mockPasswordReset).toHaveBeenCalledWith( + 'abc123', + 'password123', + 'test@example.com' + ); }); it('prevents submission when passwords do not match', async () => { diff --git a/client/tests/auth/verify-email.test.tsx b/client/tests/auth/verify-email.test.tsx index 4af4496..8735446 100644 --- a/client/tests/auth/verify-email.test.tsx +++ b/client/tests/auth/verify-email.test.tsx @@ -9,6 +9,13 @@ vi.mock('../../src/stores/index.ts', () => ({ useStore: () => ({ VerifyEmail: mockVerifyEmail, isLoading: false, + sharedData: { + auth: { + user: { + email: 'test@example.com', + }, + }, + }, }), })); @@ -29,6 +36,6 @@ describe('VerifyEmail page', () => { }); fireEvent.click(submitButton); - expect(mockVerifyEmail).toHaveBeenCalledWith(''); + expect(mockVerifyEmail).toHaveBeenCalledWith('test@example.com'); }); }); diff --git a/client/tests/setup.ts b/client/tests/setup.ts index 46d0fe1..2a33c18 100644 --- a/client/tests/setup.ts +++ b/client/tests/setup.ts @@ -9,6 +9,21 @@ class ResizeObserver { } global.ResizeObserver = ResizeObserver; +// Mock matchMedia for theme-slice.ts +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}); + // Mock js-cookie to prevent CSRF token checks from making real requests vi.mock('js-cookie', () => ({ default: { diff --git a/compose.yaml b/compose.yaml index d4000bd..475740f 100644 --- a/compose.yaml +++ b/compose.yaml @@ -120,6 +120,34 @@ services: env_file: - .env.development + + + mailpit: + networks: + - notetify-net + image: 'axllent/mailpit:latest' + container_name: mailpit + restart: always + hostname: localhost + volumes: + - ./data:/data + ports: + - '${FORWARD_MAILPIT_PORT:-1025}:1025' + - '${FORWARD_MAILPIT_DASHBOARD_PORT:-8025}:8025' + expose: + - 8025 + - 1025 + depends_on: + api: + condition: service_healthy + environment: + MP_MAX_MESSAGES: 5000 + MP_DATA_FILE: /data/mailpit.db + MP_SMTP_AUTH_ACCEPT_ANY: 1 + MP_SMTP_AUTH_ALLOW_INSECURE: 1 + env_file: + - .env.development + networks: notetify-net: driver: bridge