about summary refs log tree commit diff
path: root/gn2
diff options
context:
space:
mode:
Diffstat (limited to 'gn2')
-rw-r--r--gn2/base/trait.py31
-rw-r--r--gn2/base/webqtlConfig.py4
-rw-r--r--gn2/default_settings.py6
-rw-r--r--gn2/gn2_main.py1
-rw-r--r--gn2/runserver.py4
-rw-r--r--gn2/wqflask/__init__.py55
-rw-r--r--gn2/wqflask/api/markdown.py8
-rw-r--r--gn2/wqflask/api/router.py33
-rw-r--r--gn2/wqflask/app_errors.py27
-rw-r--r--gn2/wqflask/collect.py2
-rw-r--r--gn2/wqflask/correlation/show_corr_results.py4
-rw-r--r--gn2/wqflask/do_search.py12
-rw-r--r--gn2/wqflask/gsearch.py10
-rw-r--r--gn2/wqflask/marker_regression/display_mapping_results.py27
-rw-r--r--gn2/wqflask/oauth2/checks.py8
-rw-r--r--gn2/wqflask/oauth2/client.py157
-rw-r--r--gn2/wqflask/oauth2/data.py8
-rw-r--r--gn2/wqflask/oauth2/groups.py32
-rw-r--r--gn2/wqflask/oauth2/jwks.py86
-rw-r--r--gn2/wqflask/oauth2/request_utils.py2
-rw-r--r--gn2/wqflask/oauth2/resources.py240
-rw-r--r--gn2/wqflask/oauth2/roles.py76
-rw-r--r--gn2/wqflask/oauth2/session.py25
-rw-r--r--gn2/wqflask/oauth2/toplevel.py35
-rw-r--r--gn2/wqflask/oauth2/ui.py19
-rw-r--r--gn2/wqflask/oauth2/users.py28
-rw-r--r--gn2/wqflask/parser.py2
-rw-r--r--gn2/wqflask/requests.py10
-rw-r--r--gn2/wqflask/search_results.py321
-rw-r--r--gn2/wqflask/show_trait/SampleList.py6
-rw-r--r--gn2/wqflask/static/new/css/pills.css25
-rw-r--r--gn2/wqflask/static/new/css/resource-roles.css5
-rw-r--r--gn2/wqflask/static/new/javascript/auth/search_phenotypes.js6
-rw-r--r--gn2/wqflask/static/new/javascript/initialize_show_trait_tables.js2
-rw-r--r--gn2/wqflask/static/new/javascript/search_results.js49
-rw-r--r--gn2/wqflask/static/new/javascript/table_functions.js47
-rw-r--r--gn2/wqflask/templates/base.html2
-rw-r--r--gn2/wqflask/templates/collections/list.html8
-rw-r--r--gn2/wqflask/templates/collections/view.html4
-rw-r--r--gn2/wqflask/templates/correlation_page.html4
-rw-r--r--gn2/wqflask/templates/dataset.html27
-rw-r--r--gn2/wqflask/templates/gnqa.html4
-rw-r--r--gn2/wqflask/templates/gnqa_answer.html33
-rw-r--r--gn2/wqflask/templates/gnqa_search_history.html46
-rw-r--r--gn2/wqflask/templates/gsearch_gene.html8
-rw-r--r--gn2/wqflask/templates/oauth2/confirm-resource-role-unassign-privilege.html34
-rw-r--r--gn2/wqflask/templates/oauth2/create-role.html42
-rw-r--r--gn2/wqflask/templates/oauth2/data-list-mrna.html2
-rw-r--r--gn2/wqflask/templates/oauth2/data-list-phenotype.html3
-rw-r--r--gn2/wqflask/templates/oauth2/profile_nav.html7
-rw-r--r--gn2/wqflask/templates/oauth2/view-group-role.html102
-rw-r--r--gn2/wqflask/templates/oauth2/view-resource-role.html149
-rw-r--r--gn2/wqflask/templates/oauth2/view-resource.html56
-rw-r--r--gn2/wqflask/templates/search-syntax.html24
-rw-r--r--gn2/wqflask/templates/search_result_page.html22
-rw-r--r--gn2/wqflask/templates/show_trait_transform_and_filter.html4
-rw-r--r--gn2/wqflask/templates/tool_buttons.html4
-rw-r--r--gn2/wqflask/templates/tutorials.html11
-rw-r--r--gn2/wqflask/views.py110
59 files changed, 1393 insertions, 726 deletions
diff --git a/gn2/base/trait.py b/gn2/base/trait.py
index 701958d7..24288ba1 100644
--- a/gn2/base/trait.py
+++ b/gn2/base/trait.py
@@ -611,3 +611,34 @@ def retrieve_trait_info(trait, dataset, get_qtl_info=False):
                 f"{repr(trait.name)} information is not found in the database "
                 f"for dataset '{dataset.name}' with id '{dataset.id}'.")
         return trait
+
+def fetch_symbols(trait_db_list):
+    """
+    Fetch list of trait symbols
+
+    From a list of traits and datasets (where each item has
+    the trait and dataset name separated by a colon), return
+
+    """
+
+    trimmed_trait_list = [trait_db for trait_db in trait_db_list
+                          if 'Publish' not in trait_db and 'Geno' not in trait_db.split(":")[1]]
+
+    symbol_list = []
+    with database_connection(get_setting("SQL_URI")) as conn, conn.cursor() as cursor:
+        for trait_db in trimmed_trait_list:
+            symbol_query = """
+                SELECT ps.Symbol
+                FROM ProbeSet as ps
+                    INNER JOIN ProbeSetXRef psx ON psx.ProbeSetId = ps.Id
+                    INNER JOIN ProbeSetFreeze psf ON psx.ProbeSetFreezeId = psf.Id
+                WHERE
+                    ps.Name = %(trait_name)s AND
+                    psf.Name = %(db_name)s
+            """
+
+            cursor.execute(symbol_query, {'trait_name': trait_db.split(":")[0],
+                                          'db_name': trait_db.split(":")[1]})
+            symbol_list.append(cursor.fetchone()[0])
+
+    return "+".join(symbol_list)
\ No newline at end of file
diff --git a/gn2/base/webqtlConfig.py b/gn2/base/webqtlConfig.py
index 998c0efc..88f199f9 100644
--- a/gn2/base/webqtlConfig.py
+++ b/gn2/base/webqtlConfig.py
@@ -34,7 +34,7 @@ MAXLRS = 460.0
 PUBLICTHRESH = 0
 
 # Groups to treat as unique when drawing correlation dropdowns (not sure if this logic even makes sense or is necessary)
-BXD_GROUP_EXCEPTIONS = ['BXD-Longevity', 'BXD-AE', 'BXD-Heart-Metals', 'BXD-NIA-AD']
+BXD_GROUP_EXCEPTIONS = ['BXD-Longevity', 'BXD-AE', 'BXD-Heart-Metals', 'BXD-NIA-AD', 'BXD-JAX-OFS']
 
 # EXTERNAL LINK ADDRESSES
 PUBMEDLINK_URL = "http://www.ncbi.nlm.nih.gov/entrez/query.fcgi?cmd=Retrieve&db=PubMed&list_uids=%s&dopt=Abstract"
@@ -68,6 +68,8 @@ RGD_URL = "https://rgd.mcw.edu/rgdweb/elasticResults.html?term=%s&category=Gene&
 PHENOGEN_URL = "https://phenogen.org/gene.jsp?speciesCB=Rn&auto=Y&geneTxt=%s&genomeVer=rn7&section=geneEQTL"
 RRID_MOUSE_URL = "https://www.jax.org/strain/%s"
 RRID_RAT_URL = "https://rgd.mcw.edu/rgdweb/report/strain/main.html?id=%s"
+GENE_CUP_URL = "https://genecup.org/progress?type=GWAS&type=addiction&type=drug&type=brain&type=stress&type=psychiatric&type=cell&type=function&query=%s"
+
 
 # Temporary storage (note that this TMPDIR can be set as an
 # environment variable - use utility.tools.TEMPDIR when you
diff --git a/gn2/default_settings.py b/gn2/default_settings.py
index e781f196..ab15dbe9 100644
--- a/gn2/default_settings.py
+++ b/gn2/default_settings.py
@@ -120,3 +120,9 @@ OAUTH2_CLIENT_SECRET="yadabadaboo"
 SESSION_TYPE = "redis"
 SESSION_PERMANENT = True
 SESSION_USE_SIGNER = True
+
+
+# BEGIN: JSON WEB KEYS #####
+JWKS_ROTATION_AGE_DAYS = 7 # Days (from creation) to keep a JWK in use.
+JWKS_DELETION_AGE_DAYS = 14 # Days (from creation) to keep a JWK around before deleting it.
+# END: JSON WEB KEYS #####
diff --git a/gn2/gn2_main.py b/gn2/gn2_main.py
index 38444620..197eed83 100644
--- a/gn2/gn2_main.py
+++ b/gn2/gn2_main.py
@@ -15,5 +15,6 @@ from gn2.wqflask import search_results
 from gn2.wqflask import resource_manager
 from gn2.wqflask import update_search_results
 
+import gn2.wqflask.collect
 import gn2.wqflask.views
 import gn2.wqflask.partial_correlations_views
diff --git a/gn2/runserver.py b/gn2/runserver.py
index 0c95f715..dd176752 100644
--- a/gn2/runserver.py
+++ b/gn2/runserver.py
@@ -28,7 +28,7 @@ if WEBSERVER_MODE == 'DEBUG':
             port=SERVER_PORT,
             debug=True,
             use_debugger=False,
-            threaded=False,
+            threaded=True,
             processes=0,
             use_reloader=True)
 elif WEBSERVER_MODE == 'DEV':
@@ -37,7 +37,7 @@ elif WEBSERVER_MODE == 'DEV':
             port=SERVER_PORT,
             debug=False,
             use_debugger=False,
-            threaded=False,
+            threaded=True,
             processes=0,
             use_reloader=True)
 else:  # staging/production modes
diff --git a/gn2/wqflask/__init__.py b/gn2/wqflask/__init__.py
index e3708b0b..00fe5127 100644
--- a/gn2/wqflask/__init__.py
+++ b/gn2/wqflask/__init__.py
@@ -33,6 +33,7 @@ from gn2.wqflask.api.markdown import environments_blueprint
 from gn2.wqflask.api.markdown import facilities_blueprint
 from gn2.wqflask.api.markdown import blogs_blueprint
 from gn2.wqflask.api.markdown import news_blueprint
+from gn2.wqflask.api.markdown import xapian_syntax_blueprint
 from gn2.wqflask.api.jobs import jobs as jobs_bp
 from gn2.wqflask.oauth2.routes import oauth2
 from gn2.wqflask.oauth2.client import user_logged_in
@@ -55,28 +56,17 @@ def numcoll():
         return "ERROR"
 
 
-def parse_ssl_key(app: Flask, keyconfig: str):
-    """Parse key file paths into objects"""
-    keypath = app.config.get(keyconfig, "").strip()
-    if not bool(keypath):
-        app.logger.error("Expected configuration '%s'", keyconfig)
-        return
-
-    with open(keypath) as _sslkey:
-            app.config[keyconfig] = JsonWebKey.import_key(_sslkey.read())
-
-
 def dev_loggers(appl: Flask) -> None:
     """Default development logging."""
     formatter = logging.Formatter(
-        fmt="[%(asctime)s] %(levelname)s in %(module)s: %(message)s")
+        fmt="[%(asctime)s] %(levelname)s [%(thread)d -- %(threadName)s] in %(module)s: %(message)s")
     stderr_handler = logging.StreamHandler(stream=sys.stderr)
     stderr_handler.setFormatter(formatter)
     appl.logger.addHandler(stderr_handler)
 
     root_logger = logging.getLogger()
     root_logger.addHandler(stderr_handler)
-    root_logger.setLevel(appl.config.get("LOGLEVEL", "WARNING"))
+    root_logger.setLevel(appl.config.get("LOG_LEVEL", "WARNING"))
 
 
 def gunicorn_loggers(appl: Flask) -> None:
@@ -93,12 +83,20 @@ def setup_logging(appl: Flask) -> None:
 
 
 app = Flask(__name__)
-setup_logging(app)
-
+## BEGIN: Setup configurations ##
 # See http://flask.pocoo.org/docs/config/#configuring-from-files
 # Note no longer use the badly named WQFLASK_OVERRIDES (nyi)
 app.config.from_object('gn2.default_settings')
 app.config.from_envvar('GN2_SETTINGS')
+app.config["SESSION_REDIS"] = redis.from_url(app.config["REDIS_URL"])
+# BEGIN: SECRETS -- Should be the last of the settings to load
+secrets_file = Path(app.config.get("GN2_SECRETS", "")).absolute()
+if secrets_file.exists() and secrets_file.is_file():
+    app.config.from_pyfile(str(secrets_file))
+# END: SECRETS
+## END: Setup configurations ##
+setup_logging(app)
+### DO NOT USE logging BEFORE THIS POINT!!!! ###
 
 app.jinja_env.globals.update(
     undefined=jinja2.StrictUndefined,
@@ -109,14 +107,6 @@ app.jinja_env.globals.update(
     num_collections=numcoll,
     datetime=datetime)
 
-app.config["SESSION_REDIS"] = redis.from_url(app.config["REDIS_URL"])
-
-## BEGIN: SECRETS -- Should be the last of the settings to load
-secrets_file = Path(app.config.get("GN2_SECRETS", "")).absolute()
-if secrets_file.exists() and secrets_file.is_file():
-    app.config.from_pyfile(str(secrets_file))
-## END: SECRETS
-
 
 # Registering blueprints
 app.register_blueprint(glossary_blueprint, url_prefix="/glossary")
@@ -127,6 +117,7 @@ app.register_blueprint(environments_blueprint, url_prefix="/environments")
 app.register_blueprint(facilities_blueprint, url_prefix="/facilities")
 app.register_blueprint(blogs_blueprint, url_prefix="/blogs")
 app.register_blueprint(news_blueprint, url_prefix="/news")
+app.register_blueprint(xapian_syntax_blueprint, url_prefix="/search-syntax")
 app.register_blueprint(jupyter_notebooks, url_prefix="/jupyter_notebooks")
 
 app.register_blueprint(resource_management, url_prefix="/resource-management")
@@ -148,29 +139,11 @@ except StartupError as serr:
 
 server_session = Session(app)
 
-parse_ssl_key(app, "SSL_PRIVATE_KEY")
-parse_ssl_key(app, "AUTH_SERVER_SSL_PUBLIC_KEY")
-
 @app.before_request
 def before_request():
     g.request_start_time = time.time()
     g.request_time = lambda: "%.5fs" % (time.time() - g.request_start_time)
 
-    token = session.get("oauth2_token", False)
-    if token and not bool(session.get("user_details", False)):
-        from gn2.wqflask.oauth2.client import oauth2_client
-        config = current_app.config
-        resp = oauth2_client().client.get(
-            urljoin(config["GN_SERVER_URL"], "oauth2/user"))
-        user_details = resp.json()
-        session["user_details"] = user_details
-
-        if user_details.get("error") == "invalid_token":
-            flash(user_details["error_description"], "alert-danger")
-            flash("You are now logged out.", "alert-info")
-            session.pop("user_details", None)
-            session.pop("oauth2_token", None)
-
 @app.context_processor
 def include_admin_role_class():
     return {'AdminRole': AdminRole}
diff --git a/gn2/wqflask/api/markdown.py b/gn2/wqflask/api/markdown.py
index 580b9ac0..aa7dd3c4 100644
--- a/gn2/wqflask/api/markdown.py
+++ b/gn2/wqflask/api/markdown.py
@@ -24,6 +24,7 @@ links_blueprint = Blueprint("links_blueprint", __name__)
 policies_blueprint = Blueprint("policies_blueprint", __name__)
 facilities_blueprint = Blueprint("facilities_blueprint", __name__)
 news_blueprint = Blueprint("news_blueprint", __name__)
+xapian_syntax_blueprint = Blueprint("xapian_syntax_blueprint", __name__)
 
 blogs_blueprint = Blueprint("blogs_blueprint", __name__)
 
@@ -117,6 +118,13 @@ def news():
         rendered_markdown=render_markdown("general/news/news.md")), 200
 
 
+@xapian_syntax_blueprint.route('/')
+def xapian():
+    return render_template(
+        "search-syntax.html",
+        rendered_markdown=render_markdown("general/search/xapian_syntax.md")), 200
+
+
 @environments_blueprint.route("/")
 def environments():
 
diff --git a/gn2/wqflask/api/router.py b/gn2/wqflask/api/router.py
index bcd08e8d..e9b70919 100644
--- a/gn2/wqflask/api/router.py
+++ b/gn2/wqflask/api/router.py
@@ -379,23 +379,28 @@ def fetch_traits(dataset_name, file_format="json"):
         if len(trait_ids) > 0:
             if data_type == "ProbeSet":
                 query = """
-                            SELECT
-                                ProbeSet.Id, ProbeSet.Name, ProbeSet.Symbol, ProbeSet.description, ProbeSet.Chr, ProbeSet.Mb, ProbeSet.alias,
-                                ProbeSetXRef.mean, ProbeSetXRef.se, ProbeSetXRef.Locus, ProbeSetXRef.LRS, ProbeSetXRef.pValue, ProbeSetXRef.additive, ProbeSetXRef.h2
-                            FROM
-                                ProbeSet, ProbeSetXRef, ProbeSetFreeze
+                            SELECT DISTINCT
+                                ProbeSet.`Id`, ProbeSet.`Name`, ProbeSet.`Symbol`, ProbeSet.`description`,
+                                ProbeSet.`Chr`, ProbeSet.`Mb`, ProbeSet.`alias`, ProbeSetXRef.`mean`,
+                                ProbeSetXRef.`se`, ProbeSetXRef.`Locus`, ProbeSetXRef.`LRS`,
+                                ProbeSetXRef.`pValue`, ProbeSetXRef.`additive`, ProbeSetXRef.`h2`,
+                                Geno.`Chr`, Geno.`Mb`
+                            FROM 
+                                Species
+                                INNER JOIN InbredSet ON InbredSet.`SpeciesId`= Species.`Id`
+                                INNER JOIN ProbeFreeze ON ProbeFreeze.`InbredSetId` = InbredSet.`Id`
+                                INNER JOIN Tissue ON ProbeFreeze.`TissueId` = Tissue.`Id`
+                                INNER JOIN ProbeSetFreeze ON ProbeSetFreeze.`ProbeFreezeId` = ProbeFreeze.`Id`
+                                INNER JOIN ProbeSetXRef ON ProbeSetXRef.`ProbeSetFreezeId` = ProbeSetFreeze.`Id`
+                                INNER JOIN ProbeSet ON ProbeSet.`Id` = ProbeSetXRef.`ProbeSetId`
+                                LEFT JOIN Geno ON ProbeSetXRef.`Locus` = Geno.`Name` AND Geno.`SpeciesId` = Species.`Id`
                             WHERE
-                                ProbeSetXRef.ProbeSetFreezeId = "{0}" AND
-                                ProbeSetXRef.ProbeSetId = ProbeSet.Id AND
-                                ProbeSetXRef.ProbeSetFreezeId = ProbeSetFreeze.Id AND
-                                ProbeSetFreeze.public > 0 AND
-                                ProbeSetFreeze.confidentiality < 1
+                                ProbeSetXRef.ProbeSetFreezeId = "{0}"
                             ORDER BY
-                                ProbeSet.Id
-                        """
+                                ProbeSet.Id"""
 
-                field_list = ["Id", "Name", "Symbol", "Description", "Chr", "Mb",
-                              "Aliases", "Mean", "SE", "Locus", "LRS", "P-Value", "Additive", "h2"]
+                field_list = ["Id", "Name", "Symbol", "Description", "Chr", "Mb", "Aliases", "Mean",
+                              "SE", "Locus", "LRS", "P-Value", "Additive", "h2", "Peak Chr", "Peak Mb"]
             elif data_type == "Geno":
                 query = """
                             SELECT
