remove usernames

This commit is contained in:
2023-05-08 10:40:48 +10:00
parent 7a4f72378d
commit ac2dd23ad7
43 changed files with 372 additions and 864 deletions

View File

@@ -23,7 +23,7 @@ class UserConductor extends Conductor
{ {
$user = auth()->user(); $user = auth()->user();
if ($user === null || $user->hasPermission('admin/users') === false) { if ($user === null || $user->hasPermission('admin/users') === false) {
return ['id', 'username']; return ['id', 'display_name'];
} }
return parent::fields($model); return parent::fields($model);
@@ -41,7 +41,7 @@ class UserConductor extends Conductor
$data = $model->toArray(); $data = $model->toArray();
if ($user === null || ($user->hasPermission('admin/users') === false && strcasecmp($user->id, $model->id) !== 0)) { if ($user === null || ($user->hasPermission('admin/users') === false && strcasecmp($user->id, $model->id) !== 0)) {
$fields = ['id', 'username', 'display_name']; $fields = ['id', 'display_name'];
$data = arrayLimitKeys($data, $fields); $data = arrayLimitKeys($data, $fields);
} else { } else {
$data['permissions'] = $user->permissions; $data['permissions'] = $user->permissions;

View File

@@ -47,18 +47,18 @@ class AuthController extends ApiController
*/ */
public function login(AuthLoginRequest $request) public function login(AuthLoginRequest $request)
{ {
$user = User::where('username', '=', $request->input('username'))->first(); $user = User::where('email', '=', $request->input('email'))->first();
if ($user !== null && Hash::check($request->input('password'), $user->password) === true) { if ($user !== null && Hash::check($request->input('password'), $user->password) === true) {
if ($user->email_verified_at === null) { if ($user->email_verified_at === null) {
return $this->respondWithErrors([ return $this->respondWithErrors([
'username' => 'Email address has not been verified.' 'email' => 'Email address has not been verified.'
]); ]);
} }
if ($user->disabled === true) { if ($user->disabled === true) {
return $this->respondWithErrors([ return $this->respondWithErrors([
'username' => 'Account has been disabled.' 'email' => 'Account has been disabled.'
]); ]);
} }
@@ -78,8 +78,8 @@ class AuthController extends ApiController
}//end if }//end if
return $this->respondWithErrors([ return $this->respondWithErrors([
'username' => 'Invalid username or password', 'email' => 'Invalid email or password',
'password' => 'Invalid username or password', 'password' => 'Invalid email or password',
]); ]);
} }

View File

@@ -5,7 +5,6 @@ namespace App\Http\Controllers\Api;
use App\Enum\HttpResponseCodes; use App\Enum\HttpResponseCodes;
use App\Http\Requests\UserRequest; use App\Http\Requests\UserRequest;
use App\Http\Requests\UserForgotPasswordRequest; use App\Http\Requests\UserForgotPasswordRequest;
use App\Http\Requests\UserForgotUsernameRequest;
use App\Http\Requests\UserRegisterRequest; use App\Http\Requests\UserRegisterRequest;
use App\Http\Requests\UserResendVerifyEmailRequest; use App\Http\Requests\UserResendVerifyEmailRequest;
use App\Http\Requests\UserResetPasswordRequest; use App\Http\Requests\UserResetPasswordRequest;
@@ -14,7 +13,6 @@ use App\Jobs\SendEmailJob;
use App\Mail\ChangedEmail; use App\Mail\ChangedEmail;
use App\Mail\ChangedPassword; use App\Mail\ChangedPassword;
use App\Mail\ChangeEmailVerify; use App\Mail\ChangeEmailVerify;
use App\Mail\ForgotUsername;
use App\Mail\ForgotPassword; use App\Mail\ForgotPassword;
use App\Mail\EmailVerify; use App\Mail\EmailVerify;
use App\Models\User; use App\Models\User;
@@ -37,7 +35,6 @@ class UserController extends ApiController
'register', 'register',
'exists', 'exists',
'forgotPassword', 'forgotPassword',
'forgotUsername',
'resetPassword', 'resetPassword',
'verifyEmail', 'verifyEmail',
'resendVerifyEmailCode' 'resendVerifyEmailCode'
@@ -105,7 +102,7 @@ class UserController extends ApiController
{ {
if (UserConductor::updatable($user) === true) { if (UserConductor::updatable($user) === true) {
$input = []; $input = [];
$updatable = ['username', 'first_name', 'last_name', 'email', 'phone', 'password', 'display_name']; $updatable = ['first_name', 'last_name', 'email', 'phone', 'password', 'display_name'];
if ($request->user()->hasPermission('admin/user') === true) { if ($request->user()->hasPermission('admin/user') === true) {
$updatable = array_merge($updatable, ['email_verified_at']); $updatable = array_merge($updatable, ['email_verified_at']);
@@ -149,15 +146,28 @@ class UserController extends ApiController
public function register(UserRegisterRequest $request) public function register(UserRegisterRequest $request)
{ {
try { try {
$user = User::create([ $user = User::where('email', $request->input('email'))
'first_name' => $request->input('first_name'), ->whereNull('password')
'last_name' => $request->input('last_name'), ->first();
'username' => $request->input('username'),
'email' => $request->input('email'), if ($user === null) {
'phone' => $request->input('phone', ''), $user = User::create([
'password' => Hash::make($request->input('password')), 'first_name' => $request->input('first_name'),
'display_name' => $request->input('display_name', $request->input('username')), 'last_name' => $request->input('last_name'),
]); 'email' => $request->input('email'),
'phone' => $request->input('phone', ''),
'password' => Hash::make($request->input('password')),
'display_name' => $request->input('display_name'),
]);
} else {
$user->update([
'first_name' => $request->input('first_name'),
'last_name' => $request->input('last_name'),
'phone' => $request->input('phone', ''),
'password' => Hash::make($request->input('password')),
'display_name' => $request->input('display_name'),
]);
}//end if
$code = $user->codes()->create([ $code = $user->codes()->create([
'action' => 'verify-email', 'action' => 'verify-email',
@@ -175,26 +185,6 @@ class UserController extends ApiController
}//end try }//end try
} }
/**
* Sends an email with all the usernames registered at that address
*
* @param \App\Http\Requests\UserForgotUsernameRequest $request The forgot username request.
* @return \Illuminate\Http\Response
*/
public function forgotUsername(UserForgotUsernameRequest $request)
{
$users = User::where('email', $request->input('email'))->whereNotNull('email_verified_at')->get();
if ($users->count() > 0) {
dispatch((new SendEmailJob(
$users->first()->email,
new ForgotUsername($users->pluck('username')->toArray())
)))->onQueue('mail');
return $this->respondNoContent();
}
return $this->respondJson(['message' => 'Username send to the email address if registered']);
}
/** /**
* Generates a new reset password code * Generates a new reset password code
* *
@@ -203,7 +193,7 @@ class UserController extends ApiController
*/ */
public function forgotPassword(UserForgotPasswordRequest $request) public function forgotPassword(UserForgotPasswordRequest $request)
{ {
$user = User::where('username', $request->input('username'))->first(); $user = User::where('email', $request->input('email'))->first();
if ($user !== null) { if ($user !== null) {
$user->codes()->where('action', 'reset-password')->delete(); $user->codes()->where('action', 'reset-password')->delete();
$code = $user->codes()->create([ $code = $user->codes()->create([
@@ -299,7 +289,7 @@ class UserController extends ApiController
{ {
UserCode::clearExpired(); UserCode::clearExpired();
$user = User::where('username', $request->input('username'))->first(); $user = User::where('email', $request->input('email'))->first();
if ($user !== null) { if ($user !== null) {
$code = $user->codes()->where('action', 'verify-email')->first(); $code = $user->codes()->where('action', 'verify-email')->first();
$code->regenerate(); $code->regenerate();
@@ -324,7 +314,7 @@ class UserController extends ApiController
*/ */
public function resendVerifyEmailCode(UserResendVerifyEmailRequest $request) public function resendVerifyEmailCode(UserResendVerifyEmailRequest $request)
{ {
$user = User::where('username', $request->input('username'))->first(); $user = User::where('email', $request->input('email'))->first();
if ($user !== null) { if ($user !== null) {
$user->codes()->where('action', 'verify-email')->delete(); $user->codes()->where('action', 'verify-email')->delete();

View File

@@ -14,7 +14,7 @@ class AuthLoginRequest extends FormRequest
public function rules() public function rules()
{ {
return [ return [
'username' => 'required|string|min:6|max:255', 'email' => 'required|string|min:6|max:255',
'password' => 'required|string|min:6', 'password' => 'required|string|min:6',
]; ];
} }

View File

@@ -15,7 +15,7 @@ class UserForgotPasswordRequest extends FormRequest
public function rules() public function rules()
{ {
return [ return [
'username' => 'required|exists:users,username', 'email' => 'required|exists:users,email',
// 'captcha_token' => [new Recaptcha()], // 'captcha_token' => [new Recaptcha()],
]; ];
} }

View File

@@ -1,22 +0,0 @@
<?php
namespace App\Http\Requests;
use App\Rules\Recaptcha;
use Illuminate\Foundation\Http\FormRequest;
class UserForgotUsernameRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, mixed>
*/
public function rules()
{
return [
'email' => 'required|email|max:255',
// 'captcha_token' => [new Recaptcha()],
];
}
}

View File

@@ -16,9 +16,8 @@ class UserRegisterRequest extends FormRequest
return [ return [
'first_name' => 'required|string|max:255', 'first_name' => 'required|string|max:255',
'last_name' => 'required|string|max:255', 'last_name' => 'required|string|max:255',
'display_name' => 'required|string|max:255', 'display_name' => 'required|string|max:255|uniqueish:users',
'email' => 'required|string|email|max:255', 'email' => 'required|string|email|max:255|unique:users',
'username' => 'required|string|min:4|max:255|unique:users',
'password' => 'required|string|min:8', 'password' => 'required|string|min:8',
]; ];
} }

View File

@@ -3,9 +3,18 @@
namespace App\Http\Requests; namespace App\Http\Requests;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use App\Rules\Uniqueish;
class UserRequest extends BaseRequest class UserRequest extends BaseRequest
{ {
/**
* Fields that are required unless all are null.
*
* @var string[]
*/
protected $required_with_all = ['first_name','last_name','display_name','phone'];
/** /**
* Apply the additional POST base rules to this request * Apply the additional POST base rules to this request
* *
@@ -14,11 +23,10 @@ class UserRequest extends BaseRequest
public function postRules() public function postRules()
{ {
return [ return [
'username' => 'required|string|max:255|min:4|unique:users',
'first_name' => 'required|string|max:255|min:2', 'first_name' => 'required|string|max:255|min:2',
'last_name' => 'required|string|max:255|min:2', 'last_name' => 'required|string|max:255|min:2',
'display_name' => 'required|string|max:255', 'display_name' => 'required|string|max:255|uniqueish:users',
'email' => 'required|string|email|max:255', 'email' => 'required|string|email|max:255|unique:users',
'phone' => ['string', 'regex:/^(\+|00)?[0-9][0-9 \-\(\)\.]{7,32}$/'], 'phone' => ['string', 'regex:/^(\+|00)?[0-9][0-9 \-\(\)\.]{7,32}$/'],
'email_verified_at' => 'date' 'email_verified_at' => 'date'
]; ];
@@ -33,24 +41,32 @@ class UserRequest extends BaseRequest
{ {
$user = $this->route('user'); $user = $this->route('user');
$required_with_all = count($this->required_with_all) > 0 ? 'required_with_all:' . implode(',', $this->required_with_all) : '';
return [ return [
'username' => [ 'first_name' => "nullable|string|required_if_any:users,last_name,display_name,phone,password|between:2,255",
'last_name' => "nullable|required_if_any:users,first_name,display_name,phone,password|string|max:255|min:2",
'display_name' => [
'nullable',
'required_if_any:users,first_name,last_name,phone,password',
'string', 'string',
'max:255', 'max:255',
'min:4', 'min:2',
(new Uniqueish('users', 'display_name'))->ignore($user->id),
],
'email' => [
'string',
'email',
'max:255',
Rule::unique('users')->ignore($user->id)->when( Rule::unique('users')->ignore($user->id)->when(
$this->username !== $user->username, $this->email !== $user->email,
function ($query) { function ($query) {
return $query->where('username', $this->username); return $query->where('email', $this->email);
} }
), ),
], ],
'first_name' => 'string|max:255|min:2', 'phone' => ['nullable', 'regex:/^(\+|00)?[0-9][0-9 \-\(\)\.]{7,32}$/'],
'last_name' => 'string|max:255|min:2', 'password' => "nullable|{$required_with_all}|string|min:8"
'display_name' => 'string|max:255|min:2',
'email' => 'string|email|max:255',
'phone' => ['nullable','regex:/^(\+|00)?[0-9][0-9 \-\(\)\.]{7,32}$/'],
'password' => 'string|min:8'
]; ];
} }
} }

View File

@@ -15,7 +15,7 @@ class UserResendVerifyEmailRequest extends FormRequest
public function rules() public function rules()
{ {
return [ return [
'username' => 'required|exists:users,username', 'email' => 'required|exists:users,email',
// 'captcha_token' => [new Recaptcha()], // 'captcha_token' => [new Recaptcha()],
]; ];
} }

View File

@@ -1,60 +0,0 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class ForgotUsername extends Mailable
{
use Queueable;
use SerializesModels;
/**
* The list of usernames
*
* @var string[]
*/
public $usernames;
/**
* Create a new message instance.
*
* @param array $usernames The usernames.
* @return void
*/
public function __construct(array $usernames)
{
$this->usernames = $usernames;
}
/**
* Get the message envelope.
*
* @return \Illuminate\Mail\Mailables\Envelope
*/
public function envelope()
{
return new Envelope(
subject: '🤦 Forgot your username?',
);
}
/**
* Get the message content definition.
*
* @return \Illuminate\Mail\Mailables\Content
*/
public function content()
{
return new Content(
view: 'emails.user.forgot_username',
text: 'emails.user.forgot_username_plain',
);
}
}

View File

@@ -25,7 +25,6 @@ class User extends Authenticatable implements Auditable
* @var array<int, string> * @var array<int, string>
*/ */
protected $fillable = [ protected $fillable = [
'username',
'first_name', 'first_name',
'last_name', 'last_name',
'email', 'email',

View File

@@ -20,14 +20,12 @@ class UserFactory extends Factory
$faker = \Faker\Factory::create(); $faker = \Faker\Factory::create();
$faker->addProvider(new \Faker\Provider\CustomInternetProvider($faker)); $faker->addProvider(new \Faker\Provider\CustomInternetProvider($faker));
$username = $faker->unique()->userNameWithMinLength(6);
$first_name = $faker->firstName(); $first_name = $faker->firstName();
$last_name = $faker->lastName(); $last_name = $faker->lastName();
$display_name = $faker->randomElement([$username, $first_name . ' ' . $last_name]); $display_name = $first_name . ' ' . $last_name;
return [ return [
'username' => $username,
'first_name' => $first_name, 'first_name' => $first_name,
'last_name' => $last_name, 'last_name' => $last_name,
'email' => $faker->safeEmail(), 'email' => $faker->safeEmail(),

View File

@@ -0,0 +1,35 @@
<?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.
*
* @return void
*/
public function up()
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('username');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('users', function (Blueprint $table) {
$table->string('username')->unique();
});
DB::table('users')->update(['username' => DB::raw('display_name')]);
}
};

View File

@@ -20,7 +20,7 @@ class DatabaseSeeder extends Seeder
\App\Models\User::factory(40)->create(); \App\Models\User::factory(40)->create();
\App\Models\User::factory()->create([ \App\Models\User::factory()->create([
'username' => 'nomadjimbob', 'display_name' => 'James Collins',
'first_name' => 'James', 'first_name' => 'James',
'last_name' => 'Collins', 'last_name' => 'Collins',
'email' => 'james@stemmechanics.com.au', 'email' => 'james@stemmechanics.com.au',

View File

@@ -288,6 +288,16 @@ export const routes = [
component: () => component: () =>
import("@/views/dashboard/UserList.vue"), import("@/views/dashboard/UserList.vue"),
}, },
{
path: "create",
name: "dashboard-user-create",
meta: {
title: "Create User",
middleware: "authenticated",
},
component: () =>
import("@/views/dashboard/UserEdit.vue"),
},
{ {
path: ":id", path: ":id",
name: "dashboard-user-edit", name: "dashboard-user-edit",

View File

@@ -8,7 +8,7 @@
</h1> </h1>
<SMToolbar> <SMToolbar>
<div> <div>
<div class="author">By {{ article.user.username }}</div> <div class="author">By {{ article.user.display_name }}</div>
<div class="date">{{ formattedDate(article.publish_at) }}</div> <div class="date">{{ formattedDate(article.publish_at) }}</div>
</div> </div>
<SMButton <SMButton
@@ -47,7 +47,7 @@ const applicationStore = useApplicationStore();
*/ */
let article: Ref<Article> = ref({ let article: Ref<Article> = ref({
title: "", title: "",
user: { username: "" }, user: { display_name: "" },
}); });
/** /**

View File

@@ -5,11 +5,10 @@
<template v-if="!formDone"> <template v-if="!formDone">
<h1>Forgot Password</h1> <h1>Forgot Password</h1>
<p> <p>
Enter your username below to receive a password reset Enter your email below to receive a password reset link.
link to your email address.
</p> </p>
<SMForm v-model="form" @submit="handleSubmit"> <SMForm v-model="form" @submit="handleSubmit">
<SMInput control="username" /> <SMInput control="email" />
<SMButtonRow> <SMButtonRow>
<template #left> <template #left>
<div class="small"> <div class="small">
@@ -31,9 +30,9 @@
<template v-else> <template v-else>
<h1>Email Sent!</h1> <h1>Email Sent!</h1>
<p class="text-center"> <p class="text-center">
If that username has been registered, you will receive If that email address has been registered, you will
an email with a reset password link in the next few receive an email with a reset password link in the next
minutes. few minutes.
</p> </p>
<SMRow class="pb-2"> <SMRow class="pb-2">
<SMColumn class="justify-content-center"> <SMColumn class="justify-content-center">
@@ -56,13 +55,13 @@ import SMButtonRow from "../components/SMButtonRow.vue";
import SMInput from "../components/SMInput.vue"; import SMInput from "../components/SMInput.vue";
import { api } from "../helpers/api"; import { api } from "../helpers/api";
import { Form, FormControl } from "../helpers/form"; import { Form, FormControl } from "../helpers/form";
import { And, Min, Required } from "../helpers/validate"; import { And, Email, Required } from "../helpers/validate";
// const { executeRecaptcha, recaptchaLoaded } = useReCaptcha(); // const { executeRecaptcha, recaptchaLoaded } = useReCaptcha();
const formDone = ref(false); const formDone = ref(false);
let form = reactive( let form = reactive(
Form({ Form({
username: FormControl("", And([Required(), Min(4)])), email: FormControl("", And([Required(), Email()])),
}) })
); );
@@ -76,7 +75,7 @@ const handleSubmit = async () => {
await api.post({ await api.post({
url: "/users/forgotPassword", url: "/users/forgotPassword",
body: { body: {
username: form.controls.username.value, email: form.controls.email.value,
// captcha_token: captcha, // captcha_token: captcha,
}, },
}); });

View File

@@ -1,90 +0,0 @@
<template>
<SMPage>
<SMRow>
<SMFormCard class="mt-5">
<template v-if="!formDone">
<h1>Forgot Username</h1>
<p>
Enter your email address, and if an account exists, we
will email you your username.
</p>
<SMForm v-model="form" @submit="handleSubmit">
<SMInput control="email" />
<SMButtonRow>
<template #left>
<div class="small">
<span class="pr-1">Remember?</span
><router-link :to="{ name: 'login' }"
>Log in</router-link
>
</div>
</template>
<template #right>
<SMButton
type="submit"
label="Send"
icon="arrow-forward-outline" />
</template>
</SMButtonRow>
</SMForm>
</template>
<template v-else>
<h1>Email Sent!</h1>
<p class="text-center">
If that email has a registered account, you should
receive it shortly.
</p>
<SMRow class="pb-2">
<SMColumn class="justify-content-center">
<SMButton :to="{ name: 'home' }" label="Home" />
</SMColumn>
</SMRow>
</template>
</SMFormCard>
</SMRow>
</SMPage>
</template>
<script setup lang="ts">
import { reactive, ref } from "vue";
// import { useReCaptcha } from "vue-recaptcha-v3";
import SMButton from "../components/SMButton.vue";
import SMFormCard from "../components/SMFormCard.vue";
import SMForm from "../components/SMForm.vue";
import SMButtonRow from "../components/SMButtonRow.vue";
import SMInput from "../components/SMInput.vue";
import { api } from "../helpers/api";
import { Form, FormControl } from "../helpers/form";
import { And, Email, Required } from "../helpers/validate";
// const { executeRecaptcha, recaptchaLoaded } = useReCaptcha();
const formDone = ref(false);
let form = reactive(
Form({
email: FormControl("", And([Required(), Email()])),
})
);
const handleSubmit = async () => {
form.loading(true);
try {
// await recaptchaLoaded();
// const captcha = await executeRecaptcha("submit");
await api.post({
url: "/users/forgotUsername",
body: {
email: form.controls.email.value,
// captcha_token: captcha,
},
});
formDone.value = true;
} catch (error) {
form.apiErrors(error);
}
form.loading(false);
};
</script>

View File

@@ -9,11 +9,7 @@
</p> </p>
</template> </template>
<template #body> <template #body>
<SMInput control="username" autofocus> <SMInput control="email" autofocus> </SMInput>
<router-link to="/forgot-username"
>Forgot username?</router-link
>
</SMInput>
<SMInput control="password" type="password"> <SMInput control="password" type="password">
<router-link to="/forgot-password" <router-link to="/forgot-password"
>Forgot password?</router-link >Forgot password?</router-link
@@ -54,7 +50,7 @@ import SMInput from "../components/SMInput.vue";
import { api } from "../helpers/api"; import { api } from "../helpers/api";
import { LoginResponse } from "../helpers/api.types"; import { LoginResponse } from "../helpers/api.types";
import { Form, FormControl } from "../helpers/form"; import { Form, FormControl } from "../helpers/form";
import { And, Min, Required } from "../helpers/validate"; import { And, Email, Required } from "../helpers/validate";
import { useUserStore } from "../store/UserStore"; import { useUserStore } from "../store/UserStore";
import SMButtonRow from "../components/SMButtonRow.vue"; import SMButtonRow from "../components/SMButtonRow.vue";
@@ -63,7 +59,7 @@ const userStore = useUserStore();
const router = useRouter(); const router = useRouter();
let form = reactive( let form = reactive(
Form({ Form({
username: FormControl("", And([Required(), Min(4)])), email: FormControl("", And([Required(), Email()])),
password: FormControl("", Required()), password: FormControl("", Required()),
}) })
); );
@@ -81,7 +77,7 @@ const handleSubmit = async () => {
let result = await api.post({ let result = await api.post({
url: "/login", url: "/login",
body: { body: {
username: form.controls.username.value, email: form.controls.email.value,
password: form.controls.password.value, password: form.controls.password.value,
}, },
}); });

View File

@@ -1,7 +1,7 @@
<template> <template>
<SMContainer :center="true"> <SMContainer :center="true">
<SMForm v-if="!userRegistered" v-model="form" @submit="handleSubmit"> <SMForm v-if="!userRegistered" v-model="form" @submit="handleSubmit">
<SMFormCard full> <SMFormCard>
<template #header> <template #header>
<h2>Register</h2> <h2>Register</h2>
<p> <p>
@@ -10,36 +10,9 @@
</p> </p>
</template> </template>
<template #body> <template #body>
<SMRow> <SMInput control="email" autofocus />
<SMColumn> <SMInput control="password" type="password" />
<SMInput control="username" autofocus /> <SMInput control="display_name" label="Display Name" />
</SMColumn>
<SMColumn>
<SMInput
control="password"
type="password"></SMInput>
</SMColumn>
</SMRow>
<SMRow>
<SMColumn>
<SMInput control="first_name" />
</SMColumn>
<SMColumn>
<SMInput control="last_name" />
</SMColumn>
</SMRow>
<SMRow>
<SMColumn>
<SMInput control="email" />
</SMColumn>
<SMColumn>
<SMInput control="phone"
><template #help
>This field is optional</template
>
</SMInput>
</SMColumn>
</SMRow>
</template> </template>
<template #footer-space-between> <template #footer-space-between>
<div class="small"> <div class="small">
@@ -125,7 +98,6 @@ let form = reactive(
first_name: FormControl("", Required()), first_name: FormControl("", Required()),
last_name: FormControl("", Required()), last_name: FormControl("", Required()),
email: FormControl("", And([Required(), Email()])), email: FormControl("", And([Required(), Email()])),
phone: FormControl("", Phone()),
username: FormControl("", And([Min(4), Custom(checkUsername)])), username: FormControl("", And([Min(4), Custom(checkUsername)])),
password: FormControl("", And([Required(), Password()])), password: FormControl("", And([Required(), Password()])),
}) })
@@ -135,20 +107,14 @@ const handleSubmit = async () => {
form.loading(true); form.loading(true);
try { try {
// await recaptchaLoaded();
// const captcha = await executeRecaptcha("submit");
await api.post({ await api.post({
url: "/register", url: "/register",
body: { body: {
first_name: form.controls.first_name.value, first_name: form.controls.first_name.value,
last_name: form.controls.last_name.value, last_name: form.controls.last_name.value,
email: form.controls.email.value, email: form.controls.email.value,
phone: form.controls.phone.value,
username: form.controls.username.value,
password: form.controls.password.value, password: form.controls.password.value,
display_name: form.controls.username.value, display_name: form.controls.display_name.value,
// captcha_token: captcha,
}, },
}); });

View File

@@ -5,7 +5,7 @@
<template v-if="!formDone"> <template v-if="!formDone">
<h1>Resend Verify Email</h1> <h1>Resend Verify Email</h1>
<SMForm v-model="form" @submit="handleSubmit"> <SMForm v-model="form" @submit="handleSubmit">
<SMInput control="username" /> <SMInput control="email" />
<SMButtonRow> <SMButtonRow>
<template #left> <template #left>
<div class="small"> <div class="small">
@@ -27,9 +27,9 @@
<template v-else> <template v-else>
<h1>Email Sent!</h1> <h1>Email Sent!</h1>
<p class="text-center"> <p class="text-center">
If that username has been registered, and you still need If that email address has been registered, and you still
to verify your email, you will receive an email with a need to verify your email, you will receive an email
new verify code. with a new verify code.
</p> </p>
<SMButtonRow> <SMButtonRow>
<template #right> <template #right>
@@ -52,13 +52,13 @@ import SMButtonRow from "../components/SMButtonRow.vue";
import SMInput from "../components/SMInput.vue"; import SMInput from "../components/SMInput.vue";
import { api } from "../helpers/api"; import { api } from "../helpers/api";
import { Form, FormControl } from "../helpers/form"; import { Form, FormControl } from "../helpers/form";
import { Required } from "../helpers/validate"; import { And, Email, Required } from "../helpers/validate";
// const { executeRecaptcha, recaptchaLoaded } = useReCaptcha(); // const { executeRecaptcha, recaptchaLoaded } = useReCaptcha();
const formDone = ref(false); const formDone = ref(false);
let form = reactive( let form = reactive(
Form({ Form({
username: FormControl("", Required()), email: FormControl("", And([Required(), Email()])),
}) })
); );
@@ -72,7 +72,7 @@ const handleSubmit = async () => {
await api.post({ await api.post({
url: "/users/resendVerifyEmailCode", url: "/users/resendVerifyEmailCode",
body: { body: {
username: form.controls.username.value, email: form.controls.email.value,
// captcha_token: captcha, // captcha_token: captcha,
}, },
}); });

View File

@@ -301,7 +301,7 @@ const loadOptionsAuthors = async () => {
api.get({ api.get({
url: "/users", url: "/users",
params: { params: {
fields: "id,username,first_name,last_name", fields: "id,display_name",
limit: 100, limit: 100,
}, },
}) })
@@ -312,7 +312,7 @@ const loadOptionsAuthors = async () => {
authors.value = {}; authors.value = {};
data.users.forEach((item) => { data.users.forEach((item) => {
authors.value[item.id] = `${item.username}`; authors.value[item.id] = `${item.display_name}`;
}); });
} }
}) })

View File

@@ -2,15 +2,18 @@
<SMMastHead <SMMastHead
:title="pageHeading" :title="pageHeading"
:back-link=" :back-link="
route.params.id route.params.id || isCreatingUser
? { name: 'dashboard-user-list' } ? { name: 'dashboard-user-list' }
: { name: 'dashboard' } : { name: 'dashboard' }
" "
:back-title="route.params.id ? 'Back to Users' : 'Back to Dashboard'" /> :back-title="
route.params.id || isCreatingUser
? 'Back to Users'
: 'Back to Dashboard'
" />
<SMContainer> <SMContainer>
<SMForm :model-value="form" @submit="handleSubmit"> <SMForm :model-value="form" @submit="handleSubmit">
<SMRow> <SMRow>
<SMColumn><SMInput control="username" disabled /></SMColumn>
<SMColumn><SMInput control="display_name" /></SMColumn> <SMColumn><SMInput control="display_name" /></SMColumn>
</SMRow> </SMRow>
<SMRow> <SMRow>
@@ -90,9 +93,10 @@ const route = useRoute();
const router = useRouter(); const router = useRouter();
const userStore = useUserStore(); const userStore = useUserStore();
const isCreatingUser = route.path.endsWith("/create");
let form = reactive( let form = reactive(
Form({ Form({
username: FormControl("", And([Required()])),
display_name: FormControl("", And([Required()])), display_name: FormControl("", And([Required()])),
first_name: FormControl("", And([Required()])), first_name: FormControl("", And([Required()])),
last_name: FormControl("", And([Required()])), last_name: FormControl("", And([Required()])),
@@ -122,7 +126,6 @@ const loadData = async () => {
const data = result.data as UserResponse; const data = result.data as UserResponse;
if (data && data.user) { if (data && data.user) {
form.controls.username.value = data.user.username;
form.controls.first_name.value = data.user.first_name; form.controls.first_name.value = data.user.first_name;
form.controls.last_name.value = data.user.last_name; form.controls.last_name.value = data.user.last_name;
form.controls.display_name.value = data.user.display_name; form.controls.display_name.value = data.user.display_name;
@@ -134,8 +137,7 @@ const loadData = async () => {
} finally { } finally {
form.loading(false); form.loading(false);
} }
} else { } else if (isCreatingUser == false) {
form.controls.username.value = userStore.username;
form.controls.first_name.value = userStore.firstName; form.controls.first_name.value = userStore.firstName;
form.controls.last_name.value = userStore.lastName; form.controls.last_name.value = userStore.lastName;
form.controls.display_name.value = userStore.displayName; form.controls.display_name.value = userStore.displayName;
@@ -150,34 +152,56 @@ const loadData = async () => {
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
form.loading(true); form.loading(true);
const result = await api.put({ const id = route.params.id ? route.params.id : userStore.id;
url: "/users/{id}",
params: {
id: userStore.id,
},
body: {
first_name: form.controls.first_name.value,
last_name: form.controls.last_name.value,
display_name: form.controls.display_name.value,
email: form.controls.email.value,
phone: form.controls.phone.value,
},
});
const data = result.data as UserResponse; if (isCreatingUser == false) {
const result = await api.put({
url: "/users/{id}",
params: {
id: id,
},
body: {
first_name: form.controls.first_name.value,
last_name: form.controls.last_name.value,
display_name: form.controls.display_name.value,
email: form.controls.email.value,
phone: form.controls.phone.value,
},
});
if (data && data.user) { const data = result.data as UserResponse;
userStore.setUserDetails(data.user);
if (route.params.id && data && data.user) {
userStore.setUserDetails(data.user);
}
useToastStore().addToast({
title: "Details Updated",
content: "The user has been updated.",
type: "success",
});
} else {
await api.post({
url: "/users",
params: {
id: id,
},
body: {
first_name: form.controls.first_name.value,
last_name: form.controls.last_name.value,
display_name: form.controls.display_name.value,
email: form.controls.email.value,
phone: form.controls.phone.value,
},
});
useToastStore().addToast({
title: "User Created",
content: "The user has been created.",
type: "success",
});
} }
useToastStore().addToast({
title: route.params.id ? "Details Updated" : "User Created",
content: route.params.id
? "The user has been updated."
: "The user has been created.",
type: "success",
});
router.push({ name: "dashboard" }); router.push({ name: "dashboard" });
} catch (err) { } catch (err) {
form.apiErrors(err); form.apiErrors(err);

View File

@@ -1,91 +1,154 @@
<template> <template>
<SMPage permission="admin/users" :page-error="pageError"> <SMPage permission="admin/users">
<SMMastHead <SMMastHead
title="Users" title="Users"
:back-link="{ name: 'dashboard' }" :back-link="{ name: 'dashboard' }"
back-title="Return to Dashboard" /> back-title="Return to Dashboard" />
<SMContainer> <SMContainer class="flex-grow-1">
<SMTable <SMToolbar>
:headers="headers" <SMButton
:items="items" :to="{ name: 'dashboard-user-create' }"
@row-click="handleRowClick"> type="primary"
<template #item-actions="item"> label="Create User" />
<SMButton <SMInput
label="Edit" v-model="itemSearch"
:dropdown="{ label="Search"
download: 'Download', class="toolbar-search"
delete: 'Delete', @keyup.enter="handleSearch">
}" <template #append>
size="medium" /> <SMButton
</template> type="primary"
</SMTable> label="Search"
icon="search-outline"
@click="handleSearch" />
</template>
</SMInput>
</SMToolbar>
<SMLoading large v-if="itemsLoading" />
<template v-else>
<SMPagination
v-if="items.length < itemsTotal"
v-model="itemsPage"
:total="itemsTotal"
:per-page="itemsPerPage" />
<SMNoItems v-if="items.length == 0" text="No Media Found" />
<SMTable
:headers="headers"
:items="items"
@row-click="handleEdit">
<template #item-actions="item">
<SMButton
label="Edit"
:dropdown="{
delete: 'Delete',
}"
size="medium"
@click="handleActionButton(item, $event)" />
</template>
</SMTable>
</template>
</SMContainer> </SMContainer>
</SMPage> </SMPage>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { reactive, ref, watch } from "vue"; import { ref, watch } from "vue";
import { useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { openDialog } from "../../components/SMDialog"; import { openDialog } from "../../components/SMDialog";
import DialogConfirm from "../../components/dialogs/SMDialogConfirm.vue"; import DialogConfirm from "../../components/dialogs/SMDialogConfirm.vue";
import { api } from "../../helpers/api"; import { api, getApiResultData } from "../../helpers/api";
import { SMDate } from "../../helpers/datetime"; import { SMDate } from "../../helpers/datetime";
import SMTable from "../../components/SMTable.vue"; import SMTable from "../../components/SMTable.vue";
import SMMastHead from "../../components/SMMastHead.vue"; import SMMastHead from "../../components/SMMastHead.vue";
import { useToastStore } from "../../store/ToastStore"; import { useToastStore } from "../../store/ToastStore";
import SMNoItems from "../../components/SMNoItems.vue";
import SMButton from "../../components/SMButton.vue";
import SMInput from "../../components/SMInput.vue";
import SMToolbar from "../../components/SMToolbar.vue";
import { updateRouterParams } from "../../helpers/url";
import { User, UserCollection } from "../../helpers/api.types";
import SMLoading from "../../components/SMLoading.vue";
import SMPagination from "../../components/SMPagination.vue";
const route = useRoute();
const router = useRouter(); const router = useRouter();
const searchValue = ref("");
const pageError = ref(0); const items = ref([]);
const itemsLoading = ref(false);
const itemSearch = ref((route.query.search as string) || "");
const itemsTotal = ref(0);
const itemsPerPage = 25;
const itemsPage = ref(parseInt((route.query.page as string) || "1"));
const headers = [ const headers = [
{ text: "Username", value: "username", sortable: true }, { text: "Display name", value: "display_name", sortable: true },
{ text: "First name", value: "first_name", sortable: true }, { text: "First name", value: "first_name", sortable: true },
{ text: "Last name", value: "last_name", sortable: true }, { text: "Last name", value: "last_name", sortable: true },
{ text: "Email", value: "email", sortable: true }, { text: "Email", value: "email", sortable: true },
{ text: "Phone", value: "phone", sortable: true },
{ text: "Joined", value: "created_at", sortable: true },
// { text: "Last logged in", value: "lastAttended", width: 200},
{ text: "Actions", value: "actions" }, { text: "Actions", value: "actions" },
]; ];
const items = ref([]); /**
const formLoading = ref(false); * Watch if page number changes.
const serverItemsLength = ref(0); */
const serverOptions = ref({ watch(itemsPage, () => {
page: 1, handleLoad();
rowsPerPage: 25,
sortBy: null,
sortType: null,
}); });
const handleRowClick = (item) => { /**
router.push({ name: "dashboard-user-edit", params: { id: item.id } }); * Handle searching for item.
*/
const handleSearch = () => {
itemsPage.value = 1;
handleLoad();
}; };
const loadFromServer = async () => { /**
formLoading.value = true; * Handle user selecting option in action button.
*
* @param {Event} item The event item.
* @param option
*/
const handleActionButton = (item: Event, option: string): void => {
if (option.length == 0) {
handleEdit(item);
} else if (option.toLowerCase() == "delete") {
handleDelete(item);
}
};
/**
* Handle loading the page and list
*/
const handleLoad = async () => {
itemsLoading.value = true;
items.value = [];
itemsTotal.value = 0;
updateRouterParams(router, {
search: itemSearch.value,
page: itemsPage.value == 1 ? "" : itemsPage.value.toString(),
});
try { try {
let params = {}; let params = {
if (serverOptions.value.sortBy) { page: itemsPage.value,
params["sort"] = serverOptions.value.sortBy; limit: itemsPerPage,
if ( };
serverOptions.value.sortType &&
serverOptions.value.sortType === "desc" if (itemSearch.value.length > 0) {
) { params[
params["sort"] = "-" + params["sort"]; "filter"
} ] = `title:${itemSearch.value},OR,content:${itemSearch.value}`;
} }
params["page"] = serverOptions.value.page; let result = await api.get({
params["limit"] = serverOptions.value.rowsPerPage;
let res = await api.get({
url: "/users", url: "/users",
params: params, params: params,
}); });
items.value = res.data.users;
const userCollection = getApiResultData<UserCollection>(result);
items.value = userCollection.users;
items.value.forEach((row) => { items.value.forEach((row) => {
if (row.created_at !== "undefined") { if (row.created_at !== "undefined") {
@@ -96,44 +159,22 @@ const loadFromServer = async () => {
} }
}); });
serverItemsLength.value = res.data.total; itemsTotal.value = userCollection.total;
} catch (err) { } catch (err) {
/* empty */ /* empty */
} }
formLoading.value = false; itemsLoading.value = false;
}; };
loadFromServer(); const handleEdit = (user: User) => {
watch(
serverOptions,
() => {
loadFromServer();
},
{ deep: true }
);
const headerItemClassNameFunction = (header) => {
if (["position", "actions"].includes(header.value))
return "easy-data-table-cell-center";
return "";
};
const bodyItemClassNameFunction = (column) => {
if (["position", "actions"].includes(column))
return "easy-data-table-cell-center";
return "";
};
const handleEdit = (user) => {
router.push({ name: "dashboard-user-edit", params: { id: user.id } }); router.push({ name: "dashboard-user-edit", params: { id: user.id } });
}; };
const handleDelete = async (user) => { const handleDelete = async (user: User) => {
let result = await openDialog(DialogConfirm, { let result = await openDialog(DialogConfirm, {
title: "Delete User?", title: "Delete User?",
text: `Are you sure you want to delete the user <strong>${user.username}</strong>?`, text: `Are you sure you want to delete the user <strong>${user.display_name}</strong>?`,
cancel: { cancel: {
type: "secondary", type: "secondary",
label: "Cancel", label: "Cancel",
@@ -147,7 +188,7 @@ const handleDelete = async (user) => {
if (result == true) { if (result == true) {
try { try {
await api.delete(`users${user.id}`); await api.delete(`users${user.id}`);
loadFromServer(); handleLoad();
useToastStore().addToast({ useToastStore().addToast({
title: "User Deleted", title: "User Deleted",
@@ -163,6 +204,33 @@ const handleDelete = async (user) => {
} }
} }
}; };
handleLoad();
</script> </script>
<style lang="scss"></style> <style lang="scss">
.page-dashboard-user-list {
.toolbar-search {
max-width: 350px;
}
// .table tr {
// td:first-of-type,
// td:nth-of-type(2) {
// word-break: break-all;
// }
// td:not(:first-of-type) {
// white-space: nowrap;
// }
// }
}
@media only screen and (max-width: 768px) {
.page-dashboard-user-list {
.toolbar-search {
max-width: none;
}
}
}
</style>

View File

@@ -57,7 +57,7 @@
</td> </td>
</tr> </tr>
<tr> <tr>
<td><h2>Hey {{ $user?->username }},</h2></td> <td><h2>Hey {{ $user?->display_name }},</h2></td>
</tr> </tr>
<tr> <tr>
<td> <td>

View File

@@ -1,4 +1,4 @@
Hey {{ $user?->username }}, Hey {{ $user?->display_name }},
We just need to confirm that this is your new email address. We just need to confirm that this is your new email address.

View File

@@ -57,7 +57,7 @@
</td> </td>
</tr> </tr>
<tr> <tr>
<td><h2>Yo {{ $user?->username }}</h2></td> <td><h2>Yo {{ $user?->display_name }}</h2></td>
</tr> </tr>
<tr> <tr>
<td style="padding-bottom: 2rem"> <td style="padding-bottom: 2rem">

View File

@@ -1,4 +1,4 @@
Yo {{ $user?->username }} Yo {{ $user?->display_name }}
Just a quick word that your email has been changed to {{ $new_email }}. Just a quick word that your email has been changed to {{ $new_email }}.

View File

@@ -57,7 +57,7 @@
</td> </td>
</tr> </tr>
<tr> <tr>
<td><h2>Yo {{ $user?->username }}</h2></td> <td><h2>Yo {{ $user?->display_name }}</h2></td>
</tr> </tr>
<tr> <tr>
<td style="padding-bottom: 2rem"> <td style="padding-bottom: 2rem">

View File

@@ -1,4 +1,4 @@
Yo {{ $user?->username }} Yo {{ $user?->display_name }}
Just a quick word that your password has been changed. Just a quick word that your password has been changed.

View File

@@ -57,7 +57,7 @@
</td> </td>
</tr> </tr>
<tr> <tr>
<td><h2>Welcome {{ $user?->username }},</h2></td> <td><h2>Welcome {{ $user?->display_name }},</h2></td>
</tr> </tr>
<tr> <tr>
<td> <td>

View File

@@ -1,4 +1,4 @@
Welcome {{ $user?->username }}, Welcome {{ $user?->display_name }},
We've heard you would like to try out our workshops and courses! We've heard you would like to try out our workshops and courses!
Before we can let you loose on our website, we need to make sure you are a real person and not a pesky robot or cat. Before we can let you loose on our website, we need to make sure you are a real person and not a pesky robot or cat.

View File

@@ -57,7 +57,7 @@
</td> </td>
</tr> </tr>
<tr> <tr>
<td><h2>Yo {{ $user?->username }}</h2></td> <td><h2>Yo {{ $user?->display_name }}</h2></td>
</tr> </tr>
<tr> <tr>
<td> <td>

View File

@@ -1,4 +1,4 @@
Yo {{ $user?->username }} Yo {{ $user?->display_name }}
We all forget things sometimes! But you can reset your password typing the following into your browser https://www.stemmechanics.com.au/reset-password and entering the following code: We all forget things sometimes! But you can reset your password typing the following into your browser https://www.stemmechanics.com.au/reset-password and entering the following code:

View File

@@ -1,131 +0,0 @@
<!DOCTYPE html>
<html
lang="{{ str_replace('_', '-', app()->getLocale()) }}"
xmlns="http://www.w3.org/1999/xhtml"
xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office"
>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>STEMMechanics - Forgot Password</title>
<link
rel="noopener"
target="_blank"
href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800&display=swap"
rel="stylesheet"
/>
<!--[if gte mso 9]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG />
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<style>
@import url("https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800&display=swap");
</style>
</head>
<body>
<table
cellspacing="0"
cellpadding="0"
border="0"
role="presentation"
style="
width: 100%;
padding: 2rem;
font-size: 1.1rem;
color: #000000;
font-family: Nunito, Arial, Helvetica, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
"
>
<tr>
<td>
<a href="https://www.stemmechanics.com.au/">
<img
alt="STEMMechanics Logo"
src="{{ $message->embed(public_path('assets').'/logo.webp') }}"
width="400"
height="62"
/>
</a>
</td>
</tr>
<tr>
<td>
@if (count($usernames) > 2)
<h2>Yo {{ $usernames[0] }}, {{ $usernames[1] }}, or is it {{ $usernames[count($usernames)-1] }}?</h2>
@elseif (count($usernames) > 1)
<h2>Yo {{ $usernames[0] }}, or is it {{ $usernames[1] }}?</h2>
@else
<h2>Yo {{ $usernames[0] }},</h2>
@endif
</td>
</tr>
<tr>
<td style="padding-bottom: 2rem;">
@if (count($usernames) == 1)
Guess what, your username is <strong>{{ $usernames[0] }}</strong>.
@else
We have the following usernames registered to this email address:
</td>
</tr>
<tr>
<td style="padding-bottom: 2rem;">
<ul>
@foreach($usernames as $username)
<li>{{ $username }}</li>
@endforeach
</ul>
@endif
</td>
</tr>
<tr>
<td
align="center"
style="
font-size: 90%;
text-align: center;
padding-top: 2rem;
padding-bottom: 2rem;
border-top: 1px solid #ddd;
"
>
Need help or got feedback?
<a href="https://www.stemmechanics.com.au/contact"
>Contact us</a
>
or touch base at
<a href="https://twitter.com/stemmechanics"
>@stemmechanics</a
>.
</td>
</tr>
<tr>
<td
align="center"
style="
font-size: 80%;
text-align: center;
padding-top: 1rem;
padding-bottom: 2rem;
"
>
Sent by STEMMechanics &middot;
<a href="https://www.stemmechanics.com.au/"
>Visit our Website</a
>
&middot;
<a href="https://twitter.com/stemmechanics"
>@stemmechanics</a
><br />PO Box 36, Edmonton, QLD 4869, Australia
</td>
</tr>
</table>
</body>
</html>

View File

@@ -1,24 +0,0 @@
@if (count($usernames) > 2)
Yo {{ $usernames[0] }}, {{ $usernames[1] }}, or is it {{ $usernames[count($usernames)-1] }}?
@elseif (count($usernames) > 1)
Yo {{ $usernames[0] }} or is it {{ $usernames[1] }}?
@else
Yo {{ $usernames[0] }},
@endif
@if (count($usernames) == 1)
Guess what, your username is {{ $usernames[0] }}.
@else
We have the following usernames registered to this email address:
@foreach($usernames as $username)
- {{ $username }}
@endforeach
@endif
Need help or got feedback? Contact us at https://www.stemmechanics.com.au/contact or touch base on twitter at @stemmechanics
--
Sent by STEMMechanics
https://www.stemmechanics.com.au/
PO Box 36, Edmonton, QLD 4869, Australia

View File

@@ -1,116 +0,0 @@
<!DOCTYPE html>
<html
lang="{{ str_replace('_', '-', app()->getLocale()) }}"
xmlns="http://www.w3.org/1999/xhtml"
xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office"
>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>STEMMechanics - Forgot Password</title>
<link
rel="noopener"
target="_blank"
href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800&display=swap"
rel="stylesheet"
/>
<!--[if gte mso 9]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG />
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<style>
@import url("https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800&display=swap");
</style>
</head>
<body>
<table
cellspacing="0"
cellpadding="0"
border="0"
role="presentation"
style="
width: 100%;
padding: 2rem;
font-size: 1.1rem;
color: #000000;
font-family: Nunito, Arial, Helvetica, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
"
>
<tr>
<td>
<a href="https://www.stemmechanics.com.au/">
<img
alt="STEMMechanics Logo"
src="{{ $message->embed(public_path('assets').'/logo.webp') }}"
width="400"
height="62"
/>
</a>
</td>
</tr>
<tr>
<p></p>
<td><h2>Howdy there,</h2></td>
</tr>
<tr>
<td style="padding-bottom: 2rem">
At your request, you are now subscribed to our newsletter giving you tips, tricks and letting you know when new workshops are scheduled.
</td>
</tr>
<tr>
<td style="padding-bottom: 2rem">
If this wasn't you, you can unsubscribe by visiting <a href="https://www.stemmechanics.com.au/unsubscribe?email={{ $email }}">stemmechanics.com.au/unsubscribe</a>
</td>
</tr>
<tr>
<td
align="center"
style="
font-size: 90%;
text-align: center;
padding-top: 2rem;
padding-bottom: 2rem;
border-top: 1px solid #ddd;
"
>
Need help or got feedback?
<a href="https://www.stemmechanics.com.au/contact"
>Contact us</a
>
or touch base at
<a href="https://twitter.com/stemmechanics"
>@stemmechanics</a
>.
</td>
</tr>
<tr>
<td
align="center"
style="
font-size: 80%;
text-align: center;
padding-top: 1rem;
padding-bottom: 2rem;
"
>
Sent by STEMMechanics &middot;
<a href="https://www.stemmechanics.com.au/"
>Visit our Website</a
>
&middot;
<a href="https://twitter.com/stemmechanics"
>@stemmechanics</a
><br />PO Box 36, Edmonton, QLD 4869, Australia
</td>
</tr>
</table>
</body>
</html>

View File

@@ -1,14 +0,0 @@
Howdy there,
At your request, you are now subscribed to our newsletter giving you tips, tricks and letting you know when new workshops are scheduled.
If this wasn't you, you can unsubscribe by visiting the following URL in your browser:
https://www.stemmechanics.com.au/unsubscribe
Need help or got feedback? Contact us at https://www.stemmechanics.com.au/contact or touch base on twitter at @stemmechanics
--
Sent by STEMMechanics
https://www.stemmechanics.com.au/
PO Box 36, Edmonton, QLD 4869, Australia

View File

@@ -1,111 +0,0 @@
<!DOCTYPE html>
<html
lang="{{ str_replace('_', '-', app()->getLocale()) }}"
xmlns="http://www.w3.org/1999/xhtml"
xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office"
>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>STEMMechanics - Forgot Password</title>
<link
rel="noopener"
target="_blank"
href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800&display=swap"
rel="stylesheet"
/>
<!--[if gte mso 9]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG />
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<style>
@import url("https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800&display=swap");
</style>
</head>
<body>
<table
cellspacing="0"
cellpadding="0"
border="0"
role="presentation"
style="
width: 100%;
padding: 2rem;
font-size: 1.1rem;
color: #000000;
font-family: Nunito, Arial, Helvetica, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
"
>
<tr>
<td>
<a href="https://www.stemmechanics.com.au/">
<img
alt="STEMMechanics Logo"
src="{{ $message->embed(public_path('assets').'/logo.webp') }}"
width="400"
height="62"
/>
</a>
</td>
</tr>
<tr>
<p></p>
<td><h2>Howdy there,</h2></td>
</tr>
<tr>
<td style="padding-bottom: 2rem">
At your request, you are now unsubscribed from our newsletter.
</td>
</tr>
<tr>
<td
align="center"
style="
font-size: 90%;
text-align: center;
padding-top: 2rem;
padding-bottom: 2rem;
border-top: 1px solid #ddd;
"
>
Need help or got feedback?
<a href="https://www.stemmechanics.com.au/contact"
>Contact us</a
>
or touch base at
<a href="https://twitter.com/stemmechanics"
>@stemmechanics</a
>.
</td>
</tr>
<tr>
<td
align="center"
style="
font-size: 80%;
text-align: center;
padding-top: 1rem;
padding-bottom: 2rem;
"
>
Sent by STEMMechanics &middot;
<a href="https://www.stemmechanics.com.au/"
>Visit our Website</a
>
&middot;
<a href="https://twitter.com/stemmechanics"
>@stemmechanics</a
><br />PO Box 36, Edmonton, QLD 4869, Australia
</td>
</tr>
</table>
</body>
</html>

View File

@@ -1,10 +0,0 @@
Howdy there,
At your request, you have been unsubscribed from our newsletter.
Need help or got feedback? Contact us at https://www.stemmechanics.com.au/contact or touch base on twitter at @stemmechanics
--
Sent by STEMMechanics
https://www.stemmechanics.com.au/
PO Box 36, Edmonton, QLD 4869, Australia

View File

@@ -30,7 +30,6 @@ Route::get('/analytics', [AnalyticsController::class, 'index']);
Route::post('/analytics', [AnalyticsController::class, 'store']); Route::post('/analytics', [AnalyticsController::class, 'store']);
Route::apiResource('users', UserController::class); Route::apiResource('users', UserController::class);
Route::post('/users/forgotUsername', [UserController::class, 'forgotUsername']);
Route::post('/users/forgotPassword', [UserController::class, 'forgotPassword']); Route::post('/users/forgotPassword', [UserController::class, 'forgotPassword']);
Route::post('/users/resetPassword', [UserController::class, 'resetPassword']); Route::post('/users/resetPassword', [UserController::class, 'resetPassword']);
Route::post('/users/resendVerifyEmailCode', [UserController::class, 'resendVerifyEmailCode']); Route::post('/users/resendVerifyEmailCode', [UserController::class, 'resendVerifyEmailCode']);
@@ -45,9 +44,6 @@ Route::apiAttachmentResource('articles', ArticleController::class);
Route::apiResource('events', EventController::class); Route::apiResource('events', EventController::class);
Route::apiAttachmentResource('events', EventController::class); Route::apiAttachmentResource('events', EventController::class);
Route::apiResource('subscriptions', SubscriptionController::class);
Route::delete('subscriptions', [SubscriptionController::class, 'destroyByEmail']);
Route::post('/contact', [ContactController::class, 'send']); Route::post('/contact', [ContactController::class, 'send']);
Route::get('/logs/{name}', [LogController::class, 'show']); Route::get('/logs/{name}', [LogController::class, 'show']);

View File

@@ -1,4 +1,5 @@
<?php <?php
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase; use Tests\TestCase;
use App\Models\User; use App\Models\User;
@@ -7,15 +8,16 @@ class AuthApiTest extends TestCase
{ {
use RefreshDatabase; use RefreshDatabase;
public function testLogin() public function testLogin()
{ {
$user = User::factory()->create([ $user = User::factory()->create([
'password' => bcrypt('password'), 'password' => bcrypt('password'),
]); ]);
// Test successful login // Test successful login
$response = $this->postJson('/api/login', [ $response = $this->postJson('/api/login', [
'username' => $user->username, 'email' => $user->email,
'password' => 'password', 'password' => 'password',
]); ]);
$response->assertStatus(200); $response->assertStatus(200);
@@ -23,7 +25,7 @@ class AuthApiTest extends TestCase
'token', 'token',
]); ]);
$token = $response->json('token'); $token = $response->json('token');
// Test getting authenticated user // Test getting authenticated user
$response = $this->withHeaders([ $response = $this->withHeaders([
'Authorization' => "Bearer $token", 'Authorization' => "Bearer $token",
@@ -32,19 +34,19 @@ class AuthApiTest extends TestCase
$response->assertJson([ $response->assertJson([
'user' => [ 'user' => [
'id' => $user->id, 'id' => $user->id,
'username' => $user->username, 'email' => $user->email,
] ]
]); ]);
// Test logout // Test logout
$response = $this->withHeaders([ $response = $this->withHeaders([
'Authorization' => "Bearer $token", 'Authorization' => "Bearer $token",
])->postJson('/api/logout'); ])->postJson('/api/logout');
$response->assertStatus(204); $response->assertStatus(204);
// Test failed login // Test failed login
$response = $this->postJson('/api/login', [ $response = $this->postJson('/api/login', [
'username' => $user->username, 'email' => $user->email,
'password' => 'wrongpassword', 'password' => 'wrongpassword',
]); ]);
$response->assertStatus(422); $response->assertStatus(422);

View File

@@ -1,4 +1,5 @@
<?php <?php
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker; use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase; use Tests\TestCase;
@@ -8,6 +9,7 @@ class UsersApiTest extends TestCase
{ {
use RefreshDatabase; use RefreshDatabase;
public function testNonAdminUsersCanOnlyViewBasicUserInfo() public function testNonAdminUsersCanOnlyViewBasicUserInfo()
{ {
// create a non-admin user // create a non-admin user
@@ -25,7 +27,7 @@ class UsersApiTest extends TestCase
'users' => [ 'users' => [
'*' => [ '*' => [
'id', 'id',
'username' 'display_name'
] ]
], ],
'total' 'total'
@@ -41,7 +43,7 @@ class UsersApiTest extends TestCase
]); ]);
$response->assertJsonFragment([ $response->assertJsonFragment([
'id' => $nonAdminUser->id, 'id' => $nonAdminUser->id,
'username' => $nonAdminUser->username 'email' => $nonAdminUser->email
]); ]);
// ensure the admin user can access the endpoint and see additional user info // ensure the admin user can access the endpoint and see additional user info
@@ -51,7 +53,6 @@ class UsersApiTest extends TestCase
'users' => [ 'users' => [
'*' => [ '*' => [
'id', 'id',
'username',
'email' 'email'
] ]
], ],
@@ -66,14 +67,13 @@ class UsersApiTest extends TestCase
]); ]);
$response->assertJsonFragment([ $response->assertJsonFragment([
'id' => $nonAdminUser->id, 'id' => $nonAdminUser->id,
'username' => $nonAdminUser->username 'email' => $nonAdminUser->email
]); ]);
} }
public function testGuestCannotCreateUser() public function testGuestCannotCreateUser()
{ {
$userData = [ $userData = [
'username' => 'johndoe',
'email' => 'johndoe@example.com', 'email' => 'johndoe@example.com',
'password' => 'password', 'password' => 'password',
]; ];
@@ -81,7 +81,6 @@ class UsersApiTest extends TestCase
$response = $this->postJson('/api/users', $userData); $response = $this->postJson('/api/users', $userData);
$response->assertStatus(401); $response->assertStatus(401);
$this->assertDatabaseMissing('users', [ $this->assertDatabaseMissing('users', [
'username' => $userData['username'],
'email' => $userData['email'], 'email' => $userData['email'],
]); ]);
} }
@@ -91,7 +90,6 @@ class UsersApiTest extends TestCase
$userData = [ $userData = [
'first_name' => 'John', 'first_name' => 'John',
'last_name' => 'Doe', 'last_name' => 'Doe',
'username' => 'johndoe',
'display_name' => 'jackdoe', 'display_name' => 'jackdoe',
'email' => 'johndoe@example.com', 'email' => 'johndoe@example.com',
'password' => 'password', 'password' => 'password',
@@ -100,18 +98,16 @@ class UsersApiTest extends TestCase
$response = $this->postJson('/api/register', $userData); $response = $this->postJson('/api/register', $userData);
$response->assertStatus(200); $response->assertStatus(200);
$this->assertDatabaseHas('users', [ $this->assertDatabaseHas('users', [
'username' => $userData['username'],
'email' => $userData['email'], 'email' => $userData['email'],
]); ]);
} }
public function testCannotCreateDuplicateUsername() public function testCannotCreateDuplicateEmailOrDisplayName()
{ {
$userData = [ $userData = [
'display_name' => 'JackDoe',
'first_name' => 'Jack', 'first_name' => 'Jack',
'last_name' => 'Doe', 'last_name' => 'Doe',
'username' => 'jackdoe',
'display_name' => 'jackdoe',
'email' => 'jackdoe@example.com', 'email' => 'jackdoe@example.com',
'password' => 'password', 'password' => 'password',
]; ];
@@ -120,14 +116,13 @@ class UsersApiTest extends TestCase
$response = $this->postJson('/api/register', $userData); $response = $this->postJson('/api/register', $userData);
$response->assertStatus(200); $response->assertStatus(200);
$this->assertDatabaseHas('users', [ $this->assertDatabaseHas('users', [
'username' => 'jackdoe',
'email' => 'jackdoe@example.com', 'email' => 'jackdoe@example.com',
]); ]);
// Test creating duplicate user // Test creating duplicate user
$response = $this->postJson('/api/register', $userData); $response = $this->postJson('/api/register', $userData);
$response->assertStatus(422); $response->assertStatus(422);
$response->assertJsonValidationErrors('username'); $response->assertJsonValidationErrors(['display_name', 'email']);
} }
public function testUserCanOnlyUpdateOwnUser() public function testUserCanOnlyUpdateOwnUser()
@@ -135,7 +130,6 @@ class UsersApiTest extends TestCase
$user = User::factory()->create(); $user = User::factory()->create();
$userData = [ $userData = [
'username' => 'raffi',
'email' => 'raffi@example.com', 'email' => 'raffi@example.com',
'password' => 'password', 'password' => 'password',
]; ];
@@ -145,14 +139,12 @@ class UsersApiTest extends TestCase
$response->assertStatus(200); $response->assertStatus(200);
$this->assertDatabaseHas('users', [ $this->assertDatabaseHas('users', [
'id' => $user->id, 'id' => $user->id,
'username' => 'raffi',
'email' => 'raffi@example.com', 'email' => 'raffi@example.com',
]); ]);
// Test updating another user // Test updating another user
$otherUser = User::factory()->create(); $otherUser = User::factory()->create();
$otherUserData = [ $otherUserData = [
'username' => 'otherraffi',
'email' => 'otherraffi@example.com', 'email' => 'otherraffi@example.com',
'password' => 'password', 'password' => 'password',
]; ];
@@ -185,7 +177,6 @@ class UsersApiTest extends TestCase
$user = User::factory()->create(); $user = User::factory()->create();
$userData = [ $userData = [
'username' => 'Todd Doe',
'email' => 'todddoe@example.com', 'email' => 'todddoe@example.com',
'password' => 'password', 'password' => 'password',
]; ];
@@ -195,14 +186,12 @@ class UsersApiTest extends TestCase
$response->assertStatus(200); $response->assertStatus(200);
$this->assertDatabaseHas('users', [ $this->assertDatabaseHas('users', [
'id' => $user->id, 'id' => $user->id,
'username' => 'Todd Doe',
'email' => 'todddoe@example.com' 'email' => 'todddoe@example.com'
]); ]);
// Test updating another user // Test updating another user
$otherUser = User::factory()->create(); $otherUser = User::factory()->create();
$otherUserData = [ $otherUserData = [
'username' => 'Kim Doe',
'email' => 'kimdoe@example.com', 'email' => 'kimdoe@example.com',
'password' => 'password', 'password' => 'password',
]; ];
@@ -211,7 +200,6 @@ class UsersApiTest extends TestCase
$response->assertStatus(200); $response->assertStatus(200);
$this->assertDatabaseHas('users', [ $this->assertDatabaseHas('users', [
'id' => $otherUser->id, 'id' => $otherUser->id,
'username' => 'Kim Doe',
'email' => 'kimdoe@example.com', 'email' => 'kimdoe@example.com',
]); ]);
} }