Fix FLEXIAPI-420 Call forwarding user panel

Fix FLEXIAPI-444 Complete documentation regarding account creation with a domain
This commit is contained in:
Timothée Jaussoin 2026-02-26 17:19:46 +01:00
parent 450844ba33
commit 57381f1454
22 changed files with 179 additions and 91 deletions

View file

@ -40,6 +40,13 @@ class AccountController extends Controller
]); ]);
} }
public function telephony(Request $request)
{
return view('account.telephony', [
'account' => $request->user()
]);
}
public function store(WebRequest $request) public function store(WebRequest $request)
{ {
$account = (new AccountService())->store($request); $account = (new AccountService())->store($request);

View file

@ -0,0 +1,15 @@
<?php
namespace App\Http\Controllers\Account;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use \App\Http\Controllers\Admin\Account\CallForwardingController as AdminCallForwardingController;
class CallForwardingController extends Controller
{
public function update(Request $request)
{
return (new AdminCallForwardingController)->update($request, $request->user()->id);
}
}

View file

@ -5,6 +5,8 @@ namespace App\Http\Controllers\Account;
use App\AccountFile; use App\AccountFile;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Http\Request;
use \App\Http\Controllers\Admin\Account\FileController as AdminFileController;
class FileController extends Controller class FileController extends Controller
{ {
@ -29,4 +31,14 @@ class FileController extends Controller
return Storage::download($file->path); return Storage::download($file->path);
} }
public function delete(Request $request, string $fileId)
{
return (new AdminFileController)->delete($request->user()->id, $fileId);
}
public function destroy(Request $request, string $fileId)
{
return (new AdminFileController)->destroy($request, $request->user()->id, $fileId);
}
} }

View file

@ -21,7 +21,6 @@ namespace App\Http\Controllers\Account;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
class PasswordController extends Controller class PasswordController extends Controller

View file

@ -49,7 +49,7 @@ class ProvisioningController extends Controller
public function qrcode(Request $request, string $provisioningToken) public function qrcode(Request $request, string $provisioningToken)
{ {
$account = Account::withoutGlobalScopes() Account::withoutGlobalScopes()
->where('id', function ($query) use ($provisioningToken) { ->where('id', function ($query) use ($provisioningToken) {
$query->select('account_id') $query->select('account_id')
->from('provisioning_tokens') ->from('provisioning_tokens')

View file

@ -19,15 +19,21 @@ class CallForwardingController extends Controller
$request->validate([ $request->validate([
'always.forward_to' => $forwardTo, 'always.forward_to' => $forwardTo,
'always.sip_uri' => 'nullable|starts_with:sip:|required_if:always.forward_to,sip_uri', 'always.sip_uri' => array_key_exists('enabled', $request->get('always'))
? 'nullable|starts_with:sip:|required_if:always.forward_to,sip_uri'
: 'nullable',
'always.contact_id' => ['required_if:always.forward_to,contact', Rule::in($contactsIds)], 'always.contact_id' => ['required_if:always.forward_to,contact', Rule::in($contactsIds)],
'away.forward_to' => $forwardTo, 'away.forward_to' => $forwardTo,
'away.sip_uri' => 'nullable|starts_with:sip:|required_if:away.forward_to,sip_uri', 'away.sip_uri' => array_key_exists('enabled', $request->get('away'))
? 'nullable|starts_with:sip:|required_if:away.forward_to,sip_uri'
: 'nullable',
'away.contact_id' => ['required_if:away.forward_to,contact', Rule::in($contactsIds)], 'away.contact_id' => ['required_if:away.forward_to,contact', Rule::in($contactsIds)],
'busy.forward_to' => $forwardTo, 'busy.forward_to' => $forwardTo,
'busy.sip_uri' => 'nullable|starts_with:sip:|required_if:busy.forward_to,sip_uri', 'busy.sip_uri' => array_key_exists('enabled', $request->get('busy'))
? 'nullable|starts_with:sip:|required_if:busy.forward_to,sip_uri'
: 'nullable',
'busy.contact_id' => ['required_if:busy.forward_to,contact', Rule::in($contactsIds)], 'busy.contact_id' => ['required_if:busy.forward_to,contact', Rule::in($contactsIds)],
]); ]);
@ -72,6 +78,8 @@ class CallForwardingController extends Controller
$busyForwarding->save(); $busyForwarding->save();
} }
return redirect()->route('admin.account.telephony.show', $account); return $request->user()
? redirect()->route('admin.account.telephony.show', $account)
: redirect()->route('account.telephony');
} }
} }

View file

@ -13,7 +13,7 @@ class FileController extends Controller
$account = Account::findOrFail($accountId); $account = Account::findOrFail($accountId);
$file = $account->files()->where('id', $fileId)->firstOrFail(); $file = $account->files()->where('id', $fileId)->firstOrFail();
return view('admin.account.file.delete', [ return view('account.file.delete', [
'account' => $account, 'account' => $account,
'file' => $file 'file' => $file
]); ]);
@ -27,6 +27,8 @@ class FileController extends Controller
->firstOrFail(); ->firstOrFail();
$accountFile->delete(); $accountFile->delete();
return redirect()->route('admin.account.show', $account)->withFragment('#files'); return $request->user()->admin
? redirect()->route('admin.account.telephony.show', $account)->withFragment('#files')
: redirect()->route('account.telephony')->withFragment('#files');
} }
} }

View file

@ -21,6 +21,7 @@
"Admin": "Administrateur", "Admin": "Administrateur",
"Administration": "Administration", "Administration": "Administration",
"Admins": "Administrateurs", "Admins": "Administrateurs",
"All incoming calls are forwarded, whether you answer, decline the call or are already on a call.": "Tous les appels entrants sont renvoyés, que vous répondiez, refusiez lappel ou soyez déjà en ligne.",
"All the calls": "Tous les appels", "All the calls": "Tous les appels",
"All the admins will be super admins": "Tous les administrateurs seront super-administrateurs", "All the admins will be super admins": "Tous les administrateurs seront super-administrateurs",
"Allow a custom CSS theme": "Autoriser un thème CSS personnalisé", "Allow a custom CSS theme": "Autoriser un thème CSS personnalisé",
@ -39,6 +40,8 @@
"By email": "Inscription par email", "By email": "Inscription par email",
"By phone": "Par téléphone", "By phone": "Par téléphone",
"By": "Par", "By": "Par",
"Calls are only forwarded when your line is busy with another call.": "Les appels sont renvoyés uniquement lorsque votre ligne est occupée par un autre appel.",
"Calls are only forwarded if you do not answer or if you decline the call.": "Les appels sont renvoyés uniquement si vous ne répondez pas ou si vous refusez lappel.",
"Call Recording": "Enregistrement d'appels", "Call Recording": "Enregistrement d'appels",
"Call Forwarding": "Redirection d'appels", "Call Forwarding": "Redirection d'appels",
"Calls logs": "Journaux d'appel", "Calls logs": "Journaux d'appel",

View file

@ -2,7 +2,7 @@
{{ __('Call Forwarding') }} {{ __('Call Forwarding') }}
</h3> </h3>
<form id="edit" method="POST" action="{{ route('admin.account.call_forwardings.update', $account->id) }}" accept-charset="UTF-8"> <form id="edit" method="POST" action="@if ($account->admin) {{ route('admin.account.call_forwardings.update', $account->id) }}@else{{ route('account.call_forwardings.update') }}@endif" accept-charset="UTF-8">
@csrf @csrf
@method('put') @method('put')
@php($callForwardings = $account->callForwardingsDefault) @php($callForwardings = $account->callForwardingsDefault)
@ -16,7 +16,10 @@
<label for="always[enabled]"></label> <label for="always[enabled]"></label>
</div> </div>
@include('admin.account.call_forwardings.edit_select_part', ['callForwarding' => $callForwardings, 'type' => 'always']) @include('account.call_forwardings.edit_select_part', ['callForwarding' => $callForwardings, 'type' => 'always'])
@if (!$account->admin)
<small class="large">{{ __('All incoming calls are forwarded, whether you answer, decline the call or are already on a call.') }}</small>
@endif
</section> </section>
<section> <section>
@ -28,7 +31,10 @@
<label for="away[enabled]"></label> <label for="away[enabled]"></label>
</div> </div>
@include('admin.account.call_forwardings.edit_select_part', ['callForwarding' => $callForwardings, 'type' => 'away']) @include('account.call_forwardings.edit_select_part', ['callForwarding' => $callForwardings, 'type' => 'away'])
@if (!$account->admin)
<small class="large">{{ __('Calls are only forwarded when your line is busy with another call.') }}</small>
@endif
</section> </section>
<section> <section>
@ -40,7 +46,10 @@
<label for="busy[enabled]"></label> <label for="busy[enabled]"></label>
</div> </div>
@include('admin.account.call_forwardings.edit_select_part', ['callForwarding' => $callForwardings, 'type' => 'busy']) @include('account.call_forwardings.edit_select_part', ['callForwarding' => $callForwardings, 'type' => 'busy'])
@if (!$account->admin)
<small class="large">{{ __('Calls are only forwarded if you do not answer or if you decline the call.') }}</small>
@endif
</section> </section>
<div class="large"> <div class="large">

View file

@ -1,4 +1,4 @@
<div class="select" data-value="{{ $callForwardings[$type]->forward_to }}"> <div class="select" data-value="{{ $callForwardings[$type]->forward_to ?? 'sip_uri' }}">
<select name="{{ $type }}[forward_to]" onchange="this.parentNode.dataset.value = this.value"> <select name="{{ $type }}[forward_to]" onchange="this.parentNode.dataset.value = this.value">
<option @if ($callForwardings[$type]->forward_to == 'voicemail') selected @endif value="voicemail">{{ __('Voicemails') }}</option> <option @if ($callForwardings[$type]->forward_to == 'voicemail') selected @endif value="voicemail">{{ __('Voicemails') }}</option>
<option @if ($callForwardings[$type]->forward_to == null || $callForwardings[$type]->forward_to == 'sip_uri') selected @endif value="sip_uri">{{ __('SIP Adress') }}</option> <option @if ($callForwardings[$type]->forward_to == null || $callForwardings[$type]->forward_to == 'sip_uri') selected @endif value="sip_uri">{{ __('SIP Adress') }}</option>

View file

@ -0,0 +1,17 @@
@extends('layouts.main')
@section('content')
<header>
<h1><i class="ph ph-phone"></i> {{ __('Telephony') }}</h1>
</header>
<div class="grid">
<div class="card">
@include('account.voicemails.index', ['account' => $account, 'admin' => false])
</div>
<div class="card">
@include('account.call_forwardings.edit', ['account' => $account])
</div>
</div>
@endsection

View file

@ -0,0 +1,49 @@
<h3>
{{ __('Voicemails') }}
</h3>
<table>
<thead>
<tr>
<th>{{ __('Created') }}</th>
<th></th>
</tr>
</thead>
<tbody>
@if ($account->uploadedVoicemails->isEmpty())
<tr class="empty">
<td colspan="2">{{ __('Empty') }}</td>
</tr>
@endif
@foreach ($account->uploadedVoicemails as $voicemail)
<tr>
<td>
{{ $voicemail->created_at }}
@if ($voicemail->url)
<a style="margin-left: 1rem;" href="{{ $voicemail->download_url }}" download>
<i class="ph ph-download"></i>
</a>
@endif
@if ($voicemail->sip_from)
<br/>
<small>{{ $voicemail->sip_from }}</small>
@endif
</td>
<td>
@if ($voicemail->url)
<audio class="oppose" controls src="{{ $voicemail->url }}"></audio>
<a type="button"
class="oppose btn tertiary"
@if ($admin)
href="{{ route('admin.account.file.delete', [$account, $voicemail->id]) }}"
@else
href="{{ route('account.file.delete', [$voicemail->id]) }}"
@endif
>
<i class="ph ph-trash"></i>
</a>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>

