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 @@ -

- -

-Build Status -Total Downloads -Latest Stable Version -License -

- -## 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
. +- for internal links use `href="/"`. Such links will be handled by + Vue router (without page reload). +- for images or other resource files use `@theme_asset(images/file.jpg)`. + +See also: https://laravel.com/docs/6.x/blade diff --git a/src/resources/sass/app.scss b/src/resources/themes/app.scss similarity index 98% rename from src/resources/sass/app.scss rename to src/resources/themes/app.scss index 8953d957..ec3abf86 100644 --- a/src/resources/sass/app.scss +++ b/src/resources/themes/app.scss @@ -1,433 +1,427 @@ -@import 'variables'; -@import 'bootstrap'; -@import 'meet'; -@import 'menu'; -@import 'toast'; -@import 'forms'; - html, body, body > .outer-container { height: 100%; } #app { display: flex; flex-direction: column; min-height: 100%; overflow: hidden; & > nav { flex-shrink: 0; z-index: 12; } & > div.container { flex-grow: 1; margin-top: 2rem; margin-bottom: 2rem; } & > .filler { flex-grow: 1; } & > div.container + .filler { display: none; } } -#error-page { +.error-page { position: absolute; top: 0; height: 100%; width: 100%; align-items: center; display: flex; justify-content: center; color: #636b6f; z-index: 10; background: white; .code { text-align: right; border-right: 2px solid; font-size: 26px; padding: 0 15px; } .message { font-size: 18px; padding: 0 15px; } } .app-loader { background-color: $body-bg; height: 100%; width: 100%; position: absolute; top: 0; left: 0; display: flex; align-items: center; justify-content: center; z-index: 8; .spinner-border { width: 120px; height: 120px; border-width: 15px; color: #b2aa99; } &.small .spinner-border { width: 25px; height: 25px; border-width: 3px; } &.fadeOut { visibility: hidden; opacity: 0; transition: visibility 300ms linear, opacity 300ms linear; } } pre { margin: 1rem 0; padding: 1rem; background-color: $menu-bg-color; } .card-title { font-size: 1.2rem; font-weight: bold; } tfoot.table-fake-body { background-color: #f8f8f8; color: grey; text-align: center; td { vertical-align: middle; height: 8em; border: 0; } tbody:not(:empty) + & { display: none; } } table { td.buttons, td.email, td.price, td.datetime, td.selection { width: 1%; white-space: nowrap; } th.price, td.price { width: 1%; text-align: right; white-space: nowrap; } &.form-list { margin: 0; td { border: 0; &:first-child { padding-left: 0; } &:last-child { padding-right: 0; } } button { line-height: 1; } } .btn-action { line-height: 1; padding: 0; } } .list-details { min-height: 1em; & > ul { margin: 0; padding-left: 1.2em; } } .plan-selector { .plan-ico { font-size: 3.8rem; color: #f1a539; border: 3px solid #f1a539; width: 6rem; height: 6rem; margin-bottom: 1rem; border-radius: 50%; } } .plan-description { & > ul { padding-left: 1.2em; &:last-child { margin-bottom: 0; } } } .status-message { display: flex; align-items: center; justify-content: center; .app-loader { width: auto; position: initial; .spinner-border { color: $body-color; } } svg { font-size: 1.5em; } :first-child { margin-right: 0.4em; } } .form-separator { position: relative; margin: 1em 0; display: flex; justify-content: center; hr { border-color: #999; margin: 0; position: absolute; top: 0.75em; width: 100%; } span { background: #fff; padding: 0 1em; z-index: 1; } } #status-box { background-color: lighten($green, 35); .progress { background-color: #fff; height: 10px; } .progress-label { font-size: 0.9em; } .progress-bar { background-color: $green; } &.process-failed { background-color: lighten($orange, 30); .progress-bar { background-color: $red; } } } @keyframes blinker { 50% { opacity: 0; } } .blinker { animation: blinker 750ms step-start infinite; } #dashboard-nav { display: flex; flex-wrap: wrap; justify-content: center; & > a { padding: 1rem; text-align: center; white-space: nowrap; margin: 0.25rem; text-decoration: none; width: 150px; &.disabled { pointer-events: none; opacity: 0.6; } .badge { position: absolute; top: 0.5rem; right: 0.5rem; } } svg { width: 6rem; height: 6rem; margin: auto; } } // Various improvements for mobile @include media-breakpoint-down(sm) { - .card { + .card, + .card-footer { border: 0; } .card-body { padding: 0.5rem 0; } .form-group { margin-bottom: 0.5rem; } .nav-tabs { flex-wrap: nowrap; overflow-x: auto; .nav-link { white-space: nowrap; padding: 0.5rem 0.75rem; } } .tab-content { margin-top: 0.5rem; } .col-form-label { color: #666; font-size: 95%; } .form-group.plaintext .col-form-label { padding-bottom: 0; } form.read-only.short label { width: 35%; & + * { width: 65%; } } #app > div.container { margin-bottom: 1rem; margin-top: 1rem; max-width: 100%; } #header-menu-navbar { padding: 0; } #dashboard-nav > a { width: 135px; } .table-sm:not(.form-list) { tbody td { padding: 0.75rem 0.5rem; svg { vertical-align: -0.175em; } & > svg { font-size: 125%; margin-right: 0.25rem; } } } .table.transactions { thead { display: none; } tbody { tr { position: relative; display: flex; flex-wrap: wrap; } td { width: auto; border: 0; padding: 0.5rem; &.datetime { width: 50%; padding-left: 0; } &.description { order: 3; width: 100%; border-bottom: 1px solid $border-color; color: $secondary; padding: 0 1.5em 0.5rem 0; margin-top: -0.25em; } &.selection { position: absolute; right: 0; border: 0; top: 1.7em; padding-right: 0; } &.price { width: 50%; padding-right: 0; } &.email { display: none; } } } } } diff --git a/src/resources/sass/bootstrap.scss b/src/resources/themes/bootstrap.scss similarity index 100% rename from src/resources/sass/bootstrap.scss rename to src/resources/themes/bootstrap.scss diff --git a/src/resources/sass/_variables.scss b/src/resources/themes/default/_variables.scss similarity index 65% rename from src/resources/sass/_variables.scss rename to src/resources/themes/default/_variables.scss index 7063ee6b..74d0ae15 100644 --- a/src/resources/sass/_variables.scss +++ b/src/resources/themes/default/_variables.scss @@ -1,27 +1,17 @@ // Body $body-bg: #fff; // Typography $font-family-sans-serif: 'Nunito', sans-serif; $font-size-base: 0.9rem; $line-height-base: 1.5; // Colors -$blue: #3490dc; -$indigo: #6574cd; -$purple: #9561e2; -$pink: #f66d9b; -$red: #e3342f; $orange: #f1a539; -$yellow: #ffed4a; -$green: #38c172; -$teal: #4dc0b5; -$cyan: #6cb2eb; - $light: #f6f5f3; // App colors $menu-bg-color: $light; $menu-gray: #575656; $main-color: $orange; $warning: $orange; diff --git a/src/resources/themes/default/app.scss b/src/resources/themes/default/app.scss new file mode 100644 index 00000000..77ad4084 --- /dev/null +++ b/src/resources/themes/default/app.scss @@ -0,0 +1,7 @@ +@import 'variables'; + +@import '../bootstrap'; +@import '../menu'; +@import '../toast'; +@import '../forms'; +@import '../app'; diff --git a/src/resources/themes/default/document.scss b/src/resources/themes/default/document.scss new file mode 100644 index 00000000..2c9155ba --- /dev/null +++ b/src/resources/themes/default/document.scss @@ -0,0 +1,3 @@ +// Variables +@import 'variables'; +@import '../document'; diff --git a/src/public/images/favicon.ico b/src/resources/themes/default/images/favicon.ico similarity index 100% rename from src/public/images/favicon.ico rename to src/resources/themes/default/images/favicon.ico diff --git a/src/public/images/logo.svg b/src/resources/themes/default/images/logo.svg similarity index 100% rename from src/public/images/logo.svg rename to src/resources/themes/default/images/logo.svg diff --git a/src/public/images/logo_footer.png b/src/resources/themes/default/images/logo_footer.png similarity index 100% rename from src/public/images/logo_footer.png rename to src/resources/themes/default/images/logo_footer.png diff --git a/src/public/images/logo_header.png b/src/resources/themes/default/images/logo_header.png similarity index 100% rename from src/public/images/logo_header.png rename to src/resources/themes/default/images/logo_header.png diff --git a/src/public/images/logo_print.svg b/src/resources/themes/default/images/logo_print.svg similarity index 100% rename from src/public/images/logo_print.svg rename to src/resources/themes/default/images/logo_print.svg diff --git a/src/resources/themes/default/pages/support.blade.php b/src/resources/themes/default/pages/support.blade.php new file mode 100644 index 00000000..85e6ae3d --- /dev/null +++ b/src/resources/themes/default/pages/support.blade.php @@ -0,0 +1,21 @@ +
+
+

