パスワード変更機能を追加

- バックエンド: POST /api/auth/change-password/ エンドポイントを追加
- フロントエンド: /settings/password ページを追加(現在のPW確認・8文字バリデーション)
- Navbar: ログアウトボタン横に鍵アイコンでパスワード変更リンクを追加

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Akira
2026-02-25 09:51:03 +09:00
parent a010ece7ed
commit 407d915b35
3 changed files with 173 additions and 2 deletions

View File

@@ -19,6 +19,28 @@ from django.urls import path, include
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
from apps.fields.views import OfficialKyosaiFieldViewSet, OfficialChusankanFieldViewSet from apps.fields.views import OfficialKyosaiFieldViewSet, OfficialChusankanFieldViewSet
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
class ChangePasswordView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request):
user = request.user
current_password = request.data.get('current_password', '')
new_password = request.data.get('new_password', '')
if not current_password or not new_password:
return Response({'error': '現在のパスワードと新しいパスワードを入力してください'}, status=status.HTTP_400_BAD_REQUEST)
if not user.check_password(current_password):
return Response({'error': '現在のパスワードが正しくありません'}, status=status.HTTP_400_BAD_REQUEST)
if len(new_password) < 8:
return Response({'error': 'パスワードは8文字以上にしてください'}, status=status.HTTP_400_BAD_REQUEST)
user.set_password(new_password)
user.save()
return Response({'status': 'ok'})
master_router = DefaultRouter() master_router = DefaultRouter()
master_router.register(r'kyosai-fields', OfficialKyosaiFieldViewSet, basename='kyosai-field') master_router.register(r'kyosai-fields', OfficialKyosaiFieldViewSet, basename='kyosai-field')
@@ -32,5 +54,6 @@ urlpatterns = [
path('api/reports/', include('apps.reports.urls')), path('api/reports/', include('apps.reports.urls')),
path('api/auth/jwt/create/', TokenObtainPairView.as_view(), name='token_obtain_pair'), path('api/auth/jwt/create/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
path('api/auth/jwt/refresh/', TokenRefreshView.as_view(), name='token_refresh'), path('api/auth/jwt/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
path('api/auth/change-password/', ChangePasswordView.as_view(), name='change-password'),
path('api/mail/', include('apps.mail.urls')), path('api/mail/', include('apps.mail.urls')),
] ]

View File

@@ -0,0 +1,141 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { api } from '@/lib/api';
import Navbar from '@/components/Navbar';
import { KeyRound, CheckCircle } from 'lucide-react';
export default function ChangePasswordPage() {
const router = useRouter();
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (newPassword !== confirmPassword) {
setError('新しいパスワードと確認用パスワードが一致しません');
return;
}
if (newPassword.length < 8) {
setError('パスワードは8文字以上にしてください');
return;
}
setSubmitting(true);
try {
await api.post('/auth/change-password/', {
current_password: currentPassword,
new_password: newPassword,
});
setSuccess(true);
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ||
'パスワードの変更に失敗しました';
setError(msg);
} finally {
setSubmitting(false);
}
};
return (
<div className="min-h-screen bg-gray-50">
<Navbar />
<div className="max-w-md mx-auto px-4 py-12">
<div className="bg-white rounded-lg shadow p-8">
<div className="flex items-center mb-6">
<KeyRound className="h-6 w-6 text-green-700 mr-2" />
<h1 className="text-xl font-bold text-gray-900"></h1>
</div>
{success ? (
<div className="text-center py-8">
<CheckCircle className="h-12 w-12 text-green-500 mx-auto mb-4" />
<p className="text-gray-700 font-medium mb-2"></p>
<p className="text-sm text-gray-500 mb-6">使</p>
<button
onClick={() => router.push('/dashboard')}
className="px-4 py-2 bg-green-700 text-white rounded-md hover:bg-green-800 transition-colors"
>
</button>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500"
/>
<p className="text-xs text-gray-500 mt-1">8</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500"
/>
</div>
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-md text-sm text-red-700">
{error}
</div>
)}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={() => router.back()}
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 transition-colors"
>
</button>
<button
type="submit"
disabled={submitting}
className="flex-1 px-4 py-2 bg-green-700 text-white rounded-md hover:bg-green-800 disabled:opacity-50 transition-colors"
>
{submitting ? '変更中...' : '変更する'}
</button>
</div>
</form>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useRouter, usePathname } from 'next/navigation'; import { useRouter, usePathname } from 'next/navigation';
import { LogOut, Wheat, MapPin, FileText, Upload, LayoutDashboard, Mail, History, Shield } from 'lucide-react'; import { LogOut, Wheat, MapPin, FileText, Upload, LayoutDashboard, Mail, History, Shield, KeyRound } from 'lucide-react';
import { logout } from '@/lib/api'; import { logout } from '@/lib/api';
export default function Navbar() { export default function Navbar() {
@@ -102,7 +102,14 @@ export default function Navbar() {
</button> </button>
</div> </div>
</div> </div>
<div className="flex items-center"> <div className="flex items-center space-x-1">
<button
onClick={() => router.push('/settings/password')}
className="flex items-center px-3 py-2 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-md transition-colors"
title="パスワード変更"
>
<KeyRound className="h-4 w-4" />
</button>
<button <button
onClick={handleLogout} onClick={handleLogout}
className="flex items-center px-4 py-2 text-sm text-gray-700 hover:text-gray-900 hover:bg-gray-100 rounded-md transition-colors" className="flex items-center px-4 py-2 text-sm text-gray-700 hover:text-gray-900 hover:bg-gray-100 rounded-md transition-colors"