diff --git a/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/app/Http/Controllers/Auth/AuthenticatedSessionController.php new file mode 100644 index 0000000..d44fe97 --- /dev/null +++ b/app/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -0,0 +1,52 @@ + Route::has('password.request'), + 'status' => session('status'), + ]); + } + + /** + * Handle an incoming authentication request. + */ + public function store(LoginRequest $request): RedirectResponse + { + $request->authenticate(); + + $request->session()->regenerate(); + + return redirect()->intended(route('dashboard', absolute: false)); + } + + /** + * Destroy an authenticated session. + */ + public function destroy(Request $request): RedirectResponse + { + Auth::guard('web')->logout(); + + $request->session()->invalidate(); + + $request->session()->regenerateToken(); + + return redirect('/'); + } +} diff --git a/app/Http/Controllers/Auth/ConfirmablePasswordController.php b/app/Http/Controllers/Auth/ConfirmablePasswordController.php new file mode 100644 index 0000000..d2b1f14 --- /dev/null +++ b/app/Http/Controllers/Auth/ConfirmablePasswordController.php @@ -0,0 +1,41 @@ +validate([ + 'email' => $request->user()->email, + 'password' => $request->password, + ])) { + throw ValidationException::withMessages([ + 'password' => __('auth.password'), + ]); + } + + $request->session()->put('auth.password_confirmed_at', time()); + + return redirect()->intended(route('dashboard', absolute: false)); + } +} diff --git a/app/Http/Controllers/Auth/EmailVerificationNotificationController.php b/app/Http/Controllers/Auth/EmailVerificationNotificationController.php new file mode 100644 index 0000000..f64fa9b --- /dev/null +++ b/app/Http/Controllers/Auth/EmailVerificationNotificationController.php @@ -0,0 +1,24 @@ +user()->hasVerifiedEmail()) { + return redirect()->intended(route('dashboard', absolute: false)); + } + + $request->user()->sendEmailVerificationNotification(); + + return back()->with('status', 'verification-link-sent'); + } +} diff --git a/app/Http/Controllers/Auth/EmailVerificationPromptController.php b/app/Http/Controllers/Auth/EmailVerificationPromptController.php new file mode 100644 index 0000000..b42e0d5 --- /dev/null +++ b/app/Http/Controllers/Auth/EmailVerificationPromptController.php @@ -0,0 +1,22 @@ +user()->hasVerifiedEmail() + ? redirect()->intended(route('dashboard', absolute: false)) + : Inertia::render('Auth/VerifyEmail', ['status' => session('status')]); + } +} diff --git a/app/Http/Controllers/Auth/NewPasswordController.php b/app/Http/Controllers/Auth/NewPasswordController.php new file mode 100644 index 0000000..394cc4a --- /dev/null +++ b/app/Http/Controllers/Auth/NewPasswordController.php @@ -0,0 +1,69 @@ + $request->email, + 'token' => $request->route('token'), + ]); + } + + /** + * Handle an incoming new password request. + * + * @throws \Illuminate\Validation\ValidationException + */ + public function store(Request $request): RedirectResponse + { + $request->validate([ + 'token' => 'required', + 'email' => 'required|email', + 'password' => ['required', 'confirmed', Rules\Password::defaults()], + ]); + + // Here we will attempt to reset the user's password. If it is successful we + // will update the password on an actual user model and persist it to the + // database. Otherwise we will parse the error and return the response. + $status = Password::reset( + $request->only('email', 'password', 'password_confirmation', 'token'), + function ($user) use ($request) { + $user->forceFill([ + 'password' => Hash::make($request->password), + 'remember_token' => Str::random(60), + ])->save(); + + event(new PasswordReset($user)); + } + ); + + // If the password was successfully reset, we will redirect the user back to + // the application's home authenticated view. If there is an error we can + // redirect them back to where they came from with their error message. + if ($status == Password::PASSWORD_RESET) { + return redirect()->route('login')->with('status', __($status)); + } + + throw ValidationException::withMessages([ + 'email' => [trans($status)], + ]); + } +} diff --git a/app/Http/Controllers/Auth/PasswordController.php b/app/Http/Controllers/Auth/PasswordController.php new file mode 100644 index 0000000..57a82b5 --- /dev/null +++ b/app/Http/Controllers/Auth/PasswordController.php @@ -0,0 +1,29 @@ +validate([ + 'current_password' => ['required', 'current_password'], + 'password' => ['required', Password::defaults(), 'confirmed'], + ]); + + $request->user()->update([ + 'password' => Hash::make($validated['password']), + ]); + + return back(); + } +} diff --git a/app/Http/Controllers/Auth/PasswordResetLinkController.php b/app/Http/Controllers/Auth/PasswordResetLinkController.php new file mode 100644 index 0000000..b22c544 --- /dev/null +++ b/app/Http/Controllers/Auth/PasswordResetLinkController.php @@ -0,0 +1,51 @@ + session('status'), + ]); + } + + /** + * Handle an incoming password reset link request. + * + * @throws \Illuminate\Validation\ValidationException + */ + public function store(Request $request): RedirectResponse + { + $request->validate([ + 'email' => 'required|email', + ]); + + // We will send the password reset link to this user. Once we have attempted + // to send the link, we will examine the response then see the message we + // need to show to the user. Finally, we'll send out a proper response. + $status = Password::sendResetLink( + $request->only('email') + ); + + if ($status == Password::RESET_LINK_SENT) { + return back()->with('status', __($status)); + } + + throw ValidationException::withMessages([ + 'email' => [trans($status)], + ]); + } +} diff --git a/app/Http/Controllers/Auth/RegisteredUserController.php b/app/Http/Controllers/Auth/RegisteredUserController.php new file mode 100644 index 0000000..53a546b --- /dev/null +++ b/app/Http/Controllers/Auth/RegisteredUserController.php @@ -0,0 +1,51 @@ +validate([ + 'name' => 'required|string|max:255', + 'email' => 'required|string|lowercase|email|max:255|unique:'.User::class, + 'password' => ['required', 'confirmed', Rules\Password::defaults()], + ]); + + $user = User::create([ + 'name' => $request->name, + 'email' => $request->email, + 'password' => Hash::make($request->password), + ]); + + event(new Registered($user)); + + Auth::login($user); + + return redirect(route('dashboard', absolute: false)); + } +} diff --git a/app/Http/Controllers/Auth/VerifyEmailController.php b/app/Http/Controllers/Auth/VerifyEmailController.php new file mode 100644 index 0000000..784765e --- /dev/null +++ b/app/Http/Controllers/Auth/VerifyEmailController.php @@ -0,0 +1,27 @@ +user()->hasVerifiedEmail()) { + return redirect()->intended(route('dashboard', absolute: false).'?verified=1'); + } + + if ($request->user()->markEmailAsVerified()) { + event(new Verified($request->user())); + } + + return redirect()->intended(route('dashboard', absolute: false).'?verified=1'); + } +} diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/ProfileController.php new file mode 100644 index 0000000..873b4f7 --- /dev/null +++ b/app/Http/Controllers/ProfileController.php @@ -0,0 +1,63 @@ + $request->user() instanceof MustVerifyEmail, + 'status' => session('status'), + ]); + } + + /** + * Update the user's profile information. + */ + public function update(ProfileUpdateRequest $request): RedirectResponse + { + $request->user()->fill($request->validated()); + + if ($request->user()->isDirty('email')) { + $request->user()->email_verified_at = null; + } + + $request->user()->save(); + + return Redirect::route('profile.edit'); + } + + /** + * Delete the user's account. + */ + public function destroy(Request $request): RedirectResponse + { + $request->validate([ + 'password' => ['required', 'current_password'], + ]); + + $user = $request->user(); + + Auth::logout(); + + $user->delete(); + + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return Redirect::to('/'); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php new file mode 100644 index 0000000..c736065 --- /dev/null +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -0,0 +1,39 @@ + + */ + public function share(Request $request): array + { + return [ + ...parent::share($request), + 'auth' => [ + 'user' => $request->user(), + ], + ]; + } +} diff --git a/app/Http/Requests/Auth/LoginRequest.php b/app/Http/Requests/Auth/LoginRequest.php new file mode 100644 index 0000000..2b92f65 --- /dev/null +++ b/app/Http/Requests/Auth/LoginRequest.php @@ -0,0 +1,85 @@ + + */ + public function rules(): array + { + return [ + 'email' => ['required', 'string', 'email'], + 'password' => ['required', 'string'], + ]; + } + + /** + * Attempt to authenticate the request's credentials. + * + * @throws \Illuminate\Validation\ValidationException + */ + public function authenticate(): void + { + $this->ensureIsNotRateLimited(); + + if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) { + RateLimiter::hit($this->throttleKey()); + + throw ValidationException::withMessages([ + 'email' => trans('auth.failed'), + ]); + } + + RateLimiter::clear($this->throttleKey()); + } + + /** + * Ensure the login request is not rate limited. + * + * @throws \Illuminate\Validation\ValidationException + */ + public function ensureIsNotRateLimited(): void + { + if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) { + return; + } + + event(new Lockout($this)); + + $seconds = RateLimiter::availableIn($this->throttleKey()); + + throw ValidationException::withMessages([ + 'email' => trans('auth.throttle', [ + 'seconds' => $seconds, + 'minutes' => ceil($seconds / 60), + ]), + ]); + } + + /** + * Get the rate limiting throttle key for the request. + */ + public function throttleKey(): string + { + return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip()); + } +} diff --git a/app/Http/Requests/ProfileUpdateRequest.php b/app/Http/Requests/ProfileUpdateRequest.php new file mode 100644 index 0000000..93b0022 --- /dev/null +++ b/app/Http/Requests/ProfileUpdateRequest.php @@ -0,0 +1,23 @@ + + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'string', 'lowercase', 'email', 'max:255', Rule::unique(User::class)->ignore($this->user()->id)], + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index def621f..f2533ca 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -3,11 +3,13 @@ namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; + +use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; -class User extends Authenticatable +class User extends Authenticatable implements MustVerifyEmail { use HasFactory, Notifiable; diff --git a/bootstrap/app.php b/bootstrap/app.php index 7b162da..113955b 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -11,6 +11,11 @@ health: '/up', ) ->withMiddleware(function (Middleware $middleware) { + $middleware->web(append: [ + \App\Http\Middleware\HandleInertiaRequests::class, + \Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class, + ]); + // }) ->withExceptions(function (Exceptions $exceptions) { diff --git a/composer.json b/composer.json index 4b7e183..866fc74 100644 --- a/composer.json +++ b/composer.json @@ -6,11 +6,15 @@ "license": "MIT", "require": { "php": "^8.2", + "inertiajs/inertia-laravel": "^1.0", "laravel/framework": "^11.9", - "laravel/tinker": "^2.9" + "laravel/sanctum": "^4.0", + "laravel/tinker": "^2.9", + "tightenco/ziggy": "^2.0" }, "require-dev": { "fakerphp/faker": "^1.23", + "laravel/breeze": "^2.1", "laravel/pint": "^1.13", "laravel/sail": "^1.26", "mockery/mockery": "^1.6", diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..6269354 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["resources/js/*"], + "ziggy-js": ["./vendor/tightenco/ziggy"] + } + }, + "exclude": ["node_modules", "public"] +} diff --git a/package.json b/package.json index 4e934ca..30c3879 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,17 @@ "build": "vite build" }, "devDependencies": { + "@headlessui/react": "^2.0.0", + "@inertiajs/react": "^1.0.0", + "@tailwindcss/forms": "^0.5.3", + "@vitejs/plugin-react": "^4.2.0", + "autoprefixer": "^10.4.12", "axios": "^1.6.4", "laravel-vite-plugin": "^1.0", + "postcss": "^8.4.31", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "tailwindcss": "^3.2.1", "vite": "^5.0" } } diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..49c0612 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/resources/css/app.css b/resources/css/app.css index e69de29..b5c61c9 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/resources/js/Components/ApplicationLogo.jsx b/resources/js/Components/ApplicationLogo.jsx new file mode 100644 index 0000000..e9f15d7 --- /dev/null +++ b/resources/js/Components/ApplicationLogo.jsx @@ -0,0 +1,7 @@ +export default function ApplicationLogo(props) { + return ( + + + + ); +} diff --git a/resources/js/Components/Checkbox.jsx b/resources/js/Components/Checkbox.jsx new file mode 100644 index 0000000..34a31fe --- /dev/null +++ b/resources/js/Components/Checkbox.jsx @@ -0,0 +1,12 @@ +export default function Checkbox({ className = '', ...props }) { + return ( + + ); +} diff --git a/resources/js/Components/DangerButton.jsx b/resources/js/Components/DangerButton.jsx new file mode 100644 index 0000000..45cf59b --- /dev/null +++ b/resources/js/Components/DangerButton.jsx @@ -0,0 +1,15 @@ +export default function DangerButton({ className = '', disabled, children, ...props }) { + return ( + + ); +} diff --git a/resources/js/Components/Dropdown.jsx b/resources/js/Components/Dropdown.jsx new file mode 100644 index 0000000..2cc0b2e --- /dev/null +++ b/resources/js/Components/Dropdown.jsx @@ -0,0 +1,90 @@ +import { useState, createContext, useContext } from 'react'; +import { Link } from '@inertiajs/react'; +import { Transition } from '@headlessui/react'; + +const DropDownContext = createContext(); + +const Dropdown = ({ children }) => { + const [open, setOpen] = useState(false); + + const toggleOpen = () => { + setOpen((previousState) => !previousState); + }; + + return ( + +
{children}
+
+ ); +}; + +const Trigger = ({ children }) => { + const { open, setOpen, toggleOpen } = useContext(DropDownContext); + + return ( + <> +
{children}
+ + {open &&
setOpen(false)}>
} + + ); +}; + +const Content = ({ align = 'right', width = '48', contentClasses = 'py-1 bg-white', children }) => { + const { open, setOpen } = useContext(DropDownContext); + + let alignmentClasses = 'origin-top'; + + if (align === 'left') { + alignmentClasses = 'ltr:origin-top-left rtl:origin-top-right start-0'; + } else if (align === 'right') { + alignmentClasses = 'ltr:origin-top-right rtl:origin-top-left end-0'; + } + + let widthClasses = ''; + + if (width === '48') { + widthClasses = 'w-48'; + } + + return ( + <> + +
setOpen(false)} + > +
{children}
+
+
+ + ); +}; + +const DropdownLink = ({ className = '', children, ...props }) => { + return ( + + {children} + + ); +}; + +Dropdown.Trigger = Trigger; +Dropdown.Content = Content; +Dropdown.Link = DropdownLink; + +export default Dropdown; diff --git a/resources/js/Components/InputError.jsx b/resources/js/Components/InputError.jsx new file mode 100644 index 0000000..ec57b3e --- /dev/null +++ b/resources/js/Components/InputError.jsx @@ -0,0 +1,7 @@ +export default function InputError({ message, className = '', ...props }) { + return message ? ( +

+ {message} +

+ ) : null; +} diff --git a/resources/js/Components/InputLabel.jsx b/resources/js/Components/InputLabel.jsx new file mode 100644 index 0000000..20fcf7e --- /dev/null +++ b/resources/js/Components/InputLabel.jsx @@ -0,0 +1,7 @@ +export default function InputLabel({ value, className = '', children, ...props }) { + return ( + + ); +} diff --git a/resources/js/Components/Modal.jsx b/resources/js/Components/Modal.jsx new file mode 100644 index 0000000..98c9f71 --- /dev/null +++ b/resources/js/Components/Modal.jsx @@ -0,0 +1,54 @@ +import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react'; + +export default function Modal({ children, show = false, maxWidth = '2xl', closeable = true, onClose = () => {} }) { + const close = () => { + if (closeable) { + onClose(); + } + }; + + const maxWidthClass = { + sm: 'sm:max-w-sm', + md: 'sm:max-w-md', + lg: 'sm:max-w-lg', + xl: 'sm:max-w-xl', + '2xl': 'sm:max-w-2xl', + }[maxWidth]; + + return ( + + + +
+ + + + + {children} + + +
+
+ ); +} diff --git a/resources/js/Components/NavLink.jsx b/resources/js/Components/NavLink.jsx new file mode 100644 index 0000000..71a983c --- /dev/null +++ b/resources/js/Components/NavLink.jsx @@ -0,0 +1,18 @@ +import { Link } from '@inertiajs/react'; + +export default function NavLink({ active = false, className = '', children, ...props }) { + return ( + + {children} + + ); +} diff --git a/resources/js/Components/PrimaryButton.jsx b/resources/js/Components/PrimaryButton.jsx new file mode 100644 index 0000000..4f422cd --- /dev/null +++ b/resources/js/Components/PrimaryButton.jsx @@ -0,0 +1,15 @@ +export default function PrimaryButton({ className = '', disabled, children, ...props }) { + return ( + + ); +} diff --git a/resources/js/Components/ResponsiveNavLink.jsx b/resources/js/Components/ResponsiveNavLink.jsx new file mode 100644 index 0000000..4a64ddb --- /dev/null +++ b/resources/js/Components/ResponsiveNavLink.jsx @@ -0,0 +1,16 @@ +import { Link } from '@inertiajs/react'; + +export default function ResponsiveNavLink({ active = false, className = '', children, ...props }) { + return ( + + {children} + + ); +} diff --git a/resources/js/Components/SecondaryButton.jsx b/resources/js/Components/SecondaryButton.jsx new file mode 100644 index 0000000..730beac --- /dev/null +++ b/resources/js/Components/SecondaryButton.jsx @@ -0,0 +1,16 @@ +export default function SecondaryButton({ type = 'button', className = '', disabled, children, ...props }) { + return ( + + ); +} diff --git a/resources/js/Components/TextInput.jsx b/resources/js/Components/TextInput.jsx new file mode 100644 index 0000000..f1094dc --- /dev/null +++ b/resources/js/Components/TextInput.jsx @@ -0,0 +1,23 @@ +import { forwardRef, useEffect, useRef } from 'react'; + +export default forwardRef(function TextInput({ type = 'text', className = '', isFocused = false, ...props }, ref) { + const input = ref ? ref : useRef(); + + useEffect(() => { + if (isFocused) { + input.current.focus(); + } + }, []); + + return ( + + ); +}); diff --git a/resources/js/Layouts/AuthenticatedLayout.jsx b/resources/js/Layouts/AuthenticatedLayout.jsx new file mode 100644 index 0000000..079a0b3 --- /dev/null +++ b/resources/js/Layouts/AuthenticatedLayout.jsx @@ -0,0 +1,125 @@ +import { useState } from 'react'; +import ApplicationLogo from '@/Components/ApplicationLogo'; +import Dropdown from '@/Components/Dropdown'; +import NavLink from '@/Components/NavLink'; +import ResponsiveNavLink from '@/Components/ResponsiveNavLink'; +import { Link } from '@inertiajs/react'; + +export default function Authenticated({ user, header, children }) { + const [showingNavigationDropdown, setShowingNavigationDropdown] = useState(false); + + return ( +
+ + + {header && ( +
+
{header}
+
+ )} + +
{children}
+
+ ); +} diff --git a/resources/js/Layouts/GuestLayout.jsx b/resources/js/Layouts/GuestLayout.jsx new file mode 100644 index 0000000..090d571 --- /dev/null +++ b/resources/js/Layouts/GuestLayout.jsx @@ -0,0 +1,18 @@ +import ApplicationLogo from '@/Components/ApplicationLogo'; +import { Link } from '@inertiajs/react'; + +export default function Guest({ children }) { + return ( +
+
+ + + +
+ +
+ {children} +
+
+ ); +} diff --git a/resources/js/Pages/Auth/ConfirmPassword.jsx b/resources/js/Pages/Auth/ConfirmPassword.jsx new file mode 100644 index 0000000..fe97fd2 --- /dev/null +++ b/resources/js/Pages/Auth/ConfirmPassword.jsx @@ -0,0 +1,59 @@ +import { useEffect } from 'react'; +import GuestLayout from '@/Layouts/GuestLayout'; +import InputError from '@/Components/InputError'; +import InputLabel from '@/Components/InputLabel'; +import PrimaryButton from '@/Components/PrimaryButton'; +import TextInput from '@/Components/TextInput'; +import { Head, useForm } from '@inertiajs/react'; + +export default function ConfirmPassword() { + const { data, setData, post, processing, errors, reset } = useForm({ + password: '', + }); + + useEffect(() => { + return () => { + reset('password'); + }; + }, []); + + const submit = (e) => { + e.preventDefault(); + + post(route('password.confirm')); + }; + + return ( + + + +
+ This is a secure area of the application. Please confirm your password before continuing. +
+ +
+
+ + + setData('password', e.target.value)} + /> + + +
+ +
+ + Confirm + +
+
+
+ ); +} diff --git a/resources/js/Pages/Auth/ForgotPassword.jsx b/resources/js/Pages/Auth/ForgotPassword.jsx new file mode 100644 index 0000000..46bcd76 --- /dev/null +++ b/resources/js/Pages/Auth/ForgotPassword.jsx @@ -0,0 +1,50 @@ +import GuestLayout from '@/Layouts/GuestLayout'; +import InputError from '@/Components/InputError'; +import PrimaryButton from '@/Components/PrimaryButton'; +import TextInput from '@/Components/TextInput'; +import { Head, useForm } from '@inertiajs/react'; + +export default function ForgotPassword({ status }) { + const { data, setData, post, processing, errors } = useForm({ + email: '', + }); + + const submit = (e) => { + e.preventDefault(); + + post(route('password.email')); + }; + + return ( + + + +
+ Forgot your password? No problem. Just let us know your email address and we will email you a password + reset link that will allow you to choose a new one. +
+ + {status &&
{status}
} + +
+ setData('email', e.target.value)} + /> + + + +
+ + Email Password Reset Link + +
+ +
+ ); +} diff --git a/resources/js/Pages/Auth/Login.jsx b/resources/js/Pages/Auth/Login.jsx new file mode 100644 index 0000000..11bee02 --- /dev/null +++ b/resources/js/Pages/Auth/Login.jsx @@ -0,0 +1,97 @@ +import { useEffect } from 'react'; +import Checkbox from '@/Components/Checkbox'; +import GuestLayout from '@/Layouts/GuestLayout'; +import InputError from '@/Components/InputError'; +import InputLabel from '@/Components/InputLabel'; +import PrimaryButton from '@/Components/PrimaryButton'; +import TextInput from '@/Components/TextInput'; +import { Head, Link, useForm } from '@inertiajs/react'; + +export default function Login({ status, canResetPassword }) { + const { data, setData, post, processing, errors, reset } = useForm({ + email: '', + password: '', + remember: false, + }); + + useEffect(() => { + return () => { + reset('password'); + }; + }, []); + + const submit = (e) => { + e.preventDefault(); + + post(route('login')); + }; + + return ( + + + + {status &&
{status}
} + +
+
+ + + setData('email', e.target.value)} + /> + + +
+ +
+ + + setData('password', e.target.value)} + /> + + +
+ +
+ +
+ +
+ {canResetPassword && ( + + Forgot your password? + + )} + + + Log in + +
+
+
+ ); +} diff --git a/resources/js/Pages/Auth/Register.jsx b/resources/js/Pages/Auth/Register.jsx new file mode 100644 index 0000000..d4e9acb --- /dev/null +++ b/resources/js/Pages/Auth/Register.jsx @@ -0,0 +1,117 @@ +import { useEffect } from 'react'; +import GuestLayout from '@/Layouts/GuestLayout'; +import InputError from '@/Components/InputError'; +import InputLabel from '@/Components/InputLabel'; +import PrimaryButton from '@/Components/PrimaryButton'; +import TextInput from '@/Components/TextInput'; +import { Head, Link, useForm } from '@inertiajs/react'; + +export default function Register() { + const { data, setData, post, processing, errors, reset } = useForm({ + name: '', + email: '', + password: '', + password_confirmation: '', + }); + + useEffect(() => { + return () => { + reset('password', 'password_confirmation'); + }; + }, []); + + const submit = (e) => { + e.preventDefault(); + + post(route('register')); + }; + + return ( + + + +
+
+ + + setData('name', e.target.value)} + required + /> + + +
+ +
+ + + setData('email', e.target.value)} + required + /> + + +
+ +
+ + + setData('password', e.target.value)} + required + /> + + +
+ +
+ + + setData('password_confirmation', e.target.value)} + required + /> + + +
+ +
+ + Already registered? + + + + Register + +
+
+
+ ); +} diff --git a/resources/js/Pages/Auth/ResetPassword.jsx b/resources/js/Pages/Auth/ResetPassword.jsx new file mode 100644 index 0000000..24cf927 --- /dev/null +++ b/resources/js/Pages/Auth/ResetPassword.jsx @@ -0,0 +1,91 @@ +import { useEffect } from 'react'; +import GuestLayout from '@/Layouts/GuestLayout'; +import InputError from '@/Components/InputError'; +import InputLabel from '@/Components/InputLabel'; +import PrimaryButton from '@/Components/PrimaryButton'; +import TextInput from '@/Components/TextInput'; +import { Head, useForm } from '@inertiajs/react'; + +export default function ResetPassword({ token, email }) { + const { data, setData, post, processing, errors, reset } = useForm({ + token: token, + email: email, + password: '', + password_confirmation: '', + }); + + useEffect(() => { + return () => { + reset('password', 'password_confirmation'); + }; + }, []); + + const submit = (e) => { + e.preventDefault(); + + post(route('password.store')); + }; + + return ( + + + +
+
+ + + setData('email', e.target.value)} + /> + + +
+ +
+ + + setData('password', e.target.value)} + /> + + +
+ +
+ + + setData('password_confirmation', e.target.value)} + /> + + +
+ +
+ + Reset Password + +
+
+
+ ); +} diff --git a/resources/js/Pages/Auth/VerifyEmail.jsx b/resources/js/Pages/Auth/VerifyEmail.jsx new file mode 100644 index 0000000..c9362b8 --- /dev/null +++ b/resources/js/Pages/Auth/VerifyEmail.jsx @@ -0,0 +1,45 @@ +import GuestLayout from '@/Layouts/GuestLayout'; +import PrimaryButton from '@/Components/PrimaryButton'; +import { Head, Link, useForm } from '@inertiajs/react'; + +export default function VerifyEmail({ status }) { + const { post, processing } = useForm({}); + + const submit = (e) => { + e.preventDefault(); + + post(route('verification.send')); + }; + + return ( + + + +
+ Thanks for signing up! Before getting started, could you verify your email address by clicking on the + link we just emailed to you? If you didn't receive the email, we will gladly send you another. +
+ + {status === 'verification-link-sent' && ( +
+ A new verification link has been sent to the email address you provided during registration. +
+ )} + +
+
+ Resend Verification Email + + + Log Out + +
+
+
+ ); +} diff --git a/resources/js/Pages/Dashboard.jsx b/resources/js/Pages/Dashboard.jsx new file mode 100644 index 0000000..8948bad --- /dev/null +++ b/resources/js/Pages/Dashboard.jsx @@ -0,0 +1,21 @@ +import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout'; +import { Head } from '@inertiajs/react'; + +export default function Dashboard({ auth }) { + return ( + Dashboard} + > + + +
+
+
+
You're logged in!
+
+
+
+
+ ); +} diff --git a/resources/js/Pages/Profile/Edit.jsx b/resources/js/Pages/Profile/Edit.jsx new file mode 100644 index 0000000..aa2fa0a --- /dev/null +++ b/resources/js/Pages/Profile/Edit.jsx @@ -0,0 +1,36 @@ +import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout'; +import DeleteUserForm from './Partials/DeleteUserForm'; +import UpdatePasswordForm from './Partials/UpdatePasswordForm'; +import UpdateProfileInformationForm from './Partials/UpdateProfileInformationForm'; +import { Head } from '@inertiajs/react'; + +export default function Edit({ auth, mustVerifyEmail, status }) { + return ( + Profile} + > + + +
+
+
+ +
+ +
+ +
+ +
+ +
+
+
+
+ ); +} diff --git a/resources/js/Pages/Profile/Partials/DeleteUserForm.jsx b/resources/js/Pages/Profile/Partials/DeleteUserForm.jsx new file mode 100644 index 0000000..9bb0d13 --- /dev/null +++ b/resources/js/Pages/Profile/Partials/DeleteUserForm.jsx @@ -0,0 +1,99 @@ +import { useRef, useState } from 'react'; +import DangerButton from '@/Components/DangerButton'; +import InputError from '@/Components/InputError'; +import InputLabel from '@/Components/InputLabel'; +import Modal from '@/Components/Modal'; +import SecondaryButton from '@/Components/SecondaryButton'; +import TextInput from '@/Components/TextInput'; +import { useForm } from '@inertiajs/react'; + +export default function DeleteUserForm({ className = '' }) { + const [confirmingUserDeletion, setConfirmingUserDeletion] = useState(false); + const passwordInput = useRef(); + + const { + data, + setData, + delete: destroy, + processing, + reset, + errors, + } = useForm({ + password: '', + }); + + const confirmUserDeletion = () => { + setConfirmingUserDeletion(true); + }; + + const deleteUser = (e) => { + e.preventDefault(); + + destroy(route('profile.destroy'), { + preserveScroll: true, + onSuccess: () => closeModal(), + onError: () => passwordInput.current.focus(), + onFinish: () => reset(), + }); + }; + + const closeModal = () => { + setConfirmingUserDeletion(false); + + reset(); + }; + + return ( +
+
+

Delete Account

+ +

+ Once your account is deleted, all of its resources and data will be permanently deleted. Before + deleting your account, please download any data or information that you wish to retain. +

+
+ + Delete Account + + +
+

+ Are you sure you want to delete your account? +

+ +

+ Once your account is deleted, all of its resources and data will be permanently deleted. Please + enter your password to confirm you would like to permanently delete your account. +

+ +
+ + + setData('password', e.target.value)} + className="mt-1 block w-3/4" + isFocused + placeholder="Password" + /> + + +
+ +
+ Cancel + + + Delete Account + +
+
+
+
+ ); +} diff --git a/resources/js/Pages/Profile/Partials/UpdatePasswordForm.jsx b/resources/js/Pages/Profile/Partials/UpdatePasswordForm.jsx new file mode 100644 index 0000000..ba6a5e9 --- /dev/null +++ b/resources/js/Pages/Profile/Partials/UpdatePasswordForm.jsx @@ -0,0 +1,113 @@ +import { useRef } from 'react'; +import InputError from '@/Components/InputError'; +import InputLabel from '@/Components/InputLabel'; +import PrimaryButton from '@/Components/PrimaryButton'; +import TextInput from '@/Components/TextInput'; +import { useForm } from '@inertiajs/react'; +import { Transition } from '@headlessui/react'; + +export default function UpdatePasswordForm({ className = '' }) { + const passwordInput = useRef(); + const currentPasswordInput = useRef(); + + const { data, setData, errors, put, reset, processing, recentlySuccessful } = useForm({ + current_password: '', + password: '', + password_confirmation: '', + }); + + const updatePassword = (e) => { + e.preventDefault(); + + put(route('password.update'), { + preserveScroll: true, + onSuccess: () => reset(), + onError: (errors) => { + if (errors.password) { + reset('password', 'password_confirmation'); + passwordInput.current.focus(); + } + + if (errors.current_password) { + reset('current_password'); + currentPasswordInput.current.focus(); + } + }, + }); + }; + + return ( +
+
+

Update Password

+ +

+ Ensure your account is using a long, random password to stay secure. +

+
+ +
+
+ + + setData('current_password', e.target.value)} + type="password" + className="mt-1 block w-full" + autoComplete="current-password" + /> + + +
+ +
+ + + setData('password', e.target.value)} + type="password" + className="mt-1 block w-full" + autoComplete="new-password" + /> + + +
+ +
+ + + setData('password_confirmation', e.target.value)} + type="password" + className="mt-1 block w-full" + autoComplete="new-password" + /> + + +
+ +
+ Save + + +

