Files

2366 lines
72 KiB
JavaScript

(function () {
const root = document.getElementById("qortal-admin-root");
if (!root) {
return;
}
const statusUrl = root.dataset.statusUrl;
const setupUrl = root.dataset.setupUrl;
const setupPlanUrl = root.dataset.setupPlanUrl;
const setupOccUrl = root.dataset.setupOccUrl;
const notifyUrl = root.dataset.notifyUrl;
const searchUsersUrl = root.dataset.searchUsersUrl;
const searchGroupsUrl = root.dataset.searchGroupsUrl;
const settingsUrl = root.dataset.settingsUrl;
const walletsUrl = root.dataset.walletsUrl;
const createWalletUrl = root.dataset.createWalletUrl;
const registerExternalAuthUrl = root.dataset.registerExternalAuthUrl;
const mappingsUrl = root.dataset.mappingsUrl;
const linkMappingUrl = root.dataset.linkMappingUrl;
const unlinkMappingUrl = root.dataset.unlinkMappingUrl;
const allowlistUrl = root.dataset.allowlistUrl;
const addAllowlistUrl = root.dataset.addAllowlistUrl;
const removeAllowlistUrl = root.dataset.removeAllowlistUrl;
const invitesUrl = root.dataset.invitesUrl;
const createInviteUrl = root.dataset.createInviteUrl;
const revokeInviteUrl = root.dataset.revokeInviteUrl;
const qappsJson = root.dataset.qappsJson || "[]";
const qappsEnabledValue = root.dataset.qappsEnabled === "1";
const qappsBrowserEnabledValue = root.dataset.qappsBrowserEnabled === "1";
const qappsBrowserAddressValue = root.dataset.qappsBrowserAddress || "";
const qappsDebugEnabledValue = root.dataset.qappsDebugEnabled === "1";
const nextcloudPublicUrlValue = root.dataset.nextcloudPublicUrl || "";
const feedbackEl = document.getElementById("qortal-admin-feedback");
const statusEl = document.getElementById("qortal-admin-status");
const saveButton = document.getElementById("qortal-save-settings");
const refreshSetupButton = document.getElementById("qortal-refresh-setup");
const testButton = document.getElementById("qortal-test-connection");
const setupPlanButton = document.getElementById("qortal-setup-plan");
const setupOccButton = document.getElementById("qortal-setup-occ");
const sendNotificationsButton = document.getElementById(
"qortal-send-notifications"
);
const searchUsersButton = document.getElementById(
"qortal-user-search-button"
);
const searchGroupsButton = document.getElementById(
"qortal-group-search-button"
);
const setupOverviewList = document.getElementById(
"qortal-setup-overview-list"
);
const setupOverviewNote = document.getElementById(
"qortal-setup-overview-note"
);
const setupComponentsCard = document.getElementById(
"qortal-setup-components"
);
const allowlistCard = document.getElementById("qortal-allowlist-card");
const invitesCard = document.getElementById("qortal-invites-card");
const inviteQortalCard = document.getElementById("qortal-invite-qortal-card");
const oidcPolicyMode = document.getElementById("qortal-oidc-policy-mode");
const oidcGuard = document.getElementById("qortal-oidc-guard");
const oidcInviteTtl = document.getElementById("qortal-oidc-invite-ttl");
const oidcRequireEmail = document.getElementById("qortal-oidc-require-email");
const oidcRedirects = document.getElementById("qortal-oidc-redirects");
const oidcPolicyNote = document.getElementById("qortal-oidc-policy-note");
const oidcPolicySelect = document.getElementById("qortal-oidc-policy-select");
const oidcGuardToggle = document.getElementById("qortal-oidc-guard-toggle");
const oidcInviteInput = document.getElementById(
"qortal-oidc-invite-ttl-input"
);
const oidcRequireEmailToggle = document.getElementById(
"qortal-oidc-require-email-toggle"
);
const oidcRedirectInput = document.getElementById(
"qortal-oidc-redirect-allowlist-input"
);
const oidcEnvButton = document.getElementById("qortal-oidc-env-generate");
const oidcEnvOutput = document.getElementById("qortal-oidc-env-output");
const externalAuthBaseUrlInput = document.getElementById(
"qortal-external-auth-base-url"
);
const brokerInternalApiTokenInput = document.getElementById(
"qortal-broker-internal-api-token"
);
const externalAuthAppIdInput = document.getElementById(
"qortal-external-auth-app-id"
);
const externalAuthAppSecretInput = document.getElementById(
"qortal-external-auth-app-secret"
);
const externalAuthNodeUrlInput = document.getElementById(
"qortal-external-auth-node-url"
);
const externalAuthNodeApiKeyInput = document.getElementById(
"qortal-external-auth-node-api-key"
);
const externalAuthNodeApiKeyModeInput = document.getElementById(
"qortal-external-auth-node-api-key-mode"
);
const externalAuthNodeApiKeyPathsInput = document.getElementById(
"qortal-external-auth-node-api-key-paths"
);
const externalAuthEnvButton = document.getElementById(
"qortal-external-auth-env-generate"
);
const externalAuthEnvOutput = document.getElementById(
"qortal-external-auth-env-output"
);
const externalAuthAppNameInput = document.getElementById(
"qortal-external-auth-app-name"
);
const externalAuthRegisterButton = document.getElementById(
"qortal-external-auth-register"
);
const qortalNodeUrlInput = document.getElementById("qortal-node-url");
const qortalNodeApiKeyInput = document.getElementById("qortal-node-api-key");
const qortalGatewayUrlInput = document.getElementById("qortal-gateway-url");
const qortalGatewayInsecureInput = document.getElementById(
"qortal-gateway-insecure"
);
const qortalNodeEnvButton = document.getElementById(
"qortal-node-env-generate"
);
const qortalNodeEnvOutput = document.getElementById("qortal-node-env-output");
const createWalletButton = document.getElementById("qortal-create-wallet");
const createWalletLinkButton = document.getElementById(
"qortal-create-wallet-link"
);
const refreshWalletsButton = document.getElementById(
"qortal-refresh-wallets"
);
const walletUserIdInput = document.getElementById("qortal-wallet-user-id");
const linkMappingButton = document.getElementById("qortal-link-mapping");
const refreshMappingsButton = document.getElementById(
"qortal-refresh-mappings"
);
const addAllowlistButton = document.getElementById("qortal-add-allowlist");
const refreshAllowlistButton = document.getElementById(
"qortal-refresh-allowlist"
);
const createInviteButton = document.getElementById("qortal-create-invite");
const refreshInvitesButton = document.getElementById(
"qortal-refresh-invites"
);
const walletCreateResult = document.getElementById(
"qortal-wallet-create-result"
);
const walletsBody = document.getElementById("qortal-wallets-body");
const mappingsBody = document.getElementById("qortal-mappings-body");
const allowlistBody = document.getElementById("qortal-allowlist-body");
const inviteCreateResult = document.getElementById(
"qortal-invite-create-result"
);
const invitesBody = document.getElementById("qortal-invites-body");
const qappsEnabledInput = document.getElementById("qortal-qapps-enabled");
const qappsBrowserEnabledInput = document.getElementById(
"qortal-qapps-browser-enabled"
);
const qappsBrowserAddressInput = document.getElementById(
"qortal-qapps-browser-address"
);
const qappsDebugEnabledInput = document.getElementById(
"qortal-qapps-debug-enabled"
);
const qappsNameInput = document.getElementById("qortal-qapps-name");
const qappsAddressInput = document.getElementById("qortal-qapps-address");
const qappsIconModeInput = document.getElementById("qortal-qapps-icon-mode");
const qappsIconUrlInput = document.getElementById("qortal-qapps-icon-url");
const qappsDescriptionInput = document.getElementById(
"qortal-qapps-description"
);
const addQappButton = document.getElementById("qortal-add-qapp");
const clearQappsButton = document.getElementById("qortal-clear-qapps");
const qappsBody = document.getElementById("qortal-qapps-body");
const oidcIssuerInput = document.getElementById("qortal-oidc-issuer-url");
const oidcClientIdInput = document.getElementById("qortal-oidc-client-id");
const oidcClientSecretInput = document.getElementById(
"qortal-oidc-client-secret"
);
const nextcloudPublicUrlInput = document.getElementById(
"qortal-nextcloud-public-url"
);
const setupResult = document.getElementById("qortal-setup-result");
const inviteMessageButton = document.getElementById(
"qortal-generate-invite-message"
);
const copyInviteMessageButton = document.getElementById(
"qortal-copy-invite-message"
);
const inviteMessageBox = document.getElementById("qortal-invite-message");
const notifyUserIds = document.getElementById("qortal-notify-user-ids");
const notifyGroupIds = document.getElementById("qortal-notify-group-ids");
const notifyEmail = document.getElementById("qortal-notify-email");
const notifyInApp = document.getElementById("qortal-notify-inapp");
const notifyQueue = document.getElementById("qortal-notify-queue");
const notifyIncludeInvite = document.getElementById(
"qortal-notify-include-invite"
);
const notifyResult = document.getElementById("qortal-notify-result");
const notifyEmailSubject = document.getElementById(
"qortal-notify-email-subject"
);
const notifyEmailBody = document.getElementById("qortal-notify-email-body");
const userSearchQuery = document.getElementById("qortal-user-search-query");
const userSearchResults = document.getElementById(
"qortal-user-search-results"
);
const groupSearchQuery = document.getElementById("qortal-group-search-query");
const groupSearchResults = document.getElementById(
"qortal-group-search-results"
);
const previewEmailButton = document.getElementById("qortal-preview-email");
const previewSubject = document.getElementById(
"qortal-email-preview-subject"
);
const previewBody = document.getElementById("qortal-email-preview-body");
if (
!statusUrl ||
!setupUrl ||
!setupPlanUrl ||
!setupOccUrl ||
!notifyUrl ||
!searchUsersUrl ||
!searchGroupsUrl ||
!settingsUrl ||
!walletsUrl ||
!createWalletUrl ||
!registerExternalAuthUrl ||
!mappingsUrl ||
!linkMappingUrl ||
!unlinkMappingUrl ||
!allowlistUrl ||
!addAllowlistUrl ||
!removeAllowlistUrl ||
!invitesUrl ||
!createInviteUrl ||
!revokeInviteUrl
) {
return;
}
if (
!feedbackEl ||
!statusEl ||
!saveButton ||
!refreshSetupButton ||
!testButton ||
!setupPlanButton ||
!setupOccButton ||
!sendNotificationsButton ||
!searchUsersButton ||
!searchGroupsButton ||
!setupOverviewList ||
!setupOverviewNote ||
!setupComponentsCard ||
!allowlistCard ||
!invitesCard ||
!inviteQortalCard ||
!oidcPolicyMode ||
!oidcGuard ||
!oidcInviteTtl ||
!oidcRequireEmail ||
!oidcRedirects ||
!oidcPolicyNote ||
!oidcPolicySelect ||
!oidcGuardToggle ||
!oidcInviteInput ||
!oidcRequireEmailToggle ||
!oidcRedirectInput ||
!oidcEnvButton ||
!oidcEnvOutput ||
!externalAuthBaseUrlInput ||
!brokerInternalApiTokenInput ||
!externalAuthAppIdInput ||
!externalAuthAppSecretInput ||
!externalAuthNodeUrlInput ||
!externalAuthNodeApiKeyInput ||
!externalAuthNodeApiKeyModeInput ||
!externalAuthNodeApiKeyPathsInput ||
!externalAuthEnvButton ||
!externalAuthEnvOutput ||
!externalAuthAppNameInput ||
!externalAuthRegisterButton ||
!qortalNodeUrlInput ||
!qortalNodeApiKeyInput ||
!qortalGatewayUrlInput ||
!qortalGatewayInsecureInput ||
!qortalNodeEnvButton ||
!qortalNodeEnvOutput ||
!createWalletButton ||
!createWalletLinkButton ||
!refreshWalletsButton ||
!walletUserIdInput ||
!linkMappingButton ||
!refreshMappingsButton ||
!walletCreateResult ||
!walletsBody ||
!mappingsBody ||
!addAllowlistButton ||
!refreshAllowlistButton ||
!createInviteButton ||
!refreshInvitesButton ||
!allowlistBody ||
!inviteCreateResult ||
!invitesBody ||
!qappsEnabledInput ||
!qappsBrowserEnabledInput ||
!qappsBrowserAddressInput ||
!qappsDebugEnabledInput ||
!qappsNameInput ||
!qappsAddressInput ||
!qappsIconModeInput ||
!qappsIconUrlInput ||
!qappsDescriptionInput ||
!addQappButton ||
!clearQappsButton ||
!qappsBody ||
!oidcIssuerInput ||
!oidcClientIdInput ||
!oidcClientSecretInput ||
!nextcloudPublicUrlInput ||
!setupResult ||
!inviteMessageButton ||
!copyInviteMessageButton ||
!inviteMessageBox ||
!notifyUserIds ||
!notifyGroupIds ||
!notifyEmail ||
!notifyInApp ||
!notifyQueue ||
!notifyIncludeInvite ||
!notifyResult ||
!notifyEmailSubject ||
!notifyEmailBody ||
!userSearchQuery ||
!userSearchResults ||
!groupSearchQuery ||
!groupSearchResults ||
!previewEmailButton ||
!previewSubject ||
!previewBody
) {
return;
}
const headers = {
requesttoken: OC.requestToken,
};
let lastUserResults = [];
let lastSetupPlan = null;
let autoProvisionEnabled = false;
let autoProvisionGuardEnabled = false;
let hasUnsavedChanges = false;
let baselineSnapshot = null;
let qapps = [];
try {
const parsed = JSON.parse(qappsJson);
if (Array.isArray(parsed)) {
qapps = parsed
.filter(function (entry) {
return (
entry &&
typeof entry.address === "string" &&
entry.address.length > 0
);
})
.map(function (entry) {
return {
name: typeof entry.name === "string" ? entry.name : "",
address: entry.address,
description:
typeof entry.description === "string" ? entry.description : "",
iconMode:
typeof entry.iconMode === "string" ? entry.iconMode : "auto",
iconUrl: typeof entry.iconUrl === "string" ? entry.iconUrl : "",
};
});
}
} catch (error) {
qapps = [];
}
qappsEnabledInput.checked = qappsEnabledValue;
qappsBrowserEnabledInput.checked = qappsBrowserEnabledValue;
qappsBrowserAddressInput.value = qappsBrowserAddressValue;
qappsDebugEnabledInput.checked = qappsDebugEnabledValue;
qappsIconModeInput.value = "auto";
qappsIconUrlInput.value = "";
updateIconUrlState();
oidcEnvOutput.textContent = buildEnvSnippet();
externalAuthEnvOutput.textContent = buildExternalAuthEnvSnippet();
qortalNodeEnvOutput.textContent = buildQortalNodeEnvSnippet();
baselineSnapshot = JSON.stringify(collectSettings());
function markDirty() {
hasUnsavedChanges = true;
}
function updateIconUrlState() {
const isCustom = qappsIconModeInput.value === "custom";
qappsIconUrlInput.disabled = !isCustom;
if (!isCustom) {
qappsIconUrlInput.value = "";
}
}
function resetDirty() {
hasUnsavedChanges = false;
baselineSnapshot = JSON.stringify(collectSettings());
}
function setFeedback(message, isError) {
feedbackEl.textContent = message;
feedbackEl.classList.toggle("error", Boolean(isError));
feedbackEl.classList.toggle("success", !isError && message.length > 0);
}
function collectSettings() {
return {
brokerBaseUrl: document.getElementById("qortal-broker-base-url").value,
brokerInternalApiToken: brokerInternalApiTokenInput.value,
externalAuthDocsUrl: document.getElementById(
"qortal-external-auth-docs-url"
).value,
externalAuthBaseUrl: externalAuthBaseUrlInput.value,
externalAuthAppId: externalAuthAppIdInput.value,
externalAuthAppSecret: externalAuthAppSecretInput.value,
externalAuthNodeUrl: externalAuthNodeUrlInput.value,
externalAuthNodeApiKey: externalAuthNodeApiKeyInput.value,
externalAuthNodeApiKeyMode: externalAuthNodeApiKeyModeInput.value,
externalAuthNodeApiKeyPaths: externalAuthNodeApiKeyPathsInput.value,
featureQdnBackups: document.getElementById("qortal-feature-qdn-backups")
.checked
? "1"
: "",
featureQmail: document.getElementById("qortal-feature-qmail").checked
? "1"
: "",
oidcIssuerUrl: oidcIssuerInput.value,
oidcClientId: oidcClientIdInput.value,
oidcClientSecret: oidcClientSecretInput.value,
oidcPolicyMode: oidcPolicySelect.value,
oidcAutoProvisionGuard: oidcGuardToggle.value,
oidcInviteTtlSeconds: oidcInviteInput.value.trim(),
oidcRequireEmailForNewAccount: oidcRequireEmailToggle.value,
oidcRedirectAllowlist: oidcRedirectInput.value,
nextcloudPublicUrl: nextcloudPublicUrlInput.value,
qortalNodeUrl: qortalNodeUrlInput.value,
qortalNodeApiKey: qortalNodeApiKeyInput.value,
qortalGatewayUrl: qortalGatewayUrlInput.value,
qortalGatewayAllowInsecure: qortalGatewayInsecureInput.checked ? "1" : "",
notifyEmailSubject: notifyEmailSubject.value,
notifyEmailBody: notifyEmailBody.value,
qappsEnabled: qappsEnabledInput.checked ? "1" : "",
qappsFullBrowserEnabled: qappsBrowserEnabledInput.checked ? "1" : "",
qappsFullBrowserAddress: qappsBrowserAddressInput.value,
qappsDebugEnabled: qappsDebugEnabledInput.checked ? "1" : "",
qappsList: JSON.stringify(qapps),
};
}
async function requestJson(url, options) {
const response = await fetch(url, options);
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || "Request failed");
}
return payload;
}
function assertBrokerOk(payload, fallbackError) {
if (payload && payload.ok === false) {
throw new Error(payload.error || fallbackError);
}
}
function formatTimestamp(value) {
if (!value) {
return "";
}
try {
return new Date(value).toLocaleString();
} catch (error) {
return String(value);
}
}
function renderStatusItem(label, status, detail) {
const li = document.createElement("li");
const dot = document.createElement("span");
dot.className = "qortal-status-dot " + status;
const text = document.createElement("span");
text.textContent = detail ? `${label}: ${detail}` : label;
li.appendChild(dot);
li.appendChild(text);
return li;
}
function normalizeGuardMode(value) {
if (value === true) {
return "invite_or_allowlist";
}
if (value === false) {
return "off";
}
if (typeof value !== "string") {
return "";
}
const raw = value.trim().toLowerCase();
if (["invite_or_allowlist", "enabled", "on", "true", "1"].includes(raw)) {
return "invite_or_allowlist";
}
if (["off", "disabled", "none", "false", "0"].includes(raw)) {
return "off";
}
return "";
}
function isGuardEnabled(value) {
return normalizeGuardMode(value) === "invite_or_allowlist";
}
function extractBrokerError(config) {
if (!config || typeof config !== "object") {
return "";
}
if (
config.ok === false &&
typeof config.error === "string" &&
config.error.trim() !== ""
) {
return config.error;
}
if (
config.data &&
typeof config.data === "object" &&
typeof config.data.error === "string" &&
config.data.error.trim() !== ""
) {
return config.data.error;
}
return "";
}
function renderSetupOverview(payload) {
setupOverviewList.innerHTML = "";
setupOverviewNote.textContent = "";
const brokerHealth =
payload && payload.status ? payload.status.brokerHealth : null;
const qortalHealth =
payload && payload.status ? payload.status.qortalHealth : null;
const rawConfig = payload ? payload.oidcConfig : null;
const oidcConfig =
rawConfig && rawConfig.ok !== false ? rawConfig.data || rawConfig : null;
if (brokerHealth && brokerHealth.ok !== false) {
setupOverviewList.appendChild(renderStatusItem("Broker reachable", "ok"));
} else {
setupOverviewList.appendChild(
renderStatusItem(
"Broker reachable",
"error",
brokerHealth && brokerHealth.error
? brokerHealth.error
: "unreachable"
)
);
}
if (qortalHealth && qortalHealth.ok !== false) {
setupOverviewList.appendChild(
renderStatusItem("External Auth reachable", "ok")
);
} else {
setupOverviewList.appendChild(
renderStatusItem(
"External Auth reachable",
"error",
qortalHealth && qortalHealth.error
? qortalHealth.error
: "unreachable"
)
);
}
const externalAuthBaseUrl = externalAuthBaseUrlInput.value.trim();
const externalAuthAppId = externalAuthAppIdInput.value.trim();
const externalAuthAppSecret = externalAuthAppSecretInput.value.trim();
const externalAuthNodeUrl = externalAuthNodeUrlInput.value.trim();
const externalAuthNodeApiKey = externalAuthNodeApiKeyInput.value.trim();
const externalAuthNodeApiKeyMode =
externalAuthNodeApiKeyModeInput.value.trim();
const externalAuthNodeApiKeyPaths =
externalAuthNodeApiKeyPathsInput.value.trim();
const qortalNodeApiKey = qortalNodeApiKeyInput.value.trim();
if (externalAuthBaseUrl) {
setupOverviewList.appendChild(
renderStatusItem("External Auth Base URL", "ok", externalAuthBaseUrl)
);
} else {
setupOverviewList.appendChild(
renderStatusItem("External Auth Base URL", "warn", "missing")
);
}
if (externalAuthAppId && externalAuthAppSecret) {
setupOverviewList.appendChild(
renderStatusItem("External Auth app credentials", "ok")
);
} else {
setupOverviewList.appendChild(
renderStatusItem("External Auth app credentials", "warn", "missing")
);
}
if (externalAuthNodeUrl) {
setupOverviewList.appendChild(
renderStatusItem("External Auth Qortal node", "ok", externalAuthNodeUrl)
);
} else {
setupOverviewList.appendChild(
renderStatusItem("External Auth Qortal node", "warn", "missing")
);
}
if (externalAuthNodeApiKey) {
setupOverviewList.appendChild(
renderStatusItem("External Auth node API key", "ok", "set")
);
} else if (qortalNodeApiKey) {
setupOverviewList.appendChild(
renderStatusItem(
"External Auth node API key",
"warn",
"using Qortal node key fallback"
)
);
} else if (externalAuthNodeUrl) {
setupOverviewList.appendChild(
renderStatusItem("External Auth node API key", "warn", "missing")
);
}
if (externalAuthNodeApiKeyMode) {
setupOverviewList.appendChild(
renderStatusItem(
"External Auth key mode",
"ok",
externalAuthNodeApiKeyMode
)
);
}
if (externalAuthNodeApiKeyPaths) {
setupOverviewList.appendChild(
renderStatusItem(
"External Auth key paths",
"ok",
externalAuthNodeApiKeyPaths
)
);
}
const qortalNodeUrl = qortalNodeUrlInput.value.trim();
const qortalGatewayUrl = qortalGatewayUrlInput.value.trim();
const qortalGatewayAllowInsecure = qortalGatewayInsecureInput.checked;
if (qortalNodeUrl) {
setupOverviewList.appendChild(
renderStatusItem("Qortal node URL", "ok", qortalNodeUrl)
);
} else {
setupOverviewList.appendChild(
renderStatusItem("Qortal node URL", "warn", "missing")
);
}
if (qortalNodeApiKey) {
setupOverviewList.appendChild(
renderStatusItem("Qortal node API key", "ok", "set")
);
} else {
setupOverviewList.appendChild(
renderStatusItem("Qortal node API key", "warn", "missing")
);
}
if (qortalGatewayUrl) {
setupOverviewList.appendChild(
renderStatusItem("Gateway node URL", "ok", qortalGatewayUrl)
);
} else {
setupOverviewList.appendChild(
renderStatusItem("Gateway node URL", "warn", "missing")
);
}
if (qortalGatewayAllowInsecure) {
setupOverviewList.appendChild(
renderStatusItem("Gateway TLS verification", "warn", "disabled")
);
}
const publicUrl = nextcloudPublicUrlInput.value.trim();
if (publicUrl) {
setupOverviewList.appendChild(
renderStatusItem("Nextcloud public URL set", "ok", publicUrl)
);
} else {
setupOverviewList.appendChild(
renderStatusItem("Nextcloud public URL set", "warn", "missing")
);
}
const issuer = oidcIssuerInput.value.trim() || brokerBaseFromSettings();
if (issuer) {
setupOverviewList.appendChild(
renderStatusItem("OIDC issuer URL", "ok", issuer)
);
} else {
setupOverviewList.appendChild(
renderStatusItem("OIDC issuer URL", "warn", "missing")
);
}
if (oidcConfig && oidcConfig.policyMode) {
setupOverviewList.appendChild(
renderStatusItem("OIDC policy mode", "ok", oidcConfig.policyMode)
);
} else {
const policyError = extractBrokerError(rawConfig);
setupOverviewList.appendChild(
renderStatusItem(
"OIDC policy mode",
"warn",
policyError
? `unknown (${policyError})`
: "unknown (broker config not available)"
)
);
}
if (oidcConfig) {
const guardMode = normalizeGuardMode(oidcConfig.autoProvisionGuard);
if (guardMode) {
setupOverviewList.appendChild(
renderStatusItem(
"Auto-provision guard",
guardMode === "invite_or_allowlist" ? "ok" : "warn",
guardMode
)
);
} else {
setupOverviewList.appendChild(
renderStatusItem("Auto-provision guard", "warn", "unknown")
);
}
}
const hasClientSecret = Boolean(oidcClientSecretInput.value.trim());
setupOverviewList.appendChild(
renderStatusItem(
"OIDC client secret",
hasClientSecret ? "ok" : "error",
hasClientSecret ? "set" : "missing"
)
);
}
function brokerBaseFromSettings() {
const broker = document.getElementById("qortal-broker-base-url");
if (!broker) {
return "";
}
return broker.value.trim();
}
function renderOidcPolicy(config) {
const data = config && config.ok !== false ? config.data || config : null;
if (!data) {
const brokerError = extractBrokerError(config);
oidcPolicyMode.textContent = "unavailable";
oidcGuard.textContent = "unavailable";
oidcInviteTtl.textContent = "-";
oidcRequireEmail.textContent = "unavailable";
oidcRedirects.textContent = "-";
oidcPolicyNote.textContent = brokerError
? `Broker policy config is unavailable: ${brokerError}`
: "Broker policy config is unavailable. Check broker connectivity.";
autoProvisionEnabled = oidcPolicySelect.value === "auto_provision";
autoProvisionGuardEnabled = isGuardEnabled(oidcGuardToggle.value);
updateAutoProvisionVisibility({
policyMode: autoProvisionEnabled ? "auto_provision" : "link_only",
autoProvisionGuard: autoProvisionGuardEnabled,
});
oidcEnvOutput.textContent = buildEnvSnippet();
return;
}
const source = data.sources || {};
const policySource =
source.policyMode === "admin" ? "admin override" : "env";
const guardSource =
source.autoProvisionGuard === "admin" ? "admin override" : "env";
const ttlSource =
source.inviteTtlSeconds === "admin" ? "admin override" : "env";
const requireEmailSource =
source.requireEmailForNewAccount === "admin" ? "admin override" : "env";
const redirectsSource =
source.redirectUriAllowlist === "admin" ? "admin override" : "env";
const guardMode = normalizeGuardMode(data.autoProvisionGuard);
oidcPolicyMode.textContent = `${
data.policyMode || "unknown"
} (${policySource})`;
oidcGuard.textContent = `${guardMode || "unknown"} (${guardSource})`;
oidcInviteTtl.textContent = `${
typeof data.inviteTtlSeconds === "number"
? String(data.inviteTtlSeconds)
: "-"
} (${ttlSource})`;
oidcRequireEmail.textContent = `${
data.requireEmailForNewAccount ? "required" : "off"
} (${requireEmailSource})`;
const redirectsText =
Array.isArray(data.redirectUriAllowlist) &&
data.redirectUriAllowlist.length > 0
? data.redirectUriAllowlist.join(", ")
: "(none)";
oidcRedirects.textContent = `${redirectsText} (${redirectsSource})`;
const overrides = data.overrides || {};
oidcPolicySelect.value =
typeof overrides.policyMode === "string" ? overrides.policyMode : "";
oidcGuardToggle.value = normalizeGuardMode(overrides.autoProvisionGuard);
oidcInviteInput.value =
typeof overrides.inviteTtlSeconds === "number"
? String(overrides.inviteTtlSeconds)
: "";
if (typeof overrides.requireEmailForNewAccount === "boolean") {
oidcRequireEmailToggle.value = overrides.requireEmailForNewAccount
? "required"
: "off";
} else {
oidcRequireEmailToggle.value = "";
}
oidcRedirectInput.value = Array.isArray(overrides.redirectUriAllowlist)
? overrides.redirectUriAllowlist.join(",")
: "";
oidcEnvOutput.textContent = buildEnvSnippet();
autoProvisionEnabled = data.policyMode === "auto_provision";
autoProvisionGuardEnabled = guardMode === "invite_or_allowlist";
if (autoProvisionEnabled) {
oidcPolicyNote.textContent = `Auto-provision is enabled. Guard mode is ${
guardMode || "unknown"
}. Source labels show env vs admin overrides.`;
} else {
oidcPolicyNote.textContent =
"Auto-provision is disabled. Invite tokens are hidden; allowlist entries are not enforced. Source labels show env vs admin overrides.";
}
updateAutoProvisionVisibility(data);
}
function updateAutoProvisionVisibility(config) {
const policy =
config && config.policyMode ? config.policyMode : "link_only";
const guardEnabled = isGuardEnabled(config && config.autoProvisionGuard);
const autoProvisionEnabled = policy === "auto_provision";
allowlistCard.classList.toggle("qortal-hidden", false);
invitesCard.classList.toggle(
"qortal-hidden",
!autoProvisionEnabled || !guardEnabled
);
notifyIncludeInvite.disabled = !autoProvisionEnabled;
if (!autoProvisionEnabled) {
notifyIncludeInvite.checked = false;
}
}
function updateSetupComponentsVisibility(plan) {
if (!plan || !Array.isArray(plan.errors)) {
setupComponentsCard.classList.remove("qortal-hidden");
setupOverviewNote.textContent = "Run setup if this is a fresh instance.";
return;
}
if (plan.errors.length > 0) {
setupComponentsCard.classList.remove("qortal-hidden");
setupOverviewNote.textContent =
"Setup commands are required to complete OIDC provider configuration.";
} else {
setupComponentsCard.classList.add("qortal-hidden");
setupOverviewNote.textContent =
"Setup commands are hidden because configuration looks complete.";
}
}
function buildEnvSnippet() {
const policyMode = oidcPolicySelect.value.trim();
const guard = oidcGuardToggle.value.trim();
const inviteTtl = oidcInviteInput.value.trim();
const requireEmail = oidcRequireEmailToggle.value.trim();
const redirectAllowlist = oidcRedirectInput.value.trim();
const lines = [];
if (policyMode) {
lines.push(`OIDC_POLICY_MODE=${policyMode}`);
}
if (guard) {
lines.push(`OIDC_AUTO_PROVISION_GUARD=${guard}`);
}
if (inviteTtl) {
lines.push(`OIDC_INVITE_TTL_SECONDS=${inviteTtl}`);
}
if (requireEmail) {
lines.push(
`OIDC_REQUIRE_EMAIL_FOR_NEW_ACCOUNT=${
requireEmail === "required" ? "true" : "false"
}`
);
}
if (redirectAllowlist) {
lines.push(`OIDC_REDIRECT_URI_ALLOWLIST=${redirectAllowlist}`);
}
if (lines.length === 0) {
lines.push("# Using env defaults for OIDC policy settings");
}
return lines.join("\n");
}
function buildExternalAuthEnvSnippet() {
const baseUrl =
externalAuthBaseUrlInput.value.trim() || "http://external_auth:3191";
const brokerInternalApiToken = brokerInternalApiTokenInput.value.trim();
const appId = externalAuthAppIdInput.value.trim();
const appSecret = externalAuthAppSecretInput.value.trim();
const nodeUrl = externalAuthNodeUrlInput.value.trim();
const nodeApiKey = externalAuthNodeApiKeyInput.value.trim();
const nodeApiKeyMode =
externalAuthNodeApiKeyModeInput.value.trim() || "paths";
const defaultNodeApiKeyPaths = "/";
const nodeApiKeyPathsRaw = externalAuthNodeApiKeyPathsInput.value.trim();
const nodeApiKeyPaths =
nodeApiKeyMode === "paths"
? nodeApiKeyPathsRaw || defaultNodeApiKeyPaths
: nodeApiKeyPathsRaw;
const lines = [`QORTAL_EXTERNAL_AUTH_BASE_URL=${baseUrl}`];
if (brokerInternalApiToken) {
lines.push(`BROKER_INTERNAL_API_TOKEN=${brokerInternalApiToken}`);
}
if (appId) {
lines.push(`QORTAL_EXTERNAL_AUTH_APP_ID=${appId}`);
}
if (appSecret) {
lines.push(`QORTAL_EXTERNAL_AUTH_APP_SECRET=${appSecret}`);
}
if (nodeUrl) {
lines.push(`QORTAL_AUTH_NODE_URL=${nodeUrl}`);
}
if (nodeApiKey) {
lines.push(`QORTAL_AUTH_NODE_API_KEY=${nodeApiKey}`);
}
if (nodeApiKeyMode) {
lines.push(`QORTAL_AUTH_NODE_API_KEY_MODE=${nodeApiKeyMode}`);
}
if (nodeApiKeyPaths) {
lines.push(`QORTAL_AUTH_NODE_API_KEY_PATHS=${nodeApiKeyPaths}`);
}
return lines.join("\n");
}
async function registerExternalAuthApp() {
const name =
externalAuthAppNameInput.value.trim() || "qortal-nextcloud-integration";
const warning =
"Registering a new External Auth app will replace existing credentials.\\n\\n" +
"If External Auth is already configured via .env, this will generate a new App ID/Secret and you may lose access to existing wallets.\\n\\n" +
"Backup your .env or .env.devprod before continuing.\\n\\n" +
"Continue?";
if (!window.confirm(warning)) {
return;
}
setFeedback("Registering External Auth app...", false);
const body = new URLSearchParams({ name });
const payload = await requestJson(registerExternalAuthUrl, {
method: "POST",
headers,
body,
});
externalAuthAppIdInput.value = payload.appId || "";
externalAuthAppSecretInput.value = payload.appSecret || "";
externalAuthEnvOutput.textContent = buildExternalAuthEnvSnippet();
await saveSettings();
setFeedback("External Auth app registered and saved.", false);
}
function buildQortalNodeEnvSnippet() {
const nodeUrl = qortalNodeUrlInput.value.trim();
const nodeApiKey = qortalNodeApiKeyInput.value.trim();
const gatewayUrl = qortalGatewayUrlInput.value.trim();
const lines = [];
if (nodeUrl) {
lines.push(`QORTAL_NODE_URL=${nodeUrl}`);
}
if (nodeApiKey) {
lines.push(`QORTAL_NODE_API_KEY=${nodeApiKey}`);
}
if (gatewayUrl) {
lines.push(`QORTAL_GATEWAY_URL=${gatewayUrl}`);
}
if (lines.length === 0) {
lines.push(
"# Set Qortal node/gateway values above to generate a snippet."
);
}
return lines.join("\n");
}
function renderWallets(payload) {
const wallets =
payload &&
payload.ok &&
payload.data &&
Array.isArray(payload.data.wallets)
? payload.data.wallets
: [];
walletsBody.innerHTML = "";
if (wallets.length === 0) {
walletsBody.innerHTML =
'<tr><td colspan="3">No wallets visible to broker app credentials.</td></tr>';
return;
}
wallets.forEach(function (wallet) {
const row = document.createElement("tr");
const walletIdCell = document.createElement("td");
const walletIdCode = document.createElement("code");
walletIdCode.textContent = wallet.walletId || "";
walletIdCell.appendChild(walletIdCode);
const addressCell = document.createElement("td");
const addressCode = document.createElement("code");
addressCode.textContent = wallet.address0 || "";
addressCell.appendChild(addressCode);
const createdCell = document.createElement("td");
createdCell.textContent = formatTimestamp(
wallet.createdAt ? Number(wallet.createdAt) : ""
);
row.appendChild(walletIdCell);
row.appendChild(addressCell);
row.appendChild(createdCell);
walletsBody.appendChild(row);
});
}
function renderMappings(payload) {
const mappings =
payload &&
payload.ok &&
payload.data &&
Array.isArray(payload.data.mappings)
? payload.data.mappings
: [];
mappingsBody.innerHTML = "";
if (mappings.length === 0) {
mappingsBody.innerHTML =
'<tr><td colspan="6">No mappings found.</td></tr>';
return;
}
mappings.forEach(function (mapping) {
const row = document.createElement("tr");
const addressCell = document.createElement("td");
const addressCode = document.createElement("code");
addressCode.textContent = mapping.qortalAddress || "";
addressCell.appendChild(addressCode);
const userCell = document.createElement("td");
const userCode = document.createElement("code");
userCode.textContent = mapping.nextcloudUserId || "";
userCell.appendChild(userCode);
const walletCell = document.createElement("td");
const walletCode = document.createElement("code");
walletCode.textContent = mapping.walletId || "";
walletCell.appendChild(walletCode);
const statusCell = document.createElement("td");
statusCell.textContent = mapping.status || "";
const updatedCell = document.createElement("td");
updatedCell.textContent = formatTimestamp(mapping.updatedAt);
const actionCell = document.createElement("td");
const unlinkButton = document.createElement("button");
unlinkButton.className = "button";
unlinkButton.textContent = "Unlink";
unlinkButton.addEventListener("click", async function () {
try {
await unlinkMapping(mapping.qortalAddress);
setFeedback("Mapping unlinked.", false);
} catch (error) {
setFeedback(error.message || "Failed to unlink mapping", true);
}
});
actionCell.appendChild(unlinkButton);
row.appendChild(addressCell);
row.appendChild(userCell);
row.appendChild(walletCell);
row.appendChild(statusCell);
row.appendChild(updatedCell);
row.appendChild(actionCell);
mappingsBody.appendChild(row);
});
}
function renderAllowlist(payload) {
const entries =
payload &&
payload.ok &&
payload.data &&
Array.isArray(payload.data.allowlist)
? payload.data.allowlist
: [];
allowlistBody.innerHTML = "";
if (entries.length === 0) {
allowlistBody.innerHTML =
'<tr><td colspan="4">No allowlisted addresses yet.</td></tr>';
return;
}
entries.forEach(function (entry) {
const row = document.createElement("tr");
const addressCell = document.createElement("td");
const addressCode = document.createElement("code");
addressCode.textContent = entry.qortalAddress || "";
addressCell.appendChild(addressCode);
const addedByCell = document.createElement("td");
addedByCell.textContent = entry.addedBy || "";
const addedAtCell = document.createElement("td");
addedAtCell.textContent = formatTimestamp(entry.addedAt);
const actionCell = document.createElement("td");
const removeButton = document.createElement("button");
removeButton.className = "button";
removeButton.textContent = "Remove";
removeButton.addEventListener("click", async function () {
try {
await removeAllowlist(entry.qortalAddress);
setFeedback("Allowlist entry removed.", false);
} catch (error) {
setFeedback(
error.message || "Failed to remove allowlist entry",
true
);
}
});
actionCell.appendChild(removeButton);
row.appendChild(addressCell);
row.appendChild(addedByCell);
row.appendChild(addedAtCell);
row.appendChild(actionCell);
allowlistBody.appendChild(row);
});
}
function renderQapps() {
qappsBody.innerHTML = "";
if (qapps.length === 0) {
qappsBody.innerHTML =
'<tr><td colspan="5">No Q-Apps configured yet.</td></tr>';
return;
}
qapps.forEach(function (entry) {
const row = document.createElement("tr");
const nameCell = document.createElement("td");
nameCell.textContent = entry.name || "";
const addressCell = document.createElement("td");
const addressCode = document.createElement("code");
addressCode.textContent = entry.address || "";
addressCell.appendChild(addressCode);
const descriptionCell = document.createElement("td");
descriptionCell.textContent = entry.description || "";
const iconCell = document.createElement("td");
if (entry.iconMode === "custom" && entry.iconUrl) {
const img = document.createElement("img");
img.src = entry.iconUrl;
img.alt = entry.name || "Q-App icon";
img.className = "qortal-qapp-icon";
iconCell.appendChild(img);
} else {
iconCell.textContent = "auto";
}
const actionCell = document.createElement("td");
const removeButton = document.createElement("button");
removeButton.className = "button";
removeButton.textContent = "Remove";
removeButton.addEventListener("click", async function () {
qapps = qapps.filter(function (item) {
return item.address !== entry.address;
});
renderQapps();
markDirty();
try {
await saveSettings();
setFeedback("Q-App removed and saved.", false);
} catch (error) {
setFeedback(error.message || "Failed to save Q-App removal", true);
}
});
actionCell.appendChild(removeButton);
row.appendChild(nameCell);
row.appendChild(addressCell);
row.appendChild(iconCell);
row.appendChild(descriptionCell);
row.appendChild(actionCell);
qappsBody.appendChild(row);
});
}
function addQapp() {
const name = qappsNameInput.value.trim();
const address = qappsAddressInput.value.trim();
const iconMode = qappsIconModeInput.value === "custom" ? "custom" : "auto";
const iconUrl = qappsIconUrlInput.value.trim();
const description = qappsDescriptionInput.value.trim();
if (!address) {
throw new Error("Q-App address is required");
}
if (!/^qortal:\/\//i.test(address)) {
throw new Error("Q-App address must start with qortal://");
}
if (iconMode === "custom" && !iconUrl) {
throw new Error(
"Custom icon URL is required when icon mode is set to custom"
);
}
const entry = {
name: name || address,
address: address,
description: description,
iconMode: iconMode,
iconUrl: iconUrl,
};
const exists = qapps.some(function (item) {
return item.address === entry.address;
});
if (exists) {
throw new Error("Q-App address already exists in list");
}
qapps.push(entry);
qappsNameInput.value = "";
qappsAddressInput.value = "";
qappsIconModeInput.value = "auto";
qappsIconUrlInput.value = "";
qappsDescriptionInput.value = "";
renderQapps();
markDirty();
}
function clearQapps() {
qapps = [];
renderQapps();
markDirty();
}
function inviteStatus(invite) {
if (!invite) {
return "";
}
if (invite.revokedAt) {
return "revoked";
}
if (invite.usedAt) {
return "used";
}
if (invite.expiresAt && new Date(invite.expiresAt).getTime() < Date.now()) {
return "expired";
}
return "active";
}
function renderInvites(payload) {
const invites =
payload &&
payload.ok &&
payload.data &&
Array.isArray(payload.data.invites)
? payload.data.invites
: [];
invitesBody.innerHTML = "";
if (invites.length === 0) {
invitesBody.innerHTML =
'<tr><td colspan="5">No invites generated yet.</td></tr>';
return;
}
invites.forEach(function (invite) {
const row = document.createElement("tr");
const tokenCell = document.createElement("td");
const tokenCode = document.createElement("code");
tokenCode.textContent = invite.token || "";
tokenCell.appendChild(tokenCode);
const statusCell = document.createElement("td");
statusCell.textContent = inviteStatus(invite);
const expiresCell = document.createElement("td");
expiresCell.textContent = formatTimestamp(invite.expiresAt);
const usedByCell = document.createElement("td");
const usedText =
invite.usedByQortalAddress || invite.usedByNextcloudUserId || "";
if (usedText) {
const usedCode = document.createElement("code");
usedCode.textContent = usedText;
usedByCell.appendChild(usedCode);
}
const actionCell = document.createElement("td");
const revokeButton = document.createElement("button");
revokeButton.className = "button";
revokeButton.textContent = "Revoke";
revokeButton.disabled = Boolean(invite.revokedAt || invite.usedAt);
revokeButton.addEventListener("click", async function () {
try {
await revokeInvite(invite.token);
setFeedback("Invite revoked.", false);
} catch (error) {
setFeedback(error.message || "Failed to revoke invite", true);
}
});
actionCell.appendChild(revokeButton);
row.appendChild(tokenCell);
row.appendChild(statusCell);
row.appendChild(expiresCell);
row.appendChild(usedByCell);
row.appendChild(actionCell);
invitesBody.appendChild(row);
});
}
async function refreshSetupData() {
setFeedback("Refreshing setup data...", false);
const payload = await requestJson(setupUrl, {
method: "GET",
headers,
});
assertBrokerOk(
payload.status && payload.status.brokerHealth,
"Broker health check failed"
);
assertBrokerOk(
payload.status && payload.status.qortalHealth,
"Qortal health check failed"
);
assertBrokerOk(payload.wallets, "Failed to fetch wallets");
assertBrokerOk(payload.mappings, "Failed to fetch mappings");
statusEl.textContent = JSON.stringify(payload.status, null, 2);
renderWallets(payload.wallets);
renderMappings(payload.mappings);
renderSetupOverview(payload);
renderOidcPolicy(payload.oidcConfig);
setFeedback("Setup data refreshed.", false);
}
async function saveSettings() {
setFeedback("Saving...", false);
const form = new URLSearchParams(collectSettings());
const payload = await requestJson(settingsUrl, {
method: "POST",
headers,
body: form,
});
statusEl.textContent = JSON.stringify(
{
settings: payload.settings || {},
oidcPolicySync: payload.oidcPolicySync || null,
qortalRuntimeSync: payload.qortalRuntimeSync || null,
},
null,
2
);
refreshSetupPlan().catch(function () {
// ignore
});
refreshSetupData().catch(function () {
// ignore
});
resetDirty();
const syncErrors = [];
const syncWarnings = [];
if (payload.oidcPolicySync && payload.oidcPolicySync.ok === false) {
syncErrors.push(
`broker policy sync failed: ${
payload.oidcPolicySync.error || "Unknown error"
}`
);
}
if (payload.qortalRuntimeSync && payload.qortalRuntimeSync.ok === false) {
syncErrors.push(
`broker runtime sync failed: ${
payload.qortalRuntimeSync.error || "Unknown error"
}`
);
}
if (
payload.qortalRuntimeSync &&
payload.qortalRuntimeSync.ok !== false &&
payload.qortalRuntimeSync.nodeSettingsSync &&
payload.qortalRuntimeSync.nodeSettingsSync.warning
) {
syncWarnings.push(payload.qortalRuntimeSync.nodeSettingsSync.warning);
}
if (syncErrors.length > 0) {
setFeedback(`Settings saved, but ${syncErrors.join(" | ")}`, true);
return;
}
if (syncWarnings.length > 0) {
setFeedback(
`Settings saved with warning: ${syncWarnings.join(" | ")}`,
false
);
return;
}
setFeedback("Settings and runtime sync saved.", false);
}
async function testConnection() {
setFeedback("Testing broker connectivity...", false);
const payload = await requestJson(statusUrl, {
method: "GET",
headers,
});
statusEl.textContent = JSON.stringify(payload, null, 2);
setFeedback("Broker connectivity check completed.", false);
}
async function runSetupPlan() {
setFeedback("Generating setup plan...", false);
const payload = await requestJson(setupPlanUrl, {
method: "POST",
headers,
});
setupResult.textContent = JSON.stringify(payload, null, 2);
lastSetupPlan = payload;
updateSetupComponentsVisibility(payload);
setFeedback("Setup plan generated.", false);
}
async function refreshSetupPlan() {
const payload = await requestJson(setupPlanUrl, {
method: "POST",
headers,
});
lastSetupPlan = payload;
updateSetupComponentsVisibility(payload);
}
async function runSetupOcc() {
setFeedback("Running setup via occ...", false);
const payload = await requestJson(setupOccUrl, {
method: "POST",
headers,
});
setupResult.textContent = JSON.stringify(payload, null, 2);
setFeedback("Setup completed.", false);
}
async function sendNotifications() {
const body = new URLSearchParams({
userIds: notifyUserIds.value || "",
groupIds: notifyGroupIds.value || "",
sendEmail: notifyEmail.checked ? "1" : "",
sendInApp: notifyInApp.checked ? "1" : "",
includeInvite: notifyIncludeInvite.checked ? "1" : "",
queue: notifyQueue.checked ? "1" : "",
});
const payload = await requestJson(notifyUrl, {
method: "POST",
headers,
body,
});
notifyResult.textContent = JSON.stringify(payload, null, 2);
return payload;
}
async function searchUsers() {
const query = userSearchQuery.value.trim();
if (!query) {
userSearchResults.innerHTML =
'<tr><td colspan="4">Enter a search term.</td></tr>';
return;
}
const url = searchUsersUrl + "?query=" + encodeURIComponent(query);
const payload = await requestJson(url, {
method: "GET",
headers,
});
const users = payload.users || [];
lastUserResults = users;
userSearchResults.innerHTML = "";
if (users.length === 0) {
userSearchResults.innerHTML =
'<tr><td colspan="4">No users found.</td></tr>';
return;
}
users.forEach(function (user) {
const row = document.createElement("tr");
const idCell = document.createElement("td");
idCell.textContent = user.id || "";
const nameCell = document.createElement("td");
nameCell.textContent = user.displayName || "";
const emailCell = document.createElement("td");
emailCell.textContent = user.email || "";
const actionCell = document.createElement("td");
const addButton = document.createElement("button");
addButton.className = "button";
addButton.textContent = "Add";
addButton.addEventListener("click", function () {
appendUserId(user.id || "");
});
actionCell.appendChild(addButton);
row.appendChild(idCell);
row.appendChild(nameCell);
row.appendChild(emailCell);
row.appendChild(actionCell);
userSearchResults.appendChild(row);
});
}
async function searchGroups() {
const query = groupSearchQuery.value.trim();
if (!query) {
groupSearchResults.innerHTML =
'<tr><td colspan="3">Enter a search term.</td></tr>';
return;
}
const url = searchGroupsUrl + "?query=" + encodeURIComponent(query);
const payload = await requestJson(url, {
method: "GET",
headers,
});
const groups = payload.groups || [];
groupSearchResults.innerHTML = "";
if (groups.length === 0) {
groupSearchResults.innerHTML =
'<tr><td colspan="3">No groups found.</td></tr>';
return;
}
groups.forEach(function (group) {
const row = document.createElement("tr");
const idCell = document.createElement("td");
idCell.textContent = group.id || "";
const nameCell = document.createElement("td");
nameCell.textContent = group.displayName || "";
const actionCell = document.createElement("td");
const addButton = document.createElement("button");
addButton.className = "button";
addButton.textContent = "Add";
addButton.addEventListener("click", function () {
appendGroupId(group.id || "");
});
actionCell.appendChild(addButton);
row.appendChild(idCell);
row.appendChild(nameCell);
row.appendChild(actionCell);
groupSearchResults.appendChild(row);
});
}
function appendUserId(userId) {
if (!userId) {
return;
}
const existing = new Set(
notifyUserIds.value
.split(/\s+/)
.map(function (entry) {
return entry.trim();
})
.filter(Boolean)
);
existing.add(userId);
notifyUserIds.value = Array.from(existing).join("\n");
}
function appendGroupId(groupId) {
if (!groupId) {
return;
}
const existing = new Set(
notifyGroupIds.value
.split(/\s+/)
.map(function (entry) {
return entry.trim();
})
.filter(Boolean)
);
existing.add(groupId);
notifyGroupIds.value = Array.from(existing).join("\n");
}
function renderTemplate(template, tokens) {
let result = template || "";
Object.keys(tokens).forEach(function (key) {
result = result.split("{" + key + "}").join(tokens[key]);
});
return result.trim();
}
function buildEmailPreview() {
const subjectTemplate = notifyEmailSubject.value || "";
const bodyTemplate = notifyEmailBody.value || "";
const inviteToken = notifyIncludeInvite.checked ? "INVITE_TOKEN" : "";
const baseUrl =
nextcloudPublicUrlInput.value.trim() ||
nextcloudPublicUrlValue ||
window.location.origin;
const link =
baseUrl.replace(/\/$/, "") +
"/settings/user/qortal_integration#qortal-create-wallet";
const firstUserId =
(notifyUserIds.value || "")
.split(/\s+/)
.map(function (entry) {
return entry.trim();
})
.filter(Boolean)[0] || "user";
const match =
lastUserResults.find(function (user) {
return user.id === firstUserId;
}) || {};
const displayName = match.displayName || firstUserId;
const tokens = {
link: link,
invite: inviteToken,
user: firstUserId,
displayName: displayName,
};
previewSubject.value = renderTemplate(subjectTemplate, tokens);
previewBody.value = renderTemplate(bodyTemplate, tokens);
}
async function createWallet() {
const password = document.getElementById("qortal-wallet-password").value;
const kdfThreads = document.getElementById(
"qortal-wallet-kdf-threads"
).value;
if (!password) {
throw new Error("Wallet password is required");
}
setFeedback("Creating wallet...", false);
const body = new URLSearchParams({
password,
kdfThreads: kdfThreads || "",
});
const payload = await requestJson(createWalletUrl, {
method: "POST",
headers,
body,
});
assertBrokerOk(payload, "Wallet creation failed");
walletCreateResult.textContent = JSON.stringify(payload, null, 2);
document.getElementById("qortal-wallet-password").value = "";
await refreshWallets();
setFeedback("Wallet created.", false);
}
async function createWalletAndLink() {
const password = document.getElementById("qortal-wallet-password").value;
const kdfThreads = document.getElementById(
"qortal-wallet-kdf-threads"
).value;
const nextcloudUserId = walletUserIdInput.value.trim();
if (!password) {
throw new Error("Wallet password is required");
}
if (!nextcloudUserId) {
throw new Error("Nextcloud user ID is required to link a wallet");
}
setFeedback("Creating wallet and linking user...", false);
const body = new URLSearchParams({
password,
kdfThreads: kdfThreads || "",
});
const payload = await requestJson(createWalletUrl, {
method: "POST",
headers,
body,
});
const wallet = payload.wallet;
if (!wallet || !wallet.address0) {
throw new Error("Wallet creation did not return an address");
}
const linkBody = new URLSearchParams({
qortalAddress: wallet.address0,
walletId: wallet.walletId || "",
nextcloudUserId,
});
const linkPayload = await requestJson(linkMappingUrl, {
method: "POST",
headers,
body: linkBody,
});
statusEl.textContent = JSON.stringify(
{
wallet: payload,
mapping: linkPayload,
},
null,
2
);
await refreshWallets();
await refreshMappings();
setFeedback("Wallet created and linked.", false);
}
async function refreshWallets() {
const payload = await requestJson(walletsUrl, {
method: "GET",
headers,
});
assertBrokerOk(payload, "Failed to fetch wallets");
renderWallets(payload);
}
async function linkMapping() {
const qortalAddress = document
.getElementById("qortal-link-address")
.value.trim();
const walletId = document
.getElementById("qortal-link-wallet-id")
.value.trim();
const nextcloudUserId = document
.getElementById("qortal-link-user-id")
.value.trim();
if (!qortalAddress || !nextcloudUserId) {
throw new Error("Qortal address and Nextcloud user ID are required");
}
setFeedback("Linking mapping...", false);
const body = new URLSearchParams({
qortalAddress: qortalAddress,
walletId: walletId,
nextcloudUserId: nextcloudUserId,
});
const payload = await requestJson(linkMappingUrl, {
method: "POST",
headers,
body,
});
assertBrokerOk(payload, "Failed to link mapping");
statusEl.textContent = JSON.stringify(payload, null, 2);
await refreshMappings();
setFeedback("Mapping linked.", false);
}
async function unlinkMapping(qortalAddress) {
if (!qortalAddress) {
throw new Error("qortalAddress is required");
}
const body = new URLSearchParams({
qortalAddress: qortalAddress,
});
const payload = await requestJson(unlinkMappingUrl, {
method: "POST",
headers,
body,
});
assertBrokerOk(payload, "Failed to unlink mapping");
statusEl.textContent = JSON.stringify(payload, null, 2);
await refreshMappings();
}
async function refreshMappings() {
const payload = await requestJson(mappingsUrl, {
method: "GET",
headers,
});
assertBrokerOk(payload, "Failed to fetch mappings");
renderMappings(payload);
}
async function refreshAllowlist() {
const payload = await requestJson(allowlistUrl, {
method: "GET",
headers,
});
assertBrokerOk(payload, "Failed to fetch allowlist");
renderAllowlist(payload);
}
async function addAllowlist() {
const qortalAddress = document
.getElementById("qortal-allowlist-address")
.value.trim();
if (!qortalAddress) {
throw new Error("Qortal address is required");
}
const body = new URLSearchParams({
qortalAddress: qortalAddress,
});
const payload = await requestJson(addAllowlistUrl, {
method: "POST",
headers,
body,
});
assertBrokerOk(payload, "Failed to add allowlist entry");
await refreshAllowlist();
}
async function removeAllowlist(qortalAddress) {
if (!qortalAddress) {
throw new Error("qortalAddress is required");
}
const body = new URLSearchParams({
qortalAddress: qortalAddress,
});
const payload = await requestJson(removeAllowlistUrl, {
method: "POST",
headers,
body,
});
assertBrokerOk(payload, "Failed to remove allowlist entry");
await refreshAllowlist();
}
async function refreshInvites() {
const payload = await requestJson(invitesUrl, {
method: "GET",
headers,
});
assertBrokerOk(payload, "Failed to fetch invites");
renderInvites(payload);
}
async function createInvite() {
const expiryValue = document.getElementById(
"qortal-invite-expiry-hours"
).value;
const expiryHours = expiryValue ? Number(expiryValue) : null;
if (
expiryHours !== null &&
(!Number.isFinite(expiryHours) || expiryHours <= 0)
) {
throw new Error("Expiry hours must be a positive number");
}
const body = new URLSearchParams();
if (expiryHours !== null) {
body.set("expiresInHours", String(Math.floor(expiryHours)));
}
const payload = await requestJson(createInviteUrl, {
method: "POST",
headers,
body,
});
assertBrokerOk(payload, "Failed to create invite");
inviteCreateResult.textContent = JSON.stringify(payload, null, 2);
await refreshInvites();
return payload;
}
async function revokeInvite(token) {
if (!token) {
throw new Error("token is required");
}
const body = new URLSearchParams({ token: token });
const payload = await requestJson(revokeInviteUrl, {
method: "POST",
headers,
body,
});
assertBrokerOk(payload, "Failed to revoke invite");
await refreshInvites();
}
renderQapps();
addQappButton.addEventListener("click", function () {
(async function () {
try {
addQapp();
await saveSettings();
setFeedback("Q-App added and saved.", false);
} catch (error) {
setFeedback(error.message || "Failed to add Q-App", true);
}
})();
});
clearQappsButton.addEventListener("click", function () {
(async function () {
try {
clearQapps();
await saveSettings();
setFeedback("Q-App list cleared and saved.", false);
} catch (error) {
setFeedback(error.message || "Failed to clear Q-App list", true);
}
})();
});
saveButton.addEventListener("click", async function () {
try {
await saveSettings();
} catch (error) {
setFeedback(error.message || "Failed to save settings", true);
}
});
window.addEventListener("beforeunload", function (event) {
if (!hasUnsavedChanges) {
return;
}
event.preventDefault();
event.returnValue =
"You have unsaved changes. Save settings or they will be lost.";
});
refreshSetupButton.addEventListener("click", async function () {
try {
await refreshSetupData();
} catch (error) {
setFeedback(error.message || "Failed to load setup data", true);
}
});
testButton.addEventListener("click", async function () {
try {
await testConnection();
} catch (error) {
setFeedback(error.message || "Broker connectivity check failed", true);
}
});
setupPlanButton.addEventListener("click", async function () {
try {
await runSetupPlan();
} catch (error) {
setFeedback(error.message || "Failed to generate setup plan", true);
}
});
setupOccButton.addEventListener("click", async function () {
try {
await runSetupOcc();
} catch (error) {
setFeedback(error.message || "Setup failed", true);
}
});
sendNotificationsButton.addEventListener("click", async function () {
try {
setFeedback("Sending notifications...", false);
await sendNotifications();
setFeedback("Notifications sent.", false);
} catch (error) {
setFeedback(error.message || "Failed to send notifications", true);
}
});
searchUsersButton.addEventListener("click", async function () {
try {
await searchUsers();
} catch (error) {
setFeedback(error.message || "User search failed", true);
}
});
searchGroupsButton.addEventListener("click", async function () {
try {
await searchGroups();
} catch (error) {
setFeedback(error.message || "Group search failed", true);
}
});
previewEmailButton.addEventListener("click", function () {
buildEmailPreview();
});
oidcEnvButton.addEventListener("click", function () {
oidcEnvOutput.textContent = buildEnvSnippet();
});
[
oidcPolicySelect,
oidcGuardToggle,
oidcInviteInput,
oidcRequireEmailToggle,
oidcRedirectInput,
].forEach(function (el) {
el.addEventListener("change", function () {
oidcEnvOutput.textContent = buildEnvSnippet();
markDirty();
});
});
externalAuthEnvButton.addEventListener("click", function () {
externalAuthEnvOutput.textContent = buildExternalAuthEnvSnippet();
});
externalAuthRegisterButton.addEventListener("click", async function () {
try {
await registerExternalAuthApp();
} catch (error) {
setFeedback(
error.message || "Failed to register External Auth app",
true
);
}
});
[
brokerInternalApiTokenInput,
externalAuthBaseUrlInput,
externalAuthAppIdInput,
externalAuthAppSecretInput,
externalAuthNodeUrlInput,
externalAuthNodeApiKeyInput,
externalAuthNodeApiKeyModeInput,
externalAuthNodeApiKeyPathsInput,
].forEach(function (el) {
el.addEventListener("change", function () {
externalAuthEnvOutput.textContent = buildExternalAuthEnvSnippet();
markDirty();
});
});
qortalNodeEnvButton.addEventListener("click", function () {
qortalNodeEnvOutput.textContent = buildQortalNodeEnvSnippet();
});
[
qortalNodeUrlInput,
qortalNodeApiKeyInput,
qortalGatewayUrlInput,
qortalGatewayInsecureInput,
].forEach(function (el) {
el.addEventListener("change", function () {
qortalNodeEnvOutput.textContent = buildQortalNodeEnvSnippet();
markDirty();
});
});
[
document.getElementById("qortal-broker-base-url"),
brokerInternalApiTokenInput,
document.getElementById("qortal-external-auth-docs-url"),
document.getElementById("qortal-feature-qdn-backups"),
document.getElementById("qortal-feature-qmail"),
oidcIssuerInput,
oidcClientIdInput,
oidcClientSecretInput,
nextcloudPublicUrlInput,
notifyEmailSubject,
notifyEmailBody,
qappsEnabledInput,
qappsBrowserEnabledInput,
qappsBrowserAddressInput,
qappsDebugEnabledInput,
qappsIconModeInput,
qappsIconUrlInput,
].forEach(function (el) {
if (!el) {
return;
}
el.addEventListener("change", function () {
markDirty();
if (el === qappsIconModeInput) {
updateIconUrlState();
}
});
if (
el.tagName === "TEXTAREA" ||
el.type === "text" ||
el.type === "password"
) {
el.addEventListener("input", function () {
markDirty();
});
}
});
createWalletButton.addEventListener("click", async function () {
try {
await createWallet();
} catch (error) {
setFeedback(error.message || "Failed to create wallet", true);
}
});
createWalletLinkButton.addEventListener("click", async function () {
try {
await createWalletAndLink();
} catch (error) {
setFeedback(
error.message || "Failed to create wallet and link user",
true
);
}
});
refreshWalletsButton.addEventListener("click", async function () {
try {
await refreshWallets();
setFeedback("Wallet list refreshed.", false);
} catch (error) {
setFeedback(error.message || "Failed to load wallets", true);
}
});
linkMappingButton.addEventListener("click", async function () {
try {
await linkMapping();
} catch (error) {
setFeedback(error.message || "Failed to link mapping", true);
}
});
refreshMappingsButton.addEventListener("click", async function () {
try {
await refreshMappings();
setFeedback("Mappings refreshed.", false);
} catch (error) {
setFeedback(error.message || "Failed to load mappings", true);
}
});
addAllowlistButton.addEventListener("click", async function () {
try {
await addAllowlist();
setFeedback("Allowlist updated.", false);
} catch (error) {
setFeedback(error.message || "Failed to add allowlist entry", true);
}
});
refreshAllowlistButton.addEventListener("click", async function () {
try {
await refreshAllowlist();
setFeedback("Allowlist refreshed.", false);
} catch (error) {
setFeedback(error.message || "Failed to load allowlist", true);
}
});
createInviteButton.addEventListener("click", async function () {
try {
await createInvite();
setFeedback("Invite created.", false);
} catch (error) {
setFeedback(error.message || "Failed to create invite", true);
}
});
refreshInvitesButton.addEventListener("click", async function () {
try {
await refreshInvites();
setFeedback("Invites refreshed.", false);
} catch (error) {
setFeedback(error.message || "Failed to load invites", true);
}
});
inviteMessageButton.addEventListener("click", async function () {
try {
setFeedback("Generating invite message...", false);
let token = "";
if (autoProvisionEnabled && autoProvisionGuardEnabled) {
const payload = await createInvite();
const invite =
payload && payload.data && payload.data.invite
? payload.data.invite
: payload && payload.invite
? payload.invite
: payload;
token = invite && invite.token ? invite.token : "";
}
const baseUrl =
nextcloudPublicUrlInput.value.trim() || nextcloudPublicUrlValue || "";
const loginPath = "/login";
const fullLink = baseUrl
? baseUrl.replace(/\/$/, "") + loginPath
: loginPath;
const message =
autoProvisionEnabled && autoProvisionGuardEnabled
? [
"Hello,",
"",
"You have been invited to create a Nextcloud account using Qortal authentication.",
"Open the Qortal Integration onboarding page:",
fullLink,
"",
token
? `Invite token: ${token}`
: "Invite token: (admin to provide)",
"",
"Create new or import existing Qortal Account (seed phrase or backup) to complete setup.",
"After linking, you can sign in to Nextcloud using your Qortal account.",
].join("\n")
: [
"Hello,",
"",
"Your admin enabled Qortal login for existing Nextcloud users.",
"Open the Qortal Integration settings to create or link your wallet:",
baseUrl
? baseUrl.replace(/\/$/, "") +
"/settings/user/qortal_integration#qortal-create-wallet"
: "/settings/user/qortal_integration#qortal-create-wallet",
"",
"If you do not have a Nextcloud account yet, contact your admin to create one.",
].join("\n");
inviteMessageBox.value = message;
setFeedback("Invite message generated.", false);
} catch (error) {
setFeedback(error.message || "Failed to generate invite message", true);
}
});
copyInviteMessageButton.addEventListener("click", async function () {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(inviteMessageBox.value || "");
setFeedback("Invite message copied.", false);
} else {
inviteMessageBox.focus();
inviteMessageBox.select();
setFeedback("Copy manually (clipboard API unavailable).", true);
}
} catch (error) {
setFeedback("Failed to copy invite message", true);
}
});
refreshSetupData().catch(function (error) {
setFeedback(error.message || "Initial setup data load failed", true);
});
refreshSetupPlan().catch(function () {
// ignore
});
refreshAllowlist().catch(function (error) {
setFeedback(error.message || "Initial allowlist load failed", true);
});
refreshInvites().catch(function (error) {
setFeedback(error.message || "Initial invite load failed", true);
});
})();