feat(dns): add db-backed DNS provider, domain, and record management with ops UI support
This commit is contained in:
@@ -117,7 +117,7 @@ export const configStatePart = await appState.getStatePart<IConfigState>(
|
||||
// Determine initial view from URL path
|
||||
const getInitialView = (): string => {
|
||||
const path = typeof window !== 'undefined' ? window.location.pathname : '/';
|
||||
const validViews = ['overview', 'network', 'email', 'logs', 'access', 'security', 'certificates'];
|
||||
const validViews = ['overview', 'network', 'email', 'logs', 'access', 'security', 'domains'];
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
const view = segments[0];
|
||||
return validViews.includes(view) ? view : 'overview';
|
||||
@@ -465,8 +465,9 @@ export const setActiveViewAction = uiStatePart.createAction<string>(async (state
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// If switching to certificates view, ensure we fetch certificate data
|
||||
if (viewName === 'certificates' && currentState.activeView !== 'certificates') {
|
||||
// If switching to the Domains group, ensure we fetch certificate data
|
||||
// (Certificates is a subview of Domains).
|
||||
if (viewName === 'domains' && currentState.activeView !== 'domains') {
|
||||
setTimeout(() => {
|
||||
certificateStatePart.dispatchAction(fetchCertificateOverviewAction, null);
|
||||
}, 100);
|
||||
@@ -1555,6 +1556,403 @@ export const deleteTargetAction = profilesTargetsStatePart.createAction<{
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Domains State (DNS providers + domains + records)
|
||||
// ============================================================================
|
||||
|
||||
export interface IDomainsState {
|
||||
providers: interfaces.data.IDnsProviderPublic[];
|
||||
domains: interfaces.data.IDomain[];
|
||||
records: interfaces.data.IDnsRecord[];
|
||||
/** id of the currently-selected domain in the DNS records subview. */
|
||||
selectedDomainId: string | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
lastUpdated: number;
|
||||
}
|
||||
|
||||
export const domainsStatePart = await appState.getStatePart<IDomainsState>(
|
||||
'domains',
|
||||
{
|
||||
providers: [],
|
||||
domains: [],
|
||||
records: [],
|
||||
selectedDomainId: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: 0,
|
||||
},
|
||||
'soft',
|
||||
);
|
||||
|
||||
export const fetchDomainsAndProvidersAction = domainsStatePart.createAction(
|
||||
async (statePartArg): Promise<IDomainsState> => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState()!;
|
||||
if (!context.identity) return currentState;
|
||||
|
||||
try {
|
||||
const providersRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetDnsProviders
|
||||
>('/typedrequest', 'getDnsProviders');
|
||||
const domainsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetDomains
|
||||
>('/typedrequest', 'getDomains');
|
||||
|
||||
const [providersResponse, domainsResponse] = await Promise.all([
|
||||
providersRequest.fire({ identity: context.identity }),
|
||||
domainsRequest.fire({ identity: context.identity }),
|
||||
]);
|
||||
|
||||
return {
|
||||
...currentState,
|
||||
providers: providersResponse.providers,
|
||||
domains: domainsResponse.domains,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
...currentState,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch domains/providers',
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const fetchDnsRecordsForDomainAction = domainsStatePart.createAction<{ domainId: string }>(
|
||||
async (statePartArg, dataArg): Promise<IDomainsState> => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState()!;
|
||||
if (!context.identity) return currentState;
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetDnsRecords
|
||||
>('/typedrequest', 'getDnsRecords');
|
||||
const response = await request.fire({
|
||||
identity: context.identity,
|
||||
domainId: dataArg.domainId,
|
||||
});
|
||||
return {
|
||||
...currentState,
|
||||
records: response.records,
|
||||
selectedDomainId: dataArg.domainId,
|
||||
error: null,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
...currentState,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch DNS records',
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const createDnsProviderAction = domainsStatePart.createAction<{
|
||||
name: string;
|
||||
type: interfaces.data.TDnsProviderType;
|
||||
credentials: interfaces.data.TDnsProviderCredentials;
|
||||
}>(async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_CreateDnsProvider
|
||||
>('/typedrequest', 'createDnsProvider');
|
||||
const response = await request.fire({
|
||||
identity: context.identity!,
|
||||
name: dataArg.name,
|
||||
type: dataArg.type,
|
||||
credentials: dataArg.credentials,
|
||||
});
|
||||
if (!response.success) {
|
||||
return {
|
||||
...statePartArg.getState()!,
|
||||
error: response.message || 'Failed to create provider',
|
||||
};
|
||||
}
|
||||
return await actionContext!.dispatch(fetchDomainsAndProvidersAction, null);
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
...statePartArg.getState()!,
|
||||
error: error instanceof Error ? error.message : 'Failed to create provider',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const updateDnsProviderAction = domainsStatePart.createAction<{
|
||||
id: string;
|
||||
name?: string;
|
||||
credentials?: interfaces.data.TDnsProviderCredentials;
|
||||
}>(async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_UpdateDnsProvider
|
||||
>('/typedrequest', 'updateDnsProvider');
|
||||
const response = await request.fire({
|
||||
identity: context.identity!,
|
||||
id: dataArg.id,
|
||||
name: dataArg.name,
|
||||
credentials: dataArg.credentials,
|
||||
});
|
||||
if (!response.success) {
|
||||
return {
|
||||
...statePartArg.getState()!,
|
||||
error: response.message || 'Failed to update provider',
|
||||
};
|
||||
}
|
||||
return await actionContext!.dispatch(fetchDomainsAndProvidersAction, null);
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
...statePartArg.getState()!,
|
||||
error: error instanceof Error ? error.message : 'Failed to update provider',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const deleteDnsProviderAction = domainsStatePart.createAction<{ id: string; force?: boolean }>(
|
||||
async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_DeleteDnsProvider
|
||||
>('/typedrequest', 'deleteDnsProvider');
|
||||
const response = await request.fire({
|
||||
identity: context.identity!,
|
||||
id: dataArg.id,
|
||||
force: dataArg.force,
|
||||
});
|
||||
if (!response.success) {
|
||||
return {
|
||||
...statePartArg.getState()!,
|
||||
error: response.message || 'Failed to delete provider',
|
||||
};
|
||||
}
|
||||
return await actionContext!.dispatch(fetchDomainsAndProvidersAction, null);
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
...statePartArg.getState()!,
|
||||
error: error instanceof Error ? error.message : 'Failed to delete provider',
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const testDnsProviderAction = domainsStatePart.createAction<{ id: string }>(
|
||||
async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_TestDnsProvider
|
||||
>('/typedrequest', 'testDnsProvider');
|
||||
await request.fire({ identity: context.identity!, id: dataArg.id });
|
||||
return await actionContext!.dispatch(fetchDomainsAndProvidersAction, null);
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
...statePartArg.getState()!,
|
||||
error: error instanceof Error ? error.message : 'Failed to test provider',
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/** One-shot fetch for the import-domain modal. Does NOT modify state. */
|
||||
export async function fetchProviderDomains(
|
||||
providerId: string,
|
||||
): Promise<{ success: boolean; domains?: interfaces.data.IProviderDomainListing[]; message?: string }> {
|
||||
const context = getActionContext();
|
||||
if (!context.identity) return { success: false, message: 'Not authenticated' };
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_ListProviderDomains
|
||||
>('/typedrequest', 'listProviderDomains');
|
||||
return await request.fire({ identity: context.identity, providerId });
|
||||
}
|
||||
|
||||
export const createManualDomainAction = domainsStatePart.createAction<{
|
||||
name: string;
|
||||
description?: string;
|
||||
}>(async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_CreateDomain
|
||||
>('/typedrequest', 'createDomain');
|
||||
const response = await request.fire({
|
||||
identity: context.identity!,
|
||||
name: dataArg.name,
|
||||
description: dataArg.description,
|
||||
});
|
||||
if (!response.success) {
|
||||
return { ...statePartArg.getState()!, error: response.message || 'Failed to create domain' };
|
||||
}
|
||||
return await actionContext!.dispatch(fetchDomainsAndProvidersAction, null);
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
...statePartArg.getState()!,
|
||||
error: error instanceof Error ? error.message : 'Failed to create domain',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const importDomainsFromProviderAction = domainsStatePart.createAction<{
|
||||
providerId: string;
|
||||
domainNames: string[];
|
||||
}>(async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_ImportDomain
|
||||
>('/typedrequest', 'importDomain');
|
||||
const response = await request.fire({
|
||||
identity: context.identity!,
|
||||
providerId: dataArg.providerId,
|
||||
domainNames: dataArg.domainNames,
|
||||
});
|
||||
if (!response.success) {
|
||||
return { ...statePartArg.getState()!, error: response.message || 'Failed to import domains' };
|
||||
}
|
||||
return await actionContext!.dispatch(fetchDomainsAndProvidersAction, null);
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
...statePartArg.getState()!,
|
||||
error: error instanceof Error ? error.message : 'Failed to import domains',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const deleteDomainAction = domainsStatePart.createAction<{ id: string }>(
|
||||
async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_DeleteDomain
|
||||
>('/typedrequest', 'deleteDomain');
|
||||
const response = await request.fire({ identity: context.identity!, id: dataArg.id });
|
||||
if (!response.success) {
|
||||
return { ...statePartArg.getState()!, error: response.message || 'Failed to delete domain' };
|
||||
}
|
||||
return await actionContext!.dispatch(fetchDomainsAndProvidersAction, null);
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
...statePartArg.getState()!,
|
||||
error: error instanceof Error ? error.message : 'Failed to delete domain',
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const syncDomainAction = domainsStatePart.createAction<{ id: string }>(
|
||||
async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_SyncDomain
|
||||
>('/typedrequest', 'syncDomain');
|
||||
const response = await request.fire({ identity: context.identity!, id: dataArg.id });
|
||||
if (!response.success) {
|
||||
return { ...statePartArg.getState()!, error: response.message || 'Failed to sync domain' };
|
||||
}
|
||||
return await actionContext!.dispatch(fetchDomainsAndProvidersAction, null);
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
...statePartArg.getState()!,
|
||||
error: error instanceof Error ? error.message : 'Failed to sync domain',
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const createDnsRecordAction = domainsStatePart.createAction<{
|
||||
domainId: string;
|
||||
name: string;
|
||||
type: interfaces.data.TDnsRecordType;
|
||||
value: string;
|
||||
ttl?: number;
|
||||
proxied?: boolean;
|
||||
}>(async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_CreateDnsRecord
|
||||
>('/typedrequest', 'createDnsRecord');
|
||||
const response = await request.fire({
|
||||
identity: context.identity!,
|
||||
domainId: dataArg.domainId,
|
||||
name: dataArg.name,
|
||||
type: dataArg.type,
|
||||
value: dataArg.value,
|
||||
ttl: dataArg.ttl,
|
||||
proxied: dataArg.proxied,
|
||||
});
|
||||
if (!response.success) {
|
||||
return { ...statePartArg.getState()!, error: response.message || 'Failed to create record' };
|
||||
}
|
||||
return await actionContext!.dispatch(fetchDnsRecordsForDomainAction, { domainId: dataArg.domainId });
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
...statePartArg.getState()!,
|
||||
error: error instanceof Error ? error.message : 'Failed to create record',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const updateDnsRecordAction = domainsStatePart.createAction<{
|
||||
id: string;
|
||||
domainId: string;
|
||||
name?: string;
|
||||
value?: string;
|
||||
ttl?: number;
|
||||
proxied?: boolean;
|
||||
}>(async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_UpdateDnsRecord
|
||||
>('/typedrequest', 'updateDnsRecord');
|
||||
const response = await request.fire({
|
||||
identity: context.identity!,
|
||||
id: dataArg.id,
|
||||
name: dataArg.name,
|
||||
value: dataArg.value,
|
||||
ttl: dataArg.ttl,
|
||||
proxied: dataArg.proxied,
|
||||
});
|
||||
if (!response.success) {
|
||||
return { ...statePartArg.getState()!, error: response.message || 'Failed to update record' };
|
||||
}
|
||||
return await actionContext!.dispatch(fetchDnsRecordsForDomainAction, { domainId: dataArg.domainId });
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
...statePartArg.getState()!,
|
||||
error: error instanceof Error ? error.message : 'Failed to update record',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const deleteDnsRecordAction = domainsStatePart.createAction<{ id: string; domainId: string }>(
|
||||
async (statePartArg, dataArg, actionContext): Promise<IDomainsState> => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_DeleteDnsRecord
|
||||
>('/typedrequest', 'deleteDnsRecord');
|
||||
const response = await request.fire({ identity: context.identity!, id: dataArg.id });
|
||||
if (!response.success) {
|
||||
return { ...statePartArg.getState()!, error: response.message || 'Failed to delete record' };
|
||||
}
|
||||
return await actionContext!.dispatch(fetchDnsRecordsForDomainAction, { domainId: dataArg.domainId });
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
...statePartArg.getState()!,
|
||||
error: error instanceof Error ? error.message : 'Failed to delete record',
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Route Management Actions
|
||||
// ============================================================================
|
||||
@@ -2076,8 +2474,8 @@ async function dispatchCombinedRefreshActionInner() {
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh certificate data if on certificates view
|
||||
if (currentView === 'certificates') {
|
||||
// Refresh certificate data if on Domains > Certificates subview
|
||||
if (currentView === 'domains' && currentSubview === 'certificates') {
|
||||
try {
|
||||
await certificateStatePart.dispatchAction(fetchCertificateOverviewAction, null);
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user