2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

大尊敬するMatthew DevaneyさんがLinkedInで、Power AutomateでホストしたHTMLフォームを再現されていました。
ただただ圧巻です。Power AutomateクラウドフローでHTMLを返すと、フォームを起動させられるようです。

先日検証結果を動画にしました。

今回はその内容を踏まえて、HTMLフォーム > パスワード認証 > ワンタイムパスワード認証 > 投稿までの一連の流れをPower Automateで再現したいと思います。

一連の流れ

今回必要になるのは、合計で5つのフローです。
いずれもHTTP 要求の受信時にトリガーするフローとなります。

image.png

こちらのトリガーのレファレンスは、Power Automateで検索するとヒットしにくいですが、Azure Logic Appsで検索すると、下記の記事がヒットします。

このフローはプレミアムコネクタが使えるライセンスを必要とします。

必要となるフローは

  1. HTML フォームを表示
  2. パスワード認証を実施
  3. ワンタイムパスワードを送信
  4. ワンタイムパスワードを検証
  5. 問い合わせの投稿

合計で5つになります。

1. HTML フォームを表示

まずはフローの全体像です。

image.png

並列となっているアクションは全て作成アクションです。

並列処理の後に実施する作成アクションHTMLCSSJavaScriptを全て定義し、応答として返すフローです。

安全性という観点で、このフローは実装に不向きです。

HTTP 要求の受信時 トリガー

ほかのフローも同様ですが「フローをトリガーできるユーザー」は全てだれでもに設定します。
メソッドはGETに設定してください。

image.png

HTTP URLはフロー保存後に作成されます。

■ 並列処理の作成アクション
※こちらは全てほかのフローをキックするURLです。
このような機密性の高い情報はセキュア入力オンにすることを推奨します。

image.png

■ フォームやスクリプトを定義する作成アクション

下記を作成アクションに設定します。

