first commit

dev-backend-stuffs
kaikitamura1473 5 months ago
commit b3aad60800
  1. 177
      .gitignore
  2. 15
      backend/.env.sample
  3. 106
      backend/pom.xml
  4. 19
      backend/scripts/load-env.sh
  5. 88
      backend/scripts/setup-postgres.sh
  6. 33
      backend/sql/init-testdata.sql
  7. 23
      backend/src/main/java/com/example/todoapp/TodoApplication.java
  8. 65
      backend/src/main/java/com/example/todoapp/config/DataInitializer.java
  9. 124
      backend/src/main/java/com/example/todoapp/config/SecurityConfig.java
  10. 47
      backend/src/main/java/com/example/todoapp/controller/AuthController.java
  11. 106
      backend/src/main/java/com/example/todoapp/controller/TaskController.java
  12. 42
      backend/src/main/java/com/example/todoapp/dto/TaskDTO.java
  13. 51
      backend/src/main/java/com/example/todoapp/exception/GlobalExceptionHandler.java
  14. 87
      backend/src/main/java/com/example/todoapp/model/Task.java
  15. 52
      backend/src/main/java/com/example/todoapp/model/User.java
  16. 42
      backend/src/main/java/com/example/todoapp/repository/TaskRepository.java
  17. 34
      backend/src/main/java/com/example/todoapp/repository/UserRepository.java
  18. 103
      backend/src/main/java/com/example/todoapp/security/JwtAuthenticationFilter.java
  19. 92
      backend/src/main/java/com/example/todoapp/security/JwtTokenProvider.java
  20. 56
      backend/src/main/java/com/example/todoapp/security/UserDetailsServiceImpl.java
  21. 26
      backend/src/main/java/com/example/todoapp/security/dto/AuthRequest.java
  22. 26
      backend/src/main/java/com/example/todoapp/security/dto/AuthResponse.java
  23. 27
      backend/src/main/java/com/example/todoapp/security/dto/RegisterRequest.java
  24. 99
      backend/src/main/java/com/example/todoapp/service/AuthService.java
  25. 114
      backend/src/main/java/com/example/todoapp/service/TaskService.java
  26. 42
      backend/src/main/java/com/example/todoapp/util/MessageUtils.java
  27. 15
      backend/src/main/resources/application-azure.yml
  28. 15
      backend/src/main/resources/application-local.yml
  29. 39
      backend/src/main/resources/application.yml
  30. 9
      backend/src/main/resources/messages.properties
  31. 1
      frontend/.env.production
  32. 23
      frontend/.gitignore
  33. 28625
      frontend/package-lock.json
  34. 50
      frontend/package.json
  35. BIN
      frontend/public/favicon.ico
  36. 42
      frontend/public/index.html
  37. 15
      frontend/public/manifest.json
  38. 3
      frontend/public/robots.txt
  39. 33
      frontend/src/App.css
  40. 103
      frontend/src/App.tsx
  41. 116
      frontend/src/components/Layout.tsx
  42. 23
      frontend/src/constants/errorMessages.ts
  43. 35
      frontend/src/index.css
  44. 25
      frontend/src/index.tsx
  45. 127
      frontend/src/pages/LoginPage.tsx
  46. 127
      frontend/src/pages/RegisterPage.tsx
  47. 195
      frontend/src/pages/TaskListPage.tsx
  48. 12
      frontend/src/react-app-env.d.ts
  49. 176
      frontend/src/services/api.ts
  50. 50
      frontend/src/types/types.ts
  51. 53
      frontend/staticwebapp.config.json
  52. 26
      frontend/tsconfig.json

177
.gitignore vendored

@ -0,0 +1,177 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
frontend/node_modules
node_modules

@ -0,0 +1,15 @@
# このファイルを.envにコピーして、実際の値を設定してください
# ================================
# JWT 秘密鍵
# この設定はアプリケーションで使用します
# ================================
JWT_SECRET="your-JWT-Key"
# ================================
# ローカル開発用 PostgreSQL 接続設定
# この設定はローカル環境で使用します
# ================================
LOCAL_DB_NAME="local_db"
LOCAL_DB_USER="local_user"
LOCAL_DB_PASSWORD="your_local_password"

@ -0,0 +1,106 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.3</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>todo-app</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>todo-app</name>
<description>ToDo Application Backend</description>
<properties>
<java.version>17</java.version>
<jjwt.version>0.11.5</jjwt.version>
</properties>
<dependencies>
<!-- Spring Boot Starters -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- PostgreSQL Driver -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>

@ -0,0 +1,19 @@
#!/bin/bash
# 変数を呼び出し側に影響させないために関数化
function get_env_file_path() {
# スクリプト自身のディレクトリを取得
local SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# 1つ上の階層にある .env ファイルを読み込む
local ENV_FILE="$SCRIPT_DIR/../.env"
echo $ENV_FILE
}
if [ -f $(get_env_file_path) ]; then
echo "Loading .env from $(get_env_file_path)"
export $(grep -v '^#' "$(get_env_file_path)" | xargs)
echo "環境変数を設定しました"
else
echo ".env ファイルが見つかりません: $(get_env_file_path)"
exit 1
fi

@ -0,0 +1,88 @@
#!/bin/bash
# PostgreSQLセットアップスクリプト
# このスクリプトは、データベースとユーザーを作成し、必要な権限を付与します
# 再実行可能なスクリプトです - 既存のユーザーやデータベースは削除されます
# エラーが発生した場合にスクリプトを停止
set -e
# このスクリプト自身の絶対パスを取得
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# すべての変数を export 扱いに
set -a
# .env を読み込む
. $SCRIPT_DIR/../.env
# 通常モードに戻す
set +a
# 変数の定義
DB_NAME=$LOCAL_DB_NAME
DB_USER=$LOCAL_DB_USER
DB_PASSWORD=$LOCAL_DB_PASSWORD
DB_SCHEMA="public"
echo "PostgreSQLセットアップを開始します..."
echo "データベース: $DB_NAME"
echo "ユーザー: $DB_USER"
echo "スキーマ: $DB_SCHEMA"
# PostgreSQLのsuperuserとして実行する必要があります
# 通常はpostgresユーザーを使用します
# 既存の接続を切断
echo "既存の接続を切断します..."
sudo -u postgres psql -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$DB_NAME';" || true
# データベースの削除(存在する場合)
echo "既存のデータベースを削除します(存在する場合)..."
sudo -u postgres psql -c "DROP DATABASE IF EXISTS $DB_NAME;"
# ユーザーの削除(存在する場合)と作成
echo "ユーザー '$DB_USER' を再作成します..."
sudo -u postgres psql -c "DROP USER IF EXISTS $DB_USER;"
sudo -u postgres psql -c "CREATE USER $DB_USER WITH PASSWORD '$DB_PASSWORD';"
# データベースの作成
echo "データベース '$DB_NAME' を作成します..."
sudo -u postgres psql -c "CREATE DATABASE $DB_NAME;"
# データベースの所有者を設定
echo "データベース所有者を設定します..."
sudo -u postgres psql -c "ALTER DATABASE $DB_NAME OWNER TO $DB_USER;"
# 権限の付与
echo "データベース権限を付与します..."
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE $DB_NAME TO $DB_USER;"
# データベースに接続してスキーマ権限を付与
echo "スキーマ権限を付与します..."
sudo -u postgres psql -d $DB_NAME -c "GRANT ALL ON SCHEMA $DB_SCHEMA TO $DB_USER;"
# 既存のオブジェクトに対する権限を付与
echo "既存のオブジェクトに対する権限を付与します..."
sudo -u postgres psql -d $DB_NAME -c "GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA $DB_SCHEMA TO $DB_USER;" || true
sudo -u postgres psql -d $DB_NAME -c "GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA $DB_SCHEMA TO $DB_USER;" || true
sudo -u postgres psql -d $DB_NAME -c "GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA $DB_SCHEMA TO $DB_USER;" || true
# 今後作成されるオブジェクトに対する権限を設定(postgres ユーザーが作成する場合)
echo "postgres ユーザーが今後作成するオブジェクトに対する権限を設定します..."
sudo -u postgres psql -d $DB_NAME -c "ALTER DEFAULT PRIVILEGES FOR USER postgres IN SCHEMA $DB_SCHEMA GRANT ALL ON TABLES TO $DB_USER;"
sudo -u postgres psql -d $DB_NAME -c "ALTER DEFAULT PRIVILEGES FOR USER postgres IN SCHEMA $DB_SCHEMA GRANT ALL ON SEQUENCES TO $DB_USER;"
sudo -u postgres psql -d $DB_NAME -c "ALTER DEFAULT PRIVILEGES FOR USER postgres IN SCHEMA $DB_SCHEMA GRANT ALL ON FUNCTIONS TO $DB_USER;"
sudo -u postgres psql -d $DB_NAME -c "ALTER DEFAULT PRIVILEGES FOR USER postgres IN SCHEMA $DB_SCHEMA GRANT ALL ON TYPES TO $DB_USER;"
# ユーザー自身が作成するオブジェクトに対する権限を設定
echo "$DB_USER が今後作成するオブジェクトに対する権限を設定します..."
sudo -u postgres psql -d $DB_NAME -c "ALTER DEFAULT PRIVILEGES FOR USER $DB_USER IN SCHEMA $DB_SCHEMA GRANT ALL ON TABLES TO $DB_USER;"
sudo -u postgres psql -d $DB_NAME -c "ALTER DEFAULT PRIVILEGES FOR USER $DB_USER IN SCHEMA $DB_SCHEMA GRANT ALL ON SEQUENCES TO $DB_USER;"
sudo -u postgres psql -d $DB_NAME -c "ALTER DEFAULT PRIVILEGES FOR USER $DB_USER IN SCHEMA $DB_SCHEMA GRANT ALL ON FUNCTIONS TO $DB_USER;"
sudo -u postgres psql -d $DB_NAME -c "ALTER DEFAULT PRIVILEGES FOR USER $DB_USER IN SCHEMA $DB_SCHEMA GRANT ALL ON TYPES TO $DB_USER;"
echo "PostgreSQLセットアップが完了しました"
echo "データベース: $DB_NAME"
echo "ユーザー: $DB_USER"
echo "パスワード: $DB_PASSWORD"
echo ""
echo "このスクリプトは再実行可能です。再実行すると、既存のデータベースとユーザーは削除され、新しく作成されます。"