diff --git a/gn2/wqflask/app_errors.py b/gn2/wqflask/app_errors.py
index 503f4e1c..b7b8527b 100644
--- a/gn2/wqflask/app_errors.py
+++ b/gn2/wqflask/app_errors.py
@@ -6,7 +6,8 @@ import traceback
 from uuid import uuid4
 
 from werkzeug.exceptions import InternalServerError
-from authlib.integrations.base_client.errors import InvalidTokenError
+from authlib.integrations.base_client.errors import (
+    OAuthError, InvalidTokenError)
 from flask import (
     flash, request, redirect, current_app, render_template, make_response)
 
@@ -46,13 +47,37 @@ def handle_authorisation_error(exc: AuthorisationError):
         "authorisation_error.html", error_type=type(exc).__name__, error=exc)
 
 def handle_invalid_token_error(exc: InvalidTokenError):
+    """Handle InvalidTokenError"""
     flash("An invalid session token was detected. "
           "You have been logged out of the system.",
           "alert-danger")
+    current_app.logger.error("Invalid token detected. %s", request.url, exc_info=True)
+    session.clear_session_info()
+    return redirect("/")
+
+def __build_message__(exc: OAuthError) -> str:
+    """Build up the message to flash for any OAuthError"""
+    match exc.args[0]:
+        case "ForbiddenAccess: Token does not belong to client.":
+            return "An invalid token was used. The session has been cleared."
+        case "ForbiddenAccess: Token is expired.":
+            return "The session has expired."
+        case "ForbiddenAccess: Token has previously been revoked.":
+            return "Revoked token was used. The session has been cleared."
+        case _:
+            return exc.args[0]
+
+def handle_oauth_error(exc: OAuthError):
+    """Handle generic OAuthError"""
+    flash((f"{type(exc).__name__}: {__build_message__(exc)} "
+           "Please log in again to continue."),
+          "alert-danger")
+    current_app.logger.error("Invalid token detected. %s", request.url, exc_info=True)
     session.clear_session_info()
     return redirect("/")
 
 __handlers__ = {
+    OAuthError: handle_oauth_error,
     AuthorisationError: handle_authorisation_error,
     ExternalRequestError: lambda exc: render_error(exc),
     InternalServerError: lambda exc: render_error(exc),
diff --git a/gn2/wqflask/collect.py b/gn2/wqflask/collect.py
index 9e0640a5..dd561a78 100644
--- a/gn2/wqflask/collect.py
+++ b/gn2/wqflask/collect.py
@@ -72,7 +72,7 @@ def store_traits_list():
     return hash
 
 
-@app.route("/collections/add", methods=["POST"])
+@app.route("/collections/add", methods=('GET', 'POST'))
 def collections_add():
     anon_id = session_info()["anon_id"]
     traits = request.args.get("traits", request.form.get("traits"))
diff --git a/gn2/wqflask/correlation/show_corr_results.py b/gn2/wqflask/correlation/show_corr_results.py
index c8625222..1abf1a28 100644
--- a/gn2/wqflask/correlation/show_corr_results.py
+++ b/gn2/wqflask/correlation/show_corr_results.py
@@ -91,6 +91,8 @@ def apply_filters(trait, target_trait, target_dataset, **filters):
                             min_location_mb, max_location_mb):
 
         if target_dataset["type"] in ["ProbeSet", "Geno"] and location_type == "gene":
+            if not target_trait['mb'] or not target_trait['chr']:
+                return True
             return (
                 ((location_chr!=None) and (target_trait["chr"]!=location_chr))
                      or
@@ -154,7 +156,7 @@ def get_user_filters(start_vars):
 
     if all(keys in start_vars for keys in ["loc_chr",
                                            "min_loc_mb",
-                                           "max_location_mb"]):
+                                           "max_loc_mb"]):
 
         location_chr = get_string(start_vars, "loc_chr")
         min_location_mb = get_int(start_vars, "min_loc_mb")
diff --git a/gn2/wqflask/do_search.py b/gn2/wqflask/do_search.py
index 3c81783d..b64c6fce 100644
--- a/gn2/wqflask/do_search.py
+++ b/gn2/wqflask/do_search.py
@@ -253,13 +253,15 @@ class PhenotypeSearch(DoSearch):
         # Todo: Zach will figure out exactly what both these lines mean
         # and comment here
 
-        # if "'" not in self.search_term[0]:
-        search_term = "%" + \
-            self.handle_wildcard(self.search_term[0]) + "%"
+        search_term = self.search_term[0]
         if "_" in self.search_term[0]:
             if len(self.search_term[0].split("_")[0]) == 3:
-                search_term = "%" + self.handle_wildcard(
-                    self.search_term[0].split("_")[1]) + "%"
+                search_term = self.search_term[0].split("_")[1]
+
+        if not search_term.isnumeric() or len(search_term) != 5: # To make sure phenotype trait IDs aren't included in a fulltext search
+            search_term = "%" + \
+                self.handle_wildcard(search_term) + "%"
+
 
         # This adds a clause to the query that matches the search term
         # against each field in the search_fields tuple
diff --git a/gn2/wqflask/gsearch.py b/gn2/wqflask/gsearch.py
index cad6db94..2a214cf8 100644
--- a/gn2/wqflask/gsearch.py
+++ b/gn2/wqflask/gsearch.py
@@ -28,10 +28,16 @@ class GSearch:
         hmac = curry(3, lambda trait_name, dataset, data_hmac: f"{trait_name}:{dataset}:{data_hmac}")
         convert_lod = lambda x: x / 4.61
         self.trait_list = []
-        for i, trait in enumerate(requests.get(
+        response = requests.get(
                 urljoin(GN3_LOCAL_URL, "/api/search?" + urlencode({"query": self.terms,
                                                                    "type": self.type,
-                                                                   "per_page": MAX_SEARCH_RESULTS}))).json()):
+                                                                   "per_page": MAX_SEARCH_RESULTS})))
+        if response.status_code == 400 and "parsererror" in response.text.lower():
+            raise ValueError(f"Query `{self.terms}` has a problem: {response.json()}")
+        response.raise_for_status()
+        response_json = response.json()
+
+        for i, trait in enumerate(response_json):
             trait = MonadicDict(trait)
             trait["index"] = Just(i)
             trait["location_repr"] = (Maybe.apply(chr_mb)
diff --git a/gn2/wqflask/marker_regression/display_mapping_results.py b/gn2/wqflask/marker_regression/display_mapping_results.py
index dec414c0..345751ad 100644
--- a/gn2/wqflask/marker_regression/display_mapping_results.py
+++ b/gn2/wqflask/marker_regression/display_mapping_results.py
@@ -773,8 +773,9 @@ class DisplayMappingResults:
             pass
 
         # draw position, no need to use a separate function
-        self.drawProbeSetPosition(
-            canvas, plotXScale, offset=newoffset, zoom=zoom)
+        if self.traitList[0].mb: # Only draw if position actually exists
+            self.drawProbeSetPosition(
+                canvas, plotXScale, offset=newoffset, zoom=zoom)
 
         return gifmap
 
@@ -1130,7 +1131,7 @@ class DisplayMappingResults:
             )
             TEXT_Y_DISPLACEMENT = -8
             im_drawer.text(
-                text="Sequence Site",
+                text="Gene Location",
                 xy=(leftOffset + 20, startPosY + TEXT_Y_DISPLACEMENT), font=smallLabelFont,
                 fill=self.TOP_RIGHT_INFO_COLOR)
 
@@ -1170,20 +1171,20 @@ class DisplayMappingResults:
 
         if self.haplotypeAnalystChecked:
             im_drawer.line(
-                xy=((startPosX - 34, startPosY), (startPosX - 17, startPosY)),
-                fill=self.HAPLOTYPE_POSITIVE, width=4)
+                xy=((startPosX - 60, startPosY), (startPosX - 45, startPosY)),
+                fill=self.HAPLOTYPE_NEGATIVE, width=8)
             im_drawer.line(
-                xy=((startPosX - 17, startPosY), (startPosX, startPosY)),
-                fill=self.HAPLOTYPE_NEGATIVE, width=4)
+                xy=((startPosX - 45, startPosY), (startPosX - 30, startPosY)),
+                fill=self.HAPLOTYPE_POSITIVE, width=8)
             im_drawer.line(
-                xy=((startPosX, startPosY), (startPosX + 17, startPosY)),
-                fill=self.HAPLOTYPE_HETEROZYGOUS, width=4)
+                xy=((startPosX - 30, startPosY), (startPosX - 15, startPosY)),
+                fill=self.HAPLOTYPE_HETEROZYGOUS, width=8)
             im_drawer.line(
-                xy=((startPosX + 17, startPosY), (startPosX + 34, startPosY)),
-                fill=self.HAPLOTYPE_RECOMBINATION, width=4)
+                xy=((startPosX - 15, startPosY), (startPosX, startPosY)),
+                fill=self.HAPLOTYPE_RECOMBINATION, width=8)
             im_drawer.text(
-                text='Haplotypes (Pat, Mat, Het, Unk)',
-                xy=(startPosX + 41, startPosY + TEXT_Y_DISPLACEMENT), font=labelFont, fill=BLACK)
+                text='Haplotypes (Mat, Pat, Het, Unknown)',
+                xy=(startPosX + 10, startPosY + TEXT_Y_DISPLACEMENT), font=labelFont, fill=BLACK)
             startPosY += stepPosY
 
         if self.permChecked and self.nperm > 0:
diff --git a/gn2/wqflask/oauth2/checks.py b/gn2/wqflask/oauth2/checks.py
index 7f33348e..4a5a117f 100644
--- a/gn2/wqflask/oauth2/checks.py
+++ b/gn2/wqflask/oauth2/checks.py
@@ -2,12 +2,10 @@
 from functools import wraps
 from urllib.parse import urljoin
 
+from flask import flash, request, redirect
 from authlib.integrations.requests_client import OAuth2Session
-from flask import (
-    flash, request, redirect, session as flask_session)
 
 from . import session
-from .session import clear_session_info
 from .client import (
     oauth2_get,
     oauth2_client,
@@ -24,8 +22,6 @@ def require_oauth2(func):
 
         def __clear_session__(_no_token):
             session.clear_session_info()
-            flask_session.pop("oauth2_token", None)
-            flask_session.pop("user_details", None)
             flash("You need to be logged in.", "alert-warning")
             return redirect("/")
 
@@ -36,7 +32,7 @@ def require_oauth2(func):
             if not user_details.get("error", False):
                 return func(*args, **kwargs)
 
-            return clear_session_info(token)
+            return __clear_session__(token)
 
         return session.user_token().either(__clear_session__, __with_token__)
 
diff --git a/gn2/wqflask/oauth2/client.py b/gn2/wqflask/oauth2/client.py
index 75e438cc..a7d20f6b 100644
--- a/gn2/wqflask/oauth2/client.py
+++ b/gn2/wqflask/oauth2/client.py
@@ -1,12 +1,17 @@
 """Common oauth2 client utilities."""
 import json
+import time
+import random
 import requests
 from typing import Optional
 from urllib.parse import urljoin
+from datetime import datetime, timedelta
 
 from flask import current_app as app
 from pymonad.either import Left, Right, Either
-from authlib.jose import jwt
+from authlib.common.urls import url_decode
+from authlib.jose.errors import BadSignatureError
+from authlib.jose import KeySet, JsonWebKey, JsonWebToken
 from authlib.integrations.requests_client import OAuth2Session
 
 from gn2.wqflask.oauth2 import session
@@ -31,11 +36,76 @@ def oauth2_clientsecret():
 def user_logged_in():
     """Check whether the user has logged in."""
     suser = session.session_info()["user"]
-    if suser["logged_in"]:
-        if session.expired():
-            session.clear_session_info()
-            return False
-        return suser["token"].is_right()
+    return suser["logged_in"] and suser["token"].is_right()
+
+
+def __make_token_validator__(keys: KeySet):
+    """Make a token validator function."""
+    def __validator__(token: dict):
+        for key in keys.keys:
+            try:
+                # Fixes CVE-2016-10555. See
+                # https://docs.authlib.org/en/latest/jose/jwt.html
+                jwt = JsonWebToken(["RS256"])
+                jwt.decode(token["access_token"], key)
+                return Right(token)
+            except BadSignatureError:
+                pass
+
+        return Left("INVALID-TOKEN")
+
+    return __validator__
+
+
+def auth_server_jwks() -> Optional[KeySet]:
+    """Fetch the auth-server JSON Web Keys information."""
+    _jwks = session.session_info().get("auth_server_jwks")
+    if bool(_jwks):
+        return {
+            "last-updated": _jwks["last-updated"],
+            "jwks": KeySet([
+                JsonWebKey.import_key(key) for key in _jwks.get(
+                    "auth_server_jwks", {}).get(
+                        "jwks", {"keys": []})["keys"]])}
+
+
+def __validate_token__(keys):
+    """Validate that the token is really from the auth server."""
+    def __index__(_sess):
+        return _sess
+    return session.user_token().then(__make_token_validator__(keys)).then(
+        session.set_user_token).either(__index__, __index__)
+
+
+def __update_auth_server_jwks__():
+    """Updates the JWKs every 2 hours or so."""
+    jwks = auth_server_jwks()
+    if bool(jwks):
+        last_updated = jwks.get("last-updated")
+        now = datetime.now().timestamp()
+        if bool(last_updated) and (now - last_updated) < timedelta(hours=2).seconds:
+            return __validate_token__(jwks["jwks"])
+
+    jwksuri = urljoin(authserver_uri(), "auth/public-jwks")
+    jwks = KeySet([
+        JsonWebKey.import_key(key)
+        for key in requests.get(jwksuri).json()["jwks"]])
+    return __validate_token__(jwks)
+
+
+def is_token_expired(token):
+    """Check whether the token has expired."""
+    __update_auth_server_jwks__()
+    jwks = auth_server_jwks()
+    if bool(jwks):
+        for jwk in jwks["jwks"].keys:
+            try:
+                jwt = JsonWebToken(["RS256"]).decode(
+                    token["access_token"], key=jwk)
+                return datetime.now().timestamp() > jwt["exp"]
+            except BadSignatureError as _bse:
+                pass
+
     return False
 
 
@@ -43,20 +113,56 @@ def oauth2_client():
     def __update_token__(token, refresh_token=None, access_token=None):
         """Update the token when refreshed."""
         session.set_user_token(token)
+        return token
+
+    def __delay__():
+        """Do a tiny delay."""
+        time.sleep(random.choice(tuple(i/1000.0 for i in range(0,100))))
+
+    def __refresh_token__(token):
+        """Synchronise token refresh."""
+        if is_token_expired(token):
+            __delay__()
+            if session.is_token_refreshing():
+                while session.is_token_refreshing():
+                    __delay__()
+
+                _token = session.user_token().either(None, lambda _tok: _tok)
+                return _token
+
+            session.toggle_token_refreshing()
+            _client = __client__(token)
+            _client.get(urljoin(authserver_uri(), "auth/user/"))
+            session.toggle_token_refreshing()
+            return _client.token
+
+        return token
+
+    def __json_auth__(client, method, uri, headers, body):
+        return (
+            uri,
+            {**headers, "Content-Type": "application/json"},
+            json.dumps({
+                **dict(url_decode(body)),
+                "client_id": oauth2_clientid(),
+                "client_secret": oauth2_clientsecret()
+            }))
 
     def __client__(token) -> OAuth2Session:
-        _jwt = jwt.decode(token["access_token"],
-                          app.config["AUTH_SERVER_SSL_PUBLIC_KEY"])
         client = OAuth2Session(
             oauth2_clientid(),
             oauth2_clientsecret(),
             scope=SCOPE,
-            token_endpoint=urljoin(authserver_uri(), "/auth/token"),
+            token_endpoint=urljoin(authserver_uri(), "auth/token"),
             token_endpoint_auth_method="client_secret_post",
             token=token,
             update_token=__update_token__)
+        client.register_client_auth_method(
+            ("client_secret_post", __json_auth__))
         return client
-    return session.user_token().either(
+
+    __update_auth_server_jwks__()
+    return session.user_token().then(__refresh_token__).either(
         lambda _notok: __client__(None),
         lambda token: __client__(token))
 
@@ -70,12 +176,18 @@ def __no_token__(_err) -> Left:
     resp.status_code = 400
     return Left(resp)
 
-def oauth2_get(uri_path: str, data: dict = {},
-               jsonify_p: bool = False, **kwargs) -> Either:
+def oauth2_get(
+        uri_path: str,
+        data: dict = {},
+        jsonify_p: bool = False,
+        headers: dict = {"Content-Type": "application/json"},
+        **kwargs
+) -> Either:
     def __get__(token) -> Either:
         resp = oauth2_client().get(
             urljoin(authserver_uri(), uri_path),
             data=data,
+            headers=headers,
             **kwargs)
         if resp.status_code == 200:
             if jsonify_p:
@@ -87,11 +199,18 @@ def oauth2_get(uri_path: str, data: dict = {},
     return session.user_token().either(__no_token__, __get__)
 
 def oauth2_post(
-        uri_path: str, data: Optional[dict] = None, json: Optional[dict] = None,
-        **kwargs) -> Either:
+        uri_path: str,
+        data: Optional[dict] = None,
+        json: Optional[dict] = None,
+        headers: dict = {"Content-Type": "application/json"},
+        **kwargs
+) -> Either:
     def __post__(token) -> Either:
         resp = oauth2_client().post(
-            urljoin(authserver_uri(), uri_path), data=data, json=json,
+            urljoin(authserver_uri(), uri_path),
+            data=data,
+            json=json,
+            headers=headers,
             **kwargs)
         if resp.status_code == 200:
             return Right(resp.json())
@@ -100,10 +219,14 @@ def oauth2_post(
 
     return session.user_token().either(__no_token__, __post__)
 
-def no_token_get(uri_path: str, **kwargs) -> Either:
+def no_token_get(
+        uri_path: str,
+        headers: dict = {"Content-Type": "application/json"},
+        **kwargs
+) -> Either:
     uri = urljoin(authserver_uri(), uri_path)
     try:
-        resp = requests.get(uri, **kwargs)
+        resp = requests.get(uri, headers=headers, **kwargs)
         if resp.status_code == 200:
             return Right(resp.json())
         return Left(resp)
diff --git a/gn2/wqflask/oauth2/data.py b/gn2/wqflask/oauth2/data.py
index 29d68be0..16e5f60c 100644
--- a/gn2/wqflask/oauth2/data.py
+++ b/gn2/wqflask/oauth2/data.py
@@ -69,8 +69,10 @@ def __search_phenotypes__(query, template, **kwargs):
             template, traits=[], per_page=per_page, query=query,
             selected_traits=selected_traits, search_results=search_results,
             search_endpoint=urljoin(
-                authserver_uri(), "auth/data/search"),
-            gn_server_url = authserver_uri(),
+                request.host_url, "oauth2/data/phenotype/search"),
+            auth_server_url=authserver_uri(),
+            pheno_results_template=urljoin(
+                authserver_uri(), "auth/data/search/phenotype/<jobid>"),
             results_endpoint=urljoin(
                 authserver_uri(),
                 f"auth/data/search/phenotype/{job_id}"),
@@ -122,6 +124,7 @@ def json_search_mrna() -> Response:
 @data.route("/phenotype/search", methods=["POST"])
 def json_search_phenotypes() -> Response:
     """Search for phenotypes."""
+    from gn2.utility.tools import GN_SERVER_URL
     form = request.json
     def __handle_error__(err):
         error = process_error(err)
@@ -136,6 +139,7 @@ def json_search_phenotypes() -> Response:
             "per_page": int(form.get("per_page", 50)),
             "page": int(form.get("page", 1)),
             "auth_server_uri": authserver_uri(),
+            "gn3_server_uri": GN_SERVER_URL,
             "selected_traits": form.get("selected_traits", [])
         }).either(__handle_error__, jsonify)
 
