Firebase+JavaScriptでToDoアプリ(認証・認可)

はじめに

前回firebaseを使ったCRUD・ログイン実装までして。。。

認証機能(ユーザーごとの読み書き)を実装しようとするとコードの修正量が膨大になってしまうことに気付いたので中断。

新たにミニマム実装でToDoアプリを用いて認可の実装をしてみようと思います。

※ToDoアプリはせっかくなので勉強がてらJSPrimerのものを参考にしたいと思います。

⇒クラス・モジュールをふんだんに使ってコード量が多めだったのでやめました。1から自分で作ってみます。

リポジトリ

素のJSでタイマー機能を作ってみた④ firebaseからデータを取得して時間の処理

✅ゴール
・Firebaseの理解を深める

✅参考

JavaScriptで作るTodoリスト

ネイティブのJavaScriptでToDoアプリを作ってみた

これすごい。。。

FirebaseのRulesを理解する

$location変数など詳しく解説されている。

機能実装

※主に実装時に調べた内容です。

✅エンターキーで追加できるようにする

参考:JavaScriptでEnterキーが入力された時にSubmitする方法と無効化する方法を現役エンジニアが解説【初心者向け】

let enter = function(){
  addTodo.onkeypress = (e) => {
    const key = e.keyCode || e.charCode || 0;
    // 13はEnterキーのキーコード
    if (key == 13) {
      l("entered!!")
      addFC(); // 追加用の関数呼び出し
    }
  }
}

✅削除の識別方法

削除、更新にはアイテムごとに識別できるユニークなID属性が必要。

ためしにevalを使ってみる。(非推奨なのは知ってるけど。。。)

eval("var n" + count + "=" + count + ";");
console.log(n1);

可変変数をと思ったけど、これ関数内だとグローバルに定義されない。

JavaScriptで作るTodoリスト

これ見てevent.targetを見つけた。これ最強。。。

>console.log(event.target); 
<button id="delBtn1">削除<button>
>console.log(event.target.id); 
delBtn1

以下だとボタンだけ消えてしまう。

result.addEventListener('click', (event:any) => {
  let eventElem:any;
  eventElem = event.target;
  console.log(eventElem);
  eventElem.remove();
});

親要素に上手くアクセスしたい。。。

parentNodeを使うことでアクセスできたので、実装完了◎

// 削除機能
result.addEventListener('click', (event:any) => {
  let eventElem:any;
  eventElem = event.target;
  let parentElem :any = eventElem.parentNode;
  parentElem.remove();
});

✅削除と更新の区別

以下の条件分岐で実装。

result.addEventListener('click', (event:any) => {
  let eventElem:any = event.target;
  let parentElem :any = eventElem.parentNode;
  let input:any = parentElem.querySelector("#todo");
  if (eventElem.textContent === "削除"){
    parentElem.remove();
  } else {
    input.value = input.value;
  }
});

🙄一旦にしろ、TSの型がanyの連続で恥ずかしい。。。笑

event.targetのおかげで各ボタンへの一意のID定義が不要になりました。

FirebaseのCRUD連携

※以前アプリ自体の紐づけはやったので割愛

素のJSでタイマー機能を作ってみた~firebase連携/CRUD理解(未実装)とTSをGulpで使ってみる~

✅CRUD実装

前回、認可の部分で設計を大いにミスったのでちゃんと考えてデータを作成。

プロジェクト名/users/userといった感じでuserの部分にuidを登録。その下にTodoを追加していく形にしたいと思います。

ログイン・ログアウト機能実装

// ログインボタン
let loginFC = ()=> {
  firebase.auth().signInWithPopup(provider).then(function(result) {
    location.reload();
  }).catch(function(error) {
    errorCode = error
  });
};
// ログアウトボタン
let logoutFC = ()=> {
  firebase.auth().signOut().then(() => {
    l("Sign-out successful.")
    location.reload();
  }).catch(function(error) {
    l("An error happened.")
  });
}

