diff --git a/apps/studio/components/interfaces/Auth/AuthProvidersFormValidation.tsx b/apps/studio/components/interfaces/Auth/AuthProvidersFormValidation.tsx index 07b1732e16..5f051bfc43 100644 --- a/apps/studio/components/interfaces/Auth/AuthProvidersFormValidation.tsx +++ b/apps/studio/components/interfaces/Auth/AuthProvidersFormValidation.tsx @@ -177,7 +177,7 @@ export const getPhoneProviderValidationSchema = (config: ProjectAuthConfigData) }) } -const PROVIDER_PHONE = { +export const PROVIDER_PHONE = { $schema: JSON_SCHEMA_VERSION, type: 'object', title: 'Phone', diff --git a/apps/studio/components/interfaces/Auth/Users/BanUserModal.tsx b/apps/studio/components/interfaces/Auth/Users/BanUserModal.tsx new file mode 100644 index 0000000000..1ae878d885 --- /dev/null +++ b/apps/studio/components/interfaces/Auth/Users/BanUserModal.tsx @@ -0,0 +1,162 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import dayjs from 'dayjs' +import { useForm } from 'react-hook-form' +import * as z from 'zod' +import { toast } from 'sonner' +import { useEffect } from 'react' + +import { + Button, + cn, + Form_Shadcn_, + FormControl_Shadcn_, + FormField_Shadcn_, + Input_Shadcn_, + Modal, + Select_Shadcn_, + SelectContent_Shadcn_, + SelectItem_Shadcn_, + SelectTrigger_Shadcn_, + Separator, +} from 'ui' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { useUserUpdateMutation } from 'data/auth/user-update-mutation' +import { User } from 'data/auth/users-query' +import { useProjectApiQuery } from 'data/config/project-api-query' +import { useParams } from 'common' + +interface BanUserModalProps { + visible: boolean + user: User + onClose: () => void +} + +export const BanUserModal = ({ visible, user, onClose }: BanUserModalProps) => { + const { ref: projectRef } = useParams() + + const { data: apiData } = useProjectApiQuery({ projectRef }) + const { mutate: updateUser, isLoading: isBanningUser } = useUserUpdateMutation({ + onSuccess: (_, vars) => { + const bannedUntil = dayjs() + .add(Number(vars.banDuration), 'hours') + .format('DD MMM YYYY HH:mm (ZZ)') + toast.success(`User banned successfully until ${bannedUntil}`) + onClose() + }, + }) + + const FormSchema = z.object({ + value: z.string().min(1, { message: 'Please provide a duration' }), + unit: z.enum(['hours', 'days']), + }) + type FormType = z.infer + const defaultValues: FormType = { value: '24', unit: 'hours' } + const form = useForm({ + mode: 'onBlur', + reValidateMode: 'onChange', + resolver: zodResolver(FormSchema), + defaultValues, + }) + + const { value, unit } = form.watch() + const bannedUntil = dayjs().add(Number(value), unit).format('DD MMM YYYY HH:mm (ZZ)') + + const onSubmit = (data: FormType) => { + if (!apiData) { + return toast.error(`Failed to ban user: Error loading project config`) + } else if (user.id === undefined) { + return toast.error(`Failed to ban user: User ID not found`) + } + + const durationHours = data.unit === 'hours' ? Number(data.value) : Number(data.value) * 24 + const { protocol, endpoint, serviceApiKey } = apiData.autoApiService + updateUser({ + projectRef, + protocol, + endpoint, + serviceApiKey, + userId: user.id, + banDuration: durationHours, + }) + } + + useEffect(() => { + if (visible) form.reset(defaultValues) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [visible]) + + return ( + onClose()} + > + +
+ +

+ This will revoke the user's access to your project and prevent them from logging in + for a specified duration. +

+
+ ( + + + + + + )} + /> + ( + + + form.setValue('unit', value as 'hours' | 'days')} + > + + {field.value} + + + Hours + Days + + + + + )} + /> +
+ +
+

+ This user will not be able to log in until: +

+

+ {!!value ? bannedUntil : 'Invalid duration set'} +

+
+
+ + + + + + +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Auth/Users/CreateUserModal.tsx b/apps/studio/components/interfaces/Auth/Users/CreateUserModal.tsx index fba1838b53..90d0e02b97 100644 --- a/apps/studio/components/interfaces/Auth/Users/CreateUserModal.tsx +++ b/apps/studio/components/interfaces/Auth/Users/CreateUserModal.tsx @@ -45,8 +45,9 @@ const CreateUserModal = ({ visible, setVisible }: CreateUserModalProps) => { const canCreateUsers = useCheckPermissions(PermissionAction.AUTH_EXECUTE, 'create_user') const { mutate: createUser, isLoading: isCreatingUser } = useUserCreateMutation({ - async onSuccess(res) { + onSuccess(res) { toast.success(`Successfully created user: ${res.email}`) + form.reset({ email: '', password: '', autoConfirmUser: true }) setVisible(false) }, }) @@ -57,9 +58,6 @@ const CreateUserModal = ({ visible, setVisible }: CreateUserModalProps) => { } const { protocol, endpoint, serviceApiKey } = data.autoApiService createUser({ projectRef, endpoint, protocol, serviceApiKey, user: values }) - - //react-hook-form does not reset field values even after submit. Reset Field data so data does not persist - form.reset({ email: '', password: '', autoConfirmUser: true }) } const form = useForm>({ diff --git a/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx b/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx index f5de6ec02d..6a1119b13c 100644 --- a/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UserOverview.tsx @@ -1,6 +1,6 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import dayjs from 'dayjs' -import { Check, Copy, Mail, ShieldOff, Trash, X } from 'lucide-react' +import { Ban, Check, Copy, Mail, ShieldOff, Trash, X } from 'lucide-react' import Link from 'next/link' import { ComponentProps, ReactNode, useEffect, useState } from 'react' import { toast } from 'sonner' @@ -23,6 +23,9 @@ import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { PROVIDERS_SCHEMAS } from '../AuthProvidersFormValidation' import { PANEL_PADDING } from './UserPanel' import { getDisplayName, providerIconMap } from './Users.utils' +import { BanUserModal } from './BanUserModal' +import { useUserUpdateMutation } from 'data/auth/user-update-mutation' +import { useProjectApiQuery } from 'data/config/project-api-query' const DATE_FORMAT = 'DD MMM, YYYY HH:mm' const CONTAINER_CLASS = cn( @@ -42,6 +45,7 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => { const isEmailAuth = user.email !== null const isPhoneAuth = user.phone !== null const isAnonUser = user.is_anonymous + const isBanned = user.banned_until !== null const providers = (user.raw_app_meta_data?.providers ?? []).map((provider) => { return { @@ -67,10 +71,13 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => { const [successAction, setSuccessAction] = useState< 'send_magic_link' | 'send_recovery' | 'send_otp' >() + const [isBanModalOpen, setIsBanModalOpen] = useState(false) + const [isUnbanModalOpen, setIsUnbanModalOpen] = useState(false) const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false) const [isDeleteFactorsModalOpen, setIsDeleteFactorsModalOpen] = useState(false) const { data } = useAuthConfigQuery({ projectRef }) + const { data: apiData } = useProjectApiQuery({ projectRef }) const mailerOtpExpiry = data?.MAILER_OTP_EXP ?? 0 const minutes = Math.floor(mailerOtpExpiry / 60) const seconds = Math.floor(mailerOtpExpiry % 60) @@ -116,6 +123,12 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => { setIsDeleteFactorsModalOpen(false) }, }) + const { mutate: updateUser, isLoading: isUpdatingUser } = useUserUpdateMutation({ + onSuccess: () => { + toast.success('Successfully unbanned user') + setIsUnbanModalOpen(false) + }, + }) const handleDelete = async () => { await timeout(200) @@ -129,6 +142,24 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => { deleteUserMFAFactors({ projectRef, userId: user.id as string }) } + const handleUnban = () => { + if (!apiData) { + return toast.error(`Failed to ban user: Error loading project config`) + } else if (user.id === undefined) { + return toast.error(`Failed to ban user: User ID not found`) + } + + const { protocol, endpoint, serviceApiKey } = apiData.autoApiService + updateUser({ + projectRef, + protocol, + endpoint, + serviceApiKey, + userId: user.id, + banDuration: 'none', + }) + } + useEffect(() => { if (successAction !== undefined) { const timer = setTimeout(() => setSuccessAction(undefined), 5000) @@ -188,7 +219,7 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => { )} - {!!user.banned_until ? ( + {isBanned ? ( { }} className="!bg border-destructive-400" /> + , + text: isBanned ? 'Unban user' : 'Ban user', + disabled: !canRemoveMFAFactors, + onClick: () => { + if (isBanned) { + setIsUnbanModalOpen(true) + } else { + setIsBanModalOpen(true) + } + }, + }} + className="!bg border-destructive-400" + /> { setIsDeleteModalOpen(false)} onConfirm={() => handleDelete()} + alert={{ + title: 'Deleting a user is irreversible', + description: 'This will remove the user from the project and all associated data.', + }} >

- This is permanent! Are you sure you want to delete user {user.email}? + This is permanent! Are you sure you want to delete the user{' '} + {user.email ?? user.phone ?? 'this user'}?

setIsDeleteFactorsModalOpen(false)} onConfirm={() => handleDeleteFactors()} + alert={{ + base: { variant: 'warning' }, + title: 'Removing MFA factors is irreversible', + description: 'This will log the user out of all active sessions.', + }} >

- This is permanent! Are you sure you want to remove the user's MFA factors? + This is permanent! Are you sure you want to remove the MFA factors for the user{' '} + {user.email ?? user.phone ?? 'this user'}? +

+
+ + setIsBanModalOpen(false)} /> + + setIsUnbanModalOpen(false)} + onConfirm={() => handleUnban()} + > +

