@serve.zone/dcrouter 13.7.1 → 13.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist_serve/bundle.js +707 -563
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/acme/index.d.ts +1 -0
  4. package/dist_ts/acme/index.js +2 -0
  5. package/dist_ts/acme/manager.acme-config.d.ts +48 -0
  6. package/dist_ts/acme/manager.acme-config.js +156 -0
  7. package/dist_ts/classes.dcrouter.d.ts +2 -0
  8. package/dist_ts/classes.dcrouter.js +58 -20
  9. package/dist_ts/db/documents/classes.acme-config.doc.d.ts +22 -0
  10. package/dist_ts/db/documents/classes.acme-config.doc.js +121 -0
  11. package/dist_ts/db/documents/index.d.ts +1 -0
  12. package/dist_ts/db/documents/index.js +3 -1
  13. package/dist_ts/opsserver/classes.opsserver.d.ts +1 -0
  14. package/dist_ts/opsserver/classes.opsserver.js +3 -1
  15. package/dist_ts/opsserver/handlers/acme-config.handler.d.ts +16 -0
  16. package/dist_ts/opsserver/handlers/acme-config.handler.js +77 -0
  17. package/dist_ts/opsserver/handlers/index.d.ts +1 -0
  18. package/dist_ts/opsserver/handlers/index.js +2 -1
  19. package/dist_ts_interfaces/data/acme-config.d.ts +25 -0
  20. package/dist_ts_interfaces/data/acme-config.js +2 -0
  21. package/dist_ts_interfaces/data/index.d.ts +1 -0
  22. package/dist_ts_interfaces/data/index.js +2 -1
  23. package/dist_ts_interfaces/data/route-management.d.ts +1 -1
  24. package/dist_ts_interfaces/requests/acme-config.d.ts +42 -0
  25. package/dist_ts_interfaces/requests/acme-config.js +2 -0
  26. package/dist_ts_interfaces/requests/index.d.ts +1 -0
  27. package/dist_ts_interfaces/requests/index.js +2 -1
  28. package/dist_ts_web/00_commitinfo_data.js +1 -1
  29. package/dist_ts_web/appstate.d.ts +15 -0
  30. package/dist_ts_web/appstate.js +60 -1
  31. package/dist_ts_web/elements/domains/ops-view-certificates.d.ts +3 -0
  32. package/dist_ts_web/elements/domains/ops-view-certificates.js +208 -4
  33. package/package.json +1 -1
  34. package/ts/00_commitinfo_data.ts +1 -1
  35. package/ts/acme/index.ts +1 -0
  36. package/ts/acme/manager.acme-config.ts +182 -0
  37. package/ts/classes.dcrouter.ts +72 -25
  38. package/ts/db/documents/classes.acme-config.doc.ts +49 -0
  39. package/ts/db/documents/index.ts +3 -0
  40. package/ts/opsserver/classes.opsserver.ts +2 -0
  41. package/ts/opsserver/handlers/acme-config.handler.ts +94 -0
  42. package/ts/opsserver/handlers/index.ts +2 -1
  43. package/ts_web/00_commitinfo_data.ts +1 -1
  44. package/ts_web/appstate.ts +88 -0
  45. package/ts_web/elements/domains/ops-view-certificates.ts +205 -2
