🐧

Cognitoを使用したユーザー退会機能の実装

に公開

はじめに

ユーザーの退会機能を実装するとなった時に大抵のケースではユーザーの無効化を行う形で論理削除することが多いと思う(同じメールアドレスを使わせないなどのため)。Cognitoにもアカウントを無効化する機能があるので、ユーザーに削除機能としてそれを提供することを前提にこの記事は書いている。また作業内容をCursorにまとめて貰ったものなので、投稿者によってチェックはしているが至らない点や間違いがあればどうぞ指摘していただけるとありがたいです。

update: 初めの記事ではローカルでは削除できてもAmplify側にデプロイした場合には削除が実行できないので、そこを改修したものにアップデートしました。

ーーーーここからーーーー

概要

ユーザー削除機能は、アプリケーションにおいて重要な機能の一つです。この記事では、Next.js 14のApp RouterとAWS Amplify Gen1を使用した、セキュアでユーザーフレンドリーな削除機能の実装方法について説明します。

実装のポイント

  1. セキュアな権限分離: ユーザーは管理者権限を持たず、Lambda関数が管理者権限を持つ
  2. 環境間の一貫性: ローカル・Amplify両環境で同じ動作
  3. 論理削除によるデータの保持: 物理削除ではなく無効化による安全な削除
  4. モダンなUI/UXの実装: 確認ダイアログと適切なフィードバック
  5. 適切なエラーハンドリング: ユーザーへの分かりやすいエラー表示

技術スタック

  • Next.js 14 (App Router)
  • TypeScript
  • Tailwind CSS
  • AWS Amplify Gen1
  • AWS Cognito (認証)
  • AWS Lambda
  • AWS AppSync (GraphQL)

アーキテクチャ概要

[Next.js App] → [Server Action] → [GraphQL] → [Lambda] → [Cognito API]

権限の分離

  • ユーザー: GraphQLの読み取り権限のみ
  • Lambda: Cognitoの管理者権限(IAMロール経由)
  • Server Action: 認証確認とGraphQL呼び出しのみ

実装例

1. GraphQLスキーマの定義

# amplify/backend/api/your-api/schema.graphql
type Mutation {
  deactivateCurrentUser: DeactivateUserResponse!
    @function(name: "deactivateCurrentUser-${env}")
    @auth(rules: [{ allow: private, provider: userPools }])
}

type DeactivateUserResponse {
  success: Boolean!
  message: String
  error: String
}

2. Lambda関数の実装

// amplify/backend/function/deactivateCurrentUser/src/index.js
const { CognitoIdentityProviderClient, AdminDisableUserCommand } = require('@aws-sdk/client-cognito-identity-provider')

exports.handler = async (event) => {
  const userPoolId = process.env.USER_POOL_ID
  const userId = event.identity.sub
  
  try {
    const cognitoClient = new CognitoIdentityProviderClient({ 
      region: process.env.AWS_REGION || 'us-east-1' 
    })
    
    await cognitoClient.send(
      new AdminDisableUserCommand({
        UserPoolId: userPoolId,
        Username: userId,
      }),
    )
    
    return {
      success: true,
      message: 'ユーザーが正常に無効化されました',
      error: null,
    }
  } catch (error) {
    console.error('Deactivation failed:', error)
    return {
      success: false,
      message: null,
      error: error.message,
    }
  }
}

3. Server Actionの実装

// src/app/lib/actions/deactivateUser.ts
'use server'

import { cookieBasedClient } from '@/app/lib/utils/amplifyServerUtils'
import { cookies } from 'next/headers'
import { verifyServerSideAuth } from '@/app/lib/utils/verifyServerSideAuth'
import { runWithAmplifyServerContext } from '@/app/lib/utils/amplifyServerUtils'
import { getCurrentUser } from 'aws-amplify/auth/server'
import { GraphQLResult } from 'aws-amplify/api'

const deactivateCurrentUserMutation = `
  mutation DeactivateCurrentUser {
    deactivateCurrentUser {
      success
      message
      error
    }
  }
`

async function callDeactivateCurrentUserMutation(): Promise<any> {
  try {
    const result: GraphQLResult<any> = await cookieBasedClient.graphql({
      query: deactivateCurrentUserMutation,
      authMode: 'userPool',
    })
    
    const response = result.data.deactivateCurrentUser
    console.log(response)
    
    if (response.success) {
      return { success: true, message: response.message }
    } else {
      return { success: false, error: response.error }
    }
  } catch (error) {
    console.error('Deactivation failed:', error)
    return {
      success: false,
      error: error instanceof Error ? error.message : '無効化に失敗しました',
    }
  }
}

export async function deactivateCurrentUser(): Promise<{
  success: boolean
  message?: string
  error?: string
}> {
  const authenticated = await verifyServerSideAuth(cookies)
  if (!authenticated) {
    throw new Error('not authenticated')
  }
  
  const currentUser = authenticated
    ? await runWithAmplifyServerContext({
        nextServerContext: { cookies },
        operation: (contextSpec) => getCurrentUser(contextSpec),
      })
    : null
    
  if (!currentUser) {
    throw new Error('User not found')
  }
  
  return await callDeactivateCurrentUserMutation()
}

4. 確認ダイアログコンポーネント

// src/app/ui/confirmDialog.tsx
'use client'
import { ReactNode } from 'react'

interface ConfirmDialogProps {
  isOpen: boolean
  onClose: () => void
  onConfirm: () => void
  title: string
  message: string
  confirmText?: string
  cancelText?: string
}

export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
  isOpen,
  onClose,
  onConfirm,
  title,
  message,
  confirmText = '削除する',
  cancelText = 'キャンセル',
}) => {
  if (!isOpen) return null

  return (
    <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
      <div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
        <h3 className="text-lg font-semibold mb-4">{title}</h3>
        <p className="text-gray-600 mb-6">{message}</p>
        <div className="flex justify-end space-x-4">
          <button
            onClick={onClose}
            className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-md transition-colors"
          >
            {cancelText}
          </button>
          <button
            onClick={onConfirm}
            className="px-4 py-2 bg-red-600 text-white hover:bg-red-700 rounded-md transition-colors"
          >
            {confirmText}
          </button>
        </div>
      </div>
    </div>
  )
}

5. ユーザー情報フォームでの実装

// src/app/ui/userInfoForm.tsx
'use client'
import { FormEvent, useState } from 'react'
import { deactivateCurrentUser } from '../lib/actions/deactivateUser'
import { signOut } from 'aws-amplify/auth'
import { ConfirmDialog } from './confirmDialog'

interface UserInfoFormProps {
  nickname: string
  gender: string
  age: string
  userId: string
}

export const UserInfoForm: React.FC<UserInfoFormProps> = ({
  nickname: initNickName,
  gender,
  age,
  userId,
}) => {
  const [nickname, setNickname] = useState(initNickName)
  const [result, setResult] = useState<boolean>(false)
  const [resultMsg, setResultMsg] = useState<string>('')
  const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false)

  const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    setResultMsg('')
    const formData = new FormData(e.currentTarget)
    const result = await updateUserInfo(formData)
    if (result === true) {
      setResult(true)
      setResultMsg('ユーザー情報を更新しました')
    } else {
      setResult(false)
      setResultMsg('ユーザー情報の更新に失敗しました')
    }
  }

  const handleDeactivateUser = async () => {
    const result = await deactivateCurrentUser()
    console.log('result', result)
    if (result.success) {
      await signOut()
      window.location.href = '/'
    } else {
      console.error('Deactivation failed:', result.error)
      alert(result.error || 'ユーザーの無効化に失敗しました')
    }
  }

  return (
    <div>
      <form onSubmit={handleSubmit} className='flex flex-col'>
        {/* 既存のフォーム要素 */}
        
        {/* 削除ボタン */}
        <div className="flex justify-center pb-20 mt-6">
          <button
            type="button"
            onClick={() => setIsConfirmDialogOpen(true)}
            style={{
              color: '#888',
              textDecoration: 'underline',
              fontSize: '0.9em',
              background: 'none',
              border: 'none',
              padding: 0,
              cursor: 'pointer',
              display: 'inline',
            }}
          >
            ユーザーを削除する
          </button>
        </div>

        {/* 確認ダイアログ */}
        <ConfirmDialog
          isOpen={isConfirmDialogOpen}
          onClose={() => setIsConfirmDialogOpen(false)}
          onConfirm={handleDeactivateUser}
          title="ユーザー削除の確認"
          message="ユーザーを削除します。削除すると同じメールアドレスを使って同じ認証方法でアカウントを作成することはできません。よろしいですか?"
        />

        {/* 結果メッセージ */}
        {resultMsg !== '' && (
          <div
            className={`${
              result === true ? 'text-green-600' : 'text-red-600'
            } px-5 mt-3 text-center animate-scale-up-center`}
          >
            {resultMsg}
          </div>
        )}
      </form>
    </div>
  )
}

