Add the Account CSV import feature

This commit is contained in:
Timothée Jaussoin 2023-08-14 13:53:03 +00:00
parent 4db2b591c5
commit 49d414c9ee
18 changed files with 503 additions and 79 deletions

View file

@ -41,6 +41,7 @@ class Account extends Authenticatable
protected $casts = [
'activated' => 'boolean',
];
protected $fillable = ['username', 'domain', 'email'];
public static $dtmfProtocols = ['sipinfo' => 'SIPInfo', 'rfc2833' => 'RFC2833', 'sipmessage' => 'SIP Message'];

View file

@ -63,7 +63,7 @@ class AuthenticateController extends Controller
}
if (!$account) {
return redirect()->back()->withErrors(['authentication' => 'The account doesn\'t exists']);
return redirect()->back()->withErrors(['authentication' => 'Wrong username or password']);
}
// Try out the passwords

View file

@ -45,9 +45,7 @@ class PasswordController extends Controller
$account->activated = true;
$account->save();
$algorithm = $request->has('password_sha256') ? 'SHA-256' : 'MD5';
$account->updatePassword($request->get('password'), $algorithm);
$account->updatePassword($request->get('password'), 'SHA-256');
if ($account->passwords()->count() > 0) {
Log::channel('events')->info('Web: Password changed', ['id' => $account->identifier]);

View file

@ -36,6 +36,8 @@ class RecoveryController extends Controller
'g-recaptcha-response' => captchaConfigured() ? 'required|captcha' : '',
];
$account = null;
if ($request->get('email')) {
if (config('app.account_email_unique') == false) {
$rules['username'] = 'required';
@ -94,12 +96,18 @@ class RecoveryController extends Controller
{
$request->validate([
'account_id' => 'required',
'code' => 'required|digits:4'
'number_1' => 'required|digits:1',
'number_2' => 'required|digits:1',
'number_3' => 'required|digits:1',
'number_4' => 'required|digits:1'
]);
$code = $request->get('number_1') . $request->get('number_2') . $request->get('number_3') . $request->get('number_4');
$account = Account::where('id', Crypt::decryptString($request->get('account_id')))->firstOrFail();
if ($account->recovery_code != $request->get('code')) {
if ($account->recovery_code != $code) {
return redirect()->back()->withErrors([
'code' => 'Wrong code'
]);

View file

@ -97,6 +97,9 @@ class AccountController extends Controller
$account->phone = $request->get('phone');
$account->fillPassword($request);
$account->refresh();
$account->setRole($request->get('role'));
Log::channel('events')->info('Web Admin: Account created', ['id' => $account->identifier]);
return redirect()->route('admin.account.edit', $account->id);

View file

@ -0,0 +1,226 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Account;
use App\Alias;
use App\Http\Controllers\Controller;
use Illuminate\Support\Collection;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rules\File;
class AccountImportController extends Controller
{
private Collection $errors;
private string $importDirectory = 'imported_csv';
public function __construct()
{
$this->errors = collect();
}
public function create(Request $request)
{
return view('admin.account.import.create', [
'domains' => Account::select('domain')->distinct()->get()->pluck('domain')
]);
}
public function store(Request $request)
{
$request->validate([
'csv' => ['required', File::types(['csv', 'txt'])],
'domain' => 'required|exists:accounts'
]);
$lines = $this->csvToCollection($request->file('csv'));
/**
* Error checking
*/
// Usernames
$existingUsernames = Account::where('domain', $request->get('domain'))
->whereIn('username', $lines->pluck('username')->all())
->pluck('username');
if ($existingUsernames->isNotEmpty()) {
$this->errors['Those usernames already exists'] = $existingUsernames->join(', ', ' and ');
}
if ($duplicates = $lines->pluck('username')->duplicates()) {
if ($duplicates->isNotEmpty()) {
$this->errors['Those usernames are declared several times'] = $duplicates->join(', ', ' and ');
}
}
if ($lines->pluck('username')->contains(function ($value) {
return strlen($value) < 2;
})) {
$this->errors['Some usernames are shorter than expected'] = '';
}
// Passwords
if ($lines->pluck('password')->contains(function ($value) {
return strlen($value) < 6;
})) {
$this->errors['Some passwords are shorter than expected'] = '';
}
// Roles
if ($lines->pluck('role')->contains(function ($value) {
return !in_array($value, ['admin', 'user']);
})) {
$this->errors['Some roles are not correct'] = '';
}
// Status
if ($lines->pluck('status')->contains(function ($value) {
return !in_array($value, ['active', 'inactive']);
})) {
$this->errors['Some status are not correct'] = '';
}
// Phones
if ($phones = $lines->pluck('phone')->filter(function ($value) {
return strlen($value) > 2 && substr($value, 0, 1) != '+';
})) {
if ($phones->isNotEmpty()) {
$this->errors['Some phone numbers are not correct'] = $phones->join(', ', ' and ');
}
}
$existingPhones = Alias::whereIn('alias', $lines->pluck('phone')->all())
->pluck('alias');
if ($existingPhones->isNotEmpty()) {
$this->errors['Those phones numbers already exists'] = $existingPhones->join(', ', ' and ');
}
// Emails
if ($emails = $lines->pluck('email')->filter(function ($value) {
return !filter_var($value, FILTER_VALIDATE_EMAIL);
})) {
if ($emails->isNotEmpty()) {
$this->errors['Some emails are not correct'] = $emails->join(', ', ' and ');
}
}
$existingEmails = Account::whereIn('email', $lines->pluck('email')->all())
->pluck('email');
if ($existingEmails->isNotEmpty()) {
$this->errors['Those emails numbers already exists'] = $existingEmails->join(', ', ' and ');
}
if ($emails = $lines->pluck('email')->duplicates()) {
if ($emails->isNotEmpty()) {
$this->errors['Those emails are declared several times'] = $emails->join(', ', ' and ');
}
}
$filePath = $this->errors->isEmpty()
? Storage::putFile($this->importDirectory, $request->file('csv'))
: null;
return view('admin.account.import.check', [
'linesCount' => $lines->count(),
'errors' => $this->errors,
'domain' => $request->get('domain'),
'filePath' => $filePath
]);
}
public function handle(Request $request)
{
$request->validate([
'file_path' => 'required',
'domain' => 'required|exists:accounts'
]);
$lines = $this->csvToCollection(storage_path('app/' . $request->get('file_path')));
$accounts = [];
$now = \Carbon\Carbon::now();
$admins = $phones = [];
foreach ($lines as $line) {
if ($line->role == 'admin') {
array_push($admins, $line->username);
}
if (!empty($line->phone)) {
$phones[$line->username] = $line->phone;
}
array_push($accounts, [
'username' => $line->username,
'domain' => $request->get('domain'),
'email' => $line->email,
'activated' => $line->status == 'active',
'ip_address' => '127.0.0.1',
'user_agent' => 'CSV Import',
'created_at' => $now,
'updated_at' => $now,
]);
}
Account::insert($accounts);
// Set admins accounts
foreach ($admins as $username) {
$account = Account::where('username', $username)
->where('domain', $request->get('domain'))
->first();
$account->admin = true;
}
// Set admins accounts
foreach ($phones as $username => $phone) {
$account = Account::where('username', $username)
->where('domain', $request->get('domain'))
->first();
$account->phone = $phone;
}
return redirect()->route('admin.account.index');
}
private function csvToCollection($file): Collection
{
$lines = collect();
$csv = fopen($file, 'r');
$i = 1;
while (!feof($csv)) {
if ($line = fgetcsv($csv, 1000, ',')) {
$lines->push((object)[
'line' => $i,
'username' => $line[0],
'password' => $line[1],
'role' => $line[2],
'status' => $line[3],
'phone' => $line[4],
'email' => $line[5],
]);
$i++;
}
}
fclose($csv);
$lines->shift();
return $lines;
}
}

View file

@ -106,6 +106,7 @@ body.show_menu {
p,
a,
ul li,
ol li,
pre {
font-size: 1.5rem;
color: var(--second-7);
@ -124,11 +125,16 @@ pre code {
font-size: 1.3rem;
}
ul li {
ul li,
ol li {
margin-left: 2rem;
list-style-type: disc;
}
ol li {
list-style-type: decimal;
}
ul li ul li {
list-style-type: circle;
}
@ -279,20 +285,12 @@ header nav a#menu {
header nav a#menu:after {
display: block;
font-family: 'Material Icons';
font-family: 'Material Icons Outlined';
content: "\e5d2";
font-size: 3rem;
}
body.show_menu header nav a#menu:after {
content: "\e5cd";
}
header nav a#logo span {
margin-left: 1.5rem;
}
@media screen and (max-width: 800px) {
body.show_menu header nav a#menu:after {font-family: 'Material Icons'
header nav a#logo {
position: absolute;
left: calc(50% - 1.5rem);
@ -314,6 +312,7 @@ header nav a#logo::before {
display: block;
border-radius: 1rem;
box-shadow: 0 0 2rem rgba(0, 0, 0, 0.2);
margin-right: 2rem;
}
header nav a.oppose {
@ -619,7 +618,7 @@ table tr.empty td {
table tr.empty td:before {
content: '\e5c9';
font-family: 'Material Icons';
font-family: 'Material Icons Outlined';
font-size: 8rem;
color: var(--second-4);
display: block;
@ -753,6 +752,7 @@ select.list_toggle {
display: inline-block;
font-size: 1.5rem;
line-height: 2rem;
margin-left: 0;
}
.breadcrumb li a {
@ -769,4 +769,54 @@ select.list_toggle {
font-size: 1rem;
line-height: 2rem;
display: inline-block;
}
/* Steps */
ol.steps {
counter-reset: css-counter 0;
padding-top: 6.5rem;
}
ol.steps li {
counter-increment: css-counter 1;
display: inline-block;
position: relative;
font-weight: 600;
color: var(--grey-5);
}
ol.steps li + li {
margin-left: 9rem;
}
ol.steps li + li:after {
display: block;
border-top: 1px solid var(--grey-3);
content: "";
width: 7rem;
position: absolute;
top: -3rem;
left: -8rem;
}
ol.steps li:before {
content: counter(css-counter);
display: block;
font-size: 2rem;
font-weight: 300;
border-radius: 3rem;
line-height: 5rem;
width: 5rem;
text-align: center;
border: 1px solid var(--grey-3);
position: absolute;
left: calc(50% - 2.5rem - 1px);
bottom: 3rem;
}
ol.steps li.active:before {
background-color: var(--main-5);
border-color: var(--main-5);
color: white;
}

View file

@ -65,16 +65,18 @@ p .btn {
.btn.btn-tertiary {
background-color: var(--main-1);
border-color: transparent;
border-color: var(--main-1);
color: var(--main-5);
}
.btn.btn-tertiary:hover {
background-color: var(--main-2);
border-color: var(--main-2);
}
.btn.btn-tertiary:active {
background-color: var(--main-3);
border-color: var(--main-3);
}
form {
@ -129,7 +131,7 @@ form h2 {
grid-column: 1/-1;
}
form .disabled {
form .disabled:not(a) {
opacity: 0.5;
pointer-events: none;
filter: blur(0.25rem);
@ -205,7 +207,7 @@ form div.checkbox {
form div.search:after,
form div.select:after {
font-family: 'Material Icons';
font-family: 'Material Icons Outlined';
content: "\e5cf";
display: block;
font-size: 2.5rem;

View file

@ -1,23 +1,36 @@
@extends('layouts.main')
@section('breadcrumb')
<li class="breadcrumb-item active" aria-current="page">Delete</li>
@endsection
@section('content')
<h2>Delete my account</h2>
<header>
<h1><i class="material-icons-outlined">delete</i> Delete my account</h1>
<form method="POST" action="{{ route('account.destroy') }}" accept-charset="UTF-8">
@csrf
<a href="{{ route('account.dashboard') }}" class="btn btn-secondary oppose">Cancel</a>
<input form="delete" class="btn" type="submit" value="Delete">
</header>
@method('delete')
<form id="delete" method="POST" action="{{ route('account.destroy') }}" accept-charset="UTF-8">
@csrf
@method('delete')
<p>You are going to permanently delete your account.</p>
<p>Please enter your complete username to confirm: <b>{{ $account->identifier }}</b>.</p>
<div class="large">
<p>You are going to permanently delete your account.</p>
<p>Please enter your complete username to confirm: <b>{{ $account->identifier }}</b>.</p>
</div>
<div>
<label for="identifier">Username</label>
<input placeholder="bob@example.net" name="identifier" type="text" value="{{ old('identifier') }}">
</div>
<div>
<input placeholder="bob@example.net" name="identifier" type="text" value="{{ old('identifier') }}">
<label for="identifier">Username</label>
</div>
<input name="identifier_confirm" type="hidden" value="{{ $account->identifier }}">
<div class="on_desktop"></div>
<input class="btn" type="submit" value="Delete">
<input name="identifier_confirm" type="hidden" value="{{ $account->identifier }}">
<div>
</div>
</form>
@endsection

View file

@ -20,6 +20,7 @@
value="{{ old('username') }}">
<label for="username">Username</label>
@endif
@include('parts.errors', ['name' => 'authentication'])
</div>
<div class="on_desktop"></div>
<div>

View file

@ -1,13 +1,22 @@
@extends('layouts.main')
@section('content')
@if ($account->passwords()->count() > 0)
<h2>Change my account password</h2>
@else
<h2>Set my account password</h2>
@endif
@section('breadcrumb')
<li class="breadcrumb-item active" aria-current="page">Change password</li>
@endsection
<form method="POST" action="{{ route('account.password.update') }}" accept-charset="UTF-8">
@section('content')
<header>
@if ($account->passwords()->count() > 0)
<h1><i class="material-icons-outlined">lock</i> Change password</h1>
@else
<h1><i class="material-icons-outlined">lock</i> Set password</h1>
@endif
<a href="{{ route('account.dashboard') }}" class="btn btn-secondary oppose">Cancel</a>
<input form="password_updated" class="btn" type="submit" value="Change">
</header>
<form id="password_update" method="POST" action="{{ route('account.password.update') }}" accept-charset="UTF-8">
@csrf
<div>
@ -18,12 +27,5 @@
<input type="password_confirmation" name="password_confirmation" required>
<label for="password_confirmation">Password confirmation</label>
</div>
<div>
<input type="checkbox" name="password_sha256" checked>
<label for="password_sha256">Use a SHA-256 encrypted password. This stronger password might not work with some
old SIP clients</label>
</div>
<input class="btn btn-primary" type="submit" value="Change">
</form>
@endsection

View file

@ -9,8 +9,12 @@
<p class="large">Enter the pin code you received to recover your account.</p>
<div class="large">
<input placeholder="1234" name="code" type="text" value="{{ old('code') }}">
<label for="code">Code</label>
<input oninput="digitFilled(this)" onfocus="this.value = ''" autofocus class="digit" name="number_1" type="number" min="0" max="9">
<input oninput="digitFilled(this)" onfocus="this.value = ''" class="digit" name="number_2" type="number" min="0" max="9">
<input oninput="digitFilled(this)" onfocus="this.value = ''" class="digit" name="number_3" type="number" min="0" max="9">
<input oninput="digitFilled(this)" onfocus="this.value = ''" class="digit" name="number_4" type="number" min="0" max="9">
@include('parts.errors', ['name' => 'code'])
<input name="account_id" type="hidden" value="{{ $account_id }}">
</div>
<div class="large">

View file

@ -19,7 +19,6 @@
<input form="create_edit" class="btn" type="submit" value="Update">
</header>
<p title="{{ $account->updated_at }}">Updated on {{ $account->updated_at->format('d/m/Y') }}
@include('parts.tabs', [
'items' => [
route('admin.account.edit', $account->id, ['type' => 'messages']) => 'Information',
@ -42,7 +41,7 @@
<h2>Connexion</h2>
<div>
<input placeholder="Username" required="required" name="username" type="text"
value="{{ $account->username }}" @if ($account->id) readonly @endif>
value="@if ($account->id){{ $account->username }}@else{{ old('username') }}@endif" @if ($account->id) readonly @endif>
<label for="username">Username</label>
@include('parts.errors', ['name' => 'username'])
</div>
@ -54,32 +53,32 @@
</div>
<div>
<input placeholder="John Doe" name="display_name" type="text" value="{{ $account->display_name }}">
<input placeholder="John Doe" name="display_name" type="text" value="@if ($account->id){{ $account->display_name }}@else{{ old('display_name') }}@endif">
<label for="display_name">Display Name</label>
@include('parts.errors', ['name' => 'display_name'])
</div>
<div></div>
<div>
<input placeholder="Password" name="password" type="password" value="" autocomplete="new-password">
<input placeholder="Password" name="password" type="password" value="" autocomplete="new-password" @if (!$account->id)required @endif>
<label for="password">{{ $account->id ? 'Password (fill to change)' : 'Password' }}</label>
@include('parts.errors', ['name' => 'password'])
</div>
<div>
<input placeholder="Password" name="password_confirmation" type="password" value="" autocomplete="off">
<input placeholder="Password" name="password_confirmation" type="password" value="" autocomplete="off" @if (!$account->id)required @endif>
<label for="password_confirmation">Confirm password</label>
@include('parts.errors', ['name' => 'password_confirmation'])
</div>
<div>
<input placeholder="Email" name="email" type="email" value="{{ $account->email }}">
<input placeholder="Email" name="email" type="email" value="@if ($account->id){{ $account->email }}@else{{ old('email') }}@endif">
<label for="email">Email</label>
@include('parts.errors', ['name' => 'email'])
</div>
<div>
<input placeholder="+12123123" name="phone" type="text" value="{{ $account->phone }}">
<input placeholder="+12123123" name="phone" type="text" value="@if ($account->id){{ $account->phone }}@else{{ old('phone') }}@endif">
<label for="phone">Phone</label>
@include('parts.errors', ['name' => 'phone'])
</div>
@ -242,10 +241,12 @@
<tr>
<th scope="row">{{ $type->key }}</th>
<td>
<form method="POST" action="{{ route('admin.account.account_type.destroy', [$account, $type->id]) }}" accept-charset="UTF-8">
@csrf
@method('delete')
<input class="btn" type="submit" value="Delete">
<form method="POST"
action="{{ route('admin.account.account_type.destroy', [$account, $type->id]) }}"
accept-charset="UTF-8">
@csrf
@method('delete')
<input class="btn" type="submit" value="Delete">
</form>
</td>
</tr>

View file

@ -8,24 +8,23 @@
@endsection
@section('content')
<h2>Delete an account</h2>
<header>
<h1><i class="material-icons-outlined">delete</i> Delete an account</h1>
<form method="POST" action="{{ route('admin.account.destroy') }}" accept-charset="UTF-8">
@csrf
@method('delete')
<div class="large">
<p>You are going to permanently delete the following account. Please confirm your action.<br />
<b>{{ $account->identifier }}</b>
</p>
<input name="account_id" type="hidden" value="{{ $account->id }}">
</div>
<div>
<input class="btn" type="submit" value="Delete">
</div>
<a href="{{ route('admin.account.edit', $account->id) }}" class="btn btn-secondary oppose">Cancel</a>
<input form="delete" class="btn" type="submit" value="Delete">
</header>
<form id="delete" method="POST" action="{{ route('admin.account.destroy') }}" accept-charset="UTF-8">
@csrf
@method('delete')
<div class="large">
<p>You are going to permanently delete the following account. Please confirm your action.<br />
<b>{{ $account->identifier }}</b>
</p>
<input name="account_id" type="hidden" value="{{ $account->id }}">
</div>
<div>
</div>
</form>
@endsection

View file

@ -0,0 +1,47 @@
@extends('layouts.main')
@section('breadcrumb')
<li class="breadcrumb-item">
<a href="{{ route('admin.account.index') }}">Accounts</a>
</li>
<li class="breadcrumb-item active" aria-current="page">Import</li>
@endsection
@section('content')
<header>
<h1><i class="material-icons-outlined">people</i> Import accounts</h1>
<a href="{{ route('admin.account.index') }}" class="btn btn-secondary oppose">Cancel</a>
<a href="#" onclick="history.back()" class="btn btn-secondary">Previous</a>
<form name="handle" method="POST" action="{{ route('admin.account.import.handle') }}" accept-charset="UTF-8"
enctype="multipart/form-data">
@csrf
<input name="file_path" type="hidden" value="{{ $filePath }}">
<input name="domain" type="hidden" value="{{ $domain }}">
<a type="submit"
class="btn @if ($errors->isNotEmpty()) disabled @endif" onclick="document.querySelector('form[name=handle]').submit()">
<i class="material-icons-outlined">publish</i>
Import
</a>
</form>
</header>
<div>
<ol class="steps" style="margin: 6rem 0;">
<li>Select data file</li>
<li class="active">Import data</li>
</ol>
<h3>{{ $linesCount }} accounts will be imported for the {{ $domain }} domain</h3>
@if ($errors->isNotEmpty())
<hr />
<h3>Errors</h3>
@foreach ($errors as $title => $body)
<p><b>{{ $title }}</b> {{ $body }}</p>
@endforeach
@endif
</div>
@endsection

View file

@ -0,0 +1,58 @@
@extends('layouts.main')
@section('breadcrumb')
<li class="breadcrumb-item">
<a href="{{ route('admin.account.index') }}">Accounts</a>
</li>
<li class="breadcrumb-item active" aria-current="page">Import</li>
@endsection
@section('content')
<header>
<h1><i class="material-icons-outlined">people</i> Import accounts</h1>
<a href="{{ route('admin.account.index') }}" class="btn btn-secondary oppose">Cancel</a>
<input form="import" class="btn" type="submit" value="Next">
</header>
<div>
<ol class="steps" style="margin: 6rem 0;">
<li class="active">Select data file</li>
<li>Import data</li>
</ol>
<p>Use this existing (.csv) template or create your own csv file.</p>
<p>
This file MUST be in csv format and contain at least the following information:
</p>
<ol>
<li>The first line contains the labels</li>
<li>Username* </li>
<li>Password* (6 characters minimum)</li>
<li>Role* (admin or user)</li>
<li>Statuts* (active, inactive)</li>
<li>Phone number, must start with a + if set</li>
</ol>
<hr />
<form id="import" method="POST" action="{{ route('admin.account.import.store') }}" accept-charset="UTF-8" enctype="multipart/form-data">
@csrf
<div>
<input name="csv" type="file" accept=".csv">
@include('parts.errors', ['name' => 'csv'])
<label for="csv">CSV file to import</label>
</div>
<div class="on_desktop"></div>
<div class="select">
<select name="domain">
@foreach ($domains as $domain)
<option value="{{ $domain }}">{{ $domain }}</option>
@endforeach
</select>
<label for="domain">Domain used for the import</label>
</div>
</form>
</div>
@endsection

View file

@ -9,7 +9,11 @@
@section('content')
<header>
<h1><i class="material-icons-outlined">people</i> Accounts</h1>
<a class="btn oppose" href="{{ route('admin.account.create') }}">
<a class="btn btn-secondary oppose" href="{{ route('admin.account.import.create') }}">
<i class="material-icons-outlined">publish</i>
Import Accounts
</a>
<a class="btn" href="{{ route('admin.account.create') }}">
<i class="material-icons-outlined">add_circle</i>
New Account
</a>
@ -75,7 +79,7 @@
<tbody>
@if ($accounts->isEmpty())
<tr class="empty">
<td colspan="4">No Contacts Lists</td>
<td colspan="4">No Accounts</td>
</tr>
@endif
@foreach ($accounts as $account)

View file

@ -30,6 +30,7 @@ use App\Http\Controllers\Admin\AccountContactController;
use App\Http\Controllers\Admin\AccountDeviceController;
use App\Http\Controllers\Admin\AccountTypeController;
use App\Http\Controllers\Admin\AccountController as AdminAccountController;
use App\Http\Controllers\Admin\AccountImportController;
use App\Http\Controllers\Admin\ContactsListController;
use App\Http\Controllers\Admin\ContactsListContactController;
use App\Http\Controllers\Admin\StatisticsController;
@ -153,6 +154,12 @@ if (config('app.web_panel')) {
Route::post('{account_id}/contacts_lists', 'contactsListAdd')->name('contacts_lists.attach');
});
Route::name('import.')->prefix('import')->controller(AccountImportController::class)->group(function () {
Route::get('/', 'create')->name('create');
Route::post('/', 'store')->name('store');
Route::post('handle', 'handle')->name('handle');
});
Route::name('type.')->prefix('types')->controller(AccountTypeController::class)->group(function () {
Route::get('/', 'index')->name('index');
Route::get('create', 'create')->name('create');