inuinu blog(開発用)

BOT @wagagun の開発ノウハウや、IT向け?の雑記ブログです。

素のJavascript(Vanilla)だけでフロントエンド開発(08:確認画面編)

このページでは、ReactやVueではなく、Vanillaと呼ばれる素のJavascriptのみで、フロントエンド開発が可能であるか?を解説します。今回は確認画面を実装します。

今回は、前回…

inuinu-tech.hatenablog.jp

の続きになります。

いきなりここにたどり着いたのでしたら、お手すきの際で構いませんので、下記の記事からも読んでみていただければ幸いです。

inuinu-tech.hatenablog.jp

今回の課題のゴールは?

どうしても確認画面がなければ嫌だと言ってくるクライアントに対しては、セキュリティ上の問題を説いた上で、回避させたいところ…ですが…

それに失敗した場合に、仕方なく確認画面を作成する際のポイントを学びます

そもそも確認画面って必要?

今回は予定を変更して、確認画面について考えてみます。

そもそも確認画面とは?

入力値が正しいか再度確認してくれ!という画面ですね。

今どきのサーバーサイドのフレームワークには確認画面は存在しない

例えばRuby on RailsのScaffoldには、確認画面は存在しませんよね。

あるとしたら、ログインのためのユーザー登録くらい?

Railsチュートリアルには、メール認証的なものを含めた、確認画面的なものがあったかもしれません。

この場合は仮登録的な内容で、一度DBのユーザーテーブルに仮登録として保存しておいて、メール認証を行うことによって、DBのユーザーテーブル内の何らかのカラムを用いて登録完了=有効化するのだと思います。

大昔のWebアプリの確認画面は、POST値をhiddenで隠し持っていた

一般的に確認画面を作成する場合は、入力画面からURL的に遷移すると思いますが、この場合はPOSTパラメータを再度submit時に有効とするために、入力値に対応するだけのhiddenを保つ必要があります。

手間がかかる上に、以下の面でセキュリティ上の脆弱性を抱える気がします

hiddenで持つタイプの確認画面は、主に2つのセキュリティ上の問題を持つ

考えられる脆弱性は次のとおり。

hidden値にHTMLタグやJavascriptを挿入し、危険なリンク表示や操作を行う(XSS攻撃)

hiddenだから見えないでしょ?と思いがちですが、それは気のせいで、中のHTMLタグやJavascriptは有効になってしまいますよね?

確認画面⇒DB更新(submit)の多くは、エラーチェックをバイパスするので、予期せぬ値が挿入される

この後、さらにsubmitされ、そこで更新ロジックが走ると思いますが、そのロジックにはエラーチェックが入っていますか?
あるいはダミーのHTMLを作り、そのHTMLで項目を一致させられれば、いい加減な(後にどこかで不具合を誘発しそうな)値が更新されませんか?

それらを防ぐには、もう一度エラーチェックを通すことでしょうか。

ダミーのHTMLでの攻撃はCSRF(クロスサイト・リクエスト・フォージェリ)と呼ばれるものです。
こちらについては、よく「hiddenで第2のハッシュ値を忍ばせ、有効性をチェックする」手法が取られています。
もしそれが出来ない場合は、有効なハッシュ(セッション値)と、正しいエラーチェックを行い、どこで入れようと正しい情報ならば通すという流れで良いと思います。

可能であれば、不正な値が入ってきた場合は、ログ等を残すか、通知等を行ってください。

もし、確認画面の必要性に迫られたら?

そう考えると、余計な確認画面は百害あって一利なしだと思います。

でも、基本設計時に、今どきのWebアプリをイマイチわからない上司とかが、勝手に「確認画面があります!」と言ってしまった場合どうしましょうか?

取り返しがつけば良いんですけどね。
成り行きで、仕方なく…といった場合は、どうしたら良いでしょうか?

入力画面をJavascriptで見た目を変えて、確認画面っぽくしてしまう

解決策としては、入力画面をJavascriptで見た目を変えて、確認画面っぽくしてしまうことだと思います。
「display:none;」のクラスをaddしたりremoveしたりすることで、なんとか出来そうな気がします。

入力欄は非表示にして別途テーブルに表示欄を書いて、そちらを切り替え表示する?

ああ、Vue.jsとかのチュートリアルで見かけるやつですよね。
入力した値が、そのまま表示エリアに表示される系のヤツ。
これを活用すれば、表示欄をまとめていおいて、切り替えることも出来そうですね。

確かにその方法もありだと思いますが、レイアウトを2つ作る煩わしさはありそうです。

それとも、入力項目をdisabledにする?

もうひとつの方法は、確認画面では入力項目をdisabledにすることです。
これですと、余計なレイアウトをもうひとつ作ることはありません。

