updated tokens and emails

This commit is contained in:
2024-05-06 20:13:31 +10:00
parent 39ea570f3a
commit 742da4bf17
35 changed files with 627 additions and 340 deletions

View File

@@ -4,11 +4,9 @@ namespace App\Http\Controllers;
use App\Helpers; use App\Helpers;
use App\Jobs\SendEmail; use App\Jobs\SendEmail;
use App\Mail\EmailUpdateLink; use App\Mail\UserEmailUpdateRequest;
use App\Mail\RegisterLink;
use App\Models\User; use App\Models\User;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@@ -49,7 +47,7 @@ class AccountController extends Controller
$validator = Validator::make($request->all(), [ $validator = Validator::make($request->all(), [
'firstname' => 'required', 'firstname' => 'required',
'surname' => 'required', 'surname' => 'required',
'email' => ['required', 'email', Rule::unique('users')->ignore($user->id)], 'email' => ['required', 'email', 'unique:users,email,' . $user->id],
'phone' => 'required', 'phone' => 'required',
'home_address' => 'required_with:home_city,home_postcode,home_country,home_state', 'home_address' => 'required_with:home_city,home_postcode,home_country,home_state',
@@ -92,20 +90,18 @@ class AccountController extends Controller
$newEmail = $userData['email']; $newEmail = $userData['email'];
unset($userData['email']); unset($userData['email']);
if ($user->email !== $newEmail) { if (strtolower($user->email) !== strtolower($newEmail)) {
if(User::where('email', $request->get('email'))->exists()) { $user->tokens()->where('type', 'email-update')->delete();
$validator->errors()->add('email', __('validation.custom_messages.email_exists'));
return redirect()->back()->withErrors($validator)->withInput();
}
$token = Str::random(60); $token = $user->tokens()->create([
$user->emailUpdate()->delete(); 'type' => 'email-update',
$emailUpdate = $user->emailUpdate()->create([ 'data' => [
'email' => $newEmail, 'email' => $newEmail,
'token' => $token ],
'expires_at' => now()->addMinutes(30),
]); ]);
dispatch(new SendEmail($user->email, new EmailUpdateLink($token, $user->getName(), $user->email, $newEmail)))->onQueue('mail'); dispatch(new SendEmail($user->email, new UserEmailUpdateRequest($token->id, $user->email, $newEmail)))->onQueue('mail');
} }
$userData['subscribed'] = ($request->get('subscribed', false) === 'on'); $userData['subscribed'] = ($request->get('subscribed', false) === 'on');

View File

@@ -3,57 +3,89 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Jobs\SendEmail; use App\Jobs\SendEmail;
use App\Mail\LoginLink; use App\Mail\UserEmailUpdateConfirm;
use App\Mail\RegisterLink; use App\Mail\UserLogin;
use App\Models\EmailSubscriptions; use App\Mail\UserRegister;
use App\Models\EmailUpdate; use App\Mail\UserWelcome;
use App\Models\Token;
use App\Models\User; use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
class AuthController extends Controller class AuthController extends Controller
{ {
public function showLogin(Request $request) { /**
* Show the login form or if token present, process the login
*
* @param Request $request
* @return View|RedirectResponse
*/
public function showLogin(Request $request): View|RedirectResponse
{
if (auth()->check()) { if (auth()->check()) {
// return redirect()->route('dashboard');
return redirect()->action([HomeController::class, 'index']); return redirect()->action([HomeController::class, 'index']);
} }
$token = $request->query('token'); $token = $request->query('token');
if ($token) { if ($token) {
return $this->tokenLogin($token); return $this->LoginByToken($token);
} }
return view('auth.login'); return view('auth.login');
} }
public function tokenLogin($token) /**
* Process the login form
*
* @param Request $request
* @return View|RedirectResponse
*/
public function postLogin(Request $request): View|RedirectResponse
{ {
$loginToken = DB::table('login_tokens')->where('token', $token)->first(); $request->validate([
'email' => 'required|email',
], [
'email.required' => __('validation.custom_messages.email_required'),
'email.email' => __('validation.custom_messages.email_invalid'),
]);
if ($loginToken) { $user = User::where('email', $request->email)->whereNotNull('email_verified_at')->first();
$user = User::where('email', $loginToken->email)->first(); if($user) {
$intended_url = $loginToken->intended_url; $token = $user->tokens()->create([
'type' => 'login',
'data' => ['url' => session()->pull('url.intended', null)],
]);
DB::table('login_tokens')->where('token', $token)->delete(); dispatch(new SendEmail($user->email, new UserLogin($token->id, $user->getName(), $user->email)))->onQueue('mail');
return view('auth.login-link');
}
if ($user) { session()->flash('status', 'not-found');
Auth::login($user); return view('auth.login');
}
$user->markEmailAsVerified();
DB::table('login_tokens')->where('token', $token)->delete();
session()->flash('message', 'You have been logged in'); /**
session()->flash('message-title', 'Logged in'); * Process the login by token
session()->flash('message-type', 'success'); *
* @param string $tokenStr
* @return View|RedirectResponse
*/
public function loginByToken(string $tokenStr): View|RedirectResponse
{
$token = Token::where('id', $tokenStr)
->where('type', 'login')
->where('expires_at', '>', now())
->first();
if($intended_url) { if ($token) {
return redirect($intended_url); $user = $token->user;
} if($user) {
$token->delete();
return redirect()->action([HomeController::class, 'index']); return $this->loginByUser($user, $token->data);
} }
} }
@@ -63,27 +95,40 @@ class AuthController extends Controller
return view('auth.login'); return view('auth.login');
} }
public function postLogin(Request $request) { /**
$request->validate([ * Process the login by user
'email' => 'required|email', *
], [ * @param User $user
'email.required' => __('validation.custom_messages.email_required'), * @param array $data
'email.email' => __('validation.custom_messages.email_invalid'), * @return RedirectResponse
]); */
public function loginByUser(User $user, array $data = [])
$user = User::where('email', $request->email)->first(); {
if($user) { $url = null;
$token = $user->createLoginToken(session()->pull('url.intended', null)); if($data && isset($data->url) && $data->url) {
dispatch(new SendEmail($user->email, new LoginLink($token, $user->getName(), $user->email)))->onQueue('mail'); $url = $data->url;
return view('auth.login-link');
} }
session()->flash('status', 'not-found'); Auth::login($user);
return view('auth.login');
session()->flash('message', 'You have been logged in');
session()->flash('message-title', 'Logged in');
session()->flash('message-type', 'success');
if($url) {
return redirect($url);
}
return redirect()->action([HomeController::class, 'index']);
} }
public function logout() { /**
* Process the user logout
*
* @return RedirectResponse
*/
public function logout(): RedirectResponse
{
auth()->logout(); auth()->logout();
session()->flash('message', 'You have been logged out'); session()->flash('message', 'You have been logged out');
@@ -92,15 +137,57 @@ class AuthController extends Controller
return redirect()->route('index'); return redirect()->route('index');
} }
public function showRegister(Request $request) { /**
* Show the registration form or if token present, process the registration
*
* @param Request $request
* @return View|RedirectResponse
*/
public function showRegister(Request $request): View|RedirectResponse
{
if (auth()->check()) { if (auth()->check()) {
return redirect()->route('index'); return redirect()->route('index');
} }
$tokenStr = $request->query('token');
if ($tokenStr) {
$token = Token::where('id', $tokenStr)
->where('type', 'register')
->where('expires_at', '>', now())
->first();
if ($token) {
$user = $token->user;
if ($user) {
$user->email_verified_at = now();
$user->save();
$user->tokens()->where('type', 'register')->delete();
dispatch(new SendEmail($user->email, new UserWelcome($user->email)))->onQueue('mail');
$this->loginByUser($user);
return redirect()->route('index');
}
}
session()->flash('message', 'That token has expired or is invalid');
session()->flash('message-title', 'Registration failed');
session()->flash('message-type', 'danger');
}
return view('auth.register'); return view('auth.register');
} }
public function postRegister(Request $request) { /**
* Process the registration form
*
* @param Request $request
* @return View|RedirectResponse
*/
public function postRegister(Request $request): View|RedirectResponse
{
$request->validate([ $request->validate([
'email' => 'required|email', 'email' => 'required|email',
], [ ], [
@@ -119,46 +206,65 @@ class AuthController extends Controller
]); ]);
} }
} else if($passHoneypot) { } else if($passHoneypot) {
$firstname = explode('@', $request->email)[0];
$user = User::create([ $user = User::create([
'firstname' => $firstname,
'email' => $request->email, 'email' => $request->email,
]); ]);
EmailUpdate::where('email', $request->email)->delete();
} }
if($passHoneypot) { if($passHoneypot) {
Log::channel('honeypot')->info('Valid key used for registration using email: ' . $request->email . ', ip address: ' . $request->ip() . ', user agent: ' . $request->userAgent()); Log::channel('honeypot')->info('Valid key used for registration using email: ' . $request->email . ', ip address: ' . $request->ip() . ', user agent: ' . $request->userAgent());
$token = $user->createLoginToken(session()->pull('url.intended', null)); $user->tokens()->where('type', 'register')->delete();
dispatch(new SendEmail($user->email, new RegisterLink($token, $user->getName(), $user->email)))->onQueue('mail'); $token = $user->tokens()->create([
'type' => 'register',
'data' => ['url' => session()->pull('url.intended', null)],
]);
dispatch(new SendEmail($user->email, new UserRegister($token->id, $user->email)))->onQueue('mail');
} else { } else {
Log::channel('honeypot')->info('Invalid key used for registration using email: ' . $request->email . ', ip address: ' . $request->ip() . ', user agent: ' . $request->userAgent() . ', key: ' . $key); Log::channel('honeypot')->info('Invalid key used for registration using email: ' . $request->email . ', ip address: ' . $request->ip() . ', user agent: ' . $request->userAgent() . ', key: ' . $key);
} }
return view('auth.login-link'); return view('auth.register-link');
} }
public function updateEmail(Request $request) /**
* Confirm the user email update.
*
* @param Request $request
* @return RedirectResponse
*/
public function updateEmail(Request $request): RedirectResponse
{ {
$token = $request->query('token'); $tokenStr = $request->query('token');
$emailUpdate = EmailUpdate::where('token', $token)->first();
if($emailUpdate && $emailUpdate->user) {
$emailUpdate->user->email = $emailUpdate->email;
$emailUpdate->user->email_verified_at = now();
$emailUpdate->user->save();
$emailUpdate->delete();
session()->flash('message', 'Your email has been updated'); $token = Token::where('id', $tokenStr)
session()->flash('message-title', 'Email updated'); ->where('type', 'email-update')
session()->flash('message-type', 'success'); ->where('expires_at', '>', now())
return redirect()->route('index'); ->first();
if($token && $token->user) {
if($token->data && isset($token->data['email'])) {
$user = $token->user;
$user->email = $token->data['email'];
$user->email_verified_at = now();
$user->save();
$user->tokens()->where('type', 'email-update')->delete();
session()->flash('message', 'Your email has been updated');
session()->flash('message-title', 'Email updated');
session()->flash('message-type', 'success');
dispatch(new SendEmail($user->email, new UserEmailUpdateConfirm($user->email)))->onQueue('mail');
return redirect()->route('index');
}
} }
session()->flash('message', 'That token has expired or is invalid'); session()->flash('message', 'That token has expired or is invalid');
session()->flash('message-title', 'Email update failed'); session()->flash('message-title', 'Email update failed');
session()->flash('message-type', 'danger'); session()->flash('message-type', 'danger');
return redirect()->route('index'); return redirect()->route('index');
} }
} }

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class UserEmailUpdateConfirm extends Mailable
{
use Queueable, SerializesModels;
public $email;
public function __construct($email)
{
$this->email = $email;
}
public function build()
{
return $this
->subject('Your STEMMechanics account has been updated 👍')
->markdown('emails.email-update-confirm')
->with([
'email' => $this->email,
]);
}
}

