chore (dashboard): turn on null checks (#3390)

This commit is contained in:
robertkasza
2025-08-12 15:45:49 +02:00
committed by GitHub
parent 89f6fe6346
commit 412692c2f6
364 changed files with 2808 additions and 2566 deletions

View File

@@ -0,0 +1,5 @@
---
'@nhost/dashboard': minor
---
chore (dashboard): Turn on strictNullChecks config

0
config/.husky/pre-commit Normal file → Executable file
View File

View File

@@ -42,6 +42,7 @@ test('should be able to delete a user', async ({
await expect(
page.getByRole('button', { name: `View ${email}`, exact: true }),
).not.toBeVisible();
await expect(page.getByText('User deleted successfully.')).toBeVisible();
});
test('should be able to delete a user from the details page', async ({
@@ -75,4 +76,5 @@ test('should be able to delete a user from the details page', async ({
await expect(
page.getByRole('button', { name: `View ${email}`, exact: true }),
).not.toBeVisible();
await expect(page.getByText('User deleted successfully.')).toBeVisible();
});

View File

@@ -196,6 +196,10 @@ test('should create table with foreign key constraint', async ({
await page.getByRole('button', { name: /add/i }).click();
await expect(
page.getByTestId('foreignKeyFormSubmitButton'),
).not.toBeVisible();
await expect(
page.getByText(`public.${firstTableName}.id`, { exact: true }),
).toBeVisible();

View File

@@ -1,46 +1,46 @@
/**
* URL of the dashboard to test against.
*/
export const TEST_DASHBOARD_URL = process.env.NHOST_TEST_DASHBOARD_URL;
export const TEST_DASHBOARD_URL = process.env.NHOST_TEST_DASHBOARD_URL!;
/**
* Name of the organization to test against.
*/
export const TEST_ORGANIZATION_NAME = process.env.NHOST_TEST_ORGANIZATION_NAME;
export const TEST_ORGANIZATION_NAME = process.env.NHOST_TEST_ORGANIZATION_NAME!;
/**
* Slug of the organization to test against.
*/
export const TEST_ORGANIZATION_SLUG = process.env.NHOST_TEST_ORGANIZATION_SLUG;
export const TEST_ORGANIZATION_SLUG = process.env.NHOST_TEST_ORGANIZATION_SLUG!;
/**
* Name of the project to test against.
*/
export const TEST_PROJECT_NAME = process.env.NHOST_TEST_PROJECT_NAME;
export const TEST_PROJECT_NAME = process.env.NHOST_TEST_PROJECT_NAME!;
/**
* Subdomain of the project to test against.
*/
export const TEST_PROJECT_SUBDOMAIN = process.env.NHOST_TEST_PROJECT_SUBDOMAIN;
export const TEST_PROJECT_SUBDOMAIN = process.env.NHOST_TEST_PROJECT_SUBDOMAIN!;
/**
* Hasura admin secret of the test project to use.
*/
export const TEST_PROJECT_ADMIN_SECRET =
process.env.NHOST_TEST_PROJECT_ADMIN_SECRET;
process.env.NHOST_TEST_PROJECT_ADMIN_SECRET!;
/**
* Email of the test account to use.
*/
export const TEST_USER_EMAIL = process.env.NHOST_TEST_USER_EMAIL;
export const TEST_USER_EMAIL = process.env.NHOST_TEST_USER_EMAIL!;
/**
* Password of the test account to use.
*/
export const TEST_USER_PASSWORD = process.env.NHOST_TEST_USER_PASSWORD;
export const TEST_USER_PASSWORD = process.env.NHOST_TEST_USER_PASSWORD!;
export const TEST_PERSONAL_ORG_SLUG = process.env.NHOST_TEST_PERSONAL_ORG_SLUG;
export const TEST_PERSONAL_ORG_SLUG = process.env.NHOST_TEST_PERSONAL_ORG_SLUG!;
const freeUserEmails = process.env.NHOST_TEST_FREE_USER_EMAILS;
const freeUserEmails = process.env.NHOST_TEST_FREE_USER_EMAILS!;
export const TEST_FREE_USER_EMAILS = JSON.parse(freeUserEmails);
export const TEST_FREE_USER_EMAILS: string[] = JSON.parse(freeUserEmails);

View File

@@ -9,7 +9,7 @@ interface CookieConsentProps {
}
export default function CookieConsent({ onAccept }: CookieConsentProps) {
const [consentGiven, setConsentGiven] = useSSRLocalStorage(
const [consentGiven, setConsentGiven] = useSSRLocalStorage<boolean | null>(
'cookie-consent',
null,
);

View File

@@ -51,7 +51,7 @@ export interface DialogContextProps {
/**
* Call this function to open an alert dialog.
*/
openAlertDialog: <TPayload = string>(config?: DialogConfig<TPayload>) => void;
openAlertDialog: <TPayload = string>(config: DialogConfig<TPayload>) => void;
/**
* Call this function to close the active dialog.
*/

View File

@@ -23,6 +23,10 @@ import {
drawerReducer,
} from './dialogReducers';
function isBaseSyntheticEvent(event: any): event is BaseSyntheticEvent {
return event?.type !== undefined;
}
function DialogProvider({ children }: PropsWithChildren<unknown>) {
const router = useRouter();
@@ -87,7 +91,7 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
drawerDispatch({ type: 'CLEAR_DRAWER_CONTENT' });
}, []);
function openAlertDialog<TConfig = string>(config?: DialogConfig<TConfig>) {
function openAlertDialog<TConfig = string>(config: DialogConfig<TConfig>) {
alertDialogDispatch({ type: 'OPEN_ALERT', payload: config });
}
@@ -122,8 +126,12 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
);
const closeDrawerWithDirtyGuard = useCallback(
(event?: BaseSyntheticEvent) => {
if (isDrawerDirty.current && event?.type !== 'submit') {
(event?: any) => {
if (
isDrawerDirty.current &&
isBaseSyntheticEvent(event) &&
event.type !== 'submit'
) {
setShowDirtyConfirmation(true);
openDirtyConfirmation({ props: { onPrimaryAction: closeDrawer } });
return;
@@ -135,8 +143,12 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
);
const closeDialogWithDirtyGuard = useCallback(
(event?: BaseSyntheticEvent) => {
if (isDialogDirty.current && event?.type !== 'submit') {
(event?: any) => {
if (
isDialogDirty.current &&
isBaseSyntheticEvent(event) &&
event.type !== 'submit'
) {
setShowDirtyConfirmation(true);
openDirtyConfirmation({ props: { onPrimaryAction: closeDialog } });
return;
@@ -250,7 +262,7 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
<BaseDialog
{...dialogProps}
title={dialogTitle}
open={dialogOpen}
open={!!dialogOpen}
onClose={closeDialogWithDirtyGuard}
TransitionProps={{ onExited: clearDialogContent, unmountOnExit: false }}
PaperProps={{

View File

@@ -115,7 +115,7 @@ export function drawerReducer(
}
export type AlertDialogAction =
| { type: 'OPEN_ALERT'; payload?: DialogConfig }
| { type: 'OPEN_ALERT'; payload: DialogConfig }
| { type: 'HIDE_ALERT' }
| { type: 'CLEAR_ALERT_CONTENT' };

View File

@@ -1,11 +1,13 @@
import type { LinkProps } from '@/components/ui/v2/Link';
import { Link } from '@/components/ui/v2/Link';
import type { MakeRequired } from '@/types/common';
import NextLink from 'next/link';
import type { ForwardedRef, PropsWithoutRef } from 'react';
import { forwardRef } from 'react';
import { twMerge } from 'tailwind-merge';
export interface NavLinkProps extends PropsWithoutRef<LinkProps> {
export interface NavLinkProps
extends MakeRequired<PropsWithoutRef<LinkProps>, 'href'> {
/**
* Determines whether or not the link should be disabled.
*/

View File

@@ -5,7 +5,7 @@ import { useIsCurrentUserOwner } from '@/features/orgs/projects/common/hooks/use
interface Props {
buttonText?: string;
onClick?: () => void;
onClick: () => void;
}
function OpenTransferDialogButton({ buttonText, onClick }: Props) {

View File

@@ -19,7 +19,7 @@ export type PaginationProps = DetailedHTMLProps<
/**
* Number of total elements per page.
*/
elementsPerPage?: number;
elementsPerPage: number;
/**
* Total number of elements.
*/

View File

@@ -7,7 +7,7 @@ import { TimePickerInput } from './TimePickerInput';
interface TimePickerProps {
date: Date | undefined;
setDate: (date: Date | undefined) => void;
setDate: (date: Date) => void;
}
function TimePicker({ date, setDate }: TimePickerProps) {

View File

@@ -15,7 +15,7 @@ export interface TimePickerInputProps
extends React.InputHTMLAttributes<HTMLInputElement> {
picker: TimePickerType;
date: Date | undefined;
setDate: (date: Date | undefined) => void;
setDate: (date: Date) => void;
period?: Period;
onRightFocus?: () => void;
onLeftFocus?: () => void;

View File

@@ -19,7 +19,7 @@ function getOrderedTimezones(dateTime: string, selectedTimezone: string) {
) {
const selectedTimezoneOption = timezones.find(
(tz) => tz.value === selectedTimezone,
);
)!;
orderedTimezones = [
selectedTimezoneOption,
...timezones.filter((tz) => tz.value !== selectedTimezone),

View File

@@ -8,7 +8,7 @@ export interface UIContextProps {
/**
* The date and time when maintenance mode will end.
*/
maintenanceEndDate: Date;
maintenanceEndDate: Date | null;
}
const UIContext = createContext<UIContextProps>({

View File

@@ -24,7 +24,7 @@ type Option = {
};
interface VirtualizedCommandProps<O extends Option> {
height: string;
height?: string;
options: O[];
placeholder: string;
selectedOption: string;
@@ -181,7 +181,7 @@ interface VirtualizedComboboxProps<O extends Option> {
width?: string;
height?: string;
button?: React.JSX.Element;
onSelectOption?: (option: O) => void;
onSelectOption: (option: O) => void;
selectedOption: string;
align?: 'start' | 'center' | 'end';
side?: 'right' | 'top' | 'bottom' | 'left';
@@ -210,7 +210,7 @@ function VirtualizedCombobox<O extends Option>({
}}
>
{selectedOption
? options.find((option) => option.value === selectedOption).value
? options.find((option) => option.value === selectedOption)!.value
: searchPlaceholder}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>

View File

@@ -3,7 +3,9 @@ import type {
AutocompleteProps,
} from '@/components/ui/v2/Autocomplete';
import { Autocomplete } from '@/components/ui/v2/Autocomplete';
import { isNotEmptyValue } from '@/lib/utils';
import { callAll } from '@/utils/callAll';
import type { FilterOptionsState } from '@mui/material';
import type { ForwardedRef } from 'react';
import { forwardRef } from 'react';
import type { FieldValues, UseControllerProps } from 'react-hook-form';
@@ -28,6 +30,29 @@ export interface ControlledAutocompleteProps<
control?: UseControllerProps<TFieldValues>['control'];
}
export function defaultFilterOptions(
options: AutocompleteOption<string>[],
{ inputValue }: FilterOptionsState<AutocompleteOption<string>>,
) {
const inputValueLower = inputValue.toLowerCase();
const matched: AutocompleteOption<string>[] = [];
const otherOptions: AutocompleteOption<string>[] = [];
options.forEach((option) => {
const optionLabelLower = option.label.toLowerCase();
if (optionLabelLower.startsWith(inputValueLower)) {
matched.push(option);
} else {
otherOptions.push(option);
}
});
const result = [...matched, ...otherOptions];
return result;
}
function ControlledAutocomplete(
{
controllerProps,
@@ -38,9 +63,10 @@ function ControlledAutocomplete(
ref: ForwardedRef<HTMLInputElement>,
) {
const form = useFormContext();
const nameAttr = controllerProps?.name || name || '';
const { field } = useController({
...(controllerProps || {}),
name: controllerProps?.name || name || '',
name: nameAttr,
control: controllerProps?.control || control,
});
@@ -53,13 +79,15 @@ function ControlledAutocomplete(
return (
<Autocomplete
inputValue={
typeof field.value !== 'object' ? field.value.toString() : undefined
typeof field.value !== 'object' && isNotEmptyValue(field.value)
? field.value.toString()
: undefined
}
{...props}
{...field}
ref={mergeRefs([field.ref, ref])}
onChange={(event, options, reason, details) => {
setValue?.(controllerProps?.name || name, options, {
setValue?.(nameAttr, options, {
shouldDirty: true,
});
@@ -67,7 +95,10 @@ function ControlledAutocomplete(
props.onChange(event, options, reason, details);
}
}}
onBlur={callAll(field.onBlur, props.onBlur)}
onBlur={callAll<[React.FocusEvent<HTMLDivElement>]>(
field.onBlur,
props.onBlur,
)}
/>
);
}

View File

@@ -41,9 +41,10 @@ function ControlledCheckbox(
ref: ForwardedRef<HTMLButtonElement>,
) {
const { setValue } = useFormContext();
const nameAttr = controllerProps?.name || name || '';
const { field } = useController({
...controllerProps,
name: controllerProps?.name || name || '',
name: nameAttr,
control: controllerProps?.control || control,
});
@@ -53,13 +54,16 @@ function ControlledCheckbox(
name={field.name}
ref={mergeRefs([field.ref, ref])}
onChange={(event, checked) => {
setValue(controllerProps?.name || name, checked, { shouldDirty: true });
setValue(nameAttr, checked, { shouldDirty: true });
if (props.onChange) {
props.onChange(event, checked);
}
}}
onBlur={callAll(field.onBlur, props.onBlur)}
onBlur={callAll<[React.FocusEvent<HTMLButtonElement>]>(
field.onBlur,
props.onBlur,
)}
checked={uncheckWhenDisabled && props.disabled ? false : field.value}
/>
);

View File

@@ -27,9 +27,10 @@ function ControlledSelect(
ref: ForwardedRef<HTMLButtonElement>,
) {
const { setValue } = useFormContext();
const nameAttr = controllerProps?.name || name || '';
const { field } = useController({
...controllerProps,
name: controllerProps?.name || name || '',
name: nameAttr,
control: controllerProps?.control || control,
});
@@ -39,7 +40,7 @@ function ControlledSelect(
{...field}
ref={mergeRefs([field.ref, ref])}
onChange={(event, value) => {
setValue(controllerProps?.name || name, value, { shouldDirty: true });
setValue(nameAttr, value, { shouldDirty: true });
if (props.onChange) {
props.onChange(event, value);

View File

@@ -31,9 +31,10 @@ function ControlledSwitch(
ref: ForwardedRef<HTMLSpanElement>,
) {
const { setValue } = useFormContext();
const nameAttr = controllerProps?.name || name || '';
const { field } = useController({
...controllerProps,
name: controllerProps?.name || name || '',
name: nameAttr,
control: controllerProps?.control || control,
});
@@ -43,7 +44,7 @@ function ControlledSwitch(
{...field}
ref={mergeRefs([field.ref, ref])}
onChange={(event) => {
setValue(controllerProps?.name || name, event.target.checked, {
setValue(nameAttr, event.target.checked, {
shouldDirty: true,
});

View File

@@ -8,11 +8,11 @@ export interface FormProps extends BoxProps {
/**
* Function to be called when the form is submitted.
*/
onSubmit?: (...args: any[]) => any;
onSubmit: (...args: any[]) => any;
}
export default function Form({ onSubmit, onKeyDown, ...props }: FormProps) {
const formRef = useRef<HTMLDivElement>();
const formRef = useRef<HTMLDivElement | null>(null);
const {
handleSubmit,
formState: { isSubmitting },
@@ -28,7 +28,7 @@ export default function Form({ onSubmit, onKeyDown, ...props }: FormProps) {
}
const submitButton = Array.from(
formRef.current.getElementsByTagName('button'),
formRef.current!.getElementsByTagName('button'),
).find((item) => item.type === 'submit');
// Disabling submit if the submit button is disabled

View File

@@ -37,7 +37,9 @@ export default function AuthenticatedLayout({
const { isAuthenticated, isLoading, isSigningOut } = useAuth();
const { isHealthy, isLoading: isHealthyLoading } = useIsHealthy();
const [mainNavContainer, setMainNavContainer] = useState(null);
const [mainNavContainer, setMainNavContainer] = useState<HTMLElement | null>(
null,
);
const { mainNavPinned } = useTreeNavState();
useEffect(() => {

View File

@@ -30,7 +30,7 @@ type Option = {
export default function OrgsComboBox() {
const { orgs } = useOrgs();
const isPlatform = useIsPlatform();
const [, setLastSlug] = useSSRLocalStorage('slug', null);
const [, setLastSlug] = useSSRLocalStorage<string | null>('slug', null);
const {
query: { orgSlug },

View File

@@ -14,7 +14,7 @@ import NavTree from './NavTree';
import { useTreeNavState } from './TreeNavStateContext';
interface MainNavProps {
container: HTMLElement;
container: HTMLElement | null;
}
export default function MainNav({ container }: MainNavProps) {

View File

@@ -13,7 +13,7 @@ import { UserIcon } from '@/components/ui/v2/icons/UserIcon';
import { Badge } from '@/components/ui/v3/badge';
import { Button } from '@/components/ui/v3/button';
import { useOrgs, type Org } from '@/features/orgs/projects/hooks/useOrgs';
import { cn } from '@/lib/utils';
import { cn, isNotEmptyValue } from '@/lib/utils';
import { getConfigServerUrl, isPlatform as getIsPlatform } from '@/utils/env';
import { Box, ChevronDown, ChevronRight, Plus } from 'lucide-react';
import Link from 'next/link';
@@ -341,7 +341,7 @@ type NavItem = {
};
const buildNavTreeData = (
org: Org,
org?: Org,
): { items: Record<TreeItemIndex, TreeItem<NavItem>> } => {
if (!org) {
return {
@@ -382,8 +382,11 @@ const buildNavTreeData = (
export default function NavTree() {
const { currentOrg: org } = useOrgs();
// eslint-disable-next-line react-hooks/exhaustive-deps
const navTree = useMemo(() => buildNavTreeData(org), [org?.slug]);
const navTree = useMemo(
() => buildNavTreeData(org),
// eslint-disable-next-line react-hooks/exhaustive-deps
[org?.slug],
);
const { orgsTreeViewState, setOrgsTreeViewState, setOpen } =
useTreeNavState();
@@ -426,7 +429,7 @@ export default function NavTree() {
asChild
onClick={() => {
// do not focus an item if we already there
// this will prevent the case where clikcing on the project name
// this will prevent the case where clicking on the project name
// would focus on the project name instead of the overview page
if (
navTree.items[item.index].data.targetUrl ===
@@ -515,13 +518,19 @@ export default function NavTree() {
canSearch={false}
onExpandItem={(item) => {
setOrgsTreeViewState(
({ expandedItems: prevExpandedItems, ...rest }) => ({
...rest,
// Add item index to expandedItems only if it's not already present
expandedItems: prevExpandedItems.includes(item.index)
? prevExpandedItems
: [...prevExpandedItems, item.index],
}),
({ expandedItems: prevExpandedItems, ...rest }) => {
const newExpandedItems = isNotEmptyValue(prevExpandedItems)
? [...prevExpandedItems]
: [];
return {
...rest,
// Add item index to expandedItems only if it's not already present
expandedItems: newExpandedItems?.includes(item.index)
? prevExpandedItems
: [...newExpandedItems, item.index],
};
},
);
}}
onCollapseItem={(item) => {
@@ -529,7 +538,7 @@ export default function NavTree() {
({ expandedItems: prevExpandedItems, ...rest }) => ({
...rest,
// Remove the item index from expandedItems
expandedItems: prevExpandedItems.filter(
expandedItems: (prevExpandedItems ?? []).filter(
(index) => index !== item.index,
),
}),

View File

@@ -12,7 +12,7 @@ export default function PinnedMainNav() {
query: { orgSlug },
} = useRouter();
const scrollContainerRef = useRef();
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const { mainNavPinned, setMainNavPinned } = useTreeNavState();
useEffect(() => {

View File

@@ -31,25 +31,19 @@ interface TreeNavProviderProps {
children: ReactNode;
}
function useSyncedTreeViewState(
useTreeStateFromURL: () => {
expandedItems: string[];
focusedItem: string | null;
},
) {
const { expandedItems, focusedItem } = useTreeStateFromURL();
function useSyncedTreeViewState() {
const { expandedItems, focusedItem } = useNavTreeStateFromURL();
const [state, setState] = useState<IndividualTreeViewState<never>>({
const [state, setState] = useState<IndividualTreeViewState>({
expandedItems,
focusedItem,
selectedItems: null,
});
useEffect(() => {
setState((prevState) => ({
...prevState,
expandedItems: [
...new Set([...prevState.expandedItems, ...expandedItems]),
...new Set([...(prevState.expandedItems ?? []), ...expandedItems]),
],
focusedItem,
}));
@@ -64,7 +58,7 @@ function TreeNavStateProvider({ children }: TreeNavProviderProps) {
'pin-nav-tree',
true,
);
const orgsTreeViewState = useSyncedTreeViewState(useNavTreeStateFromURL);
const orgsTreeViewState = useSyncedTreeViewState();
const value = useMemo(
() => ({

View File

@@ -146,7 +146,7 @@ export default function SettingsContainer({
{!switchId && showSwitch && (
<Switch
checked={enabled}
onChange={(e) => onEnabledChange(e.target.checked)}
onChange={(e) => onEnabledChange?.(e.target.checked)}
className="self-center"
{...switchSlot}
/>

View File

@@ -19,7 +19,7 @@ export interface CodeBlockPropsBase {
/**
* Text of the toast that appears when the code is copied to the clipboard.
*/
copyToClipboardToastTitle?: string;
copyToClipboardToastTitle: string;
}
export type CodeBlockProps = CodeBlockPropsBase &
@@ -63,7 +63,7 @@ interface CopyToClipboardButtonProps
> {
filenameColor?: string;
tooltipColor?: string;
toastTitle?: string;
toastTitle: string;
}
function CopyToClipboardButton({

View File

@@ -11,7 +11,7 @@ function CopyToClipboardButton({
title,
...props
}: {
textToCopy: string;
textToCopy?: string | null;
title: string;
} & ButtonProps) {
const [disabled, setDisabled] = useState(true);

View File

@@ -1,13 +1,11 @@
import { useUI } from '@/components/common/UIProvider';
import { Alert } from '@/components/ui/v2/Alert';
export default function MaintenanceAlert() {
const { maintenanceActive, maintenanceEndDate } = useUI();
if (!maintenanceActive) {
return null;
}
function MaintenceEndDate({
maintenanceEndDate,
}: {
maintenanceEndDate: Date;
}) {
const dateTimeFormat = Intl.DateTimeFormat(undefined, {
hour: '2-digit',
minute: '2-digit',
@@ -27,6 +25,21 @@ export default function MaintenanceAlert() {
const minute = parts.find((part) => part.type === 'minute')?.value;
const timeZone = parts.find((part) => part.type === 'timeZoneName')?.value;
return (
<p>
Maintenance is expected to be completed at {year}-{month}-{day} {hour}:
{minute} {timeZone}.
</p>
);
}
export default function MaintenanceAlert() {
const { maintenanceActive, maintenanceEndDate } = useUI();
if (!maintenanceActive) {
return null;
}
return (
<Alert severity="warning" className="mt-4">
<p>
@@ -36,10 +49,7 @@ export default function MaintenanceAlert() {
</p>
{maintenanceEndDate && (
<p>
Maintenance is expected to be completed at {year}-{month}-{day} {hour}
:{minute} {timeZone}.
</p>
<MaintenceEndDate maintenanceEndDate={maintenanceEndDate} />
)}
</Alert>
);

View File

@@ -5,8 +5,10 @@ import type { DetailedHTMLProps, ForwardedRef, HTMLProps } from 'react';
import { forwardRef } from 'react';
import { twMerge } from 'tailwind-merge';
export interface ReadOnlyToggleProps
extends DetailedHTMLProps<HTMLProps<HTMLSpanElement>, HTMLSpanElement> {
export type ReadOnlyToggleProps = Omit<
DetailedHTMLProps<HTMLProps<HTMLSpanElement>, HTMLSpanElement>,
'checked'
> & {
/**
* Determines whether the toggle is checked or not.
*/
@@ -24,7 +26,7 @@ export interface ReadOnlyToggleProps
*/
label?: TextProps;
};
}
};
function ReadOnlyToggle(
{ checked, className, slotProps = {}, ...props }: ReadOnlyToggleProps,

View File

@@ -1,64 +0,0 @@
import type { BoxProps } from '@/components/ui/v2/Box';
import { Box } from '@/components/ui/v2/Box';
import { twMerge } from 'tailwind-merge';
export type AvatarProps = Pick<BoxProps, 'component'> & {
style?: {
[key: string]: string;
};
className?: string;
avatarUrl?: string | null;
name?: string | null;
};
/**
* @deprecated Use `v2/Avatar` instead.
*/
export default function Avatar({
style = {},
className = '',
avatarUrl,
name = '',
...rest
}: AvatarProps) {
const noAvatar = !avatarUrl || avatarUrl.includes('blank');
const classes = twMerge(
'border rounded-full bg-cover bg-center',
className,
noAvatar && 'border-0 text-white flex items-center justify-center',
);
if (noAvatar) {
const initials = name
.split(' ')
.slice(0, 2)
.map((currentNamePart) => `${currentNamePart.charAt(0).toUpperCase()}`)
.join('');
return (
<Box
className={classes}
style={style}
sx={{
backgroundColor: (theme) =>
theme.palette.mode === 'dark' ? `grey.400` : `grey.500`,
color: (theme) => `${theme.palette.common.white} !important`,
}}
{...rest}
>
{initials}
</Box>
);
}
return (
<Box
style={Object.assign(style, { backgroundImage: `url(${avatarUrl})` })}
className={classes}
aria-label={name ? `Avatar of ${name}` : 'Avatar'}
role="img"
{...rest}
/>
);
}

View File

@@ -1,2 +0,0 @@
export * from './Avatar';
export { default as Avatar } from './Avatar';

View File

@@ -11,7 +11,7 @@ export default function ClientOnlyPortal({
children,
selector,
}: ClientOnlyPortalProps) {
const ref = useRef();
const ref = useRef<Element | DocumentFragment>();
const [mounted, setMounted] = useState(false);
useEffect(() => {
@@ -19,5 +19,5 @@ export default function ClientOnlyPortal({
setMounted(true);
}, [selector]);
return mounted ? createPortal(children, ref.current) : null;
return mounted ? createPortal(children, ref.current!) : null;
}

View File

@@ -18,7 +18,7 @@ import MaterialAutocomplete, {
} from '@mui/material/Autocomplete';
import clsx from 'clsx';
import type { ForwardedRef } from 'react';
import { forwardRef, useEffect, useRef, useState } from 'react';
import { forwardRef, useEffect, useState } from 'react';
export interface AutocompleteOption<TValue = string> {
/**
@@ -217,7 +217,6 @@ function Autocomplete(
}: AutocompleteProps<AutocompleteOption>,
ref: ForwardedRef<HTMLInputElement>,
) {
const inputRef = useRef<HTMLInputElement>();
const { formControl: formControlSlotProps, ...defaultComponentsProps } =
slotProps || {};
@@ -228,7 +227,7 @@ function Autocomplete(
// TODO: Revisit this implementation. We should probably have a better way to
// make this component controlled.
useEffect(() => {
setInputValue(externalInputValue);
setInputValue(externalInputValue ?? '');
}, [externalInputValue]);
const filterOptionsFn = externalFilterOptions || filterOptions;
@@ -431,7 +430,6 @@ function Autocomplete(
...params
}) => (
<Input
ref={inputRef}
slotProps={{
input: {
className: slotProps?.input?.className,

View File

@@ -31,7 +31,9 @@ function ColorPreferenceProvider({
colorPreferenceStorageKey,
);
if (!['light', 'dark', 'system'].includes(storedColorPreference)) {
if (
!['light', 'dark', 'system'].includes(storedColorPreference as string)
) {
setColorPreference('system');
return;

View File

@@ -9,16 +9,15 @@ import { pickersDayClasses } from '@mui/x-date-pickers/PickersDay';
import type { StaticDatePickerProps } from '@mui/x-date-pickers/StaticDatePicker';
import { StaticDatePicker } from '@mui/x-date-pickers/StaticDatePicker';
export interface DatePickerProps
extends Omit<
StaticDatePickerProps<any, Date>,
'renderInput' | 'componentsProps' | 'renderDay'
> {
export type DatePickerProps = Omit<
StaticDatePickerProps<any, Date>,
'renderInput' | 'componentsProps' | 'renderDay' | 'onChange' | 'value'
> & {
/**
* Date value to be displayed in the datepicker.
* It should be of "yyyy-MM-dd'T'HH:mm" format.
*/
value: Date;
value: Date | null;
/**
* If true, it will not allow the user to select any date.
*/
@@ -35,7 +34,7 @@ export interface DatePickerProps
* Function to be called when the user selects a date.
*/
onChange: (value: Date, keyboardInputValue?: string) => void;
}
};
const CustomStaticDatePicker = styled(StaticDatePicker)(({ theme }) => ({
borderRadius: '10px',
@@ -177,7 +176,12 @@ function DatePicker({
minDate={minDate}
maxDate={maxDate}
onChange={(newValue) => onChange(newValue as Date)}
renderInput={() => null}
renderInput={
(() => null) as unknown as StaticDatePickerProps<
any,
Date
>['renderInput']
}
/>
</LocalizationProvider>
);

View File

@@ -15,7 +15,9 @@ const LinearProgress = styled(MaterialLinearProgress)(({ theme, value }) => ({
},
[`& .${linearProgressClasses.bar}`]: {
backgroundColor:
value >= 100 ? theme.palette.error.dark : theme.palette.primary.main,
value && value >= 100
? theme.palette.error.dark
: theme.palette.primary.main,
},
}));

View File

@@ -41,7 +41,7 @@ const StyledOption = styled(BaseOption)(({ theme }) => ({
},
}));
function Option<TValue>(
function Option<TValue extends {}>(
{ children, ...props }: OptionProps<TValue>,
ref: ForwardedRef<HTMLLIElement>,
) {

View File

@@ -63,7 +63,7 @@ const StyledPopper = styled(BasePopper)`
z-index: 9999;
`;
function Select<TValue>(
function Select<TValue extends {}>(
{
className,
slotProps,

View File

@@ -60,7 +60,7 @@ function Slider(
ref={ref}
components={{
Rail: SliderRail({
value: allowed,
value: allowed ?? 0,
max: props.max,
marks: props.marks,
step: props.step,
@@ -69,7 +69,7 @@ function Slider(
}}
color="primary"
{...props}
marks={allowed > 0 ? false : props.marks}
marks={allowed && allowed > 0 ? false : props.marks}
/>
);
}

View File

@@ -20,7 +20,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-accent',
prefix && 'pl-6',
{ 'pl-6': prefix },
className,
)}
ref={ref}

View File

@@ -52,7 +52,7 @@ const sheetVariants = cva(
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {
container?: HTMLElement;
container?: HTMLElement | null;
hideCloseButton?: boolean;
}
@@ -64,7 +64,7 @@ const SheetContent = React.forwardRef<
{
side = 'right',
className,
container,
container = null,
hideCloseButton,
children,
...props

View File

@@ -64,7 +64,7 @@ function MfaQRCodeAndTOTPSecret({ onSuccess }: Props) {
</p>
<img alt="qrcode" src={qrCodeDataUrl} className="mx-auto w-64" />
</div>
<CopyMfaTOTPSecret totpSecret={totpSecret} />
{totpSecret && <CopyMfaTOTPSecret totpSecret={totpSecret} />}
<MfaOtpForm loading={isActivating} sendMfaOtp={onSendMfaOtp} />
</>
)}

View File

@@ -23,8 +23,8 @@ import { FormProvider, useForm } from 'react-hook-form';
import * as Yup from 'yup';
export const createPATFormValidationSchema = Yup.object({
name: Yup.string().label('Name').nullable().required(),
expiresAt: Yup.string().label('Expiration date').nullable().required(),
name: Yup.string().label('Name').required(),
expiresAt: Yup.string().label('Expiration date').required(),
});
export type CreatePATFormValues = Yup.InferType<
@@ -70,15 +70,17 @@ export default function CreatePATForm({
onCancel,
location,
}: CreatePATFormProps) {
const [personalAccessToken, setPersonalAccessToken] = useState<string>();
const [personalAccessToken, setPersonalAccessToken] = useState<
string | null
>();
const { onDirtyStateChange } = useDialog();
const nhostClient = useNhostClient();
const apolloClient = useApolloClient();
const form = useForm<CreatePATFormValues>({
reValidateMode: 'onSubmit',
defaultValues: {
name: null,
expiresAt: null,
name: '',
expiresAt: '',
},
resolver: yupResolver(createPATFormValidationSchema),
});
@@ -150,7 +152,7 @@ export default function CreatePATForm({
color="secondary"
onClick={() => {
onDirtyStateChange(false, location);
onCancel();
onCancel?.();
}}
>
Close

View File

@@ -7,7 +7,7 @@ import RemoveSecurityKeyButton from './RemoveSecurityKeyButton';
type SecurityKeyProps = {
id: string;
nickname?: string;
nickname?: string | null;
};
function SecurityKey({ id, nickname }: SecurityKeyProps) {

View File

@@ -37,7 +37,10 @@ function useGithubAuthentication({
},
{
onError: () => {
toast.error(errorText, getToastStyleProps());
toast.error(
errorText || 'Something went wrong. Please try again later.',
getToastStyleProps(),
);
},
},
);

View File

@@ -1,7 +1,9 @@
import useOnSignInWithEmailAndPasswordHandler from '@/features/auth/SignIn/SignInWithEmailAndPassword/hooks/useOnSignInWithEmailAndPasswordHandler';
import useRequestNewMfaTicket from '@/features/auth/SignIn/SignInWithEmailAndPassword/hooks/useRequestNewMfaTicket';
import { isNotEmptyValue } from '@/lib/utils';
import { useNhostClient } from '@/providers/nhost';
import { getToastStyleProps } from '@/utils/constants/settings';
import { useRef, useState } from 'react';
import toast from 'react-hot-toast';
import MfaSignInOtpForm from './MfaSignInOtpForm';
import SignInWithEmailAndPasswordForm from './SignInWithEmailAndPasswordForm';
@@ -18,18 +20,31 @@ function SignInWithEmailAndPassword() {
const { onSignInWithEmailAndPassword, isLoading, emailAndPasswordRef } =
useOnSignInWithEmailAndPasswordHandler({ onNeedsMfa });
const requestNewMfaTicketFn = useRequestNewMfaTicket();
async function requestNewMfaTicket() {
const { email, password } = emailAndPasswordRef.current;
mfaTicket.current = await requestNewMfaTicketFn(email, password);
if (isNotEmptyValue(emailAndPasswordRef.current)) {
try {
const { email, password } = emailAndPasswordRef.current;
const response = await nhost.auth.signInEmailPassword({
email,
password,
});
mfaTicket.current = response.body?.mfa!.ticket;
} catch (error) {
toast.error(
error?.message ||
'An error occurred while verifying TOTP. Please try again.',
getToastStyleProps(),
);
}
}
}
async function onHandleSendMfaOtp(otp: string) {
try {
setIsMfaLoading(true);
await nhost.auth.verifySignInMfaTotp({
ticket: mfaTicket.current,
ticket: mfaTicket.current!,
otp,
});
} finally {

View File

@@ -1,29 +0,0 @@
import { useNhostClient } from '@/providers/nhost';
import { getToastStyleProps } from '@/utils/constants/settings';
import toast from 'react-hot-toast';
function useRequestNewMfaTicket() {
const nhost = useNhostClient();
async function requestNewMfaTicket(email: string, password: string) {
let mfaTicket: string;
try {
const response = await nhost.auth.signInEmailPassword({
email,
password,
});
mfaTicket = response.body?.mfa.ticket;
} catch (error) {
toast.error(
error?.message ||
'An error occurred while verifying TOTP. Please try again.',
getToastStyleProps(),
);
}
return mfaTicket;
}
return requestNewMfaTicket;
}
export default useRequestNewMfaTicket;

View File

@@ -43,7 +43,7 @@ function SignUpWithEmailAndPasswordForm() {
<FormLabel>Verification</FormLabel>
<FormControl>
<Turnstile
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY}
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
options={{ theme: 'dark', size: 'flexible' }}
onSuccess={(token) => {
form.setValue('turnstileToken', token, {

View File

@@ -37,7 +37,7 @@ function SignUpWithSecurityKeyForm() {
<FormLabel>Verification</FormLabel>
<FormControl>
<Turnstile
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY}
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
options={{ theme: 'dark', size: 'flexible' }}
onSuccess={(token) => {
form.setValue('turnstileToken', token, {

View File

@@ -61,12 +61,12 @@ import { z } from 'zod';
const createOrgFormSchema = z.object({
name: z.string().min(2),
organizationType: z.string().min(1, 'Please select an organization type'),
plan: z.optional(z.string()),
plan: z.string(),
});
interface CreateOrgFormProps {
plans: PrefetchNewAppPlansFragment[];
onSubmit?: ({
onSubmit: ({
name,
plan,
}: z.infer<typeof createOrgFormSchema>) => Promise<void>;
@@ -276,7 +276,7 @@ interface CreateOrgDialogProps {
isStarterDisabled?: boolean;
}
function isPropSet(prop: any) {
function isPropSet<T>(prop: T): prop is Exclude<T, undefined | null> {
return prop !== undefined;
}
@@ -315,17 +315,15 @@ export default function CreateOrgDialog({
organizationType,
plan,
}: {
name?: string;
organizationType?: string;
plan?: string;
name: string;
organizationType: string;
plan: string;
}) => {
await execPromiseWithErrorToast(
async () => {
const defaultRedirectUrl = `${window.location.origin}/orgs/verify`;
const {
data: { billingCreateOrganizationRequest: clientSecret },
} = await createOrganizationRequest({
const response = await createOrganizationRequest({
variables: {
organizationName: name,
planID: plan,
@@ -333,8 +331,10 @@ export default function CreateOrgDialog({
},
});
if (clientSecret) {
setStripeClientSecret(clientSecret);
if (response.data?.billingCreateOrganizationRequest) {
setStripeClientSecret(
response.data?.billingCreateOrganizationRequest,
);
} else {
const {
data: { organizations },
@@ -343,10 +343,10 @@ export default function CreateOrgDialog({
const newOrg = organizations.find((org) => org.plan.isFree);
analytics.track('Organization Created', {
organizationId: newOrg.id,
organizationSlug: newOrg.slug,
organizationId: newOrg?.id,
organizationSlug: newOrg?.slug,
organizationName: name,
organizationPlan: newOrg.plan.name,
organizationPlan: newOrg?.plan.name,
organizationOwnerId: currentUser?.id,
organizationOwnerEmail: currentUser?.email,
organizationMetadata: {
@@ -355,7 +355,7 @@ export default function CreateOrgDialog({
isOnboarding: false,
});
router.push(`/orgs/${newOrg.slug}/projects`);
router.push(`/orgs/${newOrg?.slug}/projects`);
handleOpenChange(false);
}
},
@@ -414,9 +414,9 @@ export default function CreateOrgDialog({
/>
</div>
)}
{!loading && !stripeClientSecret && (
{data && !loading && !stripeClientSecret && (
<CreateOrgForm
plans={data?.plans}
plans={data.plans}
onSubmit={createOrg}
onCancel={() => handleOpenChange(false)}
isStarterDisabled={isStarterDisabled}

View File

@@ -35,7 +35,7 @@ const validationSchema = Yup.object({
threshold: Yup.number().test(
'is-valid-threshold',
`Threshold must be greater than 110% of your plan's price`,
(value: number, { options }) => {
(value, { options }) => {
const planPrice = options?.context?.planPrice || 0;
if (value === 0) {
return true;
@@ -105,7 +105,7 @@ export default function SpendingNotifications() {
const enabled = watch('enabled');
const progress = useMemo(() => {
if (!enabled || threshold <= 0 || !amountDue) {
if (!enabled || !threshold || threshold <= 0 || !amountDue) {
return 0;
}
@@ -134,7 +134,7 @@ export default function SpendingNotifications() {
const updateConfigPromise = updateConfig({
variables: {
id: org?.id,
threshold: values.threshold,
threshold: values.threshold!,
},
});

View File

@@ -103,7 +103,7 @@ export default function SubscriptionPlan() {
redirectURL,
},
});
const clientSecret = result?.data?.billingUpgradeFreeOrganization;
const clientSecret = result?.data?.billingUpgradeFreeOrganization!;
setStripeClientSecret(clientSecret);
} else {
await execPromiseWithErrorToast(

View File

@@ -50,7 +50,7 @@ function TransferProjectDialogContent({
data: { organizations },
} = await refetchOrgs();
const newOrg = organizations.find((org) => org.slug === Slug);
const newOrg = organizations.find((org) => org.slug === Slug)!;
setShowContent(true);
onOrganizationChange(newOrg.id);

View File

@@ -71,7 +71,7 @@ function TransferProjectForm({
}
}, [selectedOrganizationId, form]);
const isUserAdminOfOrg = (org: Org, userId: string) =>
const isUserAdminOfOrg = (org: Org, userId?: string) =>
org.members.some(
(member) =>
member.role === Organization_Members_Role_Enum.Admin &&
@@ -99,7 +99,9 @@ function TransferProjectForm({
});
const targetOrg = orgs.find((o) => o.id === organization);
await push(`/orgs/${targetOrg.slug}/projects`);
if (targetOrg) {
await push(`/orgs/${targetOrg.slug}/projects`);
}
},
{
loadingMessage: 'Transferring project...',
@@ -132,7 +134,7 @@ function TransferProjectForm({
value={org.id}
disabled={
org.plan.isFree || // disable the personal org
org.id === currentOrg.id || // disable the current org as it can't be a destination org
org.id === currentOrg?.id || // disable the current org as it can't be a destination org
!isUserAdminOfOrg(org, user?.id) // disable orgs that the current user is not admin of
}
>

View File

@@ -165,7 +165,7 @@ export default function AnnouncementsTray() {
<DropdownMenuItem
disabled={announcement.read.length === 0}
onClick={() =>
handleSetUnread(announcement.read.at(0).id)
handleSetUnread(announcement?.read?.at(0)?.id)
}
>
Mark as unread

View File

@@ -20,7 +20,7 @@ import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatfo
import { useOrgs } from '@/features/orgs/projects/hooks/useOrgs';
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
import { useUserData } from '@/hooks/useUserData';
import { isEmptyValue } from '@/lib/utils';
import { isEmptyValue, isNotEmptyValue } from '@/lib/utils';
import {
CheckoutStatus,
useDeleteOrganizationMemberInviteMutation,
@@ -80,32 +80,23 @@ export default function NotificationsTray() {
userID: userData?.id,
},
});
if (organizationNewRequests.length > 0) {
const { sessionID } = organizationNewRequests.at(0);
const firstOrganizationNewRequests = organizationNewRequests.at(0);
if (isNotEmptyValue(firstOrganizationNewRequests)) {
const { sessionID } = firstOrganizationNewRequests;
const {
data: { billingPostOrganizationRequest },
} = await postOrganizationRequest({
const response = await postOrganizationRequest({
variables: {
sessionID,
},
});
const { Status } = billingPostOrganizationRequest;
if (response.data?.billingPostOrganizationRequest) {
const { billingPostOrganizationRequest } = response.data;
const { Status } = billingPostOrganizationRequest;
switch (Status) {
case CheckoutStatus.Open:
if (Status === CheckoutStatus.Open) {
setPendingOrgRequest(billingPostOrganizationRequest);
break;
case CheckoutStatus.Completed:
break;
case CheckoutStatus.Expired:
break;
default:
break;
}
}
}
};
@@ -303,7 +294,9 @@ export default function NotificationsTray() {
</DialogHeader>
{pendingOrgRequest && (
<StripeEmbeddedForm clientSecret={pendingOrgRequest.ClientSecret} />
<StripeEmbeddedForm
clientSecret={pendingOrgRequest.ClientSecret!}
/>
)}
</DialogContent>
</Dialog>

View File

@@ -54,7 +54,7 @@ export default function PendingInvites() {
const { org } = useCurrentOrg();
const isAdmin = useIsOrgAdmin();
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
const [orgInviteError, setOrgInviteError] = useState(null);
const [orgInviteError, setOrgInviteError] = useState<string | null>(null);
const {
data: { organizationMemberInvites = [] } = {},
@@ -110,7 +110,7 @@ export default function PendingInvites() {
{
loadingMessage: 'Sending invite...',
successMessage: `Invite to join Organization ${org?.name} sent to ${email}.`,
errorMessage: null,
errorMessage: '',
onError: async (error) => {
await discordAnnounce(
`Error trying to invite to ${email} to Organization ${org?.name} ${error.message}`,

View File

@@ -28,7 +28,7 @@ function useFinishOrgCreation({
pendingMessage,
onCompleted,
onError,
}: UseFinishOrgCreationProps): [boolean, CheckoutStatus] {
}: UseFinishOrgCreationProps) {
const router = useRouter();
const { session_id } = router.query;
@@ -44,35 +44,37 @@ function useFinishOrgCreation({
setLoading(true);
execPromiseWithErrorToast(
async () => {
const {
data: { billingPostOrganizationRequest },
} = await postOrganizationRequest({
const response = await postOrganizationRequest({
variables: {
sessionID: session_id as string,
},
});
if (response.data?.billingPostOrganizationRequest) {
const { billingPostOrganizationRequest } = response.data;
const { Status } = billingPostOrganizationRequest;
const { Status } = billingPostOrganizationRequest;
setLoading(false);
setPostOrganizationRequestStatus(Status);
setLoading(false);
setPostOrganizationRequestStatus(Status);
switch (Status) {
case CheckoutStatus.Completed:
onCompleted(billingPostOrganizationRequest);
break;
switch (Status) {
case CheckoutStatus.Completed:
onCompleted(billingPostOrganizationRequest);
break;
case CheckoutStatus.Expired:
onError?.();
throw new Error('Request to create organization has expired');
case CheckoutStatus.Expired:
onError();
throw new Error('Organization request has expired');
case CheckoutStatus.Open:
// TODO discuss what to do in this case
onError();
throw new Error(pendingMessage);
default:
break;
case CheckoutStatus.Open:
// TODO discuss what to do in this case
onError?.();
throw new Error(
pendingMessage ||
'Request to create organization with status "Open"',
);
default:
break;
}
}
},
{

View File

@@ -10,8 +10,8 @@ function useIsPiTREnabled() {
});
const isPiTREnabled = useMemo(
() => isNotNull(data?.config.postgres.pitr?.retention),
[data?.config.postgres.pitr?.retention],
() => isNotNull(data?.config?.postgres.pitr),
[data?.config?.postgres.pitr],
);
return { isPiTREnabled, loading };

View File

@@ -20,8 +20,8 @@ function useIsPiTREnabledLazy(appId?: string) {
}, [appId, getPostgresSettings]);
const isPiTREnabled = useMemo(
() => isNotEmptyValue(data?.config.postgres.pitr?.retention),
[data?.config.postgres.pitr?.retention],
() => isNotEmptyValue(data?.config?.postgres.pitr),
[data?.config?.postgres.pitr],
);
return { isPiTREnabled, loading };

View File

@@ -18,8 +18,8 @@ function usePiTRBaseBackups(appId: string) {
'An error occurred while fetching the Point-in-Time backup data. Please try again later.',
);
}
if (isNotEmptyValue(data.getPiTRBaseBackups)) {
const earliestBackup = data.getPiTRBaseBackups.slice(-1).pop();
if (isNotEmptyValue(data?.getPiTRBaseBackups)) {
const earliestBackup = data.getPiTRBaseBackups.slice(-1).pop()!;
setEarliestBackup(earliestBackup.date);
}
}

View File

@@ -10,17 +10,18 @@ import { useMemo } from 'react';
*/
export default function useRemoteApplicationGQLClient() {
const { project, loading } = useProject();
const serviceUrl = generateAppServiceUrl(
project?.subdomain,
project?.region,
'graphql',
);
const userApplicationClient = useMemo(() => {
if (loading || !serviceUrl) {
if (loading || !project?.subdomain) {
return new ApolloClient({ cache: new InMemoryCache() });
}
const serviceUrl = generateAppServiceUrl(
project.subdomain,
project.region,
'graphql',
);
return new ApolloClient({
cache: new InMemoryCache(),
link: new HttpLink({
@@ -29,11 +30,16 @@ export default function useRemoteApplicationGQLClient() {
'x-hasura-admin-secret':
process.env.NEXT_PUBLIC_ENV === 'dev'
? getHasuraAdminSecret()
: project?.config?.hasura.adminSecret,
: project?.config?.hasura.adminSecret!,
},
}),
});
}, [loading, serviceUrl, project?.config?.hasura.adminSecret]);
}, [
loading,
project?.config?.hasura.adminSecret,
project?.subdomain,
project?.region,
]);
return userApplicationClient;
}

View File

@@ -6,7 +6,11 @@ function useRestoreApplicationDatabasePiTR() {
useRestoreApplicationDatabasePiTrMutation();
async function restoreApplicationDatabase(
variables: { appId: string; recoveryTarget: string; fromAppId?: string },
variables: {
appId: string;
recoveryTarget: string;
fromAppId: string | null;
},
onCompleted?: () => void,
) {
await execPromiseWithErrorToast(

View File

@@ -68,6 +68,9 @@ export const validationSchema = Yup.object({
});
export type AssistantFormValues = Yup.InferType<typeof validationSchema>;
export type AssistantFormInitialData = AssistantFormValues & {
fileStores?: string[];
};
export interface AssistantFormProps extends DialogFormProps {
/**
@@ -78,9 +81,7 @@ export interface AssistantFormProps extends DialogFormProps {
/**
* if there is initialData then it's an update operation
*/
initialData?: AssistantFormValues & {
fileStores?: string[];
};
initialData?: AssistantFormInitialData;
fileStores?: GraphiteFileStore[];
/**
@@ -126,7 +127,7 @@ export default function AssistantForm({
const assistantFileStore = initialData?.fileStores
? fileStores?.find(
(fileStore: GraphiteFileStore) =>
fileStore.id === initialData?.fileStores[0],
fileStore.id === initialData?.fileStores?.[0],
)
: null;

View File

@@ -10,10 +10,12 @@ import { Option } from '@/components/ui/v2/Option';
import { Text } from '@/components/ui/v2/Text';
import { Tooltip } from '@/components/ui/v2/Tooltip';
import { type AssistantFormValues } from '@/features/orgs/projects/ai/AssistantForm/AssistantForm';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { useFieldArray, useFormContext, type Path } from 'react-hook-form';
type AssistantFormPath = Path<AssistantFormValues>;
interface ArgumentsFormSectionProps {
nestedField: string;
nestedField: 'graphql' | 'webhooks';
nestIndex: number;
}
@@ -32,6 +34,11 @@ export default function ArgumentsFormSection({
name: `${nestedField}.${nestIndex}.arguments`,
});
function removeOnChangeHandler(name: AssistantFormPath) {
const { onChange: defaultOnChange, ...props } = register(name);
return props;
}
return (
<Box className="space-y-4">
<div className="flex flex-row items-center justify-between">
@@ -60,7 +67,6 @@ export default function ArgumentsFormSection({
// We're putting ts-ignore here so we could use the same components for both graphql and webhooks
// by passing the nestedField = 'graphql' or nestedField = 'webhooks'
{...register(
// @ts-ignore
`${nestedField}.${nestIndex}.arguments.${index}.name`,
)}
id={`${field.id}-name`}
@@ -68,10 +74,10 @@ export default function ArgumentsFormSection({
className="w-full"
hideEmptyHelperText
error={
!!errors?.[nestedField]?.[nestIndex]?.arguments[index].name
!!errors?.[nestedField]?.[nestIndex]?.arguments?.[index]?.name
}
helperText={
errors?.[nestedField]?.[nestIndex]?.arguments[index]?.name
errors?.[nestedField]?.[nestIndex]?.arguments?.[index]?.name
?.message
}
fullWidth
@@ -80,7 +86,6 @@ export default function ArgumentsFormSection({
<Input
{...register(
// @ts-ignore
`${nestedField}.${nestIndex}.arguments.${index}.description`,
)}
id={`${field.id}-description`}
@@ -88,11 +93,11 @@ export default function ArgumentsFormSection({
className="w-full"
hideEmptyHelperText
error={
!!errors?.[nestedField]?.[nestIndex]?.arguments[index]
.description
!!errors?.[nestedField]?.[nestIndex]?.arguments?.[index]
?.description
}
helperText={
errors?.[nestedField]?.[nestIndex]?.arguments[index]
errors?.[nestedField]?.[nestIndex]?.arguments?.[index]
?.description?.message
}
fullWidth
@@ -107,8 +112,7 @@ export default function ArgumentsFormSection({
<Box className="w-full">
<ControlledSelect
fullWidth
{...register(
// @ts-ignore
{...removeOnChangeHandler(
`${nestedField}.${nestIndex}.arguments.${index}.type`,
)}
id={`${field.id}-type`}

View File

@@ -60,8 +60,8 @@ export default function GraphqlDataSourcesFormSection() {
placeholder="Name"
className="w-full"
hideEmptyHelperText
error={!!errors?.graphql?.at(index)?.name}
helperText={errors?.graphql?.at(index)?.message}
error={!!errors?.graphql?.at?.(index)?.name}
helperText={errors?.graphql?.at?.(index)?.message}
fullWidth
autoComplete="off"
/>
@@ -73,8 +73,8 @@ export default function GraphqlDataSourcesFormSection() {
placeholder="Description"
className="w-full"
hideEmptyHelperText
error={!!errors?.graphql?.at(index)?.description}
helperText={errors?.graphql?.at(index)?.description?.message}
error={!!errors?.graphql?.at?.(index)?.description}
helperText={errors?.graphql?.at?.(index)?.description?.message}
fullWidth
autoComplete="off"
multiline
@@ -90,8 +90,8 @@ export default function GraphqlDataSourcesFormSection() {
placeholder="Query"
className="w-full"
hideEmptyHelperText
error={!!errors?.graphql?.at(index)?.query}
helperText={errors?.graphql?.at(index)?.query?.message}
error={!!errors?.graphql?.at?.(index)?.query}
helperText={errors?.graphql?.at?.(index)?.query?.message}
fullWidth
autoComplete="off"
multiline

View File

@@ -60,8 +60,8 @@ export default function WebhooksDataSourcesFormSection() {
placeholder="Name"
className="w-full"
hideEmptyHelperText
error={!!errors?.webhooks?.at(index)?.name}
helperText={errors?.webhooks?.at(index)?.message}
error={!!errors?.webhooks?.at?.(index)?.name}
helperText={errors?.webhooks?.at?.(index)?.message}
fullWidth
autoComplete="off"
/>
@@ -73,8 +73,8 @@ export default function WebhooksDataSourcesFormSection() {
placeholder="Description"
className="w-full"
hideEmptyHelperText
error={!!errors?.webhooks?.at(index)?.description}
helperText={errors?.webhooks?.at(index)?.description?.message}
error={!!errors?.webhooks?.at?.(index)?.description}
helperText={errors?.webhooks?.at?.(index)?.description?.message}
fullWidth
autoComplete="off"
multiline
@@ -90,8 +90,8 @@ export default function WebhooksDataSourcesFormSection() {
placeholder="URL"
className="w-full"
hideEmptyHelperText
error={!!errors?.webhooks?.at(index)?.URL}
helperText={errors?.webhooks?.at(index)?.URL?.message}
error={!!errors?.webhooks?.at?.(index)?.URL}
helperText={errors?.webhooks?.at?.(index)?.URL?.message}
fullWidth
autoComplete="off"
multiline

View File

@@ -1 +1,2 @@
export * from './AssistantForm';
export { default as AssistantForm } from './AssistantForm';

View File

@@ -8,7 +8,10 @@ import { DotsHorizontalIcon } from '@/components/ui/v2/icons/DotsHorizontalIcon'
import { TrashIcon } from '@/components/ui/v2/icons/TrashIcon';
import { UserIcon } from '@/components/ui/v2/icons/UserIcon';
import { Text } from '@/components/ui/v2/Text';
import { AssistantForm } from '@/features/orgs/projects/ai/AssistantForm';
import {
AssistantForm,
type AssistantFormInitialData,
} from '@/features/orgs/projects/ai/AssistantForm';
import { DeleteAssistantModal } from '@/features/orgs/projects/ai/DeleteAssistantModal';
import { type Assistant } from '@/pages/orgs/[orgSlug]/projects/[appSubdomain]/ai/assistants';
import { type GraphiteFileStore } from '@/pages/orgs/[orgSlug]/projects/[appSubdomain]/ai/file-stores';
@@ -23,14 +26,14 @@ interface AssistantsListProps {
/**
* The list of file stores
*/
fileStores: GraphiteFileStore[];
fileStores?: GraphiteFileStore[];
/**
* Function to be called after a submitting the form for either creating or updating an assistant.
*
* @example onDelete={() => refetch()}
*/
onCreateOrUpdate?: () => Promise<any>;
onCreateOrUpdate: () => Promise<any>;
/**
* Function to be called after a successful delete action.
@@ -53,11 +56,9 @@ export default function AssistantsList({
component: (
<AssistantForm
assistantId={assistant.assistantID}
initialData={{
...assistant,
}}
initialData={assistant as AssistantFormInitialData}
fileStores={fileStores}
onSubmit={() => onCreateOrUpdate()}
onSubmit={onCreateOrUpdate}
/>
),
});

View File

@@ -41,6 +41,10 @@ export const validationSchema = Yup.object({
export type AutoEmbeddingsFormValues = Yup.InferType<typeof validationSchema>;
export type AutoEmbeddingsInitialData = AutoEmbeddingsFormValues & {
model: string;
};
export interface AutoEmbeddingsFormProps extends DialogFormProps {
/**
* To use in conjunction with initialData to allow for updating the autoEmbeddingsConfiguration
@@ -50,7 +54,7 @@ export interface AutoEmbeddingsFormProps extends DialogFormProps {
/**
* if there is initialData then it's an update operation
*/
initialData?: AutoEmbeddingsFormValues & { model: string };
initialData?: AutoEmbeddingsInitialData;
/**
* Function to be called when the operation is cancelled.

View File

@@ -1 +1,2 @@
export * from './AutoEmbeddingsForm';
export { default as AutoEmbeddingsForm } from './AutoEmbeddingsForm';

View File

@@ -10,7 +10,10 @@ import { TrashIcon } from '@/components/ui/v2/icons/TrashIcon';
import { UserIcon } from '@/components/ui/v2/icons/UserIcon';
import { Text } from '@/components/ui/v2/Text';
import { Tooltip } from '@/components/ui/v2/Tooltip';
import { AutoEmbeddingsForm } from '@/features/orgs/projects/ai/AutoEmbeddingsForm';
import {
AutoEmbeddingsForm,
type AutoEmbeddingsInitialData,
} from '@/features/orgs/projects/ai/AutoEmbeddingsForm';
import { DeleteAutoEmbeddingsModal } from '@/features/orgs/projects/ai/DeleteAutoEmbeddingsModal';
import type { AutoEmbeddingsConfiguration } from '@/pages/orgs/[orgSlug]/projects/[appSubdomain]/ai/auto-embeddings';
import { formatDistanceToNow } from 'date-fns';
@@ -26,7 +29,7 @@ interface AutoEmbeddingsConfigurationsListProps {
*
* @example onDelete={() => refetch()}
*/
onCreateOrUpdate?: () => Promise<any>;
onCreateOrUpdate: () => Promise<any>;
/**
* Function to be called after a successful delete action.
@@ -55,9 +58,7 @@ export default function AutoEmbeddingsList({
component: (
<AutoEmbeddingsForm
autoEmbeddingsId={autoEmbeddingsConfiguration.id}
initialData={{
...autoEmbeddingsConfiguration,
}}
initialData={autoEmbeddingsConfiguration as AutoEmbeddingsInitialData}
onSubmit={() => onCreateOrUpdate()}
/>
),

View File

@@ -6,11 +6,12 @@ import { Text } from '@/components/ui/v2/Text';
import { generateAppServiceUrl } from '@/features/orgs/projects/common/utils/generateAppServiceUrl';
import { useProject } from '@/features/orgs/projects/hooks/useProject';
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
import { isNotEmptyValue } from '@/lib/utils';
import { type AutoEmbeddingsConfiguration } from '@/pages/orgs/[orgSlug]/projects/[appSubdomain]/ai/auto-embeddings';
import { useDeleteGraphiteAutoEmbeddingsConfigurationMutation } from '@/utils/__generated__/graphite.graphql';
import { getHasuraAdminSecret } from '@/utils/env';
import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { twMerge } from 'tailwind-merge';
export interface DeleteAutoEmbeddingsModalProps {
@@ -29,24 +30,38 @@ export default function DeleteAutoEmbeddingsModal({
const { project } = useProject();
const serviceUrl = generateAppServiceUrl(
const client = useMemo(() => {
if (
isNotEmptyValue(project?.subdomain) &&
isNotEmptyValue(project?.region) &&
isNotEmptyValue(project?.config)
) {
const serviceUrl = generateAppServiceUrl(
project.subdomain,
project.region,
'graphql',
);
return new ApolloClient({
cache: new InMemoryCache(),
link: new HttpLink({
uri: serviceUrl,
headers: {
'x-hasura-admin-secret':
process.env.NEXT_PUBLIC_ENV === 'dev'
? getHasuraAdminSecret()
: project.config.hasura.adminSecret,
},
}),
});
}
return new ApolloClient({ cache: new InMemoryCache() });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
project?.config?.hasura.adminSecret,
project?.subdomain,
project?.region,
'graphql',
);
const client = new ApolloClient({
cache: new InMemoryCache(),
link: new HttpLink({
uri: serviceUrl,
headers: {
'x-hasura-admin-secret':
process.env.NEXT_PUBLIC_ENV === 'dev'
? getHasuraAdminSecret()
: project?.config?.hasura.adminSecret,
},
}),
});
]);
const [deleteAutoEmbeddingsConfiguration] =
useDeleteGraphiteAutoEmbeddingsConfigurationMutation({

View File

@@ -19,6 +19,7 @@ import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatfo
import { useAdminApolloClient } from '@/features/orgs/projects/hooks/useAdminApolloClient';
import { useOrgs } from '@/features/orgs/projects/hooks/useOrgs';
import { useProject } from '@/features/orgs/projects/hooks/useProject';
import { isNotEmptyValue } from '@/lib/utils';
import {
useSendDevMessageMutation,
useStartDevSessionMutation,
@@ -31,7 +32,9 @@ import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
const MAX_THREAD_LENGTH = 50;
export type Message = Omit<
SendDevMessageMutation['graphite']['sendDevMessage']['messages'][0],
NonNullable<
SendDevMessageMutation['graphite']
>['sendDevMessage']['messages'][number],
'__typename'
>;
@@ -84,7 +87,8 @@ export default function DevAssistant() {
if (!sessionID || hasBeenAnHourSinceLastMessage) {
const sessionRes = await startDevSession({ client: adminClient });
sessionID = sessionRes?.data?.graphite?.startDevSession?.sessionID;
sessionID =
sessionRes?.data?.graphite?.startDevSession?.sessionID || '';
setStoredSessionID(sessionID);
}
@@ -92,37 +96,33 @@ export default function DevAssistant() {
throw new Error('Failed to start a new session');
}
const {
data: {
graphite: { sendDevMessage: { messages: newMessages } = {} } = {},
} = {},
} = await sendDevMessage({
const result = await sendDevMessage({
variables: {
message: userInput,
sessionId: sessionID || '',
sessionId: sessionID,
prevMessageID: !hasBeenAnHourSinceLastMessage
? lastMessage?.id || ''
: '',
},
});
const newMessages = result.data?.graphite?.sendDevMessage.messages;
if (isNotEmptyValue(newMessages)) {
let thread = [
// remove the temp messages of the user input while we wait for the dev assistant to respond
...$messages.filter((item) => item.createdAt),
...newMessages
// remove empty messages
.filter((item) => item.message)
let thread = [
// remove the temp messages of the user input while we wait for the dev assistant to respond
...$messages.filter((item) => item.createdAt),
...newMessages
// add the currentProject.id to the new messages
.map((item) => ({ ...item, projectId: project?.id })),
];
// remove empty messages
.filter((item) => item.message)
// add the currentProject.id to the new messages
.map((item) => ({ ...item, projectId: project?.id })),
];
if (thread.length > MAX_THREAD_LENGTH) {
thread = thread.slice(thread.length - MAX_THREAD_LENGTH); // keep the thread at a max length of MAX_THREAD_LENGTH
if (thread.length > MAX_THREAD_LENGTH) {
thread = thread.slice(thread.length - MAX_THREAD_LENGTH); // keep the thread at a max length of MAX_THREAD_LENGTH
}
setMessages(thread);
}
setMessages(thread);
} catch (error) {
toast.custom(
(t) => (

View File

@@ -55,7 +55,7 @@ export default function MessageBox({ message }: { message: Message }) {
<Box
className="flex flex-col space-y-4 border-t p-4 first:border-t-0"
sx={{
backgroundColor: isUserMessage && 'background.default',
backgroundColor: isUserMessage ? 'background.default' : undefined,
}}
>
<div className="flex items-center space-x-2">

View File

@@ -12,7 +12,7 @@ interface MessagesListProps {
function MessagesList({ loading }: MessagesListProps) {
const { project } = useProject();
const bottomElement = useRef(null);
const bottomElement = useRef<HTMLDivElement | null>(null);
const messages = useRecoilValue(projectMessagesState(project?.id));
const scrollToBottom = () =>

View File

@@ -77,7 +77,10 @@ export default function FileStoreForm({
}))
: [];
const formDefaultValues = { ...initialData, buckets: [] };
const formDefaultValues = {
...initialData,
buckets: [] as { label: string; value: string }[],
};
formDefaultValues.buckets = initialData?.buckets
? initialData.buckets.map((bucket) => ({
label: bucket,

View File

@@ -25,7 +25,7 @@ interface FileStoresListProps {
*
* @example onDelete={() => refetch()}
*/
onCreateOrUpdate?: () => Promise<any>;
onCreateOrUpdate: () => Promise<any>;
/**
* Function to be called after a successful delete action.

View File

@@ -1,7 +1,10 @@
import { ApplyLocalSettingsDialog } from '@/components/common/ApplyLocalSettingsDialog';
import { useDialog } from '@/components/common/DialogProvider';
import { useUI } from '@/components/common/UIProvider';
import { ControlledAutocomplete } from '@/components/form/ControlledAutocomplete';
import {
ControlledAutocomplete,
defaultFilterOptions,
} from '@/components/form/ControlledAutocomplete';
import { Form } from '@/components/form/Form';
import { SettingsContainer } from '@/components/layout/SettingsContainer';
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
@@ -35,6 +38,7 @@ import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatfo
import { useLocalMimirClient } from '@/features/orgs/projects/hooks/useLocalMimirClient';
import { useProject } from '@/features/orgs/projects/hooks/useProject';
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
import { isNotEmptyValue } from '@/lib/utils';
const validationSchema = Yup.object({
version: Yup.object({
@@ -66,9 +70,7 @@ export default function AISettings() {
const [aiServiceEnabled, setAIServiceEnabled] = useState(true);
const {
data: {
config: { ai, postgres: { version: postgresVersion } = {} } = {},
} = {},
data,
loading: loadingAiSettings,
error: errorGettingAiSettings,
} = useGetAiSettingsQuery({
@@ -79,6 +81,9 @@ export default function AISettings() {
skip: !project?.id,
});
const ai = data?.config?.ai;
const postgresVersion = data?.config?.postgres?.version;
const { data: graphiteVersionsData, loading: loadingGraphiteVersionsData } =
useGetSoftwareVersionsQuery({
variables: {
@@ -132,13 +137,13 @@ export default function AISettings() {
if (ai) {
reset({
version: {
label: ai?.version,
value: ai?.version,
label: ai?.version!,
value: ai?.version!,
},
webhookSecret: ai?.webhookSecret,
synchPeriodMinutes: ai?.autoEmbeddings?.synchPeriodMinutes,
apiKey: ai?.openai?.apiKey,
organization: ai?.openai?.organization,
organization: ai?.openai?.organization!,
compute: {
cpu: ai?.resources?.compute?.cpu ?? 62,
memory: ai?.resources?.compute?.memory ?? 128,
@@ -156,7 +161,7 @@ export default function AISettings() {
!ai &&
!aiSettingsFormValues.version.value
) {
setValue('version', availableVersions?.at(0));
setValue('version', availableVersions.at(0)!);
}
}, [
ai,
@@ -167,7 +172,10 @@ export default function AISettings() {
]);
const toggleAIService = async (enabled: boolean) => {
if (!isPostgresVersionValidForAI(postgresVersion)) {
if (
isNotEmptyValue(postgresVersion) &&
!isPostgresVersionValidForAI(postgresVersion)
) {
toast.error(
'In order to enable the AI service you need to update your database version to 14.6-20231018-1 or newer.',
{
@@ -212,7 +220,7 @@ export default function AISettings() {
async () => {
await updateConfig({
variables: {
appId: project.id,
appId: project?.id,
config: {
ai: {
version: formValues.version.value,
@@ -309,25 +317,7 @@ export default function AISettings() {
name="version"
autoHighlight
isOptionEqualToValue={() => false}
filterOptions={(options, { inputValue }) => {
const inputValueLower = inputValue.toLowerCase();
const matched = [];
const otherOptions = [];
options.forEach((option) => {
const optionLabelLower = option.label.toLowerCase();
if (optionLabelLower.startsWith(inputValueLower)) {
matched.push(option);
} else {
otherOptions.push(option);
}
});
const result = [...matched, ...otherOptions];
return result;
}}
filterOptions={defaultFilterOptions}
fullWidth
className="col-span-4"
options={availableVersions}

View File

@@ -39,7 +39,7 @@ export default function OTPEmailSettings() {
...(!isPlatform ? { client: localMimirClient } : {}),
});
const { enabled } = data?.config?.auth?.method?.otp?.email || {};
const enabled = !!data?.config?.auth?.method?.otp?.email?.enabled;
const form = useForm<OTPEmailSettingsFormValues>({
reValidateMode: 'onSubmit',
@@ -74,7 +74,7 @@ export default function OTPEmailSettings() {
) => {
const updateConfigPromise = updateConfig({
variables: {
appId: project.id,
appId: project?.id,
config: {
auth: {
method: {

View File

@@ -51,7 +51,9 @@ export default function AllowedEmailDomainsSettings() {
const form = useForm<AllowedEmailSettingsFormValues>({
reValidateMode: 'onSubmit',
defaultValues: {
enabled: email?.allowed?.length > 0 || emailDomains?.allowed?.length > 0,
enabled:
(email?.allowed?.length ?? 0) > 0 ||
(emailDomains?.allowed?.length ?? 0) > 0,
allowedEmails: email?.allowed?.join(', ') || '',
allowedEmailDomains: emailDomains?.allowed?.join(', ') || '',
},
@@ -67,7 +69,8 @@ export default function AllowedEmailDomainsSettings() {
if (!loading && email && emailDomains) {
form.reset({
enabled:
email?.allowed?.length > 0 || emailDomains?.allowed?.length > 0,
(email?.allowed?.length ?? 0) > 0 ||
(emailDomains?.allowed?.length ?? 0) > 0,
allowedEmails: email?.allowed?.join(', ') || '',
allowedEmailDomains: emailDomains?.allowed?.join(', ') || '',
});
@@ -93,12 +96,12 @@ export default function AllowedEmailDomainsSettings() {
) => {
const updateConfigPromise = updateConfig({
variables: {
appId: project.id,
appId: project?.id,
config: {
auth: {
user: {
email: {
blocked: email.blocked,
blocked: email?.blocked,
allowed:
values.enabled && values.allowedEmails
? values.allowedEmails
@@ -107,7 +110,7 @@ export default function AllowedEmailDomainsSettings() {
: [],
},
emailDomains: {
blocked: emailDomains.blocked,
blocked: emailDomains?.blocked,
allowed:
values.enabled && values.allowedEmailDomains
? values.allowedEmailDomains

View File

@@ -82,7 +82,7 @@ export default function AllowedRedirectURLsSettings() {
) => {
const updateConfigPromise = updateConfig({
variables: {
appId: project.id,
appId: project?.id,
config: {
auth: {
redirections: {

View File

@@ -24,7 +24,7 @@ const validationSchema = Yup.object({
export type AnonymousSignInFormValues = Yup.InferType<typeof validationSchema>;
export default function AnonymousSignInSettings() {
const { project } = useProject();
const { project, loading: isProjectLoading } = useProject();
const { openDialog } = useDialog();
const isPlatform = useIsPlatform();
const { maintenanceActive } = useUI();
@@ -39,7 +39,7 @@ export default function AnonymousSignInSettings() {
...(!isPlatform ? { client: localMimirClient } : {}),
});
const { enabled } = data?.config?.auth?.method?.anonymous || {};
const enabled = !!data?.config?.auth?.method?.anonymous?.enabled;
const form = useForm<AnonymousSignInFormValues>({
reValidateMode: 'onSubmit',
@@ -55,7 +55,7 @@ export default function AnonymousSignInSettings() {
}
}, [loading, enabled, form]);
if (loading) {
if (loading || isProjectLoading) {
return (
<ActivityIndicator
delay={1000}
@@ -74,7 +74,7 @@ export default function AnonymousSignInSettings() {
) => {
const updateConfigPromise = updateConfig({
variables: {
appId: project.id,
appId: project?.id,
config: {
auth: {
method: {

View File

@@ -58,7 +58,7 @@ export type AppleProviderFormValues = Yup.InferType<typeof validationSchema>;
export default function AppleProviderSettings() {
const theme = useTheme();
const { project } = useProject();
const { project, loading: isProjectLoading } = useProject();
const { openDialog } = useDialog();
const isPlatform = useIsPlatform();
const { maintenanceActive } = useUI();
@@ -102,7 +102,7 @@ export default function AppleProviderSettings() {
}
}, [loading, teamId, keyId, clientId, privateKey, audience, enabled, form]);
if (loading) {
if (loading || isProjectLoading) {
return (
<ActivityIndicator
delay={1000}
@@ -122,7 +122,7 @@ export default function AppleProviderSettings() {
async function handleSubmit(formValues: AppleProviderFormValues) {
const updateConfigPromise = updateConfig({
variables: {
appId: project.id,
appId: project?.id,
config: {
auth: {
method: {
@@ -256,8 +256,8 @@ export default function AppleProviderSettings() {
name="redirectUrl"
id="apple-redirectUrl"
defaultValue={`${generateAppServiceUrl(
project.subdomain,
project.region,
project!.subdomain,
project!.region,
'auth',
)}/signin/provider/apple/callback`}
className="col-span-2"
@@ -275,8 +275,8 @@ export default function AppleProviderSettings() {
e.stopPropagation();
copy(
`${generateAppServiceUrl(
project.subdomain,
project.region,
project!.subdomain,
project!.region,
'auth',
)}/signin/provider/apple/callback`,
'Redirect URL',

View File

@@ -1,7 +1,10 @@
import { ApplyLocalSettingsDialog } from '@/components/common/ApplyLocalSettingsDialog';
import { useDialog } from '@/components/common/DialogProvider';
import { useUI } from '@/components/common/UIProvider';
import { ControlledAutocomplete } from '@/components/form/ControlledAutocomplete';
import {
ControlledAutocomplete,
defaultFilterOptions,
} from '@/components/form/ControlledAutocomplete';
import { Form } from '@/components/form/Form';
import { SettingsContainer } from '@/components/layout/SettingsContainer';
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
@@ -12,7 +15,7 @@ import {
useUpdateConfigMutation,
} from '@/generated/graphql';
import { yupResolver } from '@hookform/resolvers/yup';
import { useEffect } from 'react';
import { useEffect, useMemo } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import * as Yup from 'yup';
@@ -20,6 +23,7 @@ import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatfo
import { useLocalMimirClient } from '@/features/orgs/projects/hooks/useLocalMimirClient';
import { useProject } from '@/features/orgs/projects/hooks/useProject';
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
import { isEmptyValue } from '@/lib/utils';
const validationSchema = Yup.object({
version: Yup.object({
@@ -56,16 +60,25 @@ export default function AuthServiceVersionSettings() {
});
const { version } = data?.config?.auth || {};
const versions = authVersionsData?.softwareVersions || [];
const availableVersions = Array.from(
new Set(versions.map((el) => el.version)).add(version),
)
.sort()
.reverse()
.map((availableVersion) => ({
label: availableVersion,
value: availableVersion,
}));
const availableVersions = useMemo(() => {
const versions = authVersionsData?.softwareVersions || [];
if (isEmptyValue(versions)) {
return [];
}
const versionSet = new Set(versions.map((el) => el.version));
if (version) {
versionSet.add(version);
}
return Array.from(versionSet)
.sort()
.reverse()
.map((availableVersion) => ({
label: availableVersion,
value: availableVersion,
}));
}, [authVersionsData?.softwareVersions, version]);
const form = useForm<AuthServiceVersionFormValues>({
reValidateMode: 'onSubmit',
@@ -105,7 +118,7 @@ export default function AuthServiceVersionSettings() {
) => {
const updateConfigPromise = updateConfig({
variables: {
appId: project.id,
appId: project?.id,
config: {
auth: {
version: formValues.version.value,
@@ -168,25 +181,7 @@ export default function AuthServiceVersionSettings() {
return option.value;
}}
isOptionEqualToValue={() => false}
filterOptions={(options, { inputValue }) => {
const inputValueLower = inputValue.toLowerCase();
const matched = [];
const otherOptions = [];
options.forEach((option) => {
const optionLabelLower = option.label.toLowerCase();
if (optionLabelLower.startsWith(inputValueLower)) {
matched.push(option);
} else {
otherOptions.push(option);
}
});
const result = [...matched, ...otherOptions];
return result;
}}
filterOptions={defaultFilterOptions}
fullWidth
className="lg:col-span-2"
options={availableVersions}

View File

@@ -50,7 +50,7 @@ const validationSchema = Yup.object({
export type AzureADProviderFormValues = Yup.InferType<typeof validationSchema>;
export default function AzureADProviderSettings() {
const { project } = useProject();
const { project, loading: isProjectLoading } = useProject();
const { openDialog } = useDialog();
const isPlatform = useIsPlatform();
const { maintenanceActive } = useUI();
@@ -90,7 +90,7 @@ export default function AzureADProviderSettings() {
}
}, [loading, clientId, clientSecret, tenant, enabled, form]);
if (loading) {
if (loading || isProjectLoading) {
return (
<ActivityIndicator
delay={1000}
@@ -110,7 +110,7 @@ export default function AzureADProviderSettings() {
async function handleSubmit(formValues: AzureADProviderFormValues) {
const updateConfigPromise = updateConfig({
variables: {
appId: project.id,
appId: project!.id,
config: {
auth: {
method: {
@@ -186,8 +186,8 @@ export default function AzureADProviderSettings() {
name="redirectUrl"
id="azuerad-redirectUrl"
defaultValue={`${generateAppServiceUrl(
project.subdomain,
project.region,
project!.subdomain,
project!.region,
'auth',
)}/signin/provider/azuread/callback`}
className="col-span-2"
@@ -205,8 +205,8 @@ export default function AzureADProviderSettings() {
e.stopPropagation();
copy(
`${generateAppServiceUrl(
project.subdomain,
project.region,
project!.subdomain,
project!.region,
'auth',
)}/signin/provider/azuread/callback`,
'Redirect URL',

View File

@@ -26,7 +26,7 @@ import { twMerge } from 'tailwind-merge';
export default function BitbucketProviderSettings() {
const { maintenanceActive } = useUI();
const { project } = useProject();
const { project, loading: isProjectLoading } = useProject();
const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetSignInMethodsDocument],
});
@@ -49,7 +49,7 @@ export default function BitbucketProviderSettings() {
resolver: yupResolver(baseProviderValidationSchema),
});
if (loading) {
if (loading || isProjectLoading) {
return (
<ActivityIndicator
delay={1000}
@@ -69,7 +69,7 @@ export default function BitbucketProviderSettings() {
async function handleSubmit(formValues: BaseProviderSettingsFormValues) {
const updateConfigPromise = updateConfig({
variables: {
appId: project.id,
appId: project!.id,
config: {
auth: {
method: {
@@ -125,8 +125,8 @@ export default function BitbucketProviderSettings() {
hideEmptyHelperText
label="Redirect URL"
defaultValue={`${generateAppServiceUrl(
project.subdomain,
project.region,
project!.subdomain,
project!.region,
'auth',
)}/signin/provider/bitbucket/callback`}
disabled
@@ -140,8 +140,8 @@ export default function BitbucketProviderSettings() {
e.stopPropagation();
copy(
`${generateAppServiceUrl(
project.subdomain,
project.region,
project!.subdomain,
project!.region,
'auth',
)}/signin/provider/bitbucket/callback`,
'Redirect URL',

View File

@@ -49,7 +49,9 @@ export default function BlockedEmailSettings() {
const form = useForm<BlockedEmailFormValues>({
reValidateMode: 'onSubmit',
defaultValues: {
enabled: email?.blocked?.length > 0 || emailDomains?.blocked?.length > 0,
enabled:
(email?.blocked?.length ?? 0) > 0 ||
(emailDomains?.blocked?.length ?? 0) > 0,
blockedEmails: email?.blocked?.join(', ') || '',
blockedEmailDomains: emailDomains?.blocked?.join(', ') || '',
},
@@ -64,7 +66,8 @@ export default function BlockedEmailSettings() {
if (!loading && email && emailDomains) {
form.reset({
enabled:
email?.blocked?.length > 0 || emailDomains?.blocked?.length > 0,
(email?.blocked?.length ?? 0) > 0 ||
(emailDomains?.blocked?.length ?? 0) > 0,
blockedEmails: email?.blocked?.join(', ') || '',
blockedEmailDomains: emailDomains?.blocked?.join(', ') || '',
});
@@ -90,12 +93,12 @@ export default function BlockedEmailSettings() {
) => {
const updateConfigPromise = updateConfig({
variables: {
appId: project.id,
appId: project!.id,
config: {
auth: {
user: {
email: {
allowed: email.allowed,
allowed: email?.allowed,
blocked:
values.enabled && values.blockedEmails
? [
@@ -108,7 +111,7 @@ export default function BlockedEmailSettings() {
: [],
},
emailDomains: {
allowed: emailDomains.allowed,
allowed: emailDomains?.allowed,
blocked:
values.enabled && values.blockedEmailDomains
? [

View File

@@ -76,7 +76,7 @@ export default function ClientURLSettings() {
const handleClientURLChange = async (values: ClientURLFormValues) => {
const updateConfigPromise = updateConfig({
variables: {
appId: project.id,
appId: project!.id,
config: {
auth: {
redirections: {

View File

@@ -44,14 +44,14 @@ export default function ConcealErrorsSettings() {
const form = useForm<ToggleConcealErrorsFormValues>({
reValidateMode: 'onSubmit',
defaultValues: {
enabled: data?.config?.auth?.misc?.concealErrors,
enabled: !!data?.config?.auth?.misc?.concealErrors,
},
});
useEffect(() => {
if (!loading) {
form.reset({
enabled: data?.config?.auth?.misc?.concealErrors,
enabled: data?.config?.auth?.misc?.concealErrors!,
});
}
}, [loading, data, form]);
@@ -77,7 +77,7 @@ export default function ConcealErrorsSettings() {
) => {
const updateConfigPromise = updateConfig({
variables: {
appId: project.id,
appId: project!.id,
config: {
auth: {
misc: {

View File

@@ -20,7 +20,7 @@ function ConfirmDeleteSMTPSettingsModal({
close,
onDelete,
}: {
onDelete?: () => Promise<any>;
onDelete: () => Promise<any>;
close: () => void;
}) {
const onClickDelete = async () => {
@@ -76,7 +76,7 @@ export default function DeleteSMTPSettings() {
const deleteSMTPSettings = async () => {
const updateConfigPromise = updateConfig({
variables: {
appId: project.id,
appId: project!.id,
config: {
provider: {
smtp: null,

View File

@@ -42,14 +42,14 @@ export default function DisableNewUsersSettings() {
const form = useForm<DisableNewUsersFormValues>({
reValidateMode: 'onSubmit',
defaultValues: {
disabled: data?.config?.auth?.signUp?.disableNewUsers,
disabled: !!data?.config?.auth?.signUp?.disableNewUsers,
},
});
useEffect(() => {
if (!loading) {
form.reset({
disabled: data?.config?.auth?.signUp?.disableNewUsers,
disabled: !!data?.config?.auth?.signUp?.disableNewUsers,
});
}
}, [loading, data, form]);
@@ -75,7 +75,7 @@ export default function DisableNewUsersSettings() {
) => {
const updateConfigPromise = updateConfig({
variables: {
appId: project.id,
appId: project!.id,
config: {
auth: {
signUp: {

View File

@@ -74,7 +74,7 @@ export default function DisableSignUpsSettings() {
) => {
const updateConfigPromise = updateConfig({
variables: {
appId: project.id,
appId: project!.id,
config: {
auth: {
signUp: {

View File

@@ -29,7 +29,7 @@ import { FormProvider, useForm } from 'react-hook-form';
import { twMerge } from 'tailwind-merge';
export default function DiscordProviderSettings() {
const { project } = useProject();
const { project, loading: isProjectLoading } = useProject();
const { openDialog } = useDialog();
const isPlatform = useIsPlatform();
const { maintenanceActive } = useUI();
@@ -67,7 +67,7 @@ export default function DiscordProviderSettings() {
}
}, [loading, clientId, clientSecret, enabled, form]);
if (loading) {
if (loading || isProjectLoading) {
return (
<ActivityIndicator
delay={1000}
@@ -160,8 +160,8 @@ export default function DiscordProviderSettings() {
hideEmptyHelperText
label="Redirect URL"
defaultValue={`${generateAppServiceUrl(
project.subdomain,
project.region,
project!.subdomain,
project!.region,
'auth',
)}/signin/provider/discord/callback`}
disabled
@@ -175,8 +175,8 @@ export default function DiscordProviderSettings() {
e.stopPropagation();
copy(
`${generateAppServiceUrl(
project.subdomain,
project.region,
project!.subdomain,
project!.region,
'auth',
)}/signin/provider/discord/callback`,
'Redirect URL',

View File

@@ -65,8 +65,8 @@ export default function EmailAndPasswordSettings() {
useEffect(() => {
if (!loading) {
form.reset({
hibpEnabled,
emailVerificationRequired,
hibpEnabled: !!hibpEnabled,
emailVerificationRequired: !!emailVerificationRequired,
passwordMinLength,
});
}
@@ -97,7 +97,7 @@ export default function EmailAndPasswordSettings() {
async function handleSubmit(formValues: EmailAndPasswordFormValues) {
const updateConfigPromise = updateConfig({
variables: {
appId: project.id,
appId: project!.id,
config: {
auth: {
method: {

View File

@@ -30,7 +30,7 @@ import { useProject } from '@/features/orgs/projects/hooks/useProject';
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
export default function FacebookProviderSettings() {
const { project } = useProject();
const { project, loading: isProjectLoading } = useProject();
const { openDialog } = useDialog();
const isPlatform = useIsPlatform();
const { maintenanceActive } = useUI();
@@ -68,7 +68,7 @@ export default function FacebookProviderSettings() {
}
}, [loading, clientId, clientSecret, enabled, form]);
if (loading) {
if (loading || isProjectLoading) {
return (
<ActivityIndicator
delay={1000}
@@ -88,7 +88,7 @@ export default function FacebookProviderSettings() {
async function handleSubmit(formValues: BaseProviderSettingsFormValues) {
const updateConfigPromise = updateConfig({
variables: {
appId: project.id,
appId: project!.id,
config: {
auth: {
method: {
@@ -161,8 +161,8 @@ export default function FacebookProviderSettings() {
hideEmptyHelperText
label="Redirect URL"
defaultValue={`${generateAppServiceUrl(
project.subdomain,
project.region,
project!.subdomain,
project!.region,
'auth',
)}/signin/provider/facebook/callback`}
disabled
@@ -176,8 +176,8 @@ export default function FacebookProviderSettings() {
e.stopPropagation();
copy(
`${generateAppServiceUrl(
project.subdomain,
project.region,
project!.subdomain,
project!.region,
'auth',
)}/signin/provider/facebook/callback`,
'Redirect URL',

Some files were not shown because too many files have changed in this diff Show More