Add Kill Switch section

Currently there's a nasty bug which doesn't allow users from navigating
from the "Kill Switch" section to any other section of the app (see bug
twpowertools:59), but everything else is working.

Change-Id: I3c71352b1899e4ddf9ba5886aa1434a5a1ed93eb
diff --git a/frontend/src/components/EnableKillSwitchDialog.vue b/frontend/src/components/EnableKillSwitchDialog.vue
new file mode 100644
index 0000000..8cbe647
--- /dev/null
+++ b/frontend/src/components/EnableKillSwitchDialog.vue
@@ -0,0 +1,165 @@
+<script>
+import {browsers} from '../consts.js';
+
+import * as ksObjectsPb from '../api_proto/kill_switch_objects_pb.js';
+import * as ksPb from '../api_proto/kill_switch_pb.js';
+import * as commonPb from '../api_proto/common_pb.js';
+
+import KillSwitch from './KillSwitch.vue';
+
+let browserOptions = {...browsers};
+delete browserOptions['0'];
+let defaultBrowsers = Object.keys(browserOptions);
+
+export default {
+  components: {
+    KillSwitch,
+  },
+  props: {
+    modelValue: Boolean,
+  },
+  emits: [
+    'killSwitchAdded',
+  ],
+  data() {
+    return {
+      secondOpen: false,
+      featuresList: [],
+      browserOptions,
+      feature: "",
+      minVersion: "",
+      maxVersion: "",
+      browsers: [...defaultBrowsers],
+    };
+  },
+  mounted() {
+    this.loadData();
+  },
+  computed: {
+    killSwitch() {
+      let ks = new ksObjectsPb.KillSwitch();
+      for (let f of this.featuresList) {
+        if (f.getId() == this.feature) {
+          ks.setFeature(f);
+          break;
+        }
+      }
+      ks.setMinVersion(this.minVersion);
+      ks.setMaxVersion(this.maxVersion);
+      ks.setBrowsersList(this.browsers);
+      ks.setActive(true);
+      return ks;
+    },
+  },
+  methods: {
+    loadData() {
+      let request = new ksPb.ListFeaturesRequest();
+      request.setWithDeprecatedFeatures(true);
+
+      this.$store.state.client.listFeatures(
+          request, {authorization: this.$store.state.jwtToken})
+          .then(res => {
+            this.$data.featuresList = res.getFeaturesList();
+          })
+          .catch(err => console.error(err));
+    },
+    onCancel() {
+      this.secondOpen = false;
+      this.feature = "";
+      this.minVersion = "";
+      this.maxVersion = "";
+      this.browsers = [...defaultBrowsers];
+    },
+    onNext() {
+      this.secondOpen = true;
+    },
+    onPrevious() {
+      this.secondOpen = false;
+      this.$emit('update:modelValue', true);
+    },
+    onConfirm() {
+      let request = new ksPb.EnableKillSwitchRequest();
+      request.setKillSwitch(this.killSwitch.cloneMessage());
+
+      this.$store.state.client.enableKillSwitch(
+          request, {authorization: this.$store.state.jwtToken})
+          .then(res => {
+            this.$emit('killSwitchAdded');
+            this.onCancel();
+          })
+          .catch(err => console.error(err));
+    },
+  },
+};
+</script>
+
+<template>
+  <mcw-dialog
+      v-model="modelValue"
+      @update:modelValue="$emit('update:modelValue', $event)"
+      escape-key-action="close"
+      scrim-click-action="close"
+      :auto-stack-buttons="true">
+    <mcw-dialog-title>Enable Kill Switch</mcw-dialog-title>
+    <mcw-dialog-content>
+      <div>
+        <p>
+          <mcw-select ref="featureSelect" v-model="feature" label="Feature to disable" required>
+            <mcw-list-item data-value="" role="option"></mcw-list-item>
+            <mcw-list-item v-for="feature in featuresList" :data-value="feature.getId()" role="option">
+              {{ feature.getCodename() }}
+            </mcw-list-item>
+          </mcw-select>
+        </p>
+        <p>
+          <span class="helper-text">Disable the feature on extension versions greater or equal to:</span><br>
+          <mcw-textfield v-model="minVersion" label="Minimum version" helptext="(optional)" helptext-persistent fullwidth />
+        </p>
+        <p>
+          <span class="helper-text">Disable the feature on extension versions less or equal to:</span><br>
+          <mcw-textfield v-model="maxVersion" label="Maximum version" helptext="(optional)" helptext-persistent fullwidth />
+        </p>
+        <p>
+          <span class="helper-text">Disable in the following browsers:</span>
+          <template v-for="(b, i) in browserOptions"><br><mcw-checkbox :value="i" v-model="browsers" :label="b" /></template>
+        </p>
+      </div>
+    </mcw-dialog-content>
+    <mcw-dialog-footer>
+      <mcw-dialog-button @click="onCancel" action="dismiss">Cancel</mcw-dialog-button>
+      <mcw-dialog-button @click="onNext" action="accept">Next</mcw-dialog-button>
+    </mcw-dialog-footer>
+  </mcw-dialog>
+  <mcw-dialog
+      v-model="secondOpen"
+      escape-key-action="close"
+      scrim-click-action="close"
+      :auto-stack-buttons="true">
+    <mcw-dialog-title>Enable Kill Switch?</mcw-dialog-title>
+    <mcw-dialog-content>
+      <div>
+        <p>This will disable the feature globally for all the extension users, if they use one of the versions and browsers specified.</p>
+        <p>Please take a look at this preview and confirm that this is indeed what you want to do:</p>
+        <kill-switch :kill-switch="killSwitch" />
+      </div>
+    </mcw-dialog-content>
+    <mcw-dialog-footer>
+      <mcw-dialog-button @click="onPrevious">Previous</mcw-dialog-button>
+      <mcw-dialog-button class="confirm-bad" @click="onConfirm" action="accept">Confirm</mcw-dialog-button>
+    </mcw-dialog-footer>
+  </mcw-dialog>
+</template>
+
+<style scoped lang="scss">
+@use "@material/theme/color-palette" as palette;
+@use "@material/button";
+
+.helper-text {
+  font-size: 14px;
+  color: palette.$grey-700;
+}
+
+.confirm-bad {
+  @include button.ink-color(palette.$red-500);
+}
+</style>
diff --git a/frontend/src/components/KillSwitch.vue b/frontend/src/components/KillSwitch.vue
new file mode 100644
index 0000000..6fd45c4
--- /dev/null
+++ b/frontend/src/components/KillSwitch.vue
@@ -0,0 +1,107 @@
+<script>
+import {browsers} from '../consts.js';
+
+export default {
+  props: {
+    killSwitch: Object,
+    canDisable: Boolean,
+  },
+  emits: [
+    'wantsToDisableKillSwitch',
+  ],
+  computed: {
+    versionsText() {
+      if (this.killSwitch?.getMinVersion() == '' && this.killSwitch?.getMaxVersion() == '')
+        return 'All';
+
+      return 'From ' + (this.killSwitch?.getMinVersion() || '...') + ' to '+ (this.killSwitch?.getMaxVersion() || '...');
+    },
+    browsersText() {
+      return this.killSwitch?.getBrowsersList()?.map(b => browsers[parseInt(b)]).join(', ') ?? 'undefined';
+    },
+  },
+};
+</script>
+
+<template>
+  <div class="main">
+    <mcw-button @click="$emit('wantsToDisableKillSwitch', killSwitch)" class="disable-btn" v-if="canDisable && killSwitch?.getActive()">
+      Disable
+    </mcw-button>
+    <div class="feature-name">{{ killSwitch?.getFeature()?.getCodename() }}</div>
+    <div class="status"><span class="status--label">Status:</span> <span class="status-text" :class="{'status-text--active': killSwitch?.getActive()}">{{ killSwitch?.getActive() ? 'active (feature is force disabled)' : 'no longer active' }}</span></div>
+    <div class="details-container">
+      <div class="details">
+        <div class="details--title">Versions affected</div>
+        <div class="details--content">{{ versionsText }}</div>
+      </div>
+      <div class="details">
+        <div class="details--title">Browsers affected</div>
+        <div class="details--content">{{ browsersText }}</div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped lang="scss">
+@use "@material/theme/color-palette" as palette;
+
+.main {
+  position: relative;
+  width: calc(100% - 32px);
+  max-width: 500px;
+  padding: 16px;
+  border: solid 1px palette.$grey-500;
+  border-radius: 4px;
+}
+
+.disable-btn {
+  position: absolute;
+  top: 16px;
+  right: 16px;
+}
+
+.feature-name {
+  color: palette.$grey-900;
+  font-family: 'Roboto Mono', monospace;
+  font-weight: 500;
+  font-size: 20px;
+  margin-bottom: 8px;
+}
+
+.status {
+  color: palette.$grey-700;
+}
+
+.status--label {
+  font-weight: 500;
+}
+
+.status-text.status-text--active {
+  color: palette.$red-700;
+}
+
+.details-container {
+  margin-top: 16px;
+  display: flex;
+  flex-direction: row;
+  justify-content: space-around;
+}
+
+.details {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
+.details--title {
+  font-weight: 500;
+  font-size: 17px;
+  text-align: center;
+}
+
+.details--content {
+  font-size: 15px;
+  text-align: center;
+}
+</style>
diff --git a/frontend/src/consts.js b/frontend/src/consts.js
index e570b66..3d72cbc 100644
--- a/frontend/src/consts.js
+++ b/frontend/src/consts.js
@@ -3,3 +3,9 @@
   5: "Kill Switch Activator",
   10: "Admin",
 });
