mike/frontend/src/contexts/UserProfileContext.tsx
willchen96 444d1d38e4 feat: enhance user profile management and MFA login flow
- Refactor user profile loading and updating logic to improve state management and reduce unnecessary checks.
- Update MFA login gate to streamline verification checks and improve user experience.
- Ensure consistent handling of user ID across profile context and components.
- Improve error handling and loading states in user-related API calls.
2026-06-10 18:55:33 +08:00

290 lines
8.3 KiB
TypeScript

"use client";
import React, {
createContext,
useContext,
useEffect,
useState,
ReactNode,
useCallback,
} from "react";
import { useAuth } from "@/contexts/AuthContext";
import {
type ApiKeyState,
type ApiKeyProvider,
type UserProfile as ApiUserProfile,
getUserProfile,
isMfaRequiredError,
saveApiKey,
updateUserMfaOnLogin,
updateUserProfile,
} from "@/app/lib/mikeApi";
interface UserProfile {
displayName: string | null;
organisation: string | null;
messageCreditsUsed: number;
creditsResetDate: string;
creditsRemaining: number;
tier: string;
titleModel: string;
tabularModel: string;
mfaOnLogin: boolean;
apiKeys: ApiKeyState;
}
interface UserProfileContextType {
profile: UserProfile | null;
loading: boolean;
updateDisplayName: (name: string) => Promise<boolean>;
updateOrganisation: (organisation: string) => Promise<boolean>;
updateModelPreference: (
field: "titleModel" | "tabularModel",
value: string,
) => Promise<boolean>;
updateMfaOnLogin: (enabled: boolean) => Promise<boolean>;
updateApiKey: (
provider: ApiKeyProvider,
value: string | null,
) => Promise<boolean>;
reloadProfile: () => Promise<void>;
incrementMessageCredits: () => Promise<boolean>;
}
const UserProfileContext = createContext<UserProfileContextType | undefined>(
undefined,
);
const API_KEY_PROVIDERS: ApiKeyProvider[] = [
"claude",
"gemini",
"openai",
"openrouter",
"courtlistener",
];
function emptyApiKeys(): ApiKeyState {
return {
claude: { configured: false, source: null },
gemini: { configured: false, source: null },
openai: { configured: false, source: null },
openrouter: { configured: false, source: null },
courtlistener: { configured: false, source: null },
};
}
function toProfile(data: ApiUserProfile): UserProfile {
const { apiKeyStatus, ...profile } = data;
const apiKeys = emptyApiKeys();
for (const provider of API_KEY_PROVIDERS) {
apiKeys[provider] = {
configured: !!apiKeyStatus[provider],
source:
apiKeyStatus.sources?.[provider] ??
(apiKeyStatus[provider] ? "user" : null),
};
}
return {
...profile,
mfaOnLogin: profile.mfaOnLogin === true,
apiKeys,
};
}
export function UserProfileProvider({ children }: { children: ReactNode }) {
const { user, isAuthenticated } = useAuth();
const [profile, setProfile] = useState<UserProfile | null>(null);
const [loading, setLoading] = useState(true);
const userId = user?.id ?? null;
const loadProfile = useCallback(async () => {
try {
const profileData = await getUserProfile();
setProfile(toProfile(profileData));
} catch {
// Calculate a default future reset date for fallback
const futureResetDate = new Date();
futureResetDate.setDate(futureResetDate.getDate() + 30);
// Set fallback profile data on exception
setProfile({
displayName: null,
organisation: null,
messageCreditsUsed: 0,
creditsResetDate: futureResetDate.toISOString(),
creditsRemaining: 999999, // temporarily unlimited
tier: "Free",
titleModel: "gemini-3.1-flash-lite-preview",
tabularModel: "gemini-3-flash-preview",
mfaOnLogin: false,
apiKeys: emptyApiKeys(),
});
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (isAuthenticated && userId) {
setLoading(true);
loadProfile();
} else {
setProfile(null);
setLoading(false);
}
}, [isAuthenticated, userId, loadProfile]);
const updateDisplayName = useCallback(
async (displayName: string): Promise<boolean> => {
if (!user) {
return false;
}
try {
const updated = await updateUserProfile({ displayName });
setProfile((prev) =>
prev ? { ...prev, ...toProfile(updated) } : null,
);
return true;
} catch {
return false;
}
},
[user],
);
const updateOrganisation = useCallback(
async (organisation: string): Promise<boolean> => {
if (!user) return false;
try {
const updated = await updateUserProfile({ organisation });
setProfile((prev) =>
prev ? { ...prev, ...toProfile(updated) } : null,
);
return true;
} catch (error) {
if (isMfaRequiredError(error)) throw error;
return false;
}
},
[user],
);
const updateModelPreference = useCallback(
async (
field: "titleModel" | "tabularModel",
value: string,
): Promise<boolean> => {
if (!user) return false;
try {
const updated = await updateUserProfile({
[field]: value,
});
setProfile((prev) =>
prev ? { ...prev, ...toProfile(updated) } : null,
);
return true;
} catch {
return false;
}
},
[user],
);
const updateMfaOnLogin = useCallback(
async (enabled: boolean): Promise<boolean> => {
if (!user) return false;
try {
const updated = await updateUserMfaOnLogin(enabled);
setProfile((prev) =>
prev ? { ...prev, ...toProfile(updated) } : null,
);
return true;
} catch (error) {
if (isMfaRequiredError(error)) throw error;
return false;
}
},
[user],
);
const updateApiKey = useCallback(
async (
provider: ApiKeyProvider,
value: string | null,
): Promise<boolean> => {
if (!user) return false;
const normalized = value?.trim() ? value.trim() : null;
try {
await saveApiKey(provider, normalized);
setProfile((prev) =>
prev
? {
...prev,
apiKeys: {
...prev.apiKeys,
[provider]: {
configured: !!normalized,
source: normalized ? "user" : null,
},
},
}
: null,
);
return true;
} catch (error) {
if (isMfaRequiredError(error)) throw error;
return false;
}
},
[user],
);
const reloadProfile = useCallback(async () => {
if (userId) {
await loadProfile();
}
}, [userId, loadProfile]);
const incrementMessageCredits = useCallback(async (): Promise<boolean> => {
if (!user || !profile) {
return false;
}
// Check if user has credits remaining
if (profile.creditsRemaining <= 0) {
return false;
}
return false;
}, [user, profile]);
return (
<UserProfileContext.Provider
value={{
profile,
loading,
updateDisplayName,
updateOrganisation,
updateModelPreference,
updateMfaOnLogin,
updateApiKey,
reloadProfile,
incrementMessageCredits,
}}
>
{children}
</UserProfileContext.Provider>
);
}
export function useUserProfile() {
const context = useContext(UserProfileContext);
if (context === undefined) {
throw new Error(
"useUserProfile must be used within a UserProfileProvider",
);
}
return context;
}