@@ -28,6 +28,7 @@ import { RouteConfigManager, ApiTokenManager, ReferenceResolver, DbSeeder, Targe
28
28
  import { SecurityLogger, ContentScanner, IPReputationChecker } from './security/index.js';
29
29
  import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
30
30
  import { DnsManager } from './dns/manager.dns.js';
31
+ import { AcmeConfigManager } from './acme/manager.acme-config.js';
31
32
 
32
33
  export interface IDcRouterOptions {
33
34
  /** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
@@ -276,6 +277,9 @@ export class DcRouter {
276
277
  // Domain / DNS management (DB-backed providers, domains, records)
277
278
  public dnsManager?: DnsManager;
278
279
 
280
+ // ACME configuration (DB-backed singleton, replaces tls.contactEmail)
281
+ public acmeConfigManager?: AcmeConfigManager;
282
+
279
283
  // Auto-discovered public IP (populated by generateAuthoritativeRecords)
280
284
  public detectedPublicIp: string | null = null;
281
285
 
@@ -412,11 +416,35 @@ export class DcRouter {
412
416
  );
413
417
  }
414
418
 
415
- // SmartProxy: critical, depends on DcRouterDb + DnsManager (if enabled)
419
+ // AcmeConfigManager: optional, depends on DcRouterDb owns the singleton
420
+ // ACME configuration (accountEmail, useProduction, etc.). Must run before
421
+ // SmartProxy so setupSmartProxy() can read the ACME config from the DB.
422
+ // On first boot, seeds from legacy `tls.contactEmail` / `smartProxyConfig.acme`.
423
+ if (this.options.dbConfig?.enabled !== false) {
424
+ this.serviceManager.addService(
425
+ new plugins.taskbuffer.Service('AcmeConfigManager')
426
+ .optional()
427
+ .dependsOn('DcRouterDb')
428
+ .withStart(async () => {
429
+ this.acmeConfigManager = new AcmeConfigManager(this.options);
430
+ await this.acmeConfigManager.start();
431
+ })
432
+ .withStop(async () => {
433
+ if (this.acmeConfigManager) {
434
+ await this.acmeConfigManager.stop();
435
+ this.acmeConfigManager = undefined;
436
+ }
437
+ })
438
+ .withRetry({ maxRetries: 1, baseDelayMs: 500 }),
439
+ );
440
+ }
441
+
442
+ // SmartProxy: critical, depends on DcRouterDb + DnsManager + AcmeConfigManager (if enabled)
416
443
  const smartProxyDeps: string[] = [];
417
444
  if (this.options.dbConfig?.enabled !== false) {
418
445
  smartProxyDeps.push('DcRouterDb');
419
446
  smartProxyDeps.push('DnsManager');
447
+ smartProxyDeps.push('AcmeConfigManager');
420
448
  }
421
449
  this.serviceManager.addService(
422
450
  new plugins.taskbuffer.Service('SmartProxy')
@@ -837,45 +865,62 @@ export class DcRouter {
837
865
  }
838
866
 
839
867
  let routes: plugins.smartproxy.IRouteConfig[] = [];
840
- let acmeConfig: plugins.smartproxy.IAcmeOptions | undefined;
841
-
842
- // If user provides full SmartProxy config, use it directly
868
+
869
+ // If user provides full SmartProxy config, use its routes.
870
+ // NOTE: `smartProxyConfig.acme` is now seed-only consumed by
871
+ // AcmeConfigManager on first boot. The live ACME config always comes
872
+ // from the DB via `this.acmeConfigManager.getConfig()`.
843
873
  if (this.options.smartProxyConfig) {
844
874
  routes = this.options.smartProxyConfig.routes || [];
845
- acmeConfig = this.options.smartProxyConfig.acme;
846
- logger.log('info', `Found ${routes.length} routes in config, ACME config present: ${!!acmeConfig}`);
875
+ logger.log('info', `Found ${routes.length} routes in config`);
847
876
  }
848
-
877
+
849
878
  // If email config exists, automatically add email routes
850
879
  if (this.options.emailConfig) {
851
880
  const emailRoutes = this.generateEmailRoutes(this.options.emailConfig);
852
881
  logger.log('debug', 'Email routes generated', { routes: JSON.stringify(emailRoutes) });
853
882
  routes = [...routes, ...emailRoutes]; // Enable email routing through SmartProxy
854
883
  }
855
-
884
+
856
885
  // If DNS is configured, add DNS routes
857
886
  if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0) {
858
887
  const dnsRoutes = this.generateDnsRoutes();
859
888
  logger.log('debug', `DNS routes for nameservers ${this.options.dnsNsDomains.join(', ')}`, { routes: JSON.stringify(dnsRoutes) });
860
889
  routes = [...routes, ...dnsRoutes];
861
890
  }
862
-
863
- // Merge TLS/ACME configuration if provided at root level
864
- if (this.options.tls && !acmeConfig) {
865
- acmeConfig = {
866
- accountEmail: this.options.tls.contactEmail,
867
- enabled: true,
868
- useProduction: true,
869
- autoRenew: true,
870
- renewThresholdDays: 30
871
- };
891
+
892
+ // Build the ACME options for SmartProxy from the DB-backed AcmeConfigManager.
893
+ // If no config exists or it's disabled, SmartProxy's own ACME is turned off
894
+ // and dcrouter's SmartAcme / certProvisionFunction are not wired.
895
+ const dbAcme = this.acmeConfigManager?.getConfig();
896
+ const acmeConfig: plugins.smartproxy.IAcmeOptions | undefined =
897
+ dbAcme && dbAcme.enabled
898
+ ? {
899
+ accountEmail: dbAcme.accountEmail,
900
+ enabled: true,
901
+ useProduction: dbAcme.useProduction,
902
+ autoRenew: dbAcme.autoRenew,
903
+ renewThresholdDays: dbAcme.renewThresholdDays,
904
+ }
905
+ : undefined;
906
+ if (acmeConfig) {
907
+ logger.log(
908
+ 'info',
909
+ `ACME config: accountEmail=${acmeConfig.accountEmail}, useProduction=${acmeConfig.useProduction}, autoRenew=${acmeConfig.autoRenew}`,
910
+ );
911
+ } else {
912
+ logger.log('info', 'ACME config: disabled or not yet configured in DB');
872
913
  }
873
-
874
- // Configure DNS-01 challenge if any DnsProviderDoc exists in the DB.
875
- // The DnsManager dispatches each challenge to the right provider client
876
- // based on the FQDN being certificated.
914
+
915
+ // Configure DNS-01 challenge if any DnsProviderDoc exists in the DB AND
916
+ // ACME is enabled. The DnsManager dispatches each challenge to the right
917
+ // provider client based on the FQDN being certificated.
877
918
  let challengeHandlers: any[] = [];
878
- if (this.dnsManager && (await this.dnsManager.hasAcmeCapableProvider())) {
919
+ if (
920
+ acmeConfig &&
921
+ this.dnsManager &&
922
+ (await this.dnsManager.hasAcmeCapableProvider())
923
+ ) {
879
924
  logger.log('info', 'Configuring DNS-01 challenge for ACME via DnsManager (DB providers)');
880
925
  const convenientDnsProvider = this.dnsManager.buildAcmeConvenientDnsProvider();
881
926
  const dns01Handler = new plugins.smartacme.handlers.Dns01Handler(convenientDnsProvider);
@@ -977,10 +1022,12 @@ export class DcRouter {
977
1022
  logger.log('error', 'Error stopping old SmartAcme', { error: String(err) })
978
1023
  );
979
1024
  }
1025
+ // Safe non-null: challengeHandlers.length > 0 implies both dnsManager
1026
+ // and acmeConfig exist (enforced above).
980
1027
  this.smartAcme = new plugins.smartacme.SmartAcme({
981
- accountEmail: acmeConfig?.accountEmail || this.options.tls?.contactEmail || 'admin@example.com',
1028
+ accountEmail: dbAcme!.accountEmail,
982
1029
  certManager: new StorageBackedCertManager(),
983
- environment: 'production',
1030
+ environment: dbAcme!.useProduction ? 'production' : 'integration',
984
1031
  challengeHandlers: challengeHandlers,
985
1032
  challengePriority: ['dns-01'],
986
1033
  });
@@ -0,0 +1,49 @@
1
+ import * as plugins from '../../plugins.js';
2
+ import { DcRouterDb } from '../classes.dcrouter-db.js';
3
+
4
+ const getDb = () => DcRouterDb.getInstance().getDb();
5
+
6
+ /**
7
+ * Singleton ACME configuration document. One row per dcrouter instance,
8
+ * keyed on the fixed `configId = 'acme-config'` following the
9
+ * `VpnServerKeysDoc` pattern.
10
+ *
11
+ * Replaces the legacy `tls.contactEmail` and `smartProxyConfig.acme.*`
12
+ * constructor fields. Managed via the OpsServer UI at
13
+ * **Domains > Certificates > Settings**.
14
+ */
15
+ @plugins.smartdata.Collection(() => getDb())
16
+ export class AcmeConfigDoc extends plugins.smartdata.SmartDataDbDoc<AcmeConfigDoc, AcmeConfigDoc> {
17
+ @plugins.smartdata.unI()
18
+ @plugins.smartdata.svDb()
19
+ public configId: string = 'acme-config';
20
+
21
+ @plugins.smartdata.svDb()
22
+ public accountEmail: string = '';
23
+
24
+ @plugins.smartdata.svDb()
25
+ public enabled: boolean = true;
26
+
27
+ @plugins.smartdata.svDb()
28
+ public useProduction: boolean = true;
29
+
30
+ @plugins.smartdata.svDb()
31
+ public autoRenew: boolean = true;
32
+
33
+ @plugins.smartdata.svDb()
34
+ public renewThresholdDays: number = 30;
35
+
36
+ @plugins.smartdata.svDb()
37
+ public updatedAt: number = 0;
38
+
39
+ @plugins.smartdata.svDb()
40
+ public updatedBy: string = '';
41
+
42
+ constructor() {
43
+ super();
44
+ }
45
+
46
+ public static async load(): Promise<AcmeConfigDoc | null> {
47
+ return await AcmeConfigDoc.getInstance({ configId: 'acme-config' });
48
+ }
49
+ }
@@ -30,3 +30,6 @@ export * from './classes.accounting-session.doc.js';
30
30
  export * from './classes.dns-provider.doc.js';
31
31
  export * from './classes.domain.doc.js';
32
32
  export * from './classes.dns-record.doc.js';
33
+
34
+ // ACME configuration (singleton)
35
+ export * from './classes.acme-config.doc.js';
@@ -36,6 +36,7 @@ export class OpsServer {
36
36
  private dnsProviderHandler!: handlers.DnsProviderHandler;
37
37
  private domainHandler!: handlers.DomainHandler;
38
38
  private dnsRecordHandler!: handlers.DnsRecordHandler;
39
+ private acmeConfigHandler!: handlers.AcmeConfigHandler;
39
40
 
40
41
  constructor(dcRouterRefArg: DcRouter) {
41
42
  this.dcRouterRef = dcRouterRefArg;
@@ -102,6 +103,7 @@ export class OpsServer {
102
103
  this.dnsProviderHandler = new handlers.DnsProviderHandler(this);
103
104
  this.domainHandler = new handlers.DomainHandler(this);
104
105
  this.dnsRecordHandler = new handlers.DnsRecordHandler(this);
106
+ this.acmeConfigHandler = new handlers.AcmeConfigHandler(this);
105
107
 
106
108
  console.log('✅ OpsServer TypedRequest handlers initialized');
107
109
  }
@@ -0,0 +1,94 @@
1
+ import * as plugins from '../../plugins.js';
2
+ import type { OpsServer } from '../classes.opsserver.js';
3
+ import * as interfaces from '../../../ts_interfaces/index.js';
4
+
5
+ /**
6
+ * CRUD handler for the singleton `AcmeConfigDoc`.
7
+ *
8
+ * Auth: same dual-mode pattern as other handlers — admin JWT or API token
9
+ * with `acme-config:read` / `acme-config:write` scope.
10
+ */
11
+ export class AcmeConfigHandler {
12
+ public typedrouter = new plugins.typedrequest.TypedRouter();
13
+
14
+ constructor(private opsServerRef: OpsServer) {
15
+ this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
16
+ this.registerHandlers();
17
+ }
18
+
19
+ private async requireAuth(
20
+ request: { identity?: interfaces.data.IIdentity; apiToken?: string },
21
+ requiredScope?: interfaces.data.TApiTokenScope,
22
+ ): Promise<string> {
23
+ if (request.identity?.jwt) {
24
+ try {
25
+ const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
26
+ identity: request.identity,
27
+ });
28
+ if (isAdmin) return request.identity.userId;
29
+ } catch { /* fall through */ }
30
+ }
31
+
32
+ if (request.apiToken) {
33
+ const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
34
+ if (tokenManager) {
35
+ const token = await tokenManager.validateToken(request.apiToken);
36
+ if (token) {
37
+ if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
38
+ return token.createdBy;
39
+ }
40
+ throw new plugins.typedrequest.TypedResponseError('insufficient scope');
41
+ }
42
+ }
43
+ }
44
+
45
+ throw new plugins.typedrequest.TypedResponseError('unauthorized');
46
+ }
47
+
48
+ private registerHandlers(): void {
49
+ // Get current ACME config
50
+ this.typedrouter.addTypedHandler(
51
+ new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAcmeConfig>(
52
+ 'getAcmeConfig',
53
+ async (dataArg) => {
54
+ await this.requireAuth(dataArg, 'acme-config:read');
55
+ const mgr = this.opsServerRef.dcRouterRef.acmeConfigManager;
56
+ if (!mgr) return { config: null };
57
+ return { config: mgr.getConfig() };
58
+ },
59
+ ),
60
+ );
61
+
62
+ // Update (upsert) the ACME config
63
+ this.typedrouter.addTypedHandler(
64
+ new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateAcmeConfig>(
65
+ 'updateAcmeConfig',
66
+ async (dataArg) => {
67
+ const userId = await this.requireAuth(dataArg, 'acme-config:write');
68
+ const mgr = this.opsServerRef.dcRouterRef.acmeConfigManager;
69
+ if (!mgr) {
70
+ return {
71
+ success: false,
72
+ message: 'AcmeConfigManager not initialized (DB disabled?)',
73
+ };
74
+ }
75
+ try {
76
+ const updated = await mgr.updateConfig(
77
+ {
78
+ accountEmail: dataArg.accountEmail,
79
+ enabled: dataArg.enabled,
80
+ useProduction: dataArg.useProduction,
81
+ autoRenew: dataArg.autoRenew,
82
+ renewThresholdDays: dataArg.renewThresholdDays,
83
+ },
84
+ userId,
85
+ );
86
+ return { success: true, config: updated };
87
+ } catch (err: unknown) {
88
+ return { success: false, message: (err as Error).message };
89
+ }
90
+ },
91
+ ),
92
+ );
93
+ }
94
+ }
@@ -16,4 +16,5 @@ export * from './network-target.handler.js';
16
16
  export * from './users.handler.js';
17
17
  export * from './dns-provider.handler.js';
18
18
  export * from './domain.handler.js';
19
- export * from './dns-record.handler.js';
19
+ export * from './dns-record.handler.js';
20
+ export * from './acme-config.handler.js';
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '13.7.1',
6
+ version: '13.8.0',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }
@@ -197,6 +197,28 @@ export const certificateStatePart = await appState.getStatePart<ICertificateStat
197
197
  'soft'
198
198
  );
199
199
 
200
+ // ============================================================================
201
+ // ACME Config State (DB-backed singleton, managed via Domains > Certificates)
202
+ // ============================================================================
203
+
204
+ export interface IAcmeConfigState {
205
+ config: interfaces.data.IAcmeConfig | null;
206
+ isLoading: boolean;
207
+ error: string | null;
208
+ lastUpdated: number;
209
+ }
210
+
211
+ export const acmeConfigStatePart = await appState.getStatePart<IAcmeConfigState>(
212
+ 'acmeConfig',
213
+ {
214
+ config: null,
215
+ isLoading: false,
216
+ error: null,
217
+ lastUpdated: 0,
218
+ },
219
+ 'soft',
220
+ );
221
+
200
222
  // ============================================================================
201
223
  // Remote Ingress State
202
224
  // ============================================================================
@@ -1953,6 +1975,72 @@ export const deleteDnsRecordAction = domainsStatePart.createAction<{ id: string;
1953
1975
  },
1954
1976
  );
1955
1977
 
1978
+ // ============================================================================
1979
+ // ACME Config Actions
1980
+ // ============================================================================
1981
+
1982
+ export const fetchAcmeConfigAction = acmeConfigStatePart.createAction(
1983
+ async (statePartArg): Promise<IAcmeConfigState> => {
1984
+ const context = getActionContext();
1985
+ const currentState = statePartArg.getState()!;
1986
+ if (!context.identity) return currentState;
1987
+
1988
+ try {
1989
+ const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
1990
+ interfaces.requests.IReq_GetAcmeConfig
1991
+ >('/typedrequest', 'getAcmeConfig');
1992
+ const response = await request.fire({ identity: context.identity });
1993
+ return {
1994
+ config: response.config,
1995
+ isLoading: false,
1996
+ error: null,
1997
+ lastUpdated: Date.now(),
1998
+ };
1999
+ } catch (error: unknown) {
2000
+ return {
2001
+ ...currentState,
2002
+ isLoading: false,
2003
+ error: error instanceof Error ? error.message : 'Failed to fetch ACME config',
2004
+ };
2005
+ }
2006
+ },
2007
+ );
2008
+
2009
+ export const updateAcmeConfigAction = acmeConfigStatePart.createAction<{
2010
+ accountEmail?: string;
2011
+ enabled?: boolean;
2012
+ useProduction?: boolean;
2013
+ autoRenew?: boolean;
2014
+ renewThresholdDays?: number;
2015
+ }>(async (statePartArg, dataArg, actionContext): Promise<IAcmeConfigState> => {
2016
+ const context = getActionContext();
2017
+ try {
2018
+ const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
2019
+ interfaces.requests.IReq_UpdateAcmeConfig
2020
+ >('/typedrequest', 'updateAcmeConfig');
2021
+ const response = await request.fire({
2022
+ identity: context.identity!,
2023
+ accountEmail: dataArg.accountEmail,
2024
+ enabled: dataArg.enabled,
2025
+ useProduction: dataArg.useProduction,
2026
+ autoRenew: dataArg.autoRenew,
2027
+ renewThresholdDays: dataArg.renewThresholdDays,
2028
+ });
2029
+ if (!response.success) {
2030
+ return {
2031
+ ...statePartArg.getState()!,
2032
+ error: response.message || 'Failed to update ACME config',
2033
+ };
2034
+ }
2035
+ return await actionContext!.dispatch(fetchAcmeConfigAction, null);
2036
+ } catch (error: unknown) {
2037
+ return {
2038
+ ...statePartArg.getState()!,
2039
+ error: error instanceof Error ? error.message : 'Failed to update ACME config',
2040
+ };
2041
+ }
2042
+ });
2043
+
1956
2044
  // ============================================================================
1957
2045
  // Route Management Actions
1958
2046
  // ============================================================================
@@ -23,17 +23,25 @@ export class OpsViewCertificates extends DeesElement {
23
23
  @state()
24
24
  accessor certState: appstate.ICertificateState = appstate.certificateStatePart.getState()!;
25
25
 
26
+ @state()
27
+ accessor acmeState: appstate.IAcmeConfigState = appstate.acmeConfigStatePart.getState()!;
28
+
26
29
  constructor() {
27
30
  super();
28
- const sub = appstate.certificateStatePart.select().subscribe((newState) => {
31
+ const certSub = appstate.certificateStatePart.select().subscribe((newState) => {
29
32
  this.certState = newState;
30
33
  });
31
- this.rxSubscriptions.push(sub);
34
+ this.rxSubscriptions.push(certSub);
35
+ const acmeSub = appstate.acmeConfigStatePart.select().subscribe((newState) => {
36
+ this.acmeState = newState;
37
+ });
38
+ this.rxSubscriptions.push(acmeSub);
32
39
  }
33
40
 
34
41
  async connectedCallback() {
35
42
  await super.connectedCallback();
36
43
  await appstate.certificateStatePart.dispatchAction(appstate.fetchCertificateOverviewAction, null);
44
+ await appstate.acmeConfigStatePart.dispatchAction(appstate.fetchAcmeConfigAction, null);
37
45
  }
38
46
 
39
47
  public static styles = [
@@ -46,6 +54,62 @@ export class OpsViewCertificates extends DeesElement {
46
54
  gap: 24px;
47
55
  }
48
56
 
57
+ .acmeCard {
58
+ padding: 16px 20px;
59
+ background: ${cssManager.bdTheme('#f9fafb', '#111827')};
60
+ border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
61
+ border-radius: 8px;
62
+ }
63
+
64
+ .acmeCard.acmeCardEmpty {
65
+ background: ${cssManager.bdTheme('#fffbeb', '#1c1917')};
66
+ border-color: ${cssManager.bdTheme('#fde68a', '#78350f')};
67
+ }
68
+
69
+ .acmeCardHeader {
70
+ display: flex;
71
+ justify-content: space-between;
72
+ align-items: center;
73
+ margin-bottom: 12px;
74
+ }
75
+
76
+ .acmeCardTitle {
77
+ font-size: 14px;
78
+ font-weight: 600;
79
+ color: ${cssManager.bdTheme('#111827', '#f3f4f6')};
80
+ }
81
+
82
+ .acmeGrid {
83
+ display: grid;
84
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
85
+ gap: 12px 24px;
86
+ }
87
+
88
+ .acmeField {
89
+ display: flex;
90
+ flex-direction: column;
91
+ gap: 2px;
92
+ }
93
+
94
+ .acmeLabel {
95
+ font-size: 11px;
96
+ text-transform: uppercase;
97
+ letter-spacing: 0.03em;
98
+ color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
99
+ }
100
+
101
+ .acmeValue {
102
+ font-size: 13px;
103
+ color: ${cssManager.bdTheme('#111827', '#f3f4f6')};
104
+ }
105
+
106
+ .acmeEmptyHint {
107
+ margin: 0;
108
+ font-size: 13px;
109
+ line-height: 1.5;
110
+ color: ${cssManager.bdTheme('#78350f', '#fde68a')};
111
+ }
112
+
49
113
  .statusBadge {
50
114
  display: inline-flex;
51
115
  align-items: center;
@@ -162,12 +226,151 @@ export class OpsViewCertificates extends DeesElement {
162
226
  <dees-heading level="3">Certificates</dees-heading>
163
227
 
164
228
  <div class="certificatesContainer">
229
+ ${this.renderAcmeSettingsCard()}
165
230
  ${this.renderStatsTiles(summary)}
166
231
  ${this.renderCertificateTable()}
167
232
  </div>
168
233
  `;
169
234
  }
170
235
 
236
+ private renderAcmeSettingsCard(): TemplateResult {
237
+ const config = this.acmeState.config;
238
+
239
+ if (!config) {
240
+ return html`
241
+ <div class="acmeCard acmeCardEmpty">
242
+ <div class="acmeCardHeader">
243
+ <span class="acmeCardTitle">ACME Settings</span>
244
+ <dees-button
245
+ eventName="edit-acme"
246
+ @click=${() => this.showEditAcmeDialog()}
247
+ .type=${'highlighted'}
248
+ >Configure</dees-button>
249
+ </div>
250
+ <p class="acmeEmptyHint">
251
+ No ACME configuration yet. Click <strong>Configure</strong> to set up automated TLS
252
+ certificate issuance via Let's Encrypt. You'll also need at least one DNS provider
253
+ under <strong>Domains &gt; Providers</strong>.
254
+ </p>
255
+ </div>
256
+ `;
257
+ }
258
+
259
+ return html`
260
+ <div class="acmeCard">
261
+ <div class="acmeCardHeader">
262
+ <span class="acmeCardTitle">ACME Settings</span>
263
+ <dees-button eventName="edit-acme" @click=${() => this.showEditAcmeDialog()}>Edit</dees-button>
264
+ </div>
265
+ <div class="acmeGrid">
266
+ <div class="acmeField">
267
+ <span class="acmeLabel">Account email</span>
268
+ <span class="acmeValue">${config.accountEmail || '(not set)'}</span>
269
+ </div>
270
+ <div class="acmeField">
271
+ <span class="acmeLabel">Status</span>
272
+ <span class="acmeValue">
273
+ <span class="statusBadge ${config.enabled ? 'valid' : 'unknown'}">
274
+ ${config.enabled ? 'enabled' : 'disabled'}
275
+ </span>
276
+ </span>
277
+ </div>
278
+ <div class="acmeField">
279
+ <span class="acmeLabel">Mode</span>
280
+ <span class="acmeValue">
281
+ <span class="statusBadge ${config.useProduction ? 'valid' : 'provisioning'}">
282
+ ${config.useProduction ? 'production' : 'staging'}
283
+ </span>
284
+ </span>
285
+ </div>
286
+ <div class="acmeField">
287
+ <span class="acmeLabel">Auto-renew</span>
288
+ <span class="acmeValue">${config.autoRenew ? 'on' : 'off'}</span>
289
+ </div>
290
+ <div class="acmeField">
291
+ <span class="acmeLabel">Renewal threshold</span>
292
+ <span class="acmeValue">${config.renewThresholdDays} days</span>
293
+ </div>
294
+ </div>
295
+ </div>
296
+ `;
297
+ }
298
+
299
+ private async showEditAcmeDialog() {
300
+ const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
301
+ const current = this.acmeState.config;
302
+
303
+ DeesModal.createAndShow({
304
+ heading: current ? 'Edit ACME Settings' : 'Configure ACME',
305
+ content: html`
306
+ <dees-form>
307
+ <dees-input-text
308
+ .key=${'accountEmail'}
309
+ .label=${'Account email'}
310
+ .value=${current?.accountEmail ?? ''}
311
+ .required=${true}
312
+ ></dees-input-text>
313
+ <dees-input-checkbox
314
+ .key=${'enabled'}
315
+ .label=${'Enabled'}
316
+ .value=${current?.enabled ?? true}
317
+ ></dees-input-checkbox>
318
+ <dees-input-checkbox
319
+ .key=${'useProduction'}
320
+ .label=${"Use Let's Encrypt production (uncheck for staging)"}
321
+ .value=${current?.useProduction ?? true}
322
+ ></dees-input-checkbox>
323
+ <dees-input-checkbox
324
+ .key=${'autoRenew'}
325
+ .label=${'Auto-renew certificates'}
326
+ .value=${current?.autoRenew ?? true}
327
+ ></dees-input-checkbox>
328
+ <dees-input-text
329
+ .key=${'renewThresholdDays'}
330
+ .label=${'Renewal threshold (days)'}
331
+ .value=${String(current?.renewThresholdDays ?? 30)}
332
+ ></dees-input-text>
333
+ </dees-form>
334
+ <p style="margin-top: 12px; font-size: 12px; opacity: 0.7;">
335
+ Most fields take effect on the next dcrouter restart (SmartAcme is instantiated once at
336
+ startup). Changing the account email creates a new Let's Encrypt account — only do this
337
+ if you know what you're doing.
338
+ </p>
339
+ `,
340
+ menuOptions: [
341
+ { name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
342
+ {
343
+ name: 'Save',
344
+ action: async (modalArg: any) => {
345
+ const form = modalArg.shadowRoot
346
+ ?.querySelector('.content')
347
+ ?.querySelector('dees-form');
348
+ if (!form) return;
349
+ const data = await form.collectFormData();
350
+ const email = String(data.accountEmail ?? '').trim();
351
+ if (!email) {
352
+ DeesToast.show({
353
+ message: 'Account email is required',
354
+ type: 'warning',
355
+ duration: 2500,
356
+ });
357
+ return;
358
+ }
359
+ const threshold = parseInt(String(data.renewThresholdDays ?? '30'), 10);
360
+ await appstate.acmeConfigStatePart.dispatchAction(appstate.updateAcmeConfigAction, {
361
+ accountEmail: email,
362
+ enabled: Boolean(data.enabled),
363
+ useProduction: Boolean(data.useProduction),
364
+ autoRenew: Boolean(data.autoRenew),
365
+ renewThresholdDays: Number.isFinite(threshold) ? threshold : 30,
366
+ });
367
+ modalArg.destroy();
368
+ },
369
+ },
370
+ ],
371
+ });
372
+ }
373
+
171
374
  private renderStatsTiles(summary: appstate.ICertificateState['summary']): TemplateResult {
172
375
  const tiles: IStatsTile[] = [
173
376
  {