Fix FLEXIAPI-233 Add External Accounts (new version)

This commit is contained in:
Timothée Jaussoin 2025-03-24 13:36:20 +00:00
parent a8e81908ee
commit 7cb63f3e51
32 changed files with 751 additions and 108 deletions

View file

@ -26,6 +26,7 @@ v1.7
- Fix FLEXIAPI-261 Remove the TURN part in the XML provisioning (and only keep the API endpoint)
- Fix FLEXIAPI-275 Add names in Spaces
- Fix FLEXIAPI-278 Complete and reorganize the Markdown documentation
- Fix FLEXIAPI-233 Add External Accounts (new version)
v1.6
----

View file

@ -44,7 +44,7 @@ This is the host that you'll define in the Apache or webserver VirtualHost:
ServerName flexiapi-domain.tld
ServerAlias *.flexiapi-domain.tld
If you are planning to manage several SIP domains (see Spaces bellow) a wildcard `ServerAlias` as above is required.
If you are planning to manage several Spaces (see Spaces bellow) a wildcard `ServerAlias` as above is required.
## 3.2. Database migration
@ -56,14 +56,14 @@ Then configure the database connection parameters and migrate the tables. The fi
Since the 1.6 FlexiAPI can manage different SIP Domains on separate HTTP subdomains.
A Space is defined as a specific HTTP subdomain of `APP_ROOT_HOST` and is linked to a specific SIP Domain. It is also possible to host one specific Space directly under `APP_ROOT_HOST`.
A Space is defined as a specific HTTP subdomain of `APP_ROOT_HOST` and is linked to a specific SIP Domain. It is also possible to host one (and only one) specific Space directly under `APP_ROOT_HOST`.
By default administrator accounts in Spaces will only see the accounts of their own Space (that have the same SIP Domain).
However it is possible to define a Space as a "SuperSpace" allowing the admins to see all the other Spaces and accounts and create/edit/delete the other Spaces.
## 4.1. Setup the first Space
You will need to create the first Space manually, generally as a SuperSpace, after that the other Spaces can directly be created in your browser through the web panel.
You will need to create the first Space manually, generally as a SuperSpace, after that the other Spaces can directly be created in your browser through the Web Panels.
php artisan spaces:create-update {sip_domain} {host} {name} {--super}
@ -80,7 +80,7 @@ Create a first administator account:
For example:
php artisan accounts:create-admin-account -u admin -p strong_password -d my-company-sip-domain.tld
php artisan accounts:create-admin-account -u admin -p strong_password -d company-sip-domain.tld
You can now try to authenticate on the web panel and continue the setup using your admin account.

View file

@ -56,11 +56,11 @@ php artisan spaces:create-update beta.sip beta.myhost.com "Beta Space"
...
```
4. Configure your web server to point the `APP_ROOT_HOST` and subdomains to the app.
4. Configure your web server to point the `APP_ROOT_HOST` and subdomains to the app. See the related documentation in [`INSTALL.md` file](INSTALL.md#31-mandatory-app_root_host-variable).
5. Configure the upcoming Spaces.
5. Configure your Spaces.
6. Remove the instance based environnement variables and configure them directly in the spaces.
6. Remove the instance based environnement variables (see **Changed** above) and configure them directly in the spaces using the API or Web Panel.
7. (Optional) Import the old instance DotEnv environnement variables into a space.
@ -72,7 +72,7 @@ php artisan spaces:create-update beta.sip beta.myhost.com "Beta Space"
php artisan spaces:import-configuration-from-dot-env {sip_domain}
```
You can find more details regarding those steps in the [`README.md`](README.md) file.
You can find more details regarding those steps in the [`INSTALL.md`](INSTALL.md) and [`README.md`](README.md) files.
### Deprecated

View file

@ -83,7 +83,6 @@ MAIL_VERIFY_PEER_NAME=true
MAIL_SIGNATURE="The Example Team"
# CoTURN
COTURN_SERVER_HOST= # IP or domain name
COTURN_SESSION_TTL_MINUTES=1440 # 60 * 24
COTURN_STATIC_AUTH_SECRET= # static-auth-secret in the coturn configuration

View file

