Page MenuHomePhorge

No OneTemporary

diff --git a/src/app/Http/Controllers/API/V4/DomainsController.php b/src/app/Http/Controllers/API/V4/DomainsController.php
index 6b100647..1f45ec3b 100644
--- a/src/app/Http/Controllers/API/V4/DomainsController.php
+++ b/src/app/Http/Controllers/API/V4/DomainsController.php
@@ -1,329 +1,329 @@
<?php
namespace App\Http\Controllers\API\V4;
use App\Domain;
use App\Http\Controllers\RelationController;
use App\Rules\UserEmailDomain;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
class DomainsController extends RelationController
{
/** @var string Resource localization label */
protected $label = 'domain';
/** @var string Resource model name */
protected $model = Domain::class;
/** @var array Common object properties in the API response */
protected $objectProps = ['namespace', 'type'];
/** @var array Resource listing order (column names) */
protected $order = ['namespace'];
/** @var array Resource relation method arguments */
protected $relationArgs = [true, false];
/**
* Confirm ownership of the specified domain (via DNS check).
*
* @param int $id Domain identifier
*
* @return \Illuminate\Http\JsonResponse|void
*/
public function confirm($id)
{
$domain = Domain::find($id);
if (!$this->checkTenant($domain)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canRead($domain)) {
return $this->errorResponse(403);
}
if (!$domain->confirm()) {
return response()->json([
'status' => 'error',
- 'message' => self::trans('app.domain-verify-error'),
+ 'message' => self::trans('app.domain-confirm-error'),
]);
}
return response()->json([
'status' => 'success',
'statusInfo' => self::statusInfo($domain),
- 'message' => self::trans('app.domain-verify-success'),
+ 'message' => self::trans('app.domain-confirm-success'),
]);
}
/**
* Remove the specified domain.
*
* @param string $id Domain identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function destroy($id)
{
$domain = Domain::withEnvTenantContext()->find($id);
if (empty($domain)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canDelete($domain)) {
return $this->errorResponse(403);
}
// It is possible to delete domain only if there are no users/aliases/groups using it.
if (!$domain->isEmpty()) {
$response = ['status' => 'error', 'message' => self::trans('app.domain-notempty-error')];
return response()->json($response, 422);
}
$domain->delete();
return response()->json([
'status' => 'success',
'message' => self::trans('app.domain-delete-success'),
]);
}
/**
* Create a domain.
*
* @param \Illuminate\Http\Request $request
*
* @return \Illuminate\Http\JsonResponse
*/
public function store(Request $request)
{
$current_user = $this->guard()->user();
$owner = $current_user->wallet()->owner;
if ($owner->id != $current_user->id) {
return $this->errorResponse(403);
}
// Validate the input
$v = Validator::make(
$request->all(),
[
'namespace' => ['required', 'string', new UserEmailDomain()]
]
);
if ($v->fails()) {
return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
}
$namespace = \strtolower(request()->input('namespace'));
// Domain already exists
if ($domain = Domain::withTrashed()->where('namespace', $namespace)->first()) {
// Check if the domain is soft-deleted and belongs to the same user
$deleteBeforeCreate = $domain->trashed() && ($wallet = $domain->wallet())
&& $wallet->owner && $wallet->owner->id == $owner->id;
if (!$deleteBeforeCreate) {
$errors = ['namespace' => self::trans('validation.domainnotavailable')];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
}
if (empty($request->package) || !($package = \App\Package::withEnvTenantContext()->find($request->package))) {
$errors = ['package' => self::trans('validation.packagerequired')];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
if (!$package->isDomain()) {
$errors = ['package' => self::trans('validation.packageinvalid')];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
DB::beginTransaction();
// Force-delete the existing domain if it is soft-deleted and belongs to the same user
if (!empty($deleteBeforeCreate)) {
$domain->forceDelete();
}
// Create the domain
$domain = Domain::create([
'namespace' => $namespace,
'type' => \App\Domain::TYPE_EXTERNAL,
]);
$domain->assignPackage($package, $owner);
DB::commit();
return response()->json([
'status' => 'success',
'message' => self::trans('app.domain-create-success'),
]);
}
/**
* Get the information about the specified domain.
*
* @param string $id Domain identifier
*
* @return \Illuminate\Http\JsonResponse|void
*/
public function show($id)
{
$domain = Domain::find($id);
if (!$this->checkTenant($domain)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canRead($domain)) {
return $this->errorResponse(403);
}
$response = $this->objectToClient($domain, true);
// Add hash information to the response
$response['hash_text'] = $domain->hash(Domain::HASH_TEXT);
$response['hash_cname'] = $domain->hash(Domain::HASH_CNAME);
$response['hash_code'] = $domain->hash(Domain::HASH_CODE);
// Add DNS/MX configuration for the domain
$response['dns'] = self::getDNSConfig($domain);
$response['mx'] = self::getMXConfig($domain->namespace);
// Domain configuration, e.g. spf whitelist
$response['config'] = $domain->getConfig();
// Status info
$response['statusInfo'] = self::statusInfo($domain);
// Entitlements/Wallet info
SkusController::objectEntitlements($domain, $response);
return response()->json($response);
}
/**
* Provide DNS MX information to configure specified domain for
*/
protected static function getMXConfig(string $namespace): array
{
$entries = [];
// copy MX entries from an existing domain
if ($master = \config('dns.copyfrom')) {
// TODO: cache this lookup
foreach ((array) dns_get_record($master, DNS_MX) as $entry) {
$entries[] = sprintf(
"@\t%s\t%s\tMX\t%d %s.",
\config('dns.ttl', $entry['ttl']),
$entry['class'],
$entry['pri'],
$entry['target']
);
}
} elseif ($static = \config('dns.static')) {
$entries[] = strtr($static, array('\n' => "\n", '%s' => $namespace));
}
// display SPF settings
if ($spf = \config('dns.spf')) {
$entries[] = ';';
foreach (['TXT', 'SPF'] as $type) {
$entries[] = sprintf(
"@\t%s\tIN\t%s\t\"%s\"",
\config('dns.ttl'),
$type,
$spf
);
}
}
return $entries;
}
/**
* Provide sample DNS config for domain confirmation
*/
protected static function getDNSConfig(Domain $domain): array
{
$serial = date('Ymd01');
$hash_txt = $domain->hash(Domain::HASH_TEXT);
$hash_cname = $domain->hash(Domain::HASH_CNAME);
$hash = $domain->hash(Domain::HASH_CODE);
return [
"@ IN SOA ns1.dnsservice.com. hostmaster.{$domain->namespace}. (",
" {$serial} 10800 3600 604800 86400 )",
";",
"@ IN A <some-ip>",
"www IN A <some-ip>",
";",
"{$hash_cname}.{$domain->namespace}. IN CNAME {$hash}.{$domain->namespace}.",
"@ 3600 TXT \"{$hash_txt}\"",
];
}
/**
* Domain status (extended) information.
*
* @param \App\Domain $domain Domain object
*
* @return array Status information
*/
public static function statusInfo($domain): array
{
// If that is not a public domain, add domain specific steps
return self::processStateInfo(
$domain,
[
'domain-new' => true,
'domain-ldap-ready' => $domain->isLdapReady(),
'domain-verified' => $domain->isVerified(),
'domain-confirmed' => [$domain->isConfirmed(), "/domain/{$domain->id}"],
]
);
}
/**
* Execute (synchronously) specified step in a domain setup process.
*
* @param \App\Domain $domain Domain object
* @param string $step Step identifier (as in self::statusInfo())
*
* @return bool|null True if the execution succeeded, False if not, Null when
* the job has been sent to the worker (result unknown)
*/
public static function execProcessStep(Domain $domain, string $step): ?bool
{
try {
switch ($step) {
case 'domain-ldap-ready':
// Use worker to do the job
\App\Jobs\Domain\CreateJob::dispatch($domain->id);
return null;
case 'domain-verified':
// Domain existence not verified
$domain->verify();
return $domain->isVerified();
case 'domain-confirmed':
// Domain ownership confirmation
$domain->confirm();
return $domain->isConfirmed();
}
} catch (\Exception $e) {
\Log::error($e);
}
return false;
}
}
diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php
index 6fb98f4b..04922744 100644
--- a/src/resources/lang/en/app.php
+++ b/src/resources/lang/en/app.php
@@ -1,173 +1,173 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used in the application.
*/
'chart-created' => 'Created',
'chart-deleted' => 'Deleted',
'chart-average' => 'average',
'chart-allusers' => 'All Users - last year',
'chart-discounts' => 'Discounts',
'chart-vouchers' => 'Vouchers',
'chart-income' => 'Income in :currency - last 8 weeks',
'chart-payers' => 'Payers - last year',
'chart-users' => 'Users - last 8 weeks',
'companion-create-success' => 'Companion app has been created.',
'companion-delete-success' => 'Companion app has been removed.',
'mandate-delete-success' => 'The auto-payment has been removed.',
'mandate-update-success' => 'The auto-payment has been updated.',
'mandate-description-suffix' => 'Auto-Payment Setup',
'planbutton' => 'Choose :plan',
'process-async' => 'Setup process has been pushed. Please wait.',
'process-user-new' => 'Registering a user...',
'process-user-ldap-ready' => 'Creating a user...',
'process-user-imap-ready' => 'Creating a mailbox...',
'process-domain-new' => 'Registering a custom domain...',
'process-domain-ldap-ready' => 'Creating a custom domain...',
'process-domain-verified' => 'Verifying a custom domain...',
- 'process-domain-confirmed' => 'Verifying an ownership of a custom domain...',
+ 'process-domain-confirmed' => 'Confirming an ownership of a custom domain...',
'process-success' => 'Setup process finished successfully.',
'process-error-distlist-ldap-ready' => 'Failed to create a distribution list.',
'process-error-domain-ldap-ready' => 'Failed to create a domain.',
'process-error-domain-verified' => 'Failed to verify a domain.',
- 'process-error-domain-confirmed' => 'Failed to verify an ownership of a domain.',
+ 'process-error-domain-confirmed' => 'Failed to confirm an ownership of a domain.',
'process-error-resource-imap-ready' => 'Failed to verify that a shared folder exists.',
'process-error-resource-ldap-ready' => 'Failed to create a resource.',
'process-error-shared-folder-imap-ready' => 'Failed to verify that a shared folder exists.',
'process-error-shared-folder-ldap-ready' => 'Failed to create a shared folder.',
'process-error-user-ldap-ready' => 'Failed to create a user.',
'process-error-user-imap-ready' => 'Failed to verify that a mailbox exists.',
'process-distlist-new' => 'Registering a distribution list...',
'process-distlist-ldap-ready' => 'Creating a distribution list...',
'process-resource-new' => 'Registering a resource...',
'process-resource-imap-ready' => 'Creating a shared folder...',
'process-resource-ldap-ready' => 'Creating a resource...',
'process-shared-folder-new' => 'Registering a shared folder...',
'process-shared-folder-imap-ready' => 'Creating a shared folder...',
'process-shared-folder-ldap-ready' => 'Creating a shared folder...',
'discount-code' => 'Discount: :code',
'distlist-update-success' => 'Distribution list updated successfully.',
'distlist-create-success' => 'Distribution list created successfully.',
'distlist-delete-success' => 'Distribution list deleted successfully.',
'distlist-suspend-success' => 'Distribution list suspended successfully.',
'distlist-unsuspend-success' => 'Distribution list unsuspended successfully.',
'distlist-setconfig-success' => 'Distribution list settings updated successfully.',
'domain-create-success' => 'Domain created successfully.',
'domain-delete-success' => 'Domain deleted successfully.',
'domain-notempty-error' => 'Unable to delete a domain with assigned users or other objects.',
- 'domain-verify-success' => 'Domain verified successfully.',
- 'domain-verify-error' => 'Domain ownership verification failed.',
+ 'domain-confirm-success' => 'Domain ownership confirmed successfully.',
+ 'domain-confirm-error' => 'Domain ownership confirmation failed.',
'domain-suspend-success' => 'Domain suspended successfully.',
'domain-unsuspend-success' => 'Domain unsuspended successfully.',
'domain-setconfig-success' => 'Domain settings updated successfully.',
'file-create-success' => 'File created successfully.',
'file-delete-success' => 'File deleted successfully.',
'file-update-success' => 'File updated successfully.',
'file-permissions-create-success' => 'File permissions created successfully.',
'file-permissions-update-success' => 'File permissions updated successfully.',
'file-permissions-delete-success' => 'File permissions deleted successfully.',
'collection-create-success' => 'Collection created successfully.',
'collection-delete-success' => 'Collection deleted successfully.',
'collection-update-success' => 'Collection updated successfully.',
'payment-status-paid' => 'The payment has been completed successfully.',
'payment-status-canceled' => 'The payment has been canceled.',
'payment-status-failed' => 'The payment failed.',
'payment-status-expired' => 'The payment expired.',
'payment-status-checking' => "The payment hasn't been completed yet. Checking the status...",
'period-year' => 'year',
'period-month' => 'month',
'resource-update-success' => 'Resource updated successfully.',
'resource-create-success' => 'Resource created successfully.',
'resource-delete-success' => 'Resource deleted successfully.',
'resource-setconfig-success' => 'Resource settings updated successfully.',
'room-update-success' => 'Room updated successfully.',
'room-create-success' => 'Room created successfully.',
'room-delete-success' => 'Room deleted successfully.',
'room-setconfig-success' => 'Room configuration updated successfully.',
'room-unsupported-option-error' => 'Invalid room configuration option.',
'shared-folder-update-success' => 'Shared folder updated successfully.',
'shared-folder-create-success' => 'Shared folder created successfully.',
'shared-folder-delete-success' => 'Shared folder deleted successfully.',
'shared-folder-setconfig-success' => 'Shared folder settings updated successfully.',
'user-update-success' => 'User data updated successfully.',
'user-create-success' => 'User created successfully.',
'user-delete-success' => 'User deleted successfully.',
'user-resync-success' => 'User synchronization has been started.',
'user-suspend-success' => 'User suspended successfully.',
'user-unsuspend-success' => 'User unsuspended successfully.',
'user-reset-2fa-success' => '2-Factor authentication reset successfully.',
'user-reset-geo-lock-success' => 'Geo-lockin setup reset successfully.',
'user-setconfig-success' => 'User settings updated successfully.',
'user-set-sku-success' => 'The subscription added successfully.',
'user-set-sku-already-exists' => 'The subscription already exists.',
'search-foundxdomains' => ':x domains have been found.',
'search-foundxdistlists' => ':x distribution lists have been found.',
'search-foundxresources' => ':x resources have been found.',
'search-foundxshared-folders' => ':x shared folders have been found.',
'search-foundxusers' => ':x user accounts have been found.',
'signup-account-mandate' => 'Now it is required to provide your credit card details.'
. ' This way you agree to charge you with an appropriate amount of money according to the plan you signed up for.',
'signup-account-free' => 'You are signing up for an account with 100% discount. You will be redirected immediately to your Dashboard.',
'signup-plan-monthly' => 'You are choosing a monthly subscription.',
'signup-plan-yearly' => 'You are choosing a yearly subscription.',
'signup-subscription-monthly' => 'Monthly subscription',
'signup-subscription-yearly' => 'Yearly subscription',
'signup-invitations-created' => 'The invitation has been created.|:count invitations has been created.',
'signup-invitations-csv-empty' => 'Failed to find any valid email addresses in the uploaded file.',
'signup-invitations-csv-invalid-email' => 'Found an invalid email address (:email) on line :line.',
'signup-invitation-delete-success' => 'Invitation deleted successfully.',
'signup-invitation-resend-success' => 'Invitation added to the sending queue successfully.',
'support-request-success' => 'Support request submitted successfully.',
'support-request-error' => 'Failed to submit the support request.',
'siteuser' => ':site User',
'total' => 'Total',
'wallet-award-success' => 'The bonus has been added to the wallet successfully.',
'wallet-penalty-success' => 'The penalty has been added to the wallet successfully.',
'wallet-update-success' => 'User wallet updated successfully.',
'password-reset-code-delete-success' => 'Password reset code deleted successfully.',
'password-rule-min' => 'Minimum password length: :param characters',
'password-rule-max' => 'Maximum password length: :param characters',
'password-rule-lower' => 'Password contains a lower-case character',
'password-rule-upper' => 'Password contains an upper-case character',
'password-rule-digit' => 'Password contains a digit',
'password-rule-special' => 'Password contains a special character',
'password-rule-last' => 'Password cannot be the same as the last :param passwords',
'wallet-notice-date' => 'With your current subscriptions your account balance will last until about :date (:days).',
'wallet-notice-nocredit' => 'You are out of credit, top up your balance now.',
'wallet-notice-today' => 'You will run out of credit today, top up your balance now.',
'wallet-notice-trial' => 'You are in your free trial period.',
'wallet-notice-trial-end' => 'Your free trial is about to end, top up to continue.',
'vat-incl' => 'Incl. VAT :vat (:rate of :cost)',
];
diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php
index 4e94fda4..399dcffe 100644
--- a/src/resources/lang/en/ui.php
+++ b/src/resources/lang/en/ui.php
@@ -1,573 +1,576 @@
<?php
/**
* This file will be converted to a Vue-i18n compatible JSON format on build time
*
* Note: The Laravel localization features do not work here. Vue-i18n rules are different
*/
return [
'app' => [
'faq' => "FAQ",
],
'btn' => [
'add' => "Add",
'accept' => "Accept",
'back' => "Back",
'cancel' => "Cancel",
'close' => "Close",
+ 'confirm' => "Confirm",
'continue' => "Continue",
'copy' => "Copy",
'delete' => "Delete",
'deny' => "Deny",
'download' => "Download",
'edit' => "Edit",
'file' => "Choose file...",
'moreinfo' => "More information",
'refresh' => "Refresh",
'reset' => "Reset",
'resend' => "Resend",
'resync' => "Resync",
'save' => "Save",
'search' => "Search",
'share' => "Share",
'signup' => "Sign Up",
'submit' => "Submit",
'subscribe' => "Subscribe",
'suspend' => "Suspend",
'tryagain' => "Try again",
'unsuspend' => "Unsuspend",
'verify' => "Verify",
],
'companion' => [
'title' => "Companion Apps",
'companion' => "Companion App",
'name' => "Name",
'create' => "Pair new device",
'create-recovery-device' => "Prepare recovery code",
'description' => "Use the Companion App on your mobile phone as multi-factor authentication device.",
'download-description' => "You may download the Companion App for Android here: "
. "<a href=\"{href}\">Download</a>",
'description-detailed' => "Here is how this works: " .
"Pairing a device will automatically enable multi-factor autentication for all login attempts. " .
"This includes not only the Cockpit, but also logins via Webmail, IMAP, SMPT, DAV and ActiveSync. " .
"Any authentication attempt will result in a notification on your device, " .
"that you can use to confirm if it was you, or deny otherwise. " .
"Once confirmed, the same username + IP address combination will be whitelisted for 8 hours. " .
"Unpair all your active devices to disable multi-factor authentication again.",
'description-warning' => "Warning: Loosing access to all your multi-factor authentication devices, " .
"will permanently lock you out of your account with no course for recovery. " .
"Always make sure you have a recovery QR-Code printed to pair a recovery device.",
'new' => "Pair new device",
'recovery' => "Prepare recovery device",
'paired' => "Paired devices",
'print' => "Print for backup",
'pairing-instructions' => "Pair your device using the following QR-Code.",
'recovery-device' => "Recovery Device",
'new-device' => "New Device",
'deviceid' => "Device ID",
'list-empty' => "There are currently no devices",
'delete' => "Delete/Unpair",
'delete-companion' => "Delete/Unpair",
'delete-text' => "You are about to delete this entry and unpair any paired companion app. " .
"This cannot be undone, but you can pair the device again.",
'pairing-successful' => "Your companion app is paired and ready to be used " .
"as a multi-factor authentication device.",
],
'dashboard' => [
'beta' => "beta",
'distlists' => "Distribution lists",
'chat' => "Video chat",
'companion' => "Companion app",
'domains' => "Domains",
'files' => "Files",
'invitations' => "Invitations",
'myaccount' => "My account",
'policies' => "Policies",
'profile' => "Your profile",
'resources' => "Resources",
'shared-folders' => "Shared folders",
'users' => "User accounts",
'wallet' => "Wallet",
'webmail' => "Webmail",
'stats' => "Stats",
],
'distlist' => [
'list-title' => "Distribution list | Distribution lists",
'create' => "Create list",
'delete' => "Delete list",
'email' => "Email",
'list-empty' => "There are no distribution lists in this account.",
'name' => "Name",
'new' => "New distribution list",
'recipients' => "Recipients",
'sender-policy' => "Sender Access List",
'sender-policy-text' => "With this list you can specify who can send mail to the distribution list."
. " You can put a complete email address (jane@kolab.org), domain (kolab.org) or suffix (.org) that the sender email address is compared to."
. " If the list is empty, mail from anyone is allowed.",
],
'domain' => [
+ 'config' => "Domain configuration",
+ 'config-intro' => "In order to let {app} receive email traffic for your domain you need to adjust the DNS settings, more precisely the MX entries, accordingly.",
+ 'config-sample' => "Edit your domain's zone file and replace existing MX entries with the following values:",
+ 'config-hint' => "If you don't know how to set DNS entries for your domain, please contact the registration service where you registered the domain or your web hosting provider.",
+ 'confirm' => "Domain ownership confirmation",
+ 'confirm-intro' => "In order to confirm that you're the actual owner or administrator of the domain, "
+ . "we need to run a confirmation process before finally activating it for email delivery.",
+ 'confirm-dns' => "The domain <b>must have one of the following entries</b> in DNS:",
+ 'confirm-dns-txt' => "TXT entry with value:",
+ 'confirm-dns-cname' => "or CNAME entry:",
+ 'confirm-outro' => "Please add one of those records to the DNS of your domain via your domain name provider. "
+ . "When this is done press the button below to start the confirmation.",
+ 'confirm-sample' => "Here's a sample zone file for your domain:",
+ 'create' => "Create domain",
'delete' => "Delete domain",
'delete-domain' => "Delete {domain}",
'delete-text' => "Do you really want to delete this domain permanently?"
. " This is only possible if there are no users, aliases or other objects in this domain."
. " Please note that this action cannot be undone.",
- 'dns-verify' => "Domain DNS verification sample:",
+ 'dns-confirm' => "Domain DNS confirmation sample:",
'dns-config' => "Domain DNS configuration sample:",
'list-empty' => "There are no domains in this account.",
'namespace' => "Namespace",
+ 'new' => "New domain",
'spf-whitelist' => "SPF Whitelist",
'spf-whitelist-text' => "The Sender Policy Framework allows a sender domain to disclose, through DNS, "
. "which systems are allowed to send emails with an envelope sender address within said domain.",
'spf-whitelist-ex' => "Here you can specify a list of allowed servers, for example: <var>.ess.barracuda.com</var>.",
- 'verify' => "Domain verification",
- 'verify-intro' => "In order to confirm that you're the actual holder of the domain, we need to run a verification process before finally activating it for email delivery.",
- 'verify-dns' => "The domain <b>must have one of the following entries</b> in DNS:",
- 'verify-dns-txt' => "TXT entry with value:",
- 'verify-dns-cname' => "or CNAME entry:",
- 'verify-outro' => "When this is done press the button below to start the verification.",
- 'verify-sample' => "Here's a sample zone file for your domain:",
- 'config' => "Domain configuration",
- 'config-intro' => "In order to let {app} receive email traffic for your domain you need to adjust the DNS settings, more precisely the MX entries, accordingly.",
- 'config-sample' => "Edit your domain's zone file and replace existing MX entries with the following values:",
- 'config-hint' => "If you don't know how to set DNS entries for your domain, please contact the registration service where you registered the domain or your web hosting provider.",
- 'create' => "Create domain",
- 'new' => "New domain",
],
'error' => [
'400' => "Bad request",
'401' => "Unauthorized",
'403' => "Access denied",
'404' => "Not found",
'405' => "Method not allowed",
'500' => "Internal server error",
'unknown' => "Unknown Error",
'server' => "Server Error",
'form' => "Form validation error",
],
'file' => [
'create' => "Create file",
'delete' => "Delete file",
'drop' => "Click or drop file(s) here",
'list-empty' => "There are no files in this account.",
'mimetype' => "Mimetype",
'mtime' => "Modified",
'new' => "New file",
'search' => "File name",
'sharing' => "Sharing",
'sharing-links-text' => "You can share the file with other users by giving them read-only access "
. "to the file via a unique link.",
],
'collection' => [
'create' => "Create collection",
'new' => "New Collection",
'name' => "Name",
],
'form' => [
'acl' => "Access rights",
'acl-full' => "All",
'acl-read-only' => "Read-only",
'acl-read-write' => "Read-write",
'amount' => "Amount",
'anyone' => "Anyone",
'code' => "Confirmation Code",
'config' => "Configuration",
'companion' => "Companion App",
'date' => "Date",
'description' => "Description",
'details' => "Details",
'disabled' => "disabled",
'domain' => "Domain",
'email' => "Email Address",
'emails' => "Email Addresses",
'enabled' => "enabled",
'firstname' => "First Name",
'general' => "General",
'geolocation' => "Your current location: {location}",
'lastname' => "Last Name",
'name' => "Name",
'months' => "months",
'none' => "none",
'norestrictions' => "No restrictions",
'or' => "or",
'password' => "Password",
'password-confirm' => "Confirm Password",
'personal' => "Personal information",
'phone' => "Phone",
'selectcountries' => "Select countries",
'settings' => "Settings",
'shared-folder' => "Shared Folder",
'size' => "Size",
'status' => "Status",
'subscriptions' => "Subscriptions",
'surname' => "Surname",
'type' => "Type",
'unknown' => "unknown",
'user' => "User",
'primary-email' => "Primary Email",
'id' => "ID",
'created' => "Created",
'deleted' => "Deleted",
],
'invitation' => [
'create' => "Create invite(s)",
'create-title' => "Invite for a signup",
'create-email' => "Enter an email address of the person you want to invite.",
'create-csv' => "To send multiple invitations at once, provide a CSV (comma separated) file, or alternatively a plain-text file, containing one email address per line.",
'list-empty' => "There are no invitations in the database.",
'title' => "Signup invitations",
'search' => "Email address or domain",
'send' => "Send invite(s)",
'status-completed' => "User signed up",
'status-failed' => "Sending failed",
'status-sent' => "Sent",
'status-new' => "Not sent yet",
],
'lang' => [
'en' => "English",
'de' => "German",
'fr' => "French",
'it' => "Italian",
],
'login' => [
'2fa' => "Second factor code",
'2fa_desc' => "Second factor code is optional for users with no 2-Factor Authentication setup.",
'forgot_password' => "Forgot password?",
'header' => "Please sign in",
'sign_in' => "Sign in",
'signing_in' => "Signing in...",
'webmail' => "Webmail"
],
'meet' => [
// Room options dialog
'options' => "Room options",
'password' => "Password",
'password-none' => "none",
'password-clear' => "Clear password",
'password-set' => "Set password",
'password-text' => "You can add a password to your meeting. Participants will have to provide the password before they are allowed to join the meeting.",
'lock' => "Locked room",
'lock-text' => "When the room is locked participants have to be approved by a moderator before they could join the meeting.",
'nomedia' => "Subscribers only",
'nomedia-text' => "Forces all participants to join as subscribers (with camera and microphone turned off)."
. " Moderators will be able to promote them to publishers throughout the session.",
// Room menu
'partcnt' => "Number of participants",
'menu-audio-mute' => "Mute audio",
'menu-audio-unmute' => "Unmute audio",
'menu-video-mute' => "Mute video",
'menu-video-unmute' => "Unmute video",
'menu-screen' => "Share screen",
'menu-hand-lower' => "Lower hand",
'menu-hand-raise' => "Raise hand",
'menu-channel' => "Interpreted language channel",
'menu-chat' => "Chat",
'menu-fullscreen' => "Full screen",
'menu-fullscreen-exit' => "Exit full screen",
'menu-leave' => "Leave session",
// Room setup screen
'setup-title' => "Set up your session",
'mic' => "Microphone",
'cam' => "Camera",
'nick' => "Nickname",
'nick-placeholder' => "Your name",
'join' => "JOIN",
'joinnow' => "JOIN NOW",
'imaowner' => "I'm the owner",
// Room
'qa' => "Q & A",
'leave-title' => "Room closed",
'leave-body' => "The session has been closed by the room owner.",
'media-title' => "Media setup",
'join-request' => "Join request",
'join-requested' => "{user} requested to join.",
// Status messages
'status-init' => "Checking the room...",
'status-323' => "The room is closed. Please, wait for the owner to start the session.",
'status-324' => "The room is closed. It will be open for others after you join.",
'status-325' => "The room is ready. Please, provide a valid password.",
'status-326' => "The room is locked. Please, enter your name and try again.",
'status-327' => "Waiting for permission to join the room.",
'status-404' => "The room does not exist.",
'status-429' => "Too many requests. Please, wait.",
'status-500' => "Failed to connect to the room. Server error.",
// Other menus
'media-setup' => "Media setup",
'perm' => "Permissions",
'perm-av' => "Audio &amp; Video publishing",
'perm-mod' => "Moderation",
'lang-int' => "Language interpreter",
'menu-options' => "Options",
],
'menu' => [
'cockpit' => "Cockpit",
'login' => "Login",
'logout' => "Logout",
'signup' => "Signup",
'toggle' => "Toggle navigation",
],
'msg' => [
'initializing' => "Initializing...",
'loading' => "Loading...",
'loading-failed' => "Failed to load data.",
'notfound' => "Resource not found.",
'info' => "Information",
'error' => "Error",
'uploading' => "Uploading...",
'warning' => "Warning",
'success' => "Success",
],
'nav' => [
'more' => "Load more",
'step' => "Step {i}/{n}",
],
'password' => [
'link-invalid' => "The password reset code is expired or invalid.",
'reset' => "Password Reset",
'reset-step1' => "Enter your email address to reset your password.",
'reset-step1-hint' => "You may need to check your spam folder or unblock {email}.",
'reset-step2' => "We sent out a confirmation code to your external email address."
. " Enter the code we sent you, or click the link in the message.",
],
'policies' => [
'password-policy' => "Password Policy",
'password-retention' => "Password Retention",
'password-max-age' => "Require a password change every",
],
'resource' => [
'create' => "Create resource",
'delete' => "Delete resource",
'invitation-policy' => "Invitation policy",
'invitation-policy-text' => "Event invitations for a resource are normally accepted automatically"
. " if there is no conflicting event on the requested time slot. Invitation policy allows"
. " for rejecting such requests or to require a manual acceptance from a specified user.",
'ipolicy-manual' => "Manual (tentative)",
'ipolicy-accept' => "Accept",
'ipolicy-reject' => "Reject",
'list-title' => "Resource | Resources",
'list-empty' => "There are no resources in this account.",
'new' => "New resource",
],
'room' => [
'create' => "Create room",
'delete' => "Delete room",
'copy-location' => "Copy room location",
'description-hint' => "This is an optional short description for the room, so you can find it more easily on the list.",
'goto' => "Enter the room",
'list-empty' => "There are no conference rooms in this account.",
'list-empty-nocontroller' => "Do you need a room? Ask your account owner to create one and share it with you.",
'list-title' => "Voice & video conferencing rooms",
'moderators' => "Moderators",
'moderators-text' => "You can share your room with other users. They will become the room moderators with all moderator powers and ability to open the room without your presence.",
'new' => "New room",
'new-hint' => "We'll generate a unique name for the room that will then allow you to access the room.",
'title' => "Room: {name}",
'url' => "You can access the room at the URL below. Use this URL to invite people to join you. This room is only open when you (or another room moderator) is in attendance.",
],
'shf' => [
'aliases-none' => "This shared folder has no email aliases.",
'create' => "Create folder",
'delete' => "Delete folder",
'acl-text' => "Defines user permissions to access the shared folder.",
'list-title' => "Shared folder | Shared folders",
'list-empty' => "There are no shared folders in this account.",
'new' => "New shared folder",
'type-mail' => "Mail",
'type-event' => "Calendar",
'type-contact' => "Address Book",
'type-task' => "Tasks",
'type-note' => "Notes",
'type-file' => "Files",
],
'signup' => [
'email' => "Existing Email Address",
'login' => "Login",
'title' => "Sign Up",
'step1' => "Sign up to start your free month.",
'step2' => "We sent out a confirmation code to your email address. Enter the code we sent you, or click the link in the message.",
'step3' => "Create your {app} identity (you can choose additional addresses later).",
'created' => "The account is about to be created!",
'token' => "Signup authorization token",
'voucher' => "Voucher Code",
],
'status' => [
'prepare-account' => "We are preparing your account.",
'prepare-domain' => "We are preparing the domain.",
'prepare-distlist' => "We are preparing the distribution list.",
'prepare-resource' => "We are preparing the resource.",
'prepare-shared-folder' => "We are preparing the shared folder.",
'prepare-user' => "We are preparing the user account.",
'prepare-hint' => "Some features may be missing or readonly at the moment.",
'prepare-refresh' => "The process never ends? Press the \"Refresh\" button, please.",
'ready-account' => "Your account is almost ready.",
'ready-domain' => "The domain is almost ready.",
'ready-distlist' => "The distribution list is almost ready.",
'ready-resource' => "The resource is almost ready.",
'ready-shared-folder' => "The shared-folder is almost ready.",
'ready-user' => "The user account is almost ready.",
- 'verify' => "Verify your domain to finish the setup process.",
- 'verify-domain' => "Verify domain",
+ 'confirm' => "Confirm your domain to finish the setup process.",
+ 'confirm-domain' => "Confirm domain",
'degraded' => "Degraded",
'deleted' => "Deleted",
'restricted' => "Restricted",
'suspended' => "Suspended",
'notready' => "Not Ready",
'active' => "Active",
],
'support' => [
'title' => "Contact Support",
'id' => "Customer number or email address you have with us",
'id-pl' => "e.g. 12345678 or the affected email address",
'id-hint' => "Leave blank if you are not a customer yet",
'name' => "Name",
'name-pl' => "how we should call you in our reply",
'email' => "Working email address",
'email-pl' => "make sure we can reach you at this address",
'summary' => "Issue Summary",
'summary-pl' => "one sentence that summarizes your issue",
'expl' => "Issue Explanation",
],
'user' => [
'2fa-hint1' => "This will remove 2-Factor Authentication entitlement as well as the user-configured factors.",
'2fa-hint2' => "Please, make sure to confirm the user identity properly.",
'add-beta' => "Enable beta program",
'address' => "Address",
'aliases' => "Aliases",
'aliases-none' => "This user has no email aliases.",
'add-bonus' => "Add bonus",
'add-bonus-title' => "Add a bonus to the wallet",
'add-penalty' => "Add penalty",
'add-penalty-title' => "Add a penalty to the wallet",
'auto-payment' => "Auto-payment",
'auto-payment-text' => "Fill up by <b>{amount}</b> when under <b>{balance}</b> using {method}",
'country' => "Country",
'create' => "Create user",
'custno' => "Customer No.",
'degraded-warning' => "The account is degraded. Some features have been disabled.",
'degraded-hint' => "Please, make a payment.",
'delete' => "Delete user",
'delete-email' => "Delete {email}",
'delete-text' => "Do you really want to delete this user permanently?"
. " This will delete all account data and withdraw the permission to access the email account."
. " Please note that this action cannot be undone.",
'discount' => "Discount",
'discount-hint' => "applied discount",
'discount-title' => "Account discount",
'distlists' => "Distribution lists",
'domains' => "Domains",
'ext-email' => "External Email",
'email-aliases' => "Email Aliases",
'finances' => "Finances",
'geolimit' => "Geo-lockin",
'geolimit-text' => "Defines a list of locations that are allowed for logon. You will not be able to login from a country that is not listed here.",
'greylisting' => "Greylisting",
'greylisting-text' => "Greylisting is a method of defending users against spam. Any incoming mail from an unrecognized sender "
. "is temporarily rejected. The originating server should try again after a delay. "
. "This time the email will be accepted. Spammers usually do not reattempt mail delivery.",
'imapproxy' => "IMAP proxy",
'imapproxy-text' => "Enables IMAP proxy that filters out non-mail groupware folders, so your IMAP clients do not see them.",
'list-title' => "User accounts",
'list-empty' => "There are no users in this account.",
'managed-by' => "Managed by",
'new' => "New user account",
'org' => "Organization",
'package' => "Package",
'pass-input' => "Enter password",
'pass-link' => "Set via link",
'pass-link-label' => "Link:",
'pass-link-hint' => "Press Submit to activate the link",
'passwordpolicy' => "Password Policy",
'price' => "Price",
'profile-delete' => "Delete account",
'profile-delete-title' => "Delete this account?",
'profile-delete-text1' => "This will delete the account as well as all domains, users and aliases associated with this account.",
'profile-delete-warning' => "This operation is irreversible",
'profile-delete-text2' => "As you will not be able to recover anything after this point, please make sure that you have migrated all data before proceeding.",
'profile-delete-support' => "As we always strive to improve, we would like to ask for 2 minutes of your time. "
. "The best tool for improvement is feedback from users, and we would like to ask "
. "for a few words about your reasons for leaving our service. Please send your feedback to <a href=\"{href}\">{email}</a>.",
'profile-delete-contact' => "Also feel free to contact {app} Support with any questions or concerns that you may have in this context.",
'reset-2fa' => "Reset 2-Factor Auth",
'reset-2fa-title' => "2-Factor Authentication Reset",
'resources' => "Resources",
'title' => "User account",
'search' => "User email address or name",
'search-pl' => "User ID, email or domain",
'skureq' => "{sku} requires {list}.",
'subscription' => "Subscription",
'subscriptions-none' => "This user has no subscriptions.",
'users' => "Users",
],
'wallet' => [
'add-credit' => "Add credit",
'auto-payment-cancel' => "Cancel auto-payment",
'auto-payment-change' => "Change auto-payment",
'auto-payment-failed' => "The setup of automatic payments failed. Restart the process to enable automatic top-ups.",
'auto-payment-hint' => "Here is how it works: Every time your account runs low, we will charge your preferred payment method for an amount you choose."
. " You can cancel or change the auto-payment option at any time.",
'auto-payment-setup' => "Set up auto-payment",
'auto-payment-disabled' => "The configured auto-payment has been disabled. Top up your wallet or raise the auto-payment amount.",
'auto-payment-info' => "Auto-payment is <b>set</b> to fill up your account by <b>{amount}</b> every time your account balance gets under <b>{balance}</b>.",
'auto-payment-inprogress' => "The setup of the automatic payment is still in progress.",
'auto-payment-next' => "Next, you will be redirected to the checkout page, where you can provide your credit card details.",
'auto-payment-disabled-next' => "The auto-payment is disabled. Immediately after you submit new settings we'll enable it and attempt to top up your wallet.",
'auto-payment-update' => "Update auto-payment",
'banktransfer-hint' => "Please note that a bank transfer can take several days to complete.",
'coinbase-hint' => "Here is how it works: You specify the amount by which you want to top up your wallet in {wc}."
. " We will then create a charge on Coinbase for the specified amount that you can pay using Bitcoin.",
'currency-conv' => "Here is how it works: You specify the amount by which you want to top up your wallet in {wc}."
. " We will then convert this to {pc}, and on the next page you will be provided with the bank-details to transfer the amount in {pc}.",
'fill-up' => "Fill up by",
'history' => "History",
'locked-text' => "The account is locked until you set up auto-payment successfully.",
'month' => "month",
'noperm' => "Only account owners can access a wallet.",
'norefund' => "The money in your wallet is non-refundable.",
'payment-amount-hint' => "Choose the amount by which you want to top up your wallet.",
'payment-method' => "Method of payment: {method}",
'payment-warning' => "You will be charged for {price}.",
'pending-payments' => "Pending Payments",
'pending-payments-warning' => "You have payments that are still in progress. See the \"Pending Payments\" tab below.",
'pending-payments-none' => "There are no pending payments for this account.",
'receipts' => "Receipts",
'receipts-hint' => "Here you can download receipts (in PDF format) for payments in specified period. Select the period and press the Download button.",
'receipts-none' => "There are no receipts for payments in this account. Please, note that you can download receipts after the month ends.",
'title' => "Account balance",
'top-up' => "Top up your wallet",
'transactions' => "Transactions",
'transactions-none' => "There are no transactions for this account.",
'when-below' => "when account balance is below",
],
];
diff --git a/src/resources/lang/fr/app.php b/src/resources/lang/fr/app.php
index 24335726..c493bf25 100644
--- a/src/resources/lang/fr/app.php
+++ b/src/resources/lang/fr/app.php
@@ -1,118 +1,118 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used in the application.
*/
'chart-created' => "Crée",
'chart-deleted' => "Supprimé",
'chart-average' => "moyenne",
'chart-allusers' => "Tous Utilisateurs - l'année derniere",
'chart-discounts' => "Rabais",
'chart-vouchers' => "Coupons",
'chart-income' => "Revenus en :currency - 8 dernières semaines",
'chart-users' => "Utilisateurs - 8 dernières semaines",
'mandate-delete-success' => "L'auto-paiement a été supprimé.",
'mandate-update-success' => "L'auto-paiement a été mis-à-jour.",
'planbutton' => "Choisir :plan",
'siteuser' => "Utilisateur du :site",
'domain-setconfig-success' => "Les paramètres du domaine sont mis à jour avec succès.",
'user-setconfig-success' => "Les paramètres d'utilisateur sont mis à jour avec succès.",
'process-async' => "Le processus d'installation a été poussé. Veuillez patienter.",
'process-user-new' => "Enregistrement d'un utilisateur...",
'process-user-ldap-ready' => "Création d'un utilisateur...",
'process-user-imap-ready' => "Création d'une boîte aux lettres...",
'process-distlist-new' => "Enregistrement d'une liste de distribution...",
'process-distlist-ldap-ready' => "Création d'une liste de distribution...",
'process-domain-new' => "Enregistrement d'un domaine personnalisé...",
'process-domain-ldap-ready' => "Création d'un domaine personnalisé...",
'process-domain-verified' => "Vérification d'un domaine personnalisé...",
- 'process-domain-confirmed' => "vérification de la propriété d'un domaine personnalisé...",
+ 'process-domain-confirmed' => "Confirmation de la propriété d'un domaine personnalisé...",
'process-success' => "Le processus d'installation s'est terminé avec succès.",
'process-error-domain-ldap-ready' => "Échec de créer un domaine.",
'process-error-domain-verified' => "Échec de vérifier un domaine.",
- 'process-error-domain-confirmed' => "Échec de la vérification de la propriété d'un domaine.",
+ 'process-error-domain-confirmed' => "Échec de la confirmation de la propriété d'un domaine.",
'process-error-distlist-ldap-ready' => "Échec de créer une liste de distrubion.",
'process-error-resource-imap-ready' => "Échec de la vérification de l'existence d'un dossier partagé.",
'process-error-resource-ldap-ready' => "Échec de la création d'une ressource.",
'process-error-shared-folder-imap-ready' => "Impossible de vérifier qu'un dossier partagé existe.",
'process-error-shared-folder-ldap-ready' => "Échec de la création d'un dossier partagé.",
'process-error-user-ldap-ready' => "Échec de la création d'un utilisateur.",
'process-error-user-imap-ready' => "Échec de la vérification de l'existence d'une boîte aux lettres.",
'process-resource-new' => "Enregistrement d'une ressource...",
'process-resource-imap-ready' => "Création d'un dossier partagé...",
'process-resource-ldap-ready' => "Création d'un ressource...",
'process-shared-folder-new' => "Enregistrement d'un dossier partagé...",
'process-shared-folder-imap-ready' => "Création d'un dossier partagé...",
'process-shared-folder-ldap-ready' => "Création d'un dossier partagé...",
'distlist-update-success' => "Liste de distribution mis-à-jour avec succès.",
'distlist-create-success' => "Liste de distribution créer avec succès.",
'distlist-delete-success' => "Liste de distribution suppriméee avec succès.",
'distlist-suspend-success' => "Liste de distribution à été suspendue avec succès.",
'distlist-unsuspend-success' => "Liste de distribution à été débloquée avec succès.",
'distlist-setconfig-success' => "Mise à jour des paramètres de la liste de distribution avec succès.",
'domain-create-success' => "Domaine a été crée avec succès.",
'domain-delete-success' => "Domaine supprimé avec succès.",
- 'domain-verify-success' => "Domaine vérifié avec succès.",
- 'domain-verify-error' => "Vérification de propriété de domaine à échoué.",
+ 'domain-confirm-success' => "De propriété de domaine confirmé avec succès.",
+ 'domain-confirm-error' => "Confirmation de propriété de domaine à échoué.",
'domain-suspend-success' => "Domaine suspendue avec succès.",
'domain-unsuspend-success' => "Domaine debloqué avec succès.",
'resource-update-success' => "Ressource mise à jour avec succès.",
'resource-create-success' => "Resource crée avec succès.",
'resource-delete-success' => "Ressource suprimmée avec succès.",
'resource-setconfig-success' => "Les paramètres des ressources ont été mis à jour avec succès.",
'room-setconfig-success' => 'La configuration de la salle a été actualisée avec succès.',
'room-unsupported-option-error' => 'Option de configuration de la salle invalide.',
'shared-folder-update-success' => "Dossier partagé mis à jour avec succès.",
'shared-folder-create-success' => "Dossier partagé créé avec succès.",
'shared-folder-delete-success' => "Dossier partagé supprimé avec succès.",
'shared-folder-setconfig-success' => "Mise à jour des paramètres du dossier partagé avec succès.",
'user-update-success' => "Mis-à-jour des données de l'utilsateur effectué avec succès.",
'user-create-success' => "Utilisateur a été crée avec succès.",
'user-delete-success' => "Utilisateur a été supprimé avec succès.",
'user-suspend-success' => "Utilisateur a été suspendu avec succès.",
'user-unsuspend-success' => "Utilisateur a été debloqué avec succès.",
'user-reset-2fa-success' => "Réinstallation de l'authentification à 2-Facteur avec succès.",
'user-set-sku-success' => "Souscription ajoutée avec succès.",
'user-set-sku-already-exists' => "La souscription existe déjà.",
'search-foundxdomains' => "Les domaines :x ont été trouvés.",
'search-foundxdistlists' => "Les listes de distribution :x ont été trouvés.",
'search-foundxresources' => "Les ressources :x ont été trouvés.",
'search-foundxusers' => "Les comptes d'utilisateurs :x ont été trouvés.",
'search-foundxshared-folders' => ":x dossiers partagés ont été trouvés.",
'signup-invitations-created' => "L'invitation à été crée.|:count nombre d'invitations ont été crée.",
'signup-invitations-csv-empty' => "Aucune adresses email valides ont été trouvées dans le fichier téléchargé.",
'signup-invitations-csv-invalid-email' => "Une adresse email invalide a été trouvée (:email) on line :line.",
'signup-invitation-delete-success' => "Invitation supprimée avec succès.",
'signup-invitation-resend-success' => "Invitation ajoutée à la file d'attente d'envoi avec succès.",
'support-request-success' => "Demande de soutien soumise avec succès.",
'support-request-error' => "La soumission de demande de soutien a échoué.",
'wallet-award-success' => "Le bonus a été ajouté au portefeuille avec succès.",
'wallet-penalty-success' => "La pénalité a été ajoutée au portefeuille avec succès.",
'wallet-update-success' => "Portefeuille d'utilisateur a été mis-à-jour avec succès.",
'wallet-notice-date' => "Avec vos abonnements actuels, le solde de votre compte durera jusqu'à environ :date (:days).",
'wallet-notice-nocredit' => "Votre crédit a été epuisé, veuillez recharger immédiatement votre solde.",
'wallet-notice-today' => "Votre reste crédit sera épuisé aujourd'hui, veuillez recharger immédiatement.",
'wallet-notice-trial' => "Vous êtes dans votre période d'essai gratuite.",
'wallet-notice-trial-end' => "Vous approchez de la fin de votre période d'essai gratuite, veuillez recharger pour continuer.",
];
diff --git a/src/resources/lang/fr/ui.php b/src/resources/lang/fr/ui.php
index a62f8743..e62839b1 100644
--- a/src/resources/lang/fr/ui.php
+++ b/src/resources/lang/fr/ui.php
@@ -1,478 +1,474 @@
<?php
/**
* This file will be converted to a Vue-i18n compatible JSON format on build time
*
* Note: The Laravel localization features do not work here. Vue-i18n rules are different
*/
return [
'app' => [
'faq' => "FAQ",
],
'btn' => [
'add' => "Ajouter",
'accept' => "Accepter",
'back' => "Back",
'cancel' => "Annuler",
'close' => "Fermer",
'continue' => "Continuer",
'delete' => "Supprimer",
'deny' => "Refuser",
'download' => "Télécharger",
'edit' => "Modifier",
'file' => "Choisir le ficher...",
'moreinfo' => "Plus d'information",
'refresh' => "Actualiser",
'reset' => "Réinitialiser",
'resend' => "Envoyer à nouveau",
'save' => "Sauvegarder",
'search' => "Chercher",
'signup' => "S'inscrire",
'submit' => "Soumettre",
'suspend' => "Suspendre",
'unsuspend' => "Débloquer",
'verify' => "Vérifier",
],
'dashboard' => [
'beta' => "bêta",
'distlists' => "Listes de distribution",
'chat' => "Chat Vidéo",
'domains' => "Domaines",
'invitations' => "Invitations",
'profile' => "Votre profil",
'resources' => "Ressources",
'users' => "D'utilisateurs",
'wallet' => "Portefeuille",
'webmail' => "Webmail",
'stats' => "Statistiques",
],
'distlist' => [
'list-title' => "Liste de distribution | Listes de Distribution",
'create' => "Créer une liste",
'delete' => "Suprimmer une list",
'email' => "Courriel",
'list-empty' => "il n'y a pas de listes de distribution dans ce compte.",
'name' => "Nom",
'new' => "Nouvelle liste de distribution",
'recipients' => "Destinataires",
'sender-policy' => "Liste d'Accès d'Expéditeur",
'sender-policy-text' => "Cette liste vous permet de spécifier qui peut envoyer du courrier à la liste de distribution."
. " Vous pouvez mettre une adresse e-mail complète (jane@kolab.org), un domaine (kolab.org) ou un suffixe (.org)"
. " auquel l'adresse électronique de l'expéditeur est assimilée."
. " Si la liste est vide, le courriels de quiconque est autorisé."
],
'domain' => [
- 'dns-verify' => "Exemple de vérification du DNS d'un domaine:",
+ 'dns-confirm' => "Exemple de confirmation du DNS d'un domaine:",
'dns-config' => "Exemple de configuration du DNS d'un domaine:",
- 'list-empty' => "Il y a pas de domaines dans ce compte.",
- 'namespace' => "Espace de noms",
- 'verify' => "Vérification du domaine",
- 'verify-intro' => "Afin de confirmer que vous êtes bien le titulaire du domaine, nous devons exécuter un processus de vérification avant de l'activer définitivement pour la livraison d'e-mails.",
- 'verify-dns' => "Le domaine <b>doit avoir l'une des entrées suivantes</b> dans le DNS:",
- 'verify-dns-txt' => "Entrée TXT avec valeur:",
- 'verify-dns-cname' => "ou entrée CNAME:",
- 'verify-outro' => "Lorsque cela est fait, appuyez sur le bouton ci-dessous pour lancer la vérification.",
- 'verify-sample' => "Voici un fichier de zone simple pour votre domaine:",
'config' => "Configuration du domaine",
'config-intro' => "Afin de permettre à {app} de recevoir le trafic de messagerie pour votre domaine, vous devez ajuster les paramètres DNS, plus précisément les entrées MX, en conséquence.",
'config-sample' => "Modifiez le fichier de zone de votre domaine et remplacez les entrées MX existantes par les valeurs suivantes:",
'config-hint' => "Si vous ne savez pas comment définir les entrées DNS pour votre domaine, veuillez contacter le service d'enregistrement auprès duquel vous avez enregistré le domaine ou votre fournisseur d'hébergement Web.",
- 'spf-whitelist' => "SPF Whitelist",
- 'spf-whitelist-text' => "Le Sender Policy Framework permet à un domaine expéditeur de dévoiler, par le biais de DNS,"
- . " quels systèmes sont autorisés à envoyer des e-mails avec une adresse d'expéditeur d'enveloppe dans le domaine en question.",
- 'spf-whitelist-ex' => "Vous pouvez ici spécifier une liste de serveurs autorisés, par exemple: <var>.ess.barracuda.com</var>.",
+ 'confirm' => "Confirmation du domaine",
+ 'confirm-dns' => "Le domaine <b>doit avoir l'une des entrées suivantes</b> dans le DNS:",
+ 'confirm-dns-txt' => "Entrée TXT avec valeur:",
+ 'confirm-dns-cname' => "ou entrée CNAME:",
+ 'confirm-sample' => "Voici un fichier de zone simple pour votre domaine:",
'create' => "Créer domaine",
- 'new' => "Nouveau domaine",
'delete' => "Supprimer domaine",
'delete-domain' => "Supprimer {domain}",
'delete-text' => "Voulez-vous vraiment supprimer ce domaine de façon permanente?"
. " Ceci n'est possible que s'il n'y a pas d'utilisateurs, d'alias ou d'autres objets dans ce domaine."
. " Veuillez noter que cette action ne peut pas être inversée.",
+ 'list-empty' => "Il y a pas de domaines dans ce compte.",
+ 'namespace' => "Espace de noms",
+ 'spf-whitelist' => "SPF Whitelist",
+ 'spf-whitelist-text' => "Le Sender Policy Framework permet à un domaine expéditeur de dévoiler, par le biais de DNS,"
+ . " quels systèmes sont autorisés à envoyer des e-mails avec une adresse d'expéditeur d'enveloppe dans le domaine en question.",
+ 'spf-whitelist-ex' => "Vous pouvez ici spécifier une liste de serveurs autorisés, par exemple: <var>.ess.barracuda.com</var>.",
+ 'new' => "Nouveau domaine",
],
'error' => [
'400' => "Mauvaide demande",
'401' => "Non autorisé",
'403' => "Accès refusé",
'404' => "Pas trouvé",
'405' => "Méthode non autorisée",
'500' => "Erreur de serveur interne",
'unknown' => "Erreur inconnu",
'server' => "Erreur de serveur",
'form' => "Erreur de validation du formulaire",
],
'form' => [
'acl' => "Droits d'accès",
'acl-full' => "Tout",
'acl-read-only' => "Lecture seulement",
'acl-read-write' => "Lecture-écriture",
'amount' => "Montant",
'anyone' => "Chacun",
'code' => "Le code de confirmation",
'config' => "Configuration",
'date' => "Date",
'description' => "Description",
'details' => "Détails",
'domain' => "Domaine",
'email' => "Adresse e-mail",
'firstname' => "Prénom",
'lastname' => "Nom de famille",
'none' => "aucun",
'or' => "ou",
'password' => "Mot de passe",
'password-confirm' => "Confirmer le mot de passe",
'phone' => "Téléphone",
'shared-folder' => "Dossier partagé",
'status' => "État",
'subscriptions' => "Subscriptions",
'surname' => "Nom de famille",
'type' => "Type",
'user' => "Utilisateur",
'primary-email' => "Email principal",
'id' => "ID",
'created' => "Créé",
'deleted' => "Supprimé",
'disabled' => "Désactivé",
'enabled' => "Activé",
'general' => "Général",
'settings' => "Paramètres",
],
'invitation' => [
'create' => "Créez des invitation(s)",
'create-title' => "Invitation à une inscription",
'create-email' => "Saisissez l'adresse électronique de la personne que vous souhaitez inviter.",
'create-csv' => "Pour envoyer plusieurs invitations à la fois, fournissez un fichier CSV (séparé par des virgules) ou un fichier en texte brut, contenant une adresse e-mail par ligne.",
'list-empty' => "Il y a aucune invitation dans la mémoire de données.",
'title' => "Invitation d'inscription",
'search' => "Adresse E-mail ou domaine",
'send' => "Envoyer invitation(s)",
'status-completed' => "Utilisateur s'est inscrit",
'status-failed' => "L'envoi a échoué",
'status-sent' => "Envoyé",
'status-new' => "Pas encore envoyé",
],
'lang' => [
'en' => "Anglais",
'de' => "Allemand",
'fr' => "Français",
'it' => "Italien",
],
'login' => [
'2fa' => "Code du 2ème facteur",
'2fa_desc' => "Le code du 2ème facteur est facultatif pour les utilisateurs qui n'ont pas configuré l'authentification à deux facteurs.",
'forgot_password' => "Mot de passe oublié?",
'header' => "Veuillez vous connecter",
'sign_in' => "Se connecter",
'webmail' => "Webmail"
],
'meet' => [
'welcome' => "Bienvenue dans notre programme bêta pour les conférences vocales et vidéo.",
'notice' => "Il s'agit d'un travail en évolution et d'autres fonctions seront ajoutées au fil du temps. Les fonctions actuelles sont les suivantes:",
'sharing' => "Partage d'écran",
'sharing-text' => "Partagez votre écran pour des présentations ou des exposés.",
'security' => "sécurité de chambre",
'security-text' => "Renforcez la sécurité de la salle en définissant un mot de passe que les participants devront connaître."
. " avant de pouvoir entrer, ou verrouiller la porte afin que les participants doivent frapper, et un modérateur peut accepter ou refuser ces demandes.",
'qa-title' => "Lever la main (Q&A)",
'qa-text' => "Les membres du public silencieux peuvent lever la main pour animer une séance de questions-réponses avec les membres du panel.",
'moderation' => "Délégation des Modérateurs",
'moderation-text' => "Déléguer l'autorité du modérateur pour la séance, afin qu'un orateur ne soit pas inutilement"
. " interrompu par l'arrivée des participants et d'autres tâches du modérateur.",
'eject' => "Éjecter les participants",
'eject-text' => "Éjectez les participants de la session afin de les obliger à se reconnecter ou de remédier aux violations des règles."
. " Cliquez sur l'icône de l'utilisateur pour un renvoi effectif.",
'silent' => "Membres du Public en Silence",
'silent-text' => "Pour une séance de type webinaire, configurez la salle pour obliger tous les nouveaux participants à être des spectateurs silencieux.",
'interpreters' => "Canaux d'Audio Spécifiques de Langues",
'interpreters-text' => "Désignez un participant pour interpréter l'audio original dans une langue cible, pour les sessions avec des participants multilingues."
. " L'interprète doit être capable de relayer l'audio original et de le remplacer.",
'beta-notice' => "Rappelez-vous qu'il s'agit d'une version bêta et pourrait entraîner des problèmes."
. " Au cas où vous rencontreriez des problèmes, n'hésitez pas à nous en faire part en contactant le support.",
// Room options dialog
'options' => "Options de salle",
'password' => "Mot de passe",
'password-none' => "aucun",
'password-clear' => "Effacer mot de passe",
'password-set' => "Définir le mot de passe",
'password-text' => "Vous pouvez ajouter un mot de passe à votre session. Les participants devront fournir le mot de passe avant d'être autorisés à rejoindre la session.",
'lock' => "Salle verrouillée",
'lock-text' => "Lorsque la salle est verrouillée, les participants doivent être approuvés par un modérateur avant de pouvoir rejoindre la réunion.",
'nomedia' => "Réservé aux abonnés",
'nomedia-text' => "Force tous les participants à se joindre en tant qu'abonnés (avec caméra et microphone désactivés)"
. "Les modérateurs pourront les promouvoir en tant qu'éditeurs tout au long de la session.",
// Room menu
'partcnt' => "Nombres de participants",
'menu-audio-mute' => "Désactiver le son",
'menu-audio-unmute' => "Activer le son",
'menu-video-mute' => "Désactiver la vidéo",
'menu-video-unmute' => "Activer la vidéo",
'menu-screen' => "Partager l'écran",
'menu-hand-lower' => "Baisser la main",
'menu-hand-raise' => "Lever la main",
'menu-channel' => "Canal de langue interprétée",
'menu-chat' => "Le Chat",
'menu-fullscreen' => "Plein écran",
'menu-fullscreen-exit' => "Sortir en plein écran",
'menu-leave' => "Quitter la session",
// Room setup screen
'setup-title' => "Préparez votre session",
'mic' => "Microphone",
'cam' => "Caméra",
'nick' => "Surnom",
'nick-placeholder' => "Votre nom",
'join' => "JOINDRE",
'joinnow' => "JOINDRE MAINTENANT",
'imaowner' => "Je suis le propriétaire",
// Room
'qa' => "Q & A",
'leave-title' => "Salle fermée",
'leave-body' => "La session a été fermée par le propriétaire de la salle.",
'media-title' => "Configuration des médias",
'join-request' => "Demande de rejoindre",
'join-requested' => "{user} demandé à rejoindre.",
// Status messages
'status-init' => "Vérification de la salle...",
'status-323' => "La salle est fermée. Veuillez attendre le démarrage de la session par le propriétaire.",
'status-324' => "La salle est fermée. Elle sera ouverte aux autres participants après votre adhésion.",
'status-325' => "La salle est prête. Veuillez entrer un mot de passe valide.",
'status-326' => "La salle est fermée. Veuillez entrer votre nom et réessayer.",
'status-327' => "En attendant la permission de joindre la salle.",
'status-404' => "La salle n'existe pas.",
'status-429' => "Trop de demande. Veuillez, patienter.",
'status-500' => "La connexion à la salle a échoué. Erreur de serveur.",
// Other menus
'media-setup' => "configuration des médias",
'perm' => "Permissions",
'perm-av' => "Publication d'audio et vidéo",
'perm-mod' => "Modération",
'lang-int' => "Interprète de langue",
'menu-options' => "Options",
],
'menu' => [
'cockpit' => "Cockpit",
'login' => "Connecter",
'logout' => "Deconnecter",
'signup' => "S'inscrire",
'toggle' => "Basculer la navigation",
],
'msg' => [
'initializing' => "Initialisation...",
'loading' => "Chargement...",
'loading-failed' => "Échec du chargement des données.",
'notfound' => "Resource introuvable.",
'info' => "Information",
'error' => "Erreur",
'warning' => "Avertissement",
'success' => "Succès",
],
'nav' => [
'more' => "Charger plus",
'step' => "Étape {i}/{n}",
],
'password' => [
'reset' => "Réinitialiser le mot de passe",
'reset-step1' => "Entrez votre adresse e-mail pour réinitialiser votre mot de passe.",
'reset-step1-hint' => "Veuillez vérifier votre dossier de spam ou débloquer {email}.",
'reset-step2' => "Nous avons envoyé un code de confirmation à votre adresse e-mail externe."
. " Entrez le code que nous vous avons envoyé, ou cliquez sur le lien dans le message.",
],
'resource' => [
'create' => "Créer une ressource",
'delete' => "Supprimer une ressource",
'invitation-policy' => "Procédure d'invitation",
'invitation-policy-text' => "Les invitations à des événements pour une ressource sont généralement acceptées automatiquement"
. " si aucun événement n'est en conflit avec le temps demandé. La procédure d'invitation le permet"
. " de rejeter ces demandes ou d'exiger une acceptation manuelle d'un utilisateur spécifique.",
'ipolicy-manual' => "Manuel (provisoire)",
'ipolicy-accept' => "Accepter",
'ipolicy-reject' => "Rejecter",
'list-title' => "Ressource | Ressources",
'list-empty' => "Il y a aucune ressource sur ce compte.",
'new' => "Nouvelle ressource",
],
'shf' => [
'create' => "Créer un dossier",
'delete' => "Supprimer un dossier",
'acl-text' => "Permet de définir les droits d'accès des utilisateurs au dossier partagé..",
'list-title' => "Dossier partagé | Dossiers partagés",
'list-empty' => "Il y a aucun dossier partagé dans ce compte.",
'new' => "Nouvelle dossier",
'type-mail' => "Courriel",
'type-event' => "Calendrier",
'type-contact' => "Carnet d'Adresses",
'type-task' => "Tâches",
'type-note' => "Notes",
'type-file' => "Fichiers",
],
'signup' => [
'email' => "Adresse e-mail actuelle",
'login' => "connecter",
'title' => "S'inscrire",
'step1' => "Inscrivez-vous pour commencer votre mois gratuit.",
'step2' => "Nous avons envoyé un code de confirmation à votre adresse e-mail. Entrez le code que nous vous avons envoyé, ou cliquez sur le lien dans le message.",
'step3' => "Créez votre identité {app} (vous pourrez choisir des adresses supplémentaires plus tard).",
'voucher' => "Coupon Code",
],
'status' => [
'prepare-account' => "Votre compte est en cours de préparation.",
'prepare-domain' => "Le domain est en cours de préparation.",
'prepare-distlist' => "La liste de distribution est en cours de préparation.",
'prepare-shared-folder' => "Le dossier portagé est en cours de préparation.",
'prepare-user' => "Le compte d'utilisateur est en cours de préparation.",
'prepare-hint' => "Certaines fonctionnalités peuvent être manquantes ou en lecture seule pour le moment.",
'prepare-refresh' => "Le processus ne se termine jamais? Appuyez sur le bouton \"Refresh\", s'il vous plaît.",
'prepare-resource' => "Nous préparons la ressource.",
'ready-account' => "Votre compte est presque prêt.",
'ready-domain' => "Le domaine est presque prêt.",
'ready-distlist' => "La liste de distribution est presque prête.",
'ready-resource' => "La ressource est presque prête.",
'ready-shared-folder' => "Le dossier partagé est presque prêt.",
'ready-user' => "Le compte d'utilisateur est presque prêt.",
- 'verify' => "Veuillez vérifier votre domaine pour terminer le processus de configuration.",
- 'verify-domain' => "Vérifier domaine",
'degraded' => "Dégradé",
'deleted' => "Supprimé",
'suspended' => "Suspendu",
'notready' => "Pas Prêt",
'active' => "Actif",
],
'support' => [
'title' => "Contacter Support",
'id' => "Numéro de client ou adresse é-mail que vous avez chez nous.",
'id-hint' => "Laissez vide si vous n'êtes pas encore client",
'name' => "Nom",
'name-pl' => "comment nous devons vous adresser dans notre réponse",
'email' => "adresse e-mail qui fonctionne",
'email-pl' => "assurez-vous que nous pouvons vous atteindre à cette adresse",
'summary' => "Résumé du problème",
'summary-pl' => "une phrase qui résume votre situation",
'expl' => "Analyse du problème",
],
'user' => [
'2fa-hint1' => "Cela éliminera le droit à l'authentification à 2-Facteurs ainsi que les éléments configurés par l'utilisateur.",
'2fa-hint2' => "Veuillez vous assurer que l'identité de l'utilisateur est correctement confirmée.",
'add-beta' => "Activer le programme bêta",
'address' => "Adresse",
'aliases' => "Alias",
'aliases-none' => "Cet utilisateur n'aucune alias e-mail.",
'add-bonus' => "Ajouter un bonus",
'add-bonus-title' => "Ajouter un bonus au portefeuille",
'add-penalty' => "Ajouter une pénalité",
'add-penalty-title' => "Ajouter une pénalité au portefeuille",
'auto-payment' => "Auto-paiement",
'auto-payment-text' => "Recharger par <b>{amount}</b> quand le montant est inférieur à <b>{balance}</b> utilisant {method}",
'country' => "Pays",
'create' => "Créer un utilisateur",
'custno' => "No. de Client.",
'degraded-warning' => "Le compte est dégradé. Certaines fonctionnalités ont été désactivées.",
'degraded-hint' => "Veuillez effectuer un paiement.",
'delete' => "Supprimer Utilisateur",
'delete-email' => "Supprimer {email}",
'delete-text' => "Voulez-vous vraiment supprimer cet utilisateur de façon permanente?"
. " Cela supprimera toutes les données du compte et retirera la permission d'accéder au compte d'e-email."
. " Veuillez noter que cette action ne peut pas être révoquée.",
'discount' => "Rabais",
'discount-hint' => "rabais appliqué",
'discount-title' => "Rabais de compte",
'distlists' => "Listes de Distribution",
'domains' => "Domaines",
'email-aliases' => "Alias E-mail",
'ext-email' => "E-mail externe",
'finances' => "Finances",
'greylisting' => "Greylisting",
'greylisting-text' => "La greylisting est une méthode de défense des utilisateurs contre le spam."
. " Tout e-mail entrant provenant d'un expéditeur non reconnu est temporairement rejeté."
. " Le serveur d'origine doit réessayer après un délai cette fois-ci, le mail sera accepté."
. " Les spammeurs ne réessayent généralement pas de remettre le mail.",
'list-title' => "Comptes d'utilisateur",
'list-empty' => "Il n'y a aucun utilisateur dans ce compte.",
'managed-by' => "Géré par",
'new' => "Nouveau compte d'utilisateur",
'org' => "Organisation",
'package' => "Paquet",
'price' => "Prix",
'profile-delete' => "Supprimer compte",
'profile-delete-title' => "Supprimer ce compte?",
'profile-delete-text1' => "Cela supprimera le compte ainsi que tous les domaines, utilisateurs et alias associés à ce compte.",
'profile-delete-warning' => "Cette opération est irrévocable",
'profile-delete-text2' => "Comme vous ne pourrez plus rien récupérer après ce point, assurez-vous d'avoir migré toutes les données avant de poursuivre.",
'profile-delete-support' => "Étant donné que nous nous attachons à toujours nous améliorer, nous aimerions vous demander 2 minutes de votre temps. "
. "Le meilleur moyen de nous améliorer est le feedback des utilisateurs, et nous voudrions vous demander"
. "quelques mots sur les raisons pour lesquelles vous avez quitté notre service. Veuillez envoyer vos commentaires au <a href=\"{href}\">{email}</a>.",
'profile-delete-contact' => "Par ailleurs, n'hésitez pas à contacter le support de {app} pour toute question ou souci que vous pourriez avoir dans ce contexte.",
'reset-2fa' => "Réinitialiser l'authentification à 2-Facteurs.",
'reset-2fa-title' => "Réinitialisation de l'Authentification à 2-Facteurs",
'resources' => "Ressources",
'title' => "Compte d'utilisateur",
'search' => "Adresse e-mail ou nom de l'utilisateur",
'search-pl' => "ID utilisateur, e-mail ou domamine",
'skureq' => "{sku} demande {list}.",
'subscription' => "Subscription",
'subscriptions-none' => "Cet utilisateur n'a pas de subscriptions.",
'users' => "Utilisateurs",
],
'wallet' => [
'add-credit' => "Ajouter un crédit",
'auto-payment-cancel' => "Annuler l'auto-paiement",
'auto-payment-change' => "Changer l'auto-paiement",
'auto-payment-failed' => "La configuration des paiements automatiques a échoué. Redémarrer le processus pour activer les top-ups automatiques.",
'auto-payment-hint' => "Cela fonctionne de la manière suivante: Chaque fois que votre compte est épuisé, nous débiterons votre méthode de paiement préférée d'un montant que vous aurez défini."
. " Vous pouvez annuler ou modifier l'option de paiement automatique à tout moment.",
'auto-payment-setup' => "configurer l'auto-paiement",
'auto-payment-disabled' => "L'auto-paiement configuré a été désactivé. Rechargez votre porte-monnaie ou augmentez le montant d'auto-paiement.",
'auto-payment-info' => "L'auto-paiement est <b>set</b> pour recharger votre compte par <b>{amount}</b> lorsque le solde de votre compte devient inférieur à <b>{balance}</b>.",
'auto-payment-inprogress' => "La configuration d'auto-paiement est toujours en cours.",
'auto-payment-next' => "Ensuite, vous serez redirigé vers la page de paiement, où vous pourrez fournir les coordonnées de votre carte de crédit.",
'auto-payment-disabled-next' => "L'auto-paiement est désactivé. Dès que vous aurez soumis de nouveaux paramètres, nous l'activerons et essaierons de recharger votre portefeuille.",
'auto-payment-update' => "Mise à jour de l'auto-paiement.",
'banktransfer-hint' => "Veuillez noter qu'un virement bancaire peut nécessiter plusieurs jours avant d'être effectué.",
'currency-conv' => "Le principe est le suivant: Vous spécifiez le montant dont vous voulez recharger votre portefeuille en {wc}."
. " Nous convertirons ensuite ce montant en {pc}, et sur la page suivante, vous obtiendrez les coordonnées bancaires pour transférer le montant en {pc}.",
'fill-up' => "Recharger par",
'history' => "Histoire",
'month' => "mois",
'noperm' => "Seuls les propriétaires de compte peuvent accéder à un portefeuille.",
'payment-amount-hint' => "Choisissez le montant dont vous voulez recharger votre portefeuille.",
'payment-method' => "Mode de paiement: {method}",
'payment-warning' => "Vous serez facturé pour {price}.",
'pending-payments' => "Paiements en attente",
'pending-payments-warning' => "Vous avez des paiements qui sont encore en cours. Voir l'onglet \"Paiements en attente\" ci-dessous.",
'pending-payments-none' => "Il y a aucun paiement en attente pour ce compte.",
'receipts' => "Reçus",
'receipts-hint' => "Vous pouvez télécharger ici les reçus (au format PDF) pour les paiements de la période spécifiée. Sélectionnez la période et appuyez sur le bouton Télécharger.",
'receipts-none' => "Il y a aucun reçu pour les paiements de ce compte. Veuillez noter que vous pouvez télécharger les reçus après la fin du mois.",
'title' => "Solde du compte",
'top-up' => "Rechargez votre portefeuille",
'transactions' => "Transactions",
'transactions-none' => "Il y a aucun transaction pour ce compte.",
'when-below' => "lorsque le solde du compte est inférieur à",
],
];
diff --git a/src/resources/vue/Admin/Domain.vue b/src/resources/vue/Admin/Domain.vue
index 5be2d9e7..6ac4f19a 100644
--- a/src/resources/vue/Admin/Domain.vue
+++ b/src/resources/vue/Admin/Domain.vue
@@ -1,107 +1,107 @@
<template>
<div v-if="domain" class="container">
<div class="card" id="domain-info">
<div class="card-body">
<div class="card-title">{{ domain.namespace }}</div>
<div class="card-text">
<form class="read-only short">
<div class="row plaintext">
<label for="domainid" class="col-sm-4 col-form-label">
{{ $t('form.id') }} <span class="text-muted">({{ $t('form.created') }})</span>
</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="domainid">
{{ domain.id }} <span class="text-muted">({{ domain.created_at }})</span>
</span>
</div>
</div>
<div class="row plaintext">
<label for="first_name" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="status">
<span :class="$root.statusClass(domain)">{{ $root.statusText(domain) }}</span>
</span>
</div>
</div>
</form>
<div class="mt-2 buttons">
<btn v-if="!domain.isSuspended" id="button-suspend" class="btn-warning" @click="suspendDomain">
{{ $t('btn.suspend') }}
</btn>
<btn v-if="domain.isSuspended" id="button-unsuspend" class="btn-warning" @click="unsuspendDomain">
{{ $t('btn.unsuspend') }}
</btn>
</div>
</div>
</div>
</div>
<tabs class="mt-3" :tabs="['form.config', 'form.settings']"></tabs>
<div class="tab-content">
<div class="tab-pane show active" id="config" role="tabpanel" aria-labelledby="tab-config">
<div class="card-body">
<div class="card-text">
- <p>{{ $t('domain.dns-verify') }}</p>
- <p><pre id="dns-verify">{{ domain.dns.join("\n") }}</pre></p>
+ <p>{{ $t('domain.dns-confirm') }}</p>
+ <p><pre id="dns-confirm">{{ domain.dns.join("\n") }}</pre></p>
<p>{{ $t('domain.dns-config') }}</p>
<p><pre id="dns-config">{{ domain.mx.join("\n") }}</pre></p>
</div>
</div>
</div>
<div class="tab-pane" id="settings" role="tabpanel" aria-labelledby="tab-settings">
<div class="card-body">
<div class="card-text">
<form class="read-only short">
<div class="row plaintext">
<label for="spf_whitelist" class="col-sm-4 col-form-label">{{ $t('domain.spf-whitelist') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="spf_whitelist">
{{ domain.config && domain.config.spf_whitelist.length ? domain.config.spf_whitelist.join(', ') : $t('form.none') }}
</span>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
domain: null
}
},
created() {
const domain_id = this.$route.params.domain;
axios.get('/api/v4/domains/' + domain_id)
.then(response => {
this.domain = response.data
})
.catch(this.$root.errorHandler)
},
methods: {
suspendDomain() {
axios.post('/api/v4/domains/' + this.domain.id + '/suspend')
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.domain = Object.assign({}, this.domain, { isSuspended: true })
}
})
},
unsuspendDomain() {
axios.post('/api/v4/domains/' + this.domain.id + '/unsuspend')
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.domain = Object.assign({}, this.domain, { isSuspended: false })
}
})
}
}
}
</script>
diff --git a/src/resources/vue/Domain/Info.vue b/src/resources/vue/Domain/Info.vue
index dcc27e67..9dce0e0f 100644
--- a/src/resources/vue/Domain/Info.vue
+++ b/src/resources/vue/Domain/Info.vue
@@ -1,196 +1,196 @@
<template>
<div class="container">
<status-component v-if="domain_id !== 'new'" :status="status" @status-update="statusUpdate"></status-component>
<div class="card">
<div class="card-body">
<div class="card-title" v-if="domain_id === 'new'">{{ $t('domain.new') }}</div>
<div class="card-title" v-else>{{ $t('form.domain') }}
<btn class="btn-outline-danger button-delete float-end" @click="$refs.deleteDialog.show()" icon="trash-can">{{ $t('domain.delete') }}</btn>
</div>
<div class="card-text">
<tabs class="mt-3" :tabs="domain_id === 'new' ? ['form.general'] : ['form.general','form.settings']"></tabs>
<div class="tab-content">
<div class="tab-pane show active" id="general" role="tabpanel" aria-labelledby="tab-general">
<form @submit.prevent="submit" class="card-body">
<div v-if="domain.id" class="row plaintext mb-3">
<label for="status" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label>
<div class="col-sm-8">
<span :class="$root.statusClass(domain) + ' form-control-plaintext'" id="status">{{ $root.statusText(domain) }}</span>
</div>
</div>
<div class="row mb-3">
<label for="name" class="col-sm-4 col-form-label">{{ $t('domain.namespace') }}</label>
<div class="col-sm-8">
<input type="text" class="form-control" id="namespace" v-model="domain.namespace" :disabled="domain.id">
</div>
</div>
<div v-if="!domain.id" id="domain-packages" class="row">
<label class="col-sm-4 col-form-label">{{ $t('user.package') }}</label>
<package-select class="col-sm-8 pt-sm-1" type="domain"></package-select>
</div>
<div v-if="$root.hasPermission('subscriptions') && domain.id" id="domain-skus" class="row">
<label class="col-sm-4 col-form-label">{{ $t('form.subscriptions') }}</label>
<subscription-select v-if="domain.id" class="col-sm-8 pt-sm-1" ref="skus" type="domain" :object="domain" :readonly="true"></subscription-select>
</div>
<btn v-if="!domain.id" class="btn-primary mt-3" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
</form>
<hr class="m-0" v-if="domain.id">
- <div v-if="domain.id && !domain.isConfirmed" class="card-body" id="domain-verify">
- <h5 class="mb-3">{{ $t('domain.verify') }}</h5>
+ <div v-if="domain.id && !domain.isConfirmed" class="card-body" id="domain-confirm">
+ <h5 class="mb-3">{{ $t('domain.confirm') }}</h5>
<div class="card-text">
- <p>{{ $t('domain.verify-intro') }}</p>
+ <p>{{ $t('domain.confirm-intro') }}</p>
<p>
- <span v-html="$t('domain.verify-dns')"></span>
+ <span v-html="$t('domain.confirm-dns')"></span>
<ul>
- <li>{{ $t('domain.verify-dns-txt') }} <code>{{ domain.hash_text }}</code></li>
- <li>{{ $t('domain.verify-dns-cname') }} <code>{{ domain.hash_cname }}.{{ domain.namespace }}. IN CNAME {{ domain.hash_code }}.{{ domain.namespace }}.</code></li>
+ <li>{{ $t('domain.confirm-dns-txt') }} <code>{{ domain.hash_text }}</code></li>
+ <li>{{ $t('domain.confirm-dns-cname') }} <code>{{ domain.hash_cname }}.{{ domain.namespace }}. IN CNAME {{ domain.hash_code }}.{{ domain.namespace }}.</code></li>
</ul>
- <span>{{ $t('domain.verify-outro') }}</span>
+ <span>{{ $t('domain.confirm-outro') }}</span>
</p>
- <p>{{ $t('domain.verify-sample') }} <pre>{{ domain.dns.join("\n") }}</pre></p>
- <btn class="btn-primary" @click="confirm" icon="rotate">{{ $t('btn.verify') }}</btn>
+ <p>{{ $t('domain.confirm-sample') }} <pre>{{ domain.dns.join("\n") }}</pre></p>
+ <btn class="btn-primary" @click="confirm" icon="rotate">{{ $t('btn.confirm') }}</btn>
</div>
</div>
<div v-if="domain.isConfirmed" class="card-body" id="domain-config">
<h5 class="mb-3">{{ $t('domain.config') }}</h5>
<div class="card-text">
<p>{{ $t('domain.config-intro', { app: $root.appName }) }}</p>
<p>{{ $t('domain.config-sample') }} <pre>{{ domain.mx.join("\n") }}</pre></p>
<p>{{ $t('domain.config-hint') }}</p>
</div>
</div>
</div>
<div class="tab-pane" id="settings" role="tabpanel" aria-labelledby="tab-settings">
<div class="card-body">
<form @submit.prevent="submitSettings">
<div class="row mb-3">
<label for="spf_whitelist" class="col-sm-4 col-form-label">{{ $t('domain.spf-whitelist') }}</label>
<div class="col-sm-8">
<list-input id="spf_whitelist" name="spf_whitelist" :list="spf_whitelist"></list-input>
<small id="spf-hint" class="text-muted d-block mt-2">
{{ $t('domain.spf-whitelist-text') }}
<span class="d-block" v-html="$t('domain.spf-whitelist-ex')"></span>
</small>
</div>
</div>
<btn class="btn-primary" type="submit" icon="check">{{ $t('btn.submit') }}</btn>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
<modal-dialog id="delete-warning" ref="deleteDialog" @click="deleteDomain()" :buttons="['delete']" :cancel-focus="true"
:title="$t('domain.delete-domain', { domain: domain.namespace })"
>
<p>{{ $t('domain.delete-text') }}</p>
</modal-dialog>
</div>
</template>
<script>
import ListInput from '../Widgets/ListInput'
import ModalDialog from '../Widgets/ModalDialog'
import PackageSelect from '../Widgets/PackageSelect'
import StatusComponent from '../Widgets/Status'
import SubscriptionSelect from '../Widgets/SubscriptionSelect'
import { library } from '@fortawesome/fontawesome-svg-core'
library.add(
require('@fortawesome/free-solid-svg-icons/faRotate').definition,
)
export default {
components: {
ListInput,
ModalDialog,
PackageSelect,
StatusComponent,
SubscriptionSelect
},
data() {
return {
domain_id: null,
domain: {},
spf_whitelist: [],
status: {}
}
},
created() {
this.domain_id = this.$route.params.domain
if (this.domain_id !== 'new') {
axios.get('/api/v4/domains/' + this.domain_id, { loader: true })
.then(response => {
this.domain = response.data
this.spf_whitelist = this.domain.config.spf_whitelist || []
if (!this.domain.isConfirmed) {
- $('#domain-verify button').focus()
+ $('#domain-confirm button').focus()
}
this.status = response.data.statusInfo
})
.catch(this.$root.errorHandler)
}
},
mounted() {
$('#namespace').focus()
},
methods: {
confirm() {
axios.get('/api/v4/domains/' + this.domain_id + '/confirm')
.then(response => {
if (response.data.status == 'success') {
this.domain.isConfirmed = true
this.status = response.data.statusInfo
}
if (response.data.message) {
this.$toast[response.data.status](response.data.message)
}
})
},
deleteDomain() {
// Delete the domain from the confirm dialog
axios.delete('/api/v4/domains/' + this.domain_id)
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.$router.push({ name: 'domains' })
}
})
},
statusUpdate(domain) {
this.domain = Object.assign({}, this.domain, domain)
},
submit() {
this.$root.clearFormValidation($('#general form'))
let post = this.$root.pick(this.domain, ['namespace'])
post.package = $('#domain-packages input:checked').val()
axios.post('/api/v4/domains', post)
.then(response => {
this.$toast.success(response.data.message)
this.$router.push({ name: 'domains' })
})
},
submitSettings() {
this.$root.clearFormValidation($('#settings form'))
const post = this.$root.pick(this, ['spf_whitelist'])
axios.post('/api/v4/domains/' + this.domain_id + '/config', post)
.then(response => {
this.$toast.success(response.data.message)
})
}
}
}
</script>
diff --git a/src/resources/vue/Widgets/Status.vue b/src/resources/vue/Widgets/Status.vue
index 79688ea1..20c7725b 100644
--- a/src/resources/vue/Widgets/Status.vue
+++ b/src/resources/vue/Widgets/Status.vue
@@ -1,204 +1,204 @@
<template>
<div v-if="!state.isDone" id="status-box" :class="'p-4 mb-3 rounded process-' + className">
<div v-if="state.step != 'domain-confirmed'" class="d-flex align-items-start">
<p id="status-body" class="flex-grow-1">
<span>{{ $t('status.prepare-' + scopeLabel()) }}</span>
<br>
{{ $t('status.prepare-hint') }}
<br>
<span id="refresh-text" v-if="refresh">{{ $t('status.prepare-refresh') }}</span>
</p>
<btn v-if="refresh" id="status-refresh" href="#" class="btn-secondary" @click="statusRefresh" icon="rotate">
{{ $t('btn.refresh') }}
</btn>
</div>
<div v-else class="d-flex align-items-start">
<p id="status-body" class="flex-grow-1">
<span>{{ $t('status.ready-' + scopeLabel()) }}</span>
<br>
- {{ $t('status.verify') }}
+ {{ $t('status.confirm') }}
</p>
<div v-if="scope == 'domain'">
- <btn id="status-verify" class="btn-secondary text-nowrap" @click="confirmDomain" icon="rotate">
- {{ $t('btn.verify') }}
+ <btn id="status-confirm" class="btn-secondary text-nowrap" @click="confirmDomain" icon="rotate">
+ {{ $t('btn.confirm') }}
</btn>
</div>
<div v-else-if="state.link && scope != 'domain'">
- <router-link id="status-link" class="btn btn-secondary" :to="{ path: state.link }">{{ $t('status.verify-domain') }}</router-link>
+ <router-link id="status-link" class="btn btn-secondary" :to="{ path: state.link }">{{ $t('status.confirm-domain') }}</router-link>
</div>
</div>
<div class="status-progress text-center">
<div class="progress">
<div class="progress-bar" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
</div>
<span class="progress-label">{{ state.title || $t('msg.initializing') }}</span>
</div>
</div>
</template>
<script>
import { library } from '@fortawesome/fontawesome-svg-core'
library.add(
require('@fortawesome/free-solid-svg-icons/faRotate').definition,
)
export default {
props: {
status: { type: Object, default: () => {} }
},
data() {
return {
className: 'pending',
refresh: false,
delay: 5000,
scope: 'user',
state: { isDone: true },
waiting: 0,
}
},
watch: {
// We use property watcher because parent component
// might set the property with a delay and we need to parse it
// FIXME: Problem with this and update-status event is that whenever
// we emit the event a watcher function is executed, causing
// duplicate parseStatusInfo() calls. Fortunaltely this does not
// cause duplicate http requests.
status: function (val, oldVal) {
this.parseStatusInfo(val)
}
},
destroyed() {
clearTimeout(window.infoRequest)
},
mounted() {
this.scope = this.$route.name
},
methods: {
// Displays account status information
parseStatusInfo(info) {
if (info) {
if (!info.isDone) {
let failedCount = 0
let allCount = info.process.length
info.process.forEach((step, idx) => {
if (!step.state) {
failedCount++
if (!info.title) {
info.title = step.title
info.step = step.label
info.link = step.link
}
}
})
info.percent = Math.floor((allCount - failedCount) / allCount * 100);
}
this.state = info || {}
this.$nextTick(function() {
$(this.$el).find('.progress-bar')
.css('width', info.percent + '%')
.attr('aria-valuenow', info.percent)
})
// Unhide the Refresh button, the process is in failure state
this.refresh = info.processState == 'failed' && this.waiting == 0
if (this.refresh || info.step == 'domain-confirmed') {
this.className = 'failed'
}
// A async job has been dispatched, switch to a waiting mode where
// we hide the Refresh button and pull status for about a minute,
// after that we switch to normal mode, i.e. user can Refresh again (if still not ready)
if (info.processState == 'waiting') {
this.waiting = 10
this.delay = 5000
} else if (this.waiting > 0) {
this.waiting -= 1
}
}
// Update status process info every 5,6,7,8,9,... seconds
clearTimeout(window.infoRequest)
if ((!this.refresh || this.waiting > 0) && (!info || !info.isDone)) {
window.infoRequest = setTimeout(() => {
delete window.infoRequest
// Stop updates after user logged out
if (!this.$root.authInfo) {
return;
}
axios.get(this.getUrl())
.then(response => {
this.parseStatusInfo(response.data)
this.emitEvent(response.data)
})
.catch(error => {
this.parseStatusInfo(info)
})
}, this.delay);
this.delay += 1000;
}
},
statusRefresh() {
clearTimeout(window.infoRequest)
axios.get(this.getUrl() + '?refresh=1')
.then(response => {
this.$toast[response.data.status](response.data.message)
this.parseStatusInfo(response.data)
this.emitEvent(response.data)
})
.catch(error => {
this.parseStatusInfo(this.state)
})
},
confirmDomain() {
axios.get('/api/v4/domains/' + this.$route.params.domain + '/confirm')
.then(response => {
if (response.data.message) {
this.$toast[response.data.status](response.data.message)
}
if (response.data.status == 'success') {
this.parseStatusInfo(response.data.statusInfo)
response.data.isConfirmed = true
this.emitEvent(response.data)
}
})
},
emitEvent(data) {
// Remove useless data and emit the event (to parent components)
delete data.status
delete data.message
this.$emit('status-update', data)
},
getUrl() {
let scope = this.scope
let id = this.$route.params[scope]
if (scope == 'dashboard') {
id = this.$root.authInfo.id
scope = 'user'
} else if (scope =='distlist') {
id = this.$route.params.list
scope = 'group'
} else if (scope == 'shared-folder') {
id = this.$route.params.folder
}
return '/api/v4/' + scope + 's/' + id + '/status'
},
scopeLabel() {
return this.scope == 'dashboard' ? 'account' : this.scope
}
}
}
</script>
diff --git a/src/tests/Browser/Admin/DomainTest.php b/src/tests/Browser/Admin/DomainTest.php
index dcd4aa51..b81a7a2d 100644
--- a/src/tests/Browser/Admin/DomainTest.php
+++ b/src/tests/Browser/Admin/DomainTest.php
@@ -1,159 +1,159 @@
<?php
namespace Tests\Browser\Admin;
use App\Domain;
use Tests\Browser;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Admin\Domain as DomainPage;
use Tests\Browser\Pages\Admin\User as UserPage;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
use Tests\TestCaseDusk;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class DomainTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('test1@domainscontroller.com');
$this->deleteTestDomain('domainscontroller.com');
self::useAdminUrl();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$domain = $this->getTestDomain('kolab.org');
$domain->setSetting('spf_whitelist', null);
$this->deleteTestUser('test1@domainscontroller.com');
$this->deleteTestDomain('domainscontroller.com');
parent::tearDown();
}
/**
* Test domain info page (unauthenticated)
*/
public function testDomainUnauth(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$domain = $this->getTestDomain('kolab.org');
$browser->visit('/domain/' . $domain->id)->on(new Home());
});
}
/**
* Test domain info page
*/
public function testDomainInfo(): void
{
$this->browse(function (Browser $browser) {
$domain = $this->getTestDomain('kolab.org');
$domain_page = new DomainPage($domain->id);
$john = $this->getTestUser('john@kolab.org');
$user_page = new UserPage($john->id);
$domain->setSetting('spf_whitelist', null);
// Goto the domain page
$browser->visit(new Home())
->submitLogon('jeroen@jeroen.jeroen', \App\Utils::generatePassphrase(), true)
->on(new Dashboard())
->visit($user_page)
->on($user_page)
->click('@nav #tab-domains')
->pause(1000)
->click('@user-domains table tbody tr:first-child td a');
$browser->on($domain_page)
->assertSeeIn('@domain-info .card-title', 'kolab.org')
->with('@domain-info form', function (Browser $browser) use ($domain) {
$browser->assertElementsCount('.row', 2)
->assertSeeIn('.row:nth-child(1) label', 'ID (Created)')
->assertSeeIn('.row:nth-child(1) #domainid', "{$domain->id} ({$domain->created_at})")
->assertSeeIn('.row:nth-child(2) label', 'Status')
->assertSeeIn('.row:nth-child(2) #status span.text-success', 'Active');
});
// Some tabs are loaded in background, wait a second
$browser->pause(500)
->assertElementsCount('@nav a', 2);
// Assert Configuration tab
$browser->assertSeeIn('@nav #tab-config', 'Configuration')
->with('@domain-config', function (Browser $browser) {
- $browser->assertSeeIn('pre#dns-verify', 'kolab-verify.kolab.org.')
+ $browser->assertSeeIn('pre#dns-confirm', 'kolab-verify.kolab.org.')
->assertSeeIn('pre#dns-config', 'kolab.org.');
});
// Assert Settings tab
$browser->assertSeeIn('@nav #tab-settings', 'Settings')
->click('@nav #tab-settings')
->with('@domain-settings form', function (Browser $browser) {
$browser->assertElementsCount('.row', 1)
->assertSeeIn('.row:first-child label', 'SPF Whitelist')
->assertSeeIn('.row:first-child .form-control-plaintext', 'none');
});
// Assert non-empty SPF whitelist
$domain->setSetting('spf_whitelist', json_encode(['.test1.com', '.test2.com']));
$browser->refresh()
->waitFor('@nav #tab-settings')
->click('@nav #tab-settings')
->with('@domain-settings form', function (Browser $browser) {
$browser->assertSeeIn('.row:first-child .form-control-plaintext', '.test1.com, .test2.com');
});
});
}
/**
* Test suspending/unsuspending a domain
*
* @depends testDomainInfo
*/
public function testSuspendAndUnsuspend(): void
{
$this->browse(function (Browser $browser) {
$sku_domain = \App\Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$user = $this->getTestUser('test1@domainscontroller.com');
$domain = $this->getTestDomain('domainscontroller.com', [
'status' => Domain::STATUS_NEW | Domain::STATUS_ACTIVE
| Domain::STATUS_LDAP_READY | Domain::STATUS_CONFIRMED
| Domain::STATUS_VERIFIED,
'type' => Domain::TYPE_EXTERNAL,
]);
\App\Entitlement::create([
'wallet_id' => $user->wallets()->first()->id,
'sku_id' => $sku_domain->id,
'entitleable_id' => $domain->id,
'entitleable_type' => Domain::class
]);
$browser->visit(new DomainPage($domain->id))
->assertVisible('@domain-info #button-suspend')
->assertMissing('@domain-info #button-unsuspend')
->click('@domain-info #button-suspend')
->assertToast(Toast::TYPE_SUCCESS, 'Domain suspended successfully.')
->assertSeeIn('@domain-info #status span.text-warning', 'Suspended')
->assertMissing('@domain-info #button-suspend')
->click('@domain-info #button-unsuspend')
->assertToast(Toast::TYPE_SUCCESS, 'Domain unsuspended successfully.')
->assertSeeIn('@domain-info #status span.text-success', 'Active')
->assertVisible('@domain-info #button-suspend')
->assertMissing('@domain-info #button-unsuspend');
});
}
}
diff --git a/src/tests/Browser/DomainTest.php b/src/tests/Browser/DomainTest.php
index 900aa59a..e044bf72 100644
--- a/src/tests/Browser/DomainTest.php
+++ b/src/tests/Browser/DomainTest.php
@@ -1,356 +1,356 @@
<?php
namespace Tests\Browser;
use App\Domain;
use App\User;
use Tests\Browser;
use Tests\Browser\Components\Dialog;
use Tests\Browser\Components\ListInput;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\DomainInfo;
use Tests\Browser\Pages\DomainList;
use Tests\Browser\Pages\Home;
use Tests\TestCaseDusk;
class DomainTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestDomain('testdomain.com');
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestDomain('testdomain.com');
parent::tearDown();
}
/**
* Test domain info page (unauthenticated)
*/
public function testDomainInfoUnauth(): void
{
// Test that the page requires authentication
$this->browse(function ($browser) {
$browser->visit('/domain/123')->on(new Home());
});
}
/**
* Test domains list page (unauthenticated)
*/
public function testDomainListUnauth(): void
{
// Test that the page requires authentication
$this->browse(function ($browser) {
$browser->visit('/domains')->on(new Home());
});
}
/**
* Test domain info page (non-existing domain id)
* @group skipci
*/
public function testDomainInfo404(): void
{
$this->browse(function ($browser) {
// FIXME: I couldn't make loginAs() method working
// Note: Here we're also testing that unauthenticated request
// is passed to logon form and then "redirected" to the requested page
$browser->visit('/domain/123')
->on(new Home())
->submitLogon('john@kolab.org', 'simple123')
->assertErrorPage(404);
});
}
/**
* Test domain info page (existing domain)
*
* @depends testDomainInfo404
* @group skipci
*/
public function testDomainInfo(): void
{
$this->browse(function ($browser) {
// Unconfirmed domain
$domain = Domain::where('namespace', 'kolab.org')->first();
if ($domain->isConfirmed()) {
$domain->status ^= Domain::STATUS_CONFIRMED;
$domain->save();
}
$domain->setSetting('spf_whitelist', \json_encode(['.test.com']));
$browser->visit('/domain/' . $domain->id)
->on(new DomainInfo())
->assertSeeIn('.card-title', 'Domain')
->whenAvailable('@general', function ($browser) use ($domain) {
$browser->assertSeeIn('form div:nth-child(1) label', 'Status')
->assertSeeIn('form div:nth-child(1) #status.text-danger', 'Not Ready')
->assertSeeIn('form div:nth-child(2) label', 'Name')
->assertValue('form div:nth-child(2) input:disabled', $domain->namespace)
->assertSeeIn('form div:nth-child(3) label', 'Subscriptions');
})
->whenAvailable('@general form div:nth-child(3) table', function ($browser) {
$browser->assertElementsCount('tbody tr', 1)
->assertVisible('tbody tr td.selection input:checked:disabled')
->assertSeeIn('tbody tr td.name', 'External Domain')
->assertSeeIn('tbody tr td.price', '0,00 CHF/month')
->assertTip(
'tbody tr td.buttons button',
'Host a domain that is externally registered'
);
})
- ->whenAvailable('@verify', function ($browser) use ($domain) {
+ ->whenAvailable('@confirm', function ($browser) use ($domain) {
$browser->assertSeeIn('pre', $domain->namespace)
->assertSeeIn('pre', $domain->hash())
->click('button')
- ->assertToast(Toast::TYPE_SUCCESS, 'Domain verified successfully.');
+ ->assertToast(Toast::TYPE_SUCCESS, 'Domain ownership confirmed successfully.');
// TODO: Test scenario when a domain confirmation failed
})
->whenAvailable('@config', function ($browser) use ($domain) {
$browser->assertSeeIn('pre', $domain->namespace);
})
->assertMissing('@general button[type=submit]')
- ->assertMissing('@verify');
+ ->assertMissing('@confirm');
// Check that confirmed domain page contains only the config box
$browser->visit('/domain/' . $domain->id)
->on(new DomainInfo())
- ->assertMissing('@verify')
+ ->assertMissing('@confirm')
->assertPresent('@config');
});
}
/**
* Test domain settings
* @group skipci
*/
public function testDomainSettings(): void
{
$this->browse(function ($browser) {
$domain = Domain::where('namespace', 'kolab.org')->first();
$domain->setSetting('spf_whitelist', \json_encode(['.test.com']));
$browser->visit('/domain/' . $domain->id)
->on(new DomainInfo())
->assertElementsCount('@nav a', 2)
->assertSeeIn('@nav #tab-general', 'General')
->assertSeeIn('@nav #tab-settings', 'Settings')
->click('@nav #tab-settings')
->with('#settings form', function (Browser $browser) {
// Test whitelist widget
$widget = new ListInput('#spf_whitelist');
$browser->assertSeeIn('div.row:nth-child(1) label', 'SPF Whitelist')
->assertVisible('div.row:nth-child(1) .list-input')
->with($widget, function (Browser $browser) {
$browser->assertListInputValue(['.test.com'])
->assertValue('@input', '')
->addListEntry('invalid domain');
})
->click('button[type=submit]')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->with($widget, function (Browser $browser) {
$err = 'The entry format is invalid. Expected a domain name starting with a dot.';
$browser->assertFormError(2, $err, false)
->removeListEntry(2)
->removeListEntry(1)
->addListEntry('.new.domain.tld');
})
->click('button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'Domain settings updated successfully.');
});
});
}
/**
* Test domains list page
*
* @depends testDomainListUnauth
* @group skipci
*/
public function testDomainList(): void
{
$this->browse(function ($browser) {
// Login the user
$browser->visit('/login')
->on(new Home())
->submitLogon('john@kolab.org', 'simple123', true)
// On dashboard click the "Domains" link
->on(new Dashboard())
->assertSeeIn('@links a.link-domains', 'Domains')
->click('@links a.link-domains')
// On Domains List page click the domain entry
->on(new DomainList())
->waitFor('@table tbody tr')
->assertVisible('@table tbody tr:first-child td:first-child svg.fa-globe.text-success')
->assertText('@table tbody tr:first-child td:first-child svg title', 'Active')
->assertSeeIn('@table tbody tr:first-child td:first-child', 'kolab.org')
->assertMissing('@table tfoot')
->click('@table tbody tr:first-child td:first-child a')
// On Domain Info page verify that's the clicked domain
->on(new DomainInfo())
->whenAvailable('@config', function ($browser) {
$browser->assertSeeIn('pre', 'kolab.org');
});
});
// TODO: Test domains list acting as Ned (John's "delegatee")
}
/**
* Test domains list page (user with no domains)
*/
public function testDomainListEmpty(): void
{
$this->browse(function ($browser) {
// Login the user
$browser->visit('/login')
->on(new Home())
->submitLogon('jack@kolab.org', 'simple123', true)
->on(new Dashboard())
->assertVisible('@links a.link-settings')
->assertMissing('@links a.link-domains')
->assertMissing('@links a.link-users')
->assertMissing('@links a.link-wallet');
/*
// On dashboard click the "Domains" link
->assertSeeIn('@links a.link-domains', 'Domains')
->click('@links a.link-domains')
// On Domains List page click the domain entry
->on(new DomainList())
->assertMissing('@table tbody')
->assertSeeIn('tfoot td', 'There are no domains in this account.');
*/
});
}
/**
* Test domain creation page
* @group skipci
*/
public function testDomainCreate(): void
{
$this->browse(function ($browser) {
$browser->visit('/login')
->on(new Home())
->submitLogon('john@kolab.org', 'simple123', true)
->visit('/domains')
->on(new DomainList())
->assertSeeIn('.card-title button.btn-success', 'Create domain')
->click('.card-title button.btn-success')
->on(new DomainInfo())
->assertSeeIn('.card-title', 'New domain')
->assertElementsCount('@nav li', 1)
->assertSeeIn('@nav li:first-child', 'General')
->whenAvailable('@general', function ($browser) {
$browser->assertSeeIn('form div:nth-child(1) label', 'Name')
->assertValue('form div:nth-child(1) input:not(:disabled)', '')
->assertFocused('form div:nth-child(1) input')
->assertSeeIn('form div:nth-child(2) label', 'Package')
->assertMissing('form div:nth-child(3)');
})
->whenAvailable('@general form div:nth-child(2) table', function ($browser) {
$browser->assertElementsCount('tbody tr', 1)
->assertVisible('tbody tr td.selection input:checked[readonly]')
->assertSeeIn('tbody tr td.name', 'Domain Hosting')
->assertSeeIn('tbody tr td.price', '0,00 CHF/month')
->assertTip(
'tbody tr td.buttons button',
'Use your own, existing domain.'
);
})
->assertSeeIn('@general button.btn-primary[type=submit]', 'Submit')
->assertMissing('@config')
- ->assertMissing('@verify')
+ ->assertMissing('@confirm')
->assertMissing('@settings')
->assertMissing('@status')
// Test error handling
->click('button[type=submit]')
->waitFor('#namespace + .invalid-feedback')
->assertSeeIn('#namespace + .invalid-feedback', 'The namespace field is required.')
->assertFocused('#namespace')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->type('@general form div:nth-child(1) input', 'testdomain..com')
->click('button[type=submit]')
->waitFor('#namespace + .invalid-feedback')
->assertSeeIn('#namespace + .invalid-feedback', 'The specified domain is invalid.')
->assertFocused('#namespace')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
// Test success
->type('@general form div:nth-child(1) input', 'testdomain.com')
->click('button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'Domain created successfully.')
->on(new DomainList())
->assertSeeIn('@table tr:nth-child(2) a', 'testdomain.com');
});
}
/**
* Test domain deletion
* @group skipci
*/
public function testDomainDelete(): void
{
// Create the domain to delete
$john = $this->getTestUser('john@kolab.org');
$domain = $this->getTestDomain('testdomain.com', ['type' => Domain::TYPE_EXTERNAL]);
$packageDomain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$domain->assignPackage($packageDomain, $john);
$this->browse(function ($browser) {
$browser->visit('/login')
->on(new Home())
->submitLogon('john@kolab.org', 'simple123')
->visit('/domains')
->on(new DomainList())
->assertElementsCount('@table tbody tr', 2)
->assertSeeIn('@table tr:nth-child(2) a', 'testdomain.com')
->click('@table tbody tr:nth-child(2) a')
->on(new DomainInfo())
->waitFor('button.button-delete')
->assertSeeIn('button.button-delete', 'Delete domain')
->click('button.button-delete')
->with(new Dialog('#delete-warning'), function ($browser) {
$browser->assertSeeIn('@title', 'Delete testdomain.com')
->assertFocused('@button-cancel')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Delete')
->click('@button-cancel');
})
->waitUntilMissing('#delete-warning')
->click('button.button-delete')
->with(new Dialog('#delete-warning'), function (Browser $browser) {
$browser->click('@button-action');
})
->waitUntilMissing('#delete-warning')
->assertToast(Toast::TYPE_SUCCESS, 'Domain deleted successfully.')
->on(new DomainList())
->assertElementsCount('@table tbody tr', 1);
// Test error handling on deleting a non-empty domain
$err = 'Unable to delete a domain with assigned users or other objects.';
$browser->click('@table tbody tr:nth-child(1) a')
->on(new DomainInfo())
->waitFor('button.button-delete')
->click('button.button-delete')
->with(new Dialog('#delete-warning'), function ($browser) {
$browser->click('@button-action');
})
->assertToast(Toast::TYPE_ERROR, $err);
});
}
}
diff --git a/src/tests/Browser/Pages/DomainInfo.php b/src/tests/Browser/Pages/DomainInfo.php
index 3448cd3d..b049dc87 100644
--- a/src/tests/Browser/Pages/DomainInfo.php
+++ b/src/tests/Browser/Pages/DomainInfo.php
@@ -1,48 +1,48 @@
<?php
namespace Tests\Browser\Pages;
use Laravel\Dusk\Page;
class DomainInfo extends Page
{
/**
* Get the URL for the page.
*
* @return string
*/
public function url(): string
{
return '';
}
/**
* Assert that the browser is on the page.
*
* @param \Laravel\Dusk\Browser $browser The browser object
*
* @return void
*/
public function assert($browser)
{
$browser->waitUntilMissing('@app .app-loader');
}
/**
* Get the element shortcuts for the page.
*
* @return array
*/
public function elements(): array
{
return [
'@app' => '#app',
'@config' => '#domain-config',
'@general' => '#general',
'@nav' => 'ul.nav-tabs',
'@settings' => '#settings',
'@status' => '#status-box',
- '@verify' => '#domain-verify',
+ '@confirm' => '#domain-confirm',
];
}
}
diff --git a/src/tests/Browser/StatusTest.php b/src/tests/Browser/StatusTest.php
index 233ca00c..660367e7 100644
--- a/src/tests/Browser/StatusTest.php
+++ b/src/tests/Browser/StatusTest.php
@@ -1,287 +1,287 @@
<?php
namespace Tests\Browser;
use App\Domain;
use App\User;
use Carbon\Carbon;
use Tests\Browser;
use Tests\Browser\Components\Status;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\DomainInfo;
use Tests\Browser\Pages\DomainList;
use Tests\Browser\Pages\Home;
use Tests\Browser\Pages\UserInfo;
use Tests\Browser\Pages\UserList;
use Tests\TestCaseDusk;
use Illuminate\Support\Facades\DB;
class StatusTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$domain_status = Domain::STATUS_CONFIRMED | Domain::STATUS_VERIFIED;
DB::statement("UPDATE domains SET status = (status | {$domain_status})"
. " WHERE namespace = 'kolab.org'");
DB::statement("UPDATE users SET status = (status | " . (User::STATUS_IMAP_READY | User::STATUS_LDAP_READY) . ")"
. " WHERE email = 'john@kolab.org'");
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$domain_status = Domain::STATUS_CONFIRMED | Domain::STATUS_VERIFIED;
DB::statement("UPDATE domains SET status = (status | {$domain_status})"
. " WHERE namespace = 'kolab.org'");
DB::statement("UPDATE users SET status = (status | " . User::STATUS_IMAP_READY . ")"
. " WHERE email = 'john@kolab.org'");
parent::tearDown();
}
/**
* Test account status in the Dashboard
*/
public function testDashboard(): void
{
// Unconfirmed domain and user
$domain = Domain::where('namespace', 'kolab.org')->first();
if ($domain->isConfirmed()) {
$domain->status ^= Domain::STATUS_CONFIRMED;
$domain->save();
}
$john = $this->getTestUser('john@kolab.org');
$john->created_at = Carbon::now();
if ($john->isImapReady()) {
$john->status ^= User::STATUS_IMAP_READY;
}
$john->save();
$this->browse(function ($browser) use ($john, $domain) {
$browser->visit(new Home())
->submitLogon('john@kolab.org', 'simple123', true)
->on(new Dashboard())
->with(new Status(), function ($browser) use ($john) {
$browser->assertSeeIn('@body', 'We are preparing your account')
->assertProgress(71, 'Creating a mailbox...', 'pending')
- ->assertMissing('#status-verify')
+ ->assertMissing('#status-confirm')
->assertMissing('#status-link')
->assertMissing('@refresh-button')
->assertMissing('@refresh-text');
$john->status |= User::STATUS_IMAP_READY;
$john->save();
// Wait for auto-refresh, expect domain-confirmed step
$browser->pause(6000)
->assertSeeIn('@body', 'Your account is almost ready')
- ->assertProgress(85, 'Verifying an ownership of a custom domain...', 'failed')
+ ->assertProgress(85, 'Confirming an ownership of a custom domain...', 'failed')
->assertMissing('@refresh-button')
->assertMissing('@refresh-text')
- ->assertMissing('#status-verify')
+ ->assertMissing('#status-confirm')
->assertVisible('#status-link');
})
// check if the link to domain info page works
->click('#status-link')
->on(new DomainInfo())
->back()
->on(new Dashboard())
->with(new Status(), function ($browser) {
$browser->assertMissing('@refresh-button')
- ->assertProgress(85, 'Verifying an ownership of a custom domain...', 'failed');
+ ->assertProgress(85, 'Confirming an ownership of a custom domain...', 'failed');
});
// Confirm the domain and wait until the whole status box disappears
$domain->status |= Domain::STATUS_CONFIRMED;
$domain->save();
// This should take less than 10 seconds
$browser->waitUntilMissing('@status', 10);
});
// Test the Refresh button
if ($domain->isConfirmed()) {
$domain->status ^= Domain::STATUS_CONFIRMED;
$domain->save();
}
$john->created_at = Carbon::now()->subSeconds(3600);
if ($john->isImapReady()) {
$john->status ^= User::STATUS_IMAP_READY;
}
$john->save();
$this->browse(function ($browser) use ($john, $domain) {
$browser->visit(new Dashboard())
->with(new Status(), function ($browser) use ($john, $domain) {
$browser->assertSeeIn('@body', 'We are preparing your account')
->assertProgress(71, 'Creating a mailbox...', 'failed')
->assertVisible('@refresh-button')
->assertVisible('@refresh-text');
$browser->click('@refresh-button')
->assertToast(Toast::TYPE_SUCCESS, 'Setup process has been pushed. Please wait.');
$john->status |= User::STATUS_IMAP_READY;
$john->save();
$domain->status |= Domain::STATUS_CONFIRMED;
$domain->save();
})
->waitUntilMissing('@status', 10);
});
}
/**
* Test domain status on domains list and domain info page
*
* @depends testDashboard
*/
public function testDomainStatus(): void
{
$domain = Domain::where('namespace', 'kolab.org')->first();
$domain->created_at = Carbon::now();
$domain->status = Domain::STATUS_NEW | Domain::STATUS_ACTIVE | Domain::STATUS_LDAP_READY;
$domain->save();
// side-step
$this->assertFalse($domain->isNew());
$this->assertTrue($domain->isActive());
$this->assertTrue($domain->isLdapReady());
$this->assertTrue($domain->isExternal());
$this->assertFalse($domain->isHosted());
$this->assertFalse($domain->isConfirmed());
$this->assertFalse($domain->isVerified());
$this->assertFalse($domain->isSuspended());
$this->assertFalse($domain->isDeleted());
$this->browse(function ($browser) use ($domain) {
// Test auto-refresh
$browser->on(new Dashboard())
->click('@links a.link-domains')
->on(new DomainList())
->waitFor('@table tbody tr')
// Assert domain status icon
->assertVisible('@table tbody tr:first-child td:first-child svg.fa-globe.text-danger')
->assertText('@table tbody tr:first-child td:first-child svg title', 'Not Ready')
->click('@table tbody tr:first-child td:first-child a')
->on(new DomainInfo())
->with(new Status(), function ($browser) {
$browser->assertSeeIn('@body', 'We are preparing the domain')
->assertProgress(50, 'Verifying a custom domain...', 'pending')
->assertMissing('@refresh-button')
->assertMissing('@refresh-text')
->assertMissing('#status-link')
- ->assertMissing('#status-verify');
+ ->assertMissing('#status-confirm');
});
$domain->status |= Domain::STATUS_VERIFIED;
$domain->save();
// This should take less than 10 seconds
$browser->waitFor('@status.process-failed')
->with(new Status(), function ($browser) {
$browser->assertSeeIn('@body', 'The domain is almost ready')
- ->assertProgress(75, 'Verifying an ownership of a custom domain...', 'failed')
+ ->assertProgress(75, 'Confirming an ownership of a custom domain...', 'failed')
->assertMissing('@refresh-button')
->assertMissing('@refresh-text')
->assertMissing('#status-link')
- ->assertVisible('#status-verify');
+ ->assertVisible('#status-confirm');
});
$domain->status |= Domain::STATUS_CONFIRMED;
$domain->save();
// Test Verify button
- $browser->click('@status #status-verify')
- ->assertToast(Toast::TYPE_SUCCESS, 'Domain verified successfully.')
+ $browser->click('@status #status-confirm')
+ ->assertToast(Toast::TYPE_SUCCESS, 'Domain ownership confirmed successfully.')
->waitUntilMissing('@status')
- ->waitUntilMissing('@verify')
+ ->waitUntilMissing('#status-confirm')
->assertVisible('@config');
});
}
/**
* Test user status on users list and user info page
*
* @depends testDashboard
*/
public function testUserStatus(): void
{
$john = $this->getTestUser('john@kolab.org');
$john->created_at = Carbon::now();
if ($john->isImapReady()) {
$john->status ^= User::STATUS_IMAP_READY;
}
$john->save();
$domain = Domain::where('namespace', 'kolab.org')->first();
if ($domain->isConfirmed()) {
$domain->status ^= Domain::STATUS_CONFIRMED;
$domain->save();
}
$this->browse(function ($browser) use ($john, $domain) {
$browser->visit(new Dashboard())
->click('@links a.link-users')
->on(new UserList())
->waitFor('@table tbody tr')
// Assert user status icons
->assertVisible('@table tbody tr:first-child td:first-child svg.fa-user.text-success')
->assertText('@table tbody tr:first-child td:first-child svg title', 'Active')
->assertVisible('@table tbody tr:nth-child(3) td:first-child svg.fa-user.text-danger')
->assertText('@table tbody tr:nth-child(3) td:first-child svg title', 'Not Ready')
->click('@table tbody tr:nth-child(3) td:first-child a')
->on(new UserInfo())
->with('@general', function (Browser $browser) {
// Assert state in the user edit form
$browser->assertSeeIn('div.row:nth-child(1) label', 'Status')
->assertSeeIn('div.row:nth-child(1) #status', 'Not Ready');
})
->with(new Status(), function ($browser) use ($john) {
$browser->assertSeeIn('@body', 'We are preparing the user account')
->assertProgress(71, 'Creating a mailbox...', 'pending')
- ->assertMissing('#status-verify')
+ ->assertMissing('#status-confirm')
->assertMissing('#status-link')
->assertMissing('@refresh-button')
->assertMissing('@refresh-text');
$john->status |= User::STATUS_IMAP_READY;
$john->save();
// Wait for auto-refresh, expect domain-confirmed step
$browser->pause(6000)
->assertSeeIn('@body', 'The user account is almost ready')
- ->assertProgress(85, 'Verifying an ownership of a custom domain...', 'failed')
+ ->assertProgress(85, 'Confirming an ownership of a custom domain...', 'failed')
->assertMissing('@refresh-button')
->assertMissing('@refresh-text')
- ->assertMissing('#status-verify')
+ ->assertMissing('#status-confirm')
->assertVisible('#status-link');
})
->assertSeeIn('#status', 'Active');
// Confirm the domain and wait until the whole status box disappears
$domain->status |= Domain::STATUS_CONFIRMED;
$domain->save();
// This should take less than 10 seconds
$browser->waitUntilMissing('@status', 10);
});
}
}
diff --git a/src/tests/Feature/Controller/DomainsTest.php b/src/tests/Feature/Controller/DomainsTest.php
index 2ab062c0..cba4326b 100644
--- a/src/tests/Feature/Controller/DomainsTest.php
+++ b/src/tests/Feature/Controller/DomainsTest.php
@@ -1,620 +1,620 @@
<?php
namespace Tests\Feature\Controller;
use App\Domain;
use App\Entitlement;
use App\Sku;
use App\Tenant;
use App\User;
use App\Wallet;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
use Tests\TestCase;
class DomainsTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('test1@' . \config('app.domain'));
$this->deleteTestUser('test2@' . \config('app.domain'));
$this->deleteTestUser('test1@domainscontroller.com');
$this->deleteTestDomain('domainscontroller.com');
Sku::where('title', 'test')->delete();
}
public function tearDown(): void
{
$this->deleteTestUser('test1@' . \config('app.domain'));
$this->deleteTestUser('test2@' . \config('app.domain'));
$this->deleteTestUser('test1@domainscontroller.com');
$this->deleteTestDomain('domainscontroller.com');
Sku::where('title', 'test')->delete();
$domain = $this->getTestDomain('kolab.org');
$domain->settings()->whereIn('key', ['spf_whitelist'])->delete();
parent::tearDown();
}
/**
* Test domain confirm request
* @group skipci
*/
public function testConfirm(): void
{
Queue::fake();
$sku_domain = Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$user = $this->getTestUser('test1@domainscontroller.com');
$domain = $this->getTestDomain('domainscontroller.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_EXTERNAL,
]);
Entitlement::create([
'wallet_id' => $user->wallets()->first()->id,
'sku_id' => $sku_domain->id,
'entitleable_id' => $domain->id,
'entitleable_type' => Domain::class
]);
$response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}/confirm");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(2, $json);
$this->assertEquals('error', $json['status']);
- $this->assertEquals('Domain ownership verification failed.', $json['message']);
+ $this->assertEquals('Domain ownership confirmation failed.', $json['message']);
$domain->status |= Domain::STATUS_CONFIRMED;
$domain->save();
$response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}/confirm");
$response->assertStatus(200);
$json = $response->json();
$this->assertEquals('success', $json['status']);
- $this->assertEquals('Domain verified successfully.', $json['message']);
+ $this->assertEquals('Domain ownership confirmed successfully.', $json['message']);
$this->assertTrue(is_array($json['statusInfo']));
// Not authorized access
$response = $this->actingAs($john)->get("api/v4/domains/{$domain->id}/confirm");
$response->assertStatus(403);
// Authorized access by additional account controller
$domain = $this->getTestDomain('kolab.org');
$response = $this->actingAs($ned)->get("api/v4/domains/{$domain->id}/confirm");
$response->assertStatus(200);
}
/**
* Test domain delete request (DELETE /api/v4/domains/<id>)
*/
public function testDestroy(): void
{
Queue::fake();
$sku_domain = Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$john = $this->getTestUser('john@kolab.org');
$johns_domain = $this->getTestDomain('kolab.org');
$user1 = $this->getTestUser('test1@' . \config('app.domain'));
$user2 = $this->getTestUser('test2@' . \config('app.domain'));
$domain = $this->getTestDomain('domainscontroller.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_EXTERNAL,
]);
Entitlement::create([
'wallet_id' => $user1->wallets()->first()->id,
'sku_id' => $sku_domain->id,
'entitleable_id' => $domain->id,
'entitleable_type' => Domain::class
]);
// Not authorized access
$response = $this->actingAs($john)->delete("api/v4/domains/{$domain->id}");
$response->assertStatus(403);
// Can't delete non-empty domain
$response = $this->actingAs($john)->delete("api/v4/domains/{$johns_domain->id}");
$response->assertStatus(422);
$json = $response->json();
$this->assertCount(2, $json);
$this->assertEquals('error', $json['status']);
$this->assertEquals('Unable to delete a domain with assigned users or other objects.', $json['message']);
// Successful deletion
$response = $this->actingAs($user1)->delete("api/v4/domains/{$domain->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(2, $json);
$this->assertEquals('success', $json['status']);
$this->assertEquals('Domain deleted successfully.', $json['message']);
$this->assertTrue($domain->fresh()->trashed());
// Authorized access by additional account controller
$this->deleteTestDomain('domainscontroller.com');
$domain = $this->getTestDomain('domainscontroller.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_EXTERNAL,
]);
Entitlement::create([
'wallet_id' => $user1->wallets()->first()->id,
'sku_id' => $sku_domain->id,
'entitleable_id' => $domain->id,
'entitleable_type' => Domain::class
]);
$user1->wallets()->first()->addController($user2);
$response = $this->actingAs($user2)->delete("api/v4/domains/{$domain->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(2, $json);
$this->assertEquals('success', $json['status']);
$this->assertEquals('Domain deleted successfully.', $json['message']);
$this->assertTrue($domain->fresh()->trashed());
}
/**
* Test fetching domains list
*/
public function testIndex(): void
{
// User with no domains
$user = $this->getTestUser('test1@domainscontroller.com');
$response = $this->actingAs($user)->get("api/v4/domains");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(4, $json);
$this->assertSame(0, $json['count']);
$this->assertSame(false, $json['hasMore']);
$this->assertSame("0 domains have been found.", $json['message']);
$this->assertSame([], $json['list']);
// User with custom domain(s)
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$response = $this->actingAs($john)->get("api/v4/domains");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(4, $json);
$this->assertSame(1, $json['count']);
$this->assertSame(false, $json['hasMore']);
$this->assertSame("1 domains have been found.", $json['message']);
$this->assertCount(1, $json['list']);
$this->assertSame('kolab.org', $json['list'][0]['namespace']);
// Values below are tested by Unit tests
$this->assertArrayHasKey('isConfirmed', $json['list'][0]);
$this->assertArrayHasKey('isDeleted', $json['list'][0]);
$this->assertArrayHasKey('isVerified', $json['list'][0]);
$this->assertArrayHasKey('isSuspended', $json['list'][0]);
$this->assertArrayHasKey('isActive', $json['list'][0]);
if (\config('app.with_ldap')) {
$this->assertArrayHasKey('isLdapReady', $json['list'][0]);
} else {
$this->assertArrayNotHasKey('isLdapReady', $json['list'][0]);
}
$this->assertArrayHasKey('isReady', $json['list'][0]);
$response = $this->actingAs($ned)->get("api/v4/domains");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(4, $json);
$this->assertCount(1, $json['list']);
$this->assertSame('kolab.org', $json['list'][0]['namespace']);
}
/**
* Test domain config update (POST /api/v4/domains/<domain>/config)
*/
public function testSetConfig(): void
{
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$domain = $this->getTestDomain('kolab.org');
$domain->setSetting('spf_whitelist', null);
// Test unknown domain id
$post = ['spf_whitelist' => []];
$response = $this->actingAs($john)->post("/api/v4/domains/123/config", $post);
$json = $response->json();
$response->assertStatus(404);
// Test access by user not being a wallet controller
$post = ['spf_whitelist' => []];
$response = $this->actingAs($jack)->post("/api/v4/domains/{$domain->id}/config", $post);
$json = $response->json();
$response->assertStatus(403);
$this->assertSame('error', $json['status']);
$this->assertSame("Access denied", $json['message']);
$this->assertCount(2, $json);
// Test some invalid data
$post = ['grey' => 1];
$response = $this->actingAs($john)->post("/api/v4/domains/{$domain->id}/config", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json);
$this->assertCount(1, $json['errors']);
$this->assertSame('The requested configuration parameter is not supported.', $json['errors']['grey']);
$this->assertNull($domain->fresh()->getSetting('spf_whitelist'));
// Test some valid data
$post = ['spf_whitelist' => ['.test.domain.com']];
$response = $this->actingAs($john)->post("/api/v4/domains/{$domain->id}/config", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(2, $json);
$this->assertSame('success', $json['status']);
$this->assertSame('Domain settings updated successfully.', $json['message']);
$expected = \json_encode($post['spf_whitelist']);
$this->assertSame($expected, $domain->fresh()->getSetting('spf_whitelist'));
// Test input validation
$post = ['spf_whitelist' => ['aaa']];
$response = $this->actingAs($john)->post("/api/v4/domains/{$domain->id}/config", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertCount(2, $json);
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertSame(
'The entry format is invalid. Expected a domain name starting with a dot.',
$json['errors']['spf_whitelist'][0]
);
$this->assertSame($expected, $domain->fresh()->getSetting('spf_whitelist'));
}
/**
* Test fetching domain info
*/
public function testShow(): void
{
$sku_domain = Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$user = $this->getTestUser('test1@domainscontroller.com');
$domain = $this->getTestDomain('domainscontroller.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_EXTERNAL,
]);
$discount = \App\Discount::withEnvTenantContext()->where('code', 'TEST')->first();
$wallet = $user->wallet();
$wallet->discount()->associate($discount);
$wallet->save();
Entitlement::create([
'wallet_id' => $user->wallets()->first()->id,
'sku_id' => $sku_domain->id,
'entitleable_id' => $domain->id,
'entitleable_type' => Domain::class
]);
$response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertEquals($domain->id, $json['id']);
$this->assertEquals($domain->namespace, $json['namespace']);
$this->assertEquals($domain->status, $json['status']);
$this->assertEquals($domain->type, $json['type']);
$this->assertSame($domain->hash(Domain::HASH_TEXT), $json['hash_text']);
$this->assertSame($domain->hash(Domain::HASH_CNAME), $json['hash_cname']);
$this->assertSame($domain->hash(Domain::HASH_CODE), $json['hash_code']);
$this->assertSame([], $json['config']['spf_whitelist']);
$this->assertCount(4, $json['mx']);
$this->assertTrue(strpos(implode("\n", $json['mx']), $domain->namespace) !== false);
$this->assertCount(8, $json['dns']);
$this->assertTrue(strpos(implode("\n", $json['dns']), $domain->namespace) !== false);
$this->assertTrue(strpos(implode("\n", $json['dns']), $domain->hash()) !== false);
$this->assertTrue(is_array($json['statusInfo']));
// Values below are tested by Unit tests
$this->assertArrayHasKey('isConfirmed', $json);
$this->assertArrayHasKey('isDeleted', $json);
$this->assertArrayHasKey('isVerified', $json);
$this->assertArrayHasKey('isSuspended', $json);
$this->assertArrayHasKey('isActive', $json);
if (\config('app.with_ldap')) {
$this->assertArrayHasKey('isLdapReady', $json);
} else {
$this->assertArrayNotHasKey('isLdapReady', $json);
}
$this->assertArrayHasKey('isReady', $json);
$this->assertCount(1, $json['skus']);
$this->assertSame(1, $json['skus'][$sku_domain->id]['count']);
$this->assertSame([0], $json['skus'][$sku_domain->id]['costs']);
$this->assertSame($wallet->id, $json['wallet']['id']);
$this->assertSame($wallet->balance, $json['wallet']['balance']);
$this->assertSame($wallet->currency, $json['wallet']['currency']);
$this->assertSame($discount->discount, $json['wallet']['discount']);
$this->assertSame($discount->description, $json['wallet']['discount_description']);
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
// Not authorized - Other account domain
$response = $this->actingAs($john)->get("api/v4/domains/{$domain->id}");
$response->assertStatus(403);
$domain = $this->getTestDomain('kolab.org');
// Ned is an additional controller on kolab.org's wallet
$response = $this->actingAs($ned)->get("api/v4/domains/{$domain->id}");
$response->assertStatus(200);
// Jack has no entitlement/control over kolab.org
$response = $this->actingAs($jack)->get("api/v4/domains/{$domain->id}");
$response->assertStatus(403);
}
/**
* Test fetching SKUs list for a domain (GET /domains/<id>/skus)
*/
public function testSkus(): void
{
$user = $this->getTestUser('john@kolab.org');
$domain = $this->getTestDomain('kolab.org');
// Unauth access not allowed
$response = $this->get("api/v4/domains/{$domain->id}/skus");
$response->assertStatus(401);
// Create an sku for another tenant, to make sure it is not included in the result
$nsku = Sku::create([
'title' => 'test',
'name' => 'Test',
'description' => '',
'active' => true,
'cost' => 100,
'handler_class' => 'App\Handlers\Domain',
]);
$tenant = Tenant::whereNotIn('id', [\config('app.tenant_id')])->first();
$nsku->tenant_id = $tenant->id;
$nsku->save();
$response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}/skus");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(1, $json);
$this->assertSkuElement('domain-hosting', $json[0], [
'prio' => 0,
'type' => 'domain',
'handler' => 'DomainHosting',
'enabled' => true,
'readonly' => true,
]);
}
/**
* Test fetching domain status (GET /api/v4/domains/<domain-id>/status)
* and forcing setup process update (?refresh=1)
*
* @group dns
*/
public function testStatus(): void
{
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$domain = $this->getTestDomain('kolab.org');
// Test unauthorized access
$response = $this->actingAs($jack)->get("/api/v4/domains/{$domain->id}/status");
$response->assertStatus(403);
$domain->status = Domain::STATUS_NEW | Domain::STATUS_ACTIVE | Domain::STATUS_LDAP_READY;
$domain->save();
// Get domain status
$response = $this->actingAs($john)->get("/api/v4/domains/{$domain->id}/status");
$response->assertStatus(200);
$json = $response->json();
$this->assertFalse($json['isVerified']);
$this->assertFalse($json['isReady']);
$this->assertFalse($json['isDone']);
$this->assertCount(4, $json['process']);
$this->assertSame('domain-verified', $json['process'][2]['label']);
$this->assertSame(false, $json['process'][2]['state']);
$this->assertTrue(empty($json['status']));
$this->assertTrue(empty($json['message']));
// Now "reboot" the process and verify the domain
$response = $this->actingAs($john)->get("/api/v4/domains/{$domain->id}/status?refresh=1");
$response->assertStatus(200);
$json = $response->json();
$this->assertTrue($json['isVerified']);
$this->assertTrue($json['isReady']);
$this->assertTrue($json['isDone']);
$this->assertCount(4, $json['process']);
$this->assertSame('domain-verified', $json['process'][2]['label']);
$this->assertSame(true, $json['process'][2]['state']);
$this->assertSame('domain-confirmed', $json['process'][3]['label']);
$this->assertSame(true, $json['process'][3]['state']);
$this->assertSame('success', $json['status']);
$this->assertSame('Setup process finished successfully.', $json['message']);
// TODO: Test completing all process steps
}
/**
* Test domain creation (POST /api/v4/domains)
*/
public function testStore(): void
{
Queue::fake();
$jack = $this->getTestUser('jack@kolab.org');
$john = $this->getTestUser('john@kolab.org');
// Test empty request
$response = $this->actingAs($john)->post("/api/v4/domains", []);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertSame("The namespace field is required.", $json['errors']['namespace'][0]);
$this->assertCount(1, $json['errors']);
$this->assertCount(1, $json['errors']['namespace']);
$this->assertCount(2, $json);
// Test access by user not being a wallet controller
$post = ['namespace' => 'domainscontroller.com'];
$response = $this->actingAs($jack)->post("/api/v4/domains", $post);
$json = $response->json();
$response->assertStatus(403);
$this->assertSame('error', $json['status']);
$this->assertSame("Access denied", $json['message']);
$this->assertCount(2, $json);
// Test some invalid data
$post = ['namespace' => '--'];
$response = $this->actingAs($john)->post("/api/v4/domains", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json);
$this->assertSame('The specified domain is invalid.', $json['errors']['namespace'][0]);
$this->assertCount(1, $json['errors']);
$this->assertCount(1, $json['errors']['namespace']);
// Test an existing domain
$post = ['namespace' => 'kolab.org'];
$response = $this->actingAs($john)->post("/api/v4/domains", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json);
$this->assertSame('The specified domain is not available.', $json['errors']['namespace']);
$package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first();
$package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first();
// Missing package
$post = ['namespace' => 'domainscontroller.com'];
$response = $this->actingAs($john)->post("/api/v4/domains", $post);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertSame("Package is required.", $json['errors']['package']);
$this->assertCount(2, $json);
// Invalid package
$post['package'] = $package_kolab->id;
$response = $this->actingAs($john)->post("/api/v4/domains", $post);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertSame("Invalid package selected.", $json['errors']['package']);
$this->assertCount(2, $json);
// Test full and valid data
$post['package'] = $package_domain->id;
$response = $this->actingAs($john)->post("/api/v4/domains", $post);
$json = $response->json();
$response->assertStatus(200);
$this->assertSame('success', $json['status']);
$this->assertSame("Domain created successfully.", $json['message']);
$this->assertCount(2, $json);
$domain = Domain::where('namespace', $post['namespace'])->first();
$this->assertInstanceOf(Domain::class, $domain);
// Assert the new domain entitlements
$this->assertEntitlements($domain, ['domain-hosting']);
// Assert the wallet to which the new domain should be assigned to
$wallet = $domain->wallet();
$this->assertSame($john->wallets->first()->id, $wallet->id);
// Test re-creating a domain
$domain->delete();
$response = $this->actingAs($john)->post("/api/v4/domains", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame("Domain created successfully.", $json['message']);
$this->assertCount(2, $json);
$domain = Domain::where('namespace', $post['namespace'])->first();
$this->assertInstanceOf(Domain::class, $domain);
$this->assertEntitlements($domain, ['domain-hosting']);
$wallet = $domain->wallet();
$this->assertSame($john->wallets->first()->id, $wallet->id);
// Test creating a domain that is soft-deleted and belongs to another user
$domain->delete();
$domain->entitlements()->withTrashed()->update(['wallet_id' => $jack->wallets->first()->id]);
$response = $this->actingAs($john)->post("/api/v4/domains", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json);
$this->assertSame('The specified domain is not available.', $json['errors']['namespace']);
// Test acting as account controller (not owner)
$this->markTestIncomplete();
}
}

File Metadata

Mime Type
text/x-diff
Expires
Sat, Mar 1, 2:24 AM (15 h, 31 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
165493
Default Alt Text
(172 KB)

Event Timeline