├── public ├── favicon.ico ├── robots.txt ├── index.php └── .htaccess ├── 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 │ ├── public │ │ └── .gitignore │ └── .gitignore └── framework │ ├── testing │ └── .gitignore │ ├── views │ └── .gitignore │ ├── cache │ ├── data │ │ └── .gitignore │ └── .gitignore │ ├── sessions │ └── .gitignore │ └── .gitignore ├── .rnd ├── resources ├── js │ ├── types │ │ ├── vite-env.d.ts │ │ ├── auth.ts │ │ ├── user.ts │ │ ├── global.d.ts │ │ └── shared.ts │ ├── components │ │ ├── providers.tsx │ │ ├── logo.tsx │ │ ├── ui │ │ │ ├── text-field.tsx │ │ │ ├── separator.tsx │ │ │ ├── container.tsx │ │ │ ├── keyboard.tsx │ │ │ ├── link.tsx │ │ │ ├── heading.tsx │ │ │ ├── toast.tsx │ │ │ ├── field.tsx │ │ │ ├── avatar.tsx │ │ │ ├── card.tsx │ │ │ ├── popover.tsx │ │ │ ├── loader.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── modal.tsx │ │ │ ├── sheet.tsx │ │ │ ├── dialog.tsx │ │ │ ├── list-box.tsx │ │ │ ├── input.tsx │ │ │ └── button.tsx │ │ ├── flash.tsx │ │ ├── input-error.tsx │ │ ├── header.tsx │ │ └── theme-switcher.tsx │ ├── layouts │ │ ├── app-layout.tsx │ │ ├── guest-layout.tsx │ │ └── app-navbar.tsx │ ├── hooks │ │ ├── use-media-query.ts │ │ ├── use-mobile.ts │ │ └── use-theme.ts │ ├── pages │ │ ├── dashboard.tsx │ │ ├── home │ │ │ └── page.tsx │ │ ├── settings │ │ │ ├── appearance.tsx │ │ │ ├── settings-layout.tsx │ │ │ ├── delete-account.tsx │ │ │ ├── password.tsx │ │ │ └── profile.tsx │ │ └── auth │ │ │ ├── confirm-password.tsx │ │ │ ├── forgot-password.tsx │ │ │ ├── verify-email.tsx │ │ │ ├── reset-password.tsx │ │ │ ├── register.tsx │ │ │ └── login.tsx │ ├── ssr.tsx │ ├── lib │ │ └── primitive.ts │ └── app.tsx ├── views │ └── app.blade.php └── css │ └── toast.css ├── .codex └── config.toml ├── tests ├── Unit │ └── ExampleTest.php ├── Feature │ ├── ExampleTest.php │ ├── DashboardTest.php │ ├── Auth │ │ ├── RegistrationTest.php │ │ ├── PasswordConfirmationTest.php │ │ ├── AuthenticationTest.php │ │ ├── EmailVerificationTest.php │ │ └── PasswordResetTest.php │ └── Settings │ │ ├── PasswordUpdateTest.php │ │ └── ProfileUpdateTest.php ├── TestCase.php └── Pest.php ├── app ├── Http │ ├── Controllers │ │ ├── Controller.php │ │ ├── Settings │ │ │ ├── AppearanceController.php │ │ │ ├── DeleteAccountController.php │ │ │ ├── PasswordController.php │ │ │ └── ProfileController.php │ │ ├── AboutController.php │ │ ├── HomeController.php │ │ ├── DashboardController.php │ │ └── Auth │ │ │ ├── EmailVerificationNotificationController.php │ │ │ ├── EmailVerificationPromptController.php │ │ │ ├── VerifyEmailController.php │ │ │ ├── PasswordResetLinkController.php │ │ │ ├── ConfirmablePasswordController.php │ │ │ ├── AuthenticatedSessionController.php │ │ │ ├── RegisteredUserController.php │ │ │ └── NewPasswordController.php │ ├── Middleware │ │ ├── HandleTheme.php │ │ └── HandleInertiaRequests.php │ ├── Resources │ │ └── AuthenticatedUserResource.php │ └── Requests │ │ ├── Settings │ │ └── ProfileUpdateRequest.php │ │ └── Auth │ │ └── LoginRequest.php ├── helpers.php ├── Providers │ └── AppServiceProvider.php └── Models │ └── User.php ├── boost.json ├── .gitattributes ├── routes ├── console.php ├── dev.php ├── web.php ├── settings.php └── auth.php ├── .editorconfig ├── artisan ├── .gitignore ├── components.json ├── vite.config.ts ├── tsconfig.json ├── README.md ├── config ├── services.php ├── filesystems.php ├── cache.php ├── mail.php ├── queue.php ├── auth.php ├── app.php ├── logging.php └── database.php ├── phpunit.xml ├── .env.example ├── package.json ├── biome.json └── composer.json /public/favicon.ico: -------------------------------------------------------------------------------- 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/public/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/app/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !public/ 3 | !.gitignore 4 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.rnd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intentuilabs/laravel/HEAD/.rnd -------------------------------------------------------------------------------- /storage/framework/cache/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !data/ 3 | !.gitignore 4 | -------------------------------------------------------------------------------- /resources/js/types/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.codex/config.toml: -------------------------------------------------------------------------------- 1 | [mcp_servers.shadcn] 2 | command = "npx" 3 | args = ["shadcn@latest", "mcp"] 4 | -------------------------------------------------------------------------------- /bootstrap/providers.php: -------------------------------------------------------------------------------- 1 | toBeTrue(); 5 | }); 6 | -------------------------------------------------------------------------------- /app/Http/Controllers/Controller.php: -------------------------------------------------------------------------------- 1 | get('/'); 5 | 6 | $response->assertStatus(200); 7 | }); 8 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /resources/js/types/user.ts: -------------------------------------------------------------------------------- 1 | export interface User { 2 | id: number 3 | name: string 4 | email: string 5 | gravatar: string 6 | email_verified_at: string | null 7 | [key: string]: unknown 8 | } 9 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | comment(Inspiring::quote()); 8 | })->purpose('Display an inspiring quote'); 9 | -------------------------------------------------------------------------------- /routes/dev.php: -------------------------------------------------------------------------------- 1 | isProduction()) { 7 | Route::get('dev/login/{id}', function ($id = null) { 8 | $user = User::find($id); 9 | auth()->login($user); 10 | 11 | return redirect('/'); 12 | }); 13 | } 14 | -------------------------------------------------------------------------------- /app/Http/Controllers/Settings/AppearanceController.php: -------------------------------------------------------------------------------- 1 | flash('message', $message); 7 | session()->flash('type', $type); 8 | if (is_array($data)) { 9 | session()->flash('data', $data); 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /app/Http/Controllers/AboutController.php: -------------------------------------------------------------------------------- 1 | get('/dashboard')->assertRedirect('/login'); 7 | }); 8 | 9 | test('authenticated users can visit the dashboard', function () { 10 | $this->actingAs($user = User::factory()->create()); 11 | 12 | $this->get('/dashboard')->assertOk(); 13 | }); 14 | -------------------------------------------------------------------------------- /artisan: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | handleCommand(new ArgvInput); 14 | 15 | exit($status); 16 | -------------------------------------------------------------------------------- /resources/js/components/providers.tsx: -------------------------------------------------------------------------------- 1 | import { router } from "@inertiajs/react" 2 | import type React from "react" 3 | import { RouterProvider } from "react-aria-components" 4 | 5 | export function Providers({ children }: { children: React.ReactNode }) { 6 | return ( 7 | router.visit(to, options as any)}> 8 | {children} 9 | 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /resources/js/components/logo.tsx: -------------------------------------------------------------------------------- 1 | import type React from "react" 2 | import { Avatar } from "@/components/ui/avatar" 3 | 4 | export function Logo({ className, ...props }: React.ComponentProps) { 5 | return ( 6 | 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /routes/web.php: -------------------------------------------------------------------------------- 1 | name('home'); 7 | 8 | Route::middleware(['auth', 'verified'])->group(function () { 9 | Route::get('dashboard', Controllers\DashboardController::class)->name('dashboard'); 10 | }); 11 | 12 | require __DIR__.'/settings.php'; 13 | require __DIR__.'/auth.php'; 14 | require __DIR__.'/dev.php'; 15 | -------------------------------------------------------------------------------- /resources/js/layouts/app-layout.tsx: -------------------------------------------------------------------------------- 1 | import { Flash } from "@/components/flash" 2 | import { Footer } from "@/components/footer" 3 | import { AppNavbar } from "@/layouts/app-navbar" 4 | import type { PropsWithChildren } from "react" 5 | 6 | export default function AppLayout({ children }: PropsWithChildren) { 7 | return ( 8 |
9 | 10 | 11 | {children} 12 |
13 |
14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /resources/js/types/shared.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "ziggy-js" 2 | import type { Auth } from "./auth" 3 | 4 | export type FlashProps = { 5 | type: string 6 | message: string 7 | } 8 | 9 | export interface SharedData { 10 | name: string 11 | quote: { message: string; author: string } 12 | auth: Auth 13 | ziggy: Config & { location: string } 14 | sidebarOpen: boolean 15 | flash: FlashProps 16 | 17 | [key: string]: unknown 18 | } 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.phpunit.cache 2 | /bootstrap/ssr 3 | /node_modules 4 | /public/build 5 | /public/hot 6 | /public/storage 7 | .release-it.json 8 | /storage/*.key 9 | /vendor 10 | .env 11 | .release-it.json 12 | .env.backup 13 | .env.production 14 | .phpactor.json 15 | .phpunit.result.cache 16 | Homestead.json 17 | Homestead.yaml 18 | auth.json 19 | npm-debug.log 20 | yarn-error.log 21 | .github_token 22 | /.fleet 23 | /.idea 24 | /.vscode 25 | 26 | resources/js/ziggy.js 27 | -------------------------------------------------------------------------------- /app/Http/Middleware/HandleTheme.php: -------------------------------------------------------------------------------- 1 | cookie('theme') ?? 'system'); 15 | 16 | return $next($request); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /resources/js/components/ui/text-field.tsx: -------------------------------------------------------------------------------- 1 | import type { TextFieldProps } from "react-aria-components" 2 | import { TextField as TextFieldPrimitive } from "react-aria-components" 3 | import { cx } from "@/lib/primitive" 4 | import { fieldStyles } from "./field" 5 | 6 | const TextField = ({ className, ...props }: TextFieldProps) => { 7 | return ( 8 | 9 | ) 10 | } 11 | 12 | export { TextField } 13 | -------------------------------------------------------------------------------- /resources/js/components/flash.tsx: -------------------------------------------------------------------------------- 1 | import { usePage } from "@inertiajs/react" 2 | import { useEffect } from "react" 3 | import { toast } from "sonner" 4 | import { Toast } from "@/components/ui/toast" 5 | import type { SharedData } from "@/types/shared" 6 | 7 | export function Flash() { 8 | const { flash } = usePage().props 9 | useEffect(() => { 10 | if (flash?.message) { 11 | ;(toast as any)[flash.type](flash.message) 12 | } 13 | }, [flash]) 14 | return 15 | } 16 | -------------------------------------------------------------------------------- /resources/js/components/input-error.tsx: -------------------------------------------------------------------------------- 1 | import type { HTMLAttributes } from "react" 2 | import { Description } from "@/components/ui/field" 3 | import { twMerge } from "tailwind-merge" 4 | 5 | export function InputError({ 6 | message, 7 | className = "", 8 | ...props 9 | }: HTMLAttributes & { message?: string }) { 10 | return message ? ( 11 | 12 | {message} 13 | 14 | ) : null 15 | } 16 | -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | handleRequest(Request::capture()); 18 | -------------------------------------------------------------------------------- /database/seeders/DatabaseSeeder.php: -------------------------------------------------------------------------------- 1 | create(); 17 | 18 | User::factory()->create([ 19 | 'name' => 'Test User', 20 | 'email' => 'test@example.com', 21 | ]); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /resources/js/hooks/use-media-query.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | import { useEffect, useState } from "react" 4 | 5 | export const useMediaQuery = (query: string) => { 6 | const [value, setValue] = useState(false) 7 | 8 | useEffect(() => { 9 | const onChange = (event: MediaQueryListEvent) => { 10 | setValue(event.matches) 11 | } 12 | 13 | const result = matchMedia(query) 14 | result.addEventListener("change", onChange) 15 | setValue(result.matches) 16 | 17 | return () => result.removeEventListener("change", onChange) 18 | }, [query]) 19 | 20 | return value 21 | } 22 | -------------------------------------------------------------------------------- /tests/Feature/Auth/RegistrationTest.php: -------------------------------------------------------------------------------- 1 | get('/register'); 5 | 6 | $response->assertStatus(200); 7 | }); 8 | 9 | test('new users can register', function () { 10 | $response = $this->post('/register', [ 11 | 'name' => 'Test User', 12 | 'email' => 'test@example.com', 13 | 'password' => 'password', 14 | 'password_confirmation' => 'password', 15 | ]); 16 | 17 | $this->assertAuthenticated(); 18 | $response->assertRedirect(route('dashboard', absolute: false)); 19 | }); 20 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "resources/css/app.css", 9 | "baseColor": "zinc", 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": "@heroicons/react", 21 | "registries": { 22 | "@intentui": "https://intentui.com/r/{name}" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /resources/js/components/header.tsx: -------------------------------------------------------------------------------- 1 | import { twMerge } from "tailwind-merge" 2 | import { Container } from "@/components/ui/container" 3 | 4 | interface HeaderProps extends React.ComponentProps<"div"> { 5 | title?: string 6 | ref?: React.Ref 7 | } 8 | 9 | export function Header({ title, className, ref, ...props }: HeaderProps) { 10 | return ( 11 |
12 | 13 |

{title}

14 |
15 |
16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /resources/js/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | import { Separator as Divider, type SeparatorProps } from "react-aria-components" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | const Separator = ({ orientation = "horizontal", className, ...props }: SeparatorProps) => { 5 | return ( 6 | 15 | ) 16 | } 17 | 18 | export type { SeparatorProps } 19 | export { Separator } 20 | -------------------------------------------------------------------------------- /resources/js/hooks/use-mobile.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react" 2 | 3 | const MOBILE_BREAKPOINT = 768 4 | export function useIsMobile() { 5 | const [isMobile, setIsMobile] = useState(undefined) 6 | 7 | useEffect(() => { 8 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) 9 | const onChange = () => { 10 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) 11 | } 12 | mql.addEventListener("change", onChange) 13 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) 14 | return () => mql.removeEventListener("change", onChange) 15 | }, []) 16 | 17 | return !!isMobile 18 | } 19 | -------------------------------------------------------------------------------- /app/Providers/AppServiceProvider.php: -------------------------------------------------------------------------------- 1 | { 4 | constrained?: boolean 5 | } 6 | 7 | const Container = ({ className, constrained = false, ref, ...props }: ContainerProps) => ( 8 |
17 | ) 18 | 19 | export type { ContainerProps } 20 | export { Container } 21 | -------------------------------------------------------------------------------- /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 | # Redirect Trailing Slashes If Not A Folder... 13 | RewriteCond %{REQUEST_FILENAME} !-d 14 | RewriteCond %{REQUEST_URI} (.+)/$ 15 | RewriteRule ^ %1 [L,R=301] 16 | 17 | # Send Requests To Front Controller... 18 | RewriteCond %{REQUEST_FILENAME} !-d 19 | RewriteCond %{REQUEST_FILENAME} !-f 20 | RewriteRule ^ index.php [L] 21 | 22 | -------------------------------------------------------------------------------- /app/Http/Resources/AuthenticatedUserResource.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | public function toArray(Request $request): array 16 | { 17 | return [ 18 | 'id' => $this->id, 19 | 'name' => $this->name, 20 | 'email_verified_at' => $this->email_verified_at, 21 | 'email' => $this->email, 22 | 'gravatar' => $this->gravatar, 23 | ]; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /resources/js/pages/dashboard.tsx: -------------------------------------------------------------------------------- 1 | import AppLayout from "@/layouts/app-layout" 2 | import { Head } from "@inertiajs/react" 3 | import { CardHeader } from "@/components/ui/card" 4 | import { Container } from "@/components/ui/container" 5 | import type { SharedData } from "@/types/shared" 6 | 7 | export default function Dashboard({ auth }: SharedData) { 8 | return ( 9 | <> 10 | 11 | 12 | 13 | 17 | 18 | 19 | ) 20 | } 21 | 22 | Dashboard.layout = (page: any) => 23 | -------------------------------------------------------------------------------- /resources/js/pages/home/page.tsx: -------------------------------------------------------------------------------- 1 | import AppLayout from "@/layouts/app-layout" 2 | 3 | import { Head } from "@inertiajs/react" 4 | import { CardHeader } from "@/components/ui/card" 5 | import { Container } from "@/components/ui/container" 6 | 7 | export default function Home() { 8 | return ( 9 | <> 10 | 11 | 12 | 16 | 17 | 18 | ) 19 | } 20 | 21 | Home.layout = (page: any) => 22 | -------------------------------------------------------------------------------- /resources/js/components/ui/keyboard.tsx: -------------------------------------------------------------------------------- 1 | import { Keyboard as KeyboardPrimitive } from "react-aria-components" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | interface KeyboardProps extends React.ComponentProps {} 5 | 6 | const Keyboard = ({ className, ...props }: KeyboardProps) => { 7 | return ( 8 | 16 | ) 17 | } 18 | 19 | export type { KeyboardProps } 20 | export { Keyboard } 21 | -------------------------------------------------------------------------------- /app/Http/Controllers/Auth/EmailVerificationNotificationController.php: -------------------------------------------------------------------------------- 1 | user()->hasVerifiedEmail()) { 17 | return redirect()->intended(route('dashboard', absolute: false)); 18 | } 19 | 20 | $request->user()->sendEmailVerificationNotification(); 21 | 22 | return back()->with('status', 'verification-link-sent'); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/Http/Controllers/Auth/EmailVerificationPromptController.php: -------------------------------------------------------------------------------- 1 | user()->hasVerifiedEmail() 19 | ? redirect()->intended(route('dashboard', absolute: false)) 20 | : Inertia::render('auth/verify-email', ['status' => $request->session()->get('status')]); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /resources/views/app.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {{ config('app.name', 'Laravel') }} 8 | 9 | 10 | 11 | 12 | 13 | 14 | @viteReactRefresh 15 | @vite(['resources/js/app.tsx', "resources/js/pages/{$page['component']}.tsx"]) 16 | @inertiaHead 17 | 18 | 19 | @inertia 20 | 21 | 22 | -------------------------------------------------------------------------------- /resources/js/components/ui/link.tsx: -------------------------------------------------------------------------------- 1 | import { Link as LinkPrimitive, type LinkProps as LinkPrimitiveProps } from "react-aria-components" 2 | import { cx } from "@/lib/primitive" 3 | 4 | interface LinkProps extends LinkPrimitiveProps { 5 | ref?: React.RefObject 6 | } 7 | 8 | const Link = ({ className, ref, ...props }: LinkProps) => { 9 | return ( 10 | 23 | ) 24 | } 25 | 26 | export type { LinkProps } 27 | export { Link } 28 | -------------------------------------------------------------------------------- /app/Http/Requests/Settings/ProfileUpdateRequest.php: -------------------------------------------------------------------------------- 1 | |string> 16 | */ 17 | public function rules(): array 18 | { 19 | return [ 20 | 'name' => ['required', 'string', 'max:255'], 21 | 22 | 'email' => [ 23 | 'required', 24 | 'string', 25 | 'lowercase', 26 | 'email', 27 | 'max:255', 28 | Rule::unique(User::class)->ignore($this->user()->id), 29 | ], 30 | ]; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /resources/js/components/ui/heading.tsx: -------------------------------------------------------------------------------- 1 | import { twMerge } from "tailwind-merge" 2 | 3 | type HeadingType = { level?: 1 | 2 | 3 | 4 } & React.ComponentPropsWithoutRef< 4 | "h1" | "h2" | "h3" | "h4" 5 | > 6 | 7 | interface HeadingProps extends HeadingType { 8 | className?: string | undefined 9 | } 10 | 11 | const Heading = ({ className, level = 1, ...props }: HeadingProps) => { 12 | const Element: `h${typeof level}` = `h${level}` 13 | return ( 14 | 25 | ) 26 | } 27 | 28 | export type { HeadingProps } 29 | export { Heading } 30 | -------------------------------------------------------------------------------- /tests/Feature/Auth/PasswordConfirmationTest.php: -------------------------------------------------------------------------------- 1 | create(); 7 | 8 | $response = $this->actingAs($user)->get('/confirm-password'); 9 | 10 | $response->assertStatus(200); 11 | }); 12 | 13 | test('password can be confirmed', function () { 14 | $user = User::factory()->create(); 15 | 16 | $response = $this->actingAs($user)->post('/confirm-password', [ 17 | 'password' => 'password', 18 | ]); 19 | 20 | $response->assertRedirect(); 21 | $response->assertSessionHasNoErrors(); 22 | }); 23 | 24 | test('password is not confirmed with invalid password', function () { 25 | $user = User::factory()->create(); 26 | 27 | $response = $this->actingAs($user)->post('/confirm-password', [ 28 | 'password' => 'wrong-password', 29 | ]); 30 | 31 | $response->assertSessionHasErrors(); 32 | }); 33 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import tailwindcss from '@tailwindcss/vite'; 2 | import react from '@vitejs/plugin-react'; 3 | import laravel from 'laravel-vite-plugin'; 4 | import { resolve } from 'node:path'; 5 | import { defineConfig } from 'vite'; 6 | import {run} from 'vite-plugin-run' 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 | run([ 17 | { 18 | name: "ziggy", 19 | run: ["php", "artisan", "ziggy:generate"], 20 | pattern: ["routes/**/*.php"], 21 | }, 22 | ]), 23 | ], 24 | esbuild: { 25 | jsx: 'automatic', 26 | }, 27 | resolve: { 28 | alias: { 29 | 'ziggy-js': resolve(__dirname, 'vendor/tightenco/ziggy'), 30 | }, 31 | }, 32 | }); 33 | -------------------------------------------------------------------------------- /bootstrap/app.php: -------------------------------------------------------------------------------- 1 | withRouting( 9 | web: __DIR__.'/../routes/web.php', 10 | commands: __DIR__.'/../routes/console.php', 11 | health: '/up', 12 | ) 13 | ->withMiddleware(function (Middleware $middleware) { 14 | // Uncomment this when you have sidebar state 15 | // $middleware->encryptCookies(except: ['sidebar:state']); 16 | 17 | $middleware->web(append: [ 18 | \App\Http\Middleware\HandleTheme::class, 19 | \App\Http\Middleware\HandleInertiaRequests::class, 20 | \Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class, 21 | ]); 22 | 23 | // 24 | }) 25 | ->withExceptions(function (Exceptions $exceptions) { 26 | // 27 | })->create(); 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "module": "ESNext", 5 | "moduleResolution": "bundler", 6 | "jsx": "react-jsx", 7 | "strict": true, 8 | "skipDefaultLibCheck": true, 9 | "skipLibCheck": true, 10 | "isolatedModules": true, 11 | "target": "ESNext", 12 | "esModuleInterop": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "noEmit": true, 15 | "paths": { 16 | "@/*": [ 17 | "./resources/js/*" 18 | ], 19 | "ziggy-js": [ 20 | "./vendor/tightenco/ziggy" 21 | ] 22 | } 23 | }, 24 | "include": [ 25 | "resources/js/**/*.ts", 26 | "resources/js/**/*.tsx", 27 | "resources/js/**/*.d.ts" 28 | ], 29 | "exclude": [ 30 | "node_modules", 31 | "public", 32 | "resources/js/routes/**/*.ts", 33 | "resources/js/actions/**/*.tsx" 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /routes/settings.php: -------------------------------------------------------------------------------- 1 | group(function () { 7 | Route::redirect('settings', 'settings/profile'); 8 | 9 | Route::get('settings/profile', [Settings\ProfileController::class, 'edit'])->name('profile.edit'); 10 | Route::patch('settings/profile', [Settings\ProfileController::class, 'update'])->name('profile.update'); 11 | 12 | Route::get('settings/password', [Settings\PasswordController::class, 'edit'])->name('password.edit'); 13 | Route::put('settings/password', [Settings\PasswordController::class, 'update'])->name('password.update'); 14 | Route::get('settings/appearance', Settings\AppearanceController::class)->name('settings.appearance'); 15 | Route::get('settings/delete-account', [Settings\DeleteAccountController::class, 'index'])->name('settings.index'); 16 | Route::delete('settings/delete-account', [Settings\DeleteAccountController::class, 'destroy'])->name('settings.delete-account'); 17 | }); 18 | -------------------------------------------------------------------------------- /database/migrations/0001_01_01_000001_create_cache_table.php: -------------------------------------------------------------------------------- 1 | string('key')->primary(); 16 | $table->mediumText('value'); 17 | $table->integer('expiration'); 18 | }); 19 | 20 | Schema::create('cache_locks', function (Blueprint $table) { 21 | $table->string('key')->primary(); 22 | $table->string('owner'); 23 | $table->integer('expiration'); 24 | }); 25 | } 26 | 27 | /** 28 | * Reverse the migrations. 29 | */ 30 | public function down(): void 31 | { 32 | Schema::dropIfExists('cache'); 33 | Schema::dropIfExists('cache_locks'); 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /app/Http/Controllers/Auth/VerifyEmailController.php: -------------------------------------------------------------------------------- 1 | user()->hasVerifiedEmail()) { 18 | return redirect()->intended(route('dashboard', absolute: false).'?verified=1'); 19 | } 20 | 21 | if ($request->user()->markEmailAsVerified()) { 22 | /** @var \Illuminate\Contracts\Auth\MustVerifyEmail $user */ 23 | $user = $request->user(); 24 | 25 | event(new Verified($user)); 26 | } 27 | 28 | return redirect()->intended(route('dashboard', absolute: false).'?verified=1'); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/Http/Controllers/Settings/DeleteAccountController.php: -------------------------------------------------------------------------------- 1 | validate([ 23 | 'password' => ['required', 'current_password'], 24 | ]); 25 | 26 | $user = $request->user(); 27 | 28 | Auth::logout(); 29 | 30 | $user->delete(); 31 | 32 | $request->session()->invalidate(); 33 | $request->session()->regenerateToken(); 34 | 35 | flash( 36 | __('Your account has been successfully deleted.'), 37 | ); 38 | 39 | return redirect('/'); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /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 { Ziggy as ziggy } from "@/ziggy" 5 | import ReactDOMServer from "react-dom/server" 6 | import { route } from "ziggy-js" 7 | 8 | const appName = import.meta.env.VITE_APP_NAME || "Laravel" 9 | 10 | createServer((page) => 11 | createInertiaApp({ 12 | page, 13 | render: ReactDOMServer.renderToString, 14 | title: (title) => (title ? `${title} / ${appName}` : appName), 15 | resolve: (name) => 16 | resolvePageComponent(`./pages/${name}.tsx`, import.meta.glob("./pages/**/*.tsx")), 17 | setup: ({ App, props }) => { 18 | // @ts-expect-error 19 | global.route = (name, params, absolute) => 20 | // @ts-expect-error 21 | route(name, params as any, absolute, { 22 | ...ziggy, 23 | // @ts-expect-error 24 | location: new URL(page.props.ziggy.location), 25 | }) 26 | return 27 | }, 28 | }), 29 | ) 30 | -------------------------------------------------------------------------------- /resources/js/lib/primitive.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | import { composeRenderProps } from "react-aria-components" 4 | import { type ClassNameValue, twMerge } from "tailwind-merge" 5 | 6 | /** @deprecated Use cx */ 7 | export function composeTailwindRenderProps( 8 | className: string | ((v: T) => string) | undefined, 9 | tailwind: ClassNameValue, 10 | ): string | ((v: T) => string) { 11 | return composeRenderProps(className, (className) => twMerge(tailwind, className)) 12 | } 13 | 14 | type Render = string | ((v: T) => string) | undefined 15 | 16 | type CxArgs = [...ClassNameValue[], Render] | [[...ClassNameValue[], Render]] 17 | 18 | export function cx(...args: CxArgs): string | ((v: T) => string) { 19 | let resolvedArgs = args 20 | if (args.length === 1 && Array.isArray(args[0])) { 21 | resolvedArgs = args[0] as [...ClassNameValue[], Render] 22 | } 23 | 24 | const className = resolvedArgs.pop() as Render 25 | const tailwinds = resolvedArgs as ClassNameValue[] 26 | 27 | const fixed = twMerge(...tailwinds) 28 | 29 | return composeRenderProps(className, (cn) => twMerge(fixed, cn)) 30 | } 31 | -------------------------------------------------------------------------------- /resources/js/app.tsx: -------------------------------------------------------------------------------- 1 | import "../css/app.css" 2 | import { Providers } from "@/components/providers" 3 | import { createInertiaApp } from "@inertiajs/react" 4 | import { resolvePageComponent } from "laravel-vite-plugin/inertia-helpers" 5 | import { createRoot, hydrateRoot } from "react-dom/client" 6 | import { initializeTheme } from "./hooks/use-theme" 7 | import { Ziggy } from "@/ziggy" 8 | import { useRoute } from "ziggy-js" 9 | 10 | const appName = import.meta.env.VITE_APP_NAME || "Laravel" 11 | 12 | createInertiaApp({ 13 | title: (title) => (title ? `${title} / ${appName}` : appName), 14 | resolve: (name) => 15 | resolvePageComponent(`./pages/${name}.tsx`, import.meta.glob("./pages/**/*.tsx")), 16 | setup({ el, App, props }) { 17 | // @ts-expect-error 18 | window.route = useRoute(Ziggy) 19 | 20 | const appElement = ( 21 | 22 | 23 | 24 | ) 25 | if (import.meta.env.SSR) { 26 | hydrateRoot(el, appElement) 27 | return 28 | } 29 | 30 | createRoot(el).render(appElement) 31 | }, 32 | progress: false, 33 | }) 34 | 35 | initializeTheme() 36 | -------------------------------------------------------------------------------- /resources/js/pages/settings/appearance.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" 2 | import AppLayout from "@/layouts/app-layout" 3 | import SettingsLayout from "@/pages/settings/settings-layout" 4 | import { Head } from "@inertiajs/react" 5 | import { ThemeSwitcher } from "@/components/theme-switcher" 6 | 7 | const title = "Appearance" 8 | 9 | export default function Appearance() { 10 | return ( 11 | <> 12 | 13 |

{title}

14 | 15 | 16 | {title} 17 | 18 | 19 | Choose the most comfortable theme to make your experience using this app more enjoyable. 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | ) 29 | } 30 | 31 | Appearance.layout = (page: any) => ( 32 | 33 | 34 | 35 | ) 36 | -------------------------------------------------------------------------------- /tests/Feature/Auth/AuthenticationTest.php: -------------------------------------------------------------------------------- 1 | get('/login'); 7 | 8 | $response->assertStatus(200); 9 | }); 10 | 11 | test('users can authenticate using the login screen', function () { 12 | $user = User::factory()->create(); 13 | 14 | $response = $this->post('/login', [ 15 | 'email' => $user->email, 16 | 'password' => 'password', 17 | ]); 18 | 19 | $this->assertAuthenticated(); 20 | $response->assertRedirect(route('dashboard', absolute: false)); 21 | }); 22 | 23 | test('users can not authenticate with invalid password', function () { 24 | $user = User::factory()->create(); 25 | 26 | $this->post('/login', [ 27 | 'email' => $user->email, 28 | 'password' => 'wrong-password', 29 | ]); 30 | 31 | $this->assertGuest(); 32 | }); 33 | 34 | test('users can logout', function () { 35 | $user = User::factory()->create(); 36 | 37 | $response = $this->actingAs($user)->post('/logout'); 38 | 39 | $this->assertGuest(); 40 | $response->assertRedirect('/'); 41 | }); 42 | -------------------------------------------------------------------------------- /app/Http/Controllers/Settings/PasswordController.php: -------------------------------------------------------------------------------- 1 | validate([ 29 | 'current_password' => ['required', 'current_password'], 30 | 'password' => ['required', Password::defaults(), 'confirmed'], 31 | ]); 32 | 33 | $request->user()->update([ 34 | 'password' => Hash::make($validated['password']), 35 | ]); 36 | 37 | return back(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### TL;DR 2 | 3 | ```bash 4 | laravel new app --using=intentui/laravel 5 | ``` 6 | 7 | Make sure to update your `APP_URL` in the `.env` file before using the route function. Then, run `bun run dev` to generate and watch routes properly during development. 8 | 9 | ### Laravel Inertia React with TypeScript 10 | 11 | By default, packages like Laravel Breeze use regular JavaScript for React. However, this project is tailored for those who want an Inertia.js boilerplate with TypeScript. 12 | 13 | #### Features 14 | 15 | - Authentication 16 | - User Profile 17 | - User Password 18 | - User Deletion 19 | 20 | ### Quick Login 21 | 22 | This project includes a quick login feature. Simply add `/dev/login/{user_id}` to the URL to log in as a specific user. 23 | 24 | Example: 25 | 26 | ```text 27 | http://localhost:8000/dev/login/1 28 | ``` 29 | 30 | This feature is only available in development mode (`APP_ENV=local` in `.env`). Ensure that a user with the specified ID exists in your database. 31 | 32 | ### Default Branch Renaming 33 | 34 | The **9.x** branch is now named **laravel-9.x**. 35 | 36 | If you have a local clone, you can update it accordingly. 37 | -------------------------------------------------------------------------------- /resources/js/layouts/guest-layout.tsx: -------------------------------------------------------------------------------- 1 | import { Flash } from "@/components/flash" 2 | import { Logo } from "@/components/logo" 3 | import { CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" 4 | import type { PropsWithChildren, ReactNode } from "react" 5 | import { Link } from "@/components/ui/link" 6 | 7 | interface GuestLayoutProps { 8 | header?: string | null 9 | description?: string | ReactNode | null 10 | } 11 | 12 | export default function GuestLayout({ 13 | description = null, 14 | header = null, 15 | children, 16 | }: PropsWithChildren) { 17 | return ( 18 |
19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | {header} 27 | {description} 28 | 29 | {children} 30 |
31 |
32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /config/services.php: -------------------------------------------------------------------------------- 1 | [ 18 | 'token' => env('POSTMARK_TOKEN'), 19 | ], 20 | 21 | 'ses' => [ 22 | 'key' => env('AWS_ACCESS_KEY_ID'), 23 | 'secret' => env('AWS_SECRET_ACCESS_KEY'), 24 | 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), 25 | ], 26 | 27 | 'resend' => [ 28 | 'key' => env('RESEND_KEY'), 29 | ], 30 | 31 | 'slack' => [ 32 | 'notifications' => [ 33 | 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'), 34 | 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'), 35 | ], 36 | ], 37 | 38 | ]; 39 | -------------------------------------------------------------------------------- /app/Http/Controllers/Auth/PasswordResetLinkController.php: -------------------------------------------------------------------------------- 1 | $request->session()->get('status'), 21 | ]); 22 | } 23 | 24 | /** 25 | * Handle an incoming password reset link request. 26 | * 27 | * @throws \Illuminate\Validation\ValidationException 28 | */ 29 | public function store(Request $request): RedirectResponse 30 | { 31 | $request->validate([ 32 | 'email' => 'required|email', 33 | ]); 34 | 35 | Password::sendResetLink( 36 | $request->only('email') 37 | ); 38 | 39 | return back()->with('status', __('A reset link will be sent if the account exists.')); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /app/Http/Controllers/Settings/ProfileController.php: -------------------------------------------------------------------------------- 1 | $request->user() instanceof MustVerifyEmail, 21 | 'status' => $request->session()->get('status'), 22 | ]); 23 | } 24 | 25 | /** 26 | * Update the user's profile settings. 27 | */ 28 | public function update(ProfileUpdateRequest $request): RedirectResponse 29 | { 30 | $request->user()->fill($request->validated()); 31 | 32 | if ($request->user()->isDirty('email')) { 33 | $request->user()->email_verified_at = null; 34 | } 35 | 36 | $request->user()->save(); 37 | 38 | return to_route('profile.edit'); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /app/Http/Controllers/Auth/ConfirmablePasswordController.php: -------------------------------------------------------------------------------- 1 | validate([ 29 | 'email' => $request->user()->email, 30 | 'password' => $request->password, 31 | ])) { 32 | throw ValidationException::withMessages([ 33 | 'password' => __('auth.password'), 34 | ]); 35 | } 36 | 37 | $request->session()->put('auth.password_confirmed_at', time()); 38 | 39 | return redirect()->intended(route('dashboard', absolute: false)); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /database/factories/UserFactory.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class UserFactory extends Factory 13 | { 14 | /** 15 | * The current password being used by the factory. 16 | */ 17 | protected static ?string $password; 18 | 19 | /** 20 | * Define the model's default state. 21 | * 22 | * @return array 23 | */ 24 | public function definition(): array 25 | { 26 | return [ 27 | 'name' => fake()->name(), 28 | 'email' => fake()->unique()->safeEmail(), 29 | 'email_verified_at' => now(), 30 | 'password' => static::$password ??= Hash::make('password'), 31 | 'remember_token' => Str::random(10), 32 | ]; 33 | } 34 | 35 | /** 36 | * Indicate that the model's email address should be unverified. 37 | */ 38 | public function unverified(): static 39 | { 40 | return $this->state(fn (array $attributes) => [ 41 | 'email_verified_at' => null, 42 | ]); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | tests/Unit 10 | 11 | 12 | tests/Feature 13 | 14 | 15 | 16 | 17 | app 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /tests/Feature/Settings/PasswordUpdateTest.php: -------------------------------------------------------------------------------- 1 | create(); 8 | 9 | $response = $this 10 | ->actingAs($user) 11 | ->from('/settings/password') 12 | ->put('/settings/password', [ 13 | 'current_password' => 'password', 14 | 'password' => 'new-password', 15 | 'password_confirmation' => 'new-password', 16 | ]); 17 | 18 | $response 19 | ->assertSessionHasNoErrors() 20 | ->assertRedirect('/settings/password'); 21 | 22 | expect(Hash::check('new-password', $user->refresh()->password))->toBeTrue(); 23 | }); 24 | 25 | test('correct password must be provided to update password', function () { 26 | $user = User::factory()->create(); 27 | 28 | $response = $this 29 | ->actingAs($user) 30 | ->from('/settings/password') 31 | ->put('/settings/password', [ 32 | 'current_password' => 'wrong-password', 33 | 'password' => 'new-password', 34 | 'password_confirmation' => 'new-password', 35 | ]); 36 | 37 | $response 38 | ->assertSessionHasErrors('current_password') 39 | ->assertRedirect('/settings/password'); 40 | }); 41 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | APP_NAME=Laravel 2 | APP_ENV=local 3 | APP_KEY= 4 | APP_DEBUG=true 5 | APP_TIMEZONE=UTC 6 | APP_URL=http://localhost 7 | 8 | APP_LOCALE=en 9 | APP_FALLBACK_LOCALE=en 10 | APP_FAKER_LOCALE=en_US 11 | 12 | APP_MAINTENANCE_DRIVER=file 13 | # APP_MAINTENANCE_STORE=database 14 | 15 | BCRYPT_ROUNDS=12 16 | 17 | LOG_CHANNEL=stack 18 | LOG_STACK=single 19 | LOG_DEPRECATIONS_CHANNEL=null 20 | LOG_LEVEL=debug 21 | 22 | DB_CONNECTION=sqlite 23 | # DB_HOST=127.0.0.1 24 | # DB_PORT=3306 25 | # DB_DATABASE=laravel 26 | # DB_USERNAME=root 27 | # DB_PASSWORD= 28 | 29 | SESSION_DRIVER=database 30 | SESSION_LIFETIME=120 31 | SESSION_ENCRYPT=false 32 | SESSION_PATH=/ 33 | SESSION_DOMAIN=null 34 | 35 | BROADCAST_CONNECTION=log 36 | FILESYSTEM_DISK=local 37 | QUEUE_CONNECTION=database 38 | 39 | CACHE_STORE=database 40 | CACHE_PREFIX= 41 | 42 | MEMCACHED_HOST=127.0.0.1 43 | 44 | REDIS_CLIENT=phpredis 45 | REDIS_HOST=127.0.0.1 46 | REDIS_PASSWORD=null 47 | REDIS_PORT=6379 48 | 49 | MAIL_MAILER=log 50 | MAIL_HOST=127.0.0.1 51 | MAIL_PORT=2525 52 | MAIL_USERNAME=null 53 | MAIL_PASSWORD=null 54 | MAIL_ENCRYPTION=null 55 | MAIL_FROM_ADDRESS="hello@example.com" 56 | MAIL_FROM_NAME="${APP_NAME}" 57 | 58 | AWS_ACCESS_KEY_ID= 59 | AWS_SECRET_ACCESS_KEY= 60 | AWS_DEFAULT_REGION=us-east-1 61 | AWS_BUCKET= 62 | AWS_USE_PATH_STYLE_ENDPOINT=false 63 | 64 | VITE_APP_NAME="${APP_NAME}" 65 | -------------------------------------------------------------------------------- /resources/js/components/theme-switcher.tsx: -------------------------------------------------------------------------------- 1 | import { ComputerDesktopIcon, MoonIcon, SunIcon } from "@heroicons/react/24/outline" 2 | import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" 3 | import { type Theme, useTheme } from "@/hooks/use-theme" 4 | 5 | interface Themes { 6 | value: Theme 7 | icon: React.FC> 8 | label: string 9 | } 10 | 11 | export function ThemeSwitcher() { 12 | const { theme, updateTheme } = useTheme() 13 | const themes: Themes[] = [ 14 | { value: "light", icon: SunIcon, label: "Light" }, 15 | { 16 | value: "dark", 17 | icon: MoonIcon, 18 | label: "Dark", 19 | }, 20 | { 21 | value: "system", 22 | icon: ComputerDesktopIcon, 23 | label: "System", 24 | }, 25 | ] 26 | 27 | return ( 28 | { 32 | // @ts-expect-error 33 | updateTheme([...v][0]) 34 | }} 35 | selectionMode="single" 36 | aria-label="Choose theme" 37 | > 38 | {themes.map((theme) => ( 39 | 40 | 41 | {theme.label} 42 | 43 | ))} 44 | 45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /app/Http/Controllers/Auth/AuthenticatedSessionController.php: -------------------------------------------------------------------------------- 1 | Route::has('password.request'), 22 | 'status' => $request->session()->get('status'), 23 | ]); 24 | } 25 | 26 | /** 27 | * Handle an incoming authentication request. 28 | */ 29 | public function store(LoginRequest $request): RedirectResponse 30 | { 31 | $request->authenticate(); 32 | 33 | $request->session()->regenerate(); 34 | 35 | flash('Welcome back!'); 36 | 37 | return redirect()->intended(route('dashboard', absolute: false)); 38 | } 39 | 40 | /** 41 | * Destroy an authenticated session. 42 | */ 43 | public function destroy(Request $request): RedirectResponse 44 | { 45 | Auth::guard('web')->logout(); 46 | 47 | $request->session()->invalidate(); 48 | $request->session()->regenerateToken(); 49 | 50 | return redirect('/'); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /tests/Feature/Auth/EmailVerificationTest.php: -------------------------------------------------------------------------------- 1 | unverified()->create(); 10 | 11 | $response = $this->actingAs($user)->get('/verify-email'); 12 | 13 | $response->assertStatus(200); 14 | }); 15 | 16 | test('email can be verified', function () { 17 | $user = User::factory()->unverified()->create(); 18 | 19 | Event::fake(); 20 | 21 | $verificationUrl = URL::temporarySignedRoute( 22 | 'verification.verify', 23 | now()->addMinutes(60), 24 | ['id' => $user->id, 'hash' => sha1($user->email)] 25 | ); 26 | 27 | $response = $this->actingAs($user)->get($verificationUrl); 28 | 29 | Event::assertDispatched(Verified::class); 30 | expect($user->fresh()->hasVerifiedEmail())->toBeTrue(); 31 | $response->assertRedirect(route('dashboard', absolute: false).'?verified=1'); 32 | }); 33 | 34 | test('email is not verified with invalid hash', function () { 35 | $user = User::factory()->unverified()->create(); 36 | 37 | $verificationUrl = URL::temporarySignedRoute( 38 | 'verification.verify', 39 | now()->addMinutes(60), 40 | ['id' => $user->id, 'hash' => sha1('wrong-email')] 41 | ); 42 | 43 | $this->actingAs($user)->get($verificationUrl); 44 | 45 | expect($user->fresh()->hasVerifiedEmail())->toBeFalse(); 46 | }); 47 | -------------------------------------------------------------------------------- /app/Models/User.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | protected $fillable = [ 21 | 'name', 22 | 'email', 23 | 'password', 24 | ]; 25 | 26 | /** 27 | * The attributes that should be hidden for serialization. 28 | * 29 | * @var array 30 | */ 31 | protected $hidden = [ 32 | 'password', 33 | 'remember_token', 34 | ]; 35 | 36 | public function gravatar(): Attribute 37 | { 38 | return Attribute::make(fn () => $this->avatar()); 39 | } 40 | 41 | /** 42 | * Get the attributes that should be cast. 43 | * 44 | * @return array 45 | */ 46 | protected function casts(): array 47 | { 48 | return [ 49 | 'email_verified_at' => 'datetime', 50 | 'password' => 'hashed', 51 | ]; 52 | } 53 | 54 | protected function avatar($size = 200): string 55 | { 56 | return 'https://www.gravatar.com/avatar/'.md5(strtolower(trim($this->email))).'?s='.$size.'&d=mp'; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /resources/js/pages/auth/confirm-password.tsx: -------------------------------------------------------------------------------- 1 | import GuestLayout from "@/layouts/guest-layout" 2 | import { Head, Form } from "@inertiajs/react" 3 | import { Button } from "@/components/ui/button" 4 | import { TextField } from "@/components/ui/text-field" 5 | import { Loader } from "@/components/ui/loader" 6 | import { FieldError, Label } from "@/components/ui/field" 7 | import { Input } from "@/components/ui/input" 8 | 9 | export default function ConfirmPassword() { 10 | return ( 11 | <> 12 | 13 | 14 |
15 | This is a secure area of the application. Please confirm your password before continuing. 16 |
17 | 18 |
19 | {({ processing, errors }) => ( 20 | <> 21 | 22 | 23 | 24 | {errors.password} 25 | 26 | 27 |
28 | 32 |
33 | 34 | )} 35 |
36 | 37 | ) 38 | } 39 | 40 | ConfirmPassword.layout = (page: any) => 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.5.6", 3 | "private": false, 4 | "type": "module", 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "vite build", 8 | "build:ssr": "vite build && vite build --ssr", 9 | "format": "biome format --write resources && biome lint --fix resources", 10 | "lint": "biome lint --fix resources", 11 | "preview": "tsc && npm run build:ssr && php artisan inertia:start-ssr" 12 | }, 13 | "devDependencies": { 14 | "@biomejs/biome": "^2.3.8", 15 | "@inertiajs/react": "^2.3.1", 16 | "@tailwindcss/vite": "^4.1.18", 17 | "@types/react": "^19.2.7", 18 | "@types/react-dom": "^19.2.3", 19 | "@vitejs/plugin-react": "^5.1.2", 20 | "autoprefixer": "^10.4.22", 21 | "axios": "^1.13.2", 22 | "husky": "^9.1.7", 23 | "laravel-vite-plugin": "^2.0.1", 24 | "shadcn": "^3.6.1", 25 | "tailwind-merge": "^3.4.0", 26 | "tailwind-variants": "^3.2.2", 27 | "tailwindcss": "^4.1.18", 28 | "typescript": "^5.9.3", 29 | "vite-plugin-run": "^0.6.1" 30 | }, 31 | "dependencies": { 32 | "@heroicons/react": "^2.2.0", 33 | "@types/node": "^22.19.3", 34 | "motion": "^12.23.26", 35 | "react": "^19.2.3", 36 | "react-aria-components": "^1.13.0", 37 | "react-dom": "^19.2.3", 38 | "sonner": "^2.0.7", 39 | "tailwindcss-react-aria-components": "^2.0.1", 40 | "tw-animate-css": "^1.4.0", 41 | "vite": "^7.2.7" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /app/Http/Controllers/Auth/RegisteredUserController.php: -------------------------------------------------------------------------------- 1 | validate([ 33 | 'name' => 'required|string|max:255', 34 | 'email' => 'required|string|lowercase|email|max:255|unique:'.User::class, 35 | 'password' => ['required', 'confirmed', Rules\Password::defaults()], 36 | ]); 37 | 38 | $user = User::create([ 39 | 'name' => $request->name, 40 | 'email' => $request->email, 41 | 'password' => Hash::make($request->password), 42 | ]); 43 | 44 | event(new Registered($user)); 45 | 46 | Auth::login($user); 47 | 48 | flash('Your account has been successfully created.'); 49 | 50 | return to_route('dashboard'); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /resources/js/components/ui/toast.tsx: -------------------------------------------------------------------------------- 1 | import { Toaster as ToasterPrimitive, type ToasterProps } from "sonner" 2 | import { useTheme } from "@/hooks/use-theme" 3 | 4 | const Toast = ({ ...props }: ToasterProps) => { 5 | const { theme = "system" } = useTheme() 6 | return ( 7 | 40 | ) 41 | } 42 | 43 | export type { ToasterProps } 44 | export { Toast } 45 | -------------------------------------------------------------------------------- /app/Http/Middleware/HandleInertiaRequests.php: -------------------------------------------------------------------------------- 1 | 31 | */ 32 | public function share(Request $request): array 33 | { 34 | return [ 35 | ...parent::share($request), 36 | 'auth' => [ 37 | 'user' => $request->user() ? AuthenticatedUserResource::make($request->user()) : null, 38 | ], 39 | 'ziggy' => fn (): array => [ 40 | ...(new Ziggy)->toArray(), 41 | 'location' => $request->url(), 42 | ], 43 | 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', 44 | 'flash' => fn () => [ 45 | 'message' => $request->session()->get('message'), 46 | 'type' => $request->session()->get('type') ?? 'success', 47 | 'data' => $request->session()->get('data'), 48 | ], 49 | ]; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /database/migrations/0001_01_01_000000_create_users_table.php: -------------------------------------------------------------------------------- 1 | id(); 16 | $table->string('name'); 17 | $table->string('email')->unique(); 18 | $table->timestamp('email_verified_at')->nullable(); 19 | $table->string('password'); 20 | $table->rememberToken(); 21 | $table->timestamps(); 22 | }); 23 | 24 | Schema::create('password_reset_tokens', function (Blueprint $table) { 25 | $table->string('email')->primary(); 26 | $table->string('token'); 27 | $table->timestamp('created_at')->nullable(); 28 | }); 29 | 30 | Schema::create('sessions', function (Blueprint $table) { 31 | $table->string('id')->primary(); 32 | $table->foreignId('user_id')->nullable()->index(); 33 | $table->string('ip_address', 45)->nullable(); 34 | $table->text('user_agent')->nullable(); 35 | $table->longText('payload'); 36 | $table->integer('last_activity')->index(); 37 | }); 38 | } 39 | 40 | /** 41 | * Reverse the migrations. 42 | */ 43 | public function down(): void 44 | { 45 | Schema::dropIfExists('users'); 46 | Schema::dropIfExists('password_reset_tokens'); 47 | Schema::dropIfExists('sessions'); 48 | } 49 | }; 50 | -------------------------------------------------------------------------------- /resources/js/hooks/use-theme.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react" 2 | 3 | export type Theme = "light" | "dark" | "system" 4 | 5 | const prefersDark = () => 6 | typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches 7 | 8 | const applyTheme = (theme: Theme) => { 9 | const isDark = theme === "dark" || (theme === "system" && prefersDark()) 10 | 11 | document.documentElement.classList.toggle("dark", isDark) 12 | } 13 | 14 | const mediaQuery = 15 | typeof window !== "undefined" ? window.matchMedia("(prefers-color-scheme: dark)") : null 16 | 17 | const handleSystemThemeChange = () => { 18 | const currentTheme = localStorage.getItem("theme") as Theme 19 | applyTheme(currentTheme || "system") 20 | } 21 | 22 | export function initializeTheme() { 23 | const savedTheme = (localStorage.getItem("theme") as Theme) || "system" 24 | 25 | applyTheme(savedTheme) 26 | 27 | // Add the event listener for system theme changes... 28 | if (mediaQuery) { 29 | return () => mediaQuery.removeEventListener("change", handleSystemThemeChange) 30 | } 31 | return () => {} 32 | } 33 | 34 | export function useTheme() { 35 | const [theme, setTheme] = useState("system") 36 | 37 | const updateTheme = (mode: Theme) => { 38 | setTheme(mode) 39 | localStorage.setItem("theme", mode) 40 | applyTheme(mode) 41 | } 42 | 43 | useEffect(() => { 44 | const savedTheme = localStorage.getItem("theme") as Theme | null 45 | updateTheme(savedTheme || "system") 46 | 47 | if (mediaQuery) { 48 | return () => mediaQuery.removeEventListener("change", handleSystemThemeChange) 49 | } 50 | return () => {} 51 | }, []) 52 | 53 | return { theme, updateTheme } 54 | } 55 | -------------------------------------------------------------------------------- /tests/Pest.php: -------------------------------------------------------------------------------- 1 | extend(Tests\TestCase::class) 15 | ->use(Illuminate\Foundation\Testing\RefreshDatabase::class) 16 | ->in('Feature'); 17 | 18 | /* 19 | |-------------------------------------------------------------------------- 20 | | Expectations 21 | |-------------------------------------------------------------------------- 22 | | 23 | | When you're writing tests, you often need to check that values meet certain conditions. The 24 | | "expect()" function gives you access to a set of "expectations" methods that you can use 25 | | to assert different things. Of course, you may extend the Expectation API at any time. 26 | | 27 | */ 28 | 29 | expect()->extend('toBeOne', function () { 30 | return $this->toBe(1); 31 | }); 32 | 33 | /* 34 | |-------------------------------------------------------------------------- 35 | | Functions 36 | |-------------------------------------------------------------------------- 37 | | 38 | | While Pest is very powerful out-of-the-box, you may have some testing code specific to your 39 | | project that you don't want to repeat in every file. Here you can also expose helpers as 40 | | global functions to help you to reduce the number of lines of code in your test files. 41 | | 42 | */ 43 | 44 | function something() 45 | { 46 | // .. 47 | } 48 | -------------------------------------------------------------------------------- /resources/js/pages/auth/forgot-password.tsx: -------------------------------------------------------------------------------- 1 | import GuestLayout from "@/layouts/guest-layout" 2 | import { Head, Form } from "@inertiajs/react" 3 | import { Button } from "@/components/ui/button" 4 | import { TextField } from "@/components/ui/text-field" 5 | import { Loader } from "@/components/ui/loader" 6 | import { FieldError, Label } from "@/components/ui/field" 7 | import { Input } from "@/components/ui/input" 8 | 9 | interface ForgotPasswordProps { 10 | status: string 11 | } 12 | 13 | export default function ForgotPassword({ status }: ForgotPasswordProps) { 14 | return ( 15 | <> 16 | 17 | {status &&
{status}
} 18 | 19 |
20 | {({ processing, errors }) => ( 21 | <> 22 | 23 | 24 | 25 | {errors.email} 26 | 27 | 28 |
29 | 33 |
34 | 35 | )} 36 |
37 | 38 | ) 39 | } 40 | 41 | ForgotPassword.layout = (page: any) => ( 42 | 49 | ) 50 | -------------------------------------------------------------------------------- /resources/js/pages/auth/verify-email.tsx: -------------------------------------------------------------------------------- 1 | import GuestLayout from "@/layouts/guest-layout" 2 | import { Head, Form } from "@inertiajs/react" 3 | import { Button } from "@/components/ui/button" 4 | import { Link } from "@/components/ui/link" 5 | import { Loader } from "@/components/ui/loader" 6 | 7 | export default function VerifyEmail({ status }: { status?: string }) { 8 | return ( 9 | <> 10 | 11 | {status === "verification-link-sent" && ( 12 |
13 | A new verification link has been sent to the email address you provided during 14 | registration. 15 |
16 | )} 17 | 18 |
23 | {({ processing }) => ( 24 | <> 25 | 29 | 30 | 37 | Log Out 38 | 39 | 40 | )} 41 |
42 | 43 | ) 44 | } 45 | 46 | VerifyEmail.layout = (page: any) => ( 47 | 54 | ) 55 | -------------------------------------------------------------------------------- /resources/css/toast.css: -------------------------------------------------------------------------------- 1 | @theme inline { 2 | --color-success-bg: var(--success-bg); 3 | --color-success-border: var(--success-border); 4 | --color-success-text: var(--success-text); 5 | 6 | --color-info-bg: var(--info-bg); 7 | --color-info-border: var(--info-border); 8 | --color-info-text: var(--info-text); 9 | 10 | --color-warning-bg: var(--warning-bg); 11 | --color-warning-border: var(--warning-border); 12 | --color-warning-text: var(--warning-text); 13 | 14 | --color-error-bg: var(--error-bg); 15 | --color-error-border: var(--error-border); 16 | --color-error-text: var(--error-text); 17 | } 18 | 19 | :root { 20 | --success-bg: var(--color-emerald-50); 21 | --success-border: var(--color-emerald-100); 22 | --success-text: var(--color-emerald-600); 23 | 24 | --info-bg: theme(--color-blue-600/15%); 25 | --info-border: theme(--color-blue-600/15%); 26 | --info-text: var(--color-blue-600); 27 | 28 | --warning-bg: theme(--color-amber-500/15%); 29 | --warning-border: theme(--color-amber-400/40%); 30 | --warning-text: var(--color-amber-600); 31 | 32 | --error-bg: theme(--color-red-500/20%); 33 | --error-border: theme(--color-red-200); 34 | --error-text: var(--color-red-600); 35 | } 36 | 37 | .dark { 38 | --success-bg: var(--color-emerald-950); 39 | --success-border: var(--color-emerald-900); 40 | --success-text: var(--color-emerald-200); 41 | 42 | --info-bg: theme(--color-blue-600/15%); 43 | --info-border: theme(--color-blue-600/15%); 44 | --info-text: var(--color-blue-200); 45 | 46 | --warning-bg: theme(--color-amber-400/10%); 47 | --warning-border: theme(--color-amber-500/15%); 48 | --warning-text: var(--color-amber-200); 49 | 50 | --error-bg: theme(--color-red-600/10%); 51 | --error-border: theme(--color-red-600/15%); 52 | --error-text: var(--color-red-200); 53 | } 54 | -------------------------------------------------------------------------------- /tests/Feature/Auth/PasswordResetTest.php: -------------------------------------------------------------------------------- 1 | get('/forgot-password'); 9 | 10 | $response->assertStatus(200); 11 | }); 12 | 13 | test('reset password link can be requested', function () { 14 | Notification::fake(); 15 | 16 | $user = User::factory()->create(); 17 | 18 | $this->post('/forgot-password', ['email' => $user->email]); 19 | 20 | Notification::assertSentTo($user, ResetPassword::class); 21 | }); 22 | 23 | test('reset password screen can be rendered', function () { 24 | Notification::fake(); 25 | 26 | $user = User::factory()->create(); 27 | 28 | $this->post('/forgot-password', ['email' => $user->email]); 29 | 30 | Notification::assertSentTo($user, ResetPassword::class, function ($notification) { 31 | $response = $this->get('/reset-password/'.$notification->token); 32 | 33 | $response->assertStatus(200); 34 | 35 | return true; 36 | }); 37 | }); 38 | 39 | test('password can be reset with valid token', function () { 40 | Notification::fake(); 41 | 42 | $user = User::factory()->create(); 43 | 44 | $this->post('/forgot-password', ['email' => $user->email]); 45 | 46 | Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) { 47 | $response = $this->post('/reset-password', [ 48 | 'token' => $notification->token, 49 | 'email' => $user->email, 50 | 'password' => 'password', 51 | 'password_confirmation' => 'password', 52 | ]); 53 | 54 | $response 55 | ->assertSessionHasNoErrors() 56 | ->assertRedirect(route('login')); 57 | 58 | return true; 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /resources/js/pages/settings/settings-layout.tsx: -------------------------------------------------------------------------------- 1 | import { composeTailwindRenderProps } from "@/lib/primitive" 2 | import { Container } from "@/components/ui/container" 3 | import { ListBox, ListBoxItem, type ListBoxItemProps } from "react-aria-components" 4 | 5 | export default function SettingsLayout({ children }: { children: React.ReactNode }) { 6 | return ( 7 | 8 |
9 |
10 | 11 | 12 | Profile 13 | 14 | 15 | Change password 16 | 17 | 18 | Appearance 19 | 20 | 24 | Danger zone 25 | 26 | 27 |
28 |
{children}
29 |
30 |
31 | ) 32 | } 33 | 34 | interface NavLinkProps extends ListBoxItemProps { 35 | isCurrent?: boolean 36 | } 37 | export function NavLink({ isCurrent, className, ...props }: NavLinkProps) { 38 | return ( 39 | 47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /database/migrations/0001_01_01_000002_create_jobs_table.php: -------------------------------------------------------------------------------- 1 | id(); 16 | $table->string('queue')->index(); 17 | $table->longText('payload'); 18 | $table->unsignedTinyInteger('attempts'); 19 | $table->unsignedInteger('reserved_at')->nullable(); 20 | $table->unsignedInteger('available_at'); 21 | $table->unsignedInteger('created_at'); 22 | }); 23 | 24 | Schema::create('job_batches', function (Blueprint $table) { 25 | $table->string('id')->primary(); 26 | $table->string('name'); 27 | $table->integer('total_jobs'); 28 | $table->integer('pending_jobs'); 29 | $table->integer('failed_jobs'); 30 | $table->longText('failed_job_ids'); 31 | $table->mediumText('options')->nullable(); 32 | $table->integer('cancelled_at')->nullable(); 33 | $table->integer('created_at'); 34 | $table->integer('finished_at')->nullable(); 35 | }); 36 | 37 | Schema::create('failed_jobs', function (Blueprint $table) { 38 | $table->id(); 39 | $table->string('uuid')->unique(); 40 | $table->text('connection'); 41 | $table->text('queue'); 42 | $table->longText('payload'); 43 | $table->longText('exception'); 44 | $table->timestamp('failed_at')->useCurrent(); 45 | }); 46 | } 47 | 48 | /** 49 | * Reverse the migrations. 50 | */ 51 | public function down(): void 52 | { 53 | Schema::dropIfExists('jobs'); 54 | Schema::dropIfExists('job_batches'); 55 | Schema::dropIfExists('failed_jobs'); 56 | } 57 | }; 58 | -------------------------------------------------------------------------------- /resources/js/pages/auth/reset-password.tsx: -------------------------------------------------------------------------------- 1 | import GuestLayout from "@/layouts/guest-layout" 2 | import { Head, Form } from "@inertiajs/react" 3 | import { Button } from "@/components/ui/button" 4 | import { TextField } from "@/components/ui/text-field" 5 | import { Loader } from "@/components/ui/loader" 6 | import { FieldError, Label } from "@/components/ui/field" 7 | import { Input } from "@/components/ui/input" 8 | 9 | interface ResetPasswordProps { 10 | token: string 11 | email: string 12 | } 13 | 14 | export default function ResetPassword(args: ResetPasswordProps) { 15 | const { token, email } = args 16 | 17 | return ( 18 | <> 19 | 20 | 21 |
({ ...data, token, email })} 25 | action={route("password.request")} 26 | > 27 | {({ processing, errors }) => ( 28 | <> 29 | 30 | 31 | 32 | {errors.email} 33 | 34 | 35 | 36 | 37 | 38 | {errors.password} 39 | 40 | 41 | 42 | 43 | 44 | {errors.password_confirmation} 45 | 46 | 47 |
48 | 52 |
53 | 54 | )} 55 |
56 | 57 | ) 58 | } 59 | 60 | ResetPassword.layout = (page: any) => ( 61 | 66 | ) 67 | -------------------------------------------------------------------------------- /routes/auth.php: -------------------------------------------------------------------------------- 1 | group(function () { 14 | Route::get('register', [RegisteredUserController::class, 'create']) 15 | ->name('register'); 16 | 17 | Route::post('register', [RegisteredUserController::class, 'store']); 18 | 19 | Route::get('login', [AuthenticatedSessionController::class, 'create']) 20 | ->name('login'); 21 | 22 | Route::post('login', [AuthenticatedSessionController::class, 'store']); 23 | 24 | Route::get('forgot-password', [PasswordResetLinkController::class, 'create']) 25 | ->name('password.request'); 26 | 27 | Route::post('forgot-password', [PasswordResetLinkController::class, 'store']) 28 | ->name('password.email'); 29 | 30 | Route::get('reset-password/{token}', [NewPasswordController::class, 'create']) 31 | ->name('password.reset'); 32 | 33 | Route::post('reset-password', [NewPasswordController::class, 'store']) 34 | ->name('password.store'); 35 | }); 36 | 37 | Route::middleware('auth')->group(function () { 38 | Route::get('verify-email', EmailVerificationPromptController::class) 39 | ->name('verification.notice'); 40 | 41 | Route::get('verify-email/{id}/{hash}', VerifyEmailController::class) 42 | ->middleware(['signed', 'throttle:6,1']) 43 | ->name('verification.verify'); 44 | 45 | Route::post('email/verification-notification', [EmailVerificationNotificationController::class, 'store']) 46 | ->middleware('throttle:6,1') 47 | ->name('verification.send'); 48 | 49 | Route::get('confirm-password', [ConfirmablePasswordController::class, 'show']) 50 | ->name('password.confirm'); 51 | 52 | Route::post('confirm-password', [ConfirmablePasswordController::class, 'store']); 53 | 54 | Route::post('logout', [AuthenticatedSessionController::class, 'destroy']) 55 | ->name('logout'); 56 | }); 57 | -------------------------------------------------------------------------------- /tests/Feature/Settings/ProfileUpdateTest.php: -------------------------------------------------------------------------------- 1 | create(); 7 | 8 | $response = $this 9 | ->actingAs($user) 10 | ->get('/settings/profile'); 11 | 12 | $response->assertOk(); 13 | }); 14 | 15 | test('profile information can be updated', function () { 16 | $user = User::factory()->create(); 17 | 18 | $response = $this 19 | ->actingAs($user) 20 | ->patch('/settings/profile', [ 21 | 'name' => 'Test User', 22 | 'email' => 'test@example.com', 23 | ]); 24 | 25 | $response 26 | ->assertSessionHasNoErrors() 27 | ->assertRedirect('/settings/profile'); 28 | 29 | $user->refresh(); 30 | 31 | expect($user->name)->toBe('Test User'); 32 | expect($user->email)->toBe('test@example.com'); 33 | expect($user->email_verified_at)->toBeNull(); 34 | }); 35 | 36 | test('email verification status is unchanged when the email address is unchanged', function () { 37 | $user = User::factory()->create(); 38 | 39 | $response = $this 40 | ->actingAs($user) 41 | ->patch('/settings/profile', [ 42 | 'name' => 'Test User', 43 | 'email' => $user->email, 44 | ]); 45 | 46 | $response 47 | ->assertSessionHasNoErrors() 48 | ->assertRedirect('/settings/profile'); 49 | 50 | expect($user->refresh()->email_verified_at)->not->toBeNull(); 51 | }); 52 | 53 | test('user can delete their account', function () { 54 | $user = User::factory()->create(); 55 | 56 | $response = $this 57 | ->actingAs($user) 58 | ->delete('/settings/delete-account', [ 59 | 'password' => 'password', 60 | ]); 61 | 62 | $response 63 | ->assertSessionHasNoErrors() 64 | ->assertRedirect('/'); 65 | 66 | $this->assertGuest(); 67 | expect($user->fresh())->toBeNull(); 68 | }); 69 | 70 | test('correct password must be provided to delete account', function () { 71 | $user = User::factory()->create(); 72 | 73 | $response = $this 74 | ->actingAs($user) 75 | ->from('/settings/delete-account') 76 | ->delete('/settings/delete-account', [ 77 | 'password' => 'wrong-password', 78 | ]); 79 | 80 | $response 81 | ->assertSessionHasErrors('password') 82 | ->assertRedirect('/settings/delete-account'); 83 | 84 | expect($user->fresh())->not->toBeNull(); 85 | }); 86 | -------------------------------------------------------------------------------- /app/Http/Requests/Auth/LoginRequest.php: -------------------------------------------------------------------------------- 1 | |string> 26 | */ 27 | public function rules(): array 28 | { 29 | return [ 30 | 'email' => ['required', 'string', 'email'], 31 | 'password' => ['required', 'string'], 32 | ]; 33 | } 34 | 35 | /** 36 | * Attempt to authenticate the request's credentials. 37 | * 38 | * @throws \Illuminate\Validation\ValidationException 39 | */ 40 | public function authenticate(): void 41 | { 42 | $this->ensureIsNotRateLimited(); 43 | 44 | if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) { 45 | RateLimiter::hit($this->throttleKey()); 46 | 47 | throw ValidationException::withMessages([ 48 | 'email' => __('auth.failed'), 49 | ]); 50 | } 51 | 52 | RateLimiter::clear($this->throttleKey()); 53 | } 54 | 55 | /** 56 | * Ensure the login request is not rate limited. 57 | * 58 | * @throws \Illuminate\Validation\ValidationException 59 | */ 60 | public function ensureIsNotRateLimited(): void 61 | { 62 | if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) { 63 | return; 64 | } 65 | 66 | event(new Lockout($this)); 67 | 68 | $seconds = RateLimiter::availableIn($this->throttleKey()); 69 | 70 | throw ValidationException::withMessages([ 71 | 'email' => __('auth.throttle', [ 72 | 'seconds' => $seconds, 73 | 'minutes' => ceil($seconds / 60), 74 | ]), 75 | ]); 76 | } 77 | 78 | /** 79 | * Get the rate limiting throttle key for the request. 80 | */ 81 | public function throttleKey(): string 82 | { 83 | return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip()); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /app/Http/Controllers/Auth/NewPasswordController.php: -------------------------------------------------------------------------------- 1 | $request->email, 26 | 'token' => $request->route('token'), 27 | ]); 28 | } 29 | 30 | /** 31 | * Handle an incoming new password request. 32 | * 33 | * @throws \Illuminate\Validation\ValidationException 34 | */ 35 | public function store(Request $request): RedirectResponse 36 | { 37 | $request->validate([ 38 | 'token' => 'required', 39 | 'email' => 'required|email', 40 | 'password' => ['required', 'confirmed', Rules\Password::defaults()], 41 | ]); 42 | 43 | // Here we will attempt to reset the user's password. If it is successful we 44 | // will update the password on an actual user model and persist it to the 45 | // database. Otherwise we will parse the error and return the response. 46 | $status = Password::reset( 47 | $request->only('email', 'password', 'password_confirmation', 'token'), 48 | function ($user) use ($request) { 49 | $user->forceFill([ 50 | 'password' => Hash::make($request->password), 51 | 'remember_token' => Str::random(60), 52 | ])->save(); 53 | 54 | event(new PasswordReset($user)); 55 | } 56 | ); 57 | 58 | // If the password was successfully reset, we will redirect the user back to 59 | // the application's home authenticated view. If there is an error we can 60 | // redirect them back to where they came from with their error message. 61 | if ($status == Password::PasswordReset) { 62 | return to_route('login')->with('status', __($status)); 63 | } 64 | 65 | throw ValidationException::withMessages([ 66 | 'email' => [__($status)], 67 | ]); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /config/filesystems.php: -------------------------------------------------------------------------------- 1 | env('FILESYSTEM_DISK', 'local'), 17 | 18 | /* 19 | |-------------------------------------------------------------------------- 20 | | Filesystem Disks 21 | |-------------------------------------------------------------------------- 22 | | 23 | | Below you may configure as many filesystem disks as necessary, and you 24 | | may even configure multiple disks for the same driver. Examples for 25 | | most supported storage drivers are configured here for reference. 26 | | 27 | | Supported drivers: "local", "ftp", "sftp", "s3" 28 | | 29 | */ 30 | 31 | 'disks' => [ 32 | 33 | 'local' => [ 34 | 'driver' => 'local', 35 | 'root' => storage_path('app'), 36 | 'throw' => false, 37 | ], 38 | 39 | 'public' => [ 40 | 'driver' => 'local', 41 | 'root' => storage_path('app/public'), 42 | 'url' => env('APP_URL').'/storage', 43 | 'visibility' => 'public', 44 | 'throw' => false, 45 | ], 46 | 47 | 's3' => [ 48 | 'driver' => 's3', 49 | 'key' => env('AWS_ACCESS_KEY_ID'), 50 | 'secret' => env('AWS_SECRET_ACCESS_KEY'), 51 | 'region' => env('AWS_DEFAULT_REGION'), 52 | 'bucket' => env('AWS_BUCKET'), 53 | 'url' => env('AWS_URL'), 54 | 'endpoint' => env('AWS_ENDPOINT'), 55 | 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), 56 | 'throw' => false, 57 | ], 58 | 59 | ], 60 | 61 | /* 62 | |-------------------------------------------------------------------------- 63 | | Symbolic Links 64 | |-------------------------------------------------------------------------- 65 | | 66 | | Here you may configure the symbolic links that will be created when the 67 | | `storage:link` Artisan command is executed. The array keys should be 68 | | the locations of the links and the values should be their targets. 69 | | 70 | */ 71 | 72 | 'links' => [ 73 | public_path('storage') => storage_path('app/public'), 74 | ], 75 | 76 | ]; 77 | -------------------------------------------------------------------------------- /resources/js/components/ui/field.tsx: -------------------------------------------------------------------------------- 1 | import type { FieldErrorProps, LabelProps, TextProps } from "react-aria-components" 2 | import { 3 | FieldError as FieldErrorPrimitive, 4 | Label as LabelPrimitive, 5 | Text, 6 | } from "react-aria-components" 7 | import { twMerge } from "tailwind-merge" 8 | import { tv } from "tailwind-variants" 9 | import { cx } from "@/lib/primitive" 10 | 11 | export const labelStyles = tv({ 12 | base: "select-none text-base/6 text-fg in-disabled:opacity-50 group-disabled:opacity-50 sm:text-sm/6", 13 | }) 14 | 15 | export const descriptionStyles = tv({ 16 | base: "block text-muted-fg text-sm/6 in-disabled:opacity-50 group-disabled:opacity-50", 17 | }) 18 | 19 | export const fieldErrorStyles = tv({ 20 | base: "block text-danger-subtle-fg text-sm/6 in-disabled:opacity-50 group-disabled:opacity-50 forced-colors:text-[Mark]", 21 | }) 22 | 23 | export const fieldStyles = tv({ 24 | base: [ 25 | "w-full", 26 | "[&>[data-slot=label]+[data-slot=control]]:mt-2", 27 | "[&>[data-slot=label]+[data-slot=control]]:mt-2", 28 | "[&>[data-slot=label]+[slot='description']]:mt-1", 29 | "[&>[slot='description']+[data-slot=control]]:mt-2", 30 | "[&>[data-slot=control]+[slot=description]]:mt-2", 31 | "[&>[data-slot=control]+[slot=errorMessage]]:mt-2", 32 | "*:data-[slot=label]:font-medium", 33 | "in-disabled:opacity-50 disabled:opacity-50", 34 | ], 35 | }) 36 | 37 | export function Label({ className, ...props }: LabelProps) { 38 | return 39 | } 40 | 41 | export function Description({ className, ...props }: TextProps) { 42 | return 43 | } 44 | 45 | export function Fieldset({ className, ...props }: React.ComponentProps<"fieldset">) { 46 | return ( 47 |
*+[data-slot=control]]:mt-6", className)} 49 | {...props} 50 | /> 51 | ) 52 | } 53 | 54 | export function FieldGroup({ className, ...props }: React.ComponentPropsWithoutRef<"div">) { 55 | return
56 | } 57 | 58 | export function FieldError({ className, ...props }: FieldErrorProps) { 59 | return 60 | } 61 | 62 | export function Legend({ className, ...props }: React.ComponentProps<"legend">) { 63 | return ( 64 | 69 | ) 70 | } 71 | -------------------------------------------------------------------------------- /resources/js/pages/auth/register.tsx: -------------------------------------------------------------------------------- 1 | import GuestLayout from "@/layouts/guest-layout" 2 | import { Form, Head } from "@inertiajs/react" 3 | import { Button } from "@/components/ui/button" 4 | import { Link } from "@/components/ui/link" 5 | import { TextField } from "@/components/ui/text-field" 6 | import { Loader } from "@/components/ui/loader" 7 | import { FieldError, Label } from "@/components/ui/field" 8 | import { Input } from "@/components/ui/input" 9 | import type React from "react" 10 | 11 | export default function Register() { 12 | return ( 13 | <> 14 | 15 | 16 |
23 | {({ processing, errors }) => ( 24 | <> 25 | 26 | 27 | 28 | {errors.name} 29 | 30 | 31 | 32 | 33 | 34 | {errors.email} 35 | 36 | 37 | 38 | 39 | {errors.password} 40 | 41 | 42 | 43 | 44 | 45 | {errors.password_confirmation} 46 | 47 | 51 |
52 | 56 | Already registered? 57 | 58 |
59 | 60 | )} 61 |
62 | 63 | ) 64 | } 65 | 66 | Register.layout = (page: React.ReactNode) => ( 67 | 68 | ) 69 | -------------------------------------------------------------------------------- /resources/js/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | import { twMerge } from "tailwind-merge" 2 | 3 | interface AvatarProps { 4 | src?: string | null 5 | initials?: string 6 | alt?: string 7 | className?: string 8 | isSquare?: boolean 9 | size?: 10 | | "xs" 11 | | "sm" 12 | | "md" 13 | | "lg" 14 | | "xl" 15 | | "2xl" 16 | | "3xl" 17 | | "4xl" 18 | | "5xl" 19 | | "6xl" 20 | | "7xl" 21 | | "8xl" 22 | | "9xl" 23 | } 24 | 25 | const Avatar = ({ 26 | src = null, 27 | isSquare = false, 28 | size = "md", 29 | initials, 30 | alt = "", 31 | className, 32 | ...props 33 | }: AvatarProps & React.ComponentPropsWithoutRef<"span">) => { 34 | return ( 35 | 59 | {initials && ( 60 | 65 | {alt && {alt}} 66 | 74 | {initials} 75 | 76 | 77 | )} 78 | {src && {alt}} 79 | 80 | ) 81 | } 82 | 83 | export type { AvatarProps } 84 | export { Avatar } 85 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/2.0.6/schema.json", 3 | "vcs": { 4 | "enabled": false, 5 | "clientKind": "git", 6 | "useIgnoreFile": false 7 | }, 8 | "files": { 9 | "includes": ["resources/**/*.tsx"], 10 | "ignoreUnknown": false 11 | }, 12 | "formatter": { 13 | "enabled": true, 14 | "indentStyle": "space", 15 | "indentWidth": 2, 16 | "lineWidth": 100 17 | }, 18 | "linter": { 19 | "enabled": true, 20 | "rules": { 21 | "recommended": true, 22 | "performance": { 23 | "noImgElement": "off" 24 | }, 25 | "style": { 26 | "noNonNullAssertion": "off", 27 | "noParameterAssign": "warn", 28 | "useImportType": "warn" 29 | }, 30 | "complexity": { 31 | "useArrowFunction": "error", 32 | "noForEach": "off" 33 | }, 34 | "a11y": { 35 | "noRedundantRoles": "off", 36 | "noSvgWithoutTitle": "off", 37 | "useValidAnchor": "off", 38 | "useSemanticElements": "off" 39 | }, 40 | "correctness": { 41 | "noUnusedImports": { "level": "error", "fix": "safe" }, 42 | "useExhaustiveDependencies": "off", 43 | "useHookAtTopLevel": "error", 44 | "noChildrenProp": "off" 45 | }, 46 | "suspicious": { 47 | "noDocumentCookie": "off", 48 | "noExplicitAny": "off", 49 | "noArrayIndexKey": "off" 50 | }, 51 | "security": { 52 | "noDangerouslySetInnerHtml": "off" 53 | }, 54 | "nursery": { 55 | "useSortedClasses": { 56 | "level": "error", 57 | "fix": "safe", 58 | "options": { 59 | "attributes": ["classList"], 60 | "functions": ["twMerge", "twJoin", "tv", "composeRenderProps", "composeTailwindRenderProps"] 61 | } 62 | } 63 | } 64 | } 65 | }, 66 | "javascript": { 67 | "formatter": { 68 | "quoteStyle": "double", 69 | "arrowParentheses": "always", 70 | "bracketSameLine": false, 71 | "bracketSpacing": true, 72 | "jsxQuoteStyle": "double", 73 | "quoteProperties": "asNeeded", 74 | "semicolons": "asNeeded", 75 | "trailingCommas": "all" 76 | } 77 | }, 78 | "assist": { 79 | "enabled": true, 80 | "actions": { 81 | "source": { 82 | "organizeImports": "on" 83 | } 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /resources/js/pages/auth/login.tsx: -------------------------------------------------------------------------------- 1 | import GuestLayout from "@/layouts/guest-layout" 2 | import { Head, Form } from "@inertiajs/react" 3 | import type React from "react" 4 | import { Button } from "@/components/ui/button" 5 | import { Checkbox } from "@/components/ui/checkbox" 6 | import { Link } from "@/components/ui/link" 7 | import { TextField } from "@/components/ui/text-field" 8 | import { Loader } from "@/components/ui/loader" 9 | import { FieldError, Label } from "@/components/ui/field" 10 | import { Input } from "@/components/ui/input" 11 | 12 | interface LoginProps { 13 | status: string 14 | canResetPassword: boolean 15 | } 16 | 17 | export default function Login(args: LoginProps) { 18 | const { status, canResetPassword } = args 19 | return ( 20 | <> 21 | 22 | 23 | {status &&
{status}
} 24 | 25 |
31 | {({ processing, errors }) => ( 32 | <> 33 | 34 | 35 | 36 | {errors.email} 37 | 38 | 39 | 40 | 41 | {errors.password} 42 | 43 |
44 | Remember me 45 | {canResetPassword && ( 46 | 50 | Forgot your password? 51 | 52 | )} 53 |
54 | 58 |
59 | 63 | Dont have account? Register 64 | 65 |
66 | 67 | )} 68 |
69 | 70 | ) 71 | } 72 | 73 | Login.layout = (page: React.ReactNode) => ( 74 | 79 | ) 80 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://getcomposer.org/schema.json", 3 | "name": "intentui/laravel", 4 | "type": "project", 5 | "description": "The skeleton application for the Laravel framework with Inertia.js and Justd.", 6 | "keywords": [ 7 | "laravel", 8 | "framework", 9 | "intentui", 10 | "inertia" 11 | ], 12 | "license": "MIT", 13 | "require": { 14 | "php": "^8.2", 15 | "inertiajs/inertia-laravel": "^2.0", 16 | "laravel/framework": "^12.0", 17 | "laravel/sanctum": "^4.0", 18 | "laravel/tinker": "^2.10", 19 | "tightenco/ziggy": "^2.5" 20 | }, 21 | "require-dev": { 22 | "fakerphp/faker": "^1.23", 23 | "laravel/boost": "^1.8", 24 | "laravel/pint": "^1.13", 25 | "laravel/sail": "^1.26", 26 | "mockery/mockery": "^1.6", 27 | "nunomaduro/collision": "^8.0", 28 | "pestphp/pest": "^3.7" 29 | }, 30 | "autoload": { 31 | "psr-4": { 32 | "App\\": "app/", 33 | "Database\\Factories\\": "database/factories/", 34 | "Database\\Seeders\\": "database/seeders/" 35 | }, 36 | "files": [ 37 | "app/helpers.php" 38 | ] 39 | }, 40 | "autoload-dev": { 41 | "psr-4": { 42 | "Tests\\": "tests/" 43 | } 44 | }, 45 | "scripts": { 46 | "post-autoload-dump": [ 47 | "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", 48 | "@php artisan package:discover --ansi" 49 | ], 50 | "post-update-cmd": [ 51 | "@php artisan vendor:publish --tag=laravel-assets --ansi --force" 52 | ], 53 | "post-root-package-install": [ 54 | "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"" 55 | ], 56 | "post-create-project-cmd": [ 57 | "@php artisan key:generate --ansi", 58 | "@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"", 59 | "@php artisan migrate --graceful --ansi" 60 | ], 61 | "dev": [ 62 | "Composer\\Config::disableProcessTimeout", 63 | "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite" 64 | ] 65 | }, 66 | "extra": { 67 | "laravel": { 68 | "dont-discover": [] 69 | } 70 | }, 71 | "config": { 72 | "optimize-autoloader": true, 73 | "preferred-install": "dist", 74 | "sort-packages": true, 75 | "allow-plugins": { 76 | "pestphp/pest-plugin": true, 77 | "php-http/discovery": true 78 | } 79 | }, 80 | "minimum-stability": "stable", 81 | "prefer-stable": true 82 | } 83 | -------------------------------------------------------------------------------- /resources/js/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import { twMerge } from "tailwind-merge" 2 | 3 | const Card = ({ className, ...props }: React.HTMLAttributes) => { 4 | return ( 5 |
13 | ) 14 | } 15 | 16 | interface HeaderProps extends React.HTMLAttributes { 17 | title?: string 18 | description?: string 19 | } 20 | 21 | const CardHeader = ({ className, title, description, children, ...props }: HeaderProps) => ( 22 |
30 | {title && {title}} 31 | {description && {description}} 32 | {!title && typeof children === "string" ? {children} : children} 33 |
34 | ) 35 | 36 | const CardTitle = ({ className, ...props }: React.ComponentProps<"div">) => { 37 | return ( 38 |
43 | ) 44 | } 45 | 46 | const CardDescription = ({ className, ...props }: React.HTMLAttributes) => { 47 | return ( 48 |
54 | ) 55 | } 56 | 57 | const CardAction = ({ className, ...props }: React.HTMLAttributes) => { 58 | return ( 59 |
67 | ) 68 | } 69 | 70 | const CardContent = ({ className, ...props }: React.HTMLAttributes) => { 71 | return ( 72 |
77 | ) 78 | } 79 | 80 | const CardFooter = ({ className, ...props }: React.HTMLAttributes) => { 81 | return ( 82 |
90 | ) 91 | } 92 | 93 | export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, CardAction } 94 | -------------------------------------------------------------------------------- /resources/js/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | import type { 2 | DialogTriggerProps, 3 | PopoverProps as PopoverPrimitiveProps, 4 | } from "react-aria-components" 5 | import { 6 | DialogTrigger as DialogTriggerPrimitive, 7 | OverlayArrow, 8 | Popover as PopoverPrimitive, 9 | } from "react-aria-components" 10 | import { cx } from "@/lib/primitive" 11 | import { 12 | DialogBody, 13 | DialogClose, 14 | DialogDescription, 15 | DialogFooter, 16 | DialogHeader, 17 | DialogTitle, 18 | DialogTrigger, 19 | } from "./dialog" 20 | 21 | type PopoverProps = DialogTriggerProps 22 | const Popover = (props: PopoverProps) => { 23 | return 24 | } 25 | 26 | const PopoverTitle = DialogTitle 27 | const PopoverHeader = DialogHeader 28 | const PopoverBody = DialogBody 29 | const PopoverFooter = DialogFooter 30 | 31 | interface PopoverContentProps extends PopoverPrimitiveProps { 32 | arrow?: boolean 33 | ref?: React.Ref 34 | } 35 | 36 | const PopoverContent = ({ 37 | children, 38 | arrow = false, 39 | className, 40 | ref, 41 | ...props 42 | }: PopoverContentProps) => { 43 | const offset = props.offset ?? (arrow ? 12 : 8) 44 | return ( 45 | 59 | {(values) => ( 60 | <> 61 | {arrow && ( 62 | 63 | 69 | 70 | 71 | 72 | )} 73 | {typeof children === "function" ? children(values) : children} 74 | 75 | )} 76 | 77 | ) 78 | } 79 | 80 | const PopoverTrigger = DialogTrigger 81 | const PopoverClose = DialogClose 82 | const PopoverDescription = DialogDescription 83 | 84 | export type { PopoverProps, PopoverContentProps } 85 | export { 86 | Popover, 87 | PopoverTrigger, 88 | PopoverClose, 89 | PopoverDescription, 90 | PopoverContent, 91 | PopoverBody, 92 | PopoverFooter, 93 | PopoverHeader, 94 | PopoverTitle, 95 | } 96 | -------------------------------------------------------------------------------- /resources/js/components/ui/loader.tsx: -------------------------------------------------------------------------------- 1 | import { ProgressBar } from "react-aria-components" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | const Ring = ({ className, ...props }: React.SVGProps) => ( 5 | 26 | ) 27 | 28 | const Spin = ({ className, ...props }: React.SVGProps) => ( 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 54 | 55 | 56 | ) 57 | 58 | const LOADERS = { 59 | ring: Ring, 60 | spin: Spin, 61 | } 62 | 63 | const DEFAULT_SPINNER = "spin" 64 | 65 | export interface LoaderProps 66 | extends Omit, "display" | "opacity" | "intent"> { 67 | variant?: keyof typeof LOADERS 68 | percentage?: number 69 | isIndeterminate?: boolean 70 | formatOptions?: Intl.NumberFormatOptions 71 | ref?: React.RefObject 72 | } 73 | 74 | export function Loader({ isIndeterminate = true, ref, ...props }: LoaderProps) { 75 | const { className, variant = DEFAULT_SPINNER, ...spinnerProps } = props 76 | const LoaderPrimitive = LOADERS[variant in LOADERS ? variant : DEFAULT_SPINNER] 77 | 78 | return ( 79 | 85 | 96 | 97 | ) 98 | } 99 | -------------------------------------------------------------------------------- /resources/js/pages/settings/delete-account.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react" 2 | import { Head, useForm } from "@inertiajs/react" 3 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" 4 | import { TextField } from "@/components/ui/text-field" 5 | import { Button } from "@/components/ui/button" 6 | import AppLayout from "@/layouts/app-layout" 7 | import SettingsLayout from "@/pages/settings/settings-layout" 8 | import { 9 | Modal, 10 | ModalHeader, 11 | ModalTitle, 12 | ModalDescription, 13 | ModalFooter, 14 | ModalBody, 15 | ModalClose, 16 | ModalContent, 17 | } from "@/components/ui/modal" 18 | import { FieldError, Label } from "@/components/ui/field" 19 | import { Input } from "@/components/ui/input" 20 | 21 | const title = "Delete Account" 22 | export default function DeleteAccount() { 23 | const [confirmingUserDeletion, setConfirmingUserDeletion] = useState(false) 24 | const { 25 | data, 26 | setData, 27 | delete: destroy, 28 | processing, 29 | reset, 30 | errors, 31 | } = useForm({ 32 | password: "", 33 | }) 34 | 35 | const deleteUser = () => { 36 | destroy(route("settings.delete-account"), { 37 | preserveScroll: true, 38 | onSuccess: () => closeModal(), 39 | onFinish: () => reset(), 40 | }) 41 | } 42 | 43 | const closeModal = () => { 44 | setConfirmingUserDeletion(false) 45 | reset() 46 | } 47 | 48 | return ( 49 | <> 50 | 51 |

{title}

52 | 53 | 54 | {title} 55 | 56 | Once your account is deleted, all of its resources and data will be permanently deleted. 57 | Before deleting your account, please download any data or information that you wish to 58 | retain. 59 | 60 | 61 | 62 | 63 | 66 | 67 | 68 | Delete Account 69 | 70 | Are you sure you want to delete your account? Once your account is deleted, all of 71 | its resources and data will be permanently deleted. Please enter your password to 72 | confirm you would like to permanently delete your account. 73 | 74 | 75 | 76 | 77 | setData("password", v)} 80 | name="password" 81 | autoComplete="current-password" 82 | > 83 | 84 | 85 | {errors.password} 86 | 87 | 88 | 89 | Cancel 90 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | ) 100 | } 101 | 102 | DeleteAccount.layout = (page: any) => ( 103 | 104 | 105 | 106 | ) 107 | -------------------------------------------------------------------------------- /resources/js/pages/settings/password.tsx: -------------------------------------------------------------------------------- 1 | import { useRef } from "react" 2 | import { Head, useForm } from "@inertiajs/react" 3 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" 4 | import { Form } from "react-aria-components" 5 | import { TextField } from "@/components/ui/text-field" 6 | import { Button } from "@/components/ui/button" 7 | import AppLayout from "@/layouts/app-layout" 8 | import SettingsLayout from "@/pages/settings/settings-layout" 9 | import { FieldError, Label } from "@/components/ui/field" 10 | import { Input } from "@/components/ui/input" 11 | 12 | const title = "Change Password" 13 | 14 | export default function Password() { 15 | const passwordInput = useRef(null) 16 | const currentPasswordInput = useRef(null) 17 | const { data, setData, put, errors, reset, processing, recentlySuccessful } = useForm({ 18 | current_password: "", 19 | password: "", 20 | password_confirmation: "", 21 | }) 22 | 23 | const submit = (e: React.FormEvent) => { 24 | e.preventDefault() 25 | put(route("password.update"), { 26 | preserveScroll: true, 27 | onSuccess: () => { 28 | reset() 29 | }, 30 | onError: () => { 31 | if (errors.password) { 32 | reset("password", "password_confirmation") 33 | passwordInput.current?.focus() 34 | } 35 | 36 | if (errors.current_password) { 37 | reset("current_password") 38 | currentPasswordInput.current?.focus() 39 | } 40 | }, 41 | }) 42 | } 43 | 44 | return ( 45 | <> 46 | 47 |

