introduce admin area
introduce admin area first poc for connecting the authentik api Co-authored-by: Philipp Rothmann <philipprothmann@posteo.de> Reviewed-on: #2
This commit is contained in:
parent
8d760e588f
commit
44e4e4eb42
35 changed files with 367 additions and 133 deletions
7
Makefile
7
Makefile
|
@ -1,9 +1,10 @@
|
||||||
|
tag = "$$(git describe --tags)"
|
||||||
|
|
||||||
build:
|
build:
|
||||||
$(MAKE) -C backend build
|
$(MAKE) -C backend build
|
||||||
docker push yksflip/dashboard-backend:latest
|
|
||||||
docker build -t dashboard .
|
docker build -t dashboard .
|
||||||
docker tag dashboard yksflip/dashboard:latest
|
docker tag dashboard yksflip/dashboard:$(tag)
|
||||||
docker push yksflip/dashboard:latest
|
docker push yksflip/dashboard:$(tag)
|
||||||
|
|
||||||
rm:
|
rm:
|
||||||
docker stack rm ${STACK_NAME}
|
docker stack rm ${STACK_NAME}
|
||||||
|
|
|
@ -7,3 +7,4 @@ REDIRECT_URL="https://example.org/login-callback"
|
||||||
SECRET_KEY=
|
SECRET_KEY=
|
||||||
LOAD_INCLUSTER_CONFIG=false
|
LOAD_INCLUSTER_CONFIG=false
|
||||||
DATABASE_URL=sqlite:///database.db
|
DATABASE_URL=sqlite:///database.db
|
||||||
|
AUTHENTIK_BASEURL="https://sso.example.org/api/v3"
|
|
@ -1,6 +1,9 @@
|
||||||
|
tag = "$$(git describe --tags)"
|
||||||
|
|
||||||
build:
|
build:
|
||||||
docker build -t dashboard-backend .
|
docker build -t dashboard-backend .
|
||||||
docker tag dashboard-backend yksflip/dashboard-backend:latest
|
docker tag dashboard-backend yksflip/dashboard-backend:$(tag)
|
||||||
|
docker push yksflip/dashboard-backend:$(tag)
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm database.db
|
rm database.db
|
||||||
|
|
|
@ -1,32 +0,0 @@
|
||||||
"""Everything to do with Apps"""
|
|
||||||
|
|
||||||
from database import db
|
|
||||||
from .models import App
|
|
||||||
|
|
||||||
class LITApp(App):
|
|
||||||
"""
|
|
||||||
"""
|
|
||||||
|
|
||||||
def get_url(self):
|
|
||||||
return self.url
|
|
||||||
|
|
||||||
def to_dict(self):
|
|
||||||
"""
|
|
||||||
represent this object as a dict, compatible for JSON output
|
|
||||||
"""
|
|
||||||
return {"id": self.id,
|
|
||||||
"name": self.name,
|
|
||||||
"slug": self.slug,
|
|
||||||
"external": self.external,
|
|
||||||
"status": self.get_status(),
|
|
||||||
"url": self.get_url()}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def get_status(self):
|
|
||||||
"""Returns an AppStatus object that describes the current cluster state"""
|
|
||||||
return {
|
|
||||||
"installed": "",
|
|
||||||
"ready": "",
|
|
||||||
"message": "",
|
|
||||||
}
|
|
|
@ -5,15 +5,14 @@ from flask_cors import cross_origin
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from areas import api_v1
|
from areas import api_v1
|
||||||
from areas.apps import App, AppRole
|
|
||||||
from config import *
|
from config import *
|
||||||
from helpers import HydraOauth, BadRequest
|
from helpers import LITOauth, BadRequest
|
||||||
|
|
||||||
|
|
||||||
@api_v1.route("/login", methods=["POST"])
|
@api_v1.route("/login", methods=["POST"])
|
||||||
@cross_origin()
|
@cross_origin()
|
||||||
def login():
|
def login():
|
||||||
authorization_url = HydraOauth.authorize()
|
authorization_url = LITOauth.authorize()
|
||||||
return jsonify({"authorizationUrl": authorization_url})
|
return jsonify({"authorizationUrl": authorization_url})
|
||||||
|
|
||||||
|
|
||||||
|
@ -28,26 +27,17 @@ def hydra_callback():
|
||||||
if code == None:
|
if code == None:
|
||||||
raise BadRequest("Missing code query param")
|
raise BadRequest("Missing code query param")
|
||||||
|
|
||||||
token = HydraOauth.get_token(state, code)
|
token = LITOauth.get_token(state, code)
|
||||||
user_info = HydraOauth.get_user_info()
|
user_info = LITOauth.get_user_info()
|
||||||
|
|
||||||
access_token = create_access_token(
|
access_token = create_access_token(
|
||||||
identity=token, expires_delta=timedelta(days=365),
|
identity=token, expires_delta=timedelta(days=365))
|
||||||
#additional_claims={"user_id": identity["id"]}
|
isAdmin = "admin" in user_info["groups"]
|
||||||
)
|
app_roles = [
|
||||||
|
{
|
||||||
# apps = App.query.all()
|
"name": "dashboard",
|
||||||
# app_roles = []
|
"role_id": 1 if isAdmin else 2
|
||||||
# for app in apps:
|
},
|
||||||
# tmp_app_role = AppRole.query.filter_by(
|
]
|
||||||
# user_id=identity["id"], app_id=app.id
|
|
||||||
# ).first()
|
|
||||||
# app_roles.append(
|
|
||||||
# {
|
|
||||||
# "name": app.slug,
|
|
||||||
# "role_id": tmp_app_role.role_id if tmp_app_role else None,
|
|
||||||
# }
|
|
||||||
# )
|
|
||||||
|
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
|
@ -57,7 +47,7 @@ def hydra_callback():
|
||||||
"email": user_info["email"],
|
"email": user_info["email"],
|
||||||
"name": user_info["name"],
|
"name": user_info["name"],
|
||||||
"preferredUsername": user_info["preferred_username"],
|
"preferredUsername": user_info["preferred_username"],
|
||||||
# "app_roles": app_roles,
|
"app_roles": app_roles
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
39
backend/areas/users/lit_user_service.py
Normal file
39
backend/areas/users/lit_user_service.py
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
from flask import current_app
|
||||||
|
from helpers.authentik_api import AuthentikApi
|
||||||
|
from .user_service import UserService
|
||||||
|
from .models import User
|
||||||
|
|
||||||
|
|
||||||
|
class UserService(UserService):
|
||||||
|
@staticmethod
|
||||||
|
def get_users():
|
||||||
|
user_list = [User.from_authentik(u).to_dict() for u in AuthentikApi.get("/core/users")]
|
||||||
|
return user_list
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_user(id):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def post_user(data):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __start_recovery_flow(email):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def put_user(id, user_editing_id, data):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def delete_user(id):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def post_multiple_users(data):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __insertAppRoleToUser(userId, userRes):
|
||||||
|
pass
|
37
backend/areas/users/models.py
Normal file
37
backend/areas/users/models.py
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
|
||||||
|
|
||||||
|
class User:
|
||||||
|
id = None
|
||||||
|
uuid = None
|
||||||
|
traits = None
|
||||||
|
email = None
|
||||||
|
name = None
|
||||||
|
preferredUsername = None
|
||||||
|
state = None
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_authentik(authentik_user):
|
||||||
|
u = User()
|
||||||
|
u.id = authentik_user["pk"]
|
||||||
|
u.uuid = authentik_user["uid"]
|
||||||
|
u.name = authentik_user["name"]
|
||||||
|
u.email = authentik_user["email"]
|
||||||
|
u.traits = {
|
||||||
|
"name": authentik_user["name"],
|
||||||
|
"email": authentik_user["email"],
|
||||||
|
"app_roles": []
|
||||||
|
}
|
||||||
|
u.preferredUsername = authentik_user["username"]
|
||||||
|
u.state = "active" if authentik_user["is_active"] else ""
|
||||||
|
return u
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"traits": self.traits,
|
||||||
|
"preferredUsername": self.preferredUsername,
|
||||||
|
"state": self.state,
|
||||||
|
}
|
|
@ -8,16 +8,15 @@ from helpers import KratosApi
|
||||||
from helpers.auth_guard import admin_required
|
from helpers.auth_guard import admin_required
|
||||||
|
|
||||||
from .validation import schema, schema_multiple
|
from .validation import schema, schema_multiple
|
||||||
from .user_service import UserService
|
from .lit_user_service import UserService
|
||||||
|
|
||||||
|
|
||||||
@api_v1.route("/users", methods=["GET"])
|
@api_v1.route("/users", methods=["GET"])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
@cross_origin()
|
@cross_origin()
|
||||||
@admin_required()
|
# @admin_required() TODO: not needed as authentik checks permissions?
|
||||||
def get_users():
|
def get_users():
|
||||||
res = UserService.get_users()
|
return jsonify(UserService.get_users())
|
||||||
return jsonify(res)
|
|
||||||
|
|
||||||
|
|
||||||
@api_v1.route("/users/<string:id>", methods=["GET"])
|
@api_v1.route("/users/<string:id>", methods=["GET"])
|
||||||
|
|
|
@ -1,11 +1,13 @@
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
|
||||||
def env_file(key: str):
|
def env_file(key: str):
|
||||||
file_env = os.environ.get(f"{key}_FILE")
|
file_env = os.environ.get(f"{key}_FILE")
|
||||||
if file_env and os.path.exists(file_env):
|
if file_env and os.path.exists(file_env):
|
||||||
return open(file_env).read().rstrip('\n')
|
return open(file_env).read().rstrip('\n')
|
||||||
return os.environ.get(key)
|
return os.environ.get(key)
|
||||||
|
|
||||||
|
|
||||||
SECRET_KEY = env_file("SECRET_KEY")
|
SECRET_KEY = env_file("SECRET_KEY")
|
||||||
|
|
||||||
HYDRA_CLIENT_ID = os.environ.get("HYDRA_CLIENT_ID")
|
HYDRA_CLIENT_ID = os.environ.get("HYDRA_CLIENT_ID")
|
||||||
|
@ -28,4 +30,7 @@ SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
# Set this to "true" to load the config from a Kubernetes serviceaccount
|
# Set this to "true" to load the config from a Kubernetes serviceaccount
|
||||||
# running in a Kubernetes pod. Set it to "false" to load the config from the
|
# running in a Kubernetes pod. Set it to "false" to load the config from the
|
||||||
# `KUBECONFIG` environment variable.
|
# `KUBECONFIG` environment variable.
|
||||||
LOAD_INCLUSTER_CONFIG = os.environ.get("LOAD_INCLUSTER_CONFIG").lower() == "true"
|
LOAD_INCLUSTER_CONFIG = os.environ.get(
|
||||||
|
"LOAD_INCLUSTER_CONFIG").lower() == "true"
|
||||||
|
|
||||||
|
AUTHENTIK_BASEURL = os.environ.get("AUTHENTIK_BASEURL")
|
||||||
|
|
|
@ -2,3 +2,4 @@ from .kratos_api import *
|
||||||
from .error_handler import *
|
from .error_handler import *
|
||||||
from .hydra_oauth import *
|
from .hydra_oauth import *
|
||||||
from .kratos_user import *
|
from .kratos_user import *
|
||||||
|
from .lit_oauth import *
|
|
@ -12,6 +12,7 @@ def admin_required():
|
||||||
def decorator(*args, **kwargs):
|
def decorator(*args, **kwargs):
|
||||||
verify_jwt_in_request()
|
verify_jwt_in_request()
|
||||||
claims = get_jwt()
|
claims = get_jwt()
|
||||||
|
|
||||||
user_id = claims["user_id"]
|
user_id = claims["user_id"]
|
||||||
is_admin = RoleService.is_user_admin(user_id)
|
is_admin = RoleService.is_user_admin(user_id)
|
||||||
if is_admin:
|
if is_admin:
|
||||||
|
|
42
backend/helpers/authentik_api.py
Normal file
42
backend/helpers/authentik_api.py
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
from typing import List
|
||||||
|
from flask_jwt_extended import get_jwt
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from config import AUTHENTIK_BASEURL
|
||||||
|
from .error_handler import AuthentikError
|
||||||
|
|
||||||
|
class AuthentikApi: # TODO: check if can be replaced with apispec generated api?
|
||||||
|
@staticmethod
|
||||||
|
def __handleError(res):
|
||||||
|
if res.status_code >= 400:
|
||||||
|
message = res.json()["error"]["message"]
|
||||||
|
raise AuthentikError(message, res.status_code)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __token():
|
||||||
|
jwt = get_jwt()
|
||||||
|
return jwt["sub"]["refresh_token"]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get(url):
|
||||||
|
try:
|
||||||
|
res = requests.get(f"{AUTHENTIK_BASEURL}{url}", headers={
|
||||||
|
"Authorization": f"Bearer {AuthentikApi.__token()}"})
|
||||||
|
AuthentikApi.__handleError(res)
|
||||||
|
if ("pagination" in res.json()):
|
||||||
|
return AuthentikApi.__paginate(res)
|
||||||
|
return res.json()
|
||||||
|
except AuthentikError as err:
|
||||||
|
raise err
|
||||||
|
except:
|
||||||
|
raise AuthentikError()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __paginate(res: requests.Response): # TODO: test this
|
||||||
|
results = res.json()["results"]
|
||||||
|
for page in range(1, res.json()["pagination"]["total_pages"]):
|
||||||
|
res = requests.get(
|
||||||
|
f"{res.request.url}", headers=res.request.headers, params={'page': page})
|
||||||
|
AuthentikApi.__handleError(res)
|
||||||
|
results.append(res.json()["results"])
|
||||||
|
return results
|
|
@ -5,6 +5,8 @@ from jsonschema import ValidationError
|
||||||
class KratosError(Exception):
|
class KratosError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
class AuthentikError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
class HydraError(Exception):
|
class HydraError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
|
@ -9,7 +9,7 @@ class HydraOauth:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def authorize():
|
def authorize():
|
||||||
try:
|
try:
|
||||||
hydra = OAuth2Session(HYDRA_CLIENT_ID, redirect_uri=REDIRECT_URL)
|
hydra = OAuth2Session(HYDRA_CLIENT_ID)
|
||||||
authorization_url, state = hydra.authorization_url(
|
authorization_url, state = hydra.authorization_url(
|
||||||
HYDRA_AUTHORIZATION_BASE_URL
|
HYDRA_AUTHORIZATION_BASE_URL
|
||||||
)
|
)
|
||||||
|
|
|
@ -4,7 +4,6 @@ import requests
|
||||||
from config import *
|
from config import *
|
||||||
from .error_handler import KratosError
|
from .error_handler import KratosError
|
||||||
|
|
||||||
|
|
||||||
class KratosApi:
|
class KratosApi:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __handleError(res):
|
def __handleError(res):
|
||||||
|
|
51
backend/helpers/lit_oauth.py
Normal file
51
backend/helpers/lit_oauth.py
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
from flask import request, session
|
||||||
|
from requests_oauthlib import OAuth2Session
|
||||||
|
|
||||||
|
from config import *
|
||||||
|
from helpers import HydraError
|
||||||
|
|
||||||
|
|
||||||
|
class LITOauth:
|
||||||
|
@staticmethod
|
||||||
|
def authorize():
|
||||||
|
try:
|
||||||
|
scopes = ["openid", "email", "profile", "goauthentik.io/api"]
|
||||||
|
oauth = OAuth2Session(HYDRA_CLIENT_ID, redirect_uri=REDIRECT_URL, scope=scopes)
|
||||||
|
authorization_url, state = oauth.authorization_url(
|
||||||
|
HYDRA_AUTHORIZATION_BASE_URL
|
||||||
|
)
|
||||||
|
return authorization_url
|
||||||
|
except Exception as err:
|
||||||
|
raise HydraError(str(err), 500)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_token(state, code):
|
||||||
|
try:
|
||||||
|
oauth = OAuth2Session(
|
||||||
|
client_id=HYDRA_CLIENT_ID,
|
||||||
|
state=state,
|
||||||
|
)
|
||||||
|
token = oauth.fetch_token(
|
||||||
|
token_url=TOKEN_URL,
|
||||||
|
code=code,
|
||||||
|
client_secret=HYDRA_CLIENT_SECRET,
|
||||||
|
include_client_id=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
session["oauth_token"] = token
|
||||||
|
|
||||||
|
return token
|
||||||
|
except Exception as err:
|
||||||
|
raise HydraError(str(err), 500)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_user_info():
|
||||||
|
try:
|
||||||
|
hydra = OAuth2Session(
|
||||||
|
client_id=HYDRA_CLIENT_ID, token=session["oauth_token"]
|
||||||
|
)
|
||||||
|
user_info = hydra.get("{}/userinfo".format(HYDRA_PUBLIC_URL))
|
||||||
|
|
||||||
|
return user_info.json()
|
||||||
|
except Exception as err:
|
||||||
|
raise HydraError(str(err), 500)
|
|
@ -1,5 +1,8 @@
|
||||||
|
alembic==1.8.1
|
||||||
attrs==21.4.0
|
attrs==21.4.0
|
||||||
black==22.1.0
|
black==22.1.0
|
||||||
|
cachelib==0.9.0
|
||||||
|
cachetools==5.2.0
|
||||||
certifi==2021.10.8
|
certifi==2021.10.8
|
||||||
cffi==1.15.0
|
cffi==1.15.0
|
||||||
charset-normalizer==2.0.12
|
charset-normalizer==2.0.12
|
||||||
|
@ -9,32 +12,43 @@ Flask==2.0.3
|
||||||
Flask-Cors==3.0.10
|
Flask-Cors==3.0.10
|
||||||
flask-expects-json==1.7.0
|
flask-expects-json==1.7.0
|
||||||
Flask-JWT-Extended==4.3.1
|
Flask-JWT-Extended==4.3.1
|
||||||
|
Flask-Migrate==3.1.0
|
||||||
|
Flask-Session==0.4.0
|
||||||
|
Flask-SQLAlchemy==2.5.1
|
||||||
|
google-auth==2.14.0
|
||||||
|
greenlet==2.0.0.post0
|
||||||
gunicorn==20.1.0
|
gunicorn==20.1.0
|
||||||
|
hydra-client==0.4.0
|
||||||
idna==3.3
|
idna==3.3
|
||||||
install==1.3.5
|
install==1.3.5
|
||||||
itsdangerous==2.1.1
|
itsdangerous==2.1.1
|
||||||
jsonschema==4.4.0
|
|
||||||
Jinja2==3.0.3
|
Jinja2==3.0.3
|
||||||
jinja2-base64-filters==0.1.4
|
jinja2-base64-filters==0.1.4
|
||||||
|
jsonschema==4.4.0
|
||||||
kubernetes==24.2.0
|
kubernetes==24.2.0
|
||||||
|
Mako==1.2.3
|
||||||
MarkupSafe==2.1.1
|
MarkupSafe==2.1.1
|
||||||
mypy-extensions==0.4.3
|
mypy-extensions==0.4.3
|
||||||
oauthlib==3.2.0
|
oauthlib==3.2.0
|
||||||
|
ory-kratos-client==0.9.0a2
|
||||||
pathspec==0.9.0
|
pathspec==0.9.0
|
||||||
platformdirs==2.5.1
|
platformdirs==2.5.1
|
||||||
|
pyasn1==0.4.8
|
||||||
|
pyasn1-modules==0.2.8
|
||||||
pycparser==2.21
|
pycparser==2.21
|
||||||
PyJWT==2.3.0
|
PyJWT==2.3.0
|
||||||
|
PyMySQL==1.0.2
|
||||||
pyrsistent==0.18.1
|
pyrsistent==0.18.1
|
||||||
|
python-dateutil==2.8.2
|
||||||
|
PyYAML==6.0
|
||||||
regex==2022.3.15
|
regex==2022.3.15
|
||||||
requests==2.27.1
|
requests==2.27.1
|
||||||
requests-oauthlib==1.3.1
|
requests-oauthlib==1.3.1
|
||||||
|
rsa==4.9
|
||||||
six==1.16.0
|
six==1.16.0
|
||||||
|
SQLAlchemy==1.4.42
|
||||||
tomli==1.2.3
|
tomli==1.2.3
|
||||||
typing-extensions==4.1.1
|
typing_extensions==4.1.1
|
||||||
urllib3==1.26.8
|
urllib3==1.26.8
|
||||||
|
websocket-client==1.4.2
|
||||||
Werkzeug==2.0.3
|
Werkzeug==2.0.3
|
||||||
ory-kratos-client==0.9.0a2
|
|
||||||
pymysql
|
|
||||||
Flask-SQLAlchemy
|
|
||||||
hydra-client
|
|
||||||
Flask-Migrate
|
|
||||||
|
|
4
backend/web/static/base.js
vendored
4
backend/web/static/base.js
vendored
|
@ -261,8 +261,6 @@ function render_messages(data) {
|
||||||
// value: If there is already a value known, show it
|
// value: If there is already a value known, show it
|
||||||
// messages: error messages related to the field
|
// messages: error messages related to the field
|
||||||
function getFormElement(type, name, value, messages) {
|
function getFormElement(type, name, value, messages) {
|
||||||
console.log('Getting form element', type, name, value, messages);
|
|
||||||
|
|
||||||
if (value == undefined) {
|
if (value == undefined) {
|
||||||
value = '';
|
value = '';
|
||||||
}
|
}
|
||||||
|
@ -350,7 +348,6 @@ function getFormInput(type, name, value, label, placeHolder, help, messages) {
|
||||||
if (typeof help == 'undefined' || help == null) {
|
if (typeof help == 'undefined' || help == null) {
|
||||||
help = '';
|
help = '';
|
||||||
}
|
}
|
||||||
console.log('Messages: ', messages);
|
|
||||||
|
|
||||||
// Id field for help element
|
// Id field for help element
|
||||||
var nameHelp = name + 'Help';
|
var nameHelp = name + 'Help';
|
||||||
|
@ -362,7 +359,6 @@ function getFormInput(type, name, value, label, placeHolder, help, messages) {
|
||||||
// messages get appended to help info
|
// messages get appended to help info
|
||||||
if (messages.length) {
|
if (messages.length) {
|
||||||
for (message in messages) {
|
for (message in messages) {
|
||||||
console.log('adding message', messages[message]);
|
|
||||||
help += messages[message]['text'];
|
help += messages[message]['text'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
30
public/assets/zammad.svg
Normal file
30
public/assets/zammad.svg
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg width="126px" height="108px" viewBox="0 0 42 36" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns">
|
||||||
|
<!-- Generator: Sketch 3.3.2 (12043) - http://www.bohemiancoding.com/sketch -->
|
||||||
|
<title>logo</title>
|
||||||
|
<desc>Created with Sketch.</desc>
|
||||||
|
<defs></defs>
|
||||||
|
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage">
|
||||||
|
<g id="logo" sketch:type="MSArtboardGroup">
|
||||||
|
<g sketch:type="MSLayerGroup" transform="translate(1.000000, 0.000000)" id="Shape">
|
||||||
|
<path d="M27.3375,12.6 L36.72,9.72 L31.1625,13.2525 L27.3375,12.6 Z" fill="#CA2317" sketch:type="MSShapeGroup"></path>
|
||||||
|
<path d="M33.0525,19.62 L31.1625,13.2525 L36.72,9.72 L35.055,15.435 L33.0525,19.62 Z" fill="#E84F83" sketch:type="MSShapeGroup"></path>
|
||||||
|
<path d="M39.465,7.9875 L38.43,9.72 L35.055,15.435 L36.72,9.72 L39.465,7.9875 Z" fill="#CA2317" sketch:type="MSShapeGroup"></path>
|
||||||
|
<path d="M39.8025,9.1125 L37.1925,11.79 L38.43,9.72 L39.8025,9.1125 Z" fill="#E54011" sketch:type="MSShapeGroup"></path>
|
||||||
|
<path d="M27.9,10.8225 L35.5725,10.0575 L30.24,11.7 L27.9,10.8225 Z" fill="#E54011" sketch:type="MSShapeGroup"></path>
|
||||||
|
<path d="M28.1925,15.165 L31.1625,13.2525 L33.0525,19.62 L32.0625,21.645 L28.1925,15.165 Z" fill="#CA2317" sketch:type="MSShapeGroup"></path>
|
||||||
|
<path d="M23.76,22.725 L22.3425,5.4 L32.0625,21.645 L23.76,22.725 Z" fill="#B7DFF2" sketch:type="MSShapeGroup"></path>
|
||||||
|
<path d="M19.7325,27.1575 L23.76,22.725 L32.0625,21.645 L19.7325,27.1575 Z" fill="#E54011" sketch:type="MSShapeGroup"></path>
|
||||||
|
<path d="M0.1575,35.865 L19.7325,27.1575 L23.76,22.725 L17.37,22.0725 L0.1575,35.865 Z" fill="#FFCE33" sketch:type="MSShapeGroup"></path>
|
||||||
|
<path d="M0.9,28.755 L10.9575,27.225 L14.085,24.705 L12.555,24.03 L0.9,28.755 Z" fill="#D6B12D" sketch:type="MSShapeGroup"></path>
|
||||||
|
<path d="M4.5225,20.5425 L14.085,24.705 L17.37,22.0725 L4.5225,20.5425 Z" fill="#FFDE85" sketch:type="MSShapeGroup"></path>
|
||||||
|
<path d="M21.6225,11.6775 L20.4075,11.88 L17.37,22.0725 L20.655,20.0025 L21.6225,11.6775 Z" fill="#009EC6" sketch:type="MSShapeGroup"></path>
|
||||||
|
<path d="M23.4,18.2475 L20.655,20.0025 L22.3425,5.4 L23.4,18.2475 Z" fill="#5EAFCE" sketch:type="MSShapeGroup"></path>
|
||||||
|
<path d="M13.0275,13.05 L21.6225,11.6775 L22.005,8.28 L13.0275,13.05 Z" fill="#045972" sketch:type="MSShapeGroup"></path>
|
||||||
|
<path d="M12.105,5.085 L19.575,9.585 L22.005,8.28 L22.0725,7.8075 L12.105,5.085 Z" fill="#5A8591" sketch:type="MSShapeGroup"></path>
|
||||||
|
<path d="M13.5675,0.18 L20.3625,7.335 L22.0725,7.8075 L22.3425,5.4 L13.5675,0.18 Z" fill="#009EC6" sketch:type="MSShapeGroup"></path>
|
||||||
|
<path d="M17.37,22.0725 L23.4,18.2475 L23.76,22.725 L17.37,22.0725 Z" fill="#F39804" sketch:type="MSShapeGroup"></path>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 3.1 KiB |
1
public/env.js
vendored
1
public/env.js
vendored
|
@ -1,3 +1,4 @@
|
||||||
window.env = {
|
window.env = {
|
||||||
REACT_APP_API_URL: 'http://localhost:5000/api/v1',
|
REACT_APP_API_URL: 'http://localhost:5000/api/v1',
|
||||||
|
REACT_APP_SSO_LOGOUT_URL: 'https://login.example.org/if/flow/default-invalidation-flow/'
|
||||||
};
|
};
|
||||||
|
|
1
public/markdown/authentik.md
Normal file
1
public/markdown/authentik.md
Normal file
|
@ -0,0 +1 @@
|
||||||
|
Verwalte User und Gruppen
|
1
public/markdown/zammad.md
Normal file
1
public/markdown/zammad.md
Normal file
|
@ -0,0 +1 @@
|
||||||
|
Ticketsystem
|
|
@ -13,7 +13,6 @@ import { Login } from './modules/login';
|
||||||
import { Users } from './modules/users/Users';
|
import { Users } from './modules/users/Users';
|
||||||
import { AppSingle } from './modules/apps/AppSingle';
|
import { AppSingle } from './modules/apps/AppSingle';
|
||||||
import { Apps } from './modules/apps/Apps';
|
import { Apps } from './modules/apps/Apps';
|
||||||
import { DashboardLIT } from './modules/dashboard/DashboardLIT';
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
function App() {
|
function App() {
|
||||||
|
@ -53,7 +52,7 @@ function App() {
|
||||||
) : (
|
) : (
|
||||||
<Layout>
|
<Layout>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/dashboard" element={<DashboardLIT />} />
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
{apps.map((app) => (
|
{apps.map((app) => (
|
||||||
<Route key={app.name} path={app.slug} element={<AppIframe app={app} />} />
|
<Route key={app.name} path={app.slug} element={<AppIframe app={app} />} />
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { Fragment, useMemo, useState } from 'react';
|
import React, { Fragment, useMemo } from 'react';
|
||||||
import { Disclosure, Menu, Transition } from '@headlessui/react';
|
import { Disclosure, Menu, Transition } from '@headlessui/react';
|
||||||
import { MenuIcon, XIcon } from '@heroicons/react/outline';
|
import { MenuIcon, XIcon } from '@heroicons/react/outline';
|
||||||
import { useAuth } from 'src/services/auth';
|
import { useAuth } from 'src/services/auth';
|
||||||
|
@ -6,16 +6,10 @@ import Gravatar from 'react-gravatar';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { useApps } from 'src/services/apps';
|
import { useApps } from 'src/services/apps';
|
||||||
import _ from 'lodash';
|
|
||||||
|
|
||||||
import { UserModal } from '../UserModal';
|
|
||||||
|
|
||||||
const HYDRA_LOGOUT_URL = `${process.env.REACT_APP_HYDRA_PUBLIC_URL}/oauth2/sessions/logout`;
|
|
||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{ name: 'Dashboard', to: '/dashboard', requiresAdmin: false },
|
{ name: '', to: '/users', requiresAdmin: true },
|
||||||
{ name: 'Users', to: '/users', requiresAdmin: true },
|
// { name: 'Apps', to: '/apps', requiresAdmin: true },
|
||||||
{ name: 'Apps', to: '/apps', requiresAdmin: true },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function classNames(...classes: any[]) {
|
function classNames(...classes: any[]) {
|
||||||
|
@ -34,32 +28,15 @@ function filterNavigationByDashboardRole(isAdmin: boolean) {
|
||||||
interface HeaderProps {}
|
interface HeaderProps {}
|
||||||
|
|
||||||
const HeaderLIT: React.FC<HeaderProps> = () => {
|
const HeaderLIT: React.FC<HeaderProps> = () => {
|
||||||
const [currentUserModal, setCurrentUserModal] = useState(false);
|
|
||||||
const [currentUserId, setCurrentUserId] = useState(null);
|
|
||||||
const { logOut, currentUser, isAdmin } = useAuth();
|
const { logOut, currentUser, isAdmin } = useAuth();
|
||||||
|
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const { apps, loadApps, appTableLoading } = useApps();
|
const { apps } = useApps();
|
||||||
|
|
||||||
const currentUserModalOpen = (id: any) => {
|
|
||||||
setCurrentUserId(id);
|
|
||||||
setCurrentUserModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const currentUserModalClose = () => {
|
|
||||||
setCurrentUserModal(false);
|
|
||||||
setCurrentUserId(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const navigationItems = filterNavigationByDashboardRole(isAdmin);
|
const navigationItems = filterNavigationByDashboardRole(isAdmin);
|
||||||
|
|
||||||
const signOutUrl = useMemo(() => {
|
const signOutUrl = useMemo(() => {
|
||||||
const { hostname } = window.location;
|
// @ts-ignore
|
||||||
// If we are developing locally, we need to use the init cluster's public URL
|
return window.env.REACT_APP_SSO_LOGOUT_URL;
|
||||||
if (hostname === 'localhost') {
|
|
||||||
return HYDRA_LOGOUT_URL;
|
|
||||||
}
|
|
||||||
return `https://${hostname.replace(/^dashboard/, 'sso')}/oauth2/sessions/logout`;
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -102,6 +79,21 @@ const HeaderLIT: React.FC<HeaderProps> = () => {
|
||||||
{app.name}
|
{app.name}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
{/* {navigationItems.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.name}
|
||||||
|
to={item.to}
|
||||||
|
className={clsx(
|
||||||
|
'border-primary-50 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium litbutton',
|
||||||
|
{
|
||||||
|
'border-primary-500 litbutton-active hover:border-gray-300 inline-flex items-center px-1 pt-1 text-sm font-medium':
|
||||||
|
pathname.includes(item.to),
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
))} */}
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="absolute inset-y-0 right-0 flex items-center pr-2 sm:static sm:inset-auto sm:ml-6 sm:pr-0">
|
||||||
|
@ -167,10 +159,6 @@ const HeaderLIT: React.FC<HeaderProps> = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Disclosure>
|
</Disclosure>
|
||||||
|
|
||||||
{currentUserModal && (
|
|
||||||
<UserModal open={currentUserModal} onClose={currentUserModalClose} userId={currentUserId} setUserId={_.noop} />
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,2 +1 @@
|
||||||
export { default as Header } from './Header';
|
export { default as Header } from './HeaderLIT';
|
||||||
export { default as HeaderLIT } from './HeaderLIT';
|
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { HeaderLIT } from '../Header';
|
import { Header } from '../Header';
|
||||||
|
|
||||||
const Layout: React.FC = ({ children }) => {
|
const Layout: React.FC = ({ children }) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<HeaderLIT />
|
<Header />
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
export { Layout } from './Layout';
|
export { Layout } from './Layout';
|
||||||
export { Header, HeaderLIT } from './Header';
|
export { Header } from './Header';
|
||||||
export { Table } from './Table';
|
export { Table } from './Table';
|
||||||
export { Banner } from './Banner';
|
export { Banner } from './Banner';
|
||||||
export { Tabs } from './Tabs';
|
export { Tabs } from './Tabs';
|
||||||
|
|
|
@ -1,9 +1,13 @@
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { useApps } from 'src/services/apps';
|
import { AppStatusEnum, useApps } from 'src/services/apps';
|
||||||
import { DashboardCardLIT } from './components/DashboardCard/DashboardCardLIT';
|
import { useAuth } from 'src/services/auth';
|
||||||
|
import { DashboardUtility } from './components';
|
||||||
|
import { DashboardCard } from './components/DashboardCard/DashboardCardLIT';
|
||||||
|
import { UTILITY_APPS } from './consts';
|
||||||
|
|
||||||
export const DashboardLIT: React.FC = () => {
|
export const Dashboard: React.FC = () => {
|
||||||
const { apps, loadApps, appTableLoading } = useApps();
|
const { apps, loadApps, appTableLoading } = useApps();
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
|
|
||||||
// Tell React to load the apps
|
// Tell React to load the apps
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -21,9 +25,27 @@ export const DashboardLIT: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
<div className="max-w-7xl mx-auto py-4 px-3 sm:px-6 lg:px-8 h-full flex-grow">
|
<div className="max-w-7xl mx-auto py-4 px-3 sm:px-6 lg:px-8 h-full flex-grow">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 md:gap-4 lg:grid-cols-4 mb-10">
|
<div className="grid grid-cols-1 md:grid-cols-2 md:gap-4 lg:grid-cols-4 mb-10">
|
||||||
{!appTableLoading && apps.map((app) => <DashboardCardLIT app={app} key={app.name} />)}
|
{!appTableLoading &&
|
||||||
|
apps
|
||||||
|
.filter((app) => UTILITY_APPS.indexOf(app.slug) === -1)
|
||||||
|
.map((app) => <DashboardCard app={app} key={app.name} />)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{isAdmin && (
|
||||||
|
<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">Administration</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="mt-5 grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
|
{apps
|
||||||
|
.filter((app) => UTILITY_APPS.indexOf(app.slug) !== -1 && app.url !== null)
|
||||||
|
.map((app) => (
|
||||||
|
<DashboardUtility item={app} key={app.name} />
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
export const DashboardCardLIT: React.FC<any> = ({ app }: { app: any }) => {
|
export const DashboardCard: React.FC<any> = ({ app }: { app: any }) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
export { DashboardCard } from './DashboardCard';
|
export { DashboardCard } from './DashboardCardLIT';
|
||||||
|
|
|
@ -0,0 +1,36 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
export const DashboardUtility: React.FC<any> = ({ item }: { item: any }) => {
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(item.markdownSrc)
|
||||||
|
.then((res) => res.text())
|
||||||
|
.then((md) => {
|
||||||
|
return setContent(md);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, [item.markdownSrc]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/${item.slug}`}
|
||||||
|
key={item.name}
|
||||||
|
rel="noreferrer"
|
||||||
|
className="bg-white rounded-lg overflow-hidden sm:p-2 flex items-center group"
|
||||||
|
>
|
||||||
|
<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 && <item.icon className="h-6 w-6 text-primary-900" aria-hidden="true" />}
|
||||||
|
{item.assetSrc && <img className="h-6 w-6" src={item.assetSrc} alt={item.name} />}
|
||||||
|
</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">
|
||||||
|
<ReactMarkdown>{content}</ReactMarkdown>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1 +1 @@
|
||||||
export { DashboardUtility } from './DashboardUtility';
|
export { DashboardUtility } from './DashboardUtilityLIT';
|
||||||
|
|
|
@ -10,7 +10,7 @@ export const DASHBOARD_QUICK_ACCESS = [
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Apps that should not be shown on the dashboard */
|
/** Apps that should not be shown on the dashboard */
|
||||||
export const HIDDEN_APPS = ['dashboard', 'velero'];
|
export const HIDDEN_APPS = ['dashboard'];
|
||||||
|
|
||||||
/** Apps that should be shown under "Utilities" */
|
/** Apps that should be shown under "Utilities" */
|
||||||
export const UTILITY_APPS = ['monitoring'];
|
export const UTILITY_APPS = ['authentik', 'zammad'];
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
export { Dashboard } from './Dashboard';
|
export { Dashboard } from './DashboardLIT';
|
||||||
|
|
|
@ -53,6 +53,11 @@ export const Users: React.FC = () => {
|
||||||
|
|
||||||
const columns: any = React.useMemo(
|
const columns: any = React.useMemo(
|
||||||
() => [
|
() => [
|
||||||
|
{
|
||||||
|
Header: 'Username',
|
||||||
|
accessor: 'preferredUsername',
|
||||||
|
width: 'auto',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Header: 'Name',
|
Header: 'Name',
|
||||||
accessor: 'name',
|
accessor: 'name',
|
||||||
|
@ -75,11 +80,12 @@ export const Users: React.FC = () => {
|
||||||
|
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
return (
|
return (
|
||||||
<div className="text-right lg:opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="text-right lg:opacity-0 transition-opacity">
|
||||||
<button
|
<button
|
||||||
|
disabled
|
||||||
onClick={() => configureModalOpen(row.original.id)}
|
onClick={() => configureModalOpen(row.original.id)}
|
||||||
type="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"
|
className="inline-flex items-center px-4 py-2 border border-gray-200 shadow-sm text-sm font-medium rounded-md text-gray bg-gray hover:bg-gray 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" />
|
<CogIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
|
||||||
Configure
|
Configure
|
||||||
|
@ -109,17 +115,19 @@ export const Users: React.FC = () => {
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="mt-3 sm:mt-0 sm:ml-4">
|
<div className="mt-3 sm:mt-0 sm:ml-4">
|
||||||
<button
|
<button
|
||||||
|
disabled
|
||||||
onClick={() => configureModalOpen(null)}
|
onClick={() => configureModalOpen(null)}
|
||||||
type="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 mx-5 "
|
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-gray-200 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-800 mx-5 "
|
||||||
>
|
>
|
||||||
<PlusIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
|
<PlusIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
|
||||||
Add new user
|
Add new user
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
disabled
|
||||||
onClick={() => setMultipleUsersModal(true)}
|
onClick={() => setMultipleUsersModal(true)}
|
||||||
type="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"
|
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-gray-200 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-800"
|
||||||
>
|
>
|
||||||
<ViewGridAddIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
|
<ViewGridAddIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
|
||||||
Add new users
|
Add new users
|
||||||
|
|
Loading…
Reference in a new issue