LoginSignup
3
1

More than 3 years have passed since last update.

Phoenix とExpoで作るスマホアプリ ⑥認証機能編

Last updated at Posted at 2020-11-01

Phoenix とExpoで作るスマホアプリ ①Phoenix セットアップ編 + phx_gen_auth
Phoenix とExpoで作るスマホアプリ ②JWT認証+CRUD編
Phoenix とExpoで作るスマホアプリ ③ファイルアップロード編
Phoenix とExpoで作るスマホアプリ ④多対多のリレーション編
Phoenix とExpoで作るスマホアプリ ⑤ expo セットアップ編
Phoenix とExpoで作るスマホアプリ ⑥認証機能編 <- 本記事

前回はexpoのセットアップと基本的な画面を作成しました

今回は認証機能を実装していきます
流れとしては
ログインか新規作成で認証が完了した際のレスポンスのJWTをクライアント側で保存し、
リクエスト時にbaerer tokenとして付与することによってユーザーの識別を行います
JWTに関する細かい内容は本記事では扱いません

上記を踏まえて作成するものは

  • 1 登録画面
  • 2 受け取ったトークンを保存しリクエスト時に付与
  • 3 ログイン状態を取得しログイン画面かログイン後の画面を表示するスイッチャー

になります
ログイン・登録画面をTop Screen,ログイン後の画面をHome Screenとします

client server response redirect
sign up success JWT Home screen
sign up fail 401 Signup screen
attr status redirect
token nil Signup Screen
token exists Home Screen

Switcher作成

最初に3のswitcherの仮組みを行いましょう
前回の部分をMainStackに切り出します
tokenにnullをセットしていますので必ずSignupScreenが表示されます

// [edit] navigation/AppNavigator.js
import React from "react";
import { NavigationContainer } from "@react-navigation/native";
import MainStack from "./MainStack";
import SignupScreen from "../screens/Auth/SignupScreen";

const switcher = () => {
  const token = null;
  if (token === null) {
    return <SignupScreen />;
  } else {
    return <MainStack />;
  }
};

const AppNavigator = () => {
  return <NavigationContainer>{switcher()}</NavigationContainer>;
};

export default AppNavigator;

// [new] navigation/MainStack.js
import React from "react";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import HomeScreen from "../screens/HomeScreen";
import AccountScreen from "../screens/AccountScreen";

const Tab = createBottomTabNavigator();
const MainStack = () => {
  return (
    <Tab.Navigator>
      <Tab.Screen name="Home" component={HomeScreen} />
      <Tab.Screen name="Account" component={AccountScreen} />
    </Tab.Navigator>
  );
};

export default MainStack;

SignupScreen作成

それではSignupScreenを作成していきましょう

最初に基本となる形を以下のようにつくります

//[new]screens/Auth/SignupScreen.js
import React from "react";
import { View, Text, StyleSheet } from "react-native";

const SignupScreen = () => {
  return (
    <View style={styles.container}>
      <Text>SignUp</Text>
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    justifyContent: "center",
    alignSelf: "center",
    flex: 1,
  },
});
export default SignupScreen

一旦動作確認を行いましょう
シミュレーターで以下の画面が表示されていれば問題ありません
スクリーンショット 2020-11-02 1.08.40.png

次にフォーム画面を作成していきましょう
useStateで送信するデータ email,passwordを作成し
onChangeTextで変更を検知してデータを更新します
submitはまだデータは送信せず入力値をconsole.logで表示するだけにしておきます

// [edit] screens/Auth/SignupScreen.js
import React, { useState } from "react";
import { View, StyleSheet } from "react-native";
import { Text, Button, Input } from "react-native-elements";

const SignupScreen = () => {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  return (
    <View style={styles.container}>
      <Text h3>SignUp</Text>
      <Input
        label="Email"
        value={email}
        onChangeText={setEmail}
        keyboardType="email-address"
        autoCapitalize="none"
        autoCorrect={false}
      />
      <Input
        label="Password(least 12 characters)"
        value={password}
        onChangeText={setPassword}
        secureTextEntry
        autoCapitalize="none"
        autoCorrect={false}
      />
      <Button
        title="submit"
        onPress={() => {
          console.log(email);
          console.log(password);
        }}
      />
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    justifyContent: "center",
    flex: 1,
    margin: 10,
  },
});

export default SignupScreen;

通信部分作成

次にサーバーへのpostする部分を作っていきましょう
サーバーから取得したトークンを保存するためにAsyncStorageを使いますがreact-native-communityの方はduplicateになっているのでこちらをインストールします
async-storage