View file

@ -12,54 +12,7 @@
<div class="grid"> <div class="grid">
<div class="card"> <div class="card">
<h3> @include('account.call_forwardings.edit', ['account' => $account])
{{ __('Voicemails') }}
</h3>
<table>
<thead>
<tr>
<th>{{ __('Created') }}</th>
<th></th>
</tr>
</thead>
<tbody>
@if ($account->uploadedVoicemails->isEmpty())
<tr class="empty">
<td colspan="2">{{ __('Empty') }}</td>
</tr>
@endif
@foreach ($account->uploadedVoicemails as $voicemail)
<tr>
<td>
{{ $voicemail->created_at }}
@if ($voicemail->url)
<a style="margin-left: 1rem;" href="{{ $voicemail->download_url }}" download>
<i class="ph ph-download"></i>
</a>
@endif
@if ($voicemail->sip_from)
<br/>
<small>{{ $voicemail->sip_from }}</small>
@endif
</td>
<td>
@if ($voicemail->url)
<audio class="oppose" controls src="{{ $voicemail->url }}"></audio>
<a type="button"
class="oppose btn tertiary"
href="{{ route('admin.account.file.delete', [$account, $voicemail->id]) }}">
<i class="ph ph-trash"></i>
</a>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="card">
@include('admin.account.call_forwardings.edit', ['account' => $account])
</div> </div>
</div> </div>
@endsection @endsection

View file

@ -83,7 +83,7 @@ JSON parameters:
* `username` unique username, minimum 6 characters * `username` unique username, minimum 6 characters
* `password` **required** minimum 6 characters * `password` **required** minimum 6 characters
* `algorithm` **required**, values can be `SHA-256` or `MD5` * `algorithm` **required**, values can be `SHA-256` or `MD5`
* `domain` **not configurable by default**, must exist in one of the configured Spaces. Only configurable if the admin is a super admin. Otherwise the SIP domain of the corresponding space is used. * `domain` **not configurable by default**, must exist in one of the configured Spaces. Only configurable if the admin is a super admin. Otherwise the SIP domain of the corresponding space is used. Return 403 if the value doesn't resolve to a Space or if the Space is full.
* `activated` optional, a boolean, set to `false` by default * `activated` optional, a boolean, set to `false` by default
* `display_name` optional, string * `display_name` optional, string
* `email` optional, must be an email, must be unique if `ACCOUNT_EMAIL_UNIQUE` is set to `true` * `email` optional, must be an email, must be unique if `ACCOUNT_EMAIL_UNIQUE` is set to `true`

View file

@ -1,13 +1,11 @@
## Voicemails ## Voicemails
### `GET /accounts/{id/me}/voicemails` ### `GET /accounts/me/voicemails`
<span class="badge badge-warning">Admin</span>
<span class="badge badge-info">User</span> <span class="badge badge-info">User</span>
Return the currently stored voicemails Return the currently stored voicemails
### `GET /accounts/{id/me}/voicemails/{uuid}` ### `GET /accounts/me/voicemails/{uuid}`
<span class="badge badge-warning">Admin</span>
<span class="badge badge-info">User</span> <span class="badge badge-info">User</span>
``` ```

View file

@ -1,7 +1,14 @@
@if (isset($errors) && $errors->isNotEmpty()) @if (isset($errors) && isset($name) && count($errors->get($name)) > 0)
@foreach ($errors->get($name) as $error)
<small class="error">
{{ $error }}
</small>
@endforeach
@elseif (isset($errors) && $errors->isNotEmpty() && is_int($errors->keys()[0]) && !isset($name))
<ul class="errors"> <ul class="errors">
@foreach($errors->all() as $error) @foreach($errors->all() as $error)
<li>{{ $error }}</li> <li>{{ $error }}</li>
@endforeach @endforeach
</ul> </ul>
@endif @endif