@ -121,6 +121,11 @@ class Account extends Authenticatable
return $this->hasOne(ApiKey::class);
}
public function external()
{
return $this->hasOne(ExternalAccount::class);
}
public function contacts()
{
return $this->belongsToMany(Account::class, 'contacts', 'account_id', 'contact_id');
@ -324,11 +329,6 @@ class Account extends Authenticatable
return null;
}
public function getSha256PasswordAttribute()
{
return $this->passwords()->where('algorithm', 'SHA-256')->exists();
}
public static function dtmfProtocolsRule()
{
return implode(',', array_keys(self::$dtmfProtocols));

View file

@ -0,0 +1,23 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class ExternalAccount extends Model
{
use HasFactory;
public const PROTOCOLS = ['UDP', 'TCP','TLS'];
public function account()
{
return $this->belongsTo(Account::class);
}
public function getIdentifierAttribute(): string
{
return $this->attributes['username'] . '@' . $this->attributes['domain'];
}
}

View file

@ -20,12 +20,16 @@
namespace App\Http\Controllers\Admin;
use App\Account;
use App\ExternalAccount;
use App\Password;
use App\PhoneCountry;
use App\Http\Controllers\Controller;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rules\File;
use Propaganistas\LaravelPhone\PhoneNumber;
class AccountImportController extends Controller
{
@ -52,6 +56,7 @@ class AccountImportController extends Controller
]);
$lines = $this->csvToCollection($request->file('csv'));
$domain = $request->get('domain');
/**
* Error checking
@ -59,7 +64,7 @@ class AccountImportController extends Controller
// Usernames
$existingUsernames = Account::where('domain', $request->get('domain'))
$existingUsernames = Account::where('domain', $domain)
->whereIn('username', $lines->pluck('username')->all())
->pluck('username');
@ -100,13 +105,17 @@ class AccountImportController extends Controller
if ($lines->pluck('status')->contains(function ($value) {
return !in_array($value, ['active', 'inactive']);
})) {
$this->errors['Some status are not correct'] = '';
$this->errors['Some statuses are not correct'] = '';
}
// Phones
$phoneCountries = PhoneCountry::where('activated', true)->get();
if ($phones = $lines->pluck('phone')->filter(function ($value) {
return strlen($value) > 2 && substr($value, 0, 1) != '+';
return !empty($value);
})->filter(function ($value) use ($phoneCountries) {
return !$phoneCountries->firstWhere('code', (new PhoneNumber($value))->getCountry());
})) {
if ($phones->isNotEmpty()) {
$this->errors['Some phone numbers are not correct'] = $phones->join(', ', ' and ');
@ -143,6 +152,26 @@ class AccountImportController extends Controller
}
}
// External account
foreach ($lines as $line) {
if ($line->external_username != null && ($line->external_password == null || $line->external_domain == null)) {
$this->errors['Line ' . $line->line . ': The mandatory external account columns must be filled'] = '';
}
if ($line->external_username != null && $line->external_password != null && $line->external_domain != null) {
if ($line->external_domain == $line->external_realm
|| $line->external_domain == $line->external_registrar
|| $line->external_domain == $line->external_outbound_proxy) {
$this->errors['Line ' . $line->line . ': External realm, registrar or outbound proxy must be different than domain'] = '';
}
if (!in_array($line->external_protocol, ExternalAccount::PROTOCOLS)) {
$this->errors['Line ' . $line->line . ': External protocol must be UDP, TCP or TLS'] = '';
}
}
}
$filePath = $this->errors->isEmpty()
? Storage::putFile($this->importDirectory, $request->file('csv'))
: null;
@ -167,7 +196,9 @@ class AccountImportController extends Controller
$accounts = [];
$now = \Carbon\Carbon::now();
$admins = $phones = $passwords = [];
$admins = $phones = $passwords = $externals = [];
$externalAlgorithm = 'MD5';
foreach ($lines as $line) {
if ($line->role == 'admin') {
@ -182,6 +213,25 @@ class AccountImportController extends Controller
$passwords[$line->username] = $line->password;
}
if (!empty($line->external_username)) {
$externals[$line->username] = [
'username' => $line->external_username,
'domain' => $line->external_domain,
'realm' => $line->external_realm,
'registrar' => $line->external_registrar,
'outbound_proxy' => $line->external_outbound_proxy,
'protocol' => $line->external_protocol,
'password' => bchash(
$line->external_username,
$line->external_realm ?? $line->external_domain,
$line->external_password,
$externalAlgorithm
),
'algorithm' => $externalAlgorithm,
'created_at' => Carbon::now(),
];
}
array_push($accounts, [
'username' => $line->username,
'domain' => $request->get('domain'),
@ -229,7 +279,23 @@ class AccountImportController extends Controller
Password::insert($passwordsToInsert);
// Set admins accounts
// Set external account
$externalAccountsToInsert = [];
$externalAccounts = Account::whereIn('username', array_keys($externals))
->where('domain', $request->get('domain'))
->get();
foreach ($externalAccounts as $externalAccount) {
array_push($externalAccountsToInsert, [
'account_id' => $externalAccount->id
] + $externals[$externalAccount->username]);
}
ExternalAccount::insert($externalAccountsToInsert);
// Set phone accounts
foreach ($phones as $username => $phone) {
$account = Account::where('username', $username)
->where('domain', $request->get('domain'))
@ -250,12 +316,20 @@ class AccountImportController extends Controller
if ($line = fgetcsv($csv, 1000, ',')) {
$lines->push((object)[
'line' => $i,
'username' => $line[0],
'password' => $line[1],
'username' => !empty($line[0]) ? $line[0] : null,
'password' => !empty($line[1]) ? $line[1] : null,
'role' => $line[2],
'status' => $line[3],
'phone' => $line[4],
'phone' => !empty($line[4]) ? $line[4] : null,
'email' => $line[5],
'external_username' => !empty($line[6]) ? $line[6] : null,
'external_domain' => !empty($line[7]) ? $line[7] : null,
'external_password' => !empty($line[8]) ? $line[8] : null,
'external_realm' => !empty($line[9]) ? $line[9] : null,
'external_registrar' => !empty($line[10]) ? $line[10] : null,
'external_outbound_proxy' => !empty($line[11]) ? $line[11] : null,
'external_protocol' => $line[12],
]);
$i++;

View file

@ -0,0 +1,98 @@
<?php
/*
Flexisip Account Manager is a set of tools to manage SIP accounts.
Copyright (C) 2023 Belledonne Communications SARL, All rights reserved.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\ExternalAccount\CreateUpdate;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use App\ExternalAccount;
use App\Account;
class ExternalAccountController extends Controller
{
public function show(int $accountId)
{
$account = Account::findOrFail($accountId);
return view('admin.account.external.show', [
'account' => $account,
'externalAccount' => $account->external ?? new ExternalAccount,
'protocols' => ExternalAccount::PROTOCOLS
]);
}
public function store(CreateUpdate $request, int $accountId)
{
$account = Account::findOrFail($accountId);
$externalAccount = $account->external ?? new ExternalAccount;
$password = '';
if ($account->external?->realm != $request->get('realm')) {
$password = 'required_with:realm';
} elseif ($externalAccount->password == null) {
$password = 'required';
}
$request->validate(['password' => $password]);
$algorithm = 'MD5';
$externalAccount->account_id = $account->id;
$externalAccount->username = $request->get('username');
$externalAccount->domain = $request->get('domain');
$externalAccount->realm = $request->get('realm');
$externalAccount->registrar = $request->get('registrar');
$externalAccount->outbound_proxy = $request->get('outbound_proxy');
$externalAccount->protocol = $request->get('protocol');
if (!empty($request->get('password'))) {
$externalAccount->password = bchash(
$externalAccount->username,
$externalAccount->realm ?? $externalAccount->domain,
$request->get('password'),
$algorithm
);
$externalAccount->algorithm = $algorithm;
}
$externalAccount->save();
return redirect()->route('admin.account.external.show', $account->id);
}
public function delete(int $accountId)
{
$account = Account::findOrFail($accountId);
return view('admin.account.external.delete', [
'account' => $account
]);
}
public function destroy(int $accountId)
{
$account = Account::findOrFail($accountId);
$account->external->delete();
return redirect()->route('admin.account.external.show', $account->id);
}
}

View file

@ -147,16 +147,6 @@ class AccountController extends Controller
public function store(AsAdminRequest $request)
{
// Create the missing Space
/*if ($request->user()->superAdmin
&& $request->has('domain')
&& !Space::pluck('domain')->contains($request->get('domain'))) {
$space = new Space();
$space->domain = $request->get('domain');
$space->host = $request->get('host');
$space->save();
}*/
return (new AccountService())->store($request)->makeVisible(['confirmation_key', 'provisioning_token']);
}

