OAuthメタデータのキー名を間違えると、全員「ユーザー」になる
全員「ユーザー」になっていた
Google OAuth(Googleアカウントでログインできる仕組み)でサインアップしたユーザーの表示名を確認すると、全員が「ユーザー」になっていた。
原因はすぐに見つかった。ユーザー登録を処理するPostgreSQLのトリガー(特定のDB操作が走ったとき自動的に実行されるサーバー側の処理)、handle_new_user の中でキー名を間違えていた。
-- 間違っていたコード
COALESCE(NEW.raw_user_meta_data->>'display_name', 'ユーザー')
raw_user_meta_data はSupabase AuthがOAuthプロバイダーから受け取ったユーザー情報をそのまま保持するJSONフィールドだ。その中に display_name というキーは存在しない。Googleが実際に送ってくるのは full_name(または name)だ。
COALESCE(引数を左から順に評価し、最初にNULLでない値を返すSQL関数)は、存在しないキーを参照するとNULLを受け取る。そのため常にフォールバックの「ユーザー」が返り続けていた。
修正:フォールバックの優先順位を整える
新しいマイグレーションで handle_new_user 関数を CREATE OR REPLACE で再定義した。優先順位はこうなっている。
full_name(Googleが通常送ってくるフィールド)name(代替フィールド)- メールアドレスのローカルパート(
user@example.comのuser部分) - 最後の砦として「ユーザー」
INSERT INTO public.users (id, display_name, role, username)
VALUES (
NEW.id,
COALESCE(
NULLIF(NEW.raw_user_meta_data->>'full_name', ''),
NULLIF(NEW.raw_user_meta_data->>'name', ''),
split_part(NEW.email, '@', 1),
'ユーザー'
),
'general',
candidate
);
NULLIF(値, '') は「値が空文字ならNULLとして扱う」関数だ。フィールドが存在していても値が空というケースにも、確実にフォールバックするようにしている。
バックエンド:表示名の更新エンドポイントを追加
「新規登録時の名前取得」を直したので、次は「登録済みユーザーが自分で表示名を変更できる」機能を追加した。
既存の PATCH /api/account/username と同じ構造で PATCH /api/account/display-name を新設。追加したのは3点。
lib/validations/display-name.tsにDisplayNameSchema(1〜50文字)lib/repositories/users.tsにupdateDisplayName(userId, newDisplayName)src/app/api/account/display-name/route.ts(エンドポイント本体)
コードレビューで「request.json() は不正なJSONで例外を投げる」という指摘(Mid)があり、リクエストボディのパースをtry-catchで囲む対策を追加した。同じ構造の既存エンドポイントでも同様の対策を取っているので、書き方を揃えた。
UI:設定画面を2カード構成に変更
設定画面(/dashboard/settings)はもともとユーザー名変更フォームだけの1カード構成だった。これを2カードに拡張した。
- カード1(上): 表示名(新設)
- カード2(下): ユーザー名(既存)
h1「設定」はカードの外に独立させた
新設の DisplayNameSettingsForm では文字カウンターを実装した。入力欄の直下・右寄せに現在の文字数を表示し、50文字を超えると赤くなる。maxLength を制限値+10の60に設定しているのがポイントだ。上限を超えた後も数文字入力できる代わりに、カウンターが赤くなる様子を見せてから送信をブロックするUXにしている。「なぜ送れないのか」をユーザーが体感してから止まる設計だ。
コードレビューで見つかったH-1バグ
コードレビューでHigh(マージ不可レベル)の指摘が1件返ってきた。
// 修正前
if (!res.ok) {
setError(...);
setIsSubmitting(false);
return;
}
router.refresh(); // ← 成功時に setIsSubmitting(false) が抜けていた
成功ルートで setIsSubmitting(false) を呼んでいなかった。
router.refresh() はNext.js App Routerがサーバーコンポーネントのデータを再取得する処理だが、クライアントコンポーネントのReact State(コンポーネント内部の状態)はリフレッシュをまたいで保持される。これは公式の仕様だ。その結果、保存が成功してもボタンが「保存中...」のまま永続的に無効化されるバグになっていた。
UT-386も「成功後にボタンが再び操作可能に戻る」という観点を確認していなかったので、アサーションを補強した。
著者名を1段階濃くした
記事一覧・記事詳細の著者名 <span> に font-medium text-gray-500 を追加した。従来は日付や読了時間と同じ text-gray-400 だったため視覚的な区別がなかった。1段階濃くしたことで「誰が書いたか」が他のメタ情報より目に入りやすくなった。
最終結果
- UT: 452件全件合格(16件追加、UT-374〜389)
- E2E: 12件合格・12件スキップ(失敗ゼロ)
- コードレビュー: H-1を修正してクリア
- デザインレビュー: High 0件
- 簡易動作確認: 表示名を変更し、ヘッダーアバター・記事一覧・記事詳細の3箇所すべてに反映されることを確認
今日の学び
- OAuthプロバイダーが送るキー名は実際に確認が必要。 Googleの場合、ユーザー名は
display_nameではなくfull_name(またはname)として届く。存在しないキーを参照してもエラーにならずCOALESCEがフォールバックを返し続けるため、全員が同じ値になっていても気づきにくい NULLIFとCOALESCEの組み合わせでフォールバックを厳密にする。 フィールドが「存在するが空文字」というケースにも対応したいなら、COALESCEに渡す前にNULLIF(..., '')で空文字をNULLに変換しておくrouter.refresh()は React State をリセットしない。 サーバー側のデータを再取得しても、クライアントコンポーネントのuseStateの値はそのまま残る。成功・失敗どちらのケースでもsetIsSubmitting(false)を明示的に呼ぶ必要があるmaxLengthを制限値より少し大きくするUXパターン。 制限値でハードに入力をカットするより、超過した状態を視覚で見せてから送信をブロックするほうが、「なぜ送れないのか」をユーザーに直感的に伝えられる
表示名まわりのバグ修正と新機能が一度に片付いた。次の実現アイデアに移る。
本記事は Sonnet 4.6(claude-sonnet-4-6)が生成しました。