first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View File

@@ -0,0 +1,100 @@
import {Trans} from '@common/i18n/trans';
import {Dialog} from '@common/ui/overlays/dialog/dialog';
import {DialogHeader} from '@common/ui/overlays/dialog/dialog-header';
import {DialogBody} from '@common/ui/overlays/dialog/dialog-body';
import {DialogFooter} from '@common/ui/overlays/dialog/dialog-footer';
import {Button} from '@common/ui/buttons/button';
import {useDialogContext} from '@common/ui/overlays/dialog/dialog-context';
import {KeyboardArrowRightIcon} from '@common/icons/material/KeyboardArrowRight';
import {InfoStep} from '@common/custom-domains/datatable/connect-domain-dialog/info-step';
import {HostStep} from '@common/custom-domains/datatable/connect-domain-dialog/host-step';
import {ConnectDomainStep} from '@common/custom-domains/datatable/connect-domain-dialog/connect-domain-step';
import {BackendErrorResponse} from '@common/errors/backend-error-response';
import {ValidationFailedStep} from '@common/custom-domains/datatable/connect-domain-dialog/validation-failed-step';
import {useConnectDomainStepper} from '@common/custom-domains/datatable/connect-domain-dialog/use-connect-domain-stepper';
import {Form} from '@common/ui/forms/form';
import {FinalizeStep} from '@common/custom-domains/datatable/connect-domain-dialog/finalize-step';
import {KeyboardArrowLeftIcon} from '@common/icons/material/KeyboardArrowLeft';
export interface DomainValidationErrorResponse extends BackendErrorResponse {
failReason: 'serverNotConfigured' | 'dnsNotSetup';
}
interface ConnectDomainDialogProps {
showGlobalField?: boolean;
}
export function ConnectDomainDialog({
showGlobalField,
}: ConnectDomainDialogProps) {
const {close, formId} = useDialogContext();
const stepper = useConnectDomainStepper({showGlobalField});
const StepComponent = getStepComponent(stepper.state.currentStep);
return (
<Dialog>
<DialogHeader>
<Trans message="Connect domain" />
</DialogHeader>
<DialogBody>
<Form
form={stepper.form}
id={formId}
onSubmit={() => {
stepper.goToNextStep();
}}
>
<StepComponent stepper={stepper} />
</Form>
</DialogBody>
<DialogFooter
startAction={
<Button
variant="text"
onClick={() => {
close();
}}
>
<Trans message="Cancel" />
</Button>
}
>
{stepper.hasPreviousStep && (
<Button
startIcon={<KeyboardArrowLeftIcon />}
color="primary"
variant="text"
onClick={() => {
stepper.goToPreviousStep();
}}
disabled={stepper.state.isLoading}
>
<Trans message="Previous" />
</Button>
)}
<Button
variant="flat"
color="primary"
type="submit"
form={formId}
endIcon={<KeyboardArrowRightIcon />}
disabled={stepper.state.isLoading}
>
<Trans message="Next" />
</Button>
</DialogFooter>
</Dialog>
);
}
function getStepComponent(step: ConnectDomainStep) {
switch (step) {
case ConnectDomainStep.Host:
return HostStep;
case ConnectDomainStep.Info:
return InfoStep;
case ConnectDomainStep.ValidationFailed:
return ValidationFailedStep;
case ConnectDomainStep.Finalize:
return FinalizeStep;
}
}

View File

@@ -0,0 +1,12 @@
import {useConnectDomainStepper} from '@common/custom-domains/datatable/connect-domain-dialog/use-connect-domain-stepper';
export enum ConnectDomainStep {
Host = 1,
Info = 2,
ValidationFailed = 3,
Finalize = 4,
}
export interface ConnectDomainStepProps {
stepper: ReturnType<typeof useConnectDomainStepper>;
}

View File

