Setup backend for account linking
This commit is contained in:
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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/*
|
||||
|
||||
|
||||
Vendored
+7
-1
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user