LEMP環境でNext.js(TypeScript)とLaravel(PHP)を利用して、勤怠管理アプリを作成してみる。〜ログイン・新規登録・パスワード再設定編〜

環境構築編では、バックエンドのLaravelをインストールし、フロントエンドでは、Next.jsをインストールしました。


バックエンドとフロントエンドの通信構築(API)が苦手なので、

まずは、フロントエンドのサイトの画面のデザインの実装からしていきます。

まず、最初に、以下が、作成しようとしている勤怠管理アプリの機能です。

まず、新規登録・ログインの機能があり、ログインしたユーザーのみがトップページに進める

▼ログインページ
　タイトル : ログイン
　必要カラム：「パスワード」「ユーザー名」
　ボタン :
       「ログイン」→ トップ画面
       「新規登録はこちら」→新規登録ページ
　「パスワードを忘れた方はこちらへ」 →  パスワード再設定画面
  
▼新規登録ページ
   
ユーザー名
メールアドレス
パスワード
プロフィールアイコン
生年月日
性別


▼パスワード再設定ページ
　必要カラム: 「登録済みメールアドレス」
   ボタン: 「パスワードを再設定」→ 登録したメールアドレスに再設定するためのメールが届く

ログインが成功すると、

▼トップ画面(新たに作る)

〇〇〇〇年/
画面の真ん中に現在の時刻(◯(時):◯(分):◯(秒))という形で表示されるようにする。
下に各ボタンを配置
ボタン　※各ボタンを押すとデータベースに記録されるようする。
　「出勤」→ 「お仕事頑張ってください」というメッセージが出る　
　「退勤」→ 「お仕事お疲れ様でした」というメッセージが出る→ 出勤時間、退勤時間、休憩開始時間、休憩終了時間、勤務時間数、休憩時間数がレコードで表示される。
「休憩開始」→ 「休憩を開始しました。」というメッセージが出る。
「休憩終了」→「休憩を終了しました。」というメッセージが出る。　
「作業日報」→作成した「作業日報」ページへ
タイトル「掲示板」→「作業日報」 に変更
「掲示板一覧」→ 「日報一覧」に変更
「投稿する」ボタンを「提出する」に変更


# ディレクトリ構造の変更

フロントエンドであるfrontendディレクトリを以下のような構造にします。
```
attendance
├── laravel
├── frontend
├── src
│   ├── app
│   │   ├── favicon.ico
│   │   ├── globals.css
│   │   ├── layout.tsx  ←  全ページ共通のレイアウト
│   │   ├── page.tsx    ←  ルートページ（/）
│   │   ├── login
│   │   │   ├── page.tsx   ← /login ページ
│   │   ├── mypage
│   │   │   ├── page.tsx   ← /mypage ページ
│   │   ├── register
│   │   │   ├── page.tsx   ←  /register ページ
│   │   ├── report
│   │   │   ├── page.tsx   ←  /report ページ
│   │   ├── reset_password
│   │   │   ├── page.tsx   ← /reset_password ページ
│   │   ├── time_tracking
│   │   │   ├── page.tsx   ← /time_tracking ページ
│   │   ├── leave
│   │   │   ├── page.tsx      ← /leave ページ
│   │   │   ├── confirm
│   │   │   │   ├── page.tsx  ← /leave/confirm ページ
│   │   │   ├── complete
│   │   │   │   ├── page.tsx  ← /leave/complete ページ
│   │   ├── api               ←  API ルート（必要に応じて）
│   │   │   ├── auth
│   │   │   │   ├── route.ts  ← API エンドポイント（例: /api/auth）
│   │   ├── providers
│   │   │   ├── QueryClientProvider.tsx  ← React Query の設定
│   │   │   ├── ThemeProvider.tsx        ← ダークモードなどの設定
│   ├── components
│   │   ├── Globalnav
│   │   │   ├── index.tsx  ← グローバルナビ
│   │   ├── App
│   │   │   ├── index.tsx  ←  /app 用のコンポーネント
│   │   ├── Leave
│   │   │   ├── Confirm.tsx
│   │   │   ├── Complete.tsx
│   │   ├── Login
│   │   │   ├── index.tsx
│   │   ├── Mypage
│   │   │   ├── index.tsx
│   │   ├── Register
│   │   │   ├── index.tsx
│   │   ├── Report
│   │   │   ├── index.tsx
│   │   ├── ResetPassword
│   │   │   ├── index.tsx
│   │   ├── TimeTracking
│   │   │   ├── index.tsx
│   ├── hooks
│   │   ├── useAuth.ts  ←  認証用カスタムフック
│   ├── shared
│   ├── types
│   │   ├── index.ts
│   ├── styles  ←  グローバルCSS（必要なら）
│   │   ├── globals.css
│   ├── utils  ←  ユーティリティ関数
│   │   ├── fetcher.ts
├── public
│   ├── images
│   ├── next.svg
│   ├── vercel.svg
├── postcss.config.mjs
├── tailwind.config.ts
├── tsconfig.json

```

# 1.ログインページ


```tsx:frontend/src/components/Login/index.tsx
import React, {useState} from 'react';
import { useRouter } from 'next/router';
import { useQueryClient, useMutation } from 'react-query';


const Login: React.FC = () => {
    const [userId, setUserId] = useState('');
    const [password, setPassword] = useState('');
    const [message, setMessage] = useState('');
    const router = useRouter();
    const queryClient = useQueryClient();

    const mutation = useMutation(async ({userId, password}:{userId: string; password: string}) => {
        const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/login`, {
            method: 'POST',
            headers: { 
                'Content-Type': 'application/json', // JSON形式を指定
                'Accept': 'application/json', // Laravelで一般的
            },
            body: JSON.stringify({ userId, password}), // JSON形式でデータ送信
        });

        if (!response.ok) throw new Error('ログインできませんでした。');
        return await response.json();
    },{
        onSuccess: (data) => {
            if (data.success) {
                // 成功時の処理
                setMessage('ログイン成功');
                //全てのキャッシュを削除
                queryClient.clear();               
                router.push('/time_tracking');
            } else {
                setMessage(data.message);
            }
        },
        onError: (error: any) => {
            setMessage(error.message || 'エラーが発生しました。')
        }
    });

    const handleLogin = (e: React.FormEvent) => {
        e.preventDefault();
        mutation.mutate({userId, password});
    };
        
        return (
            <div>
                <h1>ログイン</h1>
                <form onSubmit={handleLogin}>
                    <input 
                    type="text"
                    value={userId}
                    onChange={(e) => setUserId(e.target.value)}
                    placeholder="ID番号"
                    required
                    />
                    <input 
                    type="password"
                    value={password}
                    onChange={(e) => setPassword(e.target.value)}
                    placeholder="パスワード"
                    required/>
                    <div>
                        <small>パスワードは7文字以上8文字以下で、英字と数字を両方含む必要があります。</small>
                    </div>
                    <button type="submit">ログイン</button>
                </form>
                {message && <div>{message}</div>}
                <div className="register">
                <a href="/register">新規登録はこちら</a>
                </div>
                <div className="password">
                <a href="/reset_password">ID・パスワードを忘れた方はこちらへ</a>
                </div>              
            </div>
        );
    };

    export default Login;
```

そして、デザインを整えます。
```css:frontend/src/components/Login/index.css
h1 {
  text-align: center;
  margin-top: 50px;
  margin-bottom: 30px;
}

.a {
  text-align: center;
  justify-content: center; /* 横中央揃え */
}

.a:hover {
  color: darkblue;
}

button {
  padding: 10px;
  margin: 0 auto; /* 自動余白で中央揃え */
  border: none;
  color: white;
  cursor: pointer; 
  font-size: 20px;
  font-weight: bold;
  width: 150px;
  height: 70px;
  display: block; /* ボタンをブロック要素に */
  background-color: blue;
  cursor: pointer;
  margin-bottom: 30px;
  border-radius: 4px;
  margin-top: 30px;

}

button:hover {
  background-color: darkblue;
}

input {
  margin:  10px 0; /* 上下に余白を追加 */
  width: 30%;
  padding: 8px;
  border: 1px solid #ccc; /* ボーダーの設定 */
  border-radius:  6px; /* 角を丸める */
}

small {
  font-size: 12px;
  color: gray;
  margin-bottom: 30px; /* 下に30pxの余白を追加 */
}

form {
  display: flex;
  flex-direction: column; /* 縦に並べる */
  align-items: center; /* 中央揃え */
}

.register {
  margin-bottom: 20px;
  text-align: center;
}

.register a,
a {
  text-decoration: none; /* 下線を消す */
  color: blue;
  font-size: 14px;
}

.register a:hover,
a:hover {
  color:darkblue;
}

.password {
  text-align: center;
}
```
すると、下のような画面になります。

![新規メモ.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3945016/c1e1de78-efdf-d3f3-9a9a-7fa3fa2af77f.jpeg)

# Laravel 側の準備

**APIルートの設定**

laravelの**routesディレクトリ内に、**api.php**ファイルを作成し、以下を記述します。

```php:laravel/routes/api.php

<?php

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;

// CORSミドルウェアを利用してAPI全体でCORS対応
Route::middleware('cors')->group(function () {

    // ユーザー認証のためのPOSTエンドポイント
    Route::post('/login', function (Request $request) {
        // CORSに対応するためOPTIONSメソッドに対して200を返す
        if ($request->isMethod('options')) {
            return response('', 200);
        }

        // リクエストから必要なデータを取得
        $userId = $request->input('userId');
        $password = $request->input('password');

        // ユーザー情報をデータベースから取得
        $user = DB::table('users')->where('user_id', $userId)->first();

        if (!$user) {
            return response()->json(['success' => false, 'message' => 'ユーザーが見つかりません。'], 404);
        }

        // パスワードを確認
        if (Hash::check($password, $user->password)) {
            // パスワードが正しい場合、パスワードを除外してユーザー情報を返す
            unset($user->password);
            return response()->json(['success' => true, 'user' => $user]);
        } else {
            return response()->json(['success' => false, 'message' => 'パスワードが正しくありません。'], 401);
        }
    });
});