View file

@ -0,0 +1,81 @@
<?php
/*
Flexisip Account Manager is a set of tools to manage SIP accounts.
Copyright (C) 2023 Belledonne Communications SARL, All rights reserved.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\ExternalAccount\CreateUpdate;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use App\ExternalAccount;
use App\Account;
class ExternalAccountController extends Controller
{
public function show(int $accountId)
{
return Account::findOrFail($accountId)->external()->firstOrFail();
}
public function store(CreateUpdate $request, int $accountId)
{
$account = Account::findOrFail($accountId);
$externalAccount = $account->external ?? new ExternalAccount;
$password = '';
if ($account->external?->realm != $request->get('realm')) {
$password = 'required_with:realm';
} elseif ($externalAccount->password == null) {
$password = 'required';
}
$request->validate(['password' => $password]);
$algorithm = 'MD5';
$externalAccount->account_id = $account->id;
$externalAccount->username = $request->get('username');
$externalAccount->domain = $request->get('domain');
$externalAccount->realm = $request->get('realm');
$externalAccount->registrar = $request->get('registrar');
$externalAccount->outbound_proxy = $request->get('outbound_proxy');
$externalAccount->protocol = $request->get('protocol');
$externalAccount->algorithm = $algorithm;
if (!empty($request->get('password'))) {
$externalAccount->password = bchash(
$externalAccount->username,
$externalAccount->realm ?? $externalAccount->domain,
$request->get('password'),
$algorithm
);
}
$externalAccount->save();
return $externalAccount;
}
public function destroy(int $accountId)
{
$account = Account::findOrFail($accountId);
return $account->external->delete();
}
}

View file

@ -0,0 +1,43 @@
<?php
/*
Flexisip Account Manager is a set of tools to manage SIP accounts.
Copyright (C) 2023 Belledonne Communications SARL, All rights reserved.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace App\Http\Requests\ExternalAccount;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use App\ExternalAccount;
class CreateUpdate extends FormRequest
{
public function rules()
{
return [
'username' => 'required',
'domain' => 'required',
'realm' => 'different:domain',
'registrar' => 'different:domain',
'outbound_proxy' => 'different:domain',
'protocol' => [
'required',
Rule::in(ExternalAccount::PROTOCOLS),
]
];
}
}

View file

@ -58,7 +58,7 @@ class StatisticsGraphFactory
$toQuery = StatisticsMessage::query();
if (!Auth::user()?->isAdmin) {
$fromQuery->where('from_domain', config('app.sip_domain'));
$fromQuery->where('from_domain', space()->domain);
$toQuery->toDomain($this->domain);
} elseif ($this->domain) {
$fromQuery->where('from_domain', $this->domain);
@ -90,8 +90,8 @@ class StatisticsGraphFactory
$toQuery = StatisticsCall::query();
if (!Auth::user()?->superAdmin) {
$fromQuery->where('from_domain', config('app.sip_domain'));
$toQuery->where('to_domain', config('app.sip_domain'));
$fromQuery->where('from_domain', space()->domain);
$toQuery->where('to_domain', space()->domain);
} elseif ($this->domain) {
$fromQuery = $fromQuery->where('to_domain', $this->domain);
$toQuery = $toQuery->where('from_domain', $this->domain);
@ -127,7 +127,7 @@ class StatisticsGraphFactory
$this->domain = $this->domain ?? $this->fromDomain;
if (!Auth::user()?->isAdmin) {
$this->data->where('domain', config('app.sip_domain'));
$this->data->where('domain', space()->domain);
} elseif ($this->domain) {
$this->data->where('domain', $this->domain);

View file

@ -60,7 +60,7 @@ class AccountService
$account = new Account();
$account->username = $request->get('username');
$account->activated = false;
$account->domain = config('app.sip_domain');
$account->domain = space()->domain;
$account->ip_address = $request->ip();
$account->created_at = Carbon::now();
$account->user_agent = space()->name;

View file

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('external_accounts', function (Blueprint $table) {
$table->id();
$table->string('username', 64);
$table->string('domain', 64);
$table->string('password', 255);
$table->string('algorithm', 10)->default('MD5');
$table->string('realm', 64)->nullable();
$table->string('registrar', 64)->nullable();
$table->string('outbound_proxy', 64)->nullable();
$table->string('protocol', 4)->default('UDP');
$table->integer('account_id')->unsigned()->nullable();
$table->foreign('account_id')->references('id')
->on('accounts')->onDelete('set null');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('external_accounts');
}
};

View file

@ -1,9 +1,12 @@
{
"A verification code was sent by email to :email.": "Un code de vérification a été envoyé par email à :email",
"A verification code was sent by SMS to :phone.": "Un code de vérification a été envoyé par SMS au :phone",
"About": "À Propos",
"Account creation": "Création de compte",
"Account recovered recently, try again later": "Tentative de récupération de compte récente, retentez ultérieurement",
"Account recovery": "Récupération de compte",
"Account settings": "Paramètres de compte",
"Account": "Compte",
"Accounts": "Comptes",
"Actions": "Actions",
"Activate All": "Tout activer",
@ -18,28 +21,26 @@
"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 client settings to be overwritten by the provisioning ones": "Écraser la configuration client avec celle du provisionnement",
"An email will be sent to this email when someone join the newsletter": "Un email sera envoyé à cette addresse quand quelqu'un rejoint la liste de diffusion",
"An email will be sent to :email with a unique link allowing the user to reset its password.": "Un email sera envoyé à :email avec un lien unique l'invitant à réinitialiser son mot de passe",
"An email will be sent to this email when someone join the newsletter": "Un email sera envoyé à cette addresse quand quelqu'un rejoint la liste de diffusion",
"App Configuration": "Configuration de l'App",
"A verification code was sent by SMS to :phone.": "Un code de vérification a été envoyé par SMS au :phone",
"A verification code was sent by email to :email.": "Un code de vérification a été envoyé par email à :email",
"Assistant": "Assistant",
"Blocked": "Bloqué",
"Calls logs": "Journaux d'appel",
"Cancel": "Annuler",
"Cannot be changed once created.": "Ne peut être changé par la suite.",
"Chat": "Chat",
"Change your phone number": "Changer votre numéro de téléphone",
"Change your email": "Changer votre email",
"Change your phone number": "Changer votre numéro de téléphone",
"Chat": "Chat",
"Check the README.md documentation": "Voir la documentation dans README.md",
"Clear to never expire": "Laisser vide pour ne jamais expirer",
"Code": "Code",
"Connection": "Connexion",
"Conference": "Conférence",
"Configuration": "Configuration",
"Confirm email": "Confirmation de l'email",
"Confirm password": "Confirmer le mot de passe",
"Confirmed registration text": "Texte de confirmation d'inscription",
"Connection": "Connexion",
"Contacts List": "Liste de Contacts",
"Contacts Lists": "Listes de Contacts",
"Contacts": "Contacts",
@ -47,6 +48,7 @@
"Country code": "Code du pays",
"Create": "Créer",
"Created on": "Créé le",
"Currently set": "Actuellement remplit",
"Custom entries": "Entrées personnalisées",
"Dactivate": "Désactiver",
"Day": "Jour",
@ -64,17 +66,21 @@
"Empty": "Vide",
"Enable the web interface": "Activer l'interface web",
"Enabled": "Activé",
"Encrypted": "Chiffré",
"Enter the pin code bellow:": "Entrez le code pin ci-dessous:",
"Errors": "Erreurs",
"Expiration": "Expiration",
"Export": "Exporter",
"Expired": "Expiré",
"Export": "Exporter",
"External Account": "Compte Externe",
"Features": "Fonctionnalités",
"Fill to change": "Remplir pour changer",
"Fill the related columns if you want to add an external account as well": "Remplissez également les colonnes suivantes si vous souhaitez ajouter un compte externe",
"From": "Depuis",
"General settings": "Paramètres généraux",
"Host": "Hôte",
"I accept the Terms and Conditions": "J'accepte les Conditions Générales",
"I accept the Privacy Policy": "J'accepte la Politique de Confidentialité",
"I accept the Terms and Conditions": "J'accepte les Conditions Générales",
"I would like to subscribe to the newsletter": "Je voudrais m'inscrire à la newsletter",
"I'm not a robot": "Je ne suis pas un robot",
"Identifier": "Identifiant",
@ -106,44 +112,50 @@
"No limit": "Sans limite",
"No phone yet": "Pas de téléphone pour le moment",
"Only display usernames (hide SIP addresses)": "N'afficher que les num d'utilisateur (cacher les addresses SIP)",
"Other information": "Autres informations",
"Outbound proxy": "Outbound proxy",
"Password": "Mot de passe",
"Phone Countries": "Numéros Internationaux",
"Phone number": "Numéro de téléphone",
"Phone registration": "Inscription via mobile",
"Please enter the new email that you would like to link to your account.": "Veuillez entre l'adresse email que vous souhaitez lier à votre compte.",
"Please enter the new phone number that you would like to link to your account.": "Veuillez entrer le numéro de téléphone que vous souhaitez lier à votre compte.",
"Protocol": "Protocole",
"Provisioning tokens": "Jetons de provisionnement",
"Provisioning": "Provisionnement",
"Please enter the new phone number that you would like to link to your account.": "Veuillez entrer le numéro de téléphone que vous souhaitez lier à votre compte.",
"Please enter the new email that you would like to link to your account.": "Veuillez entre l'adresse email que vous souhaitez lier à votre compte.",
"Public registration": "Inscription publiques",
"QR Code scanning": "Scan de QR Code",
"Realm": "Royaume",
"Record calls": "Enregistrements des appels",
"Recover your account using your email": "Récupérer votre compte avec votre email",
"Recover your account using your phone number": "Récupérer votre compte avec votre numéro de téléphone",
"Register": "Inscription",
"Registrar": "Registrar",
"Registration introduction": "Présentation lors de l'inscription",
"Remove": "Remove",
"Renew": "Renouveller",
"Requests": "Requêtes",
"Resend": "Ré-envoyer",
"Reset password": "Réinitialiser le mot de passe",
"Reset password emails": "Email de réinitialisation de mot de passe",
"Reset password": "Réinitialiser le mot de passe",
"Reset": "Réinitialiser",
"Role": "Rôle",
"Scan the following QR Code using an authenticated device and wait a few seconds.": "Scanner le QR Code avec un appareil authentifié et attendez quelques secondes",
"Search": "Rechercher",
"Select a contacts list": "Sélectionner une liste de contact",
"Select a domain": "Sélectionner un domaine",
"Select a file": "Choisir un fichier",
"Send": "Envoyer",
"Send an email to the user to reset the password": "Envoyer un email à l'utilisateur pour réinitialiser son mot de passe",
"Send": "Envoyer",
"Sip Adress": "Adresse SIP",
"SIP Domain": "Domaine SIP",
"Space": "Espace",
"Spaces": "Espaces",
"Statistics": "Statistiques",
"Scan the following QR Code using an authenticated device and wait a few seconds.": "Scanner le QR Code avec un appareil authentifié et attendez quelques secondes",
"Subdomain": "Sous-domaine",
"Super Admin": "Super Admin",
"Super Space": "Super Espace",
"Thanks for the validation": "Nous vous remercions pour la validation",
"The :attribute should not be a phone number": "Le champ :attribute ne peut pas être un numéro de téléphone",
"The account doesn't exists": "Le compte n'existe pas",
"The code has expired": "Le code a expiré",
@ -154,10 +166,9 @@
"The first line contains the labels": "La premières ligne contient les étiquettes",
"The link can only be visited once": "Le lien ne peut être utilisé qu'une fois",
"Third party SIP": "Adresse SIP tierce",
"This link will be available for :hours hours.": "Ce lien restera disponible pour :hours heures.",
"This link is not available anymore.": "Ce lien n'est plus disponible.",
"This link will be available for :hours hours.": "Ce lien restera disponible pour :hours heures.",
"To": "À",
"Thanks for the validation": "Nous vous remercions pour la validation",
"Transport": "Transport",
"Types": "Types",
"Unlimited if set to 0": "Illimité si à 0",
@ -174,10 +185,10 @@
"Welcome on :app_name": "Bienvenue sur :app_name",
"Wrong username or password": "Mauvais identifiant ou mot de passe",
"Year": "Année",
"You didn't receive the code?": "Vous n'avez pas reçu le code ?",
"You already have an account?": "Vous avez déjà un compte ?",
"You are going to permanently delete the following element. Please confirm your action.": "Vous allez supprimer l'élément suivant. Veuillez confirmer votre action.",
"You can now continue your registration process in the application": "Vous pouvez maintenant continuer le processus d'inscription dans l'application",
"You are going to permanently delete your account. Please enter your complete SIP address to confirm.": "Vous allez détruire défitivement votre compte. Veuillez entrer votre addresse SIP complète pour confirmer.",
"You can now continue your registration process in the application": "Vous pouvez maintenant continuer le processus d'inscription dans l'application",
"You didn't receive the code?": "Vous n'avez pas reçu le code ?",
"Your password was updated properly.": "Votre mot de passe a été mis à jour."
}

View file

@ -1,4 +1,4 @@
Username,Password,Role,Status,Phone,Email
john,number9,user,active,+12341234,john@lennon.com
paul,a_day_in_the_life,admin,active,,paul@apple.com
ringo,allUneedIsL3ve,user,unactive,+123456,ringo@star.co.uk
Username,Password,Role,Status,Phone,Email,External Username,External Domain,External Password,External Realm, External Registrar,External Outbound Proxy,External Encrypted,External Protocol
john,number9,user,active,+12341234,john@lennon.com,extjohn,ext.lennon.com,123ext,,,,UDP
paul,a_day_in_the_life,admin,active,,paul@apple.com,,,,,,,
ringo,allUneedIsL3ve,user,inactive,+123456,ringo@star.co.uk,extringo,ext.star.co.uk,123456,another.realm,,,UDP
1 Username Username,Password,Role,Status,Phone,Email,External Username,External Domain,External Password,External Realm, External Registrar,External Outbound Proxy,External Encrypted,External Protocol Password Role Status Phone Email
2 john john,number9,user,active,+12341234,john@lennon.com,extjohn,ext.lennon.com,123ext,,,,UDP number9 user active +12341234 john@lennon.com
3 paul paul,a_day_in_the_life,admin,active,,paul@apple.com,,,,,,, a_day_in_the_life admin active paul@apple.com
4 ringo ringo,allUneedIsL3ve,user,inactive,+123456,ringo@star.co.uk,extringo,ext.star.co.uk,123456,another.realm,,,UDP allUneedIsL3ve user unactive +123456 ringo@star.co.uk

View file

@ -85,7 +85,8 @@ p .btn {
border-color: var(--main-3);
}
form {
form,
form section {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem 2.5rem;

View file

@ -342,6 +342,13 @@ content section {
box-sizing: border-box;
}
form section {
margin: initial;
max-width: initial;
padding: 0;
grid-column: 1 / 3;
}
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
@ -533,6 +540,7 @@ h3 {
color: var(--second-6);
padding: 0.5rem 0;
padding-bottom: 1rem;
margin-bottom: 0.5rem;
font-weight: bold;
border-bottom: 1px solid var(--grey-2);
}
@ -908,4 +916,23 @@ ol.steps li.active:before {
background-color: var(--main-5);
border-color: var(--main-5);
color: white;
}
}
/** Show/hide toggle **/
details > summary::before {
content: "▶";
color: var(--grey-4);
font-size: 1.5rem;
line-height: 4rem;
padding: 0 1rem;
float: right;
}
details[open] > summary::before {
content: "▼";
}
details > summary:hover {
cursor: pointer;
}