diff --git a/gn2/wqflask/oauth2/groups.py b/gn2/wqflask/oauth2/groups.py
index 3bc4bcb2..b3f1f54d 100644
--- a/gn2/wqflask/oauth2/groups.py
+++ b/gn2/wqflask/oauth2/groups.py
@@ -44,7 +44,7 @@ def create_group():
     def __setup_group__(response):
         session["user_details"]["group"] = response
 
-    resp = oauth2_post("auth/group/create", data=dict(request.form))
+    resp = oauth2_post("auth/group/create", json=dict(request.form))
     return resp.either(
         handle_error("oauth2.group.join_or_create"),
         handle_success(
@@ -116,7 +116,7 @@ def accept_join_request():
         return redirect(url_for("oauth2.group.list_join_requests"))
     return oauth2_post(
         "auth/group/requests/join/accept",
-        data=request.form).either(
+        json=dict(request.form)).either(
             handle_error("oauth2.group.list_join_requests"),
             __success__)
 
@@ -132,34 +132,10 @@ def reject_join_request():
         return redirect(url_for("oauth2.group.list_join_requests"))
     return oauth2_post(
         "auth/group/requests/join/reject",
-        data=request.form).either(
+        json=dict(request.form)).either(
             handle_error("oauth2.group.list_join_requests"),
             __success__)
 
-@groups.route("/role/<uuid:group_role_id>", methods=["GET"])
-@require_oauth2
-def group_role(group_role_id: uuid.UUID):
-    """View the details of a particular role."""
-    def __render_error__(**kwargs):
-        return render_ui("oauth2/view-group-role.html", **kwargs)
-
-    def __gprivs_success__(role, group_privileges):
-        return render_ui(
-            "oauth2/view-group-role.html", group_role=role,
-            group_privileges=tuple(
-                priv for priv in group_privileges
-                if priv not in role["role"]["privileges"]))
-
-    def __role_success__(role):
-        return oauth2_get("auth/group/privileges").either(
-            lambda err: __render_error__(
-                group_role=group_role,
-                group_privileges_error=process_error(err)),
-            lambda privileges: __gprivs_success__(role, privileges))
-
-    return oauth2_get(f"auth/group/role/{group_role_id}").either(
-        lambda err: __render_error__(group_role_error=process_error(err)),
-        __role_success__)
 
 def add_delete_privilege_to_role(
         group_role_id: uuid.UUID, direction: str) -> Response:
@@ -187,7 +163,7 @@ def add_delete_privilege_to_role(
         }
         return oauth2_post(
             uris[direction],
-            data={
+            json={
                 "group_role_id": group_role_id,
                 "privilege_id": privilege_id
             }).either(__error__, __success__)
diff --git a/gn2/wqflask/oauth2/jwks.py b/gn2/wqflask/oauth2/jwks.py
new file mode 100644
index 00000000..efd04997
--- /dev/null
+++ b/gn2/wqflask/oauth2/jwks.py
@@ -0,0 +1,86 @@
+"""Utilities dealing with JSON Web Keys (JWK)"""
+import os
+from pathlib import Path
+from typing import Any, Union
+from datetime import datetime, timedelta
+
+from flask import Flask
+from authlib.jose import JsonWebKey
+from pymonad.either import Left, Right, Either
+
+def jwks_directory(app: Flask, configname: str) -> Path:
+    """Compute the directory where the JWKs are stored."""
+    appsecretsdir = Path(app.config[configname]).parent
+    if appsecretsdir.exists() and appsecretsdir.is_dir():
+        jwksdir = Path(appsecretsdir, "jwks/")
+        if not jwksdir.exists():
+            jwksdir.mkdir()
+        return jwksdir
+    raise ValueError(
+        "The `appsecretsdir` value should be a directory that actually exists.")
+
+
+def generate_and_save_private_key(
+        storagedir: Path,
+        kty: str = "RSA",
+        crv_or_size: Union[str, int] = 2048,
+        options: tuple[tuple[str, Any]] = (("iat", datetime.now().timestamp()),)
+) -> JsonWebKey:
+    """Generate a private key and save to `storagedir`."""
+    privatejwk = JsonWebKey.generate_key(
+        kty, crv_or_size, dict(options), is_private=True)
+    keyname = f"{privatejwk.thumbprint()}.private.pem"
+    with open(Path(storagedir, keyname), "wb") as pemfile:
+        pemfile.write(privatejwk.as_pem(is_private=True))
+
+    return privatejwk
+
+
+def pem_to_jwk(filepath: Path) -> JsonWebKey:
+    """Parse a PEM file into a JWK object."""
+    with open(filepath, "rb") as pemfile:
+        return JsonWebKey.import_key(pemfile.read())
+
+
+def __sorted_jwks_paths__(storagedir: Path) -> tuple[tuple[float, Path], ...]:
+    """A sorted list of the JWK file paths with their creation timestamps."""
+    return tuple(sorted(((os.stat(keypath).st_ctime, keypath)
+                         for keypath in (Path(storagedir, keyfile)
+                                         for keyfile in os.listdir(storagedir)
+                                         if keyfile.endswith(".pem"))),
+                        key=lambda tpl: tpl[0]))
+
+
+def list_jwks(storagedir: Path) -> tuple[JsonWebKey, ...]:
+    """
+    List all the JWKs in a particular directory in the order they were created.
+    """
+    return tuple(pem_to_jwk(keypath) for ctime,keypath in
+                 __sorted_jwks_paths__(storagedir))
+
+
+def newest_jwk(storagedir: Path) -> Either:
+    """
+    Return an Either monad with the newest JWK or a message if none exists.
+    """
+    existingkeys = __sorted_jwks_paths__(storagedir)
+    if len(existingkeys) > 0:
+        return Right(pem_to_jwk(existingkeys[-1][1]))
+    return Left("No JWKs exist")
+
+
+def newest_jwk_with_rotation(jwksdir: Path, keyage: int) -> JsonWebKey:
+    """
+    Retrieve the latests JWK, creating a new one if older than `keyage` days.
+    """
+    def newer_than_days(jwkey):
+        filestat = os.stat(Path(
+            jwksdir, f"{jwkey.as_dict()['kid']}.private.pem"))
+        oldesttimeallowed = (datetime.now() - timedelta(days=keyage))
+        if filestat.st_ctime < (oldesttimeallowed.timestamp()):
+            return Left("JWK is too old!")
+        return jwkey
+
+    return newest_jwk(jwksdir).then(newer_than_days).either(
+        lambda _errmsg: generate_and_save_private_key(jwksdir),
+        lambda key: key)
diff --git a/gn2/wqflask/oauth2/request_utils.py b/gn2/wqflask/oauth2/request_utils.py
index 1cdc465f..456aba2b 100644
--- a/gn2/wqflask/oauth2/request_utils.py
+++ b/gn2/wqflask/oauth2/request_utils.py
@@ -36,7 +36,7 @@ def process_error(error: Response,
         try:
             err = error.json()
             msg = err.get(
-                "error", err.get("error_description", f"{error.reason}"))
+                "error_message", err.get("error_description", f"{error.reason}"))
         except simplejson.errors.JSONDecodeError as _jde:
             msg = message
         return {
diff --git a/gn2/wqflask/oauth2/resources.py b/gn2/wqflask/oauth2/resources.py
index 32efbd2a..7ea7fe38 100644
--- a/gn2/wqflask/oauth2/resources.py
+++ b/gn2/wqflask/oauth2/resources.py
@@ -1,17 +1,25 @@
-import uuid
+from uuid import UUID
 
 from flask import (
     flash, request, url_for, redirect, Response, Blueprint)
 
 from . import client
-from .ui import render_ui
+from . import session
+from .ui import render_ui as _render_ui
 from .checks import require_oauth2
 from .client import oauth2_get, oauth2_post
-from .request_utils import (
-    flash_error, flash_success, request_error, process_error)
+from .request_utils import (flash_error,
+                            flash_success,
+                            request_error,
+                            process_error,
+                            with_flash_error,
+                            with_flash_success)
 
 resources = Blueprint("resource", __name__)
 
+def render_ui(template, **kwargs):
+    return _render_ui(template, uipages="resources", **kwargs)
+
 @resources.route("/", methods=["GET"])
 @require_oauth2
 def user_resources():
@@ -51,7 +59,7 @@ def create_resource():
         flash("Resource created successfully", "alert-success")
         return redirect(url_for("oauth2.resource.user_resources"))
     return oauth2_post(
-        "auth/resource/create", data=request.form).either(
+        "auth/resource/create", json=dict(request.form)).either(
             __perr__, __psuc__)
 
 def __compute_page__(submit, current_page):
@@ -59,47 +67,48 @@ def __compute_page__(submit, current_page):
         return current_page + 1
     return (current_page - 1) or 1
 
-@resources.route("/view/<uuid:resource_id>", methods=["GET"])
+@resources.route("/<uuid:resource_id>/view", methods=["GET"])
 @require_oauth2
-def view_resource(resource_id: uuid.UUID):
+def view_resource(resource_id: UUID):
     """View the given resource."""
     page = __compute_page__(request.args.get("submit"),
                             int(request.args.get("page", "1"), base=10))
     count_per_page = int(request.args.get("count_per_page", "100"), base=10)
     def __users_success__(
-            resource, unlinked_data, users_n_roles, this_user, group_roles,
+            resource, unlinked_data, users_n_roles, this_user, resource_roles,
             users):
         return render_ui(
             "oauth2/view-resource.html", resource=resource,
             unlinked_data=unlinked_data, users_n_roles=users_n_roles,
-            this_user=this_user, group_roles=group_roles, users=users,
+            this_user=this_user, resource_roles=resource_roles, users=users,
             page=page, count_per_page=count_per_page)
 
-    def __group_roles_success__(
-            resource, unlinked_data, users_n_roles, this_user, group_roles):
+    def __resource_roles_success__(
+            resource, unlinked_data, users_n_roles, this_user, resource_roles):
         return oauth2_get("auth/user/list").either(
             lambda err: render_ui(
                 "oauth2/view-resource.html", resource=resource,
                 unlinked_data=unlinked_data, users_n_roles=users_n_roles,
-                this_user=this_user, group_roles=group_roles,
+                this_user=this_user, resource_roles=resource_roles,
                 users_error=process_error(err), count_per_page=count_per_page),
             lambda users: __users_success__(
-                resource, unlinked_data, users_n_roles, this_user, group_roles,
+                resource, unlinked_data, users_n_roles, this_user, resource_roles,
                 users))
 
     def __this_user_success__(resource, unlinked_data, users_n_roles, this_user):
-        return oauth2_get("auth/group/roles").either(
+        return oauth2_get(f"auth/resource/{resource_id}/roles").either(
             lambda err: render_ui(
-                "oauth2/view-resources.html", resource=resource,
+                "oauth2/view-resource.html", resource=resource,
                 unlinked_data=unlinked_data, users_n_roles=users_n_roles,
-                this_user=this_user, group_roles_error=process_error(err)),
-            lambda groles: __group_roles_success__(
-                resource, unlinked_data, users_n_roles, this_user, groles))
+                this_user=this_user, resource_roles_error=process_error(err),
+                count_per_page=count_per_page),
+            lambda rroles: __resource_roles_success__(
+                resource, unlinked_data, users_n_roles, this_user, rroles))
 
     def __users_n_roles_success__(resource, unlinked_data, users_n_roles):
         return oauth2_get("auth/user/").either(
             lambda err: render_ui(
-                "oauth2/view-resources.html",
+                "oauth2/view-resource.html",
                 this_user_error=process_error(err)),
             lambda usr_dets: __this_user_success__(
                 resource, unlinked_data, users_n_roles, usr_dets))
@@ -120,8 +129,10 @@ def view_resource(resource_id: uuid.UUID):
         dataset_type = resource["resource_category"]["resource_category_key"]
         return oauth2_get(f"auth/group/{dataset_type}/unlinked-data").either(
             lambda err: render_ui(
-                "oauth2/view-resource.html", resource=resource,
-                unlinked_error=process_error(err)),
+                "oauth2/view-resource.html",
+                resource=resource,
+                unlinked_error=process_error(err),
+                count_per_page=count_per_page),
             lambda unlinked: __unlinked_success__(resource, unlinked))
 
     def __fetch_resource_data__(resource):
@@ -164,7 +175,7 @@ def link_data_to_resource():
             flash(f"Data linked to resource successfully", "alert-success")
             return redirect(url_for(
                 "oauth2.resource.view_resource", resource_id=resource_id))
-        return oauth2_post("auth/resource/data/link", data=dict(form)).either(
+        return oauth2_post("auth/resource/data/link", json=dict(form)).either(
             __error__,
             __success__)
     except AssertionError as aserr:
@@ -193,7 +204,7 @@ def unlink_data_from_resource():
             return redirect(url_for(
                 "oauth2.resource.view_resource", resource_id=resource_id))
         return oauth2_post(
-            "auth/resource/data/unlink", data=dict(form)).either(
+            "auth/resource/data/unlink", json=dict(form)).either(
             __error__, __success__)
     except AssertionError as aserr:
         flash(aserr.args[0], "alert-danger")
@@ -202,12 +213,12 @@ def unlink_data_from_resource():
 
 @resources.route("<uuid:resource_id>/user/assign", methods=["POST"])
 @require_oauth2
-def assign_role(resource_id: uuid.UUID) -> Response:
+def assign_role(resource_id: UUID) -> Response:
     form = request.form
-    group_role_id = form.get("group_role_id", "")
+    role_id = form.get("role_id", "")
     user_email = form.get("user_email", "")
     try:
-        assert bool(group_role_id), "The role must be provided."
+        assert bool(role_id), "The role must be provided."
         assert bool(user_email), "The user email must be provided."
 
         def __assign_error__(error):
@@ -223,22 +234,22 @@ def assign_role(resource_id: uuid.UUID) -> Response:
 
         return oauth2_post(
             f"auth/resource/{resource_id}/user/assign",
-            data={
-                "group_role_id": group_role_id,
+            json={
+                "role_id": role_id,
                 "user_email": user_email
             }).either(__assign_error__, __assign_success__)
     except AssertionError as aserr:
         flash(aserr.args[0], "alert-danger")
-        return redirect(url_for("oauth2.resources.view_resource", resource_id=resource_id))
+        return redirect(url_for("oauth2.resource.view_resource", resource_id=resource_id))
 
 @resources.route("<uuid:resource_id>/user/unassign", methods=["POST"])
 @require_oauth2
-def unassign_role(resource_id: uuid.UUID) -> Response:
+def unassign_role(resource_id: UUID) -> Response:
     form = request.form
-    group_role_id = form.get("group_role_id", "")
+    role_id = form.get("role_id", "")
     user_id = form.get("user_id", "")
     try:
-        assert bool(group_role_id), "The role must be provided."
+        assert bool(role_id), "The role must be provided."
         assert bool(user_id), "The user id must be provided."
 
         def __unassign_error__(error):
@@ -254,17 +265,17 @@ def unassign_role(resource_id: uuid.UUID) -> Response:
 
         return oauth2_post(
             f"auth/resource/{resource_id}/user/unassign",
-            data={
-                "group_role_id": group_role_id,
+            json={
+                "role_id": role_id,
                 "user_id": user_id
             }).either(__unassign_error__, __unassign_success__)
     except AssertionError as aserr:
         flash(aserr.args[0], "alert-danger")
-        return redirect(url_for("oauth2.resources.view_resource", resource_id=resource_id))
+        return redirect(url_for("oauth2.resource.view_resource", resource_id=resource_id))
 
 @resources.route("/toggle/<uuid:resource_id>", methods=["POST"])
 @require_oauth2
-def toggle_public(resource_id: uuid.UUID):
+def toggle_public(resource_id: UUID):
     """Toggle the given resource's public status."""
     def __handle_error__(err):
         flash_error(process_error(err))
@@ -277,18 +288,169 @@ def toggle_public(resource_id: uuid.UUID):
             "oauth2.resource.view_resource", resource_id=resource_id))
 
     return oauth2_post(
-        f"auth/resource/{resource_id}/toggle-public", data={}).either(
+        f"auth/resource/{resource_id}/toggle-public").either(
             lambda err: __handle_error__(err),
             lambda suc: __handle_success__(suc))
 
 @resources.route("/edit/<uuid:resource_id>", methods=["GET"])
 @require_oauth2
-def edit_resource(resource_id: uuid.UUID):
+def edit_resource(resource_id: UUID):
     """Edit the given resource."""
     return "WOULD Edit THE GIVEN RESOURCE'S DETAILS"
 
 @resources.route("/delete/<uuid:resource_id>", methods=["GET"])
 @require_oauth2
-def delete_resource(resource_id: uuid.UUID):
+def delete_resource(resource_id: UUID):
     """Delete the given resource."""
     return "WOULD DELETE THE GIVEN RESOURCE"
+
+@resources.route("/<uuid:resource_id>/roles/<uuid:role_id>", methods=["GET"])
+@require_oauth2
+def view_resource_role(resource_id: UUID, role_id: UUID):
+    """View resource role page."""
+    def __render_template__(**kwargs):
+        return render_ui("oauth2/view-resource-role.html", **kwargs)
+
+    def __fetch_users__(resource, role, unassigned_privileges):
+        return oauth2_get(
+            f"auth/resource/{resource_id}/role/{role_id}/users").either(
+            lambda error: __render_template__(
+                resource=resource,
+                role=role,
+                unassigned_privileges=unassigned_privileges,
+                user_error=process_error(error)),
+            lambda users: __render_template__(
+                resource=resource,
+                role=role,
+                unassigned_privileges=unassigned_privileges,
+                users=users))
+
+    def __fetch_all_roles__(resource, role):
+        return oauth2_get(f"auth/resource/{resource_id}/roles").either(
+            lambda error: __render_template__(
+                all_roles_error=process_error(error)),
+            lambda all_roles: __fetch_users__(
+                resource=resource,
+                role=role,
+                unassigned_privileges=[
+                    priv for role in all_roles
+                    for priv in role["privileges"]
+                    if priv not in role["privileges"]
+                ]))
+
+    def __fetch_resource_role__(resource):
+        return oauth2_get(
+        f"auth/resource/{resource_id}/role/{role_id}").either(
+            lambda error: __render_template__(
+                resource=resource,
+                role_id=role_id,
+                role_error=process_error(error)),
+            lambda role: __fetch_all_roles__(resource, role))
+
+    return oauth2_get(
+        f"auth/resource/view/{resource_id}").either(
+            lambda error: __render_template__(
+                resource_error=process_error(error)),
+            lambda resource: __fetch_resource_role__(resource=resource))
+
+@resources.route("/<uuid:resource_id>/roles/<uuid:role_id>/unassign-privilege",
+                 methods=["GET", "POST"])
+@require_oauth2
+def unassign_privilege_from_resource_role(resource_id: UUID, role_id: UUID):
+    """Remove a privilege from a resource role."""
+    form = request.form
+    returnto = redirect(url_for("oauth2.resource.view_resource_role",
+                                resource_id=resource_id,
+                                role_id=role_id))
+    privilege_id = (request.args.get("privilege_id")
+                    or form.get("privilege_id"))
+    if not privilege_id:
+        flash("You need to specify a privilege to unassign.", "alert-danger")
+        return returnto
+
+    if request.method=="POST" and form.get("confirm") == "Unassign":
+        return oauth2_post(
+            f"auth/resource/{resource_id}/role/{role_id}/unassign-privilege",
+            json={
+                "privilege_id": form["privilege_id"]
+            }).either(with_flash_error(returnto), with_flash_success(returnto))
+
+    if form.get("confirm") == "Cancel":
+        flash("Cancelled the operation to unassign the privilege.",
+              "alert-info")
+        return returnto
+
+    def __fetch_privilege__(resource, role):
+        return oauth2_get(
+            f"auth/privileges/{privilege_id}/view").either(
+                with_flash_error(returnto),
+                lambda privilege: render_ui(
+                    "oauth2/confirm-resource-role-unassign-privilege.html",
+                    resource=resource,
+                    role=role,
+                    privilege=privilege))
+
+    def __fetch_resource_role__(resource):
+        return oauth2_get(
+            f"auth/resource/{resource_id}/role/{role_id}").either(
+                with_flash_error(returnto),
+                lambda role: __fetch_privilege__(resource, role))
+
+    return oauth2_get(
+        f"auth/resource/view/{resource_id}").either(
+            with_flash_error(returnto),
+            __fetch_resource_role__)
+
+
+@resources.route("/<uuid:resource_id>/roles/create-role",
+                 methods=["GET", "POST"])
+@require_oauth2
+def create_resource_role(resource_id: UUID):
+    """Create new role for the resource."""
+    def __render__(**kwargs):
+        return render_ui("oauth2/create-role.html", **kwargs)
+
+    def __fetch_resource_roles__(resource):
+        user = session.session_info()["user"]
+        return oauth2_get(
+            f"auth/resource/{resource_id}/users/{user['user_id']}"
+            "/roles").either(
+                lambda error: {
+                    "resource": resource,
+                    "resource_role_error": process_error(error)
+                },
+                lambda roles: {"resource": resource, "roles": roles})
+
+    if request.method == "GET":
+        return oauth2_get(f"auth/resource/view/{resource_id}").map(
+            __fetch_resource_roles__).either(
+            lambda error: __render__(resource_error=error),
+            lambda kwargs: __render__(**kwargs))
+
+    formdata = request.form
+    privileges = formdata.getlist("privileges[]")
+    if not bool(privileges):
+        flash(
+            "You must provide at least one privilege for creation of the new "
+            "role.",
+            "alert-danger")
+        return redirect(url_for("oauth2.resource.create_resource_role",
+                                resource_id=resource_id))
+
+    def __handle_error__(error):
+        flash_error(process_error(error))
+        return redirect(url_for(
+            "oauth2.resource.create_resource_role", resource_id=resource_id))
+
+    def __handle_success__(success):
+        flash("Role successfully created.", "alert-success")
+        return redirect(url_for(
+            "oauth2.resource.view_resource", resource_id=resource_id))
+
+    return oauth2_post(
+        f"auth/resource/{resource_id}/roles/create",
+        json={
+            "role_name": formdata["role_name"],
+            "privileges": privileges
+        }).either(
+            __handle_error__, __handle_success__)
diff --git a/gn2/wqflask/oauth2/roles.py b/gn2/wqflask/oauth2/roles.py
index 2fe35f9b..2a21670e 100644
--- a/gn2/wqflask/oauth2/roles.py
+++ b/gn2/wqflask/oauth2/roles.py
@@ -10,31 +10,6 @@ from .request_utils import request_error, process_error
 
 roles = Blueprint("role", __name__)
 
-@roles.route("/user", methods=["GET"])
-@require_oauth2
-def user_roles():
-    def  __grerror__(roles, user_privileges, error):
-        return render_ui(
-            "oauth2/list_roles.html", roles=roles,
-            user_privileges=user_privileges,
-            group_roles_error=process_error(error))
-
-    def  __grsuccess__(roles, user_privileges, group_roles):
-        return render_ui(
-            "oauth2/list_roles.html", roles=roles,
-            user_privileges=user_privileges, group_roles=group_roles)
-
-    def __role_success__(roles):
-        uprivs = tuple(
-            privilege["privilege_id"] for role in roles
-            for privilege in role["privileges"])
-        return oauth2_get("auth/group/roles").either(
-            lambda err: __grerror__(roles, uprivs, err),
-            lambda groles: __grsuccess__(roles, uprivs, groles))
-
-    return oauth2_get("auth/system/roles").either(
-        request_error, __role_success__)
-
 @roles.route("/role/<uuid:role_id>", methods=["GET"])
 @require_oauth2
 def role(role_id: uuid.UUID):
@@ -46,54 +21,3 @@ def role(role_id: uuid.UUID):
     return oauth2_get(f"auth/role/view/{role_id}").either(
         request_error, __success__)
 
-@roles.route("/create", methods=["GET", "POST"])
-@require_oauth2
-def create_role():
-    """Create a new role."""
-    def __roles_error__(error):
-        return render_ui(
-            "oauth2/create-role.html", roles_error=process_error(error))
-
-    def __gprivs_error__(roles, error):
-        return render_ui(
-            "oauth2/create-role.html", roles=roles,
-            group_privileges_error=process_error(error))
-
-    def __success__(roles, gprivs):
-        uprivs = tuple(
-            privilege["privilege_id"] for role in roles
-            for privilege in role["privileges"])
-        return render_ui(
-            "oauth2/create-role.html", roles=roles, user_privileges=uprivs,
-            group_privileges=gprivs,
-            prev_role_name=request.args.get("role_name"))
-
-    def __fetch_gprivs__(roles):
-        return oauth2_get("auth/group/privileges").either(
-            lambda err: __gprivs_error__(roles, err),
-            lambda gprivs: __success__(roles, gprivs))
-
-    if request.method == "GET":
-        return oauth2_get("auth/user/roles").either(
-            __roles_error__, __fetch_gprivs__)
-
-    form = request.form
-    role_name = form.get("role_name")
-    privileges = form.getlist("privileges[]")
-    if len(privileges) == 0:
-        flash("You must assign at least one privilege to the role",
-              "alert-danger")
-        return redirect(url_for(
-            "oauth2.role.create_role", role_name=role_name))
-    def __create_error__(error):
-        err = process_error(error)
-        flash(f"{err['error']}: {err['error_description']}",
-              "alert-danger")
-        return redirect(url_for("oauth2.role.create_role"))
-    def __create_success__(*args):
-        flash("Role created successfully.", "alert-success")
-        return redirect(url_for("oauth2.role.user_roles"))
-    return oauth2_post(
-        "auth/group/role/create",data={
-            "role_name": role_name, "privileges[]": privileges}).either(
-        __create_error__,__create_success__)
diff --git a/gn2/wqflask/oauth2/session.py b/gn2/wqflask/oauth2/session.py
index 2ef534e2..b91534b0 100644
--- a/gn2/wqflask/oauth2/session.py
+++ b/gn2/wqflask/oauth2/session.py
@@ -22,6 +22,8 @@ class SessionInfo(TypedDict):
     user_agent: str
     ip_addr: str
     masquerade: Optional[UserDetails]
+    refreshing_token: bool
+    auth_server_jwks: Optional[dict[str, Any]]
 
 __SESSION_KEY__ = "GN::2::session_info" # Do not use this outside this module!!
 
@@ -61,16 +63,10 @@ def session_info() -> SessionInfo:
             "user_agent": request.headers.get("User-Agent"),
             "ip_addr": request.environ.get("HTTP_X_FORWARDED_FOR",
                                            request.remote_addr),
-            "masquerading": None
+            "masquerading": None,
+            "token_refreshing": False
         }))
 
-def expired():
-    the_session = session_info()
-    def __expired__(token):
-        return datetime.now() > datetime.fromtimestamp(token["expires_at"])
-    return the_session["user"]["token"].either(
-        lambda left: False,
-        __expired__)
 
 def set_user_token(token: str) -> SessionInfo:
     """Set the user's token."""
@@ -109,3 +105,16 @@ def unset_masquerading():
         "user": the_session["masquerading"],
         "masquerading": None
     })
+
+
+def toggle_token_refreshing():
+    """Toggle the state of the token_refreshing variable."""
+    _session = session_info()
+    return save_session_info({
+        **_session,
+        "token_refreshing": not _session.get("token_refreshing", False)})
+
+
+def is_token_refreshing():
+    """Returns whether the token is being refreshed or not."""
+    return session_info().get("token_refreshing", False)
diff --git a/gn2/wqflask/oauth2/toplevel.py b/gn2/wqflask/oauth2/toplevel.py
index 23965cc1..24d60311 100644
--- a/gn2/wqflask/oauth2/toplevel.py
+++ b/gn2/wqflask/oauth2/toplevel.py
@@ -3,11 +3,17 @@ import uuid
 import datetime
 from urllib.parse import urljoin, urlparse, urlunparse
 
-from authlib.jose import jwt
-from flask import (
-    flash, request, Blueprint, url_for, redirect, render_template,
-    current_app as app)
+from authlib.jose import jwt, KeySet
+from flask import (flash,
+                   request,
+                   url_for,
+                   jsonify,
+                   redirect,
+                   Blueprint,
+                   render_template,
+                   current_app as app)
 
+from . import jwks
 from . import session
 from .checks import require_oauth2
 from .request_utils import user_details, process_error
@@ -29,7 +35,9 @@ def authorisation_code():
     code = request.args.get("code", "")
     if bool(code):
         base_url = urlparse(request.base_url, scheme=request.scheme)
-        jwtkey = app.config["SSL_PRIVATE_KEY"]
+        jwtkey = jwks.newest_jwk_with_rotation(
+            jwks.jwks_directory(app, "GN2_SECRETS"),
+            int(app.config["JWKS_ROTATION_AGE_DAYS"]))
         issued = datetime.datetime.now()
         request_data = {
             "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
@@ -47,7 +55,7 @@ def authorisation_code():
                     "iss": str(oauth2_clientid()),
                     "sub": request.args["user_id"],
                     "aud": urljoin(authserver_uri(), "auth/token"),
-                    "exp": (issued + datetime.timedelta(minutes=5)),
+                    "exp": (issued + datetime.timedelta(minutes=5)).timestamp(),
                     "nbf": int(issued.timestamp()),
                     "iat": int(issued.timestamp()),
                     "jti": str(uuid.uuid4())},
@@ -75,8 +83,17 @@ def authorisation_code():
             })
             return redirect("/")
 
-        return no_token_post(
-            "auth/token", data=request_data).either(
-                lambda err: __error__(process_error(err)), __success__)
+        return no_token_post("auth/token", json=request_data).either(
+            lambda err: __error__(process_error(err)), __success__)
     flash("AuthorisationError: No code was provided.", "alert-danger")
     return redirect("/")
+
+
+@toplevel.route("/public-jwks", methods=["GET"])
+def public_jwks():
+    """Provide endpoint that returns the public keys."""
+    return jsonify({
+        "documentation": "The keys are listed in order of creation.",
+        "jwks": KeySet(jwks.list_jwks(
+            jwks.jwks_directory(app, "GN2_SECRETS"))).as_dict().get("keys")
+    })
diff --git a/gn2/wqflask/oauth2/ui.py b/gn2/wqflask/oauth2/ui.py
index cf2e9af7..04095420 100644
--- a/gn2/wqflask/oauth2/ui.py
+++ b/gn2/wqflask/oauth2/ui.py
@@ -1,22 +1,17 @@
 """UI utilities"""
-from flask import session, render_template
+from flask import render_template
 
 from .client import oauth2_get
-from .client import user_logged_in
-from .request_utils import process_error
 
 def render_ui(templatepath: str, **kwargs):
     """Handle repetitive UI rendering stuff."""
-    roles = kwargs.get("roles", tuple()) # Get roles if already provided
-    if user_logged_in() and not bool(roles): # If not, try fetching them
-        roles_results = oauth2_get("auth/system/roles").either(
-            lambda err: {"roles_error": process_error(err)},
-            lambda roles: {"roles": roles})
-        kwargs = {**kwargs, **roles_results}
+    roles = kwargs.get("roles", tuple()) # Get roles
+    if not roles:
+        roles = oauth2_get("auth/system/roles").either(
+                lambda _err: roles, lambda auth_roles: auth_roles)
     user_privileges = tuple(
-        privilege["privilege_id"] for role in roles
-        for privilege in role["privileges"])
+        privilege["privilege_id"] for role in roles for privilege in role["privileges"])
     kwargs = {
-        **kwargs, "roles": roles, "user_privileges": user_privileges
+            **kwargs, "roles": roles, "user_privileges": user_privileges,
     }
     return render_template(templatepath, **kwargs)
diff --git a/gn2/wqflask/oauth2/users.py b/gn2/wqflask/oauth2/users.py
index 8a935170..7d9186ab 100644
--- a/gn2/wqflask/oauth2/users.py
+++ b/gn2/wqflask/oauth2/users.py
@@ -1,6 +1,6 @@
 import requests
 from uuid import UUID
-from urllib.parse import urljoin
+from urllib.parse import urljoin, urlparse
 
 from authlib.integrations.base_client.errors import OAuthError
 from flask import (
@@ -11,10 +11,16 @@ from . import client
 from . import session
 from .ui import render_ui
 from .checks import require_oauth2
-from .client import (oauth2_get, oauth2_post, oauth2_client,
-                     authserver_uri, user_logged_in)
-from .request_utils import (
-    user_details, request_error, process_error, with_flash_error)
+from .client import (oauth2_get,
+                     oauth2_post,
+                     oauth2_client,
+                     authserver_uri,
+                     user_logged_in)
+from .request_utils import (user_details,
+                            request_error,
+                            process_error,
+                            with_flash_error,
+                            authserver_authorise_uri)
 
 users = Blueprint("user", __name__)
 
@@ -61,7 +67,7 @@ def request_add_to_group() -> Response:
         return redirect(url_for("oauth2.user.user_profile"))
 
     return oauth2_post(f"auth/group/requests/join/{group_id}",
-                       data=form).either(__error__, __success__)
+                       json=form).either(__error__, __success__)
 
 
 @users.route("/logout", methods=["GET", "POST"])
@@ -84,7 +90,8 @@ def logout():
             f"{the_session['masquerading']['name']} "
             f"({the_session['masquerading']['email']})",
             "alert-success")
-        return redirect("/")
+
+    return redirect("/")
 
 @users.route("/register", methods=["GET", "POST"])
 def register_user():
@@ -101,11 +108,14 @@ def register_user():
     form = request.form
     response = requests.post(
         urljoin(authserver_uri(), "auth/user/register"),
-        data = {
+        json = {
             "user_name": form.get("user_name"),
             "email": form.get("email_address"),
             "password": form.get("password"),
-            "confirm_password": form.get("confirm_password")})
+            "confirm_password": form.get("confirm_password"),
+            **dict(
+                item.split("=") for item in
+                urlparse(authserver_authorise_uri()).query.split("&"))})
     results = response.json()
     if "error" in results:
         error_messages = tuple(
diff --git a/gn2/wqflask/parser.py b/gn2/wqflask/parser.py
index ddf48d90..fdd5d164 100644
--- a/gn2/wqflask/parser.py
+++ b/gn2/wqflask/parser.py
@@ -30,7 +30,7 @@ def parse(pstring):
     pstring = re.split(r"""(?:(\w+\s*=\s*[\('"\[][^)'"]*[\)\]'"])  |  # LRS=(1 2 3), cisLRS=[4 5 6], etc
                        (\w+\s*[=:\>\<][\w\*]+)  |  # wiki=bar, GO:foobar, etc
                        (".*?") | ('.*?') | # terms in quotes, i.e. "brain weight"
-                       ([\w\*\?\-]+))  # shh, brain, etc """, pstring,
+                       ([\w\*\?\-]+\@\.))  # shh, brain, etc """, pstring,
                        flags=re.VERBOSE)
 
     pstring = [item.strip() for item in pstring if item and item.strip()]
diff --git a/gn2/wqflask/requests.py b/gn2/wqflask/requests.py
index 43c8001f..182201a5 100644
--- a/gn2/wqflask/requests.py
+++ b/gn2/wqflask/requests.py
@@ -14,3 +14,13 @@ def get(url, params=None, **kwargs) -> Either:
 def post(url, data=None, json=None, **kwargs) -> Either:
     """Wrap requests post method with Either monad"""
     return __wrap_response__(requests.post(url, data=data, json=json, **kwargs))
+
+
+def put(url, data=None, json=None, **kwargs) -> Either:
+    """Wrap requests put  method with Either monad"""
+    return __wrap_response__(requests.put(url, data=data, json=json, **kwargs))
+
+
+def delete(url,  **kwargs) -> Either:
+    """Wrap requests delete  method with Either monad"""
+    return __wrap_response__(requests.delete(url,  **kwargs))
diff --git a/gn2/wqflask/search_results.py b/gn2/wqflask/search_results.py
index b0f08463..1d89e52a 100644
--- a/gn2/wqflask/search_results.py
+++ b/gn2/wqflask/search_results.py
@@ -2,12 +2,18 @@ import uuid
 from math import *
 import requests
 import unicodedata
+from urllib.parse import urlencode, urljoin
 import re
 
 import json
 
+from pymonad.maybe import Just, Maybe
+from pymonad.tools import curry
+
 from flask import g
 
+from gn3.monads import MonadicDict
+
 from gn2.base.data_set import create_dataset
 from gn2.base.webqtlConfig import PUBMEDLINK_URL
 from gn2.wqflask import parser
@@ -15,11 +21,12 @@ from gn2.wqflask import do_search
 
 from gn2.wqflask.database import database_connection
 
-from gn2.utility import hmac
 from gn2.utility.authentication_tools import check_resource_availability
-from gn2.utility.tools import get_setting, GN2_BASE_URL
+from gn2.utility.hmac import hmac_creation
+from gn2.utility.tools import get_setting, GN2_BASE_URL, GN3_LOCAL_URL
 from gn2.utility.type_checking import is_str
 
+MAX_SEARCH_RESULTS = 50000 # Max number of search results, passed to Xapian search (this needs to match the value in GN3!)
 
 class SearchResultPage:
     #maxReturn = 3000
@@ -36,6 +43,7 @@ class SearchResultPage:
 
         self.uc_id = uuid.uuid4()
         self.go_term = None
+        self.search_type = "sql" # Assume it's an SQL search by default, since all searches will work with SQL
 
         if kw['search_terms_or']:
             self.and_or = "or"