View File

@@ -7,19 +7,17 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
class EmailUpdateLink extends Mailable class UserEmailUpdateRequest extends Mailable
{ {
use Queueable, SerializesModels; use Queueable, SerializesModels;
public $token; public $token;
public $username;
public $email; public $email;
public $newEmail; public $newEmail;
public function __construct($token, $username, $email, $newEmail) public function __construct($token, $email, $newEmail)
{ {
$this->token = $token; $this->token = $token;
$this->username = $username;
$this->email = $email; $this->email = $email;
$this->newEmail = $newEmail; $this->newEmail = $newEmail;
} }
@@ -27,11 +25,10 @@ class EmailUpdateLink extends Mailable
public function build() public function build()
{ {
return $this return $this
->subject('Confirm new email address') ->subject('Almost There! Confirm Your New Email Address 👍')
->markdown('emails.change-email-link') ->markdown('emails.email-update-request')
->with([ ->with([
'token' => $this->token, 'update_url' => route('update.email', ['token' => $this->token]),
'username' => $this->username,
'email' => $this->email, 'email' => $this->email,
'newEmail' => $this->newEmail, 'newEmail' => $this->newEmail,
]); ]);

View File

@@ -7,7 +7,7 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
class LoginLink extends Mailable class UserLogin extends Mailable
{ {
use Queueable, SerializesModels; use Queueable, SerializesModels;
@@ -25,10 +25,10 @@ class LoginLink extends Mailable
public function build() public function build()
{ {
return $this return $this
->subject('Here\'s your login link') ->subject('Here\'s your login link 🤫')
->markdown('emails.login-link') ->markdown('emails.login')
->with([ ->with([
'token' => $this->token, 'login_url' => route('login', ['token' => $this->token]),
'username' => $this->username, 'username' => $this->username,
'email' => $this->email, 'email' => $this->email,
]); ]);

View File

@@ -7,29 +7,26 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
class RegisterLink extends Mailable class UserRegister extends Mailable
{ {
use Queueable, SerializesModels; use Queueable, SerializesModels;
public $token; public $token;
public $username;
public $email; public $email;
public function __construct($token, $username, $email) public function __construct($token, $email)
{ {
$this->token = $token; $this->token = $token;
$this->username = $username;
$this->email = $email; $this->email = $email;
} }
public function build() public function build()
{ {
return $this return $this
->subject('Here\'s your registration link') ->subject('Almost There! Just One More Step to Join Us 🚀')
->markdown('emails.register-link') ->markdown('emails.register')
->with([ ->with([
'token' => $this->token, 'register_url' => route('register', ['token' => $this->token]),
'username' => $this->username,
'email' => $this->email, 'email' => $this->email,
]); ]);
} }

30
app/Mail/UserWelcome.php Normal file
View File

@@ -0,0 +1,30 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class UserWelcome extends Mailable
{
use Queueable, SerializesModels;
public $email;
public function __construct($email)
{
$this->email = $email;
}
public function build()
{
return $this
->subject('Welcome to STEMMechanics 🌟')
->markdown('emails.welcome')
->with([
'email' => $this->email,
]);
}
}

View File

@@ -1,30 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class EmailUpdate extends Model
{
use HasFactory;
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'user_id',
'email',
'token'
];
/**
* Get the user that owns the email update.
*/
public function user()
{
return $this->belongsTo(User::class);
}
}

87
app/Models/Token.php Normal file
View File

@@ -0,0 +1,87 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
class Token extends Model
{
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'user_id',
'type',
'data',
'expires_at',
];
/**
* The attributes that should be cast.
*
* @var array
*/
protected $casts = [
'expires_at' => 'datetime',
'data' => 'array',
];
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = false;
/**
* The primary key for the model is incrementing.
*
* @var bool $incrementing
*/
public $incrementing = false;
/**
* The primary key type for the model.
*
* @var string
*/
public $keyType = 'string';
/**
* The "booted" method of the model.
*
* @return void
*/
public static function boot()
{
parent::boot();
static::creating(function ($model) {
if (empty($model->{$model->getKeyName()}) === true) {
do {
$newToken = Str::random(48);
} while (self::where($model->getKeyName(), $newToken)->exists());
$model->{$model->getKeyName()} = $newToken;
}
if (empty($model->expires_at) === true) {
$model->expires_at = now()->addMinutes(10);
}
});
}
/**
* Get the user that the token belongs to.
*
* @return BelongsTo
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -2,17 +2,12 @@
namespace App\Models; namespace App\Models;
use App\Mail\LoginLink;
use App\Traits\UUID; use App\Traits\UUID;
use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use PharIo\Manifest\Email;
class User extends Authenticatable implements MustVerifyEmail class User extends Authenticatable implements MustVerifyEmail
{ {
@@ -110,34 +105,21 @@ class User extends Authenticatable implements MustVerifyEmail
}); });
} }
public function createLoginToken($redirect = null) /**
* Get the tokens for the user.
*
* @return HasMany
*/
public function tokens(): HasMany
{ {
// Generate a unique token return $this->hasMany(Token::class);
$token = Str::random(60);
// Store the token in the database
DB::table('login_tokens')->insert([
'email' => $this->email,
'token' => $token,
'intended_url' => $redirect,
]);
return $token;
}
public function softDelete()
{
foreach ($this->fillable as $field) {
if ($field === 'email_verified_at') {
$this->email_verified_at = null;
} else if ($field !== 'email') {
$this->{$field} = '';
}
}
$this->save();
} }
/**
* Get the calculated name of the user.
*
* @return string
*/
public function getName(): string public function getName(): string
{ {
$name = ''; $name = '';
@@ -183,14 +165,11 @@ class User extends Authenticatable implements MustVerifyEmail
} }
} }
public function emailUpdate()
{
return $this->hasOne(EmailUpdate::class);
}
public function getEmailUpdatePendingAttribute() public function getEmailUpdatePendingAttribute()
{ {
return $this->emailUpdate()->exists(); $emailUpdate = $this->tokens()->where('type', 'email-update')->where('expires_at', '>', now())->first();
return $emailUpdate ? $emailUpdate->data['email'] : null;
} }
public function isAdmin(): bool public function isAdmin(): bool

View File

@@ -69,6 +69,7 @@ return new class extends Migration
public function down(): void public function down(): void
{ {
Schema::dropIfExists('users'); Schema::dropIfExists('users');
Schema::dropIfExists('login_tokens');
Schema::dropIfExists('password_reset_tokens'); Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('sessions'); Schema::dropIfExists('sessions');
} }

View File

@@ -0,0 +1,56 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('login_tokens');
Schema::dropIfExists('email_updates');
Schema::create('tokens', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignUuid('user_id')->constrained()->onDelete('cascade');
$table->string('type');
$table->json('data')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamp('created_at')->default(DB::raw('CURRENT_TIMESTAMP'));
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::create('email_updates', function (Blueprint $table) {
$table->id();
$table->foreignUuid('user_id')->constrained()->onDelete('cascade');
$table->string('email');
$table->string('token')->unique();
$table->timestamps();
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->default(DB::raw('CURRENT_TIMESTAMP'));
});
Schema::create('login_tokens', function (Blueprint $table) {
$table->id();
$table->string('email');
$table->string('token')->unique();
$table->string('intended_url')->nullable();
$table->timestamp('created_at')->default(DB::raw('CURRENT_TIMESTAMP'));
});
}
};

View File

@@ -25,7 +25,7 @@ $billing_same_home = $user->home_address === $user->billing_address
</div> </div>
<div class="flex gap-8"> <div class="flex gap-8">
<div class="flex-1"> <div class="flex-1">
<x-ui.input type="email" label="Email" name="email" value="{{ $user->email }}" label-notice="{{ $user->email_update_pending ? 'There is a pending request to change the email address of this account' : '' }}"/> <x-ui.input type="email" label="Email" name="email" value="{{ $user->email }}" info="{{ $user->email_update_pending ? 'Pending request to change to ' . $user->email_update_pending : '' }}"/>
</div> </div>
<div class="flex-1"> <div class="flex-1">
<x-ui.input label="Phone" name="phone" value="{{ $user->phone }}" /> <x-ui.input label="Phone" name="phone" value="{{ $user->phone }}" />

View File

@@ -0,0 +1,7 @@
<x-layout :bodyClass="'image-background'">
<x-dialog>
<x-slot:title>Check your inbox</x-slot:title>
<x-slot:header><p class="text-center">Check your email for the registration link we just sent. Click the link to finish setting up your account.</p></x-slot:header>
<x-slot:footer class="mt-8"><x-ui.button href="{{ route('index') }}">Home</x-ui.button></x-slot:footer>
</x-dialog>
</x-layout>

View File

@@ -1,14 +0,0 @@
@component('mail::message', ['username' => $username, 'email' => $email])
<h2 class="center narrow">Confirm your new email address</h2>
<p class="center narrow">A request was made to change your email address at STEMMechanics to {{ $newEmail }}.</p>
<p class="center narrow">For your security, this link <strong>can only be used once</strong> and <strong>expires after 10 minutes.</strong></p>
<p class="center narrow">
@component('mail::button', ['url' => route('update.email', ['token' => $token])])
Update Email
@endcomponent
</p>
<hr />
<h3>Why did I get this link?</h3>
<p class="sub">Someone asked to change the email address associated with an account at STEMMechanics with this email.</p>
<p class="sub">If this wasn't you, you can ignore this email.</p>
@endcomponent

View File

@@ -0,0 +1,6 @@
@component('mail::message', ['email' => $email])
<p>Hey there!</p>
<p>Just a quick line to confirm that your email address has now been updated at STEMMechanics!</p>
<p>Warm regards,</p>
<p>—James 😁</p>
@endcomponent

View File

@@ -0,0 +1,16 @@
@component('mail::message', ['email' => $email])
<p>Hey there!</p>
<p>You requested to update your email address at STEMMechanics to {{ $newEmail }}. Click the link below to confirm this change:</p>
<p class="tall center">
@component('mail::button', ['url' => $update_url])
Update Email
@endcomponent
</p>
<p>Remember, the link expires in 30 minutes.</p>
<p>Warm regards,</p>
<p>—James 😁</p>
@slot('subcopy')
<h4>Why did I get this email?</h4>
<p class="sub">Someone asked to change the email address associated with an account at STEMMechanics with this email. If this wasn't you, you can ignore this email.</p>
@endslot
@endcomponent

View File

@@ -1,13 +0,0 @@
@component('mail::message', ['username' => $username, 'email' => $email])
<h2 class="center narrow">Follow this link to log in to your account.</h2>
<p class="center narrow">For your security, this link <strong>can only be used once</strong> and <strong>expires after 10 minutes.</strong></p>
<p class="center narrow">
@component('mail::button', ['url' => route('login', ['token' => $token])])
Log in
@endcomponent
</p>
<hr />
<h3>Why did I get this link?</h3>
<p class="sub">Someone asked for a login link to log in to STEMMechanics with this email.</p>
<p class="sub">If this wasn't you, you can ignore this email.</p>
@endcomponent

View File

@@ -0,0 +1,16 @@
@component('mail::message', ['email' => $email])
<p>Hey there!</p>
<p>You requested a link to log in to STEMMechanics, and here it is!</p>
<p class="tall center">
@component('mail::button', ['url' => $login_url])
Log in
@endcomponent
</p>
<p>Remember, the link expires in 10 minutes and can only be used once.</p>
<p>Warm regards,</p>
<p>—James 😁</p>
@slot('subcopy')
<h4>Why did I get this email?</h4>
<p class="sub">Someone asked for a link to log in to STEMMechanics with this email address. If this wasn't you, you can ignore this email.</p>
@endslot
@endcomponent

View File

@@ -1,13 +0,0 @@
@component('mail::message', ['username' => $username, 'email' => $email])
<h2 class="center narrow">Follow this link to register your account.</h2>
<p class="center narrow">For your security, this link <strong>can only be used once</strong> and <strong>expires after 10 minutes.</strong></p>
<p class="center narrow">
@component('mail::button', ['url' => route('login', ['token' => $token])])
Register
@endcomponent
</p>
<hr />
<h3>Why did I get this link?</h3>
<p class="sub">Someone asked to register an account at STEMMechanics with this email.</p>
<p class="sub">If this wasn't you, you can ignore this email.</p>
@endcomponent

View File

@@ -0,0 +1,17 @@
@component('mail::message', ['email' => $email])
<p>Hey there!</p>
<p>We're thrilled to have you join us. To complete your registration and officially become part of the community, just click link below:</p>
<p class="tall center">
@component('mail::button', ['url' => $register_url])
Register
@endcomponent
</p>
<p>Remember, the link expires in 10 minutes and can only be used once, so act fast!</p>
<p>Warm regards,</p>
<p>—James 😁</p>
@slot('subcopy')
<h4>Why did I get this email?</h4>
<p class="sub">Someone asked to register at STEMMechanics with this email address. If this wasn't you, you can ignore this email.</p>
@endslot
@endcomponent

View File

@@ -0,0 +1,7 @@
@component('mail::message', ['email' => $email, 'unsubscribe' => $unsubscribe])
@include('emails.welcome')
<hr />
<h3>Why did I get this email?</h3>
<p>Someone asked to subscribe to our mailing list at STEMMechanics with this email address.</p>
<p>If this wasn't you, you can <a href="{{$unsubscribe}}">unsubscribe</a>.</p>
@endcomponent

View File

@@ -0,0 +1,12 @@
@component('mail::message', ['email' => $email])
<p>Welcome to the community!</p>
<p>Really glad to have you here and can't wait to see you at one of our workshops.</p>
<p>You'll get information about upcoming workshops as it comes out.</p>
<p>Even though this is (of course) an automated email, just wanted to say thanks for registering and intro myself.</p>
<p>If you didn't know, I'm James and I'm the founder of STEMMechanics. I promise not to spam you, sell your data, or send you anything I don't think is absolutely necessary.</p>
<p>You know a bit about me but I don't know really anything about you...</p>
<p><strong>If you're up for it</strong>, reply to this email and tell me a bit about yourself and also let me know what workshops you are interested in?</p>
<p>I read and reply to every one 😁</p>
<p>Talk soon</p>
<p>—James</p>
@endcomponent

View File

@@ -1,11 +1 @@
<tr>
<td>
<table class="footer" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="content-cell" align="center">
{{ Illuminate\Mail\Markdown::parse($slot) }} {{ Illuminate\Mail\Markdown::parse($slot) }}
</td>
</tr>
</table>
</td>
</tr>

View File

@@ -1,7 +1,5 @@
@props(['url', 'username']) @props(['url'])
<tr> <a href="{{ $url }}">
<td class="header">
<a href="{{ $url }}" style="display: inline-block;">
<img <img
alt="STEMMechanics Logo" alt="STEMMechanics Logo"
src="https://www.stemmechanics.com.au/logo.svg" src="https://www.stemmechanics.com.au/logo.svg"
@@ -9,6 +7,3 @@
height="31" height="31"
/> />
</a> </a>
<h1>Hello, {{ $username }}</h1>
</td>
</tr>

View File

@@ -25,33 +25,46 @@ width: 100% !important;
</style> </style>
</head> </head>
<body> <body>
<table class="wrapper" align="center" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<!-- Email Body -->
<table class="inner-body" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<!-- Body header -->
<tr>
<td class="header" align="center">
{{ $header ?? '' }}
</td>
</tr>
<table class="wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation"> <!-- Body content -->
<tr> <tr>
<td align="center"> <td class="content-cell">
<table class="content" width="100%" cellpadding="0" cellspacing="0" role="presentation"> {{ Illuminate\Mail\Markdown::parse($slot) }}
{{ $header ?? '' }} </td>
</tr>
<!-- Email Body --> @isset($subcopy)
<tr> <tr>
<td class="body" width="100%" cellpadding="0" cellspacing="0" style="border: hidden !important;"> <td class="content-cell">
<table class="inner-body" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation"> <hr />
<!-- Body content --> {{ $subcopy ?? '' }}
<tr> </td>
<td class="content-cell"> </tr>
{{ Illuminate\Mail\Markdown::parse($slot) }} @endisset
</table>
{{ $subcopy ?? '' }} </td>
</td> </tr>
</tr> <tr>
</table> <td>
</td> <table class="footer" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
</tr> <tr>
<td class="content-cell" align="center">
{{ $footer ?? '' }} {{ $footer ?? '' }}
</table> </td>
</td> </tr>
</tr> </table>
</table> </td>
</tr>
</table>
</body> </body>
</html> </html>

View File

@@ -1,7 +1,7 @@
<x-mail::layout> <x-mail::layout>
{{-- Header --}} {{-- Header --}}
<x-slot:header> <x-slot:header>
<x-mail::header :url="config('app.url')" username="{{ $username }}" /> <x-mail::header :url="config('app.url')"/>
</x-slot:header> </x-slot:header>
{{-- Body --}} {{-- Body --}}
@@ -10,9 +10,7 @@
{{-- Subcopy --}} {{-- Subcopy --}}
@isset($subcopy) @isset($subcopy)
<x-slot:subcopy> <x-slot:subcopy>
<x-mail::subcopy> {{ Illuminate\Mail\Markdown::parse($subcopy) }}
{{ $subcopy }}
</x-mail::subcopy>
</x-slot:subcopy> </x-slot:subcopy>
@endisset @endisset
@@ -20,9 +18,9 @@
<x-slot:footer> <x-slot:footer>
<x-mail::footer> <x-mail::footer>
<p>This email was sent to <a href="mailto:{{ $email }}">{{ $email }}</a><br /> <p>This email was sent to <a href="mailto:{{ $email }}">{{ $email }}</a><br />
<a href="{{ config('app.url') }}">{{ config('app.name') }}</a> | 1/4 Jordan Street | Edmonton, QLD 4869 Australia<br /> <a href="{{ route('index') }}">{{ config('app.name') }}</a> | 1/4 Jordan Street | Edmonton, QLD 4869 Australia<br />
© {{ date('Y') }} {{ config('app.name') }}. {{ __('All rights reserved.') }}<br /> © {{ date('Y') }} {{ config('app.name') }}. {{ __('All rights reserved.') }}<br />
<a href="{{ config('app.url') }}/privacy">Privacy Policy</a> <a href="{{ route('privacy') }}">Privacy Policy</a> | <a href="{{ route('terms-conditions') }}">Terms & Conditions</a> @isset($unsubscribe) | <a href="{{ $unsubscribe }}">Unsubscribe</a>@endisset
</p> </p>
</x-mail::footer> </x-mail::footer>
</x-slot:footer> </x-slot:footer>

View File

@@ -1,7 +1 @@
<table class="subcopy" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td>
{{ Illuminate\Mail\Markdown::parse($slot) }} {{ Illuminate\Mail\Markdown::parse($slot) }}
</td>
</tr>
</table>

View File

@@ -10,8 +10,8 @@ body *:not(html):not(style):not(br):not(tr):not(code) {
body { body {
-webkit-text-size-adjust: none; -webkit-text-size-adjust: none;
background-color: #ffffff; background-color: #eee;
color: #718096; color: #444;
height: 100%; height: 100%;
line-height: 1.4; line-height: 1.4;
margin: 0; margin: 0;
@@ -31,7 +31,7 @@ a {
color: #3869d4; color: #3869d4;
} }
a img { img {
border: none; border: none;
} }
@@ -39,10 +39,11 @@ a img {
h1 { h1 {
color: #3d4852; color: #3d4852;
font-size: 24px; font-size: 12px;
font-weight: bold; margin: 0;
margin-top: 14px; padding: 0;
text-align: center; display: inline-block;
border: 1px solid red;
} }
h2 { h2 {
@@ -59,9 +60,17 @@ h3 {
text-align: left; text-align: left;
} }
h4 {
font-size: 12px;
font-weight: bold;
margin-top: 0;
margin-bottom: 0;
text-align: left;
}
p { p {
font-size: 16px; font-size: 14px;
line-height: 1.5em; line-height: 1.2em;
margin-top: 0; margin-top: 0;
text-align: left; text-align: left;
} }
@@ -88,9 +97,9 @@ hr {
-premailer-cellpadding: 0; -premailer-cellpadding: 0;
-premailer-cellspacing: 0; -premailer-cellspacing: 0;
-premailer-width: 100%; -premailer-width: 100%;
background-color: #edf2f7; background-color: #eee;
margin: 0; margin: 30px auto;
padding: 0; padding: 12px;
width: 100%; width: 100%;
} }
@@ -107,23 +116,21 @@ hr {
text-align: center; text-align: center;
} }
.narrow { .tall {
padding-left: 32px; padding-top: 16px;
padding-right: 32px; padding-bottom: 16px;
} }
/* Header */ /* Header */
.header { .header {
padding: 25px 0; padding-top: 28px;
text-align: center; padding-bottom: 32px;
} }
.header a { .header a {
color: #3d4852;
font-size: 19px;
font-weight: bold;
text-decoration: none; text-decoration: none;
display: inline-block;
} }
/* Logo */ /* Logo */
@@ -154,24 +161,13 @@ hr {
-premailer-width: 570px; -premailer-width: 570px;
background-color: #ffffff; background-color: #ffffff;
border-color: #e8e5ef; border-color: #e8e5ef;
border-radius: 2px; border-radius: 8px;
border-width: 1px; border-width: 1px;
box-shadow: 0 2px 0 rgba(0, 0, 150, 0.025), 2px 4px 0 rgba(0, 0, 150, 0.015); box-shadow: 4px 4px 12px rgba(0, 0, 0, 0.05);
margin: 0 auto; margin: 0 auto;
padding: 0; padding: 0 0 32px;
width: 570px; width: 570px;
} max-width: 570px;
/* Subcopy */
.subcopy {
border-top: 1px solid #e8e5ef;
margin-top: 25px;
padding-top: 25px;
}
.subcopy p {
font-size: 14px;
} }
/* Footer */ /* Footer */
@@ -181,7 +177,7 @@ hr {
-premailer-cellspacing: 0; -premailer-cellspacing: 0;
-premailer-width: 570px; -premailer-width: 570px;
margin: 0 auto; margin: 0 auto;
padding: 0; padding: 32px 0 0;
text-align: center; text-align: center;
width: 570px; width: 570px;
} }
@@ -204,7 +200,7 @@ hr {
-premailer-cellpadding: 0; -premailer-cellpadding: 0;
-premailer-cellspacing: 0; -premailer-cellspacing: 0;
-premailer-width: 100%; -premailer-width: 100%;
margin: 30px auto; margin: 0;
width: 100%; width: 100%;
} }
@@ -223,8 +219,9 @@ hr {
} }
.content-cell { .content-cell {
max-width: 100vw; max-width: 570px;
padding: 32px; padding-left: 32px;
padding-right: 32px;
} }
/* Buttons */ /* Buttons */
@@ -246,10 +243,6 @@ hr {
display: inline-block; display: inline-block;
overflow: hidden; overflow: hidden;
text-decoration: none; text-decoration: none;
}
.button-blue,
.button-primary {
background-color: #2d3748; background-color: #2d3748;
border-bottom: 8px solid #2d3748; border-bottom: 8px solid #2d3748;
border-left: 18px solid #2d3748; border-left: 18px solid #2d3748;
@@ -257,6 +250,15 @@ hr {
border-top: 8px solid #2d3748; border-top: 8px solid #2d3748;
} }
.button-blue,
.button-primary {
background-color: #0284C7;
border-bottom: 8px solid #0284C7;
border-left: 18px solid #0284C7;
border-right: 18px solid #0284C7;
border-top: 8px solid #0284C7;
}
.button-green, .button-green,
.button-success { .button-success {
background-color: #48bb78; background-color: #48bb78;

View File

@@ -1 +1 @@
{{ $slot }}: {{ $url }} {{ $url }}

View File

@@ -1 +1 @@
{{ $slot }}: {{ $url }} {{ $slot }}

View File

@@ -1,9 +1,21 @@
{!! strip_tags($header ?? '') !!} {!! strip_tags($header ?? '') !!}
{!! strip_tags($slot) !!} @php
@isset($subcopy) $slot = str_replace([' ', "\t"], '', $slot);
$slot = str_replace('</p>', "\r\n", $slot);
$slot = strip_tags($slot);
@endphp
{!! $slot !!}
{!! strip_tags($subcopy) !!} @isset($subcopy)
@php
$subcopy = str_replace([' ', "\t"], '', $subcopy);
$subcopy = str_replace("</h4>\n", " - ", $subcopy);
$subcopy = str_replace(['<br>', '<br />', '</p>'], "\r\n", $subcopy);
$subcopy = strip_tags($subcopy);
@endphp
{!! $subcopy !!}
@endisset @endisset
------
{!! strip_tags($footer ?? '') !!} {!! strip_tags($footer ?? '') !!}

View File

@@ -1,27 +1,24 @@
<x-mail::layout> <x-mail::layout>
{{-- Header --}} {{-- Body --}}
<x-slot:header> {{ $slot }}
<x-mail::header :url="config('app.url')">
{{ config('app.name') }}
</x-mail::header>
</x-slot:header>
{{-- Body --}} @isset($subcopy)
{{ $slot }} <x-slot:subcopy>
{{ $subcopy }}
</x-slot:subcopy>
@endisset
{{-- Subcopy --}}
@isset($subcopy)
<x-slot:subcopy>
<x-mail::subcopy>
{{ $subcopy }}
</x-mail::subcopy>
</x-slot:subcopy>
@endisset
{{-- Footer --}} {{-- Footer --}}
<x-slot:footer> <x-slot:footer>
<x-mail::footer> <x-mail::footer>
© {{ date('Y') }} {{ config('app.name') }}. @lang('All rights reserved.')
</x-mail::footer> This email was sent to {{ $email }}
</x-slot:footer>
STEMMechanics | 1/4 Jordan Street | Edmonton, QLD 4869 Australia
© {{ date('Y') }} {{ config('app.name') }}. {{ __('All rights reserved.') }}
@isset($unsubscribe) Unsubscribe: {{ $unsubscribe }}@endisset
</x-mail::footer>
</x-slot:footer>
</x-mail::layout> </x-mail::layout>

View File

@@ -1 +1,2 @@
{{ $slot }} {{ $slot }}

View File

@@ -8,13 +8,8 @@ use Illuminate\Support\Facades\Storage;
Artisan::command('cleanup', function() { Artisan::command('cleanup', function() {
// Clean up expired tokens // Clean up expired tokens
DB::table('login_tokens') DB::table('tokens')
->where('created_at', '<', now()->subMinutes(10)) ->where('expires_at', '<', now())
->delete();
// Clean up expired change email requests
DB::table('email_updates')
->where('created_at', '<', now()->subMinutes(10))
->delete(); ->delete();
// Published scheduled posts // Published scheduled posts