問題は、画面が全体的に薄くなるということでしょうか? ここはなんとかしたいところです…

disabledの他にreadonlyという属性もあるようですが、こちらは全ての入力タグが持ってる属性ではないようです。

disabled方式で頑張ってみる

入力項目はdisabledで制御することに決めたとします。
さらに、その他の箇所についても考えてみながら、処理を整理していきます。

ボタンを入力画面・確認画面の2つ用意し、ガイダンスも用意する

まずは、ボタンを2とおり用意しましょう。

入力画面は…

  • ホーム画面・一覧画面等に「戻る」あるいは「キャンセル」
  • 「確認画面へ」

を表示し、確認画面は…

  • ホーム画面・一覧画面等に「戻る」あるいは「キャンセル」
  • 「入力画面へ(戻る)」
  • 「登録」あるいは「更新」あるいは「変更」

を用意します。 (こちらは非表示にします。)

さらに、

確認しOKならば「登録」ボタンをクリックしてください。

のようなガイダンスを先頭に表示します。
(こちらも非表示にします。)

確認画面モードにするには?

「確認画面へ」的なボタンが押下された場合は、以下の処理をします。

  • 「確認しOKならば」旨のガイダンスを表示
  • ボタンを確認画面用に切り替え
  • 入力項目をdisabled化する
  • 画面を先頭に戻す

まるで別画面になったが如く、画面を先頭に戻してあげれば、一丁上がりですね。

入力画面モードにするには、その逆を行う

「入力画面に戻る」的なボタンが押下された場合は、戻してあげましょう。

  • 「確認したら…」旨のガイダンスを非表示
  • ボタンを入力画面用に切り替え
  • disabledを解除する
  • 画面を先頭に戻す

更新ボタンを押下した際も、再度エラーチェックは行う

セキュリティ上の問題もありますので、確認画面から呼び出すAPIはエラーチェック&更新とします。

同一API利用し、POSTパラメータ(hidden値)で制御すると良いでしょう。
(できる限りわかりにくいnameが良いかも。)
これで、入力画面の時はチェックのみ、確認画面の時はチェック+更新を行います。

もし、確認画面でエラーになってしまったら?
入力画面モードに戻り、エラーを表示すれば良いかと思います。
(確認画面表示から更新ボタン押下にかなりのタイムラグがあった場合、整合性等のチェックでエラーとなる可能性はゼロでは無いと思われます。)

プログラム例

●test_confirm01.html

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<script src="js/vanilla-front-end.js"></script>
<style>
#CONFIRM_AREA {
  background-color: lightgreen;
}
.vj-nodisp {
  display:none;
}
</style>
<script>
// form名
let FRM_NAME = 'frm';

// 「確認画面へ」ボタン
async function actCheck() {
  // メッセージ領域クリア
  // 処理割愛
  
  // エラーチェックAPI実行
  // 処理割愛
  // ここでは更新なしモードで実行する
  
  // エラーの場合
  // 処理割愛

  // エラーなしの場合
  act2Confirm(FRM_NAME);
} // function

// 「登録」ボタン
async function actTouroku() {
  // メッセージ領域クリア
  // 処理割愛
  
  // エラーチェックAPI実行
  // 処理割愛
  // ここでは更新モードで実行する
  let jsonData = await actErrorCheck(FRM_NAME, 'api_mock/check00.json')
  
  // エラーの場合
  // 処理割愛

  // エラーなしの場合
  alert("更新しました。");
  location.href = 'https://www.yahoo.co.jp/';
} // function

// DOMコンテンツのロード完了時に実行
window.addEventListener('DOMContentLoaded', (ex) => {
  // トップに移動
  go2Top();
});
</script>
</head>
<body>
<div id="CONFIRM_AREA" class="vj-nodisp">
OKならば、登録ボタンを押してください。
</div>
<br>
<form name="frm" id="FORM_AREA">
日付:<input name="foo_date" type="date" value="2023-05-20">
<br />
時刻:<input name="foo_time" type="time" value="13:26">
<br />
元号2:<select name="foo2" class="sel_gengo">
  <option value="X">ここには追加しません</option>
  <option value="M">明治</option>
  <option value="T" selected>大正</option>