長いので折りたたみ式にします
作成アクションに設定する値
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Login and Onetime password!!</title>
    <!-- Font Awesome -->
    <link
      rel="stylesheet"
      href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
    />
    <style>
      body {
        font-family: sans-serif;
        max-width: 600px;
        margin: 0 auto;
        padding: 20px;
        background-color: #f5f5f5;
      }
      .card {
        background: white;
        padding: 30px;
        border-radius: 12px;
        box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
      }
      .form-title {
        text-align: center;
        color: #2c3e50;
        margin-bottom: 30px;
        font-size: 24px;
      }
      .form-group {
        margin-bottom: 20px;
        position: relative;
      }
      label {
        display: block;
        margin-bottom: 8px;
        font-weight: bold;
        color: #2c3e50;
        font-size: 14px;
      }
      .input-group {
        position: relative;
        display: flex;
        align-items: center;
      }
      .input-icon {
        position: absolute;
        left: 12px;
        color: #7f8c8d;
        font-size: 16px;
      }
      input[type="text"],
      input[type="email"],
      input[type="password"],
      input[type="number"],
      textarea {
        width: 100%;
        padding: 12px 12px 12px 40px;
        border: 2px solid #e0e0e0;
        border-radius: 8px;
        box-sizing: border-box;
        font-size: 16px;
        transition: all 0.3s ease;
      }
      input:focus,
      textarea:focus {
        outline: none;
        border-color: #3498db;
        box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.2);
      }
      textarea {
        min-height: 120px;
        resize: vertical;
      }
      button {
        background-color: #3498db;
        color: white;
        padding: 14px 20px;
        border: none;
        border-radius: 8px;
        cursor: pointer;
        font-size: 16px;
        width: 100%;
        transition: background-color 0.3s ease;
      }
      button:hover {
        background-color: #2980b9;
      }
      button:disabled {
        background-color: #bdc3c7;
        cursor: not-allowed;
      }
      .error {
        color: #e74c3c;
        font-size: 14px;
        margin-top: 5px;
        display: none;
      }
      .success {
        color: #27ae60;
        font-size: 14px;
        margin-top: 5px;
        display: none;
        padding: 10px;
        background-color: #d4edda;
        border-radius: 4px;
      }
      .loading {
        text-align: center;
        margin: 10px 0;
        display: none;
        color: #7f8c8d;
      }
      #otpForm {
        display: none;
      }
      .otp-input {
        font-size: 24px;
        letter-spacing: 0.5em;
        text-align: center;
        padding-left: 0.25em;
      }
      .timer {
        text-align: center;
        margin: 15px 0;
        color: #7f8c8d;
        font-size: 16px;
      }
      .info-message {
        background-color: #ebf5fb;
        border: 2px solid #3498db;
        color: #2980b9;
        padding: 15px;
        margin: 15px 0;
        border-radius: 8px;
        font-size: 14px;
        line-height: 1.5;
      }
      .loading .spinner {
        animation: spin 1s infinite linear;
        display: inline-block;
      }
      @keyframes spin {
        from {
          transform: rotate(0deg);
        }
        to {
          transform: rotate(360deg);
        }
      }
    </style>
  </head>
  <body>
    <div class="card">
      <h1 class="form-title">Form</h1>

      <!-- メインフォーム -->
      <form id="loginContactForm">
        <div class="form-group">
          <label for="email">Mail</label>
          <div class="input-group">
            <i class="input-icon fas fa-envelope"></i>
            <input type="email" id="email" name="email" required />
          </div>
          <div class="error" id="emailError"></div>
        </div>

        <div class="form-group">
          <label for="password">Password</label>
          <div class="input-group">
            <i class="input-icon fas fa-lock"></i>
            <input type="password" id="password" name="password" required />
          </div>
          <div class="error" id="passwordError"></div>
        </div>

        <div class="form-group">
          <label for="subject">Subject</label>
          <div class="input-group">
            <i class="input-icon fas fa-heading"></i>
            <input type="text" id="subject" name="subject" required />
          </div>
          <div class="error" id="subjectError"></div>
        </div>

        <div class="form-group">
          <label for="message">Message</label>
          <div class="input-group">
            <i class="input-icon fas fa-comment" style="top: 12px"></i>
            <textarea id="message" name="message" required></textarea>
          </div>
          <div class="error" id="messageError"></div>
        </div>

        <button type="submit" id="submitButton">
          <i class="fas fa-paper-plane"></i> Submit
        </button>
      </form>

      <!-- OTP確認フォーム -->
      <form id="otpForm">
        <div class="info-message">
          <i class="fas fa-info-circle"></i>
          確認コードをメールアドレスに送信しました。<br />
          3分以内に入力してください。
        </div>
        <div class="timer" id="timer">
          <i class="fas fa-clock"></i> <span></span>
        </div>
        <div class="form-group">
          <label for="otp">ワンタイムパスワード</label>
          <div class="input-group">
            <i class="input-icon fas fa-key"></i>
            <input
              type="number"
              id="otp"
              name="otp"
              class="otp-input"
              minlength="6"
              maxlength="6"
              required
            />
          </div>
          <div class="error" id="otpError"></div>
        </div>
        <button type="submit"><i class="fas fa-check"></i> 確認</button>
      </form>

      <div class="loading" id="loading">
        <i class="fas fa-circle-notch spinner"></i> 送信中...
      </div>
      <div class="success" id="successMessage">
        <i class="fas fa-check-circle"></i> 送信が完了しました。
      </div>
      <div class="error" id="generalError"></div>
    </div>

    <script>
      // フォームの参照を保持
      const loginForm = document.getElementById("loginContactForm");
      const otpForm = document.getElementById("otpForm");
      const submitButton = document.getElementById("submitButton");
      const loading = document.getElementById("loading");
      const generalError = document.getElementById("generalError");
      const successMessage = document.getElementById("successMessage");

      // セッション情報を保持
      let sessionData = {
        email: "",
        subject: "",
        message: "",
        sessionId: "",
      };

      // メインフォームの送信処理
      loginForm.addEventListener("submit", async function (e) {
        e.preventDefault();
        resetErrors();

        const formData = {
          email: document.getElementById("email").value,
          password: document.getElementById("password").value,
          subject: document.getElementById("subject").value,
          message: document.getElementById("message").value,
        };

        if (!validateForm(formData)) {
          return;
        }

        submitButton.disabled = true;
        loading.style.display = "block";

        try {
          // 1. パスワード認証
          const authResponse = await fetch(
            "@{outputs('ENDPOINT_PASSWORD')}",
            {
              method: "POST",
              headers: {
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                email: formData.email,
                password: formData.password,
              }),
            }
          );

          if (!authResponse.ok) {
            throw new Error("認証に失敗しました");
          }

          const authResult = await authResponse.json();

          if (!authResult.authenticated) {
            throw new Error(
              "メールアドレスまたはパスワードが正しくありません。"
            );
          }

          // セッションIDの生成
          const currentSessionId = generateSessionId();

          // 2. OTP生成・送信リクエスト
          const otpResponse = await fetch(
            "@{outputs('ENDPOINT_MAIN')}",
            {
              method: "POST",
              headers: {
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                email: formData.email,
                sessionId: currentSessionId,
              }),
            }
          );

          if (!otpResponse.ok) {
            throw new Error("確認コードの送信に失敗しました");
          }

          // セッションデータの保存
          sessionData = {
            email: formData.email,
            subject: formData.subject,
            message: formData.message,
            sessionId: currentSessionId,
          };

          // メインフォームを非表示にし、OTP入力フォームを表示
          loginForm.style.display = "none";
          otpForm.style.display = "block";
          startOtpTimer(180); // 3分のタイマーを開始
        } catch (error) {
          generalError.textContent = error.message;
          generalError.style.display = "block";
        } finally {
          submitButton.disabled = false;
          loading.style.display = "none";
        }
      });

      // OTP検証フォームの送信処理
      otpForm.addEventListener("submit", async function (e) {
        e.preventDefault();
        resetErrors();

        const otpInput = document.getElementById("otp").value;
        loading.style.display = "block";

        try {
          // OTP検証リクエスト
          const verifyResponse = await fetch(
            "@{outputs('ENDPOINT_VERIFY')}",
            {
              method: "POST",
              headers: {
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                email: sessionData.email,
                otp: otpInput,
                sessionId: sessionData.sessionId,
              }),
            }
          );

          if (!verifyResponse.ok) {
            throw new Error("確認コードの検証に失敗しました");
          }

          const verifyResult = await verifyResponse.json();

          if (!verifyResult.verified) {
            throw new Error("確認コードが正しくありません");
          }

          // 問い合わせデータの送信
          const inquiryResponse = await fetch(
            "@{outputs('ENDPOINT_RESPONSE')}",
            {
              method: "POST",
              headers: {
                "Content-Type": "application/json",
              },
              body: JSON.stringify({
                email: sessionData.email,
                subject: sessionData.subject,
                message: sessionData.message,
                timestamp: new Date().toISOString(),
              }),
            }
          );

          if (!inquiryResponse.ok) {
            throw new Error("問い合わせの送信に失敗しました");
          }

          // 成功メッセージの表示
          successMessage.style.display = "block";
          this.style.display = "none";
          clearInterval(timerInterval);
        } catch (error) {
          generalError.textContent = error.message;
          generalError.style.display = "block";
        } finally {
          loading.style.display = "none";
        }
      });

      // OTPタイマー管理
      let timerInterval = null;

      function startOtpTimer(duration) {
        const timerDisplay = document.getElementById("timer");
        let timer = duration;

        clearInterval(timerInterval);
        timerInterval = setInterval(() => {
          const minutes = Math.floor(timer / 60);
          const seconds = timer % 60;

          timerDisplay.textContent = `残り時間: ${minutes}:${seconds
            .toString()
            .padStart(2, "0")}`;

          if (--timer < 0) {
            clearInterval(timerInterval);
            timerDisplay.textContent = "有効期限が切れました";
            otpForm.style.display = "none";
            loginForm.style.display = "block";
            generalError.textContent =
              "確認コードの有効期限が切れました。もう一度お試しください。";
            generalError.style.display = "block";
          }
        }, 1000);
      }

      // セッションID生成
      function generateSessionId() {
        return (
          "session-" +
          Date.now() +
          "-" +
          Math.random().toString(36).substr(2, 9)
        );
      }

      // バリデーション
      function validateForm(formData) {
        let isValid = true;

        if (!formData.email || !isValidEmail(formData.email)) {
          document.getElementById("emailError").textContent =
            "有効なメールアドレスを入力してください。";
          document.getElementById("emailError").style.display = "block";
          isValid = false;
        }

        if (!formData.password || formData.password.length < 8) {
          document.getElementById("passwordError").textContent =
            "パスワードは8文字以上で入力してください。";
          document.getElementById("passwordError").style.display = "block";
          isValid = false;
        }

        if (!formData.subject || formData.subject.trim().length === 0) {
          document.getElementById("subjectError").textContent =
            "タイトルを入力してください。";
          document.getElementById("subjectError").style.display = "block";
          isValid = false;
        }

        if (!formData.message || formData.message.trim().length === 0) {
          document.getElementById("messageError").textContent =
            "問い合わせ内容を入力してください。";
          document.getElementById("messageError").style.display = "block";
          isValid = false;
        }

        return isValid;
      }

      // メールアドレスのバリデーション
      function isValidEmail(email) {
        const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
        return emailRegex.test(email);
      }

      // エラーメッセージのリセット
      function resetErrors() {
        const errorElements = document.getElementsByClassName("error");
        Array.from(errorElements).forEach((element) => {
          element.style.display = "none";
          element.textContent = "";
        });
        successMessage.style.display = "none";
        generalError.style.display = "none";
      }
    </script>
  </body>
