feat: add board access settings (#249)

* wip: add board access settings

* wip: add user access control

* wip: add user access control

* feat: add user access control

* refactor: move away from mantine-modal-manager

* fix: ci issues and failing tests

* fix: lint issue

* fix: format issue

* fix: deepsource issues

* chore: address pull request feedback
This commit is contained in:
Meier Lukas
2024-03-20 20:30:58 +01:00
committed by GitHub
parent 4753bc7162
commit 361700b239
59 changed files with 1763 additions and 1338 deletions

View File

@@ -0,0 +1,92 @@
import { useCallback } from "react";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
import type {
stringOrTranslation,
TranslationFunction,
} from "@homarr/translation";
import { translateIfNecessary } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { ButtonProps, GroupProps } from "@homarr/ui";
import { Box, Button, Group } from "@homarr/ui";
import { createModal } from "./creator";
type MaybePromise<T> = T | Promise<T>;
export interface ConfirmModalProps {
title: string;
children: ReactNode;
onConfirm?: () => MaybePromise<void>;
onCancel?: () => MaybePromise<void>;
closeOnConfirm?: boolean;
closeOnCancel?: boolean;
cancelProps?: ButtonProps & ComponentPropsWithoutRef<"button">;
confirmProps?: ButtonProps & ComponentPropsWithoutRef<"button">;
groupProps?: GroupProps;
labels?: {
confirm?: stringOrTranslation;
cancel?: stringOrTranslation;
};
}
export const ConfirmModal = createModal<Omit<ConfirmModalProps, "title">>(
({ actions, innerProps }) => {
const t = useI18n();
const {
children,
onConfirm,
onCancel,
cancelProps,
confirmProps,
groupProps,
labels,
} = innerProps;
const closeOnConfirm = innerProps.closeOnConfirm ?? true;
const closeOnCancel = innerProps.closeOnCancel ?? true;
const cancelLabel =
labels?.cancel ?? ((t: TranslationFunction) => t("common.action.cancel"));
const confirmLabel =
labels?.confirm ??
((t: TranslationFunction) => t("common.action.confirm"));
const handleCancel = useCallback(
async (event: React.MouseEvent<HTMLButtonElement>) => {
typeof cancelProps?.onClick === "function" &&
cancelProps?.onClick(event);
typeof onCancel === "function" && (await onCancel());
closeOnCancel && actions.closeModal();
},
[cancelProps?.onClick, onCancel, actions.closeModal],
);
const handleConfirm = useCallback(
async (event: React.MouseEvent<HTMLButtonElement>) => {
typeof confirmProps?.onClick === "function" &&
confirmProps?.onClick(event);
typeof onConfirm === "function" && (await onConfirm());
closeOnConfirm && actions.closeModal();
},
[confirmProps?.onClick, onConfirm, actions.closeModal],
);
return (
<>
{children && <Box mb="md">{children}</Box>}
<Group justify="flex-end" {...groupProps}>
<Button variant="default" {...cancelProps} onClick={handleCancel}>
{cancelProps?.children || translateIfNecessary(t, cancelLabel)}
</Button>
<Button {...confirmProps} onClick={handleConfirm} color="red.9">
{confirmProps?.children || translateIfNecessary(t, confirmLabel)}
</Button>
</Group>
</>
);
},
).withOptions({});

View File

@@ -0,0 +1,14 @@
import type { CreateModalOptions, ModalComponent } from "./type";
export const createModal = <TInnerProps>(
component: ModalComponent<TInnerProps>,
) => {
return {
withOptions: (options: Partial<CreateModalOptions>) => {
return {
component,
options,
};
},
};
};

View File

@@ -0,0 +1,141 @@
"use client";
import type { PropsWithChildren } from "react";
import {
createContext,
useCallback,
useContext,
useReducer,
useRef,
} from "react";
import { randomId } from "@mantine/hooks";
import type { stringOrTranslation } from "@homarr/translation";
import { translateIfNecessary } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import { getDefaultZIndex, Modal } from "@homarr/ui";
import type { ConfirmModalProps } from "./confirm-modal";
import { ConfirmModal } from "./confirm-modal";
import { modalReducer } from "./reducer";
import type { inferInnerProps, ModalDefinition } from "./type";
interface ModalContextProps {
openModalInner: <TModal extends ModalDefinition>(props: {
modal: TModal;
innerProps: inferInnerProps<TModal>;
options: OpenModalOptions;
}) => void;
closeModal: (id: string) => void;
}
export const ModalContext = createContext<ModalContextProps | null>(null);
export const ModalProvider = ({ children }: PropsWithChildren) => {
const t = useI18n();
const [state, dispatch] = useReducer(modalReducer, {
modals: [],
current: null,
});
const stateRef = useRef(state);
stateRef.current = state;
const closeModal = useCallback(
(id: string, canceled?: boolean) => {
dispatch({ type: "CLOSE", modalId: id, canceled });
},
[stateRef, dispatch],
);
const openModalInner: ModalContextProps["openModalInner"] = useCallback(
({ modal, innerProps, options }) => {
const id = randomId();
const { title, ...rest } = options;
dispatch({
type: "OPEN",
modal: {
id,
modal,
props: {
...modal.options,
...rest,
defaultTitle: title ?? modal.options.defaultTitle,
innerProps,
},
},
});
return id;
},
[dispatch],
);
const handleCloseModal = useCallback(
() => state.current && closeModal(state.current.id),
[closeModal, state.current?.id],
);
const activeModals = state.modals.filter(
(modal) => modal.id === state.current?.id || modal.props.keepMounted,
);
return (
<ModalContext.Provider value={{ openModalInner, closeModal }}>
{activeModals.map((modal) => (
<Modal
key={modal.id}
zIndex={getDefaultZIndex("modal") + 1}
display={modal.id === state.current?.id ? undefined : "none"}
style={{
userSelect: modal.id === state.current?.id ? undefined : "none",
}}
styles={{
title: {
fontSize: "1.25rem",
fontWeight: 500,
},
}}
trapFocus={modal.id === state.current?.id}
{...modal.reference.modalProps}
title={translateIfNecessary(t, modal.props.defaultTitle)}
opened={state.modals.length > 0}
onClose={handleCloseModal}
>
{modal.reference.content}
</Modal>
))}
{children}
</ModalContext.Provider>
);
};
interface OpenModalOptions {
keepMounted?: boolean;
title?: stringOrTranslation;
}
export const useModalAction = <TModal extends ModalDefinition>(
modal: TModal,
) => {
const context = useContext(ModalContext);
if (!context) throw new Error("ModalContext is not provided");
return {
openModal: (
innerProps: inferInnerProps<TModal>,
options: OpenModalOptions | void,
) => {
context.openModalInner({ modal, innerProps, options: options ?? {} });
},
};
};
export const useConfirmModal = () => {
const { openModal } = useModalAction(ConfirmModal);
return {
openConfirmModal: (props: ConfirmModalProps) =>
openModal(props, { title: props.title }),
};
};

View File

@@ -0,0 +1,125 @@
"use client";
import { useContext } from "react";
import { ModalContext } from ".";
import type { ModalDefinition, ModalState } from "./type";
type ModalStateWithReference = ModalState & {
/**
* Reference to modal component instance
* Used so the modal can be persisted between navigating in newer modals
*/
reference: ReturnType<typeof getModal>;
};
interface ModalsState {
modals: ModalStateWithReference[];
/**
* Modal that is currently open or was the last open one.
* Keeping the last one is necessary for providing a clean exit transition.
*/
current: ModalStateWithReference | null;
}
interface OpenAction {
type: "OPEN";
modal: ModalState;
}
interface CloseAction {
type: "CLOSE";
modalId: string;
canceled?: boolean;
}
interface CloseAllAction {
type: "CLOSE_ALL";
canceled?: boolean;
}
export const modalReducer = (
state: ModalsState,
action: OpenAction | CloseAction | CloseAllAction,
): ModalsState => {
switch (action.type) {
case "OPEN": {
const newModal = {
...action.modal,
reference: getModal(action.modal),
};
return {
current: newModal,
modals: [...state.modals, newModal],
};
}
case "CLOSE": {
const modal = state.modals.find((modal) => modal.id === action.modalId);
if (!modal) {
return state;
}
modal.props.onClose?.();
const remainingModals = state.modals.filter(
(modal) => modal.id !== action.modalId,
);
return {
current: remainingModals[remainingModals.length - 1] || state.current,
modals: remainingModals,
};
}
case "CLOSE_ALL": {
if (!state.modals.length) {
return state;
}
// Resolve modal stack from top to bottom
state.modals
.concat()
.reverse()
.forEach((modal) => {
modal.props.onClose?.();
});
return {
current: state.current,
modals: [],
};
}
default: {
return state;
}
}
};
const getModal = <TModal extends ModalDefinition>(
modal: ModalState<TModal>,
) => {
const ModalContent = modal.modal.component;
const { innerProps, ...rest } = modal.props;
const FullModal = () => {
const context = useContext(ModalContext);
if (!context) {
throw new Error("Modal component used outside of modal context");
}
return (
<ModalContent
innerProps={innerProps}
actions={{
closeModal: () => context.closeModal(modal.id),
}}
/>
);
};
return {
modalProps: rest,
content: <FullModal />,
};
};

View File

@@ -0,0 +1,43 @@
import type { ReactNode } from "react";
import type { stringOrTranslation } from "@homarr/translation";
import type { ModalProps } from "@homarr/ui";
export type ModalComponent<TInnerProps> = (props: {
actions: { closeModal: () => void };
innerProps: TInnerProps;
}) => ReactNode;
export type CreateModalOptions = Pick<
ModalOptions<unknown>,
| "size"
| "fullScreen"
| "centered"
| "keepMounted"
| "withCloseButton"
| "zIndex"
| "scrollAreaComponent"
| "yOffset"
> & {
defaultTitle: stringOrTranslation;
};
export interface ModalDefinition {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
component: ModalComponent<any>;
options: Partial<CreateModalOptions>;
}
type ModalOptions<TInnerProps> = Partial<Omit<ModalProps, "opened">> & {
innerProps: TInnerProps;
defaultTitle?: stringOrTranslation;
};
export interface ModalState<TModal extends ModalDefinition = ModalDefinition> {
id: string;
modal: TModal;
props: ModalOptions<inferInnerProps<TModal>>;
}
export type inferInnerProps<TModal extends ModalDefinition> =
TModal["component"] extends ModalComponent<infer P> ? P : never;