@@ -89,7 +97,6 @@ class SearchResultPage:
 
         """
         trait_list = []
-        json_trait_list = []
 
         # result_set represents the results for each search term; a search of
         # "shh grin2b" would have two sets of results, one for each term
@@ -105,97 +112,137 @@ class SearchResultPage:
             if not result:
                 continue
 
-            trait_dict = {}
-            trait_dict['index'] = index + 1
-
-            trait_dict['dataset'] = self.dataset.name
-            if self.dataset.type == "ProbeSet":
-                trait_dict['display_name'] = result[2]
-                trait_dict['hmac'] = hmac.data_hmac('{}:{}'.format(trait_dict['display_name'], trait_dict['dataset']))
-                trait_dict['symbol'] = "N/A" if result[3] is None else result[3].strip()
-                description_text = ""
-                if result[4] is not None and str(result[4]) != "":
-                    description_text = unicodedata.normalize("NFKD", result[4].decode('latin1'))
-
-                target_string = result[5].decode('utf-8') if result[5] else ""
-                description_display = description_text if target_string is None or str(target_string) == "" else description_text + "; " + str(target_string).strip()
-                trait_dict['description'] = description_display
-
-                trait_dict['location'] = "N/A"
-                if (result[6] is not None) and (result[6] != "") and (result[6] != "Un") and (result[7] is not None) and (result[7] != 0):
-                    trait_dict['location'] = f"Chr{result[6]}: {float(result[7]):.6f}"
-
-                trait_dict['mean'] = "N/A" if result[8] is None or result[8] == "" else f"{result[8]:.3f}"
-                trait_dict['additive'] = "N/A" if result[12] is None or result[12] == "" else f"{result[12]:.3f}"
-                trait_dict['lod_score'] = "N/A" if result[9] is None or result[9] == "" else f"{float(result[9]) / 4.61:.1f}"
-                trait_dict['lrs_location'] = "N/A" if result[13] is None or result[13] == "" or result[14] is None else f"Chr{result[13]}: {float(result[14]):.6f}"
-            elif self.dataset.type == "Geno":
-                trait_dict['display_name'] = str(result[0])
-                trait_dict['hmac'] = hmac.data_hmac('{}:{}'.format(trait_dict['display_name'], trait_dict['dataset']))
-                trait_dict['location'] = "N/A"
-                if (result[4] != "NULL" and result[4] != "") and (result[5] != 0):
-                    trait_dict['location'] = f"Chr{result[4]}: {float(result[5]):.6f}"
-            elif self.dataset.type == "Publish":
-                # Check permissions on a trait-by-trait basis for phenotype traits
-                trait_dict['name'] = trait_dict['display_name'] = str(result[0])
-                trait_dict['hmac'] = hmac.data_hmac('{}:{}'.format(trait_dict['name'], trait_dict['dataset']))
-                permissions = check_resource_availability(
-                    self.dataset, g.user_session.user_id, trait_dict['display_name'])
-                if not any(x in permissions['data'] for x in ["view", "edit"]):
-                    continue
-
-                if result[10]:
-                    trait_dict['display_name'] = str(result[10]) + "_" + str(result[0])
-                trait_dict['description'] = "N/A"
-                trait_dict['pubmed_id'] = "N/A"
-                trait_dict['pubmed_link'] = "N/A"
-                trait_dict['pubmed_text'] = "N/A"
-                trait_dict['mean'] = "N/A"
-                trait_dict['additive'] = "N/A"
-                pre_pub_description = "N/A" if result[1] is None else result[1].strip()
-                post_pub_description = "N/A" if result[2] is None else result[2].strip()
-                if result[5] != "NULL" and result[5] != None:
-                    trait_dict['pubmed_id'] = result[5]
-                    trait_dict['pubmed_link'] = PUBMEDLINK_URL % trait_dict['pubmed_id']
-                    trait_dict['description'] = post_pub_description
-                else:
-                    trait_dict['description'] = pre_pub_description
+            if self.search_type == "xapian":
+                # These four lines are borrowed from gsearch.py; probably need to put them somewhere else to avoid duplicated code
+                chr_mb = curry(2, lambda chr, mb: f"Chr{chr}: {mb:.6f}")
+                format3f = lambda x: f"{x:.3f}"
+                hmac = curry(3, lambda trait_name, dataset, data_hmac: f"{trait_name}:{dataset}:{data_hmac}")
+                convert_lod = lambda x: x / 4.61
+
+                trait = MonadicDict(result)
+                trait["index"] = Just(index)
+                trait["display_name"] = trait["name"]
+                trait["location"] = (Maybe.apply(chr_mb)
+                                        .to_arguments(trait.pop("chr"), trait.pop("mb")))
+                trait["lod_score"] = trait.pop("lrs").map(convert_lod).map(format3f)
+                trait["additive"] = trait["additive"].map(format3f)
+                trait["mean"] = trait["mean"].map(format3f)
+                trait["lrs_location"] = (Maybe.apply(chr_mb)
+                                        .to_arguments(trait.pop("geno_chr"), trait.pop("geno_mb")))
+
+                description_text = trait['description'].maybe("N/A", lambda a: a)
+                if len(description_text) > 200:
+                    description_text = description_text[:200] + "..."
+                trait['description'] = Just(description_text)
+
+                if self.dataset.type == "ProbeSet":
+                    trait["hmac"] = (Maybe.apply(hmac)
+                                    .to_arguments(trait['name'], trait['dataset'], Just(hmac_creation(f"{trait['name']}:{trait['dataset']}"))))
+                elif self.dataset.type == "Publish":
+                    inbredsetcode = trait.pop("inbredsetcode")
+                    if inbredsetcode.map(len) == Just(3):
+                        trait["display_name"] = (Maybe.apply(
+                            curry(2, lambda inbredsetcode, name: f"{inbredsetcode}_{name}"))
+                                                .to_arguments(inbredsetcode, trait["name"]))
+
+                    trait["hmac"] = (Maybe.apply(hmac)
+                                    .to_arguments(trait['name'], trait['dataset'], Just(hmac_creation(f"{trait['name']}:{trait['dataset']}"))))
+                    trait["authors"] = trait["authors_display"] = (trait.pop("authors").map(
+                        lambda authors:
+                        ", ".join(authors[:2] + ["et al."] if len(authors) >=2 else authors)))
+                    trait["pubmed_text"] = trait["year"].map(str)
+                trait_list.append(trait.data)
+            else:
+                trait_dict = {}
+                trait_dict['index'] = index + 1
+                trait_dict['dataset'] = self.dataset.name
+                if self.dataset.type == "ProbeSet":
+                    trait_dict['display_name'] = result[2]
+                    trait_dict['hmac'] = f"{trait_dict['display_name']}:{trait_dict['dataset']}:{hmac_creation('{}:{}'.format(trait_dict['display_name'], trait_dict['dataset']))}"
+                    trait_dict['symbol'] = "N/A" if result[3] is None else result[3].strip()
+                    description_text = ""
+                    if result[4] is not None and str(result[4]) != "":
+                        description_text = unicodedata.normalize("NFKD", result[4].decode('latin1'))
+
+                    target_string = result[5].decode('utf-8') if result[5] else ""
+                    description_display = description_text if target_string is None or str(target_string) == "" else description_text + "; " + str(target_string).strip()
+                    trait_dict['description'] = description_display
+
+                    trait_dict['location'] = "N/A"
+                    if (result[6] is not None) and (result[6] != "") and (result[6] != "Un") and (result[7] is not None) and (result[7] != 0):
+                        trait_dict['location'] = f"Chr{result[6]}: {float(result[7]):.6f}"
+
+                    trait_dict['mean'] = "N/A" if result[8] is None or result[8] == "" else f"{result[8]:.3f}"
+                    trait_dict['additive'] = "N/A" if result[12] is None or result[12] == "" else f"{result[12]:.3f}"
+                    trait_dict['lod_score'] = "N/A" if result[9] is None or result[9] == "" else f"{float(result[9]) / 4.61:.1f}"
+                    trait_dict['lrs_location'] = "N/A" if result[13] is None or result[13] == "" or result[14] is None else f"Chr{result[13]}: {float(result[14]):.6f}"
+                elif self.dataset.type == "Geno":
+                    trait_dict['display_name'] = str(result[0])
+                    trait_dict['hmac'] = f"{trait_dict['display_name']}:{trait_dict['dataset']}:{hmac_creation('{}:{}'.format(trait_dict['display_name'], trait_dict['dataset']))}"
+                    trait_dict['location'] = "N/A"
+                    if (result[4] != "NULL" and result[4] != "") and (result[5] != 0):
+                        trait_dict['location'] = f"Chr{result[4]}: {float(result[5]):.6f}"
+                elif self.dataset.type == "Publish":
+                    # Check permissions on a trait-by-trait basis for phenotype traits
+                    trait_dict['name'] = trait_dict['display_name'] = str(result[0])
+                    trait_dict['hmac'] = f"{trait_dict['display_name']}:{trait_dict['dataset']}:{hmac_creation('{}:{}'.format(trait_dict['display_name'], trait_dict['dataset']))}"
+                    permissions = check_resource_availability(
+                        self.dataset, g.user_session.user_id, trait_dict['display_name'])
+                    if not any(x in permissions['data'] for x in ["view", "edit"]):
+                        continue
+
+                    if result[10]:
+                        trait_dict['display_name'] = str(result[10]) + "_" + str(result[0])
+                    trait_dict['description'] = "N/A"
+                    trait_dict['pubmed_id'] = "N/A"
+                    trait_dict['pubmed_link'] = "N/A"
+                    trait_dict['pubmed_text'] = "N/A"
+                    trait_dict['mean'] = "N/A"
+                    trait_dict['additive'] = "N/A"
+                    pre_pub_description = "N/A" if result[1] is None else result[1].strip()
+                    post_pub_description = "N/A" if result[2] is None else result[2].strip()
+                    if result[5] != "NULL" and result[5] != None:
+                        trait_dict['pubmed_id'] = result[5]
+                        trait_dict['pubmed_link'] = PUBMEDLINK_URL % trait_dict['pubmed_id']
+                        trait_dict['description'] = post_pub_description
+                    else:
+                        trait_dict['description'] = pre_pub_description
 
-                if result[4].isdigit():
-                    trait_dict['pubmed_text'] = result[4]
+                    if result[4].isdigit():
+                        trait_dict['pubmed_text'] = result[4]
 
-                trait_dict['authors'] = result[3]
-                trait_dict['authors_display'] = trait_dict['authors']
-                author_list = trait_dict['authors'].split(",")
-                if len(author_list) >= 2:
-                    trait_dict['authors_display'] = (",").join(author_list[:2]) + ", et al."
+                    trait_dict['authors'] = result[3]
+                    trait_dict['authors_display'] = trait_dict['authors']
+                    author_list = trait_dict['authors'].split(",")
+                    if len(author_list) >= 2:
+                        trait_dict['authors_display'] = (",").join(author_list[:2]) + ", et al."
 
-                if result[6] != "" and result[6] != None:
-                    trait_dict['mean'] = f"{result[6]:.3f}"
+                    if result[6] != "" and result[6] != None:
+                        trait_dict['mean'] = f"{result[6]:.3f}"
 
-                try:
-                    trait_dict['lod_score'] = f"{float(result[7]) / 4.61:.1f}"
-                except:
-                    trait_dict['lod_score'] = "N/A"
+                    try:
+                        trait_dict['lod_score'] = f"{float(result[7]) / 4.61:.1f}"
+                    except:
+                        trait_dict['lod_score'] = "N/A"
 
-                try:
-                    trait_dict['lrs_location'] = f"Chr{result[11]}: {float(result[12]):.6f}"
-                except:
-                    trait_dict['lrs_location'] = "N/A"
+                    try:
+                        trait_dict['lrs_location'] = f"Chr{result[11]}: {float(result[12]):.6f}"
+                    except:
+                        trait_dict['lrs_location'] = "N/A"
 
-                trait_dict['additive'] = "N/A" if not result[8] else f"{result[8]:.3f}"
+                    trait_dict['additive'] = "N/A" if not result[8] else f"{result[8]:.3f}"
 
-            trait_dict['trait_info_str'] = trait_info_str(trait_dict, self.dataset.type)
+                trait_dict['trait_info_str'] = trait_info_str(trait_dict, self.dataset.type)
 
-            # Convert any bytes in dict to a normal utf-8 string
-            for key in trait_dict.keys():
-                if isinstance(trait_dict[key], bytes):
-                    try:
-                        trait_dict[key] = trait_dict[key].decode('utf-8')
-                    except UnicodeDecodeError:
-                        trait_dict[key] = trait_dict[key].decode('latin-1')
+                # Convert any bytes in dict to a normal utf-8 string
+                for key in trait_dict.keys():
+                    if isinstance(trait_dict[key], bytes):
+                        try:
+                            trait_dict[key] = trait_dict[key].decode('utf-8')
+                        except UnicodeDecodeError:
+                            trait_dict[key] = trait_dict[key].decode('latin-1')
 
-            trait_list.append(trait_dict)
+                trait_list.append(trait_dict)
 
         if self.results:
             self.max_widths = {}
@@ -229,6 +276,42 @@ class SearchResultPage:
         """
         self.search_terms = parser.parse(self.search_terms)
 
+        # Set of terms compatible with Xapian currently (None is a search without a term)
+        xapian_terms = ["POSITION", "MEAN", "LRS", "LOD", "RIF", "WIKI"]
+
+        if all([(the_term['key'] in xapian_terms) or (not the_term['key'] and self.dataset.type != "Publish") for the_term in self.search_terms]):
+            self.search_type = "xapian"
+            self.results = requests.get(generate_xapian_request(self.dataset, self.search_terms, self.and_or)).json()
+            if not len(self.results) or 'error' in self.results:
+                self.results = []
+                self.sql_search()
+        else:
+            self.sql_search()
+
+    def get_search_ob(self, a_search):
+        search_term = a_search['search_term']
+        search_operator = a_search['separator']
+        search_type = {}
+        search_type['dataset_type'] = self.dataset.type
+        if a_search['key']:
+            search_type['key'] = a_search['key'].upper()
+        else:
+            search_type['key'] = None
+
+        search_ob = do_search.DoSearch.get_search(search_type)
+        if search_ob:
+            search_class = getattr(do_search, search_ob)
+            the_search = search_class(search_term,
+                                      search_operator,
+                                      self.dataset,
+                                      search_type['key']
+                                      )
+            return the_search
+        else:
+            return None
+
+    def sql_search(self):
+        self.search_type = "sql"
         combined_from_clause = ""
         combined_where_clause = ""
         # The same table can't be referenced twice in the from clause
@@ -313,27 +396,6 @@ class SearchResultPage:
             if the_search != None:
                 self.header_fields = the_search.header_fields
 
-    def get_search_ob(self, a_search):
-        search_term = a_search['search_term']
-        search_operator = a_search['separator']
-        search_type = {}
-        search_type['dataset_type'] = self.dataset.type
-        if a_search['key']:
-            search_type['key'] = a_search['key'].upper()
-        else:
-            search_type['key'] = None
-
-        search_ob = do_search.DoSearch.get_search(search_type)
-        if search_ob:
-            search_class = getattr(do_search, search_ob)
-            the_search = search_class(search_term,
-                                      search_operator,
-                                      self.dataset,
-                                      search_type['key']
-                                      )
-            return the_search
-        else:
-            return None
 
 def trait_info_str(trait, dataset_type):
     """Provide a string representation for given trait"""
@@ -431,3 +493,50 @@ def get_alias_terms(symbol, species):
         alias_terms.append(the_search_term)
 
     return alias_terms
+
+def generate_xapian_request(dataset, search_terms, and_or):
+    """ Generate the resquest to GN3 which queries Xapian """
+    match dataset.type:
+        case "ProbeSet":
+            search_type = "gene"
+        case "Publish":
+            search_type = "phenotype"
+        case "Geno":
+            search_type = "genotype"
+        case _: # This should never happen
+            raise ValueError(f"Dataset types should only be ProbeSet, Publish, or Geno, not '{dataset.type}'")
+
+    xapian_terms = f" {and_or.upper()} ".join([create_xapian_term(dataset, term) for term in search_terms])
+
+    return urljoin(GN3_LOCAL_URL, "/api/search?" + urlencode({"query": xapian_terms,
+                                                              "type": search_type,
+                                                              "per_page": MAX_SEARCH_RESULTS}))
+
+def create_xapian_term(dataset, term):
+    """ Create Xapian term for each search term """
+    search_term = term['search_term']
+    xapian_term = f"dataset:{dataset.name.lower()} AND "
+    match term['key']:
+        case 'MEAN':
+            return xapian_term + f"mean:{search_term[0]}..{search_term[1]}"
+        case 'POSITION':
+            return xapian_term + f"chr:{search_term[0].lower().replace('chr', '')} AND position:{int(search_term[1])*10**6}..{int(search_term[2])*10**6}"
+        case 'AUTHOR':
+            return xapian_term + f"author:{search_term[0]}"
+        case 'RIF':
+            return xapian_term + f"rif:{search_term[0]}"
+        case 'WIKI':
+            return xapian_term + f"wiki:{search_term[0]}"
+        case 'LRS':
+            xapian_term += f"peak:{search_term[0]}..{search_term[1]}"
+            if len(search_term) == 5:
+                xapian_term += f" AND peakchr:{search_term[2].lower().replace('chr', '')} AND peakmb:{float(search_term[3])}..{float(search_term[4])}"
+            return xapian_term
+        case 'LOD': # Basically just LRS search but all values are multiplied by 4.61
+            xapian_term += f"peak:{float(search_term[0]) * 4.61}..{float(search_term[1]) * 4.61}"
+            if len(search_term) == 5:
+                xapian_term += f" AND peakchr:{search_term[2].lower().replace('chr', '')}"
+                xapian_term += f" AND peakmb:{float(search_term[3])}..{float(search_term[4])}"
+            return xapian_term
+        case None:
+            return xapian_term + f"{search_term[0]}"
diff --git a/gn2/wqflask/show_trait/SampleList.py b/gn2/wqflask/show_trait/SampleList.py
index 64fc8fe6..200ff793 100644
--- a/gn2/wqflask/show_trait/SampleList.py
+++ b/gn2/wqflask/show_trait/SampleList.py
@@ -81,7 +81,8 @@ class SampleList:
                             sample.extra_attributes['36'].append(
                                 webqtlConfig.RRID_MOUSE_URL % the_rrid)
                     elif self.dataset.group.species == "rat":
-                        if len(rrid_string):
+                        # Check if it's a list just in case a parent/f1 strain also shows up in the .geno file, to avoid being added twice
+                        if len(rrid_string) and not isinstance(sample.extra_attributes['36'], list):
                             the_rrid = rrid_string.split("_")[1]
                             sample.extra_attributes['36'] = [
                                 rrid_string]
@@ -131,7 +132,8 @@ class SampleList:
                 "CaseAttribute, CaseAttributeXRefNew WHERE "
                 "CaseAttributeXRefNew.CaseAttributeId = CaseAttribute.CaseAttributeId "
                 "AND CaseAttributeXRefNew.InbredSetId = %s "
-                "ORDER BY CaseAttribute.CaseAttributeId", (str(self.dataset.group.id),)
+                "AND CaseAttribute.InbredSetId = %s "
+                "ORDER BY CaseAttribute.CaseAttributeId", (str(self.dataset.group.id),str(self.dataset.group.id))
             )
 
             self.attributes = {}