</html>

Claudeに作ってもらいました。

まるっとスクリプトを含めて確認しています。

  • メインフォームの送信処理
  • ワンタイムパスワードの送信
  • ワンタイムパスワードの検証
  • 検証後の処理

上記のURLは前述の作成アクションから値を渡して設定します。

フォームで実行している処理のイメージ

それぞれ並んでいるハコHTML FormPassword AuthOTP ServiceOTP VerifyResponse Serviceの機能をPower Automateで担っています。

フローの最後の応答は下記の値で返します。

image.png

Status Code : 200

Headers
{
  "Content-Type": "text/html"
}

Body: @{outputs('BASIC_HTML_FORM')}

まったくエラー処理が加味されていないですね。
応答で返すContent-Typeだけポイントというところです。

2. パスワードの認証

実装方法は複数あると思います。
SQL ServerDataverseといった領域に保存し、認証を実施する方式です。
今回は複数人に公開する意図もないため、割愛します。

image.png

トリガーのスキーマ検証をオンにして、フロー間の対話の形式だけ決めておきましょう。

image.png

Method: POST

Schema
{
    "type": "object",
    "properties": {
        "email": {
            "type": "string"
        },
        "PASSWORD": {
            "type": "string"
        }
    }
}

HTMLフォームの処理を継続させるためには、応答で下記を返すことが必要です。

