Compare commits

...

No commits in common. "main" and "dev" have entirely different histories.
main ... dev

138 changed files with 12154 additions and 72 deletions

4
.browserslistrc Normal file
View File

@ -0,0 +1,4 @@
> 1%
last 2 versions
not dead
not ie 11

5
.editorconfig Normal file
View File

@ -0,0 +1,5 @@
[*.{js,jsx,ts,tsx,vue}]
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
insert_final_newline = true

1
.env Normal file
View File

@ -0,0 +1 @@
VITE_API_URL=http://localhost:8080/

View File

@ -0,0 +1,69 @@
{
"globals": {
"Component": true,
"ComponentPublicInstance": true,
"ComputedRef": true,
"EffectScope": true,
"ExtractDefaultPropTypes": true,
"ExtractPropTypes": true,
"ExtractPublicPropTypes": true,
"InjectionKey": true,
"PropType": true,
"Ref": true,
"VNode": true,
"WritableComputedRef": true,
"computed": true,
"createApp": true,
"customRef": true,
"defineAsyncComponent": true,
"defineComponent": true,
"effectScope": true,
"getCurrentInstance": true,
"getCurrentScope": true,
"h": true,
"inject": true,
"isProxy": true,
"isReactive": true,
"isReadonly": true,
"isRef": true,
"markRaw": true,
"nextTick": true,
"onActivated": true,
"onBeforeMount": true,
"onBeforeUnmount": true,
"onBeforeUpdate": true,
"onDeactivated": true,
"onErrorCaptured": true,
"onMounted": true,
"onRenderTracked": true,
"onRenderTriggered": true,
"onScopeDispose": true,
"onServerPrefetch": true,
"onUnmounted": true,
"onUpdated": true,
"provide": true,
"reactive": true,
"readonly": true,
"ref": true,
"resolveComponent": true,
"shallowReactive": true,
"shallowReadonly": true,
"shallowRef": true,
"toRaw": true,
"toRef": true,
"toRefs": true,
"toValue": true,
"triggerRef": true,
"unref": true,
"useAttrs": true,
"useCssModule": true,
"useCssVars": true,
"useRoute": true,
"useRouter": true,
"useSlots": true,
"watch": true,
"watchEffect": true,
"watchPostEffect": true,
"watchSyncEffect": true
}
}

29
.eslintrc.js Normal file
View File

@ -0,0 +1,29 @@
/**
* .eslint.js
*
* ESLint configuration file.
*/
module.exports = {
root: true,
env: {
node: true,
},
extends: [
'plugin:vue/vue3-essential',
'eslint:recommended',
'vue3-recommended',
'plugin:prettier/recommend',
'@vue/eslint-config-typescript',
],
rules: {
'vue/multi-word-component-names': 'off',
'vue/valid-v-slot': 'off',
'prettier/prettier': [
'error',
{
endOfLine: 'auto',
},
],
},
}

22
.gitignore vendored Normal file
View File

@ -0,0 +1,22 @@
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

10
.prettierrc Normal file
View File

@ -0,0 +1,10 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": true,
"tabWidth": 2,
"singleQuote": true,
"printWidth": 80,
"trailingComma": "all",
"arrowParens": "always",
"endOfLine": "auto"
}

132
README.md
View File

