Setup backend for account linking

This commit is contained in:
Alexandru Macocian
2025-11-06 19:44:31 +02:00
parent b60165648d
commit 9851593932
11 changed files with 408 additions and 8 deletions
@@ -0,0 +1,122 @@
<?php
namespace App\Http\Controllers\Auth;
use Illuminate\Http\Request;
use App\Models\User\User;
use App\Models\Account\AccountLink;
use App\Http\Controllers\Controller;
use Illuminate\Support\Facades\Auth;
use Illuminate\Foundation\Auth\RedirectsUsers;
class AccountLinkController extends Controller
{
use RedirectsUsers;
/**
* Where to redirect users after accepting the link.
*
* @var string
*/
protected $redirectTo = '/dashboard';
/**
* Display the account link acceptance page.
*
* @param string $key
* @return \Illuminate\View\View|\Illuminate\Contracts\View\Factory|\Illuminate\Http\RedirectResponse
*/
public function show($key)
{
$accountLink = AccountLink::where('invitation_key', $key)
->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'));
}
}
@@ -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.
*
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace App\Http\Requests;
class AccountLinkRequest extends AuthorizedRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
'email' => 'required|email',
];
}
}
+10
View File
@@ -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.
*
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Models\Account;
use App\Models\User\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Notifications\Notifiable;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property Account $account
* @property User $invitedBy
* @property User $user
* @property string $invitation_key
*/
class AccountLink extends Model
{
use Notifiable;
/**
* The attributes that aren't mass assignable.
*
* @var array<string>|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');
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Facades\Config;
use App\Models\Account\AccountLink;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification as LaravelNotification;
class AccountLinkMail extends LaravelNotification implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable;
/**
* Get the notification's delivery channels.
*
* @return array
*/
public function via()
{
return ['mail'];
}
/**
* Get the mail representation of the notification.
*
* @param AccountLink $accountLink
* @return \Illuminate\Notifications\Messages\MailMessage
*/
public function toMail(AccountLink $accountLink): MailMessage
{
$user = $accountLink->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]
);
}
}
@@ -0,0 +1,39 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateAccountLinksTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('account_links', function (Blueprint $table) {
$table->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');
}
}
+1 -6
View File
@@ -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
+9
View File
@@ -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 () {
+1 -1
View File
@@ -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/*
+7 -1
View File
@@ -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()