image.png

Status Code: 200

Headers
{
  "Content-Type": "application/json"
}
Body
{
  "authenticated": true
}

authenticatedtrueである場合に後続の処理を走らせます。

繰り返しになりますが実装は一切想定していないです。

3. ワンタイムパスワードの作成と送信

ワンタイムパスワードの作成と送信をPower Automateで実行します。

image.png

■ トリガー
トリガーされる段階で、emailsessionIdをフォームから受け取ります。

image.png

Method: POST

Schema
{
    "type": "object",
    "properties": {
        "email": {
            "type": "string"
        },
        "sessionId": {
            "type": "string"
        }
    }
}

Power Automateの式でランダムな6桁数値を作ります。
formatNumber 関数とramdom関数を使います。

ワンタイムパスワードの作成
formatNumber(rand(0, 999999), 'd6')

image.png

■ ワンタイムパスワードの通知
SharePointのREST APIでメールを送信します。

image.png

このような形でSharePoint OnlineサービスからnoReplyでメールが送信できるのですが、こちら残念ながら引退してしまいます。

悲しい。こちら個人的に好きなのですが、現状の設定値を記載します。

  • サイトのアドレス: @{parameters('SP_SITE (gtai_SP_SITE)')}
  • Method: POST
  • URI: _api/SP.Utilities.Utility.SendEmail
Headers
{
  "Accept": "application/json;odata=verbose",
  "Content-Type": "application/json;odata=verbose"
}
Body
{
  "properties": {
    "To": {
      "results": ["@{triggerBody()?['email']}"]
    },
    "Subject": "ワンタイムパスワード",
    "Body": "@{outputs('OTP')}"
  }
}

