Closed7

Webアプリ開発初心者による「豆ログ」の開発3 ~仕上げ編~

gotoooogotoooo

フロントエンドのリファクタリング

現状

  • コンポーネントに実装が偏っている
  • Store内でAPIを利用している

対策方針

  • API利用部を独立したファイルに分離する
    Storeは状態管理に徹する
    コンポーネントはAPI呼び出し関数を利用する形にスリム化させる

参考:
https://zenn.dev/sutamac/articles/27246dfe1b5a8e

gotoooogotoooo

Google MapをReact v18で表示させる

ひとまず地図とマーカを表示させることはできた。
緯度と経度を取り違えて緯度の範囲外となって地図が正しく表示されないなど凡ミスに翻弄されながらなんとか形にはなった。

ライブラリは「@react-google-maps/api」を使用。

コード例
import React, { useEffect, useState } from "react";
import {
  Button,
  Container,
  Grid,
  IconButton,
  Stack,
  Typography,
} from "@mui/material";
import { useLocation, useNavigate } from "react-router-dom";
import PropertyValueItem from "../../../common/components/layouts/PropatyValueItem";
import { PropertyValuePair } from "../../../common/types/PropatyValuePair";
import { IoArrowBack } from "react-icons/io5";
import { DialogParams, DialogResult } from "../../../common/types/DialogTypes";
import StoreFormDialog from "../components/StoreFormDialog";
import { useStoreListStore } from "../../../common/stores/StoreListStore";
import { StoreSchema } from "../../../common/schemas/StoreSchema";
import { updateStoreAsync } from "../../../common/api/store_api";
import { GoogleMap, MarkerF, useJsApiLoader } from "@react-google-maps/api";

const StoreDetailPage: React.FC = () => {
  const [propValuePairs, setPropValuePairs] = useState<PropertyValuePair[]>([]);
  const [editDialogOpen, setEditDialogOpen] = useState(false);
  const [editDialogParam, setEditDialogParam] =
    useState<DialogParams["item"]>(null);
  const navigate = useNavigate();
  const location = useLocation();
  const { stores, updateStore } = useStoreListStore();

  const item = location.state.item as StoreSchema;
  const [target, setTarget] = useState<StoreSchema | null>(item);

  const { isLoaded } = useJsApiLoader({
    id: "google-map-script",
    googleMapsApiKey: import.meta.env.VITE_GOOGLE_MAP_API,
  });

  useEffect(() => {
    const pair1: PropertyValuePair = {
      property: "Latitude",
      value: target?.latitude?.toString() ?? "",
    };
    const pair2: PropertyValuePair = {
      property: "Longtitude",
      value: target?.longtitude?.toString() ?? "",
    };
    const pairs: PropertyValuePair[] = [pair1, pair2];

    console.log("setPropValuePairs");
    setPropValuePairs(pairs);
  }, [target]);

  const handleEditDialogOpen = (id: string) => {
    console.log("edit" + id);
    const target = stores.find((item) => item.id === id) ?? null;

    const setTarget = async () => {
      await setEditDialogParam({ data: target });
    };

    setTarget().then(() => {
      setEditDialogOpen(true);
      // console.log(editDialogParam);
    });
  };

  const handleEditDialogClosed = async (dialogResult: DialogResult) => {
    if (dialogResult.result === "NG") {
      setEditDialogOpen(false);
      return;
    }

    try {
      const data = dialogResult.item!["data"] as StoreSchema;
      await updateStoreAsync(data.id!, data).then(() => {
        updateStore(data.id!, data);
        setTarget(data);
        console.log("edit successful");
        setEditDialogOpen(false);
      });
      return;
    } catch (error) {
      console.log(error);
      alert(error);
    }
  };

  return (
    <Container>
      <Stack display={"flex"} direction={"row"} marginTop={"8px"}>
        <IconButton
          onClick={() => {
            navigate("/store-list");
          }}
        >
          <IoArrowBack />
        </IconButton>

        <Button
          variant="contained"
          sx={{ color: "white", backgroundColor: "brown", marginLeft: "auto" }}
          onClick={() => handleEditDialogOpen(item.id!)}
        >
          Edit
        </Button>
      </Stack>

      <Typography fontSize={"36px"} fontWeight={"bold"} marginTop={"8px"}>
        {target?.name ?? ""}
      </Typography>

      {isLoaded && target?.latitude && target?.longtitude && (
        <GoogleMap
          mapContainerStyle={{ width: "80%", aspectRatio: "16 / 9" }}
          zoom={12}
          center={{lat: target.latitude!, lng: target.longtitude!}}
          options={{zoomControl: false, streetViewControl: false, fullscreenControl: false, mapTypeControl: false }}
          onLoad={(map) => {
            console.log("onLoaded");
            console.log(target);
            map.setCenter({ lat: target.latitude!, lng: target.longtitude! });
            console.log(map.getCenter);
          }}
          onBoundsChanged={() => {
            // console.log("onBoundsChanged");
          }}
        >
          <MarkerF
            visible={true}
            position={{ lat: target.latitude, lng: target.longtitude }}
            label={target.name}
          ></MarkerF>
          <></>
        </GoogleMap>
      )}

      <Grid container spacing={2}>
        {propValuePairs.map((pair, index) => (
          <Grid item key={index} xs={12 / 2}>
            <PropertyValueItem pair={pair}></PropertyValueItem>
          </Grid>
        ))}
      </Grid>
      <StoreFormDialog
        open={editDialogOpen}
        item={editDialogParam}
        title="Edit"
        onClose={handleEditDialogClosed}
      />
    </Container>
  );
};

