Repository: finos/git-proxy

Issue:
Enable multiple strategies for UI login/passport auth
**Is your feature request related to a problem? Please describe.**
Right now, each entry in the auth config has an `enabled` flag that activates a single auth strategy in the switch found in `src/service/passport`.

It would be great to allow multiple auth strategies to be loaded dynamically rather than only allowing the `enabled` auth method in `proxy.config.json` when starting the backend:

**Describe the solution you'd like**
Load ALL the strategies with valid configurations and add frontend support so users can choose an option.

**Describe alternatives you've considered**
In our recently added OIDC support (#906), we are hard-coding the OIDC option to the list of auth methods. This is a bit too rigid since users want to log in with any method their organization provides.

**Additional context**
This is how the passport is being configured right now `src/service/passport`:

```js
const configure = async () => {
  const type = authenticationConfig.type.toLowerCase();

  switch (type) {
    case 'activedirectory':
      _passport = await activeDirectory.configure();
      break;
    case 'local':
      _passport = await local.configure();
      break;
    case 'openidconnect':
      _passport = await oidc.configure();
      break;
    default:
      throw Error(`uknown authentication type ${type}`);
  }
  _passport.type = authenticationConfig.type;
  return _passport;
};
```

Discussion:

Solution patch:
diff --git a/cypress/e2e/login.cy.js b/cypress/e2e/login.cy.js
index 16da32f8..25d80e43 100644
--- a/cypress/e2e/login.cy.js
+++ b/cypress/e2e/login.cy.js
@@ -38,8 +38,10 @@ describe('Login page', () => {
 
     // Validates that OIDC is configured correctly
     it('should redirect to /oidc', () => {
+      // Set intercept first, since redirect on click can be quick
+      cy.intercept('GET', '/api/auth/oidc').as('oidcRedirect');
       cy.get('[data-test="oidc-login"]').click();
-      cy.url().should('include', '/oidc');
+      cy.wait('@oidcRedirect');
     });
   });
 });
diff --git a/package-lock.json b/package-lock.json
index 2f57df5a..3a2c9913 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -88,6 +88,7 @@
         "mocha": "^10.8.2",
         "nyc": "^17.0.0",
         "prettier": "^3.0.0",
+        "proxyquire": "^2.1.3",
         "sinon": "^19.0.2",
         "ts-mocha": "^11.1.0",
         "ts-node": "^10.9.2",
@@ -7258,6 +7259,20 @@
         "node": "^10.12.0 || >=12.0.0"
       }
     },
+    "node_modules/fill-keys": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/fill-keys/-/fill-keys-1.0.2.tgz",
+      "integrity": "sha512-tcgI872xXjwFF4xgQmLxi76GnwJG3g/3isB1l4/G5Z4zrbddGpBjqZCO9oEAcB5wX0Hj/5iQB3toxfO7in1hHA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-object": "~1.0.1",
+        "merge-descriptors": "~1.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/fill-range": {
       "version": "7.1.1",
       "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -8411,12 +8426,16 @@
       }
     },
     "node_modules/is-core-module": {
-      "version": "2.13.1",
-      "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz",
-      "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==",
+      "version": "2.16.1",
+      "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
+      "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
       "dev": true,
+      "license": "MIT",
       "dependencies": {
-        "hasown": "^2.0.0"
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
       },
       "funding": {
         "url": "https://github.com/sponsors/ljharb"
@@ -8597,6 +8616,16 @@
         "node": ">=8"
       }
     },
+    "node_modules/is-object": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.2.tgz",
+      "integrity": "sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
     "node_modules/is-path-inside": {
       "version": "3.0.3",
       "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
@@ -10193,6 +10222,13 @@
         "node": ">=10"
       }
     },
