wip: use local storage for roles

This commit is contained in:
Ali Waseem committed 2026-04-08 12:00:40 -06:00
1 parent d09873e321
commit c705f1ee97
7 files changed
+121 -20

No files matched your search

@@ -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()
@@ -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(
@@ -78,4 +78,4 @@ TableEditorPage.getLayout = (page) => (
</DefaultLayout>
)
export default TableEditorPage
export default TableEditorPage
+3 -1
View File
@@ -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])
@@ -47,4 +47,4 @@ SQLEditorIndexPage.getLayout = (page) => (
</DefaultLayout>
)
export default SQLEditorIndexPage
export default SQLEditorIndexPage
+109 -13
View File
@@ -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<typeof createRoleImpersonationState>
export const RoleImpersonationStateContext = createContext<RoleImpersonationState>(
@@ -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}`
}
@@ -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}`,