expo install @react-native-async-storage/async-storage

サーバーとの通信はContext,Reducerを使うのですが以下を参考にContextとProviderを楽に生成できるcreateDataContextを作成します
the-complete-react-native-and-redux-course
github repo

//[new]context/createDataContext
import React, { useReducer } from "react";

export default (reducer, actions, defaultValue) => {
  const Context = React.createContext();

  const Provider = ({ children }) => {
    const [state, dispatch] = useReducer(reducer, defaultValue);

    const boundActions = {};
    for (let key in actions) {
      boundActions[key] = actions[key](dispatch);
    }

    return (
      <Context.Provider value={{ state, ...boundActions }}>
        {children}
      </Context.Provider>
    );
  };

  return { Context, Provider };
};

次にリクエストを投げる箇所を作成します
axiosにベースURLをセットします、__DEV__は開発環境時で本番環境時には下のprod urlにリクエストが行きます

//[new]api/sns.js
import axios from "axios";

let url;
if (__DEV__) {
  url = "http://localhost:4000/api/";
} else {
  url = "prod url";
}

const snsApi = axios.create({
  baseURL: url,
});

export { snsApi };

ではcontextを作成していきましょう、
createDataContextを使用することによって
reducer,state,actionを1つのファイルで管理できます

//[new]context/AuthContext.js
import AsyncStorage from "@react-native-async-storage/async-storage";
import createDataContext from "./createDataContext";

const SIGNIN = "SIGNIN";
const SIGNOUT = "SIGNOUT";
const ADD_ERROR = "ADD_ERROR";
const CLEAR_ERROR_MESSAGE = "CLEAR_ERROR_MESSAGE";

const authReducer = (state, action) => {
  switch (action.type) {
    case SIGNIN:
      return { errorMessage: "", token: action.payload };
    case SIGNOUT:
      return { errorMessage: "", token: null };
    case ADD_ERROR:
      return { ...state, errorMessage: action.payload };
    case CLEAR_ERROR_MESSAGE:
      return { ...state, errorMessage: "" };
    default:
      return state;
  }
};

const signup = (dispatch) => async ({ email, password }) => {
  dispatch({ type: CLEAR_ERROR_MESSAGE });
  try {
    const response = await snsApi.post("/v1/sign_up", {
      user: { email: email, password: password },
    });
    await AsyncStorage.setItem("token", response.data.token);
    dispatch({ type: SIGNIN, payload: response.data.token });
  } catch {
    console.log(error);
    dispatch({ type: ADD_ERROR, payload: error.message });
  }
};

const clearErrorMessage = (dispatch) => () => {
  dispatch({ type: CLEAR_ERROR_MESSAGE });
};

export const { Provider, Context } = createDataContext(
  authReducer,
  { signup, clearErrorMessage },
  { token: null, errorMessage: "" }
);

contextとproviderを作成しましたのでどのスクリーンからも使えるようにAppNavigatorを囲っていきましょう
今後providerは増えていきますのでcontext/Provider.jsで管理していきます

//[new]context/Provider.js
import React from "react";
import { StatusBar } from "react-native";
import AppNavigator from "../navigation/AppNavigator";
import { Provider as AuthProvider } from "./AuthContext";

const Provider = () => {
  return (
    <AuthProvider>
      <StatusBar style="dark" />
      <AppNavigator />
    </AuthProvider>
  );
};

export default Provider;
//[edit]App.js
import React from "react";
import Provider from "./context/Provider";

export default function App() {
  return <Provider />;
}

これでcontextを使う準備が整いました
contextを作成しましたのでSignupScreenにセットしていきましょう
onSubmitでcontextで作成したsignupを実行するように変更します

//[edit]screens/Auth/SignupScreen.js
import React, { useState, useContext } from "react";
import { View, StyleSheet } from "react-native";
import { Text, Button, Input } from "react-native-elements";
import { Context as AuthContext } from "../../context/AuthContext"; // <-ここ追加

const SignupScreen = () => {
  const { signup } = useContext(AuthContext); //<- ここ追加
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  return (
    <View style={styles.container}>
...
      <Button
        title="submit"
        onPress={() => {
          signup({ email, password }); //<-ここ変更
        }}
      />
    </View>
  );
};
....

では動作確認をしていきましょう
Image from Gyazo
ユーザーの作成に成功して、ログイン後のページに行くことができました

初期化時にtokenを読み込む