+    "node_modules/module-not-found-error": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/module-not-found-error/-/module-not-found-error-1.0.1.tgz",
+      "integrity": "sha512-pEk4ECWQXV6z2zjhRZUongnLJNUeGQJ3w6OQ5ctGwD+i5o93qjRQUk2Rt6VdNeu3sEP0AB4LcfvdebpxBRVr4g==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/moment": {
       "version": "2.30.1",
       "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
@@ -11338,6 +11374,39 @@
       "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
       "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
     },
+    "node_modules/proxyquire": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/proxyquire/-/proxyquire-2.1.3.tgz",
+      "integrity": "sha512-BQWfCqYM+QINd+yawJz23tbBM40VIGXOdDw3X344KcclI/gtBbdWF6SlQ4nK/bYhF9d27KYug9WzljHC6B9Ysg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fill-keys": "^1.0.2",
+        "module-not-found-error": "^1.0.1",
+        "resolve": "^1.11.1"
+      }
+    },
+    "node_modules/proxyquire/node_modules/resolve": {
+      "version": "1.22.10",
+      "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
+      "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-core-module": "^2.16.0",
+        "path-parse": "^1.0.7",
+        "supports-preserve-symlinks-flag": "^1.0.0"
+      },
+      "bin": {
+        "resolve": "bin/resolve"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
     "node_modules/pump": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
diff --git a/package.json b/package.json
index ad837a2f..0f0e28b5 100644
--- a/package.json
+++ b/package.json
@@ -109,6 +109,7 @@
     "mocha": "^10.8.2",
     "nyc": "^17.0.0",
     "prettier": "^3.0.0",
+    "proxyquire": "^2.1.3",
     "sinon": "^19.0.2",
     "ts-mocha": "^11.1.0",
     "ts-node": "^10.9.2",
diff --git a/proxy.config.json b/proxy.config.json
index 241811c3..be7aa232 100644
--- a/proxy.config.json
+++ b/proxy.config.json
@@ -53,6 +53,17 @@
         "baseDN": "",
         "searchBase": ""
       }
