diff --git a/src/.env.example b/src/.env.example
index af73f516..7047e83a 100644
--- a/src/.env.example
+++ b/src/.env.example
@@ -1,142 +1,145 @@
APP_NAME=Kolab
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://127.0.0.1:8000
APP_PUBLIC_URL=
APP_DOMAIN=kolabnow.com
+APP_THEME=default
ASSET_URL=http://127.0.0.1:8000
-SUPPORT_URL=
+WEBMAIL_URL=/apps
+SUPPORT_URL=/support
+SUPPORT_EMAIL=
LOG_CHANNEL=stack
DB_CONNECTION=mysql
DB_DATABASE=kolabdev
DB_HOST=127.0.0.1
DB_PASSWORD=kolab
DB_PORT=3306
DB_USERNAME=kolabdev
BROADCAST_DRIVER=redis
CACHE_DRIVER=redis
QUEUE_CONNECTION=redis
SESSION_DRIVER=file
SESSION_LIFETIME=120
MFA_DSN=mysql://roundcube:Welcome2KolabSystems@127.0.0.1/roundcube
MFA_TOTP_DIGITS=6
MFA_TOTP_INTERVAL=30
MFA_TOTP_DIGEST=sha1
IMAP_URI=ssl://127.0.0.1:993
IMAP_ADMIN_LOGIN=cyrus-admin
IMAP_ADMIN_PASSWORD=Welcome2KolabSystems
IMAP_VERIFY_HOST=false
IMAP_VERIFY_PEER=false
LDAP_BASE_DN="dc=mgmt,dc=com"
LDAP_DOMAIN_BASE_DN="ou=Domains,dc=mgmt,dc=com"
LDAP_HOSTS=127.0.0.1
LDAP_PORT=389
LDAP_SERVICE_BIND_DN="uid=kolab-service,ou=Special Users,dc=mgmt,dc=com"
LDAP_SERVICE_BIND_PW="Welcome2KolabSystems"
LDAP_USE_SSL=false
LDAP_USE_TLS=false
# Administrative
LDAP_ADMIN_BIND_DN="cn=Directory Manager"
LDAP_ADMIN_BIND_PW="Welcome2KolabSystems"
LDAP_ADMIN_ROOT_DN="dc=mgmt,dc=com"
# Hosted (public registration)
LDAP_HOSTED_BIND_DN="uid=hosted-kolab-service,ou=Special Users,dc=mgmt,dc=com"
LDAP_HOSTED_BIND_PW="Welcome2KolabSystems"
LDAP_HOSTED_ROOT_DN="dc=hosted,dc=com"
OPENVIDU_API_PASSWORD=MY_SECRET
OPENVIDU_API_URL=http://localhost:8080/api/
OPENVIDU_API_USERNAME=OPENVIDUAPP
OPENVIDU_API_VERIFY_TLS=true
OPENVIDU_COTURN_IP=127.0.0.1
OPENVIDU_COTURN_REDIS_DATABASE=2
OPENVIDU_COTURN_REDIS_IP=127.0.0.1
OPENVIDU_COTURN_REDIS_PASSWORD=turn
# Used as COTURN_IP, TURN_PUBLIC_IP, for KMS_TURN_URL
OPENVIDU_PUBLIC_IP=127.0.0.1
OPENVIDU_PUBLIC_PORT=3478
OPENVIDU_SERVER_PORT=8080
OPENVIDU_WEBHOOK=true
OPENVIDU_WEBHOOK_ENDPOINT=http://127.0.0.1:8000/webhooks/meet/openvidu
# "CDR" events, see https://docs.openvidu.io/en/2.13.0/reference-docs/openvidu-server-cdr/
#OPENVIDU_WEBHOOK_EVENTS=[sessionCreated,sessionDestroyed,participantJoined,participantLeft,webrtcConnectionCreated,webrtcConnectionDestroyed,recordingStatusChanged,filterEventDispatched,mediaNodeStatusChanged]
#OPENVIDU_WEBHOOK_HEADERS=[\"Authorization:\ Basic\ SOMETHING\"]
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
SWOOLE_HOT_RELOAD_ENABLE=true
SWOOLE_HTTP_ACCESS_LOG=true
SWOOLE_HTTP_HOST=127.0.0.1
SWOOLE_HTTP_PORT=8000
SWOOLE_HTTP_REACTOR_NUM=1
SWOOLE_HTTP_WEBSOCKET=true
SWOOLE_HTTP_WORKER_NUM=1
SWOOLE_OB_OUTPUT=true
PAYMENT_PROVIDER=
MOLLIE_KEY=
STRIPE_KEY=
STRIPE_PUBLIC_KEY=
STRIPE_WEBHOOK_SECRET=
MAIL_DRIVER=smtp
MAIL_HOST=smtp.mailtrap.io
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="noreply@example.com"
MAIL_FROM_NAME="Example.com"
MAIL_REPLYTO_ADDRESS=null
MAIL_REPLYTO_NAME=null
DNS_TTL=3600
DNS_SPF="v=spf1 mx -all"
DNS_STATIC="%s. MX 10 ext-mx01.mykolab.com."
DNS_COPY_FROM=null
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
PUSHER_APP_CLUSTER=mt1
MIX_ASSET_PATH=
MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
JWT_SECRET=
JWT_TTL=60
COMPANY_NAME=
COMPANY_ADDRESS=
COMPANY_DETAILS=
COMPANY_EMAIL=
COMPANY_LOGO=
COMPANY_FOOTER=
VAT_COUNTRIES=CH,LI
VAT_RATE=7.7
KB_ACCOUNT_DELETE=
KB_ACCOUNT_SUSPENDED=
diff --git a/src/app/Documents/Receipt.php b/src/app/Documents/Receipt.php
index 4c2d1927..a4ba1656 100644
--- a/src/app/Documents/Receipt.php
+++ b/src/app/Documents/Receipt.php
@@ -1,271 +1,276 @@
wallet = $wallet;
$this->year = $year;
$this->month = $month;
}
/**
* Render the mail template with fake data
*
* @param string $type Output format ('html' or 'pdf')
*
* @return string HTML or PDF output
*/
public static function fakeRender(string $type = 'html'): string
{
$wallet = new Wallet();
$wallet->id = \App\Utils::uuidStr();
$wallet->owner = new User(['id' => 123456789]); // @phpstan-ignore-line
$receipt = new self($wallet, date('Y'), date('n'));
self::$fakeMode = true;
if ($type == 'pdf') {
return $receipt->pdfOutput();
} elseif ($type !== 'html') {
throw new \Exception("Unsupported output format");
}
return $receipt->htmlOutput();
}
/**
* Render the receipt in HTML format.
*
* @return string HTML content
*/
public function htmlOutput(): string
{
return $this->build()->render();
}
/**
* Render the receipt in PDF format.
*
* @return string PDF content
*/
public function pdfOutput(): string
{
// Parse ther HTML template
$html = $this->build()->render();
// Link fonts from public/fonts to storage/fonts so DomPdf can find them
if (!is_link(storage_path('fonts/Roboto-Regular.ttf'))) {
symlink(
public_path('fonts/Roboto-Regular.ttf'),
storage_path('fonts/Roboto-Regular.ttf')
);
symlink(
public_path('fonts/Roboto-Bold.ttf'),
storage_path('fonts/Roboto-Bold.ttf')
);
}
// Fix font and image paths
$html = str_replace('url(/fonts/', 'url(fonts/', $html);
- $html = str_replace('src="/images/', 'src="images/', $html);
+ $html = str_replace('src="/', 'src="', $html);
// TODO: The output file is about ~200KB, we could probably slim it down
// by using separate font files with small subset of languages when
// there are no Unicode characters used, e.g. only ASCII or Latin.
// Load PDF generator
$pdf = \PDF::loadHTML($html)->setPaper('a4', 'portrait');
return $pdf->output();
}
/**
* Build the document
*
* @return \Illuminate\View\View The template object
*/
protected function build()
{
$appName = \config('app.name');
$start = Carbon::create($this->year, $this->month, 1, 0, 0, 0);
$end = $start->copy()->endOfMonth();
$month = \trans('documents.month' . intval($this->month));
$title = \trans('documents.receipt-title', ['year' => $this->year, 'month' => $month]);
$company = $this->companyData();
if (self::$fakeMode) {
$country = 'CH';
$customer = [
'id' => $this->wallet->owner->id,
'wallet_id' => $this->wallet->id,
'customer' => 'Freddie Krüger 7252 Westminster Lane Forest Hills, NY 11375',
];
$items = collect([
(object) [
'amount' => 1234,
'updated_at' => $start->copy()->next(Carbon::MONDAY),
],
(object) [
'amount' => 10000,
'updated_at' => $start->copy()->next()->next(),
],
(object) [
'amount' => 1234,
'updated_at' => $start->copy()->next()->next()->next(Carbon::MONDAY),
],
(object) [
'amount' => 99,
'updated_at' => $start->copy()->next()->next()->next(),
],
]);
} else {
$customer = $this->customerData();
$country = $this->wallet->owner->getSetting('country');
$items = $this->wallet->payments()
->where('status', PaymentProvider::STATUS_PAID)
->where('updated_at', '>=', $start)
->where('updated_at', '<', $end)
->where('amount', '<>', 0)
->orderBy('updated_at')
->get();
}
$vatRate = \config('app.vat.rate');
$vatCountries = explode(',', \config('app.vat.countries'));
$vatCountries = array_map('strtoupper', array_map('trim', $vatCountries));
if (!$country || !in_array(strtoupper($country), $vatCountries)) {
$vatRate = 0;
}
$totalVat = 0;
$total = 0;
$items = $items->map(function ($item) use (&$total, &$totalVat, $appName, $vatRate) {
$amount = $item->amount;
if ($vatRate > 0) {
$amount = round($amount * ((100 - $vatRate) / 100));
$totalVat += $item->amount - $amount;
}
$total += $amount;
if ($item->type == PaymentProvider::TYPE_REFUND) {
$description = \trans('documents.receipt-refund');
} elseif ($item->type == PaymentProvider::TYPE_CHARGEBACK) {
$description = \trans('documents.receipt-chargeback');
} else {
$description = \trans('documents.receipt-item-desc', ['site' => $appName]);
}
return [
'amount' => $this->wallet->money($amount),
'description' => $description,
'date' => $item->updated_at->toDateString(),
];
});
// Load the template
$view = view('documents.receipt')
->with([
'site' => $appName,
'title' => $title,
'company' => $company,
'customer' => $customer,
'items' => $items,
'subTotal' => $this->wallet->money($total),
'total' => $this->wallet->money($total + $totalVat),
'totalVat' => $this->wallet->money($totalVat),
'vatRate' => preg_replace('/([.,]00|0|[.,])$/', '', sprintf('%.2f', $vatRate)),
'vat' => $vatRate > 0,
]);
return $view;
}
/**
* Prepare customer data for the template
*
* @return array Customer data for the template
*/
protected function customerData(): array
{
$user = $this->wallet->owner;
$name = $user->name();
$organization = $user->getSetting('organization');
$address = $user->getSetting('billing_address');
$customer = trim(($organization ?: $name) . "\n$address");
$customer = str_replace("\n", ' ', htmlentities($customer));
return [
'id' => $this->wallet->owner->id,
'wallet_id' => $this->wallet->id,
'customer' => $customer,
];
}
/**
* Prepare company data for the template
*
* @return array Company data for the template
*/
protected function companyData(): array
{
$header = \config('app.company.name') . "\n" . \config('app.company.address');
$header = str_replace("\n", ' ', htmlentities($header));
$footerLineLength = 110;
$footer = \config('app.company.details');
$contact = \config('app.company.email');
$logo = \config('app.company.logo');
+ $theme = \config('app.theme');
if ($contact) {
$length = strlen($footer) + strlen($contact) + 3;
$contact = htmlentities($contact);
$footer .= ($length > $footerLineLength ? "\n" : ' | ')
. sprintf('%s', $contact, $contact);
}
+ if ($logo && strpos($logo, '/') === false) {
+ $logo = "/themes/$theme/images/$logo";
+ }
+
return [
- 'logo' => $logo ? "" : '',
+ 'logo' => $logo ? "" : '',
'header' => $header,
'footer' => $footer,
];
}
}
diff --git a/src/app/Http/Controllers/API/V4/SupportController.php b/src/app/Http/Controllers/API/V4/SupportController.php
new file mode 100644
index 00000000..84e7ae9c
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/SupportController.php
@@ -0,0 +1,69 @@
+ 'string|nullable|max:256',
+ 'name' => 'string|nullable|max:256',
+ 'email' => 'required|email',
+ 'summary' => 'required|string|max:512',
+ 'body' => 'required|string',
+ ];
+
+ $params = $request->only(array_keys($rules));
+
+ // Check required fields
+ $v = Validator::make($params, $rules);
+
+ if ($v->fails()) {
+ return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
+ }
+
+ $to = \config('app.support_email');
+
+ if (empty($to)) {
+ \Log::error("Failed to send a support request. SUPPORT_EMAIL not set");
+ return $this->errorResponse(500, \trans('app.support-request-error'));
+ }
+
+ $content = sprintf(
+ "ID: %s\nName: %s\nWorking email address: %s\nSubject: %s\n\n%s\n",
+ $params['user'] ?? '',
+ $params['name'] ?? '',
+ $params['email'],
+ $params['summary'],
+ $params['body'],
+ );
+
+ Mail::raw($content, function ($message) use ($params, $to) {
+ // Remove the global reply-to addressee
+ $message->getHeaders()->remove('Reply-To');
+
+ $message->to($to)
+ ->from($params['email'], $params['name'] ?? null)
+ ->replyTo($params['email'], $params['name'] ?? null)
+ ->subject($params['summary']);
+ });
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => \trans('app.support-request-success'),
+ ]);
+ }
+}
diff --git a/src/app/Http/Controllers/ContentController.php b/src/app/Http/Controllers/ContentController.php
new file mode 100644
index 00000000..7aa099dc
--- /dev/null
+++ b/src/app/Http/Controllers/ContentController.php
@@ -0,0 +1,62 @@
+with('env', \App\Utils::uiEnv());
+ }
+
+ /**
+ * Get the list of FAQ entries for the specified page
+ *
+ * @param string $page Page path
+ *
+ * @return \Illuminate\Http\JsonResponse JSON response
+ */
+ public function faqContent(string $page)
+ {
+ if (empty($page)) {
+ return $this->errorResponse(404);
+ }
+
+ $faq = [];
+
+ $theme_name = \config('app.theme');
+ $theme_file = resource_path("themes/{$theme_name}/theme.json");
+
+ if (file_exists($theme_file)) {
+ $theme = json_decode(file_get_contents($theme_file), true);
+ if (json_last_error() != JSON_ERROR_NONE) {
+ \Log::error("Failed to parse $theme_file: " . json_last_error_msg());
+ } elseif (!empty($theme['faq']) && !empty($theme['faq'][$page])) {
+ $faq = $theme['faq'][$page];
+ }
+
+ // TODO: Support pages with variables, e.g. users/
+ }
+
+ return response()->json(['status' => 'success', 'faq' => $faq]);
+ }
+}
diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php
index 2fdda4ad..fb97ac97 100644
--- a/src/app/Providers/AppServiceProvider.php
+++ b/src/app/Providers/AppServiceProvider.php
@@ -1,52 +1,59 @@
sql, implode(', ', $query->bindings)));
});
}
+
+ // Register some template helpers
+ Blade::directive('theme_asset', function ($path) {
+ $path = trim($path, '/\'"');
+ return "";
+ });
}
}
diff --git a/src/app/Providers/Payment/Mollie.php b/src/app/Providers/Payment/Mollie.php
index 1dc2f4b6..a8fc0a19 100644
--- a/src/app/Providers/Payment/Mollie.php
+++ b/src/app/Providers/Payment/Mollie.php
@@ -1,488 +1,489 @@
tag
*/
public function customerLink(Wallet $wallet): ?string
{
$customer_id = self::mollieCustomerId($wallet, false);
if (!$customer_id) {
return null;
}
return sprintf(
'%s',
$customer_id,
$customer_id
);
}
/**
* Create a new auto-payment mandate for a wallet.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data:
* - amount: Value in cents
* - currency: The operation currency
* - description: Operation desc.
*
* @return array Provider payment data:
* - id: Operation identifier
* - redirectUrl: the location to redirect to
*/
public function createMandate(Wallet $wallet, array $payment): ?array
{
// Register the user in Mollie, if not yet done
$customer_id = self::mollieCustomerId($wallet, true);
$request = [
'amount' => [
'currency' => $payment['currency'],
'value' => '0.00',
],
'customerId' => $customer_id,
'sequenceType' => 'first',
'description' => $payment['description'],
'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'),
'redirectUrl' => Utils::serviceUrl('/wallet'),
'locale' => 'en_US',
// 'method' => 'creditcard',
];
// Create the payment in Mollie
$response = mollie()->payments()->create($request);
if ($response->mandateId) {
$wallet->setSetting('mollie_mandate_id', $response->mandateId);
}
return [
'id' => $response->id,
'redirectUrl' => $response->getCheckoutUrl(),
];
}
/**
* Revoke the auto-payment mandate for the wallet.
*
* @param \App\Wallet $wallet The wallet
*
* @return bool True on success, False on failure
*/
public function deleteMandate(Wallet $wallet): bool
{
// Get the Mandate info
$mandate = self::mollieMandate($wallet);
// Revoke the mandate on Mollie
if ($mandate) {
$mandate->revoke();
$wallet->setSetting('mollie_mandate_id', null);
}
return true;
}
/**
* Get a auto-payment mandate for the wallet.
*
* @param \App\Wallet $wallet The wallet
*
* @return array|null Mandate information:
* - id: Mandate identifier
* - method: user-friendly payment method desc.
* - isPending: the process didn't complete yet
* - isValid: the mandate is valid
*/
public function getMandate(Wallet $wallet): ?array
{
// Get the Mandate info
$mandate = self::mollieMandate($wallet);
if (empty($mandate)) {
return null;
}
$result = [
'id' => $mandate->id,
'isPending' => $mandate->isPending(),
'isValid' => $mandate->isValid(),
'method' => self::paymentMethod($mandate, 'Unknown method')
];
return $result;
}
/**
* Get a provider name
*
* @return string Provider name
*/
public function name(): string
{
return 'mollie';
}
/**
* Create a new payment.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data:
* - amount: Value in cents
* - currency: The operation currency
* - type: oneoff/recurring
* - description: Operation desc.
*
* @return array Provider payment data:
* - id: Operation identifier
* - redirectUrl: the location to redirect to
*/
public function payment(Wallet $wallet, array $payment): ?array
{
if ($payment['type'] == self::TYPE_RECURRING) {
return $this->paymentRecurring($wallet, $payment);
}
// Register the user in Mollie, if not yet done
$customer_id = self::mollieCustomerId($wallet, true);
// Note: Required fields: description, amount/currency, amount/value
$request = [
'amount' => [
'currency' => $payment['currency'],
// a number with two decimals is required
'value' => sprintf('%.2f', $payment['amount'] / 100),
],
'customerId' => $customer_id,
'sequenceType' => $payment['type'],
'description' => $payment['description'],
'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'),
'locale' => 'en_US',
// 'method' => 'creditcard',
'redirectUrl' => Utils::serviceUrl('/wallet') // required for non-recurring payments
];
// TODO: Additional payment parameters for better fraud protection:
// billingEmail - for bank transfers, Przelewy24, but not creditcard
// billingAddress (it is a structured field not just text)
// Create the payment in Mollie
$response = mollie()->payments()->create($request);
// Store the payment reference in database
$payment['status'] = $response->status;
$payment['id'] = $response->id;
$this->storePayment($payment, $wallet->id);
return [
'id' => $payment['id'],
'redirectUrl' => $response->getCheckoutUrl(),
];
}
/**
* Create a new automatic payment operation.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data (see self::payment())
*
* @return array Provider payment/session data:
* - id: Operation identifier
*/
protected function paymentRecurring(Wallet $wallet, array $payment): ?array
{
// Check if there's a valid mandate
$mandate = self::mollieMandate($wallet);
if (empty($mandate) || !$mandate->isValid() || $mandate->isPending()) {
return null;
}
$customer_id = self::mollieCustomerId($wallet, true);
// Note: Required fields: description, amount/currency, amount/value
$request = [
'amount' => [
'currency' => $payment['currency'],
// a number with two decimals is required
'value' => sprintf('%.2f', $payment['amount'] / 100),
],
'customerId' => $customer_id,
'sequenceType' => $payment['type'],
'description' => $payment['description'],
'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'),
'locale' => 'en_US',
// 'method' => 'creditcard',
'mandateId' => $mandate->id
];
// Create the payment in Mollie
$response = mollie()->payments()->create($request);
// Store the payment reference in database
$payment['status'] = $response->status;
$payment['id'] = $response->id;
DB::beginTransaction();
$payment = $this->storePayment($payment, $wallet->id);
// Mollie can return 'paid' status immediately, so we don't
// have to wait for the webhook. What's more, the webhook would ignore
// the payment because it will be marked as paid before the webhook.
// Let's handle paid status here too.
if ($response->isPaid()) {
self::creditPayment($payment, $response);
$notify = true;
} elseif ($response->isFailed()) {
// Note: I didn't find a way to get any description of the problem with a payment
\Log::info(sprintf('Mollie payment failed (%s)', $response->id));
// Disable the mandate
$wallet->setSetting('mandate_disabled', 1);
$notify = true;
}
DB::commit();
if (!empty($notify)) {
\App\Jobs\PaymentEmail::dispatch($payment);
}
return [
'id' => $payment['id'],
];
}
/**
* Update payment status (and balance).
*
* @return int HTTP response code
*/
public function webhook(): int
{
$payment_id = \request()->input('id');
if (empty($payment_id)) {
return 200;
}
$payment = Payment::find($payment_id);
if (empty($payment)) {
// Mollie recommends to return "200 OK" even if the payment does not exist
return 200;
}
// Get the payment details from Mollie
// TODO: Consider https://github.com/mollie/mollie-api-php/issues/502 when it's fixed
$mollie_payment = mollie()->payments()->get($payment_id);
if (empty($mollie_payment)) {
// Mollie recommends to return "200 OK" even if the payment does not exist
return 200;
}
$refunds = [];
if ($mollie_payment->isPaid()) {
// The payment is paid. Update the balance, and notify the user
if ($payment->status != self::STATUS_PAID && $payment->amount > 0) {
$credit = true;
$notify = $payment->type == self::TYPE_RECURRING;
}
// The payment has been (partially) refunded.
// Let's process refunds with status "refunded".
if ($mollie_payment->hasRefunds()) {
foreach ($mollie_payment->refunds() as $refund) {
if ($refund->isTransferred() && $refund->amount->value) {
$refunds[] = [
'id' => $refund->id,
'description' => $refund->description,
'amount' => round(floatval($refund->amount->value) * 100),
'type' => self::TYPE_REFUND,
// Note: we assume this is the original payment/wallet currency
];
}
}
}
// The payment has been (partially) charged back.
// Let's process chargebacks (they have no states as refunds)
if ($mollie_payment->hasChargebacks()) {
foreach ($mollie_payment->chargebacks() as $chargeback) {
if ($chargeback->amount->value) {
$refunds[] = [
'id' => $chargeback->id,
'amount' => round(floatval($chargeback->amount->value) * 100),
'type' => self::TYPE_CHARGEBACK,
// Note: we assume this is the original payment/wallet currency
];
}
}
}
} elseif ($mollie_payment->isFailed()) {
// Note: I didn't find a way to get any description of the problem with a payment
\Log::info(sprintf('Mollie payment failed (%s)', $payment->id));
// Disable the mandate
if ($payment->type == self::TYPE_RECURRING) {
$notify = true;
$payment->wallet->setSetting('mandate_disabled', 1);
}
}
DB::beginTransaction();
// This is a sanity check, just in case the payment provider api
// sent us open -> paid -> open -> paid. So, we lock the payment after
// recivied a "final" state.
$pending_states = [self::STATUS_OPEN, self::STATUS_PENDING, self::STATUS_AUTHORIZED];
if (in_array($payment->status, $pending_states)) {
$payment->status = $mollie_payment->status;
$payment->save();
}
if (!empty($credit)) {
self::creditPayment($payment, $mollie_payment);
}
foreach ($refunds as $refund) {
$this->storeRefund($payment->wallet, $refund);
}
DB::commit();
if (!empty($notify)) {
\App\Jobs\PaymentEmail::dispatch($payment);
}
return 200;
}
/**
* Get Mollie customer identifier for specified wallet.
* Create one if does not exist yet.
*
* @param \App\Wallet $wallet The wallet
* @param bool $create Create the customer if does not exist yet
*
* @return ?string Mollie customer identifier
*/
protected static function mollieCustomerId(Wallet $wallet, bool $create = false): ?string
{
$customer_id = $wallet->getSetting('mollie_id');
// Register the user in Mollie
if (empty($customer_id) && $create) {
$customer = mollie()->customers()->create([
'name' => $wallet->owner->name(),
'email' => $wallet->id . '@private.' . \config('app.domain'),
]);
$customer_id = $customer->id;
$wallet->setSetting('mollie_id', $customer->id);
}
return $customer_id;
}
/**
* Get the active Mollie auto-payment mandate
*/
protected static function mollieMandate(Wallet $wallet)
{
$customer_id = $wallet->getSetting('mollie_id');
$mandate_id = $wallet->getSetting('mollie_mandate_id');
// Get the manadate reference we already have
if ($customer_id && $mandate_id) {
- $mandate = mollie()->mandates()->getForId($customer_id, $mandate_id);
- if ($mandate) {// && ($mandate->isValid() || $mandate->isPending())) {
- return $mandate;
- }
- }
+ try {
+ return mollie()->mandates()->getForId($customer_id, $mandate_id);
+ } catch (ApiException $e) {
+ // FIXME: What about 404?
+ if ($e->getCode() == 410) {
+ // The mandate is gone, remove the reference
+ $wallet->setSetting('mollie_mandate_id', null);
+ return null;
+ }
- // Get all mandates from Mollie and find the active one
- /*
- foreach ($customer->mandates() as $mandate) {
- if ($mandate->isValid() || $mandate->isPending()) {
- $wallet->setSetting('mollie_mandate_id', $mandate->id);
- return $mandate;
+ // TODO: Maybe we shouldn't always throw? It make sense in the job
+ // but for example when we're just fetching wallet info...
+ throw $e;
}
}
- */
}
/**
* Apply the successful payment's pecunia to the wallet
*/
protected static function creditPayment($payment, $mollie_payment)
{
// Extract the payment method for transaction description
$method = self::paymentMethod($mollie_payment, 'Mollie');
// TODO: Localization?
$description = $payment->type == self::TYPE_RECURRING ? 'Auto-payment' : 'Payment';
$description .= " transaction {$payment->id} using {$method}";
$payment->wallet->credit($payment->amount, $description);
// Unlock the disabled auto-payment mandate
if ($payment->wallet->balance >= 0) {
$payment->wallet->setSetting('mandate_disabled', null);
}
}
/**
* Extract payment method description from Mollie payment/mandate details
*/
protected static function paymentMethod($object, $default = ''): string
{
$details = $object->details;
// Mollie supports 3 methods here
switch ($object->method) {
case 'creditcard':
// If the customer started, but never finished the 'first' payment
// card details will be empty, and mandate will be 'pending'.
if (empty($details->cardNumber)) {
return 'Credit Card';
}
return sprintf(
'%s (**** **** **** %s)',
$details->cardLabel ?: 'Card', // @phpstan-ignore-line
$details->cardNumber
);
case 'directdebit':
return sprintf('Direct Debit (%s)', $details->customerAccount);
case 'paypal':
return sprintf('PayPal (%s)', $details->consumerAccount);
}
return $default;
}
}
diff --git a/src/app/Utils.php b/src/app/Utils.php
index d948b993..3e722043 100644
--- a/src/app/Utils.php
+++ b/src/app/Utils.php
@@ -1,389 +1,412 @@
= INET_ATON(?)
ORDER BY INET_ATON(net_number), net_mask DESC LIMIT 1
";
} else {
$query = "
SELECT id FROM ip6nets
WHERE INET6_ATON(net_number) <= INET6_ATON(?)
AND INET6_ATON(net_broadcast) >= INET6_ATON(?)
ORDER BY INET6_ATON(net_number), net_mask DESC LIMIT 1
";
}
$nets = \Illuminate\Support\Facades\DB::select($query, [$ip, $ip]);
if (sizeof($nets) > 0) {
return $nets[0]->country;
}
return 'CH';
}
/**
* Return the country ISO code for the current request.
*/
public static function countryForRequest()
{
$request = \request();
$ip = $request->ip();
return self::countryForIP($ip);
}
/**
* Shortcut to creating a progress bar of a particular format with a particular message.
*
* @param \Illuminate\Console\OutputStyle $output Console output object
* @param int $count Number of progress steps
* @param string $message The description
*
* @return \Symfony\Component\Console\Helper\ProgressBar
*/
public static function createProgressBar($output, $count, $message = null)
{
$bar = $output->createProgressBar($count);
$bar->setFormat(
'%current:7s%/%max:7s% [%bar%] %percent:3s%% %elapsed:7s%/%estimated:-7s% %message% '
);
if ($message) {
$bar->setMessage($message . " ...");
}
$bar->start();
return $bar;
}
/**
* Return the number of days in the month prior to this one.
*
* @return int
*/
public static function daysInLastMonth()
{
$start = new Carbon('first day of last month');
$end = new Carbon('last day of last month');
return $start->diffInDays($end) + 1;
}
/**
* Download a file from the interwebz and store it locally.
*
* @param string $source The source location
* @param string $target The target location
* @param bool $force Force the download (and overwrite target)
*
* @return void
*/
public static function downloadFile($source, $target, $force = false)
{
if (is_file($target) && !$force) {
return;
}
\Log::info("Retrieving {$source}");
$fp = fopen($target, 'w');
$curl = curl_init();
curl_setopt($curl, CURLOPT_URL, $source);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($curl, CURLOPT_FILE, $fp);
curl_exec($curl);
if (curl_errno($curl)) {
\Log::error("Request error on {$source}: " . curl_error($curl));
curl_close($curl);
fclose($fp);
unlink($target);
return;
}
curl_close($curl);
fclose($fp);
}
/**
* Calculate the broadcast address provided a net number and a prefix.
*
* @param string $net A valid IPv6 network number.
* @param int $prefix The network prefix.
*
* @return string
*/
public static function ip6Broadcast($net, $prefix)
{
$netHex = bin2hex(inet_pton($net));
// Overwriting first address string to make sure notation is optimal
$net = inet_ntop(hex2bin($netHex));
// Calculate the number of 'flexible' bits
$flexbits = 128 - $prefix;
// Build the hexadecimal string of the last address
$lastAddrHex = $netHex;
// We start at the end of the string (which is always 32 characters long)
$pos = 31;
while ($flexbits > 0) {
// Get the character at this position
$orig = substr($lastAddrHex, $pos, 1);
// Convert it to an integer
$origval = hexdec($orig);
// OR it with (2^flexbits)-1, with flexbits limited to 4 at a time
$newval = $origval | (pow(2, min(4, $flexbits)) - 1);
// Convert it back to a hexadecimal character
$new = dechex($newval);
// And put that character back in the string
$lastAddrHex = substr_replace($lastAddrHex, $new, $pos, 1);
// We processed one nibble, move to previous position
$flexbits -= 4;
$pos -= 1;
}
// Convert the hexadecimal string to a binary string
# Using pack() here
# Newer PHP version can use hex2bin()
$lastaddrbin = pack('H*', $lastAddrHex);
// And create an IPv6 address from the binary string
$lastaddrstr = inet_ntop($lastaddrbin);
return $lastaddrstr;
}
/**
* Provide all unique combinations of elements in $input, with order and duplicates irrelevant.
*
* @param array $input The input array of elements.
*
* @return array[]
*/
public static function powerSet(array $input): array
{
$output = [];
for ($x = 0; $x < count($input); $x++) {
self::combine($input, $x + 1, 0, [], 0, $output);
}
return $output;
}
/**
* Returns the current user's email address or null.
*
* @return string
*/
public static function userEmailOrNull(): ?string
{
$user = Auth::user();
if (!$user) {
return null;
}
return $user->email;
}
/**
* Returns a random string consisting of a quantity of segments of a certain length joined.
*
* Example:
*
* ```php
* $roomName = strtolower(\App\Utils::randStr(3, 3, '-');
* // $roomName == '3qb-7cs-cjj'
* ```
*
* @param int $length The length of each segment
* @param int $qty The quantity of segments
* @param string $join The string to use to join the segments
*
* @return string
*/
public static function randStr($length, $qty = 1, $join = '')
{
$chars = env('SHORTCODE_CHARS', self::CHARS);
$randStrs = [];
for ($x = 0; $x < $qty; $x++) {
$randStrs[$x] = [];
for ($y = 0; $y < $length; $y++) {
$randStrs[$x][] = $chars[rand(0, strlen($chars) - 1)];
}
shuffle($randStrs[$x]);
$randStrs[$x] = implode('', $randStrs[$x]);
}
return implode($join, $randStrs);
}
/**
* Returns a UUID in the form of an integer.
*
* @return integer
*/
public static function uuidInt(): int
{
$hex = Uuid::uuid4();
$bin = pack('h*', str_replace('-', '', $hex));
$ids = unpack('L', $bin);
$id = array_shift($ids);
return $id;
}
/**
* Returns a UUID in the form of a string.
*
* @return string
*/
public static function uuidStr(): string
{
return Uuid::uuid4()->toString();
}
private static function combine($input, $r, $index, $data, $i, &$output): void
{
$n = count($input);
// Current cobination is ready
if ($index == $r) {
$output[] = array_slice($data, 0, $r);
return;
}
// When no more elements are there to put in data[]
if ($i >= $n) {
return;
}
// current is included, put next at next location
$data[$index] = $input[$i];
self::combine($input, $r, $index + 1, $data, $i + 1, $output);
// current is excluded, replace it with next (Note that i+1
// is passed, but index is not changed)
self::combine($input, $r, $index, $data, $i + 1, $output);
}
/**
* Create self URL
*
* @param string $route Route/Path
* @todo Move this to App\Http\Controllers\Controller
*
* @return string Full URL
*/
public static function serviceUrl(string $route): string
{
$url = \config('app.public_url');
if (!$url) {
$url = \config('app.url');
}
return rtrim(trim($url, '/') . '/' . ltrim($route, '/'), '/');
}
/**
* Create a configuration/environment data to be passed to
* the UI
*
* @todo Move this to App\Http\Controllers\Controller
*
* @return array Configuration data
*/
public static function uiEnv(): array
{
$countries = include resource_path('countries.php');
$req_domain = preg_replace('/:[0-9]+$/', '', request()->getHttpHost());
$sys_domain = \config('app.domain');
$path = request()->path();
+ $opts = [
+ 'app.name',
+ 'app.url',
+ 'app.domain',
+ 'app.theme',
+ 'app.webmail_url',
+ 'app.support_email',
+ 'mail.from.address'
+ ];
- $opts = ['app.name', 'app.url', 'app.domain'];
$env = \app('config')->getMany($opts);
$env['countries'] = $countries ?: [];
$env['view'] = 'root';
$env['jsapp'] = 'user.js';
if ($path == 'meet' || strpos($path, 'meet/') === 0) {
$env['view'] = 'meet';
$env['jsapp'] = 'meet.js';
} elseif ($req_domain == "admin.$sys_domain") {
$env['jsapp'] = 'admin.js';
}
$env['paymentProvider'] = \config('services.payment_provider');
$env['stripePK'] = \config('services.stripe.public_key');
+ $theme_file = resource_path("themes/{$env['app.theme']}/theme.json");
+ $menu = [];
+
+ if (file_exists($theme_file)) {
+ $theme = json_decode(file_get_contents($theme_file), true);
+
+ if (json_last_error() != JSON_ERROR_NONE) {
+ \Log::error("Failed to parse $theme_file: " . json_last_error_msg());
+ } elseif (!empty($theme['menu'])) {
+ $menu = $theme['menu'];
+ }
+ }
+
+ $env['menu'] = $menu;
+
return $env;
}
}
diff --git a/src/config/app.php b/src/config/app.php
index 7612df7f..887197a7 100644
--- a/src/config/app.php
+++ b/src/config/app.php
@@ -1,263 +1,269 @@
env('APP_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
| Application Environment
|--------------------------------------------------------------------------
|
| This value determines the "environment" your application is currently
| running in. This may determine how you prefer to configure various
| services the application utilizes. Set this in your ".env" file.
|
*/
'env' => env('APP_ENV', 'production'),
/*
|--------------------------------------------------------------------------
| Application Debug Mode
|--------------------------------------------------------------------------
|
| When your application is in debug mode, detailed error messages with
| stack traces will be shown on every error that occurs within your
| application. If disabled, a simple generic error page is shown.
|
*/
'debug' => env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Application URL
|--------------------------------------------------------------------------
|
| This URL is used by the console to properly generate URLs when using
| the Artisan command line tool. You should set this to the root of
| your application so that it is used when running Artisan tasks.
*/
'url' => env('APP_URL', 'http://localhost'),
'public_url' => env('APP_PUBLIC_URL', env('APP_URL', 'http://localhost')),
'asset_url' => env('ASSET_URL', null),
'support_url' => env('SUPPORT_URL', null),
+ 'support_email' => env('SUPPORT_EMAIL', null),
+
+ 'webmail_url' => env('WEBMAIL_URL', null),
+
+ 'theme' => env('APP_THEME', 'default'),
+
/*
|--------------------------------------------------------------------------
| Application Domain
|--------------------------------------------------------------------------
|
| System domain used for user signup (kolab identity)
*/
'domain' => env('APP_DOMAIN', 'domain.tld'),
/*
|--------------------------------------------------------------------------
| Application Timezone
|--------------------------------------------------------------------------
|
| Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. We have gone
| ahead and set this to a sensible default for you out of the box.
|
*/
'timezone' => 'UTC',
/*
|--------------------------------------------------------------------------
| Application Locale Configuration
|--------------------------------------------------------------------------
|
| The application locale determines the default locale that will be used
| by the translation service provider. You are free to set this value
| to any of the locales which will be supported by the application.
|
*/
'locale' => 'en',
/*
|--------------------------------------------------------------------------
| Application Fallback Locale
|--------------------------------------------------------------------------
|
| The fallback locale determines the locale to use when the current one
| is not available. You may change the value to correspond to any of
| the language folders that are provided through your application.
|
*/
'fallback_locale' => 'en',
/*
|--------------------------------------------------------------------------
| Faker Locale
|--------------------------------------------------------------------------
|
| This locale will be used by the Faker PHP library when generating fake
| data for your database seeds. For example, this will be used to get
| localized telephone numbers, street address information and more.
|
*/
'faker_locale' => 'en_US',
/*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
|
| This key is used by the Illuminate encrypter service and should be set
| to a random, 32 character string, otherwise these encrypted strings
| will not be safe. Please do this before deploying an application!
|
*/
'key' => env('APP_KEY'),
'cipher' => 'AES-256-CBC',
/*
|--------------------------------------------------------------------------
| Autoloaded Service Providers
|--------------------------------------------------------------------------
|
| The service providers listed here will be automatically loaded on the
| request to your application. Feel free to add your own services to
| this array to grant expanded functionality to your applications.
|
*/
'providers' => [
/*
* Laravel Framework Service Providers...
*/
Illuminate\Auth\AuthServiceProvider::class,
Illuminate\Broadcasting\BroadcastServiceProvider::class,
Illuminate\Bus\BusServiceProvider::class,
Illuminate\Cache\CacheServiceProvider::class,
Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class,
Illuminate\Cookie\CookieServiceProvider::class,
Illuminate\Database\DatabaseServiceProvider::class,
Illuminate\Encryption\EncryptionServiceProvider::class,
Illuminate\Filesystem\FilesystemServiceProvider::class,
Illuminate\Foundation\Providers\FoundationServiceProvider::class,
Illuminate\Hashing\HashServiceProvider::class,
Illuminate\Mail\MailServiceProvider::class,
Illuminate\Notifications\NotificationServiceProvider::class,
Illuminate\Pagination\PaginationServiceProvider::class,
Illuminate\Pipeline\PipelineServiceProvider::class,
Illuminate\Queue\QueueServiceProvider::class,
Illuminate\Redis\RedisServiceProvider::class,
Illuminate\Auth\Passwords\PasswordResetServiceProvider::class,
Illuminate\Session\SessionServiceProvider::class,
Illuminate\Translation\TranslationServiceProvider::class,
Illuminate\Validation\ValidationServiceProvider::class,
Illuminate\View\ViewServiceProvider::class,
/*
* Package Service Providers...
*/
Barryvdh\DomPDF\ServiceProvider::class,
/*
* Application Service Providers...
*/
App\Providers\AppServiceProvider::class,
App\Providers\AuthServiceProvider::class,
// App\Providers\BroadcastServiceProvider::class,
App\Providers\EventServiceProvider::class,
App\Providers\HorizonServiceProvider::class,
App\Providers\RouteServiceProvider::class,
],
/*
|--------------------------------------------------------------------------
| Class Aliases
|--------------------------------------------------------------------------
|
| This array of class aliases will be registered when this application
| is started. However, feel free to register as many as you wish as
| the aliases are "lazy" loaded so they don't hinder performance.
|
*/
'aliases' => [
'App' => Illuminate\Support\Facades\App::class,
'Arr' => Illuminate\Support\Arr::class,
'Artisan' => Illuminate\Support\Facades\Artisan::class,
'Auth' => Illuminate\Support\Facades\Auth::class,
'Blade' => Illuminate\Support\Facades\Blade::class,
'Broadcast' => Illuminate\Support\Facades\Broadcast::class,
'Bus' => Illuminate\Support\Facades\Bus::class,
'Cache' => Illuminate\Support\Facades\Cache::class,
'Config' => Illuminate\Support\Facades\Config::class,
'Cookie' => Illuminate\Support\Facades\Cookie::class,
'Crypt' => Illuminate\Support\Facades\Crypt::class,
'DB' => Illuminate\Support\Facades\DB::class,
'Eloquent' => Illuminate\Database\Eloquent\Model::class,
'Event' => Illuminate\Support\Facades\Event::class,
'File' => Illuminate\Support\Facades\File::class,
'Gate' => Illuminate\Support\Facades\Gate::class,
'Hash' => Illuminate\Support\Facades\Hash::class,
'Lang' => Illuminate\Support\Facades\Lang::class,
'Log' => Illuminate\Support\Facades\Log::class,
'Mail' => Illuminate\Support\Facades\Mail::class,
'Notification' => Illuminate\Support\Facades\Notification::class,
'Password' => Illuminate\Support\Facades\Password::class,
'PDF' => Barryvdh\DomPDF\Facade::class,
'Queue' => Illuminate\Support\Facades\Queue::class,
'Redirect' => Illuminate\Support\Facades\Redirect::class,
'Redis' => Illuminate\Support\Facades\Redis::class,
'Request' => Illuminate\Support\Facades\Request::class,
'Response' => Illuminate\Support\Facades\Response::class,
'Route' => Illuminate\Support\Facades\Route::class,
'Schema' => Illuminate\Support\Facades\Schema::class,
'Session' => Illuminate\Support\Facades\Session::class,
'Storage' => Illuminate\Support\Facades\Storage::class,
'Str' => Illuminate\Support\Str::class,
'URL' => Illuminate\Support\Facades\URL::class,
'Validator' => Illuminate\Support\Facades\Validator::class,
'View' => Illuminate\Support\Facades\View::class,
],
// Locations of knowledge base articles
'kb' => [
// An article about suspended accounts
'account_suspended' => env('KB_ACCOUNT_SUSPENDED'),
// An article about a way to delete an owned account
'account_delete' => env('KB_ACCOUNT_DELETE'),
],
'company' => [
'name' => env('COMPANY_NAME'),
'address' => env('COMPANY_ADDRESS'),
'details' => env('COMPANY_DETAILS'),
'email' => env('COMPANY_EMAIL'),
'logo' => env('COMPANY_LOGO'),
'footer' => env('COMPANY_FOOTER', env('COMPANY_DETAILS')),
],
'vat' => [
'countries' => env('VAT_COUNTRIES'),
'rate' => (float) env('VAT_RATE'),
],
];
diff --git a/src/config/view.php b/src/config/view.php
index 22b8a18d..de84cc3c 100644
--- a/src/config/view.php
+++ b/src/config/view.php
@@ -1,36 +1,37 @@
[
resource_path('views'),
+ resource_path('themes'),
],
/*
|--------------------------------------------------------------------------
| Compiled View Path
|--------------------------------------------------------------------------
|
| This option determines where all the compiled Blade templates will be
| stored for your application. Typically, this is within the storage
| directory. However, as usual, you are free to change this value.
|
*/
'compiled' => env(
'VIEW_COMPILED_PATH',
realpath(storage_path('framework/views'))
),
];
diff --git a/src/public/images/icons/icon-128x128.png b/src/public/images/icons/icon-128x128.png
deleted file mode 100644
index cf4f4aaa..00000000
Binary files a/src/public/images/icons/icon-128x128.png and /dev/null differ
diff --git a/src/public/images/icons/icon-144x144.png b/src/public/images/icons/icon-144x144.png
deleted file mode 100644
index ce871ea1..00000000
Binary files a/src/public/images/icons/icon-144x144.png and /dev/null differ
diff --git a/src/public/images/icons/icon-152x152.png b/src/public/images/icons/icon-152x152.png
deleted file mode 100644
index fcae506a..00000000
Binary files a/src/public/images/icons/icon-152x152.png and /dev/null differ
diff --git a/src/public/images/icons/icon-192x192.png b/src/public/images/icons/icon-192x192.png
deleted file mode 100644
index 536a8d20..00000000
Binary files a/src/public/images/icons/icon-192x192.png and /dev/null differ
diff --git a/src/public/images/icons/icon-384x384.png b/src/public/images/icons/icon-384x384.png
deleted file mode 100644
index 4573d9dd..00000000
Binary files a/src/public/images/icons/icon-384x384.png and /dev/null differ
diff --git a/src/public/images/icons/icon-512x512.png b/src/public/images/icons/icon-512x512.png
deleted file mode 100644
index 5e6748ed..00000000
Binary files a/src/public/images/icons/icon-512x512.png and /dev/null differ
diff --git a/src/public/images/icons/icon-72x72.png b/src/public/images/icons/icon-72x72.png
deleted file mode 100644
index 8d40cd59..00000000
Binary files a/src/public/images/icons/icon-72x72.png and /dev/null differ
diff --git a/src/public/images/icons/icon-96x96.png b/src/public/images/icons/icon-96x96.png
deleted file mode 100644
index 0db2f3f1..00000000
Binary files a/src/public/images/icons/icon-96x96.png and /dev/null differ
diff --git a/src/public/images/icons/splash-1125x2436.png b/src/public/images/icons/splash-1125x2436.png
deleted file mode 100644
index 05b019ad..00000000
Binary files a/src/public/images/icons/splash-1125x2436.png and /dev/null differ
diff --git a/src/public/images/icons/splash-1242x2208.png b/src/public/images/icons/splash-1242x2208.png
deleted file mode 100644
index 127202e2..00000000
Binary files a/src/public/images/icons/splash-1242x2208.png and /dev/null differ
diff --git a/src/public/images/icons/splash-1242x2688.png b/src/public/images/icons/splash-1242x2688.png
deleted file mode 100644
index dec52b6a..00000000
Binary files a/src/public/images/icons/splash-1242x2688.png and /dev/null differ
diff --git a/src/public/images/icons/splash-1536x2048.png b/src/public/images/icons/splash-1536x2048.png
deleted file mode 100644
index 2008c757..00000000
Binary files a/src/public/images/icons/splash-1536x2048.png and /dev/null differ
diff --git a/src/public/images/icons/splash-1668x2224.png b/src/public/images/icons/splash-1668x2224.png
deleted file mode 100644
index 7fac0728..00000000
Binary files a/src/public/images/icons/splash-1668x2224.png and /dev/null differ
diff --git a/src/public/images/icons/splash-1668x2388.png b/src/public/images/icons/splash-1668x2388.png
deleted file mode 100644
index f39d2ceb..00000000
Binary files a/src/public/images/icons/splash-1668x2388.png and /dev/null differ
diff --git a/src/public/images/icons/splash-2048x2732.png b/src/public/images/icons/splash-2048x2732.png
deleted file mode 100644
index 3df934df..00000000
Binary files a/src/public/images/icons/splash-2048x2732.png and /dev/null differ
diff --git a/src/public/images/icons/splash-640x1136.png b/src/public/images/icons/splash-640x1136.png
deleted file mode 100644
index 73a34ad0..00000000
Binary files a/src/public/images/icons/splash-640x1136.png and /dev/null differ
diff --git a/src/public/images/icons/splash-750x1334.png b/src/public/images/icons/splash-750x1334.png
deleted file mode 100644
index a38e09d6..00000000
Binary files a/src/public/images/icons/splash-750x1334.png and /dev/null differ
diff --git a/src/public/images/icons/splash-828x1792.png b/src/public/images/icons/splash-828x1792.png
deleted file mode 100644
index f6de06c2..00000000
Binary files a/src/public/images/icons/splash-828x1792.png and /dev/null differ
diff --git a/src/public/mix-manifest.json b/src/public/mix-manifest.json
deleted file mode 100644
index b0618f8e..00000000
--- a/src/public/mix-manifest.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "/js/admin.js": "/js/admin.js",
- "/js/meet.js": "/js/meet.js",
- "/js/user.js": "/js/user.js",
- "/css/app.css": "/css/app.css",
- "/css/document.css": "/css/document.css"
-}
diff --git a/src/readme.md b/src/readme.md
deleted file mode 100644
index f95b2ec9..00000000
--- a/src/readme.md
+++ /dev/null
@@ -1,72 +0,0 @@
-
-
-
-
-
-
-
-
-
-## About Laravel
-
-Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
-
-- [Simple, fast routing engine](https://laravel.com/docs/routing).
-- [Powerful dependency injection container](https://laravel.com/docs/container).
-- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
-- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
-- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
-- [Robust background job processing](https://laravel.com/docs/queues).
-- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
-
-Laravel is accessible, powerful, and provides tools required for large, robust applications.
-
-## Learning Laravel
-
-Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.
-
-If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains over 1500 video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
-
-## Laravel Sponsors
-
-We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the Laravel [Patreon page](https://patreon.com/taylorotwell).
-
-- **[Vehikl](https://vehikl.com/)**
-- **[Tighten Co.](https://tighten.co)**
-- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
-- **[64 Robots](https://64robots.com)**
-- **[Cubet Techno Labs](https://cubettech.com)**
-- **[Cyber-Duck](https://cyber-duck.co.uk)**
-- **[British Software Development](https://www.britishsoftware.co)**
-- **[Webdock, Fast VPS Hosting](https://www.webdock.io/en)**
-- **[DevSquad](https://devsquad.com)**
-- [UserInsights](https://userinsights.com)
-- [Fragrantica](https://www.fragrantica.com)
-- [SOFTonSOFA](https://softonsofa.com/)
-- [User10](https://user10.com)
-- [Soumettre.fr](https://soumettre.fr/)
-- [CodeBrisk](https://codebrisk.com)
-- [1Forge](https://1forge.com)
-- [TECPRESSO](https://tecpresso.co.jp/)
-- [Runtime Converter](http://runtimeconverter.com/)
-- [WebL'Agence](https://weblagence.com/)
-- [Invoice Ninja](https://www.invoiceninja.com)
-- [iMi digital](https://www.imi-digital.de/)
-- [Earthlink](https://www.earthlink.ro/)
-- [Steadfast Collective](https://steadfastcollective.com/)
-- [We Are The Robots Inc.](https://watr.mx/)
-- [Understand.io](https://www.understand.io/)
-- [Abdel Elrafa](https://abdelelrafa.com)
-- [Hyper Host](https://hyper.host)
-
-## Contributing
-
-Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
-
-## Security Vulnerabilities
-
-If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
-
-## License
-
-The Laravel framework is open-source software licensed under the [MIT license](https://opensource.org/licenses/MIT).
diff --git a/src/resources/js/app.js b/src/resources/js/app.js
index 1d536e0b..11b98fd9 100644
--- a/src/resources/js/app.js
+++ b/src/resources/js/app.js
@@ -1,375 +1,427 @@
/**
* First we will load all of this project's JavaScript dependencies which
* includes Vue and other libraries. It is a great starting point when
* building robust, powerful web applications using Vue and Laravel.
*/
require('./bootstrap')
import AppComponent from '../vue/App'
import MenuComponent from '../vue/Widgets/Menu'
+import SupportForm from '../vue/Widgets/SupportForm'
import store from './store'
const loader = '
Loading
'
const app = new Vue({
el: '#app',
components: {
AppComponent,
MenuComponent,
},
store,
router: window.router,
data() {
return {
isLoading: true,
isAdmin: window.isAdmin
}
},
methods: {
// Clear (bootstrap) form validation state
clearFormValidation(form) {
$(form).find('.is-invalid').removeClass('is-invalid')
$(form).find('.invalid-feedback').remove()
},
hasRoute(name) {
return this.$router.resolve({ name: name }).resolved.matched.length > 0
},
hasBeta(name) {
const authInfo = store.state.authInfo
return authInfo.statusInfo.betaSKUs && authInfo.statusInfo.betaSKUs.indexOf(name) != -1
},
isController(wallet_id) {
if (wallet_id && store.state.authInfo) {
let i
for (i = 0; i < store.state.authInfo.wallets.length; i++) {
if (wallet_id == store.state.authInfo.wallets[i].id) {
return true
}
}
for (i = 0; i < store.state.authInfo.accounts.length; i++) {
if (wallet_id == store.state.authInfo.accounts[i].id) {
return true
}
}
}
return false
},
// Set user state to "logged in"
loginUser(response, dashboard, update) {
if (!update) {
store.commit('logoutUser') // destroy old state data
store.commit('loginUser')
}
localStorage.setItem('token', response.access_token)
axios.defaults.headers.common.Authorization = 'Bearer ' + response.access_token
if (response.email) {
store.state.authInfo = response
}
if (dashboard !== false) {
this.$router.push(store.state.afterLogin || { name: 'dashboard' })
}
store.state.afterLogin = null
// Refresh the token before it expires
let timeout = response.expires_in || 0
// We'll refresh 60 seconds before the token expires
if (timeout > 60) {
timeout -= 60
}
// TODO: We probably should try a few times in case of an error
// TODO: We probably should prevent axios from doing any requests
// while the token is being refreshed
this.refreshTimeout = setTimeout(() => {
axios.post('/api/auth/refresh').then(response => {
this.loginUser(response.data, false, true)
})
}, timeout * 1000)
},
// Set user state to "not logged in"
logoutUser(redirect) {
store.commit('logoutUser')
localStorage.setItem('token', '')
delete axios.defaults.headers.common.Authorization
if (redirect !== false) {
if (this.hasRoute('login')) {
this.$router.push({ name: 'login' })
}
}
clearTimeout(this.refreshTimeout)
},
// Display "loading" overlay inside of the specified element
addLoader(elem) {
$(elem).css({position: 'relative'}).append($(loader).addClass('small'))
},
// Remove loader element added in addLoader()
removeLoader(elem) {
$(elem).find('.app-loader').remove()
},
startLoading() {
this.isLoading = true
// Lock the UI with the 'loading...' element
let loading = $('#app > .app-loader').removeClass('fadeOut')
if (!loading.length) {
$('#app').append($(loader))
}
},
// Hide "loading" overlay
stopLoading() {
$('#app > .app-loader').addClass('fadeOut')
this.isLoading = false
},
errorPage(code, msg) {
// Until https://github.com/vuejs/vue-router/issues/977 is implemented
// we can't really use router to display error page as it has two side
// effects: it changes the URL and adds the error page to browser history.
// For now we'll be replacing current view with error page "manually".
const map = {
400: "Bad request",
401: "Unauthorized",
403: "Access denied",
404: "Not found",
405: "Method not allowed",
500: "Internal server error"
}
if (!msg) msg = map[code] || "Unknown Error"
- const error_page = `
${code}
${msg}
`
+ const error_page = `
${code}
${msg}
`
$('#error-page').remove()
$('#app').append(error_page)
+
+ app.updateBodyClass('error')
},
errorHandler(error) {
this.stopLoading()
if (!error.response) {
// TODO: probably network connection error
} else if (error.response.status === 401) {
- if (!store.state.afterLogin && this.$router.currentRoute.name != 'login') {
- store.state.afterLogin = this.$router.currentRoute
+ // Remember requested route to come back to it after log in
+ if (this.$route.meta.requiresAuth) {
+ store.state.afterLogin = this.$route
}
this.logoutUser()
} else {
this.errorPage(error.response.status, error.response.statusText)
}
},
downloadFile(url) {
// TODO: This might not be a best way for big files as the content
// will be stored (temporarily) in browser memory
// TODO: This method does not show the download progress in the browser
// but it could be implemented in the UI, axios has 'progress' property
axios.get(url, { responseType: 'blob' })
.then(response => {
const link = document.createElement('a')
const contentDisposition = response.headers['content-disposition']
let filename = 'unknown'
if (contentDisposition) {
const match = contentDisposition.match(/filename="(.+)"/);
if (match.length === 2) {
filename = match[1];
}
}
link.href = window.URL.createObjectURL(response.data)
link.download = filename
link.click()
})
},
price(price, currency) {
return ((price || 0) / 100).toLocaleString('de-DE', { style: 'currency', currency: currency || 'CHF' })
},
priceLabel(cost, units = 1, discount) {
let index = ''
if (units < 0) {
units = 1
}
if (discount) {
cost = Math.floor(cost * ((100 - discount) / 100))
index = '\u00B9'
}
return this.price(cost * units) + '/month' + index
},
clickRecord(event) {
if (!/^(a|button|svg|path)$/i.test(event.target.nodeName)) {
let link = $(event.target).closest('tr').find('a')[0]
if (link) {
link.click()
}
}
},
domainStatusClass(domain) {
if (domain.isDeleted) {
return 'text-muted'
}
if (domain.isSuspended) {
return 'text-warning'
}
if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) {
return 'text-danger'
}
return 'text-success'
},
domainStatusText(domain) {
if (domain.isDeleted) {
return 'Deleted'
}
if (domain.isSuspended) {
return 'Suspended'
}
if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) {
return 'Not Ready'
}
return 'Active'
},
+ pageName(path) {
+ let page = this.$route.path
+
+ // check if it is a "menu page", find the page name
+ // otherwise we'll use the real path as page name
+ window.config.menu.every(item => {
+ if (item.location == page && item.page) {
+ page = item.page
+ return false
+ }
+ })
+
+ page = page.replace(/^\//, '')
+
+ return page ? page : '404'
+ },
+ supportDialog(container) {
+ let dialog = $('#support-dialog')
+
+ // FIXME: Find a nicer way of doing this
+ if (!dialog.length) {
+ let form = new Vue(SupportForm)
+ form.$mount($('
').appendTo(container)[0])
+ form.$root = this
+ form.$toast = this.$toast
+ dialog = $(form.$el)
+ }
+
+ dialog.on('shown.bs.modal', () => {
+ dialog.find('input').first().focus()
+ }).modal()
+ },
userStatusClass(user) {
if (user.isDeleted) {
return 'text-muted'
}
if (user.isSuspended) {
return 'text-warning'
}
if (!user.isImapReady || !user.isLdapReady) {
return 'text-danger'
}
return 'text-success'
},
userStatusText(user) {
if (user.isDeleted) {
return 'Deleted'
}
if (user.isSuspended) {
return 'Suspended'
}
if (!user.isImapReady || !user.isLdapReady) {
return 'Not Ready'
}
return 'Active'
+ },
+ updateBodyClass(name) {
+ // Add 'class' attribute to the body, different for each page
+ // so, we can apply page-specific styles
+ let className = 'page-' + (name || this.pageName()).replace(/\/.*$/, '')
+ $(document.body).removeClass().addClass(className)
}
}
})
// Add a axios request interceptor
window.axios.interceptors.request.use(
config => {
// This is the only way I found to change configuration options
// on a running application. We need this for browser testing.
config.headers['X-Test-Payment-Provider'] = window.config.paymentProvider
return config
},
error => {
// Do something with request error
return Promise.reject(error)
}
)
// Add a axios response interceptor for general/validation error handler
window.axios.interceptors.response.use(
response => {
// Do nothing
return response
},
error => {
let error_msg
let status = error.response ? error.response.status : 200
// Do not display the error in a toast message, pass the error as-is
if (error.config.ignoreErrors) {
return Promise.reject(error)
}
if (error.response && status == 422) {
error_msg = "Form validation error"
const modal = $('div.modal.show')
$(modal.length ? modal : 'form').each((i, form) => {
form = $(form)
$.each(error.response.data.errors || {}, (idx, msg) => {
const input_name = (form.data('validation-prefix') || form.find('form').first().data('validation-prefix') || '') + idx
let input = form.find('#' + input_name)
if (!input.length) {
input = form.find('[name="' + input_name + '"]');
}
if (input.length) {
// Create an error message\
// API responses can use a string, array or object
let msg_text = ''
if ($.type(msg) !== 'string') {
$.each(msg, (index, str) => {
msg_text += str + ' '
})
}
else {
msg_text = msg
}
let feedback = $('
').text(msg_text)
if (input.is('.list-input')) {
// List input widget
input.children(':not(:first-child)').each((index, element) => {
if (msg[index]) {
$(element).find('input').addClass('is-invalid')
}
})
input.addClass('is-invalid').next('.invalid-feedback').remove()
input.after(feedback)
}
else {
// Standard form element
input.addClass('is-invalid')
input.parent().find('.invalid-feedback').remove()
input.parent().append(feedback)
}
}
})
form.find('.is-invalid:not(.listinput-widget)').first().focus()
})
}
else if (error.response && error.response.data) {
error_msg = error.response.data.message
}
else {
error_msg = error.request ? error.request.statusText : error.message
}
app.$toast.error(error_msg || "Server Error")
// Pass the error as-is
return Promise.reject(error)
}
)
+
+// TODO: Investigate if we can use App component's childMounted() method instead
+window.router.afterEach((to, from) => {
+ // When changing a page remove old:
+ // - error page
+ // - modal backdrop
+ $('#error-page,.modal-backdrop.show').remove()
+
+ app.updateBodyClass()
+})
diff --git a/src/resources/js/bootstrap.js b/src/resources/js/bootstrap.js
index 88b4aecd..d12f5c26 100644
--- a/src/resources/js/bootstrap.js
+++ b/src/resources/js/bootstrap.js
@@ -1,101 +1,94 @@
/**
* We'll load jQuery and the Bootstrap jQuery plugin which provides support
* for JavaScript based Bootstrap features such as modals and tabs. This
* code may be modified to fit the specific needs of your application.
*/
window.Popper = require('popper.js').default
window.$ = window.jQuery = require('jquery')
require('bootstrap')
/**
* We'll load Vue, VueRouter and global components
*/
import FontAwesomeIcon from './fontawesome'
import VueRouter from 'vue-router'
import Toast from '../vue/Widgets/Toast'
import store from './store'
window.Vue = require('vue')
Vue.component('svg-icon', FontAwesomeIcon)
const vTooltip = (el, binding) => {
const t = []
if (binding.modifiers.focus) t.push('focus')
if (binding.modifiers.hover) t.push('hover')
if (binding.modifiers.click) t.push('click')
if (!t.length) t.push('hover')
$(el).tooltip({
title: binding.value,
placement: binding.arg || 'top',
trigger: t.join(' '),
html: !!binding.modifiers.html,
});
}
Vue.directive('tooltip', {
bind: vTooltip,
update: vTooltip,
unbind (el) {
$(el).tooltip('dispose')
}
})
Vue.use(Toast)
Vue.use(VueRouter)
let vueRouterBase = '/'
try {
let url = new URL(window.config['app.url'])
vueRouterBase = url.pathname
} catch(e) {
// ignore
}
window.router = new VueRouter({
base: vueRouterBase,
mode: 'history',
routes: window.routes,
scrollBehavior (to, from, savedPosition) {
// Scroll the page to top, but not on Back action
return savedPosition || { x: 0, y: 0 }
}
})
router.beforeEach((to, from, next) => {
// check if the route requires authentication and user is not logged in
if (to.matched.some(route => route.meta.requiresAuth) && !store.state.isLoggedIn) {
// remember the original request, to use after login
store.state.afterLogin = to;
// redirect to login page
next({ name: 'login' })
return
}
next()
})
-router.afterEach((to, from) => {
- // When changing a page remove old:
- // - error page
- // - modal backdrop
- $('#error-page,.modal-backdrop.show').remove()
-})
-
/**
* We'll load the axios HTTP library which allows us to easily issue requests
* to our Laravel back-end. This library automatically handles sending the
* CSRF token as a header based on the value of the "XSRF" token cookie.
*/
window.axios = require('axios')
axios.defaults.baseURL = vueRouterBase
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'
diff --git a/src/resources/js/fontawesome.js b/src/resources/js/fontawesome.js
index 18fc16a8..aadaba73 100644
--- a/src/resources/js/fontawesome.js
+++ b/src/resources/js/fontawesome.js
@@ -1,57 +1,59 @@
import { library } from '@fortawesome/fontawesome-svg-core'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
//import { } from '@fortawesome/free-brands-svg-icons'
import {
faCheckSquare,
faCreditCard,
faSquare,
} from '@fortawesome/free-regular-svg-icons'
import {
faCheck,
faCheckCircle,
faComments,
faDownload,
+ faEnvelope,
faGlobe,
faExclamationCircle,
faInfoCircle,
faLock,
faKey,
faPlus,
faSearch,
faSignInAlt,
faSyncAlt,
faTrashAlt,
faUser,
faUserCog,
faUsers,
faWallet
} from '@fortawesome/free-solid-svg-icons'
// Register only these icons we need
library.add(
faCheck,
faCheckCircle,
faCheckSquare,
faComments,
faCreditCard,
faDownload,
+ faEnvelope,
faExclamationCircle,
faGlobe,
faInfoCircle,
faLock,
faKey,
faPlus,
faSearch,
faSignInAlt,
faSquare,
faSyncAlt,
faTrashAlt,
faUser,
faUserCog,
faUsers,
faWallet
)
export default FontAwesomeIcon
diff --git a/src/resources/js/routes-admin.js b/src/resources/js/routes-admin.js
index 27665db2..c34aa428 100644
--- a/src/resources/js/routes-admin.js
+++ b/src/resources/js/routes-admin.js
@@ -1,48 +1,48 @@
import DashboardComponent from '../vue/Admin/Dashboard'
import DomainComponent from '../vue/Admin/Domain'
-import Error404Component from '../vue/404'
import LoginComponent from '../vue/Login'
import LogoutComponent from '../vue/Logout'
+import PageComponent from '../vue/Page'
import UserComponent from '../vue/Admin/User'
const routes = [
{
path: '/',
redirect: { name: 'dashboard' }
},
{
path: '/dashboard',
name: 'dashboard',
component: DashboardComponent,
meta: { requiresAuth: true }
},
{
path: '/domain/:domain',
name: 'domain',
component: DomainComponent,
meta: { requiresAuth: true }
},
{
path: '/login',
name: 'login',
component: LoginComponent
},
{
path: '/logout',
name: 'logout',
component: LogoutComponent
},
{
path: '/user/:user',
name: 'user',
component: UserComponent,
meta: { requiresAuth: true }
},
{
name: '404',
path: '*',
- component: Error404Component
+ component: PageComponent
}
]
export default routes
diff --git a/src/resources/js/routes-user.js b/src/resources/js/routes-user.js
index fd0c89c3..a083dbc5 100644
--- a/src/resources/js/routes-user.js
+++ b/src/resources/js/routes-user.js
@@ -1,103 +1,99 @@
import DashboardComponent from '../vue/Dashboard'
import DomainInfoComponent from '../vue/Domain/Info'
import DomainListComponent from '../vue/Domain/List'
-import Error404Component from '../vue/404'
import LoginComponent from '../vue/Login'
import LogoutComponent from '../vue/Logout'
import MeetComponent from '../vue/Rooms'
+import PageComponent from '../vue/Page'
import PasswordResetComponent from '../vue/PasswordReset'
import SignupComponent from '../vue/Signup'
import UserInfoComponent from '../vue/User/Info'
import UserListComponent from '../vue/User/List'
import UserProfileComponent from '../vue/User/Profile'
import UserProfileDeleteComponent from '../vue/User/ProfileDelete'
import WalletComponent from '../vue/Wallet'
const routes = [
- {
- path: '/',
- redirect: { name: 'dashboard' }
- },
{
path: '/dashboard',
name: 'dashboard',
component: DashboardComponent,
meta: { requiresAuth: true }
},
{
path: '/domain/:domain',
name: 'domain',
component: DomainInfoComponent,
meta: { requiresAuth: true }
},
{
path: '/domains',
name: 'domains',
component: DomainListComponent,
meta: { requiresAuth: true }
},
{
path: '/login',
name: 'login',
component: LoginComponent
},
{
path: '/logout',
name: 'logout',
component: LogoutComponent
},
{
path: '/password-reset/:code?',
name: 'password-reset',
component: PasswordResetComponent
},
{
path: '/profile',
name: 'profile',
component: UserProfileComponent,
meta: { requiresAuth: true }
},
{
path: '/profile/delete',
name: 'profile-delete',
component: UserProfileDeleteComponent,
meta: { requiresAuth: true }
},
{
path: '/rooms',
name: 'rooms',
component: MeetComponent,
meta: { requiresAuth: true }
},
{
path: '/signup/:param?',
alias: '/signup/voucher/:param',
name: 'signup',
component: SignupComponent
},
{
path: '/user/:user',
name: 'user',
component: UserInfoComponent,
meta: { requiresAuth: true }
},
{
path: '/users',
name: 'users',
component: UserListComponent,
meta: { requiresAuth: true }
},
{
path: '/wallet',
name: 'wallet',
component: WalletComponent,
meta: { requiresAuth: true }
},
{
name: '404',
path: '*',
- component: Error404Component
+ component: PageComponent
}
]
export default routes
diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php
index 76974430..97790828 100644
--- a/src/resources/lang/en/app.php
+++ b/src/resources/lang/en/app.php
@@ -1,56 +1,59 @@
'The auto-payment has been removed.',
'mandate-update-success' => 'The auto-payment has been updated.',
'planbutton' => 'Choose :plan',
'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-success' => 'Setup process finished successfully.',
'process-error-user-ldap-ready' => 'Failed to create a user.',
'process-error-user-imap-ready' => 'Failed to verify that a mailbox exists.',
'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.',
'domain-verify-success' => 'Domain verified successfully.',
'domain-verify-error' => 'Domain ownership verification failed.',
'domain-suspend-success' => 'Domain suspended successfully.',
'domain-unsuspend-success' => 'Domain unsuspended successfully.',
'user-update-success' => 'User data updated successfully.',
'user-create-success' => 'User created successfully.',
'user-delete-success' => 'User deleted successfully.',
'user-suspend-success' => 'User suspended successfully.',
'user-unsuspend-success' => 'User unsuspended successfully.',
'user-reset-2fa-success' => '2-Factor authentication reset successfully.',
'search-foundxdomains' => ':x domains have been found.',
'search-foundxusers' => ':x user accounts have been found.',
+ 'support-request-success' => 'Support request submitted successfully.',
+ 'support-request-error' => 'Failed to submit the support request.',
+
'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.',
'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.',
];
diff --git a/src/resources/themes/README.md b/src/resources/themes/README.md
new file mode 100644
index 00000000..3a07c189
--- /dev/null
+++ b/src/resources/themes/README.md
@@ -0,0 +1,65 @@
+## THEMES
+
+### Creating a theme
+
+1. First create the theme directory and content by copying the default theme:
+
+```
+cp resources/themes/default resources/themes/mytheme
+```
+
+2. Compile resources. This will also make sure to copy static files (e.g. images)
+ to `public/themes/`:
+
+```
+npm run prod
+```
+
+3. Configure the app to use your new theme (in .env file):
+
+```
+APP_THEME=mytheme
+```
+
+### Styles
+
+The main theme directory should include following files:
+
+- "theme.json": Theme metadata, e.g. menu definition.
+- "app.scss": The app styles.
+- "document.scss": Documents styles.
+- "images/logo_header.png": An image that is not controlled by the theme (yet).
+- "images/logo_footer.png": An image that is not controlled by the theme (yet).
+- "images/favicon.ico": An image that is not controlled by the theme (yet).
+
+Note: Applying some styles to `` or other elements outside of the template
+content can be done using `.page-` class that is always added to the ``.
+
+### Menu definition
+
+The menu items are defined using "menu" property in `theme.json` file.
+It should be an array of object. Here are all available properties for such an object.
+
+- "title" (string): The displayed label for the menu item. Required.
+- "location" (string): The page location. Can be a full URL (for external pages)
+ or relative path starting with a slash for internal locations. Required.
+- "page" (string): The name of the page. Required for internal pages.
+ This is the first element of the page template file which should exist
+ in `resources/themes//pages/` directory. The template file name should be
+ `.blade.php`.
+- "footer" (bool): Whether the menu should appear only in the footer menu.
+
+Note that menu definition should not include special pages like "Signup", "Contact" or "Login".
+
+### Page templates
+
+Page content templates placed in `resources/themes//pages/` directory are
+Blade templates. Some notes about that:
+
+- the content will be placed inside the page layout so you should not use nor
+ nor even a wrapper
+ Our technical support team is here to provide help, should you run
+ into issues. You won’t have to talk to computers or navigate voice
+ menus, but have actual human beings answering you personally.
+
+
+ This support is already included in your subscription fee, so
+ there are no further costs for you. If you have issues with your
+ Kolab Now account, or questions about our product before you sign
+ up, please contact us.
+
- Enter your email address to reset your password. You may need to check your spam folder or unblock noreply@kolabnow.com.
+ Enter your email address to reset your password.
+ You may need to check your spam folder or unblock {{ fromEmail }}.
Password Reset - Step 2/3
We sent out a confirmation code to your external email address.
Enter the code we sent you, or click the link in the message.
This will delete the account as well as all domains, users and aliases associated with this account.
This operation is irreversible.
As you will not be able to recover anything after this point, please make sure
that you have migrated all data before proceeding.
-
As we always strive to improve, we would like to ask for 2 minutes of your time.
+
+ 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 support@kolabnow.com.
-
Also feel free to contact Kolab Now Support at support@kolabnow.com with any questions
+ to {{ supportEmail }}.
+
+
Also feel free to contact {{ appName }} Support with any questions
or concerns that you may have in this context.
Here you can download receipts (in PDF format) for payments in specified period.
Select the period and press the Download button.
There are no receipts for payments in this account. Please, note that you can download
receipts after the month ends.
{{ paymentDialogTitle }}
Choose the amount by which you want to top up your wallet.
or
-
+
Add auto-payment, so you never run out.
+
+ The setup of automatic payments failed. Restart the process to enable automatic top-ups.
+
-
-
Auto-payment is set to fill up your account by {{ mandate.amount }} CHF
+
+
+ Auto-payment is set to fill up your account by {{ mandate.amount }} CHF
every time your account balance gets under {{ mandate.balance }} CHF.
You will be charged via {{ mandate.method }}.
-
+
+ The setup of the automatic payment is still in progress.
+
+
The configured auto-payment has been disabled. Top up your wallet or
raise the auto-payment amount.
-
+
You can cancel or change the auto-payment at any time.