アプリをリロードした際に取得したトークンを読み込む処理を実装していきます

//[edit] context/AuthContext.js
import AsyncStorage from "@react-native-async-storage/async-storage";
import createDataContext from "./createDataContext";
import { snsApi } from "../api/sns";
...

const initAuthState = (dispatch) => async () => {
  try {
    const token = await AsyncStorage.getItem("token");
    if (token) {
      dispatch({ type: SIGNIN, payload: token });
    } else {
      dispatch({ type: SIGNOUT });
    }
  } catch (e) {
    dispatch({ type: SIGNOUT });
  }
};

...
export const { Provider, Context } = createDataContext(
  authReducer,
  {
    signup,
    initAuthState, // <= ここ追加
    clearErrorMessage,
  },
  { token: null, errorMessage: "" }
);

import React, { useContext, useEffect } from "react"; // <= useContextとuseEffect追加
import { NavigationContainer } from "@react-navigation/native";
import MainStack from "./MainStack";
import SignupScreen from "../screens/Auth/SignupScreen";
import { Context as AuthContext } from "../context/AuthContext"; // <=ここ追加

const AppNavigator = () => {
  const {
    state: { token },
    initAuthState,
  } = useContext(AuthContext);
  useEffect(() => {
    initAuthState();
  }, [token]);
  const token = null // <=ここ削除
....
};

export default AppNavigator;

これでアプリをリロードしてもログイン状態が保持されるように成りました

SignOutの実装

SignUp機能が正常に動くことが確認できましたので、次にSignOutとSignInを実装していきましょう

まずContextにsignoutのアクションを追加しましょう

//[edit] context/AuthContext.js
import AsyncStorage from "@react-native-async-storage/async-storage";
import createDataContext from "./createDataContext";
import { snsApi } from "../api/sns";

....

const signout = (dispatch) => async () => {
  await AsyncStorage.removeItem("token");
  dispatch({ type: SIGNOUT });
};

...

export const { Provider, Context } = createDataContext(
  authReducer,
  { 
    signup,
    signout, // <= ここ追加
    initAuthState,
    clearErrorMessage
  },
  { token: null, errorMessage: "" }
);

signoutボタンをAccountタブに実装してきます

//[edit] screens/AccountScreen.js
import React, { useContext } from "react"; //<= useContext追加
import { View, Text, StyleSheet } from "react-native"; 
import { Button } from "react-native-elements";  //<= ここ追加
import { Context as AuthContext } from "../context/AuthContext"; // <=ここ追加

const AccountScreen = () => {
  const { signout } = useContext(AuthContext);
  return (
    <View style={styles.container}>
      <Text>AccountScreen</Text>
      <Button title="SignOut" onPress={() => signout()} />
    </View>
  );
};
...

SignInの実装

contextにsigninのアクションを追加

//[edit] context/AuthContext
import AsyncStorage from "@react-native-async-storage/async-storage";
import createDataContext from "./createDataContext";
import { snsApi } from "../api/sns";

...
const signin = (dispatch) => async ({ email, password }) => {
  dispatch({ type: CLEAR_ERROR_MESSAGE });
  try {
    const response = await snsApi.post("/v1/sign_in", {
      email: email,
      password: password,
    });
    await AsyncStorage.setItem("token", response.data.token);
    dispatch({ type: SIGNIN, payload: response.data.token });
  } catch (error) {
    console.log(error);
    dispatch({ type: ADD_ERROR, payload: error });
  }
};
...

export const { Provider, Context } = createDataContext(
  authReducer,
  { 
   signup, 
   signin, // <=ここ追加
   signout,
   initAuthState,
   clearErrorMessage
  },
  { token: null, errorMessage: "" }
);

SignInはSignUpと作りがほぼ同じなのでcomponentに書き出して使いまわしましょう

//[new] screens/Auth/components/organisms/AuthForm.js
import React, { useState} from "react";
import { View, StyleSheet } from "react-native";
import { Text, Button, Input } from "react-native-elements";

const AuthForm = ({ headerText, onSubmit, submitButtonText }) => {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  return (
    <View style={styles.container}>
      <Text h3>{headerText}</Text>
      <Input
        label="Email"
        value={email}
        onChangeText={setEmail}
        keyboardType="email-address"
        autoCapitalize="none"
        autoCorrect={false}
      />
      <Input
        label="Password(least 12 characters)"
        value={password}
        onChangeText={setPassword}
        secureTextEntry
        autoCapitalize="none"
        autoCorrect={false}
      />
      <Button
        title={submitButtonText}
        onPress={() => {
          onSubmit({ email, password });
        }}
      />
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    justifyContent: "center",
    flex: 1,
    margin: 10,
  },
});

