diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e6d0709..160d817 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,7 +12,7 @@ jobs: release: uses: saucebase-dev/saucebase/.github/workflows/semantic-release.yml@main with: - version_file: 'module.json' + version_file: 'composer.json' version_file_path: '.version' prerelease: false secrets: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9d842e7..f831a24 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,4 +13,5 @@ jobs: test: uses: saucebase-dev/saucebase/.github/workflows/test-module.yml@main with: - module: Auth + module: auth + frameworks: '["vue","react"]' diff --git a/CLAUDE.md b/CLAUDE.md index 4c2d686..3eb07a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,6 +19,14 @@ Authentication, registration, magic link (passwordless), password reset, email v | Layout | `AuthCardLayout` — card with logo, status alerts, page transitions | | Component | `SocialiteProviders` — Google/GitHub buttons with divider | +## Frontend + +Follows the dual-framework pattern (see root `CLAUDE.md` → Architecture > Frontend). + +- Both `resources/js/vue/` and `resources/js/react/` exist and must stay in sync +- `resources/js/app.ts` is a generated re-export — do not edit it directly +- `registerIcon()`, `registerAction()`, `registerGlobalComponent()` calls in `setup()` must be mirrored in both framework implementations + ## Routes **Guest routes** (`/auth/*`): login (GET/POST), register (GET/POST), forgot-password (GET/POST), reset-password/{token} (GET, signed), reset-password (POST, throttle:6,1), magic-link (GET/POST, throttle:5,1) @@ -74,7 +82,7 @@ Redirect URIs default to `/auth/socialite/{provider}/callback`. Providers config ```bash php artisan test --testsuite=Modules --filter='^Modules\\Auth\\Tests' # PHPUnit -npx playwright test --project="@Auth*" # E2E +npx playwright test --project="@auth*" # E2E ``` **E2E coverage**: login (basic, errors, security/rate-limiting, social, logout), register (basic, errors), forgot-password (basic, errors), verify-email. Page objects in `tests/e2e/pages/`, fixtures in `tests/e2e/fixtures/users.ts`. diff --git a/Taskfile.yml b/Taskfile.yml index 27b2c63..5f75072 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -9,11 +9,17 @@ tasks: test:e2e: desc: Run E2E tests for Auth module - cmd: npx playwright test --project="@Auth*" {{.CLI_ARGS}} + cmd: npx playwright test --project="@auth*" {{.CLI_ARGS}} interactive: true + # ── Database ────────────────────────────────────────────────── + + db:seed: + desc: Seed the Auth module database + cmd: php artisan modules:seed --module=auth + # ── Code Generation ──────────────────────────────────────────── types:generate: desc: Generate TypeScript types from PHP DTOs and enums - cmd: php artisan module:generate-types Auth + cmd: php artisan module:generate-types auth diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php deleted file mode 100644 index f5d5bef..0000000 --- a/app/Providers/RouteServiceProvider.php +++ /dev/null @@ -1,17 +0,0 @@ -group(module_path('auth', '/routes/web.php')); - Route::middleware('api') - ->group(module_path('auth', '/routes/api.php')); - } -} diff --git a/composer.json b/composer.json index 1ec903a..a5bf0b8 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,9 @@ { "name": "saucebase/auth", - "description": "", + "description": "Authentication module for Saucebase", "type": "saucebase-module", + "license": "proprietary", + "version": "1.8.1", "authors": [ { "name": "Saucebase", @@ -10,13 +12,15 @@ ], "extra": { "laravel": { - "providers": [], + "providers": [ + "Modules\\Auth\\Providers\\AuthServiceProvider" + ], "aliases": {} } }, "autoload": { "psr-4": { - "Modules\\Auth\\": "app/", + "Modules\\Auth\\": "src/", "Modules\\Auth\\Database\\Factories\\": "database/factories/", "Modules\\Auth\\Database\\Seeders\\": "database/seeders/" } @@ -29,5 +33,6 @@ "require": { "laravel/socialite": "^5.21", "stechstudio/filament-impersonate": "^5.1" - } + }, + "minimum-stability": "stable" } diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php new file mode 100644 index 0000000..02873eb --- /dev/null +++ b/database/seeders/DatabaseSeeder.php @@ -0,0 +1,18 @@ +environment(['local', 'testing'])) { + return; + } + + $this->call(AuthDatabaseSeeder::class); + } +} +} diff --git a/module.json b/module.json deleted file mode 100644 index a9a6b1f..0000000 --- a/module.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "Auth", - "alias": "auth", - "description": "Authentication module for Saucebase", - "author": "Saucebase", - "version": "v1.8.1", - "keywords": [ - "auth" - ], - "priority": 0, - "providers": [ - "Modules\\Auth\\Providers\\AuthServiceProvider" - ], - "files": [] -} diff --git a/resources/js/app.ts b/resources/js/app.ts index ade9b9f..3043f15 100644 --- a/resources/js/app.ts +++ b/resources/js/app.ts @@ -1,55 +1 @@ -import { useDialog } from '@/composables/useDialog'; -import { registerGlobalComponent } from '@/lib/globalComponents'; -import { registerAction, registerIcon } from '@/lib/navigation'; -import { router } from '@inertiajs/vue3'; -import { trans } from 'laravel-vue-i18n'; -import { LogOut } from 'lucide-vue-next'; -import IconLogOut from '~icons/lucide/log-out'; -import ImpersonationAlert from './components/ImpersonationAlert.vue'; - -import '../css/style.css'; - -/** - * Auth module setup - * Called during app initialization before mounting - */ -export function setup() { - registerIcon('logout', IconLogOut); - registerAuthActions(); - registerGlobalComponent('top', ImpersonationAlert); -} - -/** - * Register auth-related navigation actions - */ -function registerAuthActions() { - // Logout action - registerAction('logout', async (event: MouseEvent) => { - event.preventDefault(); - - const { confirm } = useDialog(); - if ( - await confirm({ - title: trans('Log out'), - description: trans( - 'Are you sure you want to log out? You will need to sign in again.', - ), - confirmLabel: trans('Log out'), - cancelLabel: trans('Cancel'), - variant: 'destructive', - icon: LogOut, - align: 'left', - }) - ) { - router.post(route('logout')); - } - }); -} - -/** - * Auth module after mount logic - * Called after the app has been mounted - */ -export function afterMount() { - console.debug('Auth module after mount logic executed'); -} +export * from './vue/app'; diff --git a/resources/js/react/app.tsx b/resources/js/react/app.tsx new file mode 100644 index 0000000..b6f14a5 --- /dev/null +++ b/resources/js/react/app.tsx @@ -0,0 +1,39 @@ +import { confirm } from '@/hooks/useDialog'; +import { registerGlobalComponent } from '@/lib/globalComponents'; +import { registerAction, registerIcon } from '@/lib/navigation'; +import { router } from '@inertiajs/react'; +import '@modules/auth/resources/css/style.css'; +import { LogOut } from 'lucide-react'; +import IconLogOut from '~icons/lucide/log-out'; +import ImpersonationAlert from './components/ImpersonationAlert'; + +export function setup() { + registerIcon('logout', IconLogOut); + registerAuthActions(); + registerGlobalComponent('top', ImpersonationAlert); +} + +function registerAuthActions() { + registerAction('logout', async (event: MouseEvent) => { + event.preventDefault(); + + const confirmed = await confirm({ + title: 'Log out', + description: + 'Are you sure you want to log out? You will need to sign in again.', + confirmLabel: 'Log out', + cancelLabel: 'Cancel', + variant: 'destructive', + icon: LogOut, + align: 'left', + }); + + if (confirmed) { + router.post(route('logout')); + } + }); +} + +export function afterMount() { + console.debug('Auth module after mount logic executed'); +} diff --git a/resources/js/react/components/ImpersonationAlert.tsx b/resources/js/react/components/ImpersonationAlert.tsx new file mode 100644 index 0000000..28a7b62 --- /dev/null +++ b/resources/js/react/components/ImpersonationAlert.tsx @@ -0,0 +1,201 @@ +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { useT } from '@/i18n'; +import { cn } from '@/lib/utils'; +import type { User } from '@/types'; +import { router, usePage } from '@inertiajs/react'; +import { Drama, History, X } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +interface Impersonation { + user: User; + route: string; + label: string; + recent: User[]; +} + +function getUserInitials(name: string): string { + return name + .split(' ') + .map((word) => word.charAt(0).toUpperCase()) + .slice(0, 2) + .join(''); +} + +function getRoleBadgeClasses(role: string): string { + return cn( + 'shrink-0 rounded-xl px-1 py-0.5 text-[9px] text-white uppercase', + role === 'admin' ? 'bg-red-800' : 'bg-cyan-700', + ); +} + +export default function ImpersonationAlert() { + const t = useT(); + const page = usePage(); + const impersonation = (page.props?.impersonation as Impersonation) || null; + const [isExpanded, setIsExpanded] = useState(false); + const alertRef = useRef(null); + + const collapse = useCallback(() => setIsExpanded(false), []); + + useEffect(() => { + if (!isExpanded) return; + + function handleClickOutside(e: MouseEvent) { + if ( + alertRef.current && + !alertRef.current.contains(e.target as Node) + ) { + collapse(); + } + } + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') collapse(); + } + + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [isExpanded, collapse]); + + const reimpersonate = (userId: number) => { + router.post( + route('auth.impersonate.reimpersonate', { userId }), + {}, + { preserveScroll: true, onSuccess: collapse }, + ); + }; + + if (!impersonation) return null; + + return ( +
+ {!isExpanded ? ( + + ) : ( +
+ +
+ + + + {getUserInitials(impersonation.user.name)} + + +
+
+

+ {impersonation.user.name} +

+ {impersonation.user.role && ( + + {impersonation.user.role} + + )} +
+

+ {impersonation.user.email} +

+
+
+ + {impersonation.label} + + + {impersonation.recent?.length > 0 && ( +
+

+ + {t('Recent impersonated users')} +

+
+ {impersonation.recent.map((user) => ( + + ))} +
+
+ )} +
+ )} +
+ ); +} diff --git a/resources/js/react/components/SocialiteProviders.tsx b/resources/js/react/components/SocialiteProviders.tsx new file mode 100644 index 0000000..69dbb44 --- /dev/null +++ b/resources/js/react/components/SocialiteProviders.tsx @@ -0,0 +1,63 @@ +import { Button } from '@/components/ui/button'; +import { useT } from '@/i18n'; +import { usePage } from '@inertiajs/react'; +import IconGithub from '~icons/simple-icons/github'; +import IconGoogle from '~icons/simple-icons/google'; + +type Provider = { + name: string; + icon: React.ComponentType<{ className?: string }>; +}; + +const providers: Provider[] = [ + { name: 'google', icon: IconGoogle }, + { name: 'github', icon: IconGithub }, +]; + +export default function SocialiteProviders() { + const t = useT(); + const page = usePage(); + const lastUsed = (page.props.auth as any)?.last_social_provider as + | string + | undefined; + + if (!route().has('auth.socialite.redirect') || !providers.length) { + return null; + } + + return ( +
+ {providers.map(({ name, icon: Icon }) => ( +
+ + {lastUsed === name && ( + + {t('Last used')} + + )} +
+ ))} +
+ + {t('Or continue with email')} + +
+
+ ); +} diff --git a/resources/js/react/layouts/AuthCardLayout.tsx b/resources/js/react/layouts/AuthCardLayout.tsx new file mode 100644 index 0000000..d9981e8 --- /dev/null +++ b/resources/js/react/layouts/AuthCardLayout.tsx @@ -0,0 +1,66 @@ +import AlertMessage from '@/components/AlertMessage'; +import AppLogo from '@/components/AppLogo'; +import Footer from '@/components/Footer'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Head, Link, usePage } from '@inertiajs/react'; +import type { ReactNode } from 'react'; + +interface AuthCardLayoutProps { + title?: string; + description?: string; + cardClass?: string; + children: ReactNode; + outside?: ReactNode; +} + +export default function AuthCardLayout({ + title, + description, + cardClass, + children, + outside, +}: AuthCardLayoutProps) { + const page = usePage(); + const status = page.props.status as string | undefined; + const error = page.props.error as string | undefined; + + return ( +
+
+ + + + +
+ +
+
+ + + {title} + {description} + + + {(status || error) && ( +
+ +
+ )} + {children} +
+
+
+
+
+
+ ); +} diff --git a/resources/js/react/pages/ForgotPassword.tsx b/resources/js/react/pages/ForgotPassword.tsx new file mode 100644 index 0000000..ef709de --- /dev/null +++ b/resources/js/react/pages/ForgotPassword.tsx @@ -0,0 +1,72 @@ +import { Button } from '@/components/ui/button'; +import { Field, FieldError } from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useT } from '@/i18n'; +import { Link, useForm, usePage } from '@inertiajs/react'; +import AuthCardLayout from '../layouts/AuthCardLayout'; + +export default function ForgotPassword() { + const t = useT(); + const page = usePage(); + const email = page.props.email ? String(page.props.email) : ''; + + const { data, setData, post, processing, errors } = useForm({ email }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + post(route('password.email'), { preserveScroll: true }); + }; + + return ( + +
+ + + setData('email', e.target.value)} + /> + {errors.email && ( + + {errors.email} + + )} + + +
+ + {t('Back to login')} + + +
+
+
+ ); +} diff --git a/resources/js/react/pages/Login.tsx b/resources/js/react/pages/Login.tsx new file mode 100644 index 0000000..6e84633 --- /dev/null +++ b/resources/js/react/pages/Login.tsx @@ -0,0 +1,169 @@ +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Field, FieldError, FieldLabel } from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useT } from '@/i18n'; +import { Link, useForm, usePage } from '@inertiajs/react'; +import { Eye, EyeOff } from 'lucide-react'; +import { useState } from 'react'; +import SocialiteProviders from '../components/SocialiteProviders'; +import AuthCardLayout from '../layouts/AuthCardLayout'; + +export default function Login() { + const t = useT(); + const page = usePage(); + const [email, setEmail] = useState(''); + const [showPassword, setShowPassword] = useState(false); + + const { data, setData, post, processing, errors } = useForm({ + email: '', + password: '', + remember: false, + }); + + const forgotUrl = route('password.request', { email }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + post(route('login'), { preserveScroll: true }); + }; + + return ( + + + +
+ + + { + setData('email', e.target.value); + setEmail(e.target.value); + }} + /> + {errors.email && ( + + {errors.email} + + )} + + + + +
+ + setData('password', e.target.value) + } + /> + +
+ {errors.password && ( + + {errors.password} + + )} +
+ +
+ + + setData('remember', !!checked) + } + /> + + {t('Remember-me')} + + + + {route().has('password.request') && ( + + {t('Forgot your password?')} + + )} +
+ + + +

