From 1196551a26b6f85dd5263cdf251d36ad3c92ae2d Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 20 Jan 2026 16:53:54 -0300 Subject: [PATCH] feat: improvements on invites --- app/Actions/Fortify/CreateNewUser.php | 57 ---- .../Fortify/PasswordValidationRules.php | 18 -- app/Actions/Fortify/ResetUserPassword.php | 29 -- app/Enums/WorkspaceInvite/Status.php | 9 - .../Auth/AcceptInviteController.php | 97 ++++++ .../Auth/AuthenticatedSessionController.php | 61 ++++ ...mailVerificationNotificationController.php | 26 ++ .../EmailVerificationPromptController.php | 26 ++ .../Auth/NewPasswordController.php | 59 ++++ .../Auth/PasswordResetLinkController.php | 44 +++ .../Auth/RegisteredUserController.php | 87 +++++ .../Auth/VerifyEmailController.php | 29 ++ .../TwoFactorAuthenticationController.php | 37 --- app/Http/Controllers/WorkspaceController.php | 6 +- .../Controllers/WorkspaceInviteController.php | 89 +---- app/Http/Requests/Auth/LoginRequest.php | 87 +++++ .../TwoFactorAuthenticationRequest.php | 30 -- app/Http/Responses/LoginResponse.php | 33 -- app/Http/Responses/RegisterResponse.php | 21 -- app/Mail/WorkspaceInvite.php | 4 +- app/Models/Traits/HasWorkspace.php | 20 +- app/Models/User.php | 4 +- app/Models/WorkspaceInvite.php | 52 +-- app/Providers/FortifyServiceProvider.php | 99 ------ bootstrap/app.php | 5 + bootstrap/providers.php | 1 - composer.json | 1 - composer.lock | 222 +------------ config/fortify.php | 157 --------- database/factories/WorkspaceInviteFactory.php | 18 +- ..._233835_create_workspace_invites_table.php | 9 +- maizzle/templates/workspace-invite.html | 2 +- resources/css/app.css | 183 ++++++----- resources/js/components/AppSidebar.vue | 2 +- .../js/components/TwoFactorRecoveryCodes.vue | 124 ------- .../js/components/TwoFactorSetupModal.vue | 304 ------------------ resources/js/composables/useTwoFactorAuth.ts | 105 ------ resources/js/date.ts | 5 - resources/js/layouts/settings/Layout.vue | 5 - resources/js/pages/auth/AcceptInvite.vue | 107 ++++++ resources/js/pages/auth/Login.vue | 2 + resources/js/pages/auth/Register.vue | 2 + .../js/pages/auth/TwoFactorChallenge.vue | 139 -------- resources/js/pages/invites/Accept.vue | 138 -------- resources/js/pages/settings/Members.vue | 30 +- resources/js/pages/settings/TwoFactor.vue | 125 ------- .../views/mail/workspace-invite.blade.php | 2 +- routes/auth.php | 46 +++ routes/settings.php | 4 - routes/web.php | 6 - tests/Feature/AcceptInviteControllerTest.php | 197 ++++++++++++ tests/Feature/Auth/AuthenticationTest.php | 44 +-- .../Feature/Auth/PasswordConfirmationTest.php | 22 -- tests/Feature/Auth/PasswordResetTest.php | 6 +- tests/Feature/Auth/RegistrationTest.php | 27 +- tests/Feature/Auth/TwoFactorChallengeTest.php | 45 --- .../Settings/TwoFactorAuthenticationTest.php | 79 ----- tests/Feature/WorkspaceControllerTest.php | 7 +- .../Feature/WorkspaceInviteControllerTest.php | 85 ----- tests/Unit/Mail/WorkspaceInviteTest.php | 9 +- tests/Unit/Responses/LoginResponseTest.php | 87 ----- tests/Unit/Responses/RegisterResponseTest.php | 38 --- tests/Unit/Traits/HasWorkspaceTest.php | 37 ++- 63 files changed, 1076 insertions(+), 2375 deletions(-) delete mode 100644 app/Actions/Fortify/CreateNewUser.php delete mode 100644 app/Actions/Fortify/PasswordValidationRules.php delete mode 100644 app/Actions/Fortify/ResetUserPassword.php delete mode 100644 app/Enums/WorkspaceInvite/Status.php create mode 100644 app/Http/Controllers/Auth/AcceptInviteController.php create mode 100644 app/Http/Controllers/Auth/AuthenticatedSessionController.php create mode 100644 app/Http/Controllers/Auth/EmailVerificationNotificationController.php create mode 100644 app/Http/Controllers/Auth/EmailVerificationPromptController.php create mode 100644 app/Http/Controllers/Auth/NewPasswordController.php create mode 100644 app/Http/Controllers/Auth/PasswordResetLinkController.php create mode 100644 app/Http/Controllers/Auth/RegisteredUserController.php create mode 100644 app/Http/Controllers/Auth/VerifyEmailController.php delete mode 100644 app/Http/Controllers/Settings/TwoFactorAuthenticationController.php create mode 100644 app/Http/Requests/Auth/LoginRequest.php delete mode 100644 app/Http/Requests/Settings/TwoFactorAuthenticationRequest.php delete mode 100644 app/Http/Responses/LoginResponse.php delete mode 100644 app/Http/Responses/RegisterResponse.php delete mode 100644 app/Providers/FortifyServiceProvider.php delete mode 100644 config/fortify.php delete mode 100644 resources/js/components/TwoFactorRecoveryCodes.vue delete mode 100644 resources/js/components/TwoFactorSetupModal.vue delete mode 100644 resources/js/composables/useTwoFactorAuth.ts create mode 100644 resources/js/pages/auth/AcceptInvite.vue delete mode 100644 resources/js/pages/auth/TwoFactorChallenge.vue delete mode 100644 resources/js/pages/invites/Accept.vue delete mode 100644 resources/js/pages/settings/TwoFactor.vue create mode 100644 routes/auth.php create mode 100644 tests/Feature/AcceptInviteControllerTest.php delete mode 100644 tests/Feature/Auth/PasswordConfirmationTest.php delete mode 100644 tests/Feature/Auth/TwoFactorChallengeTest.php delete mode 100644 tests/Feature/Settings/TwoFactorAuthenticationTest.php delete mode 100644 tests/Unit/Responses/LoginResponseTest.php delete mode 100644 tests/Unit/Responses/RegisterResponseTest.php diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php deleted file mode 100644 index 59982961..00000000 --- a/app/Actions/Fortify/CreateNewUser.php +++ /dev/null @@ -1,57 +0,0 @@ - $input - */ - public function create(array $input): User - { - Validator::make($input, [ - 'name' => $this->nameRules(), - 'email' => $this->emailRules(), - 'password' => ['required', 'string', Password::default()], - ])->validate(); - - return DB::transaction(function () use ($input) { - $defaultLanguage = Language::where('code', 'en-US')->first(); - - $user = User::create([ - 'name' => $input['name'], - 'email' => $input['email'], - 'password' => $input['password'], - 'setup' => Setup::Role, - 'language_id' => $defaultLanguage?->id, - ]); - - // Create default workspace for new user - $workspace = $user->workspaces()->create([ - 'name' => 'My Workspace', - 'timezone' => 'UTC', - ]); - - // Add user as owner member - $workspace->members()->attach($user->id, ['role' => 'owner']); - - // Set as current workspace - $user->update(['current_workspace_id' => $workspace->id]); - - return $user; - }); - } -} diff --git a/app/Actions/Fortify/PasswordValidationRules.php b/app/Actions/Fortify/PasswordValidationRules.php deleted file mode 100644 index 76b19d33..00000000 --- a/app/Actions/Fortify/PasswordValidationRules.php +++ /dev/null @@ -1,18 +0,0 @@ -|string> - */ - protected function passwordRules(): array - { - return ['required', 'string', Password::default(), 'confirmed']; - } -} diff --git a/app/Actions/Fortify/ResetUserPassword.php b/app/Actions/Fortify/ResetUserPassword.php deleted file mode 100644 index 8fda5ddd..00000000 --- a/app/Actions/Fortify/ResetUserPassword.php +++ /dev/null @@ -1,29 +0,0 @@ - $input - */ - public function reset(User $user, array $input): void - { - Validator::make($input, [ - 'password' => $this->passwordRules(), - ])->validate(); - - $user->forceFill([ - 'password' => $input['password'], - ])->save(); - } -} diff --git a/app/Enums/WorkspaceInvite/Status.php b/app/Enums/WorkspaceInvite/Status.php deleted file mode 100644 index e9ae4d1e..00000000 --- a/app/Enums/WorkspaceInvite/Status.php +++ /dev/null @@ -1,9 +0,0 @@ -load('workspace'); + + return Inertia::render('auth/AcceptInvite', [ + 'invite' => [ + 'id' => $invite->id, + 'email' => $invite->email, + 'role' => [ + 'value' => $invite->role->value, + 'label' => $invite->role->label(), + ], + 'workspace' => [ + 'id' => $invite->workspace->id, + 'name' => $invite->workspace->name, + ], + ], + ]); + } + + /** + * Accept the invite. + */ + public function accept(Request $request, WorkspaceInvite $invite): RedirectResponse + { + $user = $request->user(); + + // Verify the invite is for this user + if ($invite->email !== $user->email) { + session()->flash('flash.banner', 'This invite is for a different email address.'); + session()->flash('flash.bannerStyle', 'danger'); + + return redirect()->route('calendar'); + } + + // Check if already a member + if ($invite->workspace->hasMember($user)) { + $invite->delete(); + + session()->flash('flash.banner', 'You are already a member of this workspace.'); + session()->flash('flash.bannerStyle', 'info'); + + return redirect()->route('calendar'); + } + + // Accept the invite + $workspaceId = $invite->workspace_id; + $invite->accept($user); + $user->update(['current_workspace_id' => $workspaceId]); + + session()->flash('flash.banner', 'Welcome! You are now a member of the workspace.'); + session()->flash('flash.bannerStyle', 'success'); + + return redirect()->route('calendar'); + } + + /** + * Decline the invite. + */ + public function decline(Request $request, WorkspaceInvite $invite): RedirectResponse + { + $user = $request->user(); + + // Verify the invite is for this user + if ($invite->email !== $user->email) { + session()->flash('flash.banner', 'This invite is for a different email address.'); + session()->flash('flash.bannerStyle', 'danger'); + + return redirect()->route('calendar'); + } + + $invite->delete(); + + session()->flash('flash.banner', 'Invite declined.'); + session()->flash('flash.bannerStyle', 'info'); + + return redirect()->route('calendar'); + } +} diff --git a/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/app/Http/Controllers/Auth/AuthenticatedSessionController.php new file mode 100644 index 00000000..3ebb5036 --- /dev/null +++ b/app/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -0,0 +1,61 @@ + true, + 'canRegister' => true, + 'status' => session('status'), + 'email' => $request->query('email'), + 'redirect' => $request->query('redirect'), + ]); + } + + /** + * Handle an incoming authentication request. + */ + public function store(LoginRequest $request): RedirectResponse + { + $request->authenticate(); + + $request->session()->regenerate(); + + // Check for redirect param + if ($redirect = $request->input('redirect')) { + return redirect($redirect); + } + + return redirect()->intended(route('calendar')); + } + + /** + * Destroy an authenticated session. + */ + public function destroy(Request $request): RedirectResponse + { + Auth::guard('web')->logout(); + + $request->session()->invalidate(); + + $request->session()->regenerateToken(); + + return redirect('/'); + } +} diff --git a/app/Http/Controllers/Auth/EmailVerificationNotificationController.php b/app/Http/Controllers/Auth/EmailVerificationNotificationController.php new file mode 100644 index 00000000..bc769e67 --- /dev/null +++ b/app/Http/Controllers/Auth/EmailVerificationNotificationController.php @@ -0,0 +1,26 @@ +user()->hasVerifiedEmail()) { + return redirect()->intended(route('calendar')); + } + + $request->user()->sendEmailVerificationNotification(); + + return back()->with('status', 'verification-link-sent'); + } +} diff --git a/app/Http/Controllers/Auth/EmailVerificationPromptController.php b/app/Http/Controllers/Auth/EmailVerificationPromptController.php new file mode 100644 index 00000000..6364157f --- /dev/null +++ b/app/Http/Controllers/Auth/EmailVerificationPromptController.php @@ -0,0 +1,26 @@ +user()->hasVerifiedEmail() + ? redirect()->intended(route('calendar')) + : Inertia::render('auth/VerifyEmail', [ + 'status' => session('status'), + ]); + } +} diff --git a/app/Http/Controllers/Auth/NewPasswordController.php b/app/Http/Controllers/Auth/NewPasswordController.php new file mode 100644 index 00000000..7d60f33f --- /dev/null +++ b/app/Http/Controllers/Auth/NewPasswordController.php @@ -0,0 +1,59 @@ + $request->email, + 'token' => $request->route('token'), + ]); + } + + /** + * Handle an incoming new password request. + */ + public function store(Request $request): RedirectResponse + { + $request->validate([ + 'token' => ['required'], + 'email' => ['required', 'email'], + 'password' => ['required', 'confirmed', Rules\Password::defaults()], + ]); + + $status = Password::reset( + $request->only('email', 'password', 'password_confirmation', 'token'), + function ($user) use ($request) { + $user->forceFill([ + 'password' => Hash::make($request->password), + 'remember_token' => Str::random(60), + ])->save(); + + event(new PasswordReset($user)); + } + ); + + return $status == Password::PASSWORD_RESET + ? redirect()->route('login')->with('status', __($status)) + : back()->withInput($request->only('email')) + ->withErrors(['email' => __($status)]); + } +} diff --git a/app/Http/Controllers/Auth/PasswordResetLinkController.php b/app/Http/Controllers/Auth/PasswordResetLinkController.php new file mode 100644 index 00000000..d51d543e --- /dev/null +++ b/app/Http/Controllers/Auth/PasswordResetLinkController.php @@ -0,0 +1,44 @@ + session('status'), + ]); + } + + /** + * Handle an incoming password reset link request. + */ + public function store(Request $request): RedirectResponse + { + $request->validate([ + 'email' => ['required', 'email'], + ]); + + $status = Password::sendResetLink( + $request->only('email') + ); + + return $status == Password::RESET_LINK_SENT + ? back()->with('status', __($status)) + : back()->withInput($request->only('email')) + ->withErrors(['email' => __($status)]); + } +} diff --git a/app/Http/Controllers/Auth/RegisteredUserController.php b/app/Http/Controllers/Auth/RegisteredUserController.php new file mode 100644 index 00000000..eeb39bc4 --- /dev/null +++ b/app/Http/Controllers/Auth/RegisteredUserController.php @@ -0,0 +1,87 @@ + $request->query('email'), + 'redirect' => $request->query('redirect'), + ]); + } + + /** + * Handle an incoming registration request. + */ + public function store(Request $request): RedirectResponse + { + $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class], + 'password' => ['required', Rules\Password::defaults()], + ]); + + // Check if registering via invite link (redirect contains /invites/) + $isInviteRegistration = str_contains($request->input('redirect', ''), '/invites/'); + + $user = DB::transaction(function () use ($request, $isInviteRegistration) { + $defaultLanguage = Language::where('code', 'en-US')->first(); + + $user = User::create([ + 'name' => $request->name, + 'email' => $request->email, + 'password' => $request->password, + 'setup' => $isInviteRegistration ? Setup::Completed : Setup::Role, + 'language_id' => $defaultLanguage?->id, + 'email_verified_at' => $isInviteRegistration ? now() : null, + ]); + + // Create default workspace for new user + $workspace = Workspace::create([ + 'user_id' => $user->id, + 'name' => $user->name."'s Workspace", + 'timezone' => 'UTC', + ]); + + // Add user as owner member + $workspace->members()->attach($user->id, ['role' => 'owner']); + + // Set as current workspace + $user->update(['current_workspace_id' => $workspace->id]); + + return $user; + }); + + event(new Registered($user)); + + Auth::login($user); + + // Check for redirect param + if ($redirect = $request->input('redirect')) { + return redirect($redirect); + } + + return redirect()->route('onboarding.step1'); + } +} diff --git a/app/Http/Controllers/Auth/VerifyEmailController.php b/app/Http/Controllers/Auth/VerifyEmailController.php new file mode 100644 index 00000000..567e31d6 --- /dev/null +++ b/app/Http/Controllers/Auth/VerifyEmailController.php @@ -0,0 +1,29 @@ +user()->hasVerifiedEmail()) { + return redirect()->intended(route('calendar').'?verified=1'); + } + + if ($request->user()->markEmailAsVerified()) { + event(new Verified($request->user())); + } + + return redirect()->intended(route('calendar').'?verified=1'); + } +} diff --git a/app/Http/Controllers/Settings/TwoFactorAuthenticationController.php b/app/Http/Controllers/Settings/TwoFactorAuthenticationController.php deleted file mode 100644 index 4402383a..00000000 --- a/app/Http/Controllers/Settings/TwoFactorAuthenticationController.php +++ /dev/null @@ -1,37 +0,0 @@ -ensureStateIsValid(); - - return Inertia::render('settings/TwoFactor', [ - 'twoFactorEnabled' => $request->user()->hasEnabledTwoFactorAuthentication(), - 'requiresConfirmation' => Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm'), - ]); - } -} diff --git a/app/Http/Controllers/WorkspaceController.php b/app/Http/Controllers/WorkspaceController.php index d9b699cd..af594d90 100644 --- a/app/Http/Controllers/WorkspaceController.php +++ b/app/Http/Controllers/WorkspaceController.php @@ -59,11 +59,15 @@ public function store(StoreWorkspaceRequest $request): RedirectResponse ->with('message', 'Subscribe to create more workspaces.'); } - $workspace = $user->workspaces()->create([ + $workspace = Workspace::create([ + 'user_id' => $user->id, ...$request->validated(), 'timezone' => config('app.timezone', 'UTC'), ]); + // Add user as owner member + $workspace->members()->attach($user->id, ['role' => 'owner']); + // Set as current workspace $user->switchWorkspace($workspace); diff --git a/app/Http/Controllers/WorkspaceInviteController.php b/app/Http/Controllers/WorkspaceInviteController.php index 1f96b8c2..168b3e0d 100644 --- a/app/Http/Controllers/WorkspaceInviteController.php +++ b/app/Http/Controllers/WorkspaceInviteController.php @@ -1,5 +1,7 @@ $workspace, 'invites' => $workspace->invites() - ->with('inviter') ->latest() ->get(), 'members' => $workspace->members() @@ -66,12 +67,11 @@ public function store(StoreWorkspaceInviteRequest $request): RedirectResponse $existingInvite = $workspace->invites() ->where('email', $request->email) - ->pending() ->first(); if ($existingInvite) { return back()->withErrors([ - 'email' => 'A pending invite already exists for this email.', + 'email' => 'An invite already exists for this email.', ]); } @@ -82,7 +82,6 @@ public function store(StoreWorkspaceInviteRequest $request): RedirectResponse } $invite = $workspace->invites()->create([ - 'invited_by' => $request->user()->id, 'email' => $request->email, 'role' => $request->role ?? WorkspaceRole::Member, ]); @@ -117,88 +116,6 @@ public function destroy(Request $request, WorkspaceInvite $invite): RedirectResp return back(); } - public function show(Request $request, string $token): Response|RedirectResponse - { - $invite = WorkspaceInvite::where('token', $token) - ->with(['workspace', 'inviter']) - ->firstOrFail(); - - if (! $invite->isPending()) { - session()->flash('flash.banner', 'This invite is no longer valid.'); - session()->flash('flash.bannerStyle', 'danger'); - - return redirect()->route('login'); - } - - $user = $request->user(); - - // Store token in session for after login/register - session(['pending_invite_token' => $token]); - - return Inertia::render('invites/Accept', [ - 'invite' => [ - 'id' => $invite->id, - 'token' => $invite->token, - 'email' => $invite->email, - 'role' => [ - 'value' => $invite->role->value, - 'label' => $invite->role->label(), - ], - 'workspace' => [ - 'id' => $invite->workspace->id, - 'name' => $invite->workspace->name, - ], - 'inviter' => [ - 'id' => $invite->inviter->id, - 'name' => $invite->inviter->name, - 'email' => $invite->inviter->email, - ], - ], - 'isAuthenticated' => (bool) $user, - 'userEmail' => $user?->email, - ]); - } - - public function accept(Request $request, string $token): RedirectResponse - { - $invite = WorkspaceInvite::where('token', $token)->firstOrFail(); - - if (! $invite->isPending()) { - session()->flash('flash.banner', 'This invite is no longer valid.'); - session()->flash('flash.bannerStyle', 'danger'); - - return redirect()->route('login'); - } - - $user = $request->user(); - - if (! $user) { - session(['pending_invite_token' => $token]); - - return redirect()->route('login'); - } - - // Clear the pending token - session()->forget('pending_invite_token'); - - if ($invite->workspace->hasMember($user)) { - $user->switchWorkspace($invite->workspace); - - session()->flash('flash.banner', 'You are already a member of this workspace.'); - session()->flash('flash.bannerStyle', 'info'); - - return redirect()->route('calendar'); - } - - $invite->accept($user); - $user->switchWorkspace($invite->workspace); - - session()->flash('flash.banner', 'You are now a member of the workspace!'); - session()->flash('flash.bannerStyle', 'success'); - - return redirect()->route('calendar'); - } - public function removeMember(Request $request, string $userId): RedirectResponse { $workspace = $request->user()->currentWorkspace; diff --git a/app/Http/Requests/Auth/LoginRequest.php b/app/Http/Requests/Auth/LoginRequest.php new file mode 100644 index 00000000..7befcea5 --- /dev/null +++ b/app/Http/Requests/Auth/LoginRequest.php @@ -0,0 +1,87 @@ + + */ + public function rules(): array + { + return [ + 'email' => ['required', 'string', 'email'], + 'password' => ['required', 'string'], + ]; + } + + /** + * Attempt to authenticate the request's credentials. + * + * @throws \Illuminate\Validation\ValidationException + */ + public function authenticate(): void + { + $this->ensureIsNotRateLimited(); + + if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) { + RateLimiter::hit($this->throttleKey()); + + throw ValidationException::withMessages([ + 'email' => trans('auth.failed'), + ]); + } + + RateLimiter::clear($this->throttleKey()); + } + + /** + * Ensure the login request is not rate limited. + * + * @throws \Illuminate\Validation\ValidationException + */ + public function ensureIsNotRateLimited(): void + { + if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) { + return; + } + + event(new Lockout($this)); + + $seconds = RateLimiter::availableIn($this->throttleKey()); + + throw ValidationException::withMessages([ + 'email' => trans('auth.throttle', [ + 'seconds' => $seconds, + 'minutes' => ceil($seconds / 60), + ]), + ]); + } + + /** + * Get the rate limiting throttle key for the request. + */ + public function throttleKey(): string + { + return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip()); + } +} diff --git a/app/Http/Requests/Settings/TwoFactorAuthenticationRequest.php b/app/Http/Requests/Settings/TwoFactorAuthenticationRequest.php deleted file mode 100644 index 9db81d21..00000000 --- a/app/Http/Requests/Settings/TwoFactorAuthenticationRequest.php +++ /dev/null @@ -1,30 +0,0 @@ -|string> - */ - public function rules(): array - { - return []; - } -} diff --git a/app/Http/Responses/LoginResponse.php b/app/Http/Responses/LoginResponse.php deleted file mode 100644 index 1ae1bfbe..00000000 --- a/app/Http/Responses/LoginResponse.php +++ /dev/null @@ -1,33 +0,0 @@ -user(); - - // Check for pending invite token - if ($token = session('pending_invite_token')) { - return redirect()->route('invites.show', $token); - } - - // Determine redirect based on setup status - $redirect = match ($user->setup) { - Setup::Completed => route('calendar'), - Setup::Role => route('onboarding.step1'), - Setup::Connections => route('onboarding.step2'), - Setup::Subscription => route('onboarding.step2'), - default => route('onboarding.step1'), - }; - - return $request->wantsJson() - ? response()->json(['two_factor' => false]) - : redirect()->intended($redirect); - } -} diff --git a/app/Http/Responses/RegisterResponse.php b/app/Http/Responses/RegisterResponse.php deleted file mode 100644 index 9bfb28a9..00000000 --- a/app/Http/Responses/RegisterResponse.php +++ /dev/null @@ -1,21 +0,0 @@ -route('invites.show', $token); - } - - return $request->wantsJson() - ? response()->json(['two_factor' => false]) - : redirect()->route('onboarding.step1'); - } -} diff --git a/app/Mail/WorkspaceInvite.php b/app/Mail/WorkspaceInvite.php index d3ef6d22..e666f0dc 100644 --- a/app/Mail/WorkspaceInvite.php +++ b/app/Mail/WorkspaceInvite.php @@ -31,9 +31,9 @@ public function content(): Content view: 'mail.workspace-invite', with: [ 'title' => "You've been invited to join {$this->invite->workspace->name}", - 'previewText' => "{$this->invite->inviter->name} has invited you to collaborate on TryPost.", + 'previewText' => "You've been invited to join {$this->invite->workspace->name}", 'invite' => $this->invite, - 'url' => route('invites.show', $this->invite->token), + 'url' => route('invites.show', $this->invite->id), ], ); } diff --git a/app/Models/Traits/HasWorkspace.php b/app/Models/Traits/HasWorkspace.php index 71386fb6..d48c194c 100644 --- a/app/Models/Traits/HasWorkspace.php +++ b/app/Models/Traits/HasWorkspace.php @@ -5,24 +5,15 @@ use App\Models\Workspace; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; -use Illuminate\Database\Eloquent\Relations\HasMany; trait HasWorkspace { /** - * Get workspaces owned by this user. + * Get all workspaces the user belongs to (as owner or member). */ - public function workspaces(): HasMany + public function workspaces(): BelongsToMany { - return $this->hasMany(Workspace::class); - } - - /** - * Get workspaces where the user is a member (not owner). - */ - public function memberWorkspaces(): BelongsToMany - { - return $this->belongsToMany(Workspace::class) + return $this->belongsToMany(Workspace::class, 'user_workspace') ->withPivot('role') ->withTimestamps(); } @@ -48,8 +39,7 @@ public function switchWorkspace(Workspace $workspace): void */ public function belongsToWorkspace(Workspace $workspace): bool { - return $this->workspaces()->where('id', $workspace->id)->exists() - || $this->memberWorkspaces()->where('workspaces.id', $workspace->id)->exists(); + return $this->workspaces()->where('workspaces.id', $workspace->id)->exists(); } /** @@ -57,7 +47,7 @@ public function belongsToWorkspace(Workspace $workspace): bool */ public function ownedWorkspacesCount(): int { - return $this->workspaces()->count(); + return $this->workspaces()->wherePivot('role', 'owner')->count(); } /** diff --git a/app/Models/User.php b/app/Models/User.php index f6d7e0cc..3d4bcf09 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -13,12 +13,11 @@ use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Cashier\Billable; -use Laravel\Fortify\TwoFactorAuthenticatable; class User extends Authenticatable implements MustVerifyEmail { /** @use HasFactory<\Database\Factories\UserFactory> */ - use Billable, HasFactory, HasMedia, HasUuids, HasWorkspace, Notifiable, TwoFactorAuthenticatable; + use Billable, HasFactory, HasMedia, HasUuids, HasWorkspace, Notifiable; /** * The attributes that are mass assignable. @@ -33,6 +32,7 @@ class User extends Authenticatable implements MustVerifyEmail 'persona', 'current_workspace_id', 'language_id', + 'email_verified_at', ]; /** diff --git a/app/Models/WorkspaceInvite.php b/app/Models/WorkspaceInvite.php index 1f6a6bd6..435149ee 100644 --- a/app/Models/WorkspaceInvite.php +++ b/app/Models/WorkspaceInvite.php @@ -1,33 +1,27 @@ */ - use HasFactory, HasUuids, Notifiable; + use HasFactory, HasUuids; /** * @var list */ protected $fillable = [ - 'workspace_id', - 'invited_by', 'email', - 'token', 'role', - 'status', - 'accepted_at', + 'workspace_id', ]; /** @@ -37,54 +31,20 @@ protected function casts(): array { return [ 'role' => Role::class, - 'status' => InviteStatus::class, - 'accepted_at' => 'datetime', ]; } - protected static function booted(): void - { - static::creating(function (WorkspaceInvite $invite) { - if (empty($invite->token)) { - $invite->token = Str::random(64); - } - }); - } - public function workspace(): BelongsTo { return $this->belongsTo(Workspace::class); } - public function inviter(): BelongsTo - { - return $this->belongsTo(User::class, 'invited_by'); - } - - public function scopePending(Builder $query): Builder - { - return $query->where('status', InviteStatus::Pending); - } - - public function isPending(): bool - { - return $this->status === InviteStatus::Pending; - } - public function accept(User $user): void { $this->workspace->members()->attach($user->id, [ - 'role' => $this->role, + 'role' => $this->role->value, ]); - $this->update([ - 'status' => InviteStatus::Accepted, - 'accepted_at' => now(), - ]); - } - - public function routeNotificationForMail(): string - { - return $this->email; + $this->delete(); } } diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php deleted file mode 100644 index 3e3f66af..00000000 --- a/app/Providers/FortifyServiceProvider.php +++ /dev/null @@ -1,99 +0,0 @@ -app->singleton(LoginResponseContract::class, LoginResponse::class); - $this->app->singleton(RegisterResponseContract::class, RegisterResponse::class); - } - - /** - * Bootstrap any application services. - */ - public function boot(): void - { - $this->configureActions(); - $this->configureViews(); - $this->configureRateLimiting(); - } - - /** - * Configure Fortify actions. - */ - private function configureActions(): void - { - Fortify::resetUserPasswordsUsing(ResetUserPassword::class); - Fortify::createUsersUsing(CreateNewUser::class); - } - - /** - * Configure Fortify views. - */ - private function configureViews(): void - { - Fortify::loginView(fn (Request $request) => Inertia::render('auth/Login', [ - 'canResetPassword' => Features::enabled(Features::resetPasswords()), - 'canRegister' => Features::enabled(Features::registration()), - 'status' => $request->session()->get('status'), - 'email' => $request->query('email'), - ])); - - Fortify::resetPasswordView(fn (Request $request) => Inertia::render('auth/ResetPassword', [ - 'email' => $request->email, - 'token' => $request->route('token'), - ])); - - Fortify::requestPasswordResetLinkView(fn (Request $request) => Inertia::render('auth/ForgotPassword', [ - 'status' => $request->session()->get('status'), - ])); - - Fortify::verifyEmailView(fn (Request $request) => Inertia::render('auth/VerifyEmail', [ - 'status' => $request->session()->get('status'), - ])); - - Fortify::registerView(fn (Request $request) => Inertia::render('auth/Register', [ - 'email' => $request->query('email'), - ])); - - Fortify::twoFactorChallengeView(fn () => Inertia::render('auth/TwoFactorChallenge')); - - Fortify::confirmPasswordView(fn () => Inertia::render('auth/ConfirmPassword')); - } - - /** - * Configure rate limiting. - */ - private function configureRateLimiting(): void - { - RateLimiter::for('two-factor', function (Request $request) { - return Limit::perMinute(5)->by($request->session()->get('login.id')); - }); - - RateLimiter::for('login', function (Request $request) { - $throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip()); - - return Limit::perMinute(5)->by($throttleKey); - }); - } -} diff --git a/bootstrap/app.php b/bootstrap/app.php index fabee377..cb35c54b 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -7,6 +7,7 @@ use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets; +use Illuminate\Support\Facades\Route; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( @@ -14,6 +15,10 @@ commands: __DIR__.'/../routes/console.php', channels: __DIR__.'/../routes/channels.php', health: '/up', + then: function () { + Route::middleware('web') + ->group(base_path('routes/auth.php')); + }, ) ->withMiddleware(function (Middleware $middleware): void { $middleware->encryptCookies(except: ['appearance', 'sidebar_state']); diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 63da17d7..4e3b4407 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -2,6 +2,5 @@ return [ App\Providers\AppServiceProvider::class, - App\Providers\FortifyServiceProvider::class, App\Providers\HorizonServiceProvider::class, ]; diff --git a/composer.json b/composer.json index e0ae41db..f5335e23 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,6 @@ "php": "^8.2", "inertiajs/inertia-laravel": "^2.0", "laravel/cashier": "^16.2", - "laravel/fortify": "^1.30", "laravel/framework": "^12.0", "laravel/horizon": "^5.42", "laravel/nightwatch": "^1.22", diff --git a/composer.lock b/composer.lock index 648837d5..10bdc463 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a8cb995fdd4ce6be2997e5befaa457cc", + "content-hash": "a97a5767f8bf1ecc41931942e582fc22", "packages": [ { "name": "aws/aws-crt-php", @@ -157,61 +157,6 @@ }, "time": "2026-01-14T19:13:46+00:00" }, - { - "name": "bacon/bacon-qr-code", - "version": "v3.0.3", - "source": { - "type": "git", - "url": "https://github.com/Bacon/BaconQrCode.git", - "reference": "36a1cb2b81493fa5b82e50bf8068bf84d1542563" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/36a1cb2b81493fa5b82e50bf8068bf84d1542563", - "reference": "36a1cb2b81493fa5b82e50bf8068bf84d1542563", - "shasum": "" - }, - "require": { - "dasprid/enum": "^1.0.3", - "ext-iconv": "*", - "php": "^8.1" - }, - "require-dev": { - "phly/keep-a-changelog": "^2.12", - "phpunit/phpunit": "^10.5.11 || ^11.0.4", - "spatie/phpunit-snapshot-assertions": "^5.1.5", - "spatie/pixelmatch-php": "^1.2.0", - "squizlabs/php_codesniffer": "^3.9" - }, - "suggest": { - "ext-imagick": "to generate QR code images" - }, - "type": "library", - "autoload": { - "psr-4": { - "BaconQrCode\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-2-Clause" - ], - "authors": [ - { - "name": "Ben Scholzen 'DASPRiD'", - "email": "mail@dasprids.de", - "homepage": "https://dasprids.de/", - "role": "Developer" - } - ], - "description": "BaconQrCode is a QR code generator for PHP.", - "homepage": "https://github.com/Bacon/BaconQrCode", - "support": { - "issues": "https://github.com/Bacon/BaconQrCode/issues", - "source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.3" - }, - "time": "2025-11-19T17:15:36+00:00" - }, { "name": "brick/math", "version": "0.14.1", @@ -471,56 +416,6 @@ ], "time": "2025-01-03T16:18:33+00:00" }, - { - "name": "dasprid/enum", - "version": "1.0.7", - "source": { - "type": "git", - "url": "https://github.com/DASPRiD/Enum.git", - "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce", - "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce", - "shasum": "" - }, - "require": { - "php": ">=7.1 <9.0" - }, - "require-dev": { - "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", - "squizlabs/php_codesniffer": "*" - }, - "type": "library", - "autoload": { - "psr-4": { - "DASPRiD\\Enum\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-2-Clause" - ], - "authors": [ - { - "name": "Ben Scholzen 'DASPRiD'", - "email": "mail@dasprids.de", - "homepage": "https://dasprids.de/", - "role": "Developer" - } - ], - "description": "PHP 7.1 enum implementation", - "keywords": [ - "enum", - "map" - ], - "support": { - "issues": "https://github.com/DASPRiD/Enum/issues", - "source": "https://github.com/DASPRiD/Enum/tree/1.0.7" - }, - "time": "2025-09-16T12:23:56+00:00" - }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", @@ -1706,69 +1601,6 @@ }, "time": "2026-01-06T16:30:29+00:00" }, - { - "name": "laravel/fortify", - "version": "v1.33.0", - "source": { - "type": "git", - "url": "https://github.com/laravel/fortify.git", - "reference": "e0666dabeec0b6428678af1d51f436dcfb24e3a9" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/laravel/fortify/zipball/e0666dabeec0b6428678af1d51f436dcfb24e3a9", - "reference": "e0666dabeec0b6428678af1d51f436dcfb24e3a9", - "shasum": "" - }, - "require": { - "bacon/bacon-qr-code": "^3.0", - "ext-json": "*", - "illuminate/support": "^10.0|^11.0|^12.0", - "php": "^8.1", - "pragmarx/google2fa": "^9.0", - "symfony/console": "^6.0|^7.0" - }, - "require-dev": { - "orchestra/testbench": "^8.36|^9.15|^10.8", - "phpstan/phpstan": "^1.10" - }, - "type": "library", - "extra": { - "laravel": { - "providers": [ - "Laravel\\Fortify\\FortifyServiceProvider" - ] - }, - "branch-alias": { - "dev-master": "1.x-dev" - } - }, - "autoload": { - "psr-4": { - "Laravel\\Fortify\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" - } - ], - "description": "Backend controllers and scaffolding for Laravel authentication.", - "keywords": [ - "auth", - "laravel" - ], - "support": { - "issues": "https://github.com/laravel/fortify/issues", - "source": "https://github.com/laravel/fortify" - }, - "time": "2025-12-15T14:48:33+00:00" - }, { "name": "laravel/framework", "version": "v12.47.0", @@ -4433,58 +4265,6 @@ ], "time": "2025-03-19T16:30:08+00:00" }, - { - "name": "pragmarx/google2fa", - "version": "v9.0.0", - "source": { - "type": "git", - "url": "https://github.com/antonioribeiro/google2fa.git", - "reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/e6bc62dd6ae83acc475f57912e27466019a1f2cf", - "reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf", - "shasum": "" - }, - "require": { - "paragonie/constant_time_encoding": "^1.0|^2.0|^3.0", - "php": "^7.1|^8.0" - }, - "require-dev": { - "phpstan/phpstan": "^1.9", - "phpunit/phpunit": "^7.5.15|^8.5|^9.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "PragmaRX\\Google2FA\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Antonio Carlos Ribeiro", - "email": "acr@antoniocarlosribeiro.com", - "role": "Creator & Designer" - } - ], - "description": "A One Time Password Authentication package, compatible with Google Authenticator.", - "keywords": [ - "2fa", - "Authentication", - "Two Factor Authentication", - "google2fa" - ], - "support": { - "issues": "https://github.com/antonioribeiro/google2fa/issues", - "source": "https://github.com/antonioribeiro/google2fa/tree/v9.0.0" - }, - "time": "2025-09-19T22:51:08+00:00" - }, { "name": "predis/predis", "version": "v3.3.0", diff --git a/config/fortify.php b/config/fortify.php deleted file mode 100644 index 85178608..00000000 --- a/config/fortify.php +++ /dev/null @@ -1,157 +0,0 @@ - 'web', - - /* - |-------------------------------------------------------------------------- - | Fortify Password Broker - |-------------------------------------------------------------------------- - | - | Here you may specify which password broker Fortify can use when a user - | is resetting their password. This configured value should match one - | of your password brokers setup in your "auth" configuration file. - | - */ - - 'passwords' => 'users', - - /* - |-------------------------------------------------------------------------- - | Username / Email - |-------------------------------------------------------------------------- - | - | This value defines which model attribute should be considered as your - | application's "username" field. Typically, this might be the email - | address of the users but you are free to change this value here. - | - | Out of the box, Fortify expects forgot password and reset password - | requests to have a field named 'email'. If the application uses - | another name for the field you may define it below as needed. - | - */ - - 'username' => 'email', - - 'email' => 'email', - - /* - |-------------------------------------------------------------------------- - | Lowercase Usernames - |-------------------------------------------------------------------------- - | - | This value defines whether usernames should be lowercased before saving - | them in the database, as some database system string fields are case - | sensitive. You may disable this for your application if necessary. - | - */ - - 'lowercase_usernames' => true, - - /* - |-------------------------------------------------------------------------- - | Home Path - |-------------------------------------------------------------------------- - | - | Here you may configure the path where users will get redirected during - | authentication or password reset when the operations are successful - | and the user is authenticated. You are free to change this value. - | - */ - - 'home' => '/calendar', - - /* - |-------------------------------------------------------------------------- - | Fortify Routes Prefix / Subdomain - |-------------------------------------------------------------------------- - | - | Here you may specify which prefix Fortify will assign to all the routes - | that it registers with the application. If necessary, you may change - | subdomain under which all of the Fortify routes will be available. - | - */ - - 'prefix' => '', - - 'domain' => null, - - /* - |-------------------------------------------------------------------------- - | Fortify Routes Middleware - |-------------------------------------------------------------------------- - | - | Here you may specify which middleware Fortify will assign to the routes - | that it registers with the application. If necessary, you may change - | these middleware but typically this provided default is preferred. - | - */ - - 'middleware' => ['web'], - - /* - |-------------------------------------------------------------------------- - | Rate Limiting - |-------------------------------------------------------------------------- - | - | By default, Fortify will throttle logins to five requests per minute for - | every email and IP address combination. However, if you would like to - | specify a custom rate limiter to call then you may specify it here. - | - */ - - 'limiters' => [ - 'login' => 'login', - 'two-factor' => 'two-factor', - ], - - /* - |-------------------------------------------------------------------------- - | Register View Routes - |-------------------------------------------------------------------------- - | - | Here you may specify if the routes returning views should be disabled as - | you may not need them when building your own application. This may be - | especially true if you're writing a custom single-page application. - | - */ - - 'views' => true, - - /* - |-------------------------------------------------------------------------- - | Features - |-------------------------------------------------------------------------- - | - | Some of the Fortify features are optional. You may disable the features - | by removing them from this array. You're free to only remove some of - | these features, or you can even remove all of these if you need to. - | - */ - - 'features' => [ - Features::registration(), - Features::resetPasswords(), - Features::emailVerification(), - Features::twoFactorAuthentication([ - 'confirm' => true, - 'confirmPassword' => true, - // 'window' => 0 - ]), - ], - -]; diff --git a/database/factories/WorkspaceInviteFactory.php b/database/factories/WorkspaceInviteFactory.php index c685aa6e..5a810e94 100644 --- a/database/factories/WorkspaceInviteFactory.php +++ b/database/factories/WorkspaceInviteFactory.php @@ -3,11 +3,8 @@ namespace Database\Factories; use App\Enums\UserWorkspace\Role; -use App\Enums\WorkspaceInvite\Status as InviteStatus; -use App\Models\User; use App\Models\Workspace; use Illuminate\Database\Eloquent\Factories\Factory; -use Illuminate\Support\Str; /** * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\WorkspaceInvite> @@ -22,20 +19,9 @@ class WorkspaceInviteFactory extends Factory public function definition(): array { return [ - 'workspace_id' => Workspace::factory(), - 'invited_by' => User::factory(), 'email' => fake()->unique()->safeEmail(), - 'token' => Str::random(64), - 'role' => Role::Member, - 'status' => InviteStatus::Pending, + 'role' => fake()->randomElement(Role::cases()), + 'workspace_id' => Workspace::factory(), ]; } - - public function accepted(): static - { - return $this->state(fn (array $attributes) => [ - 'status' => InviteStatus::Accepted, - 'accepted_at' => now(), - ]); - } } diff --git a/database/migrations/2026_01_14_233835_create_workspace_invites_table.php b/database/migrations/2026_01_14_233835_create_workspace_invites_table.php index d9ea1463..6f055397 100644 --- a/database/migrations/2026_01_14_233835_create_workspace_invites_table.php +++ b/database/migrations/2026_01_14_233835_create_workspace_invites_table.php @@ -13,17 +13,12 @@ public function up(): void { Schema::create('workspace_invites', function (Blueprint $table) { $table->uuid('id')->primary(); - $table->foreignUuid('workspace_id')->constrained()->cascadeOnDelete(); - $table->foreignUuid('invited_by')->constrained('users')->cascadeOnDelete(); $table->string('email'); - $table->string('token', 64)->unique(); $table->string('role')->default('member'); - $table->string('status')->default('pending'); - $table->timestamp('accepted_at')->nullable(); + $table->foreignUuid('workspace_id')->constrained()->cascadeOnDelete(); $table->timestamps(); - $table->index(['workspace_id', 'email']); - $table->index(['token', 'status']); + $table->unique(['email', 'workspace_id']); }); } diff --git a/maizzle/templates/workspace-invite.html b/maizzle/templates/workspace-invite.html index 64f9f919..c52b55ec 100644 --- a/maizzle/templates/workspace-invite.html +++ b/maizzle/templates/workspace-invite.html @@ -13,7 +13,7 @@

- @{{ $invite->inviter->name }} has invited you to collaborate on the @{{ $invite->workspace->name }} workspace. + You've been invited to collaborate on the @{{ $invite->workspace->name }} workspace.

diff --git a/resources/css/app.css b/resources/css/app.css index 1faa81a5..b382c84e 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -7,7 +7,6 @@ @custom-variant dark (&:is(.dark *)); - @layer utilities { body, @@ -20,111 +19,111 @@ @layer utilities { } :root { - --background: #faf9f5; - --foreground: #3d3929; - --card: #faf9f5; - --card-foreground: #141413; - --popover: #ffffff; - --popover-foreground: #28261b; - --primary: #c96442; + --background: #fcfcfc; + --foreground: #000000; + --card: #ffffff; + --card-foreground: #000000; + --popover: #fcfcfc; + --popover-foreground: #000000; + --primary: #000000; --primary-foreground: #ffffff; - --secondary: #e9e6dc; - --secondary-foreground: #535146; - --muted: #ede9de; - --muted-foreground: #83827d; - --accent: #e9e6dc; - --accent-foreground: #28261b; - --destructive: #141413; + --secondary: #ebebeb; + --secondary-foreground: #000000; + --muted: #f5f5f5; + --muted-foreground: #525252; + --accent: #ebebeb; + --accent-foreground: #000000; + --destructive: #e54b4f; --destructive-foreground: #ffffff; - --border: #dad9d4; - --input: #b4b2a7; - --ring: #c96442; - --chart-1: #b05730; - --chart-2: #9c87f5; - --chart-3: #ded8c4; - --chart-4: #dbd3f0; - --chart-5: #b4552d; - --sidebar: #f5f4ee; - --sidebar-foreground: #3d3d3a; - --sidebar-primary: #c96442; - --sidebar-primary-foreground: #fbfbfb; - --sidebar-accent: #e9e6dc; - --sidebar-accent-foreground: #343434; + --border: #e4e4e4; + --input: #ebebeb; + --ring: #000000; + --chart-1: #ffae04; + --chart-2: #2d62ef; + --chart-3: #a4a4a4; + --chart-4: #e4e4e4; + --chart-5: #747474; + --sidebar: #fcfcfc; + --sidebar-foreground: #000000; + --sidebar-primary: #000000; + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: #ebebeb; + --sidebar-accent-foreground: #000000; --sidebar-border: #ebebeb; - --sidebar-ring: #b5b5b5; - --font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; - --font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif; - --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + --sidebar-ring: #000000; + --font-sans: Geist, sans-serif; + --font-serif: Georgia, serif; + --font-mono: Geist Mono, monospace; --radius: 0.5rem; - --shadow-x: 0; + --shadow-x: 0px; --shadow-y: 1px; - --shadow-blur: 3px; + --shadow-blur: 2px; --shadow-spread: 0px; - --shadow-opacity: 0.1; - --shadow-color: oklch(0 0 0); - --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); - --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); - --shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10); - --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10); - --shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10); - --shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10); - --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10); - --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25); + --shadow-opacity: 0.18; + --shadow-color: hsl(0 0% 0%); + --shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09); + --shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09); + --shadow-sm: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18); + --shadow: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18); + --shadow-md: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 2px 4px -1px hsl(0 0% 0% / 0.18); + --shadow-lg: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 4px 6px -1px hsl(0 0% 0% / 0.18); + --shadow-xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 8px 10px -1px hsl(0 0% 0% / 0.18); + --shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.45); --tracking-normal: 0em; --spacing: 0.25rem; } .dark { - --background: #262624; - --foreground: #c3c0b6; - --card: #262624; - --card-foreground: #faf9f5; - --popover: #30302e; - --popover-foreground: #e5e5e2; - --primary: #d97757; - --primary-foreground: #ffffff; - --secondary: #faf9f5; - --secondary-foreground: #30302e; - --muted: #1b1b19; - --muted-foreground: #b7b5a9; - --accent: #1a1915; - --accent-foreground: #f5f4ee; - --destructive: #ef4444; - --destructive-foreground: #ffffff; - --border: #3e3e38; - --input: #52514a; - --ring: #d97757; - --chart-1: #b05730; - --chart-2: #9c87f5; - --chart-3: #1a1915; - --chart-4: #2f2b48; - --chart-5: #b4552d; - --sidebar: #1f1e1d; - --sidebar-foreground: #c3c0b6; - --sidebar-primary: #343434; - --sidebar-primary-foreground: #fbfbfb; - --sidebar-accent: #0f0f0e; - --sidebar-accent-foreground: #c3c0b6; - --sidebar-border: #ebebeb; - --sidebar-ring: #b5b5b5; - --font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; - --font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif; - --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + --background: #000000; + --foreground: #ffffff; + --card: #090909; + --card-foreground: #ffffff; + --popover: #121212; + --popover-foreground: #ffffff; + --primary: #ffffff; + --primary-foreground: #000000; + --secondary: #222222; + --secondary-foreground: #ffffff; + --muted: #1d1d1d; + --muted-foreground: #a4a4a4; + --accent: #333333; + --accent-foreground: #ffffff; + --destructive: #ff5b5b; + --destructive-foreground: #000000; + --border: #242424; + --input: #333333; + --ring: #a4a4a4; + --chart-1: #ffae04; + --chart-2: #2671f4; + --chart-3: #747474; + --chart-4: #525252; + --chart-5: #e4e4e4; + --sidebar: #121212; + --sidebar-foreground: #ffffff; + --sidebar-primary: #ffffff; + --sidebar-primary-foreground: #000000; + --sidebar-accent: #333333; + --sidebar-accent-foreground: #ffffff; + --sidebar-border: #333333; + --sidebar-ring: #a4a4a4; + --font-sans: Geist, sans-serif; + --font-serif: Georgia, serif; + --font-mono: Geist Mono, monospace; --radius: 0.5rem; - --shadow-x: 0; + --shadow-x: 0px; --shadow-y: 1px; - --shadow-blur: 3px; + --shadow-blur: 2px; --shadow-spread: 0px; - --shadow-opacity: 0.1; - --shadow-color: oklch(0 0 0); - --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); - --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); - --shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10); - --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10); - --shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10); - --shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10); - --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10); - --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25); + --shadow-opacity: 0.18; + --shadow-color: hsl(0 0% 0%); + --shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09); + --shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09); + --shadow-sm: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18); + --shadow: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18); + --shadow-md: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 2px 4px -1px hsl(0 0% 0% / 0.18); + --shadow-lg: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 4px 6px -1px hsl(0 0% 0% / 0.18); + --shadow-xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 8px 10px -1px hsl(0 0% 0% / 0.18); + --shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.45); } @theme inline { diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index 9fd5f001..217964f6 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -112,7 +112,7 @@ function handleLogout() {

{{ currentWorkspace?.name || 'Select workspace' }} diff --git a/resources/js/components/TwoFactorRecoveryCodes.vue b/resources/js/components/TwoFactorRecoveryCodes.vue deleted file mode 100644 index 11b60aa8..00000000 --- a/resources/js/components/TwoFactorRecoveryCodes.vue +++ /dev/null @@ -1,124 +0,0 @@ - - - diff --git a/resources/js/components/TwoFactorSetupModal.vue b/resources/js/components/TwoFactorSetupModal.vue deleted file mode 100644 index dfe6494a..00000000 --- a/resources/js/components/TwoFactorSetupModal.vue +++ /dev/null @@ -1,304 +0,0 @@ - - - diff --git a/resources/js/composables/useTwoFactorAuth.ts b/resources/js/composables/useTwoFactorAuth.ts deleted file mode 100644 index 9bf36686..00000000 --- a/resources/js/composables/useTwoFactorAuth.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { computed, ref } from 'vue'; - -import { qrCode, recoveryCodes, secretKey } from '@/routes/two-factor'; - -const fetchJson = async (url: string): Promise => { - const response = await fetch(url, { - headers: { Accept: 'application/json' }, - }); - - if (!response.ok) { - throw new Error(`Failed to fetch: ${response.status}`); - } - - return response.json(); -}; - -const errors = ref([]); -const manualSetupKey = ref(null); -const qrCodeSvg = ref(null); -const recoveryCodesList = ref([]); - -const hasSetupData = computed( - () => qrCodeSvg.value !== null && manualSetupKey.value !== null, -); - -export const useTwoFactorAuth = () => { - const fetchQrCode = async (): Promise => { - try { - const { svg } = await fetchJson<{ svg: string; url: string }>( - qrCode.url(), - ); - - qrCodeSvg.value = svg; - } catch { - errors.value.push('Failed to fetch QR code'); - qrCodeSvg.value = null; - } - }; - - const fetchSetupKey = async (): Promise => { - try { - const { secretKey: key } = await fetchJson<{ secretKey: string }>( - secretKey.url(), - ); - - manualSetupKey.value = key; - } catch { - errors.value.push('Failed to fetch a setup key'); - manualSetupKey.value = null; - } - }; - - const clearSetupData = (): void => { - manualSetupKey.value = null; - qrCodeSvg.value = null; - clearErrors(); - }; - - const clearErrors = (): void => { - errors.value = []; - }; - - const clearTwoFactorAuthData = (): void => { - clearSetupData(); - clearErrors(); - recoveryCodesList.value = []; - }; - - const fetchRecoveryCodes = async (): Promise => { - try { - clearErrors(); - recoveryCodesList.value = await fetchJson( - recoveryCodes.url(), - ); - } catch { - errors.value.push('Failed to fetch recovery codes'); - recoveryCodesList.value = []; - } - }; - - const fetchSetupData = async (): Promise => { - try { - clearErrors(); - await Promise.all([fetchQrCode(), fetchSetupKey()]); - } catch { - qrCodeSvg.value = null; - manualSetupKey.value = null; - } - }; - - return { - qrCodeSvg, - manualSetupKey, - recoveryCodesList, - errors, - hasSetupData, - clearSetupData, - clearErrors, - clearTwoFactorAuthData, - fetchQrCode, - fetchSetupKey, - fetchSetupData, - fetchRecoveryCodes, - }; -}; diff --git a/resources/js/date.ts b/resources/js/date.ts index 52cb54a8..f88fa6ff 100644 --- a/resources/js/date.ts +++ b/resources/js/date.ts @@ -9,11 +9,6 @@ function getUserTimezone(): string { } export default { - formatDate(date: string | null | undefined) { - if (!date) return '-'; - return dayjs(date).format('DD/MM/YYYY'); - }, - formatDateTime(date: string) { return dayjs .utc(date) diff --git a/resources/js/layouts/settings/Layout.vue b/resources/js/layouts/settings/Layout.vue index 3715f583..26ba51c3 100644 --- a/resources/js/layouts/settings/Layout.vue +++ b/resources/js/layouts/settings/Layout.vue @@ -9,7 +9,6 @@ import { members } from '@/routes'; import { edit as editAppearance } from '@/routes/appearance'; import { index as billing } from '@/routes/billing'; import { edit as editProfile } from '@/routes/profile'; -import { show } from '@/routes/two-factor'; import { edit as editPassword } from '@/routes/user-password'; import { settings as workspaceSettings } from '@/routes/workspace'; import { type NavItem, type SharedData } from '@/types'; @@ -26,10 +25,6 @@ const navItems = computed(() => { title: 'Password', href: editPassword(), }, - { - title: 'Two-Factor Auth', - href: show(), - }, { title: 'Appearance', href: editAppearance(), diff --git a/resources/js/pages/auth/AcceptInvite.vue b/resources/js/pages/auth/AcceptInvite.vue new file mode 100644 index 00000000..7b6fb655 --- /dev/null +++ b/resources/js/pages/auth/AcceptInvite.vue @@ -0,0 +1,107 @@ + + + \ No newline at end of file diff --git a/resources/js/pages/auth/Login.vue b/resources/js/pages/auth/Login.vue index cb7581ee..ac3bc645 100644 --- a/resources/js/pages/auth/Login.vue +++ b/resources/js/pages/auth/Login.vue @@ -18,6 +18,7 @@ defineProps<{ canResetPassword: boolean; canRegister: boolean; email?: string | null; + redirect?: string | null; }>(); @@ -41,6 +42,7 @@ defineProps<{ v-slot="{ errors, processing }" class="flex flex-col gap-6" > +
diff --git a/resources/js/pages/auth/Register.vue b/resources/js/pages/auth/Register.vue index d8034d0c..647f4916 100644 --- a/resources/js/pages/auth/Register.vue +++ b/resources/js/pages/auth/Register.vue @@ -13,6 +13,7 @@ import { store } from '@/routes/register'; defineProps<{ email?: string | null; + redirect?: string | null; }>(); @@ -29,6 +30,7 @@ defineProps<{ v-slot="{ errors, processing }" class="flex flex-col gap-6" > +
diff --git a/resources/js/pages/auth/TwoFactorChallenge.vue b/resources/js/pages/auth/TwoFactorChallenge.vue deleted file mode 100644 index 2349bb24..00000000 --- a/resources/js/pages/auth/TwoFactorChallenge.vue +++ /dev/null @@ -1,139 +0,0 @@ - - - diff --git a/resources/js/pages/invites/Accept.vue b/resources/js/pages/invites/Accept.vue deleted file mode 100644 index b2c348f6..00000000 --- a/resources/js/pages/invites/Accept.vue +++ /dev/null @@ -1,138 +0,0 @@ - - - diff --git a/resources/js/pages/settings/Members.vue b/resources/js/pages/settings/Members.vue index 9167c6d8..8d848b95 100644 --- a/resources/js/pages/settings/Members.vue +++ b/resources/js/pages/settings/Members.vue @@ -32,8 +32,6 @@ interface Invite { id: string; email: string; role: string; - status: string; - inviter: { name: string }; } interface Role { @@ -85,22 +83,6 @@ function handleRemoveMember(memberId: string) { } } -function getStatusLabel(status: string): string { - const labels: Record = { - pending: 'Pending', - accepted: 'Accepted', - }; - return labels[status] || status; -} - -function getStatusColor(status: string): string { - const colors: Record = { - pending: 'bg-yellow-100 text-yellow-800', - accepted: 'bg-green-100 text-green-800', - }; - return colors[status] || 'bg-gray-100 text-gray-800'; -} - function getRoleLabel(role: string): string { const labels: Record = { owner: 'Owner', @@ -203,17 +185,11 @@ function getRoleIcon(role: string) { >

{{ invite.email }}

-
- - {{ getRoleLabel(invite.role) }} - - - {{ getStatusLabel(invite.status) }} - -
+ + {{ getRoleLabel(invite.role) }} +