Contact Support

+

+ 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. +

+
+ +
+
+
diff --git a/src/resources/themes/default/theme.json b/src/resources/themes/default/theme.json new file mode 100644 index 00000000..83a03023 --- /dev/null +++ b/src/resources/themes/default/theme.json @@ -0,0 +1,41 @@ +{ + "menu": [ + { + "title": "Explore", + "location": "https://kolabnow.com/", + "admin": true + }, + { + "title": "Blog", + "location": "https://blogs.kolabnow.com/", + "admin": true + }, + { + "title": "Support", + "location": "/support", + "page": "support", + "admin": true + }, + { + "title": "ToS", + "location": "https://kolabnow.com/tos", + "footer": true + } + ], + "faq": { + "signup": [ + { + "href": "https://kolabnow.com/tos", + "title": "What are your terms of service?" + }, + { + "href": "https://kb.kolabnow.com/faq/can-i-upgrade-an-individual-account-to-a-group-account", + "title": "Can I upgrade an individual account to a group account?" + }, + { + "href": "https://kb.kolabnow.com/faq/how-much-storage-comes-with-my-account", + "title": "How much storage comes with my account?" + } + ] + } +} diff --git a/src/resources/sass/document.scss b/src/resources/themes/document.scss similarity index 97% rename from src/resources/sass/document.scss rename to src/resources/themes/document.scss index e873ff91..efc3e3dd 100644 --- a/src/resources/sass/document.scss +++ b/src/resources/themes/document.scss @@ -1,118 +1,115 @@ -// Variables -@import 'variables'; - @font-face { font-family: 'Roboto'; font-style: normal; font-weight: normal; src: url('../fonts/Roboto-Regular.ttf') format('truetype'); } @font-face { font-family: 'Roboto'; font-style: normal; font-weight: 700; src: url('../fonts/Roboto-Bold.ttf') format('truetype'); } body { font-family: Roboto, sans-serif; margin: 20pt; } a { text-decoration: none; color: $main-color; } h1 { text-align: center; margin: 30pt; font-size: 20pt; } table { width: 100%; &.content { border-spacing: initial; th { padding: 5px; background-color: #f4f4f4; border-top: 1px solid #eee; } td { padding: 5px; border-bottom: 1px solid #eee; } } &.head { margin-bottom: 1em; } td.idents { white-space: nowrap; width: 1%; text-align: right; vertical-align: top; font-size: 10pt; } .price { width: 150px; text-align: right; white-space: nowrap; } td.logo { width: 1%; } td.description { width: 98%; } tr.total { background-color: #f4f4f4; } tr.vat td, tr.subtotal td { border-bottom: 0; } } #footer { font-size: 10pt; color: gray; text-align: center; padding-top: 5pt; position: absolute; margin: 20pt; bottom: 0; left: 0; right: 0; @media print { margin-bottom: 0; } } .bold { font-weight: bold; } .gray { color: gray; } .align-right { text-align: right; } .align-left { text-align: left; } diff --git a/src/resources/sass/forms.scss b/src/resources/themes/forms.scss similarity index 100% rename from src/resources/sass/forms.scss rename to src/resources/themes/forms.scss diff --git a/src/resources/sass/menu.scss b/src/resources/themes/menu.scss similarity index 100% rename from src/resources/sass/menu.scss rename to src/resources/themes/menu.scss index e02f713b..58f683b5 100644 --- a/src/resources/sass/menu.scss +++ b/src/resources/themes/menu.scss @@ -1,151 +1,151 @@ #header-menu { background-color: $menu-bg-color; padding: 0; line-height: 85px; .navbar-brand { padding: 0; outline: 0; > img { display: inline; vertical-align: middle; } } .nav-link { color: #202020; line-height: 85px; padding: 0 0 0 25px; background: transparent; &:focus { text-decoration: underline; outline: 0; } &:hover { color: $main-color; text-decoration: underline; } &.active:not(.menulogin) { font-weight: bold; } } } #footer-menu { background-color: $main-color; height: 100px; overflow: hidden; .navbar-brand { margin: 0; img { width: 170px; } } .footer { text-align: right; color: #fff; font-size: 0.75rem; padding: 0 0.5rem; } } @include media-breakpoint-up(lg) { #header-menu { a.menulogin { text-transform: uppercase; border: 2px solid $main-color; border-radius: 21px; line-height: 21px; letter-spacing: 1px; padding: 6px 34px; margin: 25px 0 25px 25px; &:focus, &:hover { text-decoration: none; background-color: $main-color; color: #fff; font-weight: normal; } } } .navbar { .navbar { justify-content: flex-end; } } #footer-menu { .navbar { flex-direction: column; align-items: flex-end; } } } @include media-breakpoint-down(md) { #header-menu { .navbar-nav { padding-bottom: 1em; } .nav-link { line-height: 45px; padding: 0; } } #footer-menu { + height: 80px; + .navbar-nav { display: none; } .container { flex-wrap: nowrap; } } } @include media-breakpoint-down(sm) { #header-menu { padding: 0 1em; .navbar-nav { display: block; width: 100%; padding: 0; li { border-top: 1px solid #eee; } } } #footer-menu { - height: 80px; - .container { flex-direction: column; } #footer-company { display: none; } } } @media (max-width: 340px) { #header-menu { .navbar-brand img { width: 160px; } } } diff --git a/src/resources/sass/toast.scss b/src/resources/themes/toast.scss similarity index 100% rename from src/resources/sass/toast.scss rename to src/resources/themes/toast.scss diff --git a/src/resources/views/documents/receipt.blade.php b/src/resources/views/documents/receipt.blade.php index ce9e9e2c..b4829d7a 100644 --- a/src/resources/views/documents/receipt.blade.php +++ b/src/resources/views/documents/receipt.blade.php @@ -1,66 +1,66 @@

{{ $title }}

