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

[Phoenix とExpoで作るスマホアプリ ①Phoenix セットアップ編 + phx_gen_auth](https://qiita.com/the_haigo/items/76dedef74ffe98230137)
[Phoenix とExpoで作るスマホアプリ ②JWT認証+CRUD編](https://qiita.com/the_haigo/items/e01e671ca117ed2b8648)
[Phoenix とExpoで作るスマホアプリ ③ファイルアップロード編](https://qiita.com/the_haigo/items/f9373d6021bb34c45676)
[Phoenix とExpoで作るスマホアプリ ④多対多のリレーション編]
(https://qiita.com/the_haigo/items/f3ecb242a8fbb9936c4b/) 
[Phoenix とExpoで作るスマホアプリ ⑤ expo セットアップ編](https://qiita.com/the_haigo/items/ef9181ebeb2406ec8903/)
[Phoenix とExpoで作るスマホアプリ ⑥認証機能編]
(https://qiita.com/the_haigo/items/0f75da8338b4303f497f) <- 本記事

[前回](https://qiita.com/the_haigo/items/ef9181ebeb2406ec8903/)はexpoのセットアップと基本的な画面を作成しました

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

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

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

になります
ログイン・登録画面を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が表示されます

```jsx
// [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;

```

```jsx
// [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を作成していきましょう

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

```jsx
//[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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/333793cd-5dc4-bd94-e61d-d4f1e2395afd.png)


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

```jsx
// [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](https://react-native-async-storage.github.io/async-storage/docs/install/)

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

サーバーとの通信はContext,Reducerを使うのですが以下を参考にContextとProviderを楽に生成できるcreateDataContextを作成します
[the-complete-react-native-and-redux-course](https://www.udemy.com/course/the-complete-react-native-and-redux-course/)
[github repo](https://github.com/StephenGrider/rn-casts/tree/master/tracks)

```jsx
//[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にリクエストが行きます


```js
//[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を１つのファイルで管理できます

```jsx
//[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で管理していきます

```jsx
//[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;
```

```jsx
//[edit]App.js
import React from "react";
import Provider from "./context/Provider";

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

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

```jsx
//[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](https://i.gyazo.com/56497f98c49495b0445239a291ced380.gif)](https://gyazo.com/56497f98c49495b0445239a291ced380)
ユーザーの作成に成功して、ログイン後のページに行くことができました

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

```js
//[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: "" }
);

```

```jsx
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のアクションを追加しましょう

```js
//[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タブに実装してきます

```jsx
//[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のアクションを追加

```js
//[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に書き出して使いまわしましょう

```jsx
//[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;
```

```jsx
//[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;
```

```jsx
//[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に指定しています

```jsx
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に差し替えます

```jsx
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でコンポーネント内で定義してもいいでしょう


```jsx
//[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",
    ],
  },
}

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

```js
//[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"]]] });
  }
};

....
```

```jsx
//[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でエラーメッセージを渡す処理と初期化する処理を追加します

```jsx
// [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が返されているのが原因なので他のエラー処理を追加します

```elixir
# [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](https://i.gyazo.com/c2a7aefb4006c3f4d94c53d9563b9f13.gif)](https://gyazo.com/c2a7aefb4006c3f4d94c53d9563b9f13)

上記の項目が動いているのを確認できました
だいぶ長くなりましたが今回はここまでになります
次回は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