From c705f1ee9715aaa1d23626a6cf6bd5d05dcd057d Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Wed, 8 Apr 2026 12:00:40 -0600 Subject: [PATCH] wip: use local storage for roles --- .../interfaces/SQLEditor/SQLEditor.tsx | 4 +- .../TableGridEditor/TableGridEditor.tsx | 4 +- .../pages/project/[ref]/editor/index.tsx | 2 +- apps/studio/pages/project/[ref]/sql/[id].tsx | 4 +- apps/studio/pages/project/[ref]/sql/index.tsx | 2 +- .../studio/state/role-impersonation-state.tsx | 122 ++++++++++++++++-- packages/common/constants/local-storage.ts | 3 + 7 files changed, 121 insertions(+), 20 deletions(-) diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx index 86cdf7c0a55..24a97cdf5f3 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx @@ -76,7 +76,7 @@ import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector' import { isRoleImpersonationEnabled, useGetImpersonatedRoleState, - useSyncRoleImpersonationWithUrl, + useSyncRoleImpersonationState, } from '@/state/role-impersonation-state' import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' import { getSqlEditorV2StateSnapshot, useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2' @@ -94,7 +94,7 @@ export const SQLEditor = () => { const router = useRouter() const { ref, id: urlId } = useParams() - useSyncRoleImpersonationWithUrl() + useSyncRoleImpersonationState() const { profile } = useProfile() const { data: project } = useSelectedProjectQuery() diff --git a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx index 62263885da2..a3ec16e5829 100644 --- a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx @@ -24,7 +24,7 @@ import { useDashboardHistory } from '@/hooks/misc/useDashboardHistory' import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState' import { useUrlState } from '@/hooks/ui/useUrlState' import { useIsProtectedSchema } from '@/hooks/useProtectedSchemas' -import { useSyncRoleImpersonationWithUrl } from '@/state/role-impersonation-state' +import { useSyncRoleImpersonationState } from '@/state/role-impersonation-state' import { TableEditorTableStateContextProvider } from '@/state/table-editor-table' import { createTabId, useTabsStateSnapshot } from '@/state/tabs' @@ -49,7 +49,7 @@ export const TableGridEditor = ({ table: selectedTable, }) - useSyncRoleImpersonationWithUrl() + useSyncRoleImpersonationState() const [{ view: selectedView = 'data' }] = useUrlState() const { can: canEditTables } = useAsyncCheckPermissions( diff --git a/apps/studio/pages/project/[ref]/editor/index.tsx b/apps/studio/pages/project/[ref]/editor/index.tsx index 73c3c1993cc..a0a82353b61 100644 --- a/apps/studio/pages/project/[ref]/editor/index.tsx +++ b/apps/studio/pages/project/[ref]/editor/index.tsx @@ -78,4 +78,4 @@ TableEditorPage.getLayout = (page) => ( ) -export default TableEditorPage +export default TableEditorPage \ No newline at end of file diff --git a/apps/studio/pages/project/[ref]/sql/[id].tsx b/apps/studio/pages/project/[ref]/sql/[id].tsx index 75ac58d35e2..df3982406ce 100644 --- a/apps/studio/pages/project/[ref]/sql/[id].tsx +++ b/apps/studio/pages/project/[ref]/sql/[id].tsx @@ -84,7 +84,9 @@ const SqlEditor: NextPageWithLayout = () => { content === undefined ) { const snippet = allSnippets.find((snippet) => snippet.id === history.sql) - if (snippet !== undefined) router.replace(`/project/${ref}/sql/${history.sql}`) + if (snippet !== undefined) { + router.replace(`/project/${ref}/sql/${history.sql}`) + } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [id, allSnippets, content]) diff --git a/apps/studio/pages/project/[ref]/sql/index.tsx b/apps/studio/pages/project/[ref]/sql/index.tsx index 96a547d102a..7b539098b70 100644 --- a/apps/studio/pages/project/[ref]/sql/index.tsx +++ b/apps/studio/pages/project/[ref]/sql/index.tsx @@ -47,4 +47,4 @@ SQLEditorIndexPage.getLayout = (page) => ( ) -export default SQLEditorIndexPage +export default SQLEditorIndexPage \ No newline at end of file diff --git a/apps/studio/state/role-impersonation-state.tsx b/apps/studio/state/role-impersonation-state.tsx index 228663def13..84b01fba5e2 100644 --- a/apps/studio/state/role-impersonation-state.tsx +++ b/apps/studio/state/role-impersonation-state.tsx @@ -1,4 +1,4 @@ -import { useConstant } from 'common' +import { LOCAL_STORAGE_KEYS, useConstant } from 'common' import { parseAsString, useQueryState } from 'nuqs' import { createContext, PropsWithChildren, useCallback, useContext, useEffect, useRef } from 'react' import { proxy, snapshot, subscribe, useSnapshot } from 'valtio' @@ -14,6 +14,43 @@ import useLatest from '@/hooks/misc/useLatest' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { getPostgrestClaims, ImpersonationRole } from '@/lib/role-impersonation' +type PersistedRoleImpersonationState = { + role: 'anon' | 'authenticated' | 'service_role' + userId?: string +} + +function getStorageKey(projectRef: string) { + return LOCAL_STORAGE_KEYS.ROLE_IMPERSONATION(projectRef) +} + +export function saveRoleImpersonationToLocalStorage( + projectRef: string, + state: PersistedRoleImpersonationState | undefined +) { + const storageKey = getStorageKey(projectRef) + if (!state) { + localStorage.removeItem(storageKey) + sessionStorage.removeItem(storageKey) + return + } + const value = JSON.stringify(state) + localStorage.setItem(storageKey, value) + sessionStorage.setItem(storageKey, value) +} + +export function loadRoleImpersonationFromLocalStorage( + projectRef: string +): PersistedRoleImpersonationState | undefined { + const storageKey = getStorageKey(projectRef) + const jsonStr = sessionStorage.getItem(storageKey) ?? localStorage.getItem(storageKey) + if (!jsonStr) return undefined + try { + return JSON.parse(jsonStr) as PersistedRoleImpersonationState + } catch { + return undefined + } +} + export function createRoleImpersonationState( projectRef: string, customizeAccessTokenRef: { @@ -51,12 +88,23 @@ export function createRoleImpersonationState( if (claims) { roleImpersonationState.claims = claims } + + saveRoleImpersonationToLocalStorage(projectRef, roleToState(role)) }, }) return roleImpersonationState } +function roleToState(role: ImpersonationRole | undefined): PersistedRoleImpersonationState | undefined { + if (!role || role.type !== 'postgrest' || role.role === 'service_role') return undefined + if (role.role === 'anon') return { role: 'anon' } + if (role.role === 'authenticated' && role.userType === 'native' && role.user?.id) { + return { role: 'authenticated', userId: role.user.id } + } + return undefined +} + export type RoleImpersonationState = ReturnType export const RoleImpersonationStateContext = createContext( @@ -109,7 +157,6 @@ export function useGetImpersonatedRoleState() { const roleImpersonationState = useContext(RoleImpersonationStateContext) return useCallback( - // [Alaister]: typeof roleImpersonationState is needed to avoid readonly type errors everywhere () => snapshot(roleImpersonationState) as typeof roleImpersonationState, [roleImpersonationState] ) @@ -128,7 +175,7 @@ export function useSubscribeToImpersonatedRole( }, [roleImpersonationState]) } -export function useSyncRoleImpersonationWithUrl() { +export function useSyncRoleImpersonationState() { const state = useRoleImpersonationStateSnapshot() const { data: project } = useSelectedProjectQuery() const { connectionString } = useConnectionStringForReadOps() @@ -137,21 +184,59 @@ export function useSyncRoleImpersonationWithUrl() { const [urlRole, setUrlRole] = useQueryState('role', parseAsString) const [urlUserId, setUrlUserId] = useQueryState('userId', parseAsString) - const hasInitializedFromUrl = useRef(false) + const hasInitialized = useRef(false) const role = state.role + // Determine which userId we need to fetch user data for + // Priority: URL param (shared links) > localStorage + const storedState = project?.ref + ? loadRoleImpersonationFromLocalStorage(project.ref) + : undefined + const targetUserId = + urlUserId ?? (storedState?.role === 'authenticated' ? storedState.userId : undefined) + const { data: user } = useUserQuery( - { projectRef: project?.ref, connectionString, userId: urlUserId }, - { enabled: urlRole === 'authenticated' && !!urlUserId && !hasInitializedFromUrl.current } + { projectRef: project?.ref, connectionString, userId: targetUserId }, + { enabled: !hasInitialized.current && !!targetUserId } ) - // URL → State: one-time initialization from URL params + // Initialization: read from localStorage (primary) or URL params (fallback for shared links) useEffect(() => { - if (hasInitializedFromUrl.current || !urlRole) return + if (hasInitialized.current) return + + const projectRef = project?.ref + if (!projectRef) return + + const fromStorage = loadRoleImpersonationFromLocalStorage(projectRef) + + if (fromStorage) { + if (fromStorage.role === 'anon') { + hasInitialized.current = true + state.setRole({ type: 'postgrest', role: 'anon' }, customAccessTokenHookDetails) + } else if (fromStorage.role === 'authenticated' && fromStorage.userId) { + // Need user data before we can set the role + if (!user) return + hasInitialized.current = true + state.setRole( + { type: 'postgrest', role: 'authenticated', userType: 'native', user }, + customAccessTokenHookDetails + ) + } else { + hasInitialized.current = true + } + return + } + + // Fallback: URL params (for shared links) + if (!urlRole) { + hasInitialized.current = true + return + } + if (urlRole === 'authenticated' && urlUserId && !user) return - hasInitializedFromUrl.current = true + hasInitialized.current = true if (urlRole === 'anon') { state.setRole({ type: 'postgrest', role: 'anon' }, customAccessTokenHookDetails) @@ -161,10 +246,13 @@ export function useSyncRoleImpersonationWithUrl() { customAccessTokenHookDetails ) } - }, [urlRole, urlUserId, user, state, customAccessTokenHookDetails]) + }, [urlRole, urlUserId, user, state, customAccessTokenHookDetails, project?.ref]) - // State → URL: keep URL params in sync with role state. + // State → URL params: keep URL in sync for shareability + // localStorage is already kept in sync by valtio's setRole useEffect(() => { + if (!hasInitialized.current) return + if (!role || role.type !== 'postgrest' || role.role === 'service_role') { setUrlRole(null) setUrlUserId(null) @@ -174,6 +262,9 @@ export function useSyncRoleImpersonationWithUrl() { } else if (role.role === 'authenticated' && role.userType === 'native' && role.user?.id) { setUrlRole('authenticated') setUrlUserId(role.user.id) + } else { + setUrlRole(null) + setUrlUserId(null) } }, [role, setUrlRole, setUrlUserId]) } @@ -191,7 +282,12 @@ export function buildRoleImpersonationUrl({ userId: string path: 'editor' | 'sql' }) { + saveRoleImpersonationToLocalStorage(projectRef, { + role: 'authenticated', + userId, + }) + const basePath = path === 'editor' ? `/project/${projectRef}/editor` : `/project/${projectRef}/sql/new` - return `${basePath}?role=authenticated&userId=${userId}` -} + return `${basePath}` +} \ No newline at end of file diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts index a3561693600..5c54b2b0ed2 100644 --- a/packages/common/constants/local-storage.ts +++ b/packages/common/constants/local-storage.ts @@ -92,6 +92,9 @@ export const LOCAL_STORAGE_KEYS = { USER_IMPERSONATION_SELECTOR_PREVIOUS_SEARCHES: (ref: string) => `user-impersonation-selector-previous-searches-${ref}`, + // role impersonation state (role type, user id, etc.) + ROLE_IMPERSONATION: (ref: string) => `role-impersonation-${ref}`, + HOTKEY_COMMAND_MENU: 'supabase-dashboard-hotkey-command-menu', LAST_OPENED_SIDE_BAR: (ref: string) => `last-opened-sidebar-${ref}`,