+
+export const browsers = Object.freeze({
+  0: "Unknown",
+  1: "Chromium",
+  2: "Firefox",
+});
diff --git a/frontend/src/pages/AuthorizedUsers.vue b/frontend/src/pages/AuthorizedUsers.vue
index 2f2b326..1ba31e8 100644
--- a/frontend/src/pages/AuthorizedUsers.vue
+++ b/frontend/src/pages/AuthorizedUsers.vue
@@ -1,4 +1,6 @@
 <script>
+import {mapGetters} from 'vuex';
+
 import NotAuthorized from './NotAuthorized.vue';
 import Page from './utils/Page.vue';
 import AuthorizedUserDialog from '../components/AuthorizedUserDialog.vue';
@@ -14,8 +16,8 @@
 
     return {
       users: [],
-      currentUpdateUser: emptyUser, // Current user being updated
-      currentDeleteUser: emptyUser, // Current user being confirmed deletion
+      currentUpdateUser: emptyUser.cloneMessage(), // Current user being updated
+      currentDeleteUser: emptyUser.cloneMessage(), // Current user being confirmed deletion
       addDialogOpen: false,
       updateDialogOpen: false,
       deleteDialogOpen: false,
@@ -67,9 +69,9 @@
     },
   },
   computed: {
-    isSignedIn() {
-      return this.$store.state.jwtToken != null;
-    },
+    ...mapGetters([
+      'isSignedIn',
+    ]),
   },
 };
 </script>
