Page MenuHomePhorge

D1423.1775357111.diff
No OneTemporary

Authored By
Unknown
Size
71 KB
Referenced Files
None
Subscribers
None

D1423.1775357111.diff

diff --git a/src/.env.example b/src/.env.example
--- a/src/.env.example
+++ b/src/.env.example
@@ -5,10 +5,13 @@
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
diff --git a/src/app/Documents/Receipt.php b/src/app/Documents/Receipt.php
--- a/src/app/Documents/Receipt.php
+++ b/src/app/Documents/Receipt.php
@@ -99,7 +99,7 @@
// 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
@@ -254,6 +254,7 @@
$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;
@@ -262,8 +263,12 @@
. sprintf('<a href="mailto:%s">%s</a>', $contact, $contact);
}
+ if ($logo && strpos($logo, '/') === false) {
+ $logo = "/themes/$theme/images/$logo";
+ }
+
return [
- 'logo' => $logo ? "<img src=\"/images/$logo\" width=300>" : '',
+ 'logo' => $logo ? "<img src=\"$logo\" width=300>" : '',
'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
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/SupportController.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace App\Http\Controllers\API\V4;
+
+use App\Http\Controllers\Controller;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Mail;
+use Illuminate\Support\Facades\Validator;
+
+class SupportController extends Controller
+{
+ /**
+ * Submit contact request form.
+ *
+ * @param \Illuminate\Http\Request $request
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function request(Request $request)
+ {
+ $rules = [
+ 'user' => '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
--- /dev/null
+++ b/src/app/Http/Controllers/ContentController.php
@@ -0,0 +1,62 @@
+<?php
+
+namespace App\Http\Controllers;
+
+class ContentController extends Controller
+{
+ /**
+ * Get the HTML content for the specified page
+ *
+ * @param string $page Page template identifier
+ *
+ * @return \Illuminate\View\View
+ */
+ public function pageContent(string $page)
+ {
+ if (empty($page) || !preg_match('/^[a-z\/]+$/', $page)) {
+ abort(404);
+ }
+
+ $page = str_replace('/', '.', $page);
+ $file = sprintf('themes/%s/pages/%s.blade.php', \config('app.theme'), $page);
+ $view = sprintf('%s.pages.%s', \config('app.theme'), $page);
+
+ if (!file_exists(resource_path($file))) {
+ abort(404);
+ }
+
+ return view($view)->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/<user-id>
+ }
+
+ return response()->json(['status' => 'success', 'faq' => $faq]);
+ }
+}
diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php
--- a/src/app/Providers/AppServiceProvider.php
+++ b/src/app/Providers/AppServiceProvider.php
@@ -2,6 +2,7 @@
namespace App\Providers;
+use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\ServiceProvider;
@@ -48,5 +49,11 @@
\Log::debug(sprintf('[SQL] %s [%s]', $query->sql, implode(', ', $query->bindings)));
});
}
+
+ // Register some template helpers
+ Blade::directive('theme_asset', function ($path) {
+ $path = trim($path, '/\'"');
+ return "<?php echo secure_asset('themes/' . \$env['app.theme'] . '/' . '$path'); ?>";
+ });
}
}
diff --git a/src/app/Utils.php b/src/app/Utils.php
--- a/src/app/Utils.php
+++ b/src/app/Utils.php
@@ -321,7 +321,16 @@
*/
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');
@@ -333,6 +342,21 @@
$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
--- a/src/config/app.php
+++ b/src/config/app.php
@@ -59,6 +59,12 @@
'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
diff --git a/src/config/view.php b/src/config/view.php
--- a/src/config/view.php
+++ b/src/config/view.php
@@ -15,6 +15,7 @@
'paths' => [
resource_path('views'),
+ resource_path('themes'),
],
/*
diff --git a/src/public/images/icons/icon-128x128.png b/src/public/images/icons/icon-128x128.png
deleted file mode 100644
index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000
GIT binary patch
literal 0
Hc$@<O00001
literal 0
Hc$@<O00001
diff --git a/src/public/images/icons/icon-144x144.png b/src/public/images/icons/icon-144x144.png
deleted file mode 100644
index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000
GIT binary patch
literal 0
Hc$@<O00001
literal 0
Hc$@<O00001
diff --git a/src/public/images/icons/icon-152x152.png b/src/public/images/icons/icon-152x152.png
deleted file mode 100644
index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000
GIT binary patch
literal 0
Hc$@<O00001
literal 0
Hc$@<O00001
diff --git a/src/public/images/icons/icon-192x192.png b/src/public/images/icons/icon-192x192.png
deleted file mode 100644
index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000
GIT binary patch
literal 0
Hc$@<O00001
literal 0
Hc$@<O00001
diff --git a/src/public/images/icons/icon-384x384.png b/src/public/images/icons/icon-384x384.png
deleted file mode 100644
index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000
GIT binary patch
literal 0
Hc$@<O00001
literal 0
Hc$@<O00001
diff --git a/src/public/images/icons/icon-512x512.png b/src/public/images/icons/icon-512x512.png
deleted file mode 100644
index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000
GIT binary patch
literal 0
Hc$@<O00001
literal 0
Hc$@<O00001
diff --git a/src/public/images/icons/icon-72x72.png b/src/public/images/icons/icon-72x72.png
deleted file mode 100644
index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000
GIT binary patch
literal 0
Hc$@<O00001
literal 0
Hc$@<O00001
diff --git a/src/public/images/icons/icon-96x96.png b/src/public/images/icons/icon-96x96.png
deleted file mode 100644
index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000
GIT binary patch
literal 0
Hc$@<O00001
literal 0
Hc$@<O00001
diff --git a/src/public/images/icons/splash-1125x2436.png b/src/public/images/icons/splash-1125x2436.png
deleted file mode 100644
index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000
GIT binary patch
literal 0
Hc$@<O00001
literal 0
Hc$@<O00001
diff --git a/src/public/images/icons/splash-1242x2208.png b/src/public/images/icons/splash-1242x2208.png
deleted file mode 100644
index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000
GIT binary patch
literal 0
Hc$@<O00001
literal 0
Hc$@<O00001
diff --git a/src/public/images/icons/splash-1242x2688.png b/src/public/images/icons/splash-1242x2688.png
deleted file mode 100644
index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000
GIT binary patch
literal 0
Hc$@<O00001
literal 0
Hc$@<O00001
diff --git a/src/public/images/icons/splash-1536x2048.png b/src/public/images/icons/splash-1536x2048.png
deleted file mode 100644
index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000
GIT binary patch
literal 0
Hc$@<O00001
literal 0
Hc$@<O00001
diff --git a/src/public/images/icons/splash-1668x2224.png b/src/public/images/icons/splash-1668x2224.png
deleted file mode 100644
index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000
GIT binary patch
literal 0
Hc$@<O00001
literal 0
Hc$@<O00001
diff --git a/src/public/images/icons/splash-1668x2388.png b/src/public/images/icons/splash-1668x2388.png
deleted file mode 100644
index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000
GIT binary patch
literal 0
Hc$@<O00001
literal 0
Hc$@<O00001
diff --git a/src/public/images/icons/splash-2048x2732.png b/src/public/images/icons/splash-2048x2732.png
deleted file mode 100644
index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000
GIT binary patch
literal 0
Hc$@<O00001
literal 0
Hc$@<O00001
diff --git a/src/public/images/icons/splash-640x1136.png b/src/public/images/icons/splash-640x1136.png
deleted file mode 100644
index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000
GIT binary patch
literal 0
Hc$@<O00001
literal 0
Hc$@<O00001
diff --git a/src/public/images/icons/splash-750x1334.png b/src/public/images/icons/splash-750x1334.png
deleted file mode 100644
index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000
GIT binary patch
literal 0
Hc$@<O00001
literal 0
Hc$@<O00001
diff --git a/src/public/images/icons/splash-828x1792.png b/src/public/images/icons/splash-828x1792.png
deleted file mode 100644
index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000
GIT binary patch
literal 0
Hc$@<O00001
literal 0
Hc$@<O00001
diff --git a/src/public/mix-manifest.json b/src/public/mix-manifest.json
deleted file mode 100644
--- 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
--- a/src/readme.md
+++ /dev/null
@@ -1,72 +0,0 @@
-<p align="center"><img src="https://res.cloudinary.com/dtfbvvkyp/image/upload/v1566331377/laravel-logolockup-cmyk-red.svg" width="400"></p>
-
-<p align="center">
-<a href="https://travis-ci.org/laravel/framework"><img src="https://travis-ci.org/laravel/framework.svg" alt="Build Status"></a>
-<a href="https://packagist.org/packages/laravel/framework"><img src="https://poser.pugx.org/laravel/framework/d/total.svg" alt="Total Downloads"></a>
-<a href="https://packagist.org/packages/laravel/framework"><img src="https://poser.pugx.org/laravel/framework/v/stable.svg" alt="Latest Stable Version"></a>
-<a href="https://packagist.org/packages/laravel/framework"><img src="https://poser.pugx.org/laravel/framework/license.svg" alt="License"></a>
-</p>
-
-## 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
--- a/src/resources/js/app.js
+++ b/src/resources/js/app.js
@@ -8,6 +8,7 @@
import AppComponent from '../vue/App'
import MenuComponent from '../vue/Widgets/Menu'
+import SupportForm from '../vue/Widgets/SupportForm'
import store from './store'
const loader = '<div class="app-loader"><div class="spinner-border" role="status"><span class="sr-only">Loading</span></div></div>'
@@ -132,10 +133,12 @@
if (!msg) msg = map[code] || "Unknown Error"
- const error_page = `<div id="error-page"><div class="code">${code}</div><div class="message">${msg}</div></div>`
+ const error_page = `<div id="error-page" class="error-page"><div class="code">${code}</div><div class="message">${msg}</div></div>`
$('#error-page').remove()
$('#app').append(error_page)
+
+ app.updateBodyClass('error')
},
errorHandler(error) {
this.stopLoading()
@@ -231,6 +234,38 @@
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($('<div>').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'
@@ -260,6 +295,12 @@
}
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)
}
}
})
@@ -289,6 +330,11 @@
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"
@@ -356,3 +402,13 @@
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
--- a/src/resources/js/bootstrap.js
+++ b/src/resources/js/bootstrap.js
@@ -83,13 +83,6 @@
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
diff --git a/src/resources/js/fontawesome.js b/src/resources/js/fontawesome.js
--- a/src/resources/js/fontawesome.js
+++ b/src/resources/js/fontawesome.js
@@ -12,6 +12,7 @@
faCheck,
faCheckCircle,
faDownload,
+ faEnvelope,
faGlobe,
faExclamationCircle,
faInfoCircle,
@@ -35,6 +36,7 @@
faCheckSquare,
faCreditCard,
faDownload,
+ faEnvelope,
faExclamationCircle,
faGlobe,
faInfoCircle,
diff --git a/src/resources/js/routes-admin.js b/src/resources/js/routes-admin.js
--- a/src/resources/js/routes-admin.js
+++ b/src/resources/js/routes-admin.js
@@ -1,8 +1,8 @@
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 = [
@@ -41,7 +41,7 @@
{
name: '404',
path: '*',
- component: Error404Component
+ component: PageComponent
}
]
diff --git a/src/resources/js/routes-user.js b/src/resources/js/routes-user.js
--- a/src/resources/js/routes-user.js
+++ b/src/resources/js/routes-user.js
@@ -1,9 +1,9 @@
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'
@@ -14,10 +14,6 @@
const routes = [
{
- path: '/',
- redirect: { name: 'dashboard' }
- },
- {
path: '/dashboard',
name: 'dashboard',
component: DashboardComponent,
@@ -89,7 +85,7 @@
{
name: '404',
path: '*',
- component: Error404Component
+ component: PageComponent
}
]
diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php
--- a/src/resources/lang/en/app.php
+++ b/src/resources/lang/en/app.php
@@ -44,6 +44,9 @@
'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.',
diff --git a/src/resources/themes/README.md b/src/resources/themes/README.md
new file mode 100644
--- /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 `<body>` or other elements outside of the template
+content can be done using `.page-<page>` class that is always added to the `<body>`.
+
+### 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/<theme>/pages/` directory. The template file name should be
+ `<page>.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/<theme>/pages/` directory are
+Blade templates. Some notes about that:
+
+- the content will be placed inside the page layout so you should not use <html> nor <body>
+ nor even a wrapper <div>.
+- for internal links use `href="/<page_name>"`. 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
rename from src/resources/sass/app.scss
rename to src/resources/themes/app.scss
--- a/src/resources/sass/app.scss
+++ b/src/resources/themes/app.scss
@@ -1,9 +1,3 @@
-@import 'variables';
-@import 'bootstrap';
-@import 'menu';
-@import 'toast';
-@import 'forms';
-
html,
body,
body > .outer-container {
@@ -35,7 +29,7 @@
}
}
-#error-page {
+.error-page {
position: absolute;
top: 0;
height: 100%;
@@ -273,7 +267,8 @@
// Various improvements for mobile
@include media-breakpoint-down(sm) {
- .card {
+ .card,
+ .card-footer {
border: 0;
}
diff --git a/src/resources/sass/bootstrap.scss b/src/resources/themes/bootstrap.scss
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
rename from src/resources/sass/_variables.scss
rename to src/resources/themes/default/_variables.scss
--- a/src/resources/sass/_variables.scss
+++ b/src/resources/themes/default/_variables.scss
@@ -7,17 +7,7 @@
$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
diff --git a/src/resources/themes/default/app.scss b/src/resources/themes/default/app.scss
new file mode 100644
--- /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
--- /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
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
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
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
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
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
--- /dev/null
+++ b/src/resources/themes/default/pages/support.blade.php
@@ -0,0 +1,21 @@
+<div id="support" class="card">
+ <div class="card-body">
+ <h3 class="card-title text-center">Contact Support</h3>
+ <p class="card-text text-justify">
+ 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.
+ <br />
+ <br />
+ 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.
+ </p>
+ </div>
+ <div class="card-footer text-center">
+ <a href="/support/contact" class="btn btn-info">Contact Support</a>
+ </div>
+ </div>
+ </div>
+</div>
diff --git a/src/resources/themes/default/theme.json b/src/resources/themes/default/theme.json
new file mode 100644
--- /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
rename from src/resources/sass/document.scss
rename to src/resources/themes/document.scss
--- a/src/resources/sass/document.scss
+++ b/src/resources/themes/document.scss
@@ -1,6 +1,3 @@
-// Variables
-@import 'variables';
-
@font-face {
font-family: 'Roboto';
font-style: normal;
diff --git a/src/resources/sass/forms.scss b/src/resources/themes/forms.scss
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
rename from src/resources/sass/menu.scss
rename to src/resources/themes/menu.scss
--- a/src/resources/sass/menu.scss
+++ b/src/resources/themes/menu.scss
@@ -104,6 +104,8 @@
}
#footer-menu {
+ height: 80px;
+
.navbar-nav {
display: none;
}
@@ -130,8 +132,6 @@
}
#footer-menu {
- height: 80px;
-
.container {
flex-direction: column;
}
diff --git a/src/resources/sass/toast.scss b/src/resources/themes/toast.scss
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
--- a/src/resources/views/documents/receipt.blade.php
+++ b/src/resources/views/documents/receipt.blade.php
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<style>
-<?php include public_path('css/document.css') ?>
+<?php include public_path('/themes/' . config('app.theme') . '/document.css') ?>
</style>
</head>
<body>
diff --git a/src/resources/views/layouts/app.blade.php b/src/resources/views/layouts/app.blade.php
--- a/src/resources/views/layouts/app.blade.php
+++ b/src/resources/views/layouts/app.blade.php
@@ -9,8 +9,8 @@
<title>{{ config('app.name') }} -- @yield('title')</title>
{{-- TODO: PWA disabled for now: @laravelPWA --}}
- <link rel="icon" type="image/x-icon" href="{{ asset('images/favicon.ico') }}">
- <link href="{{ secure_asset('css/app.css') }}" rel="stylesheet">
+ <link rel="icon" type="image/x-icon" href="{{ secure_asset('themes/' . $env['app.theme'] . '/images/favicon.ico') }}">
+ <link href="{{ secure_asset('themes/' . $env['app.theme'] . '/app.css') }}" rel="stylesheet">
</head>
<body>
<div class="outer-container">
diff --git a/src/resources/vue/404.vue b/src/resources/vue/404.vue
deleted file mode 100644
--- a/src/resources/vue/404.vue
+++ /dev/null
@@ -1,6 +0,0 @@
-<template>
- <div id="error-page">
- <div class="code">404</div>
- <div class="message">Not Found</div>
- </div>
-</template>
diff --git a/src/resources/vue/App.vue b/src/resources/vue/App.vue
--- a/src/resources/vue/App.vue
+++ b/src/resources/vue/App.vue
@@ -1,9 +1,16 @@
<template>
- <router-view v-if="!isLoading && !routerReloading"></router-view>
+ <router-view v-if="!isLoading && !routerReloading" :key="key" @hook:mounted="childMounted"></router-view>
</template>
<script>
export default {
+ computed: {
+ key() {
+ // The 'key' property is used to reload the Page component
+ // whenever a route changes. Normally vue does not do that.
+ return this.$route.name == '404' ? this.$route.path : 'static'
+ }
+ },
data() {
return {
isLoading: true,
@@ -35,6 +42,42 @@
}
},
methods: {
+ childMounted() {
+ this.$root.updateBodyClass()
+ this.getFAQ()
+ },
+ getFAQ() {
+ let page = this.$route.path
+
+ if (page == '/' || page == '/login') {
+ return
+ }
+
+ axios.get('/content/faq' + page, { ignoreErrors: true })
+ .then(response => {
+ const result = response.data.faq
+ $('#faq').remove()
+ if (result && result.length) {
+ let faq = $('<div id="faq" class="faq mt-3"><h5>FAQ</h5><ul class="pl-4"></ul></div>')
+ let list = []
+
+ result.forEach(item => {
+ list.push($('<li>').append($('<a>').attr('href', item.href).text(item.title)))
+
+ // Handle internal links with the vue-router
+ if (item.href.charAt(0) == '/') {
+ list[list.length-1].find('a').on('click', event => {
+ event.preventDefault()
+ this.$router.push(item.href)
+ })
+ }
+ })
+
+ faq.find('ul').append(list)
+ $(this.$el).append(faq)
+ }
+ })
+ },
routerReload() {
// Together with beforeRouteUpdate even on a route component
// allows us to force reload the component. So it is possible
diff --git a/src/resources/vue/Dashboard.vue b/src/resources/vue/Dashboard.vue
--- a/src/resources/vue/Dashboard.vue
+++ b/src/resources/vue/Dashboard.vue
@@ -16,6 +16,9 @@
<svg-icon icon="wallet"></svg-icon><span class="name">Wallet</span>
<span v-if="balance < 0" class="badge badge-danger">{{ $root.price(balance) }}</span>
</router-link>
+ <a v-if="webmailURL" class="card link-webmail" :href="webmailURL">
+ <svg-icon icon="envelope"></svg-icon><span class="name">Webmail</span>
+ </a>
</div>
</div>
</template>
@@ -30,7 +33,8 @@
data() {
return {
status: {},
- balance: 0
+ balance: 0,
+ webmailURL: window.config['app.webmail_url']
}
},
mounted() {
diff --git a/src/resources/vue/Login.vue b/src/resources/vue/Login.vue
--- a/src/resources/vue/Login.vue
+++ b/src/resources/vue/Login.vue
@@ -44,6 +44,7 @@
</div>
<div class="mt-1">
<router-link v-if="!$root.isAdmin" :to="{ name: 'password-reset' }" id="forgot-password">Forgot password?</router-link>
+ <a v-if="webmailURL" :href="webmailURL" class="ml-5" id="webmail">Webmail</a>
</div>
</div>
</template>
@@ -55,7 +56,8 @@
return {
email: '',
password: '',
- secondFactor: ''
+ secondFactor: '',
+ webmailURL: window.config['app.webmail_url']
}
},
methods: {
diff --git a/src/resources/vue/Page.vue b/src/resources/vue/Page.vue
new file mode 100644
--- /dev/null
+++ b/src/resources/vue/Page.vue
@@ -0,0 +1,74 @@
+<template>
+ <div class="page-content container" @click="clickHandler" v-html="content"></div>
+</template>
+
+<script>
+ export default {
+ data() {
+ return {
+ content: ''
+ }
+ },
+ mounted() {
+ let page = this.$root.pageName()
+
+ // Redirect / to /dashboard, if root page is not defined
+ if (page == '404' && this.$route.path == '/') {
+ this.$router.push({ name: 'dashboard' })
+ return
+ }
+
+ this.$root.startLoading()
+
+ axios.get('/content/page/' + page, { ignoreErrors: true })
+ .then(response => {
+ this.$root.stopLoading()
+ this.content = response.data
+ })
+ .catch(this.$root.errorHandler)
+ },
+ methods: {
+ clickHandler(event) {
+ // ensure we use the link, in case the click has been received by a subelement
+ let target = event.target
+
+ while (target && target.tagName !== 'A') {
+ target = target.parentNode
+ }
+
+ // handle only links that do not reference external resources
+ if (target && target.href && !target.getAttribute('href').match(/:\/\//)) {
+ const { altKey, ctrlKey, metaKey, shiftKey, button, defaultPrevented } = event
+
+ if (
+ // don't handle with control keys
+ metaKey || altKey || ctrlKey || shiftKey
+ // don't handle when preventDefault called
+ || defaultPrevented
+ // don't handle right clicks
+ || (button !== undefined && button !== 0)
+ // don't handle if `target="_blank"`
+ || /_blank/i.test(target.getAttribute('target'))
+ ) {
+ return
+ }
+
+ // don't handle same page links/anchors
+ const url = new URL(target.href)
+ const to = url.pathname
+
+ if (to == '/support/contact') {
+ event.preventDefault()
+ this.$root.supportDialog(this.$el)
+ return
+ }
+
+ if (window.location.pathname !== to) {
+ event.preventDefault()
+ this.$router.push(to)
+ }
+ }
+ }
+ }
+ }
+</script>
diff --git a/src/resources/vue/PasswordReset.vue b/src/resources/vue/PasswordReset.vue
--- a/src/resources/vue/PasswordReset.vue
+++ b/src/resources/vue/PasswordReset.vue
@@ -4,7 +4,8 @@
<div class="card-body">
<h4 class="card-title">Password Reset - Step 1/3</h4>
<p class="card-text">
- Enter your email address to reset your password. You may need to check your spam folder or unblock noreply@kolabnow.com.
+ Enter your email address to reset your password.
+ <span v-if="fromEmail">You may need to check your spam folder or unblock {{ fromEmail }}.</span>
</p>
<form @submit.prevent="submitStep1" data-validation-prefix="reset_">
<div class="form-group">
@@ -65,7 +66,8 @@
code: '',
short_code: '',
password: '',
- password_confirmation: ''
+ password_confirmation: '',
+ fromEmail: window.config['mail.from.address']
}
},
created() {
diff --git a/src/resources/vue/Signup.vue b/src/resources/vue/Signup.vue
--- a/src/resources/vue/Signup.vue
+++ b/src/resources/vue/Signup.vue
@@ -1,7 +1,7 @@
<template>
<div class="container">
<div id="step0">
- <div class="plan-selector d-flex justify-content-around align-items-stretch mb-3">
+ <div class="plan-selector d-flex justify-content-around align-items-stretch">
<div v-for="item in plans" :key="item.id" :class="'p-3 m-1 text-center bg-light flex-fill plan-box d-flex flex-column align-items-center plan-' + item.title">
<div class="plan-ico">
<svg-icon :icon="plan_icons[item.title]"></svg-icon>
@@ -10,14 +10,6 @@
<div class="plan-description text-left mt-3" v-html="item.description"></div>
</div>
</div>
- <div class="faq">
- <h5>FAQ</h5>
- <ul class="pl-4">
- <li><a href="https://kolabnow.com/tos">What are your terms of service?</a></li>
- <li><a href="https://kb.kolabnow.com/faq/can-i-upgrade-an-individual-account-to-a-group-account">Can I upgrade an individual account to a group account?</a></li>
- <li><a href="https://kb.kolabnow.com/faq/how-much-storage-comes-with-my-account">How much storage comes with my account?</a></li>
- </ul>
- </div>
</div>
<div class="card d-none" id="step1">
@@ -157,7 +149,7 @@
step0() {
if (!this.plans.length) {
this.$root.startLoading()
- axios.get('/api/auth/signup/plans', {}).then(response => {
+ axios.get('/api/auth/signup/plans').then(response => {
this.$root.stopLoading()
this.plans = response.data.plans
})
diff --git a/src/resources/vue/User/ProfileDelete.vue b/src/resources/vue/User/ProfileDelete.vue
--- a/src/resources/vue/User/ProfileDelete.vue
+++ b/src/resources/vue/User/ProfileDelete.vue
@@ -8,11 +8,13 @@
<strong>This operation is irreversible</strong>.</p>
<p>As you will not be able to recover anything after this point, please make sure
that you have migrated all data before proceeding.</p>
- <p>As we always strive to improve, we would like to ask for 2 minutes of your time.
+ <p v-if="supportEmail">
+ As we always strive to improve, we would like to ask for 2 minutes of your time.
The best tool for improvement is feedback from users, and we would like to ask
for a few words about your reasons for leaving our service. Please send your feedback
- to support@kolabnow.com.</p>
- <p>Also feel free to contact Kolab Now Support at support@kolabnow.com with any questions
+ to <a :href="'mailto:' + supportEmail">{{ supportEmail }}</a>.
+ </p>
+ <p>Also feel free to contact {{ appName }} Support with any questions
or concerns that you may have in this context.</p>
<button class="btn btn-secondary button-cancel" @click="$router.go(-1)">Cancel</button>
<button class="btn btn-danger button-delete" @click="deleteProfile">
@@ -26,6 +28,12 @@
<script>
export default {
+ data() {
+ return {
+ appName: window.config['app.name'],
+ supportEmail: window.config['app.support_email']
+ }
+ },
created() {
if (!this.$root.isController(this.$store.state.authInfo.wallet.id)) {
this.$root.errorPage(403)
diff --git a/src/resources/vue/Widgets/Menu.vue b/src/resources/vue/Widgets/Menu.vue
--- a/src/resources/vue/Widgets/Menu.vue
+++ b/src/resources/vue/Widgets/Menu.vue
@@ -2,7 +2,7 @@
<nav :id="mode + '-menu'" class="navbar navbar-expand-lg navbar-light">
<div class="container">
<router-link class="navbar-brand" :to="{ name: 'dashboard' }">
- <img :src="app_url + '/images/logo_' + mode + '.png'" :alt="app_name">
+ <img :src="appUrl + themeDir + '/images/logo_' + mode + '.png'" :alt="appName">
</router-link>
<button v-if="mode == 'header'" class="navbar-toggler" type="button"
data-toggle="collapse" :data-target="'#' + mode + '-menu-navbar'"
@@ -12,35 +12,25 @@
</button>
<div :id="mode + '-menu-navbar'" :class="'navbar' + (mode == 'header' ? ' collapse navbar-collapse' : '')">
<ul class="navbar-nav">
- <li class="nav-item" v-if="!logged_in">
+ <li class="nav-item" v-if="!loggedIn">
<router-link v-if="!$root.isAdmin" class="nav-link link-signup" active-class="active" :to="{name: 'signup'}">Signup</router-link>
- <a v-else class="nav-link link-signup" :href="app_url + '/signup'">Signup</a>
+ <a v-else class="nav-link link-signup" :href="appUrl + '/signup'">Signup</a>
</li>
- <li class="nav-item" v-if="!logged_in">
- <a class="nav-link link-explore" href="https://kolabnow.com">Explore</a>
+ <li class="nav-item" v-for="item in menu()" :key="item.index">
+ <a v-if="item.href" :class="'nav-link link-' + item.index" :href="item.href">{{ item.title }}</a>
+ <router-link v-if="item.to"
+ :class="'nav-link link-' + item.index"
+ active-class="active"
+ :to="item.to"
+ :exact="item.exact"
+ >
+ {{ item.title }}
+ </router-link>
</li>
- <li class="nav-item" v-if="!logged_in">
- <a class="nav-link link-blog" href="https://blogs.kolabnow.com">Blog</a>
- </li>
- <li class="nav-item">
- <a class="nav-link link-support" href="https://kolabnow.com/support">Support</a>
- </li>
- <li class="nav-item" v-if="logged_in">
- <a class="nav-link link-contact" href="https://kolabnow.com/contact">Contact</a>
- </li>
- <li class="nav-item" v-if="!logged_in && mode == 'footer'">
- <a class="nav-link link-tos" href="https://kolabnow.com/tos">ToS</a>
- </li>
- <li class="nav-item" v-if="logged_in">
- <a class="nav-link menulogin link-webmail" href="https://kolabnow.com/apps" target="_blank">Webmail</a>
- </li>
- <li class="nav-item" v-if="logged_in">
+ <li class="nav-item" v-if="loggedIn">
<router-link class="nav-link menulogin link-logout" active-class="active" :to="{name: 'logout'}">Logout</router-link>
</li>
- <li class="nav-item" v-if="!logged_in && route == 'login'">
- <a class="nav-link menulogin link-webmail" href="https://kolabnow.com/apps" target="_blank">Webmail</a>
- </li>
- <li class="nav-item" v-if="!logged_in && (!route || route == 'signup')">
+ <li class="nav-item" v-if="!loggedIn">
<router-link class="nav-link menulogin link-login" active-class="active" :to="{name: 'login'}">Login</router-link>
</li>
</ul>
@@ -61,12 +51,13 @@
},
data() {
return {
- app_name: window.config['app.name'],
- app_url: window.config['app.url'],
+ appName: window.config['app.name'],
+ appUrl: window.config['app.url'],
+ themeDir: '/themes/' + window.config['app.theme']
}
},
computed: {
- logged_in() { return this.$store.state.isLoggedIn },
+ loggedIn() { return this.$store.state.isLoggedIn },
route() { return this.$route.name }
},
mounted() {
@@ -74,6 +65,42 @@
if (this.mode == 'header') {
$('#header-menu .navbar').on('click', function() { $(this).removeClass('show') })
}
+ },
+ methods: {
+ menu() {
+ let menu = []
+ const loggedIn = this.loggedIn
+
+ window.config.menu.forEach(item => {
+ if (!item.location || !item.title) {
+ console.error("Invalid menu entry", item)
+ return
+ }
+
+ // TODO: Different menu for different loggedIn state
+
+ if (window.isAdmin && !item.admin) {
+ return
+ } else if (!window.isAdmin && item.admin === 'only') {
+ return
+ }
+
+ if (!item.footer || this.mode == 'footer') {
+ if (item.location.match(/^https?:/)) {
+ item.href = item.location
+ } else {
+ item.to = { path: item.location }
+ }
+
+ item.exact = item.location == '/'
+ item.index = item.page || item.title.toLowerCase().replace(/\s+/g, '')
+
+ menu.push(item)
+ }
+ })
+
+ return menu
+ }
}
}
</script>
diff --git a/src/resources/vue/Widgets/SupportForm.vue b/src/resources/vue/Widgets/SupportForm.vue
new file mode 100644
--- /dev/null
+++ b/src/resources/vue/Widgets/SupportForm.vue
@@ -0,0 +1,97 @@
+<template>
+ <div class="modal" id="support-dialog" tabindex="-1" role="dialog" aria-hidden="true">
+ <div class="modal-dialog" role="document">
+ <form class="modal-content" @submit.prevent="submit">
+ <div class="modal-header">
+ <h5 class="modal-title">Contact Support</h5>
+ <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+ <span aria-hidden="true">&times;</span>
+ </button>
+ </div>
+ <div class="modal-body">
+ <div class="form">
+ <div class="form-group">
+ <label>Customer number or email address you have with us</label>
+ <input id="support-user" type="text" class="form-control" placeholder="e.g. 12345678 or john@kolab.org" v-model="user" />
+ <small class="form-text text-muted">Leave blank if you are not a customer yet</small>
+ </div>
+ <div class="form-group">
+ <label>Name</label>
+ <input id="support-name" type="text" class="form-control" placeholder="how we should call you in our reply" v-model="name" />
+ </div>
+ <div class="form-group">
+ <label>Working email address</label>
+ <input id="support-email" type="email" class="form-control" placeholder="make sure we can reach you at this address" v-model="email" required />
+ </div>
+ <div class="form-group">
+ <label>Issue Summary</label>
+ <input id="support-summary" type="text" class="form-control" placeholder="one sentence that summarizes your issue" v-model="summary" required />
+ </div>
+ <div class="form-group">
+ <label>Issue Explanation</label>
+ <textarea id="support-body" class="form-control" rows="5" v-model="body" required></textarea>
+ </div>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-secondary modal-cancel" data-dismiss="modal">Cancel</button>
+ <button type="submit" class="btn btn-primary modal-action"><svg-icon icon="check"></svg-icon> Submit</button>
+ </div>
+ </form>
+ </div>
+ </div>
+</template>
+
+<script>
+ export default {
+ mounted() {
+ this.dialog = $('#support-dialog')
+ .on('hide.bs.modal', () => {
+ this.lockForm(false)
+ if (this.cancelToken) {
+ this.cancelToken()
+ this.cancelToken = null
+ }
+ })
+ .on('show.bs.modal', () => {
+ this.cancelToken = null
+ })
+ },
+ methods: {
+ lockForm(lock) {
+ this.dialog.find('input,textarea,.modal-action').prop('disabled', lock)
+ },
+ submit() {
+ this.lockForm(true)
+
+ let params = {
+ user: this.user,
+ name: this.name,
+ email: this.email,
+ summary: this.summary,
+ body: this.body
+ }
+
+ const CancelToken = axios.CancelToken
+
+ let args = {
+ cancelToken: new CancelToken((c) => {
+ this.cancelToken = c;
+ })
+ }
+
+ axios.post('/api/v4/support/request', params, args)
+ .then(response => {
+ this.summary = ''
+ this.body = ''
+ this.lockForm(false)
+ this.dialog.modal('hide')
+ this.$toast.success(response.data.message)
+ })
+ .catch(error => {
+ this.lockForm(false)
+ })
+ }
+ }
+ }
+</script>
diff --git a/src/routes/api.php b/src/routes/api.php
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -85,6 +85,17 @@
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 () {
diff --git a/src/routes/web.php b/src/routes/web.php
--- a/src/routes/web.php
+++ b/src/routes/web.php
@@ -19,6 +19,11 @@
//'domain' => \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
--- a/src/tests/Browser/Admin/LogonTest.php
+++ b/src/tests/Browser/Admin/LogonTest.php
@@ -29,7 +29,7 @@
$this->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');
@@ -77,7 +77,7 @@
// 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');
@@ -108,7 +108,7 @@
// 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
@@ -135,7 +135,7 @@
// 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
diff --git a/src/tests/Browser/ErrorTest.php b/src/tests/Browser/ErrorTest.php
--- a/src/tests/Browser/ErrorTest.php
+++ b/src/tests/Browser/ErrorTest.php
@@ -22,7 +22,7 @@
->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) {
@@ -32,7 +32,7 @@
->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
--- a/src/tests/Browser/LogonTest.php
+++ b/src/tests/Browser/LogonTest.php
@@ -22,16 +22,19 @@
$this->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');
});
}
@@ -80,13 +83,14 @@
->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');
@@ -106,7 +110,9 @@
// 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());
});
}
@@ -131,7 +137,7 @@
// 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
@@ -158,7 +164,7 @@
// 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
diff --git a/src/tests/Browser/SupportTest.php b/src/tests/Browser/SupportTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/SupportTest.php
@@ -0,0 +1,56 @@
+<?php
+
+namespace Tests\Browser;
+
+use Tests\Browser;
+use Tests\Browser\Components\Dialog;
+use Tests\Browser\Components\Menu;
+use Tests\Browser\Components\Toast;
+use Tests\TestCaseDusk;
+
+class SupportTest extends TestCaseDusk
+{
+ /**
+ * Test support contact form
+ */
+ public function testSupportForm(): void
+ {
+ $this->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
--- /dev/null
+++ b/src/tests/Feature/Controller/SupportTest.php
@@ -0,0 +1,82 @@
+<?php
+
+namespace Tests\Feature\Controller;
+
+use App\Http\Controllers\API\SupportController;
+use Tests\TestCase;
+
+class SupportTest extends TestCase
+{
+ /**
+ * Test submitting a support request (POST /support/request)
+ */
+ public function testRequest(): void
+ {
+ $support_email = \config('app.support_email');
+ if (empty($support_email)) {
+ $support_email = 'support@email.tld';
+ \config(['app.support_email' => $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
--- a/src/webpack.mix.js
+++ b/src/webpack.mix.js
@@ -1,4 +1,3 @@
-const mix = require('laravel-mix');
/*
|--------------------------------------------------------------------------
@@ -11,6 +10,10 @@
|
*/
+const fs = require('fs');
+const glob = require('glob');
+const mix = require('laravel-mix');
+
mix.webpackConfig({
output: {
publicPath: process.env.MIX_ASSET_PATH
@@ -24,5 +27,16 @@
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')
+ }
+ })
+})

File Metadata

Mime Type
text/plain
Expires
Sun, Apr 5, 2:45 AM (14 h, 55 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18831905
Default Alt Text
D1423.1775357111.diff (71 KB)

Event Timeline