first commit

This commit is contained in:
Noor E Ilahi
2026-01-09 12:54:53 +05:30
commit 7ccf44f7da
1070 changed files with 113036 additions and 0 deletions

View File

@@ -0,0 +1,210 @@
<template>
<div class="about content-wrapper">
<div class="container">
<div class="at-container">
<div class="at-container__inner">
<div class="row">
<div class="col-8 col-offset-8 about__alert-wrapper">
<at-alert
v-if="updateVersionMsg"
:message="updateVersionMsg"
class="about__alert"
type="success"
/>
<at-alert
v-if="knownVulnerableMsg"
:message="knownVulnerableMsg"
class="about__alert"
type="error"
/>
<at-alert v-if="infoMsg" :message="infoMsg" class="about__alert" type="info" />
</div>
<!-- /.col-8 -->
</div>
<!-- /.row -->
<div class="about__logo" />
<h2>Cattr</h2>
<p class="about__version">
<skeleton :loading="isLoading" width="80px">{{ appData.version || 'Undefined' }}</skeleton>
</p>
<p>
Instance ID:
<strong>
<skeleton :loading="isLoading" width="200px"
>{{ appData.instance_id || 'Undefined' }}
</skeleton>
</strong>
</p>
<at-tabs>
<at-tab-pane :label="$t('about.module_versions')" name="about_versions">
<p v-if="modulesData.length" class="about__table">
<at-table :columns="modulesColumns" :data="modulesData" />
</p>
<p v-else v-t="'about.no_modules'" />
</at-tab-pane>
<at-tab-pane
v-if="isAdmin"
:label="$t('about.module_storage')"
class="storage"
name="about_storage"
>
<StorageManagementTab />
</at-tab-pane>
</at-tabs>
<div><a class="about__link" href="https://cattr.app">cattr.app</a></div>
<div><a class="about__link" href="https://github.com/orgs/cattr-app/discussions">community</a></div>
</div>
<!-- /.at-container__inner -->
</div>
<!-- /.at-container -->
</div>
<!-- /.container -->
</div>
<!-- /.content-wrapper -->
</template>
<script>
import AboutService from '@/services/resource/about.service';
import { hasRole } from '@/utils/user';
import semverGt from 'semver/functions/gt';
import { Skeleton } from 'vue-loading-skeleton';
const aboutService = new AboutService();
export default {
name: 'About',
components: {
StorageManagementTab: () =>
import(/* webpackChunkName: "StorageManagementTab" */ '@/components/StorageManagementTab'),
Skeleton,
},
computed: {
isAdmin() {
return hasRole(this.$store.getters['user/user'], 'admin');
},
},
async mounted() {
this.isLoading = true;
try {
const { data } = await aboutService.getGeneralInfo();
this.appData = data.app;
this.modulesData = data.modules;
if (this.appData.vulnerable) {
this.knownVulnerableMsg = `${this.$i18n.t('message.vulnerable_version')} ${
this.appData.last_version
}`;
} else if (this.appData.last_version && this.appData.last_version !== this.appData.version) {
if (semverGt(this.appData.last_version, data.app.version)) {
this.updateVersionMsg = `${this.$i18n.t('message.update_version')} ${
this.appData.last_version
}`;
} else {
this.infoMsg = this.appData.message;
}
}
} catch ({ response }) {
if (process.env.NODE_ENV === 'development') {
console.warn(response ? response : 'request to about is canceled');
}
}
this.isLoading = false;
},
data() {
return {
appData: {},
modulesData: [],
knownVulnerableMsg: null,
updateVersionMsg: null,
isLoading: false,
infoMsg: null,
modulesColumns: [
{ title: this.$i18n.t('about.modules.name'), key: 'name' },
{ title: this.$i18n.t('about.modules.version'), key: 'version' },
{
title: this.$i18n.t('about.modules.status'),
render: (h, params) =>
h('AtAlert', {
props: {
message: Object.prototype.hasOwnProperty.call(params.item, 'lastVersion')
? semverGt(params.item.version, params.item.lastVersion)
? params.item.flashMessage
: params.item.version === params.item.lastVersion
? this.$i18n.t('about.modules.ok')
: params.item.vulnerable
? this.$i18n.t('about.modules.vulnerable')
: this.$i18n.t('about.modules.outdated')
: this.$i18n.t('about.modules.ok'),
type: Object.prototype.hasOwnProperty.call(params.item, 'lastVersion')
? semverGt(params.item.version, params.item.lastVersion)
? 'info'
: params.item.version === params.item.lastVersion
? 'success'
: params.item.vulnerable
? 'error'
: 'warning'
: 'info',
},
style: {
'text-align': 'center',
padding: '4px 8px 4px 16px',
},
}),
},
],
};
},
};
</script>
<style lang="scss" scoped>
.about {
text-align: center;
p {
margin-bottom: $layout-01;
}
&__alert-wrapper {
margin-bottom: $layout-01;
}
&__alert {
margin-bottom: $layout-01;
}
&__logo {
background-image: url('../assets/logo.svg');
background-size: cover;
display: block;
height: 120px;
margin-bottom: $layout-01;
margin-left: auto;
margin-right: auto;
width: 120px;
}
&__version {
color: $gray-3;
font-weight: bold;
}
&__link {
color: $gray-3;
}
&__table {
display: flex;
justify-content: center;
& > .at-table {
width: 50%;
}
}
}
</style>

View File

@@ -0,0 +1,39 @@
<template>
<div class="api-error">
<div class="container">
<h1 class="api-error__code">500</h1>
<h1 class="api-error__title">Whoops!</h1>
<p class="api-error__description">{{ $t('message.api_error') }}</p>
</div>
</div>
</template>
<script>
export default {
name: 'ApiError',
};
</script>
<style lang="scss">
.api-error {
text-align: center;
&__code {
color: $red-1;
font-size: 8rem;
line-height: 1;
margin-bottom: 1rem;
}
&__title {
font-size: 3rem;
margin-bottom: 1rem;
line-height: 1;
}
&__description {
font-size: 1.5rem;
font-weight: bold;
}
}
</style>

View File

@@ -0,0 +1,76 @@
<template>
<div>
<validation-provider v-slot="{ errors }" rules="required|email" mode="passive" name="E-mail">
<div class="input-group">
<small>E-Mail</small>
<at-input
v-model="user.email"
name="login"
:status="errors.length > 0 ? 'error' : ''"
placeholder="E-Mail"
icon="mail"
type="text"
required
@keydown.native.enter.prevent="submit"
></at-input>
<small>{{ errors[0] }}</small>
</div>
<!-- /.input-group -->
</validation-provider>
<validation-provider v-slot="{ errors }" rules="required" mode="passive" :name="$t('field.password')">
<div class="input-group">
<small>{{ $t('field.password') }}</small>
<at-input
v-model="user.password"
name="password"
:status="errors.length > 0 ? 'error' : ''"
:placeholder="$t('field.password')"
type="password"
icon="lock"
required
@keydown.native.enter.prevent="submit"
></at-input>
<small>{{ errors[0] }}</small>
</div>
<!-- /.input-group -->
</validation-provider>
</div>
</template>
<script>
import { ValidationProvider } from 'vee-validate';
export default {
name: 'AuthInput',
components: {
ValidationProvider,
},
data() {
return {
user: {
email: null,
password: null,
},
};
},
methods: {
submit() {
this.$emit('submit');
},
},
watch: {
'user.email'(value) {
// Trim space
this.user.email = value.replace(/\s/, '');
this.$emit('change', this.user);
},
'user.password'() {
this.$emit('change', this.user);
},
},
};
</script>