</select>
<br />
外出:<input type="radio" name="gaishutsu" value="1" id="gaishutsu1">
<label for="gaishutsu1">あり</label>
<input type="radio" name="gaishutsu" value="0" checked id="gaishutsu2" checked>
<label for="gaishutsu2">なし</label>
<br />
勤怠:<input type="checkbox" name="kintai" value="1" id="kintai1" checked>
<label for="kintai1">遅刻</label>
<input type="checkbox" name="kintai" value="2" id="kintai2">
<label for="kintai2">早退</label>
<input type="checkbox" name="kintai" value="3" id="kintai3" checked>
<label for="kintai3">電車遅延</label>
<br />
メモ:<textarea name="memo" rows="5" cols="30">ああああ</textarea>
<br /><br /><br /><br /><br /><br /><br /><br /><br /><br />
<br /><br /><br /><br /><br /><br /><br /><br /><br /><br />
<br /><br /><br /><br /><br /><br /><br /><br /><br /><br />
<br /><br /><br /><br /><br /><br /><br /><br /><br /><br />
<div id="BUTTON_INPUT">
  <button type="button" onclick="actCheck();">確認画面へ</button>
</div>
<div id="BUTTON_CONFIRM" class="vj-nodisp">
  <button type="button" onclick="act2Input(FRM_NAME);">入力画面へ戻る</button>
  <button type="button" onclick="actTouroku();">登録</button>
</div>
</form>
</body>
</html>

jsディレクトリを作成し、以下のJavascriptソース(vanilla-front-end.js)を作成します。

他の演習を先に行い、vanilla-front-end.jsがすでに存在する場合は、追記してください。

●vanilla-front-end.js

// 「確認画面へ」ボタン
async function act2Confirm(frm_name) {
  // 確認画面モードへの切り替え
  document.getElementById("CONFIRM_AREA").classList.remove("vj-nodisp");
  document.getElementById("BUTTON_INPUT").classList.add("vj-nodisp");
  document.getElementById("BUTTON_CONFIRM").classList.remove("vj-nodisp");
  // 入力項目をdisebledにする
  cngInputDisabled(frm_name, true);
  // トップに移動
  go2Top();
} // function

// 「入力画面へ戻る」ボタン
async function act2Input(frm_name) {
  // 入力画面モードへの切り替え
  document.getElementById("CONFIRM_AREA").classList.add("vj-nodisp");
  document.getElementById("BUTTON_INPUT").classList.remove("vj-nodisp");
  document.getElementById("BUTTON_CONFIRM").classList.add("vj-nodisp");
  // 入力項目をdisebledを解除
  cngInputDisabled(frm_name, false);
  // トップに移動
  go2Top();
} // function

// 入力画面のdisabledを切り替える
function cngInputDisabled(frm_name, status){
  // formName内のタグを検索
  let frm = document.querySelectorAll('form[name="' + frm_name + '"] [name]');
  //console.log(frm);
  frm.forEach(function (inp) {
    //console.log('=====');
    //console.log(inp);
    // name未設定のものは操作しない
    //if(inp.name === undefined || inp.name == "") return;
    // 入力禁止(disabled)を切り替え
    inp.disabled = status;
  }); // frm.forEach(function (inp) {
} // function

// トップに移動
function go2Top() {
  window.scroll({ top: 0, behavior: "smooth" });
} // function

前回同様、api_mockディレクトリにcheck00.jsonを用意します。

●check00.json

{
  "error" : "0",
  "message" : [],
  "error_name" : [],
  "result" : [],
  "location" : "https://www.yahoo.co.jp/"
}

実行方法

http://localhost/~(ユーザー名)/test_confirm01.html

ボタンはどこ?…だと思いますが、スクロールしていくと出現します。
クリック後に、ページが自動的にスクロールし、先頭に位置することを確認してください。

例題ではアニメーションっぽくしましたが、お気に召さない場合は、変更してみてください。

disabledではformデータがAPIに渡らない

test_confirm01.htmlでは、確認画面モードのみAPIを実装しています。
画面遷移の部分をコメントにし、実行するとF12>Network>check00.jsonが見やすくなると思いますが、Payloadタブを見るとForm Dataに何も渡っていないことがわかります。

どうやら、disebledとなっている入力項目はAPIに渡らないようですね。

disabledとなっている入力項目は、fetch()だけではなく、submitでも同様にデータを渡しません。

これはまずいですね。
プログラムを改良しましょう!

プログラム例

●test_confirm02.html

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<script src="js/vanilla-front-end.js"></script>
<style>
#CONFIRM_AREA {
  background-color: lightgreen;
}
.vj-nodisp {
  display:none;
}
</style>
<script>
// form名
let FRM_NAME = 'frm';

// 「確認画面へ」ボタン
async function actCheck() {
  // メッセージ領域クリア
  // 処理割愛
  
  // エラーチェックAPI実行
  // 処理割愛
  // ここでは更新なしモードで実行する
  
  // エラーの場合
  // 処理割愛

  // エラーなしの場合
  act2Confirm(FRM_NAME);
} // function