export default AuthForm;
//[edit]screens/Auth/SignupScreen.js
import React, { useContext } from "react";
import AuthForm from './components/organisms/AuthForm'
import { Context as AuthContext } from "../../context/AuthContext";

const SignupScreen = () => {
  const { signup } = useContext(AuthContext);

  return (
    <AuthForm
      headerText="Sign Up SNS"
      submitButtonText="Sign up"
      onSubmit={signup}
    />
  );
};

export default SignupScreen;
//[new] screens/Auth/SigninScreen.js
import React, { useContext } from "react";
import AuthForm from "./components/organisms/AuthForm";
import { Context as AuthContext } from "../../context/AuthContext";

const SigninScreen = () => {
  const { signin } = useContext(AuthContext);

  return (
    <AuthForm
      headerText="Sign In SNS"
      submitButtonText="Sign in"
      onSubmit={signin}
    />
  );
};

export default SigninScreen;

AuthStackの作成

Signin,Signupのスクリーンの両方にアクセスできるようにstack navigationを組んでいきます
backボタンに当たる場所にnameの値が入るのでoptionでbackに指定しています

import React from "react";
import { createStackNavigator } from "@react-navigation/stack";
import WelcomeScreen from "../screens/Auth/WelcomeScreen";
import SignupScreen from "../screens/Auth/SignupScreen";
import SigninScreen from "../screens/Auth/SigninScreen";
const Stack = createStackNavigator();

const AuthStack = () => {
  return (
    <Stack.Navigator>
      <Stack.Screen name="welcome" component={WelcomeScreen} />
      <Stack.Screen
        name="Signup"
        component={SignupScreen}
        options={{
          headerBackTitle: "Back",
        }}
      />
      <Stack.Screen
        name="Signin"
        component={SigninScreen}
        options={{
          headerBackTitle: "Back",
        }}
      />
    </Stack.Navigator>
  );
};

export default AuthStack;


navigatorのSignupScreenをAuthStackに差し替えます

import React, { useContext, useEffect } from "react";
import { NavigationContainer } from "@react-navigation/native";
import MainStack from "./MainStack";
import AuthStack from "./AuthStack" // <=ここ追加
import SignupScreen from "../screens/Auth/SignupScreen"; // <= ここ削除

import { Context as AuthContext } from "../context/AuthContext";

const AppNavigator = () => {
...
  const switcher = () => {
    if (token === null) {
      return <AuthStack />; // <= ここ変更
    } else {
      return <MainStack />;
    }
  };

  return <NavigationContainer>{switcher()}</NavigationContainer>;
};

export default AppNavigator;

起点となるWelcomeScreenを作成します
navigationはNavigationContainerから渡されたpropsになります
useNavigationでコンポーネント内で定義してもいいでしょう

//[new] screens/Auth/WelcomeScreen.js
import React from "react";
import { View, Text, StyleSheet } from "react-native";
import { Button } from "react-native-elements";

const WelcomeScreen = ({ navigation }) => {
  return (
    <View style={styles.container}>
      <Text>Welcome to SNS</Text>
      <Button title="SignUp" onPress={() => navigation.navigate("Signup")} />
      <Button title="SignIn" onPress={() => navigation.navigate("Signin")} />
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    justifyContent: "space-around",
    alignSelf: "center",
    flex: 1,
  },
});
export default WelcomeScreen;

ErrorMessageの表示

最後にサーバーから返ってきたエラーメッセージを表示するようにしてみましょう
phoenixからのエラーのレスポンスはerror.response.dataで取得できます
signupを空でsubmitすると以下のようなエラーが帰ってきます

// error.response.data
Object {
  "errors": Object {
    "email": Array [
      "can't be blank",
    ],
    "password": Array [
      "can't be blank",
    ],
  },
}

そのままですと表示できないので加工して表示していきます

//[edit] context/AuthContext.js
import AsyncStorage from "@react-native-async-storage/async-storage";
import createDataContext from "./createDataContext";
import { snsApi } from "../api/sns";

....
const signup = (dispatch) => async ({ email, password }) => {
  dispatch({ type: CLEAR_ERROR_MESSAGE });
  try {
    ...
  } catch (error) {
    //ここ変更
    console.log(error.response);
    dispatch({
      type: ADD_ERROR,
      payload: Object.entries(error.response.data.errors),
    });

  }
};