View file

@ -4,7 +4,6 @@
@include('admin.account.parts.breadcrumb_accounts_index')
@if ($account->id)
@include('admin.account.parts.breadcrumb_accounts_edit', ['account' => $account])
<li class="breadcrumb-item active" aria-current="page">{{ __('Edit') }}</li>
@else
<li class="breadcrumb-item active" aria-current="page">{{ __('Create') }}</li>
@endif
@ -14,12 +13,10 @@
@if ($account->id)
<header>
<h1><i class="ph">users</i> {{ $account->identifier }}</h1>
<a href="{{ route('admin.account.index') }}" class="btn btn-secondary oppose">{{ __('Cancel') }}</a>
<a class="btn btn-secondary" href="{{ route('admin.account.delete', $account->id) }}">
<a class="btn btn-secondary oppose" href="{{ route('admin.account.delete', $account->id) }}">
<i class="ph">trash</i>
{{ __('Delete') }}
</a>
<input form="create_edit" class="btn" type="submit" value="{{ __('Update') }}">
</header>
@if ($account->updated_at)
<p title="{{ $account->updated_at }}">{{ __('Updated on') }} {{ $account->updated_at->format('d/m/Y') }}
@ -38,7 +35,7 @@
id="create_edit" accept-charset="UTF-8">
@csrf
@method($account->id ? 'put' : 'post')
<h2>{{ __('Connection') }}</h2>
<div>
<input placeholder="Username" required="required" name="username" type="text"
value="@if($account->id){{ $account->username }}@else{{ old('username') }}@endif"
@ -67,8 +64,8 @@
<div>
<input placeholder="Password" name="password" type="password" value="" autocomplete="new-password"
@if (!$account->id) required @endif>
<label for="password">{{ __('Password') }}</label>
<small>Fill to change</small>
<label for="password">{{ __('Password') }} @if ($account->passwords()->count() > 0) ({{ __('Currently set') }}) @endif</label>
<small>{{ __('Fill to change') }}</small>
@include('parts.errors', ['name' => 'password'])
</div>
@ -101,11 +98,10 @@
@include('parts.errors', ['name' => 'phone'])
</div>
<h2>Other information</h2>
<h3 class="large">{{ __('Other information') }}</h3>
<div>
@include('parts.form.toggle', ['object' => $account, 'key' => 'blocked', 'label' => __('Blocked')])
</div>
@include('parts.form.toggle', ['object' => $account, 'key' => 'blocked', 'label' => __('Blocked')])
@include('parts.form.toggle', ['object' => $account, 'key' => 'activated', 'label' => __('Enabled')])
<div>
<input name="role" value="admin" type="radio" @if ($account->admin) checked @endif>
@ -115,8 +111,6 @@
<label>{{ __('Role') }}</label>
</div>
@include('parts.form.toggle', ['object' => $account, 'key' => 'activated', 'label' => __('Enabled')])
@if (space()?->intercom_features)
<div class="select">
<select name="dtmf_protocol">
@ -129,12 +123,28 @@
</div>
@endif
<div class="large">
<input class="btn" type="submit" value="{{ __('Update') }}">
</div>
</form>
<hr class="large">
@if ($account->id)
<h2 id="contacts_lists">{{ __('Contacts Lists') }}</h2>
<h2 class="large">{{ __('Contacts') }}</h2>
@foreach ($account->contacts as $contact)
<p class="chip">
<a href="{{ route('admin.account.edit', $account) }}">{{ $contact->identifier }}</a>
<a href="{{ route('admin.account.contact.delete', [$account, $contact->id]) }}">
<i class="ph">x</i>
</a>
</p>
@endforeach
<a class="btn btn-tertiary" href="{{ route('admin.account.contact.create', $account) }}">{{ __('Add') }}</a>
<h3 id="contacts_lists">{{ __('Contacts Lists') }}</h3>
@if ($contacts_lists->isNotEmpty())
<form method="POST" action="{{ route('admin.account.contacts_lists.attach', $account->id) }}"
@ -170,26 +180,14 @@
</p>
@endforeach
<h2>{{ __('Contacts') }}</h2>
@foreach ($account->contacts as $contact)
<p class="chip">
<a href="{{ route('admin.account.edit', $account) }}">{{ $contact->identifier }}</a>
<a href="{{ route('admin.account.contact.delete', [$account, $contact->id]) }}">
<i class="ph">x</i>
</a>
</p>
@endforeach
<br />
<a class="btn btn-tertiary" href="{{ route('admin.account.contact.create', $account) }}">{{ __('Add') }}</a>
<hr class="large">
<h2 id="provisioning">{{ __('Provisioning') }}</h2>
<h2 class="large" id="provisioning">{{ __('Provisioning') }}</h2>
@if ($account->provisioning_token)
<img style="max-width: 15rem;" src="{{ route('provisioning.qrcode', $account->provisioning_token) }}">
<div>
<img style="max-width: 15rem;" src="{{ route('provisioning.qrcode', $account->provisioning_token) }}">
</div>
<form class="inline">
<div>

