├── app ├── Enums │ └── .gitkeep ├── Services │ └── .gitkeep ├── Actions │ ├── DeleteUser.php │ ├── CreateUserEmailVerificationNotification.php │ ├── CreateUserEmailResetNotification.php │ ├── UpdateUserPassword.php │ ├── UpdateUser.php │ ├── CreateUser.php │ └── CreateUserPassword.php ├── Http │ ├── Requests │ │ ├── UpdateEmailVerificationRequest.php │ │ ├── ShowUserTwoFactorAuthenticationRequest.php │ │ ├── DeleteUserRequest.php │ │ ├── CreateUserEmailResetNotificationRequest.php │ │ ├── UpdateUserPasswordRequest.php │ │ ├── CreateUserPasswordRequest.php │ │ ├── UpdateUserRequest.php │ │ ├── CreateUserRequest.php │ │ └── CreateSessionRequest.php │ ├── Middleware │ │ ├── HandleAppearance.php │ │ └── HandleInertiaRequests.php │ └── Controllers │ │ ├── UserEmailVerification.php │ │ ├── UserProfileController.php │ │ ├── UserEmailResetNotification.php │ │ ├── UserTwoFactorAuthenticationController.php │ │ ├── UserEmailVerificationNotificationController.php │ │ ├── UserController.php │ │ ├── SessionController.php │ │ └── UserPasswordController.php ├── Providers │ ├── AppServiceProvider.php │ └── FortifyServiceProvider.php ├── Rules │ └── ValidEmail.php └── Models │ └── User.php ├── tests ├── Unit │ ├── Services │ │ └── .gitkeep │ ├── ArchTest.php │ ├── Actions │ │ ├── DeleteUserTest.php │ │ ├── UpdateUserPasswordTest.php │ │ ├── CreateUserEmailVerificationNotificationTest.php │ │ ├── CreateUserTest.php │ │ ├── CreateUserEmailResetNotificationTest.php │ │ ├── UpdateUserTest.php │ │ └── CreateUserPasswordTest.php │ ├── Models │ │ └── UserTest.php │ ├── Middleware │ │ ├── HandleAppearanceTest.php │ │ └── HandleInertiaRequestsTest.php │ └── Rules │ │ └── ValidEmailTest.php ├── Browser │ └── WelcomeTest.php ├── TestCase.php ├── Pest.php └── Feature │ └── Controllers │ ├── UserTwoFactorAuthenticationControllerTest.php │ ├── UserEmailVerificationTest.php │ ├── UserEmailVerificationNotificationControllerTest.php │ └── UserEmailResetNotificationTest.php ├── database ├── .gitignore ├── seeders │ └── DatabaseSeeder.php ├── migrations │ ├── 0001_01_01_000001_create_cache_table.php │ ├── 0001_01_01_000000_create_users_table.php │ └── 0001_01_01_000002_create_jobs_table.php └── factories │ └── UserFactory.php ├── bootstrap ├── cache │ └── .gitignore ├── providers.php └── app.php ├── storage ├── logs │ └── .gitignore ├── app │ ├── private │ │ └── .gitignore │ ├── public │ │ └── .gitignore │ └── .gitignore └── framework │ ├── testing │ └── .gitignore │ ├── views │ └── .gitignore │ ├── cache │ ├── data │ │ └── .gitignore │ └── .gitignore │ ├── sessions │ └── .gitignore │ └── .gitignore ├── public ├── robots.txt ├── favicon.ico ├── apple-touch-icon.png ├── index.php ├── .htaccess └── favicon.svg ├── .prettierignore ├── resources ├── js │ ├── types │ │ ├── vite-env.d.ts │ │ └── index.d.ts │ ├── lib │ │ └── utils.ts │ ├── hooks │ │ ├── use-mobile-navigation.ts │ │ ├── use-initials.tsx │ │ ├── use-mobile.tsx │ │ ├── use-clipboard.ts │ │ ├── use-appearance.tsx │ │ └── use-two-factor-auth.ts │ ├── components │ │ ├── ui │ │ │ ├── skeleton.tsx │ │ │ ├── icon.tsx │ │ │ ├── label.tsx │ │ │ ├── placeholder-pattern.tsx │ │ │ ├── separator.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── input.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── avatar.tsx │ │ │ ├── toggle.tsx │ │ │ ├── card.tsx │ │ │ ├── badge.tsx │ │ │ ├── alert.tsx │ │ │ ├── tooltip.tsx │ │ │ ├── toggle-group.tsx │ │ │ ├── button.tsx │ │ │ ├── input-otp.tsx │ │ │ └── breadcrumb.tsx │ │ ├── heading-small.tsx │ │ ├── icon.tsx │ │ ├── heading.tsx │ │ ├── input-error.tsx │ │ ├── app-logo.tsx │ │ ├── app-shell.tsx │ │ ├── app-content.tsx │ │ ├── text-link.tsx │ │ ├── app-sidebar-header.tsx │ │ ├── alert-error.tsx │ │ ├── app-logo-icon.tsx │ │ ├── user-info.tsx │ │ ├── nav-main.tsx │ │ ├── appearance-tabs.tsx │ │ ├── breadcrumbs.tsx │ │ ├── app-sidebar.tsx │ │ ├── nav-user.tsx │ │ ├── nav-footer.tsx │ │ ├── user-menu-content.tsx │ │ └── appearance-dropdown.tsx │ ├── layouts │ │ ├── auth-layout.tsx │ │ ├── app-layout.tsx │ │ ├── app │ │ │ ├── app-header-layout.tsx │ │ │ └── app-sidebar-layout.tsx │ │ ├── auth │ │ │ ├── auth-card-layout.tsx │ │ │ ├── auth-simple-layout.tsx │ │ │ └── auth-split-layout.tsx │ │ └── settings │ │ │ └── layout.tsx │ ├── ssr.tsx │ ├── app.tsx │ └── pages │ │ ├── appearance │ │ └── update.tsx │ │ ├── dashboard.tsx │ │ ├── user-email-verification-notification │ │ └── create.tsx │ │ ├── user-password-confirmation │ │ └── create.tsx │ │ ├── user-email-reset-notification │ │ └── create.tsx │ │ └── user-password │ │ └── create.tsx └── views │ └── app.blade.php ├── .ai └── guidelines │ ├── general.blade.php │ └── app.actions.blade.php ├── .mcp.json ├── .cursor └── mcp.json ├── boost.json ├── .gitattributes ├── routes ├── console.php └── web.php ├── .editorconfig ├── .junie └── mcp │ └── mcp.json ├── phpstan.neon ├── artisan ├── .gitignore ├── components.json ├── .prettierrc ├── vite.config.ts ├── config ├── services.php ├── inertia.php ├── filesystems.php ├── cache.php ├── mail.php └── queue.php ├── .env.example ├── phpunit.xml ├── eslint.config.js ├── rector.php ├── pint.json ├── package.json └── .github └── workflows └── tests.yml /app/Enums/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/Services/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/Unit/Services/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /database/.gitignore: -------------------------------------------------------------------------------- 1 | *.sqlite* 2 | -------------------------------------------------------------------------------- /bootstrap/cache/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/logs/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /storage/app/private/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/app/public/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/framework/testing/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/framework/views/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/framework/cache/data/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/framework/sessions/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/framework/cache/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !data/ 3 | !.gitignore 4 | -------------------------------------------------------------------------------- /storage/app/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !private/ 3 | !public/ 4 | !.gitignore 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | resources/js/components/ui/* 2 | resources/views/mail/* 3 | -------------------------------------------------------------------------------- /resources/js/types/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nunomaduro/laravel-starter-kit-inertia-react/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nunomaduro/laravel-starter-kit-inertia-react/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /.ai/guidelines/general.blade.php: -------------------------------------------------------------------------------- 1 | # General Guidelines 2 | 3 | - Don't include any superfluous PHP Annotations, except ones that start with `@` for typing variables. 4 | -------------------------------------------------------------------------------- /storage/framework/.gitignore: -------------------------------------------------------------------------------- 1 | compiled.php 2 | config.php 3 | down 4 | events.scanned.php 5 | maintenance.php 6 | routes.php 7 | routes.scanned.php 8 | schedule-* 9 | services.json 10 | -------------------------------------------------------------------------------- /bootstrap/providers.php: -------------------------------------------------------------------------------- 1 | assertSee('Laravel'); 9 | }); 10 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | delete(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /database/seeders/DatabaseSeeder.php: -------------------------------------------------------------------------------- 1 | { 5 | // Remove pointer-events style from body... 6 | document.body.style.removeProperty('pointer-events'); 7 | }, []); 8 | } 9 | -------------------------------------------------------------------------------- /routes/console.php: -------------------------------------------------------------------------------- 1 | comment(Inspiring::quote()); 10 | })->purpose('Display an inspiring quote'); 11 | -------------------------------------------------------------------------------- /app/Http/Requests/UpdateEmailVerificationRequest.php: -------------------------------------------------------------------------------- 1 | preset()->php(); 6 | arch()->preset()->strict(); 7 | arch()->preset()->security()->ignoring([ 8 | 'assert', 9 | ]); 10 | 11 | arch('controllers') 12 | ->expect('App\Http\Controllers') 13 | ->not->toBeUsed(); 14 | 15 | // 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 4 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | 14 | [*.{yml,yaml}] 15 | indent_size = 2 16 | 17 | [docker-compose.yml] 18 | indent_size = 4 19 | -------------------------------------------------------------------------------- /app/Actions/CreateUserEmailVerificationNotification.php: -------------------------------------------------------------------------------- 1 | sendEmailVerificationNotification(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /resources/js/components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | function Skeleton({ className, ...props }: React.ComponentProps<"div">) { 4 | return ( 5 |
10 | ) 11 | } 12 | 13 | export { Skeleton } 14 | -------------------------------------------------------------------------------- /.junie/mcp/mcp.json: -------------------------------------------------------------------------------- 1 | { 2 | "mcpServers": { 3 | "laravel-boost": { 4 | "command": "/Users/nunomaduro/Library/Application Support/Herd/bin/php84", 5 | "args": [ 6 | "/Users/nunomaduro/Work/projects/nunomaduro/laravel-starter-kit-inertia-react/artisan", 7 | "boost:mcp" 8 | ] 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /app/Http/Requests/ShowUserTwoFactorAuthenticationRequest.php: -------------------------------------------------------------------------------- 1 | create(); 10 | 11 | $action = app(DeleteUser::class); 12 | 13 | $action->handle($user); 14 | 15 | expect($user->exists)->toBeFalse(); 16 | }); 17 | -------------------------------------------------------------------------------- /resources/js/components/ui/icon.tsx: -------------------------------------------------------------------------------- 1 | import { LucideIcon } from 'lucide-react'; 2 | 3 | interface IconProps { 4 | iconNode?: LucideIcon | null; 5 | className?: string; 6 | } 7 | 8 | export function Icon({ iconNode: IconComponent, className }: IconProps) { 9 | if (!IconComponent) { 10 | return null; 11 | } 12 | 13 | return ; 14 | } 15 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - vendor/larastan/larastan/extension.neon 3 | - vendor/nesbot/carbon/extension.neon 4 | - phar://phpstan.phar/conf/bleedingEdge.neon 5 | 6 | parameters: 7 | 8 | paths: 9 | - app 10 | - bootstrap/app.php 11 | - config 12 | - database 13 | - public 14 | - routes 15 | 16 | 17 | level: max 18 | 19 | tmpDir: /tmp/phpstan 20 | -------------------------------------------------------------------------------- /app/Actions/CreateUserEmailResetNotification.php: -------------------------------------------------------------------------------- 1 | $credentials 13 | */ 14 | public function handle(array $credentials): string 15 | { 16 | return Password::sendResetLink($credentials); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/Actions/UpdateUserPassword.php: -------------------------------------------------------------------------------- 1 | update([ 16 | 'password' => Hash::make($password), 17 | ]); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /resources/js/components/heading-small.tsx: -------------------------------------------------------------------------------- 1 | export default function HeadingSmall({ 2 | title, 3 | description, 4 | }: { 5 | title: string; 6 | description?: string; 7 | }) { 8 | return ( 9 |
10 |

{title}

11 | {description && ( 12 |

{description}

13 | )} 14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /app/Http/Requests/DeleteUserRequest.php: -------------------------------------------------------------------------------- 1 | > 13 | */ 14 | public function rules(): array 15 | { 16 | return [ 17 | 'password' => ['required', 'current_password'], 18 | ]; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /resources/js/components/icon.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/lib/utils'; 2 | import { type LucideProps } from 'lucide-react'; 3 | import { type ComponentType } from 'react'; 4 | 5 | interface IconProps extends Omit { 6 | iconNode: ComponentType; 7 | } 8 | 9 | export function Icon({ 10 | iconNode: IconComponent, 11 | className, 12 | ...props 13 | }: IconProps) { 14 | return ; 15 | } 16 | -------------------------------------------------------------------------------- /resources/js/components/heading.tsx: -------------------------------------------------------------------------------- 1 | export default function Heading({ 2 | title, 3 | description, 4 | }: { 5 | title: string; 6 | description?: string; 7 | }) { 8 | return ( 9 |
10 |

{title}

11 | {description && ( 12 |

{description}

13 | )} 14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /artisan: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | handleCommand(new ArgvInput); 17 | 18 | exit($status); 19 | -------------------------------------------------------------------------------- /resources/js/layouts/auth-layout.tsx: -------------------------------------------------------------------------------- 1 | import AuthLayoutTemplate from '@/layouts/auth/auth-simple-layout'; 2 | 3 | export default function AuthLayout({ 4 | children, 5 | title, 6 | description, 7 | ...props 8 | }: { 9 | children: React.ReactNode; 10 | title: string; 11 | description: string; 12 | }) { 13 | return ( 14 | 15 | {children} 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /app/Http/Requests/CreateUserEmailResetNotificationRequest.php: -------------------------------------------------------------------------------- 1 | > 13 | */ 14 | public function rules(): array 15 | { 16 | return [ 17 | 'email' => ['required', 'email'], 18 | ]; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /resources/js/layouts/app-layout.tsx: -------------------------------------------------------------------------------- 1 | import AppLayoutTemplate from '@/layouts/app/app-sidebar-layout'; 2 | import { type BreadcrumbItem } from '@/types'; 3 | import { type ReactNode } from 'react'; 4 | 5 | interface AppLayoutProps { 6 | children: ReactNode; 7 | breadcrumbs?: BreadcrumbItem[]; 8 | } 9 | 10 | export default ({ children, breadcrumbs, ...props }: AppLayoutProps) => ( 11 | 12 | {children} 13 | 14 | ); 15 | -------------------------------------------------------------------------------- /tests/Unit/Models/UserTest.php: -------------------------------------------------------------------------------- 1 | create()->refresh(); 9 | 10 | expect(array_keys($user->toArray())) 11 | ->toBe([ 12 | 'id', 13 | 'name', 14 | 'email', 15 | 'email_verified_at', 16 | 'two_factor_confirmed_at', 17 | 'created_at', 18 | 'updated_at', 19 | ]); 20 | }); 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.phpunit.cache 2 | /bootstrap/ssr 3 | /node_modules 4 | /public/build 5 | /public/hot 6 | /public/storage 7 | /resources/js/actions 8 | /resources/js/routes 9 | /resources/js/wayfinder 10 | /storage/*.key 11 | /storage/pail 12 | /vendor 13 | .DS_Store 14 | .env 15 | .env.backup 16 | .env.production 17 | .phpactor.json 18 | .phpunit.result.cache 19 | Homestead.json 20 | Homestead.yaml 21 | npm-debug.log 22 | yarn-error.log 23 | /auth.json 24 | /.fleet 25 | /.idea 26 | /.nova 27 | /.vscode 28 | /.zed 29 | /tmp/ 30 | -------------------------------------------------------------------------------- /resources/js/components/input-error.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/lib/utils'; 2 | import { type HTMLAttributes } from 'react'; 3 | 4 | export default function InputError({ 5 | message, 6 | className = '', 7 | ...props 8 | }: HTMLAttributes & { message?: string }) { 9 | return message ? ( 10 |

14 | {message} 15 |

16 | ) : null; 17 | } 18 | -------------------------------------------------------------------------------- /app/Actions/UpdateUser.php: -------------------------------------------------------------------------------- 1 | $attributes 13 | */ 14 | public function handle(User $user, array $attributes): void 15 | { 16 | $email = $attributes['email'] ?? null; 17 | 18 | $user->update([ 19 | ...$attributes, 20 | ...$user->email === $email ? [] : ['email_verified_at' => null], 21 | ]); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /resources/js/hooks/use-initials.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | 3 | export function useInitials() { 4 | return useCallback((fullName: string): string => { 5 | const names = fullName.trim().split(' '); 6 | 7 | if (names.length === 0) return ''; 8 | if (names.length === 1) return names[0].charAt(0).toUpperCase(); 9 | 10 | const firstInitial = names[0].charAt(0); 11 | const lastInitial = names[names.length - 1].charAt(0); 12 | 13 | return `${firstInitial}${lastInitial}`.toUpperCase(); 14 | }, []); 15 | } 16 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "resources/css/app.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } 22 | -------------------------------------------------------------------------------- /app/Http/Middleware/HandleAppearance.php: -------------------------------------------------------------------------------- 1 | cookie('appearance') ?? 'system'); 20 | 21 | return $next($request); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/Http/Requests/UpdateUserPasswordRequest.php: -------------------------------------------------------------------------------- 1 | > 14 | */ 15 | public function rules(): array 16 | { 17 | return [ 18 | 'current_password' => ['required', 'current_password'], 19 | 'password' => ['required', Password::defaults(), 'confirmed'], 20 | ]; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "singleAttributePerLine": false, 5 | "htmlWhitespaceSensitivity": "css", 6 | "printWidth": 80, 7 | "plugins": [ 8 | "prettier-plugin-organize-imports", 9 | "prettier-plugin-tailwindcss" 10 | ], 11 | "tailwindFunctions": [ 12 | "clsx", 13 | "cn" 14 | ], 15 | "tailwindStylesheet": "resources/css/app.css", 16 | "tabWidth": 4, 17 | "overrides": [ 18 | { 19 | "files": "**/*.yml", 20 | "options": { 21 | "tabWidth": 2 22 | } 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /app/Http/Requests/CreateUserPasswordRequest.php: -------------------------------------------------------------------------------- 1 | |string> 14 | */ 15 | public function rules(): array 16 | { 17 | return [ 18 | 'token' => ['required'], 19 | 'email' => ['required', 'email'], 20 | 'password' => ['required', 'confirmed', Password::defaults()], 21 | ]; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/Unit/Actions/UpdateUserPasswordTest.php: -------------------------------------------------------------------------------- 1 | create([ 11 | 'password' => Hash::make('old-password'), 12 | ]); 13 | 14 | $action = app(UpdateUserPassword::class); 15 | 16 | $action->handle($user, 'new-password'); 17 | 18 | expect(Hash::check('new-password', $user->refresh()->password))->toBeTrue() 19 | ->and(Hash::check('old-password', $user->password))->toBeFalse(); 20 | }); 21 | -------------------------------------------------------------------------------- /resources/js/layouts/app/app-header-layout.tsx: -------------------------------------------------------------------------------- 1 | import { AppContent } from '@/components/app-content'; 2 | import { AppHeader } from '@/components/app-header'; 3 | import { AppShell } from '@/components/app-shell'; 4 | import { type BreadcrumbItem } from '@/types'; 5 | import type { PropsWithChildren } from 'react'; 6 | 7 | export default function AppHeaderLayout({ 8 | children, 9 | breadcrumbs, 10 | }: PropsWithChildren<{ breadcrumbs?: BreadcrumbItem[] }>) { 11 | return ( 12 | 13 | 14 | {children} 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | handleRequest(Request::capture()); 23 | -------------------------------------------------------------------------------- /resources/js/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as LabelPrimitive from "@radix-ui/react-label" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | function Label({ 7 | className, 8 | ...props 9 | }: React.ComponentProps) { 10 | return ( 11 | 19 | ) 20 | } 21 | 22 | export { Label } 23 | -------------------------------------------------------------------------------- /tests/Unit/Actions/CreateUserEmailVerificationNotificationTest.php: -------------------------------------------------------------------------------- 1 | create([ 14 | 'email_verified_at' => null, 15 | ]); 16 | 17 | $action = app(CreateUserEmailVerificationNotification::class); 18 | 19 | $action->handle($user); 20 | 21 | Notification::assertSentTo($user, VerifyEmail::class); 22 | }); 23 | -------------------------------------------------------------------------------- /resources/js/components/app-logo.tsx: -------------------------------------------------------------------------------- 1 | import AppLogoIcon from './app-logo-icon'; 2 | 3 | export default function AppLogo() { 4 | return ( 5 | <> 6 |
7 | 8 |
9 |
10 | 11 | Laravel Starter Kit 12 | 13 |
14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { wayfinder } from '@laravel/vite-plugin-wayfinder'; 2 | import tailwindcss from '@tailwindcss/vite'; 3 | import react from '@vitejs/plugin-react'; 4 | import laravel from 'laravel-vite-plugin'; 5 | import { defineConfig } from 'vite'; 6 | 7 | export default defineConfig({ 8 | plugins: [ 9 | laravel({ 10 | input: ['resources/css/app.css', 'resources/js/app.tsx'], 11 | ssr: 'resources/js/ssr.tsx', 12 | refresh: true, 13 | }), 14 | react(), 15 | tailwindcss(), 16 | wayfinder({ 17 | formVariants: true, 18 | }), 19 | ], 20 | esbuild: { 21 | jsx: 'automatic', 22 | }, 23 | }); 24 | -------------------------------------------------------------------------------- /app/Actions/CreateUser.php: -------------------------------------------------------------------------------- 1 | $attributes 16 | */ 17 | public function handle(array $attributes, #[SensitiveParameter] string $password): User 18 | { 19 | $user = User::query()->create([ 20 | ...$attributes, 21 | 'password' => Hash::make($password), 22 | ]); 23 | 24 | event(new Registered($user)); 25 | 26 | return $user; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /resources/js/components/app-shell.tsx: -------------------------------------------------------------------------------- 1 | import { SidebarProvider } from '@/components/ui/sidebar'; 2 | import { SharedData } from '@/types'; 3 | import { usePage } from '@inertiajs/react'; 4 | 5 | interface AppShellProps { 6 | children: React.ReactNode; 7 | variant?: 'header' | 'sidebar'; 8 | } 9 | 10 | export function AppShell({ children, variant = 'header' }: AppShellProps) { 11 | const isOpen = usePage().props.sidebarOpen; 12 | 13 | if (variant === 'header') { 14 | return ( 15 |
{children}
16 | ); 17 | } 18 | 19 | return {children}; 20 | } 21 | -------------------------------------------------------------------------------- /resources/js/components/app-content.tsx: -------------------------------------------------------------------------------- 1 | import { SidebarInset } from '@/components/ui/sidebar'; 2 | import * as React from 'react'; 3 | 4 | interface AppContentProps extends React.ComponentProps<'main'> { 5 | variant?: 'header' | 'sidebar'; 6 | } 7 | 8 | export function AppContent({ 9 | variant = 'header', 10 | children, 11 | ...props 12 | }: AppContentProps) { 13 | if (variant === 'sidebar') { 14 | return {children}; 15 | } 16 | 17 | return ( 18 |
22 | {children} 23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /resources/js/components/text-link.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/lib/utils'; 2 | import { Link } from '@inertiajs/react'; 3 | import { ComponentProps } from 'react'; 4 | 5 | type LinkProps = ComponentProps; 6 | 7 | export default function TextLink({ 8 | className = '', 9 | children, 10 | ...props 11 | }: LinkProps) { 12 | return ( 13 | 20 | {children} 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /resources/js/hooks/use-mobile.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | const MOBILE_BREAKPOINT = 768; 4 | 5 | export function useIsMobile() { 6 | const [isMobile, setIsMobile] = useState(); 7 | 8 | useEffect(() => { 9 | const mql = window.matchMedia( 10 | `(max-width: ${MOBILE_BREAKPOINT - 1}px)`, 11 | ); 12 | 13 | const onChange = () => { 14 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); 15 | }; 16 | 17 | mql.addEventListener('change', onChange); 18 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); 19 | 20 | return () => mql.removeEventListener('change', onChange); 21 | }, []); 22 | 23 | return !!isMobile; 24 | } 25 | -------------------------------------------------------------------------------- /resources/js/components/ui/placeholder-pattern.tsx: -------------------------------------------------------------------------------- 1 | import { useId } from 'react'; 2 | 3 | interface PlaceholderPatternProps { 4 | className?: string; 5 | } 6 | 7 | export function PlaceholderPattern({ className }: PlaceholderPatternProps) { 8 | const patternId = useId(); 9 | 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /app/Http/Controllers/UserEmailVerification.php: -------------------------------------------------------------------------------- 1 | hasVerifiedEmail()) { 17 | return redirect()->intended(route('dashboard', absolute: false).'?verified=1'); 18 | } 19 | 20 | $request->fulfill(); 21 | 22 | return redirect()->intended(route('dashboard', absolute: false).'?verified=1'); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/Unit/Actions/CreateUserTest.php: -------------------------------------------------------------------------------- 1 | handle([ 16 | 'name' => 'Test User', 17 | 'email' => 'example@email.com', 18 | ], 'password'); 19 | 20 | expect($user)->toBeInstanceOf(User::class) 21 | ->and($user->name)->toBe('Test User') 22 | ->and($user->email)->toBe('example@email.com') 23 | ->and($user->password)->not->toBe('password'); 24 | 25 | Event::assertDispatched(Registered::class); 26 | }); 27 | -------------------------------------------------------------------------------- /database/migrations/0001_01_01_000001_create_cache_table.php: -------------------------------------------------------------------------------- 1 | string('key')->primary(); 15 | $table->mediumText('value'); 16 | $table->integer('expiration'); 17 | }); 18 | 19 | Schema::create('cache_locks', function (Blueprint $table): void { 20 | $table->string('key')->primary(); 21 | $table->string('owner'); 22 | $table->integer('expiration'); 23 | }); 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /resources/js/components/app-sidebar-header.tsx: -------------------------------------------------------------------------------- 1 | import { Breadcrumbs } from '@/components/breadcrumbs'; 2 | import { SidebarTrigger } from '@/components/ui/sidebar'; 3 | import { type BreadcrumbItem as BreadcrumbItemType } from '@/types'; 4 | 5 | export function AppSidebarHeader({ 6 | breadcrumbs = [], 7 | }: { 8 | breadcrumbs?: BreadcrumbItemType[]; 9 | }) { 10 | return ( 11 |
12 |
13 | 14 | 15 |
16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /resources/js/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | function Separator({ 7 | className, 8 | orientation = "horizontal", 9 | decorative = true, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 23 | ) 24 | } 25 | 26 | export { Separator } 27 | -------------------------------------------------------------------------------- /resources/js/ssr.tsx: -------------------------------------------------------------------------------- 1 | import { createInertiaApp } from '@inertiajs/react'; 2 | import createServer from '@inertiajs/react/server'; 3 | import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers'; 4 | import ReactDOMServer from 'react-dom/server'; 5 | 6 | const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; 7 | 8 | createServer((page) => 9 | createInertiaApp({ 10 | page, 11 | render: ReactDOMServer.renderToString, 12 | title: (title) => (title ? `${title} - ${appName}` : appName), 13 | resolve: (name) => 14 | resolvePageComponent( 15 | `./pages/${name}.tsx`, 16 | import.meta.glob('./pages/**/*.tsx'), 17 | ), 18 | setup: ({ App, props }) => { 19 | return ; 20 | }, 21 | }), 22 | ); 23 | -------------------------------------------------------------------------------- /tests/Pest.php: -------------------------------------------------------------------------------- 1 | extend(TestCase::class) 13 | ->use(RefreshDatabase::class) 14 | ->beforeEach(function (): void { 15 | Str::createRandomStringsNormally(); 16 | Str::createUuidsNormally(); 17 | Http::preventStrayRequests(); 18 | Process::preventStrayProcesses(); 19 | Sleep::fake(); 20 | 21 | $this->freezeTime(); 22 | }) 23 | ->in('Browser', 'Feature', 'Unit'); 24 | 25 | expect()->extend('toBeOne', fn () => $this->toBe(1)); 26 | 27 | function something(): void 28 | { 29 | // .. 30 | } 31 | -------------------------------------------------------------------------------- /resources/js/components/alert-error.tsx: -------------------------------------------------------------------------------- 1 | import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; 2 | import { AlertCircleIcon } from 'lucide-react'; 3 | 4 | export default function AlertError({ 5 | errors, 6 | title, 7 | }: { 8 | errors: string[]; 9 | title?: string; 10 | }) { 11 | return ( 12 | 13 | 14 | {title || 'Something went wrong.'} 15 | 16 |
    17 | {Array.from(new Set(errors)).map((error, index) => ( 18 |
  • {error}
  • 19 | ))} 20 |
21 |
22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /public/.htaccess: -------------------------------------------------------------------------------- 1 | 2 | 3 | Options -MultiViews -Indexes 4 | 5 | 6 | RewriteEngine On 7 | 8 | # Handle Authorization Header 9 | RewriteCond %{HTTP:Authorization} . 10 | RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] 11 | 12 | # Handle X-XSRF-Token Header 13 | RewriteCond %{HTTP:x-xsrf-token} . 14 | RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}] 15 | 16 | # Redirect Trailing Slashes If Not A Folder... 17 | RewriteCond %{REQUEST_FILENAME} !-d 18 | RewriteCond %{REQUEST_URI} (.+)/$ 19 | RewriteRule ^ %1 [L,R=301] 20 | 21 | # Send Requests To Front Controller... 22 | RewriteCond %{REQUEST_FILENAME} !-d 23 | RewriteCond %{REQUEST_FILENAME} !-f 24 | RewriteRule ^ index.php [L] 25 | 26 | -------------------------------------------------------------------------------- /resources/js/layouts/app/app-sidebar-layout.tsx: -------------------------------------------------------------------------------- 1 | import { AppContent } from '@/components/app-content'; 2 | import { AppShell } from '@/components/app-shell'; 3 | import { AppSidebar } from '@/components/app-sidebar'; 4 | import { AppSidebarHeader } from '@/components/app-sidebar-header'; 5 | import { type BreadcrumbItem } from '@/types'; 6 | import { type PropsWithChildren } from 'react'; 7 | 8 | export default function AppSidebarLayout({ 9 | children, 10 | breadcrumbs = [], 11 | }: PropsWithChildren<{ breadcrumbs?: BreadcrumbItem[] }>) { 12 | return ( 13 | 14 | 15 | 16 | 17 | {children} 18 | 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /app/Providers/AppServiceProvider.php: -------------------------------------------------------------------------------- 1 | bootModelsDefaults(); 21 | $this->bootPasswordDefaults(); 22 | } 23 | 24 | private function bootModelsDefaults(): void 25 | { 26 | Model::unguard(); 27 | } 28 | 29 | private function bootPasswordDefaults(): void 30 | { 31 | Password::defaults(fn () => app()->isLocal() || app()->runningUnitTests() ? Password::min(12)->max(255) : Password::min(12)->max(255)->uncompromised()); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /resources/js/app.tsx: -------------------------------------------------------------------------------- 1 | import '../css/app.css'; 2 | 3 | import { createInertiaApp } from '@inertiajs/react'; 4 | import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers'; 5 | import { createRoot } from 'react-dom/client'; 6 | import { initializeTheme } from './hooks/use-appearance'; 7 | 8 | const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; 9 | 10 | createInertiaApp({ 11 | title: (title) => (title ? `${title} - ${appName}` : appName), 12 | resolve: (name) => 13 | resolvePageComponent( 14 | `./pages/${name}.tsx`, 15 | import.meta.glob('./pages/**/*.tsx'), 16 | ), 17 | setup({ el, App, props }) { 18 | const root = createRoot(el); 19 | 20 | root.render(); 21 | }, 22 | progress: { 23 | color: '#4B5563', 24 | }, 25 | }); 26 | 27 | // This will set light / dark mode on load... 28 | initializeTheme(); 29 | -------------------------------------------------------------------------------- /app/Rules/ValidEmail.php: -------------------------------------------------------------------------------- 1 | ) { 6 | return 7 | } 8 | 9 | function CollapsibleTrigger({ 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 17 | ) 18 | } 19 | 20 | function CollapsibleContent({ 21 | ...props 22 | }: React.ComponentProps) { 23 | return ( 24 | 28 | ) 29 | } 30 | 31 | export { Collapsible, CollapsibleTrigger, CollapsibleContent } 32 | -------------------------------------------------------------------------------- /app/Http/Controllers/UserProfileController.php: -------------------------------------------------------------------------------- 1 | $request->session()->get('status'), 22 | ]); 23 | } 24 | 25 | public function update(UpdateUserRequest $request, #[CurrentUser] User $user, UpdateUser $action): RedirectResponse 26 | { 27 | $action->handle($user, $request->validated()); 28 | 29 | return to_route('user-profile.edit'); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /resources/js/hooks/use-clipboard.ts: -------------------------------------------------------------------------------- 1 | // Credit: https://usehooks-ts.com/ 2 | import { useCallback, useState } from 'react'; 3 | 4 | type CopiedValue = string | null; 5 | 6 | type CopyFn = (text: string) => Promise; 7 | 8 | export function useClipboard(): [CopiedValue, CopyFn] { 9 | const [copiedText, setCopiedText] = useState(null); 10 | 11 | const copy: CopyFn = useCallback(async (text) => { 12 | if (!navigator?.clipboard) { 13 | console.warn('Clipboard not supported'); 14 | 15 | return false; 16 | } 17 | 18 | try { 19 | await navigator.clipboard.writeText(text); 20 | setCopiedText(text); 21 | 22 | return true; 23 | } catch (error) { 24 | console.warn('Copy failed', error); 25 | setCopiedText(null); 26 | 27 | return false; 28 | } 29 | }, []); 30 | 31 | return [copiedText, copy]; 32 | } 33 | -------------------------------------------------------------------------------- /app/Http/Requests/UpdateUserRequest.php: -------------------------------------------------------------------------------- 1 | |string> 16 | */ 17 | public function rules(): array 18 | { 19 | $user = $this->user(); 20 | assert($user instanceof User); 21 | 22 | return [ 23 | 'name' => ['required', 'string', 'max:255'], 24 | 25 | 'email' => [ 26 | 'required', 27 | 'string', 28 | 'lowercase', 29 | 'email', 30 | 'max:255', 31 | Rule::unique(User::class)->ignore($user->id), 32 | ], 33 | ]; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /app/Actions/CreateUserPassword.php: -------------------------------------------------------------------------------- 1 | $credentials 18 | */ 19 | public function handle(array $credentials, #[SensitiveParameter] string $password): mixed 20 | { 21 | return Password::reset( 22 | $credentials, 23 | function (User $user) use ($password): void { 24 | $user->update([ 25 | 'password' => Hash::make($password), 26 | 'remember_token' => Str::random(60), 27 | ]); 28 | 29 | event(new PasswordReset($user)); 30 | } 31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /bootstrap/app.php: -------------------------------------------------------------------------------- 1 | withRouting( 14 | web: __DIR__.'/../routes/web.php', 15 | commands: __DIR__.'/../routes/console.php', 16 | ) 17 | ->withMiddleware(function (Middleware $middleware): void { 18 | $middleware->encryptCookies(except: ['appearance', 'sidebar_state']); 19 | 20 | $middleware->web(append: [ 21 | HandleAppearance::class, 22 | HandleInertiaRequests::class, 23 | AddLinkHeadersForPreloadedAssets::class, 24 | ]); 25 | }) 26 | ->withExceptions(function (Exceptions $exceptions): void { 27 | // 28 | })->create(); 29 | -------------------------------------------------------------------------------- /resources/js/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Input({ className, type, ...props }: React.ComponentProps<"input">) { 6 | return ( 7 | 18 | ) 19 | } 20 | 21 | export { Input } 22 | -------------------------------------------------------------------------------- /app/Http/Controllers/UserEmailResetNotification.php: -------------------------------------------------------------------------------- 1 | $request->session()->get('status'), 20 | ]); 21 | } 22 | 23 | public function store( 24 | CreateUserEmailResetNotificationRequest $request, 25 | CreateUserEmailResetNotification $action 26 | ): RedirectResponse { 27 | $action->handle(['email' => $request->string('email')->value()]); 28 | 29 | return back()->with('status', __('A reset link will be sent if the account exists.')); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/Http/Requests/CreateUserRequest.php: -------------------------------------------------------------------------------- 1 | |string> 17 | */ 18 | public function rules(): array 19 | { 20 | return [ 21 | 'name' => ['required', 'string', 'max:255'], 22 | 'email' => [ 23 | 'required', 24 | 'string', 25 | 'lowercase', 26 | 'max:255', 27 | 'email', 28 | new ValidEmail, 29 | Rule::unique(User::class), 30 | ], 31 | 'password' => [ 32 | 'required', 33 | 'confirmed', 34 | Password::defaults(), 35 | ], 36 | ]; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /resources/js/components/app-logo-icon.tsx: -------------------------------------------------------------------------------- 1 | import { SVGAttributes } from 'react'; 2 | 3 | export default function AppLogoIcon(props: SVGAttributes) { 4 | return ( 5 | 6 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /resources/js/types/index.d.ts: -------------------------------------------------------------------------------- 1 | import { InertiaLinkProps } from '@inertiajs/react'; 2 | import { LucideIcon } from 'lucide-react'; 3 | 4 | export interface Auth { 5 | user: User; 6 | } 7 | 8 | export interface BreadcrumbItem { 9 | title: string; 10 | href: string; 11 | } 12 | 13 | export interface NavGroup { 14 | title: string; 15 | items: NavItem[]; 16 | } 17 | 18 | export interface NavItem { 19 | title: string; 20 | href: NonNullable; 21 | icon?: LucideIcon | null; 22 | isActive?: boolean; 23 | } 24 | 25 | export interface SharedData { 26 | name: string; 27 | quote: { message: string; author: string }; 28 | auth: Auth; 29 | sidebarOpen: boolean; 30 | [key: string]: unknown; 31 | } 32 | 33 | export interface User { 34 | id: number; 35 | name: string; 36 | email: string; 37 | avatar?: string; 38 | email_verified_at: string | null; 39 | two_factor_enabled?: boolean; 40 | created_at: string; 41 | updated_at: string; 42 | [key: string]: unknown; // This allows for additional properties... 43 | } 44 | -------------------------------------------------------------------------------- /resources/js/pages/appearance/update.tsx: -------------------------------------------------------------------------------- 1 | import { Head } from '@inertiajs/react'; 2 | 3 | import AppearanceTabs from '@/components/appearance-tabs'; 4 | import HeadingSmall from '@/components/heading-small'; 5 | import { type BreadcrumbItem } from '@/types'; 6 | 7 | import AppLayout from '@/layouts/app-layout'; 8 | import SettingsLayout from '@/layouts/settings/layout'; 9 | import { edit as editAppearance } from '@/routes/appearance'; 10 | 11 | const breadcrumbs: BreadcrumbItem[] = [ 12 | { 13 | title: 'Appearance settings', 14 | href: editAppearance().url, 15 | }, 16 | ]; 17 | 18 | export default function Update() { 19 | return ( 20 | 21 | 22 | 23 | 24 |
25 | 29 | 30 |
31 |
32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /app/Http/Controllers/UserTwoFactorAuthenticationController.php: -------------------------------------------------------------------------------- 1 | ensureStateIsValid(); 28 | 29 | return Inertia::render('user-two-factor-authentication/show', [ 30 | 'twoFactorEnabled' => $user->hasEnabledTwoFactorAuthentication(), 31 | ]); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /resources/js/components/user-info.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; 2 | import { useInitials } from '@/hooks/use-initials'; 3 | import { type User } from '@/types'; 4 | 5 | export function UserInfo({ 6 | user, 7 | showEmail = false, 8 | }: { 9 | user: User; 10 | showEmail?: boolean; 11 | }) { 12 | const getInitials = useInitials(); 13 | 14 | return ( 15 | <> 16 | 17 | 18 | 19 | {getInitials(user.name)} 20 | 21 | 22 |
23 | {user.name} 24 | {showEmail && ( 25 | 26 | {user.email} 27 | 28 | )} 29 |
30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /config/services.php: -------------------------------------------------------------------------------- 1 | [ 20 | 'token' => env('POSTMARK_TOKEN'), 21 | ], 22 | 23 | 'ses' => [ 24 | 'key' => env('AWS_ACCESS_KEY_ID'), 25 | 'secret' => env('AWS_SECRET_ACCESS_KEY'), 26 | 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), 27 | ], 28 | 29 | 'resend' => [ 30 | 'key' => env('RESEND_KEY'), 31 | ], 32 | 33 | 'slack' => [ 34 | 'notifications' => [ 35 | 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'), 36 | 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'), 37 | ], 38 | ], 39 | 40 | ]; 41 | -------------------------------------------------------------------------------- /app/Http/Controllers/UserEmailVerificationNotificationController.php: -------------------------------------------------------------------------------- 1 | hasVerifiedEmail() 20 | ? redirect()->intended(route('dashboard', absolute: false)) 21 | : Inertia::render('user-email-verification-notification/create', ['status' => $request->session()->get('status')]); 22 | } 23 | 24 | public function store(#[CurrentUser] User $user, CreateUserEmailVerificationNotification $action): RedirectResponse 25 | { 26 | if ($user->hasVerifiedEmail()) { 27 | return redirect()->intended(route('dashboard', absolute: false)); 28 | } 29 | 30 | $action->handle($user); 31 | 32 | return back()->with('status', 'verification-link-sent'); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /resources/js/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox" 3 | import { CheckIcon } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | function Checkbox({ 8 | className, 9 | ...props 10 | }: React.ComponentProps) { 11 | return ( 12 | 20 | 24 | 25 | 26 | 27 | ) 28 | } 29 | 30 | export { Checkbox } 31 | -------------------------------------------------------------------------------- /app/Providers/FortifyServiceProvider.php: -------------------------------------------------------------------------------- 1 | bootFortifyDefaults(); 24 | $this->bootRateLimitingDefaults(); 25 | } 26 | 27 | private function bootFortifyDefaults(): void 28 | { 29 | Fortify::twoFactorChallengeView(fn () => Inertia::render('user-two-factor-authentication-challenge/show')); 30 | Fortify::confirmPasswordView(fn () => Inertia::render('user-password-confirmation/create')); 31 | } 32 | 33 | private function bootRateLimitingDefaults(): void 34 | { 35 | RateLimiter::for('login', fn (Request $request) => Limit::perMinute(5)->by($request->string('email')->value().$request->ip())); 36 | RateLimiter::for('two-factor', fn (Request $request) => Limit::perMinute(5)->by($request->session()->get('login.id'))); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /.ai/guidelines/app.actions.blade.php: -------------------------------------------------------------------------------- 1 | # App/Actions guidelines 2 | 3 | - This application uses the Action pattern and prefers for much logic to live in reusable and composable Action classes. 4 | - Actions live in `app/Actions`, they are named based on what they do, with no suffix. 5 | - Actions will be called from many different places: jobs, commands, HTTP requests, API requests, MCP requests, and more. 6 | - Create dedicated Action classes for business logic with a single `handle()` method. 7 | - Inject dependencies via constructor using private properties. 8 | - Create new actions with `php artisan make:action "{name}" --no-interaction` 9 | - Wrap complex operations in `DB::transaction()` within actions when multiple models are involved. 10 | - Some actions won't require dependencies via `__construct` and they can use just the `handle()` method. 11 | 12 | @boostsnippet('Example action class', 'php') 13 | favorites->add($user, $favorite); 29 | } 30 | } 31 | @endboostsnippet 32 | -------------------------------------------------------------------------------- /resources/js/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | function Avatar({ 7 | className, 8 | ...props 9 | }: React.ComponentProps) { 10 | return ( 11 | 19 | ) 20 | } 21 | 22 | function AvatarImage({ 23 | className, 24 | ...props 25 | }: React.ComponentProps) { 26 | return ( 27 | 32 | ) 33 | } 34 | 35 | function AvatarFallback({ 36 | className, 37 | ...props 38 | }: React.ComponentProps) { 39 | return ( 40 | 48 | ) 49 | } 50 | 51 | export { Avatar, AvatarImage, AvatarFallback } 52 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | APP_NAME=Laravel 2 | APP_ENV=local 3 | APP_KEY= 4 | APP_DEBUG=true 5 | APP_URL=http://localhost 6 | 7 | APP_LOCALE=en 8 | APP_FALLBACK_LOCALE=en 9 | APP_FAKER_LOCALE=en_US 10 | 11 | APP_MAINTENANCE_DRIVER=file 12 | # APP_MAINTENANCE_STORE=database 13 | 14 | PHP_CLI_SERVER_WORKERS=4 15 | 16 | BCRYPT_ROUNDS=12 17 | 18 | LOG_CHANNEL=stack 19 | LOG_STACK=single 20 | LOG_DEPRECATIONS_CHANNEL=null 21 | LOG_LEVEL=debug 22 | 23 | DB_CONNECTION=sqlite 24 | # DB_HOST=127.0.0.1 25 | # DB_PORT=3306 26 | # DB_DATABASE=laravel 27 | # DB_USERNAME=root 28 | # DB_PASSWORD= 29 | 30 | SESSION_DRIVER=database 31 | SESSION_LIFETIME=120 32 | SESSION_ENCRYPT=false 33 | SESSION_PATH=/ 34 | SESSION_DOMAIN=null 35 | 36 | BROADCAST_CONNECTION=log 37 | FILESYSTEM_DISK=local 38 | QUEUE_CONNECTION=database 39 | 40 | CACHE_STORE=database 41 | # CACHE_PREFIX= 42 | 43 | MEMCACHED_HOST=127.0.0.1 44 | 45 | REDIS_CLIENT=phpredis 46 | REDIS_HOST=127.0.0.1 47 | REDIS_PASSWORD=null 48 | REDIS_PORT=6379 49 | 50 | MAIL_MAILER=log 51 | MAIL_SCHEME=null 52 | MAIL_HOST=127.0.0.1 53 | MAIL_PORT=2525 54 | MAIL_USERNAME=null 55 | MAIL_PASSWORD=null 56 | MAIL_FROM_ADDRESS="hello@example.com" 57 | MAIL_FROM_NAME="${APP_NAME}" 58 | 59 | AWS_ACCESS_KEY_ID= 60 | AWS_SECRET_ACCESS_KEY= 61 | AWS_DEFAULT_REGION=us-east-1 62 | AWS_BUCKET= 63 | AWS_USE_PATH_STYLE_ENDPOINT=false 64 | 65 | VITE_APP_NAME="${APP_NAME}" 66 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | tests/Browser 10 | 11 | 12 | tests/Unit 13 | 14 | 15 | tests/Feature 16 | 17 | 18 | 19 | 20 | app 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /app/Http/Middleware/HandleInertiaRequests.php: -------------------------------------------------------------------------------- 1 | 32 | */ 33 | public function share(Request $request): array 34 | { 35 | $quote = Inspiring::quotes()->random(); 36 | assert(is_string($quote)); 37 | 38 | [$message, $author] = str($quote)->explode('-'); 39 | 40 | return [ 41 | ...parent::share($request), 42 | 'name' => config('app.name'), 43 | 'quote' => ['message' => mb_trim((string) $message), 'author' => mb_trim((string) $author)], 44 | 'auth' => [ 45 | 'user' => $request->user(), 46 | ], 47 | 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', 48 | ]; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /resources/js/components/nav-main.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | SidebarGroup, 3 | SidebarGroupLabel, 4 | SidebarMenu, 5 | SidebarMenuButton, 6 | SidebarMenuItem, 7 | } from '@/components/ui/sidebar'; 8 | import { type NavItem } from '@/types'; 9 | import { Link, usePage } from '@inertiajs/react'; 10 | 11 | export function NavMain({ items = [] }: { items: NavItem[] }) { 12 | const page = usePage(); 13 | return ( 14 | 15 | Platform 16 | 17 | {items.map((item) => ( 18 | 19 | 28 | 29 | {item.icon && } 30 | {item.title} 31 | 32 | 33 | 34 | ))} 35 | 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /database/factories/UserFactory.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | final class UserFactory extends Factory 15 | { 16 | private static ?string $password = null; 17 | 18 | /** 19 | * @return array 20 | */ 21 | public function definition(): array 22 | { 23 | return [ 24 | 'name' => fake()->name(), 25 | 'email' => fake()->unique()->safeEmail(), 26 | 'email_verified_at' => now(), 27 | 'password' => self::$password ??= Hash::make('password'), 28 | 'remember_token' => Str::random(10), 29 | 'two_factor_secret' => Str::random(10), 30 | 'two_factor_recovery_codes' => Str::random(10), 31 | 'two_factor_confirmed_at' => now(), 32 | ]; 33 | } 34 | 35 | public function unverified(): self 36 | { 37 | return $this->state(fn (array $attributes): array => [ 38 | 'email_verified_at' => null, 39 | ]); 40 | } 41 | 42 | public function withoutTwoFactor(): self 43 | { 44 | return $this->state(fn (array $attributes): array => [ 45 | 'two_factor_secret' => null, 46 | 'two_factor_recovery_codes' => null, 47 | 'two_factor_confirmed_at' => null, 48 | ]); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /app/Http/Controllers/UserController.php: -------------------------------------------------------------------------------- 1 | $attributes */ 28 | $attributes = $request->safe()->except('password'); 29 | 30 | $user = $action->handle( 31 | $attributes, 32 | $request->string('password')->value(), 33 | ); 34 | 35 | Auth::login($user); 36 | 37 | $request->session()->regenerate(); 38 | 39 | return redirect()->intended(route('dashboard', absolute: false)); 40 | } 41 | 42 | public function destroy(DeleteUserRequest $request, #[CurrentUser] User $user, DeleteUser $action): RedirectResponse 43 | { 44 | Auth::logout(); 45 | 46 | $action->handle($user); 47 | 48 | $request->session()->invalidate(); 49 | $request->session()->regenerateToken(); 50 | 51 | return to_route('home'); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js'; 2 | import prettier from 'eslint-config-prettier/flat'; 3 | import react from 'eslint-plugin-react'; 4 | import reactHooks from 'eslint-plugin-react-hooks'; 5 | import globals from 'globals'; 6 | import typescript from 'typescript-eslint'; 7 | 8 | /** @type {import('eslint').Linter.Config[]} */ 9 | export default [ 10 | js.configs.recommended, 11 | ...typescript.configs.recommended, 12 | { 13 | ...react.configs.flat.recommended, 14 | ...react.configs.flat['jsx-runtime'], // Required for React 17+ 15 | languageOptions: { 16 | globals: { 17 | ...globals.browser, 18 | }, 19 | }, 20 | rules: { 21 | 'react/react-in-jsx-scope': 'off', 22 | 'react/prop-types': 'off', 23 | 'react/no-unescaped-entities': 'off', 24 | }, 25 | settings: { 26 | react: { 27 | version: 'detect', 28 | }, 29 | }, 30 | }, 31 | { 32 | plugins: { 33 | 'react-hooks': reactHooks, 34 | }, 35 | rules: { 36 | 'react-hooks/rules-of-hooks': 'error', 37 | 'react-hooks/exhaustive-deps': 'warn', 38 | }, 39 | }, 40 | { 41 | ignores: [ 42 | 'resources/js/actions/**', 43 | 'vendor', 44 | 'node_modules', 45 | 'public', 46 | 'bootstrap/ssr', 47 | 'tailwind.config.js', 48 | ], 49 | }, 50 | prettier, // Turn off all rules that might conflict with Prettier 51 | ]; 52 | -------------------------------------------------------------------------------- /tests/Unit/Actions/CreateUserEmailResetNotificationTest.php: -------------------------------------------------------------------------------- 1 | create([ 15 | 'email' => 'test@example.com', 16 | ]); 17 | 18 | $action = app(CreateUserEmailResetNotification::class); 19 | 20 | $status = $action->handle(['email' => $user->email]); 21 | 22 | expect($status)->toBe(Password::RESET_LINK_SENT); 23 | 24 | Notification::assertSentTo($user, ResetPassword::class); 25 | }); 26 | 27 | it('returns throttled status when too many attempts', function (): void { 28 | $user = User::factory()->create([ 29 | 'email' => 'test@example.com', 30 | ]); 31 | 32 | $action = app(CreateUserEmailResetNotification::class); 33 | 34 | // Send multiple reset requests to trigger throttling 35 | $action->handle(['email' => $user->email]); 36 | $status = $action->handle(['email' => $user->email]); 37 | 38 | expect($status)->toBe(Password::RESET_THROTTLED); 39 | }); 40 | 41 | it('returns invalid user status for non-existent email', function (): void { 42 | $action = app(CreateUserEmailResetNotification::class); 43 | 44 | $status = $action->handle(['email' => 'nonexistent@example.com']); 45 | 46 | expect($status)->toBe(Password::INVALID_USER); 47 | }); 48 | -------------------------------------------------------------------------------- /database/migrations/0001_01_01_000000_create_users_table.php: -------------------------------------------------------------------------------- 1 | id(); 15 | $table->string('name'); 16 | $table->string('email')->unique(); 17 | $table->timestamp('email_verified_at')->nullable(); 18 | $table->string('password'); 19 | $table->text('two_factor_secret')->nullable(); 20 | $table->text('two_factor_recovery_codes')->nullable(); 21 | $table->timestamp('two_factor_confirmed_at')->nullable(); 22 | $table->rememberToken(); 23 | $table->timestamps(); 24 | }); 25 | 26 | Schema::create('password_reset_tokens', function (Blueprint $table): void { 27 | $table->string('email')->primary(); 28 | $table->string('token'); 29 | $table->timestamp('created_at')->nullable(); 30 | }); 31 | 32 | Schema::create('sessions', function (Blueprint $table): void { 33 | $table->string('id')->primary(); 34 | $table->foreignId('user_id')->nullable()->index(); 35 | $table->string('ip_address', 45)->nullable(); 36 | $table->text('user_agent')->nullable(); 37 | $table->longText('payload'); 38 | $table->integer('last_activity')->index(); 39 | }); 40 | } 41 | }; 42 | -------------------------------------------------------------------------------- /config/inertia.php: -------------------------------------------------------------------------------- 1 | [ 21 | 'enabled' => true, 22 | 'url' => 'http://127.0.0.1:13714', 23 | // 'bundle' => base_path('bootstrap/ssr/ssr.mjs'), 24 | 25 | ], 26 | 27 | /* 28 | |-------------------------------------------------------------------------- 29 | | Testing 30 | |-------------------------------------------------------------------------- 31 | | 32 | | The values described here are used to locate Inertia components on the 33 | | filesystem. For instance, when using `assertInertia`, the assertion 34 | | attempts to locate the component as a file relative to the paths. 35 | | 36 | */ 37 | 38 | 'testing' => [ 39 | 40 | 'ensure_pages_exist' => true, 41 | 42 | 'page_paths' => [ 43 | resource_path('js/pages'), 44 | ], 45 | 46 | 'page_extensions' => [ 47 | 'js', 48 | 'jsx', 49 | 'svelte', 50 | 'ts', 51 | 'tsx', 52 | 'vue', 53 | ], 54 | 55 | ], 56 | 57 | ]; 58 | -------------------------------------------------------------------------------- /app/Http/Controllers/SessionController.php: -------------------------------------------------------------------------------- 1 | Route::has('password.request'), 21 | 'status' => $request->session()->get('status'), 22 | ]); 23 | } 24 | 25 | public function store(CreateSessionRequest $request): RedirectResponse 26 | { 27 | $user = $request->validateCredentials(); 28 | 29 | if ($user->hasEnabledTwoFactorAuthentication()) { 30 | $request->session()->put([ 31 | 'login.id' => $user->getKey(), 32 | 'login.remember' => $request->boolean('remember'), 33 | ]); 34 | 35 | return to_route('two-factor.login'); 36 | } 37 | 38 | Auth::login($user, $request->boolean('remember')); 39 | 40 | $request->session()->regenerate(); 41 | 42 | return redirect()->intended(route('dashboard', absolute: false)); 43 | } 44 | 45 | public function destroy(Request $request): RedirectResponse 46 | { 47 | Auth::guard('web')->logout(); 48 | 49 | $request->session()->invalidate(); 50 | $request->session()->regenerateToken(); 51 | 52 | return redirect('/'); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /tests/Unit/Actions/UpdateUserTest.php: -------------------------------------------------------------------------------- 1 | create([ 10 | 'name' => 'Old Name', 11 | 'email' => 'old@email.com', 12 | ]); 13 | 14 | $action = app(UpdateUser::class); 15 | 16 | $action->handle($user, [ 17 | 'name' => 'New Name', 18 | ]); 19 | 20 | expect($user->refresh()->name)->toBe('New Name') 21 | ->and($user->email)->toBe('old@email.com'); 22 | }); 23 | 24 | it('resets email verification when email changes', function (): void { 25 | $user = User::factory()->create([ 26 | 'email' => 'old@email.com', 27 | 'email_verified_at' => now(), 28 | ]); 29 | 30 | expect($user->email_verified_at)->not->toBeNull(); 31 | 32 | $action = app(UpdateUser::class); 33 | 34 | $action->handle($user, [ 35 | 'email' => 'new@email.com', 36 | ]); 37 | 38 | expect($user->refresh()->email)->toBe('new@email.com') 39 | ->and($user->email_verified_at)->toBeNull(); 40 | }); 41 | 42 | it('keeps email verification when email stays the same', function (): void { 43 | $verifiedAt = now(); 44 | 45 | $user = User::factory()->create([ 46 | 'email' => 'same@email.com', 47 | 'email_verified_at' => $verifiedAt, 48 | ]); 49 | 50 | $action = app(UpdateUser::class); 51 | 52 | $action->handle($user, [ 53 | 'email' => 'same@email.com', 54 | 'name' => 'Updated Name', 55 | ]); 56 | 57 | expect($user->refresh()->email_verified_at)->not->toBeNull() 58 | ->and($user->name)->toBe('Updated Name'); 59 | }); 60 | -------------------------------------------------------------------------------- /resources/js/components/ui/toggle.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as TogglePrimitive from "@radix-ui/react-toggle" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const toggleVariants = cva( 8 | "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-transparent", 13 | outline: 14 | "border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground", 15 | }, 16 | size: { 17 | default: "h-9 px-2 min-w-9", 18 | sm: "h-8 px-1.5 min-w-8", 19 | lg: "h-10 px-2.5 min-w-10", 20 | }, 21 | }, 22 | defaultVariants: { 23 | variant: "default", 24 | size: "default", 25 | }, 26 | } 27 | ) 28 | 29 | function Toggle({ 30 | className, 31 | variant, 32 | size, 33 | ...props 34 | }: React.ComponentProps & 35 | VariantProps) { 36 | return ( 37 | 42 | ) 43 | } 44 | 45 | export { Toggle, toggleVariants } 46 | -------------------------------------------------------------------------------- /database/migrations/0001_01_01_000002_create_jobs_table.php: -------------------------------------------------------------------------------- 1 | id(); 15 | $table->string('queue')->index(); 16 | $table->longText('payload'); 17 | $table->unsignedTinyInteger('attempts'); 18 | $table->unsignedInteger('reserved_at')->nullable(); 19 | $table->unsignedInteger('available_at'); 20 | $table->unsignedInteger('created_at'); 21 | }); 22 | 23 | Schema::create('job_batches', function (Blueprint $table): void { 24 | $table->string('id')->primary(); 25 | $table->string('name'); 26 | $table->integer('total_jobs'); 27 | $table->integer('pending_jobs'); 28 | $table->integer('failed_jobs'); 29 | $table->longText('failed_job_ids'); 30 | $table->mediumText('options')->nullable(); 31 | $table->integer('cancelled_at')->nullable(); 32 | $table->integer('created_at'); 33 | $table->integer('finished_at')->nullable(); 34 | }); 35 | 36 | Schema::create('failed_jobs', function (Blueprint $table): void { 37 | $table->id(); 38 | $table->string('uuid')->unique(); 39 | $table->text('connection'); 40 | $table->text('queue'); 41 | $table->longText('payload'); 42 | $table->longText('exception'); 43 | $table->timestamp('failed_at')->useCurrent(); 44 | }); 45 | } 46 | }; 47 | -------------------------------------------------------------------------------- /resources/js/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Card({ className, ...props }: React.ComponentProps<"div">) { 6 | return ( 7 |
15 | ) 16 | } 17 | 18 | function CardHeader({ className, ...props }: React.ComponentProps<"div">) { 19 | return ( 20 |
25 | ) 26 | } 27 | 28 | function CardTitle({ className, ...props }: React.ComponentProps<"div">) { 29 | return ( 30 |
35 | ) 36 | } 37 | 38 | function CardDescription({ className, ...props }: React.ComponentProps<"div">) { 39 | return ( 40 |
45 | ) 46 | } 47 | 48 | function CardContent({ className, ...props }: React.ComponentProps<"div">) { 49 | return ( 50 |
55 | ) 56 | } 57 | 58 | function CardFooter({ className, ...props }: React.ComponentProps<"div">) { 59 | return ( 60 |
65 | ) 66 | } 67 | 68 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 69 | -------------------------------------------------------------------------------- /resources/js/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const badgeVariants = cva( 8 | "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", 14 | secondary: 15 | "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", 16 | destructive: 17 | "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 18 | outline: 19 | "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", 20 | }, 21 | }, 22 | defaultVariants: { 23 | variant: "default", 24 | }, 25 | } 26 | ) 27 | 28 | function Badge({ 29 | className, 30 | variant, 31 | asChild = false, 32 | ...props 33 | }: React.ComponentProps<"span"> & 34 | VariantProps & { asChild?: boolean }) { 35 | const Comp = asChild ? Slot : "span" 36 | 37 | return ( 38 | 43 | ) 44 | } 45 | 46 | export { Badge, badgeVariants } 47 | -------------------------------------------------------------------------------- /rector.php: -------------------------------------------------------------------------------- 1 | withSetProviders(LaravelSetProvider::class) 13 | ->withSets([ 14 | LaravelSetList::LARAVEL_ARRAYACCESS_TO_METHOD_CALL, 15 | LaravelSetList::LARAVEL_ARRAY_STR_FUNCTION_TO_STATIC_CALL, 16 | LaravelSetList::LARAVEL_CODE_QUALITY, 17 | LaravelSetList::LARAVEL_COLLECTION, 18 | LaravelSetList::LARAVEL_CONTAINER_STRING_TO_FULLY_QUALIFIED_NAME, 19 | LaravelSetList::LARAVEL_ELOQUENT_MAGIC_METHOD_TO_QUERY_BUILDER, 20 | LaravelSetList::LARAVEL_FACADE_ALIASES_TO_FULL_NAMES, 21 | LaravelSetList::LARAVEL_FACTORIES, 22 | LaravelSetList::LARAVEL_IF_HELPERS, 23 | LaravelSetList::LARAVEL_LEGACY_FACTORIES_TO_CLASSES, 24 | ]) 25 | ->withComposerBased(laravel: true) 26 | ->withCache( 27 | cacheDirectory: '/tmp/rector', 28 | cacheClass: FileCacheStorage::class, 29 | ) 30 | ->withPaths([ 31 | __DIR__.'/app', 32 | __DIR__.'/bootstrap/app.php', 33 | __DIR__.'/config', 34 | __DIR__.'/database', 35 | __DIR__.'/public', 36 | __DIR__.'/routes', 37 | __DIR__.'/tests', 38 | ]) 39 | ->withSkip([ 40 | AddOverrideAttributeToOverriddenMethodsRector::class, 41 | ]) 42 | ->withPreparedSets( 43 | deadCode: true, 44 | codeQuality: true, 45 | typeDeclarations: true, 46 | privatization: true, 47 | earlyReturn: true, 48 | strictBooleans: true, 49 | ) 50 | ->withPhpSets(); 51 | -------------------------------------------------------------------------------- /tests/Unit/Middleware/HandleAppearanceTest.php: -------------------------------------------------------------------------------- 1 | cookies->set('appearance', 'dark'); 15 | 16 | $response = $middleware->handle($request, fn ($req): Response => response('OK')); 17 | 18 | expect(View::shared('appearance'))->toBe('dark') 19 | ->and($response->getContent())->toBe('OK'); 20 | }); 21 | 22 | it('defaults to system when appearance cookie not present', function (): void { 23 | $middleware = new HandleAppearance(); 24 | 25 | $request = Request::create('/', 'GET'); 26 | 27 | $response = $middleware->handle($request, fn ($req): Response => response('OK')); 28 | 29 | expect(View::shared('appearance'))->toBe('system') 30 | ->and($response->getContent())->toBe('OK'); 31 | }); 32 | 33 | it('handles light appearance', function (): void { 34 | $middleware = new HandleAppearance(); 35 | 36 | $request = Request::create('/', 'GET'); 37 | $request->cookies->set('appearance', 'light'); 38 | 39 | $middleware->handle($request, fn ($req): Response => response('OK')); 40 | 41 | expect(View::shared('appearance'))->toBe('light'); 42 | }); 43 | 44 | it('handles system appearance', function (): void { 45 | $middleware = new HandleAppearance(); 46 | 47 | $request = Request::create('/', 'GET'); 48 | $request->cookies->set('appearance', 'system'); 49 | 50 | $middleware->handle($request, fn ($req): Response => response('OK')); 51 | 52 | expect(View::shared('appearance'))->toBe('system'); 53 | }); 54 | -------------------------------------------------------------------------------- /resources/js/components/appearance-tabs.tsx: -------------------------------------------------------------------------------- 1 | import { Appearance, useAppearance } from '@/hooks/use-appearance'; 2 | import { cn } from '@/lib/utils'; 3 | import { LucideIcon, Monitor, Moon, Sun } from 'lucide-react'; 4 | import { HTMLAttributes } from 'react'; 5 | 6 | export default function AppearanceToggleTab({ 7 | className = '', 8 | ...props 9 | }: HTMLAttributes) { 10 | const { appearance, updateAppearance } = useAppearance(); 11 | 12 | const tabs: { value: Appearance; icon: LucideIcon; label: string }[] = [ 13 | { value: 'light', icon: Sun, label: 'Light' }, 14 | { value: 'dark', icon: Moon, label: 'Dark' }, 15 | { value: 'system', icon: Monitor, label: 'System' }, 16 | ]; 17 | 18 | return ( 19 |
26 | {tabs.map(({ value, icon: Icon, label }) => ( 27 | 40 | ))} 41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /resources/js/layouts/auth/auth-card-layout.tsx: -------------------------------------------------------------------------------- 1 | import AppLogoIcon from '@/components/app-logo-icon'; 2 | import { 3 | Card, 4 | CardContent, 5 | CardDescription, 6 | CardHeader, 7 | CardTitle, 8 | } from '@/components/ui/card'; 9 | import { home } from '@/routes'; 10 | import { Link } from '@inertiajs/react'; 11 | import { type PropsWithChildren } from 'react'; 12 | 13 | export default function AuthCardLayout({ 14 | children, 15 | title, 16 | description, 17 | }: PropsWithChildren<{ 18 | name?: string; 19 | title?: string; 20 | description?: string; 21 | }>) { 22 | return ( 23 |
24 |
25 | 29 |
30 | 31 |
32 | 33 | 34 |
35 | 36 | 37 | {title} 38 | {description} 39 | 40 | 41 | {children} 42 | 43 | 44 |
45 |
46 |
47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /tests/Feature/Controllers/UserTwoFactorAuthenticationControllerTest.php: -------------------------------------------------------------------------------- 1 | create(); 9 | 10 | $this->actingAs($user)->session(['auth.password_confirmed_at' => time()]); 11 | 12 | $response = $this->fromRoute('dashboard') 13 | ->get(route('two-factor.show')); 14 | 15 | $response->assertOk() 16 | ->assertInertia(fn ($page) => $page 17 | ->component('user-two-factor-authentication/show') 18 | ->has('twoFactorEnabled')); 19 | }); 20 | 21 | it('shows two factor disabled when not enabled', function (): void { 22 | $user = User::factory()->withoutTwoFactor()->create(); 23 | 24 | $this->actingAs($user)->session(['auth.password_confirmed_at' => time()]); 25 | 26 | $response = $this->fromRoute('dashboard') 27 | ->get(route('two-factor.show')); 28 | 29 | $response->assertOk() 30 | ->assertInertia(fn ($page) => $page 31 | ->component('user-two-factor-authentication/show') 32 | ->where('twoFactorEnabled', false)); 33 | }); 34 | 35 | it('shows two factor enabled when enabled', function (): void { 36 | $user = User::factory()->create([ 37 | 'two_factor_secret' => encrypt('secret'), 38 | 'two_factor_recovery_codes' => encrypt(json_encode(['code1', 'code2'])), 39 | 'two_factor_confirmed_at' => now(), 40 | ]); 41 | 42 | $this->actingAs($user)->session(['auth.password_confirmed_at' => time()]); 43 | 44 | $response = $this->fromRoute('dashboard') 45 | ->get(route('two-factor.show')); 46 | 47 | $response->assertOk() 48 | ->assertInertia(fn ($page) => $page 49 | ->component('user-two-factor-authentication/show') 50 | ->where('twoFactorEnabled', true)); 51 | }); 52 | -------------------------------------------------------------------------------- /resources/js/layouts/auth/auth-simple-layout.tsx: -------------------------------------------------------------------------------- 1 | import AppLogoIcon from '@/components/app-logo-icon'; 2 | import { home } from '@/routes'; 3 | import { Link } from '@inertiajs/react'; 4 | import { type PropsWithChildren } from 'react'; 5 | 6 | interface AuthLayoutProps { 7 | name?: string; 8 | title?: string; 9 | description?: string; 10 | } 11 | 12 | export default function AuthSimpleLayout({ 13 | children, 14 | title, 15 | description, 16 | }: PropsWithChildren) { 17 | return ( 18 |
19 |
20 |
21 |
22 | 26 |
27 | 28 |
29 | {title} 30 | 31 | 32 |
33 |

{title}

34 |

35 | {description} 36 |

37 |
38 |
39 | {children} 40 |
41 |
42 |
43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /tests/Feature/Controllers/UserEmailVerificationTest.php: -------------------------------------------------------------------------------- 1 | create([ 10 | 'email_verified_at' => null, 11 | ]); 12 | 13 | $verificationUrl = URL::temporarySignedRoute( 14 | 'verification.verify', 15 | now()->addMinutes(60), 16 | ['id' => $user->getKey(), 'hash' => sha1($user->email)] 17 | ); 18 | 19 | $response = $this->actingAs($user) 20 | ->fromRoute('verification.notice') 21 | ->get($verificationUrl); 22 | 23 | expect($user->refresh()->hasVerifiedEmail())->toBeTrue(); 24 | 25 | $response->assertRedirect(route('dashboard', absolute: false).'?verified=1'); 26 | }); 27 | 28 | it('redirects to dashboard if already verified', function (): void { 29 | $user = User::factory()->create([ 30 | 'email_verified_at' => now(), 31 | ]); 32 | 33 | $verificationUrl = URL::temporarySignedRoute( 34 | 'verification.verify', 35 | now()->addMinutes(60), 36 | ['id' => $user->getKey(), 'hash' => sha1($user->email)] 37 | ); 38 | 39 | $response = $this->actingAs($user) 40 | ->fromRoute('verification.notice') 41 | ->get($verificationUrl); 42 | 43 | $response->assertRedirect(route('dashboard', absolute: false).'?verified=1'); 44 | }); 45 | 46 | it('requires valid signature', function (): void { 47 | $user = User::factory()->create([ 48 | 'email_verified_at' => null, 49 | ]); 50 | 51 | $invalidUrl = route('verification.verify', [ 52 | 'id' => $user->getKey(), 53 | 'hash' => sha1($user->email), 54 | ]); 55 | 56 | $response = $this->actingAs($user) 57 | ->fromRoute('verification.notice') 58 | ->get($invalidUrl); 59 | 60 | $response->assertForbidden(); 61 | }); 62 | -------------------------------------------------------------------------------- /resources/js/components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-background text-foreground", 12 | destructive: 13 | "text-destructive-foreground [&>svg]:text-current *:data-[slot=alert-description]:text-destructive-foreground/80", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | } 20 | ) 21 | 22 | function Alert({ 23 | className, 24 | variant, 25 | ...props 26 | }: React.ComponentProps<"div"> & VariantProps) { 27 | return ( 28 |
34 | ) 35 | } 36 | 37 | function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { 38 | return ( 39 |
47 | ) 48 | } 49 | 50 | function AlertDescription({ 51 | className, 52 | ...props 53 | }: React.ComponentProps<"div">) { 54 | return ( 55 |
63 | ) 64 | } 65 | 66 | export { Alert, AlertTitle, AlertDescription } 67 | -------------------------------------------------------------------------------- /resources/views/app.blade.php: -------------------------------------------------------------------------------- 1 | 2 | ($appearance ?? 'system') == 'dark'])> 3 | 4 | 5 | 6 | 7 | {{-- Inline script to detect system dark mode preference and apply it immediately --}} 8 | 21 | 22 | {{-- Inline style to set the HTML background color based on our theme in app.css --}} 23 | 32 | 33 | {{ config('app.name', 'Laravel') }} 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | @viteReactRefresh 43 | @vite(['resources/js/app.tsx', "resources/js/pages/{$page['component']}.tsx"]) 44 | @inertiaHead 45 | 46 | 47 | @inertia 48 | 49 | 50 | -------------------------------------------------------------------------------- /resources/js/components/breadcrumbs.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Breadcrumb, 3 | BreadcrumbItem, 4 | BreadcrumbLink, 5 | BreadcrumbList, 6 | BreadcrumbPage, 7 | BreadcrumbSeparator, 8 | } from '@/components/ui/breadcrumb'; 9 | import { type BreadcrumbItem as BreadcrumbItemType } from '@/types'; 10 | import { Link } from '@inertiajs/react'; 11 | import { Fragment } from 'react'; 12 | 13 | export function Breadcrumbs({ 14 | breadcrumbs, 15 | }: { 16 | breadcrumbs: BreadcrumbItemType[]; 17 | }) { 18 | return ( 19 | <> 20 | {breadcrumbs.length > 0 && ( 21 | 22 | 23 | {breadcrumbs.map((item, index) => { 24 | const isLast = index === breadcrumbs.length - 1; 25 | return ( 26 | 27 | 28 | {isLast ? ( 29 | 30 | {item.title} 31 | 32 | ) : ( 33 | 34 | 35 | {item.title} 36 | 37 | 38 | )} 39 | 40 | {!isLast && } 41 | 42 | ); 43 | })} 44 | 45 | 46 | )} 47 | 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /app/Http/Controllers/UserPasswordController.php: -------------------------------------------------------------------------------- 1 | $request->email, 26 | 'token' => $request->route('token'), 27 | ]); 28 | } 29 | 30 | public function store(CreateUserPasswordRequest $request, CreateUserPassword $action): RedirectResponse 31 | { 32 | /** @var array $credentials */ 33 | $credentials = $request->only('email', 'password', 'password_confirmation', 'token'); 34 | 35 | $status = $action->handle( 36 | $credentials, 37 | $request->string('password')->value() 38 | ); 39 | 40 | throw_if($status !== Password::PASSWORD_RESET, ValidationException::withMessages([ 41 | 'email' => [__(is_string($status) ? $status : '')], 42 | ])); 43 | 44 | return to_route('login')->with('status', __('passwords.reset')); 45 | } 46 | 47 | public function edit(): Response 48 | { 49 | return Inertia::render('user-password/edit'); 50 | } 51 | 52 | public function update(UpdateUserPasswordRequest $request, #[CurrentUser] User $user, UpdateUserPassword $action): RedirectResponse 53 | { 54 | $action->handle($user, $request->string('password')->value()); 55 | 56 | return back(); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /resources/js/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as TooltipPrimitive from "@radix-ui/react-tooltip" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | function TooltipProvider({ 7 | delayDuration = 0, 8 | ...props 9 | }: React.ComponentProps) { 10 | return ( 11 | 16 | ) 17 | } 18 | 19 | function Tooltip({ 20 | ...props 21 | }: React.ComponentProps) { 22 | return ( 23 | 24 | 25 | 26 | ) 27 | } 28 | 29 | function TooltipTrigger({ 30 | ...props 31 | }: React.ComponentProps) { 32 | return 33 | } 34 | 35 | function TooltipContent({ 36 | className, 37 | sideOffset = 4, 38 | children, 39 | ...props 40 | }: React.ComponentProps) { 41 | return ( 42 | 43 | 52 | {children} 53 | 54 | 55 | 56 | ) 57 | } 58 | 59 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } 60 | -------------------------------------------------------------------------------- /app/Models/User.php: -------------------------------------------------------------------------------- 1 | 32 | */ 33 | use HasFactory, Notifiable, TwoFactorAuthenticatable; 34 | 35 | /** 36 | * @var list 37 | */ 38 | protected $hidden = [ 39 | 'password', 40 | 'remember_token', 41 | 'two_factor_secret', 42 | 'two_factor_recovery_codes', 43 | ]; 44 | 45 | /** 46 | * @return array 47 | */ 48 | public function casts(): array 49 | { 50 | return [ 51 | 'id' => 'integer', 52 | 'name' => 'string', 53 | 'email' => 'string', 54 | 'email_verified_at' => 'datetime', 55 | 'password' => 'hashed', 56 | 'remember_token' => 'string', 57 | 'two_factor_secret' => 'string', 58 | 'two_factor_recovery_codes' => 'string', 59 | 'two_factor_confirmed_at' => 'datetime', 60 | 'created_at' => 'datetime', 61 | 'updated_at' => 'datetime', 62 | ]; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /resources/js/components/app-sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { NavFooter } from '@/components/nav-footer'; 2 | import { NavMain } from '@/components/nav-main'; 3 | import { NavUser } from '@/components/nav-user'; 4 | import { 5 | Sidebar, 6 | SidebarContent, 7 | SidebarFooter, 8 | SidebarHeader, 9 | SidebarMenu, 10 | SidebarMenuButton, 11 | SidebarMenuItem, 12 | } from '@/components/ui/sidebar'; 13 | import { dashboard } from '@/routes'; 14 | import { type NavItem } from '@/types'; 15 | import { Link } from '@inertiajs/react'; 16 | import { BookOpen, Folder, LayoutGrid } from 'lucide-react'; 17 | import AppLogo from './app-logo'; 18 | 19 | const mainNavItems: NavItem[] = [ 20 | { 21 | title: 'Dashboard', 22 | href: dashboard(), 23 | icon: LayoutGrid, 24 | }, 25 | ]; 26 | 27 | const footerNavItems: NavItem[] = [ 28 | { 29 | title: 'Repository', 30 | href: 'https://github.com/laravel/react-starter-kit', 31 | icon: Folder, 32 | }, 33 | { 34 | title: 'Documentation', 35 | href: 'https://laravel.com/docs/starter-kits#react', 36 | icon: BookOpen, 37 | }, 38 | ]; 39 | 40 | export function AppSidebar() { 41 | return ( 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /resources/js/pages/dashboard.tsx: -------------------------------------------------------------------------------- 1 | import { PlaceholderPattern } from '@/components/ui/placeholder-pattern'; 2 | import AppLayout from '@/layouts/app-layout'; 3 | import { dashboard } from '@/routes'; 4 | import { type BreadcrumbItem } from '@/types'; 5 | import { Head } from '@inertiajs/react'; 6 | 7 | const breadcrumbs: BreadcrumbItem[] = [ 8 | { 9 | title: 'Dashboard', 10 | href: dashboard().url, 11 | }, 12 | ]; 13 | 14 | export default function Dashboard() { 15 | return ( 16 | 17 | 18 |
19 |
20 |
21 | 22 |
23 |
24 | 25 |
26 |
27 | 28 |
29 |
30 |
31 | 32 |
33 |
34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /tests/Feature/Controllers/UserEmailVerificationNotificationControllerTest.php: -------------------------------------------------------------------------------- 1 | create([ 11 | 'email_verified_at' => null, 12 | ]); 13 | 14 | $response = $this->actingAs($user) 15 | ->fromRoute('home') 16 | ->get(route('verification.notice')); 17 | 18 | $response->assertOk() 19 | ->assertInertia(fn ($page) => $page 20 | ->component('user-email-verification-notification/create') 21 | ->has('status')); 22 | }); 23 | 24 | it('redirects verified users to dashboard', function (): void { 25 | $user = User::factory()->create([ 26 | 'email_verified_at' => now(), 27 | ]); 28 | 29 | $response = $this->actingAs($user) 30 | ->fromRoute('home') 31 | ->get(route('verification.notice')); 32 | 33 | $response->assertRedirectToRoute('dashboard'); 34 | }); 35 | 36 | it('may send verification notification', function (): void { 37 | Notification::fake(); 38 | 39 | $user = User::factory()->create([ 40 | 'email_verified_at' => null, 41 | ]); 42 | 43 | $response = $this->actingAs($user) 44 | ->fromRoute('verification.notice') 45 | ->post(route('verification.send')); 46 | 47 | $response->assertRedirectToRoute('verification.notice') 48 | ->assertSessionHas('status', 'verification-link-sent'); 49 | 50 | Notification::assertSentTo($user, VerifyEmail::class); 51 | }); 52 | 53 | it('redirects verified users when sending notification', function (): void { 54 | Notification::fake(); 55 | 56 | $user = User::factory()->create([ 57 | 'email_verified_at' => now(), 58 | ]); 59 | 60 | $response = $this->actingAs($user) 61 | ->fromRoute('verification.notice') 62 | ->post(route('verification.send')); 63 | 64 | $response->assertRedirectToRoute('dashboard'); 65 | 66 | Notification::assertNothingSent(); 67 | }); 68 | -------------------------------------------------------------------------------- /resources/js/pages/user-email-verification-notification/create.tsx: -------------------------------------------------------------------------------- 1 | // Components 2 | import UserEmailVerificationNotificationController from '@/actions/App/Http/Controllers/UserEmailVerificationNotificationController'; 3 | import { logout } from '@/routes'; 4 | import { Form, Head } from '@inertiajs/react'; 5 | import { LoaderCircle } from 'lucide-react'; 6 | 7 | import TextLink from '@/components/text-link'; 8 | import { Button } from '@/components/ui/button'; 9 | import AuthLayout from '@/layouts/auth-layout'; 10 | 11 | export default function VerifyEmail({ status }: { status?: string }) { 12 | return ( 13 | 17 | 18 | 19 | {status === 'verification-link-sent' && ( 20 |
21 | A new verification link has been sent to the email address 22 | you provided during registration. 23 |
24 | )} 25 | 26 |
30 | {({ processing }) => ( 31 | <> 32 | 38 | 39 | 43 | Log out 44 | 45 | 46 | )} 47 |
48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "laravel", 3 | "notPath": [ 4 | "tests/TestCase.php", 5 | "tmp" 6 | ], 7 | "rules": { 8 | "array_push": true, 9 | "backtick_to_shell_exec": true, 10 | "date_time_immutable": true, 11 | "declare_strict_types": true, 12 | "lowercase_keywords": true, 13 | "lowercase_static_reference": true, 14 | "final_class": true, 15 | "final_internal_class": true, 16 | "final_public_method_for_abstract_class": true, 17 | "fully_qualified_strict_types": true, 18 | "global_namespace_import": { 19 | "import_classes": true, 20 | "import_constants": true, 21 | "import_functions": true 22 | }, 23 | "mb_str_functions": true, 24 | "modernize_types_casting": true, 25 | "new_with_parentheses": false, 26 | "no_superfluous_elseif": true, 27 | "no_useless_else": true, 28 | "no_multiple_statements_per_line": true, 29 | "ordered_class_elements": { 30 | "order": [ 31 | "use_trait", 32 | "case", 33 | "constant", 34 | "constant_public", 35 | "constant_protected", 36 | "constant_private", 37 | "property_public", 38 | "property_protected", 39 | "property_private", 40 | "construct", 41 | "destruct", 42 | "magic", 43 | "phpunit", 44 | "method_abstract", 45 | "method_public_static", 46 | "method_public", 47 | "method_protected_static", 48 | "method_protected", 49 | "method_private_static", 50 | "method_private" 51 | ], 52 | "sort_algorithm": "none" 53 | }, 54 | "ordered_interfaces": true, 55 | "ordered_traits": true, 56 | "protected_to_private": true, 57 | "self_accessor": true, 58 | "self_static_accessor": true, 59 | "strict_comparison": true, 60 | "visibility_required": true 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /resources/js/components/ui/toggle-group.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group" 3 | import { type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | import { toggleVariants } from "@/components/ui/toggle" 7 | 8 | const ToggleGroupContext = React.createContext< 9 | VariantProps 10 | >({ 11 | size: "default", 12 | variant: "default", 13 | }) 14 | 15 | function ToggleGroup({ 16 | className, 17 | variant, 18 | size, 19 | children, 20 | ...props 21 | }: React.ComponentProps & 22 | VariantProps) { 23 | return ( 24 | 34 | 35 | {children} 36 | 37 | 38 | ) 39 | } 40 | 41 | function ToggleGroupItem({ 42 | className, 43 | children, 44 | variant, 45 | size, 46 | ...props 47 | }: React.ComponentProps & 48 | VariantProps) { 49 | const context = React.useContext(ToggleGroupContext) 50 | 51 | return ( 52 | 66 | {children} 67 | 68 | ) 69 | } 70 | 71 | export { ToggleGroup, ToggleGroupItem } 72 | -------------------------------------------------------------------------------- /resources/js/components/nav-user.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | DropdownMenu, 3 | DropdownMenuContent, 4 | DropdownMenuTrigger, 5 | } from '@/components/ui/dropdown-menu'; 6 | import { 7 | SidebarMenu, 8 | SidebarMenuButton, 9 | SidebarMenuItem, 10 | useSidebar, 11 | } from '@/components/ui/sidebar'; 12 | import { UserInfo } from '@/components/user-info'; 13 | import { UserMenuContent } from '@/components/user-menu-content'; 14 | import { useIsMobile } from '@/hooks/use-mobile'; 15 | import { type SharedData } from '@/types'; 16 | import { usePage } from '@inertiajs/react'; 17 | import { ChevronsUpDown } from 'lucide-react'; 18 | 19 | export function NavUser() { 20 | const { auth } = usePage().props; 21 | const { state } = useSidebar(); 22 | const isMobile = useIsMobile(); 23 | 24 | return ( 25 | 26 | 27 | 28 | 29 | 34 | 35 | 36 | 37 | 38 | 49 | 50 | 51 | 52 | 53 | 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /resources/js/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40", 16 | outline: 17 | "border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2 has-[>svg]:px-3", 25 | sm: "h-8 rounded-md px-3 has-[>svg]:px-2.5", 26 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4", 27 | icon: "size-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | function Button({ 38 | className, 39 | variant, 40 | size, 41 | asChild = false, 42 | ...props 43 | }: React.ComponentProps<"button"> & 44 | VariantProps & { 45 | asChild?: boolean 46 | }) { 47 | const Comp = asChild ? Slot : "button" 48 | 49 | return ( 50 | 55 | ) 56 | } 57 | 58 | export { Button, buttonVariants } 59 | -------------------------------------------------------------------------------- /resources/js/components/nav-footer.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from '@/components/icon'; 2 | import { 3 | SidebarGroup, 4 | SidebarGroupContent, 5 | SidebarMenu, 6 | SidebarMenuButton, 7 | SidebarMenuItem, 8 | } from '@/components/ui/sidebar'; 9 | import { type NavItem } from '@/types'; 10 | import { type ComponentPropsWithoutRef } from 'react'; 11 | 12 | export function NavFooter({ 13 | items, 14 | className, 15 | ...props 16 | }: ComponentPropsWithoutRef & { 17 | items: NavItem[]; 18 | }) { 19 | return ( 20 | 24 | 25 | 26 | {items.map((item) => ( 27 | 28 | 32 | 41 | {item.icon && ( 42 | 46 | )} 47 | {item.title} 48 | 49 | 50 | 51 | ))} 52 | 53 | 54 | 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /resources/js/components/user-menu-content.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | DropdownMenuGroup, 3 | DropdownMenuItem, 4 | DropdownMenuLabel, 5 | DropdownMenuSeparator, 6 | } from '@/components/ui/dropdown-menu'; 7 | import { UserInfo } from '@/components/user-info'; 8 | import { useMobileNavigation } from '@/hooks/use-mobile-navigation'; 9 | import { logout } from '@/routes'; 10 | import { edit } from '@/routes/user-profile'; 11 | import { type User } from '@/types'; 12 | import { Link, router } from '@inertiajs/react'; 13 | import { LogOut, Settings } from 'lucide-react'; 14 | 15 | interface UserMenuContentProps { 16 | user: User; 17 | } 18 | 19 | export function UserMenuContent({ user }: UserMenuContentProps) { 20 | const cleanup = useMobileNavigation(); 21 | 22 | const handleLogout = () => { 23 | cleanup(); 24 | router.flushAll(); 25 | }; 26 | 27 | return ( 28 | <> 29 | 30 |
31 | 32 |
33 |
34 | 35 | 36 | 37 | 44 | 45 | Settings 46 | 47 | 48 | 49 | 50 | 51 | 58 | 59 | Log out 60 | 61 | 62 | 63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /resources/js/pages/user-password-confirmation/create.tsx: -------------------------------------------------------------------------------- 1 | import InputError from '@/components/input-error'; 2 | import { Button } from '@/components/ui/button'; 3 | import { Input } from '@/components/ui/input'; 4 | import { Label } from '@/components/ui/label'; 5 | import AuthLayout from '@/layouts/auth-layout'; 6 | import { store } from '@/routes/password/confirm'; 7 | import { Form, Head } from '@inertiajs/react'; 8 | import { LoaderCircle } from 'lucide-react'; 9 | 10 | export default function Create() { 11 | return ( 12 | 16 | 17 | 18 |
19 | {({ processing, errors }) => ( 20 |
21 |
22 | 23 | 31 | 32 | 33 |
34 | 35 |
36 | 46 |
47 |
48 | )} 49 |
50 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /app/Http/Requests/CreateSessionRequest.php: -------------------------------------------------------------------------------- 1 | > 18 | */ 19 | public function rules(): array 20 | { 21 | return [ 22 | 'email' => ['required', 'string', 'email'], 23 | 'password' => ['required', 'string'], 24 | ]; 25 | } 26 | 27 | /** 28 | * @throws ValidationException 29 | */ 30 | public function validateCredentials(): User 31 | { 32 | $this->ensureIsNotRateLimited(); 33 | 34 | /** @var User|null $user */ 35 | $user = Auth::getProvider()->retrieveByCredentials($this->only('email', 'password')); 36 | 37 | if (! $user || ! Auth::getProvider()->validateCredentials($user, $this->only('password'))) { 38 | RateLimiter::hit($this->throttleKey()); 39 | 40 | throw ValidationException::withMessages([ 41 | 'email' => __('auth.failed'), 42 | ]); 43 | } 44 | 45 | RateLimiter::clear($this->throttleKey()); 46 | 47 | return $user; 48 | } 49 | 50 | /** 51 | * Get the rate-limiting throttle key for the request. 52 | */ 53 | public function throttleKey(): string 54 | { 55 | return $this->string('email') 56 | ->lower() 57 | ->append('|'.$this->ip()) 58 | ->transliterate() 59 | ->value(); 60 | } 61 | 62 | /** 63 | * @throws ValidationException 64 | */ 65 | private function ensureIsNotRateLimited(): void 66 | { 67 | if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) { 68 | return; 69 | } 70 | 71 | event(new Lockout($this)); 72 | 73 | $seconds = RateLimiter::availableIn($this->throttleKey()); 74 | 75 | throw ValidationException::withMessages([ 76 | 'email' => __('auth.throttle', [ 77 | 'seconds' => $seconds, 78 | 'minutes' => ceil($seconds / 60), 79 | ]), 80 | ]); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /resources/js/components/ui/input-otp.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { OTPInput, OTPInputContext } from "input-otp" 3 | import { Minus } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const InputOTP = React.forwardRef< 8 | React.ElementRef, 9 | React.ComponentPropsWithoutRef 10 | >(({ className, containerClassName, ...props }, ref) => ( 11 | 20 | )) 21 | InputOTP.displayName = "InputOTP" 22 | 23 | const InputOTPGroup = React.forwardRef< 24 | React.ElementRef<"div">, 25 | React.ComponentPropsWithoutRef<"div"> 26 | >(({ className, ...props }, ref) => ( 27 |
28 | )) 29 | InputOTPGroup.displayName = "InputOTPGroup" 30 | 31 | const InputOTPSlot = React.forwardRef< 32 | React.ElementRef<"div">, 33 | React.ComponentPropsWithoutRef<"div"> & { index: number } 34 | >(({ index, className, ...props }, ref) => { 35 | const inputOTPContext = React.useContext(OTPInputContext) 36 | const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index] 37 | 38 | return ( 39 |
48 | {char} 49 | {hasFakeCaret && ( 50 |
51 |
52 |
53 | )} 54 |
55 | ) 56 | }) 57 | InputOTPSlot.displayName = "InputOTPSlot" 58 | 59 | const InputOTPSeparator = React.forwardRef< 60 | React.ElementRef<"div">, 61 | React.ComponentPropsWithoutRef<"div"> 62 | >(({ ...props }, ref) => ( 63 |
64 | 65 |
66 | )) 67 | InputOTPSeparator.displayName = "InputOTPSeparator" 68 | 69 | export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator } 70 | -------------------------------------------------------------------------------- /tests/Feature/Controllers/UserEmailResetNotificationTest.php: -------------------------------------------------------------------------------- 1 | fromRoute('home') 11 | ->get(route('password.request')); 12 | 13 | $response->assertOk() 14 | ->assertInertia(fn ($page) => $page 15 | ->component('user-email-reset-notification/create') 16 | ->has('status')); 17 | }); 18 | 19 | it('may send password reset notification', function (): void { 20 | Notification::fake(); 21 | 22 | $user = User::factory()->create([ 23 | 'email' => 'test@example.com', 24 | ]); 25 | 26 | $response = $this->fromRoute('password.request') 27 | ->post(route('password.email'), [ 28 | 'email' => 'test@example.com', 29 | ]); 30 | 31 | $response->assertRedirectToRoute('password.request') 32 | ->assertSessionHas('status', 'A reset link will be sent if the account exists.'); 33 | 34 | Notification::assertSentTo($user, ResetPassword::class); 35 | }); 36 | 37 | it('returns generic message for non-existent email', function (): void { 38 | Notification::fake(); 39 | 40 | $response = $this->fromRoute('password.request') 41 | ->post(route('password.email'), [ 42 | 'email' => 'nonexistent@example.com', 43 | ]); 44 | 45 | $response->assertRedirectToRoute('password.request') 46 | ->assertSessionHas('status', 'A reset link will be sent if the account exists.'); 47 | 48 | Notification::assertNothingSent(); 49 | }); 50 | 51 | it('requires email', function (): void { 52 | $response = $this->fromRoute('password.request') 53 | ->post(route('password.email'), []); 54 | 55 | $response->assertRedirectToRoute('password.request') 56 | ->assertSessionHasErrors('email'); 57 | }); 58 | 59 | it('requires valid email format', function (): void { 60 | $response = $this->fromRoute('password.request') 61 | ->post(route('password.email'), [ 62 | 'email' => 'not-an-email', 63 | ]); 64 | 65 | $response->assertRedirectToRoute('password.request') 66 | ->assertSessionHasErrors('email'); 67 | }); 68 | 69 | it('redirects authenticated users away from forgot password', function (): void { 70 | $user = User::factory()->create(); 71 | 72 | $response = $this->actingAs($user) 73 | ->fromRoute('dashboard') 74 | ->get(route('password.request')); 75 | 76 | $response->assertRedirectToRoute('dashboard'); 77 | }); 78 | -------------------------------------------------------------------------------- /resources/js/hooks/use-appearance.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from 'react'; 2 | 3 | export type Appearance = 'light' | 'dark' | 'system'; 4 | 5 | const prefersDark = () => { 6 | if (typeof window === 'undefined') { 7 | return false; 8 | } 9 | 10 | return window.matchMedia('(prefers-color-scheme: dark)').matches; 11 | }; 12 | 13 | const setCookie = (name: string, value: string, days = 365) => { 14 | if (typeof document === 'undefined') { 15 | return; 16 | } 17 | 18 | const maxAge = days * 24 * 60 * 60; 19 | document.cookie = `${name}=${value};path=/;max-age=${maxAge};SameSite=Lax`; 20 | }; 21 | 22 | const applyTheme = (appearance: Appearance) => { 23 | const isDark = 24 | appearance === 'dark' || (appearance === 'system' && prefersDark()); 25 | 26 | document.documentElement.classList.toggle('dark', isDark); 27 | document.documentElement.style.colorScheme = isDark ? 'dark' : 'light'; 28 | }; 29 | 30 | const mediaQuery = () => { 31 | if (typeof window === 'undefined') { 32 | return null; 33 | } 34 | 35 | return window.matchMedia('(prefers-color-scheme: dark)'); 36 | }; 37 | 38 | const handleSystemThemeChange = () => { 39 | const currentAppearance = localStorage.getItem('appearance') as Appearance; 40 | applyTheme(currentAppearance || 'system'); 41 | }; 42 | 43 | export function initializeTheme() { 44 | const savedAppearance = 45 | (localStorage.getItem('appearance') as Appearance) || 'system'; 46 | 47 | applyTheme(savedAppearance); 48 | 49 | // Add the event listener for system theme changes... 50 | mediaQuery()?.addEventListener('change', handleSystemThemeChange); 51 | } 52 | 53 | export function useAppearance() { 54 | const [appearance, setAppearance] = useState('system'); 55 | 56 | const updateAppearance = useCallback((mode: Appearance) => { 57 | setAppearance(mode); 58 | 59 | // Store in localStorage for client-side persistence... 60 | localStorage.setItem('appearance', mode); 61 | 62 | // Store in cookie for SSR... 63 | setCookie('appearance', mode); 64 | 65 | applyTheme(mode); 66 | }, []); 67 | 68 | useEffect(() => { 69 | const savedAppearance = localStorage.getItem( 70 | 'appearance', 71 | ) as Appearance | null; 72 | updateAppearance(savedAppearance || 'system'); 73 | 74 | return () => 75 | mediaQuery()?.removeEventListener( 76 | 'change', 77 | handleSystemThemeChange, 78 | ); 79 | }, [updateAppearance]); 80 | 81 | return { appearance, updateAppearance } as const; 82 | } 83 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "type": "module", 4 | "scripts": { 5 | "build": "vite build", 6 | "build:ssr": "vite build && vite build --ssr", 7 | "dev": "vite", 8 | "lint": "eslint . --fix && prettier --write resources/", 9 | "test:lint": "eslint . && prettier --check resources/", 10 | "test:types": "tsc --noEmit" 11 | }, 12 | "devDependencies": { 13 | "@eslint/js": "^9.37.0", 14 | "@laravel/vite-plugin-wayfinder": "^0.1.7", 15 | "@types/node": "^24.7.1", 16 | "eslint": "^9.37.0", 17 | "eslint-config-prettier": "^10.1.8", 18 | "eslint-plugin-react": "^7.37.5", 19 | "eslint-plugin-react-hooks": "^7.0.0", 20 | "npm-check-updates": "^19.0.0", 21 | "playwright": "^1.56.0", 22 | "prettier": "^3.6.2", 23 | "prettier-plugin-organize-imports": "^4.3.0", 24 | "prettier-plugin-tailwindcss": "^0.6.14", 25 | "typescript-eslint": "^8.46.0" 26 | }, 27 | "dependencies": { 28 | "@headlessui/react": "^2.2.9", 29 | "@inertiajs/react": "^2.2.8", 30 | "@radix-ui/react-avatar": "^1.1.10", 31 | "@radix-ui/react-checkbox": "^1.3.3", 32 | "@radix-ui/react-collapsible": "^1.1.12", 33 | "@radix-ui/react-dialog": "^1.1.15", 34 | "@radix-ui/react-dropdown-menu": "^2.1.16", 35 | "@radix-ui/react-label": "^2.1.7", 36 | "@radix-ui/react-navigation-menu": "^1.2.14", 37 | "@radix-ui/react-select": "^2.2.6", 38 | "@radix-ui/react-separator": "^1.1.7", 39 | "@radix-ui/react-slot": "^1.2.3", 40 | "@radix-ui/react-toggle": "^1.1.10", 41 | "@radix-ui/react-toggle-group": "^1.1.11", 42 | "@radix-ui/react-tooltip": "^1.2.8", 43 | "@tailwindcss/vite": "^4.1.14", 44 | "@types/react": "^19.2.2", 45 | "@types/react-dom": "^19.2.1", 46 | "@vitejs/plugin-react": "^5.0.4", 47 | "class-variance-authority": "^0.7.1", 48 | "clsx": "^2.1.1", 49 | "concurrently": "^9.2.1", 50 | "globals": "^16.4.0", 51 | "input-otp": "^1.4.2", 52 | "laravel-vite-plugin": "^2.0", 53 | "lucide-react": "^0.545.0", 54 | "react": "^19.2.0", 55 | "react-dom": "^19.2.0", 56 | "tailwind-merge": "^3.3.1", 57 | "tailwindcss": "^4.1.14", 58 | "tailwindcss-animate": "^1.0.7", 59 | "typescript": "^5.9.3", 60 | "vite": "^7.1.9" 61 | }, 62 | "optionalDependencies": { 63 | "@rollup/rollup-linux-x64-gnu": "4.52.4", 64 | "@tailwindcss/oxide-linux-x64-gnu": "^4.1.14", 65 | "lightningcss-linux-x64-gnu": "^1.30.2" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /resources/js/components/appearance-dropdown.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@/components/ui/button'; 2 | import { 3 | DropdownMenu, 4 | DropdownMenuContent, 5 | DropdownMenuItem, 6 | DropdownMenuTrigger, 7 | } from '@/components/ui/dropdown-menu'; 8 | import { useAppearance } from '@/hooks/use-appearance'; 9 | import { Monitor, Moon, Sun } from 'lucide-react'; 10 | import { HTMLAttributes } from 'react'; 11 | 12 | export default function AppearanceToggleDropdown({ 13 | className = '', 14 | ...props 15 | }: HTMLAttributes) { 16 | const { appearance, updateAppearance } = useAppearance(); 17 | 18 | const getCurrentIcon = () => { 19 | switch (appearance) { 20 | case 'dark': 21 | return ; 22 | case 'light': 23 | return ; 24 | default: 25 | return ; 26 | } 27 | }; 28 | 29 | return ( 30 |
31 | 32 | 33 | 41 | 42 | 43 | updateAppearance('light')}> 44 | 45 | 46 | Light 47 | 48 | 49 | updateAppearance('dark')}> 50 | 51 | 52 | Dark 53 | 54 | 55 | updateAppearance('system')} 57 | > 58 | 59 | 60 | System 61 | 62 | 63 | 64 | 65 |
66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | ci: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | 19 | - name: Setup PHP 20 | uses: shivammathur/setup-php@v2 21 | with: 22 | php-version: 8.4 23 | tools: composer:v2 24 | coverage: xdebug 25 | 26 | - name: Setup Node 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version: '22' 30 | 31 | - name: Install Dependencies 32 | run: composer install --no-interaction --prefer-dist --optimize-autoloader 33 | 34 | - name: Copy Environment File 35 | run: cp .env.example .env 36 | 37 | - name: Generate Application Key 38 | run: php artisan key:generate 39 | 40 | - name: Install Node Dependencies 41 | run: npm install 42 | 43 | - name: Build Assets 44 | run: npm run build 45 | 46 | - name: Get Playwright version 47 | id: playwright-version 48 | run: echo "version=$(npm list @playwright/test --depth=0 --json | jq -r '.dependencies["@playwright/test"].version')" >> $GITHUB_OUTPUT 49 | 50 | - name: Cache Playwright Browsers 51 | uses: actions/cache@v4 52 | id: playwright-cache 53 | with: 54 | path: ~/.cache/ms-playwright 55 | key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }} 56 | 57 | - name: Install Playwright Browsers 58 | if: steps.playwright-cache.outputs.cache-hit != 'true' 59 | run: npx playwright install --with-deps 60 | 61 | - name: Install Playwright System Dependencies 62 | if: steps.playwright-cache.outputs.cache-hit == 'true' 63 | run: npx playwright install-deps 64 | - name: Rector Cache 65 | uses: actions/cache@v4 66 | with: 67 | path: /tmp/rector 68 | key: ${{ runner.os }}-rector-${{ hashFiles('composer.lock') }} 69 | restore-keys: ${{ runner.os }}-rector- 70 | - run: mkdir -p /tmp/rector 71 | 72 | - name: PHPStan Cache 73 | uses: actions/cache@v4 74 | with: 75 | path: /tmp/phpstan 76 | key: ${{ runner.os }}-phpstan-${{ hashFiles('composer.lock') }} 77 | restore-keys: ${{ runner.os }}-phpstan- 78 | - run: mkdir -p /tmp/phpstan 79 | 80 | - name: Tests 81 | run: composer test 82 | 83 | - name: Store artifacts 84 | uses: actions/upload-artifact@v4 85 | with: 86 | name: browser-screenshots 87 | path: | 88 | tests/Browser/Screenshots 89 | -------------------------------------------------------------------------------- /tests/Unit/Actions/CreateUserPasswordTest.php: -------------------------------------------------------------------------------- 1 | create([ 16 | 'email' => 'test@example.com', 17 | ]); 18 | 19 | $token = Password::createToken($user); 20 | 21 | $action = app(CreateUserPassword::class); 22 | 23 | $status = $action->handle([ 24 | 'email' => $user->email, 25 | 'token' => $token, 26 | 'password' => 'new-password', 27 | 'password_confirmation' => 'new-password', 28 | ], 'new-password'); 29 | 30 | expect($status)->toBe(Password::PASSWORD_RESET) 31 | ->and(Hash::check('new-password', $user->refresh()->password))->toBeTrue(); 32 | 33 | Event::assertDispatched(PasswordReset::class); 34 | }); 35 | 36 | it('returns invalid token status for incorrect token', function (): void { 37 | $user = User::factory()->create([ 38 | 'email' => 'test@example.com', 39 | ]); 40 | 41 | $action = app(CreateUserPassword::class); 42 | 43 | $status = $action->handle([ 44 | 'email' => $user->email, 45 | 'token' => 'invalid-token', 46 | 'password' => 'new-password', 47 | 'password_confirmation' => 'new-password', 48 | ], 'new-password'); 49 | 50 | expect($status)->toBe(Password::INVALID_TOKEN); 51 | }); 52 | 53 | it('returns invalid user status for non-existent email', function (): void { 54 | $action = app(CreateUserPassword::class); 55 | 56 | $status = $action->handle([ 57 | 'email' => 'nonexistent@example.com', 58 | 'token' => 'some-token', 59 | 'password' => 'new-password', 60 | 'password_confirmation' => 'new-password', 61 | ], 'new-password'); 62 | 63 | expect($status)->toBe(Password::INVALID_USER); 64 | }); 65 | 66 | it('updates remember token when resetting password', function (): void { 67 | $user = User::factory()->create([ 68 | 'email' => 'test@example.com', 69 | 'remember_token' => 'old-token', 70 | ]); 71 | 72 | $token = Password::createToken($user); 73 | 74 | $action = app(CreateUserPassword::class); 75 | 76 | $action->handle([ 77 | 'email' => $user->email, 78 | 'token' => $token, 79 | 'password' => 'new-password', 80 | 'password_confirmation' => 'new-password', 81 | ], 'new-password'); 82 | 83 | expect($user->refresh()->remember_token)->not->toBe('old-token') 84 | ->and($user->remember_token)->not->toBeNull(); 85 | }); 86 | -------------------------------------------------------------------------------- /resources/js/layouts/auth/auth-split-layout.tsx: -------------------------------------------------------------------------------- 1 | import AppLogoIcon from '@/components/app-logo-icon'; 2 | import { home } from '@/routes'; 3 | import { type SharedData } from '@/types'; 4 | import { Link, usePage } from '@inertiajs/react'; 5 | import { type PropsWithChildren } from 'react'; 6 | 7 | interface AuthLayoutProps { 8 | title?: string; 9 | description?: string; 10 | } 11 | 12 | export default function AuthSplitLayout({ 13 | children, 14 | title, 15 | description, 16 | }: PropsWithChildren) { 17 | const { name, quote } = usePage().props; 18 | 19 | return ( 20 |
21 |
22 |
23 | 27 | 28 | {name} 29 | 30 | {quote && ( 31 |
32 |
33 |

34 | “{quote.message}” 35 |

36 |
37 | {quote.author} 38 |
39 |
40 |
41 | )} 42 |
43 |
44 |
45 | 49 | 50 | 51 |
52 |

{title}

53 |

54 | {description} 55 |

56 |
57 | {children} 58 |
59 |
60 |
61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /config/filesystems.php: -------------------------------------------------------------------------------- 1 | env('FILESYSTEM_DISK', 'local'), 19 | 20 | /* 21 | |-------------------------------------------------------------------------- 22 | | Filesystem Disks 23 | |-------------------------------------------------------------------------- 24 | | 25 | | Below you may configure as many filesystem disks as necessary, and you 26 | | may even configure multiple disks for the same driver. Examples for 27 | | most supported storage drivers are configured here for reference. 28 | | 29 | | Supported drivers: "local", "ftp", "sftp", "s3" 30 | | 31 | */ 32 | 33 | 'disks' => [ 34 | 35 | 'local' => [ 36 | 'driver' => 'local', 37 | 'root' => storage_path('app/private'), 38 | 'serve' => true, 39 | 'throw' => false, 40 | 'report' => false, 41 | ], 42 | 43 | 'public' => [ 44 | 'driver' => 'local', 45 | 'root' => storage_path('app/public'), 46 | 'url' => env('APP_URL').'/storage', 47 | 'visibility' => 'public', 48 | 'throw' => false, 49 | 'report' => false, 50 | ], 51 | 52 | 's3' => [ 53 | 'driver' => 's3', 54 | 'key' => env('AWS_ACCESS_KEY_ID'), 55 | 'secret' => env('AWS_SECRET_ACCESS_KEY'), 56 | 'region' => env('AWS_DEFAULT_REGION'), 57 | 'bucket' => env('AWS_BUCKET'), 58 | 'url' => env('AWS_URL'), 59 | 'endpoint' => env('AWS_ENDPOINT'), 60 | 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), 61 | 'throw' => false, 62 | 'report' => false, 63 | ], 64 | 65 | ], 66 | 67 | /* 68 | |-------------------------------------------------------------------------- 69 | | Symbolic Links 70 | |-------------------------------------------------------------------------- 71 | | 72 | | Here you may configure the symbolic links that will be created when the 73 | | `storage:link` Artisan command is executed. The array keys should be 74 | | the locations of the links and the values should be their targets. 75 | | 76 | */ 77 | 78 | 'links' => [ 79 | public_path('storage') => storage_path('app/public'), 80 | ], 81 | 82 | ]; 83 | -------------------------------------------------------------------------------- /resources/js/components/ui/breadcrumb.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { ChevronRight, MoreHorizontal } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { 8 | return