+    },
+    {
+      "type": "openidconnect",
+      "enabled": false,
+      "oidcConfig": {
+        "issuer": "",
+        "clientID": "",
+        "clientSecret": "",
+        "callbackURL": "",
+        "scope": ""
+      }
     }
   ],
   "api": {
diff --git a/src/config/index.ts b/src/config/index.ts
index 970a5c61..17df0955 100644
--- a/src/config/index.ts
+++ b/src/config/index.ts
@@ -89,27 +89,29 @@ export const getDatabase = () => {
   throw Error('No database cofigured!');
 };
 
-// Gets the configured authentication method, defaults to local
-export const getAuthentication = () => {
+/**
+ * Get the list of enabled authentication methods
+ * @return {Array} List of enabled authentication methods
+ */
+export const getAuthMethods = () => {
   if (_userSettings !== null && _userSettings.authentication) {
     _authentication = _userSettings.authentication;
   }
-  for (const ix in _authentication) {
-    if (!ix) continue;
-    const auth = _authentication[ix];
-    if (auth.enabled) {
-      return auth;
-    }
+
+  const enabledAuthMethods = _authentication.filter((auth) => auth.enabled);
+
+  if (enabledAuthMethods.length === 0) {
+    throw new Error("No authentication method enabled");
   }
 
-  throw Error('No authentication cofigured!');
+  return enabledAuthMethods;
 };
 
 // Log configuration to console
 export const logConfiguration = () => {
   console.log(`authorisedList = ${JSON.stringify(getAuthorisedList())}`);
   console.log(`data sink = ${JSON.stringify(getDatabase())}`);
-  console.log(`authentication = ${JSON.stringify(getAuthentication())}`);
+  console.log(`authentication = ${JSON.stringify(getAuthMethods())}`);
   console.log(`rateLimit = ${JSON.stringify(getRateLimit())}`);
 };
 
diff --git a/src/service/passport/activeDirectory.js b/src/service/passport/activeDirectory.js
index 466f57b1..37286813 100644
--- a/src/service/passport/activeDirectory.js
+++ b/src/service/passport/activeDirectory.js
@@ -1,16 +1,19 @@
-const configure = () => {
-  const passport = require('passport');
-  const ActiveDirectoryStrategy = require('passport-activedirectory');
-  const config = require('../../config').getAuthentication();
-  const adConfig = config.adConfig;
+const ActiveDirectoryStrategy = require('passport-activedirectory');
+const ldaphelper = require('./ldaphelper');
+
+const configure = (passport) => {
   const db = require('../../db');
-  const userGroup = config.userGroup;
-  const adminGroup = config.adminGroup;
-  const domain = config.domain;
+
+  // We can refactor this by normalizing auth strategy config and pass it directly into the configure() function,
+  // ideally when we convert this to TS.
+  const authMethods = require('../../config').getAuthMethods();
+  const config = authMethods.find((method) => method.type.toLowerCase() === "activeDirectory");
+  const adConfig = config.adConfig;
+
+  const { userGroup, adminGroup, domain } = config;
 
   console.log(`AD User Group: ${userGroup}, AD Admin Group: ${adminGroup}`);
 
-  const ldaphelper = require('./ldaphelper');
   passport.use(
     new ActiveDirectoryStrategy(
       {
@@ -19,42 +22,47 @@ const configure = () => {
         ldap: adConfig,
       },
       async function (req, profile, ad, done) {
-        profile.username = profile._json.sAMAccountName.toLowerCase();
-        profile.email = profile._json.mail;
-        profile.id = profile.username;
-        req.user = profile;
-
-        console.log(
-          `passport.activeDirectory: resolved login ${
-            profile._json.userPrincipalName
-          }, profile=${JSON.stringify(profile)}`,
-        );
-        // First check to see if the user is in the usergroups
-        const isUser = await ldaphelper.isUserInAdGroup(profile.username, domain, userGroup);
-
-        if (!isUser) {
-          const message = `User it not a member of ${userGroup}`;
-          return done(message, null);
-        }
+        try {
+          profile.username = profile._json.sAMAccountName?.toLowerCase();
+          profile.email = profile._json.mail;
+          profile.id = profile.username;
+          req.user = profile;
 
-        // Now check if the user is an admin
-        const isAdmin = await ldaphelper.isUserInAdGroup(profile.username, domain, adminGroup);
+          console.log(
+            `passport.activeDirectory: resolved login ${
+              profile._json.userPrincipalName
+            }, profile=${JSON.stringify(profile)}`,
+          );
+          // First check to see if the user is in the usergroups
+          const isUser = await ldaphelper.isUserInAdGroup(profile.username, domain, userGroup);
 
-        profile.admin = isAdmin;
-        console.log(`passport.activeDirectory: ${profile.username} admin=${isAdmin}`);
+          if (!isUser) {
+            const message = `User it not a member of ${userGroup}`;
+            return done(message, null);
+          }
 
-        const user = {
-          username: profile.username,
-          admin: isAdmin,
-          email: profile._json.mail,
-          displayName: profile.displayName,
-          title: profile._json.title,
-        };
+          // Now check if the user is an admin
+          const isAdmin = await ldaphelper.isUserInAdGroup(profile.username, domain, adminGroup);
 
-        await db.updateUser(user);
+          profile.admin = isAdmin;
+          console.log(`passport.activeDirectory: ${profile.username} admin=${isAdmin}`);
 
-        return done(null, user);
-      },
+          const user = {
+            username: profile.username,
+            admin: isAdmin,
+            email: profile._json.mail,
+            displayName: profile.displayName,
+            title: profile._json.title,
+          };
+
+          await db.updateUser(user);
+
+          return done(null, user);
+        } catch (err) {
+          console.log(`Error authenticating AD user: ${err.message}`);
+          return done(err, null);
+        }
+      }
     ),
   );
 
@@ -69,4 +77,4 @@ const configure = () => {
   return passport;
 };
 
-module.exports.configure = configure;
+module.exports = { configure };
diff --git a/src/service/passport/index.js b/src/service/passport/index.js
index a2d7931e..72918282 100644
--- a/src/service/passport/index.js
+++ b/src/service/passport/index.js
@@ -1,33 +1,36 @@
+const passport = require("passport");
 const local = require('./local');
 const activeDirectory = require('./activeDirectory');
 const oidc = require('./oidc');
 const config = require('../../config');
-const authenticationConfig = config.getAuthentication();
-let _passport;
+
+// Allows obtaining strategy config function and type
+// Keep in mind to add AuthStrategy enum when refactoring this to TS
+const authStrategies = {
+  local: local,
+  activedirectory: activeDirectory,
+  openidconnect: oidc,
+};
 
 const configure = async () => {
-  const type = authenticationConfig.type.toLowerCase();
-
-  switch (type) {
-    case 'activedirectory':
-      _passport = await activeDirectory.configure();
-      break;
-    case 'local':
-      _passport = await local.configure();
-      break;
-    case 'openidconnect':
-      _passport = await oidc.configure();
-      break;
-    default:
-      throw Error(`uknown authentication type ${type}`);
+  passport.initialize();
+
+  const authMethods = config.getAuthMethods();
+
+  for (const auth of authMethods) {
+    const strategy = authStrategies[auth.type.toLowerCase()];
+    if (strategy && typeof strategy.configure === "function") {
+      await strategy.configure(passport);
+    }
   }
-  if (!_passport.type) {
-    _passport.type = type;
+
+  if (authMethods.some(auth => auth.type.toLowerCase() === "local")) {
+    await local.createDefaultAdmin();
   }
-  return _passport;
-};
 
-module.exports.configure = configure;
-module.exports.getPassport = () => {
-  return _passport;
+  return passport;
 };
+
+const getPassport = () => passport;
+
+module.exports = { authStrategies, configure, getPassport };
diff --git a/src/service/passport/local.js b/src/service/passport/local.js
index c7567657..8fc0b369 100644
--- a/src/service/passport/local.js
+++ b/src/service/passport/local.js
@@ -1,53 +1,55 @@
-const bcrypt = require('bcryptjs');
-/* eslint-disable max-len */
-const configure = async () => {
-  const passport = require('passport');
-  const Strategy = require('passport-local').Strategy;
-  const db = require('../../db');
+const bcrypt = require("bcryptjs");
+const LocalStrategy = require("passport-local").Strategy;
+const db = require("../../db");
 
+const type = "local";
+
+const configure = async (passport) => {
   passport.use(
-    new Strategy((username, password, cb) => {
-      db.findUser(username)
-        .then(async (user) => {
-          if (!user) {
-            return cb(null, false);
-          }
-
-          const passwordCorrect = await bcrypt.compare(password, user.password);
-
-          if (!passwordCorrect) {
-            return cb(null, false);
-          }
-          return cb(null, user);
-        })
-        .catch((err) => {
-          return cb(err);
-        });
-    }),
+    new LocalStrategy(async (username, password, done) => {
+      try {
+        const user = await db.findUser(username);
+        if (!user) {
+          return done(null, false, { message: "Incorrect username." });
+        }
+
+        const passwordCorrect = await bcrypt.compare(password, user.password);
+        if (!passwordCorrect) {
+          return done(null, false, { message: "Incorrect password." });
+        }
+
+        return done(null, user);
+      } catch (err) {
+        return done(err);
+      }
+    })
   );
 
-  passport.serializeUser(function (user, cb) {
-    cb(null, user.username);
+  passport.serializeUser((user, done) => {
+    done(null, user.username);
   });
 
-  passport.deserializeUser(function (username, cb) {
-    db.findUser(username)
-      .then((user) => {
-        cb(null, user);
-      })
-      .catch((err) => {
-        db(err, null);
-      });
+  passport.deserializeUser(async (username, done) => {
+    try {
+      const user = await db.findUser(username);
+      done(null, user);
+    } catch (err) {
+      done(err, null);
+    }
   });
 
-  const admin = await db.findUser('admin');
+  passport.type = 'local';
+  return passport;
+};
 
+/**
+ * Create the default admin user if it doesn't exist
+ */
+const createDefaultAdmin = async () => {
+  const admin = await db.findUser("admin");
   if (!admin) {
-    await db.createUser('admin', 'admin', 'admin@place.com', 'none', true);
+    await db.createUser("admin", "admin", "admin@place.com", "none", true);
   }
-
-  passport.type = 'local';
-  return passport;
 };
 
-module.exports.configure = configure;
+module.exports = { configure, createDefaultAdmin, type };
diff --git a/src/service/passport/oidc.js b/src/service/passport/oidc.js
index 904faff0..4954a4d7 100644
--- a/src/service/passport/oidc.js
+++ b/src/service/passport/oidc.js
@@ -1,12 +1,14 @@
 const passport = require('passport');
 const db = require('../../db');
 
+let type;
+
 const configure = async () => {
   // Temp fix for ERR_REQUIRE_ESM, will be changed when we refactor to ESM
   const { discovery, fetchUserInfo } = await import('openid-client');
   const { Strategy } = await import('openid-client/passport');
-  const config = require('../../config').getAuthentication();
-  const { oidcConfig } = config;
+  const authMethods = require('../../config').getAuthMethods();
+  const oidcConfig = authMethods.find((method) => method.type.toLowerCase() === "openidconnect")?.oidcConfig;
   const { issuer, clientID, clientSecret, callbackURL, scope } = oidcConfig;
 
   if (!oidcConfig || !oidcConfig.issuer) {
@@ -49,23 +51,21 @@ const configure = async () => {
         done(err);
       }
     })
-    passport.type = server.host;
+    console.log(`setting type to ${server.host}`)
+    type = server.host;
 
     return passport;
   } catch (error) {
     console.error('OIDC configuration failed:', error);
     throw error;
   }
-}
-
-
-module.exports.configure = configure;
+};
 
 /**
  * Handles user authentication with OIDC.
- * @param {Object} userInfo the OIDC user info object 
- * @param {Function} done the callback function
- * @return {Promise} a promise with the authenticated user or an error
+ * @param {*} userInfo the OIDC user info object 
+ * @param {*} done the callback function
+ * @return {Promise<void>} a promise with the authenticated user or an error
  */
 const handleUserAuthentication = async (userInfo, done) => {
   try {
@@ -112,3 +112,10 @@ const safelyExtractEmail = (profile) => {
 const getUsername = (email) => {
   return email ? email.split('@')[0] : '';
 };
+
+module.exports = {
+  configure,
+  get type() {
+    return type;
+  }
+};
diff --git a/src/service/routes/auth.js b/src/service/routes/auth.js
index b100b2f3..aaf2efa2 100644
--- a/src/service/routes/auth.js
+++ b/src/service/routes/auth.js
@@ -1,8 +1,8 @@
 const express = require('express');
 const router = new express.Router();
 const passport = require('../passport').getPassport();
+const authStrategies = require('../passport').authStrategies;
 const db = require('../../db');
-const passportType = passport.type;
 const { GIT_PROXY_UI_HOST: uiHost = 'http://localhost', GIT_PROXY_UI_PORT: uiPort = 3000 } = process.env;
 
 router.get('/', (req, res) => {
@@ -22,7 +22,7 @@ router.get('/', (req, res) => {
   });
 });
 
-router.post('/login', passport.authenticate(passportType), async (req, res) => {
+router.post('/login', passport.authenticate(authStrategies['local'].type), async (req, res) => {
   try {
     const currentUser = { ...req.user };
     delete currentUser.password;
@@ -42,10 +42,10 @@ router.post('/login', passport.authenticate(passportType), async (req, res) => {
   }
 });
 
-router.get('/oidc', passport.authenticate(passportType));
+router.get('/oidc', passport.authenticate(authStrategies['openidconnect'].type));
 
 router.get('/oidc/callback', (req, res, next) => {
-  passport.authenticate(passportType, (err, user, info) => {
+  passport.authenticate(authStrategies['openidconnect'].type, (err, user, info) => {
     if (err) {
       console.error('Authentication error:', err);
       return res.status(401).end();
diff --git a/src/ui/components/RouteGuard/RouteGuard.tsx b/src/ui/components/RouteGuard/RouteGuard.tsx
index d3146b63..5004360f 100644
--- a/src/ui/components/RouteGuard/RouteGuard.tsx
+++ b/src/ui/components/RouteGuard/RouteGuard.tsx
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
 import { Navigate } from 'react-router-dom';
 import { useAuth } from '../../auth/AuthProvider';
 import { getUIRouteAuth } from '../../services/config';
+import CircularProgress from '@material-ui/core/CircularProgress';
 
 interface RouteGuardProps {
   component: React.ComponentType<any>;
@@ -43,7 +44,7 @@ const RouteGuard = ({ component: Component, fullRoutePath }: RouteGuardProps) =>
   }, [fullRoutePath]);
 
   if (!authChecked || isLoading) {
-    return <div>Loading...</div>; // TODO: Add a skeleton loader or spinner
+    return <CircularProgress />;
   }
 
   if (loginRequired && !user) {
diff --git a/src/ui/views/RepoList/Components/RepoOverview.jsx b/src/ui/views/RepoList/Components/RepoOverview.jsx
index 9e98886d..826f78c9 100644
--- a/src/ui/views/RepoList/Components/RepoOverview.jsx
+++ b/src/ui/views/RepoList/Components/RepoOverview.jsx
@@ -1,6 +1,5 @@
 import React, { useEffect } from 'react';
-import TableCell from '@material-ui/core/TableCell';
-import TableRow from '@material-ui/core/TableRow';
+import { Snackbar, TableCell, TableRow } from '@material-ui/core';
 import GridContainer from '../../../components/Grid/GridContainer';
 import GridItem from '../../../components/Grid/GridItem';
 import { CodeReviewIcon, LawIcon, PeopleIcon } from '@primer/octicons-react';
@@ -572,6 +571,9 @@ import CodeActionButton from '../../../components/CustomButtons/CodeActionButton
 export default function Repositories(props) {
   const [github, setGitHub] = React.useState({});
 
+  const [errorMessage, setErrorMessage] = React.useState('');
+  const [snackbarOpen, setSnackbarOpen] = React.useState(false);
+
   useEffect(() => {
     getGitHubRepository();
   }, [props.data.project, props.data.name]);
@@ -582,8 +584,9 @@ export default function Repositories(props) {
       .then((res) => {
         setGitHub(res.data);
       })
-      .catch((err) => {
-        console.error(`Error fetching GitHub repository ${props.data.project}/${props.data.name}: ${err}`);
+      .catch((error) => {
+        setErrorMessage(`Error fetching GitHub repository ${props.data.project}/${props.data.name}: ${error}`);
+        setSnackbarOpen(true);
       });
   };
 
@@ -672,6 +675,13 @@ export default function Repositories(props) {
           <CodeActionButton cloneURL={cloneURL} />
         </div>
       </TableCell>
+      <Snackbar
+        anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
+        open={snackbarOpen}
+        autoHideDuration={6000}
+        onClose={() => setSnackbarOpen(false)}
+        message={errorMessage}
+      />
     </TableRow>
   );
 }
diff --git a/website/docs/quickstart/approve.mdx b/website/docs/quickstart/approve.mdx
index 8f01e96a..ebcd59ce 100644
--- a/website/docs/quickstart/approve.mdx
+++ b/website/docs/quickstart/approve.mdx
@@ -21,7 +21,7 @@ All pushes that flow through GitProxy require an approval (authorisation). Until
 Following on from [Push via GitProxy](/docs/quickstart/intercept#push-via-git-proxy), a unique & shareable link is generated:
 
 ```
-http://localhost:8080/admin/push/0000000000000000000000000000000000000000__79b4d8953cbc324bcc1eb53d6412ff89666c241f
+http://localhost:8080/dashboard/push/0000000000000000000000000000000000000000__79b4d8953cbc324bcc1eb53d6412ff89666c241f
 ```
 
 The `ID` for your push corresponds to the last part of the URL:
@@ -174,7 +174,7 @@ Following on from [Push via GitProxy](/docs/quickstart/intercept#push-via-git-pr
 remote: GitProxy has received your push ✅
 remote:
 remote: 🔗 Shareable Link
-remote: http://localhost:8080/admin/push/000000__b12557
+remote: http://localhost:8080/dashboard/push/000000__b12557
 ```
 
 Insert the URL directly into your web browser.
diff --git a/website/docs/quickstart/intercept.mdx b/website/docs/quickstart/intercept.mdx
index d3b5534b..1ac8e601 100644
--- a/website/docs/quickstart/intercept.mdx
+++ b/website/docs/quickstart/intercept.mdx
@@ -93,7 +93,7 @@ remote:
 remote: GitProxy has received your push ✅
 remote:
 remote: 🔗 Shareable Link
-remote: http://localhost:8080/admin/push/000000__b12557
+remote: http://localhost:8080/dashboard/push/000000__b12557
 remote:
 ```
 



Test patch:
diff --git a/test/testAuthMethods.test.js b/test/testAuthMethods.test.js
new file mode 100644
index 00000000..013c79d8
--- /dev/null
+++ b/test/testAuthMethods.test.js
@@ -0,0 +1,61 @@
+const chai = require('chai');
+const config = require('../src/config');
+const sinon = require('sinon');
+const proxyquire = require('proxyquire');
+
+chai.should();
+const expect = chai.expect;
+
+describe('auth methods', async () => {
+  it('should return a local auth method by default', async function () {
+    const authMethods = config.getAuthMethods();
+    expect(authMethods).to.have.lengthOf(1);
+    expect(authMethods[0].type).to.equal('local');
+  });
+
+  it('should return an error if no auth methods are enabled', async function () {
+    const newConfig = JSON.stringify({
+      authentication: [
+        { type: 'local', enabled: false },
+        { type: 'ActiveDirectory', enabled: false },
+        { type: 'openidconnect', enabled: false },
+      ],
+    });
+  
+    const fsStub = {
+      existsSync: sinon.stub().returns(true),
+      readFileSync: sinon.stub().returns(newConfig),
+    };
+  
+    const config = proxyquire('../src/config', {
+      fs: fsStub,
+    });
+  
+    expect(() => config.getAuthMethods()).to.throw(Error, 'No authentication method enabled');
+  });
+
+  it('should return an array of enabled auth methods when overridden', async function () {
+    const newConfig = JSON.stringify({
+      authentication: [
+        { type: 'local', enabled: true },
+        { type: 'ActiveDirectory', enabled: true },
+        { type: 'openidconnect', enabled: true },
+      ],
+    });
+
+    const fsStub = {
+      existsSync: sinon.stub().returns(true),
+      readFileSync: sinon.stub().returns(newConfig),
+    };
+
+    const config = proxyquire('../src/config', {
+      fs: fsStub,
+    });
+
+    const authMethods = config.getAuthMethods();
+    expect(authMethods).to.have.lengthOf(3);
+    expect(authMethods[0].type).to.equal('local');
+    expect(authMethods[1].type).to.equal('ActiveDirectory');
+    expect(authMethods[2].type).to.equal('openidconnect');
+  })
+});
diff --git a/test/testConfig.test.js b/test/testConfig.test.js
index 23b3b102..08d528e2 100644
--- a/test/testConfig.test.js
+++ b/test/testConfig.test.js
@@ -11,8 +11,9 @@ describe('default configuration', function () {
   it('should use default values if no user-settings.json file exists', function () {
     const config = require('../src/config');
     config.logConfiguration();
+    const enabledMethods = defaultSettings.authentication.filter(method => method.enabled);
 
-    expect(config.getAuthentication()).to.be.eql(defaultSettings.authentication[0]);
+    expect(config.getAuthMethods()).to.deep.equal(enabledMethods);
     expect(config.getDatabase()).to.be.eql(defaultSettings.sink[0]);
     expect(config.getTempPasswordConfig()).to.be.eql(defaultSettings.tempPassword);
     expect(config.getAuthorisedList()).to.be.eql(defaultSettings.authorisedList);
@@ -48,9 +49,10 @@ describe('user configuration', function () {
     fs.writeFileSync(tempUserFile, JSON.stringify(user));
 
     const config = require('../src/config');
+    const enabledMethods = defaultSettings.authentication.filter(method => method.enabled);
 
     expect(config.getAuthorisedList()).to.be.eql(user.authorisedList);
-    expect(config.getAuthentication()).to.be.eql(defaultSettings.authentication[0]);
+    expect(config.getAuthMethods()).to.deep.equal(enabledMethods);
     expect(config.getDatabase()).to.be.eql(defaultSettings.sink[0]);
     expect(config.getTempPasswordConfig()).to.be.eql(defaultSettings.tempPassword);
   });
@@ -67,9 +69,13 @@ describe('user configuration', function () {
     fs.writeFileSync(tempUserFile, JSON.stringify(user));
 
     const config = require('../src/config');
+    const authMethods = config.getAuthMethods();
+    const googleAuth = authMethods.find(method => method.type === 'google');
 
-    expect(config.getAuthentication()).to.be.eql(user.authentication[0]);
-    expect(config.getAuthentication()).to.not.be.eql(defaultSettings.authentication[0]);
+    expect(googleAuth).to.not.be.undefined;
+    expect(googleAuth.enabled).to.be.true;
+    expect(config.getAuthMethods()).to.deep.include(user.authentication[0]);
+    expect(config.getAuthMethods()).to.not.be.eql(defaultSettings.authentication);
     expect(config.getDatabase()).to.be.eql(defaultSettings.sink[0]);
     expect(config.getTempPasswordConfig()).to.be.eql(defaultSettings.tempPassword);
   });
@@ -86,10 +92,11 @@ describe('user configuration', function () {
     fs.writeFileSync(tempUserFile, JSON.stringify(user));
 
     const config = require('../src/config');
+    const enabledMethods = defaultSettings.authentication.filter(method => method.enabled);
 
     expect(config.getDatabase()).to.be.eql(user.sink[0]);
     expect(config.getDatabase()).to.not.be.eql(defaultSettings.sink[0]);
-    expect(config.getAuthentication()).to.be.eql(defaultSettings.authentication[0]);
+    expect(config.getAuthMethods()).to.deep.equal(enabledMethods);
     expect(config.getTempPasswordConfig()).to.be.eql(defaultSettings.tempPassword);
   });
 
diff --git a/test/testLogin.test.js b/test/testLogin.test.js
index 833184e0..107bb725 100644
--- a/test/testLogin.test.js
+++ b/test/testLogin.test.js
@@ -62,6 +62,22 @@ describe('auth', async () => {
 
       res.should.have.status(401);
     });
+
+    it('should fail to login with invalid username', async function () {
+      const res = await chai.request(app).post('/api/auth/login').send({
+        username: 'invalid',
+        password: 'admin',
+      });
+      res.should.have.status(401);
+    });
+
+    it('should fail to login with invalid password', async function () {
+      const res = await chai.request(app).post('/api/auth/login').send({
+        username: 'admin',
+        password: 'invalid',
+      });
+      res.should.have.status(401);
+    });
   });
 
   after(async function () {