diff --git a/gn2/wqflask/static/new/css/pills.css b/gn2/wqflask/static/new/css/pills.css
new file mode 100644
index 00000000..57c84204
--- /dev/null
+++ b/gn2/wqflask/static/new/css/pills.css
@@ -0,0 +1,25 @@
+a.pill{
+    border: 1px;
+    color: #3071A9;
+    text-decoration: none;
+    border: 1px solid #3071A9;
+    border-radius: 5px;
+    padding: 0.3em;
+    box-shadow: 3px 3px #DEDEDE;
+    text-align: center;
+}
+
+a.pill:active {
+    box-shadow: 1px 1px aquamarine;
+    position: relative;
+    left: 2px;
+    top: 2px;
+}
+
+a.pill:hover {
+    text-decoration: none;
+}
+
+a.pill:focus {
+    text-decoration: none;
+}
diff --git a/gn2/wqflask/static/new/css/resource-roles.css b/gn2/wqflask/static/new/css/resource-roles.css
new file mode 100644
index 00000000..da8d60d3
--- /dev/null
+++ b/gn2/wqflask/static/new/css/resource-roles.css
@@ -0,0 +1,5 @@
+.resource_roles {
+    display: grid;
+    grid-template-columns: 1fr 1fr 1fr 1fr 1fr;
+    grid-gap: 5px;
+}
diff --git a/gn2/wqflask/static/new/javascript/auth/search_phenotypes.js b/gn2/wqflask/static/new/javascript/auth/search_phenotypes.js
index 99ecb16e..8689af75 100644
--- a/gn2/wqflask/static/new/javascript/auth/search_phenotypes.js
+++ b/gn2/wqflask/static/new/javascript/auth/search_phenotypes.js
@@ -96,8 +96,8 @@ function display_search_results(data, textStatus, jqXHR) {
  * @param {UUID}: The job id to fetch data for
  */
 function fetch_search_results(job_id, success, error=default_error_fn) {
-    host = $("#frm-search-traits").attr("data-gn-server-url");
-    endpoint = host + "auth/data/search/phenotype/" + job_id
+    endpoint = $("#frm-search-traits").attr(
+        "data-pheno-results-template").replace("<jobid>", job_id);
     $("#txt-search").prop("disabled", true);
     $.ajax(
 	endpoint,
@@ -119,7 +119,7 @@ function search_phenotypes() {
     per_page = document.getElementById("txt-per-page").value
     search_table = new TableDataSource(
 	"#tbl-phenotypes", "data-traits", search_checkbox);
-    endpoint = "/auth/data/phenotype/search"
+    endpoint = endpoint = $("#frm-search-traits").attr("data-search-endpoint");
     $.ajax(
 	endpoint,
 	{
diff --git a/gn2/wqflask/static/new/javascript/initialize_show_trait_tables.js b/gn2/wqflask/static/new/javascript/initialize_show_trait_tables.js
index 44076c17..970a49a3 100644
--- a/gn2/wqflask/static/new/javascript/initialize_show_trait_tables.js
+++ b/gn2/wqflask/static/new/javascript/initialize_show_trait_tables.js
@@ -141,7 +141,7 @@ buildColumns = function() {
     columnList.push(
       {
         'title': "<div title='" + js_data.attributes[attrKeys[i]].description + "' style='text-align: " + js_data.attributes[attrKeys[i]].alignment + "'>" + js_data.attributes[attrKeys[i]].name + "</div>",
-        'type': "natural",
+        'type': "natural-minus-na",
         'data': null,
         'targets': attrStart + i,
         'render': function(data, type, row, meta) {
diff --git a/gn2/wqflask/static/new/javascript/search_results.js b/gn2/wqflask/static/new/javascript/search_results.js
index c263ef49..c89b4ce3 100644
--- a/gn2/wqflask/static/new/javascript/search_results.js
+++ b/gn2/wqflask/static/new/javascript/search_results.js
@@ -309,7 +309,7 @@ $(function() {
       return submit_special("/loading")
   });
 
-  $("#send_to_webgestalt, #send_to_bnw, #send_to_geneweaver").on("click", function() {
+  $("#send_to_webgestalt, #send_to_bnw, #send_to_geneweaver, #send_to_genecup").on("click", function() {
       traits = getTraitsFromTable()
       $("#trait_list").val(traits)
       url = $(this).data("url")
@@ -325,53 +325,6 @@ $(function() {
   $("#export_traits").click(exportTraits);
   $("#export_collection").click(exportCollection);
 
-  let naturalAsc = $.fn.dataTableExt.oSort["natural-ci-asc"]
-  let naturalDesc = $.fn.dataTableExt.oSort["natural-ci-desc"]
-
-  let na_equivalent_vals = ["N/A", "--", ""]; //ZS: Since there are multiple values that should be treated the same as N/A
-
-  function extractInnerText(the_string){
-    var span = document.createElement('span');
-    span.innerHTML = the_string;
-    return span.textContent || span.innerText;
-  }
-
-  function sortNAs(a, b, sort_function){
-    if ( na_equivalent_vals.includes(a) && na_equivalent_vals.includes(b)) {
-      return 0;
-    }
-    if (na_equivalent_vals.includes(a)){
-      return 1
-    }
-    if (na_equivalent_vals.includes(b)) {
-      return -1;
-    }
-    return sort_function(a, b)
-  }
-
-  $.extend( $.fn.dataTableExt.oSort, {
-    "natural-minus-na-asc": function (a, b) {
-      return sortNAs(extractInnerText(a), extractInnerText(b), naturalAsc)
-    },
-    "natural-minus-na-desc": function (a, b) {
-      return sortNAs(extractInnerText(a), extractInnerText(b), naturalDesc)
-    }
-  });
-
-  $.fn.dataTable.ext.order['dom-checkbox'] = function  ( settings, col )
-  {
-      return this.api().column( col, {order:'index'} ).nodes().map( function ( td, i ) {
-          return $('input', td).prop('checked') ? '1' : '0';
-      } );
-  };
-
-  $.fn.dataTable.ext.order['dom-inner-text'] = function  ( settings, col )
-  {
-      return this.api().column( col, {order:'index'} ).nodes().map( function ( td, i ) {
-          return $(td).text();
-      } );
-  }
-
   applyDefault = function() {
     let default_collection_id = $.cookie('default_collection');
     if (default_collection_id) {
diff --git a/gn2/wqflask/static/new/javascript/table_functions.js b/gn2/wqflask/static/new/javascript/table_functions.js
index 62888cd9..a648778e 100644
--- a/gn2/wqflask/static/new/javascript/table_functions.js
+++ b/gn2/wqflask/static/new/javascript/table_functions.js
@@ -88,3 +88,50 @@ function saveColumnSettings(tableId, traitTable) {
   // Save (or update) the settings in localStorage
   localStorage.setItem(tableId, JSON.stringify(userColumnDefs));
 }
+
+let naturalAsc = $.fn.dataTableExt.oSort["natural-ci-asc"]
+let naturalDesc = $.fn.dataTableExt.oSort["natural-ci-desc"]
+
+let na_equivalent_vals = ["N/A", "--", "", "NULL"]; //ZS: Since there are multiple values that should be treated the same as N/A
+
+function extractInnerText(the_string){
+  var span = document.createElement('span');
+  span.innerHTML = the_string;
+  return span.textContent || span.innerText;
+}
+
+function sortNAs(a, b, sort_function){
+  if ( na_equivalent_vals.includes(a) && na_equivalent_vals.includes(b)) {
+    return 0;
+  }
+  if (na_equivalent_vals.includes(a)){
+    return 1
+  }
+  if (na_equivalent_vals.includes(b)) {
+    return -1;
+  }
+  return sort_function(a, b)
+}
+
+$.extend( $.fn.dataTableExt.oSort, {
+  "natural-minus-na-asc": function (a, b) {
+    return sortNAs(extractInnerText(a), extractInnerText(b), naturalAsc)
+  },
+  "natural-minus-na-desc": function (a, b) {
+    return sortNAs(extractInnerText(a), extractInnerText(b), naturalDesc)
+  }
+});
+
+$.fn.dataTable.ext.order['dom-checkbox'] = function  ( settings, col )
+{
+    return this.api().column( col, {order:'index'} ).nodes().map( function ( td, i ) {
+        return $('input', td).prop('checked') ? '1' : '0';
+    } );
+};
+
+$.fn.dataTable.ext.order['dom-inner-text'] = function  ( settings, col )
+{
+    return this.api().column( col, {order:'index'} ).nodes().map( function ( td, i ) {
+        return $(td).text();
+    } );
+}
\ No newline at end of file
diff --git a/gn2/wqflask/templates/base.html b/gn2/wqflask/templates/base.html
index 26b75230..6c545646 100644
--- a/gn2/wqflask/templates/base.html
+++ b/gn2/wqflask/templates/base.html
@@ -188,7 +188,7 @@
 			</span>
 
 			<span  style="padding: 5px;margin-left: 65px;" >
-                            <a style="text-decoration: none" target="_blank" href="https://issues.genenetwork.org/topics/xapian/xapian-search-queries">
+                            <a style="text-decoration: none" target="_blank" href="/search-syntax">
                                 <i  style="text-align: center;color:#336699;;" class="fa fa-question-circle fa-2x" title="see more search hints" aria-hidden="true"></i>
                             </a>
 			</span>
diff --git a/gn2/wqflask/templates/collections/list.html b/gn2/wqflask/templates/collections/list.html
index c3d5d2a4..dc725fb6 100644
--- a/gn2/wqflask/templates/collections/list.html
+++ b/gn2/wqflask/templates/collections/list.html
@@ -57,8 +57,8 @@
                 <td align="center" style="padding: 0px;"><INPUT TYPE="checkbox" NAME="collection" class="checkbox trait_checkbox" VALUE="{{ uc.id }}"></td>
                 <td align="right">{{ loop.index }}
                 <td><a class="collection_name" href="{{ url_for('view_collection', uc_id=uc.id) }}">{{ uc.name }}</a></td>
-                <td>{{ uc.created_timestamp }}</td>
-                <td>{{ uc.changed_timestamp }}</td>
+                <td>{{ uc.created }}</td>
+                <td>{{ uc.changed }}</td>
                 <td align="right">{{ uc.num_members }}</td>
               </tr>
               {% endfor %}
@@ -88,8 +88,8 @@
               <td align="center" style="padding: 0px;"><INPUT TYPE="checkbox" NAME="collection" class="checkbox trait_checkbox" VALUE="{{ uc.id }}"></td>
               <td align="right">{{ loop.index }}
               <td><a class="collection_name" href="{{ url_for('view_collection', uc_id=uc.id) }}">{{ uc.name }}</a></td>
-              <td>{{ uc.created_timestamp }}</td>
-              <td>{{ uc.changed_timestamp }}</td>
+              <td>{{ uc.created }}</td>
+              <td>{{ uc.changed }}</td>
               <td align="right">{{ uc.num_members }}</td>
             </tr>
             {% endfor %}
diff --git a/gn2/wqflask/templates/collections/view.html b/gn2/wqflask/templates/collections/view.html
index 7e74442f..55669f09 100644
--- a/gn2/wqflask/templates/collections/view.html
+++ b/gn2/wqflask/templates/collections/view.html
@@ -214,9 +214,9 @@
                     'render': function(data) {
                         if (Object.hasOwn(data, 'description')){
                             try {
-                                return decodeURIComponent(escape(data.description))
+                                return decodeURIComponent(data.description)
                             } catch(err){
-                                return escape(data.description)
+                                return data.description
                             }
                         } else if (Object.hasOwn(data, 'location')){
                             return data.location
diff --git a/gn2/wqflask/templates/correlation_page.html b/gn2/wqflask/templates/correlation_page.html
index 24eaff1f..07118145 100644
--- a/gn2/wqflask/templates/correlation_page.html
+++ b/gn2/wqflask/templates/correlation_page.html
@@ -69,8 +69,8 @@
                 <input type="text" name="r_less_select" value="1.0" size="6" maxlength="10">, with mean >
                 <input type="text" name="mean_greater_select" value="0" size="6" maxlength="10">
                 <select id="mean_and_or" size="1">
-                    <option value="and" selected>AND</option>
-                    <option value="or">OR</option>
+                    <option value="and">AND</option>
+                    <option value="or" selected>OR</option>
                 </select>
                 mean <
                 <input type="text" name="mean_less_select" value="100" size="6" maxlength="10">
diff --git a/gn2/wqflask/templates/dataset.html b/gn2/wqflask/templates/dataset.html
index 096a3724..62fac650 100644
--- a/gn2/wqflask/templates/dataset.html
+++ b/gn2/wqflask/templates/dataset.html
@@ -40,31 +40,6 @@
  .panel-metadata dt::after {
      content: ":";
  }
-
- .search {
-     width: 50%;
-     margin: 1em auto;
- }
- .has-search .form-control-feedback {
-     right: initial;
-     left: 0;
-     color: #ccc;
- }
-
- .has-search .form-control {
-     padding-right: 12px;
-     padding-left: 34px;
- }
-
- .search {
-     transform: scale(1.5, 1.5);
- }
- .search input {
-     min-width: 17em;
- }
- .dataset-search {
-     padding: 0 17%;
- }
 </style>
 {% endblock %}
 
@@ -348,7 +323,7 @@
 </div>
 
 {% else %}
-<div class="container dataset-search">
+<div class="container">
     <p class="lead">We appreciate your interest, but unfortunately, we don't have any additional information available for: <strong>{{ name }}</strong>.  If you have other inquiries or need assistance with something else, please don't hesitate to get in touch with us.
 </div>
 
diff --git a/gn2/wqflask/templates/gnqa.html b/gn2/wqflask/templates/gnqa.html
index 8b50fe43..b3bc74fd 100644
--- a/gn2/wqflask/templates/gnqa.html
+++ b/gn2/wqflask/templates/gnqa.html
@@ -93,7 +93,7 @@
 	    AI Search
 	    <small>
 	      <sup>
-		<button class="search-hist-btn" hx-get="/gnqna/hist/" hx-target="#swap" hx-swap="innerHTML" >
+		<button class="search-hist-btn" hx-get="/gnqna/hist" hx-target="#swap" hx-swap="innerHTML">
 		  [Search History]
 		</button>
 	      </sup>
@@ -107,7 +107,7 @@
 	    <button class="btn btn-default btn-sm col-xs-1 col-sm-1 col-sm-offset-3"
 		    hx-post="/gnqna"
 		    hx-target="#swap"
-		    hx-swap="innerHTML"
+		    hx-swap="innerHTML"		  
 		    hx-indicator="#indicator">
 		<i class="fa fa-search fa-3x" aria-hidden="true" title="Search"></i>
 		<img id="indicator" class="htmx-indicator" src="/static/gif/loader.gif"/>
diff --git a/gn2/wqflask/templates/gnqa_answer.html b/gn2/wqflask/templates/gnqa_answer.html
index 0ddcfde7..41c1b338 100644
--- a/gn2/wqflask/templates/gnqa_answer.html
+++ b/gn2/wqflask/templates/gnqa_answer.html
@@ -3,10 +3,10 @@
     <div class="row container gnqa-answer" style="margin-bottom: 1em">
 	<p class="row lead">
 	    <mark style="font-family: 'Linux Libertine','Georgia','Times','Source Serif Pro',serif;"><b><i>{{ query }}</i></b></mark><br/>
-	    {{ answer }}
+	    {{ answer|safe }}
 	</p>
 	<div class="rating row" data-doc-id="{{query}}">
-	    <button class="btn" id="upvote" data-toggle="tooltip" data-placement="top" title="Vote Up"><i class="fa fa-thumbs-up fa-sm fa-1x" aria-hidden="true"></i></button>
+	    <button class="btn" id="upvote"  data-toggle="tooltip" data-placement="top" title="Vote Up"><i class="fa fa-thumbs-up fa-sm fa-1x" aria-hidden="true"></i></button>
             <button class="btn" id="downvote" data-toggle="tooltip" data-placement="top" title="Vote Down"><i class="fa fa-thumbs-down fa-sm fa-1x" aria-hidden="true"></i></button>
 	    <sub id="rate" class="text-info">	    
 	     </sub>
@@ -32,7 +32,7 @@
 			    </div>
 			    <div id="collapse{{reference.doc_id}}" class="panel-collapse collapse in" role="tabpanel" aria-labelledby="heading{{reference.doc_id}}">
 				<div class="panel-body">
-				    <p class="node-references">{{ reference.comboTxt }}</p>
+				    <p class="node-references">{{ reference.comboTxt|safe }}</p>
 				    <div>
 					{% if reference.pubmed %}
 					<details open>
@@ -60,7 +60,7 @@
 				</div>
 				<div id="collapse{{reference.doc_id}}" class="panel-collapse collapse" role="tabpanel" aria-labelledby="heading{{reference.doc_id}}">
 				    <div class="panel-body">
-					<p class="node-references">{{reference.comboTxt}}</p>
+					<p class="node-references">{{ reference.comboTxt|safe }}</p>
 				    <div>
 					{% if reference.pubmed %}
 					<details >
@@ -93,20 +93,27 @@
 {% block js %}
 
 <script>
+  function updateRatingHandler(target, responseObj, args){
+    let {status, response} = responseObj.xhr
+    if (status==200 && args == "upvote"){
+	  htmx.toggleClass(htmx.find('#upvote'), 'btn-success');
+	  htmx.removeClass(htmx.find("#downvote"), "btn-danger");
+      }
+      else if(status == 200 && args == "downvote") {
+          htmx.toggleClass(htmx.find('#downvote'), 'btn-danger');
+	  htmx.removeClass(htmx.find("#upvote"), "btn-success");
+      }
+      else {
+        alert(`Error occurred with status ${status} and Error ${response}` )
+}}
 var query = {{ query|tojson }};
 var answer = {{ answer|tojson }}
 var {task_id} = {{ task_id|tojson }}
-  htmx.on("#upvote", "click", function(evt){
+htmx.on("#upvote", "click", function(evt){
       vote_count = htmx.find(".btn-success") ? 0 : 1
-      htmx.ajax("POST", `/gnqna/rating/${task_id}/${vote_count}`, {target: "#rate", swap:"innerHTML",values: {'query': query, 'answer': answer}}).then(()=>{
-	  htmx.toggleClass(htmx.find('#upvote'), 'btn-success');
-	  htmx.removeClass(htmx.find("#downvote"), "btn-danger");
-})});
+    htmx.ajax("POST", `/gnqna/rating/${task_id}/${vote_count}`, {target: "#rate", handler: (target,obj)=> updateRatingHandler(target,obj,"upvote"), swap:"innerHTML",values: {'query': query, 'answer': answer}})});
   htmx.on("#downvote", "click", function(evt){
       vote_count = htmx.find(".btn-danger") ? 0 : -1
-      htmx.ajax("POST", `/gnqna/rating/${task_id}/${vote_count}`, {target: "#rate", swap:"innerHTML",values: {'query': query, 'answer': answer}}).then(()=>{
-      htmx.toggleClass(htmx.find('#downvote'), 'btn-danger');
-      htmx.removeClass(htmx.find("#upvote"), "btn-success")
-      })});
+      htmx.ajax("POST", `/gnqna/rating/${task_id}/${vote_count}`, {target: "#rate",handler: (target,obj)=> updateRatingHandler(target,obj,"downvote") , swap:"innerHTML",values: {'query': query, 'answer': answer}})});
 </script>
 {% endblock %}
diff --git a/gn2/wqflask/templates/gnqa_search_history.html b/gn2/wqflask/templates/gnqa_search_history.html
index 2c07b8c0..976fd7fd 100644
--- a/gn2/wqflask/templates/gnqa_search_history.html
+++ b/gn2/wqflask/templates/gnqa_search_history.html
@@ -1,42 +1,52 @@
-<section class="container-fluid  gnqa-copy">
+<section class="container-fluid  gnqa-copy" id="search-hist">
   <header class="row">
-
     <div class="panel panel default col-sm-6 col-sm-offset-3">
       <div class="panel panel-default">
 	<div class="panel-heading">
 	  <div>
-	    <h4 class="text-primary">You search History </h4>
+	    <h4 class="text-secondary" style="font-family: 'Linux Libertine','Georgia','Times','Source Serif Pro',serif;font-size:2.3rem">Your AI search History </h4>
 	  </div>
 	</div>
       </div>
     </div>
   </header>
   <div class="container row">
-  <div class="panel panel-default col-sm-6 col-sm-offset-3 ">
-    {% for record in prev_queries %}
-    <div class="panel-body">
-      <div class="row">
-	<input name="" type="checkbox" value="" class="col-sm-1">
+    <div>
+      <div class="col-sm-6 col-sm-offset-3" style="margin-bottom:10px">
+	<button type="button" class="btn btn-danger" id="delete-btn">Delete Selected </button>
+      </div>
+      <div >
+      <div class="panel panel-default col-sm-6 col-sm-offset-3 ">
+      <div>
+	<ul class="list-group list-group-flush" style="overflow-y:scroll">
+    {% for item in  prev_queries %}
+    <li class="row list-group-item">
+      	<input name="" type="checkbox"   value="{{item['task_id']}}" class="col-sm-1" style="height: 20px;
+  width: 20px;">
 	<div class="col-sm-10">
-	  {% for id,val in  record.items() %}
 	    <button
-	      hx-get="/gnqna/hist/search/{{id}}"
+	      hx-get="/gnqna/hist?query={{item['query']}}&search_term={{item['task_id']}}"
               hx-target="#swap"
 	      hx-swap="innerHTML"
 	      hx-trigger= "click"
 	      data-bs-toggle="tooltip"
 	      data-bs-placement="left"
-	      title="/gnqna/hist/search?{{id}}"
+	      title="/gnqna/hist?query={{item['query']}}&search_term={{item['task_id']}}"
 	      style="background:transparent;border:none;cursor:pointer"
 	      >
-	      <b class="text-info">{{val}} </b>
+	      <p class="text-info">{{item.get('query')}} </p>
 	    </button>
-	  {% endfor %}
-	</div>
-      </div>
-      </div>
-     {% endfor %}
+     </div>
+    </li>
+    {% endfor %}
+    </ul>
    </div>
   </div>
   </div>
- </section>
+</section>
+<script>
+  htmx.on("#delete-btn", "click", function(evt){
+      htmx.ajax("DELETE","/gnqna/hist", {target: "#search-hist","swap" :"outerHTML",
+				      values: Array.from(htmx.findAll("input[type=checkbox]:checked"), e => e.value)}) 
+  })
+</script>
diff --git a/gn2/wqflask/templates/gsearch_gene.html b/gn2/wqflask/templates/gsearch_gene.html
index 50b48401..3432662d 100644
--- a/gn2/wqflask/templates/gsearch_gene.html
+++ b/gn2/wqflask/templates/gsearch_gene.html
@@ -258,8 +258,12 @@
                 "scroller": true
                 {% endif %}
             }
-
-            create_table(tableId, traitsJson, columnDefs, tableSettings);
+            
+            if (traitsJson.length > 0) {
+              create_table(tableId, traitsJson, columnDefs, tableSettings);
+            } else {
+              $("#" + tableId +" td").replaceWith("<td>No data</td>")
+            }
 
         });
         
diff --git a/gn2/wqflask/templates/oauth2/confirm-resource-role-unassign-privilege.html b/gn2/wqflask/templates/oauth2/confirm-resource-role-unassign-privilege.html
new file mode 100644
index 00000000..988cf3b4
--- /dev/null
+++ b/gn2/wqflask/templates/oauth2/confirm-resource-role-unassign-privilege.html
@@ -0,0 +1,34 @@
+{%extends "base.html"%}
+{%from "oauth2/profile_nav.html" import profile_nav%}
+{%from "oauth2/display_error.html" import display_error%}
+{%block title%}View User{%endblock%}
+{%block content%}
+<div class="container">
+  {{profile_nav(uipages, user_privileges)}}
+  {{flash_me()}}
+
+  <form id="frm_confirm_resource_role_unassign_privilege"
+        method="POST"
+        action="{{url_for('oauth2.resource.unassign_privilege_from_resource_role',
+                resource_id=resource.resource_id,
+                role_id=role.role_id)}}">
+    <p>
+      Are you sure you want to unassign the privilege to
+      '{{privilege.privilege_description}}' from the role '{{role.role_name}}'
+      on resource '{{resource.resource_name}}'?</p>
+    <input type="hidden"
+           name="privilege_id"
+           value="{{privilege.privilege_id}}" />
+    
+    <input type="submit"
+           name="confirm"
+           value="Cancel"
+           class="btn btn-success" />
+
+    <input type="submit"
+           name="confirm"
+           value="Unassign"
+           class="btn btn-danger" />
+  </form>
+</div>
+{%endblock%}
diff --git a/gn2/wqflask/templates/oauth2/create-role.html b/gn2/wqflask/templates/oauth2/create-role.html
index f2bff7b4..6cf0bb78 100644
--- a/gn2/wqflask/templates/oauth2/create-role.html
+++ b/gn2/wqflask/templates/oauth2/create-role.html
@@ -7,31 +7,43 @@
   {{profile_nav("roles", user_privileges)}}
   <h3>Create Role</h3>
 
-  {{flash_me()}}
+  <p>Create a new role to act on resource "{{resource.resource_name}}"</p>
 
-  {%if group_privileges_error is defined%}
-  {{display_error("Group Privileges", group_privileges_error)}}
+  {%if resource_role_error is defined%}
+  {{display_error("Resource Role", resource_role_error)}}
   {%else%}
-  {%if "group:role:create-role" in user_privileges%}
-  <form method="POST" action="{{url_for('oauth2.role.create_role')}}">
-    <legend>Create Group Role</legend>
+  {%if "resource:role:create-role" in (user_privileges|map(attribute="privilege_id")) %}
+  <form method="POST" action="{{url_for('oauth2.resource.create_resource_role',
+                              resource_id=resource.resource_id)}}">
+    <legend>create resource role</legend>
+
+    {{flash_me()}}
+
     <div class="form-group">
       <label for="role_name" class="form-label">Name</label>
-      <input type="text" id="role_name" name="role_name" required="required"
-	     class="form-control"
-	     {%if prev_role_name is defined and prev_role_name is not none%}
-	     value="{{prev_role_name}}"
-	     {%endif%} />
+      <div class="input-group">
+        <span class="input-group-addon">
+          {{resource.resource_name|replace(" ", "_")}}::
+        </span>
+        <input type="text" id="role_name" name="role_name" required="required"
+	       class="form-control"
+	       {%if prev_role_name is defined and prev_role_name is not none%}
+	       value="{{prev_role_name}}"
+	       {%endif%} />
+      </div>
+      <span class="form-text text-muted">
+        The name of the role will have the resource's name appended.
+      </span>
     </div>
     <label class="form-label">Privileges</label>
-    {%for priv in group_privileges%}
+    {%for priv in user_privileges%}
     <div class="checkbox">
-      <label for="chk:{{priv.privilege_id}}">
-	<input type="checkbox" id="chk:{{priv.privilege_id}}"
+      <label for="chk-{{priv.privilege_id}}">
+	<input type="checkbox" id="chk-{{priv.privilege_id}}"
 	       name="privileges[]" value={{priv.privilege_id}} />
 	<span style="text-transform: capitalize;">
 	  {{priv.privilege_description}}
-	</span> ({{priv.privilege_id}})
+	</span>
       </label>
     </div>
     {%endfor%}
diff --git a/gn2/wqflask/templates/oauth2/data-list-mrna.html b/gn2/wqflask/templates/oauth2/data-list-mrna.html
index 728e95d4..c5c1c27e 100644
--- a/gn2/wqflask/templates/oauth2/data-list-mrna.html
+++ b/gn2/wqflask/templates/oauth2/data-list-mrna.html
@@ -92,7 +92,7 @@
   <div class="row">
     <span id="search-messages" class="alert-danger" style="display:none"></span>
     <form id="frm-search"
-	  action="{{search_uri}}"
+	  action="{{url_for('oauth2.data.json_search_mrna')}}"
 	  method="POST">
       <legend>Search: mRNA Assay</legend>
       <input type="hidden" value="{{species_name}}" name="species"
diff --git a/gn2/wqflask/templates/oauth2/data-list-phenotype.html b/gn2/wqflask/templates/oauth2/data-list-phenotype.html
index e5172c70..d355f3f9 100644
--- a/gn2/wqflask/templates/oauth2/data-list-phenotype.html
+++ b/gn2/wqflask/templates/oauth2/data-list-phenotype.html
@@ -113,7 +113,8 @@
     <form id="frm-search-traits"
 	  action="#"
 	  method="POST"
-	  data-gn-server-url="{{gn_server_url}}">
+          data-search-endpoint="{{search_endpoint}}"
+          data-pheno-results-template="{{pheno_results_template}}">
       {%if dataset_type == "mrna"%}
       <legend>mRNA: Search</legend>
       {%else%}
diff --git a/gn2/wqflask/templates/oauth2/profile_nav.html b/gn2/wqflask/templates/oauth2/profile_nav.html
index aa752905..c79bccbc 100644
--- a/gn2/wqflask/templates/oauth2/profile_nav.html
+++ b/gn2/wqflask/templates/oauth2/profile_nav.html
@@ -17,13 +17,6 @@
   </li>
 
   <li role="presentation"
-      {%if calling_page == "roles"%}
-      class="active"
-      {%endif%}>
-    <a href="{{url_for('oauth2.role.user_roles')}}">Roles</a>
-  </li>
-
-  <li role="presentation"
       {%if calling_page == "resources"%}
       class="active"
       {%endif%}>
diff --git a/gn2/wqflask/templates/oauth2/view-group-role.html b/gn2/wqflask/templates/oauth2/view-group-role.html
deleted file mode 100644
index 5da023bf..00000000
--- a/gn2/wqflask/templates/oauth2/view-group-role.html
+++ /dev/null
@@ -1,102 +0,0 @@
-{%extends "base.html"%}
-{%from "oauth2/profile_nav.html" import profile_nav%}
-{%from "oauth2/display_error.html" import display_error%}
-{%block title%}View User{%endblock%}
-{%block content%}
-<div class="container" style="min-width: 1250px;">
-  {{profile_nav("roles", user_privileges)}}
-  <h3>View Group Role</h3>
-
-  {{flash_me()}}
-
-  <div class="container-fluid">
-    <div class="row">
-      <h3>Role Details</h3>
-      {%if group_role_error is defined%}
-      {{display_error("Group Role", group_role_error)}}
-      {%else%}
-      <table class="table">
-	<caption>Details for '{{group_role.role.role_name}}' Role</caption>
-	<thead>
-	  <tr>
-	    <th>Privilege</th>
-	    <th>Description</th>
-	    <th>Action</th>
-	  </tr>
-	</thead>
-	<tbody>
-	  {%for privilege in group_role.role.privileges%}
-	  <tr>
-	    <td>{{privilege.privilege_id}}</td>
-	    <td>{{privilege.privilege_description}}</td>
-	    <td>
-	      <form action="{{url_for(
-			    'oauth2.group.delete_privilege_from_role',
-			    group_role_id=group_role.group_role_id)}}"
-		    method="POST">
-		<input type="hidden" name="privilege_id"
-		       value="{{privilege.privilege_id}}" />
-		<input type="submit" class="btn btn-danger"
-		       value="Remove"
-		       {%if not group_role.role.user_editable%}
-		       disabled="disabled"
-		       {%endif%} />
-	      </form>
-	    </td>
-	  </tr>
-	  {%endfor%}
-	</tbody>
-      </table>
-      {%endif%}
-    </div>
-
-    <div class="row">
-      <h3>Other Privileges</h3>
-      <table class="table">
-	<caption>Other Privileges not Assigned to this Role</caption>
-	<thead>
-	  <tr>
-	    <th>Privilege</th>
-	    <th>Description</th>
-	    <th>Action</th>
-	  </tr>
-	</thead>
-
-	<tbody>
-	  {%for priv in group_privileges%}
-	  <tr>
-	    <td>{{priv.privilege_id}}</td>
-	    <td>{{priv.privilege_description}}</td>
-	    <td>
-	      <form action="{{url_for(
-			    'oauth2.group.add_privilege_to_role',
-			    group_role_id=group_role.group_role_id)}}"
-		    method="POST">
-		<input type="hidden" name="privilege_id"
-		       value="{{priv.privilege_id}}" />
-		<input type="submit" class="btn btn-warning"
-		       value="Add to Role"
-		       {%if not group_role.role.user_editable%}
-		       disabled="disabled"
-		       {%endif%} />
-	      </form>
-	    </td>
-	  </tr>
-	  {%else%}
-	  <tr>
-	    <td colspan="3">
-	      <span class="glyphicon glyphicon-info-sign text-info">
-	      </span>
-	      &nbsp;
-	      <span class="text-info">All privileges assigned!</span>
-	    </td>
-	  </tr>
-	  {%endfor%}
-	</tbody>
-      </table>
-    </div>
-
-  </div>
-
-</div>
-{%endblock%}
diff --git a/gn2/wqflask/templates/oauth2/view-resource-role.html b/gn2/wqflask/templates/oauth2/view-resource-role.html
new file mode 100644
index 00000000..4bd0ab45
--- /dev/null
+++ b/gn2/wqflask/templates/oauth2/view-resource-role.html
@@ -0,0 +1,149 @@
+{%extends "base.html"%}
+{%from "oauth2/profile_nav.html" import profile_nav%}
+{%from "oauth2/display_error.html" import display_error%}
+{%block title%}View User{%endblock%}
+{%block content%}
+
+{%macro unassign_button(resource_id, role_id, privilege_id)%}
+<form method="GET"
+      action="{{url_for('oauth2.resource.unassign_privilege_from_resource_role',
+              resource_id=resource_id,
+              role_id=role_id)}}"
+      id="frm_unlink_privilege_{{privilege_id}}">
+  <input type="hidden" name="resource_id" value="{{resource_id}}" />
+  <input type="hidden" name="role_id" value="{{role_id}}" />
+  <input type="hidden" name="privilege_id" value="{{privilege_id}}" />
+  <input type="submit" value="Unassign" class="btn btn-danger" />
+</form>
+{%endmacro%}
+
+<div class="container">
+  <div class="row">
+    {{profile_nav(uipages, user_privileges)}}
+    {{flash_me()}}
+    {%if resource_error is defined%}
+    {{display_error("Resource", resource_error)}}
+    {%else%}
+    <h3>Role for Resource '{{resource.resource_name}}'</h3>
+    {%if role_error is defined%}
+    {{display_error("Role", role_error)}}
+    {%else%}
+    <table class="table">
+      <caption>Role '{{role.role_name}}' for resource '{{resource.resource_name}}'</caption>
+      <thead>
+        <tr>
+          <th>Role Name</th>
+          <th>Privilege</th>
+          <th>Action</th>
+        </tr>
+      </thead>
+
+      <tbody>
+        {%for priv in role.privileges%}
+        {%if loop.index0 == 0%}
+        <tr>
+          <td rowspan="{{role.privileges | length}}"
+              style="text-align: center;vertical-align: middle;">
+            {{role.role_name}}</td>
+          <td>{{priv.privilege_description}}</td>
+          <td>{{unassign_button(resource.resource_id, role.role_id, priv.privilege_id)}}</td>
+        </tr>
+        {%else%}
+        <tr>
+          <td>{{priv.privilege_description}}</td>
+          <td>{{unassign_button(resource.resource_id, role.role_id, priv.privilege_id)}}</td>
+        </tr>
+        {%endif%}
+        {%else%}
+        <tr>
+          <td colspan="3">
+            <p class="text-info">
+              <span class="glyphicon glyphicon-info-sign text-info"></span>
+              &nbsp;
+              This role has no privileges.
+            </p>
+          </td>
+        </tr>
+        {%endfor%}
+      </tbody>
+    </table>
+  </div>
+
+  <div class="row">
+    <form id="frm_assign_privileges" method="POST" action="#">
+      <input type="hidden" name="resource_id" value="{{resource_id}}" />
+      <input type="hidden" name="role_id" value="{{role_id}}" />
+      {%if unassigned_privileges | length == 0%}
+      <p class="text-info">
+        <strong>{{title}}</strong>:
+        <span class="glyphicon glyphicon-info-sign text-info"></span>
+        &nbsp;
+        There are no more privileges left to assign.
+      </p>
+      {%else%}
+      <fieldset>
+        <legend>Select privileges to assign to this role</legend>
+        {%for priv in unassigned_privileges%}
+        <div class="checkbox">
+          <label for="rdo_{{priv.privilege_id}}">
+            <input type="checkbox" value="{{priv.privilege_id}}" />
+            {{priv.privilege_description}}
+          </label>
+        </div>
+        {%endfor%}
+      </fieldset>
+
+      <input type="submit" class="btn btn-primary" value="Assign" />
+      {%endif%}
+    </form>
+  </div>
+
+  {%if user_error is defined%}
+  {{display_error("Users", user_error)}}
+  {%endif%}
+
+  {%if users is defined and users | length > 0%}
+  <div class="row">
+    <h3>Users</h3>
+
+    <table class="table">
+      <caption>
+        Users assigned role '{{role.role_name}}' on resource
+        '{{resource.resource_name}}'
+      </caption>
+
+      <thead>
+        <tr>
+          <th>Email</th>
+          <th>Name</th>
+        </tr>
+      </thead>
+
+      <tbody>
+        {%for user in users%}
+        <tr>
+          <td>{{user.email}}</td>
+          <td>{{user.name}}</td>
+        </tr>
+        {%endfor%}
+        </tbody>
+    </table>
+  </div>
+  {%endif%}
+
+  {%if users is defined and users | length == 0%}
+  <div class="row">
+    <h3>Delete this role</h3>
+    <p class="text-danger">
+      <strong>Delete Role</strong>:
+      <span class="glyphicon glyphicon-exclamation-sign text-danger"></span>
+      &nbsp;
+      This will delete this role, and you will no longer have access to it.
+    </p>
+  </div>
+  {%endif%}
+  {%endif%}
+  {%endif%}
+</div>
+
+{%endblock%}
diff --git a/gn2/wqflask/templates/oauth2/view-resource.html b/gn2/wqflask/templates/oauth2/view-resource.html
index 275fcb24..0788e30c 100644
--- a/gn2/wqflask/templates/oauth2/view-resource.html
+++ b/gn2/wqflask/templates/oauth2/view-resource.html
@@ -2,6 +2,10 @@
 {%from "oauth2/profile_nav.html" import profile_nav%}
 {%from "oauth2/display_error.html" import display_error%}
 {%block title%}View User{%endblock%}