const signin = (dispatch) => async ({ email, password }) => {
  dispatch({ type: CLEAR_ERROR_MESSAGE });
  try {
    ...
  } catch (error) {
    console.log(error.response);
    //ここ変更
    dispatch({ type: ADD_ERROR, payload: [["error", ["login error"]]] });
  }
};

....
//[edit] screens/Auth/components/organisms/AuthForm.js
import React, { useState } from "react";
import { View, StyleSheet } from "react-native";
import { Text, Button, Input } from "react-native-elements";
// propsにerrorMessage追加
const AuthForm = ({ headerText, onSubmit, submitButtonText, errorMessage }) => {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  return (
    <View style={styles.container}>
      <Text h3>{headerText}</Text>
      <Input
        label="Email"
        ...
      />
      <Input
        label="Password(least 12 characters)"
        ...
      />
      {errorMessage
        ? errorMessage.map((error) => {
            return (
              <Text style={styles.error} key={error[0]}>
                {error[0]} : {error[1].join()}
              </Text>
            );
          })
        : null}
      <Button
        title={submitButtonText}
        onPress={() => {
          onSubmit({ email, password });
        }}
      />
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    justifyContent: "center",
    flex: 1,
    margin: 10,
  },
// errorのスタイル追加
  error: {
    color: "red",
  },
});

export default AuthForm;

SignupとSigninでエラーメッセージを渡す処理と初期化する処理を追加します

// [edit] screens/Auth/SignupScreen.js
import React, { useContext, useEffect } from "react";
import AuthForm from "./components/organisms/AuthForm";
import { Context as AuthContext } from "../../context/AuthContext";

const SignupScreen = () => {
  const {
    state: { errorMessage }, // <= ここ追加
    signup,
    clearErrorMessage, // <= ここ追加
  } = useContext(AuthContext);
  // 以下追加
  useEffect(() => {
    clearErrorMessage();
  }, []);

  return (
    <AuthForm
      headerText="Sign Up SNS"
      submitButtonText="Sign up"
      errorMessage={errorMessage} // <= ここ追加
      onSubmit={signup}
    />
  );
};

export default SignupScreen;

Signinの変更する箇所は同じなので省略します

Signinで空でsubmitした際にエラーになります
これはphoenix側でunauthorized error時の処理がなかったためhtmlが返されているのが原因なので他のエラー処理を追加します

# [edit]phoenix/sns/lib/sns_web/fallback_controller.ex
defmodule SnsWeb.FallbackController do
  @moduledoc """
  Translates controller action results into valid `Plug.Conn` responses.

  See `Phoenix.Controller.action_fallback/1` for more details.
  """
  use SnsWeb, :controller

  def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
    conn
    |> put_status(:unprocessable_entity)
    |> put_view(SnsWeb.ChangesetView)
    |> render("error.json", changeset: changeset)
  end

  def call(conn, {:error, :not_found}) do
    conn
    |> put_status(:not_found)
    |> put_view(SnsWeb.ErrorView)
    |> render(:"404")
  end
  # 以下追加
  def call(conn, {:error, :unauthorized}) do
    conn
    |> put_status(:unauthorized)
    |> json(%{error: "Login error"})
  end

  def call(conn, {:error, _param}) do
    conn
    |> put_status(:internal_server_error)
    |> put_view(SnsWeb.ErrorView)
    |> render(:"500")
  end
end

動作確認

実装が完了しましたので以下の動作確認をしていきましょう
- sign up
- sign out
- sign in
- errorMessage
Image from Gyazo

上記の項目が動いているのを確認できました
だいぶ長くなりましたが今回はここまでになります
次回はCRUD部分を実装していきます

今回の差分
https://github.com/thehaigo/sns-expo/commit/5fbf081d3a4cdcc985c0de98b52484ed8153c428
https://github.com/thehaigo/sns/commit/ed2a348cacb812996041f6bcd2be2a20ae8ed85f

参考サイト

https://reactnavigation.org/docs/stack-navigator#headerbacktitle
https://reactnativeelements.com/docs/button
https://www.udemy.com/course/the-complete-react-native-and-redux-course/
https://github.com/StephenGrider/rn-casts/tree/master/tracks
https://react-native-async-storage.github.io/async-storage/docs/install/
https://qiita.com/ksh-fthr/items/2daaaf3a15c4c11956e9#%E7%B5%82%E3%82%8F%E3%82%8A%E3%81%AB

3
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
3
1