@ -1,93 +1,81 @@
# Interface
# Vuetify (Default)
This is the official scaffolding tool for Vuetify, designed to give you a head start in building your new Vuetify application. It sets up a base template with all the necessary configurations and standard directory structure, enabling you to begin development without the hassle of setting up the project from scratch.
## ❗️ Important Links
## Getting started
- 📄 [Docs](https://vuetifyjs.com/)
- 🚨 [Issues](https://issues.vuetifyjs.com/)
- 🏬 [Store](https://store.vuetifyjs.com/)
- 🎮 [Playground](https://play.vuetifyjs.com/)
- 💬 [Discord](https://community.vuetifyjs.com)
To make it easy for you to get started with GitLab, here's a list of recommended next steps.
## 💿 Install
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
Set up your project using your preferred package manager. Use the corresponding command to install the dependencies:
## Add your files
| Package Manager | Command |
|---------------------------------------------------------------|----------------|
| [yarn](https://yarnpkg.com/getting-started) | `yarn install` |
| [npm](https://docs.npmjs.com/cli/v7/commands/npm-install) | `npm install` |
| [pnpm](https://pnpm.io/installation) | `pnpm install` |
| [bun](https://bun.sh/#getting-started) | `bun install` |
- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files
- [ ] [Add files using the command line](https://docs.gitlab.com/ee/gitlab-basics/add-file.html#add-a-file-using-the-command-line) or push an existing Git repository with the following command:
After completing the installation, your environment is ready for Vuetify development.
```
cd existing_repo
git remote add origin https://git.peresvet.it/system-trace/controller/interface.git
git branch -M main
git push -uf origin main
## ✨ Features
- 🖼️ **Optimized Front-End Stack**: Leverage the latest Vue 3 and Vuetify 3 for a modern, reactive UI development experience. [Vue 3](https://v3.vuejs.org/) | [Vuetify 3](https://vuetifyjs.com/en/)
- 🗃️ **State Management**: Integrated with [Pinia](https://pinia.vuejs.org/), the intuitive, modular state management solution for Vue.
- 🚦 **Routing and Layouts**: Utilizes Vue Router for SPA navigation and vite-plugin-vue-layouts for organizing Vue file layouts. [Vue Router](https://router.vuejs.org/) | [vite-plugin-vue-layouts](https://github.com/JohnCampionJr/vite-plugin-vue-layouts)
- 💻 **Enhanced Development Experience**: Benefit from TypeScript's static type checking and the ESLint plugin suite for Vue, ensuring code quality and consistency. [TypeScript](https://www.typescriptlang.org/) | [ESLint Plugin Vue](https://eslint.vuejs.org/)
- ⚡ **Next-Gen Tooling**: Powered by Vite, experience fast cold starts and instant HMR (Hot Module Replacement). [Vite](https://vitejs.dev/)
- 🧩 **Automated Component Importing**: Streamline your workflow with unplugin-vue-components, automatically importing components as you use them. [unplugin-vue-components](https://github.com/antfu/unplugin-vue-components)
- 🛠️ **Strongly-Typed Vue**: Use vue-tsc for type-checking your Vue components, and enjoy a robust development experience. [vue-tsc](https://github.com/johnsoncodehk/volar/tree/master/packages/vue-tsc)
These features are curated to provide a seamless development experience from setup to deployment, ensuring that your Vuetify application is both powerful and maintainable.
## 💡 Usage
This section covers how to start the development server and build your project for production.
### Starting the Development Server
To start the development server with hot-reload, run the following command. The server will be accessible at [http://localhost:3000](http://localhost:3000):
```bash
yarn dev
```
## Integrate with your tools
(Repeat for npm, pnpm, and bun with respective commands.)
- [ ] [Set up project integrations](https://git.peresvet.it/system-trace/controller/interface/-/settings/integrations)
> Add NODE_OPTIONS='--no-warnings' to suppress the JSON import warnings that happen as part of the Vuetify import mapping. If you are on Node [v21.3.0](https://nodejs.org/en/blog/release/v21.3.0) or higher, you can change this to NODE_OPTIONS='--disable-warning=5401'. If you don't mind the warning, you can remove this from your package.json dev script.
## Collaborate with your team
### Building for Production
- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/)
- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html)
- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically)
- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/)
- [ ] [Set auto-merge](https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html)
To build your project for production, use:
## Test and Deploy
```bash
yarn build
```
Use the built-in continuous integration in GitLab.
(Repeat for npm, pnpm, and bun with respective commands.)
- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/index.html)
- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/ee/user/application_security/sast/)
- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html)
- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/)
- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html)
Once the build process is completed, your application will be ready for deployment in a production environment.
***
## 💪 Support Vuetify Development
# Editing this README
This project is built with [Vuetify](https://vuetifyjs.com/en/), a UI Library with a comprehensive collection of Vue components. Vuetify is an MIT licensed Open Source project that has been made possible due to the generous contributions by our [sponsors and backers](https://vuetifyjs.com/introduction/sponsors-and-backers/). If you are interested in supporting this project, please consider:
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template.
- [Requesting Enterprise Support](https://support.vuetifyjs.com/)
- [Sponsoring John on Github](https://github.com/users/johnleider/sponsorship)
- [Sponsoring Kael on Github](https://github.com/users/kaelwd/sponsorship)
- [Supporting the team on Open Collective](https://opencollective.com/vuetify)
- [Becoming a sponsor on Patreon](https://www.patreon.com/vuetify)
- [Becoming a subscriber on Tidelift](https://tidelift.com/subscription/npm/vuetify)
- [Making a one-time donation with Paypal](https://paypal.me/vuetify)
## Suggestions for a good README
## 📑 License
[MIT](http://opensource.org/licenses/MIT)
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
## Name
Choose a self-explaining name for your project.
## Description
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
## Badges
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
## Visuals
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
## Installation
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
## Usage
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
## Support
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
## Roadmap
If you have ideas for releases in the future, it is a good idea to list them in the README.
## Contributing
State if you are open to contributions and what your requirements are for accepting them.
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
## Authors and acknowledgment
Show your appreciation to those who have contributed to the project.
## License
For open source projects, say how it is licensed.
## Project status
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
Copyright (c) 2016-present Vuetify, LLC

16
index.html Normal file
View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Peresvet - System Trace</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

6672
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

53
package.json Normal file
View File

@ -0,0 +1,53 @@
{
"name": "interface",
"version": "0.0.0",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"lint": "eslint . --fix --ignore-path .gitignore"
},
"dependencies": {
"@mdi/font": "6.2.95",
"@vueuse/core": "^10.10.0",
"axios": "^1.7.2",
"chart.js": "^4.4.3",
"core-js": "^3.34.0",
"moment": "^2.30.1",
"roboto-fontface": "*",
"vue": "^3.4.21",
"vue-chartjs": "^5.3.1",
"vuetify": "^3.5.8"
},
"devDependencies": {
"@babel/types": "^7.24.0",
"@types/node": "^20.11.25",
"@vitejs/plugin-vue": "^5.0.4",
"@vue/eslint-config-typescript": "^13.0.0",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-config-standard": "^17.1.0",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-n": "^16.6.2",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-vue": "^9.22.0",
"pinia": "^2.1.7",
"postcss": "^8.4.38",
"prettier": "3.3.2",
"sass": "^1.71.1",
"tailwindcss": "^3.4.3",
"typescript": "^5.4.2",
"unplugin-auto-import": "^0.17.5",
"unplugin-fonts": "^1.1.1",
"unplugin-vue-components": "^0.26.0",
"unplugin-vue-router": "^0.8.4",
"vite": "^5.1.5",
"vite-plugin-pages": "^0.32.2",
"vite-plugin-vue-layouts": "^0.11.0",
"vite-plugin-vuetify": "^2.0.3",
"vue-router": "^4.3.0",
"vue-tsc": "^2.0.6"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

25
public/images/flag_en.svg Normal file
View File

@ -0,0 +1,25 @@
<svg width="32" height="24" viewBox="0 0 32 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_270_67366)">
<rect width="32" height="24" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0V24H32V0H0Z" fill="#2E42A5"/>
<mask id="mask0_270_67366" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="32" height="24">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0V24H32V0H0Z" fill="white"/>
</mask>
<g mask="url(#mask0_270_67366)">
<path d="M-3.56311 22.2854L3.47858 25.2635L32.1598 3.23787L35.8741 -1.18761L28.3441 -2.18297L16.6457 7.3085L7.22968 13.7035L-3.56311 22.2854Z" fill="white"/>
<path d="M-2.59912 24.3719L0.988295 26.1001L34.5403 -1.59881H29.5032L-2.59912 24.3719Z" fill="#F50100"/>
<path d="M35.5631 22.2854L28.5214 25.2635L-0.159817 3.23787L-3.87415 -1.18761L3.65593 -2.18297L15.3543 7.3085L24.7703 13.7035L35.5631 22.2854Z" fill="white"/>
<path d="M35.3229 23.7829L31.7355 25.5111L17.4487 13.6518L13.2129 12.3267L-4.23151 -1.17246H0.805637L18.2403 12.0063L22.8713 13.5952L35.3229 23.7829Z" fill="#F50100"/>
<mask id="path-7-inside-1_270_67366" fill="white">
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.7778 -2H12.2222V8H-1.97247V16H12.2222V26H19.7778V16H34.0275V8H19.7778V-2Z"/>
</mask>
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.7778 -2H12.2222V8H-1.97247V16H12.2222V26H19.7778V16H34.0275V8H19.7778V-2Z" fill="#F50100"/>
<path d="M12.2222 -2V-4H10.2222V-2H12.2222ZM19.7778 -2H21.7778V-4H19.7778V-2ZM12.2222 8V10H14.2222V8H12.2222ZM-1.97247 8V6H-3.97247V8H-1.97247ZM-1.97247 16H-3.97247V18H-1.97247V16ZM12.2222 16H14.2222V14H12.2222V16ZM12.2222 26H10.2222V28H12.2222V26ZM19.7778 26V28H21.7778V26H19.7778ZM19.7778 16V14H17.7778V16H19.7778ZM34.0275 16V18H36.0275V16H34.0275ZM34.0275 8H36.0275V6H34.0275V8ZM19.7778 8H17.7778V10H19.7778V8ZM12.2222 0H19.7778V-4H12.2222V0ZM14.2222 8V-2H10.2222V8H14.2222ZM-1.97247 10H12.2222V6H-1.97247V10ZM0.0275269 16V8H-3.97247V16H0.0275269ZM12.2222 14H-1.97247V18H12.2222V14ZM14.2222 26V16H10.2222V26H14.2222ZM19.7778 24H12.2222V28H19.7778V24ZM17.7778 16V26H21.7778V16H17.7778ZM34.0275 14H19.7778V18H34.0275V14ZM32.0275 8V16H36.0275V8H32.0275ZM19.7778 10H34.0275V6H19.7778V10ZM17.7778 -2V8H21.7778V-2H17.7778Z" fill="white" mask="url(#path-7-inside-1_270_67366)"/>
</g>
</g>
<defs>
<clipPath id="clip0_270_67366">
<rect width="32" height="24" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

18
public/images/flag_ru.svg Normal file
View File

@ -0,0 +1,18 @@
<svg width="32" height="24" viewBox="0 0 32 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_270_67492)">
<rect width="32" height="24" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0V24H32V0H0Z" fill="#3D58DB"/>
<mask id="mask0_270_67492" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="32" height="24">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0V24H32V0H0Z" fill="white"/>
</mask>
<g mask="url(#mask0_270_67492)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0V8H32V0H0Z" fill="#F7FCFF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 16V24H32V16H0Z" fill="#C51918"/>
</g>
</g>
<defs>
<clipPath id="clip0_270_67492">
<rect width="32" height="24" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 788 B

17
src/App.vue Normal file
View File

@ -0,0 +1,17 @@
<template>
<v-app>
<v-main>
<router-view />
<!-- Confirmation modal -->
<confirm-modal />
<!-- Info modal -->
<info-modal />
</v-main>
</v-app>
</template>
<script lang="ts" setup>
//
</script>

41
src/api/auth.ts Normal file
View File

@ -0,0 +1,41 @@
import { $axios } from '@/plugins/axios';
import { AxiosResponse } from 'axios';
const endpoints = {
login: 'v1/auth/login',
logout: 'v1/auth/logout',
me: 'v1/auth/me',
};
export default {
login: async (
email: string,
password: string,
): Promise<[AxiosResponse?, string?]> => {
try {
const data = await $axios.post(endpoints.login, {
email,
password,
});
return [data, undefined];
} catch (e: any) {
return [undefined, e?.response?.data?.error ?? e.message];
}
},
getMe: async (): Promise<[AxiosResponse?, string?]> => {
try {
const data = await $axios.get(endpoints.me);
return [data, undefined];
} catch (e: any) {
return [undefined, e?.response?.data?.error ?? e.message];
}
},
logout: async (): Promise<[AxiosResponse?, string?]> => {
try {
const data = await $axios.post(endpoints.logout);
return [data, undefined];
} catch (e: any) {
return [undefined, e?.response?.data?.error ?? e.message];
}
},
};

84
src/api/groups.ts Normal file
View File

@ -0,0 +1,84 @@
import { $axios } from '@/plugins/axios';
import { AxiosResponse } from 'axios';
import { OrderBy } from '@/types/order-by';
import { camelToSnake } from '@/mixins/case-converter';
import { setURIParams } from '@/mixins/query-params';
export default {
getAll: async (
count: number = 10,
page: number = 1,
orderBy: OrderBy,
search: string,
): Promise<[AxiosResponse?, string?]> => {
try {
const sp = new URLSearchParams([
...Object.entries(orderBy),
['count', String(count)],
['page', String(page)],
['input', search],
]);
setURIParams(sp);
const params: string = camelToSnake(sp.toString());
const data = await $axios.get(`v1/groups?${params}`);
return [data, undefined];
} catch (e: any) {
return [undefined, e?.response?.data?.error ?? e.message];
}
},
create: async (
name: string,
permissions: number[],
): Promise<[AxiosResponse?, string?]> => {
try {
const data = await $axios.post('v1/groups', {
name,
permissions: permissions.map((i) => ({
value: i,
})),
});
return [data, undefined];
} catch (e: any) {
return [undefined, e?.response?.data?.error ?? e.message];
}
},
update: async (
ID: number,
name: string,
permissions: number[],
): Promise<[AxiosResponse?, string?]> => {
try {
const data = await $axios.patch('v1/groups', {
ID,
name,
permissions: permissions.map((i) => ({
value: i,
})),
});
return [data, undefined];
} catch (e: any) {
return [undefined, e?.response?.data?.error ?? e.message];
}
},
delete: async (ID: number): Promise<[AxiosResponse?, string?]> => {
try {
const data = await $axios.delete(`v1/groups/${ID}`);
return [data, undefined];
} catch (e: any) {
return [undefined, e?.response?.data?.error ?? e.message];
}
},
deleteMany: async (IDs: number[]): Promise<[AxiosResponse?, string?]> => {
try {
const data = await $axios.delete('v1/groups', {
data: {
array: IDs,
},
});
return [data, undefined];
} catch (e: any) {
return [undefined, e?.response?.data?.error ?? e.message];
}
},
};

13
src/api/index.ts Normal file
View File

@ -0,0 +1,13 @@
import auth from './auth';
import users from './users';
import groups from './groups';
import permissions from './permissions';
import plugins from './plugins';
export default {
auth,
users,
groups,
permissions,
plugins,
};

13
src/api/permissions.ts Normal file
View File

@ -0,0 +1,13 @@
import { $axios } from '@/plugins/axios';
import { AxiosResponse } from 'axios';
export default {
getAll: async (): Promise<[AxiosResponse?, string?]> => {
try {
const data = await $axios.get('v1/permissions');
return [data, undefined];
} catch (e: any) {
return [undefined, e?.response?.data?.error ?? e.message];
}
},
};

13
src/api/plugins.ts Normal file
View File

@ -0,0 +1,13 @@
import { $axios } from '@/plugins/axios';
import { AxiosResponse } from 'axios';
export default {
getAll: async (): Promise<[AxiosResponse?, string?]> => {
try {
const data = await $axios.get('v1/plugins');
return [data, undefined];
} catch (e: any) {
return [undefined, e?.response?.data?.error ?? e.message];
}
},
};

117
src/api/users.ts Normal file
View File

@ -0,0 +1,117 @@
import { $axios } from '@/plugins/axios';
import { AxiosResponse } from 'axios';
import { OrderBy } from '@/types/order-by';
import { camelToSnake } from '@/mixins/case-converter';
import { setURIParams } from '@/mixins/query-params';
import { NewCredentials } from '@/types/new-credentials';
export default {
getAll: async (
count: number = 10,
page: number = 1,
orderBy: OrderBy,
search: string,
groupId: string,
): Promise<[AxiosResponse?, string?]> => {
try {
const sp = new URLSearchParams([
...Object.entries(orderBy),
['count', String(count)],
['page', String(page)],
['input', search],
['group_id', groupId],
]);
setURIParams(sp);
const params: string = camelToSnake(sp.toString());
const data = await $axios.get(`v1/users?${params}`);
return [data, undefined];
} catch (e: any) {
return [undefined, e?.response?.data?.error ?? e.message];
}
},
create: async (
email: string,
realName: string,
groupID: number,
): Promise<[AxiosResponse?, string?]> => {
try {
const data = await $axios.post('v1/users', {
email,
realName,
groupID,
});
return [data, undefined];
} catch (e: any) {
return [undefined, e?.response?.data?.error ?? e.message];
}
},
update: async (
ID: number,
email: string,
realName: string,
groupID: number,
): Promise<[AxiosResponse?, string?]> => {
try {
const data = await $axios.patch('v1/users', {
ID,
email,
realName,
groupID,
});
return [data, undefined];
} catch (e: any) {
return [undefined, e?.response?.data?.error ?? e.message];
}
},
delete: async (ID: number): Promise<[AxiosResponse?, string?]> => {
try {
const data = await $axios.delete(`v1/users/${ID}`);
return [data, undefined];
} catch (e: any) {
return [undefined, e?.response?.data?.error ?? e.message];
}
},
deleteMany: async (IDs: number[]): Promise<[AxiosResponse?, string?]> => {
try {
const data = await $axios.delete('v1/users', {
data: {
array: IDs,
},
});
return [data, undefined];
} catch (e: any) {
return [undefined, e?.response?.data?.error ?? e.message];
}
},
blockMany: async (IDs: number[]): Promise<[AxiosResponse?, string?]> => {
try {
const data = await $axios.patch('v1/users/block', {
array: IDs,
});
return [data, undefined];
} catch (e: any) {
return [undefined, e?.response?.data?.error ?? e.message];
}
},
unblockMany: async (IDs: number[]): Promise<[AxiosResponse?, string?]> => {
try {
const data = await $axios.patch('v1/users/unblock', {
array: IDs,
});
return [data, undefined];
} catch (e: any) {
return [undefined, e?.response?.data?.error ?? e.message];
}
},
resetPassword: async (
ID: number,
): Promise<[AxiosResponse<NewCredentials>?, string?]> => {
try {
const data = await $axios.patch(`v1/users/password/${ID}`);
return [data, undefined];
} catch (e: any) {
return [undefined, e?.response?.data?.error ?? e.message];
}
},
};

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

8
src/assets/logo.svg Normal file
View File

@ -0,0 +1,8 @@
<svg width="81" height="24" viewBox="0 0 81 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="48" y="16.0547" width="32.1081" height="7.94595" fill="black"/>
<rect width="32.1081" height="7.94595" fill="black"/>
<rect x="40.0547" y="7.94531" width="7.94595" height="8.10811" fill="black"/>
<rect x="32.1094" y="7.94531" width="7.94595" height="16.0541" fill="black"/>
<rect y="7.94531" width="7.94595" height="16.0541" fill="black"/>
<rect x="40.0547" width="40.0541" height="7.94595" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 518 B

View File

@ -0,0 +1,3 @@
# Styles
This directory is for configuring the styles of the application.

View File

@ -0,0 +1,37 @@
/* Fade */
.fade-leave-active {
animation: fadeOut 0.15s var(--transition);
}
.fade-enter-active {
position: absolute;
animation: fadeIn 0.15s var(--transition);
}
.fade-delay-leave-active {
animation: fadeOut 0.15s 0.5s var(--transition);
}
.fade-delay-enter-active {
animation: fadeIn 0.15s 0.5s var(--transition);
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

View File

@ -0,0 +1,95 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import './fonts.css';
@import './table.css';
@import './animations.css';
:root {
--transition: cubic-bezier(0.42, 0, 0.58, 1);
}
.accent-modal {
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity)) !important;
background: rgb(var(--v-theme-code)) !important;
}
.border-b {
border-bottom-width: 1px !important;
border-color: rgba(var(--v-border-color), var(--v-border-opacity)) !important;
}
.border-t {
border-top-width: 1px !important;
border-color: rgba(var(--v-border-color), var(--v-border-opacity)) !important;
}
.border-r {
border-right-width: 1px !important;
border-color: rgba(var(--v-border-color), var(--v-border-opacity)) !important;
}
.border-l {
border-left-width: 1px !important;
border-color: rgba(var(--v-border-color), var(--v-border-opacity)) !important;
}
.border-a {
border-width: 1px !important;
border-color: rgba(var(--v-border-color), var(--v-border-opacity)) !important;
}
.text-h6 {
font-weight: 400;
}
.cm-mini {
min-height: 30px !important;
}
html {
font-size: 0.875rem;
/* 14px */
line-height: 1.25rem;
/* 20px */
overscroll-behavior: none;
font-family: 'Roboto', sans-serif;
&::-webkit-scrollbar {
display: none;
}
}
.v-theme--light {
--v-theme-primary: 11, 87, 208 !important;
--v-theme-warning: 176, 126, 0 !important;
--v-theme-error: 217, 48, 37 !important;
--selected-row-bg: rgb(241, 245, 253);
}
.v-theme--dark {
--v-theme-primary: 51, 129, 255 !important;
--v-theme-warning: 252, 192, 10 !important;
--v-theme-error: 234, 67, 53 !important;
--selected-row-bg: rgba(241, 245, 253, 0.075);
.v-overlay__scrim {
--v-theme-on-surface: 0, 0, 0;
--v-overlay-opacity: 0.5;
}
}
.v-btn__overlay {
background-color: rgb(var(--v-theme-on-surface)) !important;
}
.v-btn:hover > .v-btn__overlay {
opacity: 0.06 !important;
}
.disabled-button {
filter: grayscale(1);
opacity: 0.5 !important;
.v-btn__overlay {
opacity: 0 !important;
}
}

View File

@ -0,0 +1,61 @@
html {
font-family: 'Roboto';
}
@font-face {
font-family: 'Roboto';
src: url('../fonts/Roboto/Roboto-Thin.ttf') format('truetype');
font-weight: 100;
}
@font-face {
font-family: 'Roboto';
src: url('../fonts/Roboto/Roboto-Light.ttf') format('truetype');
font-weight: 300;
}
@font-face {
font-family: 'Roboto';
src: url('../fonts/Roboto/Roboto-Regular.ttf') format('truetype');
font-weight: 400;
}
@font-face {
font-family: 'Roboto';
src: url('../fonts/Roboto/Roboto-Medium.ttf') format('truetype');
font-weight: 500;
}
@font-face {
font-family: 'Roboto';
src: url('../fonts/Roboto/Roboto-Bold.ttf') format('truetype');
font-weight: 700;
}
@font-face {
font-family: 'Roboto';
src: url('../fonts/Roboto/Roboto-Black.ttf') format('truetype');
font-weight: 900;
}
@font-face {
font-family: 'MDI';
src: url('../fonts/MDI/MaterialSymbolsOutlined-Light.ttf') format('truetype');
font-weight: 300;
}
@font-face {
font-family: 'MDI';
src: url('../fonts/MDI/MaterialSymbolsOutlined-Regular.ttf') format('truetype');
font-weight: 400;
}
.mso {
font-family: 'MDI';
font-size: 24px;
font-variation-settings:
'FILL' 0,
'wght' 300,
'GRAD' 0,
'opsz' 24
}

View File

@ -0,0 +1,85 @@
/**
* src/styles/settings.scss
*
* Configures SASS variables and Vuetify overwrites
*/
// https://vuetifyjs.com/features/sass-variables/`
@use 'vuetify/settings' with (
// $color-pack: false
$card-item-padding: 0.625rem 0,
$selection-control-size: 25px,
$utilities: (
'align-content': false,
'align-items': false,
'align-self': false,
'border-bottom': false,
'border-end': false,
'border-opacity': false,
'border-start': false,
'border-style': false,
'border-top': false,
'border': false,
'display': false,
'flex-direction': false,
'flex-grow': false,
'flex-shrink': false,
'flex-wrap': false,
'flex': false,
'float-ltr': false,
'float-rtl': false,
'float': false,
'font-italic': false,
'font-weight': false,
'justify-content': false,
'margin-bottom': false,
'margin-end': false,
'margin-left': false,
'margin-right': false,
'margin-start': false,
'margin-top': false,
'margin-x': false,
'margin-y': false,
'margin': false,
'negative-margin-bottom': false,
'negative-margin-end': false,
'negative-margin-left': false,
'negative-margin-right': false,
'negative-margin-start': false,
'negative-margin-top': false,
'negative-margin-x': false,
'negative-margin-y': false,
'negative-margin': false,
'order': false,
'overflow-wrap': false,
'overflow-x': false,
'overflow-y': false,
'overflow': false,
'padding-bottom': false,
'padding-end': false,
'padding-left': false,
'padding-right': false,
'padding-start': false,
'padding-top': false,
'padding-x': false,
'padding-y': false,
'padding': false,
// "rounded-bottom-end": false,
// "rounded-bottom-start": false,
// "rounded-bottom": false,
// "rounded-end": false,
// "rounded-start": false,
// "rounded-top-end": false,
// "rounded-top-start": false,
// "rounded-top": false,
// "rounded": false,
'text-align': false,
'text-decoration': false,
'text-mono': false,
'text-opacity': false,
'text-overflow': false,
'text-transform': false,
// "typography": false,
'white-space': false
)
);

View File

@ -0,0 +1,71 @@
.v-table .v-table__wrapper > table > tbody > tr:not(:last-child) > td,
.v-table .v-table__wrapper > table > tbody > tr:not(:last-child) > th {
border-bottom: none !important;
}
.v-table .v-table__wrapper > table > tbody > tr > td,
.v-table .v-table__wrapper > table > tbody > tr > th {
box-shadow: inset 0 -1px 0 rgba(var(--v-border-color), var(--v-border-opacity));
}
.v-data-table--loading .v-data-table__td {
opacity: 1 !important;
}
table > tbody > tr:has(div.v-selection-control--dirty) {
background-color: var(--selected-row-bg);
}
table > thead > tr > .v-data-table__th--sticky {
position: relative !important;
}
table
> tbody
> tr:not(.v-data-table-rows-no-data):not(.v-data-table-rows-loading)
> td:last-child,
table
> thead
> tr:not(.v-data-table-rows-no-data):not(.v-data-table-rows-loading)
> th:last-child {
position: sticky !important;
position: -webkit-sticky !important;
display: flex;
align-items: center;
justify-content: center;
right: 0;
border-right: none;
border-bottom: none !important;
z-index: 8 !important;
background-color: rgb(var(--v-theme-surface)) !important;
border-left-width: 1px !important;
border-color: rgba(var(--v-border-color), var(--v-border-opacity)) !important;
}
table
> thead
> tr:not(.v-data-table-rows-no-data):not(.v-data-table-rows-loading)
> th:last-child {
z-index: 9 !important;
}
table > thead {
z-index: 9 !important;
}
.table-actions {
background-color: rgb(var(--v-theme-surface));
}
.table-actions::before {
content: '';
position: absolute;
width: 100%;
height: 100%;
background-color: var(--selected-row-bg);
}
.v-theme--light
table
.v-selection-control__input:has(i.mdi-checkbox-marked)
i::before {
color: rgb(var(--v-theme-primary));
}

191
src/auto-imports.d.ts vendored Normal file
View File

@ -0,0 +1,191 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const effectScope: typeof import('vue')['effectScope']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAttrs: typeof import('vue')['useAttrs']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useLink: typeof import('vue-router')['useLink']
const useRoute: typeof import('vue-router/auto')['useRoute']
const useRouter: typeof import('vue-router/auto')['useRouter']
const useSlots: typeof import('vue')['useSlots']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, VNode, WritableComputedRef } from 'vue'
import('vue')
}
// for vue template auto import
import { UnwrapRef } from 'vue'
declare module 'vue' {
interface GlobalComponents {}
interface ComponentCustomProperties {
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
readonly computed: UnwrapRef<typeof import('vue')['computed']>
readonly createApp: UnwrapRef<typeof import('vue')['createApp']>
readonly customRef: UnwrapRef<typeof import('vue')['customRef']>
readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']>
readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']>
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
readonly h: UnwrapRef<typeof import('vue')['h']>
readonly inject: UnwrapRef<typeof import('vue')['inject']>
readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']>
readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']>
readonly isReadonly: UnwrapRef<typeof import('vue')['isReadonly']>
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']>
readonly nextTick: UnwrapRef<typeof import('vue')['nextTick']>
readonly onActivated: UnwrapRef<typeof import('vue')['onActivated']>
readonly onBeforeMount: UnwrapRef<typeof import('vue')['onBeforeMount']>
readonly onBeforeUnmount: UnwrapRef<typeof import('vue')['onBeforeUnmount']>
readonly onBeforeUpdate: UnwrapRef<typeof import('vue')['onBeforeUpdate']>
readonly onDeactivated: UnwrapRef<typeof import('vue')['onDeactivated']>
readonly onErrorCaptured: UnwrapRef<typeof import('vue')['onErrorCaptured']>
readonly onMounted: UnwrapRef<typeof import('vue')['onMounted']>
readonly onRenderTracked: UnwrapRef<typeof import('vue')['onRenderTracked']>
readonly onRenderTriggered: UnwrapRef<typeof import('vue')['onRenderTriggered']>
readonly onScopeDispose: UnwrapRef<typeof import('vue')['onScopeDispose']>
readonly onServerPrefetch: UnwrapRef<typeof import('vue')['onServerPrefetch']>
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
readonly provide: UnwrapRef<typeof import('vue')['provide']>
readonly reactive: UnwrapRef<typeof import('vue')['reactive']>
readonly readonly: UnwrapRef<typeof import('vue')['readonly']>
readonly ref: UnwrapRef<typeof import('vue')['ref']>
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']>
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
readonly toRaw: UnwrapRef<typeof import('vue')['toRaw']>
readonly toRef: UnwrapRef<typeof import('vue')['toRef']>
readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']>
readonly toValue: UnwrapRef<typeof import('vue')['toValue']>
readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']>
readonly unref: UnwrapRef<typeof import('vue')['unref']>
readonly useAttrs: UnwrapRef<typeof import('vue')['useAttrs']>
readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>
readonly useCssVars: UnwrapRef<typeof import('vue')['useCssVars']>
readonly useRoute: UnwrapRef<typeof import('vue-router/auto')['useRoute']>
readonly useRouter: UnwrapRef<typeof import('vue-router/auto')['useRouter']>
readonly useSlots: UnwrapRef<typeof import('vue')['useSlots']>
readonly watch: UnwrapRef<typeof import('vue')['watch']>
readonly watchEffect: UnwrapRef<typeof import('vue')['watchEffect']>
readonly watchPostEffect: UnwrapRef<typeof import('vue')['watchPostEffect']>
readonly watchSyncEffect: UnwrapRef<typeof import('vue')['watchSyncEffect']>
}
}
declare module '@vue/runtime-core' {
interface GlobalComponents {}
interface ComponentCustomProperties {
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
readonly computed: UnwrapRef<typeof import('vue')['computed']>
readonly createApp: UnwrapRef<typeof import('vue')['createApp']>
readonly customRef: UnwrapRef<typeof import('vue')['customRef']>
readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']>
readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']>
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
readonly h: UnwrapRef<typeof import('vue')['h']>
readonly inject: UnwrapRef<typeof import('vue')['inject']>
readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']>
readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']>
readonly isReadonly: UnwrapRef<typeof import('vue')['isReadonly']>
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']>
readonly nextTick: UnwrapRef<typeof import('vue')['nextTick']>
readonly onActivated: UnwrapRef<typeof import('vue')['onActivated']>
readonly onBeforeMount: UnwrapRef<typeof import('vue')['onBeforeMount']>
readonly onBeforeUnmount: UnwrapRef<typeof import('vue')['onBeforeUnmount']>
readonly onBeforeUpdate: UnwrapRef<typeof import('vue')['onBeforeUpdate']>
readonly onDeactivated: UnwrapRef<typeof import('vue')['onDeactivated']>
readonly onErrorCaptured: UnwrapRef<typeof import('vue')['onErrorCaptured']>
readonly onMounted: UnwrapRef<typeof import('vue')['onMounted']>
readonly onRenderTracked: UnwrapRef<typeof import('vue')['onRenderTracked']>
readonly onRenderTriggered: UnwrapRef<typeof import('vue')['onRenderTriggered']>
readonly onScopeDispose: UnwrapRef<typeof import('vue')['onScopeDispose']>
readonly onServerPrefetch: UnwrapRef<typeof import('vue')['onServerPrefetch']>
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
readonly provide: UnwrapRef<typeof import('vue')['provide']>
readonly reactive: UnwrapRef<typeof import('vue')['reactive']>
readonly readonly: UnwrapRef<typeof import('vue')['readonly']>
readonly ref: UnwrapRef<typeof import('vue')['ref']>
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']>
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
readonly toRaw: UnwrapRef<typeof import('vue')['toRaw']>
readonly toRef: UnwrapRef<typeof import('vue')['toRef']>
readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']>
readonly toValue: UnwrapRef<typeof import('vue')['toValue']>
readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']>
readonly unref: UnwrapRef<typeof import('vue')['unref']>
readonly useAttrs: UnwrapRef<typeof import('vue')['useAttrs']>
readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>
readonly useCssVars: UnwrapRef<typeof import('vue')['useCssVars']>
readonly useRoute: UnwrapRef<typeof import('vue-router/auto')['useRoute']>
readonly useRouter: UnwrapRef<typeof import('vue-router/auto')['useRouter']>
readonly useSlots: UnwrapRef<typeof import('vue')['useSlots']>
readonly watch: UnwrapRef<typeof import('vue')['watch']>
readonly watchEffect: UnwrapRef<typeof import('vue')['watchEffect']>
readonly watchPostEffect: UnwrapRef<typeof import('vue')['watchPostEffect']>
readonly watchSyncEffect: UnwrapRef<typeof import('vue')['watchSyncEffect']>
}
}

24
src/components.d.ts vendored Normal file
View File

@ -0,0 +1,24 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
declare module 'vue' {
export interface GlobalComponents {
AppFooter: typeof import('./components/AppFooter.vue')['default']
ConfirmModal: typeof import('./components/ConfirmModal.vue')['default']
copy: typeof import('./components/ConfirmModal copy.vue')['default']
ErrorPlate: typeof import('./components/ErrorPlate.vue')['default']
HelloWorld: typeof import('./components/HelloWorld.vue')['default']
InfoModal: typeof import('./components/InfoModal.vue')['default']
LanguageSwitcher: typeof import('./components/LanguageSwitcher.vue')['default']
LogoComponent: typeof import('./components/Icons/LogoComponent.vue')['default']
NavigationDrawer: typeof import('./components/NavigationDrawer.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
TableLoader: typeof import('./components/TableLoader.vue')['default']
ThemeSwitcher: typeof import('./components/ThemeSwitcher.vue')['default']
}
}

View File

@ -0,0 +1,79 @@
<template>
<v-footer height="40" app>
<a
v-for="item in items"
:key="item.title"
:href="item.href"
:title="item.title"
class="d-inline-block mx-2 social-link"
rel="noopener noreferrer"
target="_blank"
>
<v-icon
:icon="item.icon"
:size="item.icon === '$vuetify' ? 24 : 16"
/>
</a>
<div
class="text-caption text-disabled"
style="position: absolute; right: 16px;"
>
&copy; 2016-{{ (new Date()).getFullYear() }} <span class="d-none d-sm-inline-block">Vuetify, LLC</span>
<a
class="text-decoration-none on-surface"
href="https://vuetifyjs.com/about/licensing/"
rel="noopener noreferrer"
target="_blank"
>
MIT License
</a>
</div>
</v-footer>
</template>
<script setup lang="ts">
const items = [
{
title: 'Vuetify Documentation',
icon: `$vuetify`,
href: 'https://vuetifyjs.com/',
},
{
title: 'Vuetify Support',
icon: 'mdi-shield-star-outline',
href: 'https://support.vuetifyjs.com/',
},
{
title: 'Vuetify X',
icon: `svg:M2.04875 3.00002L9.77052 13.3248L1.99998 21.7192H3.74882L10.5519 14.3697L16.0486 21.7192H22L13.8437 10.8137L21.0765 3.00002H19.3277L13.0624 9.76874L8.0001 3.00002H2.04875ZM4.62054 4.28821H7.35461L19.4278 20.4308H16.6937L4.62054 4.28821Z`,
href: 'https://x.com/vuetifyjs',
},
{
title: 'Vuetify GitHub',
icon: `mdi-github`,
href: 'https://github.com/vuetifyjs/vuetify',
},
{
title: 'Vuetify Discord',
icon: `mdi-discord`,
href: 'https://community.vuetifyjs.com/',
},
{
title: 'Vuetify Reddit',
icon: `mdi-reddit`,
href: 'https://reddit.com/r/vuetifyjs',
},
]
</script>
<style scoped lang="sass">
.social-link :deep(.v-icon)
color: rgba(var(--v-theme-on-background), var(--v-disabled-opacity))
text-decoration: none
transition: .2s ease-in-out
&:hover
color: rgba(25, 118, 210, 1)
</style>

View File

@ -0,0 +1,42 @@
<script setup lang="ts">
import { useConfirmationStore } from '@/stores/confirmation';
import { useLocale } from 'vuetify';
const { t } = useLocale();
const $confirmation = useConfirmationStore();
</script>
<template>
<v-dialog persistent :model-value="$confirmation.show" width="450">
<v-card>
<template v-slot:title>
<v-card-title class="p-0 w-full flex items-center">
<span class="mso mr-1 text-2xl">warning</span>
<span>{{ $confirmation.title }}</span>
</v-card-title>
</template>
<v-card-text>{{ $confirmation.message }}</v-card-text>
<!-- Actions -->
<v-card-actions class="flex justify-end p-5 gap-1">
<!-- Cancel -->
<v-btn
variant="text"
class="px-4"
:text="t('$vuetify.actions.cancel')"
@click="$confirmation.doDecline()"
></v-btn>
<!-- Confirm -->
<v-btn
variant="flat"
color="primary"
class="px-4"
:text="t('$vuetify.actions.confirm')"
@click="$confirmation.doConfirm()"
></v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>

View File

@ -0,0 +1,74 @@
<script setup lang="ts">
import { useLocale } from 'vuetify';
defineProps({
error: {
type: String,
default: '',
},
block: {
type: Boolean,
default: false,
},
noBorder: {
type: Boolean,
default: false,
},
});
const { t } = useLocale();
function parseError(e: string): string {
if (e.includes('$vuetify', 0)) {
return e;
}
if (e.includes('validator')) {
const errs = e.split(' || ');
for (const err of errs) {
if (err.includes('validator', 0)) {
const splitted = err.split(':');
if (splitted?.length) {
const right = splitted.pop();
if (right && right.includes('/')) {
const [field, key] = right.split('/');
return t(
`$vuetify.validator.field`,
field.toLowerCase(),
t(`$vuetify.validator.${key.replace(/'/gm, '')}`),
);
}
}
}
}
}
const te = t(`$vuetify.${e}`);
if (!te.includes('$vuetify', 0)) {
return te;
}
return e;
}
</script>
<template>
<span
v-if="error?.length"
class="text-sm text-error flex items-center"
:class="{
'error-block': block,
'my-2': !block,
'border-b': !noBorder,
}"
><span class="mso mr-2 text-2xl">error</span> {{ parseError(error) }}</span
>
</template>
<style scoped>
.error-block {
background-color: rgba(var(--v-theme-error), var(--v-border-opacity));
border-color: rgba(var(--v-theme-error), var(--v-border-opacity));
padding: 0 0.8rem;
}
</style>

View File

@ -0,0 +1,157 @@
<template>
<v-container class="fill-height">
<v-responsive
class="align-centerfill-height mx-auto"
max-width="900"
>
<v-img
class="mb-4"
height="150"
src="@/assets/logo.png"
/>
<div class="text-center">
<div class="text-body-2 font-weight-light mb-n1">Welcome to</div>
<h1 class="text-h2 font-weight-bold">Vuetify</h1>
</div>
<div class="py-4" />
<v-row>
<v-col cols="12">
<v-card
class="py-4"
color="surface-variant"
image="https://cdn.vuetifyjs.com/docs/images/one/create/feature.png"
prepend-icon="mdi-rocket-launch-outline"
rounded="lg"
variant="outlined"
>
<template #image>
<v-img position="top right" />
</template>
<template #title>
<h2 class="text-h5 font-weight-bold">Get started</h2>
</template>
<template #subtitle>
<div class="text-subtitle-1">
Replace this page by removing <v-kbd>{{ `<HelloWorld />` }}</v-kbd> in <v-kbd>pages/index.vue</v-kbd>.
</div>
</template>
<v-overlay
opacity=".12"
scrim="primary"
contained
model-value
persistent
/>
</v-card>
</v-col>
<v-col cols="6">
<v-card
append-icon="mdi-open-in-new"
class="py-4"
color="surface-variant"
href="https://vuetifyjs.com/"
prepend-icon="mdi-text-box-outline"
rel="noopener noreferrer"
rounded="lg"
subtitle="Learn about all things Vuetify in our documentation."
target="_blank"
title="Documentation"
variant="text"
>
<v-overlay
opacity=".06"
scrim="primary"
contained
model-value
persistent
/>
</v-card>
</v-col>
<v-col cols="6">
<v-card
append-icon="mdi-open-in-new"
class="py-4"
color="surface-variant"
href="https://vuetifyjs.com/introduction/why-vuetify/#feature-guides"
prepend-icon="mdi-star-circle-outline"
rel="noopener noreferrer"
rounded="lg"
subtitle="Explore available framework Features."
target="_blank"
title="Features"
variant="text"
>
<v-overlay
opacity=".06"
scrim="primary"
contained
model-value
persistent
/>
</v-card>
</v-col>
<v-col cols="6">
<v-card
append-icon="mdi-open-in-new"
class="py-4"
color="surface-variant"
href="https://vuetifyjs.com/components/all"
prepend-icon="mdi-widgets-outline"
rel="noopener noreferrer"
rounded="lg"
subtitle="Discover components in the API Explorer."
target="_blank"
title="Components"
variant="text"
>
<v-overlay
opacity=".06"
scrim="primary"
contained
model-value
persistent
/>
</v-card>
</v-col>
<v-col cols="6">
<v-card
append-icon="mdi-open-in-new"
class="py-4"
color="surface-variant"
href="https://discord.vuetifyjs.com"
prepend-icon="mdi-account-group-outline"
rel="noopener noreferrer"
rounded="lg"
subtitle="Connect with Vuetify developers."
target="_blank"
title="Community"
variant="text"
>
<v-overlay
opacity=".06"
scrim="primary"
contained
model-value
persistent
/>
</v-card>
</v-col>
</v-row>
</v-responsive>
</v-container>
</template>
<script setup lang="ts">
//
</script>

View File

@ -0,0 +1,23 @@
<script setup lang="ts">
import { computed, type ComputedRef } from 'vue';
const props = defineProps({
theme: {
type: String,
default: 'dark'
}
})
const fill: ComputedRef<string> = computed(() => props.theme === 'dark' ? '#FFF' : '#000')
</script>
<template>
<svg width="81" height="24" viewBox="0 0 81 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="48" y="16.0547" width="32.1081" height="7.94595" :fill="fill"/>
<rect width="32.1081" height="7.94595" :fill="fill"/>
<rect x="40.0547" y="7.94531" width="7.94595" height="8.10811" :fill="fill"/>
<rect x="32.1094" y="7.94531" width="7.94595" height="16.0541" :fill="fill"/>
<rect y="7.94531" width="7.94595" height="16.0541" :fill="fill"/>
<rect x="40.0547" width="40.0541" height="7.94595" :fill="fill"/>
</svg>
</template>

View File

@ -0,0 +1,34 @@
<script setup lang="ts">
import { useLocale } from 'vuetify';
import { useInfoStore } from '@/stores/info';
const { t } = useLocale();
const $info = useInfoStore();
</script>
<template>
<v-dialog persistent :model-value="$info.show" width="450">
<v-card>
<template v-slot:title>
<v-card-title class="p-0 w-full flex items-center">
<span class="mso mr-1 text-2xl">warning</span>
<span>{{ $info.title }}</span>
</v-card-title>
</template>
<v-card-text class="whitespace-pre-line">{{ $info.message }}</v-card-text>
<!-- Actions -->
<v-card-actions class="flex justify-end p-5 gap-1">
<!-- Cancel -->
<v-btn
variant="text"
class="px-4"
:text="t('$vuetify.actions.hide')"
@click="$info.close()"
></v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>

View File

@ -0,0 +1,68 @@
<script lang="ts" setup>
import { useLocale } from 'vuetify';
import { $cookie } from '@/plugins/cookie';
import { onBeforeMount } from 'vue';
import { COOKIE_LOCALE } from '../constants/static';
import Moment from '@/plugins/moment';
const { current } = useLocale();
const languages = [
{
title: 'Русский',
locale: 'ru',
},
{
title: 'English',
locale: 'en',
},
];
const key = COOKIE_LOCALE;
const get = (): string => {
const v = $cookie.get(key);
Moment.$moment.locale(v);
return v;
};
const set = (locale: string): void => {
current.value = locale;
Moment.$moment.locale(locale);
$cookie.set(key, locale);
};
const setLocaleFromExistCookie = (): void => {
const v = get();
if (v?.length && typeof v === 'string') {
set(v);
}
};
onBeforeMount(setLocaleFromExistCookie);
</script>
<template>
<v-menu transition="none" close-delay="0" open-delay="0">
<template v-slot:activator="{ props }">
<v-btn icon density="comfortable">
<span class="mso" v-bind="props">language</span>
</v-btn>
</template>
<v-list density="compact" class="mt-3 accent-modal" elevation="0">
<v-list-item
v-for="(item, i) in languages"
:key="i"
class="cm-mini"
@click="set(item.locale)"
>
<template v-slot:prepend>
<img class="flag mr-2" :src="`/images/flag_${item.locale}.svg`" />
</template>
<v-list-item-title>{{ item.title }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</template>
<style scoped>
.flag {
height: 14px;
border-radius: 4px;
}
</style>

View File

@ -0,0 +1,74 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useLocale } from 'vuetify';
import { ROUTES } from '@/constants/routes';
const props = defineProps({
drawer: Boolean,
});
const emits = defineEmits(['change']);
const { t } = useLocale();
const modelValue = ref<boolean>(props.drawer);
watch(props, (v: any, _) => {
modelValue.value = v.drawer;
});
function change(e: boolean) {
emits('change', e);
}
</script>
<template>
<v-navigation-drawer
v-model="modelValue"
class="pt-3 elevation-0 border-r"
:scrim="false"
close-on-click
@update:modelValue="change"
>
<v-list-item
v-for="(item, i) in ROUTES"
:key="i"
:value="item"
color="primary"
:to="item.value"
@click="change(false)"
>
<template v-slot:prepend>
<span class="mso mr-3">{{ item.icon }}</span>
</template>
<v-list-item-title>{{ t(item.title) }}</v-list-item-title>
<template v-slot:append>
<v-menu
v-if="item.children?.length"
open-on-hover
close-on-click
:open-delay="0"
:close-delay="0"
transition="none"
>
<template v-slot:activator="{ props }">
<span class="mso" v-bind="props">chevron_right</span>
</template>
<v-list class="accent-modal" elevation="0" density="compact">
<v-list-item
v-for="(c, cIndex) in item.children"
:key="cIndex"
:title="t(c.title)"
:to="c.value"
:active="false"
class="cm-mini"
@click="change(false)"
></v-list-item>
</v-list>
</v-menu>
</template>
</v-list-item>
</v-navigation-drawer>
</template>

35
src/components/README.md Normal file
View File

@ -0,0 +1,35 @@
# Components
Vue template files in this folder are automatically imported.
## 🚀 Usage
Importing is handled by [unplugin-vue-components](https://github.com/unplugin/unplugin-vue-components). This plugin automatically imports `.vue` files created in the `src/components` directory, and registers them as global components. This means that you can use any component in your application without having to manually import it.
The following example assumes a component located at `src/components/MyComponent.vue`:
```vue
<template>
<div>
<MyComponent />
</div>
</template>
<script lang="ts" setup>
//
</script>
```
When your template is rendered, the component's import will automatically be inlined, which renders to this:
```vue
<template>
<div>
<MyComponent />
</div>
</template>
<script lang="ts" setup>
import MyComponent from '@/components/MyComponent.vue'
</script>
```

View File

@ -0,0 +1,14 @@
<script>
export default {
name: 'TableLoader',
};
</script>
<template>
<div
class="absolute left-0 top-0 h-full w-full z-[100]"
style="
background-color: rgba(var(--v-theme-surface), var(--v-disabled-opacity));
"
></div>
</template>

View File

@ -0,0 +1,35 @@
<script setup lang="ts">
import { useTheme } from 'vuetify'
import { $cookie } from '@/plugins/cookie'
import { onBeforeMount } from 'vue';
import { COOKIE_THEME } from '../constants/static';
const theme = useTheme()
const key = COOKIE_THEME
const get = (): string => {
return $cookie.get(key);
}
const set = (value: string): void => {
theme.global.name.value = value;
$cookie.set(key, value);
}
const toggle = (): void => {
const t = theme.global.current?.value?.dark ? 'light' : 'dark'
theme.global.name.value = t
$cookie.set(key, t);
}
const setThemeFromExistCookie = (): void => {
const v = get()
if (v?.length && typeof v === 'string') {
set(v)
}
}
onBeforeMount(setThemeFromExistCookie)
</script>
<template>
<v-btn icon density="comfortable" @click="toggle">
<span class="mso">routine</span>
</v-btn>
</template>

11
src/constants/defaults.ts Normal file
View File

@ -0,0 +1,11 @@
export const defaultCursor = JSON.stringify({
count: 10,
currentPage: 1,
totalPages: 1,
totalRows: 10,
});
export const defaultSort = JSON.stringify({
key: 'id',
order: 'asc',
});

View File

@ -0,0 +1,18 @@
// This file must be synced with backend
export enum Permissions {
DASHBOARD_READ = 1,
GROUPS_CREATE = 14,
GROUPS_DELETE = 16,
GROUPS_READ = 13,
GROUPS_UPDATE = 15,
LOGS_READ = 4,
SETTINGS_READ = 2,
SETTINGS_UPDATE = 3,
TASKS_READ = 5,
TASKS_UPDATE = 6,
USERS_BLOCK = 12,
USERS_CREATE = 9,
USERS_DELETE = 11,
USERS_READ = 7,
USERS_UPDATE = 10,
}

44
src/constants/routes.ts Normal file
View File

@ -0,0 +1,44 @@
import { UsersPageTabs } from '../enums/user-tab.enum';
export const ROUTES = [
{
title: '$vuetify.navigation.dashboards',
value: '/',
icon: 'dashboard',
},
{
title: '$vuetify.navigation.tasks',
value: '/tasks',
icon: 'web_traffic',
},
{
title: '$vuetify.navigation.servers',
value: '/servers',
icon: 'dns',
},
{
title: '$vuetify.navigation.users',
value: `/users?tab=${UsersPageTabs.USERS}`,
icon: 'group',
children: [
{
title: '$vuetify.navigation.users',
value: `/users?tab=${UsersPageTabs.USERS}`,
},
{
title: '$vuetify.navigation.groups',
value: `/users?tab=${UsersPageTabs.GROUPS}`,
},
],
},
{
title: '$vuetify.navigation.patterns',
value: '/patterns',
icon: 'layers',
},
{
title: '$vuetify.navigation.logs',
value: '/logs',
icon: 'menu',
},
];

4
src/constants/static.ts Normal file
View File

@ -0,0 +1,4 @@
export const ITEMS_PER_PAGE = [10, 25, 50, 100]
export const COOKIE_LOCALE = 'user_locale'
export const COOKIE_THEME = 'user_theme'

View File

@ -0,0 +1,5 @@
export enum timeUnits {
Seconds,
Minutes,
Hours,
}

View File

@ -0,0 +1,7 @@
export enum UserActions {
EDIT,
BLOCK,
UNBLOCK,
RESET_PASSWORD,
DELETE,
}

View File

@ -0,0 +1,4 @@
export enum UsersPageTabs {
USERS,
GROUPS,
}

5
src/layouts/README.md Normal file
View File

@ -0,0 +1,5 @@
# Layouts
Layouts are reusable components that wrap around pages. They are used to provide a consistent look and feel across multiple pages.
Full documentation for this feature can be found in the Official [vite-plugin-vue-layouts](https://github.com/JohnCampionJr/vite-plugin-vue-layouts) repository.

13
src/layouts/centered.vue Normal file
View File

@ -0,0 +1,13 @@
<script lang="ts" setup>
//
</script>
<template>
<v-app>
<v-main>
<div class="flex flex-col items-center justify-center h-full w-full">
<router-view />
</div>
</v-main>
</v-app>
</template>

42
src/layouts/default.vue Normal file
View File

@ -0,0 +1,42 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useTheme } from 'vuetify';
import NavigationDrawer from '@/components/NavigationDrawer.vue';
import LanguageSwitcher from '@/components/LanguageSwitcher.vue';
import ThemeSwitcher from '@/components/ThemeSwitcher.vue';
import { useAuthStore } from '@/stores/auth';
const $auth = useAuthStore();
const drawer = ref(false);
const theme = useTheme();
const currentTheme = computed(() => theme.global.name.value);
</script>
<template>
<v-app
:style="`background-color: ${currentTheme === 'light' ? '#f0f4f9' : '#1c1c1c'}`"
>
<v-app-bar prominent class="elevation-0 border-b px-2">
<v-app-bar-nav-icon
density="comfortable"
:disabled="!$auth.loggedIn"
@click="drawer = !drawer"
></v-app-bar-nav-icon>
<v-app-bar-title>System Trace</v-app-bar-title>
<v-spacer></v-spacer>
<!-- Theme switcher -->
<ThemeSwitcher class="mr-1" />
<!-- Language switcher -->
<LanguageSwitcher />
</v-app-bar>
<NavigationDrawer :drawer="drawer" @change="(e) => (drawer = e)" />
<v-main>
<router-view></router-view>
</v-main>
</v-app>
</template>

View File

@ -0,0 +1,19 @@
{
"actions": {
"refresh": "Refresh",
"create-group": "Create group",
"create-user": "Create user",
"reset-password": "Reset password",
"delete": "Delete",
"edit": "Edit",
"block": "Block",
"unblock": "Unblock",
"apply": "Apply",
"confirm": "Confirm",
"cancel": "Cancel",
"hide": "Hide",
"start-task": "Start task",
"stop-task": "Stop task",
"save": "Save"
}
}

11
src/locales/en/auth.json Normal file
View File

@ -0,0 +1,11 @@
{
"auth": {
"copyright": "Product of Peresvet LLC",
"title": "Authentication",
"email": "E-Mail address",
"password": "Password",
"login": "Log in",
"help": "Any problems? Contact ",
"help-link": "administrator"
}
}

View File

@ -0,0 +1,12 @@
{
"User with specified E-Mail and password not found": "User with specified email and password was not found",
"validator": {
"field": "Field {0} is not compliant: {1}",
"required": "required",
"min": "min. value",
"max": "max. value",
"password": "password rules",
"email": "E-Mail rules",
"endswith": "invalid value"
}
}

View File

@ -0,0 +1,36 @@
{
"groups": {
"heads": {
"id": "ID",
"name": "Title",
"issuer": "Issuer",
"status": "Status",
"created-at": "Created at",
"updated-at": "Last update",
"permissions": "Permissions"
},
"statuses": {
"active": "Active",
"deleted": "Deleted"
},
"creation": {
"create": "New group",
"edit": "Edit group",
"fields": {
"ID": "ID of group",
"name": "Name of group",
"permissions": "Permissions of users in group"
}
},
"delete": {
"delete-one": {
"title": "Delete group",
"message": "Are you sure you want to delete group with ID {0}? This action cannot be undone"
},
"delete-many": {
"title": "Delete groups",
"message": "Are you sure you want to delete {0} groups? This action cannot be undone"
}
}
}
}

19
src/locales/en/index.ts Normal file
View File

@ -0,0 +1,19 @@
import errors from './errors.json';
import navigation from './navigation.json';
import users from './users.json';
import groups from './groups.json';
import actions from './actions.json';
import auth from './auth.json';
import misc from './misc.json';
import permissions from './permissions.json';
export default {
...errors,
...navigation,
...users,
...groups,
...actions,
...auth,
...misc,
...permissions,
};

6
src/locales/en/misc.json Normal file
View File

@ -0,0 +1,6 @@
{
"misc": {
"actions": "Actions",
"selected": "Selected: {0}"
}
}

View File

@ -0,0 +1,11 @@
{
"navigation": {
"dashboards": "Dashboards",
"tasks": "Tasks",
"servers": "Servers",
"users": "Users",
"groups": "Groups",
"patterns": "Patterns",
"logs": "Logs"
}
}

View File

@ -0,0 +1,19 @@
{
"permissions": {
"1": "Access to dashboards",
"2": "Read settings",
"3": "Editing settings",
"4": "Reading logs",
"5": "Reading tasks",
"6": "Editing tasks",
"7": "Read users",
"8": "Creating users",
"9": "Editing users",
"10": "Deleting users",
"11": "Blocking users",
"12": "Read groups",
"13": "Creating groups",
"14": "Editing groups",
"15": "Delete groups"
}
}

54
src/locales/en/users.json Normal file
View File

@ -0,0 +1,54 @@
{
"users": {
"title": "Users",
"search": "Search",
"selected-group": "Search by group: ID {0}",
"tabs": {
"users": "Users",
"groups": "Groups"
},
"heads": {
"id": "ID",
"email": "E-Mail",
"group": "Group",
"real-name": "Name",
"password": "Password",
"status": "Status",
"last-visit": "Last visit",
"updated-at": "Last update",
"created-at": "Created at"
},
"statuses": {
"active": "Active",
"blocked": "Blocked",
"deleted": "Deleted",
"set": "Set",
"unset": "Not set"
},
"creation": {
"create": "New user",
"edit": "Edit user",
"fields": {
"ID": "ID of user",
"email": "E-Mail address",
"name": "Real name",
"group": "Group",
"no-group": "Not in group"
}
},
"delete": {
"delete-one": {
"title": "Delete user",
"message": "Are you sure you want to delete user with ID {0}? This action cannot be undone"
},
"delete-many": {
"title": "Delete users",
"message": "Are you sure you want to delete {0} users? This action cannot be undone"
}
},
"new-credentials": {
"title": "Login details",
"message": "E-Mail address: {0}\nPassword: {1}\n\nRemember or write down this data, it will be impossible to find it out again"
}
}
}

View File

@ -0,0 +1,19 @@
{
"actions": {
"refresh": "Обновить",
"create-group": "Создать группу",
"create-user": "Создать пользователя",
"reset-password": "Сбросить пароль",
"delete": "Удалить",
"edit": "Редактировать",
"block": "Заблокировать",
"unblock": "Разблокировать",
"apply": "Применить",
"confirm": "Подтвердить",
"cancel": "Отменить",
"hide": "Скрыть",
"start-task": "Запустить задачу",
"stop-task": "Остановить задачу",
"save": "Сохранить"
}
}

11
src/locales/ru/auth.json Normal file
View File

@ -0,0 +1,11 @@
{
"auth": {
"copyright": "Продукт компании Пересвет",
"title": "Авторизация",
"email": "Адрес эл. почты",
"password": "Пароль",
"login": "Войти",
"help": "Проблемы? Сообщите ",
"help-link": "администратору"
}
}

View File

@ -0,0 +1,12 @@
{
"User with specified E-Mail and password not found": "Пользователь с указанными E-Mail и паролем не найден",
"validator": {
"field": "Поле {0} не соответствует требованиям: {1}",
"required": "обязательное поле",
"min": "мин. значение",
"max": "макс. значение",
"password": "требования к паролю",
"email": "требования к E-Mail",
"endswith": "недействительное значение"
}
}

View File

@ -0,0 +1,36 @@
{
"groups": {
"heads": {
"id": "ID",
"name": "Название",
"issuer": "Издатель",
"status": "Статус",
"created-at": "Создана",
"updated-at": "Посл. изменения",
"permissions": "Права"
},
"statuses": {
"active": "Активна",
"deleted": "Удалена"
},
"creation": {
"create": "Новая группа",
"edit": "Редактирование группы",
"fields": {
"ID": "ID редактируемой группы",
"name": "Название группы",
"permissions": "Права пользователей в группе"
}
},
"delete": {
"delete-one": {
"title": "Удаление группы",
"message": "Вы уверены, что хотите удалить группу с ID {0}? Данное действие нельзя будет отменить"
},
"delete-many": {
"title": "Удаление групп",
"message": "Вы уверены, что хотите удалить {0} групп? Данное действие нельзя будет отменить"
}
}
}
}

19
src/locales/ru/index.ts Normal file
View File

@ -0,0 +1,19 @@
import errors from './errors.json';
import navigation from './navigation.json';
import users from './users.json';
import groups from './groups.json';
import actions from './actions.json';
import auth from './auth.json';
import misc from './misc.json';
import permissions from './permissions.json';
export default {
...errors,
...navigation,
...users,
...groups,
...actions,
...auth,
...misc,
...permissions,
};

6
src/locales/ru/misc.json Normal file
View File

@ -0,0 +1,6 @@
{
"misc": {
"actions": "Действия",
"selected": "Выбрано: {0}"
}
}

View File

@ -0,0 +1,11 @@
{
"navigation": {
"dashboards": "Дашборды",
"tasks": "Задачи",
"servers": "Серверы",
"users": "Пользователи",
"groups": "Группы",
"patterns": "Паттерны",
"logs": "Логи"
}
}

View File

@ -0,0 +1,19 @@
{
"permissions": {
"1": "Доступ к дашбордам",
"2": "Чтение настроек",
"3": "Редактирование настроек",
"4": "Чтение логов",
"5": "Чтение задач",
"6": "Редактирование задач",
"7": "Чтение пользователей",
"8": "Создание пользователей",
"9": "Редактирование пользователей",
"10": "Удаление пользователей",
"11": "Блокировка пользователей",
"12": "Чтение групп",
"13": "Создание групп",
"14": "Редактирование групп",
"15": "Удаление групп"
}
}

View File

@ -0,0 +1,9 @@
{
"tasks": {
"title": "Задачи",
"new-task": {
"title": "Новая задача",
"parameters": "Параметры задачи"
}
}
}

54
src/locales/ru/users.json Normal file
View File

@ -0,0 +1,54 @@
{
"users": {
"title": "Пользователи",
"search": "Поиск",
"selected-group": "Поиск по группе: ID {0}",
"tabs": {
"users": "Пользователи",
"groups": "Группы"
},
"heads": {
"id": "ID",
"email": "E-Mail",
"group": "Группа",
"real-name": "Имя",
"password": "Пароль",
"status": "Статус",
"last-visit": "Посл. вход",
"updated-at": "Посл. изменения",
"created-at": "Создан"
},
"statuses": {
"active": "Активный",
"blocked": "Заблокирован",
"deleted": "Удалён",
"set": "Установлен",
"unset": "Не установлен"
},
"creation": {
"create": "Новый пользователь",
"edit": "Редактирование пользователя",
"fields": {
"ID": "ID редактируемого пользователя",
"email": "Адрес эл. почты",
"name": "Имя пользователя",
"group": "Группа пользователя",
"no-group": "Без группы"
}
},
"delete": {
"delete-one": {
"title": "Удаление пользователя",
"message": "Вы уверены, что хотите удалить пользователя с ID {0}? Данное действие нельзя будет отменить"
},
"delete-many": {
"title": "Удаление пользователей",
"message": "Вы уверены, что хотите удалить {0} пользователей? Данное действие нельзя будет отменить"
}
},
"new-credentials": {
"title": "Данные для входа",
"message": "Адрес эл. почты: {0}\nПароль: {1}\n\nЗапомните или запишите эти данные, повторно узнать их будет невозможно"
}
}
}

23
src/main.ts Normal file
View File

@ -0,0 +1,23 @@
/**
* main.ts
*
* Bootstraps Vuetify and other plugins then mounts the App`
*/
// Plugins
import { registerPlugins } from '@/plugins'
// Components
import App from './App.vue'
// Styles
import './assets/styles/core.scss'
// Composables
import { createApp } from 'vue'
const app = createApp(App)
registerPlugins(app)
app.mount('#app')

View File

@ -0,0 +1,3 @@
export function camelToSnake(str: string): string {
return str.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase();
}

12
src/mixins/format.ts Normal file
View File

@ -0,0 +1,12 @@
export function secondsToHms(seconds: any): string {
const d = Math.floor(seconds / (3600 * 24));
const h = Math.floor((seconds % (3600 * 24)) / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
const dDisplay = d > 0 ? d + 'd ' : '';
const hDisplay = h > 0 ? h + 'h ' : '';
const mDisplay = m > 0 ? m + 'm ' : '';
const sDisplay = s > 0 ? s + 's ' : '';
return dDisplay + hDisplay + mDisplay + sDisplay;
}

View File

@ -0,0 +1,7 @@
export function isDeleted(date: string): boolean {
return new Date(date).getFullYear() > 1;
}
export function isModifiedDate(date: string): boolean {
return isDeleted(date);
}

View File

@ -0,0 +1,13 @@
export function setURIParams(params: URLSearchParams, remove = false): void {
const url = new URL(window.location.href);
params.forEach((value: string, key: string) => {
if (!remove) {
url.searchParams.set(key, value);
} else {
url.searchParams.delete(key);
}
});
window.history.replaceState(null, '', url);
}

17
src/mixins/validator.ts Normal file
View File

@ -0,0 +1,17 @@
export function validEmail(s?: string): boolean {
return (
s !== undefined &&
s.trim()?.length > 0 &&
/^(([^<>()[\]\.,;:\s@\"]+(\.[^<>()[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i.test(
s,
)
);
}
export function validName(s?: string): boolean {
return (
s !== undefined &&
s.trim()?.length > 0 &&
!/[^a-zA-Zа-яА-Я0-9]+$/gim.test(s)
);
}

5
src/pages/README.md Normal file
View File

@ -0,0 +1,5 @@
# Pages
Vue components created in this folder will automatically be converted to navigatable routes.
Full documentation for this feature can be found in the Official [unplugin-vue-router](https://github.com/posva/unplugin-vue-router) repository.

89
src/pages/auth.vue Normal file
View File

@ -0,0 +1,89 @@
<script lang="ts" setup>
import { computed } from 'vue';
import ErrorPlate from '@/components/ErrorPlate.vue';
import LogoComponent from '@/components/Icons/LogoComponent.vue';
import { useAuthStore } from '@/stores/auth';
import { useLocale, useTheme } from 'vuetify';
const { t } = useLocale();
const theme = useTheme();
const currentTheme = computed(() => theme.global.name.value);
const $auth = useAuthStore();
</script>
<template>
<div class="flex relative flex-col items-center justify-center h-full">
<!-- Card -->
<v-card width="800px" class="p-12 rounded-3xl" elevation="0">
<!-- Logo -->
<LogoComponent :theme="currentTheme" />
<div class="grid grid-cols-5 mt-8">
<!-- Left side -->
<div class="items-start flex flex-col col-span-2">
<span class="text-3xl font-medium">{{
t('$vuetify.auth.title')
}}</span>
<!-- Copyright -->
<span class="text-sm mt-2 opacity-75">{{
t('$vuetify.auth.copyright')
}}</span>
</div>
<!-- Right side -->
<div class="flex flex-col col-span-3">
<!-- Login -->
<v-text-field
v-model="$auth.form.email"
:label="t('$vuetify.auth.email')"
:variant="currentTheme === 'dark' ? 'solo-filled' : 'outlined'"
flat
hide-details
rounded
density="comfortable"
/>
<!-- Password -->
<v-text-field
v-model="$auth.form.password"
:label="t('$vuetify.auth.password')"
:variant="currentTheme === 'dark' ? 'solo-filled' : 'outlined'"
hide-details
flat
rounded
density="comfortable"
class="mt-2"
/>
<!-- Error -->
<ErrorPlate :error="$auth.err" no-border />
<!-- Help -->
<span class="mt-5 flex items-center gap-1"
>{{ t('$vuetify.auth.help') }}
<a
href="#"
class="flex items-center gap-1 text-nowrap text-primary"
>
<span class="font-bold">{{ t('$vuetify.auth.help-link') }}</span>
<span class="mso text-sm">open_in_new</span>
</a>
</span>
<!-- Buttons -->
<v-card-actions class="flex justify-end px-0 mt-8">
<v-btn
:variant="currentTheme === 'dark' ? 'flat' : 'flat'"
color="primary"
class="px-8 rounded-3xl"
@click="$auth.doAuth()"
>
<i class="mi-log-in"></i>
<span>{{ t('$vuetify.auth.login') }}</span>
</v-btn>
</v-card-actions>
</div>
</div>
</v-card>
</div>
</template>
<!-- <route lang="yaml">
meta:
layout: centered
</route> -->

7
src/pages/index.vue Normal file
View File

@ -0,0 +1,7 @@
<template>
<HelloWorld />
</template>
<script lang="ts" setup>
//
</script>

View File

@ -0,0 +1,16 @@
<script lang="ts" setup>
import { useLocale } from 'vuetify';
// Common variables
const { t } = useLocale();
</script>
<template>
<div>
<v-expansion-panel
:title="t('$vuetify.')"
text="Lorem ipsum dolor sit amet consectetur adipisicing elit. Commodi, ratione debitis quis est labore voluptatibus! Eaque cupiditate minima"
>
</v-expansion-panel>
</div>
</template>

View File

@ -0,0 +1,140 @@
<script lang="ts" setup>
import { Line as LineChart } from 'vue-chartjs';
import {
Chart as ChartJS,
Title,
Tooltip,
Legend,
LineElement,
PointElement,
CategoryScale,
LinearScale,
} from 'chart.js';
import { ref } from 'vue';
import { useTasksStore } from '@/stores/tasks';
import { timeUnits } from '@/enums/time-units.enum';
const unitsArray = Object.values(timeUnits);
ChartJS.register(
Title,
Tooltip,
Legend,
LineElement,
PointElement,
CategoryScale,
LinearScale,
);
const $tasks = useTasksStore();
const result = ref<string | null>(null);
</script>
<template>
<v-container>
<v-card>
<v-card-title> Interval Form </v-card-title>
<v-card-text>
<v-text-field
v-model.number="$tasks.duration"
label="Duration (seconds)"
type="number"
:rules="[(v) => v > 0 || 'Must be greater than zero']"
required
></v-text-field>
<v-text-field
v-model.number="$tasks.limit"
label="Limit"
type="number"
:rules="[(v) => v > 0 || 'Must be greater than zero']"
required
></v-text-field>
<v-btn @click="$tasks.addInterval">Add Interval</v-btn>
<div v-for="(interval, index) in $tasks.intervals" :key="index">
<v-row>
<v-col cols="3">
<v-text-field
v-model.number="interval.start"
label="Start Time"
type="number"
:rules="[
(v) => v >= 0 || 'Must be positive',
(v) =>
$tasks.validateStartTime(v, interval.startUnit, index) ||
'Start time must be valid and within duration',
]"
required
></v-text-field>
<v-select
v-model="interval.startUnit"
:items="unitsArray"
label="Unit"
required
></v-select>
</v-col>
<v-col cols="3">
<v-text-field
v-model.number="interval.end"
label="End Time"
type="number"
:rules="[
(v) =>
$tasks.validateEndTime(v, interval.endUnit, index) ||
'End time must be valid and within duration',
]"
required
></v-text-field>
<v-select
v-model="interval.endUnit"
:items="unitsArray"
label="Unit"
required
></v-select>
</v-col>
<v-col cols="3">
<v-text-field
v-model.number="interval.value"
label="Value"
type="number"
:rules="[
(v) =>
v <= $tasks.limit ||
`Value must be less than or equal to ${$tasks.limit}`,
(v) => v >= 0 || 'Must be greater than or equal to 0',
]"
required
></v-text-field>
</v-col>
<v-col cols="1">
<v-btn icon @click="$tasks.removeInterval(index)">
<v-icon>mdi-delete</v-icon>
</v-btn>
</v-col>
</v-row>
</div>
</v-card-text>
<v-card-actions>
<v-btn type="submit">Submit</v-btn>
</v-card-actions>
</v-card>
<v-card v-if="result">
<v-card-title> Result </v-card-title>
<v-card-text>
<pre>{{ result }}</pre>
</v-card-text>
</v-card>
<v-card v-if="$tasks.chartData.labels.length">
<v-card-title> Graph </v-card-title>
<v-card-text>
<LineChart :data="$tasks.chartData" :options="$tasks.chartOptions" />
</v-card-text>
</v-card>
</v-container>
</template>
<style scoped>
.v-card {
margin-top: 20px;
}
</style>

View File

@ -0,0 +1,15 @@
<script setup lang="ts">
import { useLocale } from 'vuetify';
import TweaksConstructor from './components/TweaksConstructor.vue';
// Common variables
const { t } = useLocale();
</script>
<template>
<div>
<TweaksConstructor />
<!-- -->
</div>
</template>

46
src/pages/tasks/index.vue Normal file
View File

@ -0,0 +1,46 @@
<script setup lang="ts">
import { useLocale } from 'vuetify';
// Common variables
const { t } = useLocale();
</script>
<template>
<div>
<!-- Header -->
<v-app-bar prominent class="elevation-0 border-b">
<!-- Title -->
<span class="text-h6 ml-5 mr-10">{{ t('$vuetify.users.title') }}</span>
<!-- Create group/user -->
<v-btn
color="primary"
variant="text"
class="mr-3 px-3"
@click="openCreationModal()"
>
<span class="mso mr-1 text-xl">{{
tab === UsersPageTabs.USERS ? 'person_add' : 'group_add'
}}</span>
<span>{{
t(
tab === UsersPageTabs.USERS
? '$vuetify.actions.create-user'
: '$vuetify.actions.create-group',
)
}}</span>
</v-btn>
<!-- User creation modal -->
<AddUser v-model="$users.createModal" />
<!-- Group creation modal -->
<AddGroup v-model="$groups.createModal" />
<!-- Refresh users -->
<v-btn class="px-3" color="primary" variant="text" @click="getAll()">
<span class="mso mr-1 text-xl">refresh</span>
<span>{{ t('$vuetify.actions.refresh') }}</span>
</v-btn>
</v-app-bar>
</div>
</template>

View File

@ -0,0 +1,134 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useLocale, useTheme } from 'vuetify';
import { usePermissionsStore } from '@/stores/permissions';
import { useGroupsStore } from '@/stores/groups';
import { validName } from '@/mixins/validator';
defineProps<{
modelValue: boolean;
}>();
const $permissions = usePermissionsStore();
const $groups = useGroupsStore();
const { t } = useLocale();
const theme = useTheme();
const currentTheme = computed(() => theme.global.name.value);
const items = computed(() =>
Object.values($permissions.all).map((v) => ({
title: t(`$vuetify.permissions.${v}`),
value: v,
})),
);
const validForm: ComputedRef<boolean> = computed(
() =>
!(
!$groups.form.name ||
!$groups.form.permissions ||
!validName($groups.form.name)
),
);
const onConfirm = () => {
$groups.isEditing ? $groups.editGroup() : $groups.createGroup();
};
</script>
<template>
<v-dialog persistent :model-value="modelValue" width="450">
<v-card>
<template v-slot:title>
<v-card-title class="p-0 w-full flex items-center">
<span class="mso mr-1 text-2xl">{{
$groups.isEditing ? 'group' : 'group_add'
}}</span>
<span>{{
t(
`$vuetify.groups.creation.${$groups.isEditing ? 'edit' : 'create'}`,
)
}}</span>
</v-card-title>
</template>
<!-- Form -->
<div class="flex flex-col w-full px-5">
<!-- ID of group -->
<v-text-field
v-if="$groups.isEditing"
v-model="$groups.form.ID"
readonly
:label="t('$vuetify.groups.creation.fields.ID')"
:variant="currentTheme === 'dark' ? 'solo-filled' : 'outlined'"
hide-details
flat
density="comfortable"
class="mb-3"
/>
<!-- Name of group -->
<v-text-field
v-model="$groups.form.name"
:label="t('$vuetify.groups.creation.fields.name')"
:variant="currentTheme === 'dark' ? 'solo-filled' : 'outlined'"
hide-details
flat
density="comfortable"
/>
<!-- Group permissions -->
<v-select
v-model="$groups.form.permissions"
:items="items"
:label="t('$vuetify.groups.creation.fields.permissions')"
:variant="currentTheme === 'dark' ? 'solo-filled' : 'outlined'"
class="mt-3"
multiple
hide-details
flat
density="comfortable"
>
<template v-slot:selection="{ item, index }">
<v-chip v-if="index < 2">
<span>{{ item.title }}</span>
</v-chip>
<span
v-if="index === 2"
class="text-grey text-caption align-self-center"
>
(+{{ ($groups.form.permissions?.length ?? 0) - 2 }})
</span>
</template>
</v-select>
<!-- Error -->
<ErrorPlate :error="$groups.errorCreate" no-border />
</div>
<!-- Actions -->
<v-card-actions class="flex justify-end p-5 gap-1">
<!-- Cancel -->
<v-btn
variant="text"
class="px-4"
:text="t('$vuetify.actions.cancel')"
@click="$groups.closeModal()"
></v-btn>
<!-- Confirm -->
<v-btn
variant="flat"
color="primary"
class="px-4"
:text="t('$vuetify.actions.apply')"
:loading="$groups.loadingCreate"
:disabled="!validForm"
:class="{
'disabled-button': !validForm,
}"
@click="onConfirm()"
></v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>

View File

@ -0,0 +1,150 @@
<script setup lang="ts">
import { useLocale, useTheme } from 'vuetify';
import { useUsersStore } from '@/stores/users';
import { validEmail, validName } from '@/mixins/validator';
import { useGroupsStore } from '@/stores/groups';
import { useInfoStore } from '@/stores/info';
defineProps<{
modelValue: boolean;
}>();
const $users = useUsersStore();
const $groups = useGroupsStore();
const $info = useInfoStore();
const { t } = useLocale();
const theme = useTheme();
const currentTheme = computed(() => theme.global.name.value);
const groupItems = computed(() => {
const d = Object.values($groups.all).map((v) => ({
title: v.name,
value: v.ID,
}));
d.unshift({
title: t('$vuetify.users.creation.fields.no-group'),
value: -1,
});
return d;
});
const validForm: ComputedRef<boolean> = computed(
() =>
!(
!$users.form.email ||
!$users.form.groupID ||
!$users.form.realName ||
!validEmail($users.form.email) ||
!validName($users.form.realName)
),
);
const onConfirm = async () => {
const res = await ($users.isEditing
? $users.editUser()
: $users.createUser());
if (res) {
console.log(res);
$info.setInfoMessage(
t('$vuetify.users.new-credentials.title'),
t('$vuetify.users.new-credentials.message', res.email, res.password),
);
}
};
</script>
<template>
<v-dialog persistent :model-value="modelValue" width="450">
<v-card>
<template v-slot:title>
<v-card-title class="p-0 w-full flex items-center">
<span class="mso mr-1 text-2xl">{{
$users.isEditing ? 'person' : 'person_add'
}}</span>
<span>{{
t(`$vuetify.users.creation.${$users.isEditing ? 'edit' : 'create'}`)
}}</span>
</v-card-title>
</template>
<!-- Form -->
<div class="flex flex-col w-full px-5">
<!-- ID of user -->
<v-text-field
v-if="$users.isEditing"
v-model="$users.form.ID"
readonly
:label="t('$vuetify.users.creation.fields.ID')"
:variant="currentTheme === 'dark' ? 'solo-filled' : 'outlined'"
hide-details
flat
density="comfortable"
class="mb-3"
/>
<!-- User email -->
<v-text-field
v-model="$users.form.email"
:label="t('$vuetify.users.creation.fields.email')"
:variant="currentTheme === 'dark' ? 'solo-filled' : 'outlined'"
hide-details
flat
density="comfortable"
/>
<!-- Name of user -->
<v-text-field
v-model="$users.form.realName"
:label="t('$vuetify.users.creation.fields.name')"
:variant="currentTheme === 'dark' ? 'solo-filled' : 'outlined'"
hide-details
class="mt-3"
flat
density="comfortable"
/>
<!-- User group -->
<v-autocomplete
v-model="$users.form.groupID"
:items="groupItems"
:label="t('$vuetify.users.creation.fields.group')"
:variant="currentTheme === 'dark' ? 'solo-filled' : 'outlined'"
class="mt-3"
hide-details
flat
density="comfortable"
>
<template v-slot:selection="{ item }">
<span>{{ item.title }}</span>
</template>
</v-autocomplete>
<!-- Error -->
<ErrorPlate :error="$users.errorCreate" no-border />
</div>
<!-- Actions -->
<v-card-actions class="flex justify-end p-5 gap-1">
<!-- Cancel -->
<v-btn
variant="text"
class="px-4"
:text="t('$vuetify.actions.cancel')"
@click="$users.closeModal()"
></v-btn>
<!-- Confirm -->
<v-btn
variant="flat"
color="primary"
class="px-4"
:text="t('$vuetify.actions.apply')"
:loading="$groups.loadingCreate"
:disabled="!validForm"
:class="{
'disabled-button': !validForm,
}"
@click="onConfirm()"
></v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>

View File

@ -0,0 +1,173 @@
<script setup lang="ts">
import { computed, watch, onBeforeMount, type ComputedRef } from 'vue';
import { useLocale } from 'vuetify';
import { ITEMS_PER_PAGE } from '@/constants/static';
import { useGroupsStore } from '@/stores/groups';
import { isDeleted, isModifiedDate } from '@/mixins/item-validation';
import { Group } from '@/types/group';
import Moment from '@/plugins/moment';
import { useConfirmationStore } from '@/stores/confirmation';
defineProps<{
tableHeight: number;
}>();
const { t } = useLocale();
const $groups = useGroupsStore();
const $confirmation = useConfirmationStore();
const headers: ComputedRef<any[]> = computed(() => [
{ title: t('$vuetify.groups.heads.id'), key: 'ID' },
{ title: t('$vuetify.groups.heads.name'), key: 'name' },
{ title: t('$vuetify.groups.heads.issuer'), key: 'issuer' },
{ title: t('$vuetify.groups.heads.status'), key: 'status' },
{ title: t('$vuetify.groups.heads.created-at'), key: 'createdAt' },
{ title: t('$vuetify.groups.heads.updated-at'), key: 'updatedAt' },
{
title: t('$vuetify.misc.actions'),
key: 'actions',
sortable: false,
align: 'center',
},
]);
// Actions
const editGroup = (g: Group) => {
$groups.openModal();
$groups.startEditGroup(g);
};
const deleteGroup = (g: Group) => {
$confirmation.openModal();
$confirmation.setTitle(t('$vuetify.groups.delete.delete-one.title'));
$confirmation.setMessage(
t('$vuetify.groups.delete.delete-one.message', [g.ID]),
);
$confirmation.setOnConfirm(() => $groups.deleteGroup(g.ID));
};
// Status
const calculateStatus = (group: Group) => {
return isDeleted(group.deletedAt)
? t('$vuetify.groups.statuses.deleted')
: t('$vuetify.groups.statuses.active');
};
const calculateStatusColor = (group: Group) => {
return isDeleted(group.deletedAt) ? 'text-error' : '';
};
onBeforeMount($groups.getAll);
watch(() => [$groups.page, $groups.perPage, $groups.orderBy], $groups.getAll);
watch(
() => [$groups.search],
() => $groups.searchWithCooldown(1 * 1000),
);
</script>
<template>
<v-data-table-server
v-model="$groups.selected"
:search="$groups.search"
:header-props="{ nowrap: true }"
:headers="headers"
:items="$groups.all"
:items-per-page-options="ITEMS_PER_PAGE"
:items-per-page="$groups.perPage"
:items-length="$groups.cursor?.totalRows"
:loading="$groups.loading"
:height="tableHeight - 62"
item-value="ID"
fixed-footer
fixed-header
show-select
checkbox-color="primary"
hover
density="compact"
style="--fixed: 10"
@update:sort-by="$groups.setSort"
@update:page="$groups.setPage"
@update:itemsPerPage="$groups.setPerPage"
>
<!-- Group name -->
<template v-slot:item.name="{ item }">
<template v-if="item.name?.length">
<a
:href="`?group_id=${item.ID}`"
target="_blank"
class="flex items-center gap-1 text-nowrap text-primary"
>
<span class="font-bold">{{ item.name }}</span>
<span class="mso text-sm">open_in_new</span>
</a>
</template>
<span v-else class="text-nowrap"></span>
</template>
<!-- Group issuer -->
<template v-slot:item.issuer="{ item }">
<template v-if="item.issuer">
<a
:href="`?input=${item.issuer.ID}`"
target="_blank"
class="flex items-center gap-1 text-nowrap text-primary"
>
<span class="font-bold">{{
`${item.issuer.email} (ID: ${item.issuer.ID})`
}}</span>
<span class="mso text-sm">open_in_new</span>
</a>
</template>
<span v-else class="text-nowrap"></span>
</template>
<!-- Group status -->
<template v-slot:item.status="{ item }">
<span :class="calculateStatusColor(item)">
{{ calculateStatus(item) }}
</span>
</template>
<!-- Created at -->
<template v-slot:item.createdAt="{ item }">
<template v-if="isModifiedDate(item.createdAt)">
<span class="text-nowrap">
{{ Moment.$moment(item.createdAt).format('DD MMM YYYY HH:mm') }}
</span>
</template>
<span v-else class="text-nowrap"></span>
</template>
<!-- Updated at -->
<template v-slot:item.updatedAt="{ item }">
<template v-if="isModifiedDate(item.updatedAt)">
<span class="text-nowrap">
{{ Moment.$moment(item.updatedAt).format('DD MMM YYYY HH:mm') }}
</span>
</template>
<span v-else class="text-nowrap"></span>
</template>
<!-- Actions -->
<template v-slot:item.actions="{ item }">
<v-btn
icon
size="small"
variant="text"
density="comfortable"
class="mr-1"
:disabled="isDeleted(item.deletedAt)"
:loading="$groups.loadingActionsID.includes(item.ID)"
@click="editGroup(item)"
>
<span class="mso text-xl">edit</span>
</v-btn>
<!-- Delete -->
<v-btn
icon
size="small"
variant="text"
density="comfortable"
:disabled="isDeleted(item.deletedAt)"
:loading="$groups.loadingActionsID.includes(item.ID)"
@click="deleteGroup(item)"
>
<span class="mso text-xl">close</span>
</v-btn>
</template>
</v-data-table-server>
</template>

View File

@ -0,0 +1,259 @@
<script setup lang="ts">
import { computed, watch, onBeforeMount, type ComputedRef } from 'vue';
import { useLocale } from 'vuetify';
import { ITEMS_PER_PAGE } from '@/constants/static';
import { useUsersStore } from '@/stores/users';
import { UserActions } from '@/enums/user-actions.enum';
import { User } from '@/types/user';
import Moment from '@/plugins/moment';
import { isDeleted, isModifiedDate } from '@/mixins/item-validation';
import { useConfirmationStore } from '@/stores/confirmation';
import { useInfoStore } from '@/stores/info';
defineProps<{
tableHeight: number;
}>();
const { t } = useLocale();
const $users = useUsersStore();
const $confirmation = useConfirmationStore();
const $info = useInfoStore();
const headers: ComputedRef<any[]> = computed(() => [
{ title: t('$vuetify.users.heads.id'), key: 'ID' },
{ title: t('$vuetify.users.heads.email'), key: 'email' },
{ title: t('$vuetify.users.heads.group'), key: 'group' },
{ title: t('$vuetify.users.heads.real-name'), key: 'realName' },
{ title: t('$vuetify.users.heads.password'), key: 'isRequiredToSetPassword' },
{ title: t('$vuetify.users.heads.status'), key: 'isActive' },
{ title: t('$vuetify.users.heads.last-visit'), key: 'lastLogin' },
{ title: t('$vuetify.users.heads.created-at'), key: 'createdAt' },
{ title: t('$vuetify.users.heads.updated-at'), key: 'updatedAt' },
{
title: t('$vuetify.misc.actions'),
key: 'actions',
sortable: false,
align: 'center',
},
]);
const actions = computed(() => [
{
title: t('$vuetify.actions.edit'),
value: UserActions.EDIT,
blocked: false,
onClick: (u: User) => editUser(u),
},
{
title: t('$vuetify.actions.block'),
value: UserActions.BLOCK,
blocked: false,
onClick: (u: User) => $users.blockMany([u.ID]),
},
{
title: t('$vuetify.actions.reset-password'),
value: UserActions.RESET_PASSWORD,
blocked: false,
onClick: (u: User) => resetPassword(u),
},
{
title: t('$vuetify.actions.unblock'),
value: UserActions.UNBLOCK,
blocked: true,
onClick: (u: User) => $users.unblockMany([u.ID]),
},
]);
// Actions
const editUser = (u: User) => {
$users.openModal();
$users.startEditUser(u);
};
const deleteUser = (u: User) => {
$confirmation.openModal();
$confirmation.setTitle(t('$vuetify.users.delete.delete-one.title'));
$confirmation.setMessage(
t('$vuetify.users.delete.delete-one.message', [u.ID]),
);
$confirmation.setOnConfirm(() => $users.deleteUser(u.ID));
};
const resetPassword = async (u: User) => {
const res = await $users.resetPassword(u.ID);
if (res) {
console.log(res);
$info.setInfoMessage(
t('$vuetify.users.new-credentials.title'),
t('$vuetify.users.new-credentials.message', res.email, res.password),
);
}
};
// Status
const calculateUserStatus = (user: User) => {
return isDeleted(user.deletedAt)
? t('$vuetify.users.statuses.deleted')
: user.isActive
? t('$vuetify.users.statuses.active')
: t('$vuetify.users.statuses.blocked');
};
const calculateStatusColor = (user: User) => {
return isDeleted(user.deletedAt)
? 'text-error'
: user.isActive
? ''
: 'text-warning';
};
// Password status
const calculateUserPasswordStatus = (user: User) => {
return user.isRequiredToSetPassword
? t('$vuetify.users.statuses.unset')
: t('$vuetify.users.statuses.set');
};
onBeforeMount($users.getAll);
watch(
() => [$users.page, $users.perPage, $users.orderBy, $users.groupId],
$users.getAll,
);
watch(
() => [$users.search],
() => $users.searchWithCooldown(1 * 1000),
);
</script>
<template>
<v-data-table-server
v-model="$users.selected"
:search="$users.search"
:header-props="{ nowrap: true }"
:headers="headers"
:items="$users.all"
:items-per-page-options="ITEMS_PER_PAGE"
:items-per-page="$users.perPage"
:items-length="$users.cursor?.totalRows"
:loading="$users.loading"
:height="tableHeight - 62"
item-value="ID"
fixed-footer
fixed-header
show-select
hover
density="compact"
style="--fixed: 10"
@update:sort-by="$users.setSort"
@update:page="$users.setPage"
@update:itemsPerPage="$users.setPerPage"
>
<!-- Group -->
<template v-slot:item.group="{ item }">
<template v-if="item.group">
<a
:href="`?group_id=${item.group.ID}`"
target="_blank"
class="flex items-center gap-1 text-nowrap text-primary"
>
<span class="font-bold">{{ item.group.name }}</span>
<span class="mso text-sm">open_in_new</span>
</a>
</template>
<span v-else class="text-nowrap"></span>
</template>
<!-- Real name -->
<template v-slot:item.realName="{ item }">
<span class="text-nowrap">
{{ item.realName?.length ? item.realName : '—' }}
</span>
</template>
<!-- Required to set password -->
<template v-slot:item.isRequiredToSetPassword="{ item }">
<span
:class="{
'text-error': item.isRequiredToSetPassword,
}"
class="text-nowrap"
>
{{ calculateUserPasswordStatus(item) }}
</span>
</template>
<!-- User status -->
<template v-slot:item.isActive="{ item }">
<span :class="calculateStatusColor(item)">
{{ calculateUserStatus(item) }}
</span>
</template>
<!-- Last visit -->
<template v-slot:item.lastLogin="{ item }">
<template v-if="isModifiedDate(item.lastLogin)">
<span class="text-nowrap">
{{ Moment.$moment(item.lastLogin).format('DD MMM YYYY HH:mm') }}
</span>
</template>
<span v-else class="text-nowrap"></span>
</template>
<!-- Created at -->
<template v-slot:item.createdAt="{ item }">
<template v-if="isModifiedDate(item.createdAt)">
<span class="text-nowrap">
{{ Moment.$moment(item.createdAt).format('DD MMM YYYY HH:mm') }}
</span>
</template>
<span v-else class="text-nowrap"></span>
</template>
<!-- Updated at -->
<template v-slot:item.updatedAt="{ item }">
<template v-if="isModifiedDate(item.updatedAt)">
<span class="text-nowrap">
{{ Moment.$moment(item.updatedAt).format('DD MMM YYYY HH:mm') }}
</span>
</template>
<span v-else class="text-nowrap"></span>
</template>
<!-- Actions -->
<template v-slot:item.actions="{ item }">
<v-menu transition="none">
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
icon
size="small"
variant="text"
density="comfortable"
class="mr-1"
:disabled="isDeleted(item.deletedAt)"
:loading="$users.loadingActionsID.includes(item.ID)"
>
<span class="mso text-xl">more_vert</span>
</v-btn>
</template>
<v-list class="mt-2 accent-modal" elevation="0" density="compact">
<template v-for="(act, index) of actions">
<v-list-item
v-if="
(act?.blocked && !item.isActive) ||
(!act?.blocked && item.isActive)
"
:key="index"
:value="act.value"
lines="one"
class="cm-mini"
@click="act.onClick ? act.onClick(item) : null"
>
<v-list-item-title>{{ act.title }}</v-list-item-title>
</v-list-item>
</template>
</v-list>
</v-menu>
<!-- Delete -->
<v-btn
icon
size="small"
variant="text"
density="comfortable"
:disabled="isDeleted(item.deletedAt)"
:loading="$users.loadingActionsID.includes(item.ID)"
@click="deleteUser(item)"
>
<span class="mso text-xl">close</span>
</v-btn>
</template>
</v-data-table-server>
</template>

346
src/pages/users/index.vue Normal file
View File

@ -0,0 +1,346 @@
<script setup lang="ts">
import { useLocale } from 'vuetify';
import { UsersPageTabs } from '@/enums/user-tab.enum';
import { useUsersStore } from '@/stores/users';
import { useGroupsStore } from '@/stores/groups';
import { useConfirmationStore } from '@/stores/confirmation';
import ErrorPlate from '@/components/ErrorPlate.vue';
import AddUser from './components/AddUser.vue';
import AddGroup from './components/AddGroup.vue';
import UsersTab from './components/UsersTab.vue';
import GroupsTab from './components/GroupsTab.vue';
import { setURIParams } from '@/mixins/query-params';
import { OrderBy } from '@/types/order-by';
// Common variables
const { t } = useLocale();
const $users = useUsersStore();
const $groups = useGroupsStore();
const $confirmation = useConfirmationStore();
const tableHeight: Ref<number> = ref(500);
setInterval(() => {
const wY = window.innerHeight;
const el = document.getElementById('table');
if (el) {
const bb = el.getBoundingClientRect();
tableHeight.value = wY - bb.top;
}
}, 100);
//
// Tabs
//
const tab: Ref<UsersPageTabs> = ref(UsersPageTabs.USERS);
const parseURLSearch = () => {
const params = new URLSearchParams(window.location.search);
// Get tab
if (params.has('tab')) {
tab.value = Number(params.get('tab'));
}
// Get page
if (params.has('page')) {
const v = Number(params.get('page'));
tab.value === UsersPageTabs.USERS ? $users.setPage(v) : $groups.setPage(v);
}
// Get per page count
if (params.has('count')) {
const v = Number(params.get('count'));
tab.value === UsersPageTabs.USERS
? $users.setPerPage(v)
: $groups.setPerPage(v);
}
// Get sort
if (params.has('key') && params.has('order')) {
const v: OrderBy = {
key: params.get('key') ?? 'ID',
order: params.get('order') ?? 'asc',
};
tab.value === UsersPageTabs.USERS
? $users.setSort([v])
: $groups.setSort([v]);
}
// Get search
if (params.has('input')) {
const v = params.get('input') ?? '';
tab.value === UsersPageTabs.USERS
? ($users.search = v)
: ($groups.search = v);
}
if (params.has('group_id')) {
const v = params.get('group_id') ?? '';
if (v?.length > 0) {
tab.value = UsersPageTabs.USERS;
$users.setGroupId(v);
$users.search = '';
}
}
};
onBeforeMount(() => {
parseURLSearch();
tab.value === UsersPageTabs.USERS ? $groups.getAll() : $users.getAll();
});
onUpdated(parseURLSearch);
watch(tab, () => {
setURIParams(new URLSearchParams([['tab', String(tab.value)]]), !tab.value);
resetActions();
getAll();
});
//
// Creation
//
const openCreationModal = () => {
tab.value === UsersPageTabs.USERS ? $users.openModal() : $groups.openModal();
};
//
// Search model
//
const $search: WritableComputedRef<string> = computed({
get() {
return tab.value === UsersPageTabs.USERS ? $users.search : $groups.search;
},
set(v: string) {
tab.value === UsersPageTabs.USERS
? ($users.search = v)
: ($groups.search = v);
},
});
//
// Error
//
const $error: ComputedRef<string> = computed(() =>
tab.value === UsersPageTabs.USERS ? $users.error : $groups.error,
);
//
// Common functions
//
const getAll = () => {
tab.value === UsersPageTabs.USERS ? $users.getAll() : $groups.getAll();
};
const clearSearch = () => {
tab.value === UsersPageTabs.USERS
? ($users.search = '')
: ($groups.search = '');
};
const resetActions = () => {
$users.resetGroupId();
$users.resetSelected();
$groups.resetSelected();
};
const deleteMany = () => {
const len =
tab.value === UsersPageTabs.USERS
? $users.selected.length
: $groups.selected.length;
$confirmation.openModal();
$confirmation.setTitle(
t(
`$vuetify.${tab.value === UsersPageTabs.USERS ? 'users' : 'groups'}.delete.delete-many.title`,
),
);
$confirmation.setMessage(
t(
`$vuetify.${tab.value === UsersPageTabs.USERS ? 'users' : 'groups'}.delete.delete-many.message`,
[len],
),
);
$confirmation.setOnConfirm(
tab.value === UsersPageTabs.USERS
? () => {
$users.deleteMany($users.selected);
$users.resetSelected();
}
: () => {
$groups.deleteMany($groups.selected);
$groups.resetSelected();
},
);
};
const blockMany = () => {
$users.blockMany($users.selected);
$users.resetSelected();
};
const unblockMany = () => {
$users.unblockMany($users.selected);
$users.resetSelected();
};
</script>
<template>
<div>
<!-- Header -->
<v-app-bar prominent class="elevation-0 border-b">
<!-- Selected -->
<div
v-if="
$users.groupId?.length > 0 ||
$groups.selected?.length ||
$users.selected?.length
"
class="flex items-center absolute h-full w-full left-0 top-0 table-actions z-[99]"
>
<!-- Clear -->
<v-btn
color="primary"
icon
size="small"
class="ml-2 mr-3"
@click="resetActions()"
>
<span class="mso text-3xl">close</span>
</v-btn>
<!-- Count -->
<span class="text-h6 z-[100]">{{
!$groups.selected?.length && !$users.selected?.length
? t('$vuetify.users.selected-group', [$users.groupId])
: t('$vuetify.misc.selected', [
tab === UsersPageTabs.USERS
? $users.selected?.length
: $groups.selected?.length,
])
}}</span>
<!-- Actions -->
<!-- Delete -->
<v-btn
color="primary"
variant="text"
class="ml-10 mr-3 px-3"
@click="deleteMany()"
>
<span class="mso mr-1 text-xl">{{
tab === UsersPageTabs.USERS ? 'person_remove' : 'group_remove'
}}</span>
<span>{{ t('$vuetify.actions.delete') }}</span>
</v-btn>
<template v-if="tab === UsersPageTabs.USERS">
<!-- Block -->
<v-btn
color="primary"
variant="text"
class="mr-3 px-3"
@click="blockMany()"
>
<span class="mso mr-1 text-xl">person_cancel</span>
<span>{{ t('$vuetify.actions.block') }}</span>
</v-btn>
<!-- Unblock -->
<v-btn
color="primary"
variant="text"
class="mr-3 px-3"
@click="unblockMany()"
>
<span class="mso mr-1 text-xl">person_check</span>
<span>{{ t('$vuetify.actions.unblock') }}</span>
</v-btn>
</template>
</div>
<!-- Title -->
<span class="text-h6 ml-5 mr-10">{{
t(
tab === UsersPageTabs.USERS
? '$vuetify.users.title'
: '$vuetify.users.tabs.groups',
)
}}</span>
<!-- Create group/user -->
<v-btn
color="primary"
variant="text"
class="mr-3 px-3"
@click="openCreationModal()"
>
<span class="mso mr-1 text-xl">{{
tab === UsersPageTabs.USERS ? 'person_add' : 'group_add'
}}</span>
<span>{{
t(
tab === UsersPageTabs.USERS
? '$vuetify.actions.create-user'
: '$vuetify.actions.create-group',
)
}}</span>
</v-btn>
<!-- User creation modal -->
<AddUser v-model="$users.createModal" />
<!-- Group creation modal -->
<AddGroup v-model="$groups.createModal" />
<!-- Refresh users -->
<v-btn class="px-3" color="primary" variant="text" @click="getAll()">
<span class="mso mr-1 text-xl">refresh</span>
<span>{{ t('$vuetify.actions.refresh') }}</span>
</v-btn>
</v-app-bar>
<!-- Tabs -->
<v-tabs
v-model="tab"
color="primary"
class="border-b"
density="comfortable"
bg-color="surface"
:disabled="$users.groupId?.length > 0"
>
<v-tab :value="UsersPageTabs.USERS">{{
t('$vuetify.users.tabs.users')
}}</v-tab>
<v-tab :value="UsersPageTabs.GROUPS">{{
t('$vuetify.users.tabs.groups')
}}</v-tab>
</v-tabs>
<!-- Search -->
<v-card-title class="p-0 w-full">
<v-text-field
v-model="$search"
density="compact"
:label="t('$vuetify.users.search')"
prepend-inner-icon="mdi-magnify"
variant="solo-filled"
class="border-b"
:rounded="false"
flat
clearable
hide-details
single-line
@click:clear="clearSearch()"
></v-text-field>
</v-card-title>
<!-- Error -->
<ErrorPlate :error="$error" block />
<div class="h-0" id="table"></div>
<v-tabs-window v-model="tab">
<!-- Loader -->
<transition name="fade">
<table-loader v-if="$users.loading || $groups.loading" />
</transition>
<!-- USERS -->
<v-tabs-window-item :value="UsersPageTabs.USERS">
<v-card flat rounded="0" :height="`${tableHeight}px`">
<!-- Data -->
<users-tab :tableHeight="tableHeight" />
</v-card>
</v-tabs-window-item>
<!-- GROUPS -->
<v-tabs-window-item :value="UsersPageTabs.GROUPS">
<!-- Data -->
<v-card flat rounded="0" :height="`${tableHeight}px`">
<groups-tab :tableHeight="tableHeight" />
</v-card>
</v-tabs-window-item>
</v-tabs-window>
</div>
</template>

3
src/plugins/README.md Normal file
View File

@ -0,0 +1,3 @@
# Plugins
Plugins are a way to extend the functionality of your Vue application. Use this folder for registering plugins that you want to use globally.

12
src/plugins/axios.ts Normal file
View File

@ -0,0 +1,12 @@
import axios, { Axios } from 'axios';
import type { App } from 'vue';
export const $axios: Axios = axios.create({
baseURL: import.meta.env.VITE_API_URL,
});
export default {
install: (app: App) => {
app.config.globalProperties.$axios = $axios;
},
};

Some files were not shown because too many files have changed in this diff Show More