{title}

48 | 49 | 50 | {title} 51 | 52 | Ensure your account is using a long, random password to stay secure. 53 | 54 | 55 | 56 | 57 |
58 | setData("current_password", v)} 61 | type="password" 62 | autoComplete="current-password" 63 | autoFocus 64 | > 65 | 66 | 67 | {errors.current_password} 68 | 69 | 70 | setData("password", v)} 75 | > 76 | 77 | 78 | {errors.password} 79 | 80 | 81 | setData("password_confirmation", v)} 85 | > 86 | 87 | 88 | {errors.password_confirmation} 89 | 90 | 91 |
92 | 95 | 96 | {recentlySuccessful &&

Saved.

} 97 |
98 |
99 |
100 |
101 | 102 | ) 103 | } 104 | 105 | Password.layout = (page: any) => ( 106 | 107 | 108 | 109 | ) 110 | -------------------------------------------------------------------------------- /config/cache.php: -------------------------------------------------------------------------------- 1 | env('CACHE_STORE', 'database'), 19 | 20 | /* 21 | |-------------------------------------------------------------------------- 22 | | Cache Stores 23 | |-------------------------------------------------------------------------- 24 | | 25 | | Here you may define all of the cache "stores" for your application as 26 | | well as their drivers. You may even define multiple stores for the 27 | | same cache driver to group types of items stored in your caches. 28 | | 29 | | Supported drivers: "array", "database", "file", "memcached", 30 | | "redis", "dynamodb", "octane", "null" 31 | | 32 | */ 33 | 34 | 'stores' => [ 35 | 36 | 'array' => [ 37 | 'driver' => 'array', 38 | 'serialize' => false, 39 | ], 40 | 41 | 'database' => [ 42 | 'driver' => 'database', 43 | 'connection' => env('DB_CACHE_CONNECTION'), 44 | 'table' => env('DB_CACHE_TABLE', 'cache'), 45 | 'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'), 46 | 'lock_table' => env('DB_CACHE_LOCK_TABLE'), 47 | ], 48 | 49 | 'file' => [ 50 | 'driver' => 'file', 51 | 'path' => storage_path('framework/cache/data'), 52 | 'lock_path' => storage_path('framework/cache/data'), 53 | ], 54 | 55 | 'memcached' => [ 56 | 'driver' => 'memcached', 57 | 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'), 58 | 'sasl' => [ 59 | env('MEMCACHED_USERNAME'), 60 | env('MEMCACHED_PASSWORD'), 61 | ], 62 | 'options' => [ 63 | // Memcached::OPT_CONNECT_TIMEOUT => 2000, 64 | ], 65 | 'servers' => [ 66 | [ 67 | 'host' => env('MEMCACHED_HOST', '127.0.0.1'), 68 | 'port' => env('MEMCACHED_PORT', 11211), 69 | 'weight' => 100, 70 | ], 71 | ], 72 | ], 73 | 74 | 'redis' => [ 75 | 'driver' => 'redis', 76 | 'connection' => env('REDIS_CACHE_CONNECTION', 'cache'), 77 | 'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'), 78 | ], 79 | 80 | 'dynamodb' => [ 81 | 'driver' => 'dynamodb', 82 | 'key' => env('AWS_ACCESS_KEY_ID'), 83 | 'secret' => env('AWS_SECRET_ACCESS_KEY'), 84 | 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), 85 | 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'), 86 | 'endpoint' => env('DYNAMODB_ENDPOINT'), 87 | ], 88 | 89 | 'octane' => [ 90 | 'driver' => 'octane', 91 | ], 92 | 93 | ], 94 | 95 | /* 96 | |-------------------------------------------------------------------------- 97 | | Cache Key Prefix 98 | |-------------------------------------------------------------------------- 99 | | 100 | | When utilizing the APC, database, memcached, Redis, and DynamoDB cache 101 | | stores, there might be other applications using the same cache. For 102 | | that reason, you may prefix every cache key to avoid collisions. 103 | | 104 | */ 105 | 106 | 'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_cache_'), 107 | 108 | ]; 109 | -------------------------------------------------------------------------------- /resources/js/pages/settings/profile.tsx: -------------------------------------------------------------------------------- 1 | import AppLayout from "@/layouts/app-layout" 2 | import { Head, useForm, usePage } from "@inertiajs/react" 3 | import type { SharedData } from "@/types/shared" 4 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" 5 | import { Form } from "react-aria-components" 6 | import { TextField } from "@/components/ui/text-field" 7 | import { Link } from "@/components/ui/link" 8 | import { Button } from "@/components/ui/button" 9 | import SettingsLayout from "@/pages/settings/settings-layout" 10 | import { FieldError, Label } from "@/components/ui/field" 11 | import { Input } from "@/components/ui/input" 12 | 13 | interface Props { 14 | mustVerifyEmail: boolean 15 | status?: string 16 | } 17 | 18 | const title = "Profile" 19 | 20 | export default function Profile({ mustVerifyEmail, status }: Props) { 21 | const { auth } = usePage().props 22 | const { data, setData, patch, errors, processing, recentlySuccessful } = useForm({ 23 | name: auth.user.name ?? "", 24 | email: auth.user.email ?? "", 25 | }) 26 | 27 | const submit = (e: React.FormEvent) => { 28 | e.preventDefault() 29 | patch("/settings/profile", { 30 | preserveScroll: true, 31 | }) 32 | } 33 | 34 | return ( 35 | <> 36 | 37 |