実装のポイント解説

1. セキュリティ設計

  • 権限の分離: ユーザーは管理者権限を持たず、Lambda関数が管理者権限を持つ
  • 認証の確認: Server Actionで認証状態を厳密に確認
  • 入力値の検証: Lambda関数でユーザーIDを認証コンテキストから取得

2. 環境対応

  • ローカル環境: AWS認証情報を使用
  • Amplify環境: IAMロールを使用
  • 一貫した動作: 両環境で同じAPIを使用

3. UI/UXの考慮点

  • 確認ダイアログ: 誤操作を防ぐための確認ステップ
  • 視覚的なフィードバック: 成功・失敗の明確な表示
  • アクセシビリティ: キーボード操作とスクリーンリーダー対応
  • レスポンシブデザイン: モバイル・デスクトップ両対応

4. エラーハンドリング

  • 具体的なエラーメッセージ: ユーザーが理解しやすいメッセージ
  • ログ記録: デバッグと監視のためのログ出力
  • 型安全な処理: TypeScriptによる型安全性の確保

ベストプラクティス

1. セキュリティ

  • 認証状態の厳密な確認
  • 適切なアクセス制御
  • エラーメッセージの制御
  • データの保護

2. UX

  • 明確な確認メッセージ
  • 視覚的なフィードバック
  • エラー状態の適切な表示
  • レスポンシブデザイン

3. パフォーマンス

  • コンポーネントの適切な分割
  • 不要なレンダリングの防止
  • 効率的な状態管理
  • キャッシュ戦略の検討

4. 保守性

  • 再利用可能なコンポーネント
  • 型安全性の確保
  • 明確な責務分担
  • モジュール化された実装

注意点

環境設定

  • AWS認証情報の適切な設定
  • Cognito User Pool IDの環境変数設定
  • 適切なIAMポリシーの設定
  • Lambda関数の実行権限

デプロイ

  • Amplify CLIを使用したデプロイ
  • 環境変数の適切な設定
  • 権限の確認とテスト

テスト

  • ローカル環境での動作確認
  • Amplify環境での動作確認
  • エラーケースのテスト
  • セキュリティテスト

ーーーーここまでーーーー

まとめ

というわけで、途中のソースは実際に動いてるものとは多少違うのと説明用に作ってるだけなので実装する時は各自のアプリケーションの内容に合わせてもらえればと思う。ちなみにcognitoで無効化したユーザーやすべての登録ユーザーの取得というのもできるので、その程度であればdynamoDBに別テーブルを作ってどうの、ということはなくても良いと思う。退会時に理由とかアンケートとか取るとなるとまた話は別だけどね(個人的にはそんなものDBに入れるのではなくてそれ用のMLでも作って担当者が読めるようにするとかGoogleスプレッドシートにAPIで投げてしまうとかした方がUI作らないで済むので何かと良いのではないかと思ったりもする。そもそも退会がそれで賄えないような自体になってたらアカンのでは?と思うしね。

最後に

営業:というわけで、Amplifyで100万超えたユーザー数のウェブサービスを運用保守してるんですが、比較的Amplifyの箱庭の中でやれることは多いので、何かご依頼があれば気軽に こちらまで にご連絡ください。その際はこの記事を読んだよ、と教えてくれると話が早いと思います。

Discussion