{!! $customer['customer'] !!} {{--{{ __('documents.account-id') }} {{ $customer['wallet_id'] }}
--}} {{ __('documents.customer-no') }} {{ $customer['id'] }}
@foreach ($items as $item) @endforeach @if ($vat) @endif
{{ __('documents.date') }} {{ __('documents.description') }} {{ __('documents.amount') }}
{{ $item['date'] }} {{ $item['description'] }} {{ $item['amount'] }}
{{ __('documents.subtotal') }} {{ $subTotal }}
{{ __('documents.vat', ['rate' => $vatRate]) }} {{ $totalVat }}
{{ __('documents.total') }} {{ $total }}
diff --git a/src/resources/vue/404.vue b/src/resources/vue/404.vue deleted file mode 100644 index 04e64cc3..00000000 --- a/src/resources/vue/404.vue +++ /dev/null @@ -1,6 +0,0 @@ - diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue index 72867877..aead8af1 100644 --- a/src/resources/vue/Admin/User.vue +++ b/src/resources/vue/Admin/User.vue @@ -1,644 +1,656 @@ diff --git a/src/resources/vue/App.vue b/src/resources/vue/App.vue index ba55af7a..53adfeb0 100644 --- a/src/resources/vue/App.vue +++ b/src/resources/vue/App.vue @@ -1,50 +1,93 @@ diff --git a/src/resources/vue/Dashboard.vue b/src/resources/vue/Dashboard.vue index e15b461e..302cc2bb 100644 --- a/src/resources/vue/Dashboard.vue +++ b/src/resources/vue/Dashboard.vue @@ -1,59 +1,63 @@ diff --git a/src/resources/vue/Login.vue b/src/resources/vue/Login.vue index db1373f2..8a1c78aa 100644 --- a/src/resources/vue/Login.vue +++ b/src/resources/vue/Login.vue @@ -1,81 +1,82 @@ diff --git a/src/resources/vue/Page.vue b/src/resources/vue/Page.vue new file mode 100644 index 00000000..077f361a --- /dev/null +++ b/src/resources/vue/Page.vue @@ -0,0 +1,74 @@ + + + diff --git a/src/resources/vue/PasswordReset.vue b/src/resources/vue/PasswordReset.vue index d612d84a..5c43211d 100644 --- a/src/resources/vue/PasswordReset.vue +++ b/src/resources/vue/PasswordReset.vue @@ -1,155 +1,157 @@ diff --git a/src/resources/vue/Signup.vue b/src/resources/vue/Signup.vue index 88a0b600..8b9a260f 100644 --- a/src/resources/vue/Signup.vue +++ b/src/resources/vue/Signup.vue @@ -1,268 +1,260 @@ diff --git a/src/resources/vue/User/ProfileDelete.vue b/src/resources/vue/User/ProfileDelete.vue index 6c1174dc..f6a3d99e 100644 --- a/src/resources/vue/User/ProfileDelete.vue +++ b/src/resources/vue/User/ProfileDelete.vue @@ -1,49 +1,57 @@ diff --git a/src/resources/vue/Wallet.vue b/src/resources/vue/Wallet.vue index 6cad58c7..d6e82952 100644 --- a/src/resources/vue/Wallet.vue +++ b/src/resources/vue/Wallet.vue @@ -1,339 +1,354 @@ diff --git a/src/resources/vue/Widgets/Menu.vue b/src/resources/vue/Widgets/Menu.vue index bd657a63..8d28e904 100644 --- a/src/resources/vue/Widgets/Menu.vue +++ b/src/resources/vue/Widgets/Menu.vue @@ -1,82 +1,109 @@ diff --git a/src/resources/vue/Widgets/SupportForm.vue b/src/resources/vue/Widgets/SupportForm.vue new file mode 100644 index 00000000..78e79ee6 --- /dev/null +++ b/src/resources/vue/Widgets/SupportForm.vue @@ -0,0 +1,97 @@ + + + diff --git a/src/routes/api.php b/src/routes/api.php index 321ea668..db9cf440 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -1,136 +1,147 @@ 'api', 'prefix' => $prefix . 'api/auth' ], function ($router) { Route::post('login', 'API\AuthController@login'); Route::group( ['middleware' => 'auth:api'], function ($router) { Route::get('info', 'API\AuthController@info'); Route::post('logout', 'API\AuthController@logout'); Route::post('refresh', 'API\AuthController@refresh'); } ); } ); Route::group( [ 'domain' => \config('app.domain'), 'middleware' => 'api', 'prefix' => $prefix . 'api/auth' ], function ($router) { Route::post('password-reset/init', 'API\PasswordResetController@init'); Route::post('password-reset/verify', 'API\PasswordResetController@verify'); Route::post('password-reset', 'API\PasswordResetController@reset'); Route::get('signup/plans', 'API\SignupController@plans'); Route::post('signup/init', 'API\SignupController@init'); Route::post('signup/verify', 'API\SignupController@verify'); Route::post('signup', 'API\SignupController@signup'); } ); Route::group( [ 'domain' => \config('app.domain'), 'middleware' => 'auth:api', 'prefix' => $prefix . 'api/v4' ], function () { Route::apiResource('domains', API\V4\DomainsController::class); Route::get('domains/{id}/confirm', 'API\V4\DomainsController@confirm'); Route::get('domains/{id}/status', 'API\V4\DomainsController@status'); Route::apiResource('entitlements', API\V4\EntitlementsController::class); Route::apiResource('packages', API\V4\PackagesController::class); Route::apiResource('skus', API\V4\SkusController::class); Route::apiResource('users', API\V4\UsersController::class); Route::get('users/{id}/skus', 'API\V4\SkusController@userSkus'); Route::get('users/{id}/status', 'API\V4\UsersController@status'); Route::apiResource('wallets', API\V4\WalletsController::class); Route::get('wallets/{id}/transactions', 'API\V4\WalletsController@transactions'); Route::get('wallets/{id}/receipts', 'API\V4\WalletsController@receipts'); Route::get('wallets/{id}/receipts/{receipt}', 'API\V4\WalletsController@receiptDownload'); Route::post('payments', 'API\V4\PaymentsController@store'); Route::get('payments/mandate', 'API\V4\PaymentsController@mandate'); Route::post('payments/mandate', 'API\V4\PaymentsController@mandateCreate'); Route::put('payments/mandate', 'API\V4\PaymentsController@mandateUpdate'); Route::delete('payments/mandate', 'API\V4\PaymentsController@mandateDelete'); Route::get('openvidu/rooms', 'API\V4\OpenViduController@index'); Route::post('openvidu/rooms/{id}/close', 'API\V4\OpenViduController@closeRoom'); } ); // Note: In Laravel 7.x we could just use withoutMiddleware() instead of a separate group Route::group( [ 'domain' => \config('app.domain'), 'prefix' => $prefix . 'api/v4' ], function () { Route::get('openvidu/rooms/{id}', 'API\V4\OpenViduController@joinRoom'); } ); +Route::group( + [ + 'domain' => \config('app.domain'), + 'middleware' => 'api', + 'prefix' => $prefix . 'api/v4' + ], + function ($router) { + Route::post('support/request', 'API\V4\SupportController@request'); + } +); + Route::group( [ 'domain' => \config('app.domain'), 'prefix' => $prefix . 'api/webhooks', ], function () { Route::post('payment/{provider}', 'API\V4\PaymentsController@webhook'); Route::post('meet/openvidu', 'API\V4\OpenViduController@webhook'); } ); Route::group( [ 'domain' => 'admin.' . \config('app.domain'), 'middleware' => ['auth:api', 'admin'], 'prefix' => $prefix . 'api/v4', ], function () { Route::apiResource('domains', API\V4\Admin\DomainsController::class); Route::get('domains/{id}/confirm', 'API\V4\Admin\DomainsController@confirm'); Route::post('domains/{id}/suspend', 'API\V4\Admin\DomainsController@suspend'); Route::post('domains/{id}/unsuspend', 'API\V4\Admin\DomainsController@unsuspend'); Route::apiResource('entitlements', API\V4\Admin\EntitlementsController::class); Route::apiResource('packages', API\V4\Admin\PackagesController::class); Route::apiResource('skus', API\V4\Admin\SkusController::class); Route::apiResource('users', API\V4\Admin\UsersController::class); Route::post('users/{id}/reset2FA', 'API\V4\Admin\UsersController@reset2FA'); Route::get('users/{id}/skus', 'API\V4\Admin\SkusController@userSkus'); Route::post('users/{id}/suspend', 'API\V4\Admin\UsersController@suspend'); Route::post('users/{id}/unsuspend', 'API\V4\Admin\UsersController@unsuspend'); Route::apiResource('wallets', API\V4\Admin\WalletsController::class); Route::post('wallets/{id}/one-off', 'API\V4\Admin\WalletsController@oneOff'); Route::get('wallets/{id}/transactions', 'API\V4\Admin\WalletsController@transactions'); Route::apiResource('discounts', API\V4\Admin\DiscountsController::class); } ); diff --git a/src/routes/web.php b/src/routes/web.php index 0d3adebc..560a4160 100644 --- a/src/routes/web.php +++ b/src/routes/web.php @@ -1,18 +1,23 @@ \config('app.domain'), ], function () { + Route::get('content/page/{page}', 'ContentController@pageContent') + ->where('page', '(.*)'); + Route::get('content/faq/{page}', 'ContentController@faqContent') + ->where('page', '(.*)'); + Route::fallback( function () { $env = \App\Utils::uiEnv(); return view($env['view'])->with('env', $env); } ); } ); diff --git a/src/tests/Browser/Admin/LogonTest.php b/src/tests/Browser/Admin/LogonTest.php index b258c23b..e74b1df0 100644 --- a/src/tests/Browser/Admin/LogonTest.php +++ b/src/tests/Browser/Admin/LogonTest.php @@ -1,145 +1,145 @@ browse(function (Browser $browser) { $browser->visit(new Home()) ->with(new Menu(), function ($browser) { - $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'webmail']); + $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login']); }) ->assertMissing('@second-factor-input') ->assertMissing('@forgot-password'); }); } /** * Test redirect to /login if user is unauthenticated */ public function testLogonRedirect(): void { $this->browse(function (Browser $browser) { $browser->visit('/dashboard'); // Checks if we're really on the login page $browser->waitForLocation('/login') ->on(new Home()); }); } /** * Logon with wrong password/user test */ public function testLogonWrongCredentials(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('jeroen@jeroen.jeroen', 'wrong') // Error message ->assertToast(Toast::TYPE_ERROR, 'Invalid username or password.') // Checks if we're still on the logon page ->on(new Home()); }); } /** * Successful logon test */ public function testLogonSuccessful(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('jeroen@jeroen.jeroen', 'jeroen', true); // Checks if we're really on Dashboard page $browser->on(new Dashboard()) ->within(new Menu(), function ($browser) { - $browser->assertMenuItems(['support', 'contact', 'webmail', 'logout']); + $browser->assertMenuItems(['explore', 'blog', 'support', 'logout']); }) ->assertUser('jeroen@jeroen.jeroen'); // Test that visiting '/' with logged in user does not open logon form // but "redirects" to the dashboard $browser->visit('/')->on(new Dashboard()); }); } /** * Logout test * * @depends testLogonSuccessful */ public function testLogout(): void { $this->browse(function (Browser $browser) { $browser->on(new Dashboard()); // Click the Logout button $browser->within(new Menu(), function ($browser) { $browser->clickMenuItem('logout'); }); // We expect the logon page $browser->waitForLocation('/login') ->on(new Home()); // with default menu $browser->within(new Menu(), function ($browser) { - $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'webmail']); + $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login']); }); // Success toast message $browser->assertToast(Toast::TYPE_SUCCESS, 'Successfully logged out'); }); } /** * Logout by URL test */ public function testLogoutByURL(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('jeroen@jeroen.jeroen', 'jeroen', true); // Checks if we're really on Dashboard page $browser->on(new Dashboard()); // Use /logout url, and expect the logon page $browser->visit('/logout') ->waitForLocation('/login') ->on(new Home()); // with default menu $browser->within(new Menu(), function ($browser) { - $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'webmail']); + $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login']); }); // Success toast message $browser->assertToast(Toast::TYPE_SUCCESS, 'Successfully logged out'); }); } } diff --git a/src/tests/Browser/ErrorTest.php b/src/tests/Browser/ErrorTest.php index aaeec604..5a49ab9a 100644 --- a/src/tests/Browser/ErrorTest.php +++ b/src/tests/Browser/ErrorTest.php @@ -1,38 +1,38 @@ browse(function (Browser $browser) { $browser->visit('/unknown') ->waitFor('#app > #error-page') ->assertVisible('#app > #header-menu') ->assertVisible('#app > #footer-menu'); $this->assertSame('404', $browser->text('#error-page .code')); - $this->assertSame('Not Found', $browser->text('#error-page .message')); + $this->assertSame('Not found', $browser->text('#error-page .message')); }); $this->browse(function (Browser $browser) { $browser->visit('/login/unknown') ->waitFor('#app > #error-page') ->assertVisible('#app > #header-menu') ->assertVisible('#app > #footer-menu'); $this->assertSame('404', $browser->text('#error-page .code')); - $this->assertSame('Not Found', $browser->text('#error-page .message')); + $this->assertSame('Not found', $browser->text('#error-page .message')); }); } } diff --git a/src/tests/Browser/LogonTest.php b/src/tests/Browser/LogonTest.php index 6ca98b9c..f7334c6f 100644 --- a/src/tests/Browser/LogonTest.php +++ b/src/tests/Browser/LogonTest.php @@ -1,211 +1,238 @@ browse(function (Browser $browser) { $browser->visit(new Home()) ->within(new Menu(), function ($browser) { - $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'webmail']); + $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login']); }); if ($browser->isDesktop()) { $browser->within(new Menu('footer'), function ($browser) { - $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'tos', 'webmail']); + $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'tos', 'login']); }); } else { $browser->assertMissing('#footer-menu .navbar-nav'); } + + $browser->assertSeeLink('Forgot password?') + ->assertSeeLink('Webmail'); }); } /** * Test redirect to /login if user is unauthenticated */ - public function testLogonRedirect(): void + public function testRequiredAuth(): void { $this->browse(function (Browser $browser) { $browser->visit('/dashboard'); // Checks if we're really on the login page $browser->waitForLocation('/login') ->on(new Home()); }); } /** * Logon with wrong password/user test */ public function testLogonWrongCredentials(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'wrong'); // Error message $browser->assertToast(Toast::TYPE_ERROR, 'Invalid username or password.'); // Checks if we're still on the logon page $browser->on(new Home()); }); } /** * Successful logon test */ public function testLogonSuccessful(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) // Checks if we're really on Dashboard page ->on(new Dashboard()) ->assertVisible('@links a.link-profile') ->assertVisible('@links a.link-domains') ->assertVisible('@links a.link-users') ->assertVisible('@links a.link-wallet') + ->assertVisible('@links a.link-webmail') ->within(new Menu(), function ($browser) { - $browser->assertMenuItems(['support', 'contact', 'webmail', 'logout']); + $browser->assertMenuItems(['explore', 'blog', 'support', 'logout']); }); if ($browser->isDesktop()) { $browser->within(new Menu('footer'), function ($browser) { - $browser->assertMenuItems(['support', 'contact', 'webmail', 'logout']); + $browser->assertMenuItems(['explore', 'blog', 'support', 'tos', 'logout']); }); } else { $browser->assertMissing('#footer-menu .navbar-nav'); } $browser->assertUser('john@kolab.org'); // Assert no "Account status" for this account $browser->assertMissing('@status'); // Goto /domains and assert that the link on logo element // leads to the dashboard $browser->visit('/domains') ->waitForText('Domains') ->click('a.navbar-brand') ->on(new Dashboard()); // Test that visiting '/' with logged in user does not open logon form // but "redirects" to the dashboard - $browser->visit('/')->on(new Dashboard()); + $browser->visit('/') + ->waitForLocation('/dashboard') + ->on(new Dashboard()); }); } /** * Logout test * * @depends testLogonSuccessful */ public function testLogout(): void { $this->browse(function (Browser $browser) { $browser->on(new Dashboard()); // Click the Logout button $browser->within(new Menu(), function ($browser) { $browser->clickMenuItem('logout'); }); // We expect the logon page $browser->waitForLocation('/login') ->on(new Home()); // with default menu $browser->within(new Menu(), function ($browser) { - $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'webmail']); + $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login']); }); // Success toast message $browser->assertToast(Toast::TYPE_SUCCESS, 'Successfully logged out'); }); } /** * Logout by URL test */ public function testLogoutByURL(): void { $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'simple123', true); // Checks if we're really on Dashboard page $browser->on(new Dashboard()); // Use /logout url, and expect the logon page $browser->visit('/logout') ->waitForLocation('/login') ->on(new Home()); // with default menu $browser->within(new Menu(), function ($browser) { - $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'webmail']); + $browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login']); }); // Success toast message $browser->assertToast(Toast::TYPE_SUCCESS, 'Successfully logged out'); }); } /** * Test 2-Factor Authentication * * @depends testLogoutByURL */ public function test2FA(): void { $this->browse(function (Browser $browser) { // Test missing 2fa code $browser->on(new Home()) ->type('@email-input', 'ned@kolab.org') ->type('@password-input', 'simple123') ->press('form button') ->waitFor('@second-factor-input.is-invalid + .invalid-feedback') ->assertSeeIn( '@second-factor-input.is-invalid + .invalid-feedback', 'Second factor code is required.' ) ->assertFocused('@second-factor-input') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); // Test invalid code $browser->type('@second-factor-input', '123456') ->press('form button') ->waitUntilMissing('@second-factor-input.is-invalid') ->waitFor('@second-factor-input.is-invalid + .invalid-feedback') ->assertSeeIn( '@second-factor-input.is-invalid + .invalid-feedback', 'Second factor code is invalid.' ) ->assertFocused('@second-factor-input') ->assertToast(Toast::TYPE_ERROR, 'Form validation error'); $code = \App\Auth\SecondFactor::code('ned@kolab.org'); // Test valid (TOTP) code $browser->type('@second-factor-input', $code) ->press('form button') ->waitUntilMissing('@second-factor-input.is-invalid') ->waitForLocation('/dashboard') ->on(new Dashboard()); }); } + + /** + * Test redirect to the requested page after logon + * + * @depends test2FA + */ + public function testAfterLogonRedirect(): void + { + $this->browse(function (Browser $browser) { + // User is logged in + $browser->visit(new UserProfile()); + + // Test redirect if the token is invalid + $browser->script("localStorage.setItem('token', '123')"); + $browser->refresh() + ->on(new Home()) + ->submitLogon('john@kolab.org', 'simple123', false) + ->waitForLocation('/profile'); + }); + } } diff --git a/src/tests/Browser/Pages/PaymentMollie.php b/src/tests/Browser/Pages/PaymentMollie.php index 498493a8..0f1c91b0 100644 --- a/src/tests/Browser/Pages/PaymentMollie.php +++ b/src/tests/Browser/Pages/PaymentMollie.php @@ -1,64 +1,65 @@ waitFor('#container'); } /** * Get the element shortcuts for the page. * * @return array */ public function elements(): array { return [ '@form' => '#container', '@title' => '#container .header__info', '@amount' => '#container .header__amount', '@methods' => '#payment-method-list', '@status-table' => 'table.table--select-status', ]; } /** * Submit payment form. * - * @param \Laravel\Dusk\Browser $browser The browser object + * @param \Laravel\Dusk\Browser $browser The browser object + * @param string $state Test payment status (paid, open, failed, canceled, expired) * * @return void */ - public function submitValidCreditCard($browser) + public function submitValidCreditCard($browser, $status = 'paid') { if ($browser->element('@methods')) { $browser->click('@methods button.grid-button-creditcard') ->waitFor('button.form__button'); } - $browser->click('input[value="paid"]') + $browser->click('input[value="' . $status . '"]') ->click('button.form__button'); } } diff --git a/src/tests/Browser/PaymentMollieTest.php b/src/tests/Browser/PaymentMollieTest.php index 5b66eefe..a0db9238 100644 --- a/src/tests/Browser/PaymentMollieTest.php +++ b/src/tests/Browser/PaymentMollieTest.php @@ -1,233 +1,307 @@ deleteTestUser('payment-test@kolabnow.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('payment-test@kolabnow.com'); parent::tearDown(); } /** * Test the payment process * * @group mollie */ public function testPayment(): void { $user = $this->getTestUser('payment-test@kolabnow.com', [ 'password' => 'simple123', ]); $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('payment-test@kolabnow.com', 'simple123', true, ['paymentProvider' => 'mollie']) ->on(new Dashboard()) ->click('@links .link-wallet') ->on(new WalletPage()) ->assertSeeIn('@main button', 'Add credit') ->click('@main button') ->with(new Dialog('@payment-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Top up your wallet') ->assertFocused('#amount') ->assertSeeIn('@button-cancel', 'Cancel') ->assertSeeIn('@body #payment-form button', 'Continue') // Test error handling ->type('@body #amount', 'aaa') ->click('@body #payment-form button') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->assertSeeIn('#amount + span + .invalid-feedback', 'The amount must be a number.') // Submit valid data ->type('@body #amount', '12.34') ->click('@body #payment-form button'); }) ->on(new PaymentMollie()) ->assertSeeIn('@title', \config('app.name') . ' Payment') ->assertSeeIn('@amount', 'CHF 12.34'); // Looks like the Mollie testing mode is limited. // We'll select credit card method and mark the payment as paid // We can't do much more, we have to trust Mollie their page works ;) // For some reason I don't get the method selection form, it // immediately jumps to the next step. Let's detect that if ($browser->element('@methods')) { $browser->click('@methods button.grid-button-creditcard') ->waitFor('button.form__button'); } $browser->click('@status-table input[value="paid"]') ->click('button.form__button'); // Now it should redirect back to wallet page and in background // use the webhook to update payment status (and balance). // Looks like in test-mode the webhook is executed before redirect // so we can expect balance updated on the wallet page $browser->waitForLocation('/wallet') ->on(new WalletPage()) ->assertSeeIn('@main .card-title', 'Account balance 12,34 CHF'); }); } /** * Test the auto-payment setup process * * @group mollie */ public function testAutoPaymentSetup(): void { $user = $this->getTestUser('payment-test@kolabnow.com', [ 'password' => 'simple123', ]); $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('payment-test@kolabnow.com', 'simple123', true, ['paymentProvider' => 'mollie']) ->on(new Dashboard()) ->click('@links .link-wallet') ->on(new WalletPage()) ->click('@main button') ->with(new Dialog('@payment-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Top up your wallet') ->assertSeeIn('@button-cancel', 'Cancel') ->assertSeeIn('@body #mandate-form button', 'Set up auto-payment') + ->assertSeeIn('@body #mandate-form p', 'Add auto-payment, so you never') + ->assertMissing('@body #mandate-form .alert') ->click('@body #mandate-form button') ->assertSeeIn('@title', 'Add auto-payment') ->assertSeeIn('@body label[for="mandate_amount"]', 'Fill up by') ->assertValue('@body #mandate_amount', PaymentProvider::MIN_AMOUNT / 100) ->assertSeeIn('@body label[for="mandate_balance"]', 'when account balance is below') // phpcs:ignore ->assertValue('@body #mandate_balance', '0') ->assertSeeIn('@button-cancel', 'Cancel') ->assertSeeIn('@button-action', 'Continue') // Test error handling ->type('@body #mandate_amount', 'aaa') ->type('@body #mandate_balance', '-1') ->click('@button-action') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->assertVisible('@body #mandate_amount.is-invalid') ->assertVisible('@body #mandate_balance.is-invalid') ->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.') ->assertSeeIn('#mandate_balance + span + .invalid-feedback', 'The balance must be at least 0.') ->type('@body #mandate_amount', 'aaa') ->type('@body #mandate_balance', '0') ->click('@button-action') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->assertVisible('@body #mandate_amount.is-invalid') ->assertMissing('@body #mandate_balance.is-invalid') ->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.') ->assertMissing('#mandate_balance + span + .invalid-feedback') // Submit valid data ->type('@body #mandate_amount', '100') ->type('@body #mandate_balance', '0') ->click('@button-action'); }) ->on(new PaymentMollie()) ->assertSeeIn('@title', \config('app.name') . ' Auto-Payment Setup') ->assertMissing('@amount') ->submitValidCreditCard() ->waitForLocation('/wallet') ->visit('/wallet?paymentProvider=mollie') ->on(new WalletPage()) ->click('@main button') ->with(new Dialog('@payment-dialog'), function (Browser $browser) { $expected = 'Auto-payment is set to fill up your account by 100 CHF every' . ' time your account balance gets under 0 CHF. You will be charged' . ' via Mastercard (**** **** **** 6787).'; $browser->assertSeeIn('@title', 'Top up your wallet') ->waitFor('#mandate-info') ->assertSeeIn('#mandate-info p:first-child', $expected) + ->assertMissing('@body .alert') ->click('@button-cancel'); }); }); - // Test updating auto-payment + // Test updating (disabled) auto-payment $this->browse(function (Browser $browser) use ($user) { $wallet = $user->wallets()->first(); $wallet->setSetting('mandate_disabled', 1); $browser->refresh() ->on(new WalletPage()) ->click('@main button') ->with(new Dialog('@payment-dialog'), function (Browser $browser) { $browser->waitFor('@body #mandate-info') ->assertSeeIn( - '@body #mandate-info p.disabled-mandate', + '@body #mandate-info .disabled-mandate', 'The configured auto-payment has been disabled' ) ->assertSeeIn('@body #mandate-info button.btn-primary', 'Change auto-payment') ->click('@body #mandate-info button.btn-primary') ->assertSeeIn('@title', 'Update auto-payment') ->assertSeeIn( - '@body form p.disabled-mandate', + '@body form .disabled-mandate', 'The auto-payment is disabled.' ) ->assertValue('@body #mandate_amount', '100') ->assertValue('@body #mandate_balance', '0') ->assertSeeIn('@button-cancel', 'Cancel') ->assertSeeIn('@button-action', 'Submit') // Test error handling ->type('@body #mandate_amount', 'aaa') ->click('@button-action') ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->assertVisible('@body #mandate_amount.is-invalid') ->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.') // Submit valid data ->type('@body #mandate_amount', '50') ->click('@button-action'); }) ->waitUntilMissing('#payment-dialog') ->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been updated.') // Open the dialog again and make sure the "disabled" text isn't there ->click('@main button') ->with(new Dialog('@payment-dialog'), function (Browser $browser) { - $browser->assertMissing('@body #mandate-info p.disabled-mandate') + $browser->assertMissing('@body #mandate-info .disabled-mandate') ->click('@body #mandate-info button.btn-primary') - ->assertMissing('@body form p.disabled-mandate') + ->assertMissing('@body form .disabled-mandate') ->click('@button-cancel'); }); }); // Test deleting auto-payment $this->browse(function (Browser $browser) { $browser->on(new WalletPage()) ->click('@main button') ->with(new Dialog('@payment-dialog'), function (Browser $browser) { $browser->assertSeeIn('@body #mandate-info button.btn-danger', 'Cancel auto-payment') ->click('@body #mandate-info button.btn-danger') ->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been removed.') ->assertVisible('@body #mandate-form') - ->assertMissing('@body #mandate-info'); + ->assertMissing('@body #mandate-info') + ->click('@button-cancel'); + }); + }); + + // Test pending and failed mandate + $this->browse(function (Browser $browser) { + $browser->on(new WalletPage()) + ->click('@main button') + ->with(new Dialog('@payment-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'Top up your wallet') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@body #mandate-form button', 'Set up auto-payment') + ->assertSeeIn('@body #mandate-form p', 'Add auto-payment, so you never') + ->assertMissing('@body #mandate-form .alert') + ->click('@body #mandate-form button') + ->assertSeeIn('@title', 'Add auto-payment') + ->assertMissing('@body .alert') + // Submit valid data + ->type('@body #mandate_amount', '100') + ->type('@body #mandate_balance', '0') + ->click('@button-action'); + }) + ->on(new PaymentMollie()) + ->submitValidCreditCard('open') + ->waitForLocation('/wallet') + ->visit('/wallet?paymentProvider=mollie') + ->on(new WalletPage()) + ->click('@main button') + ->with(new Dialog('@payment-dialog'), function (Browser $browser) { + $expected = 'Auto-payment is set to fill up your account by 100 CHF every' + . ' time your account balance gets under 0 CHF. You will be charged' + . ' via Credit Card.'; + + $browser->assertSeeIn('@title', 'Top up your wallet') + ->waitFor('#mandate-info') + ->assertSeeIn('#mandate-info p:first-child', $expected) + ->assertSeeIn( + '#mandate-info .alert-warning', + 'The setup of the automatic payment is still in progress.' + ) + ->assertSeeIn('@body #mandate-info .btn-danger', 'Cancel auto-payment') + ->assertSeeIn('@body #mandate-info .btn-primary', 'Change auto-payment') + // Delete the mandate + ->click('@body #mandate-info .btn-danger') + ->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been removed.') + ->assertSeeIn('@body #mandate-form p', 'Add auto-payment, so you never') + ->assertMissing('@body #mandate-form .alert') + ->click('@body #mandate-form button') + ->assertSeeIn('@title', 'Add auto-payment') + ->assertMissing('@body .alert') + // Submit valid data + ->type('@body #mandate_amount', '100') + ->type('@body #mandate_balance', '0') + ->click('@button-action'); + }) + ->on(new PaymentMollie()) + ->submitValidCreditCard('failed') + ->waitForLocation('/wallet') + ->visit('/wallet?paymentProvider=mollie') + ->on(new WalletPage()) + ->click('@main button') + ->with(new Dialog('@payment-dialog'), function (Browser $browser) { + $browser->waitFor('#mandate-form') + ->assertMissing('#mandate-info') + ->assertSeeIn('#mandate-form p', 'Add auto-payment') + ->waitFor('#mandate-form .alert-danger') + ->assertSeeIn( + '#mandate-form .alert-danger', + 'The setup of automatic payments failed. Restart the process to enable' + ) + ->assertSeeIn('@body #mandate-form .btn-primary', 'Set up auto-payment'); }); }); } } diff --git a/src/tests/Browser/SupportTest.php b/src/tests/Browser/SupportTest.php new file mode 100644 index 00000000..e9e7651b --- /dev/null +++ b/src/tests/Browser/SupportTest.php @@ -0,0 +1,56 @@ +browse(function (Browser $browser) { + $browser->visit('/') + ->within(new Menu(), function ($browser) { + $browser->clickMenuItem('support'); + }) + ->waitFor('#support') + ->assertSeeIn('.card-title', 'Contact Support') + ->assertSeeIn('a.btn-info', 'Contact Support') + ->click('a.btn-info') + ->with(new Dialog('#support-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'Contact Support') + ->assertFocused('#support-user') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@button-action', 'Submit') + ->assertVisible('#support-name') + ->assertVisible('#support-email') + ->assertVisible('#support-summary') + ->assertVisible('#support-body') + ->type('#support-email', 'email@address.com') + ->type('#support-summary', 'Summary') + ->type('#support-body', 'Body') + ->click('@button-cancel'); + }) + ->assertMissing('#support-dialog') + ->click('a.btn-info') + ->with(new Dialog('#support-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'Contact Support') + ->assertFocused('#support-user') + ->assertValue('#support-email', 'email@address.com') + ->assertValue('#support-summary', 'Summary') + ->assertValue('#support-body', 'Body') + ->click('@button-action'); + }) + // Note: This line assumes SUPPORT_EMAIL is not set in config + ->assertToast(Toast::TYPE_ERROR, 'Failed to submit the support request') + ->assertVisible('#support-dialog'); + }); + } +} diff --git a/src/tests/Feature/Controller/PaymentsMollieTest.php b/src/tests/Feature/Controller/PaymentsMollieTest.php index 6d28689a..968182e1 100644 --- a/src/tests/Feature/Controller/PaymentsMollieTest.php +++ b/src/tests/Feature/Controller/PaymentsMollieTest.php @@ -1,758 +1,786 @@ 'mollie']); $john = $this->getTestUser('john@kolab.org'); $wallet = $john->wallets()->first(); Payment::where('wallet_id', $wallet->id)->delete(); Wallet::where('id', $wallet->id)->update(['balance' => 0]); WalletSetting::where('wallet_id', $wallet->id)->delete(); $types = [ Transaction::WALLET_CREDIT, Transaction::WALLET_REFUND, Transaction::WALLET_CHARGEBACK, ]; Transaction::where('object_id', $wallet->id)->whereIn('type', $types)->delete(); } /** * {@inheritDoc} */ public function tearDown(): void { $john = $this->getTestUser('john@kolab.org'); $wallet = $john->wallets()->first(); Payment::where('wallet_id', $wallet->id)->delete(); Wallet::where('id', $wallet->id)->update(['balance' => 0]); WalletSetting::where('wallet_id', $wallet->id)->delete(); $types = [ Transaction::WALLET_CREDIT, Transaction::WALLET_REFUND, Transaction::WALLET_CHARGEBACK, ]; Transaction::where('object_id', $wallet->id)->whereIn('type', $types)->delete(); parent::tearDown(); } /** * Test creating/updating/deleting an outo-payment mandate * * @group mollie */ public function testMandates(): void { // Unauth access not allowed $response = $this->get("api/v4/payments/mandate"); $response->assertStatus(401); $response = $this->post("api/v4/payments/mandate", []); $response->assertStatus(401); $response = $this->put("api/v4/payments/mandate", []); $response->assertStatus(401); $response = $this->delete("api/v4/payments/mandate"); $response->assertStatus(401); $user = $this->getTestUser('john@kolab.org'); // Test creating a mandate (invalid input) $post = []; $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertSame('The amount field is required.', $json['errors']['amount'][0]); $this->assertSame('The balance field is required.', $json['errors']['balance'][0]); // Test creating a mandate (invalid input) $post = ['amount' => 100, 'balance' => 'a']; $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame('The balance must be a number.', $json['errors']['balance'][0]); // Test creating a mandate (invalid input) $post = ['amount' => -100, 'balance' => 0]; $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF'; $this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']); // Test creating a mandate (valid input) $post = ['amount' => 20.10, 'balance' => 0]; $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertRegExp('|^https://www.mollie.com|', $json['redirectUrl']); // Test fetching the mandate information $response = $this->actingAs($user)->get("api/v4/payments/mandate"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals(20.10, $json['amount']); $this->assertEquals(0, $json['balance']); $this->assertEquals('Credit Card', $json['method']); $this->assertSame(true, $json['isPending']); $this->assertSame(false, $json['isValid']); $this->assertSame(false, $json['isDisabled']); $mandate_id = $json['id']; // We would have to invoke a browser to accept the "first payment" to make // the mandate validated/completed. Instead, we'll mock the mandate object. $mollie_response = [ 'resource' => 'mandate', 'id' => $mandate_id, 'status' => 'valid', 'method' => 'creditcard', 'details' => [ 'cardNumber' => '4242', 'cardLabel' => 'Visa', ], 'customerId' => 'cst_GMfxGPt7Gj', 'createdAt' => '2020-04-28T11:09:47+00:00', ]; $responseStack = $this->mockMollie(); $responseStack->append(new Response(200, [], json_encode($mollie_response))); $wallet = $user->wallets()->first(); $wallet->setSetting('mandate_disabled', 1); $response = $this->actingAs($user)->get("api/v4/payments/mandate"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals(20.10, $json['amount']); $this->assertEquals(0, $json['balance']); $this->assertEquals('Visa (**** **** **** 4242)', $json['method']); $this->assertSame(false, $json['isPending']); $this->assertSame(true, $json['isValid']); $this->assertSame(true, $json['isDisabled']); Bus::fake(); $wallet->setSetting('mandate_disabled', null); // Test updating mandate details (invalid input) $post = []; $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertSame('The amount field is required.', $json['errors']['amount'][0]); $this->assertSame('The balance field is required.', $json['errors']['balance'][0]); $post = ['amount' => -100, 'balance' => 0]; $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']); // Test updating a mandate (valid input) $responseStack->append(new Response(200, [], json_encode($mollie_response))); $post = ['amount' => 30.10, 'balance' => 1]; $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('The auto-payment has been updated.', $json['message']); $this->assertSame($mandate_id, $json['id']); $this->assertFalse($json['isDisabled']); - $wallet = $user->wallets()->first(); + $wallet->refresh(); $this->assertEquals(30.10, $wallet->getSetting('mandate_amount')); $this->assertEquals(1, $wallet->getSetting('mandate_balance')); // Test updating a disabled mandate (invalid input) $wallet->setSetting('mandate_disabled', 1); $wallet->balance = -2000; $wallet->save(); $user->refresh(); // required so the controller sees the wallet update from above $post = ['amount' => 15.10, 'balance' => 1]; $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame('The specified amount does not cover the balance on the account.', $json['errors']['amount']); // Test updating a disabled mandate (valid input) $responseStack->append(new Response(200, [], json_encode($mollie_response))); $post = ['amount' => 30, 'balance' => 1]; $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('The auto-payment has been updated.', $json['message']); $this->assertSame($mandate_id, $json['id']); $this->assertFalse($json['isDisabled']); Bus::assertDispatchedTimes(\App\Jobs\WalletCharge::class, 1); Bus::assertDispatched(\App\Jobs\WalletCharge::class, function ($job) use ($wallet) { $job_wallet = $this->getObjectProperty($job, 'wallet'); return $job_wallet->id === $wallet->id; }); $this->unmockMollie(); // Delete mandate $response = $this->actingAs($user)->delete("api/v4/payments/mandate"); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('The auto-payment has been removed.', $json['message']); // Confirm with Mollie the mandate does not exist $customer_id = $wallet->getSetting('mollie_id'); $this->expectException(\Mollie\Api\Exceptions\ApiException::class); $this->expectExceptionMessageMatches('/410: Gone/'); $mandate = mollie()->mandates()->getForId($customer_id, $mandate_id); $this->assertNull($wallet->fresh()->getSetting('mollie_mandate_id')); + + // Test Mollie's "410 Gone" response handling when fetching the mandate info + // It is expected to remove the mandate reference + $mollie_response = [ + 'status' => 410, + 'title' => "Gone", + 'detail' => "You are trying to access an object, which has previously been deleted", + '_links' => [ + 'documentation' => [ + 'href' => "https://docs.mollie.com/errors", + 'type' => "text/html" + ] + ] + ]; + + $responseStack = $this->mockMollie(); + $responseStack->append(new Response(410, [], json_encode($mollie_response))); + + $wallet->fresh()->setSetting('mollie_mandate_id', '123'); + + $response = $this->actingAs($user)->get("api/v4/payments/mandate"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertFalse(array_key_exists('id', $json)); + $this->assertFalse(array_key_exists('method', $json)); + $this->assertNull($wallet->fresh()->getSetting('mollie_mandate_id')); } /** * Test creating a payment and receiving a status via webhook * * @group mollie */ public function testStoreAndWebhook(): void { Bus::fake(); // Unauth access not allowed $response = $this->post("api/v4/payments", []); $response->assertStatus(401); $user = $this->getTestUser('john@kolab.org'); $post = ['amount' => -1]; $response = $this->actingAs($user)->post("api/v4/payments", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF'; $this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']); $post = ['amount' => '12.34']; $response = $this->actingAs($user)->post("api/v4/payments", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertRegExp('|^https://www.mollie.com|', $json['redirectUrl']); $wallet = $user->wallets()->first(); $payments = Payment::where('wallet_id', $wallet->id)->get(); $this->assertCount(1, $payments); $payment = $payments[0]; $this->assertSame(1234, $payment->amount); $this->assertSame(\config('app.name') . ' Payment', $payment->description); $this->assertSame('open', $payment->status); $this->assertEquals(0, $wallet->balance); // Test the webhook // Note: Webhook end-point does not require authentication $mollie_response = [ "resource" => "payment", "id" => $payment->id, "status" => "paid", // Status is not enough, paidAt is used to distinguish the state "paidAt" => date('c'), "mode" => "test", ]; // We'll trigger the webhook with payment id and use mocking for // a request to the Mollie payments API. We cannot force Mollie // to make the payment status change. $responseStack = $this->mockMollie(); $responseStack->append(new Response(200, [], json_encode($mollie_response))); $post = ['id' => $payment->id]; $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status); $this->assertEquals(1234, $wallet->fresh()->balance); $transaction = $wallet->transactions() ->where('type', Transaction::WALLET_CREDIT)->get()->last(); $this->assertSame(1234, $transaction->amount); $this->assertSame( "Payment transaction {$payment->id} using Mollie", $transaction->description ); // Assert that email notification job wasn't dispatched, // it is expected only for recurring payments Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0); // Verify "paid -> open -> paid" scenario, assert that balance didn't change $mollie_response['status'] = 'open'; unset($mollie_response['paidAt']); $responseStack->append(new Response(200, [], json_encode($mollie_response))); $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status); $this->assertEquals(1234, $wallet->fresh()->balance); $mollie_response['status'] = 'paid'; $mollie_response['paidAt'] = date('c'); $responseStack->append(new Response(200, [], json_encode($mollie_response))); $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status); $this->assertEquals(1234, $wallet->fresh()->balance); // Test for payment failure Bus::fake(); $payment->refresh(); $payment->status = PaymentProvider::STATUS_OPEN; $payment->save(); $mollie_response = [ "resource" => "payment", "id" => $payment->id, "status" => "failed", "mode" => "test", ]; // We'll trigger the webhook with payment id and use mocking for // a request to the Mollie payments API. We cannot force Mollie // to make the payment status change. $responseStack = $this->mockMollie(); $responseStack->append(new Response(200, [], json_encode($mollie_response))); $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $this->assertSame('failed', $payment->fresh()->status); $this->assertEquals(1234, $wallet->fresh()->balance); // Assert that email notification job wasn't dispatched, // it is expected only for recurring payments Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0); } /** * Test automatic payment charges * * @group mollie */ public function testTopUp(): void { Bus::fake(); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); // Create a valid mandate first $this->createMandate($wallet, ['amount' => 20.10, 'balance' => 10]); // Expect a recurring payment as we have a valid mandate at this point $result = PaymentsController::topUpWallet($wallet); $this->assertTrue($result); // Check that the payments table contains a new record with proper amount // There should be two records, one for the first payment and another for // the recurring payment $this->assertCount(1, $wallet->payments()->get()); $payment = $wallet->payments()->first(); $this->assertSame(2010, $payment->amount); // In mollie we don't have to wait for a webhook, the response to // PaymentIntent already sets the status to 'paid', so we can test // immediately the balance update // Assert that email notification job has been dispatched $this->assertSame(PaymentProvider::STATUS_PAID, $payment->status); $this->assertEquals(2010, $wallet->fresh()->balance); $transaction = $wallet->transactions() ->where('type', Transaction::WALLET_CREDIT)->get()->last(); $this->assertSame(2010, $transaction->amount); $this->assertSame( "Auto-payment transaction {$payment->id} using Mastercard (**** **** **** 6787)", $transaction->description ); Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1); Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) { $job_payment = $this->getObjectProperty($job, 'payment'); return $job_payment->id === $payment->id; }); // Expect no payment if the mandate is disabled $wallet->setSetting('mandate_disabled', 1); $result = PaymentsController::topUpWallet($wallet); $this->assertFalse($result); $this->assertCount(1, $wallet->payments()->get()); // Expect no payment if balance is ok $wallet->setSetting('mandate_disabled', null); $wallet->balance = 1000; $wallet->save(); $result = PaymentsController::topUpWallet($wallet); $this->assertFalse($result); $this->assertCount(1, $wallet->payments()->get()); // Expect no payment if the top-up amount is not enough $wallet->setSetting('mandate_disabled', null); $wallet->balance = -2050; $wallet->save(); $result = PaymentsController::topUpWallet($wallet); $this->assertFalse($result); $this->assertCount(1, $wallet->payments()->get()); Bus::assertDispatchedTimes(\App\Jobs\PaymentMandateDisabledEmail::class, 1); Bus::assertDispatched(\App\Jobs\PaymentMandateDisabledEmail::class, function ($job) use ($wallet) { $job_wallet = $this->getObjectProperty($job, 'wallet'); return $job_wallet->id === $wallet->id; }); // Expect no payment if there's no mandate $wallet->setSetting('mollie_mandate_id', null); $wallet->balance = 0; $wallet->save(); $result = PaymentsController::topUpWallet($wallet); $this->assertFalse($result); $this->assertCount(1, $wallet->payments()->get()); Bus::assertDispatchedTimes(\App\Jobs\PaymentMandateDisabledEmail::class, 1); // Test webhook for recurring payments $wallet->transactions()->delete(); $responseStack = $this->mockMollie(); Bus::fake(); $payment->refresh(); $payment->status = PaymentProvider::STATUS_OPEN; $payment->save(); $mollie_response = [ "resource" => "payment", "id" => $payment->id, "status" => "paid", // Status is not enough, paidAt is used to distinguish the state "paidAt" => date('c'), "mode" => "test", ]; // We'll trigger the webhook with payment id and use mocking for // a request to the Mollie payments API. We cannot force Mollie // to make the payment status change. $responseStack->append(new Response(200, [], json_encode($mollie_response))); $post = ['id' => $payment->id]; $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status); $this->assertEquals(2010, $wallet->fresh()->balance); $transaction = $wallet->transactions() ->where('type', Transaction::WALLET_CREDIT)->get()->last(); $this->assertSame(2010, $transaction->amount); $this->assertSame( "Auto-payment transaction {$payment->id} using Mollie", $transaction->description ); // Assert that email notification job has been dispatched Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1); Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) { $job_payment = $this->getObjectProperty($job, 'payment'); return $job_payment->id === $payment->id; }); Bus::fake(); // Test for payment failure $payment->refresh(); $payment->status = PaymentProvider::STATUS_OPEN; $payment->save(); $wallet->setSetting('mollie_mandate_id', 'xxx'); $wallet->setSetting('mandate_disabled', null); $mollie_response = [ "resource" => "payment", "id" => $payment->id, "status" => "failed", "mode" => "test", ]; $responseStack->append(new Response(200, [], json_encode($mollie_response))); $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $wallet->refresh(); $this->assertSame(PaymentProvider::STATUS_FAILED, $payment->fresh()->status); $this->assertEquals(2010, $wallet->balance); $this->assertTrue(!empty($wallet->getSetting('mandate_disabled'))); // Assert that email notification job has been dispatched Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1); Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) { $job_payment = $this->getObjectProperty($job, 'payment'); return $job_payment->id === $payment->id; }); $this->unmockMollie(); } /** * Test refund/chargeback handling by the webhook * * @group mollie */ public function testRefundAndChargeback(): void { Bus::fake(); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->transactions()->delete(); $mollie = PaymentProvider::factory('mollie'); // Create a paid payment $payment = Payment::create([ 'id' => 'tr_123456', 'status' => PaymentProvider::STATUS_PAID, 'amount' => 123, 'type' => PaymentProvider::TYPE_ONEOFF, 'wallet_id' => $wallet->id, 'provider' => 'mollie', 'description' => 'test', ]); // Test handling a refund by the webhook $mollie_response1 = [ "resource" => "payment", "id" => $payment->id, "status" => "paid", // Status is not enough, paidAt is used to distinguish the state "paidAt" => date('c'), "mode" => "test", "_links" => [ "refunds" => [ "href" => "https://api.mollie.com/v2/payments/{$payment->id}/refunds", "type" => "application/hal+json" ] ] ]; $mollie_response2 = [ "count" => 1, "_links" => [], "_embedded" => [ "refunds" => [ [ "resource" => "refund", "id" => "re_123456", "status" => \Mollie\Api\Types\RefundStatus::STATUS_REFUNDED, "paymentId" => $payment->id, "description" => "refund desc", "amount" => [ "currency" => "CHF", "value" => "1.01", ], ] ] ] ]; // We'll trigger the webhook with payment id and use mocking for // requests to the Mollie payments API. $responseStack = $this->mockMollie(); $responseStack->append(new Response(200, [], json_encode($mollie_response1))); $responseStack->append(new Response(200, [], json_encode($mollie_response2))); $post = ['id' => $payment->id]; $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $wallet->refresh(); $this->assertEquals(-101, $wallet->balance); $transactions = $wallet->transactions()->where('type', Transaction::WALLET_REFUND)->get(); $this->assertCount(1, $transactions); $this->assertSame(101, $transactions[0]->amount); $this->assertSame(Transaction::WALLET_REFUND, $transactions[0]->type); $this->assertSame("refund desc", $transactions[0]->description); $payments = $wallet->payments()->where('id', 're_123456')->get(); $this->assertCount(1, $payments); $this->assertSame(-101, $payments[0]->amount); $this->assertSame(PaymentProvider::STATUS_PAID, $payments[0]->status); $this->assertSame(PaymentProvider::TYPE_REFUND, $payments[0]->type); $this->assertSame("mollie", $payments[0]->provider); $this->assertSame("refund desc", $payments[0]->description); // Test handling a chargeback by the webhook $mollie_response1["_links"] = [ "chargebacks" => [ "href" => "https://api.mollie.com/v2/payments/{$payment->id}/chargebacks", "type" => "application/hal+json" ] ]; $mollie_response2 = [ "count" => 1, "_links" => [], "_embedded" => [ "chargebacks" => [ [ "resource" => "chargeback", "id" => "chb_123456", "paymentId" => $payment->id, "amount" => [ "currency" => "CHF", "value" => "0.15", ], ] ] ] ]; // We'll trigger the webhook with payment id and use mocking for // requests to the Mollie payments API. $responseStack = $this->mockMollie(); $responseStack->append(new Response(200, [], json_encode($mollie_response1))); $responseStack->append(new Response(200, [], json_encode($mollie_response2))); $post = ['id' => $payment->id]; $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $wallet->refresh(); $this->assertEquals(-116, $wallet->balance); $transactions = $wallet->transactions()->where('type', Transaction::WALLET_CHARGEBACK)->get(); $this->assertCount(1, $transactions); $this->assertSame(15, $transactions[0]->amount); $this->assertSame(Transaction::WALLET_CHARGEBACK, $transactions[0]->type); $this->assertSame('', $transactions[0]->description); $payments = $wallet->payments()->where('id', 'chb_123456')->get(); $this->assertCount(1, $payments); $this->assertSame(-15, $payments[0]->amount); $this->assertSame(PaymentProvider::STATUS_PAID, $payments[0]->status); $this->assertSame(PaymentProvider::TYPE_CHARGEBACK, $payments[0]->type); $this->assertSame("mollie", $payments[0]->provider); $this->assertSame('', $payments[0]->description); Bus::assertNotDispatched(\App\Jobs\PaymentEmail::class); $this->unmockMollie(); } /** * Create Mollie's auto-payment mandate using our API and Chrome browser */ protected function createMandate(Wallet $wallet, array $params) { // Use the API to create a first payment with a mandate $response = $this->actingAs($wallet->owner)->post("api/v4/payments/mandate", $params); $response->assertStatus(200); $json = $response->json(); // There's no easy way to confirm a created mandate. // The only way seems to be to fire up Chrome on checkout page // and do actions with use of Dusk browser. $this->startBrowser() ->visit($json['redirectUrl']) ->click('input[value="paid"]') ->click('button.form__button'); $this->stopBrowser(); } } diff --git a/src/tests/Feature/Controller/SupportTest.php b/src/tests/Feature/Controller/SupportTest.php new file mode 100644 index 00000000..c0924257 --- /dev/null +++ b/src/tests/Feature/Controller/SupportTest.php @@ -0,0 +1,82 @@ + $support_email]); + } + + // Empty request + $response = $this->post("api/v4/support/request", []); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertCount(2, $json); + $this->assertSame('error', $json['status']); + $this->assertCount(3, $json['errors']); + $this->assertSame(['The email field is required.'], $json['errors']['email']); + $this->assertSame(['The summary field is required.'], $json['errors']['summary']); + $this->assertSame(['The body field is required.'], $json['errors']['body']); + + // Invalid email + $post = [ + 'email' => '@test.com', + 'summary' => 'Test summary', + 'body' => 'Test body', + ]; + $response = $this->post("api/v4/support/request", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertCount(2, $json); + $this->assertSame('error', $json['status']); + $this->assertCount(1, $json['errors']); + $this->assertSame(['The email must be a valid email address.'], $json['errors']['email']); + + $this->assertCount(0, $this->app->make('swift.transport')->driver()->messages()); + + // Valid input + $post = [ + 'email' => 'test@test.com', + 'summary' => 'Test summary', + 'body' => 'Test body', + 'user' => '1234567', + 'name' => 'Username', + ]; + $response = $this->post("api/v4/support/request", $post); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(2, $json); + $this->assertSame('success', $json['status']); + $this->assertSame('Support request submitted successfully.', $json['message']); + + $emails = $this->app->make('swift.transport')->driver()->messages(); + + $expected_body = "ID: 1234567\nName: Username\nWorking email address: test@test.com\n" + . "Subject: Test summary\n\nTest body"; + + $this->assertCount(1, $emails); + $this->assertSame('Test summary', $emails[0]->getSubject()); + $this->assertSame(['test@test.com' => 'Username'], $emails[0]->getFrom()); + $this->assertSame(['test@test.com' => 'Username'], $emails[0]->getReplyTo()); + $this->assertNull($emails[0]->getCc()); + $this->assertSame([$support_email => null], $emails[0]->getTo()); + $this->assertSame($expected_body, trim($emails[0]->getBody())); + } +} diff --git a/src/webpack.mix.js b/src/webpack.mix.js index 17fdb91c..d802e32d 100644 --- a/src/webpack.mix.js +++ b/src/webpack.mix.js @@ -1,29 +1,45 @@ -const mix = require('laravel-mix'); /* |-------------------------------------------------------------------------- | Mix Asset Management |-------------------------------------------------------------------------- | | Mix provides a clean, fluent API for defining some Webpack build steps | for your Laravel application. By default, we are compiling the Sass | file for the application as well as bundling up all the JS files. | */ +const fs = require('fs'); +const glob = require('glob'); +const mix = require('laravel-mix'); + mix.webpackConfig({ output: { publicPath: process.env.MIX_ASSET_PATH }, resolve: { alias: { 'jquery$': 'jquery/dist/jquery.slim.js', } } }) mix.js('resources/js/user.js', 'public/js') .js('resources/js/admin.js', 'public/js') .js('resources/js/meet.js', 'public/js') .sass('resources/sass/app.scss', 'public/css') .sass('resources/sass/document.scss', 'public/css'); + +glob.sync('resources/themes/*/', {}).forEach(fromDir => { + const toDir = fromDir.replace('resources/themes/', 'public/themes/') + + mix.sass(fromDir + 'app.scss', toDir) + .sass(fromDir + 'document.scss', toDir); + + fs.stat(fromDir + 'images', {}, (err, stats) => { + if (stats) { + mix.copyDirectory(fromDir + 'images', toDir + 'images') + } + }) +})