Initial commit
2
.env
Normal file
|
@ -0,0 +1,2 @@
|
|||
REACT_APP_API_URL=https://oas-api.initdevelopment.com/api/v1
|
||||
EXTEND_ESLINT=true
|
3
.eslintignore
Normal file
|
@ -0,0 +1,3 @@
|
|||
.eslintrc.js
|
||||
node_modules
|
||||
tailwind.config.js
|
63
.eslintrc.js
vendored
Normal file
|
@ -0,0 +1,63 @@
|
|||
const { off } = require('process');
|
||||
|
||||
module.exports = {
|
||||
extends: [
|
||||
'react-app',
|
||||
'airbnb-typescript',
|
||||
'plugin:react/recommended',
|
||||
'plugin:import/typescript',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:promise/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
'prettier',
|
||||
'plugin:cypress/recommended',
|
||||
],
|
||||
env: {
|
||||
browser: true,
|
||||
node: true,
|
||||
},
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'module',
|
||||
sourceType: 'module',
|
||||
project: './tsconfig.json',
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'no-param-reassign': ['error', { props: false }],
|
||||
'jsx-a11y/anchor-is-valid': 'off',
|
||||
'no-console': ['warn', { allow: ['debug', 'info', 'time', 'timeEnd', 'trace', 'error'] }],
|
||||
'import/prefer-default-export': 'off',
|
||||
'import/no-extraneous-dependencies': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'no-shadow': 'off',
|
||||
'no-nested-ternary': 'off',
|
||||
'@typescript-eslint/no-shadow': 'error',
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
'no-underscore-dangle': 'off',
|
||||
'react/display-name': 'off',
|
||||
'react/prop-types': ['error', { skipUndeclared: true }],
|
||||
'jsx-a11y/click-events-have-key-events': 'off',
|
||||
'jsx-a11y/no-static-element-interactions': 'off',
|
||||
'jsx-a11y/no-noninteractive-element-interactions': 'off',
|
||||
'react/jsx-props-no-spreading': 'off',
|
||||
'react/button-has-type': 'off',
|
||||
'jsx-a11y/control-has-associated-label': 'off',
|
||||
'jsx-a11y/label-has-associated-control': 'off',
|
||||
'react/require-default-props': 'off',
|
||||
'class-methods-use-this': 'off',
|
||||
'max-len': ['error', { code: 512, ignoreUrls: true }],
|
||||
'prettier/prettier': ['error', { endOfLine: 'auto' }],
|
||||
'no-debugger': 'warn',
|
||||
},
|
||||
settings: {
|
||||
'import/parsers': {
|
||||
'@typescript-eslint/parser': ['.ts', '.tsx'],
|
||||
},
|
||||
},
|
||||
};
|
5
.gitattributes
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
* text=auto
|
||||
*.css linguist-vendored
|
||||
*.scss linguist-vendored
|
||||
*.js linguist-vendored
|
||||
CHANGELOG.md export-ignore
|
26
.gitignore
vendored
Normal file
|
@ -0,0 +1,26 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
.eslintcache
|
||||
cypress/videos/
|
69
.gitlab-ci.yml
Normal file
|
@ -0,0 +1,69 @@
|
|||
image: node:14-alpine
|
||||
|
||||
stages:
|
||||
- build
|
||||
- publish
|
||||
- deploy
|
||||
|
||||
variables:
|
||||
DOCKER_HOST: tcp://docker:2375
|
||||
DOCKER_DRIVER: overlay2
|
||||
DOCKER_TLS_CERTDIR: ''
|
||||
|
||||
# only used for deploy stage
|
||||
default:
|
||||
before_script:
|
||||
- apk update
|
||||
- apk add openssh
|
||||
- apk add rsync
|
||||
- apk add bash
|
||||
- apk add yarn
|
||||
- apk add docker
|
||||
- mkdir /root/.ssh
|
||||
- touch /root/.ssh/id_rsa
|
||||
- echo -e $SSH_KEY | awk '{gsub("\\\\n","\n")};1' >> /root/.ssh/id_rsa
|
||||
- chmod 600 /root/.ssh/id_rsa
|
||||
|
||||
build:
|
||||
stage: build
|
||||
before_script: []
|
||||
script:
|
||||
- echo "Building app"
|
||||
- yarn
|
||||
- echo "REACT_APP_API_URL=https://oas-api.initdevelopment.com/api/v1" > .env
|
||||
- echo "EXTEND_ESLINT=true" >> .env
|
||||
- yarn build
|
||||
- mv build web-build
|
||||
- echo "Build successful"
|
||||
artifacts:
|
||||
expire_in: 1 hour
|
||||
name: web-build
|
||||
paths:
|
||||
- web-build
|
||||
|
||||
publish:
|
||||
stage: publish
|
||||
only:
|
||||
- master
|
||||
services:
|
||||
- docker:18.09.7-dind
|
||||
script:
|
||||
- echo "Publishing production app (ID $CI_PIPELINE_ID)"
|
||||
- cp deployment/Dockerfile web-build
|
||||
- cp deployment/nginx.conf web-build
|
||||
- cd web-build
|
||||
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
|
||||
- docker build -t $CI_REGISTRY/init/oas/image:dev .
|
||||
- docker push $CI_REGISTRY/init/oas/image:dev
|
||||
- echo "Publishing complete"
|
||||
|
||||
deploy:
|
||||
stage: deploy
|
||||
only:
|
||||
- master
|
||||
script:
|
||||
- echo "Deploying (ID $CI_PIPELINE_ID)"
|
||||
- ssh -o StrictHostKeyChecking=no ubuntu@213.108.108.105 /home/ubuntu/run-services.sh
|
||||
environment:
|
||||
name: development
|
||||
url: https://oas.initdevelopment.com
|
9
.lintstagedrc
Normal file
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"*.{js,jsx,ts,tsx}": [
|
||||
"yarn lint:fix",
|
||||
"prettier --write"
|
||||
],
|
||||
"*.{json,md}": [
|
||||
"prettier --write"
|
||||
]
|
||||
}
|
8
.prettierrc
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"bracketSpacing": true,
|
||||
"jsxBracketSameLine": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 120,
|
||||
"endOfLine": "lf"
|
||||
}
|
13
.styleci.yml
Normal file
|
@ -0,0 +1,13 @@
|
|||
php:
|
||||
preset: laravel
|
||||
disabled:
|
||||
- unused_use
|
||||
finder:
|
||||
not-name:
|
||||
- index.php
|
||||
- server.php
|
||||
js:
|
||||
finder:
|
||||
not-name:
|
||||
- webpack.mix.js
|
||||
css: true
|
46
README.md
Normal file
|
@ -0,0 +1,46 @@
|
|||
# Getting Started with Create React App
|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
|
||||
## Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `yarn start`
|
||||
|
||||
Runs the app in the development mode.\
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||
|
||||
The page will reload if you make edits.\
|
||||
You will also see any lint errors in the console.
|
||||
|
||||
### `yarn test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.\
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `yarn build`
|
||||
|
||||
Builds the app for production to the `build` folder.\
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.\
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `yarn eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
||||
|
||||
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
|
||||
|
||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
|
||||
|
||||
## Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
8
craco.config.js
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
module.exports = {
|
||||
style: {
|
||||
postcss: {
|
||||
/* eslint-disable global-require */
|
||||
plugins: [require('tailwindcss'), require('autoprefixer')],
|
||||
},
|
||||
},
|
||||
};
|
3
deployment/Dockerfile
Normal file
|
@ -0,0 +1,3 @@
|
|||
FROM nginx:latest
|
||||
COPY ./nginx.conf /etc/nginx/nginx.conf
|
||||
COPY . /usr/share/nginx/html
|
16
deployment/nginx.conf
Normal file
|
@ -0,0 +1,16 @@
|
|||
events {
|
||||
worker_connections 10000;
|
||||
}
|
||||
http {
|
||||
server {
|
||||
listen [::]:80 default_server;
|
||||
listen 80;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
include /etc/nginx/mime.types;
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
}
|
||||
}
|
16443
package-lock.json
generated
Normal file
118
package.json
Normal file
|
@ -0,0 +1,118 @@
|
|||
{
|
||||
"name": "open-app-stack",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@craco/craco": "^6.2.0",
|
||||
"@headlessui/react": "^1.3.0",
|
||||
"@heroicons/react": "^1.0.3",
|
||||
"@hookform/resolvers": "^2.6.1",
|
||||
"@reach/router": "^1.3.4",
|
||||
"@tailwindcss/forms": "^0.3.3",
|
||||
"@testing-library/jest-dom": "^5.11.4",
|
||||
"@testing-library/react": "^11.1.0",
|
||||
"@testing-library/user-event": "^12.1.10",
|
||||
"@types/jest": "^26.0.15",
|
||||
"@types/node": "^12.0.0",
|
||||
"@types/react-dom": "^17.0.0",
|
||||
"axios": "^0.21.1",
|
||||
"clsx": "^1.1.1",
|
||||
"lint-staged": "^11.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
"prismjs": "^1.24.1",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-hook-form": "^7.12.1",
|
||||
"react-hot-toast": "^2.0.0",
|
||||
"react-redux": "^7.2.4",
|
||||
"react-scripts": "4.0.3",
|
||||
"react-simple-code-editor": "^0.11.0",
|
||||
"react-table": "^7.7.0",
|
||||
"redux": "^4.1.0",
|
||||
"redux-persist": "^6.0.0",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"tailwindcss": "npm:@tailwindcss/postcss7-compat",
|
||||
"typescript": "^4.1.2",
|
||||
"urlcat": "^2.0.4",
|
||||
"web-vitals": "^1.0.1",
|
||||
"yup": "^0.32.9"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "craco start",
|
||||
"build": "craco build",
|
||||
"test": "craco test",
|
||||
"eject": "react-scripts eject",
|
||||
"lint": "eslint ./src --cache --ext .js,.jsx,.ts,.tsx",
|
||||
"lint-staged": "lint-staged"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash": "^4.14.171",
|
||||
"@types/prismjs": "^1.16.6",
|
||||
"@types/reach__router": "^1.3.9",
|
||||
"@types/react": "^17.0.18",
|
||||
"@types/react-helmet": "^6.1.2",
|
||||
"@types/react-table": "^7.7.2",
|
||||
"autoprefixer": "^9",
|
||||
"dotenv": "^10.0.0",
|
||||
"eslint-config-airbnb": "^18.2.1",
|
||||
"eslint-config-airbnb-typescript": "^12.3.1",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-cypress": "^2.11.3",
|
||||
"eslint-plugin-import": "^2.23.4",
|
||||
"eslint-plugin-jsx-a11y": "^6.4.1",
|
||||
"eslint-plugin-prettier": "^3.4.0",
|
||||
"eslint-plugin-promise": "^5.1.0",
|
||||
"postcss": "^7",
|
||||
"pre-commit": "^1.2.2",
|
||||
"prettier": "^2.3.2",
|
||||
"sass": "^1.36.0"
|
||||
},
|
||||
"pre-commit": "lint-staged",
|
||||
"lint-staged": {
|
||||
"*.{js,ts,tsx}": [
|
||||
"prettier --write",
|
||||
"eslint --ext .ts,.tsx,.js --max-warnings=3 --fix-dry-run"
|
||||
]
|
||||
},
|
||||
"eslintIgnore": [
|
||||
".eslintrc.js"
|
||||
],
|
||||
"prettier": {
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
".prettierrc",
|
||||
".babelrc",
|
||||
".eslintrc"
|
||||
],
|
||||
"options": {
|
||||
"parser": "json"
|
||||
}
|
||||
}
|
||||
],
|
||||
"singleQuote": true,
|
||||
"printWidth": 120,
|
||||
"bracketSpacing": true,
|
||||
"trailingComma": "all",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
}
|
BIN
public/android-chrome-192x192.png
Normal file
After Width: | Height: | Size: 6.4 KiB |
BIN
public/android-chrome-256x256.png
Normal file
After Width: | Height: | Size: 9.1 KiB |
BIN
public/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 5.2 KiB |
7
public/assets/logo-small.svg
Normal file
|
@ -0,0 +1,7 @@
|
|||
<svg width="25" height="44" viewBox="0 0 25 44" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 35.1171C0 39.0948 4.18188 41.6853 7.74332 39.9138L22.8687 32.39C24.0424 31.8062 24.7844 30.6083 24.7844 29.2975V20.9534L1.64288 32.4649C0.636359 32.9656 0 33.9929 0 35.1171Z" fill="#2D535A"/>
|
||||
<path d="M0 22.8091C0 27.6308 5.06914 30.7709 9.38621 28.6235L24.7844 20.9641C24.7844 16.1423 19.7151 13.0021 15.398 15.1496L0 22.8091Z" fill="#54C6CC"/>
|
||||
<path d="M2.2161 21.7068C0.858395 22.3821 -0.103566 23.8187 0.458285 25.2271C1.79955 28.5895 5.84578 30.3846 9.38621 28.6235L24.7844 20.9641C24.7844 16.1423 19.7151 13.0021 15.398 15.1496L2.2161 21.7068Z" fill="#2D535A"/>
|
||||
<path d="M2.2161 21.7068C0.858395 22.3821 -0.103566 23.8187 0.458285 25.2271C1.79955 28.5895 5.84578 30.3846 9.38621 28.6235L22.5683 22.0664C23.926 21.3911 24.888 19.9545 24.3261 18.546C22.9848 15.1836 18.9385 13.3884 15.398 15.1496L2.2161 21.7068Z" fill="#1E8290"/>
|
||||
<path d="M0 22.8121L23.3077 11.2182C24.2124 10.7682 24.7844 9.8448 24.7844 8.83432C24.7844 4.77111 20.5126 2.12495 16.8747 3.93462L2.25625 11.2064C0.873945 11.894 0 13.3048 0 14.8487V22.8121Z" fill="#54C6CC"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
9
public/assets/logo.svg
Normal file
|
@ -0,0 +1,9 @@
|
|||
<svg width="151" height="44" viewBox="0 0 151 44" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="151" height="44" fill="transparent"/>
|
||||
<path d="M40.8246 11.7373C37.2206 11.7373 34.89 13.5633 34.89 16.6388C34.89 19.7142 36.8842 20.8915 40.4162 21.6604L40.9688 21.7805C43.1312 22.2611 44.2604 22.7416 44.2604 24.1351C44.2604 25.4806 43.1792 26.4417 41.0168 26.4417C38.8544 26.4417 37.5329 25.4326 37.5329 23.4143V23.1741H34.4094V23.4143C34.4094 27.0664 37.1245 29.2288 41.0168 29.2288C44.9092 29.2288 47.3839 27.1145 47.3839 24.039C47.3839 20.9636 45.1014 19.7142 41.5214 18.9454L40.9688 18.8252C38.9025 18.3687 38.0135 17.7921 38.0135 16.5427C38.0135 15.2933 38.9025 14.5244 40.8246 14.5244C42.7468 14.5244 43.9241 15.2933 43.9241 17.2154V17.5998H47.0476V17.2154C47.0476 13.5633 44.4286 11.7373 40.8246 11.7373ZM47.5746 19.4259H50.554V26.2015C50.554 27.8353 51.6112 28.8925 53.1969 28.8925H56.5607V26.4417H54.2541C53.8216 26.4417 53.5814 26.2015 53.5814 25.7209V19.4259H56.849V16.9752H53.5814V13.275H50.554V16.9752H47.5746V19.4259ZM69.9725 28.8925V16.9752H66.9932V18.5849H66.7529C66.0321 17.5518 64.8788 16.6388 62.8125 16.6388C59.9774 16.6388 57.4305 19.0415 57.4305 22.9338C57.4305 26.8261 59.9774 29.2288 62.8125 29.2288C64.8788 29.2288 66.0321 28.3158 66.7529 27.2827H66.9932V28.8925H69.9725ZM63.7256 26.6339C61.8515 26.6339 60.4579 25.2884 60.4579 22.9338C60.4579 20.5792 61.8515 19.2337 63.7256 19.2337C65.5996 19.2337 66.9932 20.5792 66.9932 22.9338C66.9932 25.2884 65.5996 26.6339 63.7256 26.6339ZM71.4505 22.9338C71.4505 26.6339 74.1896 29.2288 77.6495 29.2288C80.9892 29.2288 82.9354 27.2827 83.6081 24.6637L80.6768 23.967C80.4126 25.5047 79.5236 26.5859 77.6975 26.5859C75.8715 26.5859 74.4779 25.2404 74.4779 22.9338C74.4779 20.6272 75.8715 19.2817 77.6975 19.2817C79.5236 19.2817 80.4126 20.435 80.5807 21.8766L83.512 21.2519C82.9834 18.5849 80.9652 16.6388 77.6495 16.6388C74.1896 16.6388 71.4505 19.2337 71.4505 22.9338ZM97.4406 16.9752H92.9716L88.0221 21.348V12.0737H84.9947V28.8925H88.0221V25.0241L89.68 23.6066L93.6204 28.8925H97.3445L91.8424 21.7565L97.4406 16.9752ZM98.2594 20.3149C98.2594 22.6695 100.23 23.5345 102.728 24.015L103.353 24.1351C104.843 24.4235 105.516 24.7839 105.516 25.5527C105.516 26.3216 104.843 26.9223 103.449 26.9223C102.056 26.9223 100.926 26.3456 100.614 24.6157L97.8269 25.3365C98.2354 27.8353 100.326 29.2288 103.449 29.2288C106.477 29.2288 108.447 27.8112 108.447 25.3125C108.447 22.8137 106.429 22.1409 103.738 21.6123L103.113 21.4922C101.863 21.2519 101.191 20.9156 101.191 20.1227C101.191 19.4019 101.815 18.9454 102.969 18.9454C104.122 18.9454 104.939 19.4259 105.227 20.7233L107.966 19.8824C107.39 17.9603 105.636 16.6388 102.969 16.6388C100.134 16.6388 98.2594 17.9603 98.2594 20.3149ZM109.985 33.6978H113.012V27.3547H113.252C113.925 28.3158 115.078 29.2288 117.145 29.2288C119.98 29.2288 122.527 26.8261 122.527 22.9338C122.527 19.0415 119.98 16.6388 117.145 16.6388C115.078 16.6388 113.925 17.5518 113.204 18.5849H112.964V16.9752H109.985V33.6978ZM116.231 26.6339C114.357 26.6339 112.964 25.2884 112.964 22.9338C112.964 20.5792 114.357 19.2337 116.231 19.2337C118.106 19.2337 119.499 20.5792 119.499 22.9338C119.499 25.2884 118.106 26.6339 116.231 26.6339ZM123.38 13.5153C123.38 14.7407 124.317 15.5816 125.518 15.5816C126.72 15.5816 127.657 14.7407 127.657 13.5153C127.657 12.2899 126.72 11.449 125.518 11.449C124.317 11.449 123.38 12.2899 123.38 13.5153ZM127.032 16.9752H124.005V28.8925H127.032V16.9752ZM129.249 16.9752V28.8925H132.277V22.7416C132.277 20.5311 133.358 19.2337 135.208 19.2337C136.842 19.2337 137.755 20.1227 137.755 21.9247V28.8925H140.782V21.7805C140.782 18.8252 138.932 16.7829 136.145 16.7829C133.958 16.7829 132.949 17.744 132.469 18.7531H132.228V16.9752H129.249Z" fill="#2D535A"/>
|
||||
<path d="M0 35.1171C0 39.0948 4.18188 41.6853 7.74332 39.9138L22.8687 32.39C24.0424 31.8062 24.7844 30.6083 24.7844 29.2975V20.9534L1.64288 32.4649C0.636359 32.9656 0 33.9929 0 35.1171Z" fill="#2D535A"/>
|
||||
<path d="M0 22.8091C0 27.6308 5.06914 30.7709 9.38621 28.6235L24.7844 20.9641C24.7844 16.1423 19.7151 13.0021 15.398 15.1496L0 22.8091Z" fill="#54C6CC"/>
|
||||
<path d="M2.2161 21.7068C0.858395 22.3821 -0.103566 23.8187 0.458285 25.2271C1.79955 28.5895 5.84578 30.3846 9.38621 28.6235L24.7844 20.9641C24.7844 16.1423 19.7151 13.0021 15.398 15.1496L2.2161 21.7068Z" fill="#2D535A"/>
|
||||
<path d="M2.2161 21.7068C0.858395 22.3821 -0.103566 23.8187 0.458285 25.2271C1.79955 28.5895 5.84578 30.3846 9.38621 28.6235L22.5683 22.0664C23.926 21.3911 24.888 19.9545 24.3261 18.546C22.9848 15.1836 18.9385 13.3884 15.398 15.1496L2.2161 21.7068Z" fill="#1E8290"/>
|
||||
<path d="M0 22.8121L23.3077 11.2182C24.2124 10.7682 24.7844 9.8448 24.7844 8.83432C24.7844 4.77111 20.5126 2.12495 16.8747 3.93462L2.25625 11.2064C0.873945 11.894 0 13.3048 0 14.8487V22.8121Z" fill="#54C6CC"/>
|
||||
</svg>
|
After Width: | Height: | Size: 4.7 KiB |
1
public/assets/nextcloud.svg
Normal file
After Width: | Height: | Size: 8.9 KiB |
5
public/assets/rocketchat.svg
Normal file
|
@ -0,0 +1,5 @@
|
|||
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="160" height="160" fill="#F5455C"/>
|
||||
<path d="M63.2762 45.8537C65.9188 47.3247 68.3617 49.1288 70.5451 51.2216C74.0286 50.5972 77.5608 50.285 81.0998 50.2888C91.8454 50.2888 102.023 53.1108 109.764 58.224C113.516 60.6466 116.718 63.8284 119.165 67.5644C121.666 71.3356 123 75.7604 123 80.2855C123 84.8107 121.666 89.2354 119.165 93.0066C116.697 96.7535 113.47 99.9397 109.691 102.359C101.95 107.476 91.7729 110.282 81.0273 110.282C77.5058 110.291 73.9905 109.987 70.5227 109.374C68.346 111.474 65.902 113.279 63.2538 114.741C59.2822 116.811 54.8747 117.907 50.3959 117.936C45.9171 117.966 41.4956 116.928 37.4971 114.91C37.4971 114.91 48.4004 105.69 46.5837 97.6219C44.2564 95.3619 42.3972 92.6654 41.1124 89.6865C39.8277 86.7076 39.1426 83.5048 39.0963 80.261C39.2156 73.7542 41.9078 67.5597 46.5837 63.0333C48.3524 54.9531 37.4971 45.7332 37.4971 45.7332C41.4946 43.704 45.9182 42.6566 50.4012 42.6775C54.8842 42.6985 59.2977 43.7872 63.2762 45.8537V45.8537ZM54.8537 94.2295C56.2113 98.575 55.4117 103.461 52.4549 108.889C52.3217 109.155 52.1884 109.422 52.0434 109.677C54.7169 109.442 57.3216 108.702 59.7196 107.496C61.6934 106.384 63.5201 105.029 65.1568 103.462L68.1004 100.615C72.369 101.749 76.7685 102.315 81.1851 102.299C99.9992 102.299 115.252 92.5098 115.252 80.2972C115.252 68.0847 99.9992 58.2283 81.1851 58.2283C62.371 58.2283 47.1264 68.1465 47.1264 80.261C47.1264 85.5671 50.005 90.4383 54.8559 94.2273L54.8537 94.2295Z" fill="white"/>
|
||||
<path d="M64.8632 85.5074C63.8532 85.525 62.8609 85.2416 62.0125 84.6932C61.1641 84.1449 60.4982 83.3565 60.0994 82.4284C59.7006 81.5004 59.587 80.4746 59.7732 79.4818C59.9593 78.4889 60.4368 77.574 61.1447 76.8534C61.8526 76.1328 62.7589 75.6392 63.7483 75.4355C64.7377 75.2317 65.7653 75.327 66.7003 75.7093C67.6353 76.0915 68.4354 76.7434 68.9987 77.5819C69.562 78.4204 69.863 79.4076 69.8634 80.4177C69.8635 81.7526 69.3391 83.0342 68.4032 83.9861C67.4673 84.938 66.1947 85.484 64.86 85.5063L64.8632 85.5074ZM80.9885 85.5074C79.9786 85.5243 78.9865 85.2404 78.1386 84.6917C77.2907 84.1429 76.6252 83.3543 76.2269 82.4262C75.8286 81.498 75.7155 80.4723 75.902 79.4797C76.0886 78.4871 76.5662 77.5724 77.2743 76.8522C77.9823 76.1319 78.8887 75.6386 79.878 75.4352C80.8673 75.2317 81.8947 75.3272 82.8296 75.7095C83.7644 76.0919 84.5643 76.7438 85.1274 77.5822C85.6906 78.4206 85.9915 79.4077 85.9918 80.4177C85.992 81.7526 85.4676 83.0342 84.5317 83.9861C83.5957 84.938 82.3232 85.484 80.9885 85.5063V85.5074ZM97.1009 85.5074C95.9321 85.5158 94.7965 85.1186 93.8876 84.3835C92.9788 83.6485 92.3529 82.621 92.1166 81.4762C91.8804 80.3315 92.0484 79.1402 92.592 78.1054C93.1356 77.0706 94.0212 76.2563 95.0979 75.8013C96.1746 75.3462 97.3758 75.2786 98.4968 75.6099C99.6177 75.9412 100.589 76.6509 101.245 77.6181C101.902 78.5854 102.203 79.7503 102.096 80.9143C101.99 82.0784 101.483 83.1696 100.663 84.002C99.7197 84.9527 98.44 85.4935 97.1009 85.5074V85.5074Z" fill="white"/>
|
||||
</svg>
|
After Width: | Height: | Size: 3 KiB |
28
public/assets/wekan.svg
Normal file
After Width: | Height: | Size: 18 KiB |
15
public/assets/wordpress.svg
Normal file
|
@ -0,0 +1,15 @@
|
|||
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="160" height="160" fill="#21759B"/>
|
||||
<g clip-path="url(#clip0)">
|
||||
<path d="M42.1836 79.4989C42.1836 94.2698 50.7678 107.035 63.2157 113.084L45.4145 64.312C43.3439 68.953 42.1836 74.0889 42.1836 79.4989V79.4989Z" fill="white"/>
|
||||
<path d="M104.694 77.6158C104.694 73.0039 103.037 69.81 101.616 67.324C99.7246 64.2502 97.9515 61.647 97.9515 58.5731C97.9515 55.1428 100.553 51.9496 104.218 51.9496C104.383 51.9496 104.54 51.9702 104.702 51.9794C98.0622 45.897 89.2167 42.1833 79.5013 42.1833C66.464 42.1833 54.994 48.8722 48.3213 59.0034C49.1968 59.0297 50.0219 59.0482 50.7228 59.0482C54.6262 59.0482 60.6683 58.5746 60.6683 58.5746C62.68 58.456 62.9171 61.4106 60.9076 61.6485C60.9076 61.6485 58.886 61.8863 56.6364 62.0042L70.2261 102.425L78.3928 77.9325L72.5786 62.0028C70.5691 61.8849 68.6653 61.647 68.6653 61.647C66.6543 61.5292 66.8901 58.4546 68.9011 58.5731C68.9011 58.5731 75.0639 59.0468 78.7308 59.0468C82.6335 59.0468 88.6763 58.5731 88.6763 58.5731C90.6894 58.4546 90.9259 61.4092 88.9156 61.647C88.9156 61.647 86.8897 61.8849 84.6444 62.0028L98.1304 102.118L101.853 89.6792C103.466 84.5177 104.694 80.8104 104.694 77.6158V77.6158Z" fill="white"/>
|
||||
<path d="M80.1561 82.7631L68.9595 115.298C72.3026 116.281 75.8381 116.818 79.5014 116.818C83.8472 116.818 88.0147 116.067 91.8939 114.703C91.7938 114.543 91.7029 114.373 91.6283 114.189L80.1561 82.7631Z" fill="white"/>
|
||||
<path d="M112.246 61.5952C112.407 62.7839 112.498 64.0599 112.498 65.4324C112.498 69.2192 111.791 73.4761 109.66 78.7988L98.2612 111.755C109.356 105.286 116.818 93.2665 116.818 79.4996C116.819 73.0117 115.161 66.9108 112.246 61.5952V61.5952Z" fill="white"/>
|
||||
<path d="M79.5014 36C55.5154 36 36 55.5135 36 79.4989C36 103.487 55.5154 123 79.5014 123C103.487 123 123.005 103.487 123.005 79.4989C123.004 55.5135 103.487 36 79.5014 36ZM79.5014 121.006C56.6153 121.006 37.9946 102.386 37.9946 79.4989C37.9946 56.6134 56.6146 37.9946 79.5014 37.9946C102.387 37.9946 121.006 56.6134 121.006 79.4989C121.006 102.386 102.387 121.006 79.5014 121.006Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0">
|
||||
<rect width="87" height="87" fill="white" transform="translate(36 36)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 2.2 KiB |
BIN
public/favicon-16x16.png
Normal file
After Width: | Height: | Size: 951 B |
BIN
public/favicon-32x32.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
public/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
43
public/index.html
Normal file
|
@ -0,0 +1,43 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Web site created using create-react-app"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>React App</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
BIN
public/logo192.png
Normal file
After Width: | Height: | Size: 5.2 KiB |
BIN
public/logo512.png
Normal file
After Width: | Height: | Size: 9.4 KiB |
25
public/manifest.json
Normal file
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
BIN
public/mstile-150x150.png
Normal file
After Width: | Height: | Size: 3.8 KiB |
3
public/robots.txt
Normal file
|
@ -0,0 +1,3 @@
|
|||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
120
react-table-config.d.ts
vendored
Normal file
|
@ -0,0 +1,120 @@
|
|||
import {
|
||||
UseColumnOrderInstanceProps,
|
||||
UseColumnOrderState,
|
||||
UseExpandedHooks,
|
||||
UseExpandedInstanceProps,
|
||||
UseExpandedOptions,
|
||||
UseExpandedRowProps,
|
||||
UseExpandedState,
|
||||
UseFiltersColumnOptions,
|
||||
UseFiltersColumnProps,
|
||||
UseFiltersInstanceProps,
|
||||
UseFiltersOptions,
|
||||
UseFiltersState,
|
||||
UseGlobalFiltersColumnOptions,
|
||||
UseGlobalFiltersInstanceProps,
|
||||
UseGlobalFiltersOptions,
|
||||
UseGlobalFiltersState,
|
||||
UseGroupByCellProps,
|
||||
UseGroupByColumnOptions,
|
||||
UseGroupByColumnProps,
|
||||
UseGroupByHooks,
|
||||
UseGroupByInstanceProps,
|
||||
UseGroupByOptions,
|
||||
UseGroupByRowProps,
|
||||
UseGroupByState,
|
||||
UsePaginationInstanceProps,
|
||||
UsePaginationOptions,
|
||||
UsePaginationState,
|
||||
UseResizeColumnsColumnOptions,
|
||||
UseResizeColumnsColumnProps,
|
||||
UseResizeColumnsOptions,
|
||||
UseResizeColumnsState,
|
||||
UseRowSelectHooks,
|
||||
UseRowSelectInstanceProps,
|
||||
UseRowSelectOptions,
|
||||
UseRowSelectRowProps,
|
||||
UseRowSelectState,
|
||||
UseRowStateCellProps,
|
||||
UseRowStateInstanceProps,
|
||||
UseRowStateOptions,
|
||||
UseRowStateRowProps,
|
||||
UseRowStateState,
|
||||
UseSortByColumnOptions,
|
||||
UseSortByColumnProps,
|
||||
UseSortByHooks,
|
||||
UseSortByInstanceProps,
|
||||
UseSortByOptions,
|
||||
UseSortByState,
|
||||
} from 'react-table';
|
||||
|
||||
declare module 'react-table' {
|
||||
// take this file as-is, or comment out the sections that don't apply to your plugin configuration
|
||||
|
||||
export interface TableOptions<D extends Record<string, unknown>>
|
||||
extends UseExpandedOptions<D>,
|
||||
UseFiltersOptions<D>,
|
||||
UseGlobalFiltersOptions<D>,
|
||||
UseGroupByOptions<D>,
|
||||
UsePaginationOptions<D>,
|
||||
UseResizeColumnsOptions<D>,
|
||||
UseRowSelectOptions<D>,
|
||||
UseRowStateOptions<D>,
|
||||
UseSortByOptions<D>,
|
||||
// note that having Record here allows you to add anything to the options, this matches the spirit of the
|
||||
// underlying js library, but might be cleaner if it's replaced by a more specific type that matches your
|
||||
// feature set, this is a safe default.
|
||||
Record<string, any> {}
|
||||
|
||||
export interface Hooks<D extends Record<string, unknown> = Record<string, unknown>>
|
||||
extends UseExpandedHooks<D>,
|
||||
UseGroupByHooks<D>,
|
||||
UseRowSelectHooks<D>,
|
||||
UseSortByHooks<D> {}
|
||||
|
||||
export interface TableInstance<D extends Record<string, unknown> = Record<string, unknown>>
|
||||
extends UseColumnOrderInstanceProps<D>,
|
||||
UseExpandedInstanceProps<D>,
|
||||
UseFiltersInstanceProps<D>,
|
||||
UseGlobalFiltersInstanceProps<D>,
|
||||
UseGroupByInstanceProps<D>,
|
||||
UsePaginationInstanceProps<D>,
|
||||
UseRowSelectInstanceProps<D>,
|
||||
UseRowStateInstanceProps<D>,
|
||||
UseSortByInstanceProps<D> {}
|
||||
|
||||
export interface TableState<D extends Record<string, unknown> = Record<string, unknown>>
|
||||
extends UseColumnOrderState<D>,
|
||||
UseExpandedState<D>,
|
||||
UseFiltersState<D>,
|
||||
UseGlobalFiltersState<D>,
|
||||
UseGroupByState<D>,
|
||||
UsePaginationState<D>,
|
||||
UseResizeColumnsState<D>,
|
||||
UseRowSelectState<D>,
|
||||
UseRowStateState<D>,
|
||||
UseSortByState<D> {}
|
||||
|
||||
export interface ColumnInterface<D extends Record<string, unknown> = Record<string, unknown>>
|
||||
extends UseFiltersColumnOptions<D>,
|
||||
UseGlobalFiltersColumnOptions<D>,
|
||||
UseGroupByColumnOptions<D>,
|
||||
UseResizeColumnsColumnOptions<D>,
|
||||
UseSortByColumnOptions<D> {}
|
||||
|
||||
export interface ColumnInstance<D extends Record<string, unknown> = Record<string, unknown>>
|
||||
extends UseFiltersColumnProps<D>,
|
||||
UseGroupByColumnProps<D>,
|
||||
UseResizeColumnsColumnProps<D>,
|
||||
UseSortByColumnProps<D> {}
|
||||
|
||||
export interface Cell<D extends Record<string, unknown> = Record<string, unknown>, V = any>
|
||||
extends UseGroupByCellProps<D>,
|
||||
UseRowStateCellProps<D> {}
|
||||
|
||||
export interface Row<D extends Record<string, unknown> = Record<string, unknown>>
|
||||
extends UseExpandedRowProps<D>,
|
||||
UseGroupByRowProps<D>,
|
||||
UseRowSelectRowProps<D>,
|
||||
UseRowStateRowProps<D> {}
|
||||
}
|
78
src/App.tsx
Normal file
|
@ -0,0 +1,78 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { useMatch, Router, navigate, RouteComponentProps, Redirect } from '@reach/router';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
|
||||
import { isValid } from 'src/services/api';
|
||||
import { useAuth } from 'src/services/auth';
|
||||
import { Apps, Dashboard, Users, Login, AppSingle } from './modules';
|
||||
import { Layout } from './components';
|
||||
|
||||
type AppProps = RouteComponentProps;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
function App(_: AppProps) {
|
||||
const { auth } = useAuth();
|
||||
const isLoginPage = useMatch('/login');
|
||||
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [initializedToken, setInitializedToken] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isValid(auth) && !isLoginPage) {
|
||||
navigate('/login');
|
||||
}
|
||||
}, [auth, isLoginPage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isValid(auth) && (!initialized || initializedToken !== auth.token)) {
|
||||
setInitialized(true);
|
||||
setInitializedToken(auth.token);
|
||||
}
|
||||
}, [auth, initialized, initializedToken]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>Stackspin</title>
|
||||
<meta name="description" content="Stackspin" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<meta name="msapplication-TileColor" content="#da532c" />
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
</Helmet>
|
||||
|
||||
<div className="app bg-gray-50 min-h-screen flex flex-col">
|
||||
{!isValid(auth) ? (
|
||||
<Router>
|
||||
<Login path="/login" />
|
||||
</Router>
|
||||
) : (
|
||||
<Layout>
|
||||
<Router>
|
||||
<Dashboard path="/dashboard" />
|
||||
<Users path="/users" />
|
||||
<Apps path="/apps" />
|
||||
<AppSingle path="/apps/:id" />
|
||||
</Router>
|
||||
</Layout>
|
||||
)}
|
||||
|
||||
{isValid(auth) ? <Redirect from="/" to="/dashboard" noThrow /> : <Redirect from="/" to="/login" noThrow />}
|
||||
|
||||
{/* Place to load notifications */}
|
||||
<div
|
||||
aria-live="assertive"
|
||||
className="fixed inset-0 flex items-end px-4 py-6 pointer-events-none sm:p-6 sm:items-start"
|
||||
>
|
||||
<div className="w-full flex flex-col items-center space-y-4 sm:items-end" />
|
||||
</div>
|
||||
</div>
|
||||
<Toaster />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
19
src/common/const.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
export const DEFAULT_API_DATETIME_FORMAT = "yyyy-MM-dd'T'HH:mm:ss";
|
||||
|
||||
export const DEFAULT_API_DATE_FORMAT = 'yyyy-MM-dd';
|
||||
|
||||
export const DEFAULT_DATE_FORMAT = 'dd/MM/yyyy HH:mm';
|
||||
|
||||
export const MAX_TABLE_ROWS = 15;
|
||||
|
||||
export const VALIDATION_MESSAGES = {
|
||||
required: (name: string) => `${name} cannot be blank`,
|
||||
invalidEmail: () => 'Email address is invalid',
|
||||
passwordConfirmation: () => 'Passwords do not match',
|
||||
min: (name: string, min: number) => `${name} must be at least ${min}`,
|
||||
};
|
||||
|
||||
export const BACK_TO_PAGE = 'prevPage';
|
||||
export const BACK_TO_SEARCH = 'prevSearch';
|
||||
export const RETURN_TO = 'returnTo';
|
||||
export const RICH_EDITOR_DEFAULT_TAB = 'richEditorTab';
|
1
src/common/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { isTouched, addParamsToLink } from './util';
|
39
src/common/types.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
export interface ImageApiData {
|
||||
data: {
|
||||
id: number;
|
||||
fileId: number;
|
||||
name: string;
|
||||
isMain: boolean;
|
||||
url: string;
|
||||
smallUrl: string;
|
||||
mediumUrl: string;
|
||||
originalFileId: number;
|
||||
mediumFileId: number;
|
||||
smallFileId: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ImageMediaData {
|
||||
id: number;
|
||||
fileId: number;
|
||||
name: string;
|
||||
isMain: boolean;
|
||||
url: string;
|
||||
smallUrl: string;
|
||||
mediumUrl: string;
|
||||
originalFileId: number;
|
||||
mediumFileId: number;
|
||||
smallFileId: number;
|
||||
smallFile?: {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type ImageUploaderValue = File | string | ImageMediaData;
|
||||
|
||||
export type Breadcrumb = {
|
||||
label: string;
|
||||
href: string;
|
||||
};
|
17
src/common/util/add-params-to-link.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import urlcat from 'urlcat';
|
||||
import { BACK_TO_PAGE, BACK_TO_SEARCH, RETURN_TO, RICH_EDITOR_DEFAULT_TAB } from '../const';
|
||||
|
||||
type UrlParams = {
|
||||
currentPage?: number;
|
||||
prevSearch?: string;
|
||||
returnTo?: [string, number | string];
|
||||
richEditorTab?: string;
|
||||
};
|
||||
export function addParamsToLink(url: string, params: UrlParams) {
|
||||
return urlcat(url, {
|
||||
[BACK_TO_PAGE]: params.currentPage,
|
||||
[BACK_TO_SEARCH]: params.prevSearch,
|
||||
[RETURN_TO]: params.returnTo?.join(':'),
|
||||
[RICH_EDITOR_DEFAULT_TAB]: params.richEditorTab,
|
||||
});
|
||||
}
|
2
src/common/util/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export { isTouched } from './is-touched';
|
||||
export { addParamsToLink } from './add-params-to-link';
|
6
src/common/util/is-touched.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { get } from 'lodash';
|
||||
import { FormState } from 'react-hook-form';
|
||||
|
||||
export function isTouched<T>(formState: FormState<T>, fieldName: keyof T | string) {
|
||||
return formState.isSubmitted || get(formState.touchedFields, fieldName);
|
||||
}
|
92
src/common/util/show-toast.tsx
Normal file
|
@ -0,0 +1,92 @@
|
|||
import React, { Fragment } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import { XCircleIcon, CheckCircleIcon } from '@heroicons/react/outline';
|
||||
import { XIcon } from '@heroicons/react/solid';
|
||||
|
||||
export enum ToastType {
|
||||
Success = 'success',
|
||||
Error = 'error',
|
||||
}
|
||||
|
||||
export const showToast = (text: string, type?: ToastType) => {
|
||||
switch (type) {
|
||||
case ToastType.Error:
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<Transition
|
||||
show={t.visible}
|
||||
as={Fragment}
|
||||
enter="transform ease-out duration-300 transition"
|
||||
enterFrom="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
|
||||
enterTo="translate-y-0 opacity-100 sm:translate-x-0"
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="max-w-sm w-full bg-white shadow-lg rounded-lg pointer-events-auto ring-1 ring-black ring-opacity-5 overflow-hidden">
|
||||
<div className="p-4">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<XCircleIcon className="h-6 w-6 text-red-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ml-3 w-0 flex-1 pt-0.5">
|
||||
<p className="text-sm font-medium text-gray-900">{text}</p>
|
||||
</div>
|
||||
<div className="ml-4 flex-shrink-0 flex">
|
||||
<button
|
||||
className="bg-white rounded-md inline-flex text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
onClick={() => toast.dismiss(t.id)}
|
||||
>
|
||||
<span className="sr-only">Close</span>
|
||||
<XIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
),
|
||||
{ position: 'top-right' },
|
||||
);
|
||||
break;
|
||||
default:
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<Transition
|
||||
show={t.visible}
|
||||
as={Fragment}
|
||||
enter="transform ease-out duration-300 transition"
|
||||
enterFrom="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
|
||||
enterTo="translate-y-0 opacity-100 sm:translate-x-0"
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="max-w-sm w-full bg-white shadow-lg rounded-lg pointer-events-auto ring-1 ring-black ring-opacity-5 overflow-hidden">
|
||||
<div className="p-4">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<CheckCircleIcon className="h-6 w-6 text-primary" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ml-3 w-0 flex-1 pt-0.5">
|
||||
<p className="text-sm font-medium text-gray-900">{text}</p>
|
||||
</div>
|
||||
<div className="ml-4 flex-shrink-0 flex">
|
||||
<button
|
||||
className="bg-white rounded-md inline-flex text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
onClick={() => toast.dismiss(t.id)}
|
||||
>
|
||||
<span className="sr-only">Close</span>
|
||||
<XIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
),
|
||||
{ position: 'top-right' },
|
||||
);
|
||||
}
|
||||
};
|
134
src/components/Header/Header.tsx
Normal file
|
@ -0,0 +1,134 @@
|
|||
import React, { Fragment } from 'react';
|
||||
import { Disclosure, Menu, Transition } from '@headlessui/react';
|
||||
import { MenuIcon, XIcon } from '@heroicons/react/outline';
|
||||
import { Link, RouteComponentProps } from '@reach/router';
|
||||
import { useAuth } from 'src/services/auth';
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', to: '/dashboard' },
|
||||
{ name: 'Apps', to: '/apps' },
|
||||
{ name: 'Users', to: '/users' },
|
||||
];
|
||||
|
||||
function classNames(...classes: any[]) {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
type HeaderProps = RouteComponentProps;
|
||||
|
||||
const Header: React.FC<HeaderProps> = () => {
|
||||
const { logOut } = useAuth();
|
||||
|
||||
return (
|
||||
<Disclosure as="nav" className="bg-white shadow">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div className="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8">
|
||||
<div className="relative flex justify-between h-16">
|
||||
<div className="absolute inset-y-0 left-0 flex items-center sm:hidden">
|
||||
{/* Mobile menu button */}
|
||||
<Disclosure.Button className="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary-500">
|
||||
<span className="sr-only">Open main menu</span>
|
||||
{open ? (
|
||||
<XIcon className="block h-6 w-6" aria-hidden="true" />
|
||||
) : (
|
||||
<MenuIcon className="block h-6 w-6" aria-hidden="true" />
|
||||
)}
|
||||
</Disclosure.Button>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center sm:items-stretch sm:justify-start">
|
||||
<div className="flex-shrink-0 flex items-center">
|
||||
<img className="block lg:hidden" src="/assets/logo-small.svg" alt="Stackspin" />
|
||||
<img className="hidden lg:block" src="/assets/logo.svg" alt="Stackspin" />
|
||||
</div>
|
||||
<div className="hidden sm:ml-6 sm:flex sm:space-x-8">
|
||||
{/* Current: "border-primary-500 text-gray-900", Default: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700" */}
|
||||
{navigation.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.to}
|
||||
getProps={({ isCurrent }) => {
|
||||
// the object returned here is passed to the
|
||||
// anchor element's props
|
||||
return {
|
||||
className: isCurrent
|
||||
? 'border-primary-400 text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium',
|
||||
'aria-current': isCurrent ? 'page' : undefined,
|
||||
};
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-2 sm:static sm:inset-auto sm:ml-6 sm:pr-0">
|
||||
{/* Profile dropdown */}
|
||||
<Menu as="div" className="ml-3 relative">
|
||||
<div>
|
||||
<Menu.Button className="bg-white rounded-full flex text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary">
|
||||
<span className="sr-only">Open user menu</span>
|
||||
<img
|
||||
className="h-8 w-8 rounded-full"
|
||||
src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
|
||||
alt=""
|
||||
/>
|
||||
</Menu.Button>
|
||||
</div>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items className="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<a
|
||||
onClick={() => logOut()}
|
||||
className={classNames(active ? 'bg-gray-100' : '', 'block px-4 py-2 text-sm text-gray-700')}
|
||||
>
|
||||
Sign out
|
||||
</a>
|
||||
)}
|
||||
</Menu.Item>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Disclosure.Panel className="sm:hidden">
|
||||
<div className="pt-2 pb-4 space-y-1">
|
||||
{navigation.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.to}
|
||||
getProps={({ isCurrent }) => {
|
||||
// the object returned here is passed to the
|
||||
// anchor element's props
|
||||
return {
|
||||
className: isCurrent
|
||||
? 'bg-primary-50 border-primary-400 text-primary-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium'
|
||||
: 'border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium',
|
||||
'aria-current': isCurrent ? 'page' : undefined,
|
||||
};
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Disclosure.Panel>
|
||||
</>
|
||||
)}
|
||||
</Disclosure>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
1
src/components/Header/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { default as Header } from './Header';
|
17
src/components/Layout/Layout.tsx
Normal file
|
@ -0,0 +1,17 @@
|
|||
import React from 'react';
|
||||
import { RouteComponentProps } from '@reach/router';
|
||||
import { Header } from '../Header';
|
||||
|
||||
type DashboardProps = RouteComponentProps;
|
||||
|
||||
const Layout: React.FC<DashboardProps> = ({ children }) => {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
1
src/components/Layout/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { default as Layout } from './Layout';
|
89
src/components/Modal/ConfirmationModal/ConfirmationModal.tsx
Normal file
|
@ -0,0 +1,89 @@
|
|||
import React, { Fragment, useRef } from 'react';
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import { ExclamationIcon } from '@heroicons/react/outline';
|
||||
|
||||
type ConfirmationModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
export const ConfirmationModal = ({ open, onClose, title, body }: ConfirmationModalProps) => {
|
||||
const cancelButtonRef = useRef(null);
|
||||
|
||||
return (
|
||||
<Transition.Root show={open} as={Fragment}>
|
||||
<Dialog
|
||||
as="div"
|
||||
auto-reopen="true"
|
||||
className="fixed z-10 inset-0 overflow-y-auto"
|
||||
initialFocus={cancelButtonRef}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
{/* This element is to trick the browser into centering the modal contents. */}
|
||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
||||
​
|
||||
</span>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
||||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<ExclamationIcon className="h-6 w-6 text-red-600" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<Dialog.Title as="h3" className="text-lg leading-6 font-medium text-gray-900">
|
||||
{title}
|
||||
</Dialog.Title>
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-gray-500">{body}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
onClick={onClose}
|
||||
ref={cancelButtonRef}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
1
src/components/Modal/ConfirmationModal/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { ConfirmationModal } from './ConfirmationModal';
|
1
src/components/Modal/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { ConfirmationModal } from './ConfirmationModal';
|
178
src/components/Table/Table.tsx
Normal file
|
@ -0,0 +1,178 @@
|
|||
import { ArrowSmDownIcon, ArrowSmUpIcon } from '@heroicons/react/solid';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useTable, useRowSelect, Column, IdType, useSortBy } from 'react-table';
|
||||
|
||||
export interface ReactTableProps<T extends Record<string, unknown>> {
|
||||
columns: Column<T>[];
|
||||
data: T[];
|
||||
onRowClick?(row: T): void;
|
||||
pagination?: boolean;
|
||||
getSelectedRowIds?(rows: Record<IdType<T>, boolean>): void;
|
||||
selectable?: boolean;
|
||||
}
|
||||
|
||||
const IndeterminateCheckbox = React.forwardRef(({ indeterminate, ...rest }: any, ref) => {
|
||||
const defaultRef = React.useRef(null);
|
||||
const resolvedRef: any = ref || defaultRef;
|
||||
|
||||
React.useEffect(() => {
|
||||
resolvedRef.current.indeterminate = indeterminate;
|
||||
}, [resolvedRef, indeterminate]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
type="checkbox"
|
||||
ref={resolvedRef}
|
||||
{...rest}
|
||||
className="focus:ring-primary-800 h-4 w-4 text-primary-700 border-gray-300 rounded"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export const Table = <T extends Record<string, unknown>>({
|
||||
columns,
|
||||
data,
|
||||
pagination = false,
|
||||
onRowClick,
|
||||
getSelectedRowIds,
|
||||
selectable = false,
|
||||
}: ReactTableProps<T>) => {
|
||||
const {
|
||||
getTableProps,
|
||||
getTableBodyProps,
|
||||
headerGroups,
|
||||
rows,
|
||||
prepareRow,
|
||||
pageCount,
|
||||
state: { selectedRowIds },
|
||||
} = useTable(
|
||||
{
|
||||
columns,
|
||||
data,
|
||||
},
|
||||
useSortBy,
|
||||
useRowSelect,
|
||||
selectable
|
||||
? (hooks) => {
|
||||
hooks.visibleColumns.push((columns2) => [
|
||||
{
|
||||
id: 'selection',
|
||||
Header: ({ getToggleAllRowsSelectedProps }: { getToggleAllRowsSelectedProps: any }) => (
|
||||
<div>
|
||||
<IndeterminateCheckbox {...getToggleAllRowsSelectedProps()} />
|
||||
</div>
|
||||
),
|
||||
Cell: ({ row }: { row: any }) => (
|
||||
<div>
|
||||
<IndeterminateCheckbox {...row.getToggleRowSelectedProps()} />
|
||||
</div>
|
||||
),
|
||||
width: 16,
|
||||
},
|
||||
...columns2,
|
||||
]);
|
||||
}
|
||||
: () => {},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedRowIds && getSelectedRowIds) {
|
||||
getSelectedRowIds(selectedRowIds);
|
||||
}
|
||||
}, [selectedRowIds, getSelectedRowIds]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<table className="min-w-full divide-y divide-gray-200 table-auto" {...getTableProps()}>
|
||||
<thead className="bg-gray-50">
|
||||
{headerGroups.map((headerGroup: any, index: any) => (
|
||||
<tr {...headerGroup.getHeaderGroupProps()} key={index!}>
|
||||
{headerGroup.headers.map((column: any) => (
|
||||
<th
|
||||
key={column}
|
||||
{...column.getHeaderProps([
|
||||
{
|
||||
style: {
|
||||
width: column.width ? column.width : 'auto !important',
|
||||
},
|
||||
},
|
||||
column.getSortByToggleProps(),
|
||||
])}
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span>{column.render('Header')}</span>
|
||||
{(column as any).isSorted ? (
|
||||
(column as any).isSortedDesc ? (
|
||||
<ArrowSmDownIcon className="w-4 h-4 text-gray-400 ml-1" />
|
||||
) : (
|
||||
<ArrowSmUpIcon className="w-4 h-4 text-gray-400 ml-1" />
|
||||
)
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody {...getTableBodyProps()}>
|
||||
{rows.map((row: any, rowIndex) => {
|
||||
prepareRow(row);
|
||||
return (
|
||||
<tr
|
||||
key={row}
|
||||
{...row.getRowProps()}
|
||||
className={rowIndex % 2 === 0 ? 'bg-white group' : 'bg-gray-50 group'}
|
||||
onClick={onRowClick ? () => onRowClick(row.original as T) : () => {}}
|
||||
>
|
||||
{row.cells.map((cell: any) => {
|
||||
return (
|
||||
<td
|
||||
key={cell}
|
||||
{...cell.getCellProps()}
|
||||
className="px-6 py-4 whitespace-nowrap text-sm text-gray-500"
|
||||
>
|
||||
{cell.render('Cell')}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{pagination && pageCount > 1 && (
|
||||
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-100 sm:px-6">
|
||||
<div className="flex-1 flex justify-between sm:hidden">
|
||||
<a
|
||||
href="#"
|
||||
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
||||
>
|
||||
Previous
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
||||
>
|
||||
Next
|
||||
</a>
|
||||
</div>
|
||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-700">
|
||||
Showing <span className="font-medium">1</span> to <span className="font-medium">3</span> of{' '}
|
||||
<span className="font-medium">3</span> results
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
1
src/components/Table/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { Table } from './Table';
|
8
src/components/Tabs/TabPanel.tsx
Normal file
|
@ -0,0 +1,8 @@
|
|||
import React from 'react';
|
||||
import { TabPanelProps } from './types';
|
||||
|
||||
export const TabPanel = ({ children, isActive }: TabPanelProps) => {
|
||||
if (!isActive) return null;
|
||||
|
||||
return <div className="pt-8 pb-4">{children}</div>;
|
||||
};
|
64
src/components/Tabs/Tabs.tsx
Normal file
|
@ -0,0 +1,64 @@
|
|||
import React, { useState } from 'react';
|
||||
import { TabPanel } from './TabPanel';
|
||||
import { TabsProps } from './types';
|
||||
|
||||
export const Tabs = ({ tabs }: TabsProps) => {
|
||||
const [activeTabIndex, setActiveTabIndex] = useState<number>(0);
|
||||
|
||||
const handleTabPress = (index: number) => () => {
|
||||
setActiveTabIndex(index);
|
||||
};
|
||||
|
||||
function classNames(...classes: any) {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sm:hidden">
|
||||
<label htmlFor="tabs" className="sr-only">
|
||||
Select a tab
|
||||
</label>
|
||||
<select
|
||||
id="tabs"
|
||||
name="tabs"
|
||||
className="block w-full focus:ring-primary-500 focus:border-primary-500 border-gray-300 rounded-md"
|
||||
// defaultValue={tabs ? tabs.find((tab) => tab.current).name : undefined}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<option key={tab.name}>{tab.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="hidden sm:block">
|
||||
<nav className="flex space-x-4" aria-label="Tabs">
|
||||
{tabs.map((tab, tabIndex) => (
|
||||
<a
|
||||
onClick={handleTabPress(tabIndex)}
|
||||
key={tab.name}
|
||||
className={classNames(
|
||||
activeTabIndex === tabIndex
|
||||
? 'bg-gray-100 text-gray-700'
|
||||
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50',
|
||||
'px-3 py-2 font-medium text-sm rounded-md cursor-pointer',
|
||||
)}
|
||||
aria-current={activeTabIndex === tabIndex ? 'page' : undefined}
|
||||
>
|
||||
{tab.name}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{tabs.map(({ component, name, disabled }, index) =>
|
||||
disabled ? (
|
||||
<React.Fragment key={name} />
|
||||
) : (
|
||||
<TabPanel key={name} isActive={activeTabIndex === index}>
|
||||
{component}
|
||||
</TabPanel>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
1
src/components/Tabs/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { Tabs } from './Tabs';
|
21
src/components/Tabs/types.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
type Tab = {
|
||||
disabled?: boolean;
|
||||
name: string;
|
||||
component: JSX.Element;
|
||||
};
|
||||
|
||||
export interface TabsProps {
|
||||
tabs: Tab[];
|
||||
}
|
||||
|
||||
export interface TabPanelProps {
|
||||
isActive: boolean;
|
||||
children: JSX.Element | JSX.Element[];
|
||||
}
|
||||
|
||||
export interface TabProps {
|
||||
title: string;
|
||||
isActive: boolean;
|
||||
onPress(): void;
|
||||
disabled: boolean;
|
||||
}
|
4
src/components/index.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export { Layout } from './Layout';
|
||||
export { Header } from './Header';
|
||||
export { Table } from './Table';
|
||||
export { Tabs } from './Tabs';
|
7
src/index.css
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
div[tabindex] {
|
||||
flex: 1;
|
||||
}
|
53
src/index.tsx
Normal file
|
@ -0,0 +1,53 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import axios from 'axios';
|
||||
import { Provider } from 'react-redux';
|
||||
import { Router } from '@reach/router';
|
||||
import { PersistGate } from 'redux-persist/integration/react';
|
||||
|
||||
import { isValid } from './services/api';
|
||||
import { configureStore } from './redux';
|
||||
import { AuthActionTypes } from './services/auth/redux';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
const { store, persistor } = configureStore();
|
||||
|
||||
axios.interceptors.request.use(
|
||||
(config) => {
|
||||
const { auth } = store.getState();
|
||||
if (isValid(auth)) {
|
||||
config.headers.Authorization = `Bearer ${auth.token}`;
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
// when we receive 401 error, we sign out the logged in user
|
||||
axios.interceptors.response.use(
|
||||
(response: any) => response,
|
||||
(error: any) => {
|
||||
if (error.response !== undefined && error.response.status === 401) {
|
||||
store.dispatch({ type: AuthActionTypes.SIGN_OUT, payload: error });
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
<Provider store={store}>
|
||||
<PersistGate loading={null} persistor={persistor}>
|
||||
<Router>
|
||||
<App path="*" />
|
||||
</Router>
|
||||
</PersistGate>
|
||||
</Provider>
|
||||
</React.StrictMode>,
|
||||
document.getElementById('root'),
|
||||
);
|
158
src/modules/apps/Advanced.tsx
Normal file
|
@ -0,0 +1,158 @@
|
|||
import React, { Fragment } from 'react';
|
||||
import Editor from 'react-simple-code-editor';
|
||||
import { Menu, Transition } from '@headlessui/react';
|
||||
import { ChevronDownIcon } from '@heroicons/react/solid';
|
||||
import { highlight, languages } from 'prismjs';
|
||||
import 'prismjs/components/prism-clike';
|
||||
import 'prismjs/components/prism-yaml';
|
||||
import 'prismjs/themes/prism.css';
|
||||
import { initialEditorYaml } from './consts';
|
||||
|
||||
function classNames(...classes: any) {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
export const Advanced = () => {
|
||||
const [code, setCode] = React.useState(initialEditorYaml);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-flow-col grid-cols-2 gap-8">
|
||||
<div>
|
||||
<div>
|
||||
<div className="px-4 h-16 sm:px-6 bg-gray-200 flex justify-between items-center rounded-t-lg">
|
||||
<span className="text-gray-600 text-lg leading-6 font-medium">Edit Configuration</span>
|
||||
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
<div>
|
||||
<Menu.Button className="inline-flex justify-center w-full rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 focus:ring-primary-500">
|
||||
Versions
|
||||
<ChevronDownIcon className="-mr-1 ml-2 h-5 w-5" aria-hidden="true" />
|
||||
</Menu.Button>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items className="z-10 origin-top-right absolute right-0 mt-2 w-56 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<div className="py-1">
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<a
|
||||
href="#"
|
||||
className={classNames(
|
||||
active ? 'bg-gray-100 text-gray-900' : 'text-gray-700',
|
||||
'block px-4 py-2 text-sm',
|
||||
)}
|
||||
>
|
||||
Save Version
|
||||
</a>
|
||||
)}
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<a
|
||||
href="#"
|
||||
className={classNames(
|
||||
active ? 'bg-gray-100 text-gray-900' : 'text-gray-700',
|
||||
'block px-4 py-2 text-sm',
|
||||
)}
|
||||
>
|
||||
View Version History
|
||||
</a>
|
||||
)}
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<a
|
||||
href="#"
|
||||
className={classNames(
|
||||
active ? 'bg-gray-100 text-gray-900' : 'text-gray-700',
|
||||
'block px-4 py-2 text-sm',
|
||||
)}
|
||||
>
|
||||
Restore Current Version
|
||||
</a>
|
||||
)}
|
||||
</Menu.Item>
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div>
|
||||
<div className="px-4 py-5 sm:p-6 border border-t-0 border-gray-200">
|
||||
<Editor
|
||||
value={code}
|
||||
onValueChange={(value) => setCode(value)}
|
||||
highlight={(value) => highlight(value, languages.js, 'yaml')}
|
||||
preClassName="font-mono whitespace-normal font-light"
|
||||
textareaClassName="font-mono overflow-auto font-light"
|
||||
className="font-mono text-sm font-light"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="bg-gray-100 overflow-hidden rounded-lg">
|
||||
<div className="px-4 h-16 sm:px-6 bg-gray-200 flex items-center">
|
||||
<span className="text-gray-600 text-lg leading-6 font-medium">Current Configuration</span>
|
||||
</div>
|
||||
<div className="px-4 py-5 sm:p-6 overflow-x-auto">
|
||||
<pre className="font-mono text-sm font-light">
|
||||
{`luck: except
|
||||
natural: still
|
||||
near: though
|
||||
search:
|
||||
- feature
|
||||
- - 1980732354.689713
|
||||
- hour
|
||||
- butter:
|
||||
ordinary: 995901949.8974948
|
||||
teeth: true
|
||||
whole:
|
||||
- -952367353
|
||||
- - talk: -1773961379
|
||||
temperature: false
|
||||
oxygen: true
|
||||
laugh:
|
||||
flag:
|
||||
in: 2144751662
|
||||
hospital: -1544066384.1973226
|
||||
law: congress
|
||||
great: stomach`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end mt-10">
|
||||
<button
|
||||
type="button"
|
||||
className="mr-3 inline-flex items-center px-4 py-2 border border-gray-200 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="mr-3 inline-flex items-center px-4 py-2 shadow-sm text-sm font-medium rounded-md text-primary-700 bg-primary-100 hover:bg-primary-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
||||
>
|
||||
Verify
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center px-4 py-2 shadow-sm text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
||||
>
|
||||
Save changes
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
97
src/modules/apps/AppSingle.tsx
Normal file
|
@ -0,0 +1,97 @@
|
|||
import React from 'react';
|
||||
import { Link, RouteComponentProps, RouterProps } from '@reach/router';
|
||||
import { ChevronRightIcon } from '@heroicons/react/solid';
|
||||
import { XCircleIcon } from '@heroicons/react/outline';
|
||||
import { Tabs } from 'src/components';
|
||||
import { GeneralTab } from './GeneralTab';
|
||||
import { Secrets } from './Secrets';
|
||||
import { Advanced } from './Advanced';
|
||||
|
||||
type AppSingleProps = RouteComponentProps & RouterProps;
|
||||
|
||||
const pages = [
|
||||
{ name: 'Apps', to: '/apps', current: true },
|
||||
{ name: 'Nextcloud', to: '', current: false },
|
||||
];
|
||||
|
||||
const tabs = [
|
||||
{ name: 'General', component: <GeneralTab /> },
|
||||
{ name: 'Secrets & Passwords', component: <Secrets /> },
|
||||
{ name: 'Advanced Configuration', component: <Advanced /> },
|
||||
];
|
||||
|
||||
export const AppSingle: React.FC<AppSingleProps> = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="max-w-7xl mx-auto py-4 sm:px-6 lg:px-8 flex-grow">
|
||||
<nav className="flex" aria-label="Breadcrumb">
|
||||
<ol className="flex items-center space-x-4">
|
||||
<li>
|
||||
<div className="flex items-center">
|
||||
<Link to="/dashboard" className="text-sm font-medium text-gray-500 hover:text-gray-700">
|
||||
<span>Dashboard</span>
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
{pages.map((page) => (
|
||||
<li key={page.name}>
|
||||
<div className="flex items-center">
|
||||
<ChevronRightIcon className="flex-shrink-0 h-5 w-5 text-gray-400" aria-hidden="true" />
|
||||
<Link
|
||||
to={page.to}
|
||||
className="ml-4 text-sm font-medium text-gray-500 hover:text-gray-700"
|
||||
aria-current={page.current ? 'page' : undefined}
|
||||
>
|
||||
{page.name}
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto py-4 sm:px-6 lg:px-8">
|
||||
<div className="bg-white overflow-hidden shadow rounded-sm mb-5">
|
||||
<div className="px-4 py-5 sm:p-6 flex justify-between items-center">
|
||||
<div className="mr-4 flex items-center">
|
||||
<img
|
||||
className="h-24 w-24 rounded-md overflow-hidden mr-4"
|
||||
src="./../assets/nextcloud.svg"
|
||||
alt="Nextcloud"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<h2 className="text-2xl leading-8 font-bold">Nextcloud</h2>
|
||||
<div className="text-sm leading-5 font-medium text-gray-500">Installed on August 25, 2020</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
className="mb-3 inline-flex items-center px-4 py-2 shadow-sm text-sm font-medium rounded-md text-yellow-900 bg-yellow-300 hover:bg-yellow-400 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 justify-center"
|
||||
>
|
||||
<XCircleIcon className="-ml-0.5 mr-2 h-4 w-4 text-yellow-900" aria-hidden="true" />
|
||||
Disable App
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-200 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 justify-center"
|
||||
>
|
||||
View Documentation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white shadow rounded-sm">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<Tabs tabs={tabs} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
218
src/modules/apps/Apps.tsx
Normal file
|
@ -0,0 +1,218 @@
|
|||
import React, { useState, useCallback } from 'react';
|
||||
import { RouteComponentProps, navigate, Link } from '@reach/router';
|
||||
import { ChevronRightIcon, SearchIcon, PlusIcon } from '@heroicons/react/solid';
|
||||
import { CogIcon, TrashIcon } from '@heroicons/react/outline';
|
||||
import { ConfirmationModal } from 'src/components/Modal';
|
||||
import { Table } from 'src/components';
|
||||
|
||||
type AppsProps = RouteComponentProps;
|
||||
|
||||
const pages = [{ name: 'Apps', href: '#', current: true }];
|
||||
|
||||
export const Apps: React.FC<AppsProps> = () => {
|
||||
const [selectedRowsIds, setSelectedRowsIds] = useState({});
|
||||
const [deleteModal, setDeleteModal] = useState(false);
|
||||
|
||||
const deleteModalOpen = () => setDeleteModal(true);
|
||||
const deleteModalClose = () => setDeleteModal(false);
|
||||
|
||||
const columns: any = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
Header: 'Name',
|
||||
accessor: 'name',
|
||||
Cell: (e: any) => {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0 h-10 w-10">
|
||||
<img className="h-10 w-10 rounded-md overflow-hidden" src={e.cell.row.original.assetSrc} alt="" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium text-gray-900">{e.cell.row.original.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
width: 'auto',
|
||||
},
|
||||
{
|
||||
Header: 'Status',
|
||||
accessor: 'status',
|
||||
Cell: (e: any) => {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0 h-4 w-4 rounded-full bg-green-600" />
|
||||
<div className="ml-2 text-sm text-green-600">{e.cell.row.original.status}</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
width: 'auto',
|
||||
},
|
||||
{
|
||||
Header: ' ',
|
||||
Cell: (e: any) => {
|
||||
return (
|
||||
<div className="text-right opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => navigate(`/apps/${e.cell.row.original.id}`)}
|
||||
type="button"
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-200 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
||||
>
|
||||
<CogIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
|
||||
Configure
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
width: 'auto',
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const data: any[] = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Nextcloud',
|
||||
status: 'Active for everyone',
|
||||
assetSrc: './assets/nextcloud.svg',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Wekan',
|
||||
status: 'Active for everyone',
|
||||
assetSrc: './assets/wekan.svg',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Rocketchat',
|
||||
status: 'Active for everyone',
|
||||
assetSrc: './assets/rocketchat.svg',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Wordpress',
|
||||
status: 'Active for everyone',
|
||||
assetSrc: './assets/wordpress.svg',
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const selectedRows = useCallback((rows: Record<string, boolean>) => {
|
||||
setSelectedRowsIds(rows);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto py-4 sm:px-6 lg:px-8 flex-grow">
|
||||
<nav className="flex mb-8" aria-label="Breadcrumb">
|
||||
<ol className="flex items-center space-x-4">
|
||||
<li>
|
||||
<div className="flex items-center">
|
||||
<Link to="/dashboard" className="text-sm font-medium text-gray-500 hover:text-gray-700">
|
||||
<span>Dashboard</span>
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
{pages.map((page) => (
|
||||
<li key={page.name}>
|
||||
<div className="flex items-center">
|
||||
<ChevronRightIcon className="flex-shrink-0 h-5 w-5 text-gray-400" aria-hidden="true" />
|
||||
<a
|
||||
href={page.href}
|
||||
className="ml-4 text-sm font-medium text-gray-500 hover:text-gray-700"
|
||||
aria-current={page.current ? 'page' : undefined}
|
||||
>
|
||||
{page.name}
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div className="pb-5 border-b border-gray-200 sm:flex sm:items-center sm:justify-between">
|
||||
<h1 className="text-3xl leading-6 font-bold text-gray-900">Apps</h1>
|
||||
<div className="mt-3 sm:mt-0 sm:ml-4">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary-700 hover:bg-primary-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-800"
|
||||
>
|
||||
<PlusIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
|
||||
Add new app
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between w-100 my-3 items-center">
|
||||
<div>
|
||||
<div className="mr-3 inline-block">
|
||||
<label htmlFor="location" className="block text-sm font-medium text-gray-700 sr-only">
|
||||
Location
|
||||
</label>
|
||||
<select
|
||||
id="location"
|
||||
name="location"
|
||||
className="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-200 focus:outline-none focus:ring-primary-dark focus:border-primary-dark sm:text-sm rounded-md"
|
||||
defaultValue="All apps"
|
||||
>
|
||||
<option>All apps</option>
|
||||
<option>Owner</option>
|
||||
<option>Admins</option>
|
||||
<option>Members</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 inline-block">
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 sr-only">
|
||||
Search candidates
|
||||
</label>
|
||||
<div className="mt-1 flex rounded-md shadow-sm">
|
||||
<div className="relative flex items-stretch flex-grow focus-within:z-10">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<SearchIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="email"
|
||||
id="email"
|
||||
className="focus:ring-primary-dark focus:border-primary-dark block w-full rounded-md pl-10 sm:text-sm border-gray-200"
|
||||
placeholder="Search Apps"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedRowsIds && Object.keys(selectedRowsIds).length !== 0 && (
|
||||
<button
|
||||
onClick={deleteModalOpen}
|
||||
type="button"
|
||||
className="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-red-700 bg-red-50 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
|
||||
>
|
||||
<TrashIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<div className="-my-2 sm:-mx-6 lg:-mx-8">
|
||||
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
|
||||
<div className="shadow border-b border-gray-200 sm:rounded-lg">
|
||||
<Table data={data} columns={columns} getSelectedRowIds={selectedRows} selectable />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmationModal
|
||||
open={deleteModal}
|
||||
onClose={deleteModalClose}
|
||||
title="Delete service"
|
||||
body="Are you sure you want to delete this service? All of your data will be permanently removed. This action cannot be undone."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
99
src/modules/apps/ChangeSecretModal.tsx
Normal file
|
@ -0,0 +1,99 @@
|
|||
import React, { Fragment, useRef } from 'react';
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
|
||||
type ChangeSecretModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSave?: () => void;
|
||||
};
|
||||
|
||||
export const ChangeSecretModal = ({ open, onClose, onSave = () => {} }: ChangeSecretModalProps) => {
|
||||
const cancelButtonRef = useRef(null);
|
||||
|
||||
return (
|
||||
<Transition.Root show={open} as={Fragment}>
|
||||
<Dialog
|
||||
as="div"
|
||||
auto-reopen="true"
|
||||
className="fixed z-10 inset-0 overflow-y-auto"
|
||||
initialFocus={cancelButtonRef}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
{/* This element is to trick the browser into centering the modal contents. */}
|
||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
||||
​
|
||||
</span>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
||||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div className="mt-3 text-center sm:mt-0 sm:text-left">
|
||||
<Dialog.Title as="h3" className="text-lg leading-6 font-medium text-gray-900">
|
||||
Change secret
|
||||
</Dialog.Title>
|
||||
<div className="my-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
<span className="font-bold text-red-500">CAUTION:</span> changing this might cause the app to
|
||||
break.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<label htmlFor="appSecret" className="block text-sm font-medium text-gray-700">
|
||||
App Secret
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="text"
|
||||
name="appSecret"
|
||||
id="appSecret"
|
||||
className="shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
placeholder="Enter New App Secret"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-primary-600 text-base font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
onClick={onSave}
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-200 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
onClick={onClose}
|
||||
ref={cancelButtonRef}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
161
src/modules/apps/GeneralTab.tsx
Normal file
|
@ -0,0 +1,161 @@
|
|||
import React, { useState } from 'react';
|
||||
import { RadioGroup } from '@headlessui/react';
|
||||
|
||||
function classNames(...classes: any) {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
const settings = [
|
||||
{ name: 'Public access', description: 'This project would be available to anyone who has the link' },
|
||||
{ name: 'Private to Project Members', description: 'Only members of this project would be able to access' },
|
||||
{ name: 'Private to you', description: 'You are the only one able to access this project' },
|
||||
];
|
||||
|
||||
export const GeneralTab = () => {
|
||||
const [selected, setSelected] = useState(settings[0]);
|
||||
|
||||
return (
|
||||
<div className="py-4">
|
||||
<div>
|
||||
<div className="md:grid md:grid-cols-3 md:gap-6">
|
||||
<div className="md:col-span-1">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900">Privacy</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">Change your app privacy</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 md:mt-0 md:col-span-2">
|
||||
<div className="mt-1">
|
||||
<RadioGroup value={selected} onChange={setSelected}>
|
||||
<RadioGroup.Label className="sr-only">Privacy setting</RadioGroup.Label>
|
||||
<div className="bg-white rounded-md -space-y-px">
|
||||
{settings.map((setting, settingIdx) => (
|
||||
<RadioGroup.Option
|
||||
key={setting.name}
|
||||
value={setting}
|
||||
className={({ checked }) =>
|
||||
classNames(
|
||||
settingIdx === 0 ? 'rounded-tl-md rounded-tr-md' : '',
|
||||
settingIdx === settings.length - 1 ? 'rounded-bl-md rounded-br-md' : '',
|
||||
checked ? 'bg-primary-50 border-primary-400 z-10' : 'border-gray-200',
|
||||
'relative border p-4 flex cursor-pointer focus:outline-none',
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ active, checked }) => (
|
||||
<>
|
||||
<span
|
||||
className={classNames(
|
||||
checked ? 'bg-primary-600 border-transparent' : 'bg-white border-gray-300',
|
||||
active ? 'ring-2 ring-offset-2 ring-primary-100' : '',
|
||||
'h-4 w-4 mt-0.5 cursor-pointer rounded-full border flex items-center justify-center',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="rounded-full bg-white w-1.5 h-1.5" />
|
||||
</span>
|
||||
<div className="ml-3 flex flex-col">
|
||||
<RadioGroup.Label
|
||||
as="span"
|
||||
className={classNames(
|
||||
checked ? 'text-primary-900' : 'text-gray-900',
|
||||
'block text-sm font-medium',
|
||||
)}
|
||||
>
|
||||
{setting.name}
|
||||
</RadioGroup.Label>
|
||||
<RadioGroup.Description
|
||||
as="span"
|
||||
className={classNames(checked ? 'text-primary-900' : 'text-gray-500', 'block text-sm')}
|
||||
>
|
||||
{setting.description}
|
||||
</RadioGroup.Description>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</RadioGroup.Option>
|
||||
))}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10">
|
||||
<div className="md:grid md:grid-cols-3 md:gap-6">
|
||||
<div className="md:col-span-1">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900">Notifications</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">Change you notifications settings</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 md:mt-0 md:col-span-2">
|
||||
<fieldset className="space-y-5 -mt-4">
|
||||
<legend className="sr-only">Notifications</legend>
|
||||
<div className="relative flex items-start">
|
||||
<div className="flex items-center h-5">
|
||||
<input
|
||||
id="comments"
|
||||
aria-describedby="comments-description"
|
||||
name="comments"
|
||||
type="checkbox"
|
||||
className="focus:ring-primary-500 h-4 w-4 text-primary-600 border-gray-300 rounded"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3 text-sm">
|
||||
<label htmlFor="comments" className="font-medium text-gray-700">
|
||||
Comments
|
||||
</label>
|
||||
<p id="comments-description" className="text-gray-500">
|
||||
Get notified when someones posts a comment on a posting.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="relative flex items-start">
|
||||
<div className="flex items-center h-5">
|
||||
<input
|
||||
id="candidates"
|
||||
aria-describedby="candidates-description"
|
||||
name="candidates"
|
||||
type="checkbox"
|
||||
className="focus:ring-primary-500 h-4 w-4 text-primary-600 border-gray-300 rounded"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3 text-sm">
|
||||
<label htmlFor="candidates" className="font-medium text-gray-700">
|
||||
Candidates
|
||||
</label>
|
||||
<p id="candidates-description" className="text-gray-500">
|
||||
Get notified when a candidate applies for a job.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="relative flex items-start">
|
||||
<div className="flex items-center h-5">
|
||||
<input
|
||||
id="offers"
|
||||
aria-describedby="offers-description"
|
||||
name="offers"
|
||||
type="checkbox"
|
||||
className="focus:ring-primary-500 h-4 w-4 text-primary-600 border-gray-300 rounded"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3 text-sm">
|
||||
<label htmlFor="offers" className="font-medium text-gray-700">
|
||||
Offers
|
||||
</label>
|
||||
<p id="offers-description" className="text-gray-500">
|
||||
Get notified when a candidate accepts or rejects an offer.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
134
src/modules/apps/Secrets.tsx
Normal file
|
@ -0,0 +1,134 @@
|
|||
import React, { useState } from 'react';
|
||||
import { ClipboardIcon, KeyIcon } from '@heroicons/react/outline';
|
||||
import { showToast } from 'src/common/util/show-toast';
|
||||
import { Table } from 'src/components';
|
||||
import { ChangeSecretModal } from './ChangeSecretModal';
|
||||
|
||||
export const Secrets = () => {
|
||||
const [secretModal, setSecretModal] = useState(false);
|
||||
|
||||
const secretModalOpen = () => setSecretModal(true);
|
||||
const secretModalClose = () => setSecretModal(false);
|
||||
|
||||
const secretsModalSave = () => {
|
||||
showToast('The secret has been saved successfully.');
|
||||
setSecretModal(false);
|
||||
};
|
||||
|
||||
const columns: any = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
Header: 'Name',
|
||||
accessor: 'name',
|
||||
width: '25%',
|
||||
},
|
||||
{
|
||||
Header: 'Secret',
|
||||
accessor: 'secret',
|
||||
Cell: (e: any) => {
|
||||
const [reveal, setReveal] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
{!reveal ? (
|
||||
<>
|
||||
<div className="w-48 bg-gray-200 h-4 rounded-full mr-4" />
|
||||
<button
|
||||
onClick={() => setReveal(true)}
|
||||
type="button"
|
||||
className="inline-flex items-center px-2.5 py-1.5 text-xs font-medium rounded text-gray-700 hover:bg-gray-200 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
||||
>
|
||||
Reveal secret
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div>{e.cell.row.original.secret}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: ' ',
|
||||
Cell: () => {
|
||||
return (
|
||||
<div className="flex items-center justify-end opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => showToast('The secret has been copied to your clipboard.')}
|
||||
type="button"
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-200 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 mr-3"
|
||||
>
|
||||
<ClipboardIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
|
||||
Copy Secret
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={secretModalOpen}
|
||||
type="button"
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-200 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
||||
>
|
||||
<KeyIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
|
||||
Change secret
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const data: any[] = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 1,
|
||||
name: 'nextcloud_mariadb_password',
|
||||
secret: 'xEtC2dvUsBVzoTA19fOyDF4IwpPSWW62I92F59s69EO9vp56',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'nextcloud_mariadb_root_password',
|
||||
secret: `OB.IHD:HPY{x<)>@+B'p(LUU@k-Wv=w(!@)?7Zq%@nE7GY]!`,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'nextcloud_password (admin login)',
|
||||
secret: `#B&>4A;O#XEF]_dE*zxF26>8fF:akgGL8gi#yALK{ZY[P$eE`,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'onlyoffice_jwt_secret',
|
||||
secret: `U),xxEbt*g~t[4:Jl6RU8Tih#3ExinoV|K?.gL/R%?;gzK)R`,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'onlyoffice_postgresql_password',
|
||||
secret: `U),xxEbt*g~t[4:Jl6RU8Tih#3ExinoV|K?.gL/R%?;gzK)R`,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'onlyoffice_rabbitmq_password',
|
||||
secret: `r7sFEUL&U(a;3yR;CaC7P,bPwUEKZOT=IyjJq%AWniY!ncP[`,
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="py-4">
|
||||
<div className="flex flex-col">
|
||||
<div className="-my-2 sm:-mx-6 lg:-mx-8">
|
||||
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
|
||||
<div className="border border-gray-200">
|
||||
<Table data={data} columns={columns} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChangeSecretModal open={secretModal} onClose={secretModalClose} onSave={secretsModalSave} />
|
||||
</>
|
||||
);
|
||||
};
|
23
src/modules/apps/consts.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
export const initialEditorYaml = () => {
|
||||
return `luck: except
|
||||
natural: still
|
||||
near: though
|
||||
search:
|
||||
- feature
|
||||
- - 1980732354.689713
|
||||
- hour
|
||||
- butter:
|
||||
ordinary: 995901949.8974948
|
||||
teeth: true
|
||||
whole:
|
||||
- -952367353
|
||||
- - talk: -1773961379
|
||||
temperature: false
|
||||
oxygen: true
|
||||
laugh:
|
||||
flag:
|
||||
in: 2144751662
|
||||
hospital: -1544066384.1973226
|
||||
law: congress
|
||||
great: stomach`;
|
||||
};
|
2
src/modules/apps/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export { Apps } from './Apps';
|
||||
export { AppSingle } from './AppSingle';
|
85
src/modules/dashboard/Dashboard.tsx
Normal file
|
@ -0,0 +1,85 @@
|
|||
import React from 'react';
|
||||
import { Link, navigate, RouteComponentProps } from '@reach/router';
|
||||
import clsx from 'clsx';
|
||||
import { DASHBOARD_APPS, DASHBOARD_QUICK_ACCESS } from './consts';
|
||||
|
||||
export const Dashboard: React.FC<RouteComponentProps> = () => {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto py-4 sm:px-6 lg:px-8 h-full flex-grow">
|
||||
<nav className="flex mb-8" aria-label="Breadcrumb">
|
||||
<ol className="flex items-center space-x-4">
|
||||
<li>
|
||||
<div className="flex items-center">
|
||||
<a href="#" className="text-sm font-medium text-gray-500 hover:text-gray-700">
|
||||
<span>Dashboard</span>
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<div className="pb-5 sm:flex sm:items-center sm:justify-between">
|
||||
<h1 className="text-3xl leading-6 font-bold text-gray-900">Dashboard</h1>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 my-10">
|
||||
{DASHBOARD_APPS.map((app) => {
|
||||
return (
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg divide-y divide-gray-100" key={app.name}>
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<div className="mr-4 flex items-center">
|
||||
<img className="h-16 w-16 rounded-md overflow-hidden mr-4" src={app.assetSrc} alt="Nextcloud" />
|
||||
|
||||
<div>
|
||||
<h2 className="text-2xl leading-8 font-bold">{app.name}</h2>
|
||||
<div className="text-sm leading-5 font-medium text-gray-500">Installed on August 25, 2020</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 py-4 sm:px-6 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="mr-3 inline-flex items-center px-4 py-2 border border-gray-200 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 justify-center"
|
||||
>
|
||||
View Documentation
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate(`/apps/${app.id}`)}
|
||||
type="button"
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
||||
>
|
||||
Configure
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="max-w-4xl mx-auto py-4 sm:px-6 lg:px-8 h-full flex-grow">
|
||||
<div className="pb-4 border-b border-gray-200 sm:flex sm:items-center">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Quick access</h3>
|
||||
</div>
|
||||
|
||||
<dl className="mt-5 grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{DASHBOARD_QUICK_ACCESS.map((item) => (
|
||||
<Link
|
||||
to={item.to}
|
||||
key={item.name}
|
||||
className={clsx('bg-white rounded-lg overflow-hidden sm:p-2 flex items-center group', {
|
||||
'opacity-40 cursor-default': !item.active,
|
||||
})}
|
||||
>
|
||||
<div className="w-16 h-16 flex items-center justify-center bg-primary-100 group-hover:bg-primary-200 transition-colors rounded-lg mr-4">
|
||||
<item.icon className="h-6 w-6 text-primary-900" aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<dt className="truncate text-sm leading-5 font-medium">{item.name}</dt>
|
||||
<dd className="mt-1 text-gray-500 text-sm leading-5 font-normal">{item.description}</dd>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
100
src/modules/dashboard/consts.ts
Normal file
|
@ -0,0 +1,100 @@
|
|||
import {
|
||||
ChartBarIcon,
|
||||
CloudIcon,
|
||||
UsersIcon,
|
||||
CogIcon,
|
||||
SwitchHorizontalIcon,
|
||||
ClockIcon,
|
||||
ShieldCheckIcon,
|
||||
InformationCircleIcon,
|
||||
} from '@heroicons/react/outline';
|
||||
|
||||
export const DASHBOARD_APPS = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Nextcloud',
|
||||
assetSrc: '/assets/nextcloud.svg',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Wekan',
|
||||
assetSrc: '/assets/wekan.svg',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Rocketchat',
|
||||
assetSrc: '/assets/rocketchat.svg',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Wordpress',
|
||||
assetSrc: '/assets/wordpress.svg',
|
||||
},
|
||||
];
|
||||
|
||||
export const DASHBOARD_QUICK_ACCESS = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Users →',
|
||||
to: '/users',
|
||||
description: 'Manage users and their permissions',
|
||||
icon: UsersIcon,
|
||||
active: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Apps →',
|
||||
to: '/apps',
|
||||
description: 'Stay on top of your deadlines, or don’t — it’s up to you',
|
||||
icon: CloudIcon,
|
||||
active: true,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Monitoring →',
|
||||
to: '/dashboard',
|
||||
description: 'Monitor your system with Grafana',
|
||||
icon: ChartBarIcon,
|
||||
active: false,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Data Migration →',
|
||||
to: '/dashboard',
|
||||
description: 'Import your data from other platforms and services',
|
||||
icon: SwitchHorizontalIcon,
|
||||
active: false,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Account Settings →',
|
||||
to: '/dashboard',
|
||||
description: 'Manage your organisation’s profile and preferences',
|
||||
icon: CogIcon,
|
||||
active: false,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'Backup & Restore →',
|
||||
to: '/dashboard',
|
||||
description: 'Backup or restore your data',
|
||||
icon: ClockIcon,
|
||||
active: false,
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: 'Security →',
|
||||
to: '/dashboard',
|
||||
description: 'Configure security settings, view alerts and analytics',
|
||||
icon: ShieldCheckIcon,
|
||||
active: false,
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: 'Support →',
|
||||
to: '/dashboard',
|
||||
description: 'Access documentation and forum',
|
||||
icon: InformationCircleIcon,
|
||||
active: false,
|
||||
},
|
||||
];
|
1
src/modules/dashboard/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { Dashboard } from './Dashboard';
|
4
src/modules/index.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export { Login } from './login';
|
||||
export { Dashboard } from './dashboard';
|
||||
export { Apps, AppSingle } from './apps';
|
||||
export { Users } from './users';
|
134
src/modules/login/Login.tsx
Normal file
|
@ -0,0 +1,134 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { navigate, RouteComponentProps } from '@reach/router';
|
||||
import * as yup from 'yup';
|
||||
import clsx from 'clsx';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { LockClosedIcon } from '@heroicons/react/solid';
|
||||
|
||||
import { useAuth } from 'src/services/auth';
|
||||
import { isValid } from 'src/services/api';
|
||||
import { VALIDATION_MESSAGES } from 'src/common/const';
|
||||
import { showToast, ToastType } from 'src/common/util/show-toast';
|
||||
|
||||
const validationSchema = yup.object({
|
||||
email: yup.string().required(VALIDATION_MESSAGES.required('Email')),
|
||||
password: yup.string().required(VALIDATION_MESSAGES.required('Password')),
|
||||
});
|
||||
|
||||
type LoginProps = RouteComponentProps;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export function Login(_: LoginProps) {
|
||||
const { auth, logIn } = useAuth();
|
||||
const { handleSubmit, formState, control } = useForm<{
|
||||
email: string;
|
||||
password: string;
|
||||
remember: boolean;
|
||||
}>({
|
||||
resolver: yupResolver(validationSchema),
|
||||
mode: 'all',
|
||||
defaultValues: { email: process.env.REACT_APP_USERNAME, password: process.env.REACT_APP_PASSWORD },
|
||||
});
|
||||
|
||||
const onSubmit = handleSubmit(async (values) => {
|
||||
const res = await logIn(values.email, values.password);
|
||||
|
||||
// @ts-ignore
|
||||
if (res.ok) {
|
||||
navigate('/dashboard');
|
||||
showToast('Logged in!');
|
||||
} else {
|
||||
showToast('Username or password incorrect', ToastType.Error);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isValid(auth)) {
|
||||
navigate('/dashboard');
|
||||
}
|
||||
}, [auth]);
|
||||
|
||||
if (isValid(auth)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div className="flex justify-center">
|
||||
<img className="hidden lg:block" src="assets/logo.svg" alt="Stackspin" />
|
||||
<h2 className="mt-6 text-center text-xl font-bold text-gray-900 sr-only">Sign in</h2>
|
||||
</div>
|
||||
<form className="mt-8 space-y-6" action="#" onSubmit={onSubmit} noValidate>
|
||||
<input type="hidden" name="remember" defaultValue="true" />
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
defaultValue=""
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<>
|
||||
<label htmlFor="email-address" className="sr-only">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
{...field}
|
||||
id="email-address"
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-primary-dark focus:border-primary-dark focus:z-10 sm:text-sm"
|
||||
placeholder="Username"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="password"
|
||||
defaultValue=""
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<>
|
||||
<label htmlFor="password" className="sr-only">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
{...field}
|
||||
id="password"
|
||||
type="password"
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-primary-dark focus:border-primary-dark focus:z-10 sm:text-sm"
|
||||
placeholder="Password"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
className={clsx(
|
||||
'group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-dark hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500',
|
||||
{
|
||||
'is-loading': formState.isSubmitting,
|
||||
},
|
||||
)}
|
||||
disabled={formState.isSubmitting || !formState.isValid}
|
||||
>
|
||||
<span className="absolute left-0 inset-y-0 flex items-center pl-3">
|
||||
<LockClosedIcon className="h-5 w-5 text-white group-hover:text-primary-light" aria-hidden="true" />
|
||||
</span>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
1
src/modules/login/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { Login } from './Login';
|
211
src/modules/users/Users.tsx
Normal file
|
@ -0,0 +1,211 @@
|
|||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { RouteComponentProps, Link } from '@reach/router';
|
||||
import { ChevronRightIcon, SearchIcon, PlusIcon } from '@heroicons/react/solid';
|
||||
import { CogIcon, KeyIcon, TrashIcon } from '@heroicons/react/outline';
|
||||
import { useUsers } from 'src/services/users';
|
||||
import { Table } from 'src/components';
|
||||
import { ConfirmationModal } from 'src/components/Modal';
|
||||
import { UserModal } from './components/UserModal';
|
||||
|
||||
const pages = [{ name: 'Users', href: '#', current: true }];
|
||||
|
||||
export const Users: React.FC<RouteComponentProps> = () => {
|
||||
const [selectedRowsIds, setSelectedRowsIds] = useState({});
|
||||
const [deleteModal, setDeleteModal] = useState(false);
|
||||
const [configureModal, setConfigureModal] = useState(false);
|
||||
const { loadUsers, users } = useUsers();
|
||||
|
||||
// TODO: Check why it needs any
|
||||
const allUsers: any = users.items;
|
||||
|
||||
useEffect(() => {
|
||||
const asyncFc = async () => {
|
||||
try {
|
||||
await loadUsers();
|
||||
} catch {
|
||||
// continue
|
||||
}
|
||||
};
|
||||
|
||||
asyncFc();
|
||||
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
||||
}, []);
|
||||
|
||||
const deleteModalOpen = () => setDeleteModal(true);
|
||||
const deleteModalClose = () => setDeleteModal(false);
|
||||
|
||||
const configureModalOpen = () => setConfigureModal(true);
|
||||
const configureModalClose = () => setConfigureModal(false);
|
||||
|
||||
const columns: any = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
Header: 'Name',
|
||||
accessor: 'name',
|
||||
width: 'auto',
|
||||
},
|
||||
{
|
||||
Header: 'Email',
|
||||
accessor: 'email',
|
||||
width: 'auto',
|
||||
},
|
||||
{
|
||||
Header: 'Status',
|
||||
accessor: 'status',
|
||||
width: 'auto',
|
||||
},
|
||||
{
|
||||
Header: 'Last Sign-In',
|
||||
accessor: 'last_login',
|
||||
width: 'auto',
|
||||
},
|
||||
{
|
||||
Header: ' ',
|
||||
Cell: () => {
|
||||
return (
|
||||
<div className="text-right opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={configureModalOpen}
|
||||
type="button"
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-200 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
||||
>
|
||||
<CogIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
|
||||
Configure
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
width: 'auto',
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const selectedRows = useCallback((rows: Record<string, boolean>) => {
|
||||
setSelectedRowsIds(rows);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto py-4 sm:px-6 lg:px-8 h-full flex-grow">
|
||||
<nav className="flex mb-8" aria-label="Breadcrumb">
|
||||
<ol className="flex items-center space-x-4">
|
||||
<li>
|
||||
<div className="flex items-center">
|
||||
<Link to="/dashboard" className="text-sm font-medium text-gray-500 hover:text-gray-700">
|
||||
<span>Dashboard</span>
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
{pages.map((page) => (
|
||||
<li key={page.name}>
|
||||
<div className="flex items-center">
|
||||
<ChevronRightIcon className="flex-shrink-0 h-5 w-5 text-gray-400" aria-hidden="true" />
|
||||
<a
|
||||
href={page.href}
|
||||
className="ml-4 text-sm font-medium text-gray-500 hover:text-gray-700"
|
||||
aria-current={page.current ? 'page' : undefined}
|
||||
>
|
||||
{page.name}
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div className="pb-5 border-b border-gray-200 sm:flex sm:items-center sm:justify-between">
|
||||
<h1 className="text-3xl leading-6 font-bold text-gray-900">Users</h1>
|
||||
<div className="mt-3 sm:mt-0 sm:ml-4">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary-700 hover:bg-primary-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-800"
|
||||
>
|
||||
<PlusIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
|
||||
Add new user
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between w-100 my-3 items-center">
|
||||
<div>
|
||||
<div className="mr-3 inline-block shadow-sm">
|
||||
<label htmlFor="location" className="block text-sm font-medium text-gray-700 sr-only">
|
||||
Location
|
||||
</label>
|
||||
<select
|
||||
id="location"
|
||||
name="location"
|
||||
className="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-200 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm rounded-md"
|
||||
defaultValue="All users"
|
||||
>
|
||||
<option>All users</option>
|
||||
<option>Owner</option>
|
||||
<option>Admins</option>
|
||||
<option>Members</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 inline-block">
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 sr-only">
|
||||
Search candidates
|
||||
</label>
|
||||
<div className="mt-1 flex rounded-md shadow-sm">
|
||||
<div className="relative flex items-stretch flex-grow focus-within:z-10">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<SearchIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="email"
|
||||
id="email"
|
||||
className="focus:ring-primary-500 focus:border-primary-500 block w-full rounded-md pl-10 sm:text-sm border-gray-200"
|
||||
placeholder="Search Users"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedRowsIds && Object.keys(selectedRowsIds).length !== 0 && (
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="mr-3 inline-flex items-center px-4 py-2 border border-gray-200 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
||||
>
|
||||
<KeyIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
|
||||
Edit app access
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={deleteModalOpen}
|
||||
type="button"
|
||||
className="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-red-700 bg-red-50 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
|
||||
>
|
||||
<TrashIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<div className="-my-2 sm:-mx-6 lg:-mx-8">
|
||||
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
|
||||
<div className="shadow border-b border-gray-200 sm:rounded-lg">
|
||||
<Table data={allUsers} columns={columns} getSelectedRowIds={selectedRows} selectable />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmationModal
|
||||
open={deleteModal}
|
||||
onClose={deleteModalClose}
|
||||
title="Delete user"
|
||||
body="Are you sure you want to delete this user? All of your data will be permanently removed. This action cannot be undone."
|
||||
/>
|
||||
|
||||
<UserModal open={configureModal} onClose={configureModalClose} onSave={configureModalClose} />
|
||||
</div>
|
||||
);
|
||||
};
|
85
src/modules/users/components/UserModal/UserModal.tsx
Normal file
|
@ -0,0 +1,85 @@
|
|||
import React, { Fragment, useRef } from 'react';
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import { Tabs } from 'src/components';
|
||||
import { UserDetailsTab } from './components/UserDetailsTab';
|
||||
import { AdvancedTab } from './components/AdvancedTab';
|
||||
import { AppAccessTab } from './components/AppAccess';
|
||||
|
||||
type UserModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSave?: () => void;
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ name: 'User Details', component: <UserDetailsTab /> },
|
||||
{ name: 'Advanced', component: <AdvancedTab /> },
|
||||
{ name: 'App Access', component: <AppAccessTab /> },
|
||||
];
|
||||
|
||||
export const UserModal = ({ open, onClose, onSave = () => {} }: UserModalProps) => {
|
||||
const cancelButtonRef = useRef(null);
|
||||
|
||||
return (
|
||||
<Transition.Root show={open} as={Fragment}>
|
||||
<Dialog
|
||||
as="div"
|
||||
auto-reopen="true"
|
||||
className="fixed z-10 inset-0 overflow-y-auto"
|
||||
initialFocus={cancelButtonRef}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
{/* This element is to trick the browser into centering the modal contents. */}
|
||||
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
||||
​
|
||||
</span>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-2xl sm:w-full">
|
||||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<Tabs tabs={tabs} />
|
||||
</div>
|
||||
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-primary-600 text-base font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
onClick={onSave}
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-200 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
onClick={onClose}
|
||||
ref={cancelButtonRef}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,113 @@
|
|||
import React, { useState } from 'react';
|
||||
import { InformationCircleIcon } from '@heroicons/react/solid';
|
||||
import { Switch } from '@headlessui/react';
|
||||
|
||||
function classNames(...classes: any) {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
export const AdvancedTab = () => {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="rounded-md py-2 mb-6">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<InformationCircleIcon className="h-5 w-5 text-blue-500" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ml-3 flex-1 md:flex md:justify-between">
|
||||
<p className="text-sm text-gray-900">You can give or revoke app access for this user</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<Switch.Group as="div" className="flex items-center justify-between">
|
||||
<span className="flex-grow flex flex-col">
|
||||
<Switch.Label as="span" className="text-lg font-medium text-gray-900" passive>
|
||||
User
|
||||
</Switch.Label>
|
||||
<Switch.Description as="span" className="text-sm text-gray-500">
|
||||
Gives this user access only to assigned apps.
|
||||
</Switch.Description>
|
||||
</span>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onChange={setEnabled}
|
||||
className={classNames(
|
||||
enabled ? 'bg-primary-600' : 'bg-gray-200',
|
||||
'relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={classNames(
|
||||
enabled ? 'translate-x-5' : 'translate-x-0',
|
||||
'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200',
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
</Switch.Group>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<Switch.Group as="div" className="flex items-center justify-between">
|
||||
<span className="flex-grow flex flex-col">
|
||||
<Switch.Label as="span" className="text-lg font-medium text-gray-900" passive>
|
||||
Admin
|
||||
</Switch.Label>
|
||||
<Switch.Description as="span" className="text-sm text-gray-500">
|
||||
Gives this user access to all apps and permission to change other users roles
|
||||
</Switch.Description>
|
||||
</span>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onChange={setEnabled}
|
||||
className={classNames(
|
||||
enabled ? 'bg-primary-600' : 'bg-gray-200',
|
||||
'relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={classNames(
|
||||
enabled ? 'translate-x-5' : 'translate-x-0',
|
||||
'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200',
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
</Switch.Group>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<Switch.Group as="div" className="flex items-center justify-between">
|
||||
<span className="flex-grow flex flex-col">
|
||||
<Switch.Label as="span" className="text-lg font-medium text-gray-900" passive>
|
||||
Super Admin
|
||||
</Switch.Label>
|
||||
<Switch.Description as="span" className="text-sm text-gray-500">
|
||||
Will transfer all Super Admin rights to this user
|
||||
</Switch.Description>
|
||||
</span>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onChange={setEnabled}
|
||||
className={classNames(
|
||||
enabled ? 'bg-primary-600' : 'bg-gray-200',
|
||||
'relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={classNames(
|
||||
enabled ? 'translate-x-5' : 'translate-x-0',
|
||||
'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200',
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
</Switch.Group>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
138
src/modules/users/components/UserModal/components/AppAccess.tsx
Normal file
|
@ -0,0 +1,138 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Switch } from '@headlessui/react';
|
||||
|
||||
function classNames(...classes: any) {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
export const AppAccessTab = () => {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<Switch.Group as="div" className="flex items-center justify-between">
|
||||
<div className="flex-grow flex items-center">
|
||||
<div className="flex-shrink-0 h-10 w-10 mr-4">
|
||||
<img className="h-10 w-10 rounded-md overflow-hidden" src="/assets/wekan.svg" alt="" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<Switch.Label as="span" className="text-lg font-medium text-gray-900" passive>
|
||||
Wekan
|
||||
</Switch.Label>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onChange={setEnabled}
|
||||
className={classNames(
|
||||
enabled ? 'bg-primary-600' : 'bg-gray-200',
|
||||
'relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={classNames(
|
||||
enabled ? 'translate-x-5' : 'translate-x-0',
|
||||
'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200',
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
</Switch.Group>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<Switch.Group as="div" className="flex items-center justify-between">
|
||||
<div className="flex-grow flex items-center">
|
||||
<div className="flex-shrink-0 h-10 w-10 mr-4">
|
||||
<img className="h-10 w-10 rounded-md overflow-hidden" src="/assets/wordpress.svg" alt="" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<Switch.Label as="span" className="text-lg font-medium text-gray-900" passive>
|
||||
Wordpress
|
||||
</Switch.Label>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onChange={setEnabled}
|
||||
className={classNames(
|
||||
enabled ? 'bg-primary-600' : 'bg-gray-200',
|
||||
'relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={classNames(
|
||||
enabled ? 'translate-x-5' : 'translate-x-0',
|
||||
'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200',
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
</Switch.Group>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<Switch.Group as="div" className="flex items-center justify-between">
|
||||
<div className="flex-grow flex items-center">
|
||||
<div className="flex-shrink-0 h-10 w-10 mr-4">
|
||||
<img className="h-10 w-10 rounded-md overflow-hidden" src="/assets/nextcloud.svg" alt="" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<Switch.Label as="span" className="text-lg font-medium text-gray-900" passive>
|
||||
Nextcloud
|
||||
</Switch.Label>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onChange={setEnabled}
|
||||
className={classNames(
|
||||
enabled ? 'bg-primary-600' : 'bg-gray-200',
|
||||
'relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={classNames(
|
||||
enabled ? 'translate-x-5' : 'translate-x-0',
|
||||
'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200',
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
</Switch.Group>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<Switch.Group as="div" className="flex items-center justify-between">
|
||||
<div className="flex-grow flex items-center">
|
||||
<div className="flex-shrink-0 h-10 w-10 mr-4">
|
||||
<img className="h-10 w-10 rounded-md overflow-hidden" src="/assets/rocketchat.svg" alt="" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<Switch.Label as="span" className="text-lg font-medium text-gray-900" passive>
|
||||
Rocketchat
|
||||
</Switch.Label>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onChange={setEnabled}
|
||||
className={classNames(
|
||||
enabled ? 'bg-primary-600' : 'bg-gray-200',
|
||||
'relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={classNames(
|
||||
enabled ? 'translate-x-5' : 'translate-x-0',
|
||||
'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200',
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
</Switch.Group>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,90 @@
|
|||
import React from 'react';
|
||||
|
||||
export const UserDetailsTab = () => {
|
||||
return (
|
||||
<div className="space-y-8 divide-y divide-gray-200">
|
||||
<div>
|
||||
<div>
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Personal Information</h3>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">
|
||||
<div className="sm:col-span-3">
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||
Name
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
autoComplete="given-name"
|
||||
className="shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
Email
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
autoComplete="family-name"
|
||||
className="shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label htmlFor="currentPassword" className="block text-sm font-medium text-gray-700">
|
||||
Current Password
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="password"
|
||||
name="currentPassword"
|
||||
id="currentPassword"
|
||||
className="shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700">
|
||||
Confirm Password
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
id="confirmPassword"
|
||||
className="shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label htmlFor="status" className="block text-sm font-medium text-gray-700">
|
||||
Status
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<select
|
||||
id="status"
|
||||
name="status"
|
||||
className="shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
>
|
||||
<option>Active</option>
|
||||
<option>Inactive</option>
|
||||
<option>Banned</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
1
src/modules/users/components/UserModal/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { UserModal } from './UserModal';
|
1
src/modules/users/components/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { UserModal } from './UserModal';
|
1
src/modules/users/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { Users } from './Users';
|
1
src/react-app-env.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/// <reference types="react-scripts" />
|
2
src/redux/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export { configureStore } from './store';
|
||||
export * from './types';
|
35
src/redux/store.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import { createStore, compose, applyMiddleware, combineReducers } from 'redux';
|
||||
import thunkMiddleware from 'redux-thunk';
|
||||
import { persistStore, persistReducer } from 'redux-persist';
|
||||
import storage from 'redux-persist/lib/storage';
|
||||
|
||||
import { reducer as authReducer } from 'src/services/auth';
|
||||
import { reducer as usersReducer } from 'src/services/users';
|
||||
|
||||
import { State } from './types';
|
||||
|
||||
const persistConfig = {
|
||||
key: 'root',
|
||||
storage,
|
||||
whitelist: ['auth'],
|
||||
};
|
||||
|
||||
const appReducer = combineReducers<State>({
|
||||
auth: authReducer,
|
||||
users: usersReducer,
|
||||
});
|
||||
|
||||
const persistedReducer = persistReducer(persistConfig, appReducer);
|
||||
|
||||
const middlewares = [thunkMiddleware];
|
||||
|
||||
// @ts-ignore
|
||||
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
|
||||
|
||||
export const configureStore = () => {
|
||||
const store = createStore(persistedReducer, composeEnhancers(applyMiddleware(...middlewares)));
|
||||
|
||||
const persistor = persistStore(store);
|
||||
|
||||
return { store, persistor };
|
||||
};
|
11
src/redux/types.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { Store } from 'redux';
|
||||
|
||||
import { AuthState } from 'src/services/auth/redux';
|
||||
import { UsersState } from 'src/services/users/redux';
|
||||
|
||||
export interface AppStore extends Store, State {}
|
||||
|
||||
export interface State {
|
||||
auth: AuthState;
|
||||
users: UsersState;
|
||||
}
|
42
src/services/api/apiCall.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
import _ from 'lodash';
|
||||
import axios from 'axios';
|
||||
|
||||
import { api } from './config';
|
||||
import { ApiConfig } from './types';
|
||||
|
||||
/**
|
||||
* Function which creates an API call for given api config
|
||||
*/
|
||||
export const createApiCall = (apiConfig: ApiConfig) => async () => {
|
||||
const hostname = apiConfig.hostname || api.hostname;
|
||||
const { path } = apiConfig;
|
||||
const method = _.get(apiConfig, 'method', 'GET');
|
||||
const contentType =
|
||||
_.includes(['POST', 'PUT', 'PATCH'], method) && !apiConfig.formData
|
||||
? {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
: {};
|
||||
const passedHeaders = _.get(apiConfig, 'headers', {});
|
||||
const headers = {
|
||||
...contentType,
|
||||
...passedHeaders,
|
||||
};
|
||||
const axiosConfig = {
|
||||
method,
|
||||
headers,
|
||||
url: `${hostname}${path}`,
|
||||
timeout: 30000,
|
||||
data: apiConfig.formData || apiConfig.body,
|
||||
};
|
||||
|
||||
return axios(axiosConfig);
|
||||
};
|
||||
|
||||
/**
|
||||
* Performs an API call for given api config and returns a promise
|
||||
*/
|
||||
export const performApiCall = (apiConfig: ApiConfig) => {
|
||||
const apiCall = createApiCall(apiConfig);
|
||||
return apiCall();
|
||||
};
|
3
src/services/api/config.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export const api = {
|
||||
hostname: process.env.REACT_APP_API_URL,
|
||||
};
|
5
src/services/api/index.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export * from './redux';
|
||||
|
||||
export { api } from './config';
|
||||
|
||||
export { createApiCall, performApiCall } from './apiCall';
|
186
src/services/api/redux/actions.ts
Normal file
|
@ -0,0 +1,186 @@
|
|||
import _ from 'lodash';
|
||||
import urlcat from 'urlcat';
|
||||
|
||||
import { createApiCall } from '../apiCall';
|
||||
import { StartAction, SuccessAction, FailureAction } from './types';
|
||||
|
||||
import { ApiConfig } from '../types';
|
||||
|
||||
/**
|
||||
* Function which creates a thunk action for given api config and associated action types
|
||||
* @param {string|Record<string, unknown>} apiConfig if string it is interprated as path in api endpoint
|
||||
* @param {string[]} actionTypes action types for start, success and failure
|
||||
*/
|
||||
export const createApiAction = (apiConfig: ApiConfig, actionTypes: string[]) => async (dispatch: any) => {
|
||||
const apiCall = createApiCall(apiConfig);
|
||||
|
||||
const startAction = (): StartAction => ({
|
||||
type: actionTypes[0],
|
||||
payload: null,
|
||||
});
|
||||
|
||||
const successAction = (payload: any): SuccessAction => ({
|
||||
type: actionTypes[1],
|
||||
payload,
|
||||
});
|
||||
|
||||
const failureAction = (error: string): FailureAction => ({
|
||||
type: actionTypes[2],
|
||||
payload: { error },
|
||||
});
|
||||
|
||||
dispatch(startAction());
|
||||
|
||||
try {
|
||||
const response = await apiCall();
|
||||
|
||||
let res;
|
||||
const additionalData = apiConfig.additionalData || {};
|
||||
|
||||
if (!_.isEmpty(additionalData)) {
|
||||
res = await dispatch(successAction({ ...response.data, ...additionalData }));
|
||||
} else {
|
||||
res = await dispatch(successAction(response.data));
|
||||
}
|
||||
|
||||
return { ok: true, res };
|
||||
} catch (e) {
|
||||
const error = _.get(e, 'response.data', {
|
||||
errorMessage: e.message || 'Undefined error, please try again.',
|
||||
});
|
||||
|
||||
await dispatch(failureAction(error));
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
errorMessage: error.message,
|
||||
status: e?.response?.status,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates opinionated CRUD actions
|
||||
*/
|
||||
export function createCrudApiActions<T>(
|
||||
basePath: string,
|
||||
startActionType: string,
|
||||
failureActionType: string,
|
||||
fetchActionType: string,
|
||||
addActionType: string,
|
||||
updateActionType: string,
|
||||
deleteActionType: string,
|
||||
transformItemForApi: (data: T) => any,
|
||||
pathTemplates: { load?: string; add?: string; change?: string } = {
|
||||
load: '/',
|
||||
add: '/',
|
||||
change: '/:id',
|
||||
},
|
||||
): any {
|
||||
const { load, add, change } = pathTemplates;
|
||||
const fetchItems = (
|
||||
pageNumber?: number,
|
||||
pageSize?: number,
|
||||
params: Record<string, unknown> = {},
|
||||
template: string = load!,
|
||||
) =>
|
||||
createApiAction(
|
||||
{
|
||||
path: urlcat(basePath, template, { pageNumber, pageSize, ...params }),
|
||||
method: 'GET',
|
||||
},
|
||||
[startActionType, fetchActionType, failureActionType],
|
||||
);
|
||||
|
||||
const addItem = (item: T, params: Record<string, unknown> = {}) =>
|
||||
createApiAction(
|
||||
{
|
||||
path: urlcat(basePath, add!, { ...params }),
|
||||
method: 'POST',
|
||||
body: transformItemForApi(item),
|
||||
},
|
||||
[startActionType, addActionType, failureActionType],
|
||||
);
|
||||
|
||||
const updateItem = (item: T, params: Record<string, unknown> = {}) =>
|
||||
createApiAction(
|
||||
{
|
||||
// @ts-ignore
|
||||
path: urlcat(basePath, change!, { id: item.id, ...params }),
|
||||
method: 'PUT',
|
||||
body: transformItemForApi(item),
|
||||
},
|
||||
[startActionType, updateActionType, failureActionType],
|
||||
);
|
||||
|
||||
const deleteItem = (itemId: number, params: Record<string, unknown> = {}) =>
|
||||
createApiAction(
|
||||
{
|
||||
path: urlcat(basePath, change!, { id: itemId, ...params }),
|
||||
method: 'DELETE',
|
||||
},
|
||||
[startActionType, deleteActionType, failureActionType],
|
||||
);
|
||||
|
||||
return [fetchItems, addItem, updateItem, deleteItem];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates opinionated CRUD actions
|
||||
*/
|
||||
export function createCrudApiActionsWithoutPaging<T extends { id?: number | string | null }>(
|
||||
basePath: string,
|
||||
startActionType: string,
|
||||
failureActionType: string,
|
||||
fetchActionType: string,
|
||||
addActionType: string,
|
||||
updateActionType: string,
|
||||
deleteActionType: string,
|
||||
transformItemForApi: (data: T) => any,
|
||||
pathTemplates: { load?: string; add?: string; change?: string } = {
|
||||
load: '/',
|
||||
add: '/',
|
||||
change: '/:id',
|
||||
},
|
||||
): any {
|
||||
const { load, add, change } = pathTemplates;
|
||||
const fetchItems = (params: Record<string, unknown> = {}, template: string = load!) =>
|
||||
createApiAction(
|
||||
{
|
||||
path: urlcat(basePath, template, params),
|
||||
method: 'GET',
|
||||
},
|
||||
[startActionType, fetchActionType, failureActionType],
|
||||
);
|
||||
|
||||
const addItem = (item: T, params: Record<string, unknown> = {}) =>
|
||||
createApiAction(
|
||||
{
|
||||
path: urlcat(basePath, add!, { ...params }),
|
||||
method: 'POST',
|
||||
body: transformItemForApi(item),
|
||||
},
|
||||
[startActionType, addActionType, failureActionType],
|
||||
);
|
||||
|
||||
const updateItem = (item: T, params: Record<string, unknown> = {}) =>
|
||||
createApiAction(
|
||||
{
|
||||
path: urlcat(basePath, change!, { id: item.id, ...params }),
|
||||
method: 'PUT',
|
||||
body: transformItemForApi(item),
|
||||
},
|
||||
[startActionType, updateActionType, failureActionType],
|
||||
);
|
||||
|
||||
const deleteItem = (itemId: number, params: Record<string, unknown> = {}) =>
|
||||
createApiAction(
|
||||
{
|
||||
path: urlcat(basePath, change!, { id: itemId, ...params }),
|
||||
method: 'DELETE',
|
||||
},
|
||||
[startActionType, deleteActionType, failureActionType],
|
||||
);
|
||||
|
||||
return [fetchItems, addItem, updateItem, deleteItem];
|
||||
}
|
14
src/services/api/redux/index.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
export {
|
||||
createApiReducer,
|
||||
createCrudApiReducer,
|
||||
createCrudApiReducerWithoutPaging,
|
||||
chainReducers,
|
||||
INITIAL_API_STATUS,
|
||||
INITIAL_API_STATE,
|
||||
} from './reducers';
|
||||
|
||||
export { createApiAction, createCrudApiActions, createCrudApiActionsWithoutPaging } from './actions';
|
||||
|
||||
export { isEmpty, isLoading, isValid, isError, getError, getErrorMessage } from './traits';
|
||||
|
||||
export * from './types';
|
176
src/services/api/redux/reducers.ts
Normal file
|
@ -0,0 +1,176 @@
|
|||
import { StartAction, SuccessAction, FailureAction, ApiStatus, ApiState } from './types';
|
||||
|
||||
export const INITIAL_API_STATUS: ApiStatus = {
|
||||
isEmpty: true,
|
||||
isLoading: false,
|
||||
isValid: false,
|
||||
isError: false,
|
||||
dateTime: Date.now(),
|
||||
error: null,
|
||||
errorMessage: null,
|
||||
};
|
||||
|
||||
export const INITIAL_API_STATE: ApiState = {
|
||||
_status: INITIAL_API_STATUS,
|
||||
items: [],
|
||||
};
|
||||
|
||||
type AllActions = StartAction | SuccessAction | FailureAction;
|
||||
|
||||
/**
|
||||
* Use this function to create any reducer which handles api communication
|
||||
* @param {Array} actionTypes Array of action types associated with fetchThunk e.g. USER_REQUEST,
|
||||
* USER_SUCCESS and USER_ERROR
|
||||
* @param {function} dataAdapter function which will be called to transform response body
|
||||
* to format you want to use in state
|
||||
* @param {function} errorAdapter function which will be called to transform error
|
||||
* to format you want to use in state
|
||||
* @returns {function} reducer handles api requests, saves response body as data and status
|
||||
*/
|
||||
export const createApiReducer =
|
||||
(
|
||||
actionTypes: string[],
|
||||
dataAdapter: (data: any, state?: any) => any = (data: any) => data,
|
||||
errorAdapter = (data: any) => data,
|
||||
) =>
|
||||
(state = INITIAL_API_STATE, action: AllActions) => {
|
||||
const [REQUEST, SUCCESS, ERROR] = actionTypes;
|
||||
|
||||
switch (action.type) {
|
||||
case REQUEST:
|
||||
return {
|
||||
...state,
|
||||
_status: {
|
||||
...state._status,
|
||||
isLoading: true,
|
||||
dateTime: Date.now(),
|
||||
},
|
||||
};
|
||||
case SUCCESS:
|
||||
return {
|
||||
_status: {
|
||||
isEmpty: false,
|
||||
isLoading: false,
|
||||
isValid: true,
|
||||
isError: false,
|
||||
dateTime: Date.now(),
|
||||
error: null,
|
||||
errorMessage: null,
|
||||
},
|
||||
...dataAdapter(action.payload, state),
|
||||
};
|
||||
case ERROR:
|
||||
return {
|
||||
...state,
|
||||
_status: {
|
||||
isEmpty: false,
|
||||
isLoading: false,
|
||||
isValid: false,
|
||||
isError: true,
|
||||
error: action.payload,
|
||||
errorMessage: errorAdapter(action.payload),
|
||||
},
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* This is higher order reducer
|
||||
* Use this if you want your state is combination of results of multiple reducers
|
||||
* under the same key, e.g. you can get the same data from multiple endpoints
|
||||
*/
|
||||
export const chainReducers =
|
||||
(initialState: any, ...args: any[]) =>
|
||||
(state = initialState, action: any) =>
|
||||
args.reduce((newState, reducer) => reducer(newState, action), state);
|
||||
|
||||
/**
|
||||
* Creates an opinionated CRUD reducer
|
||||
*/
|
||||
export function createCrudApiReducer<T>(
|
||||
startActionType: string,
|
||||
failureActionType: string,
|
||||
fetchActionType: string,
|
||||
addActionType: string,
|
||||
updateActionType: string,
|
||||
deleteActionType: string,
|
||||
transformItem: (data: any) => T,
|
||||
keyField = 'id',
|
||||
) {
|
||||
const fetchReducer = createApiReducer(
|
||||
[startActionType, fetchActionType, failureActionType],
|
||||
(data) => ({ _meta: data.meta, items: (data.data || data).map(transformItem) }),
|
||||
(data) => data,
|
||||
);
|
||||
|
||||
const addReducer = createApiReducer(
|
||||
[startActionType, addActionType, failureActionType],
|
||||
(data, state) => ({ items: [...state.items, transformItem(data)] }),
|
||||
(data) => data,
|
||||
);
|
||||
|
||||
const updateReducer = createApiReducer(
|
||||
[startActionType, updateActionType, failureActionType],
|
||||
(data, state) => ({
|
||||
items: state.items.map((i: any) => (i[keyField] === data[keyField] ? transformItem(data) : i)),
|
||||
}),
|
||||
(data) => data,
|
||||
);
|
||||
|
||||
const deleteReducer = createApiReducer(
|
||||
[startActionType, deleteActionType, failureActionType],
|
||||
(data, state) => ({
|
||||
items: state.items.filter((i: any) => i[keyField] !== data[keyField]),
|
||||
}),
|
||||
(data) => data,
|
||||
);
|
||||
|
||||
return chainReducers(INITIAL_API_STATE, fetchReducer, addReducer, updateReducer, deleteReducer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an opinionated CRUD reducer without paging
|
||||
*/
|
||||
export function createCrudApiReducerWithoutPaging<T>(
|
||||
startActionType: string,
|
||||
failureActionType: string,
|
||||
fetchActionType: string,
|
||||
addActionType: string,
|
||||
updateActionType: string,
|
||||
deleteActionType: string,
|
||||
transformItem: (data: any) => T,
|
||||
keyField = 'id',
|
||||
) {
|
||||
const fetchReducer = createApiReducer(
|
||||
[startActionType, fetchActionType, failureActionType],
|
||||
(data) => ({ items: data.map(transformItem) }),
|
||||
(data) => data,
|
||||
);
|
||||
|
||||
const addReducer = createApiReducer(
|
||||
[startActionType, addActionType, failureActionType],
|
||||
(data, state) => ({ items: [...state.items, transformItem(data)] }),
|
||||
(data) => data,
|
||||
);
|
||||
|
||||
const updateReducer = createApiReducer(
|
||||
[startActionType, updateActionType, failureActionType],
|
||||
(data, state) => ({
|
||||
items: state.items.map((i: any) => (i[keyField] === data[keyField] ? transformItem(data) : i)),
|
||||
}),
|
||||
(data) => data,
|
||||
);
|
||||
|
||||
const deleteReducer = createApiReducer(
|
||||
[startActionType, deleteActionType, failureActionType],
|
||||
(data, state) => ({
|
||||
items: state.items.filter((i: any) => i[keyField] !== data[keyField]),
|
||||
}),
|
||||
(data) => data,
|
||||
);
|
||||
|
||||
return chainReducers(INITIAL_API_STATE, fetchReducer, addReducer, updateReducer, deleteReducer);
|
||||
}
|
29
src/services/api/redux/traits.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
import _ from 'lodash';
|
||||
|
||||
export const isEmpty = (data: any): boolean => {
|
||||
return _.get(data, '_status.isEmpty', true);
|
||||
};
|
||||
|
||||
export const isLoading = (data: any): boolean => {
|
||||
return _.get(data, '_status.isLoading', false);
|
||||
};
|
||||
|
||||
export const isValid = (data: any): boolean => {
|
||||
return _.get(data, '_status.isValid', false);
|
||||
};
|
||||
|
||||
export const isError = (data: any): boolean => {
|
||||
return _.get(data, '_status.isError', false);
|
||||
};
|
||||
|
||||
export const getError = (data: any): any => {
|
||||
return _.get(data, '_status.error', null);
|
||||
};
|
||||
|
||||
export const getErrorMessage = (data: any): string | null => {
|
||||
return _.get(data, '_status.errorMessage', null);
|
||||
};
|
||||
|
||||
export const getTotalPages = (data: any): number | undefined => {
|
||||
return _.get(data, '_meta.totalPages', undefined);
|
||||
};
|
29
src/services/api/redux/types.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
export interface StartAction {
|
||||
type: string;
|
||||
payload: any;
|
||||
}
|
||||
|
||||
export interface SuccessAction {
|
||||
type: string;
|
||||
payload: any;
|
||||
}
|
||||
|
||||
export interface FailureAction {
|
||||
type: string;
|
||||
payload: { error: string };
|
||||
}
|
||||
|
||||
export interface ApiStatus {
|
||||
isEmpty: boolean;
|
||||
isLoading: boolean;
|
||||
isValid: boolean;
|
||||
isError: boolean;
|
||||
dateTime: number;
|
||||
error: any;
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
export interface ApiState<T = any> {
|
||||
_status: ApiStatus;
|
||||
items: T[];
|
||||
}
|
29
src/services/api/types.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
import { Method } from 'axios';
|
||||
|
||||
export interface ApiConfig {
|
||||
hostname?: string;
|
||||
path: string;
|
||||
method?: Method;
|
||||
headers?: any;
|
||||
formData?: any;
|
||||
body?: any;
|
||||
additionalData?: any;
|
||||
}
|
||||
|
||||
export interface SimpleSnackBarConfig {
|
||||
serviceName: any;
|
||||
}
|
||||
|
||||
export interface SnackBarConfigWithMessages {
|
||||
getMessage: {
|
||||
success(response: any): string;
|
||||
error(error: any): string;
|
||||
};
|
||||
}
|
||||
|
||||
export type SnackBarConfig = SnackBarConfigWithMessages | SimpleSnackBarConfig;
|
||||
|
||||
export enum SortDirection {
|
||||
Ascending = 'asc',
|
||||
Descending = 'desc',
|
||||
}
|
42
src/services/auth/api.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
import { performApiCall } from 'src/services/api';
|
||||
|
||||
export const sendPasswordResetLink = async (email: string): Promise<boolean> => {
|
||||
try {
|
||||
await performApiCall({
|
||||
path: `/auth/forgot-password`,
|
||||
method: 'POST',
|
||||
body: { email },
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const resetPassword = async (email: string, code: string, password: string): Promise<boolean> => {
|
||||
try {
|
||||
await performApiCall({
|
||||
path: `/auth/reset-password`,
|
||||
method: 'POST',
|
||||
body: { email, code, password },
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
export const verifyEmail = async (email: string, activationCode: string): Promise<boolean> => {
|
||||
try {
|
||||
await performApiCall({
|
||||
path: `/auth/activation`,
|
||||
method: 'POST',
|
||||
body: { email, activationCode },
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
1
src/services/auth/hooks/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { useAuth } from './use-auth';
|
38
src/services/auth/hooks/use-auth.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
import { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { getAuth, signIn, signOut, register as apiRegister, refreshUser as apiRefreshUser } from '../redux';
|
||||
|
||||
export function useAuth() {
|
||||
const dispatch = useDispatch();
|
||||
const auth = useSelector(getAuth);
|
||||
|
||||
const logIn = useCallback(
|
||||
(email: string, password: string) => {
|
||||
return dispatch(signIn(email, password));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const register = useCallback(
|
||||
(email: string, password: string, firstName: string, lastName: string) => {
|
||||
return dispatch(apiRegister(email, password, firstName, lastName));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const logOut = useCallback(() => {
|
||||
return dispatch(signOut());
|
||||
}, [dispatch]);
|
||||
|
||||
const refreshUser = useCallback(() => {
|
||||
return dispatch(apiRefreshUser());
|
||||
}, [dispatch]);
|
||||
|
||||
return {
|
||||
auth,
|
||||
logIn,
|
||||
logOut,
|
||||
register,
|
||||
refreshUser,
|
||||
};
|
||||
}
|
3
src/services/auth/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export { useAuth } from './hooks';
|
||||
export { getAuth, reducer, signIn, signOut, register, AuthActionTypes, getIsAuthLoading } from './redux';
|
||||
export * from './types';
|
56
src/services/auth/redux/actions.ts
Normal file
|
@ -0,0 +1,56 @@
|
|||
import { createApiAction } from 'src/services/api';
|
||||
import { SuccessAction } from 'src/services/api/redux/types';
|
||||
|
||||
export enum AuthActionTypes {
|
||||
SIGN_IN_START = 'auth/sign_in_start',
|
||||
SIGN_IN_SUCCESS = 'auth/sign_in_success',
|
||||
SIGN_IN_FAILURE = 'auth/sign_in_failure',
|
||||
SIGN_OUT = 'auth/SIGN_OUT',
|
||||
REGISTRATION_START = 'auth/registration_start',
|
||||
REGISTRATION_FAILURE = 'auth/registration_failure',
|
||||
}
|
||||
|
||||
const signOutAction = (): SuccessAction => ({
|
||||
type: AuthActionTypes.SIGN_OUT,
|
||||
payload: null,
|
||||
});
|
||||
|
||||
export const signIn = (email: string, password: string) =>
|
||||
createApiAction(
|
||||
{
|
||||
path: '/login',
|
||||
method: 'POST',
|
||||
body: { username: email, password },
|
||||
},
|
||||
[AuthActionTypes.SIGN_IN_START, AuthActionTypes.SIGN_IN_SUCCESS, AuthActionTypes.SIGN_IN_FAILURE],
|
||||
);
|
||||
|
||||
export const refreshUser = () =>
|
||||
createApiAction(
|
||||
{
|
||||
path: '/dashboard',
|
||||
method: 'GET',
|
||||
},
|
||||
[AuthActionTypes.SIGN_IN_START, AuthActionTypes.SIGN_IN_SUCCESS, AuthActionTypes.SIGN_IN_FAILURE],
|
||||
);
|
||||
|
||||
export function signOut() {
|
||||
return (dispatch: any) => {
|
||||
dispatch(signOutAction());
|
||||
};
|
||||
}
|
||||
|
||||
export const register = (email: string, password: string, firstName: string, lastName: string) =>
|
||||
createApiAction(
|
||||
{
|
||||
path: '/auth/register',
|
||||
method: 'POST',
|
||||
body: {
|
||||
email,
|
||||
firstName,
|
||||
lastName,
|
||||
password,
|
||||
},
|
||||
},
|
||||
[AuthActionTypes.REGISTRATION_START, AuthActionTypes.SIGN_IN_SUCCESS, AuthActionTypes.REGISTRATION_FAILURE],
|
||||
);
|
4
src/services/auth/redux/index.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export { signIn, signOut, register, refreshUser, AuthActionTypes } from './actions';
|
||||
export { default as reducer } from './reducers';
|
||||
export { getAuth, getIsAuthLoading } from './selectors';
|
||||
export * from './types';
|
23
src/services/auth/redux/reducers.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { createApiReducer, chainReducers, INITIAL_API_STATUS, INITIAL_API_STATE } from 'src/services/api';
|
||||
|
||||
import { AuthState } from './types';
|
||||
import { AuthActionTypes } from './actions';
|
||||
|
||||
const initialState: AuthState = {
|
||||
token: null,
|
||||
_status: INITIAL_API_STATUS,
|
||||
};
|
||||
|
||||
const auth = createApiReducer(
|
||||
[AuthActionTypes.SIGN_IN_START, AuthActionTypes.SIGN_IN_SUCCESS, AuthActionTypes.SIGN_IN_FAILURE],
|
||||
(data) => ({ token: data.access_token }),
|
||||
(data) => data.error.message,
|
||||
);
|
||||
|
||||
const signOut = createApiReducer(
|
||||
['', AuthActionTypes.SIGN_OUT, ''],
|
||||
() => INITIAL_API_STATE,
|
||||
() => INITIAL_API_STATE,
|
||||
);
|
||||
|
||||
export default chainReducers(initialState, auth, signOut);
|