Repository: OWASP/threat-dragon
Pull request #801 (2023-11-25 12:59:03)

Server config driven providers on HomePage.vue
**Summary**:
As a pre-req for #426 and #61 enabling the homepage to only render login options that are configured. Ignored for Electron. 

**Description for the changelog**:
Select which login options to render based on the configured environment variables, currently this is a check if the related **_CLIENT_ID is configured. 

**Other info**:
Environment variables likely require refactoring.


Patch:
diff --git a/td.server/package-lock.json b/td.server/package-lock.json
index dde83f3c..213750c5 100644
--- a/td.server/package-lock.json
+++ b/td.server/package-lock.json
@@ -10,8 +10,8 @@
       "license": "Apache-2.0",
       "dependencies": {
         "@babel/runtime": "^7.21.0",
-        "bitbucket": "^2.11.0",
         "axios": "^1.6.0",
+        "bitbucket": "^2.11.0",
         "dotenv": "^16.0.3",
         "express": "^4.18.2",
         "express-rate-limit": "^6.7.0",
diff --git a/td.server/src/config/routes.config.js b/td.server/src/config/routes.config.js
index 16b70b18..c50f84a3 100644
--- a/td.server/src/config/routes.config.js
+++ b/td.server/src/config/routes.config.js
@@ -2,6 +2,7 @@ import express from 'express';
 
 import auth from '../controllers/auth.js';
 import bearer from './bearer.config.js';
+import configController from "../controllers/configcontroller";
 import healthcheck from '../controllers/healthz.js';
 import homeController from '../controllers/homecontroller.js';
 import threatmodelController from '../controllers/threatmodelcontroller.js';
@@ -16,6 +17,8 @@ const unauthRoutes = (router) => {
     router.get('/', homeController.index);
     router.get('/healthz', healthcheck.healthz);
 
+    router.get('/api/config', configController.config);
+
     router.get('/api/threatmodel/organisation', threatmodelController.organisation);
 
     router.get('/api/login/:provider', auth.login);
diff --git a/td.server/src/controllers/configcontroller.js b/td.server/src/controllers/configcontroller.js
new file mode 100644
index 00000000..f3be73af
--- /dev/null
+++ b/td.server/src/controllers/configcontroller.js
@@ -0,0 +1,22 @@
+import env from "../env/Env";
+import loggerHelper from '../helpers/logger.helper.js';
+import responseWrapper from "./responseWrapper";
+
+const logger = loggerHelper.get('controllers/configcontroller.js');
+
+/**
+ * @param {Object} req
+ * @param {Object} res
+ * @returns {Object}
+ */
+const config = (req, res) => responseWrapper.sendResponse(() => getConfig(), req, res, logger);
+
+export const getConfig = () => ({
+        bitbucketEnabled:  env.get().config.BITBUCKET_CLIENT_ID !== undefined && env.get().config.BITBUCKET_CLIENT_ID !== null,
+        githubEnabled:  env.get().config.GITHUB_CLIENT_ID !== undefined && env.get().config.GITHUB_CLIENT_ID !== null,
+        localEnabled:  true,
+    });
+
+export default {
+    config
+};
diff --git a/td.server/src/controllers/responseWrapper.js b/td.server/src/controllers/responseWrapper.js
index a64d1a8d..347197e3 100644
--- a/td.server/src/controllers/responseWrapper.js
+++ b/td.server/src/controllers/responseWrapper.js
@@ -25,7 +25,7 @@ const sendResponse = (fn, req, res, logger) => {
 /**
  * Wraps an async function (promise) to be sent using a standardized JSON format
  * This catches errors and logs them using the logger
- * @param {Promise} asyncFn
+ * @param {function(): Promise<{localEnabled: boolean, githubEnabled, bitbucketEnabled}>} asyncFn
  * @param {*} req
  * @param {*} res
  * @param {*} logger
diff --git a/td.vue/src/store/actions/config.js b/td.vue/src/store/actions/config.js
new file mode 100644
index 00000000..b4a00267
--- /dev/null
+++ b/td.vue/src/store/actions/config.js
@@ -0,0 +1,9 @@
+export const CONFIG_CLEAR = 'CONFIG_CLEAR';
+export const CONFIG_FETCH = 'CONFIG_FETCH';
+export const CONFIG_LOADED = 'CONFIG_LOADED';
+
+export default {
+    clear: CONFIG_CLEAR,
+    fetch: CONFIG_FETCH,
+    loaded: CONFIG_LOADED,
+};
diff --git a/td.vue/src/store/index.js b/td.vue/src/store/index.js
index 79a1df58..5ef5e9b9 100644
--- a/td.vue/src/store/index.js
+++ b/td.vue/src/store/index.js
@@ -4,6 +4,7 @@ import Vuex from 'vuex';
 import auth from './modules/auth.js';
 import branch from './modules/branch.js';
 import cell from './modules/cell.js';
+import config from './modules/config.js';
 import loader from './modules/loader.js';
 import locale from './modules/locale.js';
 import provider from './modules/provider.js';
@@ -27,6 +28,7 @@ const get = () => {
                 auth,
                 branch,
                 cell,
+                config,
                 loader,
                 locale,
                 provider,
diff --git a/td.vue/src/store/modules/config.js b/td.vue/src/store/modules/config.js
new file mode 100644
index 00000000..cbc92dc1
--- /dev/null
+++ b/td.vue/src/store/modules/config.js
@@ -0,0 +1,51 @@
+import {CONFIG_CLEAR, CONFIG_LOADED} from '@/store/actions/config';
+import api from '@/service/api/api';
+
+export const clearState = (state) => {
+    state.config = null;
+};
+
+const state = {
+    config: null,
+};
+
+const actions = {
+    [CONFIG_CLEAR]: ({ commit }) => commit(CONFIG_CLEAR),
+
+    CONFIG_FETCH: async ( { commit, dispatch }) => {
+        dispatch(CONFIG_CLEAR);
+        try {
+            console.log('FETCHING');
+            const response = await api.getAsync('/api/config',);
+            console.log('RETRIEVED ' + JSON.stringify(response.data));
+
+            commit(CONFIG_LOADED, { config: response.data });
+        } catch (error) {
+            console.error('Error fetching config:', error);
+        }
+    },
+};
+
+const mutations = {
+
+    [CONFIG_CLEAR]: (state) => clearState(state),
+
+    [CONFIG_LOADED]: (state, { config}) => {
+        console.log('LOADED ' + config);
+        state.config = config;
+    }
+};
+const getters = {};
+
+//
+// const getters = {
+//     config: (state) => state.config,
+// };
+
+
+export default {
+    state,
+    actions,
+    mutations,
+    getters
+};
diff --git a/td.vue/src/views/HomePage.vue b/td.vue/src/views/HomePage.vue
index fdf26219..5905ce9c 100644
--- a/td.vue/src/views/HomePage.vue
+++ b/td.vue/src/views/HomePage.vue
@@ -1,77 +1,96 @@
 <template>
-  <b-container>
-    <b-jumbotron id="welcome-jumbotron">
-      <b-row class="text-center mb-2">
-        <b-col md="12">
-          <h1 class="display-3 text-center">{{ $t("home.title") }}</h1>
-        </b-col>
-      </b-row>
-      <b-row>
-        <b-col md="4">
-          <b-img class="td-cupcake"
-            id="home-td-logo"
-            :alt="$t('home.imgAlt')"
-            src="@/assets/threatdragon_logo_image.svg"
-          />
-        </b-col>
-        <b-col md="8">
-          <b-row>
-            <p class="td-description">
-              {{ $t("home.description") }}
-            </p>
-          </b-row>
-          <b-row>
-            <b-col class="mt-5 mr-5 text-right">
-              <!-- TODO: Load providers based on server config -->
-              <td-provider-login-button
-                v-for="(provider, idx) in providers"
-                :key="idx"
-                :provider="provider"
-              />
-            </b-col>
-          </b-row>
-        </b-col>
-      </b-row>
-    </b-jumbotron>
-  </b-container>
+    <b-container>
+        <b-jumbotron id="welcome-jumbotron">
+            <b-row class="text-center mb-2">
+                <b-col md="12">
+                    <h1 class="display-3 text-center">{{ $t("home.title") }}</h1>
+                </b-col>
+            </b-row>
+            <b-row>
+                <b-col md="4">
+                    <b-img class="td-cupcake"
+                           id="home-td-logo"
+                           :alt="$t('home.imgAlt')"
+                           src="@/assets/threatdragon_logo_image.svg"
+                    />
+                </b-col>
+                <b-col md="8">
+                    <b-row>
+                        <p class="td-description">
+                            {{ $t("home.description") }}
+                        </p>
+                    </b-row>
+                    <b-row>
+                        <b-col class="mt-5 mr-5 text-right">
+                            <td-provider-login-button
+                                v-for="(provider, idx) in providers"
+                                :key="idx"
+                                :provider="provider"
+                            />
+                        </b-col>
+                    </b-row>
+                </b-col>
+            </b-row>
+        </b-jumbotron>
+    </b-container>
 </template>
 
 <style lang="scss" scoped>
 .login-btn-icon {
-  display: block;
+    display: block;
 }
 
 .td-description {
-  font-size: 20px;
-  margin-right: 20px;
-  margin-left: 170px;
+    font-size: 20px;
+    margin-right: 20px;
+    margin-left: 170px;
 }
 
 .td-cupcake {
-  margin-top: 10px;
-  margin-bottom: 20px;
-  margin-right: 20px;
-  margin-left: 20px;
+    margin-top: 10px;
+    margin-bottom: 20px;
+    margin-right: 20px;
+    margin-left: 20px;
 }
 </style>
 
 <script>
-import { allProviders } from '@/service/provider/providers.js';
+import {allProviders} from '@/service/provider/providers.js';
 import isElectron from 'is-electron';
 import TdProviderLoginButton from '@/components/ProviderLoginButton.vue';
+import configActions from '@/store/actions/config.js';
+import {mapState} from 'vuex';
 
 export default {
     name: 'HomePage',
-    computed: {
-        providers: () => {
-            if (isElectron()) {
-                return { desktop: allProviders.desktop };
-            }
-            return { github: allProviders.github, bitbucket: allProviders.bitbucket, local: allProviders.local };
-        },
+    computed:
+        mapState({
+            config: state => {
+                return state.config.config;
+            },
+            providers: (state) => {
+                if (isElectron()) {
+                    return {desktop: allProviders.desktop};
+                }
+                let providers = {};
+                if (state.config.config.githubEnabled){
+                    providers.github = allProviders.github;
+                }
+                if (state.config.config.bitbucketEnabled){
+                    providers.bitbucket = allProviders.bitbucket;
+                }
+                if (state.config.config.localEnabled){
+                    providers.local = allProviders.local;
+                }
+                return providers;
+            },
+        }),
+    mounted() {
+        if (!isElectron()) {
+            this.$store.dispatch(configActions.fetch);
+        }
     },
     components: {
         TdProviderLoginButton,
-    },
-};
+    },};
 </script>


Test patch:
diff --git a/td.server/test/config/routes.config.spec.js b/td.server/test/config/routes.config.spec.js
index bc214962..d4045c6b 100644
--- a/td.server/test/config/routes.config.spec.js
+++ b/td.server/test/config/routes.config.spec.js
@@ -9,6 +9,7 @@ import homeController from '../../src/controllers/homecontroller.js';
 import { getMockApp } from '../mocks/express.mocks.js';
 import routeConfig from '../../src/config/routes.config.js';
 import threatmodelController from '../../src/controllers/threatmodelcontroller.js';
+import configcontroller from "../../src/controllers/configcontroller";
 
 describe('config/routes.config.js routes', () => {
     let mockApp;
@@ -46,6 +47,10 @@ describe('config/routes.config.js routes', () => {
         it('routes GET /healthz', () => {
             expect(mockRouter.get).to.have.been.calledWith('/healthz', healthcheck.healthz);
         });
+
+        it('routes GET /api/config', () => {
+            expect(mockRouter.get).to.have.been.calledWith('/api/config', configcontroller.config);
+        });
     });
 
     describe('login/logout', () => {
diff --git a/td.vue/tests/unit/views/homePage.spec.js b/td.vue/tests/unit/views/homePage.spec.js
index b697c273..340216ad 100644
--- a/td.vue/tests/unit/views/homePage.spec.js
+++ b/td.vue/tests/unit/views/homePage.spec.js
@@ -22,6 +22,13 @@ describe('HomePage.vue', () => {
             localVue.use(BootstrapVue);
             localVue.component('font-awesome-icon', FontAwesomeIcon);
             mockStore = new Vuex.Store({
+                state: {
+                    config: {
+                        config: {
+                            githubEnabled: true,
+                        },
+                    }
+                },
                 actions: {
                     [AUTH_SET_LOCAL]: () => {},
                     [PROVIDER_SELECTED]: () => {}
@@ -52,28 +59,28 @@ describe('HomePage.vue', () => {
             it('renders the home view', () => {
                 expect(wrapper.exists()).toBe(true);
             });
-        
+
             it('has a b-container', () => {
                 expect(wrapper.findComponent(BContainer).exists()).toBe(true);
             });
-        
+
             it('has a jumbotron', () => {
                 expect(wrapper.findComponent(BJumbotron).exists()).toBe(true);
             });
-        
+
             it('displays the title', () => {
                 expect(wrapper.find('h1.display-3').text()).toContain('home.title');
             });
-        
+
             it('displays the threat dragon logo', () => {
                 expect(wrapper.findComponent(BImg).attributes('src'))
                     .toContain('threatdragon_logo_image');
             });
-        
+
             it('has the description of the project', () => {
                 expect(wrapper.find('p').exists()).toBe(true);
             });
-        
+
             it('has a login button', () => {
                 expect(wrapper.findComponent(TdProviderLoginButton).exists()).toEqual(true);
             });