Online TOTP Generatorつくってみた【ワンタイムパスワード生成】

<p class="codepen" data-height="640" data-default-tab="result" data-slug-hash="KKbdbNO" data-user="04" style="height: 640px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/04/pen/KKbdbNO">
  TOTP Generator</a> by John Doe (<a href="https://codepen.io/04">@04</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

キーボードですばやく操作できるようになりました。デバッグで簡単に使用できます。


```vue
<!-- Use preprocessors via the lang attribute! e.g. <template lang="pug"> -->
<template>
  <div id="app">
    <div class="bar-bg" v-show="entries.length"></div>
    <div class="bar" ref="bar" v-show="entries.length"></div>
    <div class="entries" v-if="entries.length">
      <div
        class="entry"
        v-for="(entry, index) in entries"
        tabindex="0"
        @keydown.enter="copy(entry.otp)"
        @keydown.prevent.space="copy(entry.otp)"
        @keydown.delete="deleteEntry(index)"
        @keydown.ctrl.c="isCollapsed() && copy(entry.otp)"
        @keydown.stop.up="focusPrev"
        @keydown.stop.left="focusPrev"
        @keydown.stop.down="focusNext"
        @keydown.stop.right="focusNext"
      >
        <div class="entry-left">
          <div v-if="entry.label">{{ entry.label }}</div>
          <div>secret: {{ entry.secret }}</div>
          <div v-if="entry.issuer">issuer: {{ entry.issuer }}</div>
          <div>{{ new Date(entry.created_at) }}</div>
        </div>
        <div @click="copy(entry.otp)">
          <h1>{{ entry.otp }}</h1>
        </div>
      </div>
      <h1 title="Delete a entry by DELETE key">
        Copy a OTP by
        <span class="key">Enter</span>
        ,
        <span class="key">Space</span>
        ,
        <span class="key">Ctrl</span>
        +
        <span class="key">C</span>
        , click&nbsp;a&nbsp;OTP
      </h1>
    </div>
    <div
      v-else
      class="initial"
      title="If you paste QR image, add some white padding around the QR image by Win + Shift + S."
    >
      <h1>
        Paste a secret key, URL, QR image by
        <span class="key">Ctrl</span>
        +
        <span class="key">V</span>
      </h1>
    </div>
    <a
      href="https://tholman.com/github-corners/"
      class="github-corner"
      aria-label="View source on GitHub"
      target="_blank"
    >
      <svg
        width="80"
        height="80"
        viewBox="0 0 250 250"
        style="
          fill: #64ceaa;
          color: #fff;
          position: fixed;
          top: 0;
          border: 0;
          right: 0;
        "
        aria-hidden="true"
      >
        <path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path>
        <path
          d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2"
          fill="currentColor"
          style="transform-origin: 130px 106px"
          class="octo-arm"
        ></path>
        <path
          d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z"
          fill="currentColor"
          class="octo-body"
        ></path>
      </svg>
    </a>
  </div>
</template>

<script>
import jsQR from "https://cdn.skypack.dev/jsqr@1.4.0";

async function scanQRFromFile(file) {
  const img = new Image();
  img.src = URL.createObjectURL(file);
  await new Promise((resolve) => img.addEventListener("load", resolve));
  const canvas = new OffscreenCanvas(img.naturalWidth, img.naturalHeight);
  const ctx = canvas.getContext("2d");
  ctx.imageSmoothingEnabled = false;
  ctx.drawImage(img, 0, 0);
  const imageData = ctx.getImageData(0, 0, img.naturalWidth, img.naturalHeight);
  return jsQR(imageData.data, img.naturalWidth, img.naturalHeight)?.data;
}

export default {
  data() {
    return {
      entries: JSON.parse(localStorage.getItem("entries")) ?? []
    };
  },
  mounted() {
    this.update();
    addEventListener("keydown", (e) => {
      if (["ArrowDown", "ArrowRight"].includes(e.key)) {
        if (!document.querySelector(".entry:first-of-type")) return;
        document.querySelector(".entry:first-of-type").focus();
      }
      if (["ArrowUp", "ArrowLeft"].includes(e.key)) {
        if (!document.querySelector(".entry:last-of-type")) return;
        document.querySelector(".entry:last-of-type").focus();
      }
    });
    addEventListener("paste", async (e) => {
      const text = e.clipboardData.getData("text");
      this.addText(text);
      if (!e.clipboardData.files.length) return;
      const data = await scanQRFromFile(e.clipboardData.files[0]);
      if (!data) {
        Toastify({
          text: "Could not scan the QR code.",
          duration: 10000,
          style: {
            background: "linear-gradient(to right, #ff5f6d, #ffc371)"
          },
          gravity: "bottom"
        }).showToast();
        Toastify({
          text: "Add some white padding around the QR image by PrintScreen.",
          duration: 10000,
          destination:
            "https://github.com/cozmo/jsQR/issues/142#issuecomment-1364690399",
          newWindow: true,
          style: {
            background: "linear-gradient(to right, #ff5f6d, #ffc371)"
          },
          gravity: "bottom"
        }).showToast();
        return;
      }
      this.addText(data);
    });
    this.$nextTick().then(() => {
      if (!document.querySelector(".entry:first-of-type")) return;
      document.querySelector(".entry:first-of-type").focus();
    });
  },
  methods: {
    update() {
      const ms = 1000 * 30;
      const duration = ms - (Date.now() % ms);
      setTimeout(this.update, duration);
      this.$refs.bar.animate(
        [
          { transform: `translateX(-${((ms - duration) / ms) * 100}%)` },
          { transform: `translateX(-100%)` }
        ],
        {
          duration
        }
      );
      this.updateOtp();
    },
    updateOtp() {
      for (const entry of this.entries) {
        const totp = new OTPAuth.TOTP(entry);
        entry.otp = totp.generate();
      }
    },
    addEntry(entry) {
      entry.created_at = new Date();
      const totp = new OTPAuth.TOTP(entry);
      entry.otp = totp.generate();
      this.entries.unshift(entry);
      localStorage.setItem("entries", JSON.stringify(this.entries));
      this.copy(entry.otp);
      scrollTo({ top: 0, behavior: "smooth" });
    },
    deleteEntry(index) {
      this.$delete(this.entries, index);
      localStorage.setItem("entries", JSON.stringify(this.entries));
    },
    addText(text) {
      if (!text) return;
      if (/^[2-7]{6}$/.test(text)) return;
      if (/^[A-Z2-7]+$/.test(text)) this.addEntry({ secret: text });
      try {
        const url = new URL(text);
        const entry = Object.fromEntries(url.searchParams);
        entry.label = decodeURIComponent(url.pathname.split("/").at(-1));
        this.addEntry(entry);
      } catch (e) {
        console.error(e);
      }
    },
    copy(text) {
      const activeElement = document.activeElement;
      Toastify({ text, gravity: "bottom" }).showToast();
      const input = document.body.appendChild(document.createElement("input"));
      input.value = text;
      input.select();
      document.execCommand("copy");
      input.parentNode.removeChild(input);
      activeElement.focus();
    },
    isCollapsed() {
      return getSelection().isCollapsed;
    },
    focusPrev(e) {
      if (!e.target.previousElementSibling) return;
      e.target.previousElementSibling.focus();
    },
    focusNext(e) {
      if (!e.target.nextElementSibling) return;
      e.target.nextElementSibling.focus();
    }
  }
};
</script>

<!-- Use preprocessors via the lang attribute! e.g. <style lang="scss"> -->
<style>
body {
  margin: 0;
}

#app {
  text-align: center;
  color: #2c3e50;
}

.bar-bg {
  position: sticky;
  top: 0;
  height: 8px;
  background: lightgreen;
}

.bar {
  position: sticky;
  top: 0;
  margin-top: -8px;
  height: 8px;
  background: green;
  will-change: transform;
}

.entries {
  margin: 0 auto;
  max-width: 768px;
}

.entry {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 16px;
  margin: 16px;
  border: 2px solid #fafafa;
  border-radius: 8px;
  padding: 8px;
  box-shadow: 0 2px 5px rgba(0, 0, 0, 0.26);
  outline-color: green;
}

.entry:focus {
  border: 2px solid green;
}

.entry-left {
  text-align: left;
}

.key {
  border: 1px solid #19191c33;
  border-radius: 4px;
  padding: 0px 12px;
  line-height: 1.8;
  background: #19191c0d;
}

.initial {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100vh;
}

.github-corner:hover .octo-arm {
  animation: octocat-wave 560ms ease-in-out;
}
@keyframes octocat-wave {
  0%,
  100% {
    transform: rotate(0);
  }
  20%,
  60% {
    transform: rotate(-25deg);
  }
  40%,
  80% {
    transform: rotate(10deg);
  }
}
@media (max-width: 500px) {
  .github-corner:hover .octo-arm {
    animation: none;
  }
  .github-corner .octo-arm {
    animation: octocat-wave 560ms ease-in-out;
  }
}
</style>

```