@@ -0,0 +1,17 @@
import {ProgressCircle} from '@common/ui/progress/progress-circle';
import {Trans} from '@common/i18n/trans';
import {ReactNode} from 'react';
interface DomainProgressIndicatorProps {
message?: ReactNode;
}
export function DomainProgressIndicator({
message = <Trans message="Checking DNS configuration..." />,
}: DomainProgressIndicatorProps) {
return (
<div className="flex items-center gap-18 text-base p-12 rounded bg-primary/10 text-primary">
<ProgressCircle isIndeterminate size="sm" />
<div>{message}</div>
</div>
);
}

View File

@@ -0,0 +1,15 @@
import {Trans} from '@common/i18n/trans';
import {DomainProgressIndicator} from '@common/custom-domains/datatable/connect-domain-dialog/domain-progress-indicator';
export function FinalizeStep() {
return (
<div>
<DomainProgressIndicator
message={<Trans message="Connecting domain..." />}
/>
<div className="text-muted mt-10 text-xs">
<Trans message="Don't close this window until domain is connected." />
</div>
</div>
);
}

View File

@@ -0,0 +1,34 @@
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
import {Trans} from '@common/i18n/trans';
import {Fragment} from 'react';
import {FormSwitch} from '@common/ui/forms/toggle/switch';
import {ConnectDomainStepProps} from '@common/custom-domains/datatable/connect-domain-dialog/connect-domain-step';
export function HostStep({stepper}: ConnectDomainStepProps) {
return (
<Fragment>
<FormTextField
autoFocus
name="host"
required
maxLength={100}
label={<Trans message="Host" />}
placeholder="https://example.com"
description={
<Trans message="Enter the exact domain name you want your items to be accessible with. It can be a subdomain (example.yourdomain.com) or root domain (yourdomain.com)." />
}
/>
{stepper.showGlobalField && (
<FormSwitch
className="mt-24 border-t pt-24"
name="global"
description={
<Trans message="Whether all users should be able to select this domain." />
}
>
<Trans message="Global" />
</FormSwitch>
)}
</Fragment>
);
}

View File

@@ -0,0 +1,58 @@
import {useSettings} from '@common/core/settings/use-settings';
import {Trans} from '@common/i18n/trans';
import {ReactNode} from 'react';
import {ConnectDomainStepProps} from '@common/custom-domains/datatable/connect-domain-dialog/connect-domain-step';
import {isSubdomain} from '@common/custom-domains/datatable/connect-domain-dialog/is-subdomain';
import {DomainProgressIndicator} from '@common/custom-domains/datatable/connect-domain-dialog/domain-progress-indicator';
export function InfoStep({
stepper: {
state: {isLoading, host, serverIp},
},
}: ConnectDomainStepProps) {
const {base_url} = useSettings();
if (isLoading) {
return <DomainProgressIndicator />;
}
if (isSubdomain(host)) {
return (
<Message
title={
<Trans message="Add this CNAME record to your domain by visiting your DNS provider or registrar." />
}
record="CNAME"
target={base_url}
/>
);
}
return (
<Message
title={
<Trans message="Add this A record to your domain by visiting your DNS provider or registrar." />
}
record="A"
target={serverIp}
/>
);
}
interface MessageProps {
title: ReactNode;
record: string;
target: string;
}
function Message({title, record, target}: MessageProps) {
return (
<div>
<div className="text-muted mb-10">{title}</div>
<div className="flex items-center gap-12 text-base p-12 rounded bg-primary/10 text-primary font-semibold">
<div>{record}</div>
{target}
</div>
</div>
);
}

View File

@@ -0,0 +1,3 @@
export function isSubdomain(host: string): boolean {
return (host.replace('www.', '').match(/\./g) || []).length > 1;
}

View File

@@ -0,0 +1,163 @@
import {useState} from 'react';
import {ConnectDomainStep} from '@common/custom-domains/datatable/connect-domain-dialog/connect-domain-step';
import {DomainValidationErrorResponse} from '@common/custom-domains/datatable/connect-domain-dialog/connect-domain-dialog';
import {
AuthorizeDomainConnectPayload,
useAuthorizeDomainConnect,
} from '@common/custom-domains/datatable/requests/use-authorize-domain-connect';
import {useForm} from 'react-hook-form';
import {AxiosError} from 'axios';
import {useValidateDomainDns} from '@common/custom-domains/datatable/requests/use-validate-domain-dns';
import {useConnectDomain} from '@common/custom-domains/datatable/requests/use-connect-domain';
import {useDialogContext} from '@common/ui/overlays/dialog/dialog-context';
interface ConnectDomainStepperState {
isLoading: boolean;
currentStep: ConnectDomainStep;
host: string;
serverIp: string;
validationFailReason?: DomainValidationErrorResponse['failReason'];
}
interface StepHandlerResult {
status: 'success' | 'error';
newState?: Partial<ConnectDomainStepperState>;
}
interface UseConnectDomainStepperProps {
showGlobalField?: boolean;
}
export function useConnectDomainStepper({
showGlobalField,
}: UseConnectDomainStepperProps) {
const {close} = useDialogContext();
const form = useForm<AuthorizeDomainConnectPayload>();
const authorizeDomainConnect = useAuthorizeDomainConnect(form);
const validateDns = useValidateDomainDns();
const connectDomain = useConnectDomain();
const [state, setState] = useState<ConnectDomainStepperState>({
isLoading: false,
currentStep: ConnectDomainStep.Host,
host: '',
serverIp: '',
});
const startLoading = () => {
setState({
...state,
isLoading: true,
});
};
const handleDomainValidation = (): Promise<StepHandlerResult> => {
return new Promise(resolve => {
validateDns.mutate(
{host: state.host},
{
onSuccess: () => {
resolve({
status: 'success',
newState: {validationFailReason: undefined},
});
},
onError: err => {
resolve({
status: 'error',
newState: {
validationFailReason: (
err as AxiosError<DomainValidationErrorResponse>
).response?.data.failReason,
},
});
},
},
);
});
};
const handleDomainAuthorization = (): Promise<StepHandlerResult> => {
return new Promise(resolve => {
authorizeDomainConnect.mutate(form.getValues(), {
onSuccess: response => {
resolve({
status: 'success',
newState: {
host: form.getValues().host,
serverIp: response.serverIp,
},
});
},
onError: () => {
resolve({status: 'error'});
},
});
});
};
const hasPreviousStep = state.currentStep !== ConnectDomainStep.Host;
const goToPreviousStep = () => {
if (!hasPreviousStep || state.isLoading) return;
if (state.currentStep === ConnectDomainStep.Info) {
setState({
...state,
currentStep: ConnectDomainStep.Host,
});
} else if (state.currentStep === ConnectDomainStep.ValidationFailed) {
setState({
...state,
currentStep: ConnectDomainStep.Info,
});
}
};
const goToNextStep = async () => {
if (state.currentStep === ConnectDomainStep.Host) {
startLoading();
const result = await handleDomainAuthorization();
setState({
...state,
...result.newState,
isLoading: false,
currentStep:
result.status === 'success'
? ConnectDomainStep.Info
: ConnectDomainStep.Host,
});
} else if (
state.currentStep === ConnectDomainStep.Info ||
state.currentStep === ConnectDomainStep.ValidationFailed
) {
startLoading();
const validationResult = await handleDomainValidation();
const nextStep =
validationResult.status === 'success'
? ConnectDomainStep.Finalize
: ConnectDomainStep.ValidationFailed;
setState({
...state,
...validationResult.newState,
isLoading: false,
currentStep: nextStep,
});
if (nextStep === ConnectDomainStep.Finalize) {
connectDomain.mutate(form.getValues(), {
onSettled: response => {
close(response?.domain);
},
});
}
}
};
return {
form,
state,
goToNextStep,
hasPreviousStep,
goToPreviousStep,
showGlobalField,
};
}

View File