Saved.

+
+
+
+
+ ); +} diff --git a/resources/js/Pages/Profile/Partials/UpdateProfileInformationForm.jsx b/resources/js/Pages/Profile/Partials/UpdateProfileInformationForm.jsx new file mode 100644 index 0000000..322d397 --- /dev/null +++ b/resources/js/Pages/Profile/Partials/UpdateProfileInformationForm.jsx @@ -0,0 +1,103 @@ +import InputError from '@/Components/InputError'; +import InputLabel from '@/Components/InputLabel'; +import PrimaryButton from '@/Components/PrimaryButton'; +import TextInput from '@/Components/TextInput'; +import { Link, useForm, usePage } from '@inertiajs/react'; +import { Transition } from '@headlessui/react'; + +export default function UpdateProfileInformation({ mustVerifyEmail, status, className = '' }) { + const user = usePage().props.auth.user; + + const { data, setData, patch, errors, processing, recentlySuccessful } = useForm({ + name: user.name, + email: user.email, + }); + + const submit = (e) => { + e.preventDefault(); + + patch(route('profile.update')); + }; + + return ( +
+
+

Profile Information

+ +

+ Update your account's profile information and email address. +

+
+ +
+
+ + + setData('name', e.target.value)} + required + isFocused + autoComplete="name" + /> + + +
+ +
+ + + setData('email', e.target.value)} + required + autoComplete="username" + /> + + +
+ + {mustVerifyEmail && user.email_verified_at === null && ( +
+

+ Your email address is unverified. + + Click here to re-send the verification email. + +

+ + {status === 'verification-link-sent' && ( +
+ A new verification link has been sent to your email address. +
+ )} +
+ )} + +
+ Save + + +