@ -0,0 +1,33 @@
-- ============================================
-- データ初期化およテストデータ投入スクリプト
-- 対象テーブル:users, tasks
-- 実行順に注意(外部キー制約あり)
-- ============================================
-- 1. 既存データの削除(テーブル自体は削除されません)
-- tasksテーブルはusersに外部キー参照しているため、削除順は tasks → users
DELETE FROM tasks;
DELETE FROM users;
-- 2. usersテーブルへ初期ユーザーデータを挿入
-- パスワードは事前にBCrypt(ラウンド回数10)でハッシュ化しておくこと
-- ハッシュ生成例:https://toolbase.cc/text/bcrypt などを使用
-- パスワード対応表(すべてラウンド10で生成済み):
-- "password1" → $2a$10$QvmWALLG44WrDJ/y9Jh7p.fehSkDZfC84bXiM4ZQteM3T6x/1aEDK
-- "password2" → $2a$10$O8QS5GcMVSz7pFD3pY7c0e6SsbmDtKVLNAL7zKhnfn3hEm6.P6vvO
-- "password3" → $2a$10$5k/c3/R29iyZ7Oe9vso6ZeQvcEYkyQMS7rJ1CVNGiY9dwCL05J9VK
INSERT INTO users (id, username, password, name, email) VALUES
(1, 'testuser1', '$2a$10$QvmWALLG44WrDJ/y9Jh7p.fehSkDZfC84bXiM4ZQteM3T6x/1aEDK', 'testuser1', 'test1@test.test'),
(2, 'testuser2', '$2a$10$O8QS5GcMVSz7pFD3pY7c0e6SsbmDtKVLNAL7zKhnfn3hEm6.P6vvO', 'testuser2', 'test2@test.test'),
(3, 'testuser3', '$2a$10$5k/c3/R29iyZ7Oe9vso6ZeQvcEYkyQMS7rJ1CVNGiY9dwCL05J9VK', 'testuser3', 'test3@test.test');
-- 3. tasksテーブルへ初期タスクデータを挿入
-- created_at / updated_at は明示的に設定
-- user_id は上記で追加した users.id を参照
INSERT INTO tasks (id, title, description, completed, user_id, created_at, updated_at) VALUES
(1, 'テストタスク1', '説明1', true, 1, '2025-06-01 00:00:00', '2025-06-01 00:00:00'),
(2, 'テストタスク2', '説明2', true, 2, '2025-06-01 01:00:00', '2025-06-01 01:00:00'),
(3, 'テストタスク3', '説明3', false, 3, '2025-06-01 02:00:00', '2025-06-01 02:00:00');

@ -0,0 +1,23 @@
package com.example.todoapp;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Todoアプリケーションのメインクラス
* <p>
* このクラスはSpring Bootアプリケーションのエントリーポイントです
* アプリケーションの起動と初期化を担当します
* </p>
*/
@SpringBootApplication
public class TodoApplication {
/**
* アプリケーションのメインメソッド
*
* @param args コマンドライン引数
*/
public static void main(String[] args) {
SpringApplication.run(TodoApplication.class, args);
}
}

@ -0,0 +1,65 @@
package com.example.todoapp.config;
import com.example.todoapp.model.Task;
import com.example.todoapp.model.User;
import com.example.todoapp.repository.TaskRepository;
import com.example.todoapp.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.password.PasswordEncoder;
/**
* アプリケーション起動時のデータ初期化を行うクラス
* <p>
* このクラスはアプリケーション起動時に実行されサンプルデータを
* データベースに投入します開発環境やデモ環境での利用を想定しています
* </p>
*/
@Configuration
public class DataInitializer {
@Autowired
private UserRepository userRepository;
@Autowired
private TaskRepository taskRepository;
@Autowired
private PasswordEncoder passwordEncoder;
/**
* アプリケーション起動時に実行されるデータ初期化メソッド
*
* @return CommandLineRunner インスタンス
*/
@Bean
public CommandLineRunner initData() {
return args -> {
// サンプルユーザーの作成
if (userRepository.count() == 0) {
User testUser = new User();
testUser.setUsername("testuser");
testUser.setPassword(passwordEncoder.encode("password"));
userRepository.save(testUser);
// サンプルタスクの作成
Task task1 = new Task();
task1.setTitle("サンプルタスク1");
task1.setDescription("これはサンプルタスクです");
task1.setCompleted(false);
task1.setUser(testUser);
taskRepository.save(task1);
Task task2 = new Task();
task2.setTitle("サンプルタスク2");
task2.setDescription("これは完了済みのサンプルタスクです");
task2.setCompleted(true);
task2.setUser(testUser);
taskRepository.save(task2);
}
};
}
}

@ -0,0 +1,124 @@
package com.example.todoapp.config;
import com.example.todoapp.security.JwtAuthenticationFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
/**
* セキュリティ設定クラス
* <p>
* このクラスはアプリケーションのセキュリティ設定を定義します
* JWT認証CORS設定エンドポイントのアクセス制御などを設定します
* </p>
*/
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Autowired
private JwtAuthenticationFilter jwtAuthenticationFilter;
/**
* セキュリティフィルターチェーンの設定
*
* @param http HttpSecurityオブジェクト
* @return 設定済みのSecurityFilterChain
* @throws Exception セキュリティ設定中に例外が発生した場合
*/
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors().and()
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeHttpRequests()
.requestMatchers("/auth/**").permitAll() // 認証エンドポイントは認証不要
.anyRequest().authenticated(); // その他のエンドポイントは認証必要
// JWTフィルターを認証フィルターの前に追加
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
/**
* 認証マネージャーの設定
*
* @param authConfig 認証設定
* @return AuthenticationManagerインスタンス
* @throws Exception 設定中に例外が発生した場合
*/
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception {
return authConfig.getAuthenticationManager();
}
/**
* パスワードエンコーダーの設定
*
* @return BCryptPasswordEncoderインスタンス
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* CORS設定
* <p>
* クロスオリジンリソース共有の設定を行います
* フロントエンドアプリケーションからのリクエストを許可します
* </p>
*
* @return CORSの設定
*/
@Value("${cors.allowed-origins}")
private String allowedOrigins;
@Value("${cors.allowed-methods}")
private String allowedMethods;
@Value("${cors.allowed-headers}")
private String allowedHeaders;
@Value("${cors.exposed-headers}")
private String exposedHeaders;
@Value("${cors.allow-credentials}")
private boolean allowCredentials;
@Value("${cors.max-age}")
private long maxAge;
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList(allowedOrigins.split(",")));
configuration.setAllowedMethods(Arrays.asList(allowedMethods.split(",")));
configuration.setAllowedHeaders(Arrays.asList(allowedHeaders.split(",")));
configuration.setExposedHeaders(Arrays.asList(exposedHeaders.split(",")));
configuration.setAllowCredentials(allowCredentials);
configuration.setMaxAge(maxAge);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}