引退しないでほしい。と思っていますが諸行無常、致し方なしです。

■ ワンタイムパスワードの保存
項目の作成

ワンタイムパスワードはSharePoint Listsに一時的に保存します。

image.png

sessionIdと作成したワンタイムパスワードを後ほど検証で使います。

そして応答です。

image.png

ここは後続のステップに、このフローでは関係しないため割愛します。

4. ワンタイムパスワードの検証

まずはトリガーです。

image.png

Schema
{
    "type": "object",
    "properties": {
        "sessionId": {
            "type": "string"
        },
        "otp": {
            "type": "string"
        },
        "formData": {
            "type": "object",
            "properties": {
                "name": {
                    "type": "string"
                },
                "email": {
                    "type": "string"
                },
                "subject": {
                    "type": "string"
                },
                "message": {
                    "type": "string"
                },
                "sessionId": {
                    "type": "string"
                }
            }
        }
    }
}

複数の項目の取得を使って、sessionIdの有無を調べます。

image.png

フィルタークエリ
sessionId eq '@{triggerBody()?['sessionId']}' and otp eq '@{triggerBody()?['otp']}'

sessionIdotpが両方マッチするアイテム数を返します。
そちらをカウントする関数が下記です。

作成
length(outputs('複数の項目の取得')?['body/value'])

image.png

配列の要素数が0以上であれば、対象のレコードありのため、認証を進めましょう。

image.png

image.png

応答の本文であるverifiedtrueである場合に、後続の処理が走ります。

5. 認証後の処理

HTMLフォームに入力された値をアダプティブカードとして投稿します。

image.png

アダプティブカード
{
  "type": "AdaptiveCard",
  "version": "1.4",
  "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
  "body": [
    {
      "type": "Container",
      "style": "emphasis",
      "items": [
        {
          "type": "ColumnSet",
          "columns": [
            {
              "type": "Column",
              "width": "stretch",
              "items": [
                {
                  "type": "TextBlock",
                  "text": "新規問い合わせ",
                  "weight": "Bolder",
                  "size": "Large",
                  "color": "Accent"
                }
              ]
            }
          ]
        }
      ]
    },
    {
      "type": "Container",
      "items": [
        {
          "type": "FactSet",
          "facts": [
            {
              "title": "送信日時",
              "value": "@{convertTimeZone(triggerBody()?['timestamp'],'UTC','Tokyo Standard Time')}"
            }
          ]
        }
      ]
    },
    {
      "type": "Container",
      "style": "default",
      "items": [
        {
          "type": "TextBlock",
          "text": "件名",
          "weight": "Bolder",
          "size": "Medium",
          "color": "Accent"
        },
        {
          "type": "TextBlock",
          "text": "@{triggerBody()?['subject']}",
          "wrap": true,
          "spacing": "Small"
        },
        {
          "type": "TextBlock",
          "text": "問い合わせ内容",
          "weight": "Bolder",
          "size": "Medium",
          "color": "Accent",
          "spacing": "Medium"
        },
        {
          "type": "TextBlock",
          "text": "@{triggerBody()?['message']}",
          "wrap": true,
          "spacing": "Small"
        }
      ]
    }
  ],
  "actions": [
    {
      "type": "Action.OpenUrl",
      "title": "返信する",
      "url": "mailto:${email}?subject=Re: ${subject}",
      "style": "positive"
    },
    {
      "type": "Action.ShowCard",
      "title": "メモを残す",
      "card": {
        "type": "AdaptiveCard",
        "body": [
          {
            "type": "Input.Text",
            "placeholder": "メモを入力してください",
            "id": "comment",
            "isMultiline": true
          }
        ],
        "actions": [
          {
            "type": "Action.Submit",
            "title": "保存",
            "style": "positive"
          }
        ]
      }
    }
  ]
}

マスクしていますが、フォームに入力された値は、メールアドレス含め全て使えます。

最終的にアダプティブカードとして投稿します。

image.png

image.png

アダプティブカードの応答によって、後続の処理を実施することも可能です。

おわりに

動画を見て「うわーすごい!!」と感動して、がーっとやってしまいました!
使う場面はなさそうですが、こういうことがいつか生きると信じて・・・、Power Lifeを楽しみましょう!

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?