Commit 75ab1d67 authored by DevPilot's avatar DevPilot

fix(auth+ui): repair signup/login cycle and broken navigation on desktop and mobile

- Stop starting a second Alpine instance in app.js; Livewire already ships and
  starts Alpine, so every x-on handler fired twice and toggles (user dropdown,
  logout menu) cancelled themselves out.
- Email verification links work on any device/browser (no longer require an
  existing session); the user is logged in after verifying.
- Add /dashboard role router; logged-in users hitting /login, /register or the
  landing page go to their dashboard instead of looping back to "Login".
- Case-insensitive email login/reset (mobile keyboards capitalise the first
  letter); emails and usernames are normalised to lowercase on signup.
- Accept local Egyptian (01xxxxxxxxx), 00-prefixed and spaced phone numbers.
- Role-based sidebar for every app page; new company sidebar replacing dead
  href="#" links; wire dashboard quick actions, bell, profile/settings menus.
- Guest header shows Login/Register (or Dashboard when signed in).
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent d1ee27c1
......@@ -4,10 +4,12 @@ namespace App\Modules\Auth\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Auth\Services\AuthService;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use App\Models\User;
use App\Shared\Enums\UserStatus;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class VerificationController extends Controller
......@@ -19,17 +21,26 @@ class VerificationController extends Controller
public function notice(Request $request): View|RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended('/');
return redirect()->intended('/dashboard');
}
return view('auth.verify-email');
}
public function verify(EmailVerificationRequest $request): RedirectResponse
public function verify(Request $request, string $id, string $hash): RedirectResponse
{
$this->authService->verifyEmail($request->user());
$user = User::findOrFail($id);
return redirect()->intended('/');
abort_unless(hash_equals(sha1($user->getEmailForVerification()), $hash), 403);
$this->authService->verifyEmail($user);
if ((!Auth::check() || Auth::id() !== $user->id) && UserStatus::from($user->status)->canLogin()) {
Auth::login($user);
$request->session()->regenerate();
}
return redirect()->intended('/dashboard');
}
public function resend(Request $request): JsonResponse|RedirectResponse
......@@ -38,7 +49,7 @@ class VerificationController extends Controller
if ($request->expectsJson()) {
return $this->success(null, __('auth.already_verified'));
}
return redirect()->intended('/');
return redirect()->intended('/dashboard');
}
$request->user()->sendEmailVerificationNotification();
......
......@@ -11,6 +11,11 @@ class ForgotPasswordRequest extends FormRequest
return true;
}
protected function prepareForValidation(): void
{
$this->merge(['email' => strtolower(trim((string) $this->input('email')))]);
}
public function rules(): array
{
return [
......
......@@ -14,6 +14,11 @@ class LoginRequest extends FormRequest
return true;
}
protected function prepareForValidation(): void
{
$this->merge(['email' => strtolower(trim((string) $this->input('email')))]);
}
public function rules(): array
{
return [
......
......@@ -12,6 +12,35 @@ class RegisterCompanyRequest extends FormRequest
return true;
}
protected function prepareForValidation(): void
{
$this->merge([
'email' => strtolower(trim((string) $this->input('email'))),
'phone' => $this->normalizePhone($this->input('phone')),
]);
}
/**
* Accept the formats people actually type on a phone keyboard:
* spaces/dashes, a 00 prefix, or a local Egyptian 01xxxxxxxxx number.
*/
private function normalizePhone(?string $phone): ?string
{
if ($phone === null || trim($phone) === '') {
return null;
}
$phone = preg_replace('/[\s\-().]/', '', $phone);
if (str_starts_with($phone, '00')) {
$phone = '+' . substr($phone, 2);
} elseif (preg_match('/^01\d{9}$/', $phone)) {
$phone = '+2' . $phone;
}
return $phone;
}
public function rules(): array
{
return [
......
......@@ -12,6 +12,36 @@ class RegisterCreatorRequest extends FormRequest
return true;
}
protected function prepareForValidation(): void
{
$this->merge([
'email' => strtolower(trim((string) $this->input('email'))),
'phone' => $this->normalizePhone($this->input('phone')),
'username' => strtolower(trim((string) $this->input('username'))),
]);
}
/**
* Accept the formats people actually type on a phone keyboard:
* spaces/dashes, a 00 prefix, or a local Egyptian 01xxxxxxxxx number.
*/
private function normalizePhone(?string $phone): ?string
{
if ($phone === null || trim($phone) === '') {
return null;
}
$phone = preg_replace('/[\s\-().]/', '', $phone);
if (str_starts_with($phone, '00')) {
$phone = '+' . substr($phone, 2);
} elseif (preg_match('/^01\d{9}$/', $phone)) {
$phone = '+2' . $phone;
}
return $phone;
}
public function rules(): array
{
return [
......
......@@ -12,6 +12,11 @@ class ResetPasswordRequest extends FormRequest
return true;
}
protected function prepareForValidation(): void
{
$this->merge(['email' => strtolower(trim((string) $this->input('email')))]);
}
public function rules(): array
{
return [
......
......@@ -42,10 +42,13 @@ Route::middleware('auth')->group(function () {
Route::get('/email/verify', [VerificationController::class, 'notice'])
->name('verification.notice');
Route::get('/email/verify/{id}/{hash}', [VerificationController::class, 'verify'])
->middleware('signed')
->name('verification.verify');
Route::post('/email/verification-notification', [VerificationController::class, 'resend'])
->middleware('throttle:6,1')
->name('verification.send');
});
// Verification links must work when opened on another device/browser
// (e.g. signed up on desktop, tapped the email link on a phone).
Route::get('/email/verify/{id}/{hash}', [VerificationController::class, 'verify'])
->middleware(['signed', 'throttle:6,1'])
->name('verification.verify');
......@@ -81,7 +81,7 @@ class AuthService
public function attemptLogin(array $credentials, bool $remember = false): array
{
$user = User::where('email', $credentials['email'])->first();
$user = User::whereRaw('lower(email) = ?', [strtolower(trim($credentials['email']))])->first();
if (!$user || !Hash::check($credentials['password'], $user->password)) {
return ['success' => false, 'reason' => 'invalid_credentials'];
......@@ -122,7 +122,7 @@ class AuthService
public function sendPasswordResetLink(string $email): string
{
$user = User::where('email', $email)->first();
$user = User::whereRaw('lower(email) = ?', [strtolower(trim($email))])->first();
if (!$user) {
return Password::RESET_LINK_SENT;
......@@ -132,7 +132,7 @@ class AuthService
return Password::RESET_LINK_SENT;
}
$status = Password::sendResetLink(['email' => $email]);
$status = Password::sendResetLink(['email' => $user->email]);
if ($status === Password::RESET_LINK_SENT) {
event(new PasswordResetRequested($user));
......
......@@ -14,6 +14,10 @@ return Application::configure(basePath: dirname(__DIR__))
->withMiddleware(function (Middleware $middleware) {
$middleware->trustProxies(at: '*');
// Logged-in users hitting /login or /register go to their dashboard,
// not back to the landing page (which only offers "Login" again).
$middleware->redirectUsersTo('/dashboard');
$middleware->web(append: [
\App\Shared\Middleware\SetLocale::class,
\App\Shared\Middleware\SecurityHeaders::class,
......
import Alpine from 'alpinejs';
import { createIcons, icons } from 'lucide';
window.Alpine = Alpine;
Alpine.start();
// Alpine is bundled and started by Livewire (@livewireScripts). Starting a
// second copy here made every x-on handler fire twice, so toggles such as
// `open = !open` cancelled themselves out (dropdowns, logout menu, etc.).
createIcons({ icons });
const renderIcons = () => createIcons({ icons });
document.addEventListener('livewire:navigated', () => {
createIcons({ icons });
});
renderIcons();
document.addEventListener('livewire:navigated', renderIcons);
document.addEventListener('alpine:initialized', renderIcons);
<x-layouts.app :title="__('companies.dashboard_title')">
<x-slot:sidebar>
<x-ui.sidebar-group :label="__('ui.navigation')">
<x-ui.sidebar-item icon="layout-dashboard" :href="route('company.dashboard')" :active="true">
{{ __('companies.dashboard_title') }}
</x-ui.sidebar-item>
<x-ui.sidebar-item icon="building-2" :href="route('company.profile.edit')">
{{ __('ui.profile') }}
</x-ui.sidebar-item>
<x-ui.sidebar-item icon="megaphone" href="#">
{{ __('ui.campaigns') }}
</x-ui.sidebar-item>
<x-ui.sidebar-item icon="briefcase" href="#">
{{ __('ui.projects') }}
</x-ui.sidebar-item>
<x-ui.sidebar-item icon="users" href="#">
{{ __('ui.discover_creators') }}
</x-ui.sidebar-item>
<x-ui.sidebar-item icon="mail" href="#">
{{ __('ui.messages') }}
</x-ui.sidebar-item>
</x-ui.sidebar-group>
@include('components.layouts.company-sidebar')
</x-slot:sidebar>
<x-slot:header>
......@@ -61,13 +42,13 @@
{{ __('ui.quick_actions') }}
</h3>
<div class="space-y-2">
<a href="#" class="flex items-center gap-3 p-3 rounded-xl hover:bg-[var(--color-surface-hover)] transition-all duration-200 group">
<a href="{{ route('company.campaigns.create') }}" class="flex items-center gap-3 p-3 rounded-xl hover:bg-[var(--color-surface-hover)] transition-all duration-200 group">
<div class="w-9 h-9 rounded-lg bg-[var(--color-primary)]/10 flex items-center justify-center transition-transform duration-200 group-hover:scale-110">
<x-ui.icon name="plus" size="sm" class="text-[var(--color-primary)]" />
</div>
<span class="text-sm text-[var(--color-text-primary)]">{{ __('ui.create_campaign') }}</span>
</a>
<a href="#" class="flex items-center gap-3 p-3 rounded-xl hover:bg-[var(--color-surface-hover)] transition-all duration-200 group">
<a href="{{ route('company.discover') }}" class="flex items-center gap-3 p-3 rounded-xl hover:bg-[var(--color-surface-hover)] transition-all duration-200 group">
<div class="w-9 h-9 rounded-lg bg-[var(--color-success)]/10 flex items-center justify-center transition-transform duration-200 group-hover:scale-110">
<x-ui.icon name="users" size="sm" class="text-[var(--color-success)]" />
</div>
......
<x-layouts.app :title="__('ui.edit_profile')">
<x-slot:sidebar>
<x-ui.sidebar-group :label="__('ui.navigation')">
<x-ui.sidebar-item icon="layout-dashboard" :href="route('company.dashboard')">
{{ __('companies.dashboard_title') }}
</x-ui.sidebar-item>
<x-ui.sidebar-item icon="building-2" :href="route('company.profile.edit')" :active="true">
{{ __('ui.profile') }}
</x-ui.sidebar-item>
<x-ui.sidebar-item icon="megaphone" href="#">
{{ __('ui.campaigns') }}
</x-ui.sidebar-item>
<x-ui.sidebar-item icon="briefcase" href="#">
{{ __('ui.projects') }}
</x-ui.sidebar-item>
</x-ui.sidebar-group>
@include('components.layouts.company-sidebar')
</x-slot:sidebar>
<x-slot:header>
......
......@@ -151,8 +151,8 @@
</button>
</x-slot:trigger>
<x-ui.dropdown-item icon="user" href="#">{{ __('ui.profile') }}</x-ui.dropdown-item>
<x-ui.dropdown-item icon="settings" href="#">{{ __('ui.settings') }}</x-ui.dropdown-item>
<x-ui.dropdown-item icon="user" :href="route('admin.dashboard')">{{ __('ui.profile') }}</x-ui.dropdown-item>
<x-ui.dropdown-item icon="settings" :href="route('admin.settings.branding')">{{ __('ui.settings') }}</x-ui.dropdown-item>
<div class="border-t border-[var(--color-border)] my-1"></div>
<form method="POST" action="{{ route('logout') }}">
@csrf
......
......@@ -56,7 +56,15 @@
{{-- Sidebar Navigation --}}
<nav class="flex-1 overflow-y-auto px-3 py-4 space-y-1">
@if(auth()->user()->role === 'creator')
@include('components.layouts.creator-sidebar')
@elseif(auth()->user()->role === 'company')
@include('components.layouts.company-sidebar')
@elseif(auth()->user()->role === 'admin')
@include('components.layouts.admin-sidebar')
@else
{{ $sidebar ?? '' }}
@endif
</nav>
{{-- Sidebar Footer --}}
......@@ -99,9 +107,9 @@
<div class="flex items-center gap-2">
{{-- Notifications --}}
<button class="relative p-2 rounded-lg text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)] transition-all duration-200">
<a href="{{ route('notifications.index') }}" aria-label="{{ __('ui.notifications') }}" class="relative p-2 rounded-lg text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)] transition-all duration-200">
<x-ui.icon name="bell" size="sm" />
</button>
</a>
{{-- Locale Switcher --}}
<a
......@@ -120,7 +128,7 @@
</button>
</x-slot:trigger>
<x-ui.dropdown-item icon="user" :href="auth()->user()->role === 'creator' ? route('creator.profile.edit') : '#'">{{ __('ui.profile') }}</x-ui.dropdown-item>
<x-ui.dropdown-item icon="user" :href="match (auth()->user()->role) { 'creator' => route('creator.profile.edit'), 'company' => route('company.profile.edit'), default => route('admin.dashboard') }">{{ __('ui.profile') }}</x-ui.dropdown-item>
<x-ui.dropdown-item icon="settings" :href="auth()->user()->role === 'admin' ? route('admin.settings.branding') : route('notifications.preferences')">{{ __('ui.settings') }}</x-ui.dropdown-item>
<div class="border-t border-[var(--color-border)] my-1"></div>
<form method="POST" action="{{ route('logout') }}">
......
<x-ui.sidebar-group :label="__('ui.main_menu')">
<x-ui.sidebar-item icon="layout-dashboard" :href="route('company.dashboard')" :active="request()->routeIs('company.dashboard')">
{{ __('ui.dashboard') }}
</x-ui.sidebar-item>
<x-ui.sidebar-item icon="building-2" :href="route('company.profile.edit')" :active="request()->routeIs('company.profile.*')">
{{ __('ui.profile') }}
</x-ui.sidebar-item>
</x-ui.sidebar-group>
<x-ui.sidebar-group :label="__('creators.work')">
<x-ui.sidebar-item icon="megaphone" :href="route('company.campaigns.index')" :active="request()->routeIs('company.campaigns.*')">
{{ __('campaigns.my_campaigns') }}
</x-ui.sidebar-item>
<x-ui.sidebar-item icon="folder-kanban" :href="route('company.projects.index')" :active="request()->routeIs('company.projects.*', 'company.deliverables.*')">
{{ __('ui.projects') }}
</x-ui.sidebar-item>
</x-ui.sidebar-group>
<x-ui.sidebar-group :label="__('ui.discover')">
<x-ui.sidebar-item icon="users" :href="route('company.discover')" :active="request()->routeIs('company.discover', 'company.creators.*')">
{{ __('ui.discover_creators') }}
</x-ui.sidebar-item>
<x-ui.sidebar-item icon="send" :href="route('company.invitations.index')" :active="request()->routeIs('company.invitations.*')">
{{ __('invitations.sent_invitations') }}
</x-ui.sidebar-item>
</x-ui.sidebar-group>
<x-ui.sidebar-group :label="__('ui.communication')">
<x-ui.sidebar-item icon="message-square" :href="route('messaging.index')" :active="request()->routeIs('messaging.*')">
{{ __('messaging.inbox') }}
</x-ui.sidebar-item>
<x-ui.sidebar-item icon="bell" :href="route('notifications.index')" :active="request()->routeIs('notifications.*')">
{{ __('notifications.notifications') }}
</x-ui.sidebar-item>
</x-ui.sidebar-group>
......@@ -45,7 +45,26 @@
<x-ui.icon name="globe" size="sm" />
</a>
{{ $actions ?? '' }}
@isset($actions)
{{ $actions }}
@else
@auth
<x-ui.button href="{{ route('dashboard') }}" variant="primary" size="sm" icon="layout-dashboard">
{{ __('ui.dashboard') }}
</x-ui.button>
@else
@unless(request()->routeIs('login'))
<a href="{{ route('login') }}" class="text-sm font-medium text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors duration-200">
{{ __('auth.login') }}
</a>
@endunless
@unless(request()->routeIs('register*'))
<x-ui.button href="{{ route('register') }}" variant="primary" size="sm">
{{ __('auth.register') }}
</x-ui.button>
@endunless
@endauth
@endisset
</div>
</div>
</div>
......
......@@ -82,13 +82,13 @@
</div>
<span class="text-sm text-[var(--color-text-primary)]">{{ __('ui.edit_profile') }}</span>
</a>
<a href="#" class="flex items-center gap-3 p-3 rounded-xl hover:bg-[var(--color-surface-hover)] transition-all duration-200 group">
<a href="{{ route('campaigns.discover') }}" class="flex items-center gap-3 p-3 rounded-xl hover:bg-[var(--color-surface-hover)] transition-all duration-200 group">
<div class="w-9 h-9 rounded-lg bg-[var(--color-success)]/10 flex items-center justify-center transition-transform duration-200 group-hover:scale-110">
<x-ui.icon name="search" size="sm" class="text-[var(--color-success)]" />
</div>
<span class="text-sm text-[var(--color-text-primary)]">{{ __('ui.browse_campaigns') }}</span>
</a>
<a href="#" class="flex items-center gap-3 p-3 rounded-xl hover:bg-[var(--color-surface-hover)] transition-all duration-200 group">
<a href="{{ route('portfolio.create') }}" class="flex items-center gap-3 p-3 rounded-xl hover:bg-[var(--color-surface-hover)] transition-all duration-200 group">
<div class="w-9 h-9 rounded-lg bg-[var(--color-warning)]/10 flex items-center justify-center transition-transform duration-200 group-hover:scale-110">
<x-ui.icon name="upload" size="sm" class="text-[var(--color-warning)]" />
</div>
......
......@@ -125,8 +125,8 @@
</button>
</x-slot:trigger>
<x-ui.dropdown-item icon="user" href="#">{{ __('ui.profile') }}</x-ui.dropdown-item>
<x-ui.dropdown-item icon="settings" href="#">{{ __('ui.settings') }}</x-ui.dropdown-item>
<x-ui.dropdown-item icon="user" :href="route('admin.dashboard')">{{ __('ui.profile') }}</x-ui.dropdown-item>
<x-ui.dropdown-item icon="settings" :href="route('admin.settings.branding')">{{ __('ui.settings') }}</x-ui.dropdown-item>
<div class="border-t border-[var(--color-border)] my-1"></div>
<form method="POST" action="{{ route('logout') }}">
@csrf
......
......@@ -99,9 +99,9 @@
<div class="flex items-center gap-2">
{{-- Notifications --}}
<button class="relative p-2 rounded-lg text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)] transition-all duration-200">
<a href="{{ route('notifications.index') }}" aria-label="{{ __('ui.notifications') }}" class="relative p-2 rounded-lg text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)] transition-all duration-200">
<x-ui.icon name="bell" size="sm" />
</button>
</a>
{{-- Locale Switcher --}}
<a
......@@ -120,7 +120,7 @@
</button>
</x-slot:trigger>
<x-ui.dropdown-item icon="user" :href="auth()->user()->role === 'creator' ? route('creator.profile.edit') : '#'">{{ __('ui.profile') }}</x-ui.dropdown-item>
<x-ui.dropdown-item icon="user" :href="match (auth()->user()->role) { 'creator' => route('creator.profile.edit'), 'company' => route('company.profile.edit'), default => route('admin.dashboard') }">{{ __('ui.profile') }}</x-ui.dropdown-item>
<x-ui.dropdown-item icon="settings" :href="auth()->user()->role === 'admin' ? route('admin.settings.branding') : route('notifications.preferences')">{{ __('ui.settings') }}</x-ui.dropdown-item>
<div class="border-t border-[var(--color-border)] my-1"></div>
<form method="POST" action="{{ route('logout') }}">
......
......@@ -44,7 +44,26 @@
<x-ui.icon name="globe" size="sm" />
</a>
{{ $actions ?? '' }}
@isset($actions)
{{ $actions }}
@else
@auth
<x-ui.button href="{{ route('dashboard') }}" variant="primary" size="sm" icon="layout-dashboard">
{{ __('ui.dashboard') }}
</x-ui.button>
@else
@unless(request()->routeIs('login'))
<a href="{{ route('login') }}" class="text-sm font-medium text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors duration-200">
{{ __('auth.login') }}
</a>
@endunless
@unless(request()->routeIs('register*'))
<x-ui.button href="{{ route('register') }}" variant="primary" size="sm">
{{ __('auth.register') }}
</x-ui.button>
@endunless
@endauth
@endisset
</div>
</div>
</div>
......
......@@ -12,12 +12,18 @@
{{ __('landing.hero_subtitle') }}
</p>
<div class="flex flex-col sm:flex-row items-center justify-center gap-4">
@auth
<x-ui.button href="{{ route('dashboard') }}" variant="primary" size="lg" icon="arrow-right" iconPosition="end">
{{ __('ui.dashboard') }}
</x-ui.button>
@else
<x-ui.button href="{{ route('register') }}" variant="primary" size="lg" icon="arrow-right" iconPosition="end">
{{ __('landing.cta_get_started') }}
</x-ui.button>
<x-ui.button href="{{ route('login') }}" variant="secondary" size="lg" icon="log-in">
{{ __('auth.login') }}
</x-ui.button>
@endauth
</div>
</div>
</section>
......@@ -143,7 +149,7 @@
@endforeach
</ul>
<div class="mt-8">
<x-ui.button href="{{ route('register') }}?role=creator" variant="primary" size="md" icon="arrow-right" iconPosition="end">
<x-ui.button href="{{ route('register.creator') }}" variant="primary" size="md" icon="arrow-right" iconPosition="end">
{{ __('landing.cta_creator') }}
</x-ui.button>
</div>
......@@ -165,7 +171,7 @@
@endforeach
</ul>
<div class="mt-8">
<x-ui.button href="{{ route('register') }}?role=company" variant="primary" size="md" icon="arrow-right" iconPosition="end">
<x-ui.button href="{{ route('register.company') }}" variant="primary" size="md" icon="arrow-right" iconPosition="end">
{{ __('landing.cta_company') }}
</x-ui.button>
</div>
......
......@@ -6,6 +6,16 @@ Route::get('/', function () {
return view('welcome');
})->name('home');
// Single entry point after login/verification: send each role to its own home.
Route::get('/dashboard', function () {
return redirect(match (auth()->user()->role) {
'admin' => route('admin.dashboard'),
'company' => route('company.dashboard'),
'creator' => route('creator.dashboard'),
default => route('home'),
});
})->middleware('auth')->name('dashboard');
Route::get('/locale/{locale}', function (string $locale) {
if (in_array($locale, config('app.supported_locales', ['en', 'ar']))) {
session(['locale' => $locale]);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment