Closed7
Webアプリ開発初心者による「豆ログ」の開発3 ~仕上げ編~
これまででバックエンド、フロントエンドの骨格を作成してきた。
下記を実施していきたい。
- フロントエンドの仕上げ、リファクタリング
- DBの準備
- デプロイ
フロントエンドのリファクタリング
現状
- コンポーネントに実装が偏っている
- Store内でAPIを利用している
対策方針
- API利用部を独立したファイルに分離する
Storeは状態管理に徹する
コンポーネントはAPI呼び出し関数を利用する形にスリム化させる
参考:
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;
ようやっと全画面ができた。
購入履歴リスト
購入履歴リストカレンダー表示
銘柄リスト
店舗リスト
生産地リスト
パスワード管理メモ
アカウント作成時
- フロントエンドから平文でバックエンド側にId, Password他が渡る
- バックエンド側でハッシュ化してDBに保存
ログイン時
- フロントエンドから平文でバックエンド側にId, Passwordが渡る
- バックエンド側でIdが一致するユーザを検索し、わたってきた平文パスワード、DB保存のハッシュ化されたパスワードの一致を比較する
- 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"}
なりすまし対策メモ
こちらの動画を参考にした
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を検証する
本物DBを用意してどこかにデプロイするところまで進めたかったが、セキュリティ的な不安、Google Map APIの無料枠を超えたアクセス、想像以上に工数を要したことからフロントエンドーバックエンドの連携を仕上げるところまでとして中断する。
雑感
- Glideだと半日で作れた(自分専用)サービスがフルスクラッチだと2週間以上の期間を要した。
ローコードツールは侮れない。ケースバイケースで積極的に活用したい。 - 「店舗」を"Store"と英訳して変数名とするべきではなかった。Reactの文脈でStoreの用語が使われるため、一括変換で不用意な置換が発生し作業効率が下がる。
- フロントエンドは技術の移り変わりが激しくキャッチアップが大変という難しさを身を以て体感した。Webの情報が少しでも古かったりライブラリのバージョンが異なっていたりするとそのままでは参考にできなかったりする。
このスクラップは2024/03/18にクローズされました