diff --git a/frontend/src/pages/KillSwitches.vue b/frontend/src/pages/KillSwitches.vue
index e4fd9f6..1ea8d56 100644
--- a/frontend/src/pages/KillSwitches.vue
+++ b/frontend/src/pages/KillSwitches.vue
@@ -1,7 +1,129 @@
 <script>
-export default {};
+import {mapGetters} from 'vuex';
+
+import * as ksPb from '../api_proto/kill_switch_pb.js';
+import * as ksObjectsPb from '../api_proto/kill_switch_objects_pb.js';
+
+import EnableKillSwitchDialog from '../components/EnableKillSwitchDialog.vue';
+import KillSwitch from '../components/KillSwitch.vue';
+import MiniMessage from './MiniMessage.vue';
+
+let emptyKillSwitch = new ksObjectsPb.KillSwitch();
+
+export default {
+  data() {
+    return {
+      enableKillSwitchDialog: false,
+      disableDialogOpen: false,
+      currentDisableKillSwitch: emptyKillSwitch.cloneMessage(),
+      killSwitches: [],
+    };
+  },
+  components: {
+    EnableKillSwitchDialog,
+    KillSwitch,
+    MiniMessage,
+  },
+  computed: {
+    ...mapGetters([
+      'isSignedIn',
+    ]),
+    activeKillSwitches() {
+      return this.killSwitches.filter(ks => ks.getActive());
+    },
+  },
+  mounted() {
+    this.loadData();
+  },
+  methods: {
+    loadData() {
+      let request = new ksPb.GetKillSwitchOverviewRequest();
+
+      this.$store.state.client.getKillSwitchOverview(request)
+          .then(res => {
+            this.killSwitches = res.getKillSwitchesList();
+          })
+          .catch(err => console.error(err));
+    },
+    showDisableKillSwitchDialog(killSwitch) {
+      this.currentDisableKillSwitch = killSwitch;
+      this.disableDialogOpen = true;
+    },
+    disableKillSwitch() {
+      let request = new ksPb.DisableKillSwitchRequest();
+      request.setKillSwitchId(this.currentDisableKillSwitch.getId());
+
+      this.$store.state.client.disableKillSwitch(
+          request, {authorization: this.$store.state.jwtToken})
+          .then(res => {
+            this.loadData();
+            this.disableDialogOpen = false;
+            this.currentDisableKillSwitch = emptyKillSwitch.cloneMessage();
+          })
+          .catch(err => console.error(err));
+    },
+  },
+};
 </script>
 
 <template>
