preparing for major release w/ customer portal extension and order tracking page, real time ETAs and order progress tracker info

This commit is contained in:
Ronald A. Richardson
2024-10-02 00:15:13 +08:00
parent acc4cfba35
commit 81159b7411
80 changed files with 1152 additions and 1338 deletions

1
.gitignore vendored
View File

@@ -30,6 +30,7 @@ packages/flespi
packages/loconav
packages/internals
packages/projectargus-engine
packages/customer-portal
# wip
packages/solid
solid

View File

@@ -34,4 +34,9 @@ return [
'api_key' => env('SENDGRID_API_KEY'),
],
'stripe' => [
'key' => env('STRIPE_KEY', env('STRIPE_API_KEY')),
'secret' => env('STRIPE_SECRET', env('STRIPE_API_SECRET')),
'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
],
];

View File

@@ -1,99 +0,0 @@
<div class="fleetbase-dashboard-grid flex items-center justify-between mb-4 mt-6 px-14">
<div class="left-section">
<h1 class="text-lg font-bold">{{this.dashboard.currentDashboard.name}}</h1>
</div>
<div class="fleetbase-dashboard-actions right-section ml-4 flex items-center">
<div class="fleetbase-model-select fleetbase-power-select ember-model-select h-10">
<DropdownButton
class="h-10"
@text={{if this.dashboard.currentDashboard.name this.dashboard.currentDashboard.name (t "component.dashboard.select-dashboard")}}
@textClass="text-sm mr-2"
@buttonClass="flex-row-reverse w-44 justify-between"
@icon="caret-down"
@iconClass="mr-0i"
@size="sm"
@iconPrefix="fas"
@triggerClass="hidden md:flex"
as |dd|
>
<div class="next-dd-menu mt-1 mx-0" aria-labelledby="user-menu">
<div class="p-1">
{{#each this.dashboard.dashboards as |dashboard|}}
<a href="javascript:;" class="next-dd-item" {{on "click" (dropdown-fn dd this.selectDashboard dashboard)}}>
<div class="flex-1 flex flex-row items-center">
<div class="w-6">
<FaIcon @icon="desktop" />
</div>
<span>{{dashboard.name}}</span>
</div>
<div>
{{#if (eq this.dashboard.currentDashboard.id dashboard.id)}}
<FaIcon @icon="check" class="text-green-500" />
{{/if}}
</div>
</a>
{{/each}}
</div>
</div>
</DropdownButton>
</div>
<div class="ml-2 relative h-10">
<DropdownButton class="h-10" @icon="ellipsis-h" @size="sm" @iconPrefix="fas" @triggerClass="hidden md:flex" as |dd|>
<div class="next-dd-menu mt-1 mx-0" aria-labelledby="user-menu">
<div class="p-1">
<a href="javascript:;" class="next-dd-item" {{on "click" (dropdown-fn dd this.createDashboard)}}>
<div class="w-6">
<FaIcon @icon="add" />
</div>
<span>{{t "component.dashboard.create-new-dashboard"}}</span>
</a>
{{#unless (eq this.dashboard.currentDashboard.user_uuid "system")}}
<a href="javascript:;" class="next-dd-item" {{on "click" (dropdown-fn dd this.onChangeEdit true)}}>
<div class="w-6">
<FaIcon @icon="edit" />
</div>
<span>{{t "component.dashboard.edit-layout"}}</span>
</a>
<a href="javascript:;" class="next-dd-item" {{on "click" (dropdown-fn dd this.onAddingWidget true)}}>
<div class="w-6">
<FaIcon @icon="add" />
</div>
<span>{{t "component.dashboard.add-widgets"}}</span>
</a>
<a href="javascript:;" class="next-dd-item" {{on "click" (dropdown-fn dd this.deleteDashboard this.dashboard.currentDashboard)}}>
<div class="w-6">
<FaIcon @icon="trash" />
</div>
<span>{{t "component.dashboard.delete-dashboard"}}</span>
</a>
{{/unless}}
</div>
</div>
</DropdownButton>
</div>
{{#if this.dashboard.isEditingDashboard}}
<div class="ml-2 h-10">
<Button @type="magic" @icon="save" @helpText={{t "component.dashboard.save-dashboard"}} @onClick={{fn this.onChangeEdit false}} class="h-10" />
</div>
{{/if}}
</div>
</div>
<div class="px-10">
<Dashboard::Create @isEdit={{this.dashboard.isEditingDashboard}} @isAddingWidget={{this.dashboard.isAddingWidget}} @dashboard={{this.dashboard.currentDashboard}} />
{{#if this.dashboard.isAddingWidget}}
<EmberWormhole @to="console-home-wormhole">
<Dashboard::WidgetPanel
@isOpen={{this.dashboard.isAddingWidget}}
@onLoad={{this.setWidgetSelectorPanelContext}}
@dashboard={{this.dashboard.currentDashboard}}
@onClose={{fn this.onAddingWidget false}}
/>
</EmberWormhole>
{{/if}}
</div>

View File

@@ -1,140 +0,0 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
/**
* DashboardComponent for managing dashboards in an Ember application.
* This component handles actions such as selecting, creating, deleting dashboards,
* and managing widget selectors and dashboard editing states.
*
* @extends Component
*/
export default class DashboardComponent extends Component {
/**
* Ember Data store service.
* @type {Service}
*/
@service store;
/**
* Internationalization service for managing translations.
* @type {Service}
*/
@service intl;
/**
* Notifications service for displaying alerts or confirmations.
* @type {Service}
*/
@service notifications;
/**
* Modals manager service for handling modal dialogs.
* @type {Service}
*/
@service modalsManager;
/**
* Fetch service for handling HTTP requests.
* @type {Service}
*/
@service fetch;
/**
* Dashboard service for business logic related to dashboards.
* @type {Service}
*/
@service dashboard;
/**
* Creates an instance of DashboardComponent.
* @memberof DashboardComponent
*/
constructor() {
super(...arguments);
this.dashboard.loadDashboards.perform();
}
/**
* Action to select a dashboard.
* @param {Object} dashboard - The dashboard to be selected.
*/
@action selectDashboard(dashboard) {
this.dashboard.selectDashboard.perform(dashboard);
}
/**
* Sets the context for the widget selector panel.
* @param {Object} widgetSelectorContext - The context object for the widget selector.
*/
@action setWidgetSelectorPanelContext(widgetSelectorContext) {
this.widgetSelectorContext = widgetSelectorContext;
}
/**
* Creates a new dashboard.
* @param {Object} dashboard - The dashboard to be created.
* @param {Object} [options={}] - Optional parameters for dashboard creation.
*/
@action createDashboard(dashboard, options = {}) {
this.modalsManager.show('modals/create-dashboard', {
title: this.intl.t('component.dashboard.create-a-new-dashboard'),
acceptButtonText: this.intl.t('component.dashboard.confirm-create-dashboard'),
confirm: async (modal, done) => {
modal.startLoading();
// Get the name from the modal options
const { name } = modal.getOptions();
await this.dashboard.createDashboard.perform(name);
done();
},
...options,
});
}
/**
* Deletes a dashboard.
* @param {Object} dashboard - The dashboard to be deleted.
* @param {Object} [options={}] - Optional parameters for dashboard deletion.
*/
@action deleteDashboard(dashboard, options = {}) {
if (this.dashboard.dashboards?.length === 1) {
return this.notifications.error(this.intl.t('component.dashboard.you-cannot-delete-this-dashboard'));
}
this.modalsManager.confirm({
title: this.intl.t('component.dashboard.are-you-sure-you-want-delete-dashboard', { dashboardName: dashboard.name }),
confirm: async (modal, done) => {
modal.startLoading();
await this.dashboard.deleteDashboard.perform(dashboard);
done();
},
...options,
});
}
/**
* Action to handle the addition of a widget.
* @param {boolean} [state=true] - The state to set for adding a widget.
*/
@action onAddingWidget(state = true) {
this.dashboard.onAddingWidget(state);
}
/**
* Sets the current dashboard.
* @param {Object} dashboard - The dashboard to be set as current.
*/
@action setCurrentDashboard(dashboard) {
this.dashboard.setCurrentDashboard.perform(dashboard);
}
/**
* Changes the editing state of the dashboard.
* @param {boolean} [state=true] - The state to set for editing the dashboard.
*/
@action onChangeEdit(state = true) {
this.dashboard.onChangeEdit(state);
}
}

View File

@@ -1,16 +0,0 @@
<div class="fleetbase-dashboard-grid" ...attributes>
<GridStack @options={{this.gridOptions}} @onChange={{this.onChangeGrid}}>
{{#each @dashboard.widgets as |widget|}}
{{#if (component-resolvable widget.component)}}
<GridStackItem id={{widget.id}} @options={{spread-widget-options (hash id=widget.id options=widget.grid_options)}} class="relative">
{{component widget.component options=widget.options}}
{{#if @isEdit}}
<div class="absolute top-2 right-2">
<Button @type="default" @icon="trash" @helpText={{"Remove widget from the dashboard"}} @onClick={{fn this.removeWidget widget}} />
</div>
{{/if}}
</GridStackItem>
{{/if}}
{{/each}}
</GridStack>
</div>

View File

@@ -1,99 +0,0 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action, computed } from '@ember/object';
import { inject as service } from '@ember/service';
/**
* Component responsible for creating and managing the dashboard layout.
* Provides functionalities such as toggling widget float, changing grid layout, and removing widgets.
*
* @extends Component
*/
export default class DashboardCreateComponent extends Component {
/**
* Notifications service for displaying alerts or errors.
* @type {Service}
*/
@service notifications;
/**
* Tracked array to keep track of widgets that have been updated.
* @type {Array}
*/
@tracked updatedWidgets = [];
/**
* Action to toggle the floating state of widgets on the grid.
*/
@action toggleFloat() {
this.shouldFloat = !this.shouldFloat;
}
/**
* Handles changes to the grid layout, such as repositioning or resizing widgets.
* Iterates over each widget event detail and updates the corresponding widget's properties if necessary.
*
* @param {Event} event - Event containing details about the grid change.
* @action
*/
@action onChangeGrid(event) {
const { dashboard } = this.args;
event.detail.forEach((currentWidgetEvent) => {
const alreadyUpdated = this.updatedWidgets.find((item) => item.id === currentWidgetEvent.id);
if (alreadyUpdated || !this.dashboard) {
return;
}
const changedWidget = dashboard.widgets.find((widget) => widget.id === currentWidgetEvent.id);
if (!changedWidget) {
return;
}
const { x, y, w, h } = currentWidgetEvent;
const response = changedWidget.updateProperties({
grid_options: { x, y, w, h },
});
if (response) {
this.updatedWidgets.push(changedWidget);
}
});
}
/**
* Removes a specified widget from the dashboard.
* Performs a removal operation on the dashboard and handles any errors that occur during the process.
*
* @param {Object} widget - The widget object to be removed.
* @action
*/
@action removeWidget(widget) {
const { dashboard } = this.args;
if (dashboard) {
dashboard.removeWidget(widget.id).catch((error) => {
this.notifications.serverError(error);
});
}
}
/**
* Computed property that returns grid options based on the current edit state.
* Configures grid behavior such as floating, animation, and drag and resize capabilities.
*
* @computed
* @returns {Object} An object containing grid configuration options.
*/
@computed('args.isEdit') get gridOptions() {
return {
float: true,
animate: true,
acceptWidgets: true,
alwaysShowResizeHandle: this.args.isEdit,
disableDrag: !this.args.isEdit,
disableResize: !this.args.isEdit,
resizable: { handles: 'all' },
cellHeight: 30,
};
}
}

View File

@@ -83,7 +83,7 @@ export default class AuthLoginController extends Controller {
* @return {void}
* @memberof AuthLoginController
*/
@action async login(event) {
@action async login (event) {
// firefox patch
event.preventDefault();
// get user credentials
@@ -116,7 +116,7 @@ export default class AuthLoginController extends Controller {
this.reset('success');
});
})
.catch((error) => {
.catch(error => {
this.notifications.serverError(error);
this.reset('error');
@@ -153,14 +153,14 @@ export default class AuthLoginController extends Controller {
/**
* Transition user to onboarding screen
*/
@action transitionToOnboard() {
@action transitionToOnboard () {
return this.router.transitionTo('onboard');
}
/**
* Transition to forgot password screen, if email is set - set it.
*/
@action forgotPassword() {
@action forgotPassword () {
return this.router.transitionTo('auth.forgot-password').then(() => {
if (this.email) {
this.forgotPasswordController.email = this.email;
@@ -175,7 +175,7 @@ export default class AuthLoginController extends Controller {
* @return {Promise<Transition>}
* @memberof AuthLoginController
*/
@action sendUserForEmailVerification(email) {
@action sendUserForEmailVerification (email) {
return this.fetch.post('auth/create-verification-session', { email, send: true }).then(({ token, session }) => {
return this.session.store.persist({ email }).then(() => {
this.notifications.warning(this.intl.t('auth.login.unverified-notification'));
@@ -193,7 +193,7 @@ export default class AuthLoginController extends Controller {
* @return {Promise<Transition>}
* @memberof AuthLoginController
*/
@action sendUserForPasswordReset(email) {
@action sendUserForPasswordReset (email) {
this.notifications.warning(this.intl.t('auth.login.password-reset-required'));
return this.router.transitionTo('auth.forgot-password', { queryParams: { email } }).then(() => {
this.reset('error');
@@ -205,7 +205,7 @@ export default class AuthLoginController extends Controller {
*
* @void
*/
setRedirect() {
setRedirect () {
const shift = this.urlSearchParams.get('shift');
if (shift) {
@@ -218,7 +218,7 @@ export default class AuthLoginController extends Controller {
*
* @void
*/
success() {
success () {
this.reset('success');
}
@@ -228,7 +228,7 @@ export default class AuthLoginController extends Controller {
* @param {String} error An error message
* @void
*/
failure(error) {
failure (error) {
this.notifications.serverError(error);
this.reset('error');
}
@@ -238,7 +238,7 @@ export default class AuthLoginController extends Controller {
*
* @void
*/
slowConnection() {
slowConnection () {
this.notifications.error(this.intl.t('auth.login.slow-connection-message'));
}
@@ -248,7 +248,7 @@ export default class AuthLoginController extends Controller {
* @param {String} type
* @void
*/
reset(type) {
reset (type) {
// reset login form state
this.isLoading = false;
this.isSlowConnection = false;

View File

@@ -1,270 +0,0 @@
import Controller, { inject as controller } from '@ember/controller';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import pathToRoute from '@fleetbase/ember-core/utils/path-to-route';
export default class AuthPortalLoginController extends Controller {
@controller('auth.forgot-password') forgotPasswordController;
@service notifications;
@service urlSearchParams;
@service session;
@service router;
@service intl;
@service fetch;
/**
* Whether or not to remember the users session
*
* @var {Boolean}
*/
@tracked rememberMe = false;
/**
* The identity to authenticate with
*
* @var {String}
*/
@tracked identity = null;
/**
* The password to authenticate with
*
* @var {String}
*/
@tracked password = null;
/**
* Login is validating user input
*
* @var {Boolean}
*/
@tracked isValidating = false;
/**
* Login is processing
*
* @var {Boolean}
*/
@tracked isLoading = false;
/**
* If the connection or requesst it taking too long
*
* @var {Boolean}
*/
@tracked isSlowConnection = false;
/**
* Interval to determine when to timeout the request
*
* @var {Integer}
*/
@tracked timeout = null;
/**
* Number of failed login attempts
*
* @var {Integer}
*/
@tracked failedAttempts = 0;
/**
* Authentication token.
*
* @memberof AuthPortalLoginController
*/
@tracked token;
/**
* Action to login user.
*
* @param {Event} event
* @return {void}
* @memberof AuthPortalLoginController
*/
@action async login(event) {
// firefox patch
event.preventDefault();
// get user credentials
const { identity, password, rememberMe } = this;
// If no password error
if (!identity) {
return this.notifications.warning(this.intl.t('auth.login.no-identity-notification'));
}
// If no password error
if (!password) {
return this.notifications.warning(this.intl.t('auth.login.no-identity-notification'));
}
// start loader
this.set('isLoading', true);
// set where to redirect on login
this.setRedirect();
// send request to check for 2fa
try {
let { twoFaSession, isTwoFaEnabled } = await this.session.checkForTwoFactor(identity);
if (isTwoFaEnabled) {
return this.session.store
.persist({ identity })
.then(() => {
return this.router.transitionTo('auth.two-fa', { queryParams: { token: twoFaSession } }).then(() => {
this.reset('success');
});
})
.catch((error) => {
this.notifications.serverError(error);
this.reset('error');
throw error;
});
}
} catch (error) {
return this.notifications.serverError(error);
}
try {
this.session.setRedirect('portal');
await this.session.authenticate('authenticator:fleetbase', { identity, password }, rememberMe);
} catch (error) {
this.failedAttempts++;
// Handle unverified user
if (error.toString().includes('not verified')) {
return this.sendUserForEmailVerification(identity);
}
// Handle password reset required
if (error.toString().includes('reset required')) {
return this.sendUserForPasswordReset(identity);
}
return this.failure(error);
}
if (this.session.isAuthenticated) {
this.success();
}
}
/**
* Transition user to onboarding screen
*/
@action transitionToOnboard() {
return this.router.transitionTo('onboard');
}
/**
* Transition to forgot password screen, if email is set - set it.
*/
@action forgotPassword() {
return this.router.transitionTo('auth.forgot-password').then(() => {
if (this.email) {
this.forgotPasswordController.email = this.email;
}
});
}
/**
* Creates an email verification session and transitions user to verification route.
*
* @param {String} email
* @return {Promise<Transition>}
* @memberof AuthPortalLoginController
*/
@action sendUserForEmailVerification(email) {
return this.fetch.post('auth/create-verification-session', { email, send: true }).then(({ token, session }) => {
return this.session.store.persist({ email }).then(() => {
this.notifications.warning(this.intl.t('auth.login.unverified-notification'));
return this.router.transitionTo('auth.verification', { queryParams: { token, hello: session } }).then(() => {
this.reset('error');
});
});
});
}
/**
* Sends user to forgot password flow.
*
* @param {String} email
* @return {Promise<Transition>}
* @memberof AuthPortalLoginController
*/
@action sendUserForPasswordReset(email) {
this.notifications.warning(this.intl.t('auth.login.password-reset-required'));
return this.router.transitionTo('auth.forgot-password', { queryParams: { email } }).then(() => {
this.reset('error');
});
}
/**
* Sets correct route to send user to after login.
*
* @void
*/
setRedirect() {
const shift = this.urlSearchParams.get('shift');
if (shift) {
this.session.setRedirect(pathToRoute(shift));
}
}
/**
* Handles the authentication success
*
* @void
*/
success() {
this.reset('success');
}
/**
* Handles the authentication failure
*
* @param {String} error An error message
* @void
*/
failure(error) {
this.notifications.serverError(error);
this.reset('error');
}
/**
* Handles the request slow connection
*
* @void
*/
slowConnection() {
this.notifications.error(this.intl.t('auth.login.slow-connection-message'));
}
/**
* Reset the login form
*
* @param {String} type
* @void
*/
reset(type) {
// reset login form state
this.isLoading = false;
this.isSlowConnection = false;
// reset login form state depending on type of reset
switch (type) {
case 'success':
this.identity = null;
this.password = null;
this.isValidating = false;
break;
case 'error':
case 'fail':
this.password = null;
break;
}
// clearTimeout(this.timeout);
}
}

View File

@@ -155,9 +155,9 @@ export default class ConsoleController extends Controller {
*
* @void
*/
@action invalidateSession(noop, event) {
@action async invalidateSession(noop, event) {
event.preventDefault();
this.session.invalidateWithLoader();
await this.session.invalidateWithLoader();
}
/**

View File

@@ -12,32 +12,9 @@ import getTwoFaMethods from '@fleetbase/console/utils/get-two-fa-methods';
* @extends Controller
*/
export default class ConsoleAccountAuthController extends Controller {
/**
* Service for handling data fetching.
*
* @type {fetch}
*/
@service fetch;
/**
* Service for displaying notifications.
*
* @type {notifications}
*/
@service notifications;
/**
* Service for managing application routing.
*
* @type {router}
*/
@service router;
/**
* Service for managing modals.
*
* @type {router}
*/
@service modalsManager;
/**
@@ -187,14 +164,12 @@ export default class ConsoleAccountAuthController extends Controller {
* @param {Object} twoFaSettings - User-specific two-factor authentication settings.
*/
@task *saveUserTwoFaSettings(twoFaSettings = {}) {
yield this.fetch
.post('users/two-fa', { twoFaSettings })
.then(() => {
this.notifications.success('2FA Settings saved successfully.');
})
.catch((error) => {
this.notifications.serverError(error);
});
try {
yield this.fetch.post('users/two-fa', { twoFaSettings });
this.notifications.success('2FA Settings saved successfully.');
} catch (error) {
this.notifications.serverError(error);
}
}
/**
@@ -203,13 +178,17 @@ export default class ConsoleAccountAuthController extends Controller {
* @method loadUserTwoFaSettings
*/
@task *loadUserTwoFaSettings() {
const twoFaSettings = yield this.fetch.get('users/two-fa');
try {
const twoFaSettings = yield this.fetch.get('users/two-fa');
if (twoFaSettings) {
this.isUserTwoFaEnabled = twoFaSettings.enabled;
this.twoFaSettings = twoFaSettings;
}
if (twoFaSettings) {
this.isUserTwoFaEnabled = twoFaSettings.enabled;
this.twoFaSettings = twoFaSettings;
return twoFaSettings;
} catch (error) {
this.notifications.serverError(error);
}
return twoFaSettings;
}
/**
@@ -218,12 +197,16 @@ export default class ConsoleAccountAuthController extends Controller {
* @method loadSystemTwoFaConfig
*/
@task *loadSystemTwoFaConfig() {
const twoFaConfig = yield this.fetch.get('two-fa/config');
try {
const twoFaConfig = yield this.fetch.get('two-fa/config');
if (twoFaConfig) {
this.isSystemTwoFaEnabled = twoFaConfig.enabled;
this.twoFaConfig = twoFaConfig;
}
if (twoFaConfig) {
this.isSystemTwoFaEnabled = twoFaConfig.enabled;
this.twoFaConfig = twoFaConfig;
return twoFaConfig;
} catch (error) {
this.notifications.serverError(error);
}
return twoFaConfig;
}
}

View File

@@ -0,0 +1,7 @@
import Controller from '@ember/controller';
import { tracked } from '@glimmer/tracking';
export default class ConsoleAccountVirtualController extends Controller {
@tracked view;
queryParams = ['view'];
}

View File

@@ -0,0 +1,7 @@
import Controller from '@ember/controller';
import { tracked } from '@glimmer/tracking';
export default class ConsoleAdminVirtualController extends Controller {
@tracked view;
queryParams = ['view'];
}

View File

@@ -1,3 +1,26 @@
import Controller from '@ember/controller';
export default class ConsoleHomeController extends Controller {}
export default class ConsoleHomeController extends Controller {
rows = [
{
name: 'Jason',
age: 24,
vehicle: 'Honda',
},
];
columns = [
{
label: 'Name',
valuePath: 'name',
},
{
label: 'Age',
valuePath: 'age',
},
{
label: 'Vehicle',
valuePath: 'vehicle',
},
];
}

View File

@@ -0,0 +1,7 @@
import Controller from '@ember/controller';
import { tracked } from '@glimmer/tracking';
export default class ConsoleSettingsVirtualController extends Controller {
@tracked view;
queryParams = ['view'];
}

View File

@@ -0,0 +1,7 @@
import Controller from '@ember/controller';
import { tracked } from '@glimmer/tracking';
export default class ConsoleVirtualController extends Controller {
@tracked view;
queryParams = ['view'];
}

View File

@@ -1,29 +0,0 @@
import Controller from '@ember/controller';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
export default class PortalController extends Controller {
@service session;
/**
* Action handler.
*
* @void
*/
@action onAction(action, ...params) {
if (typeof this[action] === 'function') {
this[action](...params);
}
}
/**
* Action to invalidate and log user out
*
* @void
*/
@action invalidateSession(event) {
console.log('invalidateSession', ...arguments)
// event.preventDefault();
this.session.invalidateWithLoader();
}
}

View File

@@ -1,7 +0,0 @@
import { helper } from '@ember/component/helper';
export default helper(function spreadWidgetOptions([params]) {
const { id, options } = params;
const gridOptions = { id, ...options };
return gridOptions;
});

View File

@@ -1,7 +1,7 @@
export function initialize(owner) {
const universe = owner.lookup('service:universe');
if (universe) {
universe.createRegistry('@fleetbase/console');
universe.createRegistries(['@fleetbase/console', 'auth:login']);
universe.bootEngines(owner);
}
}

View File

@@ -0,0 +1,18 @@
export function initialize (owner) {
const leafletService = owner.lookup('service:leaflet');
if (leafletService) {
leafletService.load({
onReady: function (L) {
// This will prevent the awkward scroll bug produced by Chrome browsers
// https://github.com/Leaflet/Leaflet/issues/4125#issuecomment-356289643
L.Control.include({
_refocusOnMap: L.Util.falseFn,
});
},
});
}
}
export default {
initialize,
};

View File

@@ -83,4 +83,14 @@ export default class DashboardModel extends Model {
});
}
}
getRegistry() {
const owner = getOwner(this);
const universe = owner.lookup('service:universe');
if (universe) {
return universe.getDashboardRegistry(this.id);
}
return undefined;
}
}

View File

@@ -23,9 +23,11 @@ export default class UserModel extends Model {
@attr('string') country;
@attr('string') ip_address;
@attr('string') slug;
@attr('string') role_name;
@attr('string') type;
@attr('string') session_status;
@attr('string') status;
@attr('string') locale;
@attr('boolean') is_online;
@attr('boolean') is_admin;
@attr('raw') meta;

View File

@@ -11,7 +11,9 @@ export default class ApplicationRoute extends Route {
@service urlSearchParams;
@service modalsManager;
@service intl;
@service currentUser;
@service router;
@service universe;
@tracked defaultTheme;
/**
@@ -21,7 +23,7 @@ export default class ApplicationRoute extends Route {
* @memberof ApplicationRoute
*/
// eslint-disable-next-line ember/classic-decorator-hooks
async init() {
async init () {
super.init(...arguments);
const { shouldInstall, shouldOnboard, defaultTheme } = await this.checkInstallationStatus();
@@ -43,13 +45,12 @@ export default class ApplicationRoute extends Route {
* @return {Transition}
* @memberof ApplicationRoute
*/
async beforeModel() {
async beforeModel () {
await this.session.setup();
await this.universe.booting();
const { isAuthenticated } = this.session;
const shift = this.urlSearchParams.get('shift');
if (isAuthenticated && shift) {
if (this.session.isAuthenticated && shift) {
return this.router.transitionTo(pathToRoute(shift));
}
}
@@ -60,7 +61,20 @@ export default class ApplicationRoute extends Route {
* @memberof ApplicationRoute
* @void
*/
activate() {
activate () {
this.initializeTheme();
this.initializeLocale();
}
/**
* Initializes the application's theme settings, applying necessary class names and default theme configurations.
*
* This method prepares the theme by setting up an array of class names that should be applied to the
* application's body element. If the application is running inside an Electron environment, it adds the
* `'is-electron'` class to the array. It then calls the `initialize` method of the `theme` service,
* passing in the `bodyClassNames` array and the `defaultTheme` configuration.
*/
initializeTheme () {
const bodyClassNames = [];
if (isElectron()) {
@@ -68,7 +82,18 @@ export default class ApplicationRoute extends Route {
}
this.theme.initialize({ bodyClassNames, theme: this.defaultTheme });
this.intl.setLocale(['en-us']);
}
/**
* Initializes the application's locale settings based on the current user's preferences.
*
* This method retrieves the user's preferred locale using the `getOption` method from the `currentUser` service.
* If no locale is set by the user, it defaults to `'en-us'`. It then sets the application's locale by calling
* the `setLocale` method of the `intl` service with the retrieved locale.
*/
initializeLocale () {
const locale = this.currentUser.getOption('locale', 'en-us');
this.intl.setLocale([locale]);
}
/**
@@ -77,7 +102,7 @@ export default class ApplicationRoute extends Route {
* @return {Promise}
* @memberof ApplicationRoute
*/
checkInstallationStatus() {
checkInstallationStatus () {
return this.fetch.get('installer/initialize');
}
}

View File

@@ -3,6 +3,7 @@ import { inject as service } from '@ember/service';
export default class AuthLoginRoute extends Route {
@service session;
@service universe;
/**
* If user is authentication redirect to console.
@@ -10,7 +11,8 @@ export default class AuthLoginRoute extends Route {
* @memberof AuthLoginRoute
* @void
*/
beforeModel() {
beforeModel (transition) {
this.session.prohibitAuthentication('console');
return this.universe.virtualRouteRedirect(transition, 'auth:login', 'virtual', { restoreQueryParams: true });
}
}

View File

@@ -1,16 +0,0 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
export default class AuthPortalLoginRoute extends Route {
@service session;
/**
* If user is authentication redirect to portal.
*
* @memberof AuthPortalLoginRoute
* @void
*/
beforeModel() {
this.session.prohibitAuthentication('portal');
}
}

View File

@@ -5,7 +5,10 @@ import '@fleetbase/leaflet-routing-machine';
export default class ConsoleRoute extends Route {
@service store;
@service session;
@service universe;
@service router;
@service currentUser;
@service intl;
/**
* Require authentication to access all `console` routes.
@@ -15,13 +18,13 @@ export default class ConsoleRoute extends Route {
* @memberof ConsoleRoute
*/
async beforeModel(transition) {
this.session.requireAuthentication(transition, 'auth.login');
await this.session.requireAuthentication(transition, 'auth.login');
if (this.session.data.authenticated.type === 'customer') {
return this.router.transitionTo('portal');
this.universe.callHooks('console:before-model', this.session, this.router, transition);
if (this.session.isAuthenticated) {
return this.session.promiseCurrentUser(transition);
}
return this.session.promiseCurrentUser(transition);
}
/**

View File

@@ -4,7 +4,14 @@ import { inject as service } from '@ember/service';
export default class ConsoleAccountVirtualRoute extends Route {
@service universe;
model({ slug, view }) {
return this.universe.lookupMenuItemFromRegistry('account', slug, view);
queryParams = {
view: {
refreshModel: true,
},
};
model({ slug }, transition) {
const view = this.universe.getViewFromTransition(transition);
return this.universe.lookupMenuItemFromRegistry('console:account', slug, view);
}
}

View File

@@ -4,7 +4,14 @@ import { inject as service } from '@ember/service';
export default class ConsoleAdminVirtualRoute extends Route {
@service universe;
model({ slug, view }) {
return this.universe.lookupMenuItemFromRegistry('admin', slug, view);
queryParams = {
view: {
refreshModel: true,
},
};
model({ slug }, transition) {
const view = this.universe.getViewFromTransition(transition);
return this.universe.lookupMenuItemFromRegistry('console:admin', slug, view);
}
}

View File

@@ -4,7 +4,14 @@ import { inject as service } from '@ember/service';
export default class ConsoleSettingsVirtualRoute extends Route {
@service universe;
model({ slug, view }) {
return this.universe.lookupMenuItemFromRegistry('settings', slug, view);
queryParams = {
view: {
refreshModel: true,
},
};
model({ slug }, transition) {
const view = this.universe.getViewFromTransition(transition);
return this.universe.lookupMenuItemFromRegistry('console:settings', slug, view);
}
}

View File

@@ -4,7 +4,14 @@ import { inject as service } from '@ember/service';
export default class ConsoleVirtualRoute extends Route {
@service universe;
model({ slug, view }) {
queryParams = {
view: {
refreshModel: true,
},
};
model({ slug }, transition) {
const view = this.universe.getViewFromTransition(transition);
return this.universe.lookupMenuItemFromRegistry('console', slug, view);
}
}

View File

@@ -1,40 +0,0 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
export default class PortalRoute extends Route {
@service store;
@service session;
@service theme;
/**
* Require authentication to access all `portal` routes.
*
* @param {Transition} transition
* @return {Promise}
* @memberof PortalRoute
*/
async beforeModel(transition) {
this.session.requireAuthentication(transition, 'auth.portal-login');
return this.session.promiseCurrentUser(transition);
}
/**
* Get the branding settings.
*
* @return {BrandModel}
* @memberof PortalRoute
*/
model() {
return this.store.findRecord('brand', 1);
}
/**
* Add the fleetbase-portal body class.
*
* @memberof PortalRoute
*/
activate() {
this.theme.setRoutebodyClassNames(['fleetbase-portal']);
}
}

View File

@@ -1,3 +0,0 @@
import Route from '@ember/routing/route';
export default class PortalAccountRoute extends Route {}

View File

@@ -1,3 +0,0 @@
import Route from '@ember/routing/route';
export default class PortalHomeRoute extends Route {}

View File

@@ -0,0 +1,17 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
export default class VirtualRoute extends Route {
@service universe;
queryParams = {
view: {
refreshModel: true,
},
};
model ({ slug }, transition) {
const view = this.universe.getViewFromTransition(transition);
return this.universe.lookupMenuItemFromRegistry('auth:login', slug, view);
}
}

View File

@@ -27,6 +27,14 @@ export default class UserSerializer extends ApplicationSerializer.extend(Embedde
// delete the password always
delete json.password;
// delete verification attributes
delete json.email_verified_at;
delete json.phone_verified_at;
// delete server managed dates
delete json.deleted_at;
delete json.created_at;
delete json.updated_at;
return json;
}

View File

@@ -1,255 +0,0 @@
import Service from '@ember/service';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency-decorators';
import { action } from '@ember/object';
import { isArray } from '@ember/array';
/**
* Service for managing dashboards, including loading, creating, and deleting dashboards, as well as managing the current dashboard and widget states.
* Utilizes Ember services such as `store`, `fetch`, `notifications`, and `universe` for data management and user interaction.
*
* @extends Service
*/
export default class DashboardService extends Service {
/**
* Ember Data store service for managing model data.
* @type {Service}
*/
@service store;
/**
* Fetch service for making network requests.
* @type {Service}
*/
@service fetch;
/**
* Notifications service for displaying user notifications.
* @type {Service}
*/
@service notifications;
/**
* Universe service for accessing global application state or utility methods.
* @type {Service}
*/
@service universe;
/**
* Internationalization service.
* @type {Service}
*/
@service intl;
/**
* Tracked array of available dashboards.
* @type {Array}
*/
@tracked dashboards = [];
/**
* Tracked property representing the currently selected dashboard.
* @type {Object}
*/
@tracked currentDashboard;
/**
* Tracked boolean indicating if the dashboard is in editing mode.
* @type {boolean}
*/
@tracked isEditingDashboard = false;
/**
* Tracked boolean indicating if a widget is being added.
* @type {boolean}
*/
@tracked isAddingWidget = false;
/**
* Task for loading dashboards from the store. It sets the current dashboard and checks if adding widget is necessary.
*/
@task *loadDashboards() {
const dashboards = yield this.store.findAll('dashboard');
if (isArray(dashboards)) {
this.dashboards = dashboards.toArray();
// insert default dashboard if it's not loaded
const defaultDashboard = this._createDefaultDashboard();
if (this._isDefaultDashboardNotLoaded()) {
this.dashboards.unshiftObject(defaultDashboard);
}
// Set the current dashboard
this.currentDashboard = this._getNextDashboard();
if (this.currentDashboard && this.currentDashboard.widgets.length === 0) {
this.onAddingWidget(true);
}
}
}
/**
* Task for selecting a dashboard. Handles dashboard switching and updates the current dashboard.
* @param {Object} dashboard - The dashboard object to select.
*/
@task *selectDashboard(dashboard) {
if (dashboard.user_uuid === 'system') {
this.currentDashboard = dashboard;
yield this.fetch.post('dashboards/reset-default');
return;
}
const currentDashboard = yield this.fetch.post('dashboards/switch', { dashboard_uuid: dashboard.id }, { normalizeToEmberData: true }).catch((error) => {
this.notifications.serverError(error);
});
if (currentDashboard) {
this.currentDashboard = currentDashboard;
}
}
/**
* Task for creating a new dashboard. It handles dashboard creation, success notification, and dashboard selection.
* @param {string} name - Name of the new dashboard.
*/
@task *createDashboard(name) {
const dashboardRecord = this.store.createRecord('dashboard', { name, is_default: true });
const dashboard = yield dashboardRecord.save().catch((error) => {
this.notifications.serverError(error);
});
if (dashboard) {
this.notifications.success(this.intl.t('services.dashboard-service.create-dashboard-success-notification', { dashboardName: dashboard.name }));
this.selectDashboard.perform(dashboard);
this.dashboards.pushObject(dashboard);
}
}
/**
* Task for deleting a dashboard. Handles dashboard deletion and success notification.
* @param {Object} dashboard - The dashboard object to delete.
* @param {Object} [options={}] - Optional configuration options.
*/
@task *deleteDashboard(dashboard, options = {}) {
yield dashboard.destroyRecord().catch((error) => {
this.notification.serverError(error);
if (typeof options.onError === 'function') {
options.onError(error, dashboard);
}
});
this.notifications.success(this.intl.t('services.dashboard-service.delete-dashboard-success-notification', { dashboardName: dashboard.name }));
yield this.loadDashboards.perform();
yield this.selectDashboard.perform(this._getNextDashboard());
if (typeof options.callback === 'function') {
options.callback(this.currentDashboard);
}
}
/**
* Task for setting the current dashboard.
* @param {Object} dashboard - The dashboard object to set as current.
*/
@task *setCurrentDashboard(dashboard) {
const currentDashboard = yield this.fetch.post('dashboards/switch', { dashboard_uuid: dashboard.id }, { normalizeToEmberData: true }).catch((error) => {
this.notifications.serverError(error);
});
if (currentDashboard) {
this.currentDashboard = currentDashboard;
}
}
/**
* Action to toggle dashboard editing state.
* @param {boolean} [state=true] - State to set for editing.
*/
@action onChangeEdit(state = true) {
this.isEditingDashboard = state;
}
/**
* Action to toggle the state of adding a widget.
* @param {boolean} [state=true] - State to set for adding a widget.
*/
@action onAddingWidget(state = true) {
this.isAddingWidget = state;
}
/**
* Creates a default dashboard with predefined widgets.
* @private
* @returns {Object} The default dashboard object.
*/
_createDefaultDashboard() {
let defaultDashboard;
// check store for default dashboard
const loadedDashboars = this.store.peekAll('dashboard');
// check for default dashboard loaded in store
defaultDashboard = loadedDashboars.find((dashboard) => dashboard.id === 'system');
if (defaultDashboard) {
return defaultDashboard;
}
// create new default dashboard
defaultDashboard = this.store.createRecord('dashboard', {
id: 'system',
uuid: 'system',
name: 'Default Dashboard',
is_default: false,
user_uuid: 'system',
widgets: this._createDefaultDashboardWidgets(),
});
return defaultDashboard;
}
/**
* Creates default widgets for the default dashboard.
* @private
* @returns {Array} An array of default dashboard widgets.
*/
_createDefaultDashboardWidgets() {
const widgets = this.universe.getDefaultDashboardWidgets().map((defaultWidget) => {
return this.store.createRecord('dashboard-widget', defaultWidget);
});
return widgets;
}
/**
* Checks if default dashboard is already loaded.
* @private
* @return {Boolean}
* @memberof DashboardService
*/
_isDefaultDashboardLoaded() {
const defaultDashboard = this._createDefaultDashboard();
return this.dashboards.some((dashboard) => dashboard.id === defaultDashboard.id);
}
/**
* Checks if default dashboard is not already loaded.
* @private
* @return {Boolean}
* @memberof DashboardService
*/
_isDefaultDashboardNotLoaded() {
return !this._isDefaultDashboardLoaded();
}
/**
* Gets the current dasbhoard or next available dashboard.
*
* @return {DashboardModel}
* @memberof DashboardService
*/
_getNextDashboard() {
return this.dashboards.find((dashboard) => dashboard.is_default) || this.dashboards[0];
}
}

View File

@@ -13,16 +13,6 @@ body[data-theme='dark'] .two-fa-enforcement-alert button#two-fa-setup-button.btn
cursor: default;
}
.app-version-in-nav {
display: flex;
align-items: center;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
font-size: 0.8rem;
line-height: 1rem;
padding-left: 1rem;
padding-top: 0.2rem;
}
.fleetbase-pagination-meta-info-wrapper.within-layout-section-header {
display: flex;
flex-direction: row;

View File

@@ -1,4 +1,5 @@
{{page-title (t "app.name")}}
<ModalsContainer />
<NotificationContainer @position="top" @zindex="99999" />
<div id="application-root-wormhole"></div>
{{outlet}}

View File

@@ -19,7 +19,13 @@
</p>
</div>
</div>
<Button @text={{t "auth.login.failed-attempt.button-text"}} @type="link" class="text-yellow-100" @wrapperClass="px-4 py-2 bg-gray-900 bg-opacity-25 hover:opacity-50" @onClick={{this.forgotPassword}} />
<Button
@text={{t "auth.login.failed-attempt.button-text"}}
@type="link"
class="text-yellow-100"
@wrapperClass="px-4 py-2 bg-gray-900 bg-opacity-25 hover:opacity-50"
@onClick={{this.forgotPassword}}
/>
</div>
{{/if}}
@@ -67,7 +73,7 @@
href="javascript:;"
{{on "click" this.forgotPassword}}
disabled={{this.isLoading}}
class="font-medium transition duration-150 ease-in-out text-sky-600 hover:text-sky-500 focus:outline-none focus:underline"
class="font-medium transition duration-150 ease-in-out text-sky-500 hover:text-sky-400 focus:outline-none focus:underline"
>
{{t "auth.login.form.forgot-password-label"}}
</a>
@@ -75,8 +81,26 @@
</div>
<div class="mt-6 space-y-4">
<Button @buttonType="submit" @type="primary" @text={{t "auth.login.form.sign-in-button"}} @icon="lock" @wrapperClass="btn-block" @isLoading={{this.isLoading}} @onClick={{this.login}} />
<Button @text={{t "auth.login.form.create-account-button"}} @wrapperClass="btn-block" @disabled={{this.isLoading}} @onClick={{fn (transition-to "onboard")}} />
<Button @text="Customer Login" @type="link" @wrapperClass="btn-block py-1 border dark:border-gray-700 border-gray-200 hover:opacity-50" @disabled={{this.isLoading}} @onClick={{fn (transition-to "auth.portal-login")}} />
<Button
@buttonType="submit"
@type="primary"
@text={{t "auth.login.form.sign-in-button"}}
@icon="lock"
@wrapperClass="btn-block"
@isLoading={{this.isLoading}}
@onClick={{this.login}}
/>
<Button @text={{t "auth.login.form.create-account-button"}} @icon="briefcase" @wrapperClass="btn-block" @disabled={{this.isLoading}} @onClick={{fn (transition-to "onboard")}} />
<RegistryYield @type="menu" @registry="auth:login" as |menuItem|>
<Button
@text={{menuItem.title}}
@icon={{menuItem.icon}}
@type={{menuItem.type}}
@wrapperClass={{menuItem.wrapperClass}}
@disabled={{this.isLoading}}
@onClick={{menuItem.onClick}}
@permission={{menuItem.permission}}
/>
</RegistryYield>
</div>
</form>

View File

@@ -1,88 +0,0 @@
<div>
<div class="mx-auto w-12 h-12">
<LogoIcon @url={{@brand.icon_url}} @size="12" class="mx-auto" />
</div>
<h2 class="mt-6 mb-3 text-3xl font-semibold leading-9 text-center text-gray-900 dark:text-gray-100">
Customer Login
</h2>
</div>
{{#if (gte this.failedAttempts 3)}}
<div class="flex flex-col flex-grow-0 flex-shrink-0 text-sm bg-yellow-800 border border-yellow-600 px-2 py-2 rounded-md text-yellow-100 my-4 transition-all">
<div class="flex flex-row items-start mb-2">
<div class="w-8 flex-grow-0 flex-shrink-0">
<FaIcon @icon="triangle-exclamation" @size="xl" class="pt-1" />
</div>
<div class="flex-1">
<p class="flex-1 text-sm text-yellow-100">
{{t "auth.login.failed-attempt.message" htmlSafe=true}}
</p>
</div>
</div>
<Button @text={{t "auth.login.failed-attempt.button-text"}} @type="link" class="text-yellow-100" @wrapperClass="px-4 py-2 bg-gray-900 bg-opacity-25 hover:opacity-50" @onClick={{this.forgotPassword}} />
</div>
{{/if}}
<form class="mt-8" {{on "submit" this.login}}>
<input type="hidden" name="remember" value="true" />
<div class="rounded-md shadow-sm">
<div>
<Input
@value={{this.identity}}
aria-label={{t "auth.login.form.email-label"}}
name="email"
@type="email"
autocomplete="username"
required
class="relative block w-full px-3 py-2 text-gray-900 placeholder-gray-500 border border-gray-300 rounded-none appearance-none rounded-t-md focus:outline-none focus:shadow-outline-blue focus:border-blue-300 focus:z-10 sm:text-sm sm:leading-5 dark:text-white dark:bg-gray-700 dark:border-gray-900"
placeholder={{t "auth.login.form.email-label"}}
disabled={{this.isLoading}}
/>
</div>
<div class="-mt-px">
<Input
@value={{this.password}}
aria-label={{t "auth.login.form.password-label"}}
name="password"
@type="password"
autocomplete="current-password"
required
class="relative block w-full px-3 py-2 text-gray-900 placeholder-gray-500 border border-gray-300 rounded-none appearance-none rounded-b-md focus:outline-none focus:shadow-outline-blue focus:border-blue-300 focus:z-10 sm:text-sm sm:leading-5 dark:text-white dark:bg-gray-700 dark:border-gray-900"
placeholder={{t "auth.login.form.password-label"}}
disabled={{this.isLoading}}
/>
</div>
</div>
<div class="flex items-center justify-between mt-6">
<div class="flex items-center">
<Input id="rememberMe" @type="checkbox" @checked={{this.rememberMe}} disabled={{this.isLoading}} class="w-4 h-4 transition duration-150 ease-in-out form-checkbox text-sky-500" />
<label for="rememberMe" class="block ml-2 text-sm leading-5 text-gray-900 dark:text-gray-100">
{{t "auth.login.form.remember-me-label"}}
</label>
</div>
<div class="text-sm leading-5">
<a
href="javascript:;"
{{on "click" this.forgotPassword}}
disabled={{this.isLoading}}
class="font-medium transition duration-150 ease-in-out text-sky-600 hover:text-sky-500 focus:outline-none focus:underline"
>
{{t "auth.login.form.forgot-password-label"}}
</a>
</div>
</div>
<div class="mt-6">
<Button
@buttonType="submit"
@type="primary"
@text={{t "auth.login.form.sign-in-button"}}
@icon="lock"
@wrapperClass="btn-block"
@isLoading={{this.isLoading}}
@onClick={{this.login}}
/>
</div>
</form>

View File

@@ -2,7 +2,7 @@
<Layout::Section::Header @title="Account Auth" />
<Layout::Section::Body class="overflow-y-scroll h-full">
<div class="container mx-auto h-screen" {{increase-height-by 500}}>
<div class="container mx-auto h-screen">
<div class="max-w-3xl my-10 mx-auto space-y-6">
<ContentPanel @title="Change Password" @open={{true}} @pad={{true}} @panelBodyClass="bg-white dark:bg-gray-800">
<form id="change-password-form" aria-label="change-password" {{on "submit" (perform this.changePassword)}}>
@@ -31,4 +31,5 @@
{{/if}}
</div>
</div>
<Spacer @height="500px" />
</Layout::Section::Body>

View File

@@ -2,9 +2,10 @@
<Layout::Section::Header @title={{@model.title}} />
<Layout::Section::Body class="overflow-y-scroll h-full">
<div class="container mx-auto h-screen" {{increase-height-by 300}}>
<div class="container mx-auto h-screen">
<div class="max-w-3xl my-10 mx-auto space-y-">
{{component @model.component params=@model.componentParams}}
</div>
</div>
<Spacer @height="300px" />
</Layout::Section::Body>

View File

@@ -2,9 +2,10 @@
<Layout::Section::Header @title={{t "console.admin.config.database.title"}} />
<Layout::Section::Body class="overflow-y-scroll h-full">
<div class="container mx-auto h-screen" {{increase-height-by 800}}>
<div class="container mx-auto h-screen">
<div class="max-w-3xl my-10 mx-auto space-y-6">
<Configure::Database />
</div>
</div>
<Spacer @height="300px" />
</Layout::Section::Body>

View File

@@ -2,9 +2,10 @@
<Layout::Section::Header @title={{t "console.admin.config.filesystem.title"}} />
<Layout::Section::Body class="overflow-y-scroll h-full">
<div class="container mx-auto h-screen" {{increase-height-by 800}}>
<div class="container mx-auto h-screen">
<div class="max-w-3xl my-10 mx-auto space-y-6">
<Configure::Filesystem />
</div>
</div>
<Spacer @height="300px" />
</Layout::Section::Body>

View File

@@ -2,9 +2,10 @@
<Layout::Section::Header @title={{t "console.admin.config.mail.title"}} />
<Layout::Section::Body class="overflow-y-scroll h-full">
<div class="container mx-auto h-screen" {{increase-height-by 800}}>
<div class="container mx-auto h-screen">
<div class="max-w-3xl my-10 mx-auto space-y-6">
<Configure::Mail />
</div>
</div>
<Spacer @height="300px" />
</Layout::Section::Body>

View File

@@ -2,9 +2,10 @@
<Layout::Section::Header @title={{t "console.admin.config.notification-channels.title"}} />
<Layout::Section::Body class="overflow-y-scroll h-full">
<div class="container mx-auto h-screen" {{increase-height-by 800}}>
<div class="container mx-auto h-screen">
<div class="max-w-3xl my-10 mx-auto space-y-6">
<Configure::NotificationChannels />
</div>
</div>
<Spacer @height="300px" />
</Layout::Section::Body>

View File

@@ -2,9 +2,10 @@
<Layout::Section::Header @title={{t "console.admin.config.queue.title"}} />
<Layout::Section::Body class="overflow-y-scroll h-full">
<div class="container mx-auto h-screen" {{increase-height-by 800}}>
<div class="container mx-auto h-screen">
<div class="max-w-3xl my-10 mx-auto space-y-6">
<Configure::Queue />
</div>
</div>
<Spacer @height="300px" />
</Layout::Section::Body>

View File

@@ -2,9 +2,10 @@
<Layout::Section::Header @title={{t "console.admin.config.services.title"}} />
<Layout::Section::Body class="overflow-y-scroll h-full">
<div class="container mx-auto h-screen" {{increase-height-by 900}}>
<div class="container mx-auto h-screen">
<div class="max-w-3xl my-10 mx-auto space-y-6">
<Configure::Services />
</div>
</div>
<Spacer @height="300px" />
</Layout::Section::Body>

View File

@@ -2,9 +2,10 @@
<Layout::Section::Header @title={{t "console.admin.config.socket.title"}} />
<Layout::Section::Body class="overflow-y-scroll h-full">
<div class="container mx-auto h-screen" {{increase-height-by 900}}>
<div class="container mx-auto h-screen">
<div class="max-w-3xl my-10 mx-auto space-y-6">
<Configure::Socket />
</div>
</div>
<Spacer @height="300px" />
</Layout::Section::Body>

View File

@@ -2,7 +2,7 @@
<Layout::Section::Header @title={{t "common.overview"}} />
<Layout::Section::Body class="overflow-y-scroll h-full">
<div class="container mx-auto h-screen" {{increase-height-by 800}}>
<div class="container mx-auto h-screen">
<div class="max-w-3xl my-10 mx-auto space-y-6">
<div class="grid grid-cols-3 xs:grid-cols-1 gap-4">
<StatWidget @title={{t "console.admin.index.total-users"}} @value={{@model.total_users}} />
@@ -11,4 +11,5 @@
</div>
</div>
</div>
<Spacer @height="300px" />
</Layout::Section::Body>

View File

@@ -2,9 +2,10 @@
<Layout::Section::Header @title={{@model.title}} />
<Layout::Section::Body class="overflow-y-scroll h-full">
<div class="container mx-auto h-screen" {{increase-height-by 300}}>
<div class="container mx-auto h-screen">
<div class="max-w-3xl my-10 mx-auto space-y-">
{{component @model.component params=@model.componentParams}}
</div>
</div>
<Spacer @height="300px" />
</Layout::Section::Body>

View File

@@ -1,7 +1,7 @@
{{page-title "Dashboard"}}
<Layout::Section::Body class="overflow-y-scroll h-full">
<TwoFaEnforcementAlert />
<Dashboard @sidebar={{this.sidebarContext}} />
<Dashboard @sidebar={{this.sidebarContext}} class="flex items-center justify-between mb-4 mt-6 px-14" />
<Spacer @height="300px" />
</Layout::Section::Body>
<div id="console-home-wormhole" />

View File

@@ -1,7 +1,7 @@
<Layout::Section::Header @title={{t "console.settings.index.title"}} />
<Layout::Section::Body class="overflow-y-scroll h-full">
<div class="container mx-auto h-screen" {{increase-height-by 500}}>
<div class="container mx-auto h-screen">
<div class="max-w-3xl my-10 mx-auto space-y-6">
<ContentPanel @title={{t "console.settings.index.title"}} @open={{true}} @pad={{true}} @panelBodyClass="bg-white dark:bg-gray-800">
<form {{on "submit" this.saveSettings}}>
@@ -65,4 +65,5 @@
</ContentPanel>
</div>
</div>
<Spacer @height="500px" />
</Layout::Section::Body>

View File

@@ -2,7 +2,7 @@
<Layout::Section::Header @title="2FA" />
<Layout::Section::Body class="overflow-y-scroll h-full">
<div class="container mx-auto h-screen" {{increase-height-by 500}}>
<div class="container mx-auto h-screen">
<div class="max-w-3xl my-10 mx-auto space-y-6">
<ContentPanel @title="2FA Settings" @open={{true}} @pad={{true}} @panelBodyClass="bg-white dark:bg-gray-800">
<div class="mb-3">
@@ -33,4 +33,5 @@
</ContentPanel>
</div>
</div>
<Spacer @height="300px" />
</Layout::Section::Body>

View File

@@ -2,9 +2,10 @@
<Layout::Section::Header @title={{@model.title}} />
<Layout::Section::Body class="overflow-y-scroll h-full">
<div class="container mx-auto h-screen" {{increase-height-by 300}}>
<div class="container mx-auto h-screen">
<div class="max-w-3xl my-10 mx-auto space-y-">
{{component @model.component params=@model.componentParams}}
</div>
</div>
<Spacer @height="300px" />
</Layout::Section::Body>

View File

@@ -2,9 +2,10 @@
<Layout::Section::Header @title={{@model.title}} />
<Layout::Section::Body class="overflow-y-scroll h-full">
<div class="container mx-auto h-screen" {{increase-height-by 300}}>
<div class="container mx-auto h-screen">
<div class="max-w-3xl my-10 mx-auto space-y-">
{{component @model.component params=@model.componentParams}}
</div>
</div>
<Spacer @height="300px" />
</Layout::Section::Body>

View File

@@ -1,5 +1,6 @@
<div class="flex items-center justify-center h-screen min-h-screen px-4 py-12 bg-gray-50 dark:bg-gray-900 sm:px-6 lg:px-8 overflow-y-scroll">
<div class="w-full max-w-md h-screen flex items-center justify-center py-4" {{increase-height-by 300}}>
<div class="w-full max-w-md h-screen flex items-center justify-center py-4">
{{outlet}}
</div>
<Spacer @height="300px" />
</div>

View File

@@ -1,14 +0,0 @@
{{page-title "Portal"}}
<Portal::Container>
<Portal::Header @onAction={{this.onAction}} />
<Portal::Main>
<Portal::Sidebar />
<Portal::Section>{{outlet}}</Portal::Section>
</Portal::Main>
</Portal::Container>
{{!-- <div class="container">
<div class="flex flex-row">
<a href="javascript:;" {{on "click" this.invalidateSession}}>Sign out</a>
</div>
</div>
</div> --}}

View File

@@ -1,2 +0,0 @@
{{page-title "Account"}}
{{outlet}}

View File

@@ -1,2 +0,0 @@
{{page-title "Home"}}
{{outlet}}

View File

@@ -0,0 +1,2 @@
{{page-title @model.title}}
{{component @model.component params=@model.componentParams}}

View File

@@ -7,5 +7,14 @@ module.exports = function asArray(value) {
return value.split(',');
}
return Array.from(value);
try {
let iterable = Array.from(value);
if (Array.isArray(iterable)) {
return iterable;
}
} catch (error) {
return [];
}
return [];
};

View File

@@ -1,11 +0,0 @@
diff --git a/node_modules/ember-gridstack/addon/components/grid-stack.js b/node_modules/ember-gridstack/addon/components/grid-stack.js
index fa51392..fdabb2a 100644
--- a/node_modules/ember-gridstack/addon/components/grid-stack.js
+++ b/node_modules/ember-gridstack/addon/components/grid-stack.js
@@ -133,5 +133,6 @@ export default class GridStackComponent extends Component {
removeWidget(element, removeDOM = false, triggerEvent = true) {
triggerEvent = triggerEvent && !this.isDestroying && !this.isDestroyed;
this.gridStack?.removeWidget(element, removeDOM, triggerEvent);
+ this.gridStack?.compact();
}
}

View File

@@ -5,7 +5,7 @@ const recast = require('recast');
const babelParser = require('recast/parsers/babel');
const builders = recast.types.builders;
function getExtensionMountPath(extensionName) {
function getExtensionMountPath (extensionName) {
let extensionNameSegments = extensionName.split('/');
let mountName = extensionNameSegments[1];
@@ -16,7 +16,7 @@ function getExtensionMountPath(extensionName) {
return mountName.replace('-engine', '');
}
function only(subject, props = []) {
function only (subject, props = []) {
const keys = Object.keys(subject);
const result = {};
@@ -31,13 +31,13 @@ function only(subject, props = []) {
return result;
}
function getExtensions() {
function getExtensions () {
return new Promise((resolve, reject) => {
const extensions = [];
const seenPackages = new Set();
return fg(['node_modules/*/package.json', 'node_modules/*/*/package.json'])
.then((results) => {
.then(results => {
for (let i = 0; i < results.length; i++) {
const packagePath = results[i];
const packageJson = fs.readFileSync(packagePath);
@@ -69,7 +69,7 @@ function getExtensions() {
});
}
function getRouterFileContents() {
function getRouterFileContents () {
const routerFilePath = path.join(__dirname, 'router.map.js');
const routerFileContents = fs.readFileSync(routerFilePath, 'utf-8');
@@ -78,24 +78,25 @@ function getRouterFileContents() {
(async () => {
const extensions = await getExtensions();
const consoleExtensions = extensions.filter(extension => !extension.fleetbase || extension.fleetbase.mount !== 'root');
const rootExtensions = extensions.filter(extension => extension.fleetbase && extension.fleetbase.mount === 'root');
const routerFileContents = getRouterFileContents();
const ast = recast.parse(routerFileContents, { parser: babelParser });
recast.visit(ast, {
visitCallExpression(path) {
visitCallExpression (path) {
if (path.value.type === 'CallExpression' && path.value.callee.property.name === 'route' && path.value.arguments[0].value === 'console') {
let functionExpression;
// Find the function expression
path.value.arguments.forEach((arg) => {
path.value.arguments.forEach(arg => {
if (arg.type === 'FunctionExpression') {
functionExpression = arg;
}
});
if (functionExpression) {
// Check and add the new engine mounts
extensions.forEach((extension) => {
consoleExtensions.forEach(extension => {
const mountPath = getExtensionMountPath(extension.name);
let route = mountPath;
@@ -104,7 +105,7 @@ function getRouterFileContents() {
}
// Check if engine is already mounted
const isMounted = functionExpression.body.body.some((expressionStatement) => {
const isMounted = functionExpression.body.body.some(expressionStatement => {
return expressionStatement.expression.arguments[0].value === extension.name;
});
@@ -123,8 +124,46 @@ function getRouterFileContents() {
);
}
});
}
}
return false;
// console.log(path.value.callee.property.name);
if (path.value.type === 'CallExpression' && path.value.callee.property.name === 'map') {
let functionExpression;
path.value.arguments.forEach(arg => {
if (arg.type === 'FunctionExpression') {
functionExpression = arg;
}
});
if (functionExpression) {
rootExtensions.forEach(extension => {
const mountPath = getExtensionMountPath(extension.name);
let route = mountPath;
if (extension.fleetbase && extension.fleetbase.route) {
route = extension.fleetbase.route;
}
const isMounted = functionExpression.body.body.some(expressionStatement => {
return expressionStatement.expression.arguments[0].value === extension.name;
});
if (!isMounted) {
functionExpression.body.body.push(
builders.expressionStatement(
builders.callExpression(builders.memberExpression(builders.thisExpression(), builders.identifier('mount')), [
builders.literal(extension.name),
builders.objectExpression([
builders.property('init', builders.identifier('as'), builders.literal(route)),
builders.property('init', builders.identifier('path'), builders.literal(route)),
]),
])
)
);
}
});
}
}

View File

@@ -7,7 +7,11 @@ export default class Router extends EmberRouter {
}
Router.map(function () {
this.route('virtual', { path: '/:slug' });
this.route('install');
this.route('onboard', function () {
this.route('verify-email');
});
this.route('auth', function () {
this.route('login', { path: '/' });
this.route('forgot-password');
@@ -16,28 +20,22 @@ Router.map(function () {
this.route('verification');
this.route('portal-login', { path: '/portal' });
});
this.route('onboard', function () {
this.route('verify-email');
});
this.route('invite', { path: 'join' }, function () {
this.route('for-driver', { path: '/fleet/:public_id' });
this.route('for-user', { path: '/org/:public_id' });
});
this.route('portal', function () {
this.route('account');
});
this.route('console', { path: '/' }, function () {
this.route('home', { path: '/' });
this.route('notifications');
this.route('account', function () {
this.route('virtual', { path: '/:slug/:view' });
this.route('virtual', { path: '/:slug' });
this.route('auth');
});
this.route('settings', function () {
this.route('virtual', { path: '/:slug/:view' });
this.route('virtual', { path: '/:slug' });
this.route('two-fa');
});
this.route('virtual', { path: '/:slug/:view' });
this.route('virtual', { path: '/:slug' });
this.route('admin', function () {
this.route('config', function () {
this.route('database');
@@ -52,7 +50,7 @@ Router.map(function () {
this.route('branding');
this.route('notifications');
this.route('two-fa-settings');
this.route('virtual', { path: '/:slug/:view' });
this.route('virtual', { path: '/:slug' });
this.route('organizations', function () {
this.route('index', { path: '/' }, function () {
this.route('users', { path: '/:public_id/users' });

View File

@@ -1,26 +0,0 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from '@fleetbase/console/tests/helpers';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Component | dashboard', function (hooks) {
setupRenderingTest(hooks);
test('it renders', async function (assert) {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.set('myAction', function(val) { ... });
await render(hbs`<Dashboard />`);
assert.dom(this.element).hasText('');
// Template block usage:
await render(hbs`
<Dashboard>
template block text
</Dashboard>
`);
assert.dom(this.element).hasText('template block text');
});
});

View File

@@ -1,26 +0,0 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from '@fleetbase/console/tests/helpers';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Component | dashboard/create', function (hooks) {
setupRenderingTest(hooks);
test('it renders', async function (assert) {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.set('myAction', function(val) { ... });
await render(hbs`<Dashboard::Create />`);
assert.dom(this.element).hasText('');
// Template block usage:
await render(hbs`
<Dashboard::Create>
template block text
</Dashboard::Create>
`);
assert.dom(this.element).hasText('template block text');
});
});

View File

@@ -1,17 +0,0 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from '@fleetbase/console/tests/helpers';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Helper | spread-widget-options', function (hooks) {
setupRenderingTest(hooks);
// TODO: Replace this with your real tests.
test('it renders', async function (assert) {
this.set('inputValue', '1234');
await render(hbs`{{spread-widget-options this.inputValue}}`);
assert.dom().hasText('1234');
});
});

View File

@@ -1,12 +1,12 @@
import { module, test } from 'qunit';
import { setupTest } from '@fleetbase/console/tests/helpers';
module('Unit | Service | dashboard', function (hooks) {
module('Unit | Controller | console/account/virtual', function (hooks) {
setupTest(hooks);
// TODO: Replace this with your real tests.
test('it exists', function (assert) {
let service = this.owner.lookup('service:dashboard');
assert.ok(service);
let controller = this.owner.lookup('controller:console/account/virtual');
assert.ok(controller);
});
});

View File

@@ -1,12 +1,12 @@
import { module, test } from 'qunit';
import { setupTest } from '@fleetbase/console/tests/helpers';
module('Unit | Controller | auth/portal-login', function (hooks) {
module('Unit | Controller | console/admin/virtual', function (hooks) {
setupTest(hooks);
// TODO: Replace this with your real tests.
test('it exists', function (assert) {
let controller = this.owner.lookup('controller:auth/portal-login');
let controller = this.owner.lookup('controller:console/admin/virtual');
assert.ok(controller);
});
});

View File

@@ -0,0 +1,12 @@
import { module, test } from 'qunit';
import { setupTest } from '@fleetbase/console/tests/helpers';
module('Unit | Controller | console/settings/virtual', function (hooks) {
setupTest(hooks);
// TODO: Replace this with your real tests.
test('it exists', function (assert) {
let controller = this.owner.lookup('controller:console/settings/virtual');
assert.ok(controller);
});
});

View File

@@ -1,12 +1,12 @@
import { module, test } from 'qunit';
import { setupTest } from '@fleetbase/console/tests/helpers';
module('Unit | Controller | portal', function (hooks) {
module('Unit | Controller | console/virtual', function (hooks) {
setupTest(hooks);
// TODO: Replace this with your real tests.
test('it exists', function (assert) {
let controller = this.owner.lookup('controller:portal');
let controller = this.owner.lookup('controller:console/virtual');
assert.ok(controller);
});
});

View File

@@ -0,0 +1,39 @@
import Application from '@ember/application';
import config from '@fleetbase/console/config/environment';
import { initialize } from '@fleetbase/console/instance-initializers/load-leaflet';
import { module, test } from 'qunit';
import Resolver from 'ember-resolver';
import { run } from '@ember/runloop';
module('Unit | Instance Initializer | load-leaflet', function (hooks) {
hooks.beforeEach(function () {
this.TestApplication = class TestApplication extends Application {
modulePrefix = config.modulePrefix;
podModulePrefix = config.podModulePrefix;
Resolver = Resolver;
};
this.TestApplication.instanceInitializer({
name: 'initializer under test',
initialize,
});
this.application = this.TestApplication.create({
autoboot: false,
});
this.instance = this.application.buildInstance();
});
hooks.afterEach(function () {
run(this.instance, 'destroy');
run(this.application, 'destroy');
});
// TODO: Replace this with your real tests.
test('it works', async function (assert) {
await this.instance.boot();
assert.ok(true);
});
});

View File

@@ -1,11 +0,0 @@
import { module, test } from 'qunit';
import { setupTest } from '@fleetbase/console/tests/helpers';
module('Unit | Route | auth/portal-login', function (hooks) {
setupTest(hooks);
test('it exists', function (assert) {
let route = this.owner.lookup('route:auth/portal-login');
assert.ok(route);
});
});

View File

@@ -1,11 +0,0 @@
import { module, test } from 'qunit';
import { setupTest } from '@fleetbase/console/tests/helpers';
module('Unit | Route | portal/account', function (hooks) {
setupTest(hooks);
test('it exists', function (assert) {
let route = this.owner.lookup('route:portal/account');
assert.ok(route);
});
});

View File

@@ -1,11 +0,0 @@
import { module, test } from 'qunit';
import { setupTest } from '@fleetbase/console/tests/helpers';
module('Unit | Route | portal/home', function (hooks) {
setupTest(hooks);
test('it exists', function (assert) {
let route = this.owner.lookup('route:portal/home');
assert.ok(route);
});
});

View File

@@ -1,11 +1,11 @@
import { module, test } from 'qunit';
import { setupTest } from '@fleetbase/console/tests/helpers';
module('Unit | Route | portal', function (hooks) {
module('Unit | Route | virtual', function (hooks) {
setupTest(hooks);
test('it exists', function (assert) {
let route = this.owner.lookup('route:portal');
let route = this.owner.lookup('route:virtual');
assert.ok(route);
});
});

View File

@@ -0,0 +1,364 @@
app:
name: Fleetbase
terms:
new: جديد
sort: ترتيب
filter: تصفية
columns: أعمدة
settings: إعدادات
home: الصفحة الرئيسية
admin: مشرف
logout: تسجيل الخروج
dashboard: لوحة القيادة
search: بحث
search-input: إدخال البحث
common:
confirm: تأكيد
edit: تحرير
save: حفظ
save-changes: حفظ التغييرات
cancel: إلغاء
2fa-config: تكوين المصادقة الثنائية
account: حساب
admin: مشرف
branding: العلامة التجارية
columns: أعمدة
dashboard: لوحة القيادة
date-of-birth: تاريخ الميلاد
delete: حذف
email: البريد الإلكتروني
filesystem: نظام الملفات
filter: تصفية
home: الصفحة الرئيسية
logout: تسجيل الخروج
mail: بريد
name: اسم
new: جديد
notification-channels: قنوات الإشعارات
notifications: إشعارات
organization: منظمة
organizations: منظمات
overview: نظرة عامة
phone: رقم هاتفك
profile: الملف الشخصي
queue: قائمة الانتظار
save-button-text: حفظ التغييرات
search-input: إدخال البحث
search: بحث
services: خدمات
settings: إعدادات
socket: مقبس
sort: ترتيب
two-factor: المصادقة الثنائية
uploading: جارٍ التحميل...
your-profile: ملفك الشخصي
created-at: تم الإنشاء في
country: البلد
phone-number: الهاتف
status: الحالة
close-and-save: إغلاق وحفظ
users: المستخدمون
changelog: سجل التغييرات
ok: موافق
select-file: اختر ملف
back: رجوع
next: التالي
continue: متابعة
done: تم
export: تصدير
reload: إعادة تحميل
reload-data: إعادة تحميل البيانات
unauthorized: غير مصرح
unauthorized-to: غير مصرح لـ
unauthorized-access: وصول غير مصرح
unauthorized-access-message: وصول غير مصرح، يجب عليك طلب الأذونات للوصول.
permissions-required-for-changes: ليس لديك الأذونات المطلوبة لإجراء التغييرات.
push-notifications: إشعارات الدفع
component:
file:
dropdown-label: إجراءات الملف
import-modal:
loading-message: جارٍ معالجة الاستيراد...
drop-upload: إسقاط للتحميل
invalid: غير صالح
ready-upload: جاهز للتحميل.
upload-spreadsheets: تحميل جداول البيانات
drag-drop: اسحب وأفلت ملفات جداول البيانات في منطقة الإسقاط هذه
button-text: أو اختر جداول البيانات للتحميل
spreadsheets: جداول البيانات
upload-queue: قائمة انتظار التحميل
dropzone:
file: ملف
drop-to-upload: إسقاط للتحميل
invalid: غير صالح
files-ready-for-upload: >-
{numOfFiles} جاهز للتحميل.
upload-images-videos: تحميل الصور ومقاطع الفيديو
upload-documents: تحميل المستندات
upload-documents-files: تحميل المستندات والملفات
upload-avatar-files: تحميل الصور الرمزية المخصصة
dropzone-supported-images-videos: اسحب وأفلت ملفات الصور والفيديو في منطقة الإسقاط هذه
dropzone-supported-avatars: اسحب وأفلت ملفات SVG أو PNG
dropzone-supported-files: اسحب وأفلت الملفات في منطقة الإسقاط هذه
or-select-button-text: أو اختر الملفات للتحميل.
upload-queue: قائمة انتظار التحميل
uploading: جارٍ التحميل...
two-fa-enforcement-alert:
message: لتعزيز أمان حسابك، تتطلب مؤسستك المصادقة الثنائية (2FA). قم بتمكين المصادقة الثنائية في إعدادات حسابك للحصول على طبقة إضافية من الحماية.
button-text: إعداد المصادقة الثنائية
comment-thread:
publish-comment-button-text: نشر التعليق
publish-reply-button-text: نشر الرد
reply-comment-button-text: رد
edit-comment-button-text: تحرير
delete-comment-button-text: حذف
comment-published-ago: >-
{createdAgo} منذ
comment-input-placeholder: أدخل تعليقًا جديدًا...
comment-reply-placeholder: أدخل ردك...
comment-input-empty-notification: لا يمكنك نشر تعليقات فارغة...
comment-min-length-notification: يجب أن يكون التعليق على الأقل 2 حرف
dashboard:
select-dashboard: اختر لوحة القيادة
create-new-dashboard: إنشاء لوحة قيادة جديدة
create-a-new-dashboard: إنشاء لوحة قيادة جديدة
confirm-create-dashboard: إنشاء لوحة القيادة!
edit-layout: تحرير التخطيط
add-widgets: إضافة عناصر واجهة
delete-dashboard: حذف لوحة القيادة
save-dashboard: حفظ لوحة القيادة
you-cannot-delete-this-dashboard: لا يمكنك حذف هذه اللوحة.
are-you-sure-you-want-delete-dashboard: هل أنت متأكد من حذف {dashboardName}؟
dashboard-widget-panel:
widget-name: >-
عنصر واجهة {widgetName}
select-widgets: اختر عناصر الواجهة
close-and-save: إغلاق وحفظ
services:
dashboard-service:
create-dashboard-success-notification: تم إنشاء لوحة القيادة الجديدة `{dashboardName}` بنجاح.
delete-dashboard-success-notification: تم حذف لوحة القيادة `{dashboardName}`.
auth:
verification:
header-title: التحقق من الحساب
title: تحقق من عنوان بريدك الإلكتروني
message-text: <strong>اقتربت من الانتهاء!</strong><br> تحقق من بريدك الإلكتروني للحصول على رمز التحقق.
verification-code-text: أدخل رمز التحقق الذي تلقيته عبر البريد الإلكتروني.
verification-input-label: رمز التحقق
verify-button-text: تحقق واستمر
didnt-receive-a-code: لم تتلق رمزًا بعد؟
not-sent:
message: لم تتلق رمزًا بعد؟
alternative-choice: استخدم الخيارات البديلة أدناه للتحقق من حسابك.
resend-email: إعادة إرسال البريد الإلكتروني
send-by-sms: إرسال عبر الرسائل القصيرة
two-fa:
verify-code:
verification-code: رمز التحقق
check-title: تحقق من بريدك الإلكتروني أو هاتفك
check-subtitle: لقد أرسلنا لك رمز تحقق. أدخل الرمز أدناه لإكمال عملية تسجيل الدخول.
expired-help-text: انتهت صلاحية رمز المصادقة الثنائية الخاص بك. يمكنك طلب رمز آخر إذا كنت بحاجة إلى مزيد من الوقت.
resend-code: إعادة إرسال الرمز
verify-code: تحقق من الرمز
cancel-two-factor: إلغاء المصادقة الثنائية
invalid-session-error-notification: جلسة غير صالحة. حاول مرة أخرى.
verification-successful-notification: تم التحقق بنجاح!
verification-code-expired-notification: انتهت صلاحية رمز التحقق. يرجى طلب رمز جديد.
verification-code-failed-notification: فشل التحقق. حاول مرة أخرى.
resend-code:
verification-code-resent-notification: تم إرسال رمز التحقق الجديد.
verification-code-resent-error-notification: خطأ في إعادة إرسال رمز التحقق. حاول مرة أخرى.
forgot-password:
success-message: تحقق من بريدك الإلكتروني للمتابعة!
is-sent:
title: اقتربت من الانتهاء!
message: <strong>تحقق من بريدك الإلكتروني!</strong><br> لقد أرسلنا لك رابطًا سحريًا إلى بريدك الإلكتروني سيسمح لك بإعادة تعيين كلمة المرور الخاصة بك. ينتهي صلاحية الرابط في 15 دقيقة.
not-sent:
title: هل نسيت كلمة المرور؟
message: <strong>لا تقلق، نحن هنا لمساعدتك.</strong><br> أدخل البريد الإلكتروني الذي تستخدمه لتسجيل الدخول إلى {appName} وسنرسل لك رابطًا آمنًا لإعادة تعيين كلمة المرور الخاصة بك.
form:
email-label: عنوان بريدك الإلكتروني
submit-button: حسنًا، أرسل لي رابطًا سحريًا!
nevermind-button: لا تهتم
login:
title: تسجيل الدخول إلى حسابك
no-identity-notification: هل نسيت إدخال بريدك الإلكتروني؟
no-password-notification: هل نسيت إدخال كلمة المرور الخاصة بك؟
unverified-notification: يجب التحقق من حسابك للمتابعة.
password-reset-required: مطلوب إعادة تعيين كلمة المرور للمتابعة.
failed-attempt:
message: <strong>هل نسيت كلمة المرور الخاصة بك؟</strong><br> انقر على الزر أدناه لإعادة تعيين كلمة المرور الخاصة بك.
button-text: حسنًا، ساعدني في إعادة التعيين!
form:
email-label: عنوان البريد الإلكتروني
password-label: كلمة المرور
remember-me-label: تذكرني
forgot-password-label: هل نسيت كلمة المرور الخاصة بك؟
sign-in-button: تسجيل الدخول
create-account-button: إنشاء حساب جديد
slow-connection-message: تواجه مشكلات في الاتصال.
reset-password:
success-message: تم إعادة تعيين كلمة المرور الخاصة بك! تسجيل الدخول للمتابعة.
invalid-verification-code: هذا الرابط لإعادة تعيين كلمة المرور غير صالح أو منتهي الصلاحية.
title: إعادة تعيين كلمة المرور الخاصة بك
form:
code:
label: رمز إعادة التعيين الخاص بك
help-text: رمز التحقق الذي تلقيته في بريدك الإلكتروني.
password:
label: كلمة مرور جديدة
help-text: أدخل كلمة مرور لا تقل عن 6 أحرف للمتابعة.
confirm-password:
label: تأكيد كلمة المرور الجديدة
help-text: أدخل كلمة مرور لا تقل عن 6 أحرف للمتابعة.
submit-button: إعادة تعيين كلمة المرور
back-button: رجوع
console:
create-or-join-organization:
modal-title: إنشاء أو الانضمام إلى منظمة
join-success-notification: لقد انضممت إلى منظمة جديدة!
create-success-notification: لقد أنشأت منظمة جديدة!
switch-organization:
modal-title: هل أنت متأكد أنك تريد التبديل إلى المنظمة {organizationName}؟
modal-body: من خلال التأكيد، سيظل حسابك مسجلاً الدخول، ولكن سيتم تبديل المنظمة الأساسية الخاصة بك.
modal-accept-button-text: نعم، أريد التبديل إلى المنظمة
success-notification: لقد قمت بتبديل المنظمات
account:
index:
upload-new: تحميل جديد
phone: رقم هاتفك.
photos: الصور
admin:
schedule-monitor:
schedule-monitor: مراقب الجدول
task-logs-for: >-
سجلات المهام لـ:
showing-last-count: عرض آخر {count} سجلات
name: الاسم
type: النوع
timezone: المنطقة الزمنية
last-started: آخر بدء
last-finished: آخر انتهاء
last-failure: آخر فشل
date: التاريخ
memory: الذاكرة
runtime: وقت التشغيل
output: المخرجات
no-output: لا توجد مخرجات
config:
database:
title: تكوين قاعدة البيانات
filesystem:
title: تكوين نظام الملفات
mail:
title: تكوين البريد
notification-channels:
title: تكوين إشعارات الدفع
queue:
title: تكوين قائمة الانتظار
services:
title: تكوين الخدمات
socket:
title: تكوين المقبس
branding:
title: العلامة التجارية
icon-text: أيقونة
upload-new: تحميل جديد
reset-default: إعادة التعيين إلى الافتراضي
logo-text: شعار
theme: السمة الافتراضية
index:
total-users: إجمالي المستخدمين
total-organizations: إجمالي المنظمات
total-transactions: إجمالي المعاملات
notifications:
title: الإشعارات
notification-settings: إعدادات الإشعارات
organizations:
index:
title: المنظمات
owner-name-column: المالك
owner-phone-column: هاتف المالك
owner-email-column: بريد المالك
users-count-column: المستخدمون
phone-column: الهاتف
email-column: البريد الإلكتروني
users:
title: المستخدمون
settings:
index:
title: إعدادات المنظمة
organization-name: اسم المنظمة
organization-description: وصف المنظمة
organization-phone: رقم هاتف المنظمة
organization-currency: عملة المنظمة
organization-id: معرف المنظمة
organization-branding: العلامة التجارية للمنظمة
logo: الشعار
logo-help-text: شعار منظمتك.
upload-new-logo: تحميل شعار جديد
backdrop: الخلفية
backdrop-help-text: لافتة اختيارية أو صورة خلفية لمنظمتك.
upload-new-backdrop: تحميل خلفية جديدة
extensions:
title: الإضافات قادمة قريبًا!
message: يرجى التحقق مرة أخرى في الإصدارات القادمة حيث نستعد لإطلاق مستودع الإضافات والسوق.
notifications:
select-all: تحديد الكل
mark-as-read: تعليم كمقروء
received: >-
تم الاستلام:
message: لا توجد إشعارات لعرضها.
invite:
for-users:
invitation-message: لقد تمت دعوتك للانضمام إلى {companyName}
invitation-sent-message: لقد تمت دعوتك للانضمام إلى منظمة {companyName} على {appName}. لقبول هذه الدعوة، أدخل رمز الدعوة الذي تلقيته عبر البريد الإلكتروني وانقر على متابعة.
invitation-code-sent-text: رمز الدعوة الخاص بك
accept-invitation-text: قبول الدعوة
onboard:
index:
title: أنشئ حسابك
welcome-title: <strong>مرحبًا بك في {companyName}!</strong><br />
welcome-text: أكمل التفاصيل المطلوبة أدناه للبدء.
full-name: الاسم الكامل
full-name-help-text: اسمك الكامل
your-email: عنوان البريد الإلكتروني
your-email-help-text: عنوان بريدك الإلكتروني
phone: رقم الهاتف
phone-help-text: رقم هاتفك
organization-name: اسم المنظمة
organization-help-text: اسم منظمتك، سيتم إدارة جميع خدماتك ومواردك تحت هذه المنظمة، لاحقًا يمكنك إنشاء العديد من المنظمات كما تريد أو تحتاج.
password: أدخل كلمة مرور
password-help-text: كلمة مرورك، تأكد من أنها جيدة.
confirm-password: تأكيد كلمة المرور
confirm-password-help-text: فقط لتأكيد كلمة المرور التي أدخلتها أعلاه.
continue-button-text: متابعة
verify-email:
header-title: التحقق من الحساب
title: تحقق من عنوان بريدك الإلكتروني
message-text: <strong>اقتربت من الانتهاء!</strong><br> تحقق من بريدك الإلكتروني للحصول على رمز التحقق.
verification-code-text: أدخل رمز التحقق الذي تلقيته عبر البريد الإلكتروني.
verification-input-label: رمز التحقق
verify-button-text: تحقق واستمر
didnt-receive-a-code: لم تتلق رمزًا بعد؟
not-sent:
message: لم تتلق رمزًا بعد؟
alternative-choice: استخدم الخيارات البديلة أدناه للتحقق من حسابك.
resend-email: إعادة إرسال البريد الإلكتروني
send-by-sms: إرسال عبر الرسائل القصيرة
install:
installer-header: المثبت
failed-message-sent: فشل التثبيت! انقر على الزر أدناه لإعادة محاولة التثبيت.
retry-install: إعادة محاولة التثبيت
start-install: بدء التثبيت
layout:
header:
menus:
organization:
settings: إعدادات المنظمة
create-or-join: إنشاء أو الانضمام إلى المنظمات
explore-extensions: استكشاف الإضافات
user:
view-profile: عرض الملف الشخصي
keyboard-shortcuts: عرض اختصارات لوحة المفاتيح
changelog: سجل التغييرات

View File

@@ -0,0 +1,363 @@
app:
name: Fleetbase
terms:
new: Mới
sort: Sắp xếp
filter: Lọc
columns: Cột
settings: Cài đặt
home: Trang chủ
admin: Admin
logout: Đăng xuất
dashboard: Bảng điều khiển
search: Tìm kiếm
search-input: Đầu vào tìm kiếm
common:
confirm: Xác nhận
edit: Chỉnh sửa
save: Lưu
save-changes: Lưu thay đổi
cancel: Hủy
2fa-config: Cấu hình 2FA
account: Tài khoản
admin: Admin
branding: Thương hiệu
columns: Cột
dashboard: Bảng điều khiển
date-of-birth: Ngày sinh
delete: Xóa
email: Email
filesystem: Hệ thống tệp
filter: Lọc
home: Trang chủ
logout: Đăng xuất
mail: Mail
name: Tên
new: Mới
notification-channels: Kênh thông báo
notifications: Thông báo
organization: Tổ chức
organizations: Các tổ chức
overview: Tổng quan
phone: Số điện thoại của bạn
profile: Hồ sơ
queue: Hàng đợi
save-button-text: Lưu thay đổi
search-input: Đầu vào tìm kiếm
search: Tìm kiếm
services: Dịch vụ
settings: Cài đặt
socket: Socket
sort: Sắp xếp
two-factor: Hai yếu tố
uploading: Đang tải lên...
your-profile: Hồ sơ của bạn
created-at: Được tạo vào
country: Quốc qua
phone-number: Số điện thoại
status: Trạng thái
close-and-save: Đóng và Lưu
users: Người dùng
changelog: Nhật ký thay đổi
ok: OK
select-file: Chọn tệp
back: Quay lại
next: Tiếp theo
continue: Tiếp tục
done: Đã xong
export: Xuất
reload: Tải lại
reload-data: Tải lại dữ liệu
unauthorized: Không có quyền
unauthorized-to: Không có quyền để
unauthorized-access: Không có quyền truy cập
unauthorized-access-message: Không có quyền truy cập, bạn phải yêu cầu quyền để truy cập.
permissions-required-for-changes: Bạn không có quyền cần thiết để thực hiện thay đổi.
push-notifications: Đẩy thông báo
component:
file:
dropdown-label: Hành động với tệp
import-modal:
loading-message: Đang tiến hành nhập...
drop-upload: Thả để tải lên
invalid: Không hợp lệ
ready-upload: sẵn sàng tải lên.
upload-spreadsheets: Tải lên Bảng tính
drag-drop: Kéo và thả các tệp bảng tính vào vùng thả này
button-text: hoặc chọn bảng tính để tải lên
spreadsheets: bảng tính
upload-queue: Tải lên hàng đợi
dropzone:
file: file
drop-to-upload: Thả để tải lên
invalid: Không hợp lệ
files-ready-for-upload: >-
{numOfFiles} sẵn sàng tải lên.
upload-images-videos: Tải lên hình ảnh và video
upload-documents: Tải lên tài liệu
upload-documents-files: Tải lên tài liệu và tập tin
upload-avatar-files: Tải lên ảnh đại diện tùy chỉnh
dropzone-supported-images-videos: Kéo và thả ảnh hoặc video vào vùng thả này
dropzone-supported-avatars: Kéo và thả tệp SVG hoặc PNG
dropzone-supported-files: Kéo và thả tệp vào vùng thả này
or-select-button-text: hoặc chọn file để tải lên.
upload-queue: Tải lên hàng đợi
uploading: Đang tải lên...
two-fa-enforcement-alert:
message: Để tăng cường bảo mật cho tài khoản của bạn, tổ chức của bạn yêu cầu Xác thực hai yếu tố (2FA). Bật 2FA trong cài đặt tài khoản của bạn để có thêm một lớp bảo vệ.
button-text: Cấu hình 2FA
comment-thread:
publish-comment-button-text: Đăng bình luận
publish-reply-button-text: Đăng phản hồi
reply-comment-button-text: Phản hồi
edit-comment-button-text: Chỉnh sửa
delete-comment-button-text: Xóa
comment-published-ago: >-
{createdAgo} trước
comment-input-placeholder: Nhập một bình luận mới...
comment-reply-placeholder: Nhập phản hồi của bạn...
comment-input-empty-notification: Bạn không thể đăng bình luận trống...
comment-min-length-notification: Bình luận phải chứa ít nhất 2 ký tự
dashboard:
select-dashboard: Chọn Bảng điều khiển
create-new-dashboard: Tạo mới Bảng điều khiển
create-a-new-dashboard: Tạo mới một Bảng điều khiển
confirm-create-dashboard: Tạo Bảng điều khiển!
edit-layout: Chỉnh sửa bố cục
add-widgets: Thêm tiện ích
delete-dashboard: Xóa bảng điều khiển
save-dashboard: Lưu bảng điều khiển
you-cannot-delete-this-dashboard: Bạn không thể xóa bảng điều khiển này.
are-you-sure-you-want-delete-dashboard: Bạn có chắc chắn xóa {dashboardName}?
dashboard-widget-panel:
widget-name: >-
{widgetName} Tiện ích
select-widgets: Chọn tiện ích
close-and-save: Đóng và Lưu
services:
dashboard-service:
create-dashboard-success-notification: Bảng điều khiển `{dashboardName}` đã được tạo thành công.
delete-dashboard-success-notification: Bảng điều khiển `{dashboardName}` đã được xóa.
auth:
verification:
header-title: Xác thực tài khoản
title: Xác thực email của bạn
message-text: <strong>Gần xong rồi!</strong><br> Kiểm tả email của bạn để lấy mã xác thực.
verification-code-text: Nhập mã xác thực bạn vừa nhận qua email.
verification-input-label: Mã xác thực
verify-button-text: Xác thực & Tiếp tục
didnt-receive-a-code: Chưa nhận được mã?
not-sent:
message: Chưa nhận được mã?
alternative-choice: Sử dụng các tùy chọn thay thế bên dưới để xác thực tài khoản của bạn.
resend-email: Gửi lại email
send-by-sms: Gửi qua SMS
two-fa:
verify-code:
verification-code: Mã xác thực
check-title: Kiểm tra email hoặc điện thoại của bạn.
check-subtitle: Chúng tôi đã gửi cho bạn mã xác thực. Nhập mã bên dưới để hoàn tất quá trình đăng nhập.
expired-help-text: Mã xác thực 2FA của bạn đã hết hạn. Bạn có thể yêu cầu mã khác nếu cần thêm thời gian.
resend-code: Gửi lại mã
verify-code: Xác thực mã
cancel-two-factor: Hủy 2 yếu tố
invalid-session-error-notification: Phiên không hợp lệ. Vui lòng thử lại.
verification-successful-notification: Xác thực thành công!
verification-code-expired-notification: Mã xác thực đã hết hạn. Vui lòng yêu cầu một mã mới.
verification-code-failed-notification: Xác thực thất bại. Vui lòng thử lại.
resend-code:
verification-code-resent-notification: Mã xác thực mới đã được gửi.
verification-code-resent-error-notification: Xảy ra lỗi khi gửi lại mã xác thực. Vui lòng thử lại.
forgot-password:
success-message: Kiểm tra email của bạn để tiếp tục!
is-sent:
title: Gần xong rồi!
message: <strong>Kiểm tra email của bạn!</strong><br> Chúng tôi đã gửi cho bạn một liên kết đến email của bạn, cho phép bạn đặt lại mật khẩu. Liên kết sẽ hết hạn sau 15 phút.
not-sent:
title: Quên mật khẩu?
message: <strong>Đừng lo lắng, chúng tôi luôn hỗ trợ bạn.</strong><br> Nhập email bạn sử dụng để đăng nhập {appName} và chúng tôi sẽ gửi cho bạn một liên kết an toàn để đặt lại mật khẩu.
form:
email-label: Địa chỉ email của bạn
submit-button: OK, Gửi cho tôi một liên kết!
nevermind-button: Ok
login:
title: Đăng nhập vào tài khoản của bạn
no-identity-notification: Bạn quên nhập email của bạn?
no-password-notification: Bạn quên nhập mật khẩu của bạn?
unverified-notification: Tài khoản của bạn cần được xác minh để tiếp tục.
failed-attempt:
message: <strong>Quên mật khẩu?</strong><br> Nhấp vào nút bên dưới để đặt lại mật khẩu của bạn.
button-text: Được, giúp tôi thiết lập lại nhé!
form:
email-label: Địa chỉ email
password-label: Mật khẩu
remember-me-label: Ghi nhớ đăng nhập
forgot-password-label: Quên mật khẩu?
sign-in-button: Đăng nhập
create-account-button: Tạo mới tài khoản
slow-connection-message: Đang gặp sự cố kết nối.
reset-password:
success-message: Mật khẩu của bạn đã được đặt lại! Đăng nhập để tiếp tục.
invalid-verification-code: Liên kết đặt lại mật khẩu này không hợp lệ hoặc đã hết hạn.
title: Đặt lại mật khẩu của bạn
form:
code:
label: Mã đặt lại của bạn
help-text: Mã xác thực bạn đã nhận qua email.
password:
label: Mật khẩu mới
help-text: Nhập mật khẩu ít nhất 6 ký tự để tiếp tục.
confirm-password:
label: Xác nhận mật khẩu mới
help-text: Nhập mật khẩu ít nhất 6 ký tự để tiếp tục.
submit-button: Đặt lại mật khẩu
back-button: Quay lại
console:
create-or-join-organization:
modal-title: Tạo mới hoặc tham gia một tổ chức
join-success-notification: Bạn đã tham gia một tổ chức mới!
create-success-notification: Bạn vừa tạo ra một tổ chức mới!
switch-organization:
modal-title: Bạn có chắc chắn muốn chuyển đổi sang tổ chức {organizationName}?
modal-body: Bằng cách xác nhận, tài khoản của bạn sẽ vẫn được đăng nhập, nhưng tổ chức chính của bạn sẽ được chuyển đổi.
modal-accept-button-text: Vâng, tôi muốn chuyển đổi tổ chức
success-notification: Bạn đã chuyển đổi tổ chức
account:
index:
upload-new: Tải lên mới
phone: Số điện thoại của bạn.
photos: ảnh
admin:
schedule-monitor:
schedule-monitor: Lịch trình giám sát
task-logs-for: >-
Nhật ký tác vụ cho:
showing-last-count: Đang hiển thị {count} nhật ký cuối
name: Tên
type: Loại
timezone: Múi giờ
last-started: Lần bắt đầu cuối
last-finished: Lần kết thúc cuối
last-failure: Lần thất bại cuối
date: Ngày
memory: Memory
runtime: Runtime
output: Đầu ra
no-output: Không có đầu ra
config:
database:
title: Cấu hình cơ sở dữ liệu
filesystem:
title: Cấu hình hệ thống tập tin
mail:
title: Cấu hình Mail
notification-channels:
title: Cấu hình kênh thông báo
queue:
title: Cấu hình hàng đợi
services:
title: Cấu hình dịch vụ
socket:
title: Cấu hình Socket
branding:
title: Thương hiệu
icon-text: Icon
upload-new: Tải lên
reset-default: Đặt về mặc định
logo-text: Logo
theme: Chủ đề mặc định
index:
total-users: Tổng số người dùng
total-organizations: Tổng số tổ chức
total-transactions: Tổng số giao dịch
notifications:
title: Thông báo
notification-settings: Cài đặt thông báo
organizations:
index:
title: Tổ chức
owner-name-column: Người sở hữu
owner-phone-column: Điện thoại chủ sở hữu
owner-email-column: Email chủ sở hữu
users-count-column: Người dùng
phone-column: Số điện thoại
email-column: Email
users:
title: Người dùng
settings:
index:
title: Cài đặt tổ chức
organization-name: Tên tổ chức
organization-description: Mô tả
organization-phone: Số điện thoại
organization-currency: Tiền tệ
organization-id: ID
organization-branding: Thương hiệu
logo: Logo
logo-help-text: Logo cho tổ chức của bạn..
upload-new-logo: Tải lên logo mới
backdrop: Phông nền
backdrop-help-text: Biểu ngữ hoặc hình ảnh nền tùy chọn cho tổ chức của bạn.
upload-new-backdrop: Tải lên phông nền mới
extensions:
title: Các tiện ích mở rộng sẽ sớm ra mắt!
message: Vui lòng kiểm tra lại trong các phiên bản sắp tới khi chúng tôi chuẩn bị ra mắt kho lưu trữ và thị trường Tiện ích mở rộng.
notifications:
select-all: Chọn tất cả
mark-as-read: Đánh dấu là đã đọc
received: >-
Đã nhận:
message: Không có thông báo nào để hiển thị.
invite:
for-users:
invitation-message: Bạn đã được mời tham gia {companyName}
invitation-sent-message: Bạn đã được mời tham gia tổ chức {companyName} trên {appName}. Để chấp nhận lời mời này, hãy nhập mã mời nhận được qua email và nhấp vào tiếp tục.
invitation-code-sent-text: Mã mời của bạn
accept-invitation-text: Chấp nhận lời mời
onboard:
index:
title: Tạo tài khoản mới
welcome-title: <strong>Chào mừng tới {companyName}!</strong><br />
welcome-text: Hoàn tất các thông tin yêu cầu bên dưới để bắt đầu.
full-name: Tên đầy đủ
full-name-help-text: Tên đầy đủ của bạn
your-email: Địa chỉ email
your-email-help-text: Địa chỉ email của bạn
phone: Số điện thoại
phone-help-text: Số điện thoại của bạn
organization-name: Tên tổ chức
organization-help-text: Tên tổ chức của bạn, tất cả các dịch vụ và tài nguyên của bạn sẽ được quản lý theo tổ chức này, sau đó bạn có thể tạo nhiều tổ chức tùy ý hoặc theo nhu cầu.
password: Nhập mật khẩu
password-help-text: Mật khẩu của bạn phải an toàn.
confirm-password: Xác nhận mật khẩu của bạn
confirm-password-help-text: Chỉ để xác nhận mật khẩu bạn đã nhập ở trên.
continue-button-text: Tiếp tục
verify-email:
header-title: Xác thực tài khoản
title: Xác thực địa chỉ email của bạn
message-text: <strong>Gần xong rồi!</strong><br> Kiêm tra mã xác thực trong email của bạn.
verification-code-text: Nhập mã xác thực bạn vừa nhận qua email.
verification-input-label: Mã xác thực
verify-button-text: Xác thực & Tiếp tục
didnt-receive-a-code: Bạn vẫn chưa nhận được mã?
not-sent:
message: Bạn vẫn chưa nhận được mã?
alternative-choice: Sử dụng các tùy chọn thay thế bên dưới để xác thực tài khoản của bạn.
resend-email: Gửi lại
send-by-sms: Gửi qua SMS
install:
installer-header: Trình cài đặt
failed-message-sent: Cài đặt thất bại! Nhấp vào nút bên dưới để thử cài đặt lại.
retry-install: Thử lại cài đặt
start-install: Bắt đầu cài đặt
layout:
header:
menus:
organization:
settings: Cài đặt tổ chức
create-or-join: Tạo mới hoặc tham gia một tổ chức
explore-extensions: Khám phá phần mở rộng
user:
view-profile: Xem hồ sơ
keyboard-shortcuts: Hiển thị phím tắt
changelog: Nhật ký thay đổi