@@ -0,0 +1,81 @@
import {useSettings} from '@common/core/settings/use-settings';
import {Trans} from '@common/i18n/trans';
import {Fragment, ReactNode} from 'react';
import {ConnectDomainStepProps} from '@common/custom-domains/datatable/connect-domain-dialog/connect-domain-step';
import {useAuth} from '@common/auth/use-auth';
import {isSubdomain} from '@common/custom-domains/datatable/connect-domain-dialog/is-subdomain';
import {WarningIcon} from '@common/icons/material/Warning';
import {useValidateDomainDns} from '@common/custom-domains/datatable/requests/use-validate-domain-dns';
import {DomainProgressIndicator} from '@common/custom-domains/datatable/connect-domain-dialog/domain-progress-indicator';
export function ValidationFailedStep({
stepper: {
goToNextStep,
state: {host, serverIp, isLoading, validationFailReason},
},
}: ConnectDomainStepProps) {
const validateDns = useValidateDomainDns();
const {base_url} = useSettings();
const {hasPermission} = useAuth();
const subdomain = isSubdomain(host);
const record = subdomain ? 'CNAME' : 'A';
const location = subdomain ? base_url : serverIp;
if (isLoading) {
return <DomainProgressIndicator />;
}
const errorMessage =
validationFailReason === 'serverNotConfigured' && hasPermission('admin') ? (
<ErrorMessage>
<Trans
message="DNS records for the domain are setup, however it seems that your server is not configured to handle requests from “:host“"
values={{host: location}}
/>
</ErrorMessage>
) : (
<ErrorMessage>
<Trans
message="The domain is missing :record record pointing to :location or the changes haven't propagated yet."
values={{record, location}}
/>
</ErrorMessage>
);
return (
<Fragment>
{errorMessage}
<div className="whitespace-nowrap text-xs text-muted mt-10">
<Trans
message="You can wait and try again later, or <b>refresh</b>"
values={{
b: (text: string) => (
<button
disabled={isLoading}
type="button"
className="text-primary underline"
onClick={() => {
goToNextStep();
}}
>
{text}
</button>
),
}}
/>
</div>
</Fragment>
);
}
interface ErrorMessageProps {
children: ReactNode;
}
function ErrorMessage({children}: ErrorMessageProps) {
return (
<div className="flex items-center gap-12 text-base p-12 rounded bg-warning/15 text-warning font-medium">
<WarningIcon size="lg" />
{children}
</div>
);
}

View File

@@ -0,0 +1,45 @@
import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
import {Button} from '@common/ui/buttons/button';
import {Trans} from '@common/i18n/trans';
import {ConfirmationDialog} from '@common/ui/overlays/dialog/confirmation-dialog';
import React from 'react';
import {useDeleteDomain} from '@common/custom-domains/datatable/requests/use-delete-domain';
import {CustomDomain} from '@common/custom-domains/custom-domain';
interface DeleteDomainButtonProps {
domain: CustomDomain;
}
export function DeleteDomainButton({domain}: DeleteDomainButtonProps) {
const deleteDomain = useDeleteDomain();
return (
<DialogTrigger
type="modal"
onClose={isConfirmed => {
if (isConfirmed) {
deleteDomain.mutate({domain});
}
}}
>
<Button
variant="outline"
color="danger"
size="xs"
disabled={deleteDomain.isPending}
>
<Trans message="Remove" />
</Button>
<ConfirmationDialog
title={<Trans message="Remove domain?" />}
body={
<Trans
message="Are you sure you want to remove “:domain“?"
values={{domain: domain.host}}
/>
}
confirm={<Trans message="Remove" />}
isDanger
/>
</DialogTrigger>
);
}

View File