View file

@ -15,10 +15,16 @@
} }
@endphp @endphp
@foreach ($items as $route => $value) @include('parts.sidebar_items', ['items' => $items])
<a @if (str_starts_with(url()->current(), route($route)))class="current"@endif href="{{ route($route) }}">
<i class="ph ph-{{ $value['icon'] }}"></i> <hr />
{{ $value['title'] }}
</a> @php
@endforeach $items = [];
$items['account.dashboard'] = ['title' => __('My Account'), 'icon' => 'gauge'];
$items['account.telephony'] = ['title' => __('Telephony'), 'icon' => 'phone'];
@endphp
@include('parts.sidebar_items', ['items' => $items])
</nav> </nav>

View file

@ -0,0 +1,6 @@
@foreach ($items as $route => $value)
<a @if (str_starts_with(url()->current(), route($route)))class="current"@endif href="{{ route($route) }}">
<i class="ph ph-{{ $value['icon'] }}"></i>
{{ $value['title'] }}
</a>
@endforeach

View file

@ -185,7 +185,7 @@ Route::group(['middleware' => ['auth.jwt', 'auth.digest_or_key', 'auth.check_blo
Route::apiResource('accounts/{id}/actions', ActionController::class); Route::apiResource('accounts/{id}/actions', ActionController::class);
Route::apiResource('account_types', TypeController::class); Route::apiResource('account_types', TypeController::class);
Route::apiResource('accounts/{id}/vcards-storage', AdminVcardsStorageController::class); Route::apiResource('accounts/{id}/vcards-storage', AdminVcardsStorageController::class);
Route::apiResource('accounts/{id}/voicemails', AdminVoicemailController::class, ['only' => ['index', 'show', 'store', 'destroy']]); Route::apiResource('accounts/{id}/voicemails', AdminVoicemailController::class, ['only' => ['store', 'destroy']]);
Route::apiResource('accounts/{id}/call_forwardings', AdminCallForwardingController::class); Route::apiResource('accounts/{id}/call_forwardings', AdminCallForwardingController::class);
Route::apiResource('contacts_lists', ContactsListController::class); Route::apiResource('contacts_lists', ContactsListController::class);

View file

@ -22,6 +22,7 @@ use App\Http\Controllers\Account\AccountController;
use App\Http\Controllers\Account\ApiKeyController; use App\Http\Controllers\Account\ApiKeyController;
use App\Http\Controllers\Account\AuthenticateController; use App\Http\Controllers\Account\AuthenticateController;
use App\Http\Controllers\Account\AuthTokenController; use App\Http\Controllers\Account\AuthTokenController;
use App\Http\Controllers\Account\CallForwardingController;
use App\Http\Controllers\Account\ContactVcardController; use App\Http\Controllers\Account\ContactVcardController;
use App\Http\Controllers\Account\CreationRequestTokenController; use App\Http\Controllers\Account\CreationRequestTokenController;
use App\Http\Controllers\Account\DeviceController; use App\Http\Controllers\Account\DeviceController;
@ -36,7 +37,7 @@ use App\Http\Controllers\Account\VcardsStorageController;
use App\Http\Controllers\Admin\Account\AccountTypeController; use App\Http\Controllers\Admin\Account\AccountTypeController;
use App\Http\Controllers\Admin\Account\ActionController; use App\Http\Controllers\Admin\Account\ActionController;
use App\Http\Controllers\Admin\Account\ActivityController; use App\Http\Controllers\Admin\Account\ActivityController;
use App\Http\Controllers\Admin\Account\CallForwardingController; use App\Http\Controllers\Admin\Account\CallForwardingController as AdminCallForwardingController;
use App\Http\Controllers\Admin\Account\CardDavCredentialsController; use App\Http\Controllers\Admin\Account\CardDavCredentialsController;
use App\Http\Controllers\Admin\Account\ContactController; use App\Http\Controllers\Admin\Account\ContactController;
use App\Http\Controllers\Admin\Account\DeviceController as AdminAccountDeviceController; use App\Http\Controllers\Admin\Account\DeviceController as AdminAccountDeviceController;
@ -80,7 +81,7 @@ Route::middleware(['feature.web_panel_enabled'])->group(function () {
}); });
}); });
Route::name('file.')->prefix('files')->controller(FileController::class)->group(function () { Route::name('file.')->prefix('f')->controller(FileController::class)->group(function () {
Route::get('{uuid}/{name}', 'show')->name('show'); Route::get('{uuid}/{name}', 'show')->name('show');
Route::get('{uuid}/{name}/download', 'download')->name('download'); Route::get('{uuid}/{name}/download', 'download')->name('download');
}); });
@ -135,6 +136,11 @@ Route::middleware(['feature.web_panel_enabled'])->group(function () {
Route::post('/', 'store')->name('email.update'); Route::post('/', 'store')->name('email.update');
}); });
Route::name('file.')->prefix('files')->controller(FileController::class)->group(function () {
Route::get('{uuid}/delete', 'delete')->name('delete');
Route::delete('{uuid}', 'destroy')->name('destroy');
});
Route::middleware(['feature.phone_registration'])->group(function () { Route::middleware(['feature.phone_registration'])->group(function () {
Route::prefix('phone')->controller(PhoneController::class)->group(function () { Route::prefix('phone')->controller(PhoneController::class)->group(function () {
Route::get('change', 'change')->name('phone.change'); Route::get('change', 'change')->name('phone.change');
@ -150,8 +156,13 @@ Route::middleware(['feature.web_panel_enabled'])->group(function () {
Route::delete('/', 'destroy')->name('destroy'); Route::delete('/', 'destroy')->name('destroy');
}); });
Route::name('call_forwardings.')->prefix('call_forwardings')->controller(CallForwardingController::class)->group(function () {
Route::put('/', 'update')->name('update');
});
Route::controller(AccountController::class)->group(function () { Route::controller(AccountController::class)->group(function () {
Route::get('dashboard', 'dashboard')->name('dashboard'); Route::get('dashboard', 'dashboard')->name('dashboard');
Route::get('telephony', 'telephony')->name('telephony');
Route::get('delete', 'delete')->name('delete'); Route::get('delete', 'delete')->name('delete');
Route::delete('delete', 'destroy')->name('destroy'); Route::delete('delete', 'destroy')->name('destroy');
@ -318,7 +329,7 @@ Route::middleware(['feature.web_panel_enabled'])->group(function () {
Route::get('/', 'show')->name('show'); Route::get('/', 'show')->name('show');
}); });
Route::name('call_forwardings.')->prefix('{account}/call_forwardings')->controller(CallForwardingController::class)->group(function () { Route::name('call_forwardings.')->prefix('{account}/call_forwardings')->controller(AdminCallForwardingController::class)->group(function () {
Route::put('/', 'update')->name('update'); Route::put('/', 'update')->name('update');
}); });

View file

@ -105,23 +105,9 @@ class ApiVoicemailTest extends TestCase
$uuid = $accountFile->json()['id']; $uuid = $accountFile->json()['id'];
$this->keyAuthenticated($admin)
->get($adminRoute . '/' . $uuid)
->assertJsonFragment(['id' => $uuid]);
$this->keyAuthenticated($admin)
->get($adminRoute . '/' . $uuid)
->assertJsonFragment([
'id' => $uuid
]);
$this->keyAuthenticated($admin) $this->keyAuthenticated($admin)
->delete($adminRoute . '/' . $uuid) ->delete($adminRoute . '/' . $uuid)
->assertOk(); ->assertOk();
$this->keyAuthenticated($admin)
->get($adminRoute . '/' . $uuid)
->assertNotFound();
} }
public function testUpload() public function testUpload()