View File

@@ -0,0 +1,178 @@
<template>
<div class="login row at-row no-gutter">
<div class="login__wrap">
<div class="login__form">
<div class="box">
<div class="top">
<div class="static-message">
<div class="logo" />
</div>
<h1 class="login__title">Cattr</h1>
</div>
<template v-if="error">
<div>
<at-alert :message="$t('auth.desktop_error')" class="login__error" type="error" />
</div>
<at-button class="login__btn" type="primary" @click="commonLogin"
>{{ $t('auth.switch_to_common') }}
</at-button>
</template>
<template v-else>
<at-alert :message="$t('auth.desktop_working')" class="login__error" type="info" />
</template>
</div>
</div>
<a class="login__slogan" href="https://cattr.app" v-html="slogan" />
</div>
<div class="hero col-16" />
</div>
</template>
<style lang="scss" scoped>
.login {
flex-wrap: nowrap;
height: 100vh;
margin: 0;
max-height: 100vh;
position: relative;
width: 100%;
&__wrap {
align-items: center;
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
}
&__form {
flex: 8;
width: 100%;
}
&__slogan {
align-content: flex-start;
color: $gray-3;
display: flex;
flex: 1;
justify-content: center;
margin: 0;
width: 100%;
}
&__title {
color: $black-900;
font-size: 1.8rem;
text-align: center;
}
&__btn {
margin-bottom: 1rem;
}
&__error {
margin-bottom: 1rem;
overflow: initial;
text-align: center;
}
.box {
display: flex;
flex-direction: column;
height: 100%;
justify-content: center;
padding: 0 $spacing-08;
width: 100%;
.top {
display: flex;
flex-flow: column nowrap;
margin-bottom: $layout-01;
.static-message {
align-items: center;
display: flex;
flex-flow: column nowrap;
.logo {
align-items: center;
background: url('../../assets/logo.svg');
background-size: cover;
border-radius: 10px;
color: #ffffff;
display: flex;
font-size: 1.8rem;
font-weight: bold;
height: 60px;
justify-content: center;
text-transform: uppercase;
width: 60px;
}
}
}
}
.link {
color: $blue-1;
font-weight: 600;
text-align: center;
}
::v-deep .input-group {
margin-bottom: 0.75rem;
}
.hero {
background: #6159e6 url('../../assets/login.svg') no-repeat;
background-size: 100%;
display: flex;
}
}
</style>
<script>
import sloganGenerator from '@/helpers/sloganGenerator';
export default {
name: 'desktop-login',
computed: {
slogan() {
return sloganGenerator();
},
},
data() {
return {
error: false,
};
},
methods: {
commonLogin() {
this.$router.replace({ name: 'auth.login' });
},
finish(error = true) {
this.error = error;
this.$Loading.error();
},
},
mounted() {
this.$Loading.start();
if (location.search.length === 0) {
this.finish();
} else {
const query = location.search.substr(1).split('=');
if (query[0] !== 'token' && query.length !== 2) {
this.finish();
} else {
const apiService = this.$store.getters['user/apiService'];
apiService
.attemptDesktopLogin(query[1])
.then(() => apiService.getCompanyData())
.then(() => this.finish(false))
.catch(() => this.finish());
}
}
},
};
</script>

View File

@@ -0,0 +1,270 @@
<template>
<div class="login row at-row no-gutter">
<div class="login__wrap">
<div class="login__form">
<validation-observer ref="observer" class="box" tag="div" @submit.prevent="submit">
<div class="top">
<div class="static-message">
<div class="logo"></div>
</div>
<h1 class="login__title">Cattr</h1>
</div>
<div>
<at-alert
v-if="error"
type="error"
class="login__error"
closable
:message="error"
@on-close="error = null"
/>
<component :is="config.authInput" @change="change" @submit="submit" />
<vue-recaptcha
v-if="recaptchaKey"
ref="recaptcha"
:loadRecaptchaScript="true"
:sitekey="recaptchaKey"
class="recaptcha"
@verify="onCaptchaVerify"
@expired="onCaptchaExpired"
></vue-recaptcha>
</div>
<at-button
class="login__btn"
native-type="submit"
type="primary"
:loading="isLoading"
:disabled="isLoading"
@click="submit"
>{{ $t('auth.submit') }}</at-button
>
<router-link class="link" to="/auth/password/reset">{{ $t('auth.forgot_password') }}</router-link>
</validation-observer>
</div>
<a class="login__slogan" href="https://cattr.app" v-html="slogan" />
</div>
<div class="hero col-lg-16 col-md-14 col-sm-12"></div>
</div>
</template>
<script>
import { ValidationObserver } from 'vee-validate';
import { VueRecaptcha } from 'vue-recaptcha';
import AuthInput from './AuthInput';
import sloganGenerator from '@/helpers/sloganGenerator';
import has from 'lodash/has';
export const config = { authInput: AuthInput };
export default {
name: 'Login',
components: {
ValidationObserver,
VueRecaptcha,
AuthInput,
},
data() {
return {
user: {
email: null,
password: null,
recaptcha: null,
},
recaptchaKey: null,
error: null,
isLoading: false,
};
},
computed: {
config() {
return config;
},
slogan() {
return sloganGenerator();
},
},
mounted() {
if (this.$store.getters['user/isLoggedIn']) {
this.$router.push({ name: 'dashboard' });
}
},
methods: {
getRandomIntInclusive(min, max) {
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min + 1)) + min;
},
onCaptchaVerify(response) {
this.user.recaptcha = response;
},
onCaptchaExpired() {
this.$refs.recaptcha.reset();
},
change(user) {
this.user = { ...this.user, ...user };
},
async submit() {
const valid = await this.$refs.observer.validate();
if (!valid) {
return;
}
this.$Loading.start();
this.isLoading = true;
const apiService = this.$store.getters['user/apiService'];
try {
if ('grecaptcha' in window) {
this.$refs.recaptcha.reset();
}
await apiService.attemptLogin(this.user);
await apiService.getCompanyData();
this.error = null;
this.$Loading.finish();
} catch (e) {
this.$Loading.error();
if (has(e, 'response.status')) {
if (e.response.status === 429 && this.recaptchaKey === null) {
this.recaptchaKey = e.response.data.info.site_key;
}
let message;
if (e.response.status === 401) {
message = this.$t('auth.message.user_not_found');
} else if (e.response.status === 429) {
message = this.$t('auth.message.solve_captcha');
} else if (e.response.status === 503) {
message = this.$t('auth.message.data_reset');
} else {
message = this.$t('auth.message.auth_error');
}
this.error = message;
}
} finally {
this.isLoading = false;
}
},
},
};
</script>
<style lang="scss" scoped>
.login {
flex-wrap: nowrap;
height: 100vh;
margin: 0;
max-height: 100vh;
position: relative;
width: 100%;
&__wrap {
align-items: center;
display: flex;
width: 100%;
flex-direction: column;
justify-content: center;
}
&__form {
width: 100%;
flex: 8;
}
&__slogan {
flex: 1;
margin: 0;
width: 100%;
display: flex;
justify-content: center;
align-content: flex-start;
color: $gray-3;
}
&__title {
color: $black-900;
font-size: 1.8rem;
text-align: center;
}
&__btn {
margin-bottom: 1rem;
}
&__error {
margin-bottom: 1rem;
overflow: initial;
}
.box {
display: flex;
flex-direction: column;
height: 100%;
justify-content: center;
padding: 0 $spacing-08;
width: 100%;
.top {
display: flex;
flex-flow: column nowrap;
margin-bottom: $layout-01;
.static-message {
align-items: center;
display: flex;
flex-flow: column nowrap;
.logo {
align-items: center;
background: url('../../assets/logo.svg');
background-size: cover;
border-radius: 10px;
color: #ffffff;
display: flex;
font-size: 1.8rem;
font-weight: bold;
height: 60px;
justify-content: center;
text-transform: uppercase;
width: 60px;
}
}
}
.recaptcha {
margin-bottom: 10px;
}
}
.link {
color: $blue-1;
font-weight: 600;
text-align: center;
}
::v-deep .input-group {
margin-bottom: 0.75rem;
}
.hero {
background: url('../../assets/login.svg') #6159e6;
background-repeat: no-repeat;
background-size: 100%;
display: flex;
}
}
</style>