+ The user will have access to your project again once unbanned. Are you sure you want to + unban this user?

@@ -424,10 +511,12 @@ const RowData = ({ property, value }: { property: string; value?: string | boole
{value ? (
- +
) : ( - +
+ +
)}
) : ( diff --git a/apps/studio/components/interfaces/Auth/Users/Users.constants.ts b/apps/studio/components/interfaces/Auth/Users/Users.constants.ts new file mode 100644 index 0000000000..c66df1abdb --- /dev/null +++ b/apps/studio/components/interfaces/Auth/Users/Users.constants.ts @@ -0,0 +1,16 @@ +import { BASE_PATH } from 'lib/constants' +import { PROVIDER_PHONE, PROVIDERS_SCHEMAS } from '../AuthProvidersFormValidation' + +export const PROVIDER_FILTER_OPTIONS = PROVIDERS_SCHEMAS.map((provider) => ({ + name: provider.title, + value: provider.title.toLowerCase(), + icon: `${BASE_PATH}/img/icons/${provider.misc.iconKey}.svg`, + iconClass: provider.title === 'GitHub' ? 'dark:invert' : '', +})).concat( + PROVIDER_PHONE.properties.SMS_PROVIDER.enum.map((x) => ({ + name: x.label, + value: x.value, + icon: `${BASE_PATH}/img/icons/${x.icon}`, + iconClass: '', + })) +) diff --git a/apps/studio/components/interfaces/Auth/Users/Users.utils.ts b/apps/studio/components/interfaces/Auth/Users/Users.utils.ts deleted file mode 100644 index ceb985bf30..0000000000 --- a/apps/studio/components/interfaces/Auth/Users/Users.utils.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { UIEvent } from 'react' - -import { User } from 'data/auth/users-query' -import { BASE_PATH } from 'lib/constants' - -export const isAtBottom = ({ currentTarget }: UIEvent): boolean => { - return currentTarget.scrollTop + 10 >= currentTarget.scrollHeight - currentTarget.clientHeight -} - -export const formatUsersData = (users: User[]) => { - return users.map((user) => { - const provider: string = user.raw_app_meta_data?.provider ?? '' - const providers: string[] = user.raw_app_meta_data?.providers ?? [] - - return { - id: user.id, - email: user.email, - phone: user.phone, - created_at: user.created_at, - last_sign_in_at: user.last_sign_in_at, - providers: user.is_anonymous ? '-' : providers, - provider_icons: providers - .map((p) => { - return p === 'email' - ? `${BASE_PATH}/img/icons/email-icon2.svg` - : providerIconMap[p] - ? `${BASE_PATH}/img/icons/${providerIconMap[p]}.svg` - : undefined - }) - .filter(Boolean), - // I think it's alright to just check via the main provider since email and phone should be mutually exclusive - provider_type: user.is_anonymous - ? 'Anonymous' - : socialProviders.includes(provider) - ? 'Social' - : phoneProviders.includes(provider) - ? 'Phone' - : '-', - // [Joshen] Note that the images might not load due to CSP issues - img: getAvatarUrl(user), - name: getDisplayName(user), - } - }) -} - -const providers = { - social: [ - // { email: 'email-icon2' }, - { apple: 'apple-icon' }, - { azure: 'microsoft-icon' }, - { bitbucket: 'bitbucket-icon' }, - { discord: 'discord-icon' }, - { facebook: 'facebook-icon' }, - { figma: 'figma-icon' }, - { github: 'github-icon' }, - { gitlab: 'gitlab-icon' }, - { google: 'google-icon' }, - { kakao: 'kakao-icon' }, - { keycloak: 'keycloak-icon' }, - { linkedin: 'linkedin-icon' }, - { notion: 'notion-icon' }, - { twitch: 'twitch-icon' }, - { twitter: 'twitter-icon' }, - { slack: 'slack-icon' }, - { spotify: 'spotify-icon' }, - { workos: 'workos-icon' }, - { zoom: 'zoom-icon' }, - ], - phone: [ - { twilio: 'twilio-icon' }, - { messagebird: 'messagebird-icon' }, - { textlocal: 'messagebird-icon' }, - { vonage: 'messagebird-icon' }, - { twilioverify: 'twilio-verify-icon' }, - ], -} - -// [Joshen] Just FYI this is not stress tested as I'm not sure what -// all the potential values for each provider is under user.raw_app_meta_data.provider -// Will need to go through one by one to properly verify https://supabase.com/docs/guides/auth/social-login -// But I've made the UI handle to not render any icon if nothing matches in this map -export const providerIconMap: { [key: string]: string } = Object.values([ - ...providers.social, - ...providers.phone, -]).reduce((a, b) => { - const [[key, value]] = Object.entries(b) - return { ...a, [key]: value } -}, {}) - -const socialProviders = providers.social.map((x) => { - const [key] = Object.keys(x) - return key -}) - -const phoneProviders = providers.phone.map((x) => { - const [key] = Object.keys(x) - return key -}) - -export function getDisplayName(user: User, fallback = '-'): string { - const { - displayName, - display_name, - fullName, - full_name, - familyName, - family_name, - givenName, - given_name, - surname, - lastName, - last_name, - firstName, - first_name, - } = user.raw_user_meta_data ?? {} - - const last = familyName || family_name || surname || lastName || last_name - const first = givenName || given_name || firstName || first_name - - return ( - displayName || - display_name || - fullName || - full_name || - (first && last && `${first} ${last}`) || - fallback - ) -} - -export function getAvatarUrl(user: User): string | undefined { - const { - avatarUrl, - avatarURL, - avatar_url, - profileUrl, - profileURL, - profile_url, - profileImage, - profile_image, - profileImageUrl, - profileImageURL, - profile_image_url, - } = user.raw_user_meta_data ?? {} - - return ( - avatarUrl || - avatarURL || - avatar_url || - profileImage || - profile_image || - profileUrl || - profileURL || - profile_url || - profileImageUrl || - profileImageURL || - profile_image_url - ) -} diff --git a/apps/studio/components/interfaces/Auth/Users/Users.utils.tsx b/apps/studio/components/interfaces/Auth/Users/Users.utils.tsx new file mode 100644 index 0000000000..76ba3bec05 --- /dev/null +++ b/apps/studio/components/interfaces/Auth/Users/Users.utils.tsx @@ -0,0 +1,286 @@ +import dayjs from 'dayjs' +import { UIEvent } from 'react' +import { Column } from 'react-data-grid' +import { UserIcon } from 'lucide-react' + +import { User } from 'data/auth/users-query' +import { BASE_PATH } from 'lib/constants' +import { ColumnConfiguration, USERS_TABLE_COLUMNS, UsersTableColumn } from './UsersV2' +import { HeaderCell } from './UsersGridComponents' +import { cn } from 'ui' + +const SUPPORTED_CSP_AVATAR_URLS = [ + 'https://avatars.githubusercontent.com', + 'https://lh3.googleusercontent.com', +] + +export const isAtBottom = ({ currentTarget }: UIEvent): boolean => { + return currentTarget.scrollTop + 10 >= currentTarget.scrollHeight - currentTarget.clientHeight +} + +export const formatUsersData = (users: User[]) => { + return users.map((user) => { + const provider: string = user.raw_app_meta_data?.provider ?? '' + const providers: string[] = user.raw_app_meta_data?.providers ?? [] + + return { + id: user.id, + email: user.email, + phone: user.phone, + created_at: user.created_at, + last_sign_in_at: user.last_sign_in_at, + providers: user.is_anonymous ? '-' : providers, + provider_icons: providers + .map((p) => { + return p === 'email' + ? `${BASE_PATH}/img/icons/email-icon2.svg` + : providerIconMap[p] + ? `${BASE_PATH}/img/icons/${providerIconMap[p]}.svg` + : undefined + }) + .filter(Boolean), + // I think it's alright to just check via the main provider since email and phone should be mutually exclusive + provider_type: user.is_anonymous + ? 'Anonymous' + : provider === 'email' + ? '-' + : socialProviders.includes(provider) + ? 'Social' + : phoneProviders.includes(provider) + ? 'Phone' + : '-', + // [Joshen] Note that the images might not load due to CSP issues + img: getAvatarUrl(user), + name: getDisplayName(user), + } + }) +} + +const providers = { + social: [ + { email: 'email-icon2' }, + { apple: 'apple-icon' }, + { azure: 'microsoft-icon' }, + { bitbucket: 'bitbucket-icon' }, + { discord: 'discord-icon' }, + { facebook: 'facebook-icon' }, + { figma: 'figma-icon' }, + { github: 'github-icon' }, + { gitlab: 'gitlab-icon' }, + { google: 'google-icon' }, + { kakao: 'kakao-icon' }, + { keycloak: 'keycloak-icon' }, + { linkedin: 'linkedin-icon' }, + { notion: 'notion-icon' }, + { twitch: 'twitch-icon' }, + { twitter: 'twitter-icon' }, + { slack: 'slack-icon' }, + { spotify: 'spotify-icon' }, + { workos: 'workos-icon' }, + { zoom: 'zoom-icon' }, + ], + phone: [ + { twilio: 'twilio-icon' }, + { messagebird: 'messagebird-icon' }, + { textlocal: 'messagebird-icon' }, + { vonage: 'messagebird-icon' }, + { twilioverify: 'twilio-verify-icon' }, + ], +} + +// [Joshen] Just FYI this is not stress tested as I'm not sure what +// all the potential values for each provider is under user.raw_app_meta_data.provider +// Will need to go through one by one to properly verify https://supabase.com/docs/guides/auth/social-login +// But I've made the UI handle to not render any icon if nothing matches in this map +export const providerIconMap: { [key: string]: string } = Object.values([ + ...providers.social, + ...providers.phone, +]).reduce((a, b) => { + const [[key, value]] = Object.entries(b) + return { ...a, [key]: value } +}, {}) + +const socialProviders = providers.social.map((x) => { + const [key] = Object.keys(x) + return key +}) + +const phoneProviders = providers.phone.map((x) => { + const [key] = Object.keys(x) + return key +}) + +export function getDisplayName(user: User, fallback = '-'): string { + const { + displayName, + display_name, + fullName, + full_name, + familyName, + family_name, + givenName, + given_name, + surname, + lastName, + last_name, + firstName, + first_name, + } = user.raw_user_meta_data ?? {} + + const last = familyName || family_name || surname || lastName || last_name + const first = givenName || given_name || firstName || first_name + + return ( + displayName || + display_name || + fullName || + full_name || + (first && last && `${first} ${last}`) || + fallback + ) +} + +export function getAvatarUrl(user: User): string | undefined { + const { + avatarUrl, + avatarURL, + avatar_url, + profileUrl, + profileURL, + profile_url, + profileImage, + profile_image, + profileImageUrl, + profileImageURL, + profile_image_url, + } = user.raw_user_meta_data ?? {} + + const url = (avatarUrl || + avatarURL || + avatar_url || + profileImage || + profile_image || + profileUrl || + profileURL || + profile_url || + profileImageUrl || + profileImageURL || + profile_image_url) as string | undefined + + return SUPPORTED_CSP_AVATAR_URLS.some((x) => url?.startsWith(x)) ? url : undefined +} + +export const formatUserColumns = ({ + config, + users, + visibleColumns = [], + setSortByValue, +}: { + config: ColumnConfiguration[] + users: User[] + visibleColumns?: string[] + setSortByValue: (val: string) => void +}) => { + const columnOrder = config.map((c) => c.id) ?? USERS_TABLE_COLUMNS.map((c) => c.id) + + let gridColumns = USERS_TABLE_COLUMNS.map((col) => { + const savedConfig = config.find((c) => c.id === col.id) + const res: Column = { + key: col.id, + name: col.name, + resizable: col.resizable ?? true, + sortable: false, + draggable: true, + width: savedConfig?.width ?? col.width, + minWidth: col.minWidth ?? 120, + headerCellClass: 'z-50 outline-none !shadow-none', + renderHeaderCell: () => { + if (col.id === 'img') return undefined + return + }, + renderCell: ({ row }) => { + const value = row?.[col.id] + const user = users?.find((u) => u.id === row.id) + const formattedValue = + value !== null && ['created_at', 'last_sign_in_at'].includes(col.id) + ? dayjs(value).format('ddd DD MMM YYYY HH:mm:ss [GMT]ZZ') + : Array.isArray(value) + ? value.join(', ') + : value + const isConfirmed = user?.email_confirmed_at || user?.phone_confirmed_at + + if (col.id === 'img') { + return ( +
+
+ {!row.img && } +
+
+ ) + } + + return ( +
+ {/* [Joshen] Not convinced this is the ideal way to display the icons, but for now */} + {col.id === 'providers' && + row.provider_icons.map((icon: string, idx: number) => { + const provider = row.providers[idx] + return ( +
+ {`${provider} +
+ ) + })} + {col.id === 'last_sign_in_at' && !isConfirmed ? ( +

Waiting for verification

+ ) : ( +

+ {formattedValue === null ? '-' : formattedValue} +

+ )} +
+ ) + }, + } + return res + }) + + const profileImageColumn = gridColumns.find((col) => col.key === 'img') + + if (columnOrder.length > 0) { + gridColumns = gridColumns + .filter((col) => columnOrder.includes(col.key)) + .sort((a: any, b: any) => { + return columnOrder.indexOf(a.key) - columnOrder.indexOf(b.key) + }) + } + + return visibleColumns.length === 0 + ? gridColumns + : ([profileImageColumn].concat( + gridColumns.filter((col) => visibleColumns.includes(col.key)) + ) as Column[]) +} diff --git a/apps/studio/components/interfaces/Auth/Users/UsersGridComponents.tsx b/apps/studio/components/interfaces/Auth/Users/UsersGridComponents.tsx new file mode 100644 index 0000000000..9549405f2e --- /dev/null +++ b/apps/studio/components/interfaces/Auth/Users/UsersGridComponents.tsx @@ -0,0 +1,74 @@ +import { ChevronDown, SortAsc, SortDesc } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from 'ui' + +export const HeaderCell = ({ + col, + setSortByValue, +}: { + col: any + setSortByValue: (value: string) => void +}) => { + const ref = useRef(0) + const [open, setOpen] = useState(false) + + useEffect(() => { + ref.current = Number(new Date()) + }, [open]) + + return ( +
+
+

{col.name}

+
+ {['created_at', 'email', 'phone'].includes(col.id) && ( + { + // [Joshen] This is a temp hack between the DropdownMenu and react data grid + // as the header cell is selectable, which takes the focus away from the dropdown menu + // causing it to close immediately. + if (val === false && Number(new Date()) - ref.current > 100) setOpen(val) + }} + > + +
+ ) +} diff --git a/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx b/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx index 54544e6e7c..94ac53a8de 100644 --- a/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx @@ -1,6 +1,6 @@ -import dayjs from 'dayjs' -import { Loader2, RefreshCw, Search, User as UserIcon, Users, X } from 'lucide-react' -import { UIEvent, useMemo, useRef, useState } from 'react' +import AwesomeDebouncePromise from 'awesome-debounce-promise' +import { ArrowDown, ArrowUp, Loader2, RefreshCw, Search, Users, X } from 'lucide-react' +import { UIEvent, useEffect, useMemo, useRef, useState } from 'react' import DataGrid, { Column, DataGridHandle, Row } from 'react-data-grid' import { useParams } from 'common' @@ -8,11 +8,23 @@ import { useIsAPIDocsSidePanelEnabled } from 'components/interfaces/App/FeatureP import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import AlertError from 'components/ui/AlertError' import APIDocsButton from 'components/ui/APIDocsButton' +import { FilterPopover } from 'components/ui/FilterPopover' import { FormHeader } from 'components/ui/Forms/FormHeader' +import { useUsersCountQuery } from 'data/auth/users-count-query' import { useUsersInfiniteQuery } from 'data/auth/users-infinite-query' +import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' +import { LOCAL_STORAGE_KEYS } from 'lib/constants' import { Button, cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, LoadingLine, ResizablePanel, ResizablePanelGroup, @@ -23,41 +35,32 @@ import { SelectTrigger_Shadcn_, SelectValue_Shadcn_, } from 'ui' -import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { Input } from 'ui-patterns/DataInputs/Input' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import AddUserDropdown from './AddUserDropdown' import { UserPanel } from './UserPanel' -import { formatUsersData, isAtBottom } from './Users.utils' +import { PROVIDER_FILTER_OPTIONS } from './Users.constants' +import { formatUserColumns, formatUsersData, isAtBottom } from './Users.utils' type Filter = 'all' | 'verified' | 'unverified' | 'anonymous' -const USERS_TABLE_COLUMNS = [ +export type UsersTableColumn = { + id: string + name: string + minWidth?: number + width?: number + resizable?: boolean +} +export type ColumnConfiguration = { id: string; width?: number } +export const USERS_TABLE_COLUMNS: UsersTableColumn[] = [ { id: 'img', name: '', minWidth: 65, width: 65, resizable: false }, - { id: 'id', name: 'UID', minWidth: undefined, width: 280, resizable: true }, - { id: 'name', name: 'Display name', minWidth: 0, width: 150, resizable: false }, - { - id: 'email', - name: 'Email', - minWidth: undefined, - width: 300, - resizable: true, - }, - { id: 'phone', name: 'Phone', minWidth: undefined, resizable: true }, - { id: 'providers', name: 'Providers', minWidth: 150, resizable: true }, - { id: 'provider_type', name: 'Provider type', minWidth: 150, resizable: true }, - { - id: 'created_at', - name: 'Created at', - minWidth: undefined, - width: 260, - resizable: true, - }, - { - id: 'last_sign_in_at', - name: 'Last sign in at', - minWidth: undefined, - width: 260, - resizable: true, - }, + { id: 'id', name: 'UID', width: 280 }, + { id: 'name', name: 'Display name', minWidth: 0, width: 150 }, + { id: 'email', name: 'Email', width: 300 }, + { id: 'phone', name: 'Phone' }, + { id: 'providers', name: 'Providers', minWidth: 150 }, + { id: 'provider_type', name: 'Provider type', minWidth: 150 }, + { id: 'created_at', name: 'Created at', width: 260 }, + { id: 'last_sign_in_at', name: 'Last sign in at', width: 260 }, ] // [Joshen] Just naming it as V2 as its a rewrite of the old one, to make it easier for reviews @@ -68,10 +71,24 @@ export const UsersV2 = () => { const gridRef = useRef(null) const isNewAPIDocsEnabled = useIsAPIDocsSidePanelEnabled() + const [columns, setColumns] = useState[]>([]) const [search, setSearch] = useState('') const [filter, setFilter] = useState('all') const [filterKeywords, setFilterKeywords] = useState('') + const [selectedColumns, setSelectedColumns] = useState([]) + const [selectedProviders, setSelectedProviders] = useState([]) const [selectedRow, setSelectedRow] = useState() + const [sortByValue, setSortByValue] = useState('created_at:desc') + const [ + columnConfiguration, + setColumnConfiguration, + { isSuccess: isSuccessStorage, isError: isErrorStorage, error: errorStorage }, + ] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.AUTH_USERS_COLUMNS_CONFIGURATION(projectRef ?? ''), + null as ColumnConfiguration[] | null + ) + + const [sortColumn, sortOrder] = sortByValue.split(':') const { data, @@ -79,114 +96,36 @@ export const UsersV2 = () => { isLoading, isRefetching, isError, - // hasNextPage, isFetchingNextPage, refetch, fetchNextPage, } = useUsersInfiniteQuery( { projectRef, + connectionString: project?.connectionString, keywords: filterKeywords, filter: filter === 'all' ? undefined : filter, + providers: selectedProviders, + sort: sortColumn as 'created_at' | 'email' | 'phone', + order: sortOrder as 'asc' | 'desc', }, { keepPreviousData: Boolean(filterKeywords), } ) - // eslint-disable-next-line react-hooks/exhaustive-deps - const totalUsers = useMemo(() => data?.pages[0].total, [data?.pages[0].total]) - const users = useMemo(() => data?.pages.flatMap((page) => page.users), [data?.pages]) - - const usersTableColumns = USERS_TABLE_COLUMNS.map((col) => { - const res: Column = { - key: col.id, - name: col.name, - resizable: col.resizable, - sortable: false, - width: col.width, - minWidth: col.minWidth ?? 120, - headerCellClass: 'z-50', - renderHeaderCell: () => { - if (col.id === 'img') return undefined - return ( -
-
-

{col.name}

-
-
- ) - }, - renderCell: ({ row }) => { - const value = row?.[col.id] - const user = users?.find((u) => u.id === row.id) - const formattedValue = - value !== null && ['created_at', 'last_sign_in_at'].includes(col.id) - ? dayjs(value).format('ddd DD MMM YYYY HH:mm:ss [GMT]ZZ') - : Array.isArray(value) - ? value.join(', ') - : value - const isConfirmed = user?.email_confirmed_at || user?.phone_confirmed_at - - if (col.id === 'img') { - return ( -
-
- {!row.img && } -
-
- ) - } - - return ( -
- {/* [Joshen] Not convinced this is the ideal way to display the icons, but for now */} - {col.id === 'providers' && - row.provider_icons.map((icon: string, idx: number) => { - const provider = row.providers[idx] - return ( -
- {`${provider} -
- ) - })} - {col.id === 'last_sign_in_at' && !isConfirmed ? ( -

Waiting for verification

- ) : ( -

- {formattedValue === null ? '-' : formattedValue} -

- )} -
- ) - }, - } - return res + const { data: countData } = useUsersCountQuery({ + projectRef, + connectionString: project?.connectionString, + keywords: filterKeywords, + filter: filter === 'all' ? undefined : filter, + providers: selectedProviders, }) + // eslint-disable-next-line react-hooks/exhaustive-deps + const totalUsers = useMemo(() => countData?.result[0].count, [countData?.result[0].count]) + const users = useMemo(() => data?.pages.flatMap((page) => page.result), [data?.pages]) + const handleScroll = (event: UIEvent) => { if (isLoading || !isAtBottom(event)) return fetchNextPage() @@ -197,6 +136,58 @@ export const UsersV2 = () => { setFilterKeywords('') } + const swapColumns = (data: any[], sourceIdx: number, targetIdx: number) => { + const updatedColumns = data.slice() + const [removed] = updatedColumns.splice(sourceIdx, 1) + updatedColumns.splice(targetIdx, 0, removed) + return updatedColumns + } + + // [Joshen] Left off here - it's tricky trying to do both column toggling and re-ordering + const saveColumnConfiguration = AwesomeDebouncePromise( + (event: 'resize' | 'reorder' | 'toggle', value) => { + if (event === 'toggle') { + const columnConfig = value.columns.map((col: any) => ({ + id: col.key, + width: col.width, + })) + setColumnConfiguration(columnConfig) + } else if (event === 'resize') { + const columnConfig = columns.map((col, idx) => ({ + id: col.key, + width: idx === value.idx ? value.width : col.width, + })) + setColumnConfiguration(columnConfig) + } else if (event === 'reorder') { + const columnConfig = value.columns.map((col: any) => ({ + id: col.key, + width: col.width, + })) + setColumnConfiguration(columnConfig) + } + }, + 500 + ) + + useEffect(() => { + if ( + isSuccessStorage || + (isErrorStorage && (errorStorage as Error).message.includes('data is undefined')) + ) { + const columns = formatUserColumns({ + config: columnConfiguration ?? [], + users: users ?? [], + visibleColumns: selectedColumns, + setSortByValue, + }) + setColumns(columns) + if (columns.length < USERS_TABLE_COLUMNS.length) { + setSelectedColumns(columns.filter((col) => col.key !== 'img').map((col) => col.key)) + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isSuccessStorage, isErrorStorage, errorStorage]) + return (
@@ -204,14 +195,17 @@ export const UsersV2 = () => {
} - placeholder="Search by email, phone number or UID" + placeholder="Search email, phone or UID" value={search} onChange={(e) => setSearch(e.target.value)} onKeyDown={(e) => { - if (e.code === 'Enter') setFilterKeywords(search) + if (e.code === 'Enter') { + setSearch(search.trim()) + setFilterKeywords(search.trim()) + } }} actions={[ search && ( @@ -220,25 +214,131 @@ export const UsersV2 = () => { type="text" icon={} onClick={() => clearSearch()} - className="px-1" + className="p-0 h-5 w-5" /> ), ]} /> + setFilter(val as Filter)}> - + - All users - Verified users - Unverified users - Anonymous users + + All users + + + Verified users + + + Unverified users + + + Anonymous users + + + + +
+ + { + // When adding back hidden columns: + // (1) width set to default value if any + // (2) they will just get appended to the end + // (3) If "clearing", reset order of the columns to original + + let updatedConfig = (columnConfiguration ?? []).slice() + if (value.length === 0) { + updatedConfig = USERS_TABLE_COLUMNS.map((c) => ({ id: c.id, width: c.width })) + } else { + value.forEach((col) => { + const hasExisting = updatedConfig.find((c) => c.id === col) + if (!hasExisting) + updatedConfig.push({ + id: col, + width: USERS_TABLE_COLUMNS.find((c) => c.id === col)?.width, + }) + }) + } + + const updatedColumns = formatUserColumns({ + config: updatedConfig, + users: users ?? [], + visibleColumns: value, + setSortByValue, + }) + + setSelectedColumns(value) + setColumns(updatedColumns) + saveColumnConfiguration('toggle', { columns: updatedColumns }) + }} + /> + + + + + + + + + Sort by created at + + Ascending + + Descending + + + + + Sort by email + + Ascending + Descending + + + + Sort by phone + + Ascending + Descending + + + + +
+
{isNewAPIDocsEnabled && } - -
- - } - > - + + +
+ + {title ?? `Select ${name.toLowerCase()}`} +
- - + 7 ? maxHeightClass : ''}> +
+ {options.map((option) => { + const value = option[valueKey] + const icon = iconKey ? option[iconKey] : undefined + + return ( +
+ { + if (selectedOptions.includes(value)) { + setSelectedOptions(selectedOptions.filter((x) => x !== value)) + } else { + setSelectedOptions(selectedOptions.concat(value)) + } + }} + /> + + {icon && ( + {option[labelKey]} + )} + {option[labelKey]} + +
+ ) + })} +
+
+
+ + +
+
+ ) } diff --git a/apps/studio/data/auth/keys.ts b/apps/studio/data/auth/keys.ts index a0cafd48c3..9047a22593 100644 --- a/apps/studio/data/auth/keys.ts +++ b/apps/studio/data/auth/keys.ts @@ -13,8 +13,19 @@ export const authKeys = { params?: { keywords: string | undefined filter: string | undefined + providers: string[] | undefined + sort: string | undefined + order: string | undefined } - ) => ['auth', projectRef, 'users-infinite', ...(params ? [params] : [])] as const, + ) => ['auth', projectRef, 'users-infinite', ...(params ? [params].filter(Boolean) : [])] as const, + usersCount: ( + projectRef: string | undefined, + params?: { + keywords: string | undefined + filter: string | undefined + providers: string[] | undefined + } + ) => ['auth', projectRef, 'users-count', ...(params ? [params].filter(Boolean) : [])] as const, authConfig: (projectRef: string | undefined) => ['auth', projectRef, 'config'] as const, accessToken: () => ['access-token'] as const, diff --git a/apps/studio/data/auth/user-create-mutation.ts b/apps/studio/data/auth/user-create-mutation.ts index 2f3d35efe1..2186767087 100644 --- a/apps/studio/data/auth/user-create-mutation.ts +++ b/apps/studio/data/auth/user-create-mutation.ts @@ -5,6 +5,7 @@ import { useFlag } from 'hooks/ui/useFlag' import { post } from 'lib/common/fetch' import type { ResponseError } from 'types' import { authKeys } from './keys' +import { sqlKeys } from 'data/sql/keys' export type UserCreateVariables = { projectRef?: string @@ -50,10 +51,7 @@ export async function createUser({ protocol, endpoint, serviceApiKey, user }: Us }, } ) - if (response.error) { - throw response.error - } - + if (response.error) throw response.error return response } @@ -77,7 +75,12 @@ export const useUserCreateMutation = ({ const { projectRef } = variables if (userManagementV2) { - await queryClient.invalidateQueries(authKeys.usersInfinite(projectRef)) + Promise.all([ + queryClient.invalidateQueries(authKeys.usersInfinite(projectRef)), + queryClient.invalidateQueries( + sqlKeys.query(projectRef, authKeys.usersCount(projectRef)) + ), + ]) } else { await queryClient.invalidateQueries(authKeys.users(projectRef)) } diff --git a/apps/studio/data/auth/user-delete-mutation.ts b/apps/studio/data/auth/user-delete-mutation.ts index 6548c11f73..15ab1d4205 100644 --- a/apps/studio/data/auth/user-delete-mutation.ts +++ b/apps/studio/data/auth/user-delete-mutation.ts @@ -1,12 +1,12 @@ import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' -import { delete_ } from 'lib/common/fetch' -import { API_URL } from 'lib/constants' +import { del, handleError } from 'data/fetchers' +import { sqlKeys } from 'data/sql/keys' +import { useFlag } from 'hooks/ui/useFlag' import type { ResponseError } from 'types' import { authKeys } from './keys' import type { User } from './users-query' -import { useFlag } from 'hooks/ui/useFlag' export type UserDeleteVariables = { projectRef: string @@ -14,9 +14,12 @@ export type UserDeleteVariables = { } export async function deleteUser({ projectRef, user }: UserDeleteVariables) { - const response = await delete_(`${API_URL}/auth/${projectRef}/users`, user) - if (response.error) throw response.error - return response + const { data, error } = await del('/platform/auth/{ref}/users', { + params: { path: { ref: projectRef } }, + body: user, + }) + if (error) handleError(error) + return data } type UserDeleteData = Awaited> @@ -39,7 +42,12 @@ export const useUserDeleteMutation = ({ const { projectRef } = variables if (userManagementV2) { - await queryClient.invalidateQueries(authKeys.usersInfinite(projectRef)) + await Promise.all([ + queryClient.invalidateQueries(authKeys.usersInfinite(projectRef)), + queryClient.invalidateQueries( + sqlKeys.query(projectRef, authKeys.usersCount(projectRef)) + ), + ]) } else { await queryClient.invalidateQueries(authKeys.users(projectRef)) } diff --git a/apps/studio/data/auth/user-invite-mutation.ts b/apps/studio/data/auth/user-invite-mutation.ts index ac496284cf..d5322921f9 100644 --- a/apps/studio/data/auth/user-invite-mutation.ts +++ b/apps/studio/data/auth/user-invite-mutation.ts @@ -1,11 +1,11 @@ import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' -import { post } from 'lib/common/fetch' -import { API_URL } from 'lib/constants' +import { handleError, post } from 'data/fetchers' +import { sqlKeys } from 'data/sql/keys' +import { useFlag } from 'hooks/ui/useFlag' import type { ResponseError } from 'types' import { authKeys } from './keys' -import { useFlag } from 'hooks/ui/useFlag' export type UserInviteVariables = { projectRef: string @@ -13,9 +13,12 @@ export type UserInviteVariables = { } export async function inviteUser({ projectRef, email }: UserInviteVariables) { - const response = await post(`${API_URL}/auth/${projectRef}/invite`, { email }) - if (response.error) throw response.error - return response + const { data, error } = await post('/platform/auth/{ref}/invite', { + params: { path: { ref: projectRef } }, + body: { email }, + }) + if (error) handleError(error) + return data } type UserInviteData = Awaited> @@ -38,7 +41,12 @@ export const useUserInviteMutation = ({ const { projectRef } = variables if (userManagementV2) { - await queryClient.invalidateQueries(authKeys.usersInfinite(projectRef)) + await Promise.all([ + queryClient.invalidateQueries(authKeys.usersInfinite(projectRef)), + queryClient.invalidateQueries( + sqlKeys.query(projectRef, authKeys.usersCount(projectRef)) + ), + ]) } else { await queryClient.invalidateQueries(authKeys.users(projectRef)) } diff --git a/apps/studio/data/auth/user-update-mutation.ts b/apps/studio/data/auth/user-update-mutation.ts new file mode 100644 index 0000000000..cc1dbe4751 --- /dev/null +++ b/apps/studio/data/auth/user-update-mutation.ts @@ -0,0 +1,82 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { useFlag } from 'hooks/ui/useFlag' +import { put } from 'lib/common/fetch' +import type { ResponseError } from 'types' +import { authKeys } from './keys' + +export type UserUpdateVariables = { + projectRef?: string + protocol: string + endpoint: string + serviceApiKey: string + + userId: string + // For now just support updating banning the user + banDuration: number | 'none' // In hours, "none" to unban, otherwise a string in hours e.g "24h" +} + +export async function updateUser({ + protocol, + endpoint, + serviceApiKey, + userId, + banDuration, +}: UserUpdateVariables) { + // [Joshen] This is probably the only endpoint that needs the put method from lib/common/fetch + // as it's not our internal API. + const response = await put( + `${protocol}://${endpoint}/auth/v1/admin/users/${userId}`, + { ban_duration: typeof banDuration === 'number' ? `${banDuration}h` : banDuration }, + { + headers: { + apikey: serviceApiKey, + Authorization: `Bearer ${serviceApiKey}`, + }, + credentials: undefined, + } + ) + + if (response.error) throw response.error + return response +} + +type UserUpdateData = Awaited> + +export const useUserUpdateMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + const userManagementV2 = useFlag('userManagementV2') + + return useMutation( + (vars) => updateUser(vars), + { + async onSuccess(data, variables, context) { + const { projectRef } = variables + + if (userManagementV2) { + await queryClient.invalidateQueries(authKeys.usersInfinite(projectRef)) + } else { + await queryClient.invalidateQueries(authKeys.users(projectRef)) + } + + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to update user: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/data/auth/users-count-query.ts b/apps/studio/data/auth/users-count-query.ts new file mode 100644 index 0000000000..a85e80cbbd --- /dev/null +++ b/apps/studio/data/auth/users-count-query.ts @@ -0,0 +1,69 @@ +import { UseQueryOptions } from '@tanstack/react-query' + +import { ExecuteSqlData, ExecuteSqlError, useExecuteSqlQuery } from 'data/sql/execute-sql-query' +import { Filter } from './users-infinite-query' +import { authKeys } from './keys' + +type UsersCountVariables = { + projectRef?: string + connectionString?: string + keywords?: string + filter?: Filter + providers?: string[] +} + +const getUsersCountSQl = ({ + verified, + keywords, + providers, +}: { + verified?: Filter + keywords?: string + providers?: string[] +}) => { + const hasValidKeywords = keywords && keywords !== '' + + const conditions: string[] = [] + const baseQueryCount = `select count(*) from auth.users` + + if (hasValidKeywords) { + conditions.push( + `id::text ilike '%${keywords}%' or email ilike '%${keywords}%' or phone ilike '%${keywords}%` + ) + } + + if (verified === 'verified') { + conditions.push(`email_confirmed_at IS NOT NULL or phone_confirmed_at IS NOT NULL`) + } else if (verified === 'anonymous') { + conditions.push(`is_anonymous is true`) + } else if (verified === 'unverified') { + conditions.push(`email_confirmed_at IS NULL AND phone_confirmed_at IS NULL`) + } + + if (providers && providers.length > 0) { + conditions.push( + `(raw_app_meta_data->>'providers')::jsonb ?| array[${providers.map((p) => `'${p}'`).join(', ')}]` + ) + } + + const combinedConditions = conditions.map((x) => `(${x})`).join(' and ') + + return `${baseQueryCount}${conditions.length > 0 ? ` where ${combinedConditions}` : ''};` +} + +export type UsersCountData = { result: [{ count: number }] } +export type UsersCountError = ExecuteSqlError + +export const useUsersCountQuery = ( + { projectRef, connectionString, keywords, filter, providers }: UsersCountVariables, + options: UseQueryOptions = {} +) => + useExecuteSqlQuery( + { + projectRef, + connectionString, + sql: getUsersCountSQl({ keywords, verified: filter, providers }), + queryKey: authKeys.usersCount(projectRef, { keywords, filter, providers }), + }, + options + ) diff --git a/apps/studio/data/auth/users-infinite-query.ts b/apps/studio/data/auth/users-infinite-query.ts index bec95eb34a..79728a35c6 100644 --- a/apps/studio/data/auth/users-infinite-query.ts +++ b/apps/studio/data/auth/users-infinite-query.ts @@ -1,80 +1,108 @@ import { useInfiniteQuery, UseInfiniteQueryOptions } from '@tanstack/react-query' import type { components } from 'data/api' -import { get, handleError } from 'data/fetchers' +import { executeSql, ExecuteSqlError } from 'data/sql/execute-sql-query' import { authKeys } from './keys' -import { ResponseError } from 'types' -type Filter = 'verified' | 'unverified' | 'anonymous' +export type Filter = 'verified' | 'unverified' | 'anonymous' export type UsersVariables = { projectRef?: string + connectionString?: string page?: number keywords?: string filter?: Filter + providers?: string[] + sort?: 'created_at' | 'email' | 'phone' + order?: 'asc' | 'desc' } export const USERS_PAGE_LIMIT = 50 export type User = components['schemas']['UserBody'] -export async function getUsers( - { projectRef, page = 0, keywords = '', filter }: UsersVariables, - signal?: AbortSignal -) { - if (!projectRef) throw new Error('Project ref is required') - - const limit = USERS_PAGE_LIMIT +const getUsersSQL = ({ + page = 0, + verified, + keywords, + providers, + sort, + order, +}: { + page: number + verified?: Filter + keywords?: string + providers?: string[] + sort: string + order: string +}) => { const offset = page * USERS_PAGE_LIMIT - const query: { - limit: string - offset: string - keywords: string - verified?: Filter - } = { - limit: limit.toString(), - offset: offset.toString(), - keywords, + const hasValidKeywords = keywords && keywords !== '' + + const conditions: string[] = [] + const baseQueryUsers = `select * from auth.users` + + if (hasValidKeywords) { + conditions.push( + `id::text ilike '%${keywords}%' or email ilike '%${keywords}%' or phone ilike '%${keywords}%` + ) } - if (filter) { - query.verified = filter + if (verified === 'verified') { + conditions.push(`email_confirmed_at IS NOT NULL or phone_confirmed_at IS NOT NULL`) + } else if (verified === 'anonymous') { + conditions.push(`is_anonymous is true`) + } else if (verified === 'unverified') { + conditions.push(`email_confirmed_at IS NULL AND phone_confirmed_at IS NULL`) } - const { data, error } = await get(`/platform/auth/{ref}/users`, { - params: { - path: { ref: projectRef }, - query: query as any, - }, - signal, - }) + if (providers && providers.length > 0) { + conditions.push( + `(raw_app_meta_data->>'providers')::jsonb ?| array[${providers.map((p) => `'${p}'`).join(', ')}]` + ) + } - if (error) handleError(error) - return data + const combinedConditions = conditions.map((x) => `(${x})`).join(' and ') + const sortOn = sort ?? 'created_at' + const sortOrder = order ?? 'desc' + + return `${baseQueryUsers}${conditions.length > 0 ? ` where ${combinedConditions}` : ''} order by "${sortOn}" ${sortOrder} limit ${USERS_PAGE_LIMIT} offset ${offset};` } -export type UsersData = Awaited> -export type UsersError = ResponseError +export type UsersData = { result: User[] } +export type UsersError = ExecuteSqlError export const useUsersInfiniteQuery = ( - { projectRef, keywords, filter }: UsersVariables, + { projectRef, connectionString, keywords, filter, providers, sort, order }: UsersVariables, { enabled = true, ...options }: UseInfiniteQueryOptions = {} ) => useInfiniteQuery( - authKeys.usersInfinite(projectRef, { keywords, filter }), - ({ signal, pageParam }) => getUsers({ projectRef, keywords, filter, page: pageParam }, signal), + authKeys.usersInfinite(projectRef, { keywords, filter, providers, sort, order }), + ({ signal, pageParam }) => { + return executeSql( + { + projectRef, + connectionString, + sql: getUsersSQL({ + page: pageParam, + verified: filter, + keywords, + providers, + sort: sort ?? 'created_at', + order: order ?? 'desc', + }), + queryKey: authKeys.usersInfinite(projectRef), + }, + signal + ) + }, { staleTime: 0, enabled: enabled && typeof projectRef !== 'undefined', getNextPageParam(lastPage, pages) { const page = pages.length - const currentTotalCount = page * USERS_PAGE_LIMIT - const totalCount = lastPage.total - - if (currentTotalCount >= totalCount) { - return undefined - } - + const hasNextPage = lastPage.result.length <= USERS_PAGE_LIMIT + if (!hasNextPage) return undefined return page }, ...options, diff --git a/apps/studio/data/sql/execute-sql-query.ts b/apps/studio/data/sql/execute-sql-query.ts index 1ddb33adcc..97f9b2d079 100644 --- a/apps/studio/data/sql/execute-sql-query.ts +++ b/apps/studio/data/sql/execute-sql-query.ts @@ -129,12 +129,3 @@ export const useExecuteSqlQuery = ( ), { enabled: enabled && typeof projectRef !== 'undefined', staleTime: 0, ...options } ) - -export const prefetchExecuteSql = ( - client: QueryClient, - { projectRef, connectionString, sql, queryKey, handleError }: ExecuteSqlVariables -) => { - return client.prefetchQuery(sqlKeys.query(projectRef, queryKey ?? [btoa(sql)]), ({ signal }) => - executeSql({ projectRef, connectionString, sql, queryKey, handleError }, signal) - ) -} diff --git a/apps/studio/hooks/misc/useLocalStorage.ts b/apps/studio/hooks/misc/useLocalStorage.ts index 714311ac9e..9df2b0de56 100644 --- a/apps/studio/hooks/misc/useLocalStorage.ts +++ b/apps/studio/hooks/misc/useLocalStorage.ts @@ -58,7 +58,13 @@ export function useLocalStorageQuery(key: string, initialValue: T) { const queryClient = useQueryClient() const queryKey = ['localStorage', key] - const { data: storedValue = initialValue } = useQuery(queryKey, () => { + const { + error, + data: storedValue = initialValue, + isSuccess, + isLoading, + isError, + } = useQuery(queryKey, () => { if (typeof window === 'undefined') { return initialValue } @@ -83,5 +89,5 @@ export function useLocalStorageQuery(key: string, initialValue: T) { queryClient.invalidateQueries(queryKey) } - return [storedValue, setValue] as const + return [storedValue, setValue, { isSuccess, isLoading, isError, error }] as const } diff --git a/apps/studio/lib/constants/index.ts b/apps/studio/lib/constants/index.ts index 6b298c47a2..6539a444c6 100644 --- a/apps/studio/lib/constants/index.ts +++ b/apps/studio/lib/constants/index.ts @@ -69,6 +69,8 @@ export const LOCAL_STORAGE_KEYS = { GITHUB_AUTHORIZATION_STATE: 'supabase-github-authorization-state', // Notice banner keys AUTH_SMTP_CHANGES_WARNING: 'auth-smtp-changes-warning-dismissed', + + AUTH_USERS_COLUMNS_CONFIGURATION: (ref: string) => `supabase-auth-users-columns-${ref}`, } export const OPT_IN_TAGS = { diff --git a/apps/studio/next.config.js b/apps/studio/next.config.js index 8d82e550bd..42780a847f 100644 --- a/apps/studio/next.config.js +++ b/apps/studio/next.config.js @@ -47,6 +47,7 @@ const VERCEL_INSIGHTS_URL = 'https://*.vercel-insights.com' const GITHUB_API_URL = 'https://api.github.com' const GITHUB_USER_CONTENT_URL = 'https://raw.githubusercontent.com' const GITHUB_USER_AVATAR_URL = 'https://avatars.githubusercontent.com' +const GOOGLE_USER_AVATAR_URL = 'https://lh3.googleusercontent.com' const VERCEL_LIVE_URL = 'https://vercel.live' // used by vercel live preview const PUSHER_URL = 'https://*.pusher.com' @@ -55,7 +56,7 @@ const PUSHER_URL_WS = 'wss://*.pusher.com' const DEFAULT_SRC_URLS = `${API_URL} ${SUPABASE_URL} ${GOTRUE_URL} ${SUPABASE_LOCAL_PROJECTS_URL_WS} ${SUPABASE_PROJECTS_URL} ${SUPABASE_PROJECTS_URL_WS} ${HCAPTCHA_SUBDOMAINS_URL} ${CONFIGCAT_URL} ${STRIPE_SUBDOMAINS_URL} ${STRIPE_NETWORK_URL} ${CLOUDFLARE_URL} ${ONE_ONE_ONE_ONE_URL} ${VERCEL_INSIGHTS_URL} ${GITHUB_API_URL} ${GITHUB_USER_CONTENT_URL}` const SCRIPT_SRC_URLS = `${CLOUDFLARE_CDN_URL} ${HCAPTCHA_JS_URL} ${STRIPE_JS_URL}` const FRAME_SRC_URLS = `${HCAPTCHA_ASSET_URL} ${STRIPE_JS_URL}` -const IMG_SRC_URLS = `${SUPABASE_URL} ${SUPABASE_COM_URL} ${SUPABASE_PROJECTS_URL} ${GITHUB_USER_AVATAR_URL}` +const IMG_SRC_URLS = `${SUPABASE_URL} ${SUPABASE_COM_URL} ${SUPABASE_PROJECTS_URL} ${GITHUB_USER_AVATAR_URL} ${GOOGLE_USER_AVATAR_URL}` const STYLE_SRC_URLS = `${CLOUDFLARE_CDN_URL}` const FONT_SRC_URLS = `${CLOUDFLARE_CDN_URL}` diff --git a/apps/studio/package.json b/apps/studio/package.json index 7a09c2008a..c569dc4a6c 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -77,7 +77,6 @@ "papaparse": "^5.3.1", "path-to-regexp": "^8.0.0", "pg-minify": "^1.6.3", - "prism-react-renderer": "^2.3.1", "randombytes": "^2.1.0", "react": "^18.2.0", "react-beautiful-dnd": "^13.1.0", diff --git a/package-lock.json b/package-lock.json index 246846ed14..a86e3dbf85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1842,7 +1842,6 @@ "papaparse": "^5.3.1", "path-to-regexp": "^8.0.0", "pg-minify": "^1.6.3", - "prism-react-renderer": "^2.3.1", "randombytes": "^2.1.0", "react": "^18.2.0", "react-beautiful-dnd": "^13.1.0", @@ -43615,6 +43614,7 @@ "lucide-react": "*", "monaco-editor": "*", "next-themes": "*", + "prism-react-renderer": "^2.3.1", "react-error-boundary": "^4.0.12", "react-markdown": "^9.0.1", "react-syntax-highlighter": "^15.5.0", diff --git a/packages/api-types/types/api.d.ts b/packages/api-types/types/api.d.ts index 90819a37af..16fbc1c519 100644 --- a/packages/api-types/types/api.d.ts +++ b/packages/api-types/types/api.d.ts @@ -7072,6 +7072,9 @@ export interface operations { limit: string offset: string verified: string + sort?: string + order?: string + providers?: string[] } path: { /** @description Project ref */ diff --git a/packages/ui-patterns/Dialogs/ConfirmationModal.tsx b/packages/ui-patterns/Dialogs/ConfirmationModal.tsx index 4ed5633bf5..3942f9133f 100644 --- a/packages/ui-patterns/Dialogs/ConfirmationModal.tsx +++ b/packages/ui-patterns/Dialogs/ConfirmationModal.tsx @@ -62,10 +62,6 @@ const ConfirmationModal = forwardRef< } }, [visible]) - useEffect(() => { - setLoading(loading_) - }, [loading_]) - const [loading, setLoading] = useState(false) const onSubmit: MouseEventHandler = (e) => { diff --git a/packages/ui-patterns/package.json b/packages/ui-patterns/package.json index 3afa84724c..a981daab81 100644 --- a/packages/ui-patterns/package.json +++ b/packages/ui-patterns/package.json @@ -20,6 +20,7 @@ "lucide-react": "*", "monaco-editor": "*", "next-themes": "*", + "prism-react-renderer": "^2.3.1", "react-error-boundary": "^4.0.12", "react-markdown": "^9.0.1", "react-syntax-highlighter": "^15.5.0",