export default StoreDetailPage;

gotoooogotoooo

ようやっと全画面ができた。

購入履歴リスト

購入履歴リストカレンダー表示

銘柄リスト

店舗リスト

生産地リスト

gotoooogotoooo

パスワード管理メモ

アカウント作成時

  1. フロントエンドから平文でバックエンド側にId, Password他が渡る
  2. バックエンド側でハッシュ化してDBに保存

ログイン時

  1. フロントエンドから平文でバックエンド側にId, Passwordが渡る
  2. バックエンド側でIdが一致するユーザを検索し、わたってきた平文パスワード、DB保存のハッシュ化されたパスワードの一致を比較する
  3. ID, パスワードが一致すれば成功を返す

バックエンド側ではpasslib、bcryptを使用した。

※フロントエンド->バックエンド でパスワードが平文のままPOSTされるのはよろしくない気がする。
フロントエンドでハッシュ化する場合、復号化方法を共有する必要があり複雑になりそう。

バックエンド側コード例
from passlib.context import CryptContext

pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto")
secret_key = setting.JWT_KEY # 環境変数に収めたKEY文字列

def generate_hashed_pw(password : str) -> str:
    return pwd_ctx.hash(password)

def verify_pw(plain_pw : str, hashed_pw : str) -> bool:
    return pwd_ctx.verify(plain_pw, hashed_pw)


@router.post("/api/register", response_model=UserInfo)
async def signup(
    request: Request,
    user: UserBody,
    repository: UserRepository = Depends(fac.get_user_repository),
):
    user_model = UserModel(user)

    # DBで保持するのは暗号化されたものなので、パスワードを暗号化する。
    hashed_pw = auth.generate_hashed_pw(user.password)
    user_model.password = hashed_pw

    # 重複登録検知, password検証
    user_models = repository.get_all()
    a_user_model = next(
        (user_model for user_model in user_models if user_model.login_id == user.login_id), None
    )
    if a_user_model:
        raise HTTPException(status_code=400, detail="Login id is already token.")
    if not user.password or len(user.password) < 6:
        raise HTTPException(status_code=400, detail="Password too short")

    created = repository.add(user_model)
    user_info = created.to_schema_model()
    return user_info


@router.post("/api/login", response_model=SuccessMessage)
async def login(
    request: Request,
    response: Response,
    user: LoginUserBody,
    repository: UserRepository = Depends(fac.get_user_repository),
):
    # ログイン処理
    user_models = repository.get_all()
    a_user_model = next(
        (user_model for user_model in user_models if user_model.login_id == user.login_id), None
    )
    if not a_user_model or not auth.verify_pw(user.password, a_user_model.password):
        raise HTTPException(status_code=401, detail="Invalid login id or password.")
    return {"message": "Successfully logged-in"}

gotoooogotoooo

なりすまし対策メモ

こちらの動画を参考にした
FastAPI + React によるフルスタック Web開発

フロントエンド側起動時

  • バックエンド側からCSRF Tokenを取得する
    バックエンド側へのリクエスト時にヘッダにCSRF Tokenを収める
  • CSRF Tokenの期限が切れたら再取得する
    CSRF Tokenが有効かどうかをStateで管理し、useEffectでStateの変更に基づき再取得する

ログイン時

  • LoginIdから暗号化したJWTを生成しクッキーに格納する

EntityのGetアクセス時(DB変更を伴わないアクセス)

  • JWTのみ検証する
    CORSにより外部からのアクセスをブロックできるためCSRF Tokenでの検証は不要

EntityのPost, Delete, Putアクセス時(DB変更を伴うアクセス)

  • CSRF Tokenを検証する
    JWTの不正な送信をブロックする
  • JWTを検証する
gotoooogotoooo

本物DBを用意してどこかにデプロイするところまで進めたかったが、セキュリティ的な不安、Google Map APIの無料枠を超えたアクセス、想像以上に工数を要したことからフロントエンドーバックエンドの連携を仕上げるところまでとして中断する。

雑感

  • Glideだと半日で作れた(自分専用)サービスがフルスクラッチだと2週間以上の期間を要した。
    ローコードツールは侮れない。ケースバイケースで積極的に活用したい。
  • 「店舗」を"Store"と英訳して変数名とするべきではなかった。Reactの文脈でStoreの用語が使われるため、一括変換で不用意な置換が発生し作業効率が下がる。
  • フロントエンドは技術の移り変わりが激しくキャッチアップが大変という難しさを身を以て体感した。Webの情報が少しでも古かったりライブラリのバージョンが異なっていたりするとそのままでは参考にできなかったりする。
このスクラップは2024/03/18にクローズされました