Files

1364 lines
72 KiB
PHP

<?php
declare(strict_types=1);
style('qortal_integration', 'admin');
script('qortal_integration', 'admin');
$settings = $_['settings'];
$notices = [];
$brokerBaseUrl = trim((string)($settings['brokerBaseUrl'] ?? ''));
$brokerInternalApiToken = trim((string)($settings['brokerInternalApiToken'] ?? ''));
$brokerInternalApiTokenEnv = trim((string)(getenv('QORTAL_BROKER_INTERNAL_API_TOKEN') ?: ''));
$externalAuthBaseUrl = trim((string)($settings['externalAuthBaseUrl'] ?? ''));
$externalAuthBaseUrlEnv = trim((string)(getenv('QORTAL_EXTERNAL_AUTH_BASE_URL') ?: ''));
$externalAuthAppId = trim((string)($settings['externalAuthAppId'] ?? ''));
$externalAuthAppSecret = trim((string)($settings['externalAuthAppSecret'] ?? ''));
$externalAuthAppIdEnv = trim((string)(getenv('QORTAL_EXTERNAL_AUTH_APP_ID') ?: ''));
$externalAuthAppSecretEnv = trim((string)(getenv('QORTAL_EXTERNAL_AUTH_APP_SECRET') ?: ''));
$externalAuthDocsUrl = trim((string)($settings['externalAuthDocsUrl'] ?? ''));
$externalAuthNodeUrl = trim((string)($settings['externalAuthNodeUrl'] ?? ''));
$externalAuthNodeUrlEnv = trim((string)(getenv('QORTAL_AUTH_NODE_URL') ?: ''));
$externalAuthNodeApiKey = trim((string)($settings['externalAuthNodeApiKey'] ?? ''));
$externalAuthNodeApiKeyMode = trim((string)($settings['externalAuthNodeApiKeyMode'] ?? ''));
if ($externalAuthNodeApiKeyMode !== 'paths') {
$externalAuthNodeApiKeyMode = 'paths';
}
$externalAuthNodeApiKeyPaths = trim((string)($settings['externalAuthNodeApiKeyPaths'] ?? ''));
$oidcIssuerUrl = trim((string)($settings['oidcIssuerUrl'] ?? ''));
$oidcClientId = trim((string)($settings['oidcClientId'] ?? ''));
$oidcClientSecret = trim((string)($settings['oidcClientSecret'] ?? ''));
$oidcPolicyModeOverride = trim((string)($settings['oidcPolicyMode'] ?? ''));
if ($oidcPolicyModeOverride !== 'link_only' && $oidcPolicyModeOverride !== 'auto_provision') {
$oidcPolicyModeOverride = '';
}
$oidcGuardOverride = trim((string)($settings['oidcAutoProvisionGuard'] ?? ''));
if ($oidcGuardOverride !== 'invite_or_allowlist' && $oidcGuardOverride !== 'off') {
$oidcGuardOverride = '';
}
$oidcRequireEmailOverride = trim((string)($settings['oidcRequireEmailForNewAccount'] ?? ''));
if ($oidcRequireEmailOverride !== 'required' && $oidcRequireEmailOverride !== 'off') {
$oidcRequireEmailOverride = '';
}
$nextcloudPublicUrl = trim((string)($settings['nextcloudPublicUrl'] ?? ''));
$qortalNodeUrl = trim((string)($settings['qortalNodeUrl'] ?? ''));
$qortalNodeApiKey = trim((string)($settings['qortalNodeApiKey'] ?? ''));
$qortalGatewayUrl = trim((string)($settings['qortalGatewayUrl'] ?? ''));
$qortalGatewayAllowInsecure = !empty($settings['qortalGatewayAllowInsecure']);
$filesPublishMaxSizeOverride = trim((string)($settings['filesPublishMaxSizeOverride'] ?? ''));
$filesPublishMaxCountOverride = trim((string)($settings['filesPublishMaxCountOverride'] ?? ''));
$filesPublishLimits = is_array($settings['filesPublishLimits'] ?? null) ? $settings['filesPublishLimits'] : [];
$filesPublishDesiredSource = trim((string)($filesPublishLimits['desiredSource'] ?? ''));
$filesPublishEnvDefaultRaw = trim((string)($filesPublishLimits['envDefaultRaw'] ?? ''));
$filesPublishEnvCountRaw = trim((string)($filesPublishLimits['envCountRaw'] ?? ''));
$filesPublishBrokerLimitRaw = trim((string)($filesPublishLimits['brokerRequestBodyLimitRaw'] ?? ''));
$filesPublishPhpUploadRaw = trim((string)($filesPublishLimits['phpUploadMaxFilesizeRaw'] ?? ''));
$filesPublishPhpPostRaw = trim((string)($filesPublishLimits['phpPostMaxSizeRaw'] ?? ''));
$filesPublishPhpMemoryRaw = trim((string)($filesPublishLimits['phpMemoryLimitRaw'] ?? ''));
$filesPublishEffectiveLabel = trim((string)($filesPublishLimits['effectiveMaxFileLabel'] ?? ''));
$filesPublishBatchLabel = trim((string)($filesPublishLimits['effectiveBatchPayloadLabel'] ?? ''));
$filesPublishEffectiveCountLabel = trim((string)($filesPublishLimits['effectiveMaxImmediateFileCountLabel'] ?? ''));
$filesPublishEffectiveCountSource = trim((string)($filesPublishLimits['effectiveMaxImmediateFileCountSource'] ?? 'default'));
$filesPublishNotes = is_array($filesPublishLimits['notes'] ?? null) ? $filesPublishLimits['notes'] : [];
$qappsNonAdminRestrictionsEnabled = !array_key_exists('qappsNonAdminRestrictionsEnabled', $settings)
|| !empty($settings['qappsNonAdminRestrictionsEnabled']);
$qappsNonAdminRestrictionRules = is_array($settings['qappsNonAdminRestrictionRules'] ?? null)
? $settings['qappsNonAdminRestrictionRules']
: [];
$qappsNonAdminRestrictionRulesJson = json_encode($qappsNonAdminRestrictionRules, JSON_UNESCAPED_SLASHES);
if (!is_string($qappsNonAdminRestrictionRulesJson) || $qappsNonAdminRestrictionRulesJson === '') {
$qappsNonAdminRestrictionRulesJson = '[]';
}
$scBillingManagedByChd = !array_key_exists('scBillingManagedByChd', $settings) || !empty($settings['scBillingManagedByChd']);
$scAdvancedAdminEnabled = !empty($settings['scAdvancedAdminEnabled']);
$scBillingProvider = trim((string)($settings['scBillingProvider'] ?? 'paymenter'));
if ($scBillingProvider !== 'custom' && $scBillingProvider !== 'paymenter') {
$scBillingProvider = 'paymenter';
}
$scBillingSyncMode = trim((string)($settings['scBillingSyncMode'] ?? 'read_only'));
if ($scBillingSyncMode !== 'read_only' && $scBillingSyncMode !== 'managed') {
$scBillingSyncMode = 'read_only';
}
$scChdBaseUrl = trim((string)($settings['scChdBaseUrl'] ?? ''));
$scChdSyncPath = trim((string)($settings['scChdSyncPath'] ?? '/api/sovereign/v1/entitlements/sync'));
$scChdRegisterPath = trim((string)($settings['scChdRegisterPath'] ?? '/api/sovereign/v1/install/register'));
$scChdCheckoutPath = trim((string)($settings['scChdCheckoutPath'] ?? '/api/sovereign/v1/billing/checkout-link'));
$scChdCatalogPath = trim((string)($settings['scChdCatalogPath'] ?? '/api/sovereign/v1/billing/catalog'));
$scChdCatalogCategoryId = trim((string)($settings['scChdCatalogCategoryId'] ?? ''));
$scChdCatalogAudience = trim((string)($settings['scChdCatalogAudience'] ?? ''));
$scChdRegistrationToken = trim((string)($settings['scChdRegistrationToken'] ?? ''));
$scChdInstanceId = trim((string)($settings['scChdInstanceId'] ?? ''));
$scChdInstanceToken = trim((string)($settings['scChdInstanceToken'] ?? ''));
$scChdInstanceSecret = trim((string)($settings['scChdInstanceSecret'] ?? ''));
$scChdLastSyncAt = trim((string)($settings['scChdLastSyncAt'] ?? ''));
$scChdLastSyncStatus = trim((string)($settings['scChdLastSyncStatus'] ?? ''));
$scChdLastSyncMessage = trim((string)($settings['scChdLastSyncMessage'] ?? ''));
$mspModeEnabled = !empty($settings['mspModeEnabled']);
$mspAdminGroup = trim((string)($settings['mspAdminGroup'] ?? 'MSP_Admin'));
if ($mspAdminGroup === '') {
$mspAdminGroup = 'MSP_Admin';
}
$chdAdminDashboardEnabled = !empty($settings['chdAdminDashboardEnabled']);
$sovereignMode = trim((string)($settings['sovereignMode'] ?? 'powered'));
if ($sovereignMode !== 'powered' && $sovereignMode !== 'full_qortal') {
$sovereignMode = 'powered';
}
$isPoweredMode = $sovereignMode === 'powered';
$scBillingDashboardEnabled = array_key_exists('scBillingDashboardEnabled', $settings)
? !empty($settings['scBillingDashboardEnabled'])
: true;
if (!$isPoweredMode) {
if ($brokerBaseUrl === '') {
$notices[] = ['type' => 'error', 'message' => 'Broker Base URL is required. Set it to http://broker:3000 (Docker) or your broker public URL.'];
}
if ($brokerInternalApiToken === '' && $brokerInternalApiTokenEnv === '') {
$notices[] = ['type' => 'error', 'message' => 'Broker Internal API Token is not configured. Set BROKER_INTERNAL_API_TOKEN in your env file and/or set a matching token below.'];
}
if ($externalAuthDocsUrl === '') {
$notices[] = ['type' => 'warning', 'message' => 'External Auth Docs URL is empty. Set it to your External Auth docs page for quick access.'];
}
if ($externalAuthBaseUrl === '' && $externalAuthBaseUrlEnv !== '') {
$notices[] = ['type' => 'info', 'message' => 'External Auth Base URL is configured via .env. Admin panel value is empty by design.'];
} elseif ($externalAuthBaseUrl === '') {
$notices[] = ['type' => 'warning', 'message' => 'External Auth Base URL is not saved in this panel and no .env value was detected. Broker account operations may fail until it is configured.'];
}
if (($externalAuthAppId === '' || $externalAuthAppSecret === '') && $externalAuthAppIdEnv !== '' && $externalAuthAppSecretEnv !== '') {
$notices[] = ['type' => 'info', 'message' => 'External Auth App ID/Secret are configured via .env. Admin panel values are empty by design.'];
} elseif ($externalAuthAppId === '' || $externalAuthAppSecret === '') {
$notices[] = ['type' => 'warning', 'message' => 'External Auth App ID/Secret are not saved in this panel and no complete .env pair was detected. Set them here (or run finish-initial-setup) for account operations.'];
}
if ($externalAuthNodeUrl === '' && $externalAuthNodeUrlEnv !== '') {
$notices[] = ['type' => 'info', 'message' => 'External Auth Qortal Node URL is configured via .env. Admin panel value is empty by design.'];
} elseif ($externalAuthNodeUrl === '') {
$notices[] = ['type' => 'warning', 'message' => 'External Auth Qortal Node URL is not saved in this panel and no .env value was detected.'];
}
if ($externalAuthNodeUrl !== '' && $externalAuthNodeApiKey === '' && $qortalNodeApiKey === '') {
$notices[] = ['type' => 'warning', 'message' => 'External Auth node API key is empty. Restricted Qortal endpoints may fail if the node requires an API key. In containerized setups, set QORTAL_AUTH_NODE_API_KEY in .env.devprod and recreate external_auth.'];
} elseif ($externalAuthNodeUrl !== '' && $externalAuthNodeApiKey === '' && $qortalNodeApiKey !== '') {
$notices[] = ['type' => 'warning', 'message' => 'External Auth node API key is empty. Runtime sync will fall back to the Qortal Node API key value.'];
}
if ($oidcIssuerUrl === '' && $brokerBaseUrl === '') {
$notices[] = ['type' => 'error', 'message' => 'OIDC Issuer URL cannot be resolved. Set Broker Base URL or provide an explicit issuer URL.'];
}
if ($oidcClientId === '') {
$notices[] = ['type' => 'warning', 'message' => 'OIDC Client ID is empty. Default will fall back to nextcloud-local.'];
}
if ($oidcClientSecret === '') {
$notices[] = ['type' => 'error', 'message' => 'OIDC Client Secret is required. Set it in the OIDC Provider Settings section.'];
} elseif ($oidcClientSecret === 'dev-secret') {
$notices[] = ['type' => 'warning', 'message' => 'OIDC Client Secret in this panel is set to dev-secret (default). If you use this panel to configure OIDC, replace it before production.'];
}
}
if ($nextcloudPublicUrl === '') {
$notices[] = ['type' => 'warning', 'message' => 'Nextcloud Public URL is not saved in this panel. Setup actions in this panel may not update trusted domains/overwrite settings unless this is set here.'];
}
if ($isPoweredMode && $scBillingManagedByChd) {
if ($scChdBaseUrl === '' || $scChdInstanceId === '') {
$notices[] = ['type' => 'warning', 'message' => 'CHD billing is enabled but not fully connected yet. Use "Connect Installation + Sync" in NuQloud V1 Billing + Entitlements to complete one-click bootstrap.'];
}
}
?>
<div
id="qortal-admin-root"
class="section"
data-status-url="<?php p($_['statusUrl']); ?>"
data-setup-url="<?php p($_['setupUrl']); ?>"
data-setup-plan-url="<?php p($_['setupPlanUrl']); ?>"
data-setup-occ-url="<?php p($_['setupOccUrl']); ?>"
data-notify-url="<?php p($_['notifyUrl']); ?>"
data-search-users-url="<?php p($_['searchUsersUrl']); ?>"
data-search-groups-url="<?php p($_['searchGroupsUrl']); ?>"
data-user-mappings-url="<?php p($_['userMappingsUrl']); ?>"
data-account-dashboard-url="<?php p($_['accountDashboardUrl']); ?>"
data-admin-display-name="<?php p((string)($_['currentAdminDisplayName'] ?? '')); ?>"
data-admin-email="<?php p((string)($_['currentAdminEmail'] ?? '')); ?>"
data-settings-url="<?php p($_['settingsUrl']); ?>"
data-billing-test-url="<?php p($_['billingTestUrl']); ?>"
data-billing-sync-url="<?php p($_['billingSyncUrl']); ?>"
data-billing-bootstrap-url="<?php p($_['billingBootstrapUrl']); ?>"
data-billing-checkout-url="<?php p($_['billingCheckoutUrl']); ?>"
data-billing-catalog-url="<?php p($_['billingCatalogUrl']); ?>"
data-wallets-url="<?php p($_['walletsUrl']); ?>"
data-create-wallet-url="<?php p($_['createWalletUrl']); ?>"
data-register-external-auth-url="<?php p($_['registerExternalAuthUrl']); ?>"
data-mappings-url="<?php p($_['mappingsUrl']); ?>"
data-link-mapping-url="<?php p($_['linkMappingUrl']); ?>"
data-unlink-mapping-url="<?php p($_['unlinkMappingUrl']); ?>"
data-allowlist-url="<?php p($_['allowlistUrl']); ?>"
data-oidc-clients-url="<?php p($_['oidcClientsUrl']); ?>"
data-create-oidc-client-url="<?php p($_['createOidcClientUrl']); ?>"
data-update-oidc-client-url-template="<?php p($_['updateOidcClientUrlTemplate']); ?>"
data-set-oidc-client-enabled-url-template="<?php p($_['setOidcClientEnabledUrlTemplate']); ?>"
data-rotate-oidc-client-secret-url-template="<?php p($_['rotateOidcClientSecretUrlTemplate']); ?>"
data-oidc-audit-events-url="<?php p($_['oidcAuditEventsUrl']); ?>"
data-add-allowlist-url="<?php p($_['addAllowlistUrl']); ?>"
data-remove-allowlist-url="<?php p($_['removeAllowlistUrl']); ?>"
data-invites-url="<?php p($_['invitesUrl']); ?>"
data-create-invite-url="<?php p($_['createInviteUrl']); ?>"
data-revoke-invite-url="<?php p($_['revokeInviteUrl']); ?>"
data-admin-recovery-status-url="<?php p($_['adminRecoveryStatusUrl']); ?>"
data-admin-recovery-issue-code-url="<?php p($_['adminRecoveryIssueCodeUrl']); ?>"
data-admin-recovery-start-emergency-url="<?php p($_['adminRecoveryStartEmergencyUrl']); ?>"
data-qapps-json="<?php p(json_encode($settings['qappsList'] ?? [], JSON_UNESCAPED_SLASHES)); ?>"
data-qapps-enabled="<?php p(!empty($settings['qappsEnabled']) ? '1' : '0'); ?>"
data-qapps-browser-enabled="<?php p(!empty($settings['qappsFullBrowserEnabled']) ? '1' : '0'); ?>"
data-qapps-browser-address="<?php p((string)($settings['qappsFullBrowserAddress'] ?? '')); ?>"
data-qapps-debug-enabled="<?php p(!empty($settings['qappsDebugEnabled']) ? '1' : '0'); ?>"
data-qapps-advanced-unlock-settings-enabled="<?php p(!empty($settings['qappsAdvancedUnlockSettingsEnabled']) ? '1' : '0'); ?>"
data-qapps-non-admin-restrictions-enabled="<?php p($qappsNonAdminRestrictionsEnabled ? '1' : '0'); ?>"
data-qapps-non-admin-restriction-rules-json="<?php p($qappsNonAdminRestrictionRulesJson); ?>"
data-user-publishing-enabled="<?php p(!empty($settings['userPublishingEnabled']) ? '1' : '0'); ?>"
data-external-auth-env-base-url="<?php p($externalAuthBaseUrlEnv !== '' ? '1' : '0'); ?>"
data-external-auth-env-app-creds="<?php p(($externalAuthAppIdEnv !== '' && $externalAuthAppSecretEnv !== '') ? '1' : '0'); ?>"
data-external-auth-env-node-url="<?php p($externalAuthNodeUrlEnv !== '' ? '1' : '0'); ?>"
data-nextcloud-public-url="<?php p($nextcloudPublicUrl); ?>"
data-sovereign-mode="<?php p($sovereignMode); ?>"
data-mode-active="<?php p($sovereignMode); ?>"
data-billing-advanced="<?php p($scAdvancedAdminEnabled ? '1' : '0'); ?>"
>
<h2><?php p($_['title']); ?></h2>
<p class="qortal-help">
<?php if ($isPoweredMode) { ?>
Configure NuQloud for Nextcloud integration, onboarding, and app access.
<?php } else { ?>
Configure broker connectivity, setup accounts, and manage pre-linked identities for Qortal OIDC link mode.
<?php } ?>
</p>
<div class="qortal-actions qortal-section-actions">
<a class="button" href="/apps/custom_pwa/install" target="_blank" rel="noopener">Open Custom PWA Installer</a>
<a class="button" href="/apps/chd_admin/admin" target="_blank" rel="noopener">Open CHD Admin</a>
<a class="button" href="/apps/qortal_files_bridge/" target="_blank" rel="noopener">Open Files Bridge</a>
<a class="button" href="/apps/qortal_talk_bridge/" target="_blank" rel="noopener">Open Talk Bridge</a>
</div>
<div class="qortal-actions qortal-section-actions">
<label>
<input id="qortal-chd-admin-dashboard-enabled" type="checkbox" <?php if ($chdAdminDashboardEnabled) { print_unescaped('checked'); } ?>>
Add CHD Admin link to dashboard (admin users)
</label>
<button id="qortal-save-chd-admin-dashboard" class="button">Save CHD Dashboard Link</button>
</div>
<?php if (!empty($notices)) { ?>
<div class="qortal-card qortal-notices" id="qortal-setup-notices-card" data-collapsible="1" data-collapsible-default="closed">
<h3>Setup Notices</h3>
<p class="qortal-note">These checks reflect values saved in this admin panel. Runtime container env values may differ.</p>
<ul>
<?php foreach ($notices as $notice) { ?>
<li class="qortal-notice <?php p($notice['type']); ?>"><?php p($notice['message']); ?></li>
<?php } ?>
</ul>
</div>
<?php } ?>
<div class="qortal-grid">
<label for="qortal-sovereign-mode">NuQloud Mode</label>
<div class="qortal-input-group">
<select id="qortal-sovereign-mode">
<option value="full_qortal" <?php if ($sovereignMode === 'full_qortal') { ?>selected<?php } ?>>Full Qortal Mode</option>
<option value="powered" <?php if ($sovereignMode === 'powered') { ?>selected<?php } ?>>Default</option>
</select>
<p class="qortal-note">
Powered mode is a guided mode with assistance and services provided by CHD.
</p>
</div>
<label for="qortal-qapps-debug-enabled">Debug Mode</label>
<div class="qortal-input-group">
<label>
<input id="qortal-qapps-debug-enabled" type="checkbox" <?php if (!empty($settings['qappsDebugEnabled'])) { print_unescaped('checked'); } ?>>
Enable debug mode
</label>
<p class="qortal-note">
Shows extra diagnostics for integration workflows, including Activity + Notifications debug data and the app viewer debug panel.
</p>
</div>
<label for="qortal-qapps-advanced-unlock-settings-enabled">Unlock Options</label>
<div class="qortal-input-group">
<label>
<input id="qortal-qapps-advanced-unlock-settings-enabled" type="checkbox" <?php if (!empty($settings['qappsAdvancedUnlockSettingsEnabled'])) { print_unescaped('checked'); } ?>>
Allow advanced account unlocking settings
</label>
<p class="qortal-note">
When disabled, users only see the password + unlock flow with the default unlock window. When enabled, users can choose a longer or shorter unlock window from their personal settings.
</p>
</div>
<label for="qortal-qapps-non-admin-restrictions-enabled">Non-Admin Node Restrictions</label>
<div class="qortal-input-group">
<label>
<input id="qortal-qapps-non-admin-restrictions-enabled" type="checkbox" <?php if ($qappsNonAdminRestrictionsEnabled) { print_unescaped('checked'); } ?>>
Block or limit default sensitive node paths for non-admin users
</label>
<p class="qortal-note">
Enabled by default. Applies non-admin protections similar to your HAProxy hardening defaults.
</p>
<p class="qortal-note">
Default protected areas include <code>/lists</code> POST/DELETE, <code>/crosschain/tradebot</code>,
sensitive <code>/admin/*</code> mutation endpoints (including <code>/admin/apikey/generate</code>),
and selected fully blocked discovery/maintenance paths.
</p>
</div>
<label>Additional Restriction Rules</label>
<div class="qortal-input-group">
<div id="qortal-qapps-non-admin-rules"></div>
<div class="qortal-actions">
<button id="qortal-qapps-non-admin-rules-add" class="button" type="button">Add Rule</button>
</div>
<p class="qortal-note">
Custom rules are evaluated after defaults. Use these to add project-specific non-admin API path limits.
</p>
</div>
<label for="qortal-broker-base-url" class="qortal-full-only">Broker Base URL</label>
<div class="qortal-input-group qortal-full-only">
<textarea id="qortal-broker-base-url" rows="2" spellcheck="false" placeholder="http://broker:3000"><?php p((string)$settings['brokerBaseUrl']); ?></textarea>
<p class="qortal-note">Required broker endpoints: <code>/api/health</code> and <code>/api/qortal/health</code></p>
</div>
<label for="qortal-broker-internal-api-token" class="qortal-full-only">Broker Internal API Token</label>
<div class="qortal-input-group qortal-full-only">
<input id="qortal-broker-internal-api-token" type="password" value="<?php p($brokerInternalApiToken); ?>" placeholder="Must match BROKER_INTERNAL_API_TOKEN">
<p class="qortal-note">
Used by Nextcloud when calling broker internal APIs. Must match broker env <code>BROKER_INTERNAL_API_TOKEN</code>.
For containerized setup, prefer setting the env value in <code>.env.devprod</code>.
</p>
</div>
<label for="qortal-external-auth-docs-url" class="qortal-full-only">External Auth Docs URL</label>
<div class="qortal-input-group qortal-full-only">
<textarea id="qortal-external-auth-docs-url" rows="2" spellcheck="false" placeholder="http://localhost:3191/docs/static/index.html"><?php p((string)$settings['externalAuthDocsUrl']); ?></textarea>
</div>
</div>
<div class="qortal-actions qortal-section-actions">
<button id="qortal-save-access" class="button">Save Access + Branding</button>
</div>
<p class="qortal-note">
Save only mode/branding/apps and user-facing access settings from this section.
</p>
<div class="qortal-card" id="qortal-setup-overview" data-collapsible="1" data-collapsible-default="closed">
<h3>Setup Overview</h3>
<ul id="qortal-setup-overview-list" class="qortal-status-list"></ul>
<p id="qortal-setup-overview-note" class="qortal-note"></p>
<div class="qortal-actions qortal-section-actions">
<button id="qortal-save-settings" class="button button-primary">Save All Sections</button>
<button id="qortal-refresh-setup" class="button">Refresh Setup Data</button>
<button id="qortal-test-connection" class="button">Test Broker Connection</button>
</div>
<p class="qortal-note">
Save All updates all settings and attempts live broker/external-auth runtime sync.
Container env files are not changed by this action.
</p>
</div>
<div class="qortal-card" id="qortal-v1-packaging-card" data-collapsible="1" data-collapsible-default="open">
<h3>NuQloud V1 Billing + Entitlements</h3>
<p class="qortal-note">
Billing Authority: CHD.
Ensure to sync entitlements if data is outdated. Initial sync should be automatic, but can be attempted again if issues occurred during setup.
</p>
<div class="qortal-grid">
<label for="qortal-sc-billing-managed-by-chd">Managed By CHD</label>
<div class="qortal-input-group">
<label>
<input id="qortal-sc-billing-managed-by-chd" type="checkbox" <?php if ($scBillingManagedByChd) { print_unescaped('checked'); } ?>>
Enable CHD-managed package + credits sync
</label>
</div>
<label for="qortal-sc-billing-provider" class="qortal-chd-advanced">Billing Provider</label>
<select id="qortal-sc-billing-provider" class="qortal-chd-advanced">
<option value="paymenter" <?php if ($scBillingProvider === 'paymenter') { print_unescaped('selected'); } ?>>Paymenter</option>
<option value="custom" <?php if ($scBillingProvider === 'custom') { print_unescaped('selected'); } ?>>Custom API</option>
</select>
<label for="qortal-sc-billing-sync-mode" class="qortal-chd-advanced">Connector Mode</label>
<select id="qortal-sc-billing-sync-mode" class="qortal-chd-advanced">
<option value="read_only" <?php if ($scBillingSyncMode === 'read_only') { print_unescaped('selected'); } ?>>Read-only (entitlements sync only)</option>
<option value="managed" <?php if ($scBillingSyncMode === 'managed') { print_unescaped('selected'); } ?>>Managed (registration + checkout + entitlements)</option>
</select>
<label for="qortal-sc-chd-base-url" class="qortal-chd-advanced">CHD Billing Base URL</label>
<input id="qortal-sc-chd-base-url" class="qortal-chd-advanced" type="text" value="<?php p($scChdBaseUrl); ?>" placeholder="https://billing.crowetic.com">
<label for="qortal-sc-chd-sync-path" class="qortal-chd-advanced">CHD Entitlements Sync Path</label>
<input id="qortal-sc-chd-sync-path" class="qortal-chd-advanced" type="text" value="<?php p($scChdSyncPath); ?>" placeholder="/api/sovereign/v1/entitlements/sync">
<label for="qortal-sc-chd-register-path" class="qortal-chd-advanced">CHD Registration Path</label>
<input id="qortal-sc-chd-register-path" class="qortal-chd-advanced" type="text" value="<?php p($scChdRegisterPath); ?>" placeholder="/api/sovereign/v1/install/register">
<label for="qortal-sc-chd-checkout-path" class="qortal-chd-advanced">CHD Checkout Link Path</label>
<input id="qortal-sc-chd-checkout-path" class="qortal-chd-advanced" type="text" value="<?php p($scChdCheckoutPath); ?>" placeholder="/api/sovereign/v1/billing/checkout-link">
<label for="qortal-sc-chd-catalog-path" class="qortal-chd-advanced">CHD Catalog Path</label>
<input id="qortal-sc-chd-catalog-path" class="qortal-chd-advanced" type="text" value="<?php p($scChdCatalogPath); ?>" placeholder="/api/sovereign/v1/billing/catalog">
<label for="qortal-sc-chd-catalog-category-id" class="qortal-chd-advanced">Catalog Category ID</label>
<input id="qortal-sc-chd-catalog-category-id" class="qortal-chd-advanced" type="text" value="<?php p($scChdCatalogCategoryId); ?>" placeholder="NuQloud product category id">
<label for="qortal-sc-chd-catalog-audience" class="qortal-chd-advanced">Catalog Audience Key (optional)</label>
<input id="qortal-sc-chd-catalog-audience" class="qortal-chd-advanced" type="text" value="<?php p($scChdCatalogAudience); ?>" placeholder="MSP_NoBrand_User">
<label for="qortal-sc-chd-registration-token" class="qortal-chd-advanced">CHD Registration Token</label>
<input id="qortal-sc-chd-registration-token" class="qortal-chd-advanced" type="password" value="<?php p($scChdRegistrationToken); ?>" placeholder="Matches connector REGISTRATION_TOKEN">
<label for="qortal-sc-chd-instance-id" class="qortal-chd-advanced">CHD Instance ID</label>
<input id="qortal-sc-chd-instance-id" class="qortal-chd-advanced" type="text" value="<?php p($scChdInstanceId); ?>" placeholder="instance identifier">
<label for="qortal-sc-chd-instance-token" class="qortal-chd-advanced">CHD Instance Token</label>
<input id="qortal-sc-chd-instance-token" class="qortal-chd-advanced" type="password" value="<?php p($scChdInstanceToken); ?>" placeholder="Bearer token from CHD">
<label for="qortal-sc-chd-instance-secret" class="qortal-chd-advanced">CHD Instance Secret (optional)</label>
<input id="qortal-sc-chd-instance-secret" class="qortal-chd-advanced" type="password" value="<?php p($scChdInstanceSecret); ?>" placeholder="HMAC secret (optional)">
<label>Last CHD Sync</label>
<div class="qortal-input-group">
<p class="qortal-note">
Status:
<strong id="qortal-sc-chd-last-sync-status"><?php p($scChdLastSyncStatus !== '' ? $scChdLastSyncStatus : 'not synced'); ?></strong>
<?php if ($scChdLastSyncAt !== '') { ?>
• <?php p($scChdLastSyncAt); ?>
<?php } ?>
</p>
<?php if ($scChdLastSyncMessage !== '') { ?>
<p id="qortal-sc-chd-last-sync-message" class="qortal-note"><?php p($scChdLastSyncMessage); ?></p>
<?php } else { ?>
<p id="qortal-sc-chd-last-sync-message" class="qortal-note"></p>
<?php } ?>
</div>
<label for="qortal-sc-package-tier">Active Tier</label>
<select id="qortal-sc-package-tier" disabled>
<?php
$scPackageTierValue = (string)($settings['scPackageTier'] ?? 'free_core');
$scPackageTierOptions = [
'free_core' => 'Free Core',
'nuqloud_starter' => 'Starter',
'nuqloud_advanced' => 'Advanced',
'nuqloud_pro' => 'Pro',
'nuqloud_team_starter' => 'Team Starter',
'nuqloud_team_advanced' => 'Team Advanced',
'nuqloud_team_pro' => 'Team Pro',
'nuqloud_branded' => 'NuQloud Branded',
'nuqloud_branded_pro' => 'Branded Pro',
'nuqloud_branded_enterprise' => 'Branded Enterprise',
];
foreach ($scPackageTierOptions as $tierValue => $tierLabel) { ?>
<option value="<?php p($tierValue); ?>" <?php if ($scPackageTierValue === $tierValue) { print_unescaped('selected'); } ?>><?php p($tierLabel); ?></option>
<?php } ?>
</select>
</div>
<div class="qortal-billing-form">
<h4>Instance-Wide Admin Shared Billing</h4>
<p class="qortal-note">
This billing information is instance-wide and shared with admins. If you do not want to use shared instance billing, purchase from the
<a href="<?php p((string)($_['accountDashboardUrl'] ?? '/apps/qortal_integration/account')); ?>">NuQloud Dashboard</a>,
which uses individualized billing per user account.
</p>
<div class="qortal-billing-profile-grid">
<div class="qortal-billing-field qortal-billing-field--wide">
<label for="qortal-sc-billing-contact-name">Shared Billing Contact Name</label>
<input id="qortal-sc-billing-contact-name" type="text" value="<?php p((string)($settings['scBillingContactName'] ?? '')); ?>" placeholder="Jane Doe">
</div>
<div class="qortal-billing-field qortal-billing-field--wide">
<label for="qortal-sc-billing-contact-email">Shared Billing Contact Email</label>
<input id="qortal-sc-billing-contact-email" type="email" value="<?php p((string)($settings['scBillingContactEmail'] ?? '')); ?>" placeholder="billing@example.com">
</div>
<div class="qortal-billing-field qortal-billing-field--wide">
<label for="qortal-sc-billing-company-name">Company / Organization</label>
<input id="qortal-sc-billing-company-name" type="text" value="<?php p((string)($settings['scBillingCompanyName'] ?? '')); ?>" placeholder="Acme, Inc.">
</div>
<div class="qortal-billing-field qortal-billing-field--wide">
<label for="qortal-sc-billing-phone">Billing Phone</label>
<input id="qortal-sc-billing-phone" type="text" value="<?php p((string)($settings['scBillingPhone'] ?? '')); ?>" placeholder="+1 555 555 5555">
</div>
<div class="qortal-billing-field qortal-billing-field--full">
<label for="qortal-sc-billing-address1">Billing Address Line 1</label>
<input id="qortal-sc-billing-address1" type="text" value="<?php p((string)($settings['scBillingAddress1'] ?? '')); ?>" placeholder="123 Main Street">
</div>
<div class="qortal-billing-field qortal-billing-field--full">
<label for="qortal-sc-billing-address2">Billing Address Line 2</label>
<input id="qortal-sc-billing-address2" type="text" value="<?php p((string)($settings['scBillingAddress2'] ?? '')); ?>" placeholder="Suite 200">
</div>
<div class="qortal-billing-field">
<label for="qortal-sc-billing-city">City</label>
<input id="qortal-sc-billing-city" type="text" value="<?php p((string)($settings['scBillingCity'] ?? '')); ?>" placeholder="Austin">
</div>
<div class="qortal-billing-field">
<label for="qortal-sc-billing-state">State / Region</label>
<input id="qortal-sc-billing-state" type="text" value="<?php p((string)($settings['scBillingState'] ?? '')); ?>" placeholder="TX">
</div>
<div class="qortal-billing-field">
<label for="qortal-sc-billing-postal-code">Postal / ZIP Code</label>
<input id="qortal-sc-billing-postal-code" type="text" value="<?php p((string)($settings['scBillingPostalCode'] ?? '')); ?>" placeholder="78701">
</div>
<div class="qortal-billing-field">
<label for="qortal-sc-billing-country">Country</label>
<input id="qortal-sc-billing-country" type="text" value="<?php p((string)($settings['scBillingCountry'] ?? '')); ?>" placeholder="US">
</div>
<div class="qortal-billing-field qortal-billing-field--full">
<label for="qortal-sc-admin-q-address">Shared NuQloud Funding Address</label>
<input id="qortal-sc-admin-q-address" type="text" value="<?php p((string)($settings['scAdminQortalAddress'] ?? '')); ?>" placeholder="Create/link your account first" readonly>
<p id="qortal-sc-account-status" class="qortal-note"></p>
</div>
</div>
</div>
<div class="qortal-grid">
<label for="qortal-sc-credits-balance">Credits Balance</label>
<input id="qortal-sc-credits-balance" type="number" min="0" step="1" value="<?php p((string)($settings['scCreditsBalance'] ?? '0')); ?>" disabled>
<label for="qortal-sc-monthly-credits">Included Monthly Credits</label>
<input id="qortal-sc-monthly-credits" type="number" min="0" step="1" value="<?php p((string)($settings['scMonthlyCredits'] ?? '0')); ?>" disabled>
<label for="qortal-sc-capacity-gb">Encrypted Capacity (GB)</label>
<input id="qortal-sc-capacity-gb" type="number" min="0" step="1" value="<?php p((string)($settings['scEncryptedCapacityGb'] ?? '0')); ?>" disabled>
<label for="qortal-sc-replication-target">Replication Target (nodes)</label>
<input id="qortal-sc-replication-target" type="number" min="0" step="1" value="<?php p((string)($settings['scReplicationTarget'] ?? '0')); ?>" disabled>
</div>
<div class="qortal-actions qortal-section-actions">
<button id="qortal-save-packaging" class="button qortal-chd-advanced">Save Billing Connectivity</button>
<button id="qortal-bootstrap-packaging" class="button button-primary">Connect Installation + Sync</button>
<button id="qortal-test-packaging" class="button">Test Connector (No Apply)</button>
<button id="qortal-sync-packaging" class="button">Sync Entitlements From CHD</button>
</div>
<p class="qortal-note">
Billing Authority: CHD. Use this section to connect the instance,
provide billing profile details, and sync entitlements.
When required values are available, initial connect/sync is attempted automatically.
</p>
</div>
<div class="qortal-card" id="qortal-purchases-card">
<div class="qortal-card-header">
<h3>Purchases</h3>
<button id="qortal-purchases-toggle" class="button" type="button" aria-expanded="true">Hide</button>
</div>
<p id="qortal-purchases-note" class="qortal-note">
Choose subscription packages and add-ons. Your billing profile above is reused automatically.
</p>
<div id="qortal-purchases-body">
<div class="qortal-purchase-section">
<h4>Subscription Packages</h4>
<div id="qortal-subscription-cards" class="qortal-purchase-grid qortal-purchase-grid--subscriptions"></div>
</div>
<div class="qortal-purchase-section">
<h4>Add-ons</h4>
<div id="qortal-onetime-cards" class="qortal-purchase-grid qortal-purchase-grid--onetime"></div>
</div>
</div>
</div>
<div class="qortal-card" id="qortal-advanced-billing-settings-card" data-collapsible="1" data-collapsible-default="closed">
<h3>Advanced Billing Settings</h3>
<p class="qortal-note">
Support and troubleshooting options. Most installations should leave this collapsed.
</p>
<div class="qortal-grid">
<label for="qortal-sc-advanced-admin-enabled">Show Advanced Billing Inputs</label>
<div class="qortal-input-group">
<label>
<input id="qortal-sc-advanced-admin-enabled" type="checkbox" <?php if ($scAdvancedAdminEnabled) { print_unescaped('checked'); } ?>>
Show connector endpoint, credentials, and path overrides
</label>
</div>
<label for="qortal-sc-billing-enabled" class="qortal-chd-advanced">Dashboard Billing Visibility</label>
<div class="qortal-input-group qortal-chd-advanced">
<label>
<input id="qortal-sc-billing-enabled" type="checkbox" <?php if ($scBillingDashboardEnabled) { print_unescaped('checked'); } ?>>
Show billing and purchases on user dashboards
</label>
<p class="qortal-note">
Advanced option. This hides billing/purchase UI on dashboards but does not disconnect CHD or remove admin shared billing settings.
Use CHD Managed Billing / disconnect options for a full connector disconnect.
</p>
</div>
</div>
</div>
<div class="qortal-card" id="qortal-msp-delegation-card" data-collapsible="1" data-collapsible-default="closed">
<h3>MSP Delegated Admin</h3>
<p class="qortal-note">
Default is disabled. When enabled, users in this Nextcloud group get delegated admin access for invites, initial grant queue actions, and instance billing operations.
</p>
<div class="qortal-grid">
<label for="qortal-msp-mode-enabled">Enable Delegated MSP Admin Mode</label>
<div class="qortal-input-group">
<label>
<input id="qortal-msp-mode-enabled" type="checkbox" <?php if ($mspModeEnabled) { print_unescaped('checked'); } ?>>
Allow configured group to perform delegated admin operations
</label>
</div>
<label for="qortal-msp-admin-group">MSP Admin Group ID</label>
<div class="qortal-input-group">
<input id="qortal-msp-admin-group" type="text" value="<?php p($mspAdminGroup); ?>" placeholder="MSP_Admin">
<p class="qortal-note">
Use the exact Nextcloud group id. Group creation is managed separately (for example via OCC: <code>group:add MSP_Admin</code>).
</p>
</div>
</div>
<div class="qortal-actions qortal-section-actions">
<button id="qortal-save-msp" class="button">Save MSP Delegation</button>
</div>
<p class="qortal-note">
Saves only delegated MSP admin settings from this section.
</p>
</div>
<div class="qortal-card" id="qortal-oidc-settings-card" data-collapsible="1" data-collapsible-default="closed">
<h3><?php p($isPoweredMode ? 'NuQloud OIDC Settings' : 'Qortal OIDC Settings'); ?></h3>
<p class="qortal-note">
These values are used when generating or running the <code>user_oidc</code> provider setup only.
They do not update broker runtime env values.
</p>
<div class="qortal-grid">
<label for="qortal-oidc-issuer-url">OIDC Issuer URL</label>
<div class="qortal-input-group">
<textarea id="qortal-oidc-issuer-url" rows="2" spellcheck="false" placeholder="http://broker:3000"><?php p((string)$settings['oidcIssuerUrl']); ?></textarea>
<p class="qortal-note">Defaults to broker base URL if left empty.</p>
</div>
<label for="qortal-oidc-client-id">OIDC Client ID</label>
<input id="qortal-oidc-client-id" type="text" value="<?php p((string)$settings['oidcClientId']); ?>" placeholder="nextcloud-local">
<label for="qortal-oidc-client-secret">OIDC Client Secret</label>
<input id="qortal-oidc-client-secret" type="password" value="<?php p((string)$settings['oidcClientSecret']); ?>" placeholder="dev-secret">
<label for="qortal-nextcloud-public-url">Nextcloud Public URL</label>
<div class="qortal-input-group">
<input id="qortal-nextcloud-public-url" type="text" value="<?php p((string)$settings['nextcloudPublicUrl']); ?>" placeholder="https://cloud.example.com">
<p class="qortal-note">Used to update trusted domains and overwrite settings when running setup.</p>
</div>
</div>
<div class="qortal-actions qortal-section-actions">
<button id="qortal-save-identity" class="button">Save Identity Settings</button>
</div>
<p class="qortal-note">
Saves OIDC provider values and policy overrides.
</p>
</div>
<div class="qortal-card qortal-full-only" id="qortal-external-auth-settings-card" data-collapsible="1" data-collapsible-default="closed">
<h3>External Auth Configuration</h3>
<p class="qortal-note">
Store External Auth connection details here for runtime sync and env generation.
Save Settings stores these in Nextcloud and attempts live runtime sync through the broker.
If your daemon does not expose runtime settings endpoints, update env files and recreate/restart containers.
</p>
<p class="qortal-note">
Important: for bundled/containerized External Auth, set <code>QORTAL_AUTH_NODE_API_KEY</code> in
<code>.env.devprod</code> and recreate <code>external_auth</code>. The admin field below is a best-effort
runtime override and may not persist across container restarts.
</p>
<div class="qortal-grid">
<label for="qortal-external-auth-base-url">External Auth Base URL</label>
<div class="qortal-input-group">
<input id="qortal-external-auth-base-url" type="text" value="<?php p((string)$settings['externalAuthBaseUrl']); ?>" placeholder="http://external_auth:3191">
<p class="qortal-note">Used by broker as <code>QORTAL_EXTERNAL_AUTH_BASE_URL</code>.</p>
</div>
<label for="qortal-external-auth-app-id">External Auth App ID</label>
<input id="qortal-external-auth-app-id" type="text" value="<?php p((string)$settings['externalAuthAppId']); ?>" placeholder="UUID">
<label for="qortal-external-auth-app-secret">External Auth App Secret</label>
<input id="qortal-external-auth-app-secret" type="password" value="<?php p((string)$settings['externalAuthAppSecret']); ?>" placeholder="Secret">
<label for="qortal-external-auth-app-name">External Auth App Name</label>
<input id="qortal-external-auth-app-name" type="text" value="qortal-nextcloud-integration" placeholder="qortal-nextcloud-integration">
<label></label>
<div class="qortal-input-group">
<button id="qortal-external-auth-register" class="button">Register External Auth App</button>
<p class="qortal-note">
Warning: registering a new app will replace existing credentials. If External Auth is already configured
via <code>.env</code>, this will generate a new App ID/Secret and you may lose access to existing wallets.
Backup your <code>.env</code> or <code>.env.devprod</code> first.
</p>
</div>
<label for="qortal-external-auth-node-url">Qortal Node URL (External Auth)</label>
<input id="qortal-external-auth-node-url" type="text" value="<?php p((string)$settings['externalAuthNodeUrl']); ?>" placeholder="http://qortal:12391">
<label for="qortal-external-auth-node-api-key">Qortal Node API Key (External Auth)</label>
<input id="qortal-external-auth-node-api-key" type="password" value="<?php p((string)$settings['externalAuthNodeApiKey']); ?>" placeholder="API key (optional)">
<p class="qortal-note">
If left empty, runtime sync falls back to the key in “<?php p($isPoweredMode ? 'Publisher Node + Gateway' : 'Qortal Node + Gateway'); ?>”.
For containerized setup, still set <code>QORTAL_AUTH_NODE_API_KEY</code> in <code>.env.devprod</code>.
</p>
<label for="qortal-external-auth-node-api-key-mode">Node API Key Mode</label>
<select id="qortal-external-auth-node-api-key-mode">
<option value="paths" <?php if ($externalAuthNodeApiKeyMode === 'paths') { print_unescaped('selected'); } ?>>paths (recommended)</option>
</select>
<label for="qortal-external-auth-node-api-key-paths">Node API Key Paths</label>
<input id="qortal-external-auth-node-api-key-paths" type="text" value="<?php p((string)$settings['externalAuthNodeApiKeyPaths']); ?>" placeholder="/">
<p class="qortal-note">Only used when mode is set to <code>paths</code>. Use <code>/</code> to send <code>X-API-KEY</code> on all node API calls.</p>
</div>
<div class="qortal-actions">
<button id="qortal-external-auth-env-generate" class="button">Generate External Auth Env Snippet</button>
</div>
<pre id="qortal-external-auth-env-output" class="qortal-status qortal-compact-status"></pre>
<p class="qortal-note">
Save Settings attempts live runtime sync through the broker. If your External Auth daemon
does not expose runtime settings endpoints, apply env files and restart with
<code>./recreate-devprod.sh --extauth</code> or
<code>docker compose up -d --build broker external_auth</code>.
</p>
</div>
<div class="qortal-card" id="qortal-node-gateway-settings-card" data-collapsible="1" data-collapsible-default="closed">
<h3><?php p($isPoweredMode ? 'Publisher Node + Gateway' : 'Qortal Node + Gateway'); ?></h3>
<p class="qortal-note">
<?php if ($isPoweredMode) { ?>
Configure the publisher node used for encrypted/chunked backup publishing and app rendering.
Gateway nodes expose a separate gateway port and do not require an API key.
<?php } else { ?>
Configure the node used for Q-App rendering and signed requests. Gateway nodes expose a separate gateway port
and do not require an API key.
<?php } ?>
</p>
<div class="qortal-grid">
<label for="qortal-node-url">Qortal Node URL</label>
<input id="qortal-node-url" type="text" value="<?php p((string)$settings['qortalNodeUrl']); ?>" placeholder="http://qortal:12391">
<label for="qortal-node-api-key">Qortal Node API Key</label>
<input id="qortal-node-api-key" type="password" value="<?php p((string)$settings['qortalNodeApiKey']); ?>" placeholder="API key (if required)">
<label for="qortal-gateway-url">Gateway Node URL</label>
<div class="qortal-input-group">
<input id="qortal-gateway-url" type="text" value="<?php p((string)$settings['qortalGatewayUrl']); ?>" placeholder="http://gateway:12393">
<p class="qortal-note">Use a public gateway (e.g. https://qortal.link) or your own gateway node URL.</p>
</div>
<label for="qortal-gateway-insecure">Allow insecure gateway TLS</label>
<div class="qortal-input-group">
<label>
<input id="qortal-gateway-insecure" type="checkbox" <?php if ($qortalGatewayAllowInsecure) { print_unescaped('checked'); } ?>>
Disable TLS verification for gateway proxy requests
</label>
<p class="qortal-note">
Use only if your gateway uses a self-signed certificate or the container lacks CA roots. Recommended to keep off for production.
</p>
</div>
</div>
<div class="qortal-actions">
<button id="qortal-node-env-generate" class="button">Generate Node Env Snippet</button>
</div>
<pre id="qortal-node-env-output" class="qortal-status qortal-compact-status"></pre>
<p class="qortal-note">
When running a local node container, ensure gateway mode is enabled and expose the gateway port.
</p>
<div class="qortal-actions qortal-section-actions">
<button id="qortal-save-runtime" class="button">Save Runtime Connectivity</button>
</div>
<p class="qortal-note qortal-full-only">
Broker internal APIs require <code>BROKER_INTERNAL_API_TOKEN</code> on the broker service.
If this token changes in env, update the matching token here (or via app env <code>QORTAL_BROKER_INTERNAL_API_TOKEN</code>).
</p>
</div>
<div class="qortal-card qortal-hidden qortal-full-only" id="qortal-setup-components">
<h3>Setup Components</h3>
<p class="qortal-note">
Generate the setup commands or run them automatically (requires <code>occ</code> access inside the Nextcloud container).
</p>
<div class="qortal-actions">
<button id="qortal-setup-plan" class="button">Generate Setup Commands</button>
<button id="qortal-setup-occ" class="button">Run Setup (occ)</button>
</div>
<pre id="qortal-setup-result" class="qortal-status qortal-compact-status"></pre>
</div>
<div class="qortal-card qortal-full-only" id="qortal-oidc-policy-card" data-collapsible="1" data-collapsible-default="closed">
<h3>Auto-Provision Policy (Broker)</h3>
<p class="qortal-note">
Read-only effective broker policy values (from env defaults plus optional admin overrides).
</p>
<div class="qortal-grid qortal-policy-grid">
<label>Policy Mode</label>
<div><code id="qortal-oidc-policy-mode">unknown</code></div>
<label>Auto-Provision Guard</label>
<div><code id="qortal-oidc-guard">unknown</code></div>
<label>Invite TTL (seconds)</label>
<div><code id="qortal-oidc-invite-ttl">-</code></div>
<label>Require Email For New Account</label>
<div><code id="qortal-oidc-require-email">unknown</code></div>
<label>Redirect Allowlist</label>
<div><code id="qortal-oidc-redirects">-</code></div>
</div>
<p id="qortal-oidc-policy-note" class="qortal-note"></p>
<div class="qortal-grid qortal-policy-grid">
<label for="qortal-oidc-policy-select">Policy Mode Override</label>
<select id="qortal-oidc-policy-select">
<option value="" <?php if ($oidcPolicyModeOverride === '') { ?>selected<?php } ?>>(use env default)</option>
<option value="link_only" <?php if ($oidcPolicyModeOverride === 'link_only') { ?>selected<?php } ?>>link_only (existing users only)</option>
<option value="auto_provision" <?php if ($oidcPolicyModeOverride === 'auto_provision') { ?>selected<?php } ?>>auto_provision (new users allowed)</option>
</select>
<label for="qortal-oidc-guard-toggle">Guard Override</label>
<select id="qortal-oidc-guard-toggle">
<option value="" <?php if ($oidcGuardOverride === '') { ?>selected<?php } ?>>(use env default)</option>
<option value="invite_or_allowlist" <?php if ($oidcGuardOverride === 'invite_or_allowlist') { ?>selected<?php } ?>>invite_or_allowlist</option>
<option value="off" <?php if ($oidcGuardOverride === 'off') { ?>selected<?php } ?>>off</option>
</select>
<label for="qortal-oidc-invite-ttl-input">Invite TTL Override (seconds)</label>
<input id="qortal-oidc-invite-ttl-input" type="number" min="60" placeholder="(env default)">
<label for="qortal-oidc-require-email-toggle">Require Email For New Account</label>
<select id="qortal-oidc-require-email-toggle">
<option value="" <?php if ($oidcRequireEmailOverride === '') { ?>selected<?php } ?>>(use env default)</option>
<option value="required" <?php if ($oidcRequireEmailOverride === 'required') { ?>selected<?php } ?>>required</option>
<option value="off" <?php if ($oidcRequireEmailOverride === 'off') { ?>selected<?php } ?>>off</option>
</select>
<label for="qortal-oidc-redirect-allowlist-input">Redirect Allowlist Override</label>
<input id="qortal-oidc-redirect-allowlist-input" type="text" placeholder="(env default)">
</div>
<div class="qortal-actions">
<button id="qortal-oidc-env-generate" class="button">Generate Broker Env Snippet</button>
</div>
<p class="qortal-note">
Save Settings now syncs these overrides to broker runtime. Leave a field blank to keep using env defaults.
</p>
<pre id="qortal-oidc-env-output" class="qortal-status qortal-compact-status"></pre>
</div>
<div class="qortal-card" id="qortal-identity-provider-card" data-collapsible="1" data-collapsible-default="closed">
<h3><?php p($isPoweredMode ? 'NuQloud Identity Provider' : 'Qortal Identity Provider'); ?></h3>
<p class="qortal-note">
Register trusted private apps as OIDC clients. Client secrets are shown only when created or rotated.
</p>
<div class="qortal-grid qortal-identity-provider-form">
<label for="qortal-idp-template">Template</label>
<div class="qortal-input-group">
<select id="qortal-idp-template">
<option value="gitea">Gitea</option>
<option value="grafana">Grafana</option>
<option value="wikijs">Wiki.js</option>
<option value="openproject">OpenProject</option>
<option value="outline">Outline</option>
<option value="generic">Generic OIDC App</option>
</select>
<p class="qortal-note">Templates prefill common values. Always replace example domains with the app's real public URL.</p>
</div>
<label for="qortal-idp-client-id">Client ID</label>
<input id="qortal-idp-client-id" type="text" placeholder="gitea">
<label for="qortal-idp-display-name">Display Name</label>
<input id="qortal-idp-display-name" type="text" placeholder="Gitea">
<label for="qortal-idp-redirect-uris">Redirect URIs</label>
<textarea id="qortal-idp-redirect-uris" placeholder="https://git.example.com/user/oauth2/NuQloud/callback"></textarea>
<label for="qortal-idp-post-logout-redirect-uris">Post Logout Redirect URIs</label>
<textarea id="qortal-idp-post-logout-redirect-uris" placeholder="Optional, one per line"></textarea>
<label for="qortal-idp-allowed-scopes">Allowed Scopes</label>
<input id="qortal-idp-allowed-scopes" type="text" value="openid,profile,email" placeholder="openid,profile,email,groups,qortal_address">
<label for="qortal-idp-allowed-groups">Allowed Groups</label>
<input id="qortal-idp-allowed-groups" type="text" placeholder="Optional, comma-separated Nextcloud groups">
<label for="qortal-idp-require-pkce">Require PKCE</label>
<label class="qortal-inline-check">
<input id="qortal-idp-require-pkce" type="checkbox">
Require PKCE for authorization code exchange
</label>
</div>
<div class="qortal-actions">
<button id="qortal-idp-apply-template" class="button" type="button">Apply Template</button>
<button id="qortal-idp-save-client" class="button">Create Client</button>
<button id="qortal-idp-cancel-edit" class="button qortal-hidden" type="button">Cancel Edit</button>
<button id="qortal-idp-refresh-clients" class="button">Refresh Clients</button>
<button id="qortal-idp-refresh-audit" class="button">Refresh Audit</button>
<button id="qortal-idp-copy-secret" class="button" type="button">Copy Secret</button>
</div>
<pre id="qortal-idp-secret-output" class="qortal-status qortal-compact-status"></pre>
<pre id="qortal-idp-config-output" class="qortal-status qortal-compact-status"></pre>
<div class="qortal-table-wrap">
<table class="qortal-table">
<thead>
<tr>
<th>Client</th>
<th>Redirect URIs</th>
<th>Scopes</th>
<th>Groups</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="qortal-idp-clients-body"></tbody>
</table>
</div>
<div class="qortal-table-wrap">
<table class="qortal-table">
<thead>
<tr>
<th>Event</th>
<th>Client</th>
<th>User</th>
<th>Created</th>
</tr>
</thead>
<tbody id="qortal-idp-audit-body"></tbody>
</table>
</div>
</div>
<div class="qortal-card qortal-full-only" id="qortal-full-setup-options-card" data-collapsible="1" data-collapsible-default="closed">
<h3>Full Setup Options</h3>
<p class="qortal-note">
Use one of the supported setup entry points depending on your environment.
</p>
<div class="qortal-setup-options">
<div class="qortal-setup-option">
<strong>Local Docker Dev</strong>
<pre><code>./start-dev.sh</code></pre>
</div>
<div class="qortal-setup-option">
<strong>Dev-Prod (Caddy SSL)</strong>
<pre><code>./start-devprod.sh</code></pre>
</div>
<div class="qortal-setup-option">
<strong>Dev-Prod (No SSL / External Proxy)</strong>
<pre><code>./start-devprod.sh</code></pre>
<p class="qortal-note">Choose "no" when prompted for Caddy SSL.</p>
</div>
<div class="qortal-setup-option">
<strong>VM Install (Nextcloud VM + broker containers)</strong>
<pre><code>sudo bash scripts/nextcloud-vm-install.sh</code></pre>
</div>
<div class="qortal-setup-option">
<strong>Recreate Containers (apply new env)</strong>
<pre><code>./recreate-devprod.sh</code></pre>
</div>
</div>
</div>
<div class="qortal-card" id="qortal-qapps-access-card" data-collapsible="1" data-collapsible-default="closed">
<h3><?php p($isPoweredMode ? 'NuQloud Apps Access' : 'Q-Apps Access'); ?></h3>
<p class="qortal-note">
Enable app access in Nextcloud and define allowed <code>qortal://</code> app addresses.
</p>
<div class="qortal-toggles">
<label>
<input id="qortal-user-publishing-enabled" type="checkbox" <?php if (!empty($settings['userPublishingEnabled'])) { print_unescaped('checked'); } ?>>
Expose user publishing
</label>
<label>
<input id="qortal-qapps-enabled" type="checkbox" <?php if (!empty($settings['qappsEnabled'])) { print_unescaped('checked'); } ?>>
Enable apps menu
</label>
<label>
<input id="qortal-qapps-browser-enabled" type="checkbox" <?php if (!empty($settings['qappsFullBrowserEnabled'])) { print_unescaped('checked'); } ?>>
Enable full browser
</label>
</div>
<p class="qortal-note">
When disabled, end users keep account/backup access but publishing-credit request, send, and name-registration actions are hidden.
</p>
<div class="qortal-grid">
<label for="qortal-files-publish-max-size-override">Files Publish Size Override</label>
<div class="qortal-input-group">
<input
id="qortal-files-publish-max-size-override"
type="text"
placeholder="Example: 24mb"
value="<?php p($filesPublishMaxSizeOverride); ?>"
>
<p class="qortal-note">
Optional admin override for Files Bridge publish size. Leave blank to use the env default
(<code><?php p($filesPublishEnvDefaultRaw !== '' ? $filesPublishEnvDefaultRaw : 'not set'); ?></code>) or the broker ceiling.
Actual publish size is still capped by the broker request limit and PHP runtime limits.
</p>
<p class="qortal-note">
Effective publish limit: <strong><?php p($filesPublishEffectiveLabel !== '' ? $filesPublishEffectiveLabel : 'Unavailable'); ?></strong>
<?php if ($filesPublishBatchLabel !== '') { ?>
| Effective batch payload: <strong><?php p($filesPublishBatchLabel); ?></strong>
<?php } ?>
| Desired source: <strong><?php p($filesPublishDesiredSource !== '' ? $filesPublishDesiredSource : 'broker_default'); ?></strong>
</p>
<p class="qortal-note">
Broker body limit: <code><?php p($filesPublishBrokerLimitRaw !== '' ? $filesPublishBrokerLimitRaw : 'not set'); ?></code>
| PHP upload_max_filesize: <code><?php p($filesPublishPhpUploadRaw !== '' ? $filesPublishPhpUploadRaw : 'not set'); ?></code>
| PHP post_max_size: <code><?php p($filesPublishPhpPostRaw !== '' ? $filesPublishPhpPostRaw : 'not set'); ?></code>
| PHP memory_limit: <code><?php p($filesPublishPhpMemoryRaw !== '' ? $filesPublishPhpMemoryRaw : 'not set'); ?></code>
</p>
<?php if (!empty($filesPublishNotes)) { ?>
<ul class="qortal-note">
<?php foreach ($filesPublishNotes as $filesPublishNote) { ?>
<li><?php p((string)$filesPublishNote); ?></li>
<?php } ?>
</ul>
<?php } ?>
<p class="qortal-note">
Container recreation envs: <code>BROKER_REQUEST_BODY_LIMIT</code>, <code>QORTAL_FILES_PUBLISH_MAX_SIZE</code>,
<code>NEXTCLOUD_PHP_UPLOAD_MAX_FILESIZE</code>, <code>NEXTCLOUD_PHP_POST_MAX_SIZE</code>, and
<code>NEXTCLOUD_PHP_MEMORY_LIMIT</code>.
</p>
</div>
</div>
<div class="qortal-grid">
<label for="qortal-files-publish-max-count-override">Immediate Publish File Cap</label>
<div class="qortal-input-group">
<input
id="qortal-files-publish-max-count-override"
type="number"
min="1"
step="1"
placeholder="Example: 24"
value="<?php p($filesPublishMaxCountOverride); ?>"
>
<p class="qortal-note">
Optional admin override for the maximum number of eligible files that may be published in one immediate request.
Leave blank to use the env default (<code><?php p($filesPublishEnvCountRaw !== '' ? $filesPublishEnvCountRaw : 'not set'); ?></code>)
or the built-in safety default.
</p>
<p class="qortal-note">
Effective immediate publish cap: <strong><?php p($filesPublishEffectiveCountLabel !== '' ? $filesPublishEffectiveCountLabel . ' files' : 'Unavailable'); ?></strong>
| Source: <strong><?php p($filesPublishEffectiveCountSource !== '' ? $filesPublishEffectiveCountSource : 'default'); ?></strong>
</p>
<p class="qortal-note">
This cap prevents a single publish action from overrunning typical Qortal node unconfirmed transaction ceilings.
Larger selections should be split into multiple batches until queued publishing is added.
</p>
<p class="qortal-note">
Optional env override: <code>QORTAL_FILES_PUBLISH_MAX_COUNT</code>.
</p>
</div>
</div>
<div class="qortal-inline-form qortal-qapps-form">
<label for="qortal-qapps-name">App Name</label>
<input id="qortal-qapps-name" type="text" placeholder="Qortal Notes">
<label for="qortal-qapps-address">Qortal Address</label>
<input id="qortal-qapps-address" type="text" placeholder="qortal://APP/NAME">
<label for="qortal-qapps-icon-mode">Icon Mode</label>
<select id="qortal-qapps-icon-mode">
<option value="auto" selected>Auto (gateway thumbnail)</option>
<option value="custom">Custom URL</option>
<option value="name">Use Icon From Name</option>
</select>
<label for="qortal-qapps-icon-url">Icon URL (optional)</label>
<input id="qortal-qapps-icon-url" type="text" placeholder="https://gateway/arbitrary/THUMBNAIL/APP/qortal_avatar">
<label for="qortal-qapps-icon-from-name">Icon Source Name (optional)</label>
<input id="qortal-qapps-icon-from-name" type="text" placeholder="Q-Mail">
<label for="qortal-qapps-description">Description (optional)</label>
<input id="qortal-qapps-description" type="text" placeholder="Short description">
<button id="qortal-add-qapp" class="button">Add App</button>
<button id="qortal-cancel-qapp-edit" class="button qortal-hidden" type="button">Cancel Edit</button>
<button id="qortal-clear-qapps" class="button">Clear List</button>
</div>
<div class="qortal-table-wrap">
<table class="qortal-table">
<thead>
<tr>
<th>Name</th>
<th>Qortal Address</th>
<th>Icon</th>
<th>Description</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="qortal-qapps-body"></tbody>
</table>
</div>
</div>
<div class="qortal-card qortal-full-only" id="qortal-initial-setup-checklist-card" data-collapsible="1" data-collapsible-default="closed">
<h3>Initial Setup Checklist</h3>
<ol>
<li>Configure broker URL and save settings.</li>
<li>Use <strong>Refresh Setup Data</strong> and confirm broker and External Auth are healthy.</li>
<li>Create or import account(s) visible to broker app credentials.</li>
<li>Link each Qortal address to a Nextcloud user (required for <code>link_only</code> mode).</li>
<li>Verify OIDC provider in Nextcloud login page.</li>
</ol>
</div>
<div class="qortal-card qortal-full-only" id="qortal-account-operations-card" data-collapsible="1" data-collapsible-default="closed">
<h3>Account Operations</h3>
<p class="qortal-note">
Create accounts through the broker using configured External Auth app credentials.
If you link an account to a user, share the password securely with that user.
</p>
<div class="qortal-inline-form">
<label for="qortal-wallet-password">Account Password</label>
<input id="qortal-wallet-password" type="password" placeholder="Required for account creation">
<label for="qortal-wallet-kdf-threads">KDF Threads</label>
<input id="qortal-wallet-kdf-threads" type="number" min="1" placeholder="Optional">
<label for="qortal-wallet-user-id">Nextcloud User ID (optional)</label>
<input id="qortal-wallet-user-id" type="text" placeholder="admin">
<button id="qortal-create-wallet" class="button">Create Account</button>
<button id="qortal-create-wallet-link" class="button">Create &amp; Link User</button>
<button id="qortal-refresh-wallets" class="button">Refresh Accounts</button>
</div>
<pre id="qortal-wallet-create-result" class="qortal-status qortal-compact-status"></pre>
<div class="qortal-table-wrap">
<table class="qortal-table">
<thead>
<tr>
<th>Account ID</th>
<th>Address</th>
<th>Created</th>
</tr>
</thead>
<tbody id="qortal-wallets-body"></tbody>
</table>
</div>
</div>
<div class="qortal-card qortal-full-only" id="qortal-identity-mapping-card" data-collapsible="1" data-collapsible-default="closed">
<h3>Identity Mapping Operations</h3>
<p class="qortal-note">
Link or unlink Qortal addresses so OIDC login can resolve identities in <code>link_only</code> mode.
</p>
<div class="qortal-inline-form qortal-link-form">
<label for="qortal-link-address">Qortal Address</label>
<input id="qortal-link-address" type="text" placeholder="Q...">
<label for="qortal-link-wallet-id">Account ID (optional)</label>
<input id="qortal-link-wallet-id" type="text" placeholder="UUID">
<label for="qortal-link-user-id">Nextcloud User ID</label>
<input id="qortal-link-user-id" type="text" placeholder="admin">
<button id="qortal-link-mapping" class="button">Link Mapping</button>
<button id="qortal-refresh-mappings" class="button">Refresh Mappings</button>
</div>
<div class="qortal-table-wrap">
<table class="qortal-table">
<thead>
<tr>
<th>Qortal Address</th>
<th>Nextcloud User</th>
<th>Account ID</th>
<th>Status</th>
<th>Updated</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="qortal-mappings-body"></tbody>
</table>
</div>
</div>
<div class="qortal-card qortal-full-only" id="qortal-allowlist-card" data-collapsible="1" data-collapsible-default="closed">
<h3>Allowlisted Qortal Addresses</h3>
<p class="qortal-note">
These addresses can be preloaded for future use. Enforcement only applies when
<code>OIDC_POLICY_MODE=auto_provision</code> and the guard is enabled.
</p>
<div class="qortal-inline-form">
<label for="qortal-allowlist-address">Allowlist Qortal Address</label>
<input id="qortal-allowlist-address" type="text" placeholder="Q...">
<button id="qortal-add-allowlist" class="button">Add To Allowlist</button>
<button id="qortal-refresh-allowlist" class="button">Refresh Allowlist</button>
</div>
<div class="qortal-table-wrap">
<table class="qortal-table">
<thead>
<tr>
<th>Qortal Address</th>
<th>Added By</th>
<th>Added</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="qortal-allowlist-body"></tbody>
</table>
</div>
</div>
<div class="qortal-card qortal-hidden" id="qortal-invites-card" data-collapsible="1" data-collapsible-default="closed">
<h3><?php p($isPoweredMode ? 'NuQloud Invite Tokens' : 'Invite Tokens'); ?></h3>
<p class="qortal-note">
<?php if ($isPoweredMode) { ?>
Generate an invite token for creating a NuQloud Create Account / Login cloud user via invite onboarding.
<?php } else { ?>
Generate invite tokens for users to paste into the Qortal login form when auto-provisioning.
<?php } ?>
</p>
<div class="qortal-inline-form">
<label for="qortal-invite-expiry-hours">Invite Expiry (hours)</label>
<input id="qortal-invite-expiry-hours" type="number" min="1" placeholder="168">
<label for="qortal-invite-comment">Comment / Recipient Note</label>
<input id="qortal-invite-comment" type="text" maxlength="500" placeholder="Example: Sent to jane@example.com">
<button id="qortal-create-invite" class="button">Create Invite</button>
<button id="qortal-refresh-invites" class="button">Refresh Invites</button>
</div>
<pre id="qortal-invite-create-result" class="qortal-status qortal-compact-status"></pre>
<div class="qortal-table-wrap">
<table class="qortal-table">
<thead>
<tr>
<th>Token</th>
<th>Comment</th>
<th>Status</th>
<th>Expires</th>
<th>Used By</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="qortal-invites-body"></tbody>
</table>
</div>
</div>
<div class="qortal-card" id="qortal-recovery-ops-card" data-collapsible="1" data-collapsible-default="closed">
<h3><?php p($isPoweredMode ? 'NuQloud Recovery Operations' : 'Recovery Operations'); ?></h3>
<p class="qortal-note">
Support tools for users who opted into managed recovery. Recovery reveal still requires both admin one-time code and user recovery passphrase.
</p>
<div class="qortal-inline-form">
<label for="qortal-recovery-target-user-id">Target Nextcloud User ID</label>
<input id="qortal-recovery-target-user-id" type="text" placeholder="alice">
<button id="qortal-recovery-status-check" class="button">Check Recovery Status</button>
</div>
<div class="qortal-inline-form">
<label for="qortal-recovery-code-expiry-minutes">Code Expiry (minutes)</label>
<input id="qortal-recovery-code-expiry-minutes" type="number" min="5" max="60" value="15">
<button id="qortal-recovery-issue-code" class="button">Issue One-Time Recovery Code</button>
</div>
<div class="qortal-inline-form">
<label for="qortal-recovery-emergency-hours">Emergency Window (hours)</label>
<input id="qortal-recovery-emergency-hours" type="number" min="1" max="72" value="24">
<button id="qortal-recovery-start-emergency" class="button">Start Emergency Temporary Login</button>
</div>
<pre id="qortal-recovery-admin-result" class="qortal-status qortal-compact-status"></pre>
</div>
<div class="qortal-card" id="qortal-invite-qortal-card" data-collapsible="1" data-collapsible-default="closed">
<h3><?php p($isPoweredMode ? 'Create NuQloud Account / Login' : 'Invite Existing Qortal Users To Cloud'); ?></h3>
<p class="qortal-note">
<?php if ($isPoweredMode) { ?>
Generate an invite message and invite token to create a new cloud user via NuQloud Create Account / Login.
Use the E-Mail address you used on the payment server so the account stays connected properly to the payment system.
<?php } else { ?>
Generate a message for existing Qortal users. When auto-provisioning is enabled, an invite token is included.
In link-only mode, this message prompts users to link their Qortal account to an existing Nextcloud login.
<?php } ?>
</p>
<div class="qortal-actions">
<button id="qortal-generate-invite-message" class="button"><?php p($isPoweredMode ? 'Generate NuQloud Invite Message' : 'Generate Invite Message'); ?></button>
<button id="qortal-copy-invite-message" class="button">Copy Message</button>
</div>
<textarea id="qortal-invite-message" rows="6" spellcheck="false" placeholder="<?php p($isPoweredMode ? 'NuQloud invite message will appear here.' : 'Invite message will appear here.'); ?>"></textarea>
</div>
<div class="qortal-card" id="qortal-onboard-cloud-card">
<div class="qortal-card-header">
<h3>Decentralized Auth Onboarding</h3>
<button id="qortal-onboard-toggle" class="button" type="button" aria-expanded="false">Show</button>
</div>
<div id="qortal-onboard-cloud-body">
<p class="qortal-note">
This assists existing cloud users in setting up their decentralized NuQloud authentication powered by Qortal.
</p>
<div class="qortal-grid">
<label for="qortal-notify-email-subject">Invite Subject Template</label>
<input id="qortal-notify-email-subject" type="text" value="<?php p((string)($settings['notifyEmailSubject'] ?? '')); ?>">
<label for="qortal-notify-email-body">Invite Message Template</label>
<div class="qortal-input-group">
<textarea id="qortal-notify-email-body" rows="6" spellcheck="false"><?php p((string)($settings['notifyEmailBody'] ?? '')); ?></textarea>
<p class="qortal-note">Placeholders: <code>{link}</code>, <code>{invite}</code>, <code>{user}</code>, <code>{displayName}</code>, <code>{email}</code>, <code>{cloudName}</code>, <code>{instanceName}</code>, <code>{orderId}</code>, <code>{invoiceId}</code>, <code>{packageTier}</code></p>
</div>
</div>
<div class="qortal-grid">
<label for="qortal-notify-user-ids">Target Users (one per line)</label>
<textarea id="qortal-notify-user-ids" rows="4" spellcheck="false" placeholder="admin&#10;alice&#10;bob"></textarea>
<label for="qortal-notify-group-ids">Target Groups (optional)</label>
<textarea id="qortal-notify-group-ids" rows="2" spellcheck="false" placeholder="admins&#10;staff"></textarea>
</div>
<div class="qortal-inline-form qortal-user-search">
<label for="qortal-user-search-query">Search Users</label>
<input id="qortal-user-search-query" type="text" placeholder="Type a username">
<button id="qortal-user-search-button" class="button">Search</button>
</div>
<p class="qortal-note">
Search and add users or groups, or add one per line above for invite functionality.
</p>
<div class="qortal-table-wrap">
<table class="qortal-table">
<thead>
<tr>
<th>User ID</th>
<th>Display Name</th>
<th>Email</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="qortal-user-search-results"></tbody>
</table>
</div>
<div class="qortal-inline-form qortal-user-search">
<label for="qortal-group-search-query">Search Groups</label>
<input id="qortal-group-search-query" type="text" placeholder="Type a group name">
<button id="qortal-group-search-button" class="button">Search</button>
</div>
<div class="qortal-table-wrap">
<table class="qortal-table">
<thead>
<tr>
<th>Group ID</th>
<th>Display Name</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="qortal-group-search-results"></tbody>
</table>
</div>
<div class="qortal-toggles">
<label>
<input id="qortal-notify-email" type="checkbox" checked>
Send Email
</label>
<label>
<input id="qortal-notify-inapp" type="checkbox" checked>
Send In-App Notification
</label>
</div>
<div class="qortal-actions">
<button id="qortal-preview-email" class="button">Preview Invite Message</button>
</div>
<div class="qortal-grid">
<label for="qortal-email-preview-subject">Invite Message Preview Subject</label>
<input id="qortal-email-preview-subject" type="text" readonly>
<label for="qortal-email-preview-body">Invite Message Preview Body</label>
<textarea id="qortal-email-preview-body" rows="6" readonly></textarea>
</div>
<div class="qortal-actions">
<button id="qortal-send-notifications" class="button">Send Onboarding Messages</button>
</div>
<pre id="qortal-notify-result" class="qortal-status qortal-compact-status"></pre>
</div>
</div>
<div id="qortal-admin-feedback" class="qortal-feedback" role="status"></div>
<pre id="qortal-admin-status" class="qortal-status"></pre>
</div>