diff --git a/CHANGELOG.md b/CHANGELOG.md index 167bd17..efe2a60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ---- diff --git a/INSTALL.md b/INSTALL.md index 97c502c..0406ba0 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -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. diff --git a/RELEASE.md b/RELEASE.md index 4fb3bde..d724f79 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -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 diff --git a/flexiapi/.env.example b/flexiapi/.env.example index 5818d5c..55b08ed 100644 --- a/flexiapi/.env.example +++ b/flexiapi/.env.example @@ -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 diff --git a/flexiapi/app/Account.php b/flexiapi/app/Account.php index ac3d379..3996474 100644 --- a/flexiapi/app/Account.php +++ b/flexiapi/app/Account.php @@ -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)); diff --git a/flexiapi/app/ExternalAccount.php b/flexiapi/app/ExternalAccount.php new file mode 100644 index 0000000..453f20c --- /dev/null +++ b/flexiapi/app/ExternalAccount.php @@ -0,0 +1,23 @@ +belongsTo(Account::class); + } + + public function getIdentifierAttribute(): string + { + return $this->attributes['username'] . '@' . $this->attributes['domain']; + } +} diff --git a/flexiapi/app/Http/Controllers/Admin/AccountImportController.php b/flexiapi/app/Http/Controllers/Admin/AccountImportController.php index 898d3ef..651f816 100644 --- a/flexiapi/app/Http/Controllers/Admin/AccountImportController.php +++ b/flexiapi/app/Http/Controllers/Admin/AccountImportController.php @@ -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++; diff --git a/flexiapi/app/Http/Controllers/Admin/ExternalAccountController.php b/flexiapi/app/Http/Controllers/Admin/ExternalAccountController.php new file mode 100644 index 0000000..67502dc --- /dev/null +++ b/flexiapi/app/Http/Controllers/Admin/ExternalAccountController.php @@ -0,0 +1,98 @@ +. +*/ + +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); + } +} diff --git a/flexiapi/app/Http/Controllers/Api/Admin/AccountController.php b/flexiapi/app/Http/Controllers/Api/Admin/AccountController.php index 4ab3e19..b7ec760 100644 --- a/flexiapi/app/Http/Controllers/Api/Admin/AccountController.php +++ b/flexiapi/app/Http/Controllers/Api/Admin/AccountController.php @@ -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']); } diff --git a/flexiapi/app/Http/Controllers/Api/Admin/ExternalAccountController.php b/flexiapi/app/Http/Controllers/Api/Admin/ExternalAccountController.php new file mode 100644 index 0000000..2cb162b --- /dev/null +++ b/flexiapi/app/Http/Controllers/Api/Admin/ExternalAccountController.php @@ -0,0 +1,81 @@ +. +*/ + +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(); + } +} diff --git a/flexiapi/app/Http/Requests/ExternalAccount/CreateUpdate.php b/flexiapi/app/Http/Requests/ExternalAccount/CreateUpdate.php new file mode 100644 index 0000000..fa8631a --- /dev/null +++ b/flexiapi/app/Http/Requests/ExternalAccount/CreateUpdate.php @@ -0,0 +1,43 @@ +. +*/ + +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), + ] + ]; + } +} diff --git a/flexiapi/app/Libraries/StatisticsGraphFactory.php b/flexiapi/app/Libraries/StatisticsGraphFactory.php index 9b2b3de..6fc1b6e 100644 --- a/flexiapi/app/Libraries/StatisticsGraphFactory.php +++ b/flexiapi/app/Libraries/StatisticsGraphFactory.php @@ -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); diff --git a/flexiapi/app/Services/AccountService.php b/flexiapi/app/Services/AccountService.php index cbd8e03..09a386c 100644 --- a/flexiapi/app/Services/AccountService.php +++ b/flexiapi/app/Services/AccountService.php @@ -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; diff --git a/flexiapi/database/migrations/2025_03_13_135937_create_again_external_accounts_table.php b/flexiapi/database/migrations/2025_03_13_135937_create_again_external_accounts_table.php new file mode 100644 index 0000000..f78e1c7 --- /dev/null +++ b/flexiapi/database/migrations/2025_03_13_135937_create_again_external_accounts_table.php @@ -0,0 +1,34 @@ +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'); + } +}; diff --git a/flexiapi/lang/fr.json b/flexiapi/lang/fr.json index 27f012d..c7ba0d9 100644 --- a/flexiapi/lang/fr.json +++ b/flexiapi/lang/fr.json @@ -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." } \ No newline at end of file diff --git a/flexiapi/public/accounts_example.csv b/flexiapi/public/accounts_example.csv index b982405..a29ac6a 100644 --- a/flexiapi/public/accounts_example.csv +++ b/flexiapi/public/accounts_example.csv @@ -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 \ No newline at end of file +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 \ No newline at end of file diff --git a/flexiapi/public/css/form.css b/flexiapi/public/css/form.css index 5f8bf6b..6d1dcef 100644 --- a/flexiapi/public/css/form.css +++ b/flexiapi/public/css/form.css @@ -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; diff --git a/flexiapi/public/css/style.css b/flexiapi/public/css/style.css index 98eb7ba..5c230e4 100644 --- a/flexiapi/public/css/style.css +++ b/flexiapi/public/css/style.css @@ -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; -} \ No newline at end of file +} + +/** 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; +} diff --git a/flexiapi/resources/views/admin/account/create_edit.blade.php b/flexiapi/resources/views/admin/account/create_edit.blade.php index bdee986..1bcfd89 100644 --- a/flexiapi/resources/views/admin/account/create_edit.blade.php +++ b/flexiapi/resources/views/admin/account/create_edit.blade.php @@ -4,7 +4,6 @@ @include('admin.account.parts.breadcrumb_accounts_index') @if ($account->id) @include('admin.account.parts.breadcrumb_accounts_edit', ['account' => $account]) - @else @endif @@ -14,12 +13,10 @@ @if ($account->id)

users {{ $account->identifier }}

- {{ __('Cancel') }} - + trash {{ __('Delete') }} -
@if ($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') -

{{ __('Connection') }}

+
id) required @endif> - - Fill to change + + {{ __('Fill to change') }} @include('parts.errors', ['name' => 'password'])
@@ -101,11 +98,10 @@ @include('parts.errors', ['name' => 'phone']) -

Other information

+

{{ __('Other information') }}

-
- @include('parts.form.toggle', ['object' => $account, 'key' => 'blocked', 'label' => __('Blocked')]) -
+ @include('parts.form.toggle', ['object' => $account, 'key' => 'blocked', 'label' => __('Blocked')]) + @include('parts.form.toggle', ['object' => $account, 'key' => 'activated', 'label' => __('Enabled')])
admin) checked @endif> @@ -115,8 +111,6 @@
- @include('parts.form.toggle', ['object' => $account, 'key' => 'activated', 'label' => __('Enabled')]) - @if (space()?->intercom_features)
+

@if ($account->id) -

{{ __('Contacts Lists') }}

+

{{ __('Contacts') }}

+ + @foreach ($account->contacts as $contact) +

+ {{ $contact->identifier }} + + x + +

+ @endforeach + + {{ __('Add') }} + +

{{ __('Contacts Lists') }}

@if ($contacts_lists->isNotEmpty())
@endforeach -

{{ __('Contacts') }}

- - @foreach ($account->contacts as $contact) -

- {{ $contact->identifier }} - - x - -

- @endforeach -
- {{ __('Add') }} -
- -

{{ __('Provisioning') }}

+

{{ __('Provisioning') }}

@if ($account->provisioning_token) - +
+ +
diff --git a/flexiapi/resources/views/admin/account/device/index.blade.php b/flexiapi/resources/views/admin/account/device/index.blade.php index 56f5f97..c4683a4 100644 --- a/flexiapi/resources/views/admin/account/device/index.blade.php +++ b/flexiapi/resources/views/admin/account/device/index.blade.php @@ -10,7 +10,6 @@

users {{ $account->identifier }}

- {{ __('Cancel') }}
@include('admin.account.parts.tabs') diff --git a/flexiapi/resources/views/admin/account/dictionary/index.blade.php b/flexiapi/resources/views/admin/account/dictionary/index.blade.php index c20b713..2270874 100644 --- a/flexiapi/resources/views/admin/account/dictionary/index.blade.php +++ b/flexiapi/resources/views/admin/account/dictionary/index.blade.php @@ -10,8 +10,7 @@

users {{ $account->identifier }}

- {{ __('Cancel') }} - + plus {{ __('Add') }} diff --git a/flexiapi/resources/views/admin/account/external/delete.blade.php b/flexiapi/resources/views/admin/account/external/delete.blade.php new file mode 100644 index 0000000..5be13da --- /dev/null +++ b/flexiapi/resources/views/admin/account/external/delete.blade.php @@ -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]) + + +@endsection + +@section('content') +
+

trash {{ __('Delete') }}

+ + {{ __('Cancel') }} + +
+ + @csrf + @method('delete') + +
+

{{ __('You are going to permanently delete the following element. Please confirm your action.') }}
+ {{ $account->external->identifier }} +

+ +
+
+
+ +@endsection diff --git a/flexiapi/resources/views/admin/account/external/show.blade.php b/flexiapi/resources/views/admin/account/external/show.blade.php new file mode 100644 index 0000000..6ed8b90 --- /dev/null +++ b/flexiapi/resources/views/admin/account/external/show.blade.php @@ -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]) + +@endsection + +@section('content') +
+

user-circle-dashed {{ __('External Account') }}

+ @if($externalAccount->id) + + trash + {{ __('Delete') }} + + @endif +
+ @include('admin.account.parts.tabs') + +
+ @csrf + @method('post') +

{{ __('Connection') }}

+
+ + + @include('parts.errors', ['name' => 'username']) +
+
+ + +
+ +
+ id) required @endif> + + @if($externalAccount->id){{ __('Fill to change') }}@endif + @include('parts.errors', ['name' => 'password']) +
+ +
isNotEmpty())open @endif> + +

+ {{ __('Other information') }} +

+
+
+
+ + + @include('parts.errors', ['name' => 'realm']) +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+ + +@endsection \ No newline at end of file diff --git a/flexiapi/resources/views/admin/account/import/check.blade.php b/flexiapi/resources/views/admin/account/import/check.blade.php index 073c092..3431bcc 100644 --- a/flexiapi/resources/views/admin/account/import/check.blade.php +++ b/flexiapi/resources/views/admin/account/import/check.blade.php @@ -30,16 +30,14 @@
  • {{ __('Import') }}
  • -

    {{ $linesCount }} accounts will be imported for the {{ $domain }} domain

    - @if ($errors->isNotEmpty()) -
    -

    {{ __('Errors') }}

    @foreach ($errors as $title => $body)

    {{ $title }} {{ $body }}

    @endforeach + @else +

    {{ $linesCount }} accounts will be imported for the {{ $domain }} domain

    @endif
    @endsection diff --git a/flexiapi/resources/views/admin/account/import/create.blade.php b/flexiapi/resources/views/admin/account/import/create.blade.php index f93f998..abd12a4 100644 --- a/flexiapi/resources/views/admin/account/import/create.blade.php +++ b/flexiapi/resources/views/admin/account/import/create.blade.php @@ -19,8 +19,10 @@

    {{ __('The file must be in CSV following this template') }}: example_template.csv

    + +

    {{ __('Account') }}

    +

    {{ __('The first line contains the labels') }}

      -
    1. {{ __('The first line contains the labels') }}
    2. {{ __('Username') }}*
    3. {{ __('Password') }}* (6 characters minimum)
    4. {{ __('Role') }}* (admin or user)
    5. @@ -29,6 +31,20 @@
    6. {{ __('Email') }}
    +

    {{ __('External Account') }}

    + +

    {{ __('Fill the related columns if you want to add an external account as well') }}

    +
      +
    1. {{ __('Username') }}*
    2. +
    3. {{ __('Domain') }}*
    4. +
    5. {{ __('Password') }}*
    6. +
    7. {{ __('Realm') }} (different than domain)
    8. +
    9. {{ __('Registrar') }} (different than domain)
    10. +
    11. {{ __('Outbound Proxy') }} (different than domain)
    12. +
    13. {{ __('Encrypted') }} (yes or no)
    14. +
    15. {{ __('Protocol') }} (UDP, TCP or TLS)
    16. +
    +
    diff --git a/flexiapi/resources/views/admin/account/index.blade.php b/flexiapi/resources/views/admin/account/index.blade.php index 6778769..395c783 100644 --- a/flexiapi/resources/views/admin/account/index.blade.php +++ b/flexiapi/resources/views/admin/account/index.blade.php @@ -90,18 +90,15 @@ @if ($account->activated) - Act. + check @endif @if ($account->superAdmin) - Super Adm. + Super Adm. @elseif ($account->admin) - Adm. - @endif - @if ($account->sha256Password) - SHA256 + Adm. @endif @if ($account->blocked) - {{ __('Blocked') }} + prohibit @endif {{ $account->updated_at }} diff --git a/flexiapi/resources/views/admin/account/parts/tabs.blade.php b/flexiapi/resources/views/admin/account/parts/tabs.blade.php index 7a58618..a05644b 100644 --- a/flexiapi/resources/views/admin/account/parts/tabs.blade.php +++ b/flexiapi/resources/views/admin/account/parts/tabs.blade.php @@ -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'), diff --git a/flexiapi/resources/views/admin/phone_country/index.blade.php b/flexiapi/resources/views/admin/phone_country/index.blade.php index f96a6f3..1b3fd5e 100644 --- a/flexiapi/resources/views/admin/phone_country/index.blade.php +++ b/flexiapi/resources/views/admin/phone_country/index.blade.php @@ -9,7 +9,7 @@ {{ __('Activate All') }} - trash + minus {{ __('Deactivate All') }} diff --git a/flexiapi/resources/views/api/documentation_markdown.blade.php b/flexiapi/resources/views/api/documentation_markdown.blade.php index 1606fc7..c6fd447 100644 --- a/flexiapi/resources/views/api/documentation_markdown.blade.php +++ b/flexiapi/resources/views/api/documentation_markdown.blade.php @@ -677,10 +677,32 @@ JSON parameters: * `value` **required**, the entry value -### `DELETE /accounts/{id}/dictionary/{key}` +## External Account + +### `GET /accounts/{id}/external` Admin -Remove an entry from the dictionary. +Get the external account. + +### `POST /accounts/{id}/external` +Admin + +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` +Admin + +Delete the external account. ## Account Actions diff --git a/flexiapi/routes/api.php b/flexiapi/routes/api.php index bab4be0..240af03 100644 --- a/flexiapi/routes/api.php +++ b/flexiapi/routes/api.php @@ -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'); diff --git a/flexiapi/routes/web.php b/flexiapi/routes/web.php index a0a9a92..bb9dd2b 100644 --- a/flexiapi/routes/web.php +++ b/flexiapi/routes/web.php @@ -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'); }); diff --git a/flexiapi/tests/Feature/ApiAccountExternalAccountTest.php b/flexiapi/tests/Feature/ApiAccountExternalAccountTest.php new file mode 100644 index 0000000..a7ad876 --- /dev/null +++ b/flexiapi/tests/Feature/ApiAccountExternalAccountTest.php @@ -0,0 +1,100 @@ +. +*/ + +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); + } +}