+{%block css%}
+<link rel="stylesheet" href="/static/new/css/pills.css" />
+<link rel="stylesheet" href="/static/new/css/resource-roles.css" />
+{%endblock%}
 {%block content%}
 <div class="container" style="min-width: 1250px;">
   {{profile_nav("resources", user_privileges)}}
@@ -230,7 +234,27 @@
     </div>
 
     <div class="row">
-      <h3>User Roles</h3>
+      <h3>Available Resource Roles</h3>
+      <div class="resource_roles">
+        {%for role in resource_roles%}
+        <a class="pill"
+           href="{{url_for('oauth2.resource.view_resource_role',
+                 resource_id=resource.resource_id,
+                 role_id=role.role_id)}}"
+           title="Role page for role named '{{role.role_name}}'">
+          {{role.role_name}}
+        </a>
+        {%endfor%}
+      </div>
+      <hr />
+      <a title="create a new role for this resource"
+         href="{{url_for('oauth2.resource.create_resource_role',
+               resource_id=resource.resource_id)}}"
+         class="btn btn-info">New Role</a>
+    </div>
+
+    <div class="row">
+      <h3>Users: Assigned Roles</h3>
       {%if users_n_roles_error is defined%}
       {{display_error("Users and Roles", users_n_roles_error)}}
       {%else%}