+ {(page.props.auth as any)?.magic_link_enabled && ( + + {t('Login with magic link')} + + )} +

+ +

+ {t("Don't have an account?")}{' '} + + {t('Sign up')} + +

+
+
+ ); +} diff --git a/resources/js/react/pages/MagicLink.tsx b/resources/js/react/pages/MagicLink.tsx new file mode 100644 index 0000000..a431d73 --- /dev/null +++ b/resources/js/react/pages/MagicLink.tsx @@ -0,0 +1,65 @@ +import { Button } from '@/components/ui/button'; +import { Field, FieldError } from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useT } from '@/i18n'; +import { Link, useForm } from '@inertiajs/react'; +import AuthCardLayout from '../layouts/AuthCardLayout'; + +export default function MagicLink() { + const t = useT(); + const { data, setData, post, processing, errors } = useForm({ email: '' }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + post(route('magic-link.store'), { preserveScroll: true }); + }; + + return ( + +
+ + + setData('email', e.target.value)} + data-testid="magic-link-email" + /> + {errors.email && {errors.email}} + + +
+ + {t('Back to login')} + + +
+
+
+ ); +} diff --git a/resources/js/react/pages/Register.tsx b/resources/js/react/pages/Register.tsx new file mode 100644 index 0000000..6579862 --- /dev/null +++ b/resources/js/react/pages/Register.tsx @@ -0,0 +1,140 @@ +import { Button } from '@/components/ui/button'; +import { Field, FieldError } from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useT } from '@/i18n'; +import { Link, useForm } from '@inertiajs/react'; +import { Eye, EyeOff } from 'lucide-react'; +import { useState } from 'react'; +import SocialiteProviders from '../components/SocialiteProviders'; +import AuthCardLayout from '../layouts/AuthCardLayout'; + +export default function Register() { + const t = useT(); + const [showPassword, setShowPassword] = useState(false); + const { data, setData, post, processing, errors } = useForm({ + name: '', + email: '', + password: '', + password_confirmation: '', + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + post(route('register'), { preserveScroll: true }); + }; + + return ( + + + +
+ + + setData('name', e.target.value)} + /> + {errors.name && ( + + {errors.name} + + )} + + + + + setData('email', e.target.value)} + /> + {errors.email && ( + + {errors.email} + + )} + + + + +
+ + setData('password', e.target.value) + } + /> + +
+ {errors.password && ( + + {errors.password} + + )} +
+ + + +

+ {t('Already registered?')}{' '} + + {t('Log in')} + +

+
+
+ ); +} diff --git a/resources/js/react/pages/ResetPassword.tsx b/resources/js/react/pages/ResetPassword.tsx new file mode 100644 index 0000000..422a7d4 --- /dev/null +++ b/resources/js/react/pages/ResetPassword.tsx @@ -0,0 +1,116 @@ +import { Button } from '@/components/ui/button'; +import { Field, FieldError } from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useT } from '@/i18n'; +import { useForm } from '@inertiajs/react'; +import AuthCardLayout from '../layouts/AuthCardLayout'; + +interface Props { + email: string; + token: string; +} + +export default function ResetPassword({ email, token }: Props) { + const t = useT(); + const { data, setData, post, processing, errors } = useForm({ + token, + email, + password: '', + password_confirmation: '', + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + post(route('password.store'), { preserveScroll: true }); + }; + + return ( + +
+ + + + + + {errors.email && ( + + {errors.email} + + )} + + + + + setData('password', e.target.value)} + /> + {errors.password && ( + + {errors.password} + + )} + + + + + + setData('password_confirmation', e.target.value) + } + /> + {errors.password_confirmation && ( + + {errors.password_confirmation} + + )} + + + +
+
+ ); +} diff --git a/resources/js/react/pages/VerifyEmail.tsx b/resources/js/react/pages/VerifyEmail.tsx new file mode 100644 index 0000000..4472df5 --- /dev/null +++ b/resources/js/react/pages/VerifyEmail.tsx @@ -0,0 +1,45 @@ +import { Button } from '@/components/ui/button'; +import { useT } from '@/i18n'; +import { Link, useForm } from '@inertiajs/react'; +import AuthCardLayout from '../layouts/AuthCardLayout'; + +export default function VerifyEmail() { + const t = useT(); + const { post, processing } = useForm({}); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + post(route('verification.send')); + }; + + return ( + +
+ + +

+ + {t('Log Out')} + +

+
+
+ ); +} diff --git a/resources/js/vue/app.ts b/resources/js/vue/app.ts new file mode 100644 index 0000000..cf3ff5f --- /dev/null +++ b/resources/js/vue/app.ts @@ -0,0 +1,54 @@ +import { useDialog } from '@/composables/useDialog'; +import { registerGlobalComponent } from '@/lib/globalComponents'; +import { registerAction, registerIcon } from '@/lib/navigation'; +import { router } from '@inertiajs/vue3'; +import '@modules/auth/resources/css/style.css'; +import { trans } from 'laravel-vue-i18n'; +import { LogOut } from 'lucide-vue-next'; +import IconLogOut from '~icons/lucide/log-out'; +import ImpersonationAlert from './components/ImpersonationAlert.vue'; + +/** + * Auth module setup + * Called during app initialization before mounting + */ +export function setup() { + registerIcon('logout', IconLogOut); + registerAuthActions(); + registerGlobalComponent('top', ImpersonationAlert); +} + +/** + * Register auth-related navigation actions + */ +function registerAuthActions() { + // Logout action + registerAction('logout', async (event: MouseEvent) => { + event.preventDefault(); + + const { confirm } = useDialog(); + if ( + await confirm({ + title: trans('Log out'), + description: trans( + 'Are you sure you want to log out? You will need to sign in again.', + ), + confirmLabel: trans('Log out'), + cancelLabel: trans('Cancel'), + variant: 'destructive', + icon: LogOut, + align: 'left', + }) + ) { + router.post(route('logout')); + } + }); +} + +/** + * Auth module after mount logic + * Called after the app has been mounted + */ +export function afterMount() { + console.debug('Auth module after mount logic executed'); +} diff --git a/resources/js/components/ImpersonationAlert.vue b/resources/js/vue/components/ImpersonationAlert.vue similarity index 100% rename from resources/js/components/ImpersonationAlert.vue rename to resources/js/vue/components/ImpersonationAlert.vue diff --git a/resources/js/components/SocialiteProviders.vue b/resources/js/vue/components/SocialiteProviders.vue similarity index 100% rename from resources/js/components/SocialiteProviders.vue rename to resources/js/vue/components/SocialiteProviders.vue diff --git a/resources/js/layouts/AuthCardLayout.vue b/resources/js/vue/layouts/AuthCardLayout.vue similarity index 95% rename from resources/js/layouts/AuthCardLayout.vue rename to resources/js/vue/layouts/AuthCardLayout.vue index 01f0222..f79470a 100644 --- a/resources/js/layouts/AuthCardLayout.vue +++ b/resources/js/vue/layouts/AuthCardLayout.vue @@ -21,7 +21,7 @@ defineProps<{ diff --git a/resources/js/pages/ForgotPassword.vue b/resources/js/vue/pages/ForgotPassword.vue similarity index 100% rename from resources/js/pages/ForgotPassword.vue rename to resources/js/vue/pages/ForgotPassword.vue diff --git a/resources/js/pages/Login.vue b/resources/js/vue/pages/Login.vue similarity index 100% rename from resources/js/pages/Login.vue rename to resources/js/vue/pages/Login.vue diff --git a/resources/js/pages/MagicLink.vue b/resources/js/vue/pages/MagicLink.vue similarity index 100% rename from resources/js/pages/MagicLink.vue rename to resources/js/vue/pages/MagicLink.vue diff --git a/resources/js/pages/Register.vue b/resources/js/vue/pages/Register.vue similarity index 100% rename from resources/js/pages/Register.vue rename to resources/js/vue/pages/Register.vue diff --git a/resources/js/pages/ResetPassword.vue b/resources/js/vue/pages/ResetPassword.vue similarity index 100% rename from resources/js/pages/ResetPassword.vue rename to resources/js/vue/pages/ResetPassword.vue diff --git a/resources/js/pages/VerifyEmail.vue b/resources/js/vue/pages/VerifyEmail.vue similarity index 100% rename from resources/js/pages/VerifyEmail.vue rename to resources/js/vue/pages/VerifyEmail.vue diff --git a/routes/api.php b/routes/api.php index 5a7d39e..4d9a221 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,6 +3,8 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Route; -Route::middleware(['auth:sanctum'])->prefix('api/v1/auth')->group(function () { - Route::get('me', fn () => response()->json(Auth::user())); +Route::middleware('api')->group(function (): void { + Route::middleware(['auth:sanctum'])->prefix('api/v1/auth')->group(function (): void { + Route::get('me', fn () => response()->json(Auth::user())); + }); }); diff --git a/routes/web.php b/routes/web.php index 0b34f42..3215399 100644 --- a/routes/web.php +++ b/routes/web.php @@ -13,85 +13,87 @@ use Modules\Auth\Http\Controllers\SocialiteController; use Modules\Auth\Http\Controllers\VerifyEmailController; -Route::prefix('auth')->group(function () { - Route::middleware('guest')->group(function () { +Route::middleware('web')->group(function (): void { + Route::prefix('auth')->group(function (): void { + Route::middleware('guest')->group(function (): void { - Route::get('login', [LoginController::class, 'create']) - ->name('login'); + Route::get('login', [LoginController::class, 'create']) + ->name('login'); - Route::post('login', [LoginController::class, 'store']); + Route::post('login', [LoginController::class, 'store']); - Route::get('register', [RegisterController::class, 'create']) - ->name('register'); + Route::get('register', [RegisterController::class, 'create']) + ->name('register'); - Route::post('register', [RegisterController::class, 'store']); + Route::post('register', [RegisterController::class, 'store']); - Route::get('forgot-password', [ForgotPasswordController::class, 'create']) - ->name('password.request'); + Route::get('forgot-password', [ForgotPasswordController::class, 'create']) + ->name('password.request'); - Route::post('forgot-password', [ForgotPasswordController::class, 'store']) - ->name('password.email'); + Route::post('forgot-password', [ForgotPasswordController::class, 'store']) + ->name('password.email'); - Route::get('reset-password/{token}', [ResetPasswordController::class, 'create']) - ->name('password.reset'); + Route::get('reset-password/{token}', [ResetPasswordController::class, 'create']) + ->name('password.reset'); - Route::post('reset-password', [ResetPasswordController::class, 'store']) - ->middleware('throttle:6,1') - ->name('password.store'); + Route::post('reset-password', [ResetPasswordController::class, 'store']) + ->middleware('throttle:6,1') + ->name('password.store'); - Route::get('magic-link', [MagicLinkController::class, 'create']) - ->name('magic-link.create'); + Route::get('magic-link', [MagicLinkController::class, 'create']) + ->name('magic-link.create'); - Route::post('magic-link', [MagicLinkController::class, 'store']) - ->middleware('throttle:5,1') - ->name('magic-link.store'); - }); + Route::post('magic-link', [MagicLinkController::class, 'store']) + ->middleware('throttle:5,1') + ->name('magic-link.store'); + }); - Route::middleware('auth')->group(function () { + Route::middleware('auth')->group(function (): void { - Route::any('logout', [LoginController::class, 'destroy']) - ->name('logout'); + Route::any('logout', [LoginController::class, 'destroy']) + ->name('logout'); - Route::get('verify-email', EmailVerificationPromptController::class) - ->name('verification.notice'); + Route::get('verify-email', EmailVerificationPromptController::class) + ->name('verification.notice'); - Route::get('verify-email/{id}/{hash}', VerifyEmailController::class) - ->middleware(['signed', 'throttle:6,1']) - ->name('verification.verify'); + Route::get('verify-email/{id}/{hash}', VerifyEmailController::class) + ->middleware(['signed', 'throttle:6,1']) + ->name('verification.verify'); - Route::post('email/verification-notification', [EmailVerificationNotificationController::class, 'store']) - ->middleware('throttle:6,1') - ->name('verification.send'); + Route::post('email/verification-notification', [EmailVerificationNotificationController::class, 'store']) + ->middleware('throttle:6,1') + ->name('verification.send'); - Route::put('password', [PasswordController::class, 'update'])->name('password.update'); + Route::put('password', [PasswordController::class, 'update'])->name('password.update'); - Route::delete('socialite/{provider}', [SocialiteController::class, 'disconnect']) - ->name('auth.socialite.disconnect'); + Route::delete('socialite/{provider}', [SocialiteController::class, 'disconnect']) + ->name('auth.socialite.disconnect'); - Route::post('impersonate/{userId}', ReimpersonateController::class) - ->name('auth.impersonate.reimpersonate'); - }); + Route::post('impersonate/{userId}', ReimpersonateController::class) + ->name('auth.impersonate.reimpersonate'); + }); + + /** + * Socialite Routes + * + * These routes are placed outside the auth/guest middleware groups because: + * - Guests can use them for social login/registration + * - Authenticated users can use them to connect additional social providers + */ + Route::get('socialite/{provider}', [SocialiteController::class, 'redirect']) + ->name('auth.socialite.redirect'); - /** - * Socialite Routes - * - * These routes are placed outside the auth/guest middleware groups because: - * - Guests can use them for social login/registration - * - Authenticated users can use them to connect additional social providers - */ - Route::get('socialite/{provider}', [SocialiteController::class, 'redirect']) - ->name('auth.socialite.redirect'); - - Route::get('socialite/{provider}/callback', [SocialiteController::class, 'callback']) - ->name('auth.socialite.callback'); - - /** - * Magic Link Authentication - * - * Placed outside guest/auth groups because the user clicking the link - * is not yet authenticated (they are in their email client). - */ - Route::get('magic-link/{token}', [MagicLinkController::class, 'authenticate']) - ->middleware('throttle:10,1') - ->name('magic-link.authenticate'); + Route::get('socialite/{provider}/callback', [SocialiteController::class, 'callback']) + ->name('auth.socialite.callback'); + + /** + * Magic Link Authentication + * + * Placed outside guest/auth groups because the user clicking the link + * is not yet authenticated (they are in their email client). + */ + Route::get('magic-link/{token}', [MagicLinkController::class, 'authenticate']) + ->middleware('throttle:10,1') + ->name('magic-link.authenticate'); + }); }); diff --git a/app/Exceptions/AuthException.php b/src/Exceptions/AuthException.php similarity index 92% rename from app/Exceptions/AuthException.php rename to src/Exceptions/AuthException.php index 730ea8b..85b32f0 100644 --- a/app/Exceptions/AuthException.php +++ b/src/Exceptions/AuthException.php @@ -30,7 +30,7 @@ public function __construct(string $message = 'Authentication error.') */ public static function invalidCredentials(): self { - return new self(trans('auth.failed')); + return new self(trans('auth::auth.failed')); } /** @@ -41,7 +41,7 @@ public static function invalidCredentials(): self */ public static function throttle(int $seconds = 60): self { - $message = trans('auth.throttle', [ + $message = trans('auth::auth.throttle', [ 'seconds' => $seconds, 'minutes' => ceil($seconds / 60), ]); diff --git a/app/Exceptions/SocialiteException.php b/src/Exceptions/SocialiteException.php similarity index 52% rename from app/Exceptions/SocialiteException.php rename to src/Exceptions/SocialiteException.php index 3d7a1b2..097c83a 100644 --- a/app/Exceptions/SocialiteException.php +++ b/src/Exceptions/SocialiteException.php @@ -8,36 +8,36 @@ class SocialiteException extends Exception { public static function invalidSocialUser(): self { - return new self(trans('socialite.invalid_user')); + return new self(trans('auth::socialite.invalid_user')); } public static function cannotDisconnectOnlyMethod(): self { - return new self(trans('socialite.cannot_disconnect_only_method')); + return new self(trans('auth::socialite.cannot_disconnect_only_method')); } public static function authenticationFailed(): self { - return new self(trans('socialite.error')); + return new self(trans('auth::socialite.error')); } public static function providerNotConnected(string $provider): self { - return new self(trans('socialite.not_connected', ['Provider' => $provider])); + return new self(trans('auth::socialite.not_connected', ['Provider' => $provider])); } public static function missingSocialAccountsRelation(): self { - return new self(trans('socialite.missing_social_accounts_relation')); + return new self(trans('auth::socialite.missing_social_accounts_relation')); } public static function accountAlreadyLinked(string $provider): self { - return new self(trans('socialite.account_already_linked', ['provider' => ucfirst($provider)])); + return new self(trans('auth::socialite.account_already_linked', ['provider' => ucfirst($provider)])); } public static function unsupportedProvider(string $provider): self { - return new self(trans('socialite.unsupported_provider', ['provider' => ucfirst($provider)])); + return new self(trans('auth::socialite.unsupported_provider', ['provider' => ucfirst($provider)])); } } diff --git a/app/Filament/AuthPlugin.php b/src/Filament/AuthPlugin.php similarity index 90% rename from app/Filament/AuthPlugin.php rename to src/Filament/AuthPlugin.php index 387fa3c..4a71c3c 100644 --- a/app/Filament/AuthPlugin.php +++ b/src/Filament/AuthPlugin.php @@ -23,6 +23,11 @@ public function getId(): string return 'auth'; } + public static function getNavigationGroupSort(): int + { + return 2; + } + public function boot(Panel $panel): void { $panel->navigationGroups([ diff --git a/app/Filament/Resources/Users/Pages/CreateUser.php b/src/Filament/Resources/Users/Pages/CreateUser.php similarity index 100% rename from app/Filament/Resources/Users/Pages/CreateUser.php rename to src/Filament/Resources/Users/Pages/CreateUser.php diff --git a/app/Filament/Resources/Users/Pages/EditUser.php b/src/Filament/Resources/Users/Pages/EditUser.php similarity index 100% rename from app/Filament/Resources/Users/Pages/EditUser.php rename to src/Filament/Resources/Users/Pages/EditUser.php diff --git a/app/Filament/Resources/Users/Pages/ListUsers.php b/src/Filament/Resources/Users/Pages/ListUsers.php similarity index 100% rename from app/Filament/Resources/Users/Pages/ListUsers.php rename to src/Filament/Resources/Users/Pages/ListUsers.php diff --git a/app/Filament/Resources/Users/Pages/ViewUser.php b/src/Filament/Resources/Users/Pages/ViewUser.php similarity index 100% rename from app/Filament/Resources/Users/Pages/ViewUser.php rename to src/Filament/Resources/Users/Pages/ViewUser.php diff --git a/app/Filament/Resources/Users/Schemas/UserForm.php b/src/Filament/Resources/Users/Schemas/UserForm.php similarity index 100% rename from app/Filament/Resources/Users/Schemas/UserForm.php rename to src/Filament/Resources/Users/Schemas/UserForm.php diff --git a/app/Filament/Resources/Users/Schemas/UserInfolist.php b/src/Filament/Resources/Users/Schemas/UserInfolist.php similarity index 100% rename from app/Filament/Resources/Users/Schemas/UserInfolist.php rename to src/Filament/Resources/Users/Schemas/UserInfolist.php diff --git a/app/Filament/Resources/Users/Tables/UsersTable.php b/src/Filament/Resources/Users/Tables/UsersTable.php similarity index 100% rename from app/Filament/Resources/Users/Tables/UsersTable.php rename to src/Filament/Resources/Users/Tables/UsersTable.php diff --git a/app/Filament/Resources/Users/UserResource.php b/src/Filament/Resources/Users/UserResource.php similarity index 100% rename from app/Filament/Resources/Users/UserResource.php rename to src/Filament/Resources/Users/UserResource.php diff --git a/app/Http/Controllers/Controller.php b/src/Http/Controllers/Controller.php similarity index 100% rename from app/Http/Controllers/Controller.php rename to src/Http/Controllers/Controller.php diff --git a/app/Http/Controllers/EmailVerificationNotificationController.php b/src/Http/Controllers/EmailVerificationNotificationController.php similarity index 86% rename from app/Http/Controllers/EmailVerificationNotificationController.php rename to src/Http/Controllers/EmailVerificationNotificationController.php index d2cc516..22f9ce6 100644 --- a/app/Http/Controllers/EmailVerificationNotificationController.php +++ b/src/Http/Controllers/EmailVerificationNotificationController.php @@ -18,6 +18,6 @@ public function store(Request $request): RedirectResponse $request->user()->sendEmailVerificationNotification(); - return back()->with('status', trans('auth.verification-link-sent')); + return back()->with('status', trans('auth::auth.verification-link-sent')); } } diff --git a/app/Http/Controllers/EmailVerificationPromptController.php b/src/Http/Controllers/EmailVerificationPromptController.php similarity index 100% rename from app/Http/Controllers/EmailVerificationPromptController.php rename to src/Http/Controllers/EmailVerificationPromptController.php diff --git a/app/Http/Controllers/ForgotPasswordController.php b/src/Http/Controllers/ForgotPasswordController.php similarity index 92% rename from app/Http/Controllers/ForgotPasswordController.php rename to src/Http/Controllers/ForgotPasswordController.php index daf9b75..ff861c9 100644 --- a/app/Http/Controllers/ForgotPasswordController.php +++ b/src/Http/Controllers/ForgotPasswordController.php @@ -37,6 +37,6 @@ public function store(Request $request): RedirectResponse $request->only('email') ); - return back()->with('status', trans('passwords.sent')); + return back()->with('status', trans('auth::passwords.sent')); } } diff --git a/app/Http/Controllers/LoginController.php b/src/Http/Controllers/LoginController.php similarity index 95% rename from app/Http/Controllers/LoginController.php rename to src/Http/Controllers/LoginController.php index 2618caf..c1a0017 100644 --- a/app/Http/Controllers/LoginController.php +++ b/src/Http/Controllers/LoginController.php @@ -41,7 +41,7 @@ public function store(LoginRequest $request) request()->session()->regenerate(); Toast::default( - __('auth.welcome-back', ['name' => $user->name]), + __('auth::auth.welcome-back', ['name' => $user->name]), ); if ($request->session()->has('url.intended')) { diff --git a/app/Http/Controllers/MagicLinkController.php b/src/Http/Controllers/MagicLinkController.php similarity index 93% rename from app/Http/Controllers/MagicLinkController.php rename to src/Http/Controllers/MagicLinkController.php index 821635a..03c6ad8 100644 --- a/app/Http/Controllers/MagicLinkController.php +++ b/src/Http/Controllers/MagicLinkController.php @@ -67,7 +67,7 @@ public function store(Request $request): RedirectResponse $user->notify(new MagicLinkNotification($url)); } - return back()->with('status', __('auth.magic-link-sent')); + return back()->with('status', __('auth::auth.magic-link-sent')); } /** @@ -85,21 +85,21 @@ public function authenticate(Request $request, string $token): \Symfony\Componen ->update(['used_at' => $now]); if (! $consumed) { - return redirect()->route('login')->with('error', __('auth.magic-link-expired')); + return redirect()->route('login')->with('error', __('auth::auth.magic-link-expired')); } $record = MagicLinkToken::where('token', $hashed)->with('user')->first(); $user = $record?->user; if ($user === null) { - return redirect()->route('login')->with('error', __('auth.magic-link-expired')); + return redirect()->route('login')->with('error', __('auth::auth.magic-link-expired')); } Auth::login($user); $request->session()->regenerate(); - Toast::default(__('auth.welcome-back', ['name' => $user->name])); + Toast::default(__('auth::auth.welcome-back', ['name' => $user->name])); $intended = $request->query('intended'); diff --git a/app/Http/Controllers/PasswordController.php b/src/Http/Controllers/PasswordController.php similarity index 100% rename from app/Http/Controllers/PasswordController.php rename to src/Http/Controllers/PasswordController.php diff --git a/app/Http/Controllers/RegisterController.php b/src/Http/Controllers/RegisterController.php similarity index 94% rename from app/Http/Controllers/RegisterController.php rename to src/Http/Controllers/RegisterController.php index 5b28533..bc945f5 100644 --- a/app/Http/Controllers/RegisterController.php +++ b/src/Http/Controllers/RegisterController.php @@ -37,7 +37,7 @@ public function store(RegisterRequest $request): RedirectResponse Auth::login($user); Toast::default( - __('auth.welcome', ['name' => $user->name]), + __('auth::auth.welcome', ['name' => $user->name]), ); return redirect()->intended(route('dashboard')); diff --git a/app/Http/Controllers/ReimpersonateController.php b/src/Http/Controllers/ReimpersonateController.php similarity index 100% rename from app/Http/Controllers/ReimpersonateController.php rename to src/Http/Controllers/ReimpersonateController.php diff --git a/app/Http/Controllers/ResetPasswordController.php b/src/Http/Controllers/ResetPasswordController.php similarity index 96% rename from app/Http/Controllers/ResetPasswordController.php rename to src/Http/Controllers/ResetPasswordController.php index 5a702dc..220ed6d 100644 --- a/app/Http/Controllers/ResetPasswordController.php +++ b/src/Http/Controllers/ResetPasswordController.php @@ -59,9 +59,9 @@ function ($user) use ($request) { // the application's home authenticated view. If there is an error we can // redirect them back to where they came from with their error message. if ($status == Password::PASSWORD_RESET) { - return redirect()->route('login')->with('status', trans($status)); + return redirect()->route('login')->with('status', trans('auth::'.$status)); } - return back()->with('error', trans($status)); + return back()->with('error', trans('auth::'.$status)); } } diff --git a/app/Http/Controllers/SocialiteController.php b/src/Http/Controllers/SocialiteController.php similarity index 84% rename from app/Http/Controllers/SocialiteController.php rename to src/Http/Controllers/SocialiteController.php index d47c0f6..ea1e159 100644 --- a/app/Http/Controllers/SocialiteController.php +++ b/src/Http/Controllers/SocialiteController.php @@ -32,7 +32,7 @@ public function callback(string $provider): RedirectResponse ]); if ($validator->fails()) { - return back()->with('error', trans('socialite.error')); + return back()->with('error', trans('auth::socialite.error')); } // Check if user is already authenticated (account linking flow) @@ -41,11 +41,11 @@ public function callback(string $provider): RedirectResponse /** @var User $socialUser */ $socialUser = Socialite::driver($provider)->user(); $this->socialiteService->linkAccountToUser(Auth::user(), $provider, $socialUser); - Toast::success(trans('socialite.account_connected', ['provider' => ucfirst($provider)])); + Toast::success(trans('auth::socialite.account_connected', ['provider' => ucfirst($provider)])); } catch (SocialiteException $e) { Toast::error($e->getMessage()); } catch (\Exception $e) { - Toast::error(trans('socialite.error')); + Toast::error(trans('auth::socialite.error')); report($e); } finally { return back(); @@ -60,7 +60,7 @@ public function callback(string $provider): RedirectResponse request()->session()->regenerate(); Toast::default( - __($user->wasRecentlyCreated ? 'auth.welcome' : 'auth.welcome-back', [ + __($user->wasRecentlyCreated ? 'auth::auth.welcome' : 'auth::auth.welcome-back', [ 'name' => $user->name, ]), ); @@ -79,7 +79,7 @@ public function disconnect(string $provider): RedirectResponse try { $this->socialiteService->disconnectProvider($user, $provider); - Toast::success(trans('socialite.account_disconnected', ['provider' => $provider])); + Toast::success(trans('auth::socialite.account_disconnected', ['provider' => $provider])); } catch (SocialiteException $e) { Toast::error($e->getMessage()); } diff --git a/app/Http/Controllers/VerifyEmailController.php b/src/Http/Controllers/VerifyEmailController.php similarity index 100% rename from app/Http/Controllers/VerifyEmailController.php rename to src/Http/Controllers/VerifyEmailController.php diff --git a/app/Http/Middleware/EnsureMagicLinkEnabled.php b/src/Http/Middleware/EnsureMagicLinkEnabled.php similarity index 100% rename from app/Http/Middleware/EnsureMagicLinkEnabled.php rename to src/Http/Middleware/EnsureMagicLinkEnabled.php diff --git a/app/Http/Requests/LoginRequest.php b/src/Http/Requests/LoginRequest.php similarity index 100% rename from app/Http/Requests/LoginRequest.php rename to src/Http/Requests/LoginRequest.php diff --git a/app/Http/Requests/RegisterRequest.php b/src/Http/Requests/RegisterRequest.php similarity index 100% rename from app/Http/Requests/RegisterRequest.php rename to src/Http/Requests/RegisterRequest.php diff --git a/app/Listeners/AssignUserRole.php b/src/Listeners/AssignUserRole.php similarity index 100% rename from app/Listeners/AssignUserRole.php rename to src/Listeners/AssignUserRole.php diff --git a/app/Listeners/Impersonation.php b/src/Listeners/Impersonation.php similarity index 100% rename from app/Listeners/Impersonation.php rename to src/Listeners/Impersonation.php diff --git a/app/Listeners/SendWelcomeNotification.php b/src/Listeners/SendWelcomeNotification.php similarity index 100% rename from app/Listeners/SendWelcomeNotification.php rename to src/Listeners/SendWelcomeNotification.php diff --git a/app/Listeners/UpdateUserLastLogin.php b/src/Listeners/UpdateUserLastLogin.php similarity index 100% rename from app/Listeners/UpdateUserLastLogin.php rename to src/Listeners/UpdateUserLastLogin.php diff --git a/app/Models/MagicLinkToken.php b/src/Models/MagicLinkToken.php similarity index 100% rename from app/Models/MagicLinkToken.php rename to src/Models/MagicLinkToken.php diff --git a/app/Models/SocialAccount.php b/src/Models/SocialAccount.php similarity index 100% rename from app/Models/SocialAccount.php rename to src/Models/SocialAccount.php diff --git a/app/Notifications/MagicLinkNotification.php b/src/Notifications/MagicLinkNotification.php similarity index 100% rename from app/Notifications/MagicLinkNotification.php rename to src/Notifications/MagicLinkNotification.php diff --git a/app/Notifications/WelcomeNotification.php b/src/Notifications/WelcomeNotification.php similarity index 100% rename from app/Notifications/WelcomeNotification.php rename to src/Notifications/WelcomeNotification.php diff --git a/app/Providers/AuthServiceProvider.php b/src/Providers/AuthServiceProvider.php similarity index 91% rename from app/Providers/AuthServiceProvider.php rename to src/Providers/AuthServiceProvider.php index 6d563c9..54f703b 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/src/Providers/AuthServiceProvider.php @@ -12,19 +12,6 @@ class AuthServiceProvider extends ModuleServiceProvider { - protected string $name = 'Auth'; - - protected string $nameLower = 'auth'; - - protected array $providers = [ - RouteServiceProvider::class, - ]; - - public function boot(): void - { - parent::boot(); - } - /** * Share Inertia data globally. */ @@ -130,6 +117,6 @@ protected function registerConfig(): void { parent::registerConfig(); - $this->mergeConfigFrom(module_path($this->name, 'config/services.php'), 'services'); + $this->mergeConfigFrom(module_path($this->moduleName(), 'config/services.php'), 'services'); } } diff --git a/app/Services/SocialiteService.php b/src/Services/SocialiteService.php similarity index 100% rename from app/Services/SocialiteService.php rename to src/Services/SocialiteService.php diff --git a/app/Traits/Sociable.php b/src/Traits/Sociable.php similarity index 100% rename from app/Traits/Sociable.php rename to src/Traits/Sociable.php diff --git a/vite.config.js b/vite.config.js index e100344..589d91b 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,15 +1,2 @@ -/** - * Module Asset Configuration - * - * Define CSS, JS, and TS files to be compiled when this module is enabled. - * Paths are relative to this module's resources/assets/ directory. - * Vue components in resources/js/pages/ are discovered automatically. - * - * Example: - * export const paths = [ - * 'css/app.css', // Becomes modules/ModuleName/resources/assets/css/app.css - * 'js/app.ts', // Becomes modules/ModuleName/resources/assets/js/app.ts - * 'js/custom-feature.js', // Becomes modules/ModuleName/resources/assets/js/custom-feature.js - * ]; - */ -export const paths = ['css/style.css', 'js/app.ts']; +// TODO: figure out if we need to export anything here for the auth module's frontend assets. If not, we can just leave this file empty or delete it. +export default {};