useAuthUsersShortcuts.ts187 lines · main
1import { PermissionAction } from '@supabase/shared-types/out/constants'
2import {
3 parseAsArrayOf,
4 parseAsBoolean,
5 parseAsString,
6 parseAsStringEnum,
7 useQueryState,
8} from 'nuqs'
9import { Dispatch, RefObject, SetStateAction, useEffect, useState } from 'react'
10import type { CellKeyboardEvent, DataGridHandle } from 'react-data-grid'
11
12import { MAX_BULK_DELETE } from './Users.constants'
13import type { User } from '@/data/auth/users-infinite-query'
14import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
15import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
16import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
17import { useShortcut } from '@/state/shortcuts/useShortcut'
18
19interface UseAuthUsersShortcutsParams {
20 gridRef: RefObject<DataGridHandle | null>
21 searchInputRef: RefObject<HTMLInputElement | null>
22 users: User[]
23 selectedUsers: Set<unknown>
24 setSelectedUsers: Dispatch<SetStateAction<Set<unknown>>>
25 setSearch: Dispatch<SetStateAction<string>>
26 onRefresh: () => void
27}
28
29interface GridCellKeyDownArgs {
30 mode: 'SELECT' | 'EDIT'
31 row: unknown
32 rowIdx: number
33}
34
35interface UseAuthUsersShortcutsResult {
36 onCellKeyDown: (args: GridCellKeyDownArgs, event: CellKeyboardEvent) => void
37 showDeleteModal: boolean
38 setShowDeleteModal: Dispatch<SetStateAction<boolean>>
39}
40
41export function useAuthUsersShortcuts({
42 gridRef,
43 searchInputRef,
44 users,
45 selectedUsers,
46 setSelectedUsers,
47 setSearch,
48 onRefresh,
49}: UseAuthUsersShortcutsParams): UseAuthUsersShortcutsResult {
50 const [hasCellSelected, setHasCellSelected] = useState(false)
51 const [showDeleteModal, setShowDeleteModal] = useState(false)
52
53 const [selectedId, setSelectedId] = useQueryState(
54 'show',
55 parseAsString.withOptions({ history: 'push', clearOnDefault: true })
56 )
57 const [, setFilterKeywords] = useQueryState('keywords', { defaultValue: '' })
58 const [, setFilterUserType] = useQueryState(
59 'userType',
60 parseAsStringEnum(['all', 'verified', 'unverified', 'anonymous']).withDefault('all')
61 )
62 const [, setSelectedProviders] = useQueryState(
63 'providers',
64 parseAsArrayOf(parseAsString, ',').withDefault([])
65 )
66 const [sortByValue, setSortByValue] = useQueryState('sortBy', {
67 defaultValue: 'created_at:desc',
68 })
69 const [, setInviteVisible] = useQueryState(
70 'invite',
71 parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true })
72 )
73 const [, setCreateVisible] = useQueryState(
74 'new',
75 parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true })
76 )
77
78 const { can: canCreateUsers } = useAsyncCheckPermissions(
79 PermissionAction.AUTH_EXECUTE,
80 'create_user'
81 )
82 const { can: canInviteUsers } = useAsyncCheckPermissions(
83 PermissionAction.AUTH_EXECUTE,
84 'invite_user'
85 )
86 const showSendInvitation = useIsFeatureEnabled('authentication:show_send_invitation')
87
88 useEffect(() => {
89 const el = gridRef.current?.element
90 if (!el) return
91 const onFocusIn = () => setHasCellSelected(true)
92 const onFocusOut = (event: FocusEvent) => {
93 if (!el.contains(event.relatedTarget as Node | null)) {
94 setHasCellSelected(false)
95 }
96 }
97 el.addEventListener('focusin', onFocusIn)
98 el.addEventListener('focusout', onFocusOut)
99 return () => {
100 el.removeEventListener('focusin', onFocusIn)
101 el.removeEventListener('focusout', onFocusOut)
102 }
103 }, [gridRef])
104
105 useShortcut(
106 SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH,
107 () => {
108 searchInputRef.current?.focus()
109 searchInputRef.current?.select()
110 },
111 { label: 'Search users' }
112 )
113
114 useShortcut(SHORTCUT_IDS.LIST_PAGE_RESET_FILTERS, () => {
115 setSearch('')
116 setFilterKeywords('')
117 setFilterUserType('all')
118 setSelectedProviders([])
119 })
120
121 useShortcut(SHORTCUT_IDS.AUTH_USERS_REFRESH, onRefresh)
122
123 useShortcut(SHORTCUT_IDS.AUTH_USERS_CLEAR_SORT, () => setSortByValue('created_at:desc'), {
124 enabled: sortByValue !== 'created_at:desc',
125 })
126
127 useShortcut(
128 SHORTCUT_IDS.AUTH_USERS_TOGGLE_ALL_SELECTION,
129 () => {
130 if (selectedUsers.size === users.length) {
131 setSelectedUsers(new Set([]))
132 } else {
133 setSelectedUsers(new Set(users.map((u) => u.id)))
134 }
135 },
136 { enabled: users.length > 0 && users.length <= MAX_BULK_DELETE }
137 )
138
139 useShortcut(SHORTCUT_IDS.AUTH_USERS_DELETE_SELECTED, () => setShowDeleteModal(true), {
140 enabled: selectedUsers.size > 0,
141 })
142
143 useShortcut(
144 SHORTCUT_IDS.AUTH_USERS_EXIT_SELECTION,
145 () => {
146 setSelectedUsers(new Set([]))
147 setHasCellSelected(false)
148 ;(document.activeElement as HTMLElement | null)?.blur()
149 },
150 { enabled: !selectedId && (selectedUsers.size > 0 || hasCellSelected) }
151 )
152
153 useShortcut(SHORTCUT_IDS.AUTH_USERS_CLOSE_PANEL, () => setSelectedId(null), {
154 enabled: !!selectedId,
155 })
156
157 const startGridNavigation = () => {
158 if (users.length === 0) return
159 gridRef.current?.selectCell({ idx: 1, rowIdx: 0 })
160 }
161
162 useShortcut(SHORTCUT_IDS.AUTH_USERS_START_NAV_DOWN, startGridNavigation, {
163 enabled: !hasCellSelected && users.length > 0,
164 })
165
166 useShortcut(SHORTCUT_IDS.AUTH_USERS_START_NAV_UP, startGridNavigation, {
167 enabled: !hasCellSelected && users.length > 0,
168 })
169
170 useShortcut(SHORTCUT_IDS.AUTH_USERS_CREATE_USER, () => setCreateVisible(true), {
171 enabled: canCreateUsers,
172 })
173
174 useShortcut(SHORTCUT_IDS.AUTH_USERS_INVITE_USER, () => setInviteVisible(true), {
175 enabled: canInviteUsers && showSendInvitation,
176 })
177
178 const onCellKeyDown = (args: GridCellKeyDownArgs, event: CellKeyboardEvent) => {
179 if (args.mode !== 'SELECT' || event.key !== 'Enter') return
180 const id = (args.row as { id?: unknown } | null | undefined)?.id
181 if (typeof id !== 'string') return
182 setSelectedId(id)
183 gridRef.current?.scrollToCell({ idx: 0, rowIdx: args.rowIdx })
184 }
185
186 return { onCellKeyDown, showDeleteModal, setShowDeleteModal }
187}