@@ -254,14 +278,14 @@
 	    <th>Role</th>
 	    <th>Action</th>
 	  </tr>
-	  {%for grole in user_row.roles%}
+	  {%for role in user_row.roles%}
 	  <tr>
 	    <td>
 	      <a href="{{url_for(
 		       'oauth2.role.role',
-		       role_id=grole.role_id)}}"
-		 title="Details for '{{grole.role_name}}' role">
-		{{grole.role_name}}
+		       role_id=role.role_id)}}"
+		 title="Details for '{{role.role_name}}' role">
+		{{role.role_name}}
 	      </a>
 	    </td>
 	    <td>
@@ -270,8 +294,8 @@
 		    method="POST">
 		<input type="hidden" name="user_id"
 		       value="{{user_row.user.user_id}}" />
-		<input type="hidden" name="group_role_id"
-		       value="{{grole.group_role_id}}">
+		<input type="hidden" name="role_id"
+		       value="{{role.role_id}}">
 		<input type="submit"
 		       value="Unassign"
 		       class="btn btn-danger"
@@ -301,8 +325,8 @@
 
     <div class="row">
       <h3>Assign</h3>
-      {%if group_roles_error is defined%}
-      {{display_error("Group Roles", group_roles_error)}}
+      {%if resource_roles_error is defined%}
+      {{display_error("Resource Roles", resource_roles_error)}}
       {%elif users_error is defined%}
       {{display_error("Users", users_error)}}
       {%else%}
@@ -312,13 +336,13 @@
 	    method="POST" autocomplete="off">
 	<input type="hidden" name="resource_id" value="{{resource_id}}" />
 	<div class="form-group">
-	  <label for="group_role_id" class="form-label">Role</label>
-	  <select class="form-control" name="group_role_id"
-		  id="group_role_id" required="required">
-	    <option value="">Select role</option>
-	    {%for grole in group_roles%}
-	    <option value="{{grole.group_role_id}}">
-	      {{grole.role.role_name}}
+	  <label for="role_id" class="form-label">Role</label>
+	  <select class="form-control" name="role_id"
+		  id="role_id" required="required">
+	    <option value="">Select role</option>>
+	    {%for rrole in resource_roles%}
+	    <option value="{{rrole.role_id}}">
+	      {{rrole.role_name}}
 	    </option>
 	    {%endfor%}
 	  </select>
diff --git a/gn2/wqflask/templates/search-syntax.html b/gn2/wqflask/templates/search-syntax.html
new file mode 100644
index 00000000..52538826
--- /dev/null
+++ b/gn2/wqflask/templates/search-syntax.html
@@ -0,0 +1,24 @@
+{% extends "base.html" %}
+
+{% block title %}Global Search Syntax{% endblock %}
+
+{% block css %}
+<link rel="stylesheet" type="text/css" href="/static/new/css/markdown.css" />
+{% endblock %}
+
+{% block content %}
+
+ <div class="github-btn-container">
+    <div class="github-btn">
+        <a href="https://github.com/genenetwork/gn-docs/blob/master/general/search/xapian_syntax.md">
+            Edit Text
+            <img src="/static/images/edit.png">
+        </a>
+    </div>
+</div>
+<div id="markdown" class="container">
+   {{ rendered_markdown|safe }}
+
+</div>
+
+{% endblock %}
\ No newline at end of file
diff --git a/gn2/wqflask/templates/search_result_page.html b/gn2/wqflask/templates/search_result_page.html
index 934b6a9d..fccda1ae 100644
--- a/gn2/wqflask/templates/search_result_page.html
+++ b/gn2/wqflask/templates/search_result_page.html
@@ -224,7 +224,8 @@
               'type': "natural",
               'width': "{{ max_widths.symbol * 8 }}px",
               'targets': 3,
-              'data': "symbol"
+              'data': "symbol",
+              'defaultContent': "N/A"
             },
             {
               'title': "Description",
@@ -237,9 +238,9 @@
                   description = data.description.slice(0, 200) + '...'
                 }
                 try {
-                  return decodeURIComponent(escape(description))
+                  return decodeURIComponent(description)
                 } catch(err){
-                  return escape(description)
+                  return description
                 }
               }
             },
@@ -248,14 +249,16 @@
               'type': "natural-minus-na",
               'width': "130px",
               'targets': 5,
-              'data': "location"
+              'data': "location",
+              'defaultContent': "N/A"
             },
             {
               'title': "<div style='text-align: right;'>Mean</div>",
               'type': "natural-minus-na",
               'width': "40px",
-              'data': "mean",
               'targets': 6,
+              'data': "mean",
+              'defaultContent': "N/A",
               'orderSequence': [ "desc", "asc"]
             },
             {
@@ -279,6 +282,7 @@
               'data': "additive",
               'width': "65px",
               'targets': 9,
+              'defaultContent': "N/A",
               'orderSequence': [ "desc", "asc"]
             }{% elif dataset.type == 'Publish' %},
             {
@@ -294,6 +298,7 @@
             {
               'title': "Description",
               'type': "natural",
+              'width': "500px",
               'data': null,
               'targets': 3,
               'render': function(data) {
@@ -302,9 +307,9 @@
                   description = data.description.slice(0, 200) + '...'
                 }
                 try {
-                  return decodeURIComponent(escape(description))
+                  return decodeURIComponent(description)
                 } catch(err){
-                  return escape(description)
+                  return description
                 }
               }
             },
@@ -312,8 +317,9 @@
               'title': "<div style='text-align: right;'>Mean</div>",
               'type': "natural-minus-na",
               'width': "60px",
-              'data': "mean",
               'targets': 4,
+              'data': "mean",
+              'defaultContent': "N/A",
               'orderSequence': [ "desc", "asc"]
             },
             {
diff --git a/gn2/wqflask/templates/show_trait_transform_and_filter.html b/gn2/wqflask/templates/show_trait_transform_and_filter.html
index e61e1248..8a7cd8a5 100644
--- a/gn2/wqflask/templates/show_trait_transform_and_filter.html
+++ b/gn2/wqflask/templates/show_trait_transform_and_filter.html
@@ -5,7 +5,7 @@
         needed.
     </p>
     <div id="blockMenuSpan" class="input-append block-div">
-        <label for="remove_samples_field">Block samples by index:</label>
+        <label for="remove_samples_field">Filter samples by index:</label>
         <input type="text" id="remove_samples_field" placeholder="Example: 3, 5-10, 12">
         <select id="block_group" size="1">
           <option value="primary">
@@ -23,7 +23,7 @@
     </div>
     {% if categorical_attr_exists == "true" %}
     <div class="input-append block-div-2">
-        <label for="exclude_column">Block samples by group:</label>
+        <label for="exclude_column">Filter samples by group:</label>
         <select id="exclude_column" size=1>
           {% for attribute in sample_groups[0].attributes %}
           {% if sample_groups[0].attributes[attribute].distinct_values|length <= 500 and sample_groups[0].attributes[attribute].distinct_values|length > 1 %}
diff --git a/gn2/wqflask/templates/tool_buttons.html b/gn2/wqflask/templates/tool_buttons.html
index c6d1476c..3e716d70 100644
--- a/gn2/wqflask/templates/tool_buttons.html
+++ b/gn2/wqflask/templates/tool_buttons.html
@@ -18,6 +18,10 @@
   BNW
 </button>
 
+<button id="send_to_genecup" class="btn btn-primary submit_special" data-url="/genecup" >
+  GeneCup
+</button>
+
 <button id="wgcna_setup" class="btn btn-primary submit_special" data-url="/wgcna_setup" title="WGCNA Analysis" >
     WGCNA
 </button>
diff --git a/gn2/wqflask/templates/tutorials.html b/gn2/wqflask/templates/tutorials.html
index 870dba1a..6281291f 100644
--- a/gn2/wqflask/templates/tutorials.html
+++ b/gn2/wqflask/templates/tutorials.html
@@ -221,11 +221,12 @@ $('#myTable3').dataTable( {
         </tr>
         </thead>
         <tbody>
-        <tr><td><a href="https://www.biorxiv.org/content/10.1101/2020.12.23.424047v1">GeneNetwork: a continuously updated tool for systems genetics analyses</a></td></tr>
-        <tr><td><a href="https://doi.org/10.3390/genes13040614">New Insights on Gene by Environmental Effects of Drugs of Abuse in Animal Models Using GeneNetwork</a></td>
-        <tr><td><a href="https://www.opar.io/pdf/Rat_HRDP_Brain_Proteomics_Wang_WIlliams_08Oct2021.pdf">A Primer on Brain Proteomics and protein-QTL Analysis for Substance Use Disorders</a></td></tr>
-        
-                </tbody>
+            <tr><td><a href="https://www.opar.io/pdf/Ashbrook_ExperimentalPrecisionMedicineMouseModels_2023.pdf">Experimental precision medicine: Mouse models in which to test precision medicine</a></td>
+            <tr><td><a href="https://www.opar.io/pdf/Williams_ResourcesForSystemsGenetics_2017.pdf">Systems Genetics. Methods and Protocols</a></td></tr>
+            <tr><td><a href="https://www.biorxiv.org/content/10.1101/2020.12.23.424047v1">GeneNetwork: a continuously updated tool for systems genetics analyses</a></td></tr>
+            <tr><td><a href="https://doi.org/10.3390/genes13040614">New Insights on Gene by Environmental Effects of Drugs of Abuse in Animal Models Using GeneNetwork</a></td>
+            <tr><td><a href="https://www.opar.io/pdf/Rat_HRDP_Brain_Proteomics_Wang_WIlliams_08Oct2021.pdf">A Primer on Brain Proteomics and protein-QTL Analysis for Substance Use Disorders</a></td></tr>
+        </tbody>
     </table>
         <script>
 $('#myTable4').dataTable( {
diff --git a/gn2/wqflask/views.py b/gn2/wqflask/views.py
index 843ed07a..993c6f0c 100644
--- a/gn2/wqflask/views.py
+++ b/gn2/wqflask/views.py
@@ -46,12 +46,12 @@ from gn2.wqflask import search_results
 from gn2.wqflask import server_side
 # Used by YAML in marker_regression
 from gn2.base.data_set import create_dataset
+from gn2.base.trait import fetch_symbols
 from gn2.wqflask.show_trait import show_trait
 from gn2.wqflask.show_trait import export_trait_data
 from gn2.wqflask.show_trait.show_trait import get_diff_of_vals
 from gn2.wqflask.heatmap import heatmap
-from gn2.wqflask.external_tools import send_to_bnw
-from gn2.wqflask.external_tools import send_to_webgestalt
+from gn2.wqflask.external_tools import send_to_bnw, send_to_webgestalt
 from gn2.wqflask.external_tools import send_to_geneweaver
 from gn2.wqflask.comparison_bar_chart import comparison_bar_chart
 from gn2.wqflask.marker_regression import run_mapping
@@ -88,8 +88,8 @@ from gn2.utility.redis_tools import get_redis_conn
 
 import gn2.utility.hmac as hmac
 
-from gn2.base.webqtlConfig import TMPDIR
-from gn2.base.webqtlConfig import GENERATED_IMAGE_DIR
+from gn2.base.webqtlConfig import TMPDIR, GENERATED_IMAGE_DIR
+from gn2.base.webqtlConfig import GENE_CUP_URL
 
 from gn2.wqflask.database import database_connection
 
@@ -137,6 +137,10 @@ def handle_generic_exceptions(e):
                                          stack={formatted_lines},
                                          error_image=animation,
                                          version=current_app.config.get("GN_VERSION")))
+    try:
+        resp.status_code = exc_type.code
+    except AttributeError:
+        resp.status_code = 500
     resp.set_cookie(err_msg[:32], animation)
     return resp
 
@@ -258,28 +262,19 @@ def gsearchtable():
 @app.route("/gnqna", methods=["POST", "GET"])
 @require_oauth2
 def gnqna():
-
     if request.method == "POST":
         try:
-            def __error__(resp):
-                return resp.json()
-
             def error_page(resp):
                 return render_template("gnqa_errors.html",
                                        **{"status_code": resp.status_code, **resp.json()})
 
             def __success__(resp):
                 return render_template("gnqa_answer.html", **{"gn_server_url": GN3_LOCAL_URL, **(resp.json())})
-            """
-            disable gn-auth currently not stable
-            if not user_logged_in():
-                return error_page("Please Login/Register to  Genenetwork to access this Service")
-            """
             token = session_info()["user"]["token"].either(
                 lambda err: err, lambda tok: tok["access_token"])
-            return monad_requests.post(
+            return monad_requests.put(
                 urljoin(GN3_LOCAL_URL,
-                        "/api/llm/gnqna"),
+                        "/api/llm/search"),
                 json=dict(request.form),
                 headers={
                     "Authorization": f"Bearer {token}"
@@ -290,44 +285,39 @@ def gnqna():
                 error_page, __success__)
         except Exception as error:
             return flask.jsonify({"error": str(error)})
-    prev_queries = (monad_requests.get(
-        urljoin(GN3_LOCAL_URL,
-                "/api/llm/get_hist_names")
-    ).then(
-        lambda resp: resp
-    ).either(lambda x: [], lambda x: x.json()["prev_queries"]))
-
-    return render_template("gnqa.html", prev_queries=prev_queries)
+    return render_template("gnqa.html")
 
 
-@app.route("/gnqna/hist/", methods=["GET"])
-@require_oauth2
-def get_hist_titles():
-    token = session_info()["user"]["token"].either(
-        lambda err: err, lambda tok: tok["access_token"])
-    response = monad_requests.get(urljoin(GN3_LOCAL_URL,
-                                          "/api/llm/hist/titles"),
-                                  headers={
-        "Authorization": f"Bearer {token}"
-    }
-    ).then(lambda resp: resp).either(
-        lambda x:  x.json(), lambda x: x.json())
-    return render_template("gnqa_search_history.html", **response)
-
 
-@app.route("/gnqna/hist/search/<search_term>", methods=["GET"])
+@app.route("/gnqna/hist", methods=["GET", "DELETE"])
 @require_oauth2
-def fetch_hist_records(search_term):
+def get_gnqa_history():
+    def _error_(resp):
+        return render_template("gnqa_errors.html",
+                               **{"status_code": resp.status_code,
+                                  **resp.json()})
     token = session_info()["user"]["token"].either(
         lambda err: err, lambda tok: tok["access_token"])
+    if request.method == "DELETE":
+        monad_requests.delete(urljoin(GN3_LOCAL_URL, "/api/llm/history"),
+                            json=dict(request.form),
+                            headers={
+                                 "Authorization": f"Bearer {token}"
+                            }
+                            ).either(
+                   _error_, lambda x: x.json())
     response = monad_requests.get(urljoin(GN3_LOCAL_URL,
-                                          f"/api/llm/history/{search_term}"),
+                 (f"/api/llm/history?search_term={request.args.get('search_term')}"
+                  if request.args.get("search_term") else "/api/llm/history")),
                                   headers={
         "Authorization": f"Bearer {token}"
     }
     ).then(lambda resp: resp).either(
-        lambda x:  x.json(), lambda x: x.json())
-    return render_template("gnqa_answer.html", **response)
+        _error_, lambda x: x.json())
+    if request.args.get("search_term"):
+        return render_template("gnqa_answer.html", **response)
+    return render_template("gnqa_search_history.html",
+                           prev_queries=response)
 
 
 @app.route("/gnqna/rating/<task_id>/<int(signed=True):weight>",
@@ -741,6 +731,22 @@ def geneweaver_page():
     return rendered_template
 
 
+@app.route("/genecup", methods=('POST',))
+def genecup_page():
+    start_vars = request.form
+
+    traits = [trait.strip() for trait in start_vars['trait_list'].split(',')]
+
+    if traits[0] != "":
+        symbol_string = fetch_symbols(traits)
+        return redirect(GENE_CUP_URL % symbol_string)
+    else:
+        rendered_template = render_template(
+            "empty_collection.html", **{'tool': 'GeneWeaver'})
+
+        return rendered_template
+
+
 @app.route("/comparison_bar_chart", methods=('POST',))
 def comp_bar_chart_page():
     start_vars = request.form
@@ -1230,8 +1236,8 @@ def get_dataset(name):
         lambda err: {"roles": []},
         lambda val: val
     )
-
-    metadata["editable"] = "group:resource:edit-resource" in result["roles"]
+    if metadata:
+        metadata["editable"] = "group:resource:edit-resource" in result["roles"]
     return render_template(
         "dataset.html",
         name=name,
@@ -1346,7 +1352,7 @@ def edit_case_attributes(inbredset_id: int) -> Response:
         return monad_requests.post(
             urljoin(
                 current_app.config["GN_SERVER_URL"],
-                f"/api/case-attribute/{inbredset_id}/edit"),
+                f"case-attribute/{inbredset_id}/edit"),
             json={
                 "edit-data": reduce(__process_data__, form.items(), {})
             },
@@ -1358,29 +1364,33 @@ def edit_case_attributes(inbredset_id: int) -> Response:
     def __fetch_strains__(inbredset_group):
         return monad_requests.get(urljoin(
             current_app.config["GN_SERVER_URL"],
-            f"/api/case-attribute/{inbredset_id}/strains")).then(
+            f"case-attribute/{inbredset_id}/strains")).then(
                 lambda resp: {**inbredset_group, "strains": resp.json()})
 
     def __fetch_names__(strains):
         return monad_requests.get(urljoin(
             current_app.config["GN_SERVER_URL"],
-            f"/api/case-attribute/{inbredset_id}/names")).then(
+            f"case-attribute/{inbredset_id}/names")).then(
                 lambda resp: {**strains, "case_attribute_names": resp.json()})
 
     def __fetch_values__(canames):
         return monad_requests.get(urljoin(
             current_app.config["GN_SERVER_URL"],
-            f"/api/case-attribute/{inbredset_id}/values")).then(
+            f"case-attribute/{inbredset_id}/values")).then(
                 lambda resp: {**canames, "case_attribute_values": {
                     value["StrainName"]: value for value in resp.json()}})
 
+    def __view_error__(err):
+        current_app.logger.error("%s", err)
+        return "We experienced an error"
+
     return monad_requests.get(urljoin(
         current_app.config["GN_SERVER_URL"],
-        f"/api/case-attribute/{inbredset_id}")).then(
+        f"case-attribute/{inbredset_id}")).then(
             lambda resp: {"inbredset_group": resp.json()}).then(
                 __fetch_strains__).then(__fetch_names__).then(
                     __fetch_values__).either(
-                        lambda err: err,  # TODO: Handle error better
+                        __view_error__,
                         lambda values: render_template(
                             "edit_case_attributes.html", inbredset_id=inbredset_id, **values))