diff --git a/src/.env.example b/src/.env.example index 746928dc..174e8ce7 100644 --- a/src/.env.example +++ b/src/.env.example @@ -1,117 +1,120 @@ 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=log 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" REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null REDIS_PORT=6379 SWOOLE_HTTP_HOST=127.0.0.1 SWOOLE_HTTP_PORT=8000 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/Utils.php b/src/app/Utils.php index 281fe7c8..57200dbc 100644 --- a/src/app/Utils.php +++ b/src/app/Utils.php @@ -1,338 +1,362 @@ = 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 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 * * @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 For a lack of better place this is put here for now * * @return array Configuration data */ public static function uiEnv(): array { - $opts = ['app.name', 'app.url', 'app.domain']; + $opts = [ + 'app.name', + 'app.url', + 'app.domain', + 'app.theme', + 'app.webmail_url', + 'app.support_email', + 'mail.from.address' + ]; + $env = \app('config')->getMany($opts); $countries = include resource_path('countries.php'); $env['countries'] = $countries ?: []; $isAdmin = strpos(request()->getHttpHost(), 'admin.') === 0; $env['jsapp'] = $isAdmin ? 'admin.js' : 'user.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 e8c1be22..79162361 100644 --- a/src/config/app.php +++ b/src/config/app.php @@ -1,262 +1,268 @@ 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\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 f452e347..00000000 --- a/src/public/mix-manifest.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "/js/admin.js": "/js/admin.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 d9d439e9..a88421c5 100644 --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -1,358 +1,414 @@ /** * 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() }, 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() { store.commit('logoutUser') localStorage.setItem('token', '') delete axios.defaults.headers.common.Authorization 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) { // 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 318a8da5..0ee06731 100644 --- a/src/resources/js/fontawesome.js +++ b/src/resources/js/fontawesome.js @@ -1,55 +1,57 @@ 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, 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, 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 1612aaac..9b0dc774 100644 --- a/src/resources/js/routes-user.js +++ b/src/resources/js/routes-user.js @@ -1,96 +1,92 @@ 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 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: '/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 87f10d47..7453bb9a 100644 --- a/src/resources/sass/app.scss +++ b/src/resources/themes/app.scss @@ -1,398 +1,393 @@ -@import 'variables'; -@import 'bootstrap'; -@import 'menu'; -@import 'toast'; -@import 'forms'; - html, body, body > .outer-container { height: 100%; } #app { display: flex; flex-direction: column; min-height: 100%; & > 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-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; } } } #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; } } .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; } } // 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 63% rename from src/resources/sass/_variables.scss rename to src/resources/themes/default/_variables.scss index 6995c999..9c39fcf7 100644 --- a/src/resources/sass/_variables.scss +++ b/src/resources/themes/default/_variables.scss @@ -1,26 +1,16 @@ // 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; $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/views/layouts/app.blade.php b/src/resources/views/layouts/app.blade.php index ab6e2cc7..e225e3f6 100644 --- a/src/resources/views/layouts/app.blade.php +++ b/src/resources/views/layouts/app.blade.php @@ -1,23 +1,23 @@ {{ config('app.name') }} -- @yield('title') {{-- TODO: PWA disabled for now: @laravelPWA --}} - - + +
@yield('content')
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/App.vue b/src/resources/vue/App.vue index 3670b843..4de50bbe 100644 --- a/src/resources/vue/App.vue +++ b/src/resources/vue/App.vue @@ -1,49 +1,92 @@ diff --git a/src/resources/vue/Dashboard.vue b/src/resources/vue/Dashboard.vue index 93f2030a..e8cd55c4 100644 --- a/src/resources/vue/Dashboard.vue +++ b/src/resources/vue/Dashboard.vue @@ -1,55 +1,59 @@ diff --git a/src/resources/vue/Login.vue b/src/resources/vue/Login.vue index f3f96b16..879ad089 100644 --- a/src/resources/vue/Login.vue +++ b/src/resources/vue/Login.vue @@ -1,76 +1,78 @@ 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/Widgets/Menu.vue b/src/resources/vue/Widgets/Menu.vue index b4ca9e74..9a1bdd72 100644 --- a/src/resources/vue/Widgets/Menu.vue +++ b/src/resources/vue/Widgets/Menu.vue @@ -1,79 +1,106 @@ 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 9f3f8cb6..c87f7c2e 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -1,119 +1,130 @@ '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}/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::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::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::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 42c0803f..a6e7e733 100644 --- a/src/routes/web.php +++ b/src/routes/web.php @@ -1,28 +1,33 @@ \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 () { return view('root')->with('env', \App\Utils::uiEnv()); } ); } ); 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 524760bf..f7334c6f 100644 --- a/src/tests/Browser/LogonTest.php +++ b/src/tests/Browser/LogonTest.php @@ -1,232 +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 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/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/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 6e0c595f..05e7dddf 100644 --- a/src/webpack.mix.js +++ b/src/webpack.mix.js @@ -1,28 +1,42 @@ -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') - .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') + } + }) +})