{title}

38 | 39 | 40 | Profile Information 41 | 42 | Update your account's profile information and email address. 43 | 44 | 45 | 46 |
47 | setData("name", v)} 50 | autoFocus 51 | autoComplete="name" 52 | > 53 | 54 | 55 | {errors.name} 56 | 57 | setData("email", v)} 60 | autoComplete="email" 61 | > 62 | 63 | 64 | {errors.email} 65 | 66 | 67 | {mustVerifyEmail && auth.user.email_verified_at === null && ( 68 |
69 |

70 | Your email address is unverified. 71 | 78 | Click here to re-send the verification email. 79 | 80 |

81 | 82 | {status === "verification-link-sent" && ( 83 |
84 | A new verification link has been sent to your email address. 85 |
86 | )} 87 |
88 | )} 89 | 90 |
91 | 94 | {recentlySuccessful &&

Saved.

} 95 |
96 |
97 |
98 |
99 | 100 | ) 101 | } 102 | 103 | Profile.layout = (page: any) => ( 104 | 105 | 106 | 107 | ) 108 | -------------------------------------------------------------------------------- /config/mail.php: -------------------------------------------------------------------------------- 1 | env('MAIL_MAILER', 'log'), 18 | 19 | /* 20 | |-------------------------------------------------------------------------- 21 | | Mailer Configurations 22 | |-------------------------------------------------------------------------- 23 | | 24 | | Here you may configure all of the mailers used by your application plus 25 | | their respective settings. Several examples have been configured for 26 | | you and you are free to add your own as your application requires. 27 | | 28 | | Laravel supports a variety of mail "transport" drivers that can be used 29 | | when delivering an email. You may specify which one you're using for 30 | | your mailers below. You may also add additional mailers if needed. 31 | | 32 | | Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2", 33 | | "postmark", "resend", "log", "array", 34 | | "failover", "roundrobin" 35 | | 36 | */ 37 | 38 | 'mailers' => [ 39 | 40 | 'smtp' => [ 41 | 'transport' => 'smtp', 42 | 'url' => env('MAIL_URL'), 43 | 'host' => env('MAIL_HOST', '127.0.0.1'), 44 | 'port' => env('MAIL_PORT', 2525), 45 | 'encryption' => env('MAIL_ENCRYPTION', 'tls'), 46 | 'username' => env('MAIL_USERNAME'), 47 | 'password' => env('MAIL_PASSWORD'), 48 | 'timeout' => null, 49 | 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url(env('APP_URL', 'http://localhost'), PHP_URL_HOST)), 50 | ], 51 | 52 | 'ses' => [ 53 | 'transport' => 'ses', 54 | ], 55 | 56 | 'postmark' => [ 57 | 'transport' => 'postmark', 58 | // 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'), 59 | // 'client' => [ 60 | // 'timeout' => 5, 61 | // ], 62 | ], 63 | 64 | 'resend' => [ 65 | 'transport' => 'resend', 66 | ], 67 | 68 | 'sendmail' => [ 69 | 'transport' => 'sendmail', 70 | 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'), 71 | ], 72 | 73 | 'log' => [ 74 | 'transport' => 'log', 75 | 'channel' => env('MAIL_LOG_CHANNEL'), 76 | ], 77 | 78 | 'array' => [ 79 | 'transport' => 'array', 80 | ], 81 | 82 | 'failover' => [ 83 | 'transport' => 'failover', 84 | 'mailers' => [ 85 | 'smtp', 86 | 'log', 87 | ], 88 | ], 89 | 90 | 'roundrobin' => [ 91 | 'transport' => 'roundrobin', 92 | 'mailers' => [ 93 | 'ses', 94 | 'postmark', 95 | ], 96 | ], 97 | 98 | ], 99 | 100 | /* 101 | |-------------------------------------------------------------------------- 102 | | Global "From" Address 103 | |-------------------------------------------------------------------------- 104 | | 105 | | You may wish for all emails sent by your application to be sent from 106 | | the same address. Here you may specify a name and address that is 107 | | used globally for all emails that are sent by your application. 108 | | 109 | */ 110 | 111 | 'from' => [ 112 | 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), 113 | 'name' => env('MAIL_FROM_NAME', 'Example'), 114 | ], 115 | 116 | ]; 117 | -------------------------------------------------------------------------------- /resources/js/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | import { CheckIcon, MinusIcon } from "@heroicons/react/20/solid" 2 | import type { CheckboxGroupProps, CheckboxProps } from "react-aria-components" 3 | import { 4 | CheckboxGroup as CheckboxGroupPrimitive, 5 | Checkbox as CheckboxPrimitive, 6 | composeRenderProps, 7 | } from "react-aria-components" 8 | import { twMerge } from "tailwind-merge" 9 | import { cx } from "@/lib/primitive" 10 | import { Label } from "./field" 11 | 12 | export function CheckboxGroup({ className, ...props }: CheckboxGroupProps) { 13 | return ( 14 | 22 | ) 23 | } 24 | 25 | export function Checkbox({ className, children, ...props }: CheckboxProps) { 26 | return ( 27 | 35 | {composeRenderProps( 36 | children, 37 | (children, { isSelected, isIndeterminate, isFocusVisible, isInvalid }) => { 38 | const isStringChild = typeof children === "string" 39 | const indicator = isIndeterminate ? ( 40 | 41 | ) : isSelected ? ( 42 | 43 | ) : null 44 | 45 | const content = isStringChild ? {children} : children 46 | 47 | return ( 48 |
57 | 76 | {indicator} 77 | 78 | {content} 79 |
80 | ) 81 | }, 82 | )} 83 |
84 | ) 85 | } 86 | 87 | export function CheckboxLabel(props: React.ComponentProps) { 88 | return