Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fa65f31c3 | |||
| 93d6c7d341 | |||
| b2ccd54079 |
@@ -1,5 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-04-05 - 13.0.6 - fix(certificates)
|
||||
resolve base-domain certificate lookups and route profile list inputs
|
||||
|
||||
- Look up ACME certificate metadata by base domain first, with fallback to the exact domain, so subdomain certificate status and deletion work reliably.
|
||||
- Trigger certificate reprovisioning through SmartProxy routes and clear cached status before refresh, including force-renew cache invalidation handling.
|
||||
- Replace comma-separated target profile form fields with list inputs and route suggestions for domains, targets, and route references.
|
||||
|
||||
## 2026-04-05 - 13.0.5 - fix(ts_web)
|
||||
replace custom section heading component with dees-heading across ops views
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"private": false,
|
||||
"version": "13.0.5",
|
||||
"version": "13.0.6",
|
||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -35,12 +35,12 @@
|
||||
"@api.global/typedserver": "^8.4.6",
|
||||
"@api.global/typedsocket": "^4.1.2",
|
||||
"@apiclient.xyz/cloudflare": "^7.1.0",
|
||||
"@design.estate/dees-catalog": "^3.61.0",
|
||||
"@design.estate/dees-catalog": "^3.61.1",
|
||||
"@design.estate/dees-element": "^2.2.4",
|
||||
"@push.rocks/lik": "^6.4.0",
|
||||
"@push.rocks/projectinfo": "^5.1.0",
|
||||
"@push.rocks/qenv": "^6.1.3",
|
||||
"@push.rocks/smartacme": "^9.4.0",
|
||||
"@push.rocks/smartacme": "^9.5.0",
|
||||
"@push.rocks/smartdata": "^7.1.6",
|
||||
"@push.rocks/smartdb": "^2.5.9",
|
||||
"@push.rocks/smartdns": "^7.9.0",
|
||||
|
||||
27
pnpm-lock.yaml
generated
27
pnpm-lock.yaml
generated
@@ -24,8 +24,8 @@ importers:
|
||||
specifier: ^7.1.0
|
||||
version: 7.1.0
|
||||
'@design.estate/dees-catalog':
|
||||
specifier: ^3.61.0
|
||||
version: 3.61.0(@tiptap/pm@2.27.2)
|
||||
specifier: ^3.61.1
|
||||
version: 3.61.1(@tiptap/pm@2.27.2)
|
||||
'@design.estate/dees-element':
|
||||
specifier: ^2.2.4
|
||||
version: 2.2.4
|
||||
@@ -39,8 +39,8 @@ importers:
|
||||
specifier: ^6.1.3
|
||||
version: 6.1.3
|
||||
'@push.rocks/smartacme':
|
||||
specifier: ^9.4.0
|
||||
version: 9.4.0(socks@2.8.7)
|
||||
specifier: ^9.5.0
|
||||
version: 9.5.0(socks@2.8.7)
|
||||
'@push.rocks/smartdata':
|
||||
specifier: ^7.1.6
|
||||
version: 7.1.6(socks@2.8.7)
|
||||
@@ -350,8 +350,8 @@ packages:
|
||||
'@configvault.io/interfaces@1.0.17':
|
||||
resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==}
|
||||
|
||||
'@design.estate/dees-catalog@3.61.0':
|
||||
resolution: {integrity: sha512-gBcNotstwnapGuf/DSapVu+R8F1ITp1wypDOw4NLFak0FwOmPb7ao5pALUbcz+MZmZmB0VuBuqN5GcTyIGIX3Q==}
|
||||
'@design.estate/dees-catalog@3.61.1':
|
||||
resolution: {integrity: sha512-RA85O87pRM3QPlncBNB27wJTl+UVGaGtx8l5DaeOhru78agu4+y+ByAdUgS9Ahdpr/ZZVYSAADkZETsf/l08UQ==}
|
||||
|
||||
'@design.estate/dees-comms@1.0.30':
|
||||
resolution: {integrity: sha512-KchMlklJfKAjQiJiR0xmofXtQ27VgZtBIxcMwPE9d+h3jJRv+lPZxzBQVOM0eyM0uS44S5vJMZ11IeV4uDXSHg==}
|
||||
@@ -1108,8 +1108,8 @@ packages:
|
||||
'@push.rocks/qenv@6.1.3':
|
||||
resolution: {integrity: sha512-+z2hsAU/7CIgpYLFqvda8cn9rUBMHqLdQLjsFfRn5jPoD7dJ5rFlpkbhfM4Ws8mHMniwWaxGKo+q/YBhtzRBLg==}
|
||||
|
||||
'@push.rocks/smartacme@9.4.0':
|
||||
resolution: {integrity: sha512-mSqsI859mHI9fCZxLfayzPf/WvukDFzVHOh02vXq3ujxbb5M+ArMnXe0MmC2egR9GeXmQTm3DTENaETX5ffMtw==}
|
||||
'@push.rocks/smartacme@9.5.0':
|
||||
resolution: {integrity: sha512-soOjER2c4umKaOSsB6uq/k08aA9rfd7Dicm6DNX3XB16LjCjldVHpizeOGqRBkFga+VroDQ/rEYecHT5tFiWvg==}
|
||||
|
||||
'@push.rocks/smartarchive@4.2.4':
|
||||
resolution: {integrity: sha512-uiqVAXPxmr8G5rv3uZvZFMOCt8l7cZC3nzvsy4YQqKf/VkPhKIEX+b7LkAeNlxPSYUiBQUkNRoawg9+5BaMcHg==}
|
||||
@@ -4358,7 +4358,7 @@ snapshots:
|
||||
'@api.global/typedrequest-interfaces': 3.0.19
|
||||
'@api.global/typedsocket': 4.1.2(@push.rocks/smartserve@2.0.3)
|
||||
'@cloudflare/workers-types': 4.20260317.1
|
||||
'@design.estate/dees-catalog': 3.61.0(@tiptap/pm@2.27.2)
|
||||
'@design.estate/dees-catalog': 3.61.1(@tiptap/pm@2.27.2)
|
||||
'@design.estate/dees-comms': 1.0.30
|
||||
'@push.rocks/lik': 6.4.0
|
||||
'@push.rocks/smartdelay': 3.0.5
|
||||
@@ -4887,7 +4887,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@api.global/typedrequest-interfaces': 3.0.19
|
||||
|
||||
'@design.estate/dees-catalog@3.61.0(@tiptap/pm@2.27.2)':
|
||||
'@design.estate/dees-catalog@3.61.1(@tiptap/pm@2.27.2)':
|
||||
dependencies:
|
||||
'@design.estate/dees-domtools': 2.5.4
|
||||
'@design.estate/dees-element': 2.2.4
|
||||
@@ -5977,7 +5977,7 @@ snapshots:
|
||||
'@push.rocks/smartlog': 3.2.1
|
||||
'@push.rocks/smartpath': 6.0.0
|
||||
|
||||
'@push.rocks/smartacme@9.4.0(socks@2.8.7)':
|
||||
'@push.rocks/smartacme@9.5.0(socks@2.8.7)':
|
||||
dependencies:
|
||||
'@apiclient.xyz/cloudflare': 7.1.0
|
||||
'@peculiar/x509': 2.0.0
|
||||
@@ -5997,11 +5997,14 @@ snapshots:
|
||||
- '@aws-sdk/credential-providers'
|
||||
- '@mongodb-js/zstd'
|
||||
- '@nuxt/kit'
|
||||
- bare-abort-controller
|
||||
- bare-buffer
|
||||
- encoding
|
||||
- gcp-metadata
|
||||
- kerberos
|
||||
- mongodb-client-encryption
|
||||
- react
|
||||
- react-native-b4a
|
||||
- snappy
|
||||
- socks
|
||||
- supports-color
|
||||
@@ -6965,7 +6968,7 @@ snapshots:
|
||||
|
||||
'@serve.zone/catalog@2.11.2(@tiptap/pm@2.27.2)':
|
||||
dependencies:
|
||||
'@design.estate/dees-catalog': 3.61.0(@tiptap/pm@2.27.2)
|
||||
'@design.estate/dees-catalog': 3.61.1(@tiptap/pm@2.27.2)
|
||||
'@design.estate/dees-domtools': 2.5.4
|
||||
'@design.estate/dees-element': 2.2.4
|
||||
'@design.estate/dees-wcctools': 3.8.0
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.0.5',
|
||||
version: '13.0.6',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
@@ -1076,7 +1076,10 @@ export class DcRouter {
|
||||
if (!expiryDate) {
|
||||
try {
|
||||
const cleanDomain = entry.domain.replace(/^\*\.?/, '');
|
||||
const certDoc = await AcmeCertDoc.findByDomain(cleanDomain);
|
||||
const domParts = cleanDomain.split('.');
|
||||
const baseDomain = domParts.length > 2 ? domParts.slice(-2).join('.') : cleanDomain;
|
||||
const certDoc = await AcmeCertDoc.findByDomain(baseDomain)
|
||||
|| (baseDomain !== cleanDomain ? await AcmeCertDoc.findByDomain(cleanDomain) : null);
|
||||
if (certDoc?.validUntil) {
|
||||
expiryDate = new Date(certDoc.validUntil).toISOString();
|
||||
}
|
||||
|
||||
@@ -191,7 +191,11 @@ export class CertificateHandler {
|
||||
// Check persisted cert data from smartdata document classes
|
||||
if (status === 'unknown') {
|
||||
const cleanDomain = domain.replace(/^\*\.?/, '');
|
||||
const acmeDoc = await AcmeCertDoc.findByDomain(cleanDomain);
|
||||
// SmartAcme stores certs under the base domain (e.g. example.com for api.example.com)
|
||||
const parts = cleanDomain.split('.');
|
||||
const baseDomain = parts.length > 2 ? parts.slice(-2).join('.') : cleanDomain;
|
||||
const acmeDoc = await AcmeCertDoc.findByDomain(baseDomain)
|
||||
|| (baseDomain !== cleanDomain ? await AcmeCertDoc.findByDomain(cleanDomain) : null);
|
||||
const proxyDoc = !acmeDoc ? await ProxyCertDoc.findByDomain(domain) : null;
|
||||
|
||||
if (acmeDoc?.validUntil) {
|
||||
@@ -331,31 +335,32 @@ export class CertificateHandler {
|
||||
await dcRouter.certProvisionScheduler.clearBackoff(domain);
|
||||
}
|
||||
|
||||
// Clear status map entry so it gets refreshed
|
||||
// Find routes matching this domain — needed to provision through SmartProxy
|
||||
const routeNames = dcRouter.findRouteNamesForDomain(domain);
|
||||
if (routeNames.length === 0) {
|
||||
return { success: false, message: `No routes found for domain '${domain}'` };
|
||||
}
|
||||
|
||||
// If forceRenew, invalidate SmartAcme's cache so the next provision gets a fresh cert
|
||||
if (forceRenew && dcRouter.smartAcme) {
|
||||
try {
|
||||
await dcRouter.smartAcme.getCertificateForDomain(domain, { forceRenew: true });
|
||||
} catch {
|
||||
// Cache invalidation failed — proceed with provisioning anyway
|
||||
}
|
||||
}
|
||||
|
||||
// Clear status map entry so it gets refreshed by the certificate-issued event
|
||||
dcRouter.certificateStatusMap.delete(domain);
|
||||
|
||||
// Try to provision via SmartAcme directly
|
||||
if (dcRouter.smartAcme) {
|
||||
try {
|
||||
await dcRouter.smartAcme.getCertificateForDomain(domain, { forceRenew: forceRenew ?? false });
|
||||
return { success: true, message: forceRenew ? `Certificate force-renewed for domain '${domain}'` : `Certificate reprovisioning triggered for domain '${domain}'` };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message || `Failed to reprovision certificate for ${domain}` };
|
||||
}
|
||||
// Provision through SmartProxy — this triggers the full pipeline:
|
||||
// certProvisionFunction → bridge.loadCertificate → certificate-issued event → status map updated
|
||||
try {
|
||||
await smartProxy.provisionCertificate(routeNames[0]);
|
||||
return { success: true, message: forceRenew ? `Certificate force-renewed for domain '${domain}'` : `Certificate reprovisioning triggered for domain '${domain}'` };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message || `Failed to reprovision certificate for ${domain}` };
|
||||
}
|
||||
|
||||
// Fallback: try provisioning via the first matching route
|
||||
const routeNames = dcRouter.findRouteNamesForDomain(domain);
|
||||
if (routeNames.length > 0) {
|
||||
try {
|
||||
await smartProxy.provisionCertificate(routeNames[0]);
|
||||
return { success: true, message: `Certificate reprovisioning triggered for domain '${domain}' via route '${routeNames[0]}'` };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message || `Failed to reprovision certificate for ${domain}` };
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, message: `No routes found for domain '${domain}'` };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -364,9 +369,12 @@ export class CertificateHandler {
|
||||
private async deleteCertificate(domain: string): Promise<{ success: boolean; message?: string }> {
|
||||
const dcRouter = this.opsServerRef.dcRouterRef;
|
||||
const cleanDomain = domain.replace(/^\*\.?/, '');
|
||||
const parts = cleanDomain.split('.');
|
||||
const baseDomain = parts.length > 2 ? parts.slice(-2).join('.') : cleanDomain;
|
||||
|
||||
// Delete from smartdata document classes
|
||||
const acmeDoc = await AcmeCertDoc.findByDomain(cleanDomain);
|
||||
// Delete from smartdata document classes (try base domain first, then exact)
|
||||
const acmeDoc = await AcmeCertDoc.findByDomain(baseDomain)
|
||||
|| (baseDomain !== cleanDomain ? await AcmeCertDoc.findByDomain(cleanDomain) : null);
|
||||
if (acmeDoc) {
|
||||
await acmeDoc.delete();
|
||||
}
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.0.5',
|
||||
version: '13.0.6',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
@@ -148,17 +148,35 @@ export class OpsViewTargetProfiles extends DeesElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private getRouteCandidates() {
|
||||
const routeState = appstate.routeManagementStatePart.getState();
|
||||
const routes = routeState?.mergedRoutes || [];
|
||||
return routes
|
||||
.filter((mr) => mr.route.name)
|
||||
.map((mr) => ({ viewKey: mr.route.name! }));
|
||||
}
|
||||
|
||||
private async ensureRoutesLoaded() {
|
||||
const routeState = appstate.routeManagementStatePart.getState();
|
||||
if (!routeState?.mergedRoutes?.length) {
|
||||
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
|
||||
}
|
||||
}
|
||||
|
||||
private async showCreateProfileDialog() {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
await this.ensureRoutesLoaded();
|
||||
const routeCandidates = this.getRouteCandidates();
|
||||
|
||||
DeesModal.createAndShow({
|
||||
heading: 'Create Target Profile',
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-text .key=${'name'} .label=${'Name'} .required=${true}></dees-input-text>
|
||||
<dees-input-text .key=${'description'} .label=${'Description'}></dees-input-text>
|
||||
<dees-input-text .key=${'domains'} .label=${'Domains (comma-separated, e.g. *.example.com)'} ></dees-input-text>
|
||||
<dees-input-text .key=${'targets'} .label=${'Targets (comma-separated host:port, e.g. 10.0.0.1:443)'}></dees-input-text>
|
||||
<dees-input-text .key=${'routeRefs'} .label=${'Route Refs (comma-separated route names/IDs)'}></dees-input-text>
|
||||
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'e.g. *.example.com'} .allowFreeform=${true}></dees-input-list>
|
||||
<dees-input-list .key=${'targets'} .label=${'Targets (host:port)'} .placeholder=${'e.g. 10.0.0.1:443'} .allowFreeform=${true}></dees-input-list>
|
||||
<dees-input-list .key=${'routeRefs'} .label=${'Route Refs'} .placeholder=${'Type to search routes...'} .candidates=${routeCandidates} .allowFreeform=${true}></dees-input-list>
|
||||
</dees-form>
|
||||
`,
|
||||
menuOptions: [
|
||||
@@ -172,30 +190,26 @@ export class OpsViewTargetProfiles extends DeesElement {
|
||||
const data = await form.collectFormData();
|
||||
if (!data.name) return;
|
||||
|
||||
const domains = data.domains
|
||||
? String(data.domains).split(',').map((s: string) => s.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
const targets = data.targets
|
||||
? String(data.targets).split(',').map((s: string) => {
|
||||
const trimmed = s.trim();
|
||||
const lastColon = trimmed.lastIndexOf(':');
|
||||
if (lastColon === -1) return null;
|
||||
return {
|
||||
host: trimmed.substring(0, lastColon),
|
||||
port: parseInt(trimmed.substring(lastColon + 1), 10),
|
||||
};
|
||||
}).filter((t): t is { host: string; port: number } => t !== null && !isNaN(t.port))
|
||||
: undefined;
|
||||
const routeRefs = data.routeRefs
|
||||
? String(data.routeRefs).split(',').map((s: string) => s.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
const domains: string[] = Array.isArray(data.domains) ? data.domains : [];
|
||||
const targetStrings: string[] = Array.isArray(data.targets) ? data.targets : [];
|
||||
const targets = targetStrings
|
||||
.map((s: string) => {
|
||||
const lastColon = s.lastIndexOf(':');
|
||||
if (lastColon === -1) return null;
|
||||
return {
|
||||
host: s.substring(0, lastColon),
|
||||
port: parseInt(s.substring(lastColon + 1), 10),
|
||||
};
|
||||
})
|
||||
.filter((t): t is { host: string; port: number } => t !== null && !isNaN(t.port));
|
||||
const routeRefs: string[] = Array.isArray(data.routeRefs) ? data.routeRefs : [];
|
||||
|
||||
await appstate.targetProfilesStatePart.dispatchAction(appstate.createTargetProfileAction, {
|
||||
name: String(data.name),
|
||||
description: data.description ? String(data.description) : undefined,
|
||||
domains,
|
||||
targets,
|
||||
routeRefs,
|
||||
domains: domains.length > 0 ? domains : undefined,
|
||||
targets: targets.length > 0 ? targets : undefined,
|
||||
routeRefs: routeRefs.length > 0 ? routeRefs : undefined,
|
||||
});
|
||||
modalArg.destroy();
|
||||
},
|
||||
@@ -205,20 +219,23 @@ export class OpsViewTargetProfiles extends DeesElement {
|
||||
}
|
||||
|
||||
private async showEditProfileDialog(profile: interfaces.data.ITargetProfile) {
|
||||
const currentDomains = profile.domains?.join(', ') ?? '';
|
||||
const currentTargets = profile.targets?.map(t => `${t.host}:${t.port}`).join(', ') ?? '';
|
||||
const currentRouteRefs = profile.routeRefs?.join(', ') ?? '';
|
||||
const currentDomains = profile.domains || [];
|
||||
const currentTargets = profile.targets?.map(t => `${t.host}:${t.port}`) || [];
|
||||
const currentRouteRefs = profile.routeRefs || [];
|
||||
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
await this.ensureRoutesLoaded();
|
||||
const routeCandidates = this.getRouteCandidates();
|
||||
|
||||
DeesModal.createAndShow({
|
||||
heading: `Edit Profile: ${profile.name}`,
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-text .key=${'name'} .label=${'Name'} .value=${profile.name}></dees-input-text>
|
||||
<dees-input-text .key=${'description'} .label=${'Description'} .value=${profile.description || ''}></dees-input-text>
|
||||
<dees-input-text .key=${'domains'} .label=${'Domains (comma-separated, e.g. *.example.com)'} .value=${currentDomains}></dees-input-text>
|
||||
<dees-input-text .key=${'targets'} .label=${'Targets (comma-separated host:port, e.g. 10.0.0.1:443)'} .value=${currentTargets}></dees-input-text>
|
||||
<dees-input-text .key=${'routeRefs'} .label=${'Route Refs (comma-separated route names/IDs)'} .value=${currentRouteRefs}></dees-input-text>
|
||||
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'e.g. *.example.com'} .allowFreeform=${true} .value=${currentDomains}></dees-input-list>
|
||||
<dees-input-list .key=${'targets'} .label=${'Targets (host:port)'} .placeholder=${'e.g. 10.0.0.1:443'} .allowFreeform=${true} .value=${currentTargets}></dees-input-list>
|
||||
<dees-input-list .key=${'routeRefs'} .label=${'Route Refs'} .placeholder=${'Type to search routes...'} .candidates=${routeCandidates} .allowFreeform=${true} .value=${currentRouteRefs}></dees-input-list>
|
||||
</dees-form>
|
||||
`,
|
||||
menuOptions: [
|
||||
@@ -231,24 +248,19 @@ export class OpsViewTargetProfiles extends DeesElement {
|
||||
if (!form) return;
|
||||
const data = await form.collectFormData();
|
||||
|
||||
const domains = data.domains
|
||||
? String(data.domains).split(',').map((s: string) => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
const targets = data.targets
|
||||
? String(data.targets).split(',').map((s: string) => {
|
||||
const trimmed = s.trim();
|
||||
if (!trimmed) return null;
|
||||
const lastColon = trimmed.lastIndexOf(':');
|
||||
if (lastColon === -1) return null;
|
||||
return {
|
||||
host: trimmed.substring(0, lastColon),
|
||||
port: parseInt(trimmed.substring(lastColon + 1), 10),
|
||||
};
|
||||
}).filter((t): t is { host: string; port: number } => t !== null && !isNaN(t.port))
|
||||
: [];
|
||||
const routeRefs = data.routeRefs
|
||||
? String(data.routeRefs).split(',').map((s: string) => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
const domains: string[] = Array.isArray(data.domains) ? data.domains : [];
|
||||
const targetStrings: string[] = Array.isArray(data.targets) ? data.targets : [];
|
||||
const targets = targetStrings
|
||||
.map((s: string) => {
|
||||
const lastColon = s.lastIndexOf(':');
|
||||
if (lastColon === -1) return null;
|
||||
return {
|
||||
host: s.substring(0, lastColon),
|
||||
port: parseInt(s.substring(lastColon + 1), 10),
|
||||
};
|
||||
})
|
||||
.filter((t): t is { host: string; port: number } => t !== null && !isNaN(t.port));
|
||||
const routeRefs: string[] = Array.isArray(data.routeRefs) ? data.routeRefs : [];
|
||||
|
||||
await appstate.targetProfilesStatePart.dispatchAction(appstate.updateTargetProfileAction, {
|
||||
id: profile.id,
|
||||
|
||||
Reference in New Issue
Block a user