🙄あまり他の処理と干渉したくなかったのでリロード処理で完結させる形に。

⇒関数を指定する形だとエラー出たので。。。うまい対処法がわからず。

DB追加機能

let addFB = () => {
  db.ref(`/users/${firebase.auth().currentUser.uid}/1`).set({
    todo:addTodo.value
  });
}

これで保存できる形に。

ただキー名を1に固定しているので、このままだと自動で上書きされる。

一意にするにはpushでもいいかも??

と思ったけど削除、更新するには数字で管理できたほうが良いので修正していきます。

firebaseのルール

デフォルト

{
  "rules": {
    ".read": true,
    ".write": true
  }
}

ログインしていれば読み書き可能

※rootは拒否。users以下のデータには認証済みユーザーがアクセスできる

{
  "rules": {
    "users": {
      ".read": "auth != null",
      ".write": "auth != null"
    }
  }
}

※auth.uidはユーザーを一意に示すIDが取得できる

ログイン中であれば以下で取得できる。

db.ref(`/users`).on('value', (snap) => {

ただこれだと他のユーザーのデータも取得できてしまっているのでNGですね。

また、ルートにアクセスするとデータは何も取得できない。

db.ref(`/`).on('value', (snap) => {

さらにルールをネストさせて以下。

{
  "rules": {
    "users": {
      "$userId": {
        ".read": "auth != null",
        ".write": "auth != null"
      }
    }
  }
}

これで以下でアクセスするとこうなる。

db.ref(`/users/uid`).on('value', (snap) => {

だいぶ絞られてきた。

ログインユーザーのIDで取得するようにする。

db.ref(`/users/${firebase.auth().currentUser.uid}/`).on('value'

Cannot read property ‘uid’エラー発生

ログイン有無を確認してから処理するようにしないといけない。

ちなみにユーザーIDを明示すれば取得できた

db.ref(`/users/※ユーザーID`).on('value', (snap) => {

ついでにルールの完成形で確認。ログインユーザーと一致したデータの取得。

{
  "rules": {
    "users": {
      "$userId": {
        ".read": "auth.uid == $userId",
        ".write": "auth.uid == $userId",
      }
    }
  }
}

$location変数を用いる。頭に$を付けた好きな名前の変数を宣言すると、「その階層にあるノードのキー」が入る。

取得できた!!

これを試しに以下のようにして仮データにアクセスすると取得できないので、ルールの記述は合っていることが判明。

db.ref(`/users/uid`).on('value', (snap) => {

あとはどう変数で取得できるように処理を遅らせるかですね。。。

async関数を複製&ミュートして検証していきます。

function async(){
  return new Promise((resolve, reject)=>{
    if ( firebase.auth().currentUser !== null){
      resolve("firebase取得済み")
    } else {
      reject("firebase未取得")
      async();
    }
  })
}

これだと無限ループでエラーが出たので。。。笑

修正

function async(){
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if ( firebase.auth().currentUser !== null){
        resolve("firebase取得済み")
      } else {
        reject("firebase未取得")
      }
    }, 1000)
  })
}

async().then(
  response => {
    console.log(response);
    console.log(firebase.auth().currentUser);
  },
  error => {
    console.log(error);
    async();
  }
)

これで大体成功した。が、失敗した際の再実行方法がわからず。。。

async()
  .then(
    response => {
      console.log(response);
      console.log(firebase.auth().currentUser);
  })
  .catch(
    error => {
      console.log(error);
      async().then(
        response => {
          console.log(response);
          console.log(firebase.auth().currentUser);
      })
      .catch(
        error => {
          console.log(error);
        })
  })

色々試してこれに行き着くけどナンセンスな感じですね。。。笑

これはどういう判断で皆さん実装されているんだろう。

データ追加

let addFB = () => {
  db.ref(`/users/${firebase.auth().currentUser.uid}/${lenDB+1}`).set({
    todo:addTodo.value
  });
}

DBの配列数+1で実装。

ただ、以下のように2を消してから追加すると3のtodoが上書きされる形で保存されてしまう。

lenDB = Number(Object.keys(allDB).length);

前回同様、上記のように初めは配列の数を取得していましたが、これをやめて一意の数をIDとして作りました。

以下は追加ボタンを押すと再度0から表示を作成してしまうエラー。。。

🙄firebaseのメソッド(DB追加のsetメソッドなど)を実行すると再度firebaseに接続されるみたいですね。しかも追加時はそんなにタイムラグを感じないので凄い。

再取得時用の変数を追加&取得の条件分岐を作って実装完了。

✅削除・更新機能

event.targetのおかげで一意のIDを作っていませんでしたが、firebaseの削除機能実装するには識別をしないといけないことに気付く。。。遅い。。。笑

delBtn.id = `delBtn${value.id}`;

一通り削除は実装し終えたと思いきや特定の条件でエラーが発生する。

原因はmapだったので調べました。。。

・.map is not a function 参考

上記の感じでmapメソッドはArray.prototype.map()でプロトタイプチェーンや型の構造を理解する必要があるっぽい。

👉allDB.mapでいつエラーが出るのか検証。

データが全くない場合、1つ目を追加はOK

allDB.map
ƒ map() { [native code] }

1を消して2のみにするとNG

1を消して2,3の場合はOK

🙄関係ないと思うけどempty×2になることに気付いた。

1,2を消して3の場合はNG

1,2を消して3,4の場合もNG(※4,5,6のみもNGだが3,4,5,6だとOK,4,5,6,7だとOK)

2を消して1,3,4の場合OK

OKなケース

protoがArray(0)

ダメなケース

protoの中にmapがない②Objectと認識されている

empty>データ数になるとObjectに認識されてエラーになるっぽい。

0に仮データを入れるとどう認識されるか確認

⇒0,5,6でエラー。

pushで一意にするもそもそもキーが文字列になるので配列ではなくオブジェクトと認識されてしまい、mapメソッドが使えない。

最初からobjectを想定して作ったほうが良いのか。。。

オブジェクトになるケースを条件分岐させてみる。

以下で配列をちゃんと識別できるとわかった。

Array.isArray(allDB)

.mapが使える際はtrue、使えない際はfalse!

結果以下を今まで作っていた条件文に追記することで解決した。配列falseで初期読み込み時の動作。

if(Array.isArray(allDB) === false && lastID === 0){
  let objDB =Object.keys(allDB);
  objDB.map((value)=>{
  ~~~~~

🙄条件文が大きくなりすぎたので関数化したい。

次に削除時にコンソールエラーが出る問題。

TypeError: Cannot read property ‘id’ of undefined

obj状態で初回データを取得⇒データ追加で処理されなかったので実装した処理部分がひっかかる。

} else if(allDB[delayID].id !== undefined){

基本的に削除はできているもののエラーが出る。。。もう少しのところで。。。

allDB[delayID].idなのでfirebaseから取得したデータにdelayIDと同じキーの値がないのが原因。当たり前。

ただこれ上手く処理できる方法が浮かばなかったので。。。

条件分岐の頭に以下追記

if (message === "削除" || message === "更新") {
  console.log(message);
}

削除と更新時の処理

if (eventElem.textContent === "削除") {
    l("削除");
    delID = Number(parentElem.id);
    parentElem.remove();
    message = "削除";
    db.ref(`/users/${firebase.auth().currentUser.uid}/${delID}`).remove();
  }
  else if (eventElem.textContent === "更新"){
    l("更新");
    message = "更新"
    input.value = input.value;
    db.ref(`/users/${firebase.auth().currentUser.uid}/${parentElem.id}`).update({
      todo: input.value
    })
  }

こんな感じでmessageに処理を明示して条件分岐で受け取るようにしました。

デプロイする

とりあえず完成っぽいのでデプロイしていきます。

デプロイ。

firebase deploy

スマホでログインを試みたところポップアップが消えてしまう現象。。。笑

全く使い物にならない。

参考:JavaScript で Google ログインを使用して認証する

確認したところスマホではリダイレクト推奨だったので変更。

let loginFC = ()=> {
  // firebase.auth().signInWithPopup(provider).then(function(result) {
  firebase.auth().signInWithRedirect(provider).then(function(result) {

その後の処理は記載しても実行はされないっぽい。

リダイレクト後は以下を記述すると実行してくれる。

firebase.auth().getRedirectResult().then(function(result) {
  if (result.credential) {
    l("redirected")
    location.reload();
  }

スマホでできた!!

一応完成。

✅APIの対処

※未解決です

参考:バレたらまずそうなfirebaseのAPIキーをどう扱うか.

まんま参考にさせていただいたので引用させていただきます。

引用

  • https://console.cloud.google.com/にアクセス
  • プロジェクトを選択
  • APIとサービス -> 認証情報
  • APIキーの編集ボタン
  • アプリケーションの制限 -> HTTPリファラー
  • ウェブサイトの制限 -> デプロイ先のURLを追加(記載したURLでしかAPIキーが動作しないようにする)
  • 「保存」クリック

HTTPリファラーのキーの制限をしたところ403でアクセスが拒否された。。。

対処が必要そう。。。

OAuth同意画面でfirebaseapp.comの記載しかなかったのでweb.appも承認するように追記。

認証情報⇒OAuth2.0にも追加、リダイレクトには以下を追記

https://~~~~.web.app/login/google/callback
https://~~~~.web.app/__/auth/handler

うまくいかない。。。

以下を追記するも失敗

var token = result.credential.accessToken;

認証情報⇒APIキー

https://~~~~.web.app/login/google/callback
https://~~~~.web.app/__/auth/handler

これもうまくいかない。

一旦ステイします

その他躓いた点

・database()、auth()ともにhtml側にfirebaseの専用jsファイルを読み込ませる。

・Promiseのエラーの処理(繰り返し実行できない??)

・変数の型

typeof(変数);

参考:javascript:変数(オブジェクト)の型を確認する方法

リアルタイム同期

参考:JavaScript でのオフライン機能の有効化

オフライン対応は簡単にできた。

var presenceRef = firebase.database().ref("disconnectmessage");
// Write a string when this client loses connection
presenceRef.onDisconnect().set("I disconnected!");

上記のコードを追記するだけでコンソール上で反応が確認できた!すごい!!

※今回はデータの受信処理を取得できないようなコード、設計にしてしまったのが勿体ない。

おわりに

デプロイ後のAPIの処理が引っかかっているが一旦完成。

firebaseConfig.js以外のファイルは以下に保管。

リポジトリ

デプロイ後、もっさり感など気になる点は多々あった。

が、リアルタイムチャットはチュートリアルでよく見かけるが、仕組みを理解したことで割と簡単に実装できることがわかった。

参考:Firebase を使ってリアルタイムウェブアプリを作ってみる

てか、今更気づいたけどref.on(‘child_added’~~)やonceメソッド使えば確実にもっとうまく実装できた。valueイベントしか使ってない。。。笑

参考:ウェブでのデータの取得

次はVue.jsかReactか。FW&Firestoreに挑戦してみるのもいいかも◎

参考:Vue.js + FirebaseでTodoアプリを作る

備忘録

JSPrimerのサーバーのリポジトリ。

@js-primer/local-server

file://で始まるURL(fileスキーマ)ではSame Origin Policyのセキュリティ制限で、アプリが正しく動作しないケースが多い。

DOMは言語機能(ECMAScript)ではなくブラウザが実装しているAPIのため、DOMを持たないNode.jsなどの実行環境では使えない。
※documentなどのグローバルオブジェクトも存在しないので注意。

GitHubのAPI

https://api.github.com/users/chobi1125にアクセスするとJSONが取得できる。

コメントを残す