View file

@ -10,7 +10,6 @@
<header>
<h1><i class="ph">users</i> {{ $account->identifier }}</h1>
<a href="{{ route('admin.account.edit', $account->id) }}" class="btn btn-secondary oppose">{{ __('Cancel') }}</a>
</header>
@include('admin.account.parts.tabs')

View file

@ -10,8 +10,7 @@
<header>
<h1><i class="ph">users</i> {{ $account->identifier }}</h1>
<a href="{{ route('admin.account.edit', $account->id) }}" class="btn btn-secondary oppose">{{ __('Cancel') }}</a>
<a class="btn" href="{{ route('admin.account.dictionary.create', $account) }}">
<a class="btn oppose" href="{{ route('admin.account.dictionary.create', $account) }}">
<i class="ph">plus</i>
{{ __('Add') }}
</a>

View file

@ -0,0 +1,30 @@
@extends('layouts.main')
@section('breadcrumb')
@include('admin.account.parts.breadcrumb_accounts_index')
@include('admin.account.parts.breadcrumb_accounts_edit', ['account' => $account])
<li class="breadcrumb-item"><a href="{{ route('admin.account.external.show', ['account' => $account]) }}">{{ __('External Account') }}</a></li>
<li class="breadcrumb-item active" aria-current="page">{{ __('Delete') }}</li>
@endsection
@section('content')
<header>
<h1><i class="ph">trash</i> {{ __('Delete') }}</h1>
<a href="{{ route('admin.account.external.show', ['account' => $account]) }}" 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.external.destroy', $account->id) }}" accept-charset="UTF-8">
@csrf
@method('delete')
<div class="large">
<p>{{ __('You are going to permanently delete the following element. Please confirm your action.') }}<br />
<b>{{ $account->external->identifier }}</b>
</p>
<input name="account_id" type="hidden" value="{{ $account->id }}">
</div>
<div>
</div>
</form>
@endsection

View file

@ -0,0 +1,86 @@
@extends('layouts.main')
@section('breadcrumb')
@include('admin.account.parts.breadcrumb_accounts_index')
@include('admin.account.parts.breadcrumb_accounts_edit', ['account' => $account])
<li class="breadcrumb-item active">{{ __('External Account') }}</li>
@endsection
@section('content')
<header>
<h1><i class="ph">user-circle-dashed</i> {{ __('External Account') }}</h1>
@if($externalAccount->id)
<a class="btn btn-secondary oppose" href="{{ route('admin.account.external.delete', $account->id) }}">
<i class="ph">trash</i>
{{ __('Delete') }}
</a>
@endif
</header>
@include('admin.account.parts.tabs')
<form method="POST"
action="{{ route('admin.account.external.store', $account->id) }}"
id="show" accept-charset="UTF-8">
@csrf
@method('post')
<h3 class="large">{{ __('Connection') }}</h3>
<div>
<input placeholder="username" required="required" name="username" type="text"
value="@if($externalAccount->id){{ $externalAccount->username }}@else{{ old('username') }}@endif">
<label for="username">{{ __('Username') }}</label>
@include('parts.errors', ['name' => 'username'])
</div>
<div>
<input placeholder="domain.tld" required="required" name="domain" type="text"
value="@if($externalAccount->id){{ $externalAccount->domain }}@else{{ old('domain') }}@endif">
<label for="domain">{{ __('Domain') }}</label>
</div>
<div>
<input placeholder="Password" name="password" type="password" value="" autocomplete="new-password"
@if (!$externalAccount->id) required @endif>
<label for="password">{{ __('Password') }} @if ($externalAccount->id) ({{ __('Currently set') }}) @endif</label>
@if($externalAccount->id)<small>{{ __('Fill to change') }}</small>@endif
@include('parts.errors', ['name' => 'password'])
</div>
<details class="large" @if ($errors->isNotEmpty())open @endif>
<summary>
<h3 class="large">
{{ __('Other information') }}
</h3>
</summary>
<section>
<div>
<input placeholder="realm" name="realm" type="text"
value="@if($externalAccount->id){{ $externalAccount->realm }}@else{{ old('realm') }}@endif">
<label for="username">{{ __('Realm') }}</label>
@include('parts.errors', ['name' => 'realm'])
</div>
<div>
<input placeholder="domain.tld" name="registrar" type="text"
value="@if($externalAccount->id){{ $externalAccount->registrar }}@else{{ old('registrar') }}@endif">
<label for="domain">{{ __('Registrar') }}</label>
</div>
<div>
<input placeholder="outbound.tld" name="outbound_proxy" type="text"
value="@if($externalAccount->id){{ $externalAccount->outbound_proxy }}@else{{ old('outbound_proxy') }}@endif">
<label for="domain">{{ __('Outbound Proxy') }}</label>
</div>
<div class="select">
<select name="protocol">
@foreach ($protocols as $protocol)
<option value="{{ $protocol }}" @if ($externalAccount->protocol == $protocol) selected="selected" @endif>
{{ $protocol }}</option>
@endforeach
</select>
<label for="dtmf_protocol">{{ __('Protocol') }}</label>
</div>
</section>
</details>
</form>
<br />
<input form="show" class="btn" type="submit" value="@if($externalAccount->id){{ __('Update') }}@else{{ __('Create') }}@endif">
@endsection

View file

@ -30,16 +30,14 @@
<li class="active">{{ __('Import') }}</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
@else
<h3>{{ $linesCount }} accounts will be imported for the {{ $domain }} domain</h3>
@endif
</div>
@endsection

View file

@ -19,8 +19,10 @@
</ol>
<p>{{ __('The file must be in CSV following this template') }}: <a href="{{ route('account.home') }}/accounts_example.csv">example_template.csv</a></p>
<h4>{{ __('Account') }}</h4>
<p>{{ __('The first line contains the labels') }}</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>
@ -29,6 +31,20 @@
<li>{{ __('Email') }}</li>
</ol>
<h4>{{ __('External Account') }}</h4>
<p>{{ __('Fill the related columns if you want to add an external account as well') }}</p>
<ol>
<li>{{ __('Username') }}* </li>
<li>{{ __('Domain') }}* </li>
<li>{{ __('Password') }}*</li>
<li>{{ __('Realm') }} (different than domain)</li>
<li>{{ __('Registrar') }} (different than domain)</li>
<li>{{ __('Outbound Proxy') }} (different than domain)</li>
<li>{{ __('Encrypted') }} (yes or no)</li>
<li>{{ __('Protocol') }} (UDP, TCP or TLS)</li>
</ol>
<hr />
<form id="import" method="POST" action="{{ route('admin.account.import.store') }}" accept-charset="UTF-8" enctype="multipart/form-data">

View file

@ -90,18 +90,15 @@
</td>
<td>
@if ($account->activated)
<span class="badge badge-success" title="Activated">Act.</span>
<span class="badge badge-success" title="{{ __('Activated') }}"><i class="ph">check</i></span>
@endif
@if ($account->superAdmin)
<span class="badge badge-error" title="Admin">Super Adm.</span>
<span class="badge badge-error" title="{{ __('Super Admin') }}">Super Adm.</span>
@elseif ($account->admin)
<span class="badge badge-primary" title="Admin">Adm.</span>
@endif
@if ($account->sha256Password)
<span class="badge badge-info">SHA256</span>
<span class="badge badge-primary" title="{{ __('Admin') }}">Adm.</span>
@endif
@if ($account->blocked)
<span class="badge badge-error">{{ __('Blocked') }}</span>
<span class="badge badge-error" title="{{ __('Blocked') }}"><i class="ph">prohibit</i></span>
@endif
</td>
<td>{{ $account->updated_at }}</td>

View file

@ -1,6 +1,7 @@
@include('parts.tabs', [
'items' => [
route('admin.account.edit', $account->id) => __('Information'),
route('admin.account.external.show', $account->id) => __('External Account'),
route('admin.account.statistics.show_call_logs', $account->id) => __('Calls logs'),
route('admin.account.device.index', $account->id) => __('Devices'),
route('admin.account.statistics.show', $account->id) => __('Statistics'),

View file

@ -9,7 +9,7 @@
{{ __('Activate All') }}
</a>
<a class="btn btn-secondary" href="{{ route('admin.phone_countries.deactivate_all') }}">
<i class="ph">trash</i>
<i class="ph">minus</i>
{{ __('Deactivate All') }}
</a>
</header>

View file

@ -677,10 +677,32 @@ JSON parameters:
* `value` **required**, the entry value
### `DELETE /accounts/{id}/dictionary/{key}`
## External Account
### `GET /accounts/{id}/external`
<span class="badge badge-warning">Admin</span>
Remove an entry from the dictionary.
Get the external account.
### `POST /accounts/{id}/external`
<span class="badge badge-warning">Admin</span>
Create or update the external account.
JSON parameters:
* `username` **required**
* `domain` **required**
* `password` **required**
* `realm` must be different than `domain`
* `registrar` must be different than `domain`
* `outbound_proxy` must be different than `domain`
* `protocol` **required**, must be `UDP`, `TCP` or `TLS`
### `DELETE /accounts/{id}/external`
<span class="badge badge-warning">Admin</span>
Delete the external account.
## Account Actions

View file

@ -24,6 +24,7 @@ use App\Http\Controllers\Api\Admin\AccountController as AdminAccountController;
use App\Http\Controllers\Api\Admin\AccountDictionaryController;
use App\Http\Controllers\Api\Admin\AccountTypeController;
use App\Http\Controllers\Api\Admin\ContactsListController;
use App\Http\Controllers\Api\Admin\ExternalAccountController;
use App\Http\Controllers\Api\Admin\SpaceController;
use App\Http\Controllers\Api\Admin\VcardsStorageController as AdminVcardsStorageController;
use App\Http\Controllers\Api\StatisticsMessageController;
@ -163,6 +164,12 @@ Route::group(['middleware' => ['auth.jwt', 'auth.digest_or_key', 'auth.check_blo
Route::delete('{key}', 'destroy');
});
Route::prefix('accounts/{id}/external')->controller(ExternalAccountController::class)->group(function () {
Route::get('/', 'show');
Route::post('/', 'store');
Route::delete('/', 'destroy');
});
Route::prefix('statistics/messages')->controller(StatisticsMessageController::class)->group(function () {
Route::post('/', 'store');
Route::patch('{message_id}/to/{to}/devices/{device_id}', 'storeDevice');

View file

@ -38,6 +38,7 @@ use App\Http\Controllers\Admin\AccountController as AdminAccountController;
use App\Http\Controllers\Admin\AccountStatisticsController;
use App\Http\Controllers\Admin\ContactsListController;
use App\Http\Controllers\Admin\ContactsListContactController;
use App\Http\Controllers\Admin\ExternalAccountController;
use App\Http\Controllers\Admin\PhoneCountryController;
use App\Http\Controllers\Admin\ResetPasswordEmailController;
use App\Http\Controllers\Admin\SpaceController;
@ -265,6 +266,13 @@ Route::middleware(['web_panel_enabled', 'space.expired'])->group(function () {
Route::delete('/', 'destroy')->name('destroy');
});
Route::name('external.')->prefix('{account}/external')->controller(ExternalAccountController::class)->group(function () {
Route::get('/', 'show')->name('show');
Route::post('/', 'store')->name('store');
Route::get('delete', 'delete')->name('delete');
Route::delete('/', 'destroy')->name('destroy');
});
Route::name('activity.')->prefix('{account}/activity')->controller(AccountActivityController::class)->group(function () {
Route::get('/', 'index')->name('index');
});

View file

@ -0,0 +1,100 @@
<?php
/*
Flexisip Account Manager is a set of tools to manage SIP accounts.
Copyright (C) 2020 Belledonne Communications SARL, All rights reserved.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Tests\Feature;
use App\Account;
use Tests\TestCase;
class ApiAccountExternalAccountTest extends TestCase
{
protected $route = '/api/accounts';
protected $method = 'POST';
public function testCreate()
{
$account = Account::factory()->create();
$admin = Account::factory()->admin()->create();
$admin->generateApiKey();
$username = 'foo';
$this->keyAuthenticated($admin)
->get($this->route . '/' . $account->id . '/external/')
->assertStatus(404);
$this->keyAuthenticated($admin)
->json($this->method, $this->route . '/' . $account->id . '/external/', [
'username' => $username,
'domain' => 'bar',
'password' => 'password',
'protocol' => 'UDP'
])->assertStatus(201);
$this->keyAuthenticated($admin)
->json($this->method, $this->route . '/' . $account->id . '/external/', [
'username' => $username,
'domain' => 'bar',
'registrar' => 'bar',
'password' => 'password',
'protocol' => 'UDP'
])->assertJsonValidationErrors(['registrar']);
$this->keyAuthenticated($admin)
->get($this->route . '/' . $account->id . '/external/')
->assertStatus(200)
->assertJson([
'username' => $username,
]);
$this->keyAuthenticated($admin)
->get($this->route . '/123/external/')
->assertStatus(404);
$this->keyAuthenticated($admin)
->json($this->method, $this->route . '/' . $account->id . '/external/', [
'username' => $username . '2',
'domain' => 'bar',
'protocol' => 'UDP'
])->assertStatus(200);
$this->keyAuthenticated($admin)
->json($this->method, $this->route . '/' . $account->id . '/external/', [
'username' => $username . '2',
'domain' => 'bar',
'realm' => 'newrealm',
'protocol' => 'UDP'
])->assertJsonValidationErrors(['password']);
$this->keyAuthenticated($admin)
->get($this->route . '/' . $account->id . '/external/')
->assertStatus(200)
->assertJson([
'username' => $username . '2',
]);
$this->keyAuthenticated($admin)
->delete($this->route . '/' . $account->id . '/external')
->assertStatus(200);
$this->keyAuthenticated($admin)
->get($this->route . '/' . $account->id . '/external/')
->assertStatus(404);
}
}