├── 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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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
9 | }
10 |
11 | function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
12 | return (
13 |
21 | )
22 | }
23 |
24 | function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
25 | return (
26 |
31 | )
32 | }
33 |
34 | function BreadcrumbLink({
35 | asChild,
36 | className,
37 | ...props
38 | }: React.ComponentProps<"a"> & {
39 | asChild?: boolean
40 | }) {
41 | const Comp = asChild ? Slot : "a"
42 |
43 | return (
44 |
49 | )
50 | }
51 |
52 | function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
53 | return (
54 |
62 | )
63 | }
64 |
65 | function BreadcrumbSeparator({
66 | children,
67 | className,
68 | ...props
69 | }: React.ComponentProps<"li">) {
70 | return (
71 | svg]:size-3.5", className)}
76 | {...props}
77 | >
78 | {children ?? }
79 |
80 | )
81 | }
82 |
83 | function BreadcrumbEllipsis({
84 | className,
85 | ...props
86 | }: React.ComponentProps<"span">) {
87 | return (
88 |
95 |
96 | More
97 |
98 | )
99 | }
100 |
101 | export {
102 | Breadcrumb,
103 | BreadcrumbList,
104 | BreadcrumbItem,
105 | BreadcrumbLink,
106 | BreadcrumbPage,
107 | BreadcrumbSeparator,
108 | BreadcrumbEllipsis,
109 | }
110 |
--------------------------------------------------------------------------------
/resources/js/pages/user-email-reset-notification/create.tsx:
--------------------------------------------------------------------------------
1 | // Components
2 | import UserEmailResetNotification from '@/actions/App/Http/Controllers/UserEmailResetNotification';
3 | import { login } from '@/routes';
4 | import { Form, Head } from '@inertiajs/react';
5 | import { LoaderCircle } from 'lucide-react';
6 |
7 | import InputError from '@/components/input-error';
8 | import TextLink from '@/components/text-link';
9 | import { Button } from '@/components/ui/button';
10 | import { Input } from '@/components/ui/input';
11 | import { Label } from '@/components/ui/label';
12 | import AuthLayout from '@/layouts/auth-layout';
13 |
14 | export default function ForgotPassword({ status }: { status?: string }) {
15 | return (
16 |
20 |
21 |
22 | {status && (
23 |
24 | {status}
25 |
26 | )}
27 |
28 |
29 |
61 |
62 |
63 | Or, return to
64 | log in
65 |
66 |
67 |
68 | );
69 | }
70 |
--------------------------------------------------------------------------------
/resources/js/layouts/settings/layout.tsx:
--------------------------------------------------------------------------------
1 | import Heading from '@/components/heading';
2 | import { Button } from '@/components/ui/button';
3 | import { Separator } from '@/components/ui/separator';
4 | import { cn } from '@/lib/utils';
5 | import { edit as editAppearance } from '@/routes/appearance';
6 | import { edit as editPassword } from '@/routes/password';
7 | import { show } from '@/routes/two-factor';
8 | import { edit } from '@/routes/user-profile';
9 | import { type NavItem } from '@/types';
10 | import { Link } from '@inertiajs/react';
11 | import { type PropsWithChildren } from 'react';
12 |
13 | const sidebarNavItems: NavItem[] = [
14 | {
15 | title: 'Profile',
16 | href: edit(),
17 | icon: null,
18 | },
19 | {
20 | title: 'Password',
21 | href: editPassword(),
22 | icon: null,
23 | },
24 | {
25 | title: 'Two-Factor Auth',
26 | href: show(),
27 | icon: null,
28 | },
29 | {
30 | title: 'Appearance',
31 | href: editAppearance(),
32 | icon: null,
33 | },
34 | ];
35 |
36 | export default function SettingsLayout({ children }: PropsWithChildren) {
37 | // When server-side rendering, we only render the layout on the client...
38 | if (typeof window === 'undefined') {
39 | return null;
40 | }
41 |
42 | const currentPath = window.location.pathname;
43 |
44 | return (
45 |
46 |
50 |
51 |
52 |
78 |
79 |
80 |
81 |
82 |
85 |
86 |
87 |
88 | );
89 | }
90 |
--------------------------------------------------------------------------------
/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/resources/js/hooks/use-two-factor-auth.ts:
--------------------------------------------------------------------------------
1 | import { qrCode, recoveryCodes, secretKey } from '@/routes/two-factor';
2 | import { useCallback, useMemo, useState } from 'react';
3 |
4 | interface TwoFactorSetupData {
5 | svg: string;
6 | url: string;
7 | }
8 |
9 | interface TwoFactorSecretKey {
10 | secretKey: string;
11 | }
12 |
13 | export const OTP_MAX_LENGTH = 6;
14 |
15 | const fetchJson = async (url: string): Promise => {
16 | const response = await fetch(url, {
17 | headers: { Accept: 'application/json' },
18 | });
19 |
20 | if (!response.ok) {
21 | throw new Error(`Failed to fetch: ${response.status}`);
22 | }
23 |
24 | return response.json();
25 | };
26 |
27 | export const useTwoFactorAuth = () => {
28 | const [qrCodeSvg, setQrCodeSvg] = useState(null);
29 | const [manualSetupKey, setManualSetupKey] = useState(null);
30 | const [recoveryCodesList, setRecoveryCodesList] = useState([]);
31 | const [errors, setErrors] = useState([]);
32 |
33 | const hasSetupData = useMemo(
34 | () => qrCodeSvg !== null && manualSetupKey !== null,
35 | [qrCodeSvg, manualSetupKey],
36 | );
37 |
38 | const fetchQrCode = useCallback(async (): Promise => {
39 | try {
40 | const { svg } = await fetchJson(qrCode.url());
41 | setQrCodeSvg(svg);
42 | } catch {
43 | setErrors((prev) => [...prev, 'Failed to fetch QR code']);
44 | setQrCodeSvg(null);
45 | }
46 | }, []);
47 |
48 | const fetchSetupKey = useCallback(async (): Promise => {
49 | try {
50 | const { secretKey: key } = await fetchJson(
51 | secretKey.url(),
52 | );
53 | setManualSetupKey(key);
54 | } catch {
55 | setErrors((prev) => [...prev, 'Failed to fetch a setup key']);
56 | setManualSetupKey(null);
57 | }
58 | }, []);
59 |
60 | const clearErrors = useCallback((): void => {
61 | setErrors([]);
62 | }, []);
63 |
64 | const clearSetupData = useCallback((): void => {
65 | setManualSetupKey(null);
66 | setQrCodeSvg(null);
67 | clearErrors();
68 | }, [clearErrors]);
69 |
70 | const fetchRecoveryCodes = useCallback(async (): Promise => {
71 | try {
72 | clearErrors();
73 | const codes = await fetchJson(recoveryCodes.url());
74 | setRecoveryCodesList(codes);
75 | } catch {
76 | setErrors((prev) => [...prev, 'Failed to fetch recovery codes']);
77 | setRecoveryCodesList([]);
78 | }
79 | }, [clearErrors]);
80 |
81 | const fetchSetupData = useCallback(async (): Promise => {
82 | try {
83 | clearErrors();
84 | await Promise.all([fetchQrCode(), fetchSetupKey()]);
85 | } catch {
86 | setQrCodeSvg(null);
87 | setManualSetupKey(null);
88 | }
89 | }, [clearErrors, fetchQrCode, fetchSetupKey]);
90 |
91 | return {
92 | qrCodeSvg,
93 | manualSetupKey,
94 | recoveryCodesList,
95 | hasSetupData,
96 | errors,
97 | clearErrors,
98 | clearSetupData,
99 | fetchQrCode,
100 | fetchSetupKey,
101 | fetchSetupData,
102 | fetchRecoveryCodes,
103 | };
104 | };
105 |
--------------------------------------------------------------------------------
/tests/Unit/Rules/ValidEmailTest.php:
--------------------------------------------------------------------------------
1 | validate('email', $email, function () use (&$failed): void {
13 | $failed = true;
14 | });
15 |
16 | expect($failed)->toBeFalse();
17 | })->with([
18 | // Standard Valid Emails
19 | 'simple@example.com',
20 | 'very.common@example.com',
21 | 'disposable.style.email.with+symbol@example.com',
22 | 'other.email-with-hyphen@example.com',
23 | 'x@example.com',
24 | 'example-indeed@strange-example.com',
25 | 'admin@mailserver1.com',
26 | 'user.name+tag+sorting@example.com',
27 | 'user.name@sub.domain.com',
28 | 'firstname-lastname@example.com',
29 |
30 | // Emails with Numbers
31 | '1234567890@example.com',
32 | 'user.123@example.com',
33 | 'user123@example.com',
34 | '9876543210@example.net',
35 | 'test456@domain123.com',
36 |
37 | // Emails with Long Local Parts
38 | 'a.very.long.email.address.but.valid@example.com',
39 | 'another.really.long.email.address@example.co.uk',
40 | 'longlocalpart123456789012345678901234567890@example.com',
41 | 'superlongemailaddresswith123456789@example.org',
42 | 'excessive-length-testing-allowed@example.com',
43 |
44 | // Emails with Special Characters
45 | 'user@ex-ample.com',
46 |
47 | // Emails with Subdomains
48 | 'user@mail.example.com',
49 | 'contact@support.company.com',
50 | 'info@help.docs.example.com',
51 | 'customer.service@global.enterprise.org',
52 | 'feedback@eu.store.example.net',
53 |
54 | // Emails with Newer TLDs
55 | 'user@company.app',
56 | 'support@business.dev',
57 | 'test@something.xyz',
58 | 'email@custom.tld',
59 | 'person@organization.online',
60 |
61 | // Emails with Uncommon TLDs
62 | 'user@domain.museum',
63 | 'info@charity.foundation',
64 | 'admin@website.travel',
65 | 'sales@company.agency',
66 | 'team@startup.tech',
67 | ]);
68 |
69 | it('fails with invalid email', function (string $email): void {
70 | $rule = new ValidEmail;
71 |
72 | $failed = false;
73 |
74 | $rule->validate('email', $email, function () use (&$failed): void {
75 | $failed = true;
76 | });
77 |
78 | expect($failed)->toBeTrue();
79 | })->with([
80 | // Only Lowercase:
81 | 'R@r.com',
82 | 'r@R.com',
83 |
84 | // Empty on any part
85 | '@example.com',
86 | 'user@',
87 | 'user@.com',
88 | 'user@.example',
89 | 'user@.example.com',
90 | 'user@sub..example.com',
91 | 'user',
92 | '',
93 |
94 | // IP Addresses
95 | 'user@123.123.123.123',
96 | 'user@[192.168.1.1]',
97 | 'user@[IPv6:2001:db8::1]',
98 |
99 | // Quotes
100 | '"user@with-quotes"@example.com',
101 | "'user@with-quotes'@example.com",
102 | '"very.unusual.@.email"@example.com',
103 | '"quoted.local@part"@example.com',
104 | '"user name"@example.com',
105 |
106 | // International & Unicode Emails
107 | 'üñîçødé@example.com',
108 | 'δοκιμή@παράδειγμα.ελ',
109 | '测试@测试.中国',
110 | 'пример@пример.рус',
111 | 'उपयोगकर्ता@उदाहरण.भारत',
112 |
113 | // Edge Case Emails
114 | 'mat@me',
115 | 'user@localserver',
116 | 'user@localdomain',
117 | 'user@sub.-domain.com',
118 | '𝓊𝓃𝒾𝒸ℴ𝒹ℯ@𝒹ℴ𝓂𝒶𝒾𝓃.𝒸ℴ𝓂',
119 | ]);
120 |
--------------------------------------------------------------------------------
/tests/Unit/Middleware/HandleInertiaRequestsTest.php:
--------------------------------------------------------------------------------
1 | share($request);
15 |
16 | expect($shared)->toHaveKey('name')
17 | ->and($shared['name'])->toBe(config('app.name'));
18 | });
19 |
20 | it('shares inspiring quote with message and author', function (): void {
21 | $middleware = new HandleInertiaRequests();
22 |
23 | $request = Request::create('/', 'GET');
24 |
25 | $shared = $middleware->share($request);
26 |
27 | expect($shared)->toHaveKey('quote')
28 | ->and($shared['quote'])->toHaveKeys(['message', 'author'])
29 | ->and($shared['quote']['message'])->toBeString()->not->toBeEmpty()
30 | ->and($shared['quote']['author'])->toBeString()->not->toBeEmpty();
31 | });
32 |
33 | it('shares null user when guest', function (): void {
34 | $middleware = new HandleInertiaRequests();
35 |
36 | $request = Request::create('/', 'GET');
37 |
38 | $shared = $middleware->share($request);
39 |
40 | expect($shared)->toHaveKey('auth')
41 | ->and($shared['auth'])->toHaveKey('user')
42 | ->and($shared['auth']['user'])->toBeNull();
43 | });
44 |
45 | it('shares authenticated user data', function (): void {
46 | $user = User::factory()->create([
47 | 'name' => 'Test User',
48 | 'email' => 'test@example.com',
49 | ]);
50 |
51 | $middleware = new HandleInertiaRequests();
52 |
53 | $request = Request::create('/', 'GET');
54 | $request->setUserResolver(fn () => $user);
55 |
56 | $shared = $middleware->share($request);
57 |
58 | expect($shared['auth']['user'])->not->toBeNull()
59 | ->and($shared['auth']['user']->id)->toBe($user->id)
60 | ->and($shared['auth']['user']->name)->toBe('Test User')
61 | ->and($shared['auth']['user']->email)->toBe('test@example.com');
62 | });
63 |
64 | it('defaults sidebarOpen to true when no cookie', function (): void {
65 | $middleware = new HandleInertiaRequests();
66 |
67 | $request = Request::create('/', 'GET');
68 |
69 | $shared = $middleware->share($request);
70 |
71 | expect($shared)->toHaveKey('sidebarOpen')
72 | ->and($shared['sidebarOpen'])->toBeTrue();
73 | });
74 |
75 | it('sets sidebarOpen to true when cookie is true', function (): void {
76 | $middleware = new HandleInertiaRequests();
77 |
78 | $request = Request::create('/', 'GET');
79 | $request->cookies->set('sidebar_state', 'true');
80 |
81 | $shared = $middleware->share($request);
82 |
83 | expect($shared['sidebarOpen'])->toBeTrue();
84 | });
85 |
86 | it('sets sidebarOpen to false when cookie is false', function (): void {
87 | $middleware = new HandleInertiaRequests();
88 |
89 | $request = Request::create('/', 'GET');
90 | $request->cookies->set('sidebar_state', 'false');
91 |
92 | $shared = $middleware->share($request);
93 |
94 | expect($shared['sidebarOpen'])->toBeFalse();
95 | });
96 |
97 | it('includes parent shared data', function (): void {
98 | $middleware = new HandleInertiaRequests();
99 |
100 | $request = Request::create('/', 'GET');
101 |
102 | $shared = $middleware->share($request);
103 |
104 | // Parent Inertia middleware shares 'errors' by default
105 | expect($shared)->toHaveKey('errors');
106 | });
107 |
--------------------------------------------------------------------------------
/routes/web.php:
--------------------------------------------------------------------------------
1 | Inertia::render('welcome'))->name('home');
17 |
18 | Route::middleware(['auth', 'verified'])->group(function (): void {
19 | Route::get('dashboard', fn () => Inertia::render('dashboard'))->name('dashboard');
20 | });
21 |
22 | Route::middleware('auth')->group(function (): void {
23 | // User...
24 | Route::delete('user', [UserController::class, 'destroy'])->name('user.destroy');
25 |
26 | // User Profile...
27 | Route::redirect('settings', '/settings/profile');
28 | Route::get('settings/profile', [UserProfileController::class, 'edit'])->name('user-profile.edit');
29 | Route::patch('settings/profile', [UserProfileController::class, 'update'])->name('user-profile.update');
30 |
31 | // User Password...
32 | Route::get('settings/password', [UserPasswordController::class, 'edit'])->name('password.edit');
33 | Route::put('settings/password', [UserPasswordController::class, 'update'])
34 | ->middleware('throttle:6,1')
35 | ->name('password.update');
36 |
37 | // Appearance...
38 | Route::get('settings/appearance', fn () => Inertia::render('appearance/update'))->name('appearance.edit');
39 |
40 | // User Two-Factor Authentication...
41 | Route::get('settings/two-factor', [UserTwoFactorAuthenticationController::class, 'show'])
42 | ->name('two-factor.show');
43 | });
44 |
45 | Route::middleware('guest')->group(function (): void {
46 | // User...
47 | Route::get('register', [UserController::class, 'create'])
48 | ->name('register');
49 | Route::post('register', [UserController::class, 'store'])
50 | ->name('register.store');
51 |
52 | // User Password...
53 | Route::get('reset-password/{token}', [UserPasswordController::class, 'create'])
54 | ->name('password.reset');
55 | Route::post('reset-password', [UserPasswordController::class, 'store'])
56 | ->name('password.store');
57 |
58 | // User Email Reset Notification...
59 | Route::get('forgot-password', [UserEmailResetNotification::class, 'create'])
60 | ->name('password.request');
61 | Route::post('forgot-password', [UserEmailResetNotification::class, 'store'])
62 | ->name('password.email');
63 |
64 | // Session...
65 | Route::get('login', [SessionController::class, 'create'])
66 | ->name('login');
67 | Route::post('login', [SessionController::class, 'store'])
68 | ->name('login.store');
69 | });
70 |
71 | Route::middleware('auth')->group(function (): void {
72 | // User Email Verification...
73 | Route::get('verify-email', [UserEmailVerificationNotificationController::class, 'create'])
74 | ->name('verification.notice');
75 | Route::post('email/verification-notification', [UserEmailVerificationNotificationController::class, 'store'])
76 | ->middleware('throttle:6,1')
77 | ->name('verification.send');
78 |
79 | // User Email Verification...
80 | Route::get('verify-email/{id}/{hash}', [UserEmailVerification::class, 'update'])
81 | ->middleware(['signed', 'throttle:6,1'])
82 | ->name('verification.verify');
83 |
84 | // Session...
85 | Route::post('logout', [SessionController::class, 'destroy'])
86 | ->name('logout');
87 | });
88 |
--------------------------------------------------------------------------------
/config/cache.php:
--------------------------------------------------------------------------------
1 | env('CACHE_STORE', 'database'),
21 |
22 | /*
23 | |--------------------------------------------------------------------------
24 | | Cache Stores
25 | |--------------------------------------------------------------------------
26 | |
27 | | Here you may define all of the cache "stores" for your application as
28 | | well as their drivers. You may even define multiple stores for the
29 | | same cache driver to group types of items stored in your caches.
30 | |
31 | | Supported drivers: "array", "database", "file", "memcached",
32 | | "redis", "dynamodb", "octane", "null"
33 | |
34 | */
35 |
36 | 'stores' => [
37 |
38 | 'array' => [
39 | 'driver' => 'array',
40 | 'serialize' => false,
41 | ],
42 |
43 | 'database' => [
44 | 'driver' => 'database',
45 | 'connection' => env('DB_CACHE_CONNECTION'),
46 | 'table' => env('DB_CACHE_TABLE', 'cache'),
47 | 'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
48 | 'lock_table' => env('DB_CACHE_LOCK_TABLE'),
49 | ],
50 |
51 | 'file' => [
52 | 'driver' => 'file',
53 | 'path' => storage_path('framework/cache/data'),
54 | 'lock_path' => storage_path('framework/cache/data'),
55 | ],
56 |
57 | 'memcached' => [
58 | 'driver' => 'memcached',
59 | 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
60 | 'sasl' => [
61 | env('MEMCACHED_USERNAME'),
62 | env('MEMCACHED_PASSWORD'),
63 | ],
64 | 'options' => [
65 | // Memcached::OPT_CONNECT_TIMEOUT => 2000,
66 | ],
67 | 'servers' => [
68 | [
69 | 'host' => env('MEMCACHED_HOST', '127.0.0.1'),
70 | 'port' => env('MEMCACHED_PORT', 11211),
71 | 'weight' => 100,
72 | ],
73 | ],
74 | ],
75 |
76 | 'redis' => [
77 | 'driver' => 'redis',
78 | 'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
79 | 'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
80 | ],
81 |
82 | 'dynamodb' => [
83 | 'driver' => 'dynamodb',
84 | 'key' => env('AWS_ACCESS_KEY_ID'),
85 | 'secret' => env('AWS_SECRET_ACCESS_KEY'),
86 | 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
87 | 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
88 | 'endpoint' => env('DYNAMODB_ENDPOINT'),
89 | ],
90 |
91 | 'octane' => [
92 | 'driver' => 'octane',
93 | ],
94 |
95 | ],
96 |
97 | /*
98 | |--------------------------------------------------------------------------
99 | | Cache Key Prefix
100 | |--------------------------------------------------------------------------
101 | |
102 | | When utilizing the APC, database, memcached, Redis, and DynamoDB cache
103 | | stores, there might be other applications using the same cache. For
104 | | that reason, you may prefix every cache key to avoid collisions.
105 | |
106 | */
107 |
108 | 'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel'), '_').'_cache_'),
109 |
110 | ];
111 |
--------------------------------------------------------------------------------
/config/mail.php:
--------------------------------------------------------------------------------
1 | env('MAIL_MAILER', 'log'),
20 |
21 | /*
22 | |--------------------------------------------------------------------------
23 | | Mailer Configurations
24 | |--------------------------------------------------------------------------
25 | |
26 | | Here you may configure all of the mailers used by your application plus
27 | | their respective settings. Several examples have been configured for
28 | | you and you are free to add your own as your application requires.
29 | |
30 | | Laravel supports a variety of mail "transport" drivers that can be used
31 | | when delivering an email. You may specify which one you're using for
32 | | your mailers below. You may also add additional mailers if needed.
33 | |
34 | | Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
35 | | "postmark", "resend", "log", "array",
36 | | "failover", "roundrobin"
37 | |
38 | */
39 |
40 | 'mailers' => [
41 |
42 | 'smtp' => [
43 | 'transport' => 'smtp',
44 | 'scheme' => env('MAIL_SCHEME'),
45 | 'url' => env('MAIL_URL'),
46 | 'host' => env('MAIL_HOST', '127.0.0.1'),
47 | 'port' => env('MAIL_PORT', 2525),
48 | 'username' => env('MAIL_USERNAME'),
49 | 'password' => env('MAIL_PASSWORD'),
50 | 'timeout' => null,
51 | 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
52 | ],
53 |
54 | 'ses' => [
55 | 'transport' => 'ses',
56 | ],
57 |
58 | 'postmark' => [
59 | 'transport' => 'postmark',
60 | // 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
61 | // 'client' => [
62 | // 'timeout' => 5,
63 | // ],
64 | ],
65 |
66 | 'resend' => [
67 | 'transport' => 'resend',
68 | ],
69 |
70 | 'sendmail' => [
71 | 'transport' => 'sendmail',
72 | 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
73 | ],
74 |
75 | 'log' => [
76 | 'transport' => 'log',
77 | 'channel' => env('MAIL_LOG_CHANNEL'),
78 | ],
79 |
80 | 'array' => [
81 | 'transport' => 'array',
82 | ],
83 |
84 | 'failover' => [
85 | 'transport' => 'failover',
86 | 'mailers' => [
87 | 'smtp',
88 | 'log',
89 | ],
90 | ],
91 |
92 | 'roundrobin' => [
93 | 'transport' => 'roundrobin',
94 | 'mailers' => [
95 | 'ses',
96 | 'postmark',
97 | ],
98 | ],
99 |
100 | ],
101 |
102 | /*
103 | |--------------------------------------------------------------------------
104 | | Global "From" Address
105 | |--------------------------------------------------------------------------
106 | |
107 | | You may wish for all emails sent by your application to be sent from
108 | | the same address. Here you may specify a name and address that is
109 | | used globally for all emails that are sent by your application.
110 | |
111 | */
112 |
113 | 'from' => [
114 | 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
115 | 'name' => env('MAIL_FROM_NAME', 'Example'),
116 | ],
117 |
118 | ];
119 |
--------------------------------------------------------------------------------
/resources/js/pages/user-password/create.tsx:
--------------------------------------------------------------------------------
1 | import UserPasswordController from '@/actions/App/Http/Controllers/UserPasswordController';
2 | import { Form, Head } from '@inertiajs/react';
3 | import { LoaderCircle } from 'lucide-react';
4 |
5 | import InputError from '@/components/input-error';
6 | import { Button } from '@/components/ui/button';
7 | import { Input } from '@/components/ui/input';
8 | import { Label } from '@/components/ui/label';
9 | import AuthLayout from '@/layouts/auth-layout';
10 |
11 | interface ResetPasswordProps {
12 | token: string;
13 | email: string;
14 | }
15 |
16 | export default function ResetPassword({ token, email }: ResetPasswordProps) {
17 | return (
18 |
22 |
23 |
24 |
94 |
95 | );
96 | }
97 |
--------------------------------------------------------------------------------
/config/queue.php:
--------------------------------------------------------------------------------
1 | env('QUEUE_CONNECTION', 'database'),
19 |
20 | /*
21 | |--------------------------------------------------------------------------
22 | | Queue Connections
23 | |--------------------------------------------------------------------------
24 | |
25 | | Here you may configure the connection options for every queue backend
26 | | used by your application. An example configuration is provided for
27 | | each backend supported by Laravel. You're also free to add more.
28 | |
29 | | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", "null"
30 | |
31 | */
32 |
33 | 'connections' => [
34 |
35 | 'sync' => [
36 | 'driver' => 'sync',
37 | ],
38 |
39 | 'database' => [
40 | 'driver' => 'database',
41 | 'connection' => env('DB_QUEUE_CONNECTION'),
42 | 'table' => env('DB_QUEUE_TABLE', 'jobs'),
43 | 'queue' => env('DB_QUEUE', 'default'),
44 | 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
45 | 'after_commit' => false,
46 | ],
47 |
48 | 'beanstalkd' => [
49 | 'driver' => 'beanstalkd',
50 | 'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
51 | 'queue' => env('BEANSTALKD_QUEUE', 'default'),
52 | 'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
53 | 'block_for' => 0,
54 | 'after_commit' => false,
55 | ],
56 |
57 | 'sqs' => [
58 | 'driver' => 'sqs',
59 | 'key' => env('AWS_ACCESS_KEY_ID'),
60 | 'secret' => env('AWS_SECRET_ACCESS_KEY'),
61 | 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
62 | 'queue' => env('SQS_QUEUE', 'default'),
63 | 'suffix' => env('SQS_SUFFIX'),
64 | 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
65 | 'after_commit' => false,
66 | ],
67 |
68 | 'redis' => [
69 | 'driver' => 'redis',
70 | 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
71 | 'queue' => env('REDIS_QUEUE', 'default'),
72 | 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
73 | 'block_for' => null,
74 | 'after_commit' => false,
75 | ],
76 |
77 | ],
78 |
79 | /*
80 | |--------------------------------------------------------------------------
81 | | Job Batching
82 | |--------------------------------------------------------------------------
83 | |
84 | | The following options configure the database and table that store job
85 | | batching information. These options can be updated to any database
86 | | connection and table which has been defined by your application.
87 | |
88 | */
89 |
90 | 'batching' => [
91 | 'database' => env('DB_CONNECTION', 'sqlite'),
92 | 'table' => 'job_batches',
93 | ],
94 |
95 | /*
96 | |--------------------------------------------------------------------------
97 | | Failed Queue Jobs
98 | |--------------------------------------------------------------------------
99 | |
100 | | These options configure the behavior of failed queue job logging so you
101 | | can control how and where failed jobs are stored. Laravel ships with
102 | | support for storing failed jobs in a simple file or in a database.
103 | |
104 | | Supported drivers: "database-uuids", "dynamodb", "file", "null"
105 | |
106 | */
107 |
108 | 'failed' => [
109 | 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
110 | 'database' => env('DB_CONNECTION', 'sqlite'),
111 | 'table' => 'failed_jobs',
112 | ],
113 |
114 | ];
115 |
--------------------------------------------------------------------------------