diff --git a/README.md b/README.md index fe28370f4..e6ae88aeb 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ ![group-manager-jpeg 001](https://user-images.githubusercontent.com/33748835/117114823-45700b00-adc7-11eb-8bab-7442f38a7065.jpeg) ### api -ruby 2.7.1
+ruby 2.7.1
rails 6.1.3.1
### view(ユーザー画面) @@ -31,3 +31,16 @@ nuxt.js
## セットアップ [git cloneをしたら](https://github.com/NUTFes/group-manager-2/wiki/git-clone-%E3%82%92%E3%81%97%E3%81%9F%E3%82%89) + +## メール送信設定(Outlook SMTP) +本番環境でOutlook(Office365)のSMTPを利用する場合、以下の環境変数を設定してください。値は `.env` やインフラ側のシークレットマネージャ等で安全に管理します。 + +- MAILER_SENDER: 送信元メールアドレス(例: no-reply@group-manager.nutfes.net) +- MAILER_HOST: メール内のURLで利用するホスト名(例: group-manager.nutfes.net) +- MAILER_PROTOCOL: URLに使用するプロトコル。既定値は `https` +- MAILER_ASSET_HOST: メール内の静的アセットURLに使用するホスト。既定値は `https://group-manager.nutfes.net` +- SMTP_ADDRESS: SMTPサーバーのアドレス。既定値は `smtp.office365.com` +- SMTP_PORT: SMTPサーバーのポート番号。既定値は `587` +- SMTP_DOMAIN: SMTP認証で利用するドメイン(HELO/EHLO)。既定値は `group-manager.nutfes.net` +- SMTP_USERNAME: Outlookアカウントのユーザー名(メールアドレス) +- SMTP_PASSWORD: Outlookアカウントのパスワード diff --git a/api/Gemfile b/api/Gemfile index bd9ba7e67..189105d9c 100644 --- a/api/Gemfile +++ b/api/Gemfile @@ -50,6 +50,8 @@ end group :development do gem 'listen', '~> 3.2' # Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring + gem 'letter_opener' + gem 'letter_opener_web' gem 'r2-oas' gem 'spring' gem 'spring-watcher-listen', '~> 2.0.0' diff --git a/api/Gemfile.lock b/api/Gemfile.lock index 4a3255d16..4b5f98c8a 100644 --- a/api/Gemfile.lock +++ b/api/Gemfile.lock @@ -60,6 +60,8 @@ GEM minitest (>= 5.1) tzinfo (~> 2.0) zeitwerk (~> 2.3) + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) ast (2.4.3) base64 (0.2.0) bcrypt (3.1.16) @@ -67,6 +69,8 @@ GEM msgpack (~> 1.2) builder (3.2.4) byebug (11.1.3) + childprocess (5.1.0) + logger (~> 1.5) concurrent-ruby (1.1.8) crass (1.0.6) devise (4.7.3) @@ -128,6 +132,17 @@ GEM json (2.13.2) key_flatten (1.0.0) language_server-protocol (3.17.0.5) + launchy (3.1.1) + addressable (~> 2.8) + childprocess (~> 5.0) + logger (~> 1.6) + letter_opener (1.10.0) + launchy (>= 2.2, < 4) + letter_opener_web (2.0.0) + actionmailer (>= 5.2) + letter_opener (~> 1.7) + railties (>= 5.2) + rexml lint_roller (1.1.0) listen (3.5.1) rb-fsevent (~> 0.10, >= 0.10.3) @@ -158,6 +173,7 @@ GEM racc pdfkit (0.8.5) prism (1.4.0) + public_suffix (6.0.2) puma (4.3.7) nio4r (~> 2.0) r2-oas (0.5.0) @@ -298,6 +314,8 @@ DEPENDENCIES devise_token_auth dotenv-rails jbuilder (~> 2.7) + letter_opener + letter_opener_web listen (~> 3.2) mysql2 (>= 0.4.4) pdfkit diff --git a/api/app/controllers/api/auth/passwords_controller.rb b/api/app/controllers/api/auth/passwords_controller.rb new file mode 100644 index 000000000..f01027277 --- /dev/null +++ b/api/app/controllers/api/auth/passwords_controller.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Api + module Auth + class PasswordsController < DeviseTokenAuth::PasswordsController + # Devise Token Auth 標準のパスワードリセットフローを利用します。 + # 基本的な挙動は親クラスに委譲し、必要があればここでカスタマイズします。 + end + end +end diff --git a/api/app/mailers/application_mailer.rb b/api/app/mailers/application_mailer.rb index d84cb6e71..ad8a92a73 100644 --- a/api/app/mailers/application_mailer.rb +++ b/api/app/mailers/application_mailer.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true class ApplicationMailer < ActionMailer::Base - default from: 'from@example.com' + default from: ENV.fetch('MAILER_SENDER', 'no-reply@group-manager.nutfes.net') layout 'mailer' end diff --git a/api/app/views/devise/mailer/reset_password_instructions.html.erb b/api/app/views/devise/mailer/reset_password_instructions.html.erb new file mode 100644 index 000000000..02c4c1673 --- /dev/null +++ b/api/app/views/devise/mailer/reset_password_instructions.html.erb @@ -0,0 +1,78 @@ +<% email_label = + if @resource.respond_to?(:name) && @resource.name.present? + "#{@resource.name} 様" + elsif @resource.respond_to?(:email) && @resource.email.present? + "#{@resource.email} 様" + else + "ご利用者様" + end %> +<% uid = + if @resource.respond_to?(:uid) && @resource.uid.present? + @resource.uid + elsif @resource.respond_to?(:email) && @resource.email.present? + @resource.email + end %> +<% client_id = + if defined?(@client_id) && @client_id.present? + @client_id + elsif defined?(@client_config) && @client_config.present? + @client_config + end %> +<% redirect_base = defined?(@redirect_url) ? @redirect_url : nil %> +<% tokenised_link = + if redirect_base.present? && client_id.present? && uid.present? + query = { client_id: client_id, token: @token, uid: uid }.to_query + separator = redirect_base.include?('?') ? '&' : '?' + "#{redirect_base}#{separator}#{query}" + end %> +<% reset_link = + if tokenised_link.present? + tokenised_link + elsif respond_to?(:edit_password_url) + edit_password_url(@resource, reset_password_token: @token) + end %> +<% reset_link ||= '#' %> + + + + + + パスワード再設定のご案内 + + +
+
+

パスワード再設定のご案内

+

+ <%= email_label %>、こんにちは。
+ パスワード再設定のお申し込みを受け付けました。以下のボタンをクリックし、パスワード再設定ページへ進んでください。 +

+ +
+ + パスワードを再設定する + +
+ +

+ ボタンが正しく動作しない場合は、以下のURLをコピーしてブラウザに貼り付けてください。 +

+

+ <%= reset_link %> +

+ +

+ このリンクの有効期限は一度のみです。お心当たりのない操作の場合は、このメールを破棄してください。 +

+

+ 本メールは送信専用です。ご返信いただいても対応いたしかねますのでご了承ください。 +

+
+
+ + diff --git a/api/app/views/devise/mailer/reset_password_instructions.text.erb b/api/app/views/devise/mailer/reset_password_instructions.text.erb new file mode 100644 index 000000000..b18d2ed10 --- /dev/null +++ b/api/app/views/devise/mailer/reset_password_instructions.text.erb @@ -0,0 +1,46 @@ +<% email_label = + if @resource.respond_to?(:name) && @resource.name.present? + "#{@resource.name} 様" + elsif @resource.respond_to?(:email) && @resource.email.present? + "#{@resource.email} 様" + else + "ご利用者様" + end %> +<% uid = + if @resource.respond_to?(:uid) && @resource.uid.present? + @resource.uid + elsif @resource.respond_to?(:email) && @resource.email.present? + @resource.email + end %> +<% client_id = + if defined?(@client_id) && @client_id.present? + @client_id + elsif defined?(@client_config) && @client_config.present? + @client_config + end %> +<% redirect_base = defined?(@redirect_url) ? @redirect_url : nil %> +<% tokenised_link = + if redirect_base.present? && client_id.present? && uid.present? + query = { client_id: client_id, token: @token, uid: uid }.to_query + separator = redirect_base.include?('?') ? '&' : '?' + "#{redirect_base}#{separator}#{query}" + end %> +<% reset_link = + if tokenised_link.present? + tokenised_link + elsif respond_to?(:edit_password_url) + edit_password_url(@resource, reset_password_token: @token) + end %> +<% reset_link ||= '#' %> + +パスワード再設定のご案内 + +<%= email_label %>、こんにちは。 + +パスワード再設定のお申し込みを受け付けました。以下のURLからパスワード再設定ページへ進んでください。 + +<%= reset_link %> + +このリンクの有効期限は一度のみです。お心当たりのない操作の場合は、このメールを破棄してください。 + +本メールは送信専用です。ご返信いただいても対応いたしかねますのでご了承ください。 diff --git a/api/config/environments/development.rb b/api/config/environments/development.rb index 0e7fad6b6..4535fb5f4 100644 --- a/api/config/environments/development.rb +++ b/api/config/environments/development.rb @@ -54,4 +54,10 @@ localhost api ] + # パスワードメール送信用の開発環境設定(letter_opener) + config.action_mailer.default_url_options = { host: 'localhost', port: 3000 } + config.action_mailer.asset_host = 'http://localhost:3000' + config.action_mailer.delivery_method = :letter_opener + config.action_mailer.perform_deliveries = true + config.action_mailer.raise_delivery_errors = true end diff --git a/api/config/environments/production.rb b/api/config/environments/production.rb index 12e0aa85b..7648a0ecf 100644 --- a/api/config/environments/production.rb +++ b/api/config/environments/production.rb @@ -56,6 +56,23 @@ # config.active_job.queue_name_prefix = "myapp_production" config.action_mailer.perform_caching = false + config.action_mailer.default_url_options = { + host: ENV.fetch('MAILER_HOST', 'group-manager.nutfes.net'), + protocol: ENV.fetch('MAILER_PROTOCOL', 'https') + } + config.action_mailer.asset_host = ENV.fetch('MAILER_ASSET_HOST', 'https://group-manager.nutfes.net') + config.action_mailer.raise_delivery_errors = true + config.action_mailer.perform_deliveries = true + config.action_mailer.delivery_method = :smtp + config.action_mailer.smtp_settings = { + address: ENV.fetch('SMTP_ADDRESS', 'smtp.office365.com'), + port: ENV.fetch('SMTP_PORT', 587).to_i, + domain: ENV.fetch('SMTP_DOMAIN', 'group-manager.nutfes.net'), + user_name: ENV.fetch('SMTP_USERNAME'), + password: ENV.fetch('SMTP_PASSWORD'), + authentication: :login, + enable_starttls_auto: true + } # Ignore bad email addresses and do not raise email delivery errors. # Set this to true and configure the email server for immediate delivery to raise delivery errors. diff --git a/api/config/initializers/devise.rb b/api/config/initializers/devise.rb index 35311c03f..55acd7341 100644 --- a/api/config/initializers/devise.rb +++ b/api/config/initializers/devise.rb @@ -24,7 +24,7 @@ # Configure the e-mail address which will be shown in Devise::Mailer, # note that it will be overwritten if you use your own mailer class # with default "from" parameter. - config.mailer_sender = 'please-change-me-at-config-initializers-devise@example.com' + config.mailer_sender = ENV.fetch('MAILER_SENDER', 'no-reply@example.com') # Configure the class responsible to send e-mails. # config.mailer = 'Devise::Mailer' diff --git a/api/config/routes.rb b/api/config/routes.rb index 0f3c60d26..f03f69e22 100644 --- a/api/config/routes.rb +++ b/api/config/routes.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true Rails.application.routes.draw do + mount LetterOpenerWeb::Engine, at: '/letter_opener' if Rails.env.development? + # 識別番号割り当て get 'group_identification' => 'group_identification#index' post 'group_identification' => 'group_identification#create' @@ -336,7 +338,8 @@ namespace :api do mount_devise_token_auth_for 'User', at: 'auth', controllers: { - registrations: 'api/auth/registrations' + registrations: 'api/auth/registrations', + passwords: 'api/auth/passwords' } namespace :auth do resources :sessions diff --git a/user/src/components/PasswordResetCard/PasswordResetCard.tsx b/user/src/components/PasswordResetCard/PasswordResetCard.tsx new file mode 100644 index 000000000..256c3d6cc --- /dev/null +++ b/user/src/components/PasswordResetCard/PasswordResetCard.tsx @@ -0,0 +1,114 @@ +import { FormEvent, useMemo, useState } from 'react'; +import { toast } from 'react-toastify'; +import Button from '@/components/Button'; +import TextBox from '@/components/Form/TextBox'; + +const DEFAULT_REDIRECT_PATH = '/password_reset.html'; + +const buildRedirectUrl = () => { + const apiUrl = process.env.NEXT_PUBLIC_API_URL; + if (!apiUrl) return ''; + try { + const url = new URL(apiUrl); + url.pathname = DEFAULT_REDIRECT_PATH; + url.search = ''; + url.hash = ''; + return url.toString(); + } catch (error) { + console.error('Invalid NEXT_PUBLIC_API_URL:', apiUrl, error); + return ''; + } +}; + +const ERROR_MESSAGE = + 'パスワード再設定メールの送信に失敗しました。メールアドレスが間違っているか、登録されていない可能性があります。'; + +const PasswordResetCard = () => { + const [email, setEmail] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + const redirectUrl = useMemo(() => buildRedirectUrl(), []); + const apiUrl = process.env.NEXT_PUBLIC_API_URL; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + if (!email) { + toast.error('メールアドレスを入力してください。'); + return; + } + if (!apiUrl) { + console.error( + 'NEXT_PUBLIC_API_URL is not defined.', + 'Password reset cannot proceed.' + ); + toast.error(ERROR_MESSAGE); + return; + } + if (!redirectUrl) { + console.error('Redirect URL for password reset is invalid.'); + toast.error(ERROR_MESSAGE); + return; + } + + setIsSubmitting(true); + try { + const response = await fetch(`${apiUrl}/api/auth/password`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + email, + redirect_url: redirectUrl, + }), + }); + + const body = await response.json().catch(() => null); + + if (!response.ok) { + if (body) { + console.error('Password reset error body:', body); + } + toast.error(ERROR_MESSAGE); + return; + } + + toast.success('パスワード再設定メールを送信しました。'); + setEmail(''); + } catch (error) { + console.error('Failed to request password reset:', error); + toast.error(ERROR_MESSAGE); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+

パスワードをお忘れの方

+

+ 登録済みのメールアドレスを入力すると、パスワード再設定用のリンクをお送りします。 +

+
+
+ + + +
+ ); +}; + +export default PasswordResetCard; diff --git a/user/src/components/PasswordResetCard/index.ts b/user/src/components/PasswordResetCard/index.ts new file mode 100644 index 000000000..418c8e3ce --- /dev/null +++ b/user/src/components/PasswordResetCard/index.ts @@ -0,0 +1 @@ +export { default } from './PasswordResetCard'; diff --git a/user/src/pages/index.tsx b/user/src/pages/index.tsx index 5e9fc55a5..fa3dfe7dc 100644 --- a/user/src/pages/index.tsx +++ b/user/src/pages/index.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import LoginModal from '@/components/LoginModal'; import NewsList from '@/components/NewsList'; +import PasswordResetCard from '@/components/PasswordResetCard'; import RegisterCarousel from '@/components/RegisterCarousel'; import WelcomeBox from '@/components/WelcomeBox'; @@ -30,14 +31,17 @@ export default function Home() {
- { - handleLoginClick(); - }} - handleRegisterClick={() => { - handleRegisterClick(); - }} - /> +
+ { + handleLoginClick(); + }} + handleRegisterClick={() => { + handleRegisterClick(); + }} + /> + +
);