From 9851593932f2a1eecd882fcaac91b39b6948cfd1 Mon Sep 17 00:00:00 2001 From: Alexandru Macocian Date: Thu, 6 Nov 2025 19:44:31 +0200 Subject: [PATCH] Setup backend for account linking --- .../Auth/AccountLinkController.php | 122 ++++++++++++++++++ app/Http/Controllers/SettingsController.php | 82 ++++++++++++ app/Http/Requests/AccountLinkRequest.php | 18 +++ app/Models/Account/Account.php | 10 ++ app/Models/Account/AccountLink.php | 56 ++++++++ app/Notifications/AccountLinkMail.php | 63 +++++++++ ...1_06_000000_create_account_links_table.php | 39 ++++++ docker-compose.dev.yml | 7 +- routes/web.php | 9 ++ scripts/docker/Dockerfile | 2 +- webpack.mix.js | 8 +- 11 files changed, 408 insertions(+), 8 deletions(-) create mode 100644 app/Http/Controllers/Auth/AccountLinkController.php create mode 100644 app/Http/Requests/AccountLinkRequest.php create mode 100644 app/Models/Account/AccountLink.php create mode 100644 app/Notifications/AccountLinkMail.php create mode 100644 database/migrations/2025_11_06_000000_create_account_links_table.php diff --git a/app/Http/Controllers/Auth/AccountLinkController.php b/app/Http/Controllers/Auth/AccountLinkController.php new file mode 100644 index 000000000..e8dbddd9e --- /dev/null +++ b/app/Http/Controllers/Auth/AccountLinkController.php @@ -0,0 +1,122 @@ +firstOrFail(); + + // Check if the current user matches the invited user + if (! Auth::check()) { + return redirect()->route('login') + ->with('status', trans('settings.account_link_login_required')); + } + + if (Auth::id() !== $accountLink->user_id) { + return redirect()->route('dashboard') + ->withErrors(trans('settings.account_link_wrong_user')); + } + + return view('settings.users.accept-link') + ->with('accountLink', $accountLink) + ->with('key', $key); + } + + /** + * Accept the account link invitation. + * + * @param Request $request + * @param string $key + * @return \Illuminate\Http\RedirectResponse + */ + public function store(Request $request, $key) + { + if (! Auth::check()) { + return redirect()->route('login'); + } + + $accountLink = AccountLink::where('invitation_key', $key) + ->firstOrFail(); + + // Verify the user is the one invited + if (Auth::id() !== $accountLink->user_id) { + return redirect()->route('dashboard') + ->withErrors(trans('settings.account_link_wrong_user')); + } + + // Security check: verify the inviter's email + if ($request->input('email_security') != $accountLink->invitedBy->email) { + return redirect()->back() + ->withErrors(trans('settings.users_error_email_not_similar')) + ->withInput(); + } + + $user = Auth::user(); + $oldAccountId = $user->account_id; + + // Update the user's account to the new account + $user->account_id = $accountLink->account_id; + $user->invited_by_user_id = $accountLink->invited_by_user_id; + $user->save(); + + // Delete the account link invitation + $accountLink->delete(); + + // TODO: Optionally handle the old account (delete if no users left, etc.) + + return redirect($this->redirectPath()) + ->with('status', trans('settings.account_link_accepted_success')); + } + + /** + * Decline the account link invitation. + * + * @param string $key + * @return \Illuminate\Http\RedirectResponse + */ + public function destroy($key) + { + if (! Auth::check()) { + return redirect()->route('login'); + } + + $accountLink = AccountLink::where('invitation_key', $key) + ->firstOrFail(); + + // Verify the user is the one invited + if (Auth::id() !== $accountLink->user_id) { + return redirect()->route('dashboard') + ->withErrors(trans('settings.account_link_wrong_user')); + } + + $accountLink->delete(); + + return redirect()->route('dashboard') + ->with('status', trans('settings.account_link_declined')); + } +} diff --git a/app/Http/Controllers/SettingsController.php b/app/Http/Controllers/SettingsController.php index eef3e26c7..13c542a36 100644 --- a/app/Http/Controllers/SettingsController.php +++ b/app/Http/Controllers/SettingsController.php @@ -14,12 +14,15 @@ use App\Models\Contact\Contact; use App\Jobs\AddContactFromVCard; use App\Models\Account\ImportJob; use App\Models\Account\Invitation; +use App\Models\Account\AccountLink; use App\Services\User\EmailChange; use App\Exceptions\StripeException; use App\Http\Requests\ImportsRequest; use Illuminate\Http\RedirectResponse; use App\Notifications\InvitationMail; +use App\Notifications\AccountLinkMail; use App\Http\Requests\SettingsRequest; +use App\Http\Requests\AccountLinkRequest; use App\Services\Contact\Tag\UpdateTag; use LaravelWebauthn\Models\WebauthnKey; use App\Http\Requests\InvitationRequest; @@ -321,6 +324,85 @@ class SettingsController extends Controller ->with('success', trans('settings.users_invitation_deleted_confirmation_message')); } + /** + * Show the form for linking an existing user account. + * + * @return \Illuminate\View\View|\Illuminate\Contracts\View\Factory|\Illuminate\Http\RedirectResponse + */ + public function linkAccount() + { + if (AccountHelper::hasLimitations(auth()->user()->account)) { + return redirect()->route('settings.subscriptions.index'); + } + + return view('settings.users.link'); + } + + /** + * Send an account link invitation to an existing user. + * + * @param AccountLinkRequest $request + * @return \Illuminate\Http\RedirectResponse + */ + public function inviteExistingUser(AccountLinkRequest $request) + { + // Make sure the confirmation to invite has not been bypassed + if (! $request->input('confirmation')) { + return redirect()->back()->withErrors(trans('settings.users_error_please_confirm'))->withInput(); + } + + // Find the user by email + $user = User::where('email', $request->input('email'))->first(); + + if (! $user) { + return redirect()->back()->withErrors(trans('settings.users_error_email_not_found'))->withInput(); + } + + // Check if user is trying to link their own account + if ($user->id === auth()->user()->id) { + return redirect()->back()->withErrors(trans('settings.users_error_cannot_link_own_account'))->withInput(); + } + + // Check if user is already part of this account + if ($user->account_id === auth()->user()->account_id) { + return redirect()->back()->withErrors(trans('settings.users_error_already_in_account'))->withInput(); + } + + // Has this user already been invited to link? + $existingLink = AccountLink::where('user_id', $user->id) + ->where('account_id', auth()->user()->account_id) + ->count(); + if ($existingLink > 0) { + return redirect()->back()->withErrors(trans('settings.users_error_already_link_invited'))->withInput(); + } + + $accountLink = auth()->user()->account->accountLinks()->create([ + 'user_id' => $user->id, + 'invited_by_user_id' => auth()->user()->id, + 'account_id' => auth()->user()->account_id, + 'invitation_key' => Str::random(100), + ]); + + $accountLink->notify((new AccountLinkMail())->locale(auth()->user()->locale)); + + return redirect()->route('settings.users.index') + ->with('status', trans('settings.account_link_sent_success')); + } + + /** + * Remove the specified account link invitation. + * + * @param AccountLink $accountLink + * @return \Illuminate\Http\RedirectResponse + */ + public function destroyAccountLink(AccountLink $accountLink) + { + $accountLink->delete(); + + return redirect()->route('settings.users.index') + ->with('success', trans('settings.account_link_deleted_confirmation_message')); + } + /** * Delete additional user account. * diff --git a/app/Http/Requests/AccountLinkRequest.php b/app/Http/Requests/AccountLinkRequest.php new file mode 100644 index 000000000..9abd11b30 --- /dev/null +++ b/app/Http/Requests/AccountLinkRequest.php @@ -0,0 +1,18 @@ + 'required|email', + ]; + } +} diff --git a/app/Models/Account/Account.php b/app/Models/Account/Account.php index 5dc12a778..d74469418 100644 --- a/app/Models/Account/Account.php +++ b/app/Models/Account/Account.php @@ -129,6 +129,16 @@ class Account extends Model return $this->hasMany(Invitation::class); } + /** + * Get the account link invitations associated with the account. + * + * @return HasMany + */ + public function accountLinks() + { + return $this->hasMany(AccountLink::class); + } + /** * Get the debt records associated with the account. * diff --git a/app/Models/Account/AccountLink.php b/app/Models/Account/AccountLink.php new file mode 100644 index 000000000..1b925fece --- /dev/null +++ b/app/Models/Account/AccountLink.php @@ -0,0 +1,56 @@ +|bool + */ + protected $guarded = ['id']; + + /** + * Get the account record that is being shared. + * + * @return BelongsTo + */ + public function account() + { + return $this->belongsTo(Account::class); + } + + /** + * Get the user who sent the invitation. + * + * @return BelongsTo + */ + public function invitedBy() + { + return $this->belongsTo(User::class, 'invited_by_user_id'); + } + + /** + * Get the existing user being invited to link accounts. + * + * @return BelongsTo + */ + public function user() + { + return $this->belongsTo(User::class, 'user_id'); + } +} diff --git a/app/Notifications/AccountLinkMail.php b/app/Notifications/AccountLinkMail.php new file mode 100644 index 000000000..227f4236d --- /dev/null +++ b/app/Notifications/AccountLinkMail.php @@ -0,0 +1,63 @@ +invitedBy; + $acceptLinkUrl = $this->acceptAccountLinkUrl($accountLink); + + return (new MailMessage) + ->subject(trans('mail.account_link_title', ['name' => $user->name])) + ->line(trans('mail.account_link_intro', ['name' => $user->name, 'email' => $user->email])) + ->line(trans('mail.account_link_explanation')) + ->line(trans('mail.account_link_link')) + ->action(trans('mail.account_link_button'), $acceptLinkUrl) + ->line(trans('mail.account_link_expiration', ['count' => Config::get('auth.invitation.expire', 2)])); + } + + /** + * Get the account link acceptance URL. + * + * @param AccountLink $accountLink + * @return string + */ + protected function acceptAccountLinkUrl(AccountLink $accountLink) + { + return URL::temporarySignedRoute( + 'account-links.accept', + now()->addDays(Config::get('auth.invitation.expire', 2)), + ['key' => $accountLink->invitation_key] + ); + } +} diff --git a/database/migrations/2025_11_06_000000_create_account_links_table.php b/database/migrations/2025_11_06_000000_create_account_links_table.php new file mode 100644 index 000000000..fb3b2fcfb --- /dev/null +++ b/database/migrations/2025_11_06_000000_create_account_links_table.php @@ -0,0 +1,39 @@ +increments('id'); + $table->integer('account_id')->unsigned(); + $table->integer('invited_by_user_id')->unsigned(); + $table->integer('user_id')->unsigned(); + $table->string('invitation_key'); + $table->timestamps(); + + $table->foreign('account_id')->references('id')->on('accounts')->onDelete('cascade'); + $table->foreign('invited_by_user_id')->references('id')->on('users')->onDelete('cascade'); + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('account_links'); + } +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index e38b43bb6..8ad0ba751 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -19,12 +19,7 @@ services: - 8080:80 env_file: .env.dev volumes: - - /var/www/html/storage - - ./app:/var/www/html/app - - ./database:/var/www/html/database - - ./resources:/var/www/html/resources - - ./routes:/var/www/html/routes - - ./config:/var/www/html/config + - ./storage:/var/www/html/storage mysql: image: mysql:8 diff --git a/routes/web.php b/routes/web.php index bad3e0f3d..3f01c4eed 100644 --- a/routes/web.php +++ b/routes/web.php @@ -32,6 +32,10 @@ Route::permanentRedirect('/.well-known/security.txt', '/security.txt'); Route::get('/invitations/accept/{key}', 'Auth\InvitationController@show')->name('invitations.accept'); Route::post('/invitations/accept/{key}', 'Auth\InvitationController@store')->name('invitations.send'); +Route::get('/account-links/accept/{key}', 'Auth\AccountLinkController@show')->name('account-links.accept'); +Route::post('/account-links/accept/{key}', 'Auth\AccountLinkController@store')->name('account-links.send'); +Route::delete('/account-links/decline/{key}', 'Auth\AccountLinkController@destroy')->name('account-links.decline'); + Route::middleware(['auth'])->group(function () { Route::get('/logout', 'Auth\LoginController@logout'); Route::get('/auth/login-recovery', 'Auth\RecoveryLoginController@get')->name('recovery.login'); @@ -269,6 +273,11 @@ Route::middleware(['auth', 'verified', 'mfa'])->group(function () { Route::post('/settings/users', 'SettingsController@inviteUser')->name('store'); Route::delete('/settings/users/{user}', 'SettingsController@deleteAdditionalUser')->name('destroy'); Route::delete('/settings/users/invitations/{invitation}', 'SettingsController@destroyInvitation')->name('invitation.delete'); + + // Account linking routes + Route::get('/settings/users/link', 'SettingsController@linkAccount')->name('link.create'); + Route::post('/settings/users/link', 'SettingsController@inviteExistingUser')->name('link.store'); + Route::delete('/settings/users/account-links/{accountLink}', 'SettingsController@destroyAccountLink')->name('link.delete'); }); Route::name('storage.')->group(function () { diff --git a/scripts/docker/Dockerfile b/scripts/docker/Dockerfile index b70fc1036..c7cc1de04 100644 --- a/scripts/docker/Dockerfile +++ b/scripts/docker/Dockerfile @@ -186,7 +186,7 @@ RUN set -ex; \ apt-get install -y nodejs; \ npm install -g yarn; \ yarn run inst; \ - yarn run dev; \ + yarn run production; \ \ rm -rf /var/lib/apt/lists/* diff --git a/webpack.mix.js b/webpack.mix.js index 990acb031..fa858cf4c 100644 --- a/webpack.mix.js +++ b/webpack.mix.js @@ -40,7 +40,7 @@ const purgeCssOptions = { } }; -mix.js('resources/js/app.js', 'public/js').vue() +mix.js('resources/js/app.js', 'public/js').vue({ version: 2 }) .sass('resources/sass/app-ltr.scss', 'public/css') .sass('resources/sass/app-rtl.scss', 'public/css') @@ -52,6 +52,12 @@ mix.js('resources/js/app.js', 'public/js').vue() vue$: path.join(__dirname, 'node_modules/vue/dist/vue.esm.js'), }) + .webpackConfig({ + resolve: { + symlinks: false, + } + }) + // global commands .purgeCss(purgeCssOptions) .extract()