@@ -0,0 +1,73 @@
import {CustomDomain} from '@common/custom-domains/custom-domain';
import {ColumnConfig} from '@common/datatable/column-config';
import {FormattedDate} from '@common/i18n/formatted-date';
import {Trans} from '@common/i18n/trans';
import {RemoteFavicon} from '@common/ui/remote-favicon';
import React from 'react';
import {NameWithAvatar} from '@common/datatable/column-templates/name-with-avatar';
import {BooleanIndicator} from '@common/datatable/column-templates/boolean-indicator';
import {DeleteDomainButton} from '@common/custom-domains/datatable/delete-domain-button';
export const domainsDatatableColumns: ColumnConfig<CustomDomain>[] = [
{
key: 'host',
allowsSorting: true,
header: () => <Trans message="Domain" />,
width: 'flex-3 min-w-200',
visibleInMode: 'all',
body: domain => (
<div>
<div className="flex items-center gap-6 whitespace-nowrap">
<RemoteFavicon url={domain.host} />
<a
className="block font-semibold hover:underline overflow-ellipsis overflow-hidden w-min"
href={domain.host}
target="_blank"
rel="noreferrer"
data-testid="host-name"
>
{domain.host}
</a>
</div>
</div>
),
},
{
key: 'user_id',
allowsSorting: true,
header: () => <Trans message="Owner" />,
width: 'flex-2 min-w-140',
body: domain => {
if (!domain.user) return '';
return (
<NameWithAvatar
image={domain.user.avatar}
label={domain.user.display_name}
description={domain.user.email}
/>
);
},
},
{
key: 'global',
allowsSorting: true,
header: () => <Trans message="Global" />,
body: domain => <BooleanIndicator value={domain.global} />,
},
{
key: 'updated_at',
allowsSorting: true,
header: () => <Trans message="Last updated" />,
body: domain =>
domain.updated_at ? <FormattedDate date={domain.updated_at} /> : '',
},
{
key: 'actions',
header: () => <Trans message="Actions" />,
hideHeader: true,
width: 'w-80 flex-shrink-0',
visibleInMode: 'all',
align: 'end',
body: domain => <DeleteDomainButton domain={domain} />,
},
];

View File

@@ -0,0 +1,40 @@
import {
BackendFilter,
FilterControlType,
FilterOperator,
} from '@common/datatable/filters/backend-filter';
import {message} from '@common/i18n/message';
import {USER_MODEL} from '@common/auth/user';
import {
createdAtFilter,
updatedAtFilter,
} from '@common/datatable/filters/timestamp-filters';
export const DomainsDatatableFilters: BackendFilter[] = [
{
key: 'global',
label: message('Global'),
description: message('Whether domain is marked as global'),
defaultOperator: FilterOperator.eq,
control: {
type: FilterControlType.BooleanToggle,
defaultValue: true,
},
},
createdAtFilter({
description: message('Date domain was created'),
}),
updatedAtFilter({
description: message('Date domain was last updated'),
}),
{
key: 'user_id',
label: message('Owner'),
description: message('User domain belongs to'),
defaultOperator: FilterOperator.eq,
control: {
type: FilterControlType.SelectModel,
model: USER_MODEL,
},
},
];

View File

@@ -0,0 +1,20 @@
import world from '@common/custom-domains/datatable/world.svg';
import {Trans} from '@common/i18n/trans';
import {
DataTableEmptyStateMessage,
DataTableEmptyStateMessageProps,
} from '@common/datatable/page/data-table-emty-state-message';
import React from 'react';
export function DomainsEmptyStateMessage(
props: Partial<DataTableEmptyStateMessageProps>
) {
return (
<DataTableEmptyStateMessage
{...props}
image={world}
title={<Trans message="No domains have been connected yet" />}
filteringTitle={<Trans message="No matching domains" />}
/>
);
}

View File

@@ -0,0 +1,29 @@
import {useMutation} from '@tanstack/react-query';
import {apiClient} from '@common/http/query-client';
import {BackendResponse} from '@common/http/backend-response/backend-response';
import {UseFormReturn} from 'react-hook-form';
import {onFormQueryError} from '@common/errors/on-form-query-error';
interface Response extends BackendResponse {
serverIp: string;
}
export interface AuthorizeDomainConnectPayload {
host: string;
}
// check if is this host is not connected already and if user has permissions to connect domains
export function useAuthorizeDomainConnect(
form: UseFormReturn<AuthorizeDomainConnectPayload>,
) {
return useMutation({
mutationFn: (props: AuthorizeDomainConnectPayload) => authorize(props),
onError: err => onFormQueryError(err, form),
});
}
function authorize(payload: AuthorizeDomainConnectPayload): Promise<Response> {
return apiClient
.post('secure/custom-domain/authorize/store', payload)
.then(r => r.data);
}

View File