Saved.

+
+
+
+
+ ); +} diff --git a/resources/js/Pages/Welcome.jsx b/resources/js/Pages/Welcome.jsx new file mode 100644 index 0000000..04404fc --- /dev/null +++ b/resources/js/Pages/Welcome.jsx @@ -0,0 +1,339 @@ +import { Link, Head } from '@inertiajs/react'; + +export default function Welcome({ auth, laravelVersion, phpVersion }) { + const handleImageError = () => { + document.getElementById('screenshot-container')?.classList.add('!hidden'); + document.getElementById('docs-card')?.classList.add('!row-span-1'); + document.getElementById('docs-card-content')?.classList.add('!flex-row'); + document.getElementById('background')?.classList.add('!hidden'); + }; + + return ( + <> + +
+ +
+
+
+
+ + + +
+ +
+ +
+
+ +
+ Laravel documentation screenshot + Laravel documentation screenshot +
+
+ +
+
+
+ + + + +
+ +
+

+ Documentation +

+ +

+ Laravel has wonderful documentation covering every aspect of the + framework. Whether you are a newcomer or have prior experience with + Laravel, we recommend reading our documentation from beginning to + end. +

+
+
+ + + + +
+
+ + +
+ + + + + +
+ +
+

Laracasts

+ +

+ Laracasts offers thousands of video tutorials on Laravel, PHP, and + JavaScript development. Check them out, see for yourself, and massively + level up your development skills in the process. +

+
+ + + + +
+ + +
+ + + + + + + +
+ +
+

+ Laravel News +

+ +

+ Laravel News is a community driven portal and newsletter aggregating all of + the latest and most important news in the Laravel ecosystem, including new + package releases and tutorials. +

+
+ + + + +
+ +
+
+ + + + + +
+ +
+

+ Vibrant Ecosystem +

+ +

+ Laravel's robust library of first-party tools and libraries, such as{' '} + + Forge + + ,{' '} + + Vapor + + ,{' '} + + Nova + + ,{' '} + + Envoyer + + , and{' '} + + Herd + {' '} + help you take your projects to the next level. Pair them with powerful open + source libraries like{' '} + + Cashier + + ,{' '} + + Dusk + + ,{' '} + + Echo + + ,{' '} + + Horizon + + ,{' '} + + Sanctum + + ,{' '} + + Telescope + + , and more. +

+
+
+
+
+ + +
+
+
+ + ); +} diff --git a/resources/js/app.js b/resources/js/app.js deleted file mode 100644 index e59d6a0..0000000 --- a/resources/js/app.js +++ /dev/null @@ -1 +0,0 @@ -import './bootstrap'; diff --git a/resources/js/app.jsx b/resources/js/app.jsx new file mode 100644 index 0000000..c150780 --- /dev/null +++ b/resources/js/app.jsx @@ -0,0 +1,21 @@ +import './bootstrap'; +import '../css/app.css'; + +import { createRoot } from 'react-dom/client'; +import { createInertiaApp } from '@inertiajs/react'; +import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers'; + +const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; + +createInertiaApp({ + title: (title) => `${title} - ${appName}`, + resolve: (name) => resolvePageComponent(`./Pages/${name}.jsx`, import.meta.glob('./Pages/**/*.jsx')), + setup({ el, App, props }) { + const root = createRoot(el); + + root.render(); + }, + progress: { + color: '#4B5563', + }, +}); diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php new file mode 100644 index 0000000..965a77b --- /dev/null +++ b/resources/views/app.blade.php @@ -0,0 +1,22 @@ + + + + + + + {{ config('app.name', 'Laravel') }} + + + + + + + @routes + @viteReactRefresh + @vite(['resources/js/app.jsx', "resources/js/Pages/{$page['component']}.jsx"]) + @inertiaHead + + + @inertia + + diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php deleted file mode 100644 index a9898e3..0000000 --- a/resources/views/welcome.blade.php +++ /dev/null @@ -1,172 +0,0 @@ - - - - - - - Laravel - - - - - - - - - -
- - -
- - diff --git a/routes/auth.php b/routes/auth.php new file mode 100644 index 0000000..1040b51 --- /dev/null +++ b/routes/auth.php @@ -0,0 +1,59 @@ +group(function () { + Route::get('register', [RegisteredUserController::class, 'create']) + ->name('register'); + + Route::post('register', [RegisteredUserController::class, 'store']); + + Route::get('login', [AuthenticatedSessionController::class, 'create']) + ->name('login'); + + Route::post('login', [AuthenticatedSessionController::class, 'store']); + + Route::get('forgot-password', [PasswordResetLinkController::class, 'create']) + ->name('password.request'); + + Route::post('forgot-password', [PasswordResetLinkController::class, 'store']) + ->name('password.email'); + + Route::get('reset-password/{token}', [NewPasswordController::class, 'create']) + ->name('password.reset'); + + Route::post('reset-password', [NewPasswordController::class, 'store']) + ->name('password.store'); +}); + +Route::middleware('auth')->group(function () { + Route::get('verify-email', EmailVerificationPromptController::class) + ->name('verification.notice'); + + Route::get('verify-email/{id}/{hash}', VerifyEmailController::class) + ->middleware(['signed', 'throttle:6,1']) + ->name('verification.verify'); + + Route::post('email/verification-notification', [EmailVerificationNotificationController::class, 'store']) + ->middleware('throttle:6,1') + ->name('verification.send'); + + Route::get('confirm-password', [ConfirmablePasswordController::class, 'show']) + ->name('password.confirm'); + + Route::post('confirm-password', [ConfirmablePasswordController::class, 'store']); + + Route::put('password', [PasswordController::class, 'update'])->name('password.update'); + + Route::post('logout', [AuthenticatedSessionController::class, 'destroy']) + ->name('logout'); +}); diff --git a/routes/web.php b/routes/web.php index 86a06c5..067c4f5 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,7 +1,27 @@ Route::has('login'), + 'canRegister' => Route::has('register'), + 'laravelVersion' => Application::VERSION, + 'phpVersion' => PHP_VERSION, + ]); }); + +Route::get('/dashboard', function () { + return Inertia::render('Dashboard'); +})->middleware(['auth', 'verified'])->name('dashboard'); + +Route::middleware('auth')->group(function () { + Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); + Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update'); + Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); +}); + +require __DIR__.'/auth.php'; diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..50f85b3 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,23 @@ +import defaultTheme from 'tailwindcss/defaultTheme'; +import forms from '@tailwindcss/forms'; + +/** @type {import('tailwindcss').Config} */ +export default { + darkMode: 'class', + content: [ + './vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php', + './storage/framework/views/*.php', + './resources/views/**/*.blade.php', + './resources/js/**/*.jsx', + ], + + theme: { + extend: { + fontFamily: { + sans: ['Figtree', ...defaultTheme.fontFamily.sans], + }, + }, + }, + + plugins: [forms], +}; diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php new file mode 100644 index 0000000..13dcb7c --- /dev/null +++ b/tests/Feature/Auth/AuthenticationTest.php @@ -0,0 +1,54 @@ +get('/login'); + + $response->assertStatus(200); + } + + public function test_users_can_authenticate_using_the_login_screen(): void + { + $user = User::factory()->create(); + + $response = $this->post('/login', [ + 'email' => $user->email, + 'password' => 'password', + ]); + + $this->assertAuthenticated(); + $response->assertRedirect(route('dashboard', absolute: false)); + } + + public function test_users_can_not_authenticate_with_invalid_password(): void + { + $user = User::factory()->create(); + + $this->post('/login', [ + 'email' => $user->email, + 'password' => 'wrong-password', + ]); + + $this->assertGuest(); + } + + public function test_users_can_logout(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post('/logout'); + + $this->assertGuest(); + $response->assertRedirect('/'); + } +} diff --git a/tests/Feature/Auth/EmailVerificationTest.php b/tests/Feature/Auth/EmailVerificationTest.php new file mode 100644 index 0000000..705570b --- /dev/null +++ b/tests/Feature/Auth/EmailVerificationTest.php @@ -0,0 +1,58 @@ +unverified()->create(); + + $response = $this->actingAs($user)->get('/verify-email'); + + $response->assertStatus(200); + } + + public function test_email_can_be_verified(): void + { + $user = User::factory()->unverified()->create(); + + Event::fake(); + + $verificationUrl = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(60), + ['id' => $user->id, 'hash' => sha1($user->email)] + ); + + $response = $this->actingAs($user)->get($verificationUrl); + + Event::assertDispatched(Verified::class); + $this->assertTrue($user->fresh()->hasVerifiedEmail()); + $response->assertRedirect(route('dashboard', absolute: false).'?verified=1'); + } + + public function test_email_is_not_verified_with_invalid_hash(): void + { + $user = User::factory()->unverified()->create(); + + $verificationUrl = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(60), + ['id' => $user->id, 'hash' => sha1('wrong-email')] + ); + + $this->actingAs($user)->get($verificationUrl); + + $this->assertFalse($user->fresh()->hasVerifiedEmail()); + } +} diff --git a/tests/Feature/Auth/PasswordConfirmationTest.php b/tests/Feature/Auth/PasswordConfirmationTest.php new file mode 100644 index 0000000..ff85721 --- /dev/null +++ b/tests/Feature/Auth/PasswordConfirmationTest.php @@ -0,0 +1,44 @@ +create(); + + $response = $this->actingAs($user)->get('/confirm-password'); + + $response->assertStatus(200); + } + + public function test_password_can_be_confirmed(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post('/confirm-password', [ + 'password' => 'password', + ]); + + $response->assertRedirect(); + $response->assertSessionHasNoErrors(); + } + + public function test_password_is_not_confirmed_with_invalid_password(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post('/confirm-password', [ + 'password' => 'wrong-password', + ]); + + $response->assertSessionHasErrors(); + } +} diff --git a/tests/Feature/Auth/PasswordResetTest.php b/tests/Feature/Auth/PasswordResetTest.php new file mode 100644 index 0000000..aa50350 --- /dev/null +++ b/tests/Feature/Auth/PasswordResetTest.php @@ -0,0 +1,73 @@ +get('/forgot-password'); + + $response->assertStatus(200); + } + + public function test_reset_password_link_can_be_requested(): void + { + Notification::fake(); + + $user = User::factory()->create(); + + $this->post('/forgot-password', ['email' => $user->email]); + + Notification::assertSentTo($user, ResetPassword::class); + } + + public function test_reset_password_screen_can_be_rendered(): void + { + Notification::fake(); + + $user = User::factory()->create(); + + $this->post('/forgot-password', ['email' => $user->email]); + + Notification::assertSentTo($user, ResetPassword::class, function ($notification) { + $response = $this->get('/reset-password/'.$notification->token); + + $response->assertStatus(200); + + return true; + }); + } + + public function test_password_can_be_reset_with_valid_token(): void + { + Notification::fake(); + + $user = User::factory()->create(); + + $this->post('/forgot-password', ['email' => $user->email]); + + Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) { + $response = $this->post('/reset-password', [ + 'token' => $notification->token, + 'email' => $user->email, + 'password' => 'password', + 'password_confirmation' => 'password', + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect(route('login')); + + return true; + }); + } +} diff --git a/tests/Feature/Auth/PasswordUpdateTest.php b/tests/Feature/Auth/PasswordUpdateTest.php new file mode 100644 index 0000000..bbf079d --- /dev/null +++ b/tests/Feature/Auth/PasswordUpdateTest.php @@ -0,0 +1,51 @@ +create(); + + $response = $this + ->actingAs($user) + ->from('/profile') + ->put('/password', [ + 'current_password' => 'password', + 'password' => 'new-password', + 'password_confirmation' => 'new-password', + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect('/profile'); + + $this->assertTrue(Hash::check('new-password', $user->refresh()->password)); + } + + public function test_correct_password_must_be_provided_to_update_password(): void + { + $user = User::factory()->create(); + + $response = $this + ->actingAs($user) + ->from('/profile') + ->put('/password', [ + 'current_password' => 'wrong-password', + 'password' => 'new-password', + 'password_confirmation' => 'new-password', + ]); + + $response + ->assertSessionHasErrors('current_password') + ->assertRedirect('/profile'); + } +} diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php new file mode 100644 index 0000000..1489d0e --- /dev/null +++ b/tests/Feature/Auth/RegistrationTest.php @@ -0,0 +1,31 @@ +get('/register'); + + $response->assertStatus(200); + } + + public function test_new_users_can_register(): void + { + $response = $this->post('/register', [ + 'name' => 'Test User', + 'email' => 'test@example.com', + 'password' => 'password', + 'password_confirmation' => 'password', + ]); + + $this->assertAuthenticated(); + $response->assertRedirect(route('dashboard', absolute: false)); + } +} diff --git a/tests/Feature/ProfileTest.php b/tests/Feature/ProfileTest.php new file mode 100644 index 0000000..49886c3 --- /dev/null +++ b/tests/Feature/ProfileTest.php @@ -0,0 +1,99 @@ +create(); + + $response = $this + ->actingAs($user) + ->get('/profile'); + + $response->assertOk(); + } + + public function test_profile_information_can_be_updated(): void + { + $user = User::factory()->create(); + + $response = $this + ->actingAs($user) + ->patch('/profile', [ + 'name' => 'Test User', + 'email' => 'test@example.com', + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect('/profile'); + + $user->refresh(); + + $this->assertSame('Test User', $user->name); + $this->assertSame('test@example.com', $user->email); + $this->assertNull($user->email_verified_at); + } + + public function test_email_verification_status_is_unchanged_when_the_email_address_is_unchanged(): void + { + $user = User::factory()->create(); + + $response = $this + ->actingAs($user) + ->patch('/profile', [ + 'name' => 'Test User', + 'email' => $user->email, + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect('/profile'); + + $this->assertNotNull($user->refresh()->email_verified_at); + } + + public function test_user_can_delete_their_account(): void + { + $user = User::factory()->create(); + + $response = $this + ->actingAs($user) + ->delete('/profile', [ + 'password' => 'password', + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect('/'); + + $this->assertGuest(); + $this->assertNull($user->fresh()); + } + + public function test_correct_password_must_be_provided_to_delete_account(): void + { + $user = User::factory()->create(); + + $response = $this + ->actingAs($user) + ->from('/profile') + ->delete('/profile', [ + 'password' => 'wrong-password', + ]); + + $response + ->assertSessionHasErrors('password') + ->assertRedirect('/profile'); + + $this->assertNotNull($user->fresh()); + } +} diff --git a/vite.config.js b/vite.config.js index 421b569..19f2908 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,11 +1,13 @@ import { defineConfig } from 'vite'; import laravel from 'laravel-vite-plugin'; +import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [ laravel({ - input: ['resources/css/app.css', 'resources/js/app.js'], + input: 'resources/js/app.jsx', refresh: true, }), + react(), ], });