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 (
+
+ {children}
+
+ );
+}
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 (
+
+ {value ? value : children}
+
+ );
+}
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 (
+
+ {children}
+
+ );
+}
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 (
+
+ {children}
+
+ );
+}
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 (
+
+
+
+
+
+
+
+
+
+ Dashboard
+
+
+
+
+
+
+
+
+
+
+ {user.name}
+
+
+
+
+
+
+
+
+
+ Profile
+
+ Log Out
+
+
+
+
+
+
+
+
setShowingNavigationDropdown((previousState) => !previousState)}
+ className="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:bg-gray-100 focus:text-gray-500 transition duration-150 ease-in-out"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+ Dashboard
+
+
+
+
+
+
{user.name}
+
{user.email}
+
+
+
+ Profile
+
+ Log Out
+
+
+
+
+
+
+ {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 (
+
+ );
+}
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.
+
+
+
+
+ );
+}
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}
}
+
+
+
+ );
+}
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}
}
+
+
+
+ );
+}
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 (
+
+
+
+
+
+ );
+}
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 (
+
+
+
+
+
+ );
+}
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.
+
+ )}
+
+
+
+ );
+}
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}
+ >
+
+
+
+
+ );
+}
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
+
+
+
+
+
+ );
+}
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 (
+
+ );
+}
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 (
+
+ );
+}
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 (
+ <>
+
+ @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
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
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.
-
-
-
-
-
-
-
- Laravel v{{ Illuminate\Foundation\Application::VERSION }} (PHP v{{ PHP_VERSION }})
-
-
-
-
-
-
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(),
],
});
+ >
+ );
+}
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 @@
+
+
+