@@ -0,0 +1,43 @@
import {useMutation, useQueryClient} from '@tanstack/react-query';
import {apiClient} from '@common/http/query-client';
import {useTrans} from '@common/i18n/use-trans';
import {BackendResponse} from '@common/http/backend-response/backend-response';
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
import {message} from '@common/i18n/message';
import {toast} from '@common/ui/toast/toast';
import {DatatableDataQueryKey} from '@common/datatable/requests/paginated-resources';
import {CustomDomain} from '@common/custom-domains/custom-domain';
interface Response extends BackendResponse {
domain: CustomDomain;
}
interface Payload {
host: string;
global?: boolean;
}
export function useConnectDomain() {
const {trans} = useTrans();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (props: Payload) => connectDomain(props),
onSuccess: response => {
toast.positive(
trans(
message('“:domain” connected', {
values: {domain: response.domain.host},
}),
),
);
queryClient.invalidateQueries({
queryKey: DatatableDataQueryKey('custom-domain'),
});
},
onError: err => showHttpErrorToast(err),
});
}
function connectDomain(payload: Payload): Promise<Response> {
return apiClient.post('custom-domain', payload).then(r => r.data);
}

View File

@@ -0,0 +1,40 @@
import {useMutation} from '@tanstack/react-query';
import {apiClient, queryClient} from '@common/http/query-client';
import {useTrans} from '@common/i18n/use-trans';
import {BackendResponse} from '@common/http/backend-response/backend-response';
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
import {message} from '@common/i18n/message';
import {toast} from '@common/ui/toast/toast';
import {DatatableDataQueryKey} from '@common/datatable/requests/paginated-resources';
import {CustomDomain} from '@common/custom-domains/custom-domain';
import {removeProtocol} from '@common/utils/urls/remove-protocol';
interface Response extends BackendResponse {}
interface Payload {
domain: CustomDomain;
}
export function useDeleteDomain() {
const {trans} = useTrans();
return useMutation({
mutationFn: (props: Payload) => deleteDomain(props),
onSuccess: (response, props) => {
toast.positive(
trans(
message('“:domain” removed', {
values: {domain: removeProtocol(props.domain.host)},
}),
),
);
queryClient.invalidateQueries({
queryKey: DatatableDataQueryKey('custom-domain'),
});
},
onError: err => showHttpErrorToast(err),
});
}
function deleteDomain({domain}: Payload): Promise<Response> {
return apiClient.delete(`custom-domain/${domain.id}`).then(r => r.data);
}

View File

@@ -0,0 +1,36 @@
import {useMutation} from '@tanstack/react-query';
import {apiClient, queryClient} from '@common/http/query-client';
import {BackendResponse} from '@common/http/backend-response/backend-response';
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
import {DatatableDataQueryKey} from '@common/datatable/requests/paginated-resources';
import {CustomDomain} from '@common/custom-domains/custom-domain';
interface Response extends BackendResponse {
domain: CustomDomain;
}
interface Payload {
domainId: number | string;
host?: string;
global?: boolean;
resource_id?: number | null;
resource_type?: string | null;
}
export function useUpdateDomain() {
return useMutation({
mutationFn: (props: Payload) => updateDomain(props),
onSuccess: (response, props) => {
return queryClient.invalidateQueries({
queryKey: DatatableDataQueryKey('custom-domain'),
});
},
onError: err => showHttpErrorToast(err),
});
}
function updateDomain(payload: Payload): Promise<Response> {
return apiClient
.put(`custom-domain/${payload.domainId}`, payload)
.then(r => r.data);
}

View File

@@ -0,0 +1,23 @@
import {useMutation} from '@tanstack/react-query';
import {apiClient} from '@common/http/query-client';
import {BackendResponse} from '@common/http/backend-response/backend-response';
interface Response extends BackendResponse {
result: 'connected' | null;
}
interface Payload {
host: string;
}
export function useValidateDomainDns() {
return useMutation({
mutationFn: (props: Payload) => authorize(props),
});
}
function authorize(payload: Payload): Promise<Response> {
return apiClient
.post('secure/custom-domain/validate/2BrM45vvfS/api', payload)
.then(r => r.data);
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 25 KiB