-  Kill switches!
+  <mini-message icon="toggle_off" v-if="activeKillSwitches?.length == 0">
+    There aren't any kill switches enabled currently.<br>
+    Everything's working normally.
+  </mini-message>
+  <template v-else>
+    <div class="kill-switch-container">
+      <kill-switch
+          class="kill-switch"
+          :kill-switch="killSwitch"
+          :can-disable="isSignedIn"
+          @wantsToDisableKillSwitch="showDisableKillSwitchDialog($event)"
+          v-for="killSwitch in activeKillSwitches" />
+    </div>
+  </template>
+
+  <template v-if="isSignedIn">
+    <mcw-fab @click="enableKillSwitchDialog = true" class="enable-kill-switch" icon="add" />
+
+    <mcw-dialog
+        v-model="disableDialogOpen"
+        escape-key-action="close"
+        scrim-click-action="close"
+        aria-labelledby="disable-title"
+        aria-describedby="disable-content"
+        :auto-stack-buttons="true">
+      <mcw-dialog-title id="disable-title">Disable kill switch?</mcw-dialog-title>
+      <mcw-dialog-content id="disable-content">
+        <div>Feature <span class="feature-codename">{{ currentDisableKillSwitch.getFeature()?.getCodename() }}</span> will be progressively enabled to all the users of the extension.</div>
+      </mcw-dialog-content>
+      <mcw-dialog-footer>
+        <mcw-dialog-button action="dismiss">Cancel</mcw-dialog-button>
+        <mcw-dialog-button @click="disableKillSwitch()" action="accept">Confirm</mcw-dialog-button>
+      </mcw-dialog-footer>
+    </mcw-dialog>
+
+    <enable-kill-switch-dialog @kill-switch-added="loadData" v-model="enableKillSwitchDialog" />
+  </template>
 </template>
