@authrim/setup 0.1.141 → 0.1.142

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 (103) hide show
  1. package/dist/__tests__/keys.test.js.map +1 -1
  2. package/dist/__tests__/migrate.test.js +4 -4
  3. package/dist/__tests__/migrate.test.js.map +1 -1
  4. package/dist/__tests__/paths.test.js.map +1 -1
  5. package/dist/cli/commands/deploy.d.ts.map +1 -1
  6. package/dist/cli/commands/deploy.js +57 -63
  7. package/dist/cli/commands/deploy.js.map +1 -1
  8. package/dist/cli/commands/init.d.ts.map +1 -1
  9. package/dist/cli/commands/init.js +231 -171
  10. package/dist/cli/commands/init.js.map +1 -1
  11. package/dist/core/admin.d.ts.map +1 -1
  12. package/dist/core/admin.js +13 -3
  13. package/dist/core/admin.js.map +1 -1
  14. package/dist/core/cloudflare.d.ts +38 -1
  15. package/dist/core/cloudflare.d.ts.map +1 -1
  16. package/dist/core/cloudflare.js +729 -115
  17. package/dist/core/cloudflare.js.map +1 -1
  18. package/dist/core/config.d.ts +136 -28
  19. package/dist/core/config.d.ts.map +1 -1
  20. package/dist/core/config.js +58 -11
  21. package/dist/core/config.js.map +1 -1
  22. package/dist/core/deploy.d.ts +18 -0
  23. package/dist/core/deploy.d.ts.map +1 -1
  24. package/dist/core/deploy.js +126 -25
  25. package/dist/core/deploy.js.map +1 -1
  26. package/dist/core/keys.d.ts.map +1 -1
  27. package/dist/core/keys.js +2 -0
  28. package/dist/core/keys.js.map +1 -1
  29. package/dist/core/login-ui-client.d.ts.map +1 -1
  30. package/dist/core/login-ui-client.js +43 -7
  31. package/dist/core/login-ui-client.js.map +1 -1
  32. package/dist/core/paths.d.ts.map +1 -1
  33. package/dist/core/paths.js +5 -5
  34. package/dist/core/paths.js.map +1 -1
  35. package/dist/core/tenant-mode.d.ts +4 -0
  36. package/dist/core/tenant-mode.d.ts.map +1 -0
  37. package/dist/core/tenant-mode.js +17 -0
  38. package/dist/core/tenant-mode.js.map +1 -0
  39. package/dist/core/ui-deployment.d.ts +21 -0
  40. package/dist/core/ui-deployment.d.ts.map +1 -0
  41. package/dist/core/ui-deployment.js +90 -0
  42. package/dist/core/ui-deployment.js.map +1 -0
  43. package/dist/core/ui-env.d.ts +17 -0
  44. package/dist/core/ui-env.d.ts.map +1 -1
  45. package/dist/core/ui-env.js +16 -0
  46. package/dist/core/ui-env.js.map +1 -1
  47. package/dist/core/url-config.d.ts +16 -0
  48. package/dist/core/url-config.d.ts.map +1 -0
  49. package/dist/core/url-config.js +46 -0
  50. package/dist/core/url-config.js.map +1 -0
  51. package/dist/core/wrangler.d.ts +50 -1
  52. package/dist/core/wrangler.d.ts.map +1 -1
  53. package/dist/core/wrangler.js +169 -55
  54. package/dist/core/wrangler.js.map +1 -1
  55. package/dist/i18n/locales/de.d.ts.map +1 -1
  56. package/dist/i18n/locales/de.js +37 -0
  57. package/dist/i18n/locales/de.js.map +1 -1
  58. package/dist/i18n/locales/en.d.ts.map +1 -1
  59. package/dist/i18n/locales/en.js +37 -0
  60. package/dist/i18n/locales/en.js.map +1 -1
  61. package/dist/i18n/locales/es.d.ts.map +1 -1
  62. package/dist/i18n/locales/es.js +37 -0
  63. package/dist/i18n/locales/es.js.map +1 -1
  64. package/dist/i18n/locales/fr.d.ts.map +1 -1
  65. package/dist/i18n/locales/fr.js +37 -0
  66. package/dist/i18n/locales/fr.js.map +1 -1
  67. package/dist/i18n/locales/id.d.ts.map +1 -1
  68. package/dist/i18n/locales/id.js +37 -0
  69. package/dist/i18n/locales/id.js.map +1 -1
  70. package/dist/i18n/locales/ja.d.ts.map +1 -1
  71. package/dist/i18n/locales/ja.js +37 -0
  72. package/dist/i18n/locales/ja.js.map +1 -1
  73. package/dist/i18n/locales/ko.d.ts.map +1 -1
  74. package/dist/i18n/locales/ko.js +37 -0
  75. package/dist/i18n/locales/ko.js.map +1 -1
  76. package/dist/i18n/locales/pt.d.ts.map +1 -1
  77. package/dist/i18n/locales/pt.js +37 -0
  78. package/dist/i18n/locales/pt.js.map +1 -1
  79. package/dist/i18n/locales/ru.d.ts.map +1 -1
  80. package/dist/i18n/locales/ru.js +37 -0
  81. package/dist/i18n/locales/ru.js.map +1 -1
  82. package/dist/i18n/locales/zh-CN.d.ts.map +1 -1
  83. package/dist/i18n/locales/zh-CN.js +37 -0
  84. package/dist/i18n/locales/zh-CN.js.map +1 -1
  85. package/dist/i18n/locales/zh-TW.d.ts.map +1 -1
  86. package/dist/i18n/locales/zh-TW.js +37 -0
  87. package/dist/i18n/locales/zh-TW.js.map +1 -1
  88. package/dist/i18n/types.d.ts +8 -0
  89. package/dist/i18n/types.d.ts.map +1 -1
  90. package/dist/index.js +38 -29
  91. package/dist/index.js.map +1 -1
  92. package/dist/web/api.d.ts.map +1 -1
  93. package/dist/web/api.js +207 -95
  94. package/dist/web/api.js.map +1 -1
  95. package/dist/web/ui.d.ts.map +1 -1
  96. package/dist/web/ui.js +506 -109
  97. package/dist/web/ui.js.map +1 -1
  98. package/migrations/000_fresh_schema.sql +227 -9
  99. package/migrations/admin/006_admin_setup_tokens.sql +91 -91
  100. package/migrations/admin/007_admin_role_inheritance.sql +32 -0
  101. package/migrations/admin/008_admin_rebac_definitions.sql +117 -0
  102. package/migrations/admin/009_optimize_admin_audit_indexes.sql +15 -0
  103. package/package.json +5 -5
package/dist/web/ui.js CHANGED
@@ -2034,6 +2034,7 @@ export function getHtmlTemplate(sessionToken, manageOnly, locale = 'en', transla
2034
2034
  <label for="env"><span data-i18n="web.form.envName">Environment Name</span> <span style="color: var(--error);">*</span></label>
2035
2035
  <input type="text" id="env" placeholder="e.g., prod, staging, dev" data-i18n-placeholder="web.form.envNamePlaceholder" required>
2036
2036
  <small style="color: var(--text-muted)" data-i18n="web.form.envNameHint">Lowercase letters, numbers, and hyphens only</small>
2037
+ <span id="env-error" style="display: none; color: var(--error); font-size: 0.85rem;" data-i18n="web.form.envNameError">Only lowercase letters, numbers, and hyphens are allowed (must start with a letter)</span>
2037
2038
  </div>
2038
2039
 
2039
2040
  <!-- 3. Domain Configuration -->
@@ -2045,6 +2046,16 @@ export function getHtmlTemplate(sessionToken, manageOnly, locale = 'en', transla
2045
2046
  <label for="base-domain" data-i18n="web.form.baseDomain">Base Domain (API Domain)</label>
2046
2047
  <input type="text" id="base-domain" placeholder="oidc.example.com" data-i18n-placeholder="web.form.baseDomainPlaceholder">
2047
2048
  <small style="color: var(--text-muted)" data-i18n="web.form.baseDomainHint">Custom domain for Authrim. Leave empty to use workers.dev</small>
2049
+ <div id="domain-check-row" style="display: none; margin-top: 0.5rem; align-items: center; gap: 0.5rem;">
2050
+ <button type="button" id="check-domain-btn" class="btn btn-secondary" style="padding: 0.3rem 0.75rem; font-size: 0.85rem;" data-i18n="domain.checkZoneButton">
2051
+ Check Zone
2052
+ </button>
2053
+ <span id="domain-check-status" style="font-size: 0.85rem;"></span>
2054
+ </div>
2055
+ <label class="checkbox-item" id="custom-domain-binding-row" style="display: none; align-items: center; gap: 0.5rem; margin-top: 0.5rem;">
2056
+ <input type="checkbox" id="custom-domain-binding" checked>
2057
+ <span data-i18n="domain.configureBinding">Configure custom domain binding for Workers</span>
2058
+ </label>
2048
2059
  <label class="checkbox-item" id="naked-domain-label" style="display: flex; align-items: center; gap: 0.5rem; margin-top: 0.5rem;">
2049
2060
  <input type="checkbox" id="naked-domain">
2050
2061
  <span data-i18n="web.form.nakedDomain">Exclude tenant name from URL</span>
@@ -2061,8 +2072,8 @@ export function getHtmlTemplate(sessionToken, manageOnly, locale = 'en', transla
2061
2072
  <div id="tenant-fields">
2062
2073
  <div class="form-group" style="margin-bottom: 0.5rem;">
2063
2074
  <label for="tenant-name" data-i18n="web.form.tenantId">Default Tenant ID</label>
2064
- <input type="text" id="tenant-name" placeholder="default" value="default" data-i18n-placeholder="web.form.tenantIdPlaceholder">
2065
- <small style="color: var(--text-muted)" data-i18n="web.form.tenantIdHint">First tenant identifier (lowercase, no spaces)</small>
2075
+ <input type="text" id="tenant-name" placeholder="default" value="default" disabled readonly data-i18n-placeholder="web.form.tenantIdPlaceholder">
2076
+ <small style="color: var(--text-muted)" data-i18n="web.form.tenantIdHint">First tenant identifier (lowercase, no spaces). Leave empty to use "default".</small>
2066
2077
  <small id="tenant-workers-note" style="color: #6b7280; display: none;" data-i18n="web.form.tenantIdWorkerNote">
2067
2078
  (Tenant ID is used internally. URL subdomain requires custom domain.)
2068
2079
  </small>
@@ -2071,9 +2082,25 @@ export function getHtmlTemplate(sessionToken, manageOnly, locale = 'en', transla
2071
2082
 
2072
2083
  <div class="form-group" style="margin-bottom: 0;">
2073
2084
  <label for="tenant-display" data-i18n="web.form.tenantDisplay">Tenant Display Name</label>
2074
- <input type="text" id="tenant-display" placeholder="My Company" value="Default Tenant" data-i18n-placeholder="web.form.tenantDisplayPlaceholder">
2085
+ <input type="text" id="tenant-display" placeholder="My Company" value="" data-i18n-placeholder="web.form.tenantDisplayPlaceholder">
2075
2086
  <small style="color: var(--text-muted)" data-i18n="web.form.tenantDisplayHint">Name shown on login page and consent screen</small>
2076
2087
  </div>
2088
+
2089
+ <!-- Primary Tenant (for naked domain) -->
2090
+ <div class="form-group" id="primary-tenant-row" style="margin-bottom: 0.75rem;">
2091
+ <label for="primary-tenant">Primary Tenant (for naked domain)</label>
2092
+ <input type="text" id="primary-tenant" placeholder="Leave empty to use default tenant">
2093
+ <small style="color: var(--text-muted)">Tenant ID to use when accessing the naked domain (e.g., example.com). Leave empty to use the default tenant above.</small>
2094
+ </div>
2095
+
2096
+ <div class="form-group" style="margin-bottom: 0;">
2097
+ <label for="user-id-format" data-i18n="web.form.userIdFormat">User ID Format</label>
2098
+ <select id="user-id-format">
2099
+ <option value="nanoid" selected data-i18n="web.form.userIdNanoid">NanoID (recommended)</option>
2100
+ <option value="uuid" data-i18n="web.form.userIdUuid">UUID v4</option>
2101
+ </select>
2102
+ <small style="color: var(--text-muted)" data-i18n="web.form.userIdFormatHint">Format for generating user IDs. Cannot be changed after users are created.</small>
2103
+ </div>
2077
2104
  </div>
2078
2105
 
2079
2106
  <!-- 3.2 UI Domains -->
@@ -2539,6 +2566,15 @@ export function getHtmlTemplate(sessionToken, manageOnly, locale = 'en', transla
2539
2566
  </div>
2540
2567
  </div>
2541
2568
 
2569
+ <div class="resource-section" style="margin-bottom: 1.5rem;">
2570
+ <div class="resource-section-title">
2571
+ 🔗 URLs
2572
+ </div>
2573
+ <div id="detail-url-list" class="resource-list">
2574
+ <div style="color: var(--text-muted); padding: 0.75rem 0;" data-i18n="web.status.loading">Loading...</div>
2575
+ </div>
2576
+ </div>
2577
+
2542
2578
  <div id="detail-resources">
2543
2579
  <!-- Workers -->
2544
2580
  <div class="resource-section">
@@ -3073,7 +3109,7 @@ export function getHtmlTemplate(sessionToken, manageOnly, locale = 'en', transla
3073
3109
  return div;
3074
3110
  }
3075
3111
 
3076
- function createUrlItem(label, url) {
3112
+ function createUrlItem(label, text, href) {
3077
3113
  const div = document.createElement('div');
3078
3114
  div.className = 'url-item';
3079
3115
 
@@ -3081,14 +3117,23 @@ export function getHtmlTemplate(sessionToken, manageOnly, locale = 'en', transla
3081
3117
  labelSpan.className = 'url-label';
3082
3118
  labelSpan.textContent = label;
3083
3119
 
3084
- const link = document.createElement('a');
3085
- link.href = url;
3086
- link.target = '_blank';
3087
- link.className = 'url-value';
3088
- link.textContent = url;
3120
+ let valueEl;
3121
+ if (href) {
3122
+ const link = document.createElement('a');
3123
+ link.href = href;
3124
+ link.target = '_blank';
3125
+ link.className = 'url-value';
3126
+ link.textContent = text;
3127
+ valueEl = link;
3128
+ } else {
3129
+ const span = document.createElement('span');
3130
+ span.className = 'url-value';
3131
+ span.textContent = text;
3132
+ valueEl = span;
3133
+ }
3089
3134
 
3090
3135
  div.appendChild(labelSpan);
3091
- div.appendChild(link);
3136
+ div.appendChild(valueEl);
3092
3137
  return div;
3093
3138
  }
3094
3139
 
@@ -3376,14 +3421,26 @@ export function getHtmlTemplate(sessionToken, manageOnly, locale = 'en', transla
3376
3421
  components,
3377
3422
  };
3378
3423
 
3424
+ // Helper to remove https:// prefix for display in input fields
3425
+ const stripProtocol = (url) => {
3426
+ if (!url) return '';
3427
+ return url.replace(/^https?:[/][/]/, '');
3428
+ };
3429
+
3379
3430
  // Set form values
3380
3431
  document.getElementById('env').value = config.env;
3381
- document.getElementById('base-domain').value = config.tenant?.baseDomain || config.apiDomain || '';
3382
- document.getElementById('login-domain').value = config.loginUiDomain || '';
3383
- document.getElementById('admin-domain').value = config.adminUiDomain || '';
3432
+ document.getElementById('base-domain').value = stripProtocol(config.tenant?.baseDomain || config.apiDomain);
3433
+ document.getElementById('login-domain').value = stripProtocol(config.loginUiDomain);
3434
+ document.getElementById('admin-domain').value = stripProtocol(config.adminUiDomain);
3384
3435
  document.getElementById('tenant-name').value = config.tenant?.name || 'default';
3385
3436
  document.getElementById('tenant-display').value = config.tenant?.displayName || 'Default Tenant';
3386
3437
  document.getElementById('naked-domain').checked = config.tenant?.nakedDomain || false;
3438
+ if (document.getElementById('user-id-format')) {
3439
+ document.getElementById('user-id-format').value = config.tenant?.userIdFormat || 'nanoid';
3440
+ }
3441
+ if (document.getElementById('primary-tenant')) {
3442
+ document.getElementById('primary-tenant').value = config.tenant?.primaryTenant || '';
3443
+ }
3387
3444
 
3388
3445
  // Set component checkboxes
3389
3446
  if (document.getElementById('comp-login-ui')) {
@@ -3402,6 +3459,14 @@ export function getHtmlTemplate(sessionToken, manageOnly, locale = 'en', transla
3402
3459
  document.getElementById('comp-vc').checked = components.vc === true;
3403
3460
  }
3404
3461
 
3462
+ // Restore domain check UI if custom domain is set
3463
+ const loadedBaseDomain = document.getElementById('base-domain').value.trim();
3464
+ if (loadedBaseDomain && /^[a-z0-9][a-z0-9.-]*\\.[a-z]{2,}$/i.test(loadedBaseDomain)) {
3465
+ document.getElementById('domain-check-row').style.display = 'flex';
3466
+ // Auto-trigger zone check for loaded domain
3467
+ setTimeout(() => document.getElementById('check-domain-btn').click(), 300);
3468
+ }
3469
+
3405
3470
  // Trigger env input to update preview/default labels
3406
3471
  document.getElementById('env').dispatchEvent(new Event('input'));
3407
3472
 
@@ -3420,7 +3485,7 @@ export function getHtmlTemplate(sessionToken, manageOnly, locale = 'en', transla
3420
3485
  const env = document.getElementById('env').value.trim().toLowerCase().replace(/[^a-z0-9-]/g, '') || '{env}';
3421
3486
  const baseDomain = document.getElementById('base-domain').value.trim();
3422
3487
  const nakedDomain = document.getElementById('naked-domain').checked;
3423
- const tenantName = document.getElementById('tenant-name').value.trim() || 'default';
3488
+ const tenantName = document.getElementById('tenant-name').value.trim();
3424
3489
  const loginDomain = document.getElementById('login-domain').value.trim();
3425
3490
  const adminDomain = document.getElementById('admin-domain').value.trim();
3426
3491
 
@@ -3457,7 +3522,9 @@ export function getHtmlTemplate(sessionToken, manageOnly, locale = 'en', transla
3457
3522
  if (nakedDomain) {
3458
3523
  document.getElementById('preview-issuer').textContent = 'https://' + baseDomain;
3459
3524
  } else {
3460
- document.getElementById('preview-issuer').textContent = 'https://' + tenantName + '.' + baseDomain;
3525
+ // Multi-tenant: show placeholder or actual tenant name
3526
+ const tenantDisplay = tenantName || '{tenant}';
3527
+ document.getElementById('preview-issuer').textContent = 'https://' + tenantDisplay + '.' + baseDomain;
3461
3528
  }
3462
3529
  } else {
3463
3530
  // Workers.dev - no tenant prefix (wildcard subdomains not supported)
@@ -3504,6 +3571,32 @@ export function getHtmlTemplate(sessionToken, manageOnly, locale = 'en', transla
3504
3571
  }
3505
3572
  });
3506
3573
 
3574
+ // Validate environment name on blur
3575
+ const envInput = document.getElementById('env');
3576
+ const envError = document.getElementById('env-error');
3577
+ function validateEnvName(value) {
3578
+ return /^[a-z][a-z0-9-]*$/.test(value);
3579
+ }
3580
+ envInput.addEventListener('blur', () => {
3581
+ const value = envInput.value.trim();
3582
+ if (value && !validateEnvName(value)) {
3583
+ envInput.style.borderColor = 'var(--error)';
3584
+ if (envError) envError.style.display = 'block';
3585
+ } else {
3586
+ envInput.style.borderColor = '';
3587
+ if (envError) envError.style.display = 'none';
3588
+ }
3589
+ });
3590
+ envInput.addEventListener('input', () => {
3591
+ if (envInput.style.borderColor) {
3592
+ const value = envInput.value.trim();
3593
+ if (!value || validateEnvName(value)) {
3594
+ envInput.style.borderColor = '';
3595
+ if (envError) envError.style.display = 'none';
3596
+ }
3597
+ }
3598
+ });
3599
+
3507
3600
  // Update UI based on base domain presence
3508
3601
  function updateBaseDomainUI() {
3509
3602
  const baseDomain = document.getElementById('base-domain').value.trim();
@@ -3513,6 +3606,9 @@ export function getHtmlTemplate(sessionToken, manageOnly, locale = 'en', transla
3513
3606
  const workersDevNote = document.getElementById('workers-dev-note');
3514
3607
  const tenantWorkersNote = document.getElementById('tenant-workers-note');
3515
3608
  const tenantFields = document.getElementById('tenant-fields');
3609
+ const tenantNameInput = document.getElementById('tenant-name');
3610
+ const primaryTenantRow = document.getElementById('primary-tenant-row');
3611
+ const primaryTenantInput = document.getElementById('primary-tenant');
3516
3612
 
3517
3613
  if (baseDomain) {
3518
3614
  // Custom domain - enable tenant subdomain options
@@ -3521,10 +3617,13 @@ export function getHtmlTemplate(sessionToken, manageOnly, locale = 'en', transla
3521
3617
  nakedDomainHint.style.display = 'block';
3522
3618
  workersDevNote.style.display = 'none';
3523
3619
  tenantWorkersNote.style.display = 'none';
3620
+ tenantNameInput.disabled = false;
3621
+ tenantNameInput.readOnly = false;
3524
3622
  // Show tenant fields if naked domain is not checked
3525
3623
  if (!nakedDomainCheckbox.checked) {
3526
3624
  tenantFields.style.display = 'block';
3527
3625
  }
3626
+ primaryTenantRow.style.display = nakedDomainCheckbox.checked ? 'block' : 'none';
3528
3627
  } else {
3529
3628
  // Workers.dev - tenant subdomains not supported
3530
3629
  nakedDomainCheckbox.disabled = true;
@@ -3533,7 +3632,12 @@ export function getHtmlTemplate(sessionToken, manageOnly, locale = 'en', transla
3533
3632
  nakedDomainHint.style.display = 'none';
3534
3633
  workersDevNote.style.display = 'block';
3535
3634
  tenantWorkersNote.style.display = 'block';
3536
- tenantFields.style.display = 'block'; // Show tenant fields (for internal use)
3635
+ tenantFields.style.display = 'block';
3636
+ tenantNameInput.value = 'default';
3637
+ tenantNameInput.disabled = true;
3638
+ tenantNameInput.readOnly = true;
3639
+ primaryTenantInput.value = '';
3640
+ primaryTenantRow.style.display = 'none';
3537
3641
  }
3538
3642
  }
3539
3643
 
@@ -3541,6 +3645,68 @@ export function getHtmlTemplate(sessionToken, manageOnly, locale = 'en', transla
3541
3645
  document.getElementById('base-domain').addEventListener('input', () => {
3542
3646
  updateBaseDomainUI();
3543
3647
  updatePreview();
3648
+ // Show/hide domain check row
3649
+ const domainCheckRow = document.getElementById('domain-check-row');
3650
+ const baseDomain = document.getElementById('base-domain').value.trim();
3651
+ if (baseDomain && /^[a-z0-9][a-z0-9.-]*\\.[a-z]{2,}$/i.test(baseDomain)) {
3652
+ domainCheckRow.style.display = 'flex';
3653
+ } else {
3654
+ domainCheckRow.style.display = 'none';
3655
+ document.getElementById('domain-check-status').textContent = '';
3656
+ document.getElementById('custom-domain-binding-row').style.display = 'none';
3657
+ }
3658
+ });
3659
+
3660
+ // Check Domain button handler
3661
+ let domainZoneId = null;
3662
+ document.getElementById('check-domain-btn').addEventListener('click', async () => {
3663
+ const domain = document.getElementById('base-domain').value.trim();
3664
+ if (!domain) return;
3665
+
3666
+ const statusEl = document.getElementById('domain-check-status');
3667
+ const bindingRow = document.getElementById('custom-domain-binding-row');
3668
+ statusEl.textContent = t('domain.checkingZone', { domain });
3669
+ statusEl.style.color = 'var(--text-muted)';
3670
+ domainZoneId = null;
3671
+
3672
+ try {
3673
+ const result = await api('/cloudflare/check-zone', {
3674
+ method: 'POST',
3675
+ body: { domain },
3676
+ });
3677
+
3678
+ if (result.found) {
3679
+ statusEl.textContent = '✓ ' + t('domain.zoneFound', { zone: result.zone.name, status: result.zone.status });
3680
+ statusEl.style.color = 'var(--success, #22c55e)';
3681
+ bindingRow.style.display = 'flex';
3682
+ domainZoneId = result.zone.id;
3683
+ } else {
3684
+ const errorMsg = result.error
3685
+ ? '⚠ ' + t('domain.zoneCheckFailed') + ': ' + result.error
3686
+ : '⚠ ' + t('domain.zoneNotFound', { zone: domain });
3687
+ statusEl.textContent = errorMsg;
3688
+ statusEl.style.color = 'var(--warning, #d97706)';
3689
+ bindingRow.style.display = 'none';
3690
+ domainZoneId = null;
3691
+ }
3692
+ } catch (e) {
3693
+ statusEl.textContent = '⚠ ' + t('domain.zoneCheckFailed');
3694
+ statusEl.style.color = 'var(--warning, #d97706)';
3695
+ bindingRow.style.display = 'none';
3696
+ domainZoneId = null;
3697
+ }
3698
+ });
3699
+
3700
+ // Auto-check domain on blur (debounced)
3701
+ let domainCheckTimer;
3702
+ document.getElementById('base-domain').addEventListener('blur', () => {
3703
+ clearTimeout(domainCheckTimer);
3704
+ domainCheckTimer = setTimeout(() => {
3705
+ const domain = document.getElementById('base-domain').value.trim();
3706
+ if (domain && /^[a-z0-9][a-z0-9.-]*\\.[a-z]{2,}$/i.test(domain)) {
3707
+ document.getElementById('check-domain-btn').click();
3708
+ }
3709
+ }, 500);
3544
3710
  });
3545
3711
 
3546
3712
  // Initial UI state
@@ -3549,12 +3715,15 @@ export function getHtmlTemplate(sessionToken, manageOnly, locale = 'en', transla
3549
3715
  // Naked domain toggle - show/hide tenant name field and update placeholder
3550
3716
  document.getElementById('naked-domain').addEventListener('change', (e) => {
3551
3717
  const tenantFields = document.getElementById('tenant-fields');
3718
+ const primaryTenantRow = document.getElementById('primary-tenant-row');
3552
3719
  const baseDomainInput = document.getElementById('base-domain');
3553
3720
  if (e.target.checked) {
3554
3721
  tenantFields.style.display = 'none';
3722
+ primaryTenantRow.style.display = 'block';
3555
3723
  baseDomainInput.placeholder = 'example.com';
3556
3724
  } else {
3557
3725
  tenantFields.style.display = 'block';
3726
+ primaryTenantRow.style.display = 'none';
3558
3727
  baseDomainInput.placeholder = 'oidc.example.com';
3559
3728
  }
3560
3729
  updatePreview();
@@ -3597,11 +3766,15 @@ export function getHtmlTemplate(sessionToken, manageOnly, locale = 'en', transla
3597
3766
 
3598
3767
  document.getElementById('btn-configure').addEventListener('click', async () => {
3599
3768
  // Get and validate environment name
3600
- let env = document.getElementById('env').value.toLowerCase().replace(/[^a-z0-9-]/g, '');
3601
- if (!env) {
3602
- alert('Please enter a valid environment name');
3769
+ const envRaw = document.getElementById('env').value.trim();
3770
+ if (!envRaw || !validateEnvName(envRaw)) {
3771
+ document.getElementById('env').style.borderColor = 'var(--error)';
3772
+ const errEl = document.getElementById('env-error');
3773
+ if (errEl) errEl.style.display = 'block';
3774
+ document.getElementById('env').focus();
3603
3775
  return;
3604
3776
  }
3777
+ const env = envRaw;
3605
3778
 
3606
3779
  // Check if environment already exists
3607
3780
  const configureBtn = document.getElementById('btn-configure');
@@ -3627,26 +3800,32 @@ export function getHtmlTemplate(sessionToken, manageOnly, locale = 'en', transla
3627
3800
  }
3628
3801
 
3629
3802
  const baseDomain = document.getElementById('base-domain').value.trim();
3630
- const nakedDomain = document.getElementById('naked-domain').checked;
3631
- const tenantName = document.getElementById('tenant-name').value.trim() || 'default';
3803
+ const hasCustomApiDomain = !!baseDomain;
3804
+ const nakedDomain = hasCustomApiDomain && document.getElementById('naked-domain').checked;
3805
+ const tenantName = hasCustomApiDomain
3806
+ ? (document.getElementById('tenant-name').value.trim() || 'default')
3807
+ : 'default';
3632
3808
  const tenantDisplayName = document.getElementById('tenant-display').value.trim() || 'Default Tenant';
3809
+ const userIdFormat = document.getElementById('user-id-format').value || 'nanoid';
3810
+ const primaryTenant = hasCustomApiDomain && nakedDomain
3811
+ ? (document.getElementById('primary-tenant').value.trim() || undefined)
3812
+ : undefined;
3633
3813
  const loginDomain = document.getElementById('login-domain').value.trim();
3634
3814
  const adminDomain = document.getElementById('admin-domain').value.trim();
3635
3815
 
3636
- // API domain = base domain or null (workers.dev fallback)
3637
- const apiDomain = baseDomain || null;
3638
-
3639
3816
  config = {
3640
3817
  env,
3641
- apiDomain,
3818
+ apiDomain: baseDomain || null,
3642
3819
  loginUiDomain: loginDomain || null,
3643
3820
  adminUiDomain: adminDomain || null,
3644
3821
  tenant: {
3645
- name: nakedDomain ? null : tenantName, // null for naked domain
3822
+ name: tenantName,
3646
3823
  displayName: tenantDisplayName,
3647
- multiTenant: baseDomain ? true : false, // Only multi-tenant with custom domain
3648
- baseDomain: baseDomain || undefined,
3649
- nakedDomain: baseDomain ? nakedDomain : false,
3824
+ multiTenant: hasCustomApiDomain,
3825
+ baseDomain: hasCustomApiDomain ? baseDomain : undefined,
3826
+ nakedDomain: nakedDomain,
3827
+ userIdFormat: userIdFormat,
3828
+ primaryTenant: primaryTenant,
3650
3829
  },
3651
3830
  components: {
3652
3831
  api: true,
@@ -3661,15 +3840,18 @@ export function getHtmlTemplate(sessionToken, manageOnly, locale = 'en', transla
3661
3840
  };
3662
3841
 
3663
3842
  // Create default config with component settings
3843
+ const customDomainBinding = document.getElementById('custom-domain-binding')?.checked ?? false;
3664
3844
  await api('/config/default', {
3665
3845
  method: 'POST',
3666
3846
  body: {
3667
3847
  env,
3668
- apiDomain,
3848
+ apiDomain: config.apiDomain,
3669
3849
  loginUiDomain: loginDomain,
3670
3850
  adminUiDomain: adminDomain,
3671
3851
  tenant: config.tenant,
3672
3852
  components: config.components,
3853
+ zoneId: domainZoneId || null,
3854
+ customDomainBinding: config.apiDomain ? customDomainBinding : false,
3673
3855
  },
3674
3856
  });
3675
3857
 
@@ -3956,7 +4138,7 @@ export function getHtmlTemplate(sessionToken, manageOnly, locale = 'en', transla
3956
4138
 
3957
4139
  const result = await api('/provision', {
3958
4140
  method: 'POST',
3959
- body: { env: config.env, databaseConfig: config.database },
4141
+ body: { env: config.env, databaseConfig: config.database, createR2: true },
3960
4142
  });
3961
4143
 
3962
4144
  // Stop polling
@@ -3969,6 +4151,10 @@ export function getHtmlTemplate(sessionToken, manageOnly, locale = 'en', transla
3969
4151
  // Final progress update
3970
4152
  updateProgressUI('provision', totalResources, totalResources, '✅ Provisioning complete!');
3971
4153
  output.textContent += '\\n✅ Provisioning complete!\\n';
4154
+ if (result.savedPaths) {
4155
+ output.textContent += '📁 Config: ' + result.savedPaths.config + '\\n';
4156
+ output.textContent += '📁 Lock: ' + result.savedPaths.lock + '\\n';
4157
+ }
3972
4158
  scrollToBottom(log);
3973
4159
  status.textContent = t('web.status.complete');
3974
4160
  status.className = 'status-badge status-success';
@@ -4128,11 +4314,14 @@ export function getHtmlTemplate(sessionToken, manageOnly, locale = 'en', transla
4128
4314
  apiUrl = 'https://' + workersDomain;
4129
4315
  }
4130
4316
  // Login UI URL for setup page (setup page is in Login UI, not API)
4317
+ const loginUiEnabled = config.components?.loginUi !== false;
4131
4318
  const loginPagesDomain = config.env + '-ar-login-ui.pages.dev';
4132
- const loginUiUrl = config.loginUiDomain ? 'https://' + config.loginUiDomain : 'https://' + loginPagesDomain;
4319
+ const loginUiUrl = loginUiEnabled
4320
+ ? (config.loginUiDomain ? 'https://' + config.loginUiDomain : 'https://' + loginPagesDomain)
4321
+ : null;
4133
4322
 
4134
4323
  output.textContent += ' API URL: ' + apiUrl + '\\n';
4135
- output.textContent += ' Login UI URL: ' + loginUiUrl + '\\n';
4324
+ output.textContent += ' Login UI URL: ' + (loginUiUrl || 'Not deployed') + '\\n';
4136
4325
  output.textContent += ' Keys Dir: .authrim-keys/' + config.env + '/\\n';
4137
4326
  scrollToBottom(log);
4138
4327
 
@@ -4215,19 +4404,32 @@ export function getHtmlTemplate(sessionToken, manageOnly, locale = 'en', transla
4215
4404
  // Workers.dev - no tenant prefix (wildcard subdomains not supported)
4216
4405
  apiUrl = 'https://' + workersDomain;
4217
4406
  }
4218
- const loginUrl = config.loginUiDomain ? 'https://' + config.loginUiDomain : 'https://' + loginPagesDomain;
4219
- const adminUrl = config.adminUiDomain ? 'https://' + config.adminUiDomain : 'https://' + adminPagesDomain;
4407
+ const loginUiEnabled = config.components?.loginUi !== false;
4408
+ const loginUrl = loginUiEnabled
4409
+ ? (config.loginUiDomain ? 'https://' + config.loginUiDomain : 'https://' + loginPagesDomain)
4410
+ : null;
4411
+ const adminUrl = (config.adminUiDomain ? 'https://' + config.adminUiDomain : 'https://' + adminPagesDomain) + '/admin';
4220
4412
 
4221
4413
  // Clear and rebuild URLs section safely
4222
4414
  urlsEl.textContent = '';
4223
4415
 
4224
4416
  // API URL with OIDC Discovery link
4225
- urlsEl.appendChild(createUrlItem('API (Issuer):', apiUrl));
4417
+ urlsEl.appendChild(createUrlItem('API (Issuer):', apiUrl, apiUrl));
4226
4418
  const discoveryUrl = apiUrl + '/.well-known/openid-configuration';
4227
- urlsEl.appendChild(createUrlItem('Discovery:', discoveryUrl));
4228
-
4229
- urlsEl.appendChild(createUrlItem('Login UI:', loginUrl));
4230
- urlsEl.appendChild(createUrlItem('Admin UI:', adminUrl));
4419
+ urlsEl.appendChild(createUrlItem('Discovery:', discoveryUrl, discoveryUrl));
4420
+
4421
+ urlsEl.appendChild(createUrlItem('Login UI:', loginUrl || t('web.status.notDeployed'), loginUrl || undefined));
4422
+ urlsEl.appendChild(createUrlItem('Admin UI:', adminUrl, adminUrl));
4423
+
4424
+ // Show custom domain propagation note when any custom domain is set
4425
+ if (config.apiDomain || config.loginUiDomain || config.adminUiDomain) {
4426
+ const domainNoteDiv = document.createElement('div');
4427
+ domainNoteDiv.className = 'hint-box';
4428
+ domainNoteDiv.style.marginTop = '0.75rem';
4429
+ domainNoteDiv.setAttribute('data-i18n', 'web.complete.customDomainNote');
4430
+ domainNoteDiv.textContent = t('web.complete.customDomainNote');
4431
+ urlsEl.appendChild(domainNoteDiv);
4432
+ }
4231
4433
 
4232
4434
  // Create Admin Setup section (separate, prominent box)
4233
4435
  const adminSetupSection = document.createElement('div');
@@ -4242,50 +4444,123 @@ export function getHtmlTemplate(sessionToken, manageOnly, locale = 'en', transla
4242
4444
  const day = expiresDate.getDate();
4243
4445
  const hours = expiresDate.getHours().toString().padStart(2, '0');
4244
4446
  const minutes = expiresDate.getMinutes().toString().padStart(2, '0');
4245
- expiresText = \`on \${month}/\${day} at \${hours}:\${minutes}\`;
4447
+ expiresText = \`\${month}/\${day} \${hours}:\${minutes}\`;
4246
4448
  }
4247
4449
 
4248
- adminSetupSection.innerHTML = \`
4249
- <div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem;">
4250
- <span style="font-size: 1.5rem;">🔐</span>
4251
- <h4 style="margin: 0; font-size: 1.1rem; font-weight: 600; color: var(--primary);">Admin Account Setup</h4>
4252
- <span style="background: var(--warning); color: white; font-size: 0.7rem; padding: 0.2rem 0.5rem; border-radius: 4px; font-weight: 600;">IMPORTANT</span>
4253
- </div>
4254
- <p style="margin: 0 0 0.75rem; font-size: 0.9rem; color: var(--text-muted);">
4255
- Register your first administrator account with Passkey authentication:
4256
- </p>
4257
- <div style="display: flex; gap: 0.5rem; align-items: center;">
4258
- <input type="text" value="\${result.setupUrl}" readonly style="flex: 1; min-width: 200px; padding: 0.625rem 0.75rem; border: 1px solid var(--border); border-radius: 8px; font-family: var(--font-mono); font-size: 0.8rem; background: var(--card-bg); color: var(--text);">
4259
- <button class="btn-secondary" onclick="navigator.clipboard.writeText('\${result.setupUrl}'); this.textContent='✓ Copied'; setTimeout(() => this.textContent='📋 Copy', 2000);" style="white-space: nowrap;">📋 Copy</button>
4260
- </div>
4261
- <div style="text-align: center; margin-top: 1rem;">
4262
- <a href="\${result.setupUrl}" target="_blank" class="btn-primary">🔑 Open Setup</a>
4263
- </div>
4264
- <div class="hint-box" style="margin-top: 0.75rem;">
4265
- ⚠️ This URL can only be used <strong>once</strong> and expires <strong>\${expiresText}</strong>.
4266
- </div>
4267
- \`;
4450
+ // Header row
4451
+ const headerDiv = document.createElement('div');
4452
+ headerDiv.style.cssText = 'display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem;';
4453
+ const iconSpan = document.createElement('span');
4454
+ iconSpan.style.fontSize = '1.5rem';
4455
+ iconSpan.textContent = '🔐';
4456
+ const titleH4 = document.createElement('h4');
4457
+ titleH4.style.cssText = 'margin: 0; font-size: 1.1rem; font-weight: 600; color: var(--primary);';
4458
+ titleH4.textContent = t('web.complete.adminAccountTitle');
4459
+ titleH4.setAttribute('data-i18n', 'web.complete.adminAccountTitle');
4460
+ const importantBadge = document.createElement('span');
4461
+ importantBadge.style.cssText = 'background: var(--warning); color: white; font-size: 0.7rem; padding: 0.2rem 0.5rem; border-radius: 4px; font-weight: 600;';
4462
+ importantBadge.textContent = t('web.complete.adminAccountImportant');
4463
+ importantBadge.setAttribute('data-i18n', 'web.complete.adminAccountImportant');
4464
+ headerDiv.appendChild(iconSpan);
4465
+ headerDiv.appendChild(titleH4);
4466
+ headerDiv.appendChild(importantBadge);
4467
+
4468
+ // Description
4469
+ const descP = document.createElement('p');
4470
+ descP.style.cssText = 'margin: 0 0 0.75rem; font-size: 0.9rem; color: var(--text-muted);';
4471
+ descP.textContent = t('web.complete.adminAccountDesc');
4472
+ descP.setAttribute('data-i18n', 'web.complete.adminAccountDesc');
4473
+
4474
+ // URL input + copy button row
4475
+ const inputRow = document.createElement('div');
4476
+ inputRow.style.cssText = 'display: flex; gap: 0.5rem; align-items: center;';
4477
+ const urlInput = document.createElement('input');
4478
+ urlInput.type = 'text';
4479
+ urlInput.value = result.setupUrl;
4480
+ urlInput.readOnly = true;
4481
+ urlInput.style.cssText = 'flex: 1; min-width: 200px; padding: 0.625rem 0.75rem; border: 1px solid var(--border); border-radius: 8px; font-family: var(--font-mono); font-size: 0.8rem; background: var(--card-bg); color: var(--text);';
4482
+ const copyBtn = document.createElement('button');
4483
+ copyBtn.className = 'btn-secondary';
4484
+ copyBtn.style.whiteSpace = 'nowrap';
4485
+ copyBtn.textContent = t('web.complete.copy');
4486
+ copyBtn.setAttribute('data-i18n', 'web.complete.copy');
4487
+ const setupUrlForCopy = result.setupUrl;
4488
+ copyBtn.addEventListener('click', () => {
4489
+ navigator.clipboard.writeText(setupUrlForCopy);
4490
+ copyBtn.removeAttribute('data-i18n'); // prevent updateAllTranslations from overwriting "Copied"
4491
+ copyBtn.textContent = t('web.complete.copied');
4492
+ setTimeout(() => {
4493
+ copyBtn.textContent = t('web.complete.copy');
4494
+ copyBtn.setAttribute('data-i18n', 'web.complete.copy');
4495
+ }, 2000);
4496
+ });
4497
+ inputRow.appendChild(urlInput);
4498
+ inputRow.appendChild(copyBtn);
4499
+
4500
+ // Open Setup button
4501
+ const openDiv = document.createElement('div');
4502
+ openDiv.style.cssText = 'text-align: center; margin-top: 1rem;';
4503
+ const openLink = document.createElement('a');
4504
+ openLink.href = result.setupUrl;
4505
+ openLink.target = '_blank';
4506
+ openLink.className = 'btn-primary';
4507
+ openLink.textContent = t('web.complete.openSetup');
4508
+ openLink.setAttribute('data-i18n', 'web.complete.openSetup');
4509
+ openDiv.appendChild(openLink);
4510
+
4511
+ // Warning hint (uses innerHTML for <strong> tags — not updated on language change)
4512
+ const hintDiv = document.createElement('div');
4513
+ hintDiv.className = 'hint-box';
4514
+ hintDiv.style.marginTop = '0.75rem';
4515
+ const warningTemplate = t('web.complete.urlWarning', { date: expiresText });
4516
+ // warningTemplate may contain <strong> tags — parse safely
4517
+ const warningPrefix = document.createTextNode('⚠️ ');
4518
+ hintDiv.appendChild(warningPrefix);
4519
+ const warningSpan = document.createElement('span');
4520
+ warningSpan.innerHTML = warningTemplate; // safe: value from our own translation strings only
4521
+ hintDiv.appendChild(warningSpan);
4522
+
4523
+ adminSetupSection.appendChild(headerDiv);
4524
+ adminSetupSection.appendChild(descP);
4525
+ adminSetupSection.appendChild(inputRow);
4526
+ adminSetupSection.appendChild(openDiv);
4527
+ adminSetupSection.appendChild(hintDiv);
4268
4528
  } else {
4269
- // Show message when setup URL is missing
4270
- let debugInfo = '';
4529
+ // Header row
4530
+ const headerDiv = document.createElement('div');
4531
+ headerDiv.style.cssText = 'display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;';
4532
+ const iconSpan = document.createElement('span');
4533
+ iconSpan.style.fontSize = '1.5rem';
4534
+ iconSpan.textContent = '🔐';
4535
+ const titleH4 = document.createElement('h4');
4536
+ titleH4.style.cssText = 'margin: 0; font-size: 1.1rem; font-weight: 600; color: var(--text-muted);';
4537
+ titleH4.textContent = t('web.complete.adminAccountTitle');
4538
+ titleH4.setAttribute('data-i18n', 'web.complete.adminAccountTitle');
4539
+ headerDiv.appendChild(iconSpan);
4540
+ headerDiv.appendChild(titleH4);
4541
+
4542
+ const descP = document.createElement('p');
4543
+ descP.style.cssText = 'margin: 0; font-size: 0.9rem; color: var(--text-muted);';
4544
+ descP.textContent = t('web.complete.adminSetupUnavailable');
4545
+ descP.setAttribute('data-i18n', 'web.complete.adminSetupUnavailable');
4546
+
4547
+ adminSetupSection.appendChild(headerDiv);
4548
+ adminSetupSection.appendChild(descP);
4549
+
4271
4550
  if (result && result.adminSetupDebug) {
4272
4551
  const debug = result.adminSetupDebug;
4552
+ const debugP = document.createElement('p');
4553
+ debugP.style.cssText = 'margin: 0.5rem 0 0; font-size: 0.85rem;';
4273
4554
  if (debug.alreadyCompleted) {
4274
- debugInfo = '<p style="margin: 0.5rem 0 0; font-size: 0.85rem; color: var(--text-muted);">Admin setup has already been completed for this environment.</p>';
4555
+ debugP.style.color = 'var(--text-muted)';
4556
+ debugP.textContent = t('web.complete.adminSetupUnavailable');
4557
+ debugP.setAttribute('data-i18n', 'web.complete.adminSetupUnavailable');
4275
4558
  } else if (debug.error) {
4276
- debugInfo = '<p style="margin: 0.5rem 0 0; font-size: 0.85rem; color: var(--error);">Error: ' + debug.error + '</p>';
4559
+ debugP.style.color = 'var(--error)';
4560
+ debugP.textContent = 'Error: ' + debug.error;
4277
4561
  }
4562
+ if (debugP.textContent) adminSetupSection.appendChild(debugP);
4278
4563
  }
4279
- adminSetupSection.innerHTML = \`
4280
- <div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;">
4281
- <span style="font-size: 1.5rem;">🔐</span>
4282
- <h4 style="margin: 0; font-size: 1.1rem; font-weight: 600; color: var(--text-muted);">Admin Account Setup</h4>
4283
- </div>
4284
- <p style="margin: 0; font-size: 0.9rem; color: var(--text-muted);">
4285
- Setup URL not available. You can configure admin access from the Admin UI later.
4286
- </p>
4287
- \${debugInfo}
4288
- \`;
4289
4564
  }
4290
4565
  urlsEl.parentNode.insertBefore(adminSetupSection, urlsEl.nextSibling);
4291
4566
 
@@ -4421,22 +4696,13 @@ export function getHtmlTemplate(sessionToken, manageOnly, locale = 'en', transla
4421
4696
  const now = new Date().toISOString();
4422
4697
  const env = config.env;
4423
4698
 
4424
- // Calculate auto-generated URLs
4425
- const workersDomain = env + '-ar-router.workers.dev';
4699
+ // Calculate auto-generated URLs (use workersSubdomain if available for full-form URL)
4700
+ const workersDomain = workersSubdomain
4701
+ ? env + '-ar-router.' + workersSubdomain + '.workers.dev'
4702
+ : env + '-ar-router.workers.dev';
4426
4703
  const loginPagesDomain = env + '-ar-login-ui.pages.dev';
4427
4704
  const adminPagesDomain = env + '-ar-admin-ui.pages.dev';
4428
4705
 
4429
- // Build issuer URL based on tenant settings
4430
- let issuerAutoUrl = 'https://' + workersDomain;
4431
- if (config.tenant && config.tenant.baseDomain) {
4432
- if (config.tenant.nakedDomain) {
4433
- issuerAutoUrl = 'https://' + config.tenant.baseDomain;
4434
- } else {
4435
- const tenantName = config.tenant.name || 'default';
4436
- issuerAutoUrl = 'https://' + tenantName + '.' + config.tenant.baseDomain;
4437
- }
4438
- }
4439
-
4440
4706
  // Build config in AuthrimConfigSchema format
4441
4707
  const configToSave = {
4442
4708
  version: '1.0.0',
@@ -4448,7 +4714,9 @@ export function getHtmlTemplate(sessionToken, manageOnly, locale = 'en', transla
4448
4714
  urls: {
4449
4715
  api: {
4450
4716
  custom: config.apiDomain || null,
4451
- auto: config.apiDomain ? issuerAutoUrl : 'https://' + workersDomain,
4717
+ // api.auto must always be the workers.dev URL (used for proxy backend and CORS).
4718
+ // The custom domain (issuer URL) belongs in api.custom, not api.auto.
4719
+ auto: 'https://' + workersDomain,
4452
4720
  },
4453
4721
  loginUi: {
4454
4722
  custom: config.loginUiDomain || null,
@@ -4464,6 +4732,9 @@ export function getHtmlTemplate(sessionToken, manageOnly, locale = 'en', transla
4464
4732
  displayName: config.tenant?.displayName || 'Default Tenant',
4465
4733
  multiTenant: config.tenant?.multiTenant || false,
4466
4734
  baseDomain: config.tenant?.baseDomain || undefined,
4735
+ nakedDomain: config.tenant?.nakedDomain ?? false,
4736
+ userIdFormat: config.tenant?.userIdFormat || 'nanoid',
4737
+ primaryTenant: config.tenant?.primaryTenant || undefined,
4467
4738
  },
4468
4739
  components: config.components || {
4469
4740
  api: true,
@@ -4497,6 +4768,9 @@ export function getHtmlTemplate(sessionToken, manageOnly, locale = 'en', transla
4497
4768
  if (!configToSave.tenant.baseDomain) {
4498
4769
  delete configToSave.tenant.baseDomain;
4499
4770
  }
4771
+ if (!configToSave.tenant.primaryTenant) {
4772
+ delete configToSave.tenant.primaryTenant;
4773
+ }
4500
4774
  if (!configToSave.features.email.fromAddress) {
4501
4775
  delete configToSave.features.email.fromAddress;
4502
4776
  }
@@ -4691,6 +4965,7 @@ export function getHtmlTemplate(sessionToken, manageOnly, locale = 'en', transla
4691
4965
  selectedEnvForDetail = env;
4692
4966
 
4693
4967
  document.getElementById('detail-env-name').textContent = env.env;
4968
+ renderEnvDetailUrls(env);
4694
4969
 
4695
4970
  // Render resource lists with loading state
4696
4971
  renderResourceList('detail-workers-list', 'detail-workers-count', env.workers, 'name', 'worker');
@@ -4737,6 +5012,118 @@ export function getHtmlTemplate(sessionToken, manageOnly, locale = 'en', transla
4737
5012
  loadResourceDetails(env);
4738
5013
  }
4739
5014
 
5015
+ function stripTrailingSlash(url) {
5016
+ const value = String(url || '');
5017
+ return value.endsWith('/') ? value.slice(0, -1) : value;
5018
+ }
5019
+
5020
+ function createEnvDetailUrlRow(label, url, description) {
5021
+ const row = document.createElement('div');
5022
+ row.style.cssText = 'display: flex; flex-direction: column; gap: 0.35rem; padding: 0.875rem 1rem; background: var(--bg); border: 1px solid var(--border); border-radius: 8px;';
5023
+
5024
+ const top = document.createElement('div');
5025
+ top.style.cssText = 'display: flex; justify-content: space-between; gap: 1rem; align-items: center; flex-wrap: wrap;';
5026
+
5027
+ const labelEl = document.createElement('div');
5028
+ labelEl.style.cssText = 'font-weight: 600;';
5029
+ labelEl.textContent = label;
5030
+ top.appendChild(labelEl);
5031
+
5032
+ const open = document.createElement('a');
5033
+ open.href = url;
5034
+ open.target = '_blank';
5035
+ open.rel = 'noopener noreferrer';
5036
+ open.className = 'btn-secondary';
5037
+ open.style.cssText = 'padding: 0.35rem 0.75rem; font-size: 0.8rem; white-space: nowrap;';
5038
+ open.textContent = 'Open';
5039
+ top.appendChild(open);
5040
+
5041
+ const urlEl = document.createElement('a');
5042
+ urlEl.href = url;
5043
+ urlEl.target = '_blank';
5044
+ urlEl.rel = 'noopener noreferrer';
5045
+ urlEl.style.cssText = 'font-family: var(--font-mono); font-size: 0.85rem; color: var(--primary); word-break: break-all;';
5046
+ urlEl.textContent = url;
5047
+
5048
+ row.appendChild(top);
5049
+ row.appendChild(urlEl);
5050
+
5051
+ if (description) {
5052
+ const descEl = document.createElement('div');
5053
+ descEl.style.cssText = 'font-size: 0.8rem; color: var(--text-muted);';
5054
+ descEl.textContent = description;
5055
+ row.appendChild(descEl);
5056
+ }
5057
+
5058
+ return row;
5059
+ }
5060
+
5061
+ function buildEnvDetailUrls(envName, config) {
5062
+ const workersDomain = workersSubdomain
5063
+ ? envName + '-ar-router.' + workersSubdomain + '.workers.dev'
5064
+ : envName + '-ar-router.workers.dev';
5065
+ const fallbackIssuer = 'https://' + workersDomain;
5066
+
5067
+ const tenant = config?.tenant || {};
5068
+ const tenantName = tenant.name || 'default';
5069
+ const baseDomain = tenant.baseDomain;
5070
+ const nakedDomain = tenant.nakedDomain === true;
5071
+
5072
+ let issuerUrl = fallbackIssuer;
5073
+ if (baseDomain) {
5074
+ issuerUrl = nakedDomain
5075
+ ? 'https://' + baseDomain
5076
+ : 'https://' + tenantName + '.' + baseDomain;
5077
+ } else if (config?.urls?.api?.custom) {
5078
+ issuerUrl = stripTrailingSlash(config.urls.api.custom);
5079
+ }
5080
+
5081
+ const loginBaseUrl = stripTrailingSlash(
5082
+ config?.urls?.loginUi?.custom || 'https://' + envName + '-ar-login-ui.pages.dev'
5083
+ );
5084
+ const adminBaseUrl = stripTrailingSlash(
5085
+ config?.urls?.adminUi?.custom || 'https://' + envName + '-ar-admin-ui.pages.dev'
5086
+ );
5087
+
5088
+ return [
5089
+ {
5090
+ label: 'Issuer',
5091
+ url: issuerUrl,
5092
+ description: 'Canonical OIDC issuer URL',
5093
+ },
5094
+ {
5095
+ label: 'Login UI',
5096
+ url: loginBaseUrl + '/login',
5097
+ description: 'Login screen entry point',
5098
+ },
5099
+ {
5100
+ label: 'Admin UI',
5101
+ url: adminBaseUrl + '/admin/info',
5102
+ description: 'Admin console entry point',
5103
+ },
5104
+ ];
5105
+ }
5106
+
5107
+ async function renderEnvDetailUrls(env) {
5108
+ const listEl = document.getElementById('detail-url-list');
5109
+ listEl.textContent = '';
5110
+
5111
+ let config = null;
5112
+ try {
5113
+ const configResponse = await api('/config?env=' + encodeURIComponent(env.env));
5114
+ if (configResponse.exists && configResponse.config) {
5115
+ config = configResponse.config;
5116
+ }
5117
+ } catch (error) {
5118
+ console.warn('Failed to load config for env detail URLs:', error);
5119
+ }
5120
+
5121
+ const urls = buildEnvDetailUrls(env.env, config);
5122
+ for (const item of urls) {
5123
+ listEl.appendChild(createEnvDetailUrlRow(item.label, item.url, item.description));
5124
+ }
5125
+ }
5126
+
4740
5127
  // ===========================================
4741
5128
  // Worker Update Functions
4742
5129
  // ===========================================
@@ -5311,28 +5698,37 @@ export function getHtmlTemplate(sessionToken, manageOnly, locale = 'en', transla
5311
5698
  return;
5312
5699
  }
5313
5700
 
5314
- // Find router worker to construct base URL
5315
- const router = selectedEnvForDetail.workers.find(w =>
5316
- w.name.toLowerCase().includes('router')
5317
- );
5318
-
5701
+ // Determine base URL: prefer custom domain from config, fallback to workers.dev
5319
5702
  let baseUrl = '';
5320
- if (router && router.name) {
5321
- // Construct URL from worker name with subdomain
5322
- // Format: https://{worker-name}.{subdomain}.workers.dev
5323
- if (workersSubdomain) {
5324
- baseUrl = 'https://' + router.name + '.' + workersSubdomain + '.workers.dev';
5325
- } else {
5326
- // Fallback without subdomain (shouldn't happen in practice)
5327
- baseUrl = 'https://' + router.name + '.workers.dev';
5703
+
5704
+ // Try to load config to get custom API domain
5705
+ try {
5706
+ const configResponse = await api('/config?env=' + encodeURIComponent(selectedEnvForDetail.env));
5707
+ if (configResponse.exists && configResponse.config) {
5708
+ baseUrl = configResponse.config.urls?.api?.custom || configResponse.config.urls?.api?.auto || '';
5328
5709
  }
5329
- } else {
5330
- // Fallback - ask for URL
5331
- baseUrl = prompt('Enter the base URL for the router (e.g., https://myenv-ar-router.subdomain.workers.dev):');
5332
- if (!baseUrl) {
5333
- btn.disabled = false;
5334
- btn.textContent = '🔐 Start Admin Account Setup with Passkey';
5335
- return;
5710
+ } catch (e) {
5711
+ // Config not available, will fallback to workers.dev
5712
+ }
5713
+
5714
+ // Fallback to workers.dev URL if no config URL found
5715
+ if (!baseUrl) {
5716
+ const router = selectedEnvForDetail.workers.find(w =>
5717
+ w.name.toLowerCase().includes('router')
5718
+ );
5719
+ if (router && router.name) {
5720
+ if (workersSubdomain) {
5721
+ baseUrl = 'https://' + router.name + '.' + workersSubdomain + '.workers.dev';
5722
+ } else {
5723
+ baseUrl = 'https://' + router.name + '.workers.dev';
5724
+ }
5725
+ } else {
5726
+ baseUrl = prompt('Enter the base URL for the router (e.g., https://myenv-ar-router.subdomain.workers.dev):');
5727
+ if (!baseUrl) {
5728
+ btn.disabled = false;
5729
+ btn.textContent = '🔐 Start Admin Account Setup with Passkey';
5730
+ return;
5731
+ }
5336
5732
  }
5337
5733
  }
5338
5734
 
@@ -5342,6 +5738,7 @@ export function getHtmlTemplate(sessionToken, manageOnly, locale = 'en', transla
5342
5738
  body: JSON.stringify({
5343
5739
  kvNamespaceId: configKv.id,
5344
5740
  baseUrl: baseUrl,
5741
+ env: selectedEnvForDetail.env,
5345
5742
  }),
5346
5743
  });
5347
5744