```

## CORS設定
フロントエンドが別ドメイン（例: http://localhost:3000）で動作している場合、CORSを許可する必要があります。

laravel/config/cors.phpを編集して、http://localhost:3000を許可します。

デフォルトのLaravelプロジェクトにはconfig/cors.phpファイルが含まれています。このファイルを開いて、http://localhost:3000を許可します。

config/cors.phpファイルが存在しない場合、CORS設定用のパッケージがインストールされていない可能性があります。以下の手順で確認・インストールを行います。

1.1 パッケージの確認
fruitcake/laravel-corsはLaravelにCORS機能を提供します。このパッケージがインストールされているか確認します。

```
$ composer show fruitcake/laravel-cors
```
出力例:
```
name     : fruitcake/laravel-cors
descrip. : Adds CORS (Cross-Origin Resource Sharing) headers support in your Laravel application
version  : v3.0.0
```
## インストール

インストールされていない場合は、以下のコマンドを実行してインストールします。

```
$ composer require fruitcake/laravel-cors
```
### cors.phpファイルの作成

パッケージがインストールされたら、以下のコマンドを実行してconfig/cors.phpを生成します。

```
$ php artisan vendor:publish --tag="cors"
```
このコマンドを実行すると、config/cors.phpファイルが生成されます。


```php:laravel/config/cors.php
<?php

return [

    /*
    |--------------------------------------------------------------------------
    | Cross-Origin Resource Sharing (CORS) Configuration
    |--------------------------------------------------------------------------
    |
    | Here you may configure your settings for cross-origin resource sharing
    | or "CORS". This determines what cross-origin operations may execute
    | in web browsers. You are free to adjust these settings as needed.
    |
    | To learn more: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
    |
    */

    'paths' => ['api/*', 'sanctum/csrf-cookie'], // APIリクエストを許可するパス
    'allowed_methods' => ['*'], // 許可するHTTPメソッド（例: GET, POSTなど）
    'allowed_origins' => ['http://localhost:3000'], // 許可するオリジン
    'allowed_origins_patterns' => [], // 特定のパターンで許可する場合
    'allowed_headers' => ['*'], // 許可するヘッダー
    'exposed_headers' => [], // クライアント側でアクセス可能なヘッダー
    'max_age' => 0, // プリフライトリクエストのキャッシュ時間
    'supports_credentials' => true, // クッキー情報を許可する場合
];
```
Laravel (※Laravel11以外)では、CORS設定を管理するためのファイルを自分で作成することができます。上の手順で作成してください。

そして、

laravel/app/Http/Kernel.php にミドルウェアを追加

cors.php 設定を有効にするために、app/Http/Kernel.php ファイルを編集し、以下のミドルウェアを追加してください。

$middleware 配列に次のコードを追加します：
```diff_php:laravel/app/Http/Kernel.php
<?php

namespace App\Http;

use Illuminate\Foundation\Http\Kernel as HttpKernel;

class Kernel extends HttpKernel
{
     /**
     * The application's global HTTP middleware stack.
     *
     * This value is typically used by your application to define the
     * middleware that should be run during every HTTP request.
     *
     * @var array
     */
    protected $middleware = [
        \App\Http\Middleware\TrustProxies::class,
+ \Illuminate\Http\Middleware\HandleCors::class,
        \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
        \Illuminate\Http\Middleware\LoadAverage::class,
        \Illuminate\Session\Middleware\StartSession::class,
        \Illuminate\View\Middleware\ShareErrorsFromSession::class,
        \App\Http\Middleware\Authenticate::class,
    ];

    /**
     * The application's route middleware groups.
     *
     * These middleware may be assigned to groups for easy usage.
     *
     * @var array
     */
    protected $middlewareGroups = [
        'web' => [
            \App\Http\Middleware\EncryptCookies::class,
            \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
            \Illuminate\Session\Middleware\StartSession::class,
            \Illuminate\View\Middleware\ShareErrorsFromSession::class,
            \Illuminate\Routing\Middleware\SubstituteBindings::class,
        ],

        'api' => [
            'throttle:api',
            \Illuminate\Routing\Middleware\SubstituteBindings::class,
        ],
    ];