+
+<style scoped>
+.kill-switch-container {
+  font-family: 'Roboto', 'Helvetica', 'Arial', sans-serif;
+  max-width: 500px;
+  margin: 16px auto;
+}
+
+.kill-switch {
+  margin-bottom: 16px;
+}
+
+.enable-kill-switch {
+  position: fixed;
+  right: 16px;
+  bottom: 16px;
+}
+
+.feature-codename {
+  font-family: 'Roboto Mono', monospace;
+}
+</style>
diff --git a/frontend/src/pages/MiniMessage.vue b/frontend/src/pages/MiniMessage.vue
new file mode 100644
index 0000000..8f87ca8
--- /dev/null
+++ b/frontend/src/pages/MiniMessage.vue
@@ -0,0 +1,42 @@
+<script>
+import Page from './utils/Page.vue';
+
+export default {
+  props: [
+    'icon',
+  ],
+  components: {
+    Page,
+  },
+};
+</script>
+
+<template>
+  <page mini>
+    <div class="layout">
+      <mcw-material-icon class="main-icon" :icon="icon" />
+      <span class="label"><slot></slot></span>
+    </div>
+  </page>
+</template>
+
+<style lang="scss" scoped>
+@use "@material/theme/color-palette" as palette;
+
+.layout {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  margin: 16px 0;
+}
+
+.main-icon {
+  font-size: 75px;
+  color: palette.$grey-800;
+}
+
+.label {
+  margin-top: 14px;
+  text-align: center;
+}
+</style>
diff --git a/frontend/src/pages/NotAuthorized.vue b/frontend/src/pages/NotAuthorized.vue
index f0b0a49..99b81ff 100644
--- a/frontend/src/pages/NotAuthorized.vue
+++ b/frontend/src/pages/NotAuthorized.vue
@@ -1,38 +1,13 @@
 <script>
-import Page from './utils/Page.vue';
+import MiniMessage from './MiniMessage.vue';
 
 export default {
   components: {
-    Page,
+    MiniMessage,
   },
 };
 </script>
 
 <template>
-  <page mini>
-    <div class="layout">
-      <mcw-material-icon class="main-icon" icon="no_accounts" />
-      <span class="label">You're not signed in.</span>
-    </div>
-  </page>
+  <mini-message icon="no_accounts">You're not signed in.</mini-message>
 </template>
-
-<style lang="scss" scoped>
-@use "@material/theme/color-palette" as palette;
-
-.layout {
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  margin: 16px 0;
-}
-
-.main-icon {
-  font-size: 75px;
-  color: palette.$grey-800;
-}
-
-.label {
-  margin-top: 14px;
-}
-</style>
diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js
index 9dbf288..0666838 100644
--- a/frontend/src/store/index.js
+++ b/frontend/src/store/index.js
@@ -29,7 +29,12 @@
   },
   actions: {
     connectClient(store, host) {
+      // We enable the dev tools in case they are useful sometime in the future.
+      const enableDevTools = window.__GRPCWEB_DEVTOOLS__ || (() => {});
       store.state.client = new KillSwitchServicePromiseClient(host, null, null);
+      enableDevTools([
+        store.state.client,
+      ]);
     },
   },
 });
diff --git a/frontend/src/theme.scss b/frontend/src/theme.scss
index 0c4e5a8..c0d06ac 100644
--- a/frontend/src/theme.scss
+++ b/frontend/src/theme.scss
@@ -20,6 +20,7 @@
 @use "@material/notched-outline/mdc-notched-outline";
 @use "@material/textfield";
 @use "@material/select/styles" as b-style; // https://github.com/sass/sass/issues/2778#issuecomment-558365532
+@use "@material/form-field";
 
 @include drawer.core-styles;
 @include drawer.dismissible-core-styles;
@@ -32,6 +33,8 @@
 @include dialog.core-styles;
 @include fab.core-styles;
 @include textfield.core-styles;
+@include checkbox.core-styles;
+@include form-field.core-styles;
 
 html {
   height: 100%;
diff --git a/frontend/src/vma.js b/frontend/src/vma.js
index 07b0727..88563d5 100644
--- a/frontend/src/vma.js
+++ b/frontend/src/vma.js
@@ -1,9 +1,10 @@
 // We just import the components needed.
-import {button, dataTable, dialog, drawer, fab, iconButton, list, materialIcon, menu, select, textfield, topAppBar} from 'vue-material-adapter';
+import {button, checkbox, dataTable, dialog, drawer, fab, iconButton, list, materialIcon, menu, select, textfield, topAppBar} from 'vue-material-adapter';
 
 export default {
   install(vm) {
     vm.use(button);
+    vm.use(checkbox);
     vm.use(dataTable);
     vm.use(dialog);
     vm.use(drawer);