@ -0,0 +1,47 @@
package com.example.todoapp.controller;
import com.example.todoapp.security.dto.AuthRequest;
import com.example.todoapp.security.dto.AuthResponse;
import com.example.todoapp.security.dto.RegisterRequest;
import com.example.todoapp.service.AuthService;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
/**
* 認証機能のRESTコントローラー
* <p>
* このコントローラーはユーザー登録とログイン機能のエンドポイントを提供します
* 認証に成功するとJWTトークンを発行します
* </p>
*/
@RestController
@RequestMapping("/auth")
public class AuthController {
@Autowired
private AuthService authService;
/**
* 新規ユーザー登録を行う
*
* @param request ユーザー登録情報ユーザー名パスワード
* @return JWT認証トークンと登録されたユーザー情報
*/
@PostMapping("/register")
public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
return ResponseEntity.ok(authService.register(request));
}
/**
* ユーザーログイン処理を行う
*
* @param request ログイン情報ユーザー名パスワード
* @return JWT認証トークンとユーザー情報
*/
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(@Valid @RequestBody AuthRequest request) {
return ResponseEntity.ok(authService.login(request));
}
}

@ -0,0 +1,106 @@
package com.example.todoapp.controller;
import com.example.todoapp.dto.TaskDTO;
import com.example.todoapp.model.Task;
import com.example.todoapp.service.TaskService;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
/**
* タスク管理のRESTコントローラー
* <p>
* このコントローラーはタスクの取得作成更新削除などの
* エンドポイントを提供しますすべてのエンドポイントは認証が必要です
* </p>
*/
@RestController
@RequestMapping("/tasks")
public class TaskController {
@Autowired
private TaskService taskService;
/**
* ログインユーザーのすべてのタスクを取得する
*
* @param authentication 認証情報
* @return ユーザーのタスクリスト
*/
@GetMapping
public ResponseEntity<List<TaskDTO>> getAllTasks(Authentication authentication) {
List<Task> tasks = taskService.getAllTasksByUser(authentication.getName());
// エンティティからDTOへの変換
List<TaskDTO> taskDTOs = tasks.stream()
.map(TaskDTO::fromEntity)
.collect(Collectors.toList());
return ResponseEntity.ok(taskDTOs);
}
/**
* 指定されたIDのタスクを取得する
*
* @param authentication 認証情報
* @param taskId タスクID
* @return タスク情報
*/
@GetMapping("/{id}")
public ResponseEntity<TaskDTO> getTaskById(
Authentication authentication,
@PathVariable("id") Long taskId) {
Task task = taskService.getTaskById(authentication.getName(), taskId);
return ResponseEntity.ok(TaskDTO.fromEntity(task));
}
/**
* 新しいタスクを作成する
*
* @param authentication 認証情報
* @param task 作成するタスクの情報
* @return 作成されたタスク
*/
@PostMapping
public ResponseEntity<TaskDTO> createTask(
Authentication authentication,
@Valid @RequestBody Task task) {
Task createdTask = taskService.createTask(authentication.getName(), task);
return ResponseEntity.ok(TaskDTO.fromEntity(createdTask));
}
/**
* 指定されたIDのタスクを更新する
*
* @param authentication 認証情報
* @param taskId 更新するタスクのID
* @param taskDetails 更新内容
* @return 更新されたタスク
*/
@PutMapping("/{id}")
public ResponseEntity<TaskDTO> updateTask(
Authentication authentication,
@PathVariable("id") Long taskId,
@Valid @RequestBody Task taskDetails) {
Task updatedTask = taskService.updateTask(authentication.getName(), taskId, taskDetails);
return ResponseEntity.ok(TaskDTO.fromEntity(updatedTask));
}
/**
* 指定されたIDのタスクを削除する
*
* @param authentication 認証情報
* @param taskId 削除するタスクのID
* @return 空のレスポンス
*/
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteTask(
Authentication authentication,
@PathVariable("id") Long taskId) {
taskService.deleteTask(authentication.getName(), taskId);
return ResponseEntity.ok().build();
}
}

@ -0,0 +1,42 @@
package com.example.todoapp.dto;
import com.example.todoapp.model.Task;
import lombok.Data;
import java.time.LocalDateTime;
/**
* タスクのデータ転送オブジェクトDTO
* <p>
* このクラスはクライアントとサーバー間でタスク情報をやり取りするために使用されます
* エンティティとは異なり必要な情報のみを含み関連エンティティへの参照ではなくIDのみを保持します
* </p>
*/
@Data
public class TaskDTO {
private Long id;
private String title;
private String description;
private boolean completed;
private Long userId;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
/**
* タスクエンティティからDTOを作成する
*
* @param task 変換元のタスクエンティティ
* @return 変換されたTaskDTOオブジェクト
*/
public static TaskDTO fromEntity(Task task) {
TaskDTO dto = new TaskDTO();
dto.setId(task.getId());
dto.setTitle(task.getTitle());
dto.setDescription(task.getDescription());
dto.setCompleted(task.isCompleted());
dto.setUserId(task.getUser() != null ? task.getUser().getId() : null);
dto.setCreatedAt(task.getCreatedAt());
dto.setUpdatedAt(task.getUpdatedAt());
return dto;
}
}

@ -0,0 +1,51 @@
package com.example.todoapp.exception;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.MethodArgumentNotValidException;
import java.util.HashMap;
import java.util.Map;
/**
* アプリケーション全体の例外ハンドリングを行うクラス
* <p>
* このクラスはアプリケーション内で発生した例外を捕捉し
* クライアントに適切なエラーレスポンスを返します
* </p>
*/
@ControllerAdvice
public class GlobalExceptionHandler {
/**
* RuntimeExceptionとそのサブクラスの例外をハンドリングする
*
* @param ex 発生した例外
* @return エラーメッセージを含むレスポンス
*/
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<?> handleRuntimeException(RuntimeException ex) {
Map<String, String> response = new HashMap<>();
response.put("message", ex.getMessage());
return ResponseEntity.badRequest().body(response);
}
/**
* バリデーション例外をハンドリングする
* <p>
* リクエストボディのバリデーションエラーを処理し
* フィールド名とエラーメッセージのマップを返します
* </p>
*
* @param ex バリデーション例外
* @return フィールド別のエラーメッセージを含むレスポンス
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<?> handleValidationException(MethodArgumentNotValidException ex) {
Map<String, String> response = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(error -> {
response.put(error.getField(), error.getDefaultMessage());
});
return ResponseEntity.badRequest().body(response);
}
}

@ -0,0 +1,87 @@
package com.example.todoapp.model;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* タスクエンティティクラス
* <p>
* このクラスはユーザーのタスク情報を表します
* タスクはタイトル説明完了状態などの情報を持ちます
* </p>
*/
@Data
@NoArgsConstructor
@Entity
@Table(name = "tasks")
public class Task {
/**
* タスクID主キー
*/
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* タスクのタイトル必須
*/
@NotBlank
@Column(length = 255, nullable = false)
private String title;
/**
* タスクの詳細説明任意
*/
@Column(length = 255)
private String description;
/**
* タスクの完了状態
* デフォルトは未完了false
*/
@Column(nullable = false)
private boolean completed = false;
/**
* タスクの所有者ユーザー
* 多対一の関係で遅延ロードを使用
*/
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
/**
* タスクの作成日時
*/
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
/**
* タスクの最終更新日時
*/
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
/**
* エンティティ作成時に自動的に実行される処理
* 作成日時と更新日時を現在時刻に設定します
*/
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
/**
* エンティティ更新時に自動的に実行される処理
* 更新日時を現在時刻に設定します
*/
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

@ -0,0 +1,52 @@
package com.example.todoapp.model;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.ArrayList;
import java.util.List;
/**
* ユーザーエンティティクラス
* <p>
* このクラスはアプリケーションのユーザー情報を表します
* ユーザー認証情報と個人情報および関連するタスクを管理します
* </p>
*/
@Data
@NoArgsConstructor
@Entity
@Table(name = "users")
public class User {
/**
* ユーザーID主キー
*/
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* ユーザー名ログインID
* システム内で一意である必要があります
*/
@NotBlank
@Column(unique = true, length = 255, nullable = false)
private String username;
/**
* パスワードハッシュ化して保存
*/
@NotBlank
@Column(length = 255, nullable = false)
private String password;
/**
* ユーザーが所有するタスクのリスト
* ユーザーが削除された場合関連するタスクも削除されます
*/
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Task> tasks = new ArrayList<>();
}

@ -0,0 +1,42 @@
package com.example.todoapp.repository;
import com.example.todoapp.model.Task;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
* タスクエンティティのリポジトリインターフェース
* <p>
* このインターフェースはタスクデータへのアクセスと操作を提供します
* Spring Data JPAによって自動的に実装されます
* </p>
*/
@Repository
public interface TaskRepository extends JpaRepository<Task, Long> {
/**
* 指定されたユーザーIDに関連するすべてのタスクを作成日時の降順で取得する
*
* @param userId ユーザーID
* @return 指定されたユーザーのタスクリスト作成日時の降順
*/
List<Task> findByUserIdOrderByCreatedAtDesc(Long userId);
/**
* 指定されたユーザーIDと完了状態に一致するタスクを作成日時の降順で取得する
*
* @param userId ユーザーID
* @param completed タスクの完了状態
* @return 条件に一致するタスクのリスト作成日時の降順
*/
List<Task> findByUserIdAndCompletedOrderByCreatedAtDesc(Long userId, boolean completed);
/**
* 指定されたユーザーIDとタスクIDに一致するタスクを削除する
*
* @param userId ユーザーID
* @param taskId タスクID
*/
void deleteByUserIdAndId(Long userId, Long taskId);
}

@ -0,0 +1,34 @@
package com.example.todoapp.repository;
import com.example.todoapp.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
/**
* ユーザーエンティティのリポジトリインターフェース
* <p>
* このインターフェースはユーザーデータへのアクセスと操作を提供します
* Spring Data JPAによって自動的に実装されます
* </p>
*/
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
/**
* ユーザー名からユーザーを検索する
*
* @param username 検索するユーザー名
* @return 見つかったユーザー存在しない場合は空のOptional
*/
Optional<User> findByUsername(String username);
/**
* 指定されたユーザー名が既に存在するかを確認する
*
* @param username 確認するユーザー名
* @return ユーザー名が存在する場合はtrue存在しない場合はfalse
*/
boolean existsByUsername(String username);
}

@ -0,0 +1,103 @@
package com.example.todoapp.security;
import com.example.todoapp.util.MessageUtils;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
* JWT認証フィルタークラス
* <p>
* このフィルターはHTTPリクエストからJWTトークンを抽出し
* トークンの検証とユーザー認証を行います
* 認証に成功した場合SecurityContextに認証情報を設定します
* </p>
*/
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtTokenProvider tokenProvider;
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private MessageUtils messageUtils;
@Value("${jwt.token-prefix}")
private String tokenPrefix;
@Value("${jwt.header-name}")
private String headerName;
/**
* リクエスト処理の内部メソッド
* <p>
* リクエストからJWTトークンを抽出し検証して認証を行います
* </p>
*
* @param request HTTPリクエスト
* @param response HTTPレスポンス
* @param filterChain フィルターチェーン
* @throws ServletException サーブレット例外
* @throws IOException I/O例外
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
try {
// リクエストからJWTトークンを取得
String jwt = getJwtFromRequest(request);
// トークンが存在し、有効な場合は認証を行う
if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
String username = tokenProvider.getUsernameFromToken(jwt);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
// 認証トークンを作成
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// SecurityContextに認証情報を設定
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception ex) {
logger.error(messageUtils.getMessage("error.auth.failed"), ex);
}
// 次のフィルターを実行
filterChain.doFilter(request, response);
}
/**
* HTTPリクエストからJWTトークンを抽出する
*
* @param request HTTPリクエスト
* @return 抽出されたJWTトークン存在しない場合はnull
*/
private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader(headerName);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(tokenPrefix + " ")) {
// 例: "Bearer eyJhbGciOiJIUzI1NiJ9..." から "Bearer " を除去して
// 実際のトークン部分 "eyJhbGciOiJIUzI1NiJ9..." のみを返す
// tokenPrefix.length() + 1 は "Bearer" の長さ + スペース1文字分のインデックス
return bearerToken.substring(tokenPrefix.length() + 1);
}
return null;
}
}

@ -0,0 +1,92 @@
package com.example.todoapp.security;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import java.security.Key;
import java.util.Date;
/**
* JWTトークン生成検証プロバイダークラス
* <p>
* このクラスはJWTトークンの生成検証およびトークンからの情報抽出を担当します
* アプリケーションの認証システムで使用されます
* </p>
*/
@Component
public class JwtTokenProvider {
@Value("${jwt.secret}")
private String jwtSecret;
@Value("${jwt.expiration}")
private long jwtExpirationInMs;
/**
* JWT署名用の鍵を取得する
*
* @return 署名用のキー
*/
private Key getSigningKey() {
byte[] keyBytes = jwtSecret.getBytes();
return Keys.hmacShaKeyFor(keyBytes);
}
/**
* 認証情報からJWTトークンを生成する
*
* @param authentication 認証情報
* @return 生成されたJWTトークン
*/
public String generateToken(Authentication authentication) {
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
Date now = new Date();
Date expiryDate = new Date(now.getTime() + jwtExpirationInMs);
return Jwts.builder()
.setSubject(userDetails.getUsername())
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(getSigningKey())
.compact();
}
/**
* トークンからユーザー名を抽出する
*
* @param token JWTトークン
* @return トークンに含まれるユーザー名
*/
public String getUsernameFromToken(String token) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();
return claims.getSubject();
}
/**
* トークンの有効性を検証する
*
* @param token 検証するJWTトークン
* @return トークンが有効な場合はtrue無効な場合はfalse
*/
public boolean validateToken(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
// トークンの検証に失敗した場合
return false;
}
}
}

@ -0,0 +1,56 @@
package com.example.todoapp.security;
import com.example.todoapp.model.User;
import com.example.todoapp.repository.UserRepository;
import com.example.todoapp.util.MessageUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
/**
* Spring Securityのユーザー詳細サービス実装クラス
* <p>
* このクラスはユーザー名からユーザー情報を取得し
* Spring Securityの認証システムで使用するUserDetailsオブジェクトを提供します
* </p>
*/
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserRepository userRepository;
@Autowired
private MessageUtils messageUtils;
/**
* ユーザー名からユーザー詳細情報を読み込む
* <p>
* データベースからユーザー情報を検索しSpring Securityで使用する
* UserDetailsオブジェクトに変換します
* </p>
*
* @param username 検索するユーザー名
* @return UserDetailsオブジェクト
* @throws UsernameNotFoundException ユーザーが見つからない場合
*/
@Override
@Transactional
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException(
messageUtils.getMessage("error.auth.user.not.found.with.username", new Object[]{username})));
// 権限リストは現在空のリストを使用(将来的に拡張可能)
return new org.springframework.security.core.userdetails.User(
user.getUsername(),
user.getPassword(),
new ArrayList<>()
);
}
}

@ -0,0 +1,26 @@
package com.example.todoapp.security.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 認証リクエストのデータ転送オブジェクトDTO
* <p>
* このクラスはログイン処理で使用される認証情報を表します
* クライアントからサーバーへのログインリクエストに使用されます
* </p>
*/
@Data
public class AuthRequest {
/**
* ユーザー名必須
*/
@NotBlank
private String username;
/**
* パスワード必須
*/
@NotBlank
private String password;
}

@ -0,0 +1,26 @@
package com.example.todoapp.security.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
/**
* 認証レスポンスのデータ転送オブジェクトDTO
* <p>
* このクラスは認証成功時にクライアントに返される情報を表します
* JWTトークンとユーザー情報を含みます
* </p>
*/
@Data
@AllArgsConstructor
public class AuthResponse {
/**
* JWT認証トークン
*/
private String token;
/**
* ユーザー名ログインID
*/
private String username;
}

@ -0,0 +1,27 @@
package com.example.todoapp.security.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* ユーザー登録リクエストのデータ転送オブジェクトDTO
* <p>
* このクラスは新規ユーザー登録時に必要な情報を表します
* クライアントからサーバーへのユーザー登録リクエストに使用されます
* </p>
*/
@Data
public class RegisterRequest {
/**
* ユーザー名ログインID必須
*/
@NotBlank
private String username;
/**
* パスワード必須
*/
@NotBlank
private String password;
}

@ -0,0 +1,99 @@
package com.example.todoapp.service;
import com.example.todoapp.model.User;
import com.example.todoapp.util.MessageUtils;
import com.example.todoapp.repository.UserRepository;
import com.example.todoapp.security.JwtTokenProvider;
import com.example.todoapp.security.dto.AuthRequest;
import com.example.todoapp.security.dto.AuthResponse;
import com.example.todoapp.security.dto.RegisterRequest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 認証機能のサービスクラス
* <p>
* このクラスはユーザー登録とログイン処理のビジネスロジックを提供します
* 認証に成功した場合JWTトークンを発行します
* </p>
*/
@Service
public class AuthService {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private UserRepository userRepository;
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private JwtTokenProvider tokenProvider;
@Autowired
private MessageUtils messageUtils;
/**
* 新規ユーザー登録を行う
* <p>
* ユーザー名の重複チェックを行い
* 問題がなければ新規ユーザーを作成します
* </p>
*
* @param request ユーザー登録情報
* @return JWT認証トークンとユーザー情報
* @throws RuntimeException ユーザー名が既に使用されている場合
*/
@Transactional
public AuthResponse register(RegisterRequest request) {
// ユーザー名の重複チェック
if (userRepository.existsByUsername(request.getUsername())) {
throw new RuntimeException(messageUtils.getMessage("error.auth.username.exists"));
}
// 新規ユーザーの作成
User user = new User();
user.setUsername(request.getUsername());
user.setPassword(passwordEncoder.encode(request.getPassword())); // パスワードをハッシュ化
userRepository.save(user);
// 認証処理
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword())
);
// JWTトークンの生成
String token = tokenProvider.generateToken(authentication);
return new AuthResponse(token, user.getUsername());
}
/**
* ユーザーログイン処理を行う
*
* @param request ログイン情報ユーザー名パスワード
* @return JWT認証トークンとユーザー情報
* @throws RuntimeException 認証に失敗した場合
*/
public AuthResponse login(AuthRequest request) {
// 認証処理
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword())
);
// JWTトークンの生成
String token = tokenProvider.generateToken(authentication);
User user = userRepository.findByUsername(request.getUsername())
.orElseThrow(() -> new RuntimeException(messageUtils.getMessage("error.auth.user.not.found")));
return new AuthResponse(token, user.getUsername());
}
}

@ -0,0 +1,114 @@
package com.example.todoapp.service;
import com.example.todoapp.model.Task;
import com.example.todoapp.util.MessageUtils;
import com.example.todoapp.model.User;
import com.example.todoapp.repository.TaskRepository;
import com.example.todoapp.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* タスク管理のサービスクラス
* <p>
* このクラスはタスクの取得作成更新削除などのビジネスロジックを提供します
* ユーザーごとのタスク管理を行い他のユーザーのタスクへのアクセスを制限します
* </p>
*/
@Service
public class TaskService {
@Autowired
private TaskRepository taskRepository;
@Autowired
private UserRepository userRepository;
@Autowired
private MessageUtils messageUtils;
/**
* 指定されたユーザーのすべてのタスクを取得する
*
* @param username ユーザー名
* @return ユーザーのタスクリスト作成日時の降順
*/
public List<Task> getAllTasksByUser(String username) {
User user = getUserByUsername(username);
return taskRepository.findByUserIdOrderByCreatedAtDesc(user.getId());
}
/**
* 指定されたユーザーの特定のタスクを取得する
*
* @param username ユーザー名
* @param taskId タスクID
* @return タスクエンティティ
* @throws RuntimeException タスクが見つからない場合または他のユーザーのタスクにアクセスしようとした場合
*/
public Task getTaskById(String username, Long taskId) {
User user = getUserByUsername(username);
return taskRepository.findById(taskId)
.filter(task -> task.getUser().getId().equals(user.getId())) // ユーザーのタスクかどうかを確認
.orElseThrow(() -> new RuntimeException(messageUtils.getMessage("error.task.not.found")));
}
/**
* 新しいタスクを作成する
*
* @param username ユーザー名
* @param task 作成するタスクの情報
* @return 保存されたタスクエンティティ
*/
@Transactional
public Task createTask(String username, Task task) {
User user = getUserByUsername(username);
task.setUser(user);
return taskRepository.save(task);
}
/**
* 指定されたタスクを更新する
*
* @param username ユーザー名
* @param taskId 更新するタスクのID
* @param taskDetails 更新内容
* @return 更新されたタスクエンティティ
*/
@Transactional
public Task updateTask(String username, Long taskId, Task taskDetails) {
Task task = getTaskById(username, taskId);
task.setTitle(taskDetails.getTitle());
task.setDescription(taskDetails.getDescription());
task.setCompleted(taskDetails.isCompleted());
return taskRepository.save(task);
}
/**
* 指定されたタスクを削除する
*
* @param username ユーザー名
* @param taskId 削除するタスクのID
*/
@Transactional
public void deleteTask(String username, Long taskId) {
Task task = getTaskById(username, taskId);
taskRepository.delete(task);
}
/**
* ユーザー名からユーザーエンティティを取得する
*
* @param username ユーザー名
* @return ユーザーエンティティ
* @throws UsernameNotFoundException ユーザーが見つからない場合
*/
private User getUserByUsername(String username) {
return userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException(messageUtils.getMessage("error.auth.user.not.found.with.name", new Object[]{username})));
}
}

@ -0,0 +1,42 @@
package com.example.todoapp.util;
import org.springframework.context.MessageSource;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.stereotype.Component;
import java.util.Locale;
/**
* プロパティファイルからメッセージを読み込むユーティリティクラス
*/
@Component
public class MessageUtils {
private final MessageSource messageSource;
public MessageUtils(MessageSource messageSource) {
this.messageSource = messageSource;
}
/**
* メッセージコードに対応するメッセージを取得します
*
* @param code メッセージコード
* @return メッセージ
*/
public String getMessage(String code) {
return getMessage(code, null);
}
/**
* メッセージコードに対応するメッセージを取得し引数でフォーマットします
*
* @param code メッセージコード
* @param args フォーマット引数
* @return フォーマットされたメッセージ
*/
public String getMessage(String code, Object[] args) {
Locale locale = LocaleContextHolder.getLocale();
return messageSource.getMessage(code, args, locale);
}
}

@ -0,0 +1,15 @@
spring:
datasource:
url: jdbc:postgresql://${AZURE_DB_HOST}:5432/${AZURE_DB_NAME}?sslmode=require
driver-class-name: org.postgresql.Driver
username: ${AZURE_DB_USER}
password: ${AZURE_DB_PASSWORD}
jpa:
database-platform: org.hibernate.dialect.PostgreSQLDialect
properties:
hibernate:
jdbc:
lob:
non_contextual_creation: true
cors:
allowed-origins: ${AZURE_STATIC_WEBAPP_URL}

@ -0,0 +1,15 @@
spring:
datasource:
url: jdbc:postgresql://localhost:5432/${LOCAL_DB_NAME}
driver-class-name: org.postgresql.Driver
username: ${LOCAL_DB_USER}
password: ${LOCAL_DB_PASSWORD}
jpa:
database-platform: org.hibernate.dialect.PostgreSQLDialect
properties:
hibernate:
jdbc:
lob:
non_contextual_creation: true
cors:
allowed-origins: http://localhost:3000

@ -0,0 +1,39 @@
logging:
level:
com.example.todoapp: DEBUG
org.springframework.security: DEBUG
spring:
jpa:
hibernate:
ddl-auto: update
show-sql: true
jackson:
serialization:
WRITE_DATES_AS_TIMESTAMPS: false
date-format: yyyy-MM-dd'T'HH:mm:ss.SSS'Z'
messages:
basename: messages # プロパティファイルのベース名(messages.propertiesを指定)
encoding: UTF-8 # プロパティファイルの文字エンコーディング(日本語対応のため)
cache-duration: 3600s # メッセージのキャッシュ期間(パフォーマンス向上のため)
server:
port: 8080
servlet:
context-path: /api
jwt:
# 重要: アプリケーションを起動する前に、環境変数JWT_SECRETを設定してください
# 例: export JWT_SECRET=your-256-bit-secret-key-here
# この環境変数が設定されていない場合、アプリケーションは起動しません
secret: ${JWT_SECRET}
expiration: 259200000 # 60 * 60 * 24 * 3 * 1000 (3日) ここで扱う値はミリ秒単位です
token-prefix: Bearer
header-name: Authorization
cors:
allowed-methods: GET,POST,PUT,DELETE,OPTIONS
allowed-headers: Authorization,Content-Type
exposed-headers: Authorization
allow-credentials: true
max-age: 3600

@ -0,0 +1,9 @@
# 認証関連のエラーメッセージ
error.auth.username.exists=このユーザー名は既に使用されています
error.auth.user.not.found=ユーザーが見つかりません
error.auth.user.not.found.with.name=ユーザーが見つかりません: %s
error.auth.user.not.found.with.username=ユーザーが見つかりません: %s
error.auth.failed=認証情報の設定に失敗しました
# タスク関連のエラーメッセージ
error.task.not.found=タスクが見つかりません

@ -0,0 +1 @@
REACT_APP_API_BASE_URL=${AZURE_APP_SERVICE_URL}

@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

File diff suppressed because it is too large Load Diff

@ -0,0 +1,50 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"homepage": ".",
"proxy": "http://localhost:8080",
"dependencies": {
"@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.15.10",
"@mui/material": "^5.15.10",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
"@types/node": "^16.18.68",
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.1",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

@ -0,0 +1,15 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

@ -0,0 +1,33 @@
/* ボディ要素の基本スタイルリセット */
body {
margin: 0;
padding: 0;
}
/* アプリケーションのルートコンテナスタイル */
.app {
display: flex;
flex-direction: column; /* 子要素を縦方向に配置 */
min-height: 100vh; /* ビューポートの高さいっぱいに広げる */
background-color: #f5f5f5; /* 薄いグレーの背景色 */
}
/* メインコンテンツエリアのスタイル */
.main-content {
flex: 1; /* 利用可能なスペースをすべて使用 */
padding: 24px;
width: 100%;
max-width: 1200px; /* コンテンツの最大幅を制限 */
margin: 0 auto; /* 中央揃え */
}
/* Material UIのコンテナコンポーネントのカスタマイズ */
.MuiContainer-root {
padding-top: 24px;
padding-bottom: 24px;
}
/* Material UIのペーパーコンポーネントのカスタマイズ */
.MuiPaper-root {
background-color: white; /* 白背景を確保 */
}

@ -0,0 +1,103 @@
/**
*
*
*/
import React from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { ThemeProvider, createTheme, CssBaseline, Box } from '@mui/material';
import Layout from './components/Layout';
import LoginPage from './pages/LoginPage';
import RegisterPage from './pages/RegisterPage';
import TaskListPage from './pages/TaskListPage';
import './App.css';
/**
* Material UIテーマを定義
*
*/
const theme = createTheme({
palette: {
mode: 'light',
primary: {
main: '#1976d2',
},
secondary: {
main: '#dc004e',
},
background: {
default: '#f5f5f5',
paper: '#ffffff',
},
},
typography: {
fontFamily: [
'-apple-system',
'BlinkMacSystemFont',
'"Segoe UI"',
'Roboto',
'"Helvetica Neue"',
'Arial',
'sans-serif',
].join(','),
},
components: {
MuiPaper: {
styleOverrides: {
root: {
boxShadow: '0px 2px 4px rgba(0, 0, 0, 0.1)',
},
},
},
},
});
/**
*
*
*
*/
const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const token = localStorage.getItem('token');
if (!token) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
};
/**
*
*
* - 未認証ユーザー: ログイン/
* - 認証済みユーザー: タスク一覧ページにアクセス可能
* -
*/
const App: React.FC = () => {
return (
<ThemeProvider theme={theme}>
<CssBaseline /> {/* MUIのリセットCSSを適用 */}
<Box className="app">
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/" element={<Layout />}>
{/* ルートパスへのアクセスはタスク一覧にリダイレクト */}
<Route index element={<Navigate to="/tasks" replace />} />
{/* タスク一覧は認証が必要なため、PrivateRouteでラップ */}
<Route
path="tasks"
element={
<PrivateRoute>
<TaskListPage />
</PrivateRoute>
}
/>
</Route>
</Routes>
</BrowserRouter>
</Box>
</ThemeProvider>
);
};
export default App;

@ -0,0 +1,116 @@
/**
*
* AppBar
*/
import React, { useState } from 'react';
import {
AppBar,
Toolbar,
Typography,
Container,
Box,
Button,
Drawer,
List,
ListItemText,
ListItemIcon,
ListItemButton,
Divider,
IconButton
} from '@mui/material';
import {
Menu as MenuIcon,
ListAlt as ListAltIcon,
} from '@mui/icons-material';
import { useNavigate, Outlet, useLocation } from 'react-router-dom';
const Layout: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const [drawerOpen, setDrawerOpen] = useState(false);
/**
*
*
*/
const handleLogout = () => {
localStorage.removeItem('token');
navigate('/login');
};
/**
*
*
*/
const handleNavigate = (path: string) => {
navigate(path);
setDrawerOpen(false);
};
// 現在のパスに基づいてメニュー項目が選択状態かどうかを判定
const isSelected = (path: string): boolean => {
return location.pathname === path;
};
// メニューを開閉するハンドラー
const toggleDrawer = () => {
setDrawerOpen(!drawerOpen);
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
{/* ヘッダー部分 - アプリ名とログアウトボタンを表示 */}
<AppBar position="static" elevation={0}>
<Toolbar>
<IconButton
edge="start"
color="inherit"
aria-label="menu"
onClick={toggleDrawer}
sx={{ mr: 2 }}
>
<MenuIcon />
</IconButton>
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
ToDoアプリ
</Typography>
<Button color="inherit" onClick={handleLogout}>
</Button>
</Toolbar>
</AppBar>
{/* サイドメニュー */}
<Drawer
anchor="left"
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
>
<Box
sx={{ width: 250 }}
role="presentation"
>
<List>
<ListItemButton
onClick={() => handleNavigate('/tasks')}
selected={isSelected('/tasks')}
>
<ListItemIcon><ListAltIcon /></ListItemIcon>
<ListItemText primary="タスク一覧" />
</ListItemButton>
<Divider />
</List>
</Box>
</Drawer>
{/* メインコンテンツ領域 - 子ルートのコンポーネントがここに表示される */}
<Box component="main" sx={{ flexGrow: 1, bgcolor: 'background.default', py: 3 }}>
<Container>
<Outlet /> {/* React Router の Outlet - 子ルートのコンポーネントがここにレンダリングされる */}
</Container>
</Box>
</Box>
);
};
export default Layout;

@ -0,0 +1,23 @@
/**
*
* 使
*/
// 一般的なエラーメッセージ
export const GENERAL_ERRORS = {
UNEXPECTED_ERROR: '予期せぬエラーが発生しました',
};
// 認証関連のエラーメッセージ
export const AUTH_ERRORS = {
LOGIN_FAILED: 'ログインに失敗しました',
REGISTER_FAILED: 'ユーザー登録に失敗しました',
};
// タスク関連のエラーメッセージ
export const TASK_ERRORS = {
FETCH_FAILED: 'タスクの取得に失敗しました',
CREATE_FAILED: 'タスクの作成に失敗しました',
UPDATE_FAILED: 'タスクの更新に失敗しました',
DELETE_FAILED: 'タスクの削除に失敗しました',
};

@ -0,0 +1,35 @@
/* グローバルリセット - すべての要素のマージン、パディング、ボックスサイズを統一 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* HTML/Body要素の基本スタイル設定 */
html, body {
height: 100%; /* 画面全体の高さを使用 */
margin: 0;
/* システムフォントスタックを使用して最適なフォントを表示 */
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
/* フォントのスムージング設定 - より読みやすく美しい表示に */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/* スクロールバーのレイアウトシフトを防止 */
scrollbar-gutter: stable both-edges;
overflow: auto
}
/* ルート要素のスタイル - Reactアプリケーションのマウントポイント */
#root {
height: 100%; /* コンテナの高さを100%に */
display: flex;
flex-direction: column; /* 子要素を縦方向に配置 */
}
/* コードブロックのスタイル - 等幅フォントを使用 */
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