View File

@@ -0,0 +1,232 @@
<template>
<div class="content-wrapper">
<div v-if="isTokenValid" class="container">
<div class="at-container">
<div class="at-container__inner">
<div v-if="isRegisterSuccess">
<div class="header-text">
<i class="icon icon-check"></i>
<h2 class="header-text__title">
{{ $t('register.success_title') }}
</h2>
<p class="header-text__subtitle">{{ $t('register.success_subtitle') }}</p>
<router-link to="/auth/login">{{ $t('reset.go_away') }}</router-link>
</div>
</div>
<div v-else class="row">
<div class="col-6 col-offset-9">
<div class="header-text">
<h2 class="header-text__title">
{{ $t('register.title') }}
</h2>
<p class="header-text__subtitle">
{{ $t('register.subtitle') }}
</p>
</div>
<at-alert
v-if="errorMessage"
type="error"
class="alert"
closable
:message="errorMessage"
@on-close="errorMessage = null"
/>
<validation-observer v-slot="{ invalid }">
<validation-provider v-slot="{ errors }" rules="required|email" name="E-mail">
<div class="input-group">
<small>E-Mail</small>
<at-input
v-model="email"
name="login"
:status="errors.length > 0 ? 'error' : ''"
placeholder="E-Mail"
icon="mail"
type="text"
disabled="true"
>
</at-input>
<p class="error-message">
<small>{{ errors[0] }}</small>
</p>
</div>
</validation-provider>
<validation-provider v-slot="{ errors }" rules="required" :name="$t('field.full_name')">
<div class="input-group">
<small>{{ $t('field.full_name') }}</small>
<at-input
v-model="fullName"
name="full_name"
:status="errors.length > 0 ? 'error' : ''"
:placeholder="$t('field.full_name')"
icon="user"
type="text"
>
</at-input>
<p class="error-message">
<small>{{ errors[0] }}</small>
</p>
</div>
</validation-provider>
<validation-provider
v-slot="{ errors }"
rules="required|min:6"
vid="password"
:name="$t('field.password')"
>
<div class="input-group">
<small>{{ $t('field.password') }}</small>
<at-input
v-model="password"
name="password"
:status="errors.length > 0 ? 'error' : ''"
:placeholder="$t('field.full_name')"
icon="lock"
type="password"
>
</at-input>
<p class="error-message">
<small>{{ errors[0] }}</small>
</p>
</div>
</validation-provider>
<validation-provider
v-slot="{ errors }"
rules="required|min:6|confirmed:password"
:name="$t('reset.confirm_password')"
>
<div class="input-group">
<small>{{ $t('reset.confirm_password') }}</small>
<at-input
v-model="passwordConfirmation"
name="passwordConfirmation"
:status="errors.length > 0 ? 'error' : ''"
:placeholder="$t('reset.confirm_password')"
icon="lock"
type="password"
>
</at-input>
<p class="error-message">
<small>{{ errors[0] }}</small>
</p>
</div>
</validation-provider>
<at-button
class="btn"
native-type="submit"
type="primary"
:disabled="invalid || isLoading"
:loading="isLoading"
@click="register"
>{{ $t('register.register_btn') }}</at-button
>
</validation-observer>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- /.content-wrapper -->
</template>
<script>
import { ValidationObserver, ValidationProvider } from 'vee-validate';
import axios from 'axios';
export default {
name: 'ResetPassword',
components: {
ValidationProvider,
ValidationObserver,
},
created() {
if (this.$route.query.token) {
this.token = this.$route.query.token;
this.validateToken();
} else {
this.$router.push({ name: 'not-found' });
}
},
data() {
return {
email: null,
password: null,
passwordConfirmation: null,
fullName: null,
token: null,
isTokenValid: false,
isLoading: false,
isRegisterSuccess: false,
errorMessage: null,
};
},
methods: {
async validateToken() {
try {
const { data } = await axios.get(`/auth/register/${this.token}`);
this.email = data.email;
this.isTokenValid = true;
} catch ({ response }) {
if (response.status === 404) {
this.$router.replace({ name: 'not-found' });
}
}
},
async register() {
this.isLoading = true;
const data = {
email: this.email,
full_name: this.fullName,
password: this.password,
password_confirmation: this.passwordConfirmation,
};
try {
await axios.post(`auth/register/${this.token}`, data);
this.isRegisterSuccess = true;
} catch ({ response }) {
this.errorMessage = response.data.error;
} finally {
this.isLoading = false;
}
},
},
};
</script>
<style lang="scss" scoped>
.header-text {
text-align: center;
&__title {
margin-bottom: 1rem;
}
&__subtitle {
margin-bottom: 1rem;
}
}
.btn {
width: 100%;
}
.input-group {
margin-bottom: $layout-01;
}
.icon {
margin-bottom: 1rem;
font-size: 92px;
&-check {
color: $green-1;
}
}
.alert {
margin-bottom: $layout-01;
}
</style>

View File