// 「登録」ボタン
async function actTouroku() {
  // メッセージ領域クリア
  // 処理割愛
  
  // エラーチェックAPI実行
  // 処理割愛
  // ここでは更新モードで実行する
  let jsonData = await actErrorCheck2(FRM_NAME, 'api_mock/check00.json')

  // エラーの場合
  // 処理割愛

  // エラーなしの場合
  alert("更新しました。");
  location.href = 'https://www.yahoo.co.jp/';
} // function

// POSTによるAPI実行(確認画面用)
async function actErrorCheck2(frm_name, api_url) {
  // 入力項目をdisebledを解除
  await cngInputDisabled(FRM_NAME, false);
  // エラーチェックAPI実行
  let frm = document.querySelector('form[name="' + frm_name + '"]');
  let fd = new FormData(frm);
  let result = await fetch(api_url, {
      method: "POST",
        body: fd
  });
  // 入力項目をdisebledにする
  await cngInputDisabled(FRM_NAME, true);

  // JSONを取り出す
  return await result.json();
}

// DOMコンテンツのロード完了時に実行
window.addEventListener('DOMContentLoaded', (ex) => {
  // トップに移動
  go2Top();
});
</script>
</head>
<body>
<div id="CONFIRM_AREA" class="vj-nodisp">
OKならば、登録ボタンを押してください。
</div>
<br>
<form name="frm" id="FORM_AREA">
日付:<input name="foo_date" type="date" value="2023-05-20">
<br />
時刻:<input name="foo_time" type="time" value="13:26">
<br />
元号2:<select name="foo2" class="sel_gengo">
  <option value="X">ここには追加しません</option>
  <option value="M">明治</option>
  <option value="T" selected>大正</option>
</select>
<br />
外出:<input type="radio" name="gaishutsu" value="1" id="gaishutsu1">
<label for="gaishutsu1">あり</label>
<input type="radio" name="gaishutsu" value="0" checked id="gaishutsu2" checked>
<label for="gaishutsu2">なし</label>
<br />
勤怠:<input type="checkbox" name="kintai" value="1" id="kintai1" checked>
<label for="kintai1">遅刻</label>
<input type="checkbox" name="kintai" value="2" id="kintai2">
<label for="kintai2">早退</label>
<input type="checkbox" name="kintai" value="3" id="kintai3" checked>
<label for="kintai3">電車遅延</label>
<br />
メモ:<textarea name="memo" rows="5" cols="30">ああああ</textarea>
<div id="BUTTON_INPUT">
  <button type="button" onclick="actCheck();">確認画面へ</button>
</div>
<div id="BUTTON_CONFIRM" class="vj-nodisp">
  <button type="button" onclick="act2Input(FRM_NAME);">入力画面へ戻る</button>
  <button type="button" onclick="actTouroku();">登録</button>
</div>
</form>
</body>
</html>

vanilla-front-end.jsにあるactErrorCheck()ではなく、別途actErrorCheck2()を作成し、そこに、disabled解除とdisabled化を挟み込んでいます。

とても乱暴そうなロジックですが…まあ、これが一番、楽ではあります。

実行方法

http://localhost/~(ユーザー名)/test_confirm02.html<

F12>Network>check00.jsonの、Payloadタブを見るとForm Dataが渡っていることが確認できると思います。

一瞬、まばたきのようにdisabledが解除されるように感じられなくもないですが、まあ、ご愛嬌としましょう。

この手法が嫌な場合は、空のFormDataをnewして、地味にひとつづつ追加していったほうが良いかもしれませんね。

確認画面の手法は、照会画面に応用できる?

入力画面にGETパラメータを付けて、照会画面モードにできる?

GETパラメータはJavascriptでも取得できますので、何らかのパラメータで、すべてdisabledにして、照会画面のように振る舞うことは、できなくはないです。

ただし…

GETパラメータの仕組みを知っている人が、そのパラメータを消してしまったら…
その画面は途端に登録・変更画面になり、誰でもデータを改竄できる危険を伴いますので、そういった仕様は避けるべきかと思います

ですので、明細照会用の画面は別途作成しましょう。

照会画面については下記の記事も参照してみてください。
(明細照会画面はアコーディオンあるいはモーダルで行うアイディアもあります。)

inuinu-tech.hatenablog.jp

別途作った照会画面は、確認画面としても利用できる?

そうすると、リアルな画面遷移になりますよね?

hidden地獄が待っていますよ。

やめましょう。

最後に

残された機能は、入力画面で明細行に対応したり…でしょうか。
また、このチュートリアルの範囲からは外れるかもしれませんが、共通箇所のローディング手法なども、まだ、取り上げてはいませんね。

次回は、明細行の追加や削除になります。

inuinu-tech.hatenablog.jp

それでは、また。