@ -0,0 +1,25 @@
/**
*
* Reactアプリケーションの初期化とルートコンポーネントのレンダリングを行う
*/
import React from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App';
// DOMからルート要素を取得
const container = document.getElementById('root');
if (!container) {
// ルート要素が見つからない場合はエラーをスロー
throw new Error('Failed to find the root element');
}
// React 18のcreateRootAPIを使用してルートを作成
const root = createRoot(container);
// Appコンポーネントをレンダリング
// StrictModeで囲むことで開発時の潜在的な問題を検出
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

@ -0,0 +1,127 @@
/**
*
* APIと連携
*/
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Container,
Box,
Typography,
TextField,
Button,
Paper,
Alert,
Link,
Grid,
} from '@mui/material';
import { LoginCredentials } from '../types/types';
import { authApi } from '../services/api';
import { GENERAL_ERRORS } from '../constants/errorMessages';
const LoginPage: React.FC = () => {
const navigate = useNavigate();
// ログイン情報の状態管理
const [credentials, setCredentials] = useState<LoginCredentials>({
username: '',
password: '',
});
// エラーメッセージの状態管理
const [error, setError] = useState<string>('');
/**
*
* credentials状態に反映
*/
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setCredentials(prev => ({
...prev,
[name]: value, // 動的にプロパティ名を使用して状態を更新
}));
};
/**
*
* APIを呼び出し
*
*/
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); // フォームのデフォルト送信動作を防止
try {
const response = await authApi.login(credentials);
localStorage.setItem('token', response.token); // 認証トークンをローカルストレージに保存
navigate('/tasks'); // タスク一覧ページにリダイレクト
} catch (err) {
setError(err instanceof Error ? err.message : GENERAL_ERRORS.UNEXPECTED_ERROR);
}
};
return (
<Container maxWidth="md" sx={{ mt: 8, height: '100vh'}}>
<Grid container justifyContent="center" alignItems="center" sx={{ height: '100%' }}>
<Grid item xs={12} md={5}>
<Paper elevation={3} sx={{ p: 4 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<Typography component="h1" variant="h5" gutterBottom>
ToDoアプリ
</Typography>
{/* エラーがある場合のみアラートを表示 */}
{error && (
<Alert severity="error" sx={{ mb: 2, width: '100%' }}>
{error}
</Alert>
)}
{/* ログインフォーム */}
<Box component="form" onSubmit={handleSubmit} sx={{ width: '100%' }}>
{/* ユーザー名入力フィールド */}
<TextField
margin="normal"
required
fullWidth
id="username"
label="ユーザー名"
name="username"
autoComplete="username"
autoFocus
value={credentials.username}
onChange={handleChange}
/>
{/* パスワード入力フィールド */}
<TextField
margin="normal"
required
fullWidth
name="password"
label="パスワード"
type="password"
id="password"
autoComplete="current-password"
value={credentials.password}
onChange={handleChange}
/>
{/* ログインボタン */}
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
>
</Button>
{/* 新規登録ページへのリンク */}
<Box sx={{ textAlign: 'center' }}>
<Link href="/register" variant="body2">
</Link>
</Box>
</Box>
</Box>
</Paper>
</Grid>
</Grid>
</Container>
);
};
export default LoginPage;

@ -0,0 +1,127 @@
/**
*
* APIと連携
*/
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Container,
Box,
Typography,
TextField,
Button,
Paper,
Alert,
Link,
Grid,
} from '@mui/material';
import { RegisterCredentials } from '../types/types';
import { authApi } from '../services/api';
import { GENERAL_ERRORS } from '../constants/errorMessages';
const RegisterPage: React.FC = () => {
const navigate = useNavigate();
// 登録情報の状態管理 - ユーザー名、パスワードを含む
const [credentials, setCredentials] = useState<RegisterCredentials>({
username: '',
password: '',
});
// エラーメッセージの状態管理
const [error, setError] = useState<string>('');
/**
*
* credentials状態に反映
*/
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setCredentials(prev => ({
...prev,
[name]: value, // 動的にプロパティ名を使用して状態を更新
}));
};
/**
*
* APIを呼び出し
*
*/
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); // フォームのデフォルト送信動作を防止
try {
const response = await authApi.register(credentials);
localStorage.setItem('token', response.token); // 認証トークンをローカルストレージに保存
navigate('/tasks'); // タスク一覧ページにリダイレクト
} catch (err) {
setError(err instanceof Error ? err.message : GENERAL_ERRORS.UNEXPECTED_ERROR);
}
};
return (
<Container maxWidth="md" sx={{ mt: 8, height: '100vh' }}>
<Grid container justifyContent="center" alignItems="center" sx={{ height: '100%' }}>
<Grid item xs={12} md={5}>
<Paper elevation={3} sx={{ p: 4 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<Typography component="h1" variant="h5" gutterBottom>
</Typography>
{/* エラーがある場合のみアラートを表示 */}
{error && (
<Alert severity="error" sx={{ mb: 2, width: '100%' }}>
{error}
</Alert>
)}
{/* 登録フォーム */}
<Box component="form" onSubmit={handleSubmit} sx={{ width: '100%' }}>
{/* ユーザー名入力フィールド */}
<TextField
margin="normal"
required
fullWidth
id="username"
label="ユーザー名"
name="username"
autoComplete="username"
autoFocus
value={credentials.username}
onChange={handleChange}
/>
{/* パスワード入力フィールド */}
<TextField
margin="normal"
required
fullWidth
name="password"
label="パスワード"
type="password"
id="password"
autoComplete="new-password"
value={credentials.password}
onChange={handleChange}
/>
{/* 登録ボタン */}
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
>
</Button>
{/* ログインページへのリンク */}
<Box sx={{ textAlign: 'center' }}>
<Link href="/login" variant="body2">
</Link>
</Box>
</Box>
</Box>
</Paper>
</Grid>
</Grid>
</Container>
);
};
export default RegisterPage;

@ -0,0 +1,195 @@
/**
*
*
*/
import React, { useState, useEffect } from 'react';
import { taskApi } from '../services/api';
import {
Container,
Typography,
List,
ListItem,
ListItemText,
ListItemSecondaryAction,
IconButton,
Checkbox,
Fab,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Button,
Box,
} from '@mui/material';
import { Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-material';
import { Task } from '../types/types';
import { TASK_ERRORS } from '../constants/errorMessages';
// 新規タスクの初期状態
const EMPTY_TASK = { title: '', description: '', completed: false };
const TaskListPage: React.FC = () => {
// タスク一覧の状態管理
const [tasks, setTasks] = useState<Task[]>([]);
// 新規タスク作成ダイアログの表示状態
const [openDialog, setOpenDialog] = useState(false);
// 新規タスクの入力内容
const [newTask, setNewTask] = useState(EMPTY_TASK);
// コンポーネントマウント時にタスク一覧を取得
useEffect(() => {
fetchTasks();
}, []);
/**
* APIからタスク一覧を取得する関数
* state(tasks)
*/
const fetchTasks = async () => {
try {
const tasks = await taskApi.getTasks();
setTasks(tasks);
} catch (error) {
console.error(`${TASK_ERRORS.FETCH_FAILED}:`, error);
}
};
/**
*
* APIに更新を要求
*/
const handleToggleComplete = async (taskId: number) => {
try {
const task = tasks.find(t => t.id === taskId);
if (!task) return;
await taskApi.updateTask(taskId, { ...task, completed: !task.completed });
fetchTasks(); // 更新後のタスク一覧を再取得
} catch (error) {
console.error(`${TASK_ERRORS.UPDATE_FAILED}:`, error);
}
};
/**
*
* IDのタスクをAPIを通じて削除
*/
const handleDeleteTask = async (taskId: number) => {
try {
await taskApi.deleteTask(taskId);
fetchTasks(); // 削除後のタスク一覧を再取得
} catch (error) {
console.error(`${TASK_ERRORS.DELETE_FAILED}:`, error);
}
};
/**
*
* APIに送信して新規作成
*
*/
const handleCreateTask = async () => {
try {
await taskApi.createTask(newTask);
setOpenDialog(false); // ダイアログを閉じる
setNewTask(EMPTY_TASK); // 入力内容をリセット
fetchTasks(); // 作成後のタスク一覧を再取得
} catch (error) {
console.error(`${TASK_ERRORS.CREATE_FAILED}:`, error);
}
};
return (
<Container>
<Typography variant="h4" component="h1" gutterBottom>
</Typography>
{/* タスク一覧表示エリア - 青い背景のコンテナ */}
<div style={{ border: '3px solid black', borderRadius: '8px', backgroundColor: '#add8e6', height: 'auto', padding: '20px'}}>
<List>
{/* タスク一覧をマップして各タスクをリストアイテムとして表示 */}
{tasks.map((task) => (
<ListItem
key={task.id}
sx={{
bgcolor: 'background.paper',
mb: 1,
borderRadius: 1,
boxShadow: 1,
}}
>
{/* タスク完了状態を切り替えるチェックボックス */}
<Checkbox
checked={task.completed}
onChange={() => handleToggleComplete(task.id)}
/>
{/* タスクのタイトルと説明 - 完了状態に応じて取り消し線を表示 */}
<ListItemText
primary={task.title}
secondary={task.description}
sx={{
textDecoration: task.completed ? 'line-through' : 'none',
}}
/>
{/* タスク削除ボタン */}
<ListItemSecondaryAction>
<IconButton
edge="end"
aria-label="delete"
onClick={() => handleDeleteTask(task.id)}
>
<DeleteIcon />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
</div>
{/* 新規タスク作成ボタン - 画面下部に固定表示 */}
<Fab
color="primary"
sx={{ position: 'fixed', bottom: 16, left: '50%', transform: 'translateX(-50%)'}}
onClick={() => setOpenDialog(true)}
>
<AddIcon />
</Fab>
{/* 新規タスク作成ダイアログ */}
<Dialog open={openDialog} onClose={() => setOpenDialog(false)} disableScrollLock={true}>
<DialogTitle></DialogTitle>
<DialogContent>
<Box sx={{ pt: 1 }}>
{/* タスクタイトル入力フィールド */}
<TextField
autoFocus
margin="dense"
label="タイトル"
fullWidth
value={newTask.title}
onChange={(e) => setNewTask({ ...newTask, title: e.target.value })}
/>
{/* タスク説明入力フィールド - 複数行入力可能 */}
<TextField
margin="dense"
label="説明"
fullWidth
multiline
rows={4}
value={newTask.description}
onChange={(e) => setNewTask({ ...newTask, description: e.target.value })}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpenDialog(false)}></Button>
<Button onClick={handleCreateTask} variant="contained">
</Button>
</DialogActions>
</Dialog>
</Container>
);
};
export default TaskListPage;

@ -0,0 +1,12 @@
/**
* TypeScript型定義参照ファイル
*
* Create React Appによって自動生成され
* react-scriptsパッケージの型定義を参照しています
*
* (process.env)
* Create React App特有の機能に対する型定義が提供されます
*
*
*/
/// <reference types="react-scripts" />

@ -0,0 +1,176 @@
/**
* APIサービス
* APIとの通信を担当するモジュール
*
*/
import { LoginCredentials, RegisterCredentials, AuthResponse, Task } from '../types/types';
import { AUTH_ERRORS, TASK_ERRORS } from '../constants/errorMessages';
// APIのベースURL - 環境変数から取得するか、デフォルト値を使用
const API_BASE_URL = process.env.REACT_APP_API_BASE_URL || 'http://localhost:8080';
/**
* APIリクエスト用のヘッダーを生成する関数
* @param includeAuth
* @returns
*/
const getHeaders = (includeAuth: boolean = true) => {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
// 認証トークンが必要な場合はローカルストレージから取得してヘッダーに追加
if (includeAuth) {
const token = localStorage.getItem('token');
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
}
return headers;
};
/**
* API機能を提供するオブジェクト
*
*/
export const authApi = {
/**
*
* @param credentials
* @returns
*/
login: async (credentials: LoginCredentials): Promise<AuthResponse> => {
const response = await fetch(`${API_BASE_URL}/api/auth/login`, {
method: 'POST',
headers: getHeaders(false), // 認証前なのでトークンは不要
body: JSON.stringify(credentials),
});
// エラーレスポンスの処理
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(
errorData?.message || AUTH_ERRORS.LOGIN_FAILED
);
}
return response.json();
},
/**
*
* @param credentials
* @returns
*/
register: async (credentials: RegisterCredentials): Promise<AuthResponse> => {
const response = await fetch(`${API_BASE_URL}/api/auth/register`, {
method: 'POST',
headers: getHeaders(false), // 認証前なのでトークンは不要
body: JSON.stringify(credentials),
});
// エラーレスポンスの処理
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(
errorData?.message || AUTH_ERRORS.REGISTER_FAILED
);
}
return response.json();
},
};
/**
* API機能を提供するオブジェクト
*
*/
export const taskApi = {
/**
*
* @returns
*/
getTasks: async (): Promise<Task[]> => {
const response = await fetch(`${API_BASE_URL}/api/tasks`, {
headers: getHeaders(), // 認証トークンを含むヘッダー
});
if (!response.ok) {
throw new Error(TASK_ERRORS.FETCH_FAILED);
}
return response.json();
},
/**
* IDのタスクを取得
* @param id ID
* @returns
*/
getTask: async (id: number): Promise<Task> => {
const response = await fetch(`${API_BASE_URL}/api/tasks/${id}`, {
headers: getHeaders(),
});
if (!response.ok) {
throw new Error(TASK_ERRORS.FETCH_FAILED);
}
return response.json();
},
/**
*
* @param task IDID
* @returns
*/
createTask: async (task: Omit<Task, 'id' | 'userId' | 'createdAt' | 'updatedAt'>): Promise<Task> => {
const response = await fetch(`${API_BASE_URL}/api/tasks`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(task),
});
if (!response.ok) {
throw new Error(TASK_ERRORS.CREATE_FAILED);
}
return response.json();
},
/**
*
* @param id ID
* @param task
* @returns
*/
updateTask: async (id: number, task: Partial<Task>): Promise<Task> => {
const response = await fetch(`${API_BASE_URL}/api/tasks/${id}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify(task),
});
if (!response.ok) {
throw new Error(TASK_ERRORS.UPDATE_FAILED);
}
return response.json();
},
/**
*
* @param id ID
*/
deleteTask: async (id: number): Promise<void> => {
const response = await fetch(`${API_BASE_URL}/api/tasks/${id}`, {
method: 'DELETE',
headers: getHeaders(),
});
if (!response.ok) {
throw new Error(TASK_ERRORS.DELETE_FAILED);
}
},
};

@ -0,0 +1,50 @@
/**
*
*
*/
export interface Task {
id: number; // タスクの一意識別子
title: string; // タスクのタイトル
description?: string; // タスクの詳細説明(任意)
completed: boolean; // タスクの完了状態
userId: number; // タスクの所有者ID
createdAt: string; // タスク作成日時
updatedAt: string; // タスク更新日時
}
/**
*
*
*/
export interface User {
id: number; // ユーザーの一意識別子
username: string; // ユーザー名(ログイン用)
}
/**
*
*
*/
export interface AuthResponse {
token: string; // JWT認証トークン
user: User; // ログインしたユーザーの情報
}
/**
*
*
*/
export interface LoginCredentials {
username: string; // ユーザー名
password: string; // パスワード
}
/**
*
*
*/
export interface RegisterCredentials {
username: string; // ユーザー名
password: string; // パスワード
}

@ -0,0 +1,53 @@
{
"navigationFallback": {
"rewrite": "/index.html",
"exclude": ["/images/*.{png,jpg,gif}", "/css/*", "/js/*", "/static/*"]
},
"routes": [
{
"route": "/static/*",
"headers": {
"cache-control": "must-revalidate, max-age=15770000"
}
},
{
"route": "/login",
"rewrite": "/index.html"
},
{
"route": "/register",
"rewrite": "/index.html"
},
{
"route": "/tasks",
"rewrite": "/index.html"
},
{
"route": "/*",
"rewrite": "/index.html"
}
],
"responseOverrides": {
"404": {
"rewrite": "/index.html",
"statusCode": 200
}
},
"globalHeaders": {
"content-security-policy": "default-src 'self' 'unsafe-inline' 'unsafe-eval' https://*.azurewebsites.net https://*.japaneast-01.azurewebsites.net https://*.azurestaticapps.net; connect-src 'self' https://*.azurewebsites.net https://*.japaneast-01.azurewebsites.net; img-src 'self' data:; font-src 'self' data:;",
"cache-control": "no-cache, no-store, must-revalidate"
},
"mimeTypes": {
".json": "application/json",
".html": "text/html",
".js": "text/javascript",
".css": "text/css",
".png": "image/png",
".jpg": "image/jpeg",
".svg": "image/svg+xml",
".ico": "image/x-icon"
},
"networking": {
"allowedIpRanges": []
}
}

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}
Loading…
Cancel
Save