@@ -0,0 +1,281 @@
<template>
<div class="content-wrapper">
<div class="container">
<div class="at-container crud__content">
<at-steps :current="currentStep" class="steps col-lg-offset-4">
<at-step
:title="$t('reset.step', { n: 1 })"
:description="$t('reset.step_description.step_1')"
></at-step>
<at-step
:title="$t('reset.step', { n: 2 })"
:description="$t('reset.step_description.step_2')"
></at-step>
<at-step
:title="$t('reset.step', { n: 3 })"
:description="$t('reset.step_description.step_3')"
></at-step>
<at-step
:title="$t('reset.step', { n: 4 })"
:description="$t('reset.step_description.step_4')"
></at-step>
</at-steps>
<div class="row">
<div v-if="currentStep == 0" class="col-6 col-offset-9">
<validation-observer v-slot="{ invalid }">
<div class="header-text">
<h2 class="header-text__title">
{{ $t('reset.tabs.enter_email.title') }}
</h2>
<p class="header-text__subtitle">
{{ $t('reset.tabs.enter_email.subtitle') }}
</p>
</div>
<validation-provider v-slot="{ errors }" rules="required|email">
<small>E-Mail</small>
<at-input
v-model="email"
name="login"
:status="errors.length > 0 ? 'error' : ''"
placeholder="E-Mail"
icon="mail"
type="text"
:disabled="disabledForm"
>
</at-input>
</validation-provider>
<at-button
class="btn"
native-type="submit"
type="primary"
:disabled="invalid || disabledForm"
@click="resetPassword"
>{{ $t('reset.reset_password') }}</at-button
>
</validation-observer>
</div>
<div v-if="currentStep == 1" class="col-6 col-offset-9">
<div class="header-text">
<i class="icon icon-mail"></i>
<h2 class="header-text__title">
{{ $t('reset.tabs.check_email.title') }}
</h2>
<p class="header-text__subtitle">
{{ $t('reset.tabs.check_email.subtitle') }}
</p>
</div>
</div>
<div v-if="currentStep == 2" class="col-6 col-offset-9">
<validation-observer v-if="isValidToken" ref="form" v-slot="{ invalid }">
<div class="header-text">
<h2 class="header-text__title">
{{ $t('reset.tabs.new_password.title') }}
</h2>
<p class="header-text__subtitle">
{{ $t('reset.tabs.new_password.subtitle') }}
</p>
</div>
<validation-provider
v-slot="{ errors }"
rules="required|min:6"
:name="$t('field.password')"
vid="password"
>
<small>{{ $t('field.password') }}</small>
<at-input
v-model="password"
:name="$t('field.password')"
:status="errors.length > 0 ? 'error' : ''"
:placeholder="$t('field.password')"
icon="lock"
type="password"
:disabled="disabledForm"
>
</at-input>
<p class="error-message">
<small>{{ errors[0] }}</small>
</p>
</validation-provider>
<validation-provider
v-slot="{ errors }"
rules="required|min:6|confirmed:password"
:name="$t('reset.confirm_password')"
vid="passwordConfirmation"
>
<small>{{ $t('reset.confirm_password') }}</small>
<at-input
v-model="passwordConfirmation"
name="passwordConfirmation"
:status="errors.length > 0 ? 'error' : ''"
:placeholder="$t('reset.confirm_password')"
icon="lock"
type="password"
:disabled="disabledForm"
>
</at-input>
<p class="error-message">
<small>{{ errors[0] }}</small>
</p>
</validation-provider>
<at-button
class="btn"
native-type="submit"
type="primary"
:disabled="invalid || disabledForm"
@click="submitNewPassword"
>{{ $t('control.submit') }}</at-button
>
</validation-observer>
<div v-else>
<div class="header-text">
<h2 class="header-text__title">
{{ $t('reset.page_is_not_available') }}
</h2>
<router-link to="/auth/login">{{ $t('reset.go_away') }}</router-link>
</div>
</div>
</div>
<div v-if="currentStep == 3" class="col-6 col-offset-9">
<div class="header-text">
<i class="icon icon-check"></i>
<h2 class="header-text__title">
{{ $t('reset.tabs.success.title') }}
</h2>
<p class="header-text__subtitle"></p>
<router-link to="/auth/login">{{ $t('reset.go_away') }}</router-link>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- /.content-wrapper -->
</template>
<script>
import { ValidationObserver, ValidationProvider } from 'vee-validate';
import AuthService from '@/services/auth.service';
export default {
name: 'ResetPassword',
components: {
ValidationProvider,
ValidationObserver,
},
created() {
if (this.$route.query.token && this.$route.query.email) {
this.currentStep = 2;
this.validateToken();
}
},
data() {
return {
email: null,
password: null,
passwordConfirmation: null,
currentStep: 0,
disabledForm: false,
isValidToken: true,
authService: new AuthService(),
};
},
methods: {
async resetPassword() {
this.disabledForm = true;
const payload = {
email: this.email,
};
try {
await this.authService.resetPasswordRequest(payload);
this.currentStep = 1;
} catch (e) {
//
} finally {
this.disabledForm = false;
}
},
async validateToken() {
const payload = {
email: this.$route.query.email,
token: this.$route.query.token,
};
try {
await this.authService.resetPasswordValidateToken(payload);
this.isValidToken = true;
} catch (e) {
this.isValidToken = false;
}
},
async submitNewPassword() {
this.disabledForm = true;
const payload = {
email: this.$route.query.email,
token: this.$route.query.token,
password: this.password,
password_confirmation: this.passwordConfirmation,
};
try {
await this.authService.resetPasswordProcess(payload);
this.currentStep = 3;
this.disabledForm = false;
} catch (e) {
//
} finally {
this.disabledForm = false;
}
},
},
};
</script>
<style lang="scss" scoped>
.steps {
margin-bottom: 1.5rem;
}
.header-text {
text-align: center;
&__title {
margin-bottom: 1rem;
}
&__subtitle {
margin-bottom: 1rem;
}
}
.icon {
margin-bottom: 1rem;
font-size: 92px;
&-mail {
color: $blue-2;
}
&-check {
color: $green-1;
}
}
.error-message {
margin-bottom: 1rem;
}
.btn {
width: 100%;
}
.at-input {
margin-bottom: 0.75rem;
}
</style>

View File

