【第4回】パスワードの登録・更新機能を実装する

パスワード管理アプリ作成

こんにちは。今回も引き続き、ローカル環境で開発している個人向けパスワード管理アプリについて、開発の記録を綴っていきます。
前回までで、パスワードの一覧表示ができるようになりました。今回は、いよいよユーザーが新しい情報を登録したり、既存の情報を編集したりできるようにしていきます。

【第1回】なぜ自分でパスワード管理アプリを作ったのか?〜40代エンジニアの試行錯誤〜
【第2回】Next.js × SQLite で始める個人アプリ開発入門
【第3回】画面の構成とパスワード表示機能の実装

「登録」と「編集」ができるようになると、アプリは“使える”ものになる

ただ情報を見るだけのアプリと、実際に情報を追加・変更できるアプリ。この違いはとても大きいです。
使いながら自分のデータを蓄積していけることで、日々の生活の中に自然と組み込まれていきます。

今回実装したのは、以下の2つの画面です:

  • パスワードの新規登録フォーム
  • パスワードの更新(編集)フォーム

パスワード登録画面(app/passwords/new/page.tsx)

ユーザーが入力できる項目は以下のとおりです:

  • カテゴリ
  • サイト名
  • サイトURL
  • ログインID
  • パスワード
  • メールアドレス
  • メモ

Next.js のクライアントコンポーネントとして、useState で状態を管理します。

    
'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';

const AddPassword: React.FC = () => {

  const router = useRouter();

  const [formData, setFormData] = useState({
    category: "",
    site_name: "",
    site_url: "",
    login_id: "",
    password: "",
    email: "",
    memo: "",
  });

  const handleChange = (
    e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
  ): void => {
    const { name, value } = e.target;
    setFormData({ ...formData, [name]: value });
  };

  const handleSubmit = async (e: React.FormEvent): Promise<void> => {
    e.preventDefault();
    const res = await fetch("/api/passwords", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(formData),
    });
    if (res.ok) {
      alert("登録成功!");
      setFormData({ category: "", site_name: "", site_url: "", login_id: "", password: "", email: "", memo: "" });
      router.push('/');
    } else {
      alert("登録失敗");
    }
  };

  return (
    <div className="p-4">
      <h1 className="text-2xl font-bold mb-4">新しいサイトを作成</h1>
      <form onSubmit={handleSubmit} className="flex flex-col gap-4">
        <div>
          <label>カテゴリ</label>
          <input
            type="text"
            name="category"
            value={formData.category}
            className="w-full p-2 border rounded"
            onChange={handleChange}
          />
        </div>
        <div>
          <label>サイト名</label>
          <input
            type="text"
            name="site_name"
            value={formData.site_name}
            className="w-full p-2 border rounded"
            onChange={handleChange}
            required
          />
        </div>
        <div>
          <label>サイトURL</label>
          <input
            type="text"
            name="site_url"
            value={formData.site_url}
            className="w-full p-2 border rounded"
            onChange={handleChange}
            required
          />
        </div>
        <div>
          <label>ログインID</label>
          <input
            type="text"
            name="login_id"
            value={formData.login_id}
            className="w-full p-2 border rounded"
            onChange={handleChange}
          />
        </div>
        <div>
          <label>パスワード</label>
          <input
            type="text"
            name="password"
            value={formData.password}
            className="w-full p-2 border rounded"
            onChange={handleChange}
            required
          />
        </div>
        <div>
          <label>メールアドレス</label>
          <input
            type="email"
            name="email"
            value={formData.email}
            className="w-full p-2 border rounded"
            onChange={handleChange}
          />
        </div>
        <div>
          <label>メモ</label>
          <textarea
            name="memo"
            value={formData.memo}
            className="w-full p-2 border rounded"
            onChange={handleChange}
          ></textarea>
        </div>
        <button type="submit" className="bg-blue-500 text-white py-2 px-4 rounded hover:bg--600">登録</button>
      </form>
    </div>
  );
};

export default AddPassword;

    
  

パスワード更新画面(app/passwords/edit/[id]/page.tsx)

既存の情報を後から修正できることで、安心してデータを活用できるようになります。
この画面もクライアントコンポーネントで構成し、[id] 付きのルーティングにより、特定のレコードを取得・編集できるようにしました。

    
'use client';

import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';

type Password = {
  id: number;
  category: string | null;
  site_name: string;
  site_url: string;
  login_id: string | null;
  password: string;
  email: string | null;
  memo: string | null;
};

const UpdatePasswordPage = ({ params }: { params: Promise<{ id: string }> }) => {
  const router = useRouter();
  const [id, setId] = useState<string | null>(null);
  const [siteName, setSiteName] = useState('');
  const [category, setCategory] = useState('');
  const [siteUrl, setSiteUrl] = useState('');
  const [loginId, setLoginId] = useState('');
  const [password, setPassword] = useState<Password | null>(null);
  const [pass, setPass] = useState('');
  const [email, setEmail] = useState('');
  const [memo, setMemo] = useState('');

  const [error, setError] = useState<string | null>(null);

  const [loading, setLoading] = useState(true);

  // paramsをアンラップしてIDを取得
  useEffect(() => {
    const fetchParams = async () => {
      const resolvedParams = await params;
      setId(resolvedParams.id);
    };

    fetchParams();
  }, [params]);

  // IDが取得できたらパスワードデータを取得
  useEffect(() => {
    if (!id) return;

    const fetchPassword = async () => {
      setLoading(true);
      setError(null);

      try {
        const response = await fetch(`/api/passwords/${id}`);
        if (!response.ok) throw new Error('Failed to fetch password data');

        const data: Password = await response.json();
        setPassword(data);
        setCategory(data.category || '');
        setSiteName(data.site_name);
        setSiteUrl(data.site_url);
        setLoginId(data.login_id || '');
        setPass(data.password);
        setEmail(data.email || '');
        setMemo(data.memo || '');

      } catch (err: unknown) {
        if (err instanceof Error) {
          setError(err.message);
        } else {
          console.error('Error fetching password:', err);
        }
      } finally {
        setLoading(false);
      }
    };

    fetchPassword();
  }, [id]);


  // 更新処理
  const handleUpdate = async (e: React.FormEvent) => {
    e.preventDefault();
    setError(null);
    try {
      const response = await fetch(`/api/passwords/${id}`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ category, siteName, siteUrl, loginId, pass, email, memo }),
      });

      if (!response.ok) throw new Error('Failed to update password');

      alert('パスワードが更新されました!');

      router.push('/'); // 更新後にメインページへ遷移
    } catch (error) {
      console.error('Error updating password:', error);
    }
  };

  if (loading) {
    return <div>読み込み中...</div>;
  }

  if (error) {
    return <p className="text-red-500">{error}</p>;
  }

  if (!password) {
    return <div>データが見つかりません</div>;
  }

  return (
    <div className="p-4">
      <h1 className="text-xl font-bold mb-4">パスワード更新</h1>
      <form onSubmit={handleUpdate} className="space-y-4">
        <div>
          <label className="block text-gray-700 font-bold mb-2" htmlFor="siteName">カテゴリ</label>
          <input
            id="category"
            type="text"
            value={category}
            onChange={(e) => setCategory(e.target.value)}
            className="w-full p-2 border rounded"
            required
          />
        </div>
        <div>
          <label className="block text-gray-700 font-bold mb-2" htmlFor="siteName">サイト名</label>
          <input
            id="siteName"
            type="text"
            value={siteName}
            onChange={(e) => setSiteName(e.target.value)}
            className="w-full p-2 border rounded"
            required
          />
        </div>
        <div>
          <label className="block text-gray-700 font-bold mb-2" htmlFor="siteUrl">サイトURL</label>
          <input
            id="siteUrl"
            type="text"
            value={siteUrl}
            onChange={(e) => setSiteUrl(e.target.value)}
            className="w-full p-2 border rounded"
            required
          />
        </div>
        <div>
          <label className="block text-gray-700 font-bold mb-2" htmlFor="loginId">ログインID</label>
          <input
            id="loginId"
            type="text"
            value={loginId}
            onChange={(e) => setLoginId(e.target.value)}
            className="w-full p-2 border rounded"
          />
        </div>
        <div>
          <label className="block text-gray-700 font-bold mb-2" htmlFor="password">パスワード</label>
          <input
            id="password"
            type="text"
            value={pass}
            onChange={(e) => setPass(e.target.value)}
            className="w-full p-2 border rounded"
          />
        </div>
        <div>
          <label className="block text-gray-700 font-bold mb-2" htmlFor="email">メールアドレス</label>
          <input
            id="email"
            type="text"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            className="w-full p-2 border rounded"
          />
        </div>
        <div>
          <label className="block text-gray-700 font-bold mb-2" htmlFor="memo">メモ</label>
          <textarea
            id="content"
            value={memo}
            onChange={(e) => setMemo(e.target.value)}
            className="w-full p-2 border rounded"
            rows={6}
          />
        </div>
        <button
          type="submit"
          className="bg-blue-600 text-white py-2 px-4 rounded hover:bg-blue-700"
        >
          更新
        </button>
      </form>
    </div>
  );
};

export default UpdatePasswordPage;

    
  

バックエンド:APIエンドポイントの拡張

登録(POST):app/api/passwords/route.ts

必須項目が入力されていない場合には 400 エラーを返すようにしています。

なお前回記述したソースコードの下に記載してください。

    
export async function POST(req: Request) {
  try {
    const body = await req.json();
    const { category, site_name, site_url, login_id, password, email, memo } = body;

    if (!site_name || !site_url || !password) {
      return NextResponse.json({ error: '必須項目不足' }, { status: 400 });
    }

    runExecute(
      'INSERT INTO password_manager (category, site_name, site_url, login_id, password, email, memo) VALUES (?, ?, ?, ?, ?, ?, ?)',
      [category, site_name, site_url, login_id, password, email, memo]
    );

    return NextResponse.json({ message: '登録成功' });
  } catch (error) {
    return NextResponse.json({ error: '登録失敗' }, { status: 500 });
  }
}

    
  

更新(PUT):app/api/passwords/[id]/route.ts

対象データが存在しない場合や、更新が行われなかった場合には 404 エラーを返すことで、フロント側でのエラーハンドリングが容易になります。

    
import { NextResponse } from 'next/server';
import { runGet, runExecute } from '../../../../lib/db';
import type { Password } from '../../../../types/password';


// GET: 特定のパスワード情報を取得
export async function GET(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;

  if (!id || isNaN(Number(id))) {
    return NextResponse.json(
      { error: 'Invalid password_manager ID.' },
      { status: 400 }
    );
  }

  try {
    const result = runGet<Password>(
      'SELECT * FROM password_manager WHERE id = ?',
      [Number(id)]
    );

    if (!result) {
      return NextResponse.json(
        { error: 'password_manager entry not found.' },
        { status: 404 }
      );
    }

    return NextResponse.json(result);
  } catch (error) {
    console.error('Error fetching password_manager entry:', error);
    return NextResponse.json(
      { error: 'Failed to fetch password_manager entry.' },
      { status: 500 }
    );
  }
}

// PUT: 特定のパスワード情報を更新
export async function PUT(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;

  try {
    const body = await request.json();
    const {
      category,
      siteName,
      siteUrl,
      loginId,
      pass,
      email,
      memo,
    } = body;

    if (!siteName || !siteUrl || !pass) {
      return NextResponse.json(
        { error: 'site_name, site_url, and password are required.' },
        { status: 400 }
      );
    }

    const result = runExecute(
      'UPDATE password_manager SET category = ?, site_name = ?, site_url = ?, login_id = ?, password = ?, email = ?, memo = ? WHERE id = ?',
      [category, siteName, siteUrl, loginId, pass, email, memo, Number(id)]
    );



    return NextResponse.json({
      message: 'password_manager entry updated successfully.',
    });
  } catch (error) {
    console.error('Error updating password_manager entry:', error);
    return NextResponse.json(
      { error: 'Failed to update password_manager entry.' },
      { status: 500 }
    );
  }
}
    
  

今回の実装で感じたこと

アプリというのは、“見る”だけから“書ける”ようになると、一気に実用性が増します。
とはいえ、入力ミスや操作ミスを防ぐ工夫、わかりやすい導線づくりがより重要になってきます。

たとえば……

  • 「更新ボタンを押したつもりが反応しない」
  • 「どこを修正したのか忘れてしまった」

こうした“小さな不便”を減らすには、バリデーションやUXの改善が鍵になりそうです。
今後はそのあたりも丁寧に取り組んでいきたいと考えています。

次回予告

次回は GitHub へのコミットやバージョン管理のポイントについてご紹介します。
開発履歴をきちんと残すことで、メンテナンス性も大きく向上します。

おわりに

自分の生活に合わせて、自分のために作るアプリ。
完璧ではなくても、日々少しずつ使いやすくなっていく感覚が、40代になってからの開発の面白さだと感じています。

同じように「もう一度コードを書いてみたい」と思っている方にとって、少しでもヒントや励みになれば嬉しいです。

コメント

0 件のコメント:

コメントを投稿

コメントをお待ちしています。