From a07cb8c34ca2a3a9fe5030e34ad5030f7446d865 Mon Sep 17 00:00:00 2001 From: Amarusinggithub Date: Thu, 15 Jan 2026 21:22:05 -0500 Subject: [PATCH 1/3] feat: implemented fortify for auth --- .env.example | 5 +- api/app/Actions/Fortify/CreateNewUser.php | 6 +- .../Fortify/UpdateUserProfileInformation.php | 10 +- api/app/Providers/FortifyServiceProvider.php | 9 ++ api/config/cors.php | 2 +- api/config/fortify.php | 2 +- api/routes/api.php | 7 + client/src/pages/auth/confirm-password.tsx | 12 +- client/src/pages/auth/forgot-password.tsx | 25 ++- client/src/pages/auth/login.tsx | 38 ++++- client/src/pages/auth/one-time-password.tsx | 13 +- client/src/pages/auth/register.tsx | 8 +- client/src/pages/auth/reset-password.tsx | 13 +- .../pages/auth/two-factor-verification.tsx | 4 +- client/src/pages/auth/verify-email.tsx | 15 +- client/src/services/auth-service.ts | 134 +++++++++++++-- client/src/stores/index.ts | 4 +- client/src/stores/slices/auth-slice.ts | 152 ++++++++++++++++-- 18 files changed, 380 insertions(+), 79 deletions(-) diff --git a/.env.example b/.env.example index f6b6747..e1c015c 100644 --- a/.env.example +++ b/.env.example @@ -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/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/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/stores/slices/auth-slice.ts b/client/src/stores/slices/auth-slice.ts index 228f216..6e32375 100644 --- a/client/src/stores/slices/auth-slice.ts +++ b/client/src/stores/slices/auth-slice.ts @@ -71,7 +71,7 @@ export const createAuthSlice: StateCreator = ( clearErrors: () => set({ errors: null }), setSharedData: (s) => set({ sharedData: s }), -async getTwoFactorQrCode() { + async getTwoFactorQrCode() { set({ isLoading: true, errors: null }); try { const svg = await authService.getTwoFactorQrCode(); @@ -113,7 +113,7 @@ async getTwoFactorQrCode() { async confirmTwoFactor(code) { set({ isLoading: true, errors: null }); try { - const statuCode=await authService.confirmTwoFactor(code); + const statuCode = await authService.confirmTwoFactor(code); } catch (error: any) { set({ errors: mapAxiosErrorToFieldErrors(error) }); } finally { @@ -147,7 +147,11 @@ async getTwoFactorQrCode() { set({ isLoading: true, errors: null }); try { const shared = await authService.submitRecoveryCode(recoveryCode); - set({ isAuthenticated: true, sharedData: shared, authenticationStep: 'credentials' }); + set({ + isAuthenticated: true, + sharedData: shared, + authenticationStep: 'credentials', + }); return shared; } catch (error: any) { set({ errors: mapAxiosErrorToFieldErrors(error) }); @@ -161,7 +165,11 @@ async getTwoFactorQrCode() { set({ isLoading: true, errors: null }); try { const shared = await authService.submitTwoFactorCode(code); - set({ isAuthenticated: true, sharedData: shared, authenticationStep: 'credentials' }); + set({ + isAuthenticated: true, + sharedData: shared, + authenticationStep: 'credentials', + }); return shared; } catch (error: any) { set({ errors: mapAxiosErrorToFieldErrors(error) }); @@ -229,7 +237,6 @@ async getTwoFactorQrCode() { const shared = await authService.login({ email, password, remember }); if ('requiresTwoFactor' in shared) { set({ authenticationStep: 'two-factor' }); - } else if ('requiresRecovery' in shared) { set({ authenticationStep: 'recovery' }); } else { @@ -278,7 +285,11 @@ async getTwoFactorQrCode() { return false; } - const statusCode=await authService.passwordReset({ token, password, email }); + const statusCode = await authService.passwordReset({ + token, + password, + email, + }); return true; } catch (error: any) { set({ errors: mapAxiosErrorToFieldErrors(error) }); 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/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 From 169093f16eeb7b6eca4c60ecf49bce4c253e0d50 Mon Sep 17 00:00:00 2001 From: Amarusinggithub Date: Sat, 17 Jan 2026 09:10:36 -0500 Subject: [PATCH 3/3] refactor: improve type safety and clean up settings pages - Add NotesType union type to use-note.ts for better cache typing - Add null check for user in NavUser component - Remove unused account.tsx and settings.tsx pages - Consolidate settings functionality into general.tsx - Update auth-slice with improved typing - Add preferredLanguage field to User model - Update compose.yaml with additional services - Fix unused variable warnings in Cypress commands - Change default filesystem disk to public in .env.example --- .env.example | 2 +- api/app/Models/User.php | 4 +- client/cypress/support/commands.ts | 2 +- client/src/components/nav-user.tsx | 10 +++- client/src/hooks/use-note.ts | 26 ++++----- client/src/layouts/settings/layout.tsx | 10 ++-- client/src/pages/settings/account.tsx | 61 --------------------- client/src/pages/settings/general.tsx | 65 +++++++++++++++-------- client/src/pages/settings/settings.tsx | 8 --- client/src/routes/app-routes.tsx | 4 +- client/src/stores/slices/auth-slice.ts | 23 +++++--- client/src/types/index.ts | 3 ++ client/tests/auth/login.test.tsx | 5 +- client/tests/auth/reset-password.test.tsx | 13 ++++- client/tests/auth/verify-email.test.tsx | 9 +++- client/tests/setup.ts | 15 ++++++ compose.yaml | 28 ++++++++++ 17 files changed, 158 insertions(+), 130 deletions(-) delete mode 100644 client/src/pages/settings/account.tsx delete mode 100644 client/src/pages/settings/settings.tsx diff --git a/.env.example b/.env.example index e1c015c..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= 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/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/settings/account.tsx b/client/src/pages/settings/account.tsx deleted file mode 100644 index 6399134..0000000 --- a/client/src/pages/settings/account.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { useState } from 'react'; -import { toast } from 'sonner'; -import { Button } from '../../components/ui/button'; -import { Input } from '../../components/ui/input'; -import { Label } from '../../components/ui/label'; -import { useStore } from '../../stores/index'; - -export default function Account() { - const { sharedData, setSharedData } = useStore(); - const user = sharedData?.auth.user; - - const [firstName, setFirstName] = useState(user?.first_name || ''); - const [lastName, setLastName] = useState(user?.last_name || ''); - const [email] = useState(user?.email || ''); - - const onSave = (e: React.FormEvent) => { - e.preventDefault(); - if (!sharedData || !user) return; - const updated = { - ...sharedData, - name: `${firstName} ${lastName}`.trim(), - auth: { - user: { - ...user, - first_name: firstName.trim(), - last_name: lastName.trim(), - }, - }, - }; - setSharedData(updated); - toast.success('Account details updated'); - }; - - return ( - -
- - setFirstName(e.target.value)} - /> -
-
- - setLastName(e.target.value)} - /> -
-
- - -
-
- -
- - ); -} 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/stores/slices/auth-slice.ts b/client/src/stores/slices/auth-slice.ts index 228f216..6e32375 100644 --- a/client/src/stores/slices/auth-slice.ts +++ b/client/src/stores/slices/auth-slice.ts @@ -71,7 +71,7 @@ export const createAuthSlice: StateCreator = ( clearErrors: () => set({ errors: null }), setSharedData: (s) => set({ sharedData: s }), -async getTwoFactorQrCode() { + async getTwoFactorQrCode() { set({ isLoading: true, errors: null }); try { const svg = await authService.getTwoFactorQrCode(); @@ -113,7 +113,7 @@ async getTwoFactorQrCode() { async confirmTwoFactor(code) { set({ isLoading: true, errors: null }); try { - const statuCode=await authService.confirmTwoFactor(code); + const statuCode = await authService.confirmTwoFactor(code); } catch (error: any) { set({ errors: mapAxiosErrorToFieldErrors(error) }); } finally { @@ -147,7 +147,11 @@ async getTwoFactorQrCode() { set({ isLoading: true, errors: null }); try { const shared = await authService.submitRecoveryCode(recoveryCode); - set({ isAuthenticated: true, sharedData: shared, authenticationStep: 'credentials' }); + set({ + isAuthenticated: true, + sharedData: shared, + authenticationStep: 'credentials', + }); return shared; } catch (error: any) { set({ errors: mapAxiosErrorToFieldErrors(error) }); @@ -161,7 +165,11 @@ async getTwoFactorQrCode() { set({ isLoading: true, errors: null }); try { const shared = await authService.submitTwoFactorCode(code); - set({ isAuthenticated: true, sharedData: shared, authenticationStep: 'credentials' }); + set({ + isAuthenticated: true, + sharedData: shared, + authenticationStep: 'credentials', + }); return shared; } catch (error: any) { set({ errors: mapAxiosErrorToFieldErrors(error) }); @@ -229,7 +237,6 @@ async getTwoFactorQrCode() { const shared = await authService.login({ email, password, remember }); if ('requiresTwoFactor' in shared) { set({ authenticationStep: 'two-factor' }); - } else if ('requiresRecovery' in shared) { set({ authenticationStep: 'recovery' }); } else { @@ -278,7 +285,11 @@ async getTwoFactorQrCode() { return false; } - const statusCode=await authService.passwordReset({ token, password, email }); + const statusCode = await authService.passwordReset({ + token, + password, + email, + }); return true; } catch (error: any) { set({ errors: mapAxiosErrorToFieldErrors(error) }); 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