Clerkのアカウントポータル,Account LinkingのUIをカスタム実装する
public.icon
Clerk
AccountLinking
異なるSSOを用いてログインしても、メールアドレスが同じであれば同一ユーザーとして管理するdoc Clerk UI Components
Clerkをアプリに導入する際に、すでに構築済みのコンポーネントを呼び出すだけでさまざまな機能を提供できるdoc https://gyazo.com/db2534e866fcebfb75fa914890fd93a8
code:clerk
import { SignIn } from '@clerk/clerk-expo/web'
export default function SignInPage() {
return <SignIn />
}
Account Portal
ユーザーのログインメールの変更や、他のアカウントを紐づけるような画面doc このAccount Portalをブランディングの観点から、埋め込みではなくカスタマイズして自前実装がしたい
調べたところわかりやすい資料が見つからなかったためまとめを作成する
今回はExpoを使って、ネイティブアプリを基本とした構成で検討する
メールアドレスを追加する
電話を追加する
SSOの追加
次回実装する際にコピペできるようにガイドをmarkdownで作成した
code:markdown
# ClerkのAccount Portal カスタム実装ガイド
## 概要
Clerkのアカウントポータル(Account Portal)を、ブランディングの観点から埋め込みUIではなくカスタマイズして自前実装するためのガイドです。
### 前提知識
### なぜカスタム実装するのか
- ブランディングの統一
- 既存のデザインシステムとの整合性
- より細かいUX制御
- ネイティブアプリの操作感
## 前提条件
- @clerk/clerk-expo をインストール済み
- expo-web-browser をインストール済み(OAuth用)
- Clerkダッシュボードで各認証方法を有効化済み
## 1. メールアドレス管理の実装
### 1-1. メールアドレス追加画面 (change-email.tsx)
**主要な実装ポイント:**
`typescript
import { useUser, useReverification } from "@clerk/clerk-expo";
import type { EmailAddressResource } from "@clerk/types";
export default function ChangeEmailScreen() {
const { user } = useUser();
// セキュリティ強化: 再認証が必要
const createEmailAddress = useReverification((email: string) =>
user?.createEmailAddress({ email }),
);
const handleSendVerification = async () => {
const res = await createEmailAddress(newEmail);
// 重要: user objectをリロードして最新状態を取得
await user?.reload();
const emailAddress = user?.emailAddresses.find((a) => a.id === res?.id);
await emailAddress.prepareVerification({ strategy: "email_code" });
setIsVerificationSent(true);
};
const handleVerifyAndChange = async () => {
const emailAddress = user?.emailAddresses.find(
email => email.emailAddress === newEmail,
);
const emailVerifyAttempt = await emailAddress.attemptVerification({
code: verificationCode,
});
// 検証ステータスを明示的にチェック
if (emailVerifyAttempt?.verification.status !== "verified") {
Alert.alert("エラー", "確認に失敗しました");
return;
}
await user?.reload();
Alert.alert("成功", "メールアドレスを追加しました");
};
}
`
**重要な注意点:**
1. useReverificationフックで再認証を要求(セキュリティベストプラクティス)
2. user.reload()を必ず呼び出してUser objectを最新化
3. 検証ステータスを明示的にチェック
### 1-2. メールアドレス一覧・管理画面 (manage-emails.tsx)
`typescript
export default function ManageEmailsScreen() {
const { user } = useUser();
const emailAddresses = user?.emailAddresses || [];
const primaryEmailAddressId = user?.primaryEmailAddressId;
// プライマリ変更
const handleSetPrimary = async (emailId: string) => {
await user?.update({
primaryEmailAddressId: emailId,
});
await user?.reload();
};
// 削除
const handleDeleteEmail = async (emailId: string) => {
const email = user?.emailAddresses.find((e) => e.id === emailId);
await email?.destroy();
await user?.reload();
};
return (
<>
{emailAddresses.map((email) => {
const isPrimary = email.id === primaryEmailAddressId;
const isVerified = email.verification?.status === "verified";
const canSetPrimary = !isPrimary && isVerified;
return (
<View key={email.id}>
<Text>{email.emailAddress}</Text>
{isPrimary && <Badge>プライマリ</Badge>}
<Text>{isVerified ? "検証済み" : "未検証"}</Text>
{canSetPrimary && (
<Button onPress={() => handleSetPrimary(email.id)}>
プライマリに設定
</Button>
)}
{!isPrimary && (
<Button onPress={() => handleDeleteEmail(email.id)}>
削除
</Button>
)}
</View>
);
})}
</>
);
}
`
**プライマリメールアドレスとは:**
- 通知の送信先(パスワードリセット、セキュリティアラートなど)
- アカウント復旧時の連絡先
- user.primaryEmailAddressでアクセス可能
- ログインには影響しない(検証済みならどのメールでもログイン可能)
## 2. SSO連携管理の実装
### 2-1. 基本実装 (connected-accounts.tsx)
`typescript
import { useUser, useReverification } from "@clerk/clerk-expo";
import * as WebBrowser from "expo-web-browser";
import type { ExternalAccountResource } from "@clerk/types";
export default function ConnectedAccountsScreen() {
const { user } = useUser();
// 再認証で保護
const createExternalAccount = useReverification((params: any) =>
user?.createExternalAccount(params),
);
const accountDestroy = useReverification(
(account: ExternalAccountResource) => account.destroy(),
);
const handleConnect = async (provider: 'google' | 'apple' | 'line') => {
const res = await createExternalAccount({
strategy: oauth_${provider},
redirectUrl: "exp://", // your app scheme
});
// アプリ内ブラウザでOAuth認証(UX改善)
if (res?.verification?.externalVerificationRedirectURL) {
const result = await WebBrowser.openAuthSessionAsync(
res.verification.externalVerificationRedirectURL.href,
"exp://",
);
if (result.type === "success") {
await user?.reload();
}
}
};
const handleDisconnect = async (account: ExternalAccountResource) => {
await accountDestroy(account);
await user?.reload();
};
}
`
### 2-2. 検証ステータスの表示と再検証
`typescript
const renderAccountRow = (provider: string) => {
const account = user?.externalAccounts?.find(
acc => acc.provider === provider,
);
const isVerified = account?.verification?.status === "verified";
return (
<View>
<Text>{provider}</Text>
{/* 検証ステータス表示 */}
<View style={{ backgroundColor: isVerified ? "#51CF66" : "#FF6B6B" }}>
<Text>
{isVerified
? "検証済み"
: account?.verification?.error?.longMessage || "未検証"}
</Text>
</View>
{/* 未検証の場合は再検証ボタン */}
{account && !isVerified && (
<Button onPress={() => handleReverify(account)}>
再検証する
</Button>
)}
</View>
);
};
const handleReverify = async (account: ExternalAccountResource) => {
if (!account.verification?.externalVerificationRedirectURL) return;
const result = await WebBrowser.openAuthSessionAsync(
account.verification.externalVerificationRedirectURL.href,
"exp://",
);
if (result.type === "success") {
await user?.reload();
}
};
`
## 3. 重要なベストプラクティス
### 3-1. expo-web-browser vs Linking.openURL()
| 方法 | メリット | デメリット | 推奨度 |
|------|---------|-----------|--------|
| expo-web-browser | アプリ内で完結、スムーズなUX | - | ⭐⭐⭐ |
| Linking.openURL() | OSネイティブの安全性 | Safari/Chromeに飛ぶ、UXが悪い | ⭐ |
**推奨: expo-web-browser**
- Clerk公式がExpoアプリで推奨
- アプリ内モーダルブラウザで完結
- セキュリティも保たれている
`typescript
// ❌ 避けるべき
await Linking.openURL(redirectURL);
// ✅ 推奨
const result = await WebBrowser.openAuthSessionAsync(
redirectURL,
"exp://",
);
if (result.type === "success") {
await user?.reload();
}
`
### 3-2. エラーハンドリング
`typescript
try {
const res = await createExternalAccount({ strategy, redirectUrl });
// ... OAuth処理
} catch (error: any) {
// Clerk APIのエラーメッセージを使用
Alert.alert(
"エラー",
error.errors?.0?.message || "連携に失敗しました", );
}
`
### 3-3. よくあるエラーと対処法
| エラー | 原因 | 解決方法 |
|--------|------|----------|
| that_address_is_taken_please_another | メールアドレスが既に使用されている | 別のメールアドレスを使用 |
| too_many_unverified_contacts | 未検証のメールアドレスが多すぎる | 未検証のメールを削除してから追加 |
| メールが届かない | Clerkダッシュボードの設定 | "Sign-up with email"をONにする |
## 4. Clerkダッシュボード設定
### 4-1. メール認証を有効化
1. Clerk Dashboard → User & Authentication → Email
2. "Sign-up with email" を **ON**
3. Email verification を **Required** に設定
### 4-2. OAuth設定(例: Google)
1. Clerk Dashboard → User & Authentication → Social Connections
2. Google を有効化
3. Client ID と Client Secret を設定
4. Redirect URLs に exp:// を追加
## 5. 電話番号管理の実装
### 5-1. 電話番号追加画面 (add-phone.tsx)
電話番号の追加フローはメールアドレスと非常に似ていますが、SMS認証を使用します。
`typescript
import { useUser, useReverification } from "@clerk/clerk-expo";
import { useState } from "react";
import { Alert } from "react-native";
export default function AddPhoneScreen() {
const { user } = useUser();
// 再認証で保護
const createPhoneNumber = useReverification((phone: string) =>
user?.createPhoneNumber({ phoneNumber: phone }),
);
const handleSendVerification = async () => {
// 電話番号の簡易バリデーション
const phoneRegex = /^\+1-9\d{1,14}$/; // E.164形式 if (!phoneRegex.test(phoneNumber)) {
Alert.alert("エラー", "有効な電話番号を入力してください(例: +819012345678)");
return;
}
try {
const res = await createPhoneNumber(phoneNumber);
// 重要: user objectをリロード
await user?.reload();
const phone = user?.phoneNumbers.find((p) => p.id === res?.id);
if (!phone) {
Alert.alert("エラー", "電話番号の追加に失敗しました");
return;
}
// SMS認証コードを送信
await phone.prepareVerification({ strategy: "phone_code" });
setIsVerificationSent(true);
Alert.alert("確認コード送信", ${phoneNumber} にSMSを送信しました);
} catch (error: any) {
console.error("Send verification error:", error);
Alert.alert(
"エラー",
error.errors?.0?.message || "確認コードの送信に失敗しました", );
}
};
const handleVerifyAndAdd = async () => {
if (!verificationCode.trim()) {
Alert.alert("エラー", "確認コードを入力してください");
return;
}
try {
const phone = user?.phoneNumbers.find(
p => p.phoneNumber === phoneNumber,
);
if (!phone) {
Alert.alert("エラー", "電話番号が見つかりません");
return;
}
const phoneVerifyAttempt = await phone.attemptVerification({
code: verificationCode,
});
// 検証ステータスを明示的にチェック
if (phoneVerifyAttempt?.verification.status !== "verified") {
Alert.alert("エラー", "確認に失敗しました");
return;
}
await user?.reload();
Alert.alert("成功", "電話番号を追加しました");
} catch (error: any) {
console.error("Verification error:", error);
Alert.alert(
"エラー",
error.errors?.0?.message || "確認コードが正しくありません", );
}
};
const handleResendCode = async () => {
try {
const phone = user?.phoneNumbers.find(
p => p.phoneNumber === phoneNumber,
);
await phone?.prepareVerification({ strategy: "phone_code" });
Alert.alert("成功", "確認コードを再送信しました");
} catch (error) {
console.error("Resend error:", error);
Alert.alert("エラー", "確認コードの再送信に失敗しました");
}
};
return (
<View>
{!isVerificationSent ? (
<>
<TextInput
placeholder="+819012345678"
value={phoneNumber}
onChangeText={setPhoneNumber}
keyboardType="phone-pad"
/>
<Button onPress={handleSendVerification}>
確認コードを送信
</Button>
</>
) : (
<>
<TextInput
placeholder="6桁のコードを入力"
value={verificationCode}
onChangeText={setVerificationCode}
keyboardType="number-pad"
maxLength={6}
/>
<Button onPress={handleVerifyAndAdd}>
追加する
</Button>
<Button onPress={handleResendCode}>
確認コードを再送信
</Button>
</>
)}
</View>
);
}
`
**電話番号特有の注意点:**
1. **E.164形式が必須**: +[国コード][番号] の形式(例: +819012345678)
2. **SMS送信のコスト**: Clerkのプランによっては追加費用が発生
3. **国際電話番号対応**: 日本以外の番号も考慮する場合は国コード選択UIを追加
### 5-2. 電話番号一覧・管理画面 (manage-phones.tsx)
`typescript
export default function ManagePhonesScreen() {
const { user } = useUser();
const phoneNumbers = user?.phoneNumbers || [];
const primaryPhoneNumberId = user?.primaryPhoneNumberId;
const handleSetPrimary = async (phoneId: string, phoneNumber: string) => {
Alert.alert(
"プライマリに設定",
${phoneNumber} をプライマリ電話番号に設定しますか?,
[
{ text: "キャンセル", style: "cancel" },
{
text: "設定",
onPress: async () => {
setSettingPrimaryId(phoneId);
try {
await user?.update({
primaryPhoneNumberId: phoneId,
});
await user?.reload();
Alert.alert("成功", "プライマリ電話番号を変更しました");
} catch (error: any) {
Alert.alert(
"エラー",
error.errors?.0?.message || "変更に失敗しました", );
} finally {
setSettingPrimaryId(null);
}
},
},
],
);
};
const handleDeletePhone = async (phoneId: string, phoneNumber: string) => {
Alert.alert(
"電話番号を削除",
${phoneNumber} を削除しますか?,
[
{ text: "キャンセル", style: "cancel" },
{
text: "削除",
style: "destructive",
onPress: async () => {
setDeletingPhoneId(phoneId);
try {
const phone = user?.phoneNumbers.find((p) => p.id === phoneId);
await phone?.destroy();
await user?.reload();
Alert.alert("成功", "電話番号を削除しました");
} catch (error: any) {
Alert.alert(
"エラー",
error.errors?.0?.message || "削除に失敗しました", );
} finally {
setDeletingPhoneId(null);
}
},
},
],
);
};
return (
<ScrollView>
{phoneNumbers.map((phone) => {
const isPrimary = phone.id === primaryPhoneNumberId;
const isVerified = phone.verification?.status === "verified";
const canDelete = !isPrimary && phoneNumbers.length > 1;
const canSetPrimary = !isPrimary && isVerified;
return (
<View key={phone.id}>
<Text>{phone.phoneNumber}</Text>
{isPrimary && <Badge>プライマリ</Badge>}
{/* 検証ステータス */}
<View style={{
backgroundColor: isVerified ? "#51CF66" : "#FF6B6B"
}}>
<Text>
{isVerified ? "検証済み" : "未検証"}
</Text>
</View>
{canSetPrimary && (
<Button onPress={() => handleSetPrimary(phone.id, phone.phoneNumber)}>
プライマリに設定
</Button>
)}
{canDelete && (
<Button onPress={() => handleDeletePhone(phone.id, phone.phoneNumber)}>
削除
</Button>
)}
</View>
);
})}
<Button onPress={() => router.push("/add-phone")}>
新しい電話番号を追加
</Button>
</ScrollView>
);
}
`
## 6. 統合的なアカウント設定画面の設計
### 6-1. メニュー構成の推奨例
`typescript
export default function AccountSettingsScreen() {
const { user } = useUser();
const { signOut } = useAuthContext();
const accountMenuItems = [
{
section: "アカウント情報",
items: [
{
label: "プロフィール編集",
icon: "user",
route: "/edit-profile"
},
{
label: "メールアドレス管理",
icon: "mail",
route: "/manage-emails",
badge: user?.emailAddresses.length.toString()
},
{
label: "電話番号管理",
icon: "phone",
route: "/manage-phones",
badge: user?.phoneNumbers.length.toString()
},
]
},
{
section: "セキュリティ",
items: [
{
label: "パスワード変更",
icon: "lock",
route: "/change-password"
},
{
label: "2段階認証",
icon: "shield",
route: "/two-factor"
},
{
label: "連携アカウント",
icon: "link",
route: "/connected-accounts",
badge: user?.externalAccounts.length.toString()
},
]
},
{
section: "プライバシー",
items: [
{
label: "ブロックしたユーザー",
icon: "user-x",
route: "/blocked-users"
},
{
label: "アクティブなセッション",
icon: "activity",
route: "/active-sessions"
},
]
},
];
return (
<ScrollView>
{accountMenuItems.map((section) => (
<View key={section.section}>
<Text>{section.section}</Text>
{section.items.map((item) => (
<TouchableOpacity
key={item.label}
onPress={() => router.push(item.route)}
<Feather name={item.icon} size={20} />
<Text>{item.label}</Text>
{item.badge && <Badge>{item.badge}</Badge>}
<Feather name="chevron-right" size={16} />
</TouchableOpacity>
))}
</View>
))}
<Button onPress={signOut}>
ログアウト
</Button>
</ScrollView>
);
}
`
## 7. パスワード管理の実装
### 7-1. パスワード変更画面
`typescript
export default function ChangePasswordScreen() {
const { user } = useUser();
const handleChangePassword = async () => {
// バリデーション
if (newPassword !== confirmPassword) {
Alert.alert("エラー", "新しいパスワードが一致しません");
return;
}
if (newPassword.length < 8) {
Alert.alert("エラー", "パスワードは8文字以上である必要があります");
return;
}
setIsLoading(true);
try {
await user?.updatePassword({
currentPassword,
newPassword,
});
Alert.alert("成功", "パスワードを変更しました", [
{ text: "OK", onPress: () => router.back() }
]);
} catch (error: any) {
console.error("Password change error:", error);
Alert.alert(
"エラー",
error.errors?.0?.message || "パスワードの変更に失敗しました", );
} finally {
setIsLoading(false);
}
};
return (
<View>
<TextInput
placeholder="現在のパスワード"
value={currentPassword}
onChangeText={setCurrentPassword}
secureTextEntry
/>
<TextInput
placeholder="新しいパスワード"
value={newPassword}
onChangeText={setNewPassword}
secureTextEntry
/>
<TextInput
placeholder="新しいパスワード(確認)"
value={confirmPassword}
onChangeText={setConfirmPassword}
secureTextEntry
/>
<Button
onPress={handleChangePassword}
disabled={isLoading}
{isLoading ? "変更中..." : "パスワードを変更"}
</Button>
</View>
);
}
`
**パスワード設定の注意点:**
1. **パスワードが未設定の場合**: OAuth経由でサインアップしたユーザーはパスワードを持っていない可能性があります
2. **パスワードポリシー**: Clerkダッシュボードで設定したポリシー(最小文字数、複雑性など)に従う必要があります
`typescript
// パスワードが設定されているかチェック
const hasPassword = user?.passwordEnabled;
if (!hasPassword) {
// 初回パスワード設定の場合
await user?.updatePassword({
newPassword: newPassword,
// currentPasswordは不要
});
} else {
// パスワード変更の場合
await user?.updatePassword({
currentPassword: currentPassword,
newPassword: newPassword,
});
}
`
## 8. 2段階認証(2FA)の実装
### 8-1. TOTP(Time-based One-Time Password)の有効化
`typescript
export default function TwoFactorScreen() {
const { user } = useUser();
useEffect(() => {
// 2FAが既に有効かチェック
setIsEnabled(user?.twoFactorEnabled || false);
const handleEnableTOTP = async () => {
try {
// TOTPシークレットを生成
const totpResponse = await user?.createTOTP();
setTotpSecret(totpResponse?.secret);
setQrCodeUrl(totpResponse?.uri);
Alert.alert(
"認証アプリで設定",
"QRコードをGoogle AuthenticatorやAuthyなどのアプリでスキャンしてください"
);
} catch (error: any) {
Alert.alert(
"エラー",
error.errors?.0?.message || "2FAの有効化に失敗しました", );
}
};
const handleVerifyTOTP = async () => {
try {
await user?.verifyTOTP({
code: verificationCode,
});
await user?.reload();
setIsEnabled(true);
Alert.alert("成功", "2段階認証を有効化しました");
} catch (error: any) {
Alert.alert(
"エラー",
error.errors?.0?.message || "コードが正しくありません", );
}
};
const handleDisableTOTP = async () => {
Alert.alert(
"2段階認証を無効化",
"本当に2段階認証を無効化しますか?セキュリティが低下します。",
[
{ text: "キャンセル", style: "cancel" },
{
text: "無効化",
style: "destructive",
onPress: async () => {
try {
await user?.disableTOTP();
await user?.reload();
setIsEnabled(false);
Alert.alert("成功", "2段階認証を無効化しました");
} catch (error: any) {
Alert.alert("エラー", "無効化に失敗しました");
}
},
},
],
);
};
if (isEnabled) {
return (
<View>
<Text>2段階認証は有効です</Text>
<Button onPress={handleDisableTOTP}>
2段階認証を無効化
</Button>
</View>
);
}
return (
<View>
{!totpSecret ? (
<Button onPress={handleEnableTOTP}>
2段階認証を有効化
</Button>
) : (
<>
<QRCode value={qrCodeUrl} size={200} />
<Text>シークレットキー: {totpSecret}</Text>
<TextInput
placeholder="6桁のコードを入力"
value={verificationCode}
onChangeText={setVerificationCode}
keyboardType="number-pad"
maxLength={6}
/>
<Button onPress={handleVerifyTOTP}>
確認して有効化
</Button>
</>
)}
</View>
);
}
`
**QRコード表示のための追加パッケージ:**
`bash
npx expo install react-native-qrcode-svg
`
`typescript
import QRCode from 'react-native-qrcode-svg';
`
## 9. バックアップコード(リカバリーコード)の管理
`typescript
export default function BackupCodesScreen() {
const { user } = useUser();
const handleGenerateBackupCodes = async () => {
try {
const response = await user?.createBackupCode();
setBackupCodes(response?.codes || []);
Alert.alert(
"重要",
"これらのコードを安全な場所に保存してください。認証アプリにアクセスできない場合に使用できます。"
);
} catch (error: any) {
Alert.alert("エラー", "バックアップコードの生成に失敗しました");
}
};
return (
<View>
<Text>
バックアップコードは、認証アプリにアクセスできない場合の
緊急用ログイン方法です。
</Text>
{backupCodes.length === 0 ? (
<Button onPress={handleGenerateBackupCodes}>
バックアップコードを生成
</Button>
) : (
<>
<Text>以下のコードを安全に保存してください:</Text>
{backupCodes.map((code, index) => (
<Text key={index} style={{ fontFamily: 'monospace' }}>
{code}
</Text>
))}
<Button onPress={() => {
// コードをクリップボードにコピー
Clipboard.setString(backupCodes.join('\n'));
Alert.alert("成功", "クリップボードにコピーしました");
}}>
コピー
</Button>
</>
)}
</View>
);
}
`
## 10. アクティブセッション管理
`typescript
export default function ActiveSessionsScreen() {
const { user } = useUser();
const { signOut } = useAuthContext();
useEffect(() => {
loadSessions();
}, []);
const loadSessions = async () => {
try {
// 全てのアクティブセッションを取得
const activeSessions = await user?.getSessions();
setSessions(activeSessions || []);
} catch (error) {
console.error("Load sessions error:", error);
}
};
const handleRevokeSession = async (sessionId: string) => {
Alert.alert(
"セッションを終了",
"このデバイスからログアウトしますか?",
[
{ text: "キャンセル", style: "cancel" },
{
text: "ログアウト",
style: "destructive",
onPress: async () => {
try {
const session = sessions.find(s => s.id === sessionId);
await session?.revoke();
await loadSessions();
Alert.alert("成功", "セッションを終了しました");
} catch (error: any) {
Alert.alert("エラー", "セッションの終了に失敗しました");
}
},
},
],
);
};
const handleRevokeAllOtherSessions = async () => {
Alert.alert(
"他のデバイスからログアウト",
"現在のデバイス以外の全てのセッションを終了しますか?",
[
{ text: "キャンセル", style: "cancel" },
{
text: "全て終了",
style: "destructive",
onPress: async () => {
try {
const currentSessionId = await user?.getActiveSession()?.id;
const otherSessions = sessions.filter(
s => s.id !== currentSessionId
);
await Promise.all(
otherSessions.map(session => session.revoke())
);
await loadSessions();
Alert.alert("成功", "他のセッションを全て終了しました");
} catch (error) {
Alert.alert("エラー", "セッションの終了に失敗しました");
}
},
},
],
);
};
return (
<ScrollView>
<Text>アクティブなセッション ({sessions.length})</Text>
{sessions.map((session) => {
const isCurrent = session.id === user?.getActiveSession()?.id;
return (
<View key={session.id}>
<Text>{session.latestActivity?.deviceType || "Unknown device"}</Text>
<Text>{session.latestActivity?.browserName || "Unknown browser"}</Text>
<Text>
最終アクセス: {new Date(session.lastActiveAt).toLocaleString('ja-JP')}
</Text>
{isCurrent && <Badge>現在のセッション</Badge>}
{!isCurrent && (
<Button onPress={() => handleRevokeSession(session.id)}>
ログアウト
</Button>
)}
</View>
);
})}
{sessions.length > 1 && (
<Button onPress={handleRevokeAllOtherSessions}>
他の全てのデバイスからログアウト
</Button>
)}
</ScrollView>
);
}
`
## 11. アカウント削除機能
`typescript
export default function DeleteAccountScreen() {
const { user } = useUser();
const { signOut, redirectToAuth } = useAuthContext();
const handleDeleteAccount = async () => {
if (confirmText !== "削除") {
Alert.alert("エラー", "「削除」と入力してください");
return;
}
Alert.alert(
"アカウントを削除",
"本当にアカウントを削除しますか?この操作は取り消せません。",
[
{ text: "キャンセル", style: "cancel" },
{
text: "削除",
style: "destructive",
onPress: async () => {
setIsDeleting(true);
try {
await user?.delete();
await signOut();
redirectToAuth();
Alert.alert("完了", "アカウントを削除しました");
} catch (error: any) {
console.error("Delete account error:", error);
Alert.alert(
"エラー",
error.errors?.0?.message || "アカウントの削除に失敗しました", );
} finally {
setIsDeleting(false);
}
},
},
],
);
};
return (
<View>
<View style={{ backgroundColor: "#FEE2E2", padding: 16 }}>
<Text style={{ color: "#DC2626", fontWeight: "bold" }}>
⚠️ 警告
</Text>
<Text style={{ color: "#DC2626" }}>
アカウントを削除すると、以下のデータが完全に失われます:
</Text>
<Text style={{ color: "#DC2626" }}>
• プロフィール情報{'\n'}
• 投稿やコメント{'\n'}
• フォロー・フォロワー{'\n'}
• その他全てのデータ
</Text>
<Text style={{ color: "#DC2626", marginTop: 8 }}>
この操作は取り消せません。
</Text>
</View>
<Text>
本当にアカウントを削除する場合は、下のボックスに「削除」と入力してください
</Text>
<TextInput
placeholder="削除"
value={confirmText}
onChangeText={setConfirmText}
/>
<Button
onPress={handleDeleteAccount}
disabled={isDeleting || confirmText !== "削除"}
style={{ backgroundColor: "#DC2626" }}
{isDeleting ? "削除中..." : "アカウントを完全に削除"}
</Button>
</View>
);
}
`
## 12. エラーハンドリングのベストプラクティス
### 12-1. 共通エラーハンドラーの作成
`typescript
// utils/clerkErrorHandler.ts
export const handleClerkError = (error: any, defaultMessage: string = "エラーが発生しました") => {
console.error("Clerk Error:", error);
// Clerk APIのエラーメッセージを使用
const clerkMessage = error?.errors?.0?.message; const clerkCode = error?.errors?.0?.code; // エラーコード別の日本語メッセージ
const errorMessages: Record<string, string> = {
'form_password_pwned': 'このパスワードは過去のデータ侵害で流出しています。別のパスワードを使用してください。',
'form_password_length_too_short': 'パスワードが短すぎます。',
'form_identifier_exists': 'このメールアドレスは既に使用されています。',
'session_exists': '既にログインしています。',
'too_many_requests': 'リクエストが多すぎます。しばらく待ってから再度お試しください。',
'verification_expired': '確認コードの有効期限が切れました。新しいコードを送信してください。',
'verification_failed': '確認コードが正しくありません。',
};
const message = errorMessagesclerkCode || clerkMessage || defaultMessage; Alert.alert("エラー", message);
return message;
};
`
使用例:
`typescript
try {
await user?.updatePassword({ currentPassword, newPassword });
} catch (error) {
handleClerkError(error, "パスワードの変更に失敗しました");
}
`
## 13. ローディング状態とUXの最適化
### 13-1. スケルトンスクリーン
`typescript
const LoadingSkeleton = () => (
<View>
<View key={i} style={{
height: 60,
backgroundColor: '#E5E7EB',
marginBottom: 8,
borderRadius: 8
}} />
))}
</View>
);
export default function ManageEmailsScreen() {
const { isLoaded, user } = useUser();
if (!isLoaded) {
return <LoadingSkeleton />;
}
// ... 通常のレンダリング
}
`
### 13-2. オプティミスティックUI更新
`typescript
const handleDeleteEmail = async (emailId: string) => {
// UIを即座に更新(楽観的更新)
const optimisticEmails = previousEmails.filter(e => e.id !== emailId);
// 楽観的に状態を更新(実際のコードではstate管理ライブラリを使用)
try {
const email = user?.emailAddresses.find((e) => e.id === emailId);
await email?.destroy();
await user?.reload();
} catch (error) {
// エラー時は元に戻す
// 状態を previousEmails に戻す
handleClerkError(error, "削除に失敗しました");
}
};
`
## 14. テストとデバッグ
### 14-1. Clerk開発モードの活用
`typescript
// デバッグ情報の表示
export default function DebugScreen() {
const { user, isLoaded } = useUser();
if (!isLoaded) return <Text>Loading...</Text>;
return (
<ScrollView>
<Text>User ID: {user?.id}</Text>
<Text>Primary Email: {user?.primaryEmailAddress?.emailAddress}</Text>
<Text>Email Count: {user?.emailAddresses.length}</Text>
<Text>Phone Count: {user?.phoneNumbers.length}</Text>
<Text>External Accounts: {user?.externalAccounts.length}</Text>
<Text>2FA Enabled: {user?.twoFactorEnabled ? 'Yes' : 'No'}</Text>
<Text>Password Enabled: {user?.passwordEnabled ? 'Yes' : 'No'}</Text>
<Text style={{ marginTop: 16, fontWeight: 'bold' }}>
All Email Addresses:
</Text>
{user?.emailAddresses.map(email => (
<View key={email.id}>
<Text>• {email.emailAddress}</Text>
<Text> Status: {email.verification?.status}</Text>
<Text> Primary: {email.id === user.primaryEmailAddressId ? 'Yes' : 'No'}</Text>
</View>
))}
<Text style={{ marginTop: 16, fontWeight: 'bold' }}>
External Accounts:
</Text>
{user?.externalAccounts.map(account => (
<View key={account.id}>
<Text>• {account.provider}</Text>
<Text> Status: {account.verification?.status}</Text>
<Text> Email: {account.emailAddress}</Text>
</View>
))}
</ScrollView>
);
}
`
## 15. 実装チェックリスト
### 基本機能
- メールアドレス追加・削除・プライマリ設定
- 電話番号追加・削除・プライマリ設定
- SSO連携追加・削除・再検証
- パスワード変更(初回設定含む)
### セキュリティ機能
- useReverificationによる再認証
- 2段階認証(TOTP)
- バックアップコード生成
- アクティブセッション管理
### UX最適化
- expo-web-browserでアプリ内OAuth
- ローディング状態の表示
- エラーハンドリング
- 検証ステータスの明確な表示
### データ整合性
- 操作後のuser.reload()
- 検証ステータスの明示的チェック
- 最低1つのログイン方法を維持
### アクセシビリティ
- 説明テキストの追加
- エラーメッセージの日本語化
- 操作の確認ダイアログ
## 16. 参考リソース
## まとめ
Clerk のAccount Portal をカスタム実装する際の最重要ポイント:
1. **セキュリティ**: useReverificationで再認証を要求
2. **状態管理**: 操作後は必ずuser.reload()
3. **UX**: expo-web-browserでアプリ内完結
4. **検証**: ステータスを明示的にチェック
5. **エラー処理**: Clerk APIのエラーメッセージを活用
これらを守ることで、Clerkの埋め込みUIと同等以上のセキュリティとUXを実現できます。