@@ -0,0 +1,461 @@
<template>
<div class="container-fluid crud">
<div class="row flex-around">
<div class="col-24 col-sm-22 col-lg-20">
<div class="at-container crud__content crud__edit-view">
<div class="page-controls">
<h1 class="control-item title">
{{
$route.params.id
? `${$t(pageData.title)} #${$route.params.id}`
: `${$t(pageData.title)}`
}}
</h1>
<div class="control-items">
<template v-if="pageData.pageControls && pageData.pageControls.length > 0">
<template v-for="(button, key) of pageData.pageControls">
<at-button
v-if="checkRenderCondition(button)"
:key="key"
class="control-item"
size="large"
:type="button.renderType || ''"
:icon="button.icon || ''"
@click="handleClick(button)"
>
{{ $t(button.label) }}
</at-button>
</template>
</template>
<BackButton size="large" class="control-item">{{ $t('control.back') }}</BackButton>
</div>
</div>
<component
:is="component"
v-for="(component, index) of pageData.topComponents"
:key="index"
:parent="this"
></component>
<validation-observer ref="form" v-slot="{ invalid }">
<div class="data-entries">
<template v-for="(field, key) of fields">
<template v-if="isDisplayable(field)">
<div :key="key" class="data-entry">
<div class="row">
<div class="col-6 col-xs-24">
<at-tooltip
v-if="field.tooltipValue"
:content="$t(field.tooltipValue)"
placement="top-left"
>
<u class="label label-tooltip">
{{ $t(field.label) }}
<span v-if="field.required">*</span>
</u>
</at-tooltip>
<p v-else class="label">
{{ $t(field.label) }}
<span v-if="field.required">*</span>
</p>
</div>
<at-input
v-if="isDataLoading && pageData.type === 'edit'"
class="col-18 col-xs-24"
disabled
/>
<div v-else class="col-18 col-xs-24">
<validation-provider
v-if="typeof field.render !== 'undefined'"
v-slot="{ errors }"
:rules="
typeof field.rules === 'string'
? field.rules
: field.required
? 'required'
: ''
"
:name="$t(field.label)"
:vid="field.key"
>
<renderable-field
v-model="values[field.key]"
:render="field.render"
:field="field"
:values="values"
:setValue="setValue"
:class="{
'at-select--error at-input--error has-error':
errors.length > 0,
}"
/>
<small>{{ errors[0] }}</small>
</validation-provider>
<validation-provider
v-else-if="field.key === 'email'"
v-slot="{ errors }"
:rules="field.required ? 'required|email' : ''"
:name="field.key"
:vid="field.key"
>
<at-input
v-model="values[field.key]"
:placeholder="$t(field.placeholder) || ''"
:type="field.frontendType || ''"
:status="errors.length > 0 ? 'error' : ''"
></at-input>
<small>{{ errors[0] }}</small>
</validation-provider>
<validation-provider
v-else-if="field.type === 'input' || field.type === 'text'"
v-slot="{ errors }"
:rules="field.required ? 'required' : ''"
:name="$t(field.label)"
:vid="field.key"
>
<at-input
v-model="values[field.key]"
:placeholder="$t(field.placeholder) || ''"
:type="field.frontendType || ''"
:status="errors.length > 0 ? 'error' : ''"
></at-input>
<small>{{ errors[0] }}</small>
</validation-provider>
<validation-provider
v-else-if="field.type === 'number'"
v-slot="{ errors }"
:rules="field.required ? 'required' : ''"
:name="$t(field.label)"
:vid="field.key"
>
<at-input-number
v-model="values[field.key]"
:min="field.minValue"
:max="field.maxValue"
></at-input-number>
<small>{{ errors[0] }}</small>
</validation-provider>
<validation-provider
v-else-if="field.type === 'select'"
v-slot="{ errors }"
:rules="field.required ? 'required' : ''"
:name="$t(field.label)"
:vid="field.key"
>
<at-select
v-model="values[field.key]"
:class="{
'at-select--error': errors.length > 0,
}"
:placeholder="$t('control.select')"
>
<at-option
v-for="(option, optionKey) of field.options"
:key="optionKey"
:value="option.value"
>{{ ucfirst($t(option.label)) }}
</at-option>
</at-select>
<small>{{ errors[0] }}</small>
</validation-provider>
<validation-provider
v-else-if="field.type === 'checkbox'"
v-slot="{ errors }"
:rules="field.required ? 'required' : ''"
:name="$t(field.label)"
:vid="field.key"
>
<at-checkbox v-model="values[field.key]" label="" />
<small>{{ errors[0] }}</small>
</validation-provider>
<validation-provider
v-else-if="field.type === 'resource-select'"
v-slot="{ errors }"
:rules="field.required ? 'required' : ''"
:name="$t(field.label)"
:vid="field.key"
>
<resource-select
v-model="values[field.key]"
:service="field.service"
:class="{
'at-select--error': errors.length > 0,
}"
/>
<small>{{ errors[0] }}</small>
</validation-provider>
<validation-provider
v-else-if="field.type === 'textarea'"
v-slot="{ errors }"
:rules="field.required ? 'required' : ''"
:name="$t(field.label)"
:vid="field.key"
>
<at-textarea
v-model="values[field.key]"
autosize
min-rows="2"
:class="{
'at-textarea--error': errors.length > 0,
}"
:placeholder="$t(field.placeholder) || ''"
/>
<small>{{ errors[0] }}</small>
</validation-provider>
</div>
</div>
</div>
</template>
</template>
</div>
<component
:is="component"
v-for="(component, index) of pageData.bottomComponents.components"
:key="index"
:parent="this"
/>
<div class="bottom-control">
<at-button
type="primary"
:disabled="invalid || isLoading"
:loading="isLoading"
@click="submit"
>{{ $t('control.save') }}</at-button
>
<template
v-if="
pageData.bottomComponents.pageControls &&
pageData.bottomComponents.pageControls.length > 0
"
>
<div class="bottom-control__added-items">
<template v-for="(button, key) of pageData.bottomComponents.pageControls">
<at-button
v-if="checkRenderCondition(button)"
:key="key"
class="bottom-control__added-items__item"
:type="button.type || ''"
:icon="button.icon || ''"
@click="handleClick(button)"
>
{{ $t(button.label) }}
</at-button>
</template>
</div>
</template>
</div>
</validation-observer>
</div>
</div>
</div>
</div>
</template>
<script>
import BackButton from '@/components/BackButton';
import RenderableField from '@/components/RenderableField';
import ResourceSelect from '@/components/ResourceSelect';
import { ValidationObserver, ValidationProvider } from 'vee-validate';
import { ucfirst } from '@/utils/string';
export default {
name: 'EditView',
components: {
BackButton,
RenderableField,
ResourceSelect,
ValidationProvider,
ValidationObserver,
},
data() {
const meta = this.$route.meta;
const pageData = meta.pageData || {};
return {
service: meta.service,
fields: meta.fields || [],
values: {},
filters: this.$route.meta.filters,
pageData: {
title: pageData.title,
topComponents: pageData.topComponents || [],
bottomComponents: pageData.bottomComponents || [],
type: pageData.type || 'new',
routeNamedSection: pageData.editRouteName || '',
pageControls: this.$route.meta.pageData.pageControls || [],
editRouteName: pageData.editRouteName || '',
},
isLoading: false,
isDataLoading: false,
afterSubmitCallback: meta.afterSubmitCallback,
};
},
async mounted() {
if (!Object.values(this.values).length) {
await this.fetchData();
}
},
async beforeRouteEnter(to, from, next) {
next(async vm => {
await vm.fetchData();
next();
});
},
async beforeRouteUpdate(to, from, next) {
await this.fetchData();
next();
},
methods: {
ucfirst,
async fetchData() {
this.isDataLoading = true;
if (this.pageData.type === 'edit') {
try {
const { data } = await this.service.getItem(
this.$route.params[this.service.getIdParam()],
this.filters,
);
this.values = { ...this.values, ...data.data };
} catch ({ response }) {
if (
response &&
Object.prototype.hasOwnProperty.call(response, 'data') &&
response.data.error_type === 'query.item_not_found'
) {
this.$router.replace({ name: 'forbidden' });
}
}
} else if (this.pageData.type === 'new') {
this.fields.forEach(field => {
if (field.default !== undefined) {
this.values[field.key] =
typeof field.default === 'function' ? field.default(this.$store) : field.default;
}
});
}
this.isDataLoading = false;
},
async submit() {
const valid = await this.$refs.form.validate();
if (!valid) {
return;
}
this.isLoading = true;
try {
const { data } = (await this.service.save(this.values, this.pageData.type === 'new')).data;
this.$Notify({
type: 'success',
title: this.$t('notification.record.save.success.title'),
message: this.$t('notification.record.save.success.message'),
});
this.isLoading = false;
if (this.afterSubmitCallback) {
this.afterSubmitCallback();
} else if (this.pageData.type === 'new') {
const result = this.$router.push({
name: this.$route.meta.navigation.view ?? this.$route.meta.navigation.edit,
params: { id: data[this.service.getIdParam()] },
});
}
} catch ({ message }) {
this.isLoading = false;
// TODO: fix error printing
this.$refs.form.setErrors(message);
}
},
handleClick(button) {
button.onClick(this, this.values[this.service.getIdParam()]);
},
checkRenderCondition(button) {
return typeof button.renderCondition !== 'undefined' ? button.renderCondition(this) : true;
},
setValue(key, value) {
this.$set(this.values, key, value);
},
isDisplayable(field) {
if (typeof field.displayable === 'function') {
return field.displayable(this);
}
if (typeof field.displayable !== 'undefined') {
return !!field.displayable;
}
return true;
},
},
};
</script>
<style lang="scss" scoped>
.crud {
&__edit-view {
.page-controls {
margin-bottom: 1.5em;
display: flex;
justify-content: space-between;
align-items: flex-start;
.control-item {
&:last-child {
margin-right: 0;
}
}
.title {
font-size: 1.6rem;
@media (max-width: 768px) {
font-size: 1rem;
}
}
}
.data-entries {
.data-entry {
margin-bottom: $layout-02;
.label {
font-weight: bold;
@media (max-width: 768px) {
font-size: 0.8rem;
width: 100%;
}
}
}
}
.bottom-control {
display: flex;
&__added-items {
margin-left: auto;
}
}
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,234 @@
<template>
<div class="container-fluid crud">
<div class="row flex-around">
<div class="col-24 col-sm-22 col-lg-20">
<div class="at-container crud__content crud__item-view">
<div class="page-controls">
<h1 class="control-item title">
<Skeleton :loading="isDataLoading" width="200px">{{ title }}</Skeleton>
</h1>
<div class="control-items">
<at-button
size="large"
class="control-item"
@click="$router.go($route.meta.navigation.from)"
>{{ $t('control.back') }}
</at-button>
<template v-if="pageData.pageControls && pageData.pageControls.length > 0">
<template v-for="(button, key) of pageData.pageControls">
<at-button
v-if="checkRenderCondition(button)"
:key="key"
class="control-item"
size="large"
:type="button.renderType || ''"
:icon="button.icon || ''"
@click="handleClick(button)"
>{{ $t(button.label) }}
</at-button>
</template>
</template>
</div>
</div>
<component
:is="component"
v-for="(component, index) of pageData.topComponents"
:key="index"
:parent="this"
></component>
<div class="data-entries">
<div v-for="(field, key) of fields" v-bind:key="key" class="data-entry">
<div class="row">
<div class="col-6 col-xs-24 label">{{ $t(field.label) }}:</div>
<div class="col">
<Skeleton :loading="isDataLoading">
<renderable-field
v-if="typeof field.render !== 'undefined' && Object.keys(values).length > 0"
:render="field.render"
:value="values[field.key]"
:field="field"
:values="values"
></renderable-field>
<template v-else>{{ values[field.key] }}</template>
</Skeleton>
</div>
</div>
</div>
</div>
<component
v-bind:is="component"
v-for="(component, index) of pageData.bottomComponents"
v-bind:key="index"
:parent="this"
></component>
</div>
</div>
<!-- /.col-24 -->
</div>
<!-- /.row -->
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import RenderableField from '@/components/RenderableField';
import { Skeleton } from 'vue-loading-skeleton';
export default {
name: 'ItemView',
components: {
RenderableField,
Skeleton,
},
provide() {
return {
reload: this.load,
};
},
computed: {
...mapGetters('user', ['user']),
title() {
const { fields, values, service, filters, pageData } = this;
const { titleCallback } = this.$route.meta;
if (typeof titleCallback === 'function') {
return titleCallback({ fields, values, service, filters, pageData });
}
return this.$t(pageData.title);
},
},
data() {
const { fields, service, filters, pageData } = this.$route.meta;
return {
service,
filters,
values: {},
fields: fields || [],
isDataLoading: false,
pageData: {
title: pageData.title || null,
topComponents: pageData.topComponents || [],
bottomComponents: pageData.bottomComponents || [],
pageControls: pageData.pageControls || [],
},
};
},
async mounted() {
await this.load();
this.websocketEnterChannel = this.$route.meta.pageData.websocketEnterChannel;
this.websocketLeaveChannel = this.$route.meta.pageData.websocketLeaveChannel;
if (typeof this.websocketEnterChannel !== 'undefined') {
this.websocketEnterChannel(this.user.id, {
edit: data => {
const id = this.$route.params[this.service.getIdParam()];
if (+id === +data.model.id) {
this.values = data.model;
}
},
});
}
},
beforeRouteEnter(to, from, next) {
if ('pageData' in from.meta && from.meta.pageData.type === 'new') {
to.meta.navigation.from = -2;
} else {
to.meta.navigation.from = -1;
}
next();
},
beforeDestroy() {
if (typeof this.websocketLeaveChannel !== 'undefined') {
this.websocketLeaveChannel(this.user.id);
}
},
methods: {
async load() {
this.isDataLoading = true;
const id = this.$route.params[this.service.getIdParam()];
try {
const { data } = (await this.service.getItem(id, this.filters)).data;
this.values = data;
} catch ({ response }) {
if (response.data.error_type === 'query.item_not_found') {
this.$router.replace({ name: 'forbidden' });
}
}
this.isDataLoading = false;
},
handleClick(button) {
button.onClick(this, this.values[this.service.getIdParam()]);
},
checkRenderCondition(button) {
return typeof button.renderCondition !== 'undefined' ? button.renderCondition(this) : true;
},
},
};
</script>
<style lang="scss" scoped>
.crud {
&__item-view {
.page-controls {
margin-bottom: 1.5em;
display: flex;
justify-content: space-between;
align-items: flex-start;
.control-items {
gap: 1em;
}
.control-item {
&:last-child {
margin-right: 0;
}
}
.title {
font-size: 1.6rem;
@media (max-width: 768px) {
font-size: 1rem;
}
}
}
.data-entries {
.data-entry {
padding-bottom: 1em;
margin-bottom: 1em;
border-bottom: 1px solid $gray-6;
&:last-child {
border-bottom: none;
}
.label {
margin-right: 1em;
font-weight: bold;
@media (max-width: 768px) {
font-size: 0.8rem;
width: 100%;
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,154 @@
<template>
<div class="desktop-key content-wrapper">
<div class="container">
<div class="at-container crud__content">
<div class="at-container__inner">
<h2 v-t="'auth.desktop.header'" />
</div>
<at-steps :current="current" class="col-lg-offset-7">
<at-step
:icon="transferStatus === 'process' ? 'icon-lock' : undefined"
:status="transferStatus"
:description="$t('auth.desktop.step1')"
:title="$t('auth.desktop.step', { n: 1 })"
/>
<at-step
:icon="openStatus !== 'finish' && openStatus !== 'error' ? 'icon-monitor' : undefined"
:status="openStatus"
:description="$t('auth.desktop.step2')"
:title="$t('auth.desktop.step', { n: 2 })"
/>
</at-steps>
<div class="row">
<div v-if="transferStatus === 'finish' && openStatus === 'error'" class="col-10 col-offset-7">
<p v-t="'auth.desktop.error'" />
<i18n path="auth.desktop.download" tag="p">
<a v-t="'auth.desktop.download_button'" href="https://cattr.app/desktop" target="_blank" />
</i18n>
</div>
<div class="col-6 col-offset-9">
<at-button
:type="
transferStatus === 'finish'
? openStatus === 'finish'
? 'success'
: 'primary'
: 'error'
"
@click="click"
>
{{
transferStatus === 'error' || openStatus === 'error'
? $t('auth.desktop.retry')
: transferStatus === 'finish' && openStatus !== 'finish'
? $t('auth.desktop.open')
: transferStatus === 'finish' && openStatus === 'finish'
? $t('auth.desktop.finish')
: $t('auth.desktop.cancel')
}}
</at-button>
</div>
<!-- /.row -->
</div>
<!-- /.at-container__inner -->
</div>
<!-- /.at-container -->
</div>
<!-- /.container -->
</div>
<!-- /.content-wrapper -->
</template>
<script>
import axios from 'axios';
export default {
name: 'DesktopLogin',
async mounted() {
this.isLoading = true;
try {
const { data } = await axios.get('auth/desktop-key');
this.current += 1;
this.transferStatus = 'finish';
this.token = data.access_token;
} catch ({ response }) {
this.transferStatus = 'error';
if (process.env.NODE_ENV === 'development') {
console.warn(response ? response : 'Issuing desktop key has been canceled');
}
}
this.isLoading = false;
},
data() {
return {
current: 0,
transferStatus: 'process',
openStatus: 'wait',
token: null,
};
},
methods: {
click() {
if (this.transferStatus === 'error' || this.openStatus === 'error') {
window.location.reload();
} else if (
this.transferStatus === 'finish' &&
(this.openStatus === 'wait' || this.openStatus === 'process')
) {
this.current += 1;
this.openStatus = 'process';
const client = window.open(
`cattr://authenticate?url=${encodeURIComponent(
process.env.VUE_APP_API_URL || `${window.location.origin}/api`,
)}&token=${this.token}`,
);
const failFunction = () => {
if (this.openStatus !== 'finish') {
this.openStatus = 'error';
}
};
const timeout = setTimeout(failFunction, 1);
client.onblur = () => {
clearTimeout(timeout);
if (this.openStatus !== 'error') {
this.openStatus = 'finish';
}
};
client.onunload = client.onclose = () => {
clearTimeout(timeout);
failFunction();
};
} else {
window.history.back();
}
},
},
};
</script>
<style lang="scss" scoped>
.desktop-key {
text-align: center;
.at-steps {
text-align: left;
}
.row {
margin-top: 20px;
& > .col-10 {
margin-bottom: 10px;
}
}
}
</style>

View File

@@ -0,0 +1,39 @@
<template>
<div class="page-forbidden">
<div class="container">
<h1 class="page-forbidden__code">403</h1>
<h1 class="page-forbidden__title">Whoops!</h1>
<p class="page-forbidden__description">{{ $t('message.page_forbidden') }}</p>
</div>
</div>
</template>
<script>
export default {
name: 'PageForbidden',
};
</script>
<style lang="scss">
.page-forbidden {
text-align: center;
&__code {
color: $red-1;
font-size: 8rem;
line-height: 1;
margin-bottom: 1rem;
}
&__title {
font-size: 3rem;
margin-bottom: 1rem;
line-height: 1;
}
&__description {
font-size: 1.5rem;
font-weight: bold;
}
}
</style>

View File

@@ -0,0 +1,39 @@
<template>
<div class="error-page-system">
<h1 class="code">404</h1>
<h1 class="title">Whoops!</h1>
<p class="description">{{ $t('message.page_not_found') }}</p>
</div>
</template>
<script>
export default {
name: 'PageNotFound',
};
</script>
<style lang="scss">
.error-page-system {
margin-left: 15%;
margin-right: 15%;
.code {
text-align: center;
color: $red-1;
font-size: 10rem;
line-height: 1;
}
.title {
font-size: 3.5rem;
text-align: center;
margin-bottom: 1rem;
}
.description {
font-size: 1.5rem;
text-align: center;
font-weight: bold;
}
}
</style>

View File

@@ -0,0 +1,72 @@
<template>
<div :class="containerClass">
<h1 class="page-title">{{ $t('navigation.company_settings') }}</h1>
<div class="at-container settings">
<div class="row">
<div class="col-5">
<at-menu v-if="sections" class="settings__menu" router mode="vertical">
<template v-for="(section, key) in sections">
<at-menu-item v-if="section.access" :key="key" :to="{ name: section.pathName }">
{{ $t(section.label) }}
</at-menu-item>
</template>
</at-menu>
</div>
<div class="col-19">
<div class="settings__content">
<router-view />
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'CompanySettings',
computed: {
containerClass() {
return 'container';
},
sections() {
return this.$store.getters['settings/sections']
.filter(section => section.scope === 'company')
.sort((a, b) => a.order - b.order);
},
},
};
</script>
<style lang="scss" scoped>
.settings {
&::v-deep {
.page-title {
font-size: 24px;
}
}
&__menu {
padding: $layout-01 0;
height: 100%;
border-top-left-radius: $border-radius-lger;
border-bottom-left-radius: $border-radius-lger;
}
&__content {
padding: $spacing-05 $spacing-06 $spacing-07;
}
}
.settings__content::v-deep {
.at-container,
.at-container__inner,
.crud {
all: unset;
&__table {
margin-bottom: $layout-01;
}
}
}
</style>

View File

@@ -0,0 +1,376 @@
<template>
<div>
<h1 v-if="this.section" class="page-title settings__title">{{ $t(this.section.label) }}</h1>
<template v-if="this.section && values">
<component
:is="component"
v-for="(component, index) of this.section.topComponents"
:key="index"
:parent="this"
/>
<validation-observer ref="form">
<div class="data-entries">
<template v-for="(fields, groupKey) of this.groups">
<template v-for="(field, key) of fields">
<template v-if="typeof field.displayable === 'function' ? field.displayable($store) : true">
<div :key="key" class="data-entry">
<div class="row">
<div class="col-6 label">
<at-tooltip
v-if="field.tooltipValue"
:content="$t(field.tooltipValue)"
placement="top-right"
>
<u class="label label-tooltip">
{{ $t(field.label) }}
<span v-if="field.required">*</span>
</u>
</at-tooltip>
<p v-else class="label">
{{ $t(field.label) }}
<span v-if="field.required">*</span>
</p>
</div>
<div class="col">
<validation-provider
v-if="typeof field.render === 'function'"
v-slot="{ errors }"
:rules="field.rules || ''"
:name="$t(field.label)"
:vid="field.key"
>
<renderable-field
v-model="values[field.key]"
:render="field.render"
:field="field"
:values="values"
class="with-margin"
:class="{
'at-select--error at-input--error has-error': errors.length > 0,
}"
/>
<small>{{ errors[0] }}</small>
</validation-provider>
<validation-provider
v-else-if="
field.fieldOptions.type === 'input' ||
field.fieldOptions.type === 'text'
"
v-slot="{ errors }"
:rules="field.rules || ''"
:name="$t(field.label)"
:vid="field.key"
>
<at-input
v-model="values[field.key]"
:readonly="field.fieldOptions.disableAutocomplete || false"
:placeholder="$t(field.fieldOptions.placeholder) || ''"
:type="field.fieldOptions.frontendType || ''"
:status="errors.length > 0 ? 'error' : ''"
@focus="removeReadonly"
/>
<small>{{ errors[0] }}</small>
</validation-provider>
<validation-provider
v-else-if="field.fieldOptions.type === 'number'"
v-slot="{ errors }"
:rules="field.rules || ''"
:name="$t(field.label)"
:vid="field.key"
>
<at-input-number
v-model="values[field.key]"
:min="field.minValue"
:max="field.maxValue"
size="large"
@blur="handleInputNumber($event, field.key)"
/>
<small>{{ errors[0] }}</small>
</validation-provider>
<validation-provider
v-else-if="field.fieldOptions.type === 'select'"
v-slot="{ errors }"
:rules="field.rules || ''"
:name="$t(field.label)"
:vid="field.key"
>
<at-select v-model="values[field.key]" class="with-margin">
<at-option
v-for="(option, optionKey) of getSelectOptions(field, values)"
:key="optionKey"
:value="option.value"
>{{ $t(option.label) }}
</at-option>
</at-select>
<small>{{ errors[0] }}</small>
</validation-provider>
<validation-provider
v-else-if="field.fieldOptions.type === 'textarea'"
v-slot="{ errors }"
:rules="field.rules || ''"
:name="$t(field.label)"
:vid="field.key"
>
<at-textarea
v-model="values[field.key]"
autosize
class="with-margin"
:class="{
'at-textarea--error': errors.length > 0,
}"
/>
<small>{{ errors[0] }}</small>
</validation-provider>
<validation-provider
v-else-if="field.fieldOptions.type === 'listbox'"
v-slot="{ errors }"
:rules="field.rules || ''"
:name="$t(field.label)"
:vid="field.key"
>
<ListBox
v-model="values[field.key]"
:keyField="field.fieldOptions.keyField"
:labelField="field.fieldOptions.labelField"
:valueField="field.fieldOptions.valueField"
/>
<small>{{ errors[0] }}</small>
</validation-provider>
<validation-provider
v-else-if="field.fieldOptions.type === 'checkbox'"
v-slot="{ errors }"
:rules="field.rules || ''"
:name="$t(field.label)"
:vid="field.key"
>
<at-checkbox v-model="values[field.key]" label="" />
<small>{{ errors[0] }}</small>
</validation-provider>
<validation-provider
v-else-if="field.fieldOptions.type === 'switch'"
v-slot="{ errors }"
:rules="field.rules || ''"
:name="$t(field.label)"
:vid="field.key"
>
<span
v-if="field.fieldOptions.checkedText"
v-html="field.fieldOptions.checkedText"
/>
<at-switch
v-model="values[field.key]"
size="large"
@change="$set(values, field.key, $event)"
>
<template
v-if="field.fieldOptions.innerCheckedText"
v-slot:checkedText
>
<span v-html="field.fieldOptions.innerCheckedText" />
</template>
<template
v-if="field.fieldOptions.innerUnCheckedText"
v-slot:unCheckedText
>
<span v-html="field.fieldOptions.innerUnCheckedText" />
</template>
</at-switch>
<span
v-if="field.fieldOptions.unCheckedText"
v-html="field.fieldOptions.unCheckedText"
/>
<small>{{ errors[0] }}</small>
</validation-provider>
</div>
</div>
</div>
</template>
</template>
<hr :key="groupKey" class="group-divider" />
</template>
</div>
<component
:is="component"
v-for="(component, index) of this.section.bottomComponents"
:key="index"
:parent="this"
></component>
<at-button type="primary" :loading="isLoading" :disabled="isLoading" @click="submit"
>{{ $t('control.save') }}
</at-button>
</validation-observer>
</template>
</div>
</template>
<script>
import ListBox from '@/components/ListBox';
import RenderableField from '@/components/RenderableField';
import { ValidationObserver, ValidationProvider } from 'vee-validate';
export default {
name: 'DynamicSettings',
components: {
RenderableField,
ListBox,
ValidationObserver,
ValidationProvider,
},
data() {
return {
section: {},
values: {},
isLoading: false,
};
},
mounted() {
this.fetchSectionData();
},
watch: {
sections() {
this.fetchSectionData();
},
},
computed: {
sections() {
return this.$store.getters['settings/sections'];
},
groups() {
if (!this.section) {
return {};
}
const { fields } = this.section;
if (!fields) {
return {};
}
return Object.keys(fields)
.map(key => ({ key, field: fields[key] }))
.reduce((groups, { key, field }) => {
const groupKey = field.group || 'default';
if (!groups[groupKey]) {
groups[groupKey] = {};
}
groups[groupKey][key] = field;
return groups;
}, {});
},
},
methods: {
handleInputNumber(ev, key) {
let number = ev.target.valueAsNumber;
if (ev.target.max && number > ev.target.max) {
number = Number(ev.target.max);
ev.target.valueAsNumber = number;
ev.target.value = String(number);
}
if (ev.target.min && number < ev.target.min) {
number = Number(ev.target.min);
ev.target.valueAsNumber = number;
ev.target.value = String(number);
}
this.values[key] = number;
},
fetchSectionData() {
const name = this.$route.name;
this.section = this.$store.getters['settings/sections'].find(s => s.pathName === name);
if (this.section) {
this.values = { ...this.values, ...this.section.data };
}
},
removeReadonly(el) {
if (el.target.getAttribute('readonly') === 'readonly') {
el.target.removeAttribute('readonly');
}
},
getSelectOptions(field, values) {
const { options } = field.fieldOptions;
if (typeof options === 'function') {
return options({ field, values });
}
return options;
},
async submit() {
const valid = await this.$refs.form.validate();
if (!valid) {
return;
}
this.isLoading = true;
try {
await this.section.service.save(this.values);
this.$Notify({
type: 'success',
title: this.$t('notification.settings.save.success.title'),
message: this.$t('notification.settings.save.success.message'),
});
this.$router.go(0);
} catch ({ response }) {
if (
typeof response !== 'undefined' &&
Object.prototype.hasOwnProperty.call(response, 'data') &&
Object.prototype.hasOwnProperty.call(response.data, 'info')
) {
this.$refs.form.setErrors(response.data.info);
}
} finally {
this.isLoading = false;
}
},
},
};
</script>
<style lang="scss" scoped>
.settings {
&__title {
font-size: 24px;
}
&__content {
width: 100%;
.data-entry {
margin-bottom: 1em;
}
.label {
font-weight: bold;
}
}
}
.group-divider {
border: 0;
border-top: 1px solid #eeeef5;
&:last-child {
display: none;
}
}
</style>

View File

@@ -0,0 +1,69 @@
<template>
<div class="container">
<h1 class="page-title">{{ $t('navigation.settings') }}</h1>
<div class="at-container settings">
<div class="row">
<div class="col-5">
<at-menu v-if="sections" class="settings__menu" router mode="vertical">
<template v-for="(section, key) in sections">
<at-menu-item v-if="section.access" :key="key" :to="{ name: section.pathName }">
{{ $t(section.label) }}
</at-menu-item>
</template>
</at-menu>
</div>
<div class="col-19">
<div class="settings__content">
<router-view />
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'Settings',
computed: {
sections() {
return this.$store.getters['settings/sections']
.filter(section => section.scope === 'settings')
.sort((a, b) => a.order - b.order);
},
},
};
</script>
<style lang="scss" scoped>
.settings {
&::v-deep {
.page-title {
font-size: 24px;
}
}
&__menu {
padding: $layout-01 0;
height: 100%;
border-top-left-radius: $border-radius-lger;
border-bottom-left-radius: $border-radius-lger;
}
&__content {
padding: $spacing-05 $spacing-06 $spacing-08;
&::v-deep {
.at-container,
.at-container__inner,
.crud {
all: unset;
&__table {
margin-bottom: $layout-01;
}
}
}
}
}
</style>