diff --git a/CHANGELOG.md b/CHANGELOG.md index 9eefbf5b0bc3ff87b9f9ca68d3b7305631dc6d1b..f4316fe9826fc4f63b2f66ed9d60c2be3ce675de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [0.7.6] + +- Add Forgejo metadata for use as custom app. + +## [0.7.5] + +- Add Jitsi and Mattermost metadata for use as custom apps. + ## [0.7.4] - Make the sign-in UI less wide. diff --git a/README.md b/README.md index 153595f5a6d1da09257264e0a29ee27c8e1759ee..34a99ff6d48667b90a7b42a5fc53f2df0eaf520e 100644 --- a/README.md +++ b/README.md @@ -154,11 +154,20 @@ might behave differently in the local development environment compared to a regular Stackspin instance, i.e., one that's not a local/cluster hybrid. In this case, you'll want to run your new version in a regular Stackspin cluster. -To do that, make sure to increase the chart version number in `Chart.yaml`, and -push your work to a MR. The CI pipeline should then publish your new chart -version in the Gitlab helm chart repo for the dashboard project, but in the -`unstable` channel -- the `stable` channel is reserved for chart versions that -have been merged to the `main` branch. +To do that: +* Push your work to an MR. +* Set the image tags in `values.yaml` to the one created for your branch; if + unsure, check the available tags in the Gitlab container registry for the + dashboard project. +* Make sure to increase the chart version number in `Chart.yaml`, preferably + with a suffix to denote that it's not a stable version. For example, if the + last stable release is 1.2.3, make the version 1.2.4-myawesomefeature in your + branch. + +The CI pipeline should then publish your new chart version in the Gitlab helm +chart repo for the dashboard project, but in the `unstable` channel -- the +`stable` channel is reserved for chart versions that have been merged to the +`main` branch. Once your package is published, use it by diff --git a/backend/app.py b/backend/app.py index 3115dcbcca51344a0d746201ab5acf6bb8188c29..a24224c899b04414b43b317a31e6be160497bad7 100644 --- a/backend/app.py +++ b/backend/app.py @@ -62,6 +62,7 @@ app = Flask(__name__) app.config["SECRET_KEY"] = SECRET_KEY app.config["SQLALCHEMY_DATABASE_URI"] = SQLALCHEMY_DATABASE_URI +app.config["SQLALCHEMY_ENGINE_OPTIONS"] = {'pool_pre_ping': True} app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = SQLALCHEMY_TRACK_MODIFICATIONS app.logger.setLevel(logging.INFO) diff --git a/backend/areas/auth/auth.py b/backend/areas/auth/auth.py index 5ea14d93a7721ac74bd976a2c3698df8f1b1b434..6cd27d343e77514b37f9147d93c31d42d9d775d0 100644 --- a/backend/areas/auth/auth.py +++ b/backend/areas/auth/auth.py @@ -1,4 +1,4 @@ -from flask import jsonify, request +from flask import current_app, jsonify, request from flask_jwt_extended import create_access_token from flask_cors import cross_origin from datetime import timedelta @@ -29,24 +29,22 @@ def hydra_callback(): token = HydraOauth.get_token(state, code) user_info = HydraOauth.get_user_info() - # Match Kratos identity with Hydra - identities = KratosApi.get("/identities") - identity = None - for i in identities.json(): - if i["traits"]["email"] == user_info["email"]: - identity = i + kratos_id = user_info["sub"] - # Short lifetime for token. If the session is still active, it will be - # automatically renewed via Hydra. - access_token = create_access_token( - identity=token, expires_delta=timedelta(hours=1), additional_claims={"user_id": identity["id"]} - ) + # TODO: add a check to see if this a valid ID/active account + + try: + access_token = create_access_token( + identity=token, expires_delta=timedelta(hours=1), additional_claims={"user_id": kratos_id} + ) + except Exception as e: + raise BadRequest("Error with creating auth token between backend and frontend") apps = App.query.all() app_roles = [] for app in apps: tmp_app_role = AppRole.query.filter_by( - user_id=identity["id"], app_id=app.id + user_id=kratos_id, app_id=app.id ).first() app_roles.append( { @@ -59,7 +57,7 @@ def hydra_callback(): { "accessToken": access_token, "userInfo": { - "id": identity["id"], + "id": kratos_id, "email": user_info["email"], "name": user_info["name"], "preferredUsername": user_info["preferred_username"], diff --git a/backend/areas/users/user_service.py b/backend/areas/users/user_service.py index 75166d12474e31d8781e63e2eec703a43cb0b8da..ebb965a1b494d2d8d559c21d477e0307bb442f56 100644 --- a/backend/areas/users/user_service.py +++ b/backend/areas/users/user_service.py @@ -22,10 +22,18 @@ kratos_identity_api = identity_api.IdentityApi(kratos_client) class UserService: @staticmethod def get_users(): - res = KratosApi.get("/admin/identities").json() + page = 1 userList = [] - for r in res: - userList.append(UserService.__insertAppRoleToUser(r["id"], r)) + while page > 0: + res = KratosApi.get("/admin/identities?per_page=1000&page={}".format(page)).json() + for r in res: + # removed the app role assignment function, passing simple user data + # userList.append(UserService.__insertAppRoleToUser(r["id"], r)) + userList.append(r) + if len(res) == 0: + page = -1 + else: + page = page + 1 return userList @@ -135,6 +143,38 @@ class UserService: return UserService.get_user(id) + @staticmethod + def put_multiple_users(user_editing_id, data): + for user_data in data["users"]: + kratos_data = { + # "schema_id": "default", + "traits": {"email": user_data["email"]}, + } + KratosApi.put("/admin/identities/{}".format(user_data["id"]), kratos_data) + + is_admin = RoleService.is_user_admin(user_editing_id) + + if is_admin and user_data["app_roles"]: + app_roles = user_data["app_roles"] + for ar in app_roles: + app = App.query.filter_by(slug=ar["name"]).first() + app_role = AppRole.query.filter_by( + user_id=user_data["id"], app_id=app.id).first() + + if app_role: + app_role.role_id = ar["role_id"] if "role_id" in ar else None + db.session.commit() + else: + appRole = AppRole( + user_id=user_Data["id"], + role_id=ar["role_id"] if "role_id" in ar else None, + app_id=app.id, + ) + db.session.add(appRole) + db.session.commit() + + return UserService.get_user(user_data["id"]) + @staticmethod def delete_user(id): app_role = AppRole.query.filter_by(user_id=id).all() diff --git a/backend/areas/users/users.py b/backend/areas/users/users.py index 25d906e593b405cb9a6a19f21a44f123194c181a..a00e7d14671a0911c9064a990367f910529e9be6 100644 --- a/backend/areas/users/users.py +++ b/backend/areas/users/users.py @@ -7,7 +7,7 @@ from areas import api_v1 from helpers import KratosApi from helpers.auth_guard import admin_required -from .validation import schema, schema_multiple +from .validation import schema, schema_multiple, schema_multi_edit from .user_service import UserService @@ -83,6 +83,18 @@ def post_multiple_users(): return jsonify(res) +# multi-user editing of app roles +@api_v1.route("/users-multi-edit", methods=["PUT"]) +@jwt_required() +@cross_origin() +@expects_json(schema_multi_edit) +@admin_required() +def put_multiple_users(): + data = request.get_json() + user_id = __get_user_id_from_jwt() + res = UserService.put_multiple_users(user_id, data) + return jsonify(res) + @api_v1.route("/me", methods=["GET"]) @jwt_required() @cross_origin() diff --git a/backend/areas/users/validation.py b/backend/areas/users/validation.py index 4131c838a4e59cd6f9d3fc33b4e706cdbdf42d89..972a024373dfa19df3b426209ffdcecffb7b22ee 100644 --- a/backend/areas/users/validation.py +++ b/backend/areas/users/validation.py @@ -17,7 +17,7 @@ schema = { "name": { "type": "string", "description": "Name of the app", - "minLenght": 1, + "minLength": 1, }, "role_id": { "type": ["integer", "null"], @@ -40,3 +40,45 @@ schema_multiple = { } } } + +# Multiple app role edit of existing users +schema_multi_edit = { + "users": { + "type": "array", + "items" : { + "type": "object", + "properties": { + "email": { + "type": "string", + "description": "Email of the user", + "minLength": 1, + }, + "id": { + "type": "string", + "description": "UUID of the user", + "minLength": 1, + }, + "app_roles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the app", + "minLength": 1, + }, + "role_id": { + "type": ["integer", "null"], + "description": "Role of the user", + "minimum": 1, + }, + }, + # "required": ["name", "role_id"], + }, + }, + }, + # "required": ["email", "app_roles"], + } + } +} \ No newline at end of file diff --git a/backend/helpers/kratos_user.py b/backend/helpers/kratos_user.py index 3615809e0adcd29abcdd167a69bb4ca6cf7aafb0..e1540368bdb0a44d0754331a7acbe1a0df31265d 100644 --- a/backend/helpers/kratos_user.py +++ b/backend/helpers/kratos_user.py @@ -133,12 +133,18 @@ class KratosUser(): kratos_id = None # Get out user ID by iterating over all available IDs - data = api.list_identities() - for kratos_obj in data: - # Unique identifier we use - if kratos_obj.traits['email'] == email: - kratos_id = str(kratos_obj.id) - return KratosUser(api, kratos_id) + page = 1 + while page > 0: + data = api.list_identities(per_page=1000, page=page) + for kratos_obj in data: + # Unique identifier we use + if kratos_obj.traits['email'] == email: + kratos_id = str(kratos_obj.id) + return KratosUser(api, kratos_id) + if len(data) == 0: + page = -1 + else: + page = page + 1 return None @@ -151,11 +157,17 @@ class KratosUser(): kratos_id = None return_list = [] - # Get out user ID by iterating over all available IDs - data = api.list_identities() - for kratos_obj in data: - kratos_id = str(kratos_obj.id) - return_list.append(KratosUser(api, kratos_id)) + # Get out user ID by iterating over all available ID + page = 1 + while page > 0: + data = api.list_identities(per_page=1000, page=page) + for kratos_obj in data: + kratos_id = str(kratos_obj.id) + return_list.append(KratosUser(api, kratos_id)) + if len(data) == 0: + page = -1 + else: + page = page + 1 return return_list @@ -340,9 +352,6 @@ class KratosUser(): return False raise BackendError("Unable to set password by submitting form") - # Pylint complains about app not used. That is correct, but we will use that - # in the future. Ignore this error - # pylint: disable=unused-argument def get_claims(self, app, roles, mapping=None) -> Dict[str, Dict[str, str]]: """Create openID Connect token Use the userdata stored in the user object to create an OpenID Connect token. @@ -352,7 +361,7 @@ class KratosUser(): Example: getClaims('nextcloud', mapping=[("name", "username"),("roles", "groups")]) Attributes: - appname - Name or ID of app to connect to + appname - client_id of app to connect to roles - List of roles to add to the `stackspin_roles` claim mapping - Mapping of the fields @@ -380,6 +389,34 @@ class KratosUser(): "stackspin_roles": roles, } + if app == "wekan": + # This is a non-standard extension to OIDC. It's used in this form + # by Wekan. We don't really have user groups in Stackspin, just an + # admin flag. However as far as I can see, the only way to make + # some users admin in Wekan via OIDC is to have a group for them. + # + # We include a default "stackspin_users" group, because Wekan doesn't + # process group information if the `groups` list is empty, so we + # would not be able to remove a user from the admin group + # otherwise. + # + # Actually Wekan doesn't remove users from groups based on this + # list apparently, but it still implements a correct `isAdmin` + # check based on this group data, which is the part we care about. + groups = [{ + "displayName": "Stackspin users", + "isAdmin": False, + "forceCreate": True, + "isActive": True, + }] + if "admin" in roles: + groups.append({ + "displayName": "Stackspin admins", + "isAdmin": True, + "forceCreate": True, + "isActive": True, + }) + token["groups"] = groups # Relabel field names if mapping: diff --git a/backend/web/login/login.py b/backend/web/login/login.py index e03b03ed3f07a347a40897f4b49aa0e485cb0647..572253717af5926a14e1023b9790e4c5f71e7743 100644 --- a/backend/web/login/login.py +++ b/backend/web/login/login.py @@ -4,9 +4,10 @@ Hydra for OIDC sessions and MariaDB for application and role specifications. The application provides also several command line options to interact with the user entries in the database(s)""" +import ast +import json import urllib.parse import urllib.request -import ast import ory_hydra_client # hydra v2 @@ -129,17 +130,23 @@ def login(): """ # Check if we are logged in: - identity = get_auth() + (identity, auth_response) = get_auth() + # We ignore the potential `auth_response` in this case: that's for telling + # the user they have to upgrade their session to include a second factor, + # but we're already on the login page so there's no use for that here -- + # they'd be redirected by Kratos back to this same login page anyway, + # creating a redirect loop. Chances are that if `auth_response` is not + # None, we're actually serving or processing the TOTP form here. # List to contain messages pushed to the frontend messages = list() - refresh = False flow = request.args.get("flow") if flow: cookies = request.headers['cookie'] flow = kratos_public_frontend_api.get_login_flow(flow, cookie=cookies) + # current_app.logger.info("flow found in login: {}".format(flow)) refresh = flow['refresh'] if refresh: message = { @@ -241,7 +248,12 @@ def auth(): abort(400, description="Challenge required when requesting authorization") # Check if we are logged in: - identity = get_auth() + (identity, auth_response) = get_auth() + + if auth_response is not None: + # According to `get_auth`, we have to send the user a response already, + # probably a redirect to let the user provide their second factor. + return auth_response # If the user is not logged in yet, we redirect to the login page # but before we do that, we set the "flow_state" cookie to auth. @@ -370,7 +382,7 @@ def consent(): current_app.logger.info(f"Providing consent to {client_id} for {kratos_id}") current_app.logger.info(f"{kratos_id} was granted admin access to {client_id}") # Get claims for this user, provided the current app - claims = user.get_claims(None, ['admin']) + claims = user.get_claims(client_id, ['admin']) current_app.logger.info(f"claims: {claims}") return redirect( hydra_admin_api.accept_consent_request( @@ -431,7 +443,8 @@ def consent(): current_app.logger.info(f"Using '{roles}' when applying consent for {kratos_id}") # Get claims for this user, provided the current app - claims = user.get_claims(None, roles) + claims = user.get_claims(client_id, roles) + current_app.logger.info(f"claims: {claims}") # pylint: disable=fixme # TODO: Need to implement checking claims here, once the backend for that is @@ -464,7 +477,7 @@ def status(): Show if there is an user is logged in. If not shows: not-auth """ - auth_status = get_auth() + (auth_status, auth_response) = get_auth() if auth_status: return auth_status.id @@ -475,27 +488,43 @@ def get_auth(): """Checks if user is logged in Queries the cookies. If an authentication cookie is found, it checks with Kratos if the cookie is still valid. If so, - the profile is returned. Otherwise False is returned. - :return: Profile or False if not logged in + the profile is returned. Otherwise False is returned, possibly with a + response to send to the user, for redirecting them to the kratos-suggested + url, for providing 2FA in particular. + :return: (Profile, None) or (False, None) or (False, Response) """ cookie = get_kratos_cookie() if not cookie: - return False + return False, None # Given a cookie, check if it is valid and get the profile try: api_response = kratos_public_frontend_api.to_session(cookie=cookie) # Get all traits from ID - return api_response.identity + return api_response.identity, None except ory_kratos_client.ApiException as ex: + # If it fails because the client needs to provide 2FA, we return a + # redirect response for use by the caller of this function. + if ex.body is not None: + body = json.loads(ex.body) + current_app.logger.info("Error in to_session: {}".format(body)) + error_id = body.get('error', {}).get('id') + if error_id == 'session_aal2_required': + current_app.logger.info("2FA requested by Kratos. Redirecting the user.") + redirect_url = body.get('redirect_browser_to') + if redirect_url is None: + response = None + else: + response = redirect(redirect_url) + return False, response current_app.logger.error( f"Exception when calling to_session(): {ex}\n" ) - return False + return False, None def get_kratos_cookie(): diff --git a/backend/web/static/base.js b/backend/web/static/base.js index 61d08200d5409383f40d02e7aa7d32530eb10142..bf79733c8c7eaf990a84556bf5753d67430be9e9 100644 --- a/backend/web/static/base.js +++ b/backend/web/static/base.js @@ -16,8 +16,8 @@ */ -// In default configuration the dashboed is on '/'. This can be overwritten -// before calling the scripts (and configured by the flask app +// In default configuration the dashboard is on '/'. This can be overwritten +// before calling the scripts (and configured by the flask app). var dashboard_url = ""; // Render a message by appending the data to the messages box. The message id is @@ -42,6 +42,7 @@ function renderMessage(id, message, type) { // case. function check_flow_auth() { var state = Cookies.get("flow_state"); + window.console.log("check_flow_auth: flow_state=" + state); var url = Cookies.get("auth_url"); // Redirect to the specified URL @@ -51,6 +52,15 @@ function check_flow_auth() { return; } + if (state == "recovery") { + Cookies.set("flow_state", ""); + // Set a custom cookie so the settings page knows we're in + // recovery context and can open the right tab. + Cookies.set("stackspin_context", "recovery"); + window.location.href = api_url + '/self-service/settings/browser'; + return; + } + // Some older stackspin releases, do not provide the dashboard_url, // flask writes 'None' as string in that case, we want to cover those // cases and revert to the default @@ -76,7 +86,6 @@ function check_flow_expired() { function flow_login() { var flow = $.urlParam("flow"); var uri = api_url + "self-service/login/flows?id=" + flow; - // Query the Kratos backend to know what fields to render for the // current flow $.ajax({ @@ -112,7 +121,6 @@ function flow_login() { function flow_settings_validate() { var flow = $.urlParam("flow"); var uri = api_url + "self-service/settings/flows?id=" + flow; - $.ajax({ type: "GET", url: uri, @@ -153,6 +161,7 @@ function flow_settings() { url: uri, success: function (data) { var state = Cookies.get("flow_state"); + var context = Cookies.get("stackspin_context"); // If we have confirmation the settings are saved, show the // notification @@ -203,6 +212,12 @@ function flow_settings() { }, }); }); + + // If we are in recovery context, switch to the password tab. + if (context == "recovery") { + $('#pills-password-tab').tab('show'); + Cookies.set('stackspin_context', ''); + } }, complete: function (obj) { // If we get a 410, the flow is expired, need to refresh the flow @@ -211,6 +226,25 @@ function flow_settings() { window.location.href = "settings"; } }, + error: function (xhr, textStatus, errorThrown) { + // Check if we got a 403 error from Kratos. + if (textStatus == "error" && xhr.status == 403) { + var response = $.parseJSON(xhr.responseText); + window.console.log(response); + if (response.error.id == "session_aal2_required") { + // Redirect so user can enter 2FA. + window.location.href = response.redirect_browser_to; + return; + } + } + // There was another error, one we don't specifically prepared for. + $("#contentProfileSaveFailed").show(); + + // For now, this code assumes that only the password can fail + // validation. Other forms might need to be added in the future. + html = render_form(data, "password", "validation"); + $("#contentPassword").html(html); + }, }); } diff --git a/deployment/helmchart/CHANGELOG.md b/deployment/helmchart/CHANGELOG.md index c6a7b57464f6bd18369061afdcbb2a5fa9c19dd4..b8a775b3034a7072247467b690755364e579900d 100644 --- a/deployment/helmchart/CHANGELOG.md +++ b/deployment/helmchart/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [1.7.6] + +* Update dashboard to version 0.7.6. + +## [1.7.5] + +* Update dashboard to version 0.7.5. + ## [1.7.4] * Update dashboard to version 0.7.4. diff --git a/deployment/helmchart/Chart.yaml b/deployment/helmchart/Chart.yaml index 952b004e46ffe3b42250d0742ca2c4ace3decdc8..b402fc65c6023850bb399aed0406680f6be7bd07 100644 --- a/deployment/helmchart/Chart.yaml +++ b/deployment/helmchart/Chart.yaml @@ -1,7 +1,7 @@ annotations: category: Dashboard apiVersion: v2 -appVersion: 0.7.4 +appVersion: 0.7.6 dependencies: - name: common # https://artifacthub.io/packages/helm/bitnami/common @@ -23,4 +23,4 @@ name: stackspin-dashboard sources: - https://open.greenhost.net/stackspin/dashboard/ - https://open.greenhost.net/stackspin/dashboard-backend/ -version: 1.7.5-nosecrets +version: 1.7.6 diff --git a/deployment/helmchart/values.yaml b/deployment/helmchart/values.yaml index 6b42b622bf50284996b5b7c216dacb9b2b74fe55..754b8e0a4c2681a74f2cfd4c97b56246e83c0fb4 100644 --- a/deployment/helmchart/values.yaml +++ b/deployment/helmchart/values.yaml @@ -68,7 +68,7 @@ dashboard: image: registry: open.greenhost.net:4567 repository: stackspin/dashboard/dashboard - tag: 143-make-dashboard-secret-generation-generic + tag: 0.7.6 digest: "" ## Optionally specify an array of imagePullSecrets. ## Secrets must be manually created in the namespace. @@ -236,7 +236,7 @@ backend: image: registry: open.greenhost.net:4567 repository: stackspin/dashboard/dashboard-backend - tag: 143-make-dashboard-secret-generation-generic + tag: 0.7.6 digest: "" ## Optionally specify an array of imagePullSecrets. ## Secrets must be manually created in the namespace. @@ -723,7 +723,7 @@ tests: image: registry: open.greenhost.net:4567 repository: stackspin/dashboard/cypress-test - tag: 143-make-dashboard-secret-generation-generic + tag: 0.7.6 pullPolicy: IfNotPresent credentials: user: "" diff --git a/docker-compose.yml b/docker-compose.yml index be2676feaec1edbdef067e114f7765219092e052..d8861940817bcf271a7dc059792c7ba2d57b88fa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,7 @@ services: - "3000:3000" command: "yarn start --watch --verbose" stackspin_proxy: - image: nginx:1.25.1 + image: nginx:1.25.2 ports: - "8081:8081" volumes: @@ -57,7 +57,7 @@ services: - kube_port_mysql entrypoint: ["bash", "-c", "flask run --host $$(hostname -i)"] kube_port_kratos_admin: - image: bitnami/kubectl:1.27.3 + image: bitnami/kubectl:1.27.5 user: "${KUBECTL_UID}:${KUBECTL_GID}" expose: - 8000 @@ -70,7 +70,7 @@ services: "kubectl -n stackspin port-forward --address $$(hostname -i) service/kratos-admin 8000:80", ] kube_port_hydra_admin: - image: bitnami/kubectl:1.27.3 + image: bitnami/kubectl:1.27.5 user: "${KUBECTL_UID}:${KUBECTL_GID}" expose: - 4445 @@ -83,7 +83,7 @@ services: "kubectl -n stackspin port-forward --address $$(hostname -i) service/hydra-admin 4445:4445", ] kube_port_kratos_public: - image: bitnami/kubectl:1.27.3 + image: bitnami/kubectl:1.27.5 user: "${KUBECTL_UID}:${KUBECTL_GID}" ports: - "8080:8080" @@ -98,7 +98,7 @@ services: "kubectl -n stackspin port-forward --address 0.0.0.0 service/kratos-public 8080:80", ] kube_port_mysql: - image: bitnami/kubectl:1.27.3 + image: bitnami/kubectl:1.27.5 user: "${KUBECTL_UID}:${KUBECTL_GID}" expose: - 3306 diff --git a/frontend/package.json b/frontend/package.json index daaa1f9a4e1ff6abe55fc7a6acd7a14494a599cf..a56e177575604dc3873831e890e7b01dc5dacf1a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,8 @@ "@hookform/resolvers": "^2.6.1", "@tailwindcss/forms": "^0.3.3", "@tailwindcss/typography": "^0.4.1", + "@tanstack/match-sorter-utils": "^8.8.4", + "@tanstack/react-table": "^8.9.3", "@testing-library/jest-dom": "^5.11.4", "@testing-library/react": "^11.1.0", "@testing-library/user-event": "^12.1.10", diff --git a/frontend/public/custom/assets/forgejo.svg b/frontend/public/custom/assets/forgejo.svg new file mode 100644 index 0000000000000000000000000000000000000000..bcacdc020034d0d1e6db88e9b2377d4c33cbe184 --- /dev/null +++ b/frontend/public/custom/assets/forgejo.svg @@ -0,0 +1,27 @@ +<svg viewBox="0 0 212 212" xmlns="http://www.w3.org/2000/svg"> + <style type="text/css"> + circle { + fill: none; + stroke: #000; + stroke-width: 15; + } + path { + fill: none; + stroke: #000; + stroke-width: 25; + } + .orange { + stroke:#ff6600; + } + .red { + stroke:#d40000; + } + </style> + <g transform="translate(6,6)"> + <path d="M58 168 v-98 a50 50 0 0 1 50-50 h20" class="orange" /> + <path d="M58 168 v-30 a50 50 0 0 1 50-50 h20" class="red" /> + <circle cx="142" cy="20" r="18" class="orange" /> + <circle cx="142" cy="88" r="18" class="red" /> + <circle cx="58" cy="180" r="18" class="red" /> + </g> +</svg> diff --git a/frontend/public/custom/assets/jitsi.svg b/frontend/public/custom/assets/jitsi.svg new file mode 100644 index 0000000000000000000000000000000000000000..5a3526ac89ef7578bd411360348ebaddfc5772c5 --- /dev/null +++ b/frontend/public/custom/assets/jitsi.svg @@ -0,0 +1,650 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Created with Inkscape (http://www.inkscape.org/) --> + +<svg + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns="http://www.w3.org/2000/svg" + xmlns:xlink="http://www.w3.org/1999/xlink" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + width="744.09448819" + height="1052.3622047" + id="svg5488" + version="1.1" + inkscape:version="0.47 r22583" + sodipodi:docname="New document 5"> + <defs + id="defs5490"> + <inkscape:perspective + sodipodi:type="inkscape:persp3d" + inkscape:vp_x="0 : 526.18109 : 1" + inkscape:vp_y="0 : 1000 : 0" + inkscape:vp_z="744.09448 : 526.18109 : 1" + inkscape:persp3d-origin="372.04724 : 350.78739 : 1" + id="perspective5496" /> + <inkscape:perspective + id="perspective5347" + inkscape:persp3d-origin="0.5 : 0.33333333 : 1" + inkscape:vp_z="1 : 0.5 : 1" + inkscape:vp_y="0 : 1000 : 0" + inkscape:vp_x="0 : 0.5 : 1" + sodipodi:type="inkscape:persp3d" /> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient8896" + id="linearGradient4242" + gradientUnits="userSpaceOnUse" + gradientTransform="translate(-297.23084,320.86007)" + x1="439.90353" + y1="455.73935" + x2="469.71744" + y2="557.74847" /> + <linearGradient + id="linearGradient8896"> + <stop + id="stop8898" + offset="0" + style="stop-color:#0f3060;stop-opacity:1;" /> + <stop + style="stop-color:#0575ce;stop-opacity:1;" + offset="0.27115166" + id="stop8902" /> + <stop + id="stop12553" + offset="0.7327472" + style="stop-color:#0575ce;stop-opacity:1;" /> + <stop + style="stop-color:#0f3060;stop-opacity:1;" + offset="1" + id="stop8900" /> + </linearGradient> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient5041" + id="linearGradient4244" + gradientUnits="userSpaceOnUse" + gradientTransform="translate(-297.23084,320.86007)" + x1="382.80234" + y1="585.22589" + x2="347.44287" + y2="645.02435" /> + <linearGradient + id="linearGradient5041"> + <stop + style="stop-color:#092d61;stop-opacity:1;" + offset="0" + id="stop5043" /> + <stop + id="stop5047" + offset="1" + style="stop-color:#0575ce;stop-opacity:1;" /> + </linearGradient> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient3493" + id="linearGradient4246" + gradientUnits="userSpaceOnUse" + gradientTransform="translate(-297.23084,320.86007)" + x1="415.81378" + y1="620.09808" + x2="436.35599" + y2="486.43097" /> + <linearGradient + id="linearGradient3493"> + <stop + style="stop-color:#0f3060;stop-opacity:1;" + offset="0" + id="stop3495" /> + <stop + id="stop3497" + offset="0.45698157" + style="stop-color:#0575ce;stop-opacity:1;" /> + <stop + style="stop-color:#0575ce;stop-opacity:1;" + offset="0.73828435" + id="stop3501" /> + <stop + id="stop3507" + offset="1" + style="stop-color:#0f3060;stop-opacity:1;" /> + </linearGradient> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient3529" + id="linearGradient4248" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(0.245729,0,0,0.245729,34.35214,723.04037)" + x1="389.59329" + y1="763.3017" + x2="308.98642" + y2="420.01578" /> + <linearGradient + id="linearGradient3529"> + <stop + id="stop3531" + offset="0" + style="stop-color:#ff8000;stop-opacity:1;" /> + <stop + style="stop-color:#fff4e1;stop-opacity:1;" + offset="0.6627211" + id="stop3533" /> + <stop + id="stop3535" + offset="0.75" + style="stop-color:#fff4e1;stop-opacity:1;" /> + <stop + id="stop3537" + offset="1.0000000" + style="stop-color:#ff8400;stop-opacity:1.0000000;" /> + </linearGradient> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient3591" + id="linearGradient4250" + gradientUnits="userSpaceOnUse" + gradientTransform="translate(-297.23084,320.86007)" + x1="528.82031" + y1="511.71811" + x2="458.46918" + y2="527.10736" /> + <linearGradient + id="linearGradient3591"> + <stop + style="stop-color:#ffffff;stop-opacity:1;" + offset="0" + id="stop3593" /> + <stop + style="stop-color:#ffffff;stop-opacity:0;" + offset="1" + id="stop3595" /> + </linearGradient> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient7434" + id="linearGradient4252" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(0.245729,0,0,0.245729,34.35214,723.04037)" + x1="197.17841" + y1="703.80151" + x2="146.54216" + y2="785.90436" /> + <linearGradient + id="linearGradient7434"> + <stop + id="stop7436" + offset="0" + style="stop-color:#ffc768;stop-opacity:1;" /> + <stop + style="stop-color:#ff8400;stop-opacity:1.0000000;" + offset="0.37842149" + id="stop7438" /> + <stop + id="stop7440" + offset="0.77420431" + style="stop-color:#ff8400;stop-opacity:1.0000000;" /> + <stop + id="stop7442" + offset="1" + style="stop-color:#ffc768;stop-opacity:1;" /> + </linearGradient> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient3591" + id="linearGradient4254" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(1.0334058,0,0,1.0138319,-309.91012,314.18347)" + x1="413.63229" + y1="484.60083" + x2="423.52518" + y2="541.83301" /> + <linearGradient + id="linearGradient5384"> + <stop + style="stop-color:#ffffff;stop-opacity:1;" + offset="0" + id="stop5386" /> + <stop + style="stop-color:#ffffff;stop-opacity:0;" + offset="1" + id="stop5388" /> + </linearGradient> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient3509" + id="linearGradient4256" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(0.245729,0,0,0.245729,34.35214,723.04037)" + x1="409.69571" + y1="559.36359" + x2="467.88617" + y2="676.03516" /> + <linearGradient + id="linearGradient3509"> + <stop + id="stop3511" + offset="0" + style="stop-color:#ff8000;stop-opacity:1;" /> + <stop + style="stop-color:#ffc768;stop-opacity:1.0000000;" + offset="0.34144846" + id="stop3513" /> + <stop + id="stop3515" + offset="0.71198046" + style="stop-color:#ffc768;stop-opacity:1.0000000;" /> + <stop + id="stop3517" + offset="1.0000000" + style="stop-color:#ff8400;stop-opacity:1.0000000;" /> + </linearGradient> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient3651" + id="linearGradient4258" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(0.245729,0,0,0.245729,34.35214,723.04037)" + x1="522.63641" + y1="168.68723" + x2="585.93317" + y2="161.10866" /> + <linearGradient + id="linearGradient3651"> + <stop + style="stop-color:#ff8000;stop-opacity:1;" + offset="0" + id="stop3653" /> + <stop + id="stop16786" + offset="0.5" + style="stop-color:#fff4e1;stop-opacity:1;" /> + <stop + style="stop-color:#fff4e1;stop-opacity:1;" + offset="0.75" + id="stop17514" /> + <stop + style="stop-color:#ff8400;stop-opacity:1.0000000;" + offset="1.0000000" + id="stop3655" /> + </linearGradient> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient3509" + id="linearGradient4260" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(0.245729,0,0,0.245729,34.35214,723.04037)" + x1="481.48975" + y1="417.49979" + x2="405.41116" + y2="316.78976" /> + <linearGradient + id="linearGradient5403"> + <stop + id="stop5405" + offset="0" + style="stop-color:#ff8000;stop-opacity:1;" /> + <stop + style="stop-color:#ffc768;stop-opacity:1.0000000;" + offset="0.34144846" + id="stop5407" /> + <stop + id="stop5409" + offset="0.71198046" + style="stop-color:#ffc768;stop-opacity:1.0000000;" /> + <stop + id="stop5411" + offset="1.0000000" + style="stop-color:#ff8400;stop-opacity:1.0000000;" /> + </linearGradient> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient3591" + id="linearGradient4262" + gradientUnits="userSpaceOnUse" + gradientTransform="translate(-297.23084,320.86007)" + x1="473.09195" + y1="499.58444" + x2="457.68967" + y2="477.5997" /> + <linearGradient + id="linearGradient5414"> + <stop + style="stop-color:#ffffff;stop-opacity:1;" + offset="0" + id="stop5416" /> + <stop + style="stop-color:#ffffff;stop-opacity:0;" + offset="1" + id="stop5418" /> + </linearGradient> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient3591" + id="linearGradient4264" + gradientUnits="userSpaceOnUse" + gradientTransform="translate(-297.23084,320.86007)" + x1="394.34113" + y1="501.80154" + x2="446.31302" + y2="485.37762" /> + <linearGradient + id="linearGradient5421"> + <stop + style="stop-color:#ffffff;stop-opacity:1;" + offset="0" + id="stop5423" /> + <stop + style="stop-color:#ffffff;stop-opacity:0;" + offset="1" + id="stop5425" /> + </linearGradient> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient3591" + id="linearGradient4266" + gradientUnits="userSpaceOnUse" + gradientTransform="translate(-297.23084,320.86007)" + x1="395.81326" + y1="590.73315" + x2="369.55322" + y2="572.16907" /> + <linearGradient + id="linearGradient5428"> + <stop + style="stop-color:#ffffff;stop-opacity:1;" + offset="0" + id="stop5430" /> + <stop + style="stop-color:#ffffff;stop-opacity:0;" + offset="1" + id="stop5432" /> + </linearGradient> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient2937" + id="linearGradient4268" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(0.245729,0,0,0.245729,34.35214,723.04037)" + x1="297.05316" + y1="667.16193" + x2="310.45529" + y2="713.86633" /> + <linearGradient + id="linearGradient2937"> + <stop + style="stop-color:#212a3a;stop-opacity:1.0000000;" + offset="0.0000000" + id="stop2939" /> + <stop + style="stop-color:#404e67;stop-opacity:0.0000000;" + offset="1.0000000" + id="stop2941" /> + </linearGradient> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient2937" + id="linearGradient4270" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(0.09307993,0,0,0.1860535,131.94157,665.87199)" + x1="246.30438" + y1="690.02673" + x2="366.87921" + y2="632.15985" /> + <linearGradient + id="linearGradient5439"> + <stop + style="stop-color:#212a3a;stop-opacity:1.0000000;" + offset="0.0000000" + id="stop5441" /> + <stop + style="stop-color:#404e67;stop-opacity:0.0000000;" + offset="1.0000000" + id="stop5443" /> + </linearGradient> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient2937" + id="linearGradient4272" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(-0.09281332,-0.00703915,0.01407026,-0.1855207,186.19996,929.70821)" + x1="301.05194" + y1="645.89917" + x2="367.94604" + y2="654.72131" /> + <linearGradient + id="linearGradient5446"> + <stop + style="stop-color:#212a3a;stop-opacity:1.0000000;" + offset="0.0000000" + id="stop5448" /> + <stop + style="stop-color:#404e67;stop-opacity:0.0000000;" + offset="1.0000000" + id="stop5450" /> + </linearGradient> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient2937" + id="linearGradient4274" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(0.245729,0,0,0.245729,98.31046,642.63728)" + x1="279.81714" + y1="688.32355" + x2="386.78625" + y2="667.6355" /> + <linearGradient + id="linearGradient5453"> + <stop + style="stop-color:#212a3a;stop-opacity:1.0000000;" + offset="0.0000000" + id="stop5455" /> + <stop + style="stop-color:#404e67;stop-opacity:0.0000000;" + offset="1.0000000" + id="stop5457" /> + </linearGradient> + <linearGradient + inkscape:collect="always" + xlink:href="#linearGradient2937" + id="linearGradient4276" + gradientUnits="userSpaceOnUse" + gradientTransform="matrix(0.245729,0,0,0.245729,25.55056,660.77045)" + x1="338.14404" + y1="668.3009" + x2="266.215" + y2="702.89276" /> + <linearGradient + id="linearGradient5460"> + <stop + style="stop-color:#212a3a;stop-opacity:1.0000000;" + offset="0.0000000" + id="stop5462" /> + <stop + style="stop-color:#404e67;stop-opacity:0.0000000;" + offset="1.0000000" + id="stop5464" /> + </linearGradient> + <linearGradient + y2="702.89276" + x2="266.215" + y1="668.3009" + x1="338.14404" + gradientTransform="matrix(0.245729,0,0,0.245729,25.55056,660.77045)" + gradientUnits="userSpaceOnUse" + id="linearGradient5486" + xlink:href="#linearGradient2937" + inkscape:collect="always" /> + </defs> + <sodipodi:namedview + id="base" + pagecolor="#ffffff" + bordercolor="#666666" + borderopacity="1.0" + inkscape:pageopacity="0.0" + inkscape:pageshadow="2" + inkscape:zoom="0.35" + inkscape:cx="375" + inkscape:cy="520" + inkscape:document-units="px" + inkscape:current-layer="layer1" + showgrid="false" + inkscape:window-width="798" + inkscape:window-height="690" + inkscape:window-x="20" + inkscape:window-y="20" + inkscape:window-maximized="0" /> + <metadata + id="metadata5493"> + <rdf:RDF> + <cc:Work + rdf:about=""> + <dc:format>image/svg+xml</dc:format> + <dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> + <dc:title></dc:title> + </cc:Work> + </rdf:RDF> + </metadata> + <g + inkscape:label="Layer 1" + inkscape:groupmode="layer" + id="layer1"> + <g + id="g4202" + transform="matrix(4.2070673,0,0,4.2070673,-152.93197,-3059.7049)"> + <path + inkscape:export-ydpi="19.25" + inkscape:export-xdpi="19.25" + inkscape:export-filename="/home/emcho/storage/images/sc_logo/sc_logo136x203.png" + sodipodi:nodetypes="csssssssssssssssssssssssssssssssssccssssssssssccsssssssssccsssssssssscssssssssssccssssssscssssssssc" + id="path4204" + d="m 52.51386,963.95968 c -0.59762,-1.31419 -1.08659,-2.49776 -1.08659,-2.63031 0,-0.13252 -2.08061,-11.07486 -2.31769,-13.88681 -1.03506,-12.27691 -2.66752,-13.45265 -2.21175,-24.39857 0.2761,-6.63061 -0.64392,-8.89235 1.31344,-14.31478 4.03102,-11.16694 6.98662,-15.56085 12.2251,-18.17414 4.24974,-2.12004 8.23695,-1.83207 11.97929,0.86518 0.98634,0.71092 4.29577,2.95048 7.35426,4.97678 3.05851,2.02634 6.69103,4.48296 8.07229,5.45919 1.38125,0.97628 3.68511,2.38497 5.11972,3.13046 2.53058,1.3151 2.62411,1.3351 3.13729,0.67104 1.12761,-1.45907 3.84947,-5.55395 5.32124,-7.50854 7.37209,-9.79052 0.42914,-8.70222 -1.30592,-9.21291 -2.72783,-0.80294 -3.87684,-1.97938 -6.42642,-4.96758 -5.92163,-6.94035 -8.72797,-14.66214 -9.14262,-24.20339 -0.48154,-11.07972 0.63266,-20.01531 5.49789,-25.10088 2.08527,-2.17973 6.49949,-5.34524 10.53505,-7.55495 3.4177,-1.87139 24.37883,-5.78232 26.21837,-4.96975 0.91898,0.40593 -3.63796,-11.63862 -1.01552,-27.29896 0.52096,-3.11103 4.05934,-12.73788 4.43322,-13.39298 0.92313,-1.61744 16.13503,-6.82395 20.46221,-9.36515 4.83486,-2.83935 15.29074,-9.67883 16.07843,-14.76568 1.73099,-11.1786 2.03825,-15.9556 2.75981,-16.37531 1.07388,-0.62465 8.25251,14.17566 3.39352,28.13116 -0.95893,2.75414 -5.53206,9.14865 -7.57975,11.8233 -1.47452,1.92602 1.72775,2.82621 3.56985,5.60679 1.67534,2.52882 2.77675,8.71086 2.9918,11.91416 0.21959,3.27101 -0.81543,9.98235 -1.7425,11.2988 -0.37549,0.53325 -0.52929,1.05709 -0.34387,1.17136 0.18449,0.11368 1.43417,-0.0352 2.77708,-0.33076 1.34289,-0.29559 4.25366,-0.7663 6.46837,-1.046 3.94259,-0.49787 4.08384,-0.49095 6.7595,0.33213 4.01386,1.23476 23.53789,5.95177 11.07947,60.50742 -4.12753,18.07453 -23.38873,41.92489 -25.8106,40.0783 l -5.96407,-4.5474 -6.87325,10.19022 c -3.50798,3.60293 -7.29065,7.08029 -9.52167,8.75312 -3.52051,2.63972 -10.56453,6.79799 -11.51562,6.79799 -0.24492,0 -1.63251,0.56931 -3.08354,1.26516 -4.19508,2.01176 -10.47851,3.66081 -17.13429,4.09795 -25.77135,1.66956 -15.93975,-1.44192 -37.27858,-11.77515 -1.68316,-0.81506 -5.55753,3.29679 -10.22719,7.54571 -4.98524,4.53606 -7.60226,7.98846 -9.57996,12.63792 -0.66947,1.57389 -1.71168,3.98838 -2.31602,5.36552 -1.64444,3.74731 -2.99719,8.93229 -3.19818,12.25814 -0.0983,1.62685 -0.31514,3.04193 -0.48187,3.14469 -0.16673,0.10272 -0.7921,-0.88841 -1.38973,-2.20249 z m 28.33197,-45.90886 c 3.30313,-0.83536 3.66477,-1.01966 7.21765,-3.67825 2.54576,-1.90501 4.12321,-5.3446 4.8004,-5.64508 4.4324,-1.96673 -3.3083,-3.73691 -6.20226,-5.68891 -2.89398,-1.95202 -6.65724,-3.39508 -9.2209,-5.3521 -2.56367,-1.95698 -4.78356,-3.48279 -4.93308,-3.39068 -0.14952,0.0921 -0.27471,1.40551 -0.27821,2.91861 -0.01023,4.41323 -0.6021,8.84164 -1.42935,10.69448 -0.42105,0.94301 -0.84611,2.76087 -0.94461,4.03969 -0.22816,2.96221 0.46109,4.38683 2.91271,6.02036 2.06595,1.3766 2.92385,1.38532 8.07765,0.0819 z m 119.70575,-83.58495 c -1.07558,-7.22468 -0.18498,-17.16053 -5.81444,-20.77728 -12.98767,-8.34415 -14.4135,-0.0696 -15.21021,1.75899 -1.31072,3.0083 -2.11753,5.46892 -1.12273,8.7761 3.05641,10.16086 6.96643,19.15821 8.82255,26.38029 2.13021,8.28856 4.47797,13.53077 4.71288,20.02986 0.16678,4.61378 0.12302,5.09887 -0.55601,6.16474 -0.9655,1.51542 -15.55215,5.22752 -22.89767,7.39176 -2.52211,0.74311 -0.28804,6.12175 -0.57696,7.89239 -0.28892,1.77059 -4.23701,11.66713 -2.96729,13.65682 12.52923,19.63374 40.23506,-40.20634 35.60988,-71.27367 z m -26.98831,48.27201 c 1.95984,-0.47131 3.79219,-1.04626 4.0719,-1.27773 0.27971,-0.23143 0.99066,-0.38419 1.57991,-0.33941 1.05432,0.0801 5.18218,-1.21949 5.4995,-1.73148 0.0894,-0.14415 3.7722,-2.06556 3.0355,-2.55357 -0.73674,-0.48803 -15.13237,-11.07005 -17.19649,-12.14627 -23.77004,-12.39364 -36.26526,-13.24685 -44.11407,-11.66556 -13.44457,2.70866 -25.05657,9.52598 -26.45346,11.20755 -0.42178,0.50774 21.04913,-6.66097 36.28977,0.87085 2.81591,1.39161 8.62965,3.78304 13.00058,8.46269 3.99268,4.27467 7.63329,8.79778 10.49869,9.5617 3.10695,0.82834 9.47337,0.64882 13.78817,-0.38877 z m -28.65731,-51.09687 c 7.52619,-1.317 19.18627,-16.95235 18.27894,-18.41606 -0.12276,-0.19808 2.25527,-4.14459 1.86904,-4.22161 -14.0842,-2.80833 -13.74485,-16.83242 -13.94793,-16.95753 -0.20308,-0.12514 -20.76525,9.71022 -25.50376,11.75869 -4.02092,1.73825 0.57503,12.10208 2.99929,18.53437 1.07855,2.86172 7.01744,6.67785 9.40766,8.23365 2.82773,1.84055 6.25378,1.181 6.89676,1.06849 z m 16.21969,-47.71727 c 4.06405,-3.20289 14.88802,-19.23422 13.73961,-25.73988 -4.67874,-26.5048 -6.69569,-10.0962 -6.99598,-7.73088 -0.12436,0.97963 -0.47868,3.14936 -0.7874,4.82161 -0.30868,1.67227 -0.70224,4.08678 -0.87456,5.36555 -0.1723,1.2788 -0.64865,3.53233 -1.05855,5.00784 -0.4099,1.47554 -1.05724,3.92831 -1.43856,5.45066 -0.38128,1.52232 -1.66486,5.27236 -2.85234,8.33339 -1.18749,3.06104 -2.15908,5.63846 -2.15908,5.72759 0,0.447 0.82393,0.0274 2.42686,-1.23588 z" + style="fill:url(#linearGradient4242);fill-opacity:1" /> + <path + sodipodi:nodetypes="ccssc" + id="path4206" + d="m 53.57922,963.11994 c 6.35309,-26.31992 12.10112,-29.26957 27.22751,-39.0261 -3.32781,-1.89079 -11.51745,-8.1594 -11.49606,-11.49606 0.05726,-9.74197 1.6676,-26.72201 -9.07584,-19.66431 -20.38515,13.39167 -13.0087,55.96766 -6.65561,70.18647 z" + style="fill:url(#linearGradient4244);fill-opacity:1;fill-rule:evenodd;stroke:none;display:inline" /> + <path + sodipodi:nodetypes="ccccsc" + id="path4208" + d="m 75.93257,920.7226 c 8.98173,6.78619 16.91991,12.78559 22.55412,15.96751 54.94138,2.77762 68.66033,-27.59386 69.6583,-52.49321 -4.89006,0.22454 -13.22311,-0.97302 -17.71397,-7.7405 -11.21439,3.68089 -45.16617,14.00314 -45.75692,16.32304 -3.06467,12.03515 -12.17523,21.75574 -28.74153,27.94316 z" + style="fill:url(#linearGradient4246);fill-opacity:1;fill-rule:evenodd;stroke:none;display:inline" /> + <path + inkscape:export-ydpi="19.25" + inkscape:export-xdpi="19.25" + inkscape:export-filename="/home/emcho/storage/images/sc_logo/sc_logo136x203.png" + sodipodi:nodetypes="cssssss" + id="path4210" + d="m 171.27539,910.58112 c 33.18508,-24.69965 28.91369,-74.159 29.44979,-76.69151 1.36299,-6.43866 -6.64955,-28.25155 -16.27383,-24.24947 -16.02424,6.66336 11.2767,44.44456 7.61407,65.1661 -0.76342,4.31907 -13.98948,7.50294 -22.98022,8.89864 -1.03689,0.16096 -0.1163,14.0955 -4.62673,20.50927 -0.64571,0.91818 6.21023,6.81854 6.81692,6.36697 z" + style="fill:url(#linearGradient4248);fill-opacity:1;fill-rule:evenodd;stroke:none;display:inline" /> + <path + sodipodi:nodetypes="ccc" + id="path4212" + d="m 175.90353,907.19115 c 42.49251,-41.74886 20.98218,-116.27813 6.14636,-94.50993 20.37687,-4.1207 23.61854,64.2671 -6.14636,94.50993 z" + style="fill:url(#linearGradient4250);fill-opacity:1;fill-rule:evenodd;stroke:none" /> + <path + inkscape:export-ydpi="19.25" + inkscape:export-xdpi="19.25" + inkscape:export-filename="/home/emcho/storage/images/sc_logo/sc_logo136x203.png" + sodipodi:nodetypes="csszs" + id="path4214" + d="m 95.21705,907.77562 c 0.78602,-1.06057 -13.88152,-6.08284 -22.74639,-14.06472 -0.98292,-0.88501 -1.14144,12.91242 -2.0703,18.66551 -0.57712,3.57451 5.4925,7.17351 6.87977,7.62184 1.15081,0.37191 14.71014,-7.86884 17.93692,-12.22263 z" + style="fill:url(#linearGradient4252);fill-opacity:1;fill-rule:evenodd;stroke:none;display:inline" /> + <path + sodipodi:nodetypes="ccccc" + id="path4216" + d="m 83.58536,868.01334 c -5.099007,-25.84347 4.179267,-41.03939 41.3579,-46.37791 0.18024,0.56879 41.31909,-13.25606 55.79189,-13.35001 -3.92283,6.23323 0.0433,21.62385 4.80916,34.27609 0,0 -34.8276,9.08768 -101.95895,25.45183 z" + style="fill:url(#linearGradient4254);fill-opacity:1;fill-rule:evenodd;stroke:none" /> + <path + inkscape:export-ydpi="19.25" + inkscape:export-xdpi="19.25" + inkscape:export-filename="/home/emcho/storage/images/sc_logo/sc_logo136x203.png" + sodipodi:nodetypes="csssss" + id="path4218" + d="m 101.65537,863.89391 c 4.41182,-1.58073 17.00722,-5.39035 32.05421,-0.1519 10.3057,3.58781 16.2195,12.63312 20.66816,16.112 10.4789,8.19457 29.82289,-0.90843 33.32172,-0.85341 1.43753,0.0226 -25.96694,-24.60193 -46.62844,-25.8478 -27.77538,-1.67482 -45.11147,12.78188 -39.41565,10.74111 z" + style="fill:url(#linearGradient4256);fill-opacity:1;fill-rule:evenodd;stroke:none;display:inline" /> + <path + inkscape:export-ydpi="19.25" + inkscape:export-xdpi="19.25" + inkscape:export-filename="/home/emcho/storage/images/sc_logo/sc_logo136x203.png" + sodipodi:nodetypes="csss" + id="path4220" + d="m 169.42704,741.43522 c -0.1528,8.40398 -2.79669,28.46338 -11.30718,42.1727 -3.89274,6.27072 14.52008,-8.27661 16.50236,-20.78075 1.09029,-6.8775 -5.06881,-28.08959 -5.19518,-21.39195 z" + style="fill:url(#linearGradient4258);fill-opacity:1;fill-rule:evenodd;stroke:none" /> + <path + inkscape:export-ydpi="19.25" + inkscape:export-xdpi="19.25" + inkscape:export-filename="/home/emcho/storage/images/sc_logo/sc_logo136x203.png" + sodipodi:nodetypes="cssscsc" + id="path4222" + d="m 141.79058,833.10697 c 9.23931,-4.71783 17.65654,-10.92192 22.99128,-22.44259 0.9032,-1.9505 -4.39968,-0.0586 -8.47555,-5.84433 -4.0357,-5.72867 -3.08341,-11.54295 -6.54001,-11.07789 -10.80021,1.4531 -25.20082,11.63758 -25.20082,11.63758 0,0 0.82824,11.14408 3.74751,15.67372 6.01898,9.33928 13.47759,12.05351 13.47759,12.05351 z" + style="fill:url(#linearGradient4260);fill-opacity:1;fill-rule:evenodd;stroke:none;display:inline" /> + <path + sodipodi:nodetypes="ccc" + id="path4224" + d="m 156.09152,803.78111 c 2.72579,9.88879 17.87347,8.09668 14.70668,-11.15251 -1.36528,1.99619 -4.77608,12.95694 -14.70668,11.15251 z" + style="opacity:0.55223843;fill:url(#linearGradient4262);fill-opacity:1;fill-rule:evenodd;stroke:none" /> + <path + sodipodi:nodetypes="csccsc" + id="path4226" + d="m 140.48504,832.28297 c -5.10096,-3.46887 -9.50419,-7.78444 -12.32504,-12.34966 -2.82085,-4.56521 -4.0593,-27.84724 0.68685,-33.63375 2.60826,-6.3637 11.84132,-12.51424 22.09721,-15.2308 -19.49494,22.60506 -13.17796,56.86685 4.07359,53.53526 0.86206,-0.16648 -12.82587,7.73026 -14.53261,7.67895 z" + style="fill:url(#linearGradient4264);fill-opacity:1;fill-rule:evenodd;stroke:none" /> + <path + sodipodi:nodetypes="ccc" + id="path4228" + d="m 72.32239,893.02911 c 3.80886,2.81881 20.23882,15.53557 23.85984,13.52306 -8.41018,20.55895 -30.55349,3.14238 -23.85984,-13.52306 z" + style="fill:url(#linearGradient4266);fill-opacity:1;fill-rule:evenodd;stroke:none" /> + <path + inkscape:export-ydpi="19.25" + inkscape:export-xdpi="19.25" + inkscape:export-filename="/home/emcho/storage/images/sc_logo/sc_logo136x203.png" + sodipodi:nodetypes="ccsssccssscccsssscscsccsssssccssssssccsssccssssccsscssssssssssssccsssccssssssccssssssssssssscsssscssscssc" + id="path4230" + d="m 140.05745,777.56053 c -3.94728,2.30056 -8.69302,4.87373 -9.63531,7.20019 -3.16217,6.64336 -4.34745,18.56552 -3.38465,17.88089 9.38401,-6.67285 20.24871,-8.52283 24.20205,-12.0967 11.71489,-10.59045 16.26287,-32.48353 13.80119,-29.2383 -7.96915,10.50564 -20.9111,13.88057 -24.98328,16.25392 z m -61.4085,147.01185 c 0,0 -11.08091,-7.66436 -10.87669,-10.1233 1.697,-20.4327 -1.92942,-21.625 -3.75706,-21.40828 -2.20803,0.26184 -13.6822,7.50553 -15.92058,27.95835 -1.62427,14.84147 4.24013,38.0296 5.13347,39.28426 4.62276,-12.42378 2.81919,-22.42331 25.42086,-35.71103 z m 121.05779,-90.52225 c -0.55133,-15.31772 -9.77382,-25.84979 -16.03746,-23.38322 -2.65737,1.04645 -4.4415,8.17887 -3.50846,12.60125 0.82755,3.92256 6.46634,19.10152 6.87179,20.38471 0.95868,3.03418 7.43087,21.62978 6.28035,30.56471 -1.00846,7.8316 -23.7097,10.5413 -23.7097,10.5413 0,0 -0.38149,6.37449 -1.16407,10.68177 -0.96202,5.29492 -2.86934,9.45033 -2.86934,9.45033 0,0 4.94251,4.60429 5.14224,4.59918 0.19973,-0.005 31.10844,-16.7127 28.99465,-75.44003 z m -23.69666,47.44964 c 1.59781,-0.44722 11.03201,-2.94175 10.52638,-3.27341 -3.50095,-2.2964 -7.48504,-5.18714 -14.1578,-10.37693 -37.04552,-28.81248 -70.69468,-4.54502 -70.03165,-4.76554 6.15153,-2.04594 12.64754,-3.20211 21.67232,-2.5993 7.26918,0.48555 18.69619,4.17029 28.70607,16.10244 3.72328,4.43828 7.94798,7.4497 23.28468,4.91274 z M 159.4851,818.95922 c 1.12085,-0.96962 5.01656,-8.31336 4.31505,-8.48395 -9.83076,-2.39074 -10.78824,-6.74031 -11.75043,-11.4727 -0.90123,-4.43254 -0.76219,-5.06465 -2.62452,-4.44953 -15.04703,4.96997 -19.20919,7.70131 -21.51157,9.61214 -3.60666,2.99331 -1.0768,11.03634 0.33349,14.23298 1.96563,4.4554 9.19232,12.51758 12.55437,13.16681 4.90391,0.94697 16.49344,-9.09974 18.68361,-12.60575 z m 10.93597,-27.14087 c -0.81548,-3.33524 -3.43538,-7.63066 -4.13349,-7.63066 -0.65744,0 -7.61876,5.38666 -9.4168,7.26354 -4.08086,4.25969 -2.09206,11.97893 2.43483,14.62671 7.66207,4.48154 13.28874,-5.37111 11.11546,-14.25959 z m -5.77705,-12.75642 c 3.35221,-3.34862 7.80844,-8.84532 8.99344,-17.19839 0.46443,-3.27384 0.73411,-6.15364 -1.30205,-12.62655 -0.47191,-1.50022 -1.90642,-5.8148 -1.96005,-5.24636 -1.05907,11.22382 -2.61083,23.68653 -9.93627,36.93167 -2.29934,4.15745 -1.06286,3.40174 4.20493,-1.86037 z m -78.6191,66.84814 c -3.99349,16.75103 6.58927,38.75062 15.16215,40.00456 8.28707,1.21213 32.04487,-5.96093 44.50484,-9.42568 3.26456,-0.90778 5.02074,-0.43427 -2.20131,-6.48455 -11.3226,-7.69755 -20.46099,-8.68868 -29.5776,-7.65392 -10.05581,1.14135 -17.59102,4.11772 -16.88606,3.52743 4.61053,-3.86056 12.8225,-10.76401 28.68812,-13.89013 6.89585,-1.35875 17.53788,-1.47808 30.38613,3.54005 15.93675,6.2244 24.23739,15.45542 30.97531,18.58381 2.39935,1.11401 3.25644,0.59604 4.20446,-2.18817 1.5883,-4.66452 -8.06663,-33.78938 -11.56456,-42.28236 -5.33802,-12.96073 0.89004,-19.15557 -0.47125,-18.85551 -6.6027,1.45543 -12.42508,5.69932 -17.306,10.06932 -11.93084,10.68198 -19.20273,12.95753 -20.28189,12.86579 -3.40727,-0.28972 -9.78249,-6.32022 -12.09877,-9.38241 -1.47795,-1.95389 -27.92949,3.15177 -35.72972,9.92827 -0.17845,0.15503 -6.19704,4.01498 -7.80385,11.6435 z M 77.5498,918.8389 c 8.72435,-3.6605 17.26585,-11.43939 16.52709,-11.56787 -4.52939,-0.78772 -21.23379,-12.23383 -21.22226,-12.11514 0.57378,5.90697 -0.71357,11.21473 -1.30092,17.06944 -0.24494,2.44161 3.00975,6.22428 5.99609,6.61357 z m 28.15174,-25.60492 c -1.66901,3.72058 -3.41156,18.3512 -27.53028,27.73991 -0.69864,0.27195 20.21205,14.57036 21.35002,14.62032 69.0544,3.03144 68.46916,-49.55437 67.5526,-50.88016 -0.13265,-0.19188 -6.03889,0.0138 -10.61352,-2.15692 -0.34887,-0.1655 -3.45204,-1.76502 -5.30227,-4.17423 -0.62757,-0.81715 -0.72524,-1.26122 -3.05074,-0.58541 -10.76269,3.12776 -42.89539,14.65696 -42.40581,15.43649 z M 84.18935,867.5847 c -12.33127,-43.08647 30.94329,-45.08535 37.80451,-46.74439 5.30593,-1.28297 4.14977,-1.78189 3.03089,-6.80033 -1.57099,-7.04629 -1.0816,-13.18841 0.26513,-19.77217 1.27351,-6.2258 1.93035,-9.37394 4.61998,-12.49571 2.16789,-2.51621 14.38379,-8.27049 17.4121,-9.72295 12.90529,-6.18974 17.83586,-12.99328 19.27422,-18.34465 1.014,-3.77255 2.49779,-13.07094 1.83081,-19.71727 -0.16416,-1.6359 0.89135,0.92745 6.14205,17.24159 2.68293,8.33593 1.36177,18.34502 -7.09994,27.38172 -1.14918,1.22726 -0.59185,1.4703 0.95354,4.02388 3.21078,5.30554 4.98573,11.93993 4.48566,16.76662 -0.40972,3.95432 -2.10008,7.6934 0.31754,7.40286 0.34951,-0.042 11.02834,-1.65875 18.33848,2.98757 4.95048,3.14653 12.00258,9.17808 10.17204,34.37342 -4.00842,55.17187 -30.9649,67.17892 -31.15387,67.18542 -0.4563,0.0156 -5.49956,-5.22074 -5.79806,-4.60407 -15.45677,31.93103 -47.3506,30.78387 -65.13826,31.11951 -3.55635,0.0671 -18.34444,-12.38469 -19.59088,-11.53286 -15.74294,10.75882 -20.11352,13.44589 -26.18374,40.94381 -0.23264,-0.43595 -8.41803,-14.95561 -8.07826,-44.15673 0.27838,-23.92523 13.34923,-35.21616 19.15997,-34.56498 7.05422,0.79055 10.59443,4.47084 20.52923,10.99795 1.77335,1.16509 8.0388,4.95377 9.46457,4.91926 3.5521,-0.0838 5.97534,-6.60969 6.55509,-10.83484 0.58475,-4.26168 1.15009,-3.23167 -2.17605,-4.37633 -4.1934,-1.44311 -11.42652,-9.296 -15.13675,-21.67633 z" + style="fill:#2c3b54;fill-opacity:1;fill-rule:nonzero;stroke:none" /> + <path + inkscape:export-ydpi="19.25" + inkscape:export-xdpi="19.25" + inkscape:export-filename="/home/emcho/storage/images/sc_logo/sc_logo136x203.png" + sodipodi:nodetypes="czssss" + id="path4232" + d="m 106.11345,892.83442 c 6.47531,-3.53335 43.23312,-15.02538 43.56314,-15.74675 0.24089,-0.52655 -43.11579,17.30875 -52.93537,10.69734 -0.59185,-0.39849 2.29344,1.72127 4.90219,2.25652 0.18326,0.0376 0.17092,6.89208 -2.30068,10.77041 -0.6931,1.08758 5.26729,-7.15715 6.77072,-7.97752 z" + style="fill:url(#linearGradient4268);fill-opacity:1;fill-rule:evenodd;stroke:none" /> + <path + inkscape:export-ydpi="19.25" + inkscape:export-xdpi="19.25" + inkscape:export-filename="/home/emcho/storage/images/sc_logo/sc_logo136x203.png" + sodipodi:nodetypes="csss" + id="path4234" + d="m 166.00752,784.18642 c -4.02929,2.81265 -9.5846,6.74255 -10.72057,9.77333 -0.66996,1.78745 -1.12247,-4.99153 3.45182,-9.03009 2.57716,-2.27533 8.09315,-1.31871 7.26875,-0.74324 z" + style="fill:url(#linearGradient4270);fill-opacity:1;fill-rule:evenodd;stroke:none;display:inline" /> + <path + inkscape:export-ydpi="19.25" + inkscape:export-xdpi="19.25" + inkscape:export-filename="/home/emcho/storage/images/sc_logo/sc_logo136x203.png" + sodipodi:nodetypes="csss" + id="path4236" + d="m 160.58968,806.82846 c 4.57007,1.3627 8.10001,-1.42847 10.03503,-7.4422 0.22874,-0.71087 -0.56553,5.2666 -3.40562,7.48857 -2.70767,2.11837 -7.59288,-0.33365 -6.62941,-0.0464 z" + style="fill:url(#linearGradient4272);fill-opacity:1;fill-rule:evenodd;stroke:none;display:inline" /> + <path + inkscape:export-ydpi="19.25" + inkscape:export-xdpi="19.25" + inkscape:export-filename="/home/emcho/storage/images/sc_logo/sc_logo136x203.png" + sodipodi:nodetypes="cscss" + id="path4238" + d="m 172.18948,813.03639 c 6.47531,-3.53335 13.10498,-2.9926 18.75586,-2.73805 3.75103,0.16896 -7.06591,-4.20527 -19.35477,-3.52147 -1.31254,1.11755 -5.21285,7.63595 -10.70973,14.23704 -0.82526,0.99104 9.80521,-7.15715 11.30864,-7.97752 z" + style="fill:url(#linearGradient4274);fill-opacity:1;fill-rule:evenodd;stroke:none;display:inline" /> + <path + inkscape:export-ydpi="19.25" + inkscape:export-xdpi="19.25" + inkscape:export-filename="/home/emcho/storage/images/sc_logo/sc_logo136x203.png" + sodipodi:nodetypes="csccscs" + id="path4240" + d="m 110.55877,827.01111 c 3.80202,-0.78266 8.57073,-2.07478 11.72698,-2.39299 2.63172,-0.26532 5.92925,-1.74739 8.46259,0.97744 -1.41052,-1.93357 -4.10815,-5.83882 -4.94871,-8.39667 0.76528,3.00553 -2.65492,3.42931 -3.50504,3.56438 -19.11709,3.02037 -30.61745,8.16849 -36.41981,17.74206 10.99033,-8.50263 23.96641,-11.34651 24.68399,-11.49422 z" + style="fill:url(#linearGradient5486);fill-opacity:1;fill-rule:evenodd;stroke:none;display:inline" /> + </g> + </g> +</svg> diff --git a/frontend/public/custom/assets/mattermost.svg b/frontend/public/custom/assets/mattermost.svg new file mode 100644 index 0000000000000000000000000000000000000000..2fd174307012c3da2182ba391961479b83925f92 --- /dev/null +++ b/frontend/public/custom/assets/mattermost.svg @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> +<svg width="100%" height="100%" viewBox="0 0 1437 1437" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"> + <g transform="matrix(4.16667,0,0,4.16667,-1365.17,-1.88902)"> + <path d="M601.285,33.35L603.099,69.934C632.758,102.699 644.465,149.102 629.701,192.713C607.663,257.815 534.992,292.037 467.386,269.151C399.78,246.265 362.84,174.936 384.879,109.835C399.692,66.078 437.379,36.272 481.027,28.379L504.609,0.516C431.034,-1.476 361.621,44.193 336.785,117.557C306.27,207.698 354.607,305.509 444.748,336.024C534.889,366.539 632.7,318.202 663.215,228.061C688.011,154.814 660.742,76.504 601.285,33.35Z" style="fill:rgb(29,50,92);"/> + </g> + <g transform="matrix(4.16667,0,0,4.16667,-1365.17,-1.88902)"> + <path d="M559.032,141.297L557.783,90.155L556.781,60.726L556.103,35.23C556.103,35.23 556.245,22.936 555.816,20.047C555.725,19.439 555.534,18.945 555.307,18.513C555.279,18.452 555.251,18.392 555.22,18.332C555.188,18.277 555.156,18.226 555.122,18.174C554.651,17.363 553.909,16.704 552.951,16.379C551.969,16.047 550.955,16.128 550.073,16.509C550.055,16.516 550.037,16.523 550.019,16.53C549.914,16.577 549.814,16.63 549.714,16.686C549.296,16.889 548.871,17.153 548.455,17.556C546.359,19.59 539.004,29.442 539.004,29.442L522.979,49.283L504.307,72.052L472.249,111.919C472.249,111.919 457.537,130.28 460.788,152.879C464.039,175.479 480.841,186.489 493.875,190.902C506.91,195.314 526.944,196.775 543.255,180.797C559.565,164.819 559.032,141.297 559.032,141.297Z" style="fill:rgb(29,50,92);"/> + </g> +</svg> diff --git a/frontend/public/custom/markdown/forgejo.md b/frontend/public/custom/markdown/forgejo.md new file mode 100644 index 0000000000000000000000000000000000000000..4c5bdde80842207eaf4901879edcfabac74a2af3 --- /dev/null +++ b/frontend/public/custom/markdown/forgejo.md @@ -0,0 +1,14 @@ +--- +title: 'Forgejo' +tileExcerpt: 'Software development tool and version control using Git, including bug tracking, code review, tickets and wikis' +--- + +## Introduction + +> [Forgejo](https://forgejo.org/) is a self-hosted lightweight software forge. Easy to install and low maintenance, it just does the job. + +## Signing in + +Forgejo shows some public data without requiring a sign-in. To sign in, first +click "Sign in" in the top-right corner, then on the resulting Forgejo sign-in +page click the organisation logo at the bottom saying "Sign in with". diff --git a/frontend/public/custom/markdown/jitsi.md b/frontend/public/custom/markdown/jitsi.md new file mode 100644 index 0000000000000000000000000000000000000000..9c8ce191f32e71e5d0e11e56b21b617cbfc50e52 --- /dev/null +++ b/frontend/public/custom/markdown/jitsi.md @@ -0,0 +1,12 @@ +--- +title: 'Jitsi' +tileExcerpt: 'Secure and flexible video conferencing' +--- + +## Introduction + +> Go ahead, video chat with the whole team. In fact, invite everyone you know. Jitsi Meet is a fully encrypted, 100% open source video conferencing solution that you can use all day, every day, for free — with no account needed. (from the [official website](https://jitsi.org/jitsi-meet/)) + +## Signing in + +Jitsi does not require logins; every video session is end-to-end encrypted and is only visible to people currently on the call. diff --git a/frontend/public/custom/markdown/mattermost.md b/frontend/public/custom/markdown/mattermost.md new file mode 100644 index 0000000000000000000000000000000000000000..a044e2d23b75917fd77f2b20e997a4932c50d907 --- /dev/null +++ b/frontend/public/custom/markdown/mattermost.md @@ -0,0 +1,8 @@ +--- +title: 'Mattermost' +tileExcerpt: 'Team collaboration and communication app' +--- + +## Introduction + +> Work together effectively with real-time communication, file and code snippet sharing, in-line code syntax highlighting, and workflow automation purpose-built for technical teams. From the [official website](https://mattermost.com/) diff --git a/frontend/src/components/Form/Select/Select.tsx b/frontend/src/components/Form/Select/Select.tsx index 06a5322f710e625ab06c5cbe4c2b13429fe1e8b9..aad51fa9d97789c95635086d21ff6655aabccda1 100644 --- a/frontend/src/components/Form/Select/Select.tsx +++ b/frontend/src/components/Form/Select/Select.tsx @@ -26,7 +26,7 @@ export const Select = ({ control, name, label, options, disabled = false }: Sele value={field.value ? field.value : ''} // input value name={name} // send down the input name ref={field.ref} // send input ref, so we can focus on input when error appear - className="block shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-300 rounded-md" + className="shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-300 rounded-md" disabled={disabled} > {options?.map((option) => ( diff --git a/frontend/src/components/MultiEditUserModal/MultiEditUserModal.tsx b/frontend/src/components/MultiEditUserModal/MultiEditUserModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..37b5614ec5a71d9ef8a3bbc6a2e894d24fa512b9 --- /dev/null +++ b/frontend/src/components/MultiEditUserModal/MultiEditUserModal.tsx @@ -0,0 +1,310 @@ +import React, { useEffect, useState } from 'react'; +import _ from 'lodash'; +import { useFieldArray, useForm, useWatch } from 'react-hook-form'; +import { Banner, ConfirmationModal, Modal } from 'src/components'; +import { Select } from 'src/components/Form'; +import { User, UserRole, MultiEditUser, useUsers, NoChange } from 'src/services/users'; +import { useAuth } from 'src/services/auth'; +import { AppStatusEnum } from 'src/services/apps/types'; + +import { HIDDEN_APPS } from 'src/modules/dashboard/consts'; +// import { initialUserForm } from './consts'; +import { TrashIcon } from '@heroicons/react/outline'; + +import { MultiEditUserModalProps } from './types'; + +export const MultiEditUserModal = ({ open, onClose, userIds, setUserId, apps }: MultiEditUserModalProps) => { + const [deleteModal, setDeleteModal] = useState(false); + const [isAdminRoleSelected, setAdminRoleSelected] = useState(true); + const { + user, + editUserById, + deleteUserById, + // editMultipleUsers, + userModalLoading, + clearSelectedUser, + } = useUsers(); + const { currentUser, isAdmin } = useAuth(); + + // Extending the app list with "No Change" value, so that + // there is a sane default selection + // when doing multi-user edits + + interface AppListInt { + name: string; + role: UserRole | NoChange; + } + const appList: AppListInt[] = []; + const initialAppRoleLatest = () => { + apps + .filter((app) => app.status !== AppStatusEnum.NotInstalled) + .map((app) => appList.push({ name: app.slug, role: NoChange.NoChange })); + }; + initialAppRoleLatest(); + + const userIdsList: string[] = []; + const populateUserIdsList = () => { + userIds.map((id: any) => userIdsList.push(id.original.id)); + }; + populateUserIdsList(); + + const userNamesList: string[] = []; + const populateUserNamesList = () => { + userIds.map((id: any) => userNamesList.push(id.original.name)); + }; + populateUserNamesList(); + + const userEmailsList: string[] = []; + const populateUserEmailsList = () => { + userIds.map((id: any) => userEmailsList.push(id.original.email)); + }; + populateUserEmailsList(); + + const initialUserForm = { + userEmails: userEmailsList, + userIds: userIdsList, + app_roles: appList, + userNames: userNamesList, + }; + + // populate the initial "New User" window with installed apps and default roles + const { control, reset, handleSubmit } = useForm<MultiEditUser>({ + defaultValues: initialUserForm, + }); + + const { fields, update } = useFieldArray({ + control, + name: 'app_roles', + }); + + useEffect(() => { + if (!_.isEmpty(user)) { + reset(user); + } + }, [user, reset, open]); + + const dashboardRole = useWatch({ + control, + name: 'app_roles.0.role', + }); + + useEffect(() => { + const isAdminDashboardRoleSelected = dashboardRole === UserRole.Admin; + setAdminRoleSelected(isAdminDashboardRoleSelected); + if (isAdminDashboardRoleSelected) { + fields.forEach((field, index) => update(index, { name: field.name, role: UserRole.Admin })); + } else { + fields.forEach((field, index) => update(index, { name: field.name, role: NoChange.NoChange })); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dashboardRole]); + + function transformSubmitData(data: MultiEditUser) { + const userBatch: any = []; + const editedAppList: any = []; + const populateEditedAppList = () => { + data.app_roles + .filter((role) => role.role !== NoChange.NoChange) + .map((role) => editedAppList.push({ name: role.name, role: role.role })); + }; + populateEditedAppList(); + const populateUserBatch = () => { + data.userIds.map((userId, index) => + userBatch.push({ + email: data.userEmails[index], + name: data.userNames[index], + id: userId, + app_roles: editedAppList, + }), + ); + }; + populateUserBatch(); + + return userBatch; + } + + const handleSave = async () => { + try { + await handleSubmit((data) => { + const transformedData = transformSubmitData(data); + // For now, this function loops over users and sends multiple individual PUT requests. + // Once the JSON payload schema issue is solved, we can test the batch edit + // with the below command + // (remember to also uncomment the import on top of this file) + // return editMultipleUsers(transformedData); + transformedData.forEach((userId: User) => { + return editUserById(userId); + }); + })(); + } catch (e: any) { + // Continue + } + + onClose(); + clearSelectedUser(); + }; + + const handleClose = () => { + onClose(); + clearSelectedUser(); + }; + + const deleteModalOpen = () => setDeleteModal(true); + const deleteModalClose = () => setDeleteModal(false); + + const handleDelete = () => { + userIdsList.forEach((id: string) => { + deleteUserById(id); + }); + + clearSelectedUser(); + setUserId(null); + handleClose(); + deleteModalClose(); + }; + + // Button with delete option. + const buttonDelete = () => { + return ( + !userIdsList.includes(currentUser?.id as string) && ( + <button + onClick={deleteModalOpen} + type="button" + className="mb-4 sm:mb-0 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 {userIds.length} users + </button> + ) + ); + }; + return ( + <> + <Modal + onClose={handleClose} + open={open} + onSave={handleSave} + isLoading={userModalLoading} + leftActions={<>{buttonDelete()}</>} + useCancelButton + > + <div className="bg-white px-4"> + <div className="space-y-10 divide-y divide-gray-200"> + <div> + <div> + <h3 className="text-lg leading-6 font-medium text-gray-900">Edit {userIds.length} users</h3> + </div> + + <div className="mt-6 grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6"> + {isAdmin && ( + <> + <div className="sm:col-span-3"> + {fields + .filter((field) => field.name === 'dashboard') + .map((item, index) => ( + <Select + key={item.name} + control={control} + name={`app_roles.${index}.role`} + label="Role" + options={[ + { value: UserRole.User, name: 'User' }, + { value: UserRole.Admin, name: 'Admin' }, + ]} + /> + ))} + </div> + <div className="sm:col-span-3 opacity-40 cursor-default pointer-events-none select-none"> + <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> + {isAdmin && !userModalLoading && ( + <div> + <div className="mt-8"> + <h3 className="text-lg leading-6 font-medium text-gray-900">App Access</h3> + </div> + + {isAdminRoleSelected && ( + <div className="sm:col-span-6"> + <Banner + title="Admin users automatically have admin-level access to all apps." + titleSm="Admin user" + /> + </div> + )} + + {!isAdminRoleSelected && ( + <div> + <div className="flow-root mt-6"> + <ul className="-my-5 divide-y divide-gray-200"> + {fields.map((item, index) => { + if (item.name != null && HIDDEN_APPS.indexOf(item.name) !== -1) { + return null; + } + + return ( + <li className="py-4" key={item.name}> + <div className="flex items-center space-x-4"> + <div className="flex-shrink-0 flex-1 flex items-center"> + <img + className="h-10 w-10 rounded-md overflow-hidden" + src={_.find(apps, ['slug', item.name!])?.assetSrc} + alt={item.name ?? 'Image'} + /> + <h3 className="ml-4 text-md leading-6 font-medium text-gray-900"> + {_.find(apps, ['slug', item.name!])?.name} + </h3> + </div> + <div> + <Select + key={item.id} + control={control} + name={`app_roles.${index}.role`} + disabled={isAdminRoleSelected} + options={[ + { value: NoChange.NoChange, name: '...' }, + { value: UserRole.NoAccess, name: 'No Access' }, + { value: UserRole.User, name: 'User' }, + { value: UserRole.Admin, name: 'Admin' }, + ]} + /> + </div> + </div> + </li> + ); + })} + </ul> + </div> + </div> + )} + </div> + )} + </div> + </div> + </Modal> + <ConfirmationModal + onDeleteAction={handleDelete} + open={deleteModal} + onClose={deleteModalClose} + title="Delete user" + body={`You are about to delete ${userIds.length} users. Are sure you want to delete them? All of the user data will be permanently removed. This action cannot be undone.`} + /> + </> + ); +}; diff --git a/frontend/src/components/MultiEditUserModal/consts.ts b/frontend/src/components/MultiEditUserModal/consts.ts new file mode 100644 index 0000000000000000000000000000000000000000..bb750df0b4d7952d5c3d3e9a85f9651ce579a09a --- /dev/null +++ b/frontend/src/components/MultiEditUserModal/consts.ts @@ -0,0 +1,96 @@ +// This file is still being used in the AppSingle.tsx file +// to populate the single app card with URLs and images. +// Single App is not an active view at the moment, +// so automating this is not a priority at the moment. +// once we activate single app views, we will need to use the API call +// to populate the AppSingle card with this info. +// See UserModal.tsx for inspiration, search for initialAppRoleLatest() + +// import { UserRole } from 'src/services/users'; + +export const appAccessList = [ + { + name: 'hedgedoc', + image: '/assets/hedgedoc.svg', + label: 'HedgeDoc', + documentationUrl: 'https://docs.hedgedoc.org/', + }, + { + name: 'wekan', + image: '/assets/wekan.svg', + label: 'Wekan', + documentationUrl: 'https://github.com/wekan/wekan/wiki', + }, + { + name: 'wordpress', + image: '/assets/wordpress.svg', + label: 'Wordpress', + documentationUrl: 'https://wordpress.org/support/', + }, + { + name: 'nextcloud', + image: '/assets/nextcloud.svg', + label: 'Nextcloud', + documentationUrl: 'https://docs.nextcloud.com/server/latest/user_manual/en/', + }, + { + name: 'zulip', + image: '/assets/zulip.svg', + label: 'Zulip', + documentationUrl: 'https://docs.zulip.com/help/', + }, + { + name: 'monitoring', + image: '/assets/monitoring.svg', + label: 'Monitoring', + documentationUrl: 'https://grafana.com/docs/', + }, +]; + +export const allAppAccessList = [ + { + name: 'dashboard', + image: '/assets/logo-small.svg', + label: 'Dashboard', + }, + ...appAccessList, +]; + +// export const initialAppRoles = [ +// { +// name: 'dashboard', +// role: UserRole.User, +// }, +// { +// name: 'hedgedoc', +// role: UserRole.User, +// }, +// { +// name: 'wekan', +// role: UserRole.User, +// }, +// { +// name: 'wordpress', +// role: UserRole.User, +// }, +// { +// name: 'nextcloud', +// role: UserRole.User, +// }, +// { +// name: 'zulip', +// role: UserRole.User, +// }, +// { +// name: 'monitoring', +// role: UserRole.NoAccess, +// }, +// ]; + +// export const initialUserForm = { +// id: '', +// name: '', +// email: '', +// app_roles: initialAppRoles, +// status: '', +// }; diff --git a/frontend/src/components/MultiEditUserModal/index.ts b/frontend/src/components/MultiEditUserModal/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..15b8809c59059122e88ff8dbd70794cdc9b90302 --- /dev/null +++ b/frontend/src/components/MultiEditUserModal/index.ts @@ -0,0 +1 @@ +export { MultiEditUserModal } from './MultiEditUserModal'; diff --git a/frontend/src/components/MultiEditUserModal/types.ts b/frontend/src/components/MultiEditUserModal/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..a566c9b96c7af54862236d59caa85bde8ddc9e61 --- /dev/null +++ b/frontend/src/components/MultiEditUserModal/types.ts @@ -0,0 +1,9 @@ +import { App } from 'src/services/apps'; + +export type MultiEditUserModalProps = { + open: boolean; + onClose: () => void; + userIds: any; + setUserId: any; + apps: App[]; +}; diff --git a/frontend/src/components/Table/Table.tsx b/frontend/src/components/Table/Table.tsx index 95f22567a3a0760f239c38ab0f8a03b6f1e8840c..e8f46a5492cdb3533c469ab3d31ba4fcf5cfa981 100644 --- a/frontend/src/components/Table/Table.tsx +++ b/frontend/src/components/Table/Table.tsx @@ -1,6 +1,6 @@ import { ArrowSmDownIcon, ArrowSmUpIcon } from '@heroicons/react/solid'; import React, { useEffect } from 'react'; -import { useTable, useRowSelect, Column, IdType, useSortBy } from 'react-table'; +import { useTable, useRowSelect, Column, IdType, useSortBy, usePagination } from 'react-table'; export interface ReactTableProps<T extends Record<string, unknown>> { columns: Column<T>[]; @@ -38,7 +38,7 @@ export const Table = <T extends Record<string, unknown>>({ pagination = false, onRowClick, getSelectedRowIds, - selectable = false, + selectable = true, loading = false, }: ReactTableProps<T>) => { const { @@ -55,6 +55,7 @@ export const Table = <T extends Record<string, unknown>>({ data, }, useSortBy, + usePagination, useRowSelect, selectable ? (hooks) => { diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 64e430d54ff3ea8a1af2dae1ab0feac9e7c82450..954427fdfa6348af31f2fbf716ad77ba6b7eb874 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -6,3 +6,4 @@ export { Tabs } from './Tabs'; export { Modal, ConfirmationModal, InfoModal, StepsModal } from './Modal'; export { UserModal } from './UserModal'; export { ProgressSteps } from './ProgressSteps'; +export { MultiEditUserModal } from './MultiEditUserModal'; diff --git a/frontend/src/modules/users/Users.tsx b/frontend/src/modules/users/Users.tsx index 157b4cda9e61e2fe753134413a401a20551ce7e3..b8bbefe45de85fd13cd35ee777d61c56393ac7c9 100644 --- a/frontend/src/modules/users/Users.tsx +++ b/frontend/src/modules/users/Users.tsx @@ -4,25 +4,64 @@ * * Admin users can add one or more users, or edit a user. */ -import React, { useState, useCallback, useEffect, useMemo } from 'react'; -import { SearchIcon, PlusIcon, ViewGridAddIcon } from '@heroicons/react/solid'; -import { CogIcon, TrashIcon } from '@heroicons/react/outline'; -import { useUsers } from 'src/services/users'; -import { Table } from 'src/components'; -import { debounce } from 'lodash'; + +// React main +import React, { useState, useCallback, useEffect, useMemo, HTMLProps } from 'react'; + +// Icons +import { + SearchIcon, + PlusIcon, + ViewGridAddIcon, + ChevronDownIcon, + ChevronUpIcon, + ChevronDoubleLeftIcon, + ChevronDoubleRightIcon, + ChevronLeftIcon, + ChevronRightIcon, +} from '@heroicons/react/solid'; +import { CogIcon } from '@heroicons/react/outline'; + +// API - Redux +import { useUsers, User } from 'src/services/users'; import { useAuth } from 'src/services/auth'; import { useApps } from 'src/services/apps'; +// Regular Table +// import { Table } from 'src/components'; +import { debounce } from 'lodash'; + +// User Table +import { + // Column, + ColumnDef, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + SortingState, + // Table, + useReactTable, +} from '@tanstack/react-table'; + +import { MultiEditUserModal } from 'src/components'; + +// Local components import { UserModal } from '../../components/UserModal'; import { MultipleUsersModal } from './components'; +// /////////////////////////////////////////// + export const Users: React.FC = () => { - const [selectedRowsIds, setSelectedRowsIds] = useState({}); + // const [selectedRowsIds, setSelectedRowsIds] = useState({}); const [configureModal, setConfigureModal] = useState(false); const [multipleUsersModal, setMultipleUsersModal] = useState(false); + const [multiEditUserModal, setMultiEditUserModal] = useState(false); const [userId, setUserId] = useState(null); + const [multiUserIds, setMultiUserIds] = useState(null); const [search, setSearch] = useState(''); - const { users, loadUsers, userTableLoading } = useUsers(); + const { users, loadUsers } = useUsers(); const { isAdmin } = useAuth(); const { apps, loadApps } = useApps(); @@ -46,7 +85,11 @@ export const Users: React.FC = () => { }, []); const filterSearch = useMemo(() => { - return users.filter((item: any) => item.email?.toLowerCase().includes(search.toLowerCase())); + return users.filter( + (item: any) => + item.email?.toLowerCase().includes(search.toLowerCase()) || + item.name?.toLowerCase().includes(search.toLowerCase()), + ); }, [search, users]); const configureModalOpen = (id: any) => { @@ -54,87 +97,143 @@ export const Users: React.FC = () => { setConfigureModal(true); }; + const multiEditUserModalOpen = (ids: any) => { + setMultiUserIds(ids); + setMultiEditUserModal(true); + }; + const configureModalClose = () => setConfigureModal(false); const multipleUsersModalClose = () => setMultipleUsersModal(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: ' ', - Cell: (props: any) => { - const { row } = props; - - if (isAdmin) { - return ( - <div className="text-right lg:opacity-0 group-hover:opacity-100 transition-opacity"> - <button - onClick={() => configureModalOpen(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> - ); - } + const multiEditUserModalClose = () => setMultiEditUserModal(false); - return null; - }, - width: 'auto', - }, - ], - [isAdmin], - ); + // const selectedRows = useCallback((rows: Record<string, boolean>) => { + // setSelectedRowsIds(rows); + // }, []); - const selectedRows = useCallback((rows: Record<string, boolean>) => { - setSelectedRowsIds(rows); - }, []); + // //////////////////////// + // New Table Start + // //////////////////////// - return ( - <div className="relative"> - <div className="max-w-7xl mx-auto py-4 px-3 sm:px-6 lg:px-8 h-full flex-grow"> - <div className="pb-5 mt-6 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> + function IndeterminateCheckbox({ + indeterminate, + ...rest + }: { indeterminate?: boolean } & HTMLProps<HTMLInputElement>) { + const ref = React.useRef<HTMLInputElement>(null!); - {isAdmin && ( - <div className="mt-3 sm:mt-0 sm:ml-4"> - <button - onClick={() => configureModalOpen(null)} - 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 " - > - <PlusIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" /> - Add new user - </button> - <button - onClick={() => setMultipleUsersModal(true)} - 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" - > - <ViewGridAddIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" /> - Add new users - </button> + React.useEffect(() => { + if (typeof indeterminate === 'boolean') { + ref.current.indeterminate = !rest.checked && indeterminate; + } + }, [ref, indeterminate]); + + return ( + <input + type="checkbox" + ref={ref} + className="focus:ring-primary-800 h-4 w-4 text-primary-700 border-gray-300 rounded cursor-pointer" + {...rest} + /> + ); + } + function CreateUserTable() { + const [rowSelection, setRowSelection] = React.useState({}); + const [sorting, setSorting] = React.useState<SortingState>([]); + const userColumns = React.useMemo<ColumnDef<User>[]>( + () => [ + { + id: 'select', + header: ({ table }) => ( + <IndeterminateCheckbox + {...{ + checked: table.getIsAllRowsSelected(), + indeterminate: table.getIsSomeRowsSelected(), + onChange: table.getToggleAllRowsSelectedHandler(), + }} + /> + ), + cell: ({ row }) => ( + <div className="flex items-center"> + <IndeterminateCheckbox + {...{ + checked: row.getIsSelected(), + disabled: !row.getCanSelect(), + indeterminate: row.getIsSomeSelected(), + onChange: row.getToggleSelectedHandler(), + }} + /> </div> - )} - </div> + ), + }, + { + header: 'Name', + footer: (props) => props.column.id, + accessorKey: 'name', + }, + { + header: 'Email', + footer: (props) => props.column.id, + accessorKey: 'email', + }, + { + header: 'Status', + footer: (props) => props.column.id, + accessorKey: 'status', + }, + { + header: ' ', + cell: (props: any) => { + const { row } = props; + + if (isAdmin) { + return ( + <div className="text-right relative"> + <div className="absolute inline-flex px-2 py-1 text-transparent items-center font-medium border border-transparent right-0 z-0"> + Configure <CogIcon className="-mr-0.5 ml-2 h-4 w-4 text-gray-500" /> + </div> + <button + onClick={() => configureModalOpen(row.original.id)} + type="button" + className="relative z-10 opacity-0 group-hover:opacity-100 transition-opacity inline-flex items-center px-2 py-1 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" + > + Configure <CogIcon className="-mr-0.5 ml-2 h-4 w-4" /> + </button> + </div> + ); + } + + return null; + }, + }, + ], + [isAdmin], + ); + const table = useReactTable({ + data: filterSearch, + columns: userColumns, + state: { + rowSelection, + sorting, + }, + initialState: { + pagination: { + pageSize: 10, + }, + }, + onSortingChange: setSorting, + getSortedRowModel: getSortedRowModel(), + enableRowSelection: true, // enable row selection for all rows + onRowSelectionChange: setRowSelection, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + debugTable: false, // make true if needed + }); + + return ( + <> <div className="flex justify-between w-100 my-3 items-center mb-5 "> <div className="flex items-center"> <div className="inline-block"> @@ -151,7 +250,7 @@ export const Users: React.FC = () => { 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" + placeholder="Search everything..." onChange={debouncedSearch} /> </div> @@ -159,21 +258,194 @@ export const Users: React.FC = () => { </div> </div> - {selectedRowsIds && Object.keys(selectedRowsIds).length !== 0 && ( + {Object.keys(rowSelection).length !== 0 && ( <div className="flex items-center"> <button - onClick={() => {}} + onClick={() => multiEditUserModalOpen(table.getSelectedRowModel().flatRows)} 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" + className="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-gray-500 bg-primary-50 hover:bg-primary-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 border border-gray-200 shadow-sm" > - <TrashIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" /> - Delete + <CogIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" /> + Configure {Object.keys(rowSelection).length} user{Object.keys(rowSelection).length !== 1 ? 's' : null} </button> </div> )} </div> + <div className="flex justify-between items-center text-xs font-medium text-gray-500 uppercase tracking-wider"> + <div className="py-3 text-left "> + Showing {table.getRowModel().rows.length} of {table.getCoreRowModel().rows.length} entries + </div> + <nav className="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination"> + <button + className="relative inline-flex items-center rounded-l-md px-2 py-2 ring-1 ring-inset ring-gray-300 transition hover:bg-gray-100 focus:z-20 focus:outline-offset-0" + onClick={() => table.setPageIndex(0)} + disabled={!table.getCanPreviousPage()} + > + <ChevronDoubleLeftIcon className="w-4 h-4" /> + </button> + <button + className="relative inline-flex items-center px-2 py-2 ring-1 ring-inset ring-gray-300 transition hover:bg-gray-100 focus:z-20 focus:outline-offset-0" + onClick={() => table.previousPage()} + disabled={!table.getCanPreviousPage()} + > + <ChevronLeftIcon className="w-4 h-4" /> + </button> + <div className="relative inline-flex items-center px-2 py-2 ring-1 ring-inset ring-gray-300 focus:z-20 focus:outline-offset-0"> + <span> + Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()} + </span> + </div> + <button + className="relative inline-flex items-center px-2 py-2 ring-1 ring-inset ring-gray-300 transition hover:bg-gray-100 focus:z-20 focus:outline-offset-0" + onClick={() => table.nextPage()} + disabled={!table.getCanNextPage()} + > + <ChevronRightIcon className="w-4 h-4" /> + </button> + <button + className="relative inline-flex items-center rounded-r-md px-2 py-2 ring-1 ring-inset ring-gray-300 transition hover:bg-gray-100 focus:z-20 focus:outline-offset-0" + onClick={() => table.setPageIndex(table.getPageCount() - 1)} + disabled={!table.getCanNextPage()} + > + <ChevronDoubleRightIcon className="w-4 h-4" /> + </button> + </nav> + <div className="flex items-center gap-2 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> + <span>Results per page</span> + <select + value={table.getState().pagination.pageSize} + className="focus:ring-primary-500 focus:border-primary-500 py-1 block rounded-md text-sm border-gray-200" + onChange={(e) => { + table.setPageSize(Number(e.target.value)); + }} + > + {[10, 20, 50, 100, 250, 500, 10000, table.getCoreRowModel().rows.length].map((pageSize) => + pageSize <= table.getCoreRowModel().rows.length ? ( + <option key={pageSize} value={pageSize}> + {pageSize === table.getCoreRowModel().rows.length ? `all` : `${pageSize}`} + </option> + ) : null, + )} + </select> + </div> + </div> + <div className="shadow border-b border-gray-200 sm:rounded-lg overflow-hidden"> + <table className="min-w-full divide-y divide-gray-200 table-auto"> + <thead className="bg-gray-50"> + {table.getHeaderGroups().map((headerGroup) => ( + <tr key={headerGroup.id}> + {headerGroup.headers.map((header) => { + return ( + <th + key={header.id} + colSpan={header.colSpan} + scope="col" + className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer" + > + {header.isPlaceholder ? null : ( + <div + {...{ + className: header.column.getCanSort() ? 'flex items-center' : '', + onClick: header.column.getToggleSortingHandler(), + }} + > + <span> {flexRender(header.column.columnDef.header, header.getContext())}</span> + {{ + asc: <ChevronUpIcon className="w-4 h-4 text-gray-400 ml-1" />, + desc: <ChevronDownIcon className="w-4 h-4 text-gray-400 ml-1" />, + }[header.column.getIsSorted() as string] ?? null} + </div> + )} + </th> + ); + })} + </tr> + ))} + </thead> + <tbody className=""> + {table.getRowModel().rows.map((row, rowIndex) => { + return ( + <tr + key={row.id} + role="row" + className={ + rowIndex % 2 === 0 + ? 'bg-white group border-l-4 border-transparent transition hover:border-primary-600' + : 'bg-gray-50 group border-l-4 border-transparent transition hover:border-primary-600' + } + > + {row.getVisibleCells().map((cell) => { + return ( + <td + key={cell.id} + className={ + cell.id.substring(2) === 'select' + ? 'w-4 px-6 py-4 whitespace-nowrap text-sm text-gray-500' + : 'px-6 py-4 whitespace-nowrap text-sm text-gray-500' + } + > + {flexRender(cell.column.columnDef.cell, cell.getContext())} + </td> + ); + })} + </tr> + ); + })} + </tbody> + </table> + {/* Debugging buttons below, uncomment if needee */} + {/* <hr /> + <br /> + <div> + <button className="border rounded p-2 mb-2" onClick={() => console.info('rowSelection', rowSelection)}> + Log `rowSelection` state + </button> + </div> + <div> + <button + className="border rounded p-2 mb-2" + onClick={() => console.info('table.getSelectedRowModel().flatRows', table.getSelectedRowModel().flatRows)} + > + Log table.getSelectedRowModel().flatRows + </button> + </div> */} + </div> + </> + ); + } - <div className="flex flex-col"> + // //////////////////////// + // New Table End + // //////////////////////// + return ( + <div className="relative"> + <div className="max-w-7xl mx-auto py-4 px-3 sm:px-6 lg:px-8 h-full flex-grow"> + <div className="pb-5 mt-6 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> + + {isAdmin && ( + <div className="mt-3 sm:mt-0 sm:ml-4"> + <button + onClick={() => configureModalOpen(null)} + 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 " + > + <PlusIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" /> + Add new user + </button> + <button + onClick={() => setMultipleUsersModal(true)} + 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" + > + <ViewGridAddIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" /> + Add new users + </button> + </div> + )} + </div> + + {/* <div className="flex flex-col"> <div className="-my-2 overflow-x-auto 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 overflow-hidden"> @@ -186,6 +458,12 @@ export const Users: React.FC = () => { </div> </div> </div> + </div> */} + + <div className="flex flex-col"> + <div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8"> + <div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">{CreateUserTable()}</div> + </div> </div> {configureModal && ( @@ -200,6 +478,15 @@ export const Users: React.FC = () => { {multipleUsersModal && ( <MultipleUsersModal open={multipleUsersModal} onClose={multipleUsersModalClose} apps={apps} /> )} + {multiEditUserModal && ( + <MultiEditUserModal + open={multiEditUserModal} + onClose={multiEditUserModalClose} + apps={apps} + setUserId={setUserId} + userIds={multiUserIds} + /> + )} </div> </div> ); diff --git a/frontend/src/modules/users/components/MultipleUsersModal/MultipleUsersModal.tsx b/frontend/src/modules/users/components/MultipleUsersModal/MultipleUsersModal.tsx index 65b92c7348b1854f7d399aacf52d63be2eaee1b4..cab59812b7f73b9babddd79840bbf532dd0bbedd 100644 --- a/frontend/src/modules/users/components/MultipleUsersModal/MultipleUsersModal.tsx +++ b/frontend/src/modules/users/components/MultipleUsersModal/MultipleUsersModal.tsx @@ -4,6 +4,7 @@ import { useFieldArray, useForm, useWatch } from 'react-hook-form'; import { Banner, StepsModal, ProgressSteps } from 'src/components'; import { Select, TextArea } from 'src/components/Form'; +import { HIDDEN_APPS } from 'src/modules/dashboard/consts'; import { MultipleUsersData, UserRole, useUsers } from 'src/services/users'; import { AppStatusEnum } from 'src/services/apps'; import { ProgressStepInfo, ProgressStepStatus } from 'src/components/ProgressSteps/types'; @@ -133,7 +134,7 @@ export const MultipleUsersModal = ({ open, onClose, apps }: MultipleUsersModalPr ))} {!isAdminRoleSelected && fields.map((item, index) => { - if (item.name === 'dashboard') { + if (item.name != null && HIDDEN_APPS.indexOf(item.name) !== -1) { return null; } diff --git a/frontend/src/services/users/hooks/use-users.ts b/frontend/src/services/users/hooks/use-users.ts index 447bcce90e083a48866ad1a26b9e0e6b788225e1..cf189fc899b70be2da788eb9621bd953ea3e2265 100644 --- a/frontend/src/services/users/hooks/use-users.ts +++ b/frontend/src/services/users/hooks/use-users.ts @@ -5,6 +5,7 @@ import { fetchUserById, fetchPersonalInfo, updateUserById, + updateMultipleUsers, updatePersonalInfo, createUser, deleteUser, @@ -42,6 +43,10 @@ export function useUsers() { return dispatch(updateUserById(data)); } + function editMultipleUsers(data: any) { + return dispatch(updateMultipleUsers(data)); + } + function editPersonalInfo(data: any) { return dispatch(updatePersonalInfo(data)); } @@ -69,6 +74,7 @@ export function useUsers() { loadUsers, loadPersonalInfo, editUserById, + editMultipleUsers, editPersonalInfo, userModalLoading, userTableLoading, diff --git a/frontend/src/services/users/redux/actions.ts b/frontend/src/services/users/redux/actions.ts index 9fc8770d4ec06109a73a736c0a9aa9e4e0ae0159..394a93407288cbd18e7af40f6e1661c419715c89 100644 --- a/frontend/src/services/users/redux/actions.ts +++ b/frontend/src/services/users/redux/actions.ts @@ -7,8 +7,10 @@ import { AuthActionTypes } from 'src/services/auth'; import { transformBatchResponse, transformRequestMultipleUsers, + transformRequestUpdateMultipleUsers, transformRequestUser, transformUser, + transformUpdateMultipleUsers, transformRecoveryLink, } from '../transformations'; @@ -22,6 +24,7 @@ export enum UserActionTypes { SET_USER_MODAL_LOADING = 'users/user_modal_loading', SET_USERS_LOADING = 'users/users_loading', CREATE_BATCH_USERS = 'users/create_batch_users', + UPDATE_MULTIPLE_USERS = '/users/multi-edit', } export const setUsersLoading = (isLoading: boolean) => (dispatch: Dispatch<any>) => { @@ -132,6 +135,35 @@ export const updateUserById = (user: any) => async (dispatch: Dispatch<any>, get dispatch(setUserModalLoading(false)); }; +// //////////////////// + +export const updateMultipleUsers = (users: any) => async (dispatch: Dispatch<any>) => { + dispatch(setUserModalLoading(true)); + + try { + const { data } = await performApiCall({ + path: '/users/multi-edit', + method: 'PUT', + body: transformRequestUpdateMultipleUsers(users), + }); + + dispatch({ + type: UserActionTypes.UPDATE_MULTIPLE_USERS, + payload: transformUpdateMultipleUsers(data), + }); + + showToast('Users updated successfully.', ToastType.Success); + + dispatch(fetchUsers()); + } catch (err) { + console.error(err); + } + + dispatch(setUserModalLoading(false)); +}; + +// ///////////////////// + export const updatePersonalInfo = (user: any) => async (dispatch: Dispatch<any>) => { dispatch(setUserModalLoading(true)); diff --git a/frontend/src/services/users/transformations.ts b/frontend/src/services/users/transformations.ts index 545c947580639e57a95b3c89ad3b2a0d3a88632a..3d4a0ecb85084b1f5ede794f1c8a9c569446cb52 100644 --- a/frontend/src/services/users/transformations.ts +++ b/frontend/src/services/users/transformations.ts @@ -64,6 +64,22 @@ export const transformRequestUser = (data: Pick<User, 'app_roles' | 'name' | 'em }; }; +export const transformUpdateMultipleUsers = (response: any): any => { + return { + success: response.success, + existing: response.existing, + failed: response.failed, + }; +}; + +export const transformRequestUpdateMultipleUsers = (data: any) => { + return { + users: _.map(data, (user: Pick<User, 'email' | 'id' | 'app_roles'>) => { + return { email: user.email ?? '', id: user.id ?? '', app_roles: user.app_roles.map(transformRequestAppRoles) }; + }), + }; +}; + const extractUsersFromCsv = (csvData: string) => { const csvRows = csvData.split('\n'); diff --git a/frontend/src/services/users/types.ts b/frontend/src/services/users/types.ts index d22811cba75d1cd8aa84c983de14f632df089dda..bdc6cf7303ab798375668315d26eafcea9a8ed66 100644 --- a/frontend/src/services/users/types.ts +++ b/frontend/src/services/users/types.ts @@ -18,11 +18,20 @@ export enum UserRole { User = 'user', } +export enum NoChange { + NoChange = 'no_change', +} + export interface AppRoles { name: string | null; role: UserRole | null; } +export interface MultiEditAppRoles { + name: string | null; + role: UserRole | NoChange | null; +} + export interface UserApiRequest { id: number | null; email: string; @@ -34,3 +43,11 @@ export interface MultipleUsersData { csvUserData: string; appRoles: AppRoles[]; } + +export interface MultiEditUser { + userIds: string[]; + userEmails: string[]; + userNames: string[]; + app_roles: MultiEditAppRoles[]; + status: string; +} diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 92616b45cbf0ea6149aced0eea9bab148cb745a3..2614f4c42f57c14c73a10a0e5fb38acd81c94226 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -1716,6 +1716,25 @@ lodash.merge "^4.6.2" lodash.uniq "^4.5.0" +"@tanstack/match-sorter-utils@^8.8.4": + version "8.8.4" + resolved "https://registry.yarnpkg.com/@tanstack/match-sorter-utils/-/match-sorter-utils-8.8.4.tgz#0b2864d8b7bac06a9f84cb903d405852cc40a457" + integrity sha512-rKH8LjZiszWEvmi01NR72QWZ8m4xmXre0OOwlRGnjU01Eqz/QnN+cqpty2PJ0efHblq09+KilvyR7lsbzmXVEw== + dependencies: + remove-accents "0.4.2" + +"@tanstack/react-table@^8.9.3": + version "8.9.3" + resolved "https://registry.yarnpkg.com/@tanstack/react-table/-/react-table-8.9.3.tgz#03a52e9e15f65c82a8c697a445c42bfca0c5cfc4" + integrity sha512-Ng9rdm3JPoSCi6cVZvANsYnF+UoGVRxflMb270tVj0+LjeT/ZtZ9ckxF6oLPLcKesza6VKBqtdF9mQ+vaz24Aw== + dependencies: + "@tanstack/table-core" "8.9.3" + +"@tanstack/table-core@8.9.3": + version "8.9.3" + resolved "https://registry.yarnpkg.com/@tanstack/table-core/-/table-core-8.9.3.tgz#991da6b015f6200fdc841c48048bee5e197f6a46" + integrity sha512-NpHZBoHTfqyJk0m/s/+CSuAiwtebhYK90mDuf5eylTvgViNOujiaOaxNDxJkQQAsVvHWZftUGAx1EfO1rkKtLg== + "@testing-library/dom@^7.28.1": version "7.31.2" resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-7.31.2.tgz#df361db38f5212b88555068ab8119f5d841a8c4a" @@ -10791,6 +10810,11 @@ remark-rehype@^9.0.0: mdast-util-to-hast "^11.0.0" unified "^10.0.0" +remove-accents@0.4.2: + version "0.4.2" + resolved "https://registry.yarnpkg.com/remove-accents/-/remove-accents-0.4.2.tgz#0a43d3aaae1e80db919e07ae254b285d9e1c7bb5" + integrity sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA== + remove-trailing-separator@^1.0.1: version "1.1.0" resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef"