mirror of https://github.com/usememos/memos
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
184 lines
5.7 KiB
TypeScript
184 lines
5.7 KiB
TypeScript
import { useQueryClient } from "@tanstack/react-query";
|
|
import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from "react";
|
|
import { clearAccessToken, getAccessToken } from "@/auth-state";
|
|
import { authServiceClient, refreshAccessToken, shortcutServiceClient, userServiceClient } from "@/connect";
|
|
import { userKeys } from "@/hooks/useUserQueries";
|
|
import type { Shortcut } from "@/types/proto/api/v1/shortcut_service_pb";
|
|
import type { User, UserSetting_GeneralSetting, UserSetting_WebhooksSetting } from "@/types/proto/api/v1/user_service_pb";
|
|
|
|
interface AuthState {
|
|
currentUser: User | undefined;
|
|
userGeneralSetting: UserSetting_GeneralSetting | undefined;
|
|
userWebhooksSetting: UserSetting_WebhooksSetting | undefined;
|
|
shortcuts: Shortcut[];
|
|
isInitialized: boolean;
|
|
isLoading: boolean;
|
|
}
|
|
|
|
interface AuthContextValue extends AuthState {
|
|
initialize: () => Promise<void>;
|
|
logout: () => Promise<void>;
|
|
refetchSettings: () => Promise<void>;
|
|
}
|
|
|
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
|
|
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
|
const queryClient = useQueryClient();
|
|
const [state, setState] = useState<AuthState>({
|
|
currentUser: undefined,
|
|
userGeneralSetting: undefined,
|
|
userWebhooksSetting: undefined,
|
|
shortcuts: [],
|
|
isInitialized: false,
|
|
isLoading: true,
|
|
});
|
|
|
|
const fetchUserSettings = useCallback(async (userName: string) => {
|
|
const [{ settings }, { shortcuts }] = await Promise.all([
|
|
userServiceClient.listUserSettings({ parent: userName }),
|
|
shortcutServiceClient.listShortcuts({ parent: userName }),
|
|
]);
|
|
|
|
const generalSetting = settings.find((s) => s.value.case === "generalSetting");
|
|
const webhooksSetting = settings.find((s) => s.value.case === "webhooksSetting");
|
|
|
|
return {
|
|
userGeneralSetting: generalSetting?.value.case === "generalSetting" ? generalSetting.value.value : undefined,
|
|
userWebhooksSetting: webhooksSetting?.value.case === "webhooksSetting" ? webhooksSetting.value.value : undefined,
|
|
shortcuts,
|
|
};
|
|
}, []);
|
|
|
|
const initialize = useCallback(async () => {
|
|
setState((prev) => ({ ...prev, isLoading: true }));
|
|
|
|
// Try to get or refresh the access token.
|
|
// This handles PWA isolated storage scenarios (e.g., iOS Safari) where localStorage
|
|
// may be empty but a valid HTTP-only refresh token cookie still exists.
|
|
// getAccessToken() returns a cached token or loads from localStorage if valid.
|
|
if (!getAccessToken()) {
|
|
try {
|
|
await refreshAccessToken();
|
|
} catch {
|
|
// Refresh failed - no valid session
|
|
}
|
|
}
|
|
|
|
// If we still don't have a token after refresh attempt, skip getCurrentUser call
|
|
// to avoid unnecessary network request for unauthenticated users.
|
|
if (!getAccessToken()) {
|
|
setState({
|
|
currentUser: undefined,
|
|
userGeneralSetting: undefined,
|
|
userWebhooksSetting: undefined,
|
|
shortcuts: [],
|
|
isInitialized: true,
|
|
isLoading: false,
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const { user: currentUser } = await authServiceClient.getCurrentUser({});
|
|
|
|
if (!currentUser) {
|
|
clearAccessToken();
|
|
setState({
|
|
currentUser: undefined,
|
|
userGeneralSetting: undefined,
|
|
userWebhooksSetting: undefined,
|
|
shortcuts: [],
|
|
isInitialized: true,
|
|
isLoading: false,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const settings = await fetchUserSettings(currentUser.name);
|
|
|
|
setState({
|
|
currentUser,
|
|
...settings,
|
|
isInitialized: true,
|
|
isLoading: false,
|
|
});
|
|
|
|
// Pre-populate React Query cache
|
|
queryClient.setQueryData(userKeys.currentUser(), currentUser);
|
|
queryClient.setQueryData(userKeys.detail(currentUser.name), currentUser);
|
|
} catch (error) {
|
|
console.error("Failed to initialize auth:", error);
|
|
clearAccessToken();
|
|
setState({
|
|
currentUser: undefined,
|
|
userGeneralSetting: undefined,
|
|
userWebhooksSetting: undefined,
|
|
shortcuts: [],
|
|
isInitialized: true,
|
|
isLoading: false,
|
|
});
|
|
}
|
|
}, [fetchUserSettings, queryClient]);
|
|
|
|
const logout = useCallback(async () => {
|
|
try {
|
|
await authServiceClient.signOut({});
|
|
} catch (error) {
|
|
console.error("[AuthContext] Failed to sign out:", error);
|
|
} finally {
|
|
clearAccessToken();
|
|
setState({
|
|
currentUser: undefined,
|
|
userGeneralSetting: undefined,
|
|
userWebhooksSetting: undefined,
|
|
shortcuts: [],
|
|
isInitialized: true,
|
|
isLoading: false,
|
|
});
|
|
queryClient.clear();
|
|
}
|
|
}, [queryClient]);
|
|
|
|
const refetchSettings = useCallback(async () => {
|
|
// Use functional setState to get current user without including state in dependencies
|
|
setState((prev) => {
|
|
if (!prev.currentUser) return prev;
|
|
|
|
// Fetch settings asynchronously
|
|
fetchUserSettings(prev.currentUser.name).then((settings) => {
|
|
setState((current) => ({ ...current, ...settings }));
|
|
});
|
|
|
|
return prev;
|
|
});
|
|
}, [fetchUserSettings]);
|
|
|
|
// Memoize context value to prevent unnecessary re-renders of consumers
|
|
const value = useMemo(
|
|
() => ({
|
|
...state,
|
|
initialize,
|
|
logout,
|
|
refetchSettings,
|
|
}),
|
|
[state, initialize, logout, refetchSettings],
|
|
);
|
|
|
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
|
}
|
|
|
|
export function useAuth() {
|
|
const context = useContext(AuthContext);
|
|
if (!context) {
|
|
throw new Error("useAuth must be used within AuthProvider");
|
|
}
|
|
return context;
|
|
}
|
|
|
|
// Convenience hook for just the current user
|
|
export function useCurrentUserFromAuth() {
|
|
const { currentUser } = useAuth();
|
|
return currentUser;
|
|
}
|