     /**
     * The application's route middleware.
     *
     * These middleware may be assigned to individual routes.
     *
     * @var array
     */
    protected $routeMiddleware = [
        'auth' => \App\Http\Middleware\Authenticate::class,
        'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
        'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
        'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
        'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class,
    ];
}
```

これにより、Laravel(※Laravel11以外)の標準機能でCORSが有効化されます。


重要ポイント:

allowed_origins: http://localhost:3000のようにフロントエンドのオリジンを指定します。

すべてのオリジンを許可する場合: ['*'] と設定。

**supports_credentials:** フロントエンドで認証付きリクエストを行う場合（例: Cookieやセッションを使用）、**true**に設定します。

# CORS設定をリフレッシュ
設定を変更した後は、キャッシュをクリアして設定を反映します。

```
$ php artisan config:clear
$ php artisan config:cache
```

# 2.新規登録ページ

パスワード入力の制限

今回は、パスワードを半角の英字・数字を両方含む7文字以上8文字以下に設定。

パスワードに対するバリデーションを追加するためには、

バリデーション関数を作成: パスワードが半角の英字・数字を両方含む7文字以上8文字以下であるように設定します。
```typescript
const validatePassword = (password: string) => { const passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{7,8}$/;return passwordRegex.test(password); };
```

handleRegister関数内でパスワードの条件をチェックし、条件を満たさない場合にはエラーメッセージを表示するようにします
```typescript
if (!validatePassword(password)) { setMessage('パスワードを7文字以上8文字以下で、英字と数字を両方含むようにしてください。'); return; }
```


### アイコン項目の追加

今回は、登録情報にアイコン項目も追加します。

アイコンを登録するための項目を追加するには、ファイル入力を使って画像をアップロードできるようにします。以下の手順で、コードにアイコンアップロード機能を追加します。

状態管理: アイコンの状態を管理するためのステートを追加します。

ファイル入力: アイコンを選択するためのファイル入力をフォームに追加します。
ファイルアップロード処理: フォーム送信時にアイコンも一緒に送信するようします。

アイコンの状態管理:
```typescript:
const [icon, setIcon] = useState<File | null>(null);
```

を追加。


ファイル入力の追加:
```typescript
<input type="icon" accept="image/*" onChange={(e) => e.target.files && setIcon(e.target.files[0])} />を追加。
```

FormDataの使用: フォームのデータをFormDataオブジェクトに追加し、アイコンファイルを送信。

これでアイコンを選択して登録できるようになります。サーバー側でもアイコンの処理を適切に行う必要がありますので、その点も考慮してください。

これらを踏まえてのコードは以下になります。

```tsx:frontend/src/components/Register/index.tsx
import React, {useState} from 'react';
import { useMutation } from 'react-query';
import { Me } from '../../../types';

const Register: React.FC = () => {
    const [username, setUsername] = useState('');
    const [email, setEmail] = useState('');
    const [password, setPassword] = useState('');
    const [birthYear, setBirthYear] = useState('');
    const [birthMonth, setBirthMonth] = useState('');
    const [birthDay, setBirthDay] = useState('');
    const [gender, setGender] = useState('');
    const [icon, setIcon] = useState<File | null>(null);
    const [message, setMessage] = useState('');

    const years = Array.from({ length: 100}, (_, i) => new Date(). getFullYear() - i);
    const months = Array.from({ length: 12}, (_, i) => i + 1);
    const days = Array.from ({length: 31}, (_, i) => i + 1);

    const validatePassword = (password: string) => {
        const passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{7,8}$/;
        return passwordRegex.test(password);
    }

    const mutation = useMutation(async () => {
        if (!validatePassword(password)) {
            throw new Error('パスワードを7文字以上8文字以下で、英字と数字を両方含むように設定してください。');
        }

        const formData = new FormData();
        formData.append('username', username);
        formData.append('email', email);
        formData.append('password', password);
        formData.append('birthdate', `${birthYear}年${String(birthMonth).padStart(2, '0')}月${String(birthDay).padStart(2,'0')}`);
        formData.append('gender', gender);
        if (icon) {
            formData.append('icon', icon);
        }

        const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/register`,{
            method: 'POST',
            body: formData,
        });

        if (!response.ok) throw new Error('登録できませんでした。');
        return await response.json();
    }, {
        onSuccess: (data) => {
            // 登録成功時の処理
            if (data.username && data.iconpath) {
                const user: Me = {
                    userId: data.user_id, // ここには、PHP側で生成したuser_idを返す必要があります
                    username: data.username,
                    email: email,
                    birthdate: `${birthYear}年${String(birthMonth).padStart(2,'0')}月${String(birthDay).padStart(2,'0')}`,
                    gender: gender,
                    icon: data.iconpath, // PHPから返されたアイコンのパス
                };

                // localStorageに保存
                localStorage.setItem('user', JSON.stringify(user));
            }
            setMessage(data.message);
        },
        onError: (error: Error) => {
            setMessage(error.message);
        },
    });



    const handleRegister = async (e: React.FormEvent) => {
        e.preventDefault();
        mutation.mutate();
    };

    return (
        <div>
            <h1>新規登録</h1>
        <form onSubmit={handleRegister}>
            <div className="file-gender-container">
            <input
              type = "icon"
              accept="image/*"
              onChange={(e) => e.target.files && setIcon(e.target.files[0])}
              />
            <p>性別 :
            <select
            value={gender}
            onChange={(e) => setGender(e.target.value)}>
                <option value="gender">性別を選択</option>
                <option value="male">男性</option>
                <option value="female">女性</option>
                <option value="other">その他</option>
            </select>
            </p>
            </div>
            <input 
            type="text"
            value={username}
            onChange={(e) => setUsername(e.target.value)} placeholder="ユーザー名" required />
            <input
            type="email"
            value={email}
            onChange={(e) => setEmail(e.target.value)} placeholder="メールアドレス" required />
            <input
            type="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)} placeholder="パスワード" required />
            <div>
                <small>パスワードは7文字以上8文字以下で、英字と数字を両方含む必要があります。</small>
            </div>
            <div>
                <p>生年月日 :
                <select value={birthYear} onChange={(e) => setBirthYear(e.target.value)} required>
                    <option value="birthYear">年を選択</option>
                    {years.map((year) => (
                        <option key={year} value={year}>{year}</option>
                    ))}
                </select>年

                <select value={birthMonth} onChange={(e) => setBirthMonth(e.target.value)} required>
                    <option value="birthMonth">月を選択</option>
                    {months.map((month) => (
                        <option key={month} value={month}>{month}</option>
                    ))}
                </select>月

                <select value={birthDay} onChange={(e) => setBirthDay(e.target.value)} required>
                    <option value="birthDay">日を選択</option>
                    {days.map((day) => (
                        <option key={day} value={day}>{day}</option>
                    ))}
                </select>日
                </p>
            </div>
        <button type="submit" disabled={mutation.isLoading}>{mutation.isLoading ? '登録中...': '登録'}</button>
    </form>
    {message && <div>{message}</div>}
    </div>
    );
};

export default Register;
```
 そして、デザインの調整をします。

 Regiterディレクトリにindex.cssファイルを作成し、以下を記述します。

 ```css:frontend/src/components/Register/index.css
 /* フォーム全体のスタイル */
form {
  display: flex;
  flex-direction: column; /* 縦に並べる */
  align-items: center; /* 中央揃え */
  max-width: 600px; /* 最大幅を600pxに設定(画面幅に合わせて調整) */
  width: 100%; /* 幅は100% */
  margin: 0 auto; /* 自動マージンで中央寄せ */
  padding: 20px; /* 内部に余白を設定 */
  box-sizing: border-box; /* パディングとボーダーを含めた幅計算 */
}

/* 各フォーム要素のスタイル */
input, select, button {
  width: 100%;
  padding: 10px; /* 内側の余白 */
  margin-bottom: 12px; /* 下に余白を追加 */
  font-size: 16px;
  border: 1px solid #ccc; /* ボーダー */
  border-radius: 6px; /* 角を丸くする */
}

/* 生年月日のセレクトボックスを横並びにする */
p {
  font-weight: bold;
  display: flex;
  gap: 8px;
  align-items: center; /* 垂直方向の中央揃え */
  margin-bottom: 12px;
}

/* 性別のセレクトボックスのスタイル */
p:last-child {
  margin-bottom: 20px; /* 性別セクションの下に余白を追加 */
}

h1 {
  text-align: center;
  margin-top: 50px;
  margin-bottom: 30px;
}


button {
  padding: 10px;
  margin: 0 auto; /* 自動余白で中央揃え */
  border: none;
  color: white;
  cursor: pointer; 
  font-size: 20px;
  font-weight: bold;
  width: 150px;
  height: 70px;
  display: block; /* ボタンをブロック要素に */
  background-color: green;
  cursor: pointer;
  margin-bottom: 30px;
  border-radius: 6px;
  margin-top: 30px;

}

button:hover {
  background-color: darkgreen;
}

input {
  margin:  10px 0; /* 上下に余白を追加 */
  width: 50%;
  padding: 8px;
  border: 1px solid #ccc; /* ボーダーの設定 */
  border-radius:  6px; /* 角を丸める */
}

small {
  font-size: 12px;
  color: gray;
  margin-bottom: 30px; /* 下に30pxの余白を追加 */
}

form {
  display: flex;
  flex-direction: column; /* 縦に並べる */
  align-items: center; /* 中央揃え */
}

select {
  width: auto;
  height: 38px;
  padding: 4px; /* 内側の余白を設定 */
  border-radius: 6px;
  margin: 0; /* 必要に応じてマージンをリセット */
  margin-bottom: 12px;
  border: 1px solid #ccc /* 枠線の色と太さ */
}

.option {
  font: gray;
}

/* アイコン選択と性別選択を横並びにする */
.file-gender-container {
  display: flex;
  gap: 16px;
  align-items: center;
  width: 100%;
}

/* ファイル入力欄のスタイル */
input[type="file"] {
  width: 300px;
  padding: 10px;
  margin-bottom: 12px;
}
```
すると、

![新規メモ.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3945016/e7ec1515-76bd-92ae-334d-83ed395ef6bc.jpeg)

といったデザインの画面が作成されます。

送信されたフォームデータをLaravelを使用して、データベースに登録するには、以下の手順を実行します。

## マイグレーションファイルを作成

データベースに新しいテーブルを作成するためのマイグレーションファイルを作成します。



ターミナルで以下のコマンドを実行します
```
$ php artisan make:migration create_users_table --table=users
```
を実行すると、
databse/migrationsディレクトリ内にXXXX_XX_XX_XXXXX_create_users_table.phpといったファイルが作成されます。

そこに、以下のコードを追加します。

```diff_php:laravel/database/migrations/XXXX_XX_XX_XXXXX_create_users_table.php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
+ //すでに存在する場合は、テーブルを作成しない
+ if (!Schema::hasTable('users')) {
        Schema::create('users', function (Blueprint $table) {
+ $table->id();
+ $table->string('username');
+ $table->string('email')->unique();
+ $table->string('password');
+ $table->rememberToken(); // リメンバートークン
+ $table->enum('birthdate', ['birthYear', 'birthMonth', 'birthDay']);
+ $table->enum('gender', ['male', 'female', 'other']);
+ $table->string('icon')->nullable(); // アップロードされたアイコンのパス
+ $table->timestamps();
        });
    }
+ }
    public function down(): void
    {
- Schema::table('users', function (Blueprint $table)
+ Schema::dropIfExists('users');
    }
};
```

マイグレーションを実行してテーブルを作成します：

```
$ php artisan migrate
```
マイグレーションファイルを修正した場合

以下のコマンドでマイグレーションをリセットして再実行します。

```
# データベースのマイグレーションをリセット
$ php artisan migrate:reset

# 再びマイグレーションを実行
$ php artisan migrate
```
## モデルを作成

User モデルを作成または更新します。

Laravelにはデフォルトで User モデルがあります。app/Models/User.php を以下のように編集します：

```diff_php:laravel/app/Models/User.php

<?php

namespace App\Models;

// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;

class User extends Authenticatable
{
    /** @use HasFactory<\Database\Factories\UserFactory> */
    use HasFactory, Notifiable;

    /**
     * The attributes that are mass assignable.
     *
     * @var list<string>
     */
    protected $fillable = [
        'name',
        'email',
        'password',
+ 'birthdate',
+ 'gender',
+ 'icon',
    ];

    /**
     * The attributes that should be hidden for serialization.
     *
     * @var list<string>
     */
    protected $hidden = [
        'password',
        'remember_token',
    ];

    /**
     * Get the attributes that should be cast.
     *
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
        ];
    }
}

```
## コントローラーを作成

新規ユーザーを登録する処理を行うコントローラを作成します。

ターミナルで以下を実行
```
$ php artisan make:controller UserController
```
すると、app/Http/Controllersディレクトリ内に、UserController.phpファイルが作成されると思います。

生成されたUserController.phpを以下のように編集します。

```php:laravel/app/Http/Controllers/UserController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;

class UserController extends Controller
{
    public function register(Request $request)
    {
        // バリデーション
        $validator = Validator::make($request->all(), [
            'username' => 'required|string|max:255',
            'email' => 'required|email|unique:users,email',
            'password' => 'required|string|min:7|max:8|regex:/^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{7,8}$/',
            'birthdate' => 'required|in:birthYear,birthMonth,birthDay',
            'gender' => 'required|in:male,female,other',
            'icon' => 'nullable|file|image|max:2048',
        ]);

        // バリデーションエラー時のレスポンス
        if ($validator->fails()) {
            return response()->json([
                'message' => '入力データにエラーがあります。',
                'errors' => $validator->errors(),
            ], 422);
        }

        // 7桁の数字IDを生成
        $userId = $this->generateNumericId();

        // アイコンの保存
        $iconPath = null;
        if ($request->hasFile('icon')) {
            $file = $request->file('icon');
            $fileName = Str::uuid() . '.' . $file->getClientOriginalExtension();
            $iconPath = $file->storeAs('public/images/icons', $fileName);
        }

        // ユーザー作成
        $user = User::create([
            'user_id' => $userId,
            'username' => $request->username,
            'email' => $request->email,
            'password' => Hash::make($request->password),
            'birthdate' => $request->birthdate,
            'gender' => $request->gender,
            'icon' => $iconPath ? Storage::url($iconPath) : null,
        ]);

        // メール送信
        try {
            $this->sendRegistrationEmail($user);
        } catch (\Exception $e) {
            \Log::error("メール送信エラー: " . $e->getMessage());
            return response()->json([
                'message' => '登録は成功しましたが、メール送信に失敗しました。',
                'error' => $e->getMessage(),
            ], 500);
        }

        return response()->json([
            'message' => '登録が完了しました。メールを確認してください。',
            'user_id' => $user->user_id,
            'username' => $user->username,
            'iconpath' => $user->icon,
        ], 201);
    }
    
    public function listUsers()
    {
        $users = User::select('user_id', 'username')->get();
        return response()->json(['users' => $users]);
    }

    private function generateNumericId($length = 7)
    {
        do {
            $id = str_pad(mt_rand(0, 9999999), $length, '0', STR_PAD_LEFT);
        } while (User::where('user_id', $id)->exists());

        return $id;
    }

    private function sendRegistrationEmail($user)
    {
        $subject = "登録完了のお知らせ";
        $message = "ID番号: {$user->user_id}\nIDは大切に保管してください。";

        try {
            Mail::raw($message, function ($mail) use ($user, $subject) {
                $mail->to($user->email)
                    ->subject($subject)
                    ->from(config('mail.from.address'), config('mail.from.name'));
            });
        } catch (\Exception $e) {
            \Log::error("メール送信エラー: " . $e->getMessage());
            throw $e;
        }
    }
}

```

## ルートを定義

routes/api.php に以下のルートを追加します

```php:laravel/routes/api.php

use App\Http\Controllers\UserController;

Route::post('/register', [UserController::class, 'register']);
Route::get('/users', [UserController::class, 'listUsers']);
```

## メール設定

config/mail.phpでメール設定を行います。

例えば、Gmailを使用する場合：

```php:laravel/config/mail.php

return [
    'default' => env('MAIL_MAILER', 'smtp'),
    'mailers' => [
        'smtp' => [
            'transport' => 'smtp',
            'host' => env('MAIL_HOST', 'smtp.gmail.com'),
            'port' => env('MAIL_PORT', 587),
            'encryption' => env('MAIL_ENCRYPTION', 'tls'),
            'username' => env('MAIL_USERNAME'),
            'password' => env('MAIL_PASSWORD'),
        ],
    ],
    'from' => [
        'address' => env('MAIL_FROM_ADDRESS', 'xxxxxxxx@xxxx.xxx(自身のメールアドレス)'),
        'name' => env('MAIL_FROM_NAME', '${APP_NAME}'),
    ],
];
```
というふうに変えます。

そして、
.envファイルにあるメール設定も変更します。

```.env:.env
(gmailの場合)
MAIL_MAILER=smtp
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_USERNAME=XXXXXXXX@XXXX.XXX(自身のメールアドレス)
MAIL_PASSWORD=＊＊＊＊＊＊＊＊(自身のパスワード)
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS="XXXXXXXX@XXXX.XXX"(自身のメールアドレス)
MAIL_FROM_NAME="${APP_NAME}"
```

## ストレージの公開

アップロードされた画像を公開するために、以下を実行します

```
$ php artisan storage:link
```

これで、アップロードされたアイコンは /storage/icons パスでアクセス可能になります。

### フロントエンドからリクエストを送信

```tsx:
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/register`, {
    method: 'POST',
    body: formData,
});
```
の部分が、Laravelがフォームデータを受け取り、データベースに登録し、成功レスポンスを返すようになります。
 
 登録した情報は、他のファイルで結構使うので、インポートするために、srcディレクトリ内にtypesというディレクトリを作成し、tsファイルを作成します。
 
```typescript:frontend/src/types/index.ts
export type Me = {
    userId: number; // 7桁のユーザーID
    username: string; // ユーザー名
    email: string; //メールアドレス
    birthdate: string; // 生年月日
    gender: string; // 性別
    icon?: string; // アイコンのパス 
}

```


# 3.パスワード再設定ページ

```tsx:frontend/src/components/ResetPassword/index.tsx
import React, {useState} from 'react';
import { useMutation} from 'react-query';

const ResetPassword: React.FC = () => {
    const [email, setEmail] = useState('');
    const [newPassword, setNewPassword] = useState('');
    const [confirmPassword, setConfirmPassword] = useState('');
    const [message, setMessage] = useState('');

    const validatePassword = (password: string) => {
    const passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{7,8}$/;
    return passwordRegex.test(password);
    };

    const mutation = useMutation(async () => {
        if (!validatePassword(newPassword)) {
            throw new Error('新しいパスワードは7〜8文字で、英字と数字を含めるようにしてください。');
        }

        if (newPassword !== confirmPassword) {
            throw new Error('パスワードが一致しません。')
        }

        const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/reset_password`, {
            method: 'POST',
            headers: {'Content-Type' : 'application/json'},
            body: JSON.stringify({ email, new_password: newPassword, confirm_password: confirmPassword}),
        });

        if (!response.ok) {
            throw new Error('再設定メールの送信ができませんでした。');
        }

        return await response.json();
    },{
        onSuccess: (data) => {
            setMessage(data.message || 'パスワードが正常にリセットされました。');
        },
        onError: (error: Error) => {
            setMessage(error.message || 'エラーが発生しました。')
        }
    });

    const handleReset = async (e: React.FormEvent) => {
        e.preventDefault();
        mutation.mutate();
    };

    return (
        <div>
            <h1>パスワード再設定</h1>
            <form onSubmit={handleReset}>
                <input 
                type="email"
                value={email}
                onChange={(e) => setEmail(e.target.value)} placeholder="登録済みメールアドレス"  required />
                <input
                type="password"
                value={newPassword}
                onChange={(e) => setNewPassword(e.target.value)}
                placeholder="新しいパスワード"
                required />
                <div>
                    <small>パスワードは7文字以上8文字以下で、英字と数字を両方含む必要があります。</small>
                </div>
                <input
                type="password"
                value={confirmPassword}
                onChange={(e) => setConfirmPassword(e.target.value)}
                placeholder="パスワード確認"
                required />
                <button type="submit" disabled={mutation.isLoading}>{mutation.isLoading ? '処理中...' :'ID・パスワードを再設定'}</button>
            </form>
            {message && <div>{message}</div>}
        </div>
    );
};

export default ResetPassword;
```
デザインの調整をします。

ResetPasswordディレクトリにindex.cssファイルを作成し、以下を記述します

```css:frontend/src/components/ResetPassword/index.css
h1 {
  text-align: center;
  margin-top: 50px;
  margin-bottom: 30px;
}

button {
  padding: 10px;
  margin: 0 auto; /* 自動余白で中央揃え */
  border: none;
  color: white;
  cursor: pointer; 
  font-size: 20px;
  font-weight: bold;
  width: 180px;
  height: 80px;
  display: block; /* ボタンをブロック要素に */
  background-color: palevioletred;
  cursor: pointer;
  margin-bottom: 30px;
  border-radius: 4px;
  margin-top: 30px;

}

button:hover {
  background-color: orchid;
}

input {
  margin:  10px 0; /* 上下に余白を追加 */
  width: 30%;
  padding: 8px;
  border: 1px solid #ccc; /* ボーダーの設定 */
  border-radius:  4px; /* 角を丸める */
}

small {
  font-size: 12px;
  color: gray;
  margin-bottom: 30px; /* 下に30pxの余白を追加 */
}

form {
  display: flex;
  flex-direction: column; /* 縦に並べる */
  align-items: center; /* 中央揃え */
}

.password {
  text-align: center;
}
```
こういうデザインのページになります。

![新規メモ.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3945016/1fa6e726-0c31-4d95-0401-6a484c3d4b24.jpeg)

## コントローラーの作成

作成したパスワード再設定ページで入力された情報をデータベースに反映させるために、

laravelプロジェクトの中に、ResetPasswordControllerを作成します。

laravelディレクトリ内で、

```
$ php artisan make:controller ResetPasswordController
```
とコマンドを打つと、

app/Http/Controllers内に、ResetPasswordController.phpファイルができるので、

以下のコードを記述します。

```php:laravel/app/Http/Controllers/ResetPasswordController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Validator;

class ResetPasswordController extends Controller
{
    public function resetPassword(Request $request)
    {
        // バリデーション
        $validator = Validator::make($request->all(), [
            'email' => 'required|email|exists:users,email',
            'new_password' => [
                'required',
                'string',
                'min:7',
                'max:8',
                'regex:/^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{7,8}$/',
            ],
            'confirm_password' => 'required|same:new_password',
        ]);

        // バリデーションエラー時のレスポンス
        if ($validator->fails()) {
            return response()->json([
                'message' => '入力データにエラーがあります。',
                'errors' => $validator->errors(),
            ], 422);
        }

        // ユーザーの取得
        $user = User::where('email', $request->email)->first();

        // 新しいパスワードをハッシュ化して保存
        $user->password = Hash::make($request->new_password);

        // 新しい7桁のIDを生成
        $user->user_id = $this->generateNumericId();

        // データ保存
        if (!$user->save()) {
            return response()->json(['message' => 'パスワードの更新に失敗しました。'], 500);
        }

        // メール送信
        try {
            $this->sendResetEmail($user);
        } catch (\Exception $e) {
            \Log::error("メール送信エラー: " . $e->getMessage());
            return response()->json([
                'message' => 'パスワードは更新されましたが、メール送信に失敗しました。',
                'error' => $e->getMessage(),
            ], 500);
        }

        return response()->json([
            'message' => 'パスワードが更新され、新しいIDが発行され、メールが送信されました。',
            'user_id' => $user->user_id,
        ], 200);
    }

    private function generateNumericId($length = 7)
    {
        do {
            $id = str_pad(mt_rand(0, 9999999), $length, '0', STR_PAD_LEFT);
        } while (User::where('user_id', $id)->exists());

        return $id;
    }

    private function sendResetEmail($user)
    {
        $subject = "パスワードリセットのお知らせ";
        $message = "ID番号: {$user->user_id}\nIDは大切に保管してください。";

        try {
            Mail::raw($message, function ($mail) use ($user, $subject) {
                $mail->to($user->email)
                     ->subject($subject)
                     ->from(config('mail.from.address'), config('mail.from.name'));
            });
        } catch (\Exception $e) {
            \Log::error("メール送信エラー: " . $e->getMessage());
            throw $e;
        }
    }
}
```
また、routes/api.phpにエンドポイントを追加します。

```php:laravel/routes/api.php

use App\Http\Controllers\ResetPasswordController;

Route::post('/reset-password', [ResetPasswordController::class, 'resetPassword']);
```
## ユーザーモデルの準備
app/Models/User.phpにuser_idフィールドを追加できるように、

モデルの$fillableプロパティを更新します。

```diff_php:laravel/app/Models/User.php

protected $fillable = [
+ 'user_id',
    'name',
    'email',
    'password',
];
```
Laravelのマイグレーションを利用して、usersテーブルにuser_idを追加します。

```
$ php artisan make:migration add_user_id_to_users_table --table=users
```
というコマンドを打つと、database/migrationsディレクトリ内に、
xxxx_xx_xx_xxxxxx_add_user_id_to_users_table.phpファイルが作成されます。

そのファイルに以下のコードを追加します。

```diff_php:laravel/database/migrations/xxxx_xx_xx_xxxxxx_add_user_id_to_users_table.php

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::table('users', function (Blueprint $table) {
+ $table->string('user_id', 7)->unique()->after('id');
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::table('users', function (Blueprint $table) {
+ $table->dropColumn('user_id');
        });
    }
};
```

そして、再度、マイグレーションを実行します。

```
$ php artisan migrate
```

ここまでが、ログイン・新規登録・パスワード再設定ページ編までです。

次は、ログインが成功した時に表示される勤怠管理ページの作成を行なっていきます。↓

https://qiita.com/makkyo-git/items/dc33cda738be1026e7ff