@agentconnect.md/setup 2.11.0-rc.10 → 2.11.0-rc.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/dist/index.js +462 -23
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -62803,7 +62803,11 @@ const AuthSchema = discriminatedUnion("mode", [strictObject({ mode: literal("non
|
|
|
62803
62803
|
appId: string().min(1),
|
|
62804
62804
|
apiResource: NullableUrlSchema
|
|
62805
62805
|
}),
|
|
62806
|
-
socialProviders: array(string().trim().min(1))
|
|
62806
|
+
socialProviders: array(string().trim().min(1)),
|
|
62807
|
+
sso: strictObject({
|
|
62808
|
+
connectorId: string().trim().min(1),
|
|
62809
|
+
name: string().trim().min(1)
|
|
62810
|
+
}).nullable().default(null)
|
|
62807
62811
|
})]);
|
|
62808
62812
|
const LogtoBrowserSchema = strictObject({
|
|
62809
62813
|
applicationName: string().trim().min(1).default("AgentConnect"),
|
|
@@ -62835,6 +62839,33 @@ const LogtoSlackConnectorSchema = preprocess(withoutDerivedConnectorId, strictOb
|
|
|
62835
62839
|
appId: string().trim().min(1),
|
|
62836
62840
|
clientId: string().trim().min(1)
|
|
62837
62841
|
}));
|
|
62842
|
+
const SsoDomainSchema = string().trim().toLowerCase().regex(/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/, "must be an email domain");
|
|
62843
|
+
/** The deployment's one enterprise SSO connector. Logto mints its id at creation; the IdP details arrive later. */
|
|
62844
|
+
const LogtoSsoConnectorSchema = strictObject({
|
|
62845
|
+
connectorId: string().trim().min(1),
|
|
62846
|
+
protocol: _enum(["oidc", "saml"]),
|
|
62847
|
+
/** Shown on the console's sign-in button. */
|
|
62848
|
+
name: string().trim().min(1).max(64),
|
|
62849
|
+
/** Email domains Logto routes to this connector on its own sign-in page. */
|
|
62850
|
+
domains: array(SsoDomainSchema).default([]),
|
|
62851
|
+
/** OIDC IdP; the client secret is the write-only 'logto.ssoConnectorClientSecret'. */
|
|
62852
|
+
oidc: strictObject({
|
|
62853
|
+
issuer: HttpUrlSchema,
|
|
62854
|
+
clientId: string().trim().min(1),
|
|
62855
|
+
scope: string().trim().default(""),
|
|
62856
|
+
trustUnverifiedEmail: boolean().default(false)
|
|
62857
|
+
}).nullable().default(null),
|
|
62858
|
+
/** SAML IdP metadata, by URL or inline XML. */
|
|
62859
|
+
saml: strictObject({
|
|
62860
|
+
metadataUrl: HttpUrlSchema.nullable().default(null),
|
|
62861
|
+
metadata: string().trim().min(1).nullable().default(null),
|
|
62862
|
+
attributeMapping: strictObject({
|
|
62863
|
+
id: string().trim().min(1).optional(),
|
|
62864
|
+
email: string().trim().min(1).optional(),
|
|
62865
|
+
name: string().trim().min(1).optional()
|
|
62866
|
+
}).default({})
|
|
62867
|
+
}).nullable().default(null)
|
|
62868
|
+
});
|
|
62838
62869
|
const RegionalLoginAppSchema = strictObject({ loginAppId: string().trim().min(1) });
|
|
62839
62870
|
const GithubAppSchema = preprocess(withoutProviderUrlSnapshot, strictObject({
|
|
62840
62871
|
appId: number$1().int().positive(),
|
|
@@ -62909,7 +62940,9 @@ const DeploymentConfigValuesV1Schema = strictObject({
|
|
|
62909
62940
|
/** Multi-tenant Microsoft Entra application for Microsoft sign-in. Its secret stays write-only. */
|
|
62910
62941
|
azureadConnector: strictObject({ clientId: string().trim().min(1) }).nullable().optional(),
|
|
62911
62942
|
/** Deployment Slack App identity reused for Slack social sign-in. */
|
|
62912
|
-
slackConnector: LogtoSlackConnectorSchema.nullable().optional()
|
|
62943
|
+
slackConnector: LogtoSlackConnectorSchema.nullable().optional(),
|
|
62944
|
+
/** Enterprise SSO (OIDC or SAML) connector. */
|
|
62945
|
+
ssoConnector: LogtoSsoConnectorSchema.nullable().optional()
|
|
62913
62946
|
}).nullable(),
|
|
62914
62947
|
features: strictObject({
|
|
62915
62948
|
presetAgentsEnabled: boolean(),
|
|
@@ -62929,7 +62962,34 @@ const DeploymentConfigValuesV1Schema = strictObject({
|
|
|
62929
62962
|
message: "must reuse the deployment Slack App identity"
|
|
62930
62963
|
});
|
|
62931
62964
|
}
|
|
62965
|
+
const sso = values.logto?.ssoConnector;
|
|
62966
|
+
if (sso) {
|
|
62967
|
+
const other = sso.protocol === "oidc" ? "saml" : "oidc";
|
|
62968
|
+
if (sso[other] !== null) ctx.addIssue({
|
|
62969
|
+
code: "custom",
|
|
62970
|
+
path: [
|
|
62971
|
+
"logto",
|
|
62972
|
+
"ssoConnector",
|
|
62973
|
+
other
|
|
62974
|
+
],
|
|
62975
|
+
message: `must be null for ${sso.protocol}`
|
|
62976
|
+
});
|
|
62977
|
+
if (sso.saml && sso.saml.metadataUrl === null === (sso.saml.metadata === null)) ctx.addIssue({
|
|
62978
|
+
code: "custom",
|
|
62979
|
+
path: [
|
|
62980
|
+
"logto",
|
|
62981
|
+
"ssoConnector",
|
|
62982
|
+
"saml"
|
|
62983
|
+
],
|
|
62984
|
+
message: "set exactly one of metadataUrl and metadata"
|
|
62985
|
+
});
|
|
62986
|
+
}
|
|
62932
62987
|
if (values.auth.mode === "oidc") {
|
|
62988
|
+
if (values.auth.socialProviders.length === 0 && values.auth.sso === null) ctx.addIssue({
|
|
62989
|
+
code: "custom",
|
|
62990
|
+
path: ["auth", "socialProviders"],
|
|
62991
|
+
message: "an enabled login needs a social provider or an SSO connector"
|
|
62992
|
+
});
|
|
62933
62993
|
const tokenAudience = values.auth.browserClient.apiResource ?? values.auth.browserClient.appId;
|
|
62934
62994
|
if (values.auth.audience !== tokenAudience) ctx.addIssue({
|
|
62935
62995
|
code: "custom",
|
|
@@ -62965,7 +63025,8 @@ const DEPLOYMENT_SECRET_KEYS = [
|
|
|
62965
63025
|
"logto.googleConnectorClientSecret",
|
|
62966
63026
|
"logto.telegramConnectorClientSecret",
|
|
62967
63027
|
"logto.discordConnectorClientSecret",
|
|
62968
|
-
"logto.azureadConnectorClientSecret"
|
|
63028
|
+
"logto.azureadConnectorClientSecret",
|
|
63029
|
+
"logto.ssoConnectorClientSecret"
|
|
62969
63030
|
];
|
|
62970
63031
|
const DeploymentSecretKeySchema = _enum(DEPLOYMENT_SECRET_KEYS);
|
|
62971
63032
|
/** Omission preserves; a string replaces; null clears. Unknown keys fail. */
|
|
@@ -63057,6 +63118,9 @@ function deploymentSecretsRequiringRefresh(previous, next) {
|
|
|
63057
63118
|
const logtoIdentityChanged = next.logto && previous?.logto?.managementAppId !== next.logto.managementAppId;
|
|
63058
63119
|
const logtoGithubConnectorChanged = next.logto?.githubConnector && (previous?.logto?.githubConnector?.appId !== next.logto.githubConnector.appId || previous?.logto?.githubConnector?.clientId !== next.logto.githubConnector.clientId);
|
|
63059
63120
|
const logtoGoogleConnectorChanged = next.logto?.googleConnector && previous?.logto?.googleConnector?.clientId !== next.logto.googleConnector.clientId;
|
|
63121
|
+
const nextSsoOidc = next.logto?.ssoConnector?.oidc;
|
|
63122
|
+
const previousSsoOidc = previous?.logto?.ssoConnector?.oidc;
|
|
63123
|
+
const logtoSsoClientChanged = nextSsoOidc && (previousSsoOidc?.issuer !== nextSsoOidc.issuer || previousSsoOidc?.clientId !== nextSsoOidc.clientId);
|
|
63060
63124
|
return [
|
|
63061
63125
|
...githubAppChanged ? ["github.privateKeyB64"] : [],
|
|
63062
63126
|
...next.github && githubWebhookEnabled && githubAppChanged ? ["github.webhookSecret"] : [],
|
|
@@ -63070,6 +63134,7 @@ function deploymentSecretsRequiringRefresh(previous, next) {
|
|
|
63070
63134
|
...logtoIdentityChanged ? ["logto.managementAppSecret"] : [],
|
|
63071
63135
|
...logtoGithubConnectorChanged ? ["logto.githubConnectorClientSecret"] : [],
|
|
63072
63136
|
...logtoGoogleConnectorChanged ? ["logto.googleConnectorClientSecret"] : [],
|
|
63137
|
+
...logtoSsoClientChanged ? ["logto.ssoConnectorClientSecret"] : [],
|
|
63073
63138
|
...[
|
|
63074
63139
|
"telegram",
|
|
63075
63140
|
"discord",
|
|
@@ -63094,6 +63159,7 @@ function requiredSecrets(values) {
|
|
|
63094
63159
|
...values.logto ? ["logto.managementAppSecret"] : [],
|
|
63095
63160
|
...values.logto?.githubConnector ? ["logto.githubConnectorClientSecret"] : [],
|
|
63096
63161
|
...values.logto?.googleConnector ? ["logto.googleConnectorClientSecret"] : [],
|
|
63162
|
+
...values.logto?.ssoConnector?.oidc ? ["logto.ssoConnectorClientSecret"] : [],
|
|
63097
63163
|
...[
|
|
63098
63164
|
"telegram",
|
|
63099
63165
|
"discord",
|
|
@@ -63808,6 +63874,86 @@ const DeploymentConfigPutSchema = strictObject({
|
|
|
63808
63874
|
values: DeploymentConfigValuesV1Schema,
|
|
63809
63875
|
secrets: DeploymentSecretPatchSchema.optional()
|
|
63810
63876
|
});
|
|
63877
|
+
/** An SSO connector counts as a sign-in method once it has its identity provider. */
|
|
63878
|
+
function ssoReady(logto) {
|
|
63879
|
+
const sso = logto.ssoConnector;
|
|
63880
|
+
return Boolean(sso && (sso.oidc || sso.saml));
|
|
63881
|
+
}
|
|
63882
|
+
/** The connector Logto just minted, before the operator has the IdP details. */
|
|
63883
|
+
function logtoSsoConnectorCreatedPut(current, created) {
|
|
63884
|
+
const logto = current.values.logto;
|
|
63885
|
+
if (!logto?.browser) throw new Error("save Logto browser configuration first");
|
|
63886
|
+
return DeploymentConfigPutSchema.parse({
|
|
63887
|
+
values: {
|
|
63888
|
+
...current.values,
|
|
63889
|
+
logto: {
|
|
63890
|
+
...logto,
|
|
63891
|
+
ssoConnector: {
|
|
63892
|
+
...created,
|
|
63893
|
+
domains: [],
|
|
63894
|
+
oidc: null,
|
|
63895
|
+
saml: null
|
|
63896
|
+
}
|
|
63897
|
+
}
|
|
63898
|
+
},
|
|
63899
|
+
secrets: { "logto.ssoConnectorClientSecret": null }
|
|
63900
|
+
});
|
|
63901
|
+
}
|
|
63902
|
+
/** The IdP details for the created connector; null removes SSO sign-in. */
|
|
63903
|
+
function logtoSsoConnectorPut(current, settings) {
|
|
63904
|
+
const logto = current.values.logto;
|
|
63905
|
+
if (!logto?.browser) throw new Error("save Logto browser configuration first");
|
|
63906
|
+
if (!settings) {
|
|
63907
|
+
if (logto.browser.socialProviders.length === 0) throw new Error("keep at least one sign-in provider configured");
|
|
63908
|
+
return DeploymentConfigPutSchema.parse({
|
|
63909
|
+
values: {
|
|
63910
|
+
...current.values,
|
|
63911
|
+
logto: {
|
|
63912
|
+
...logto,
|
|
63913
|
+
ssoConnector: null
|
|
63914
|
+
}
|
|
63915
|
+
},
|
|
63916
|
+
secrets: { "logto.ssoConnectorClientSecret": null }
|
|
63917
|
+
});
|
|
63918
|
+
}
|
|
63919
|
+
const existing = logto.ssoConnector;
|
|
63920
|
+
if (!existing) throw new Error("create the SSO connector first");
|
|
63921
|
+
const { oidc, saml } = settings;
|
|
63922
|
+
if (existing.protocol === "oidc" ? !oidc || saml : !saml || oidc) throw new Error(`this connector uses ${existing.protocol.toUpperCase()}; send its settings only`);
|
|
63923
|
+
let next;
|
|
63924
|
+
if (oidc) {
|
|
63925
|
+
const sameClient = existing.oidc?.issuer === oidc.issuer && existing.oidc.clientId === oidc.clientId;
|
|
63926
|
+
if (!oidc.clientSecret && !sameClient) throw new Error("a new OIDC client requires its client secret");
|
|
63927
|
+
const { clientSecret: _secret, ...stored } = oidc;
|
|
63928
|
+
next = {
|
|
63929
|
+
...existing,
|
|
63930
|
+
name: settings.name,
|
|
63931
|
+
domains: settings.domains,
|
|
63932
|
+
oidc: stored,
|
|
63933
|
+
saml: null
|
|
63934
|
+
};
|
|
63935
|
+
} else next = {
|
|
63936
|
+
...existing,
|
|
63937
|
+
name: settings.name,
|
|
63938
|
+
domains: settings.domains,
|
|
63939
|
+
oidc: null,
|
|
63940
|
+
saml: {
|
|
63941
|
+
metadataUrl: saml?.metadataUrl ?? null,
|
|
63942
|
+
metadata: saml?.metadata ?? null,
|
|
63943
|
+
attributeMapping: saml?.attributeMapping ?? {}
|
|
63944
|
+
}
|
|
63945
|
+
};
|
|
63946
|
+
return DeploymentConfigPutSchema.parse({
|
|
63947
|
+
values: {
|
|
63948
|
+
...current.values,
|
|
63949
|
+
logto: {
|
|
63950
|
+
...logto,
|
|
63951
|
+
ssoConnector: next
|
|
63952
|
+
}
|
|
63953
|
+
},
|
|
63954
|
+
...oidc?.clientSecret ? { secrets: { "logto.ssoConnectorClientSecret": oidc.clientSecret } } : {}
|
|
63955
|
+
});
|
|
63956
|
+
}
|
|
63811
63957
|
function githubDeploymentPut(current, credentials, options = {}) {
|
|
63812
63958
|
const appId = Number(credentials.appId);
|
|
63813
63959
|
if (!Number.isSafeInteger(appId) || appId <= 0) throw new Error("GitHub App creation returned an invalid app id");
|
|
@@ -63864,7 +64010,7 @@ function logtoGitlabSignInPut(current, enabled) {
|
|
|
63864
64010
|
const logto = current.values.logto;
|
|
63865
64011
|
if (!logto?.browser || enabled && !current.values.gitlab) throw new Error("save the GitLab application and Logto browser configuration first");
|
|
63866
64012
|
const socialProviders = enabled ? [.../* @__PURE__ */ new Set([...logto.browser.socialProviders, "gitlab"])] : logto.browser.socialProviders.filter((target) => target !== "gitlab");
|
|
63867
|
-
if (!socialProviders.length) throw new Error("keep at least one sign-in provider configured");
|
|
64013
|
+
if (!socialProviders.length && !ssoReady(logto)) throw new Error("keep at least one sign-in provider configured");
|
|
63868
64014
|
return DeploymentConfigPutSchema.parse({ values: {
|
|
63869
64015
|
...current.values,
|
|
63870
64016
|
logto: {
|
|
@@ -64000,7 +64146,7 @@ function logtoLoginAppPut(current, provider, credentials, teamsTenantId) {
|
|
|
64000
64146
|
const key = `${provider}Connector`;
|
|
64001
64147
|
if (credentials && !credentials.clientSecret && logto[key]?.clientId !== credentials.clientId) throw new Error("a new login application requires its client secret");
|
|
64002
64148
|
const socialProviders = credentials ? [.../* @__PURE__ */ new Set([...logto.browser.socialProviders, provider])] : logto.browser.socialProviders.filter((target) => target !== provider);
|
|
64003
|
-
if (!credentials && socialProviders.length === 0) throw new Error("keep at least one sign-in provider configured");
|
|
64149
|
+
if (!credentials && socialProviders.length === 0 && !ssoReady(logto)) throw new Error("keep at least one sign-in provider configured");
|
|
64004
64150
|
const secret = credentials ? credentials.clientSecret : null;
|
|
64005
64151
|
const teams = provider !== "azuread" ? void 0 : credentials ? teamsTenantId : null;
|
|
64006
64152
|
return DeploymentConfigPutSchema.parse({
|
|
@@ -67088,15 +67234,40 @@ function _taggedTemplateLiteral(e, t) {
|
|
|
67088
67234
|
}
|
|
67089
67235
|
// src/server/html.ts
|
|
67090
67236
|
var _templateObject;
|
|
67237
|
+
const ssoForm = (prefix) => String.raw`
|
|
67238
|
+
<label class="field">Protocol<select id="${prefix}-protocol"><option value="oidc">OIDC</option><option value="saml">SAML</option></select></label>
|
|
67239
|
+
<label class="field">Name on the sign-in button<input id="${prefix}-name" value="SSO" autocomplete="off"></label>
|
|
67240
|
+
<div class="row"><button id="${prefix}-create">Create SSO connector</button></div>
|
|
67241
|
+
<div id="${prefix}-details" hidden>
|
|
67242
|
+
<p>Register these with the identity provider:</p><ul id="${prefix}-urls" class="uris"></ul>
|
|
67243
|
+
<div id="${prefix}-oidc">
|
|
67244
|
+
<label class="field">Issuer URL<input id="${prefix}-issuer" type="url" autocomplete="off"></label>
|
|
67245
|
+
<label class="field">Client ID<input id="${prefix}-client-id" autocomplete="off"></label>
|
|
67246
|
+
<label class="field">Client secret<input id="${prefix}-client-secret" type="password" autocomplete="new-password" placeholder="Required when the issuer or client ID changes"></label>
|
|
67247
|
+
<label class="field">Additional scopes<input id="${prefix}-scope" autocomplete="off" placeholder="openid profile email are always requested"></label>
|
|
67248
|
+
<label><input id="${prefix}-trust-email" type="checkbox" checked> Trust email addresses when the identity provider does not say whether they are verified</label>
|
|
67249
|
+
</div>
|
|
67250
|
+
<div id="${prefix}-saml" hidden>
|
|
67251
|
+
<label class="field">IdP metadata URL<input id="${prefix}-metadata-url" type="url" autocomplete="off"></label>
|
|
67252
|
+
<label class="field">Or IdP metadata XML<textarea id="${prefix}-metadata" rows="4"></textarea></label>
|
|
67253
|
+
<label class="field">Email attribute<input id="${prefix}-email-attribute" autocomplete="off" placeholder="email"></label>
|
|
67254
|
+
</div>
|
|
67255
|
+
<label class="field">Email domains, comma-separated<input id="${prefix}-domains" autocomplete="off" placeholder="example.com"></label>
|
|
67256
|
+
<div class="row"><button id="${prefix}-submit">Save SSO and configure Logto</button></div>
|
|
67257
|
+
</div>`;
|
|
67091
67258
|
const SETUP_HTML = String.raw(_templateObject || (_templateObject = _taggedTemplateLiteral([
|
|
67092
|
-
"<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n <title>AgentConnect deployment settings</title>\n <style>\n :root { color-scheme: light dark; font: 15px/1.5 system-ui, sans-serif; }\n body { max-width: 1280px; margin: 48px auto; padding: 0 20px 60px; }\n h1 { margin-bottom: 4px; } h2 { margin-top: 32px; } h3 { margin: 0 0 8px; }\n .muted { color: #777; } .row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }\n button, .button { padding: 8px 13px; cursor: pointer; }\n .button { border: 1px solid #8888; border-radius: 4px; color: inherit; text-decoration: none; display: inline-block; }\n input, select { width: min(520px, 100%); padding: 7px; box-sizing: border-box; }\n input[type=\"checkbox\"] { width: auto; padding: 0; }\n .field { display: grid; gap: 4px; margin: 10px 0; }\n .provider-stack { display: grid; gap: 16px; }\n .panel { border: 1px solid #8886; border-radius: 8px; padding: 16px; }\n .section-head, .provider-head { display: flex; justify-content: space-between; gap: 16px; align-items: start; }\n .section-head h2, .provider-head h3 { margin: 0; }\n .provider-head p { margin: 4px 0 0; }\n .badge { flex: none; border: 1px solid #8886; border-radius: 999px; padding: 3px 9px; font-size: 13px; }\n .badge.pass { color: #198754; border-color: #19875466; background: #19875412; }\n .badge.warn { color: #a66b00; border-color: #d99b1366; background: #d99b1315; }\n .badge.fail { color: #c33; border-color: #c333; background: #c3331111; }\n .credentials { display: grid; grid-template-columns: minmax(160px, 220px) minmax(0, 1fr); gap: 7px 16px; margin: 16px 0; }\n .credentials dt { color: #777; }\n .credentials dd { margin: 0; overflow-wrap: anywhere; }\n .credentials code { user-select: all; }\n .redacted { font-family: ui-monospace, monospace; letter-spacing: .08em; }\n .secret-line, .value-line { display: flex; gap: 10px; align-items: center; min-height: 30px; }\n .edit-secret { padding: 2px 8px; font-size: 13px; }\n .secret-editor { display: flex; gap: 8px; flex-wrap: wrap; width: 100%; }\n .secret-editor input { width: min(420px, 100%); }\n .edit-configuration { padding: 2px 8px; font-size: 13px; }\n .startup-owned { color: #777; font-size: 13px; }\n .danger { color: #c33; border-color: #c336; }\n .subsection { margin-top: 16px; padding-top: 14px; border-top: 1px solid #8883; }\n textarea { width: min(720px, 100%); min-height: 100px; padding: 7px; box-sizing: border-box; }\n .uris { margin: 8px 0; padding-left: 20px; } .uris code { user-select: all; }\n .notice { border-left: 4px solid #d99b13; padding: 8px 12px; background: #d99b1315; }\n .diff-title { display: block; margin-bottom: 8px; }\n .config-diff { display: grid; gap: 1px; border: 1px solid #8884; border-radius: 6px; overflow: hidden; background: #8884; }\n .diff-row { display: grid; grid-template-columns: minmax(140px, .8fr) minmax(0, 1.2fr) minmax(0, 1.2fr); background: Canvas; }\n .diff-row > * { min-width: 0; padding: 7px 9px; overflow-wrap: anywhere; white-space: pre-wrap; }\n .diff-head { font-size: 12px; font-weight: 600; color: #777; }\n .diff-field { font-weight: 600; }\n .diff-value { font-family: ui-monospace, monospace; font-size: 13px; }\n pre { padding: 12px; border-radius: 6px; background: #8881; overflow-x: auto; user-select: all; }\n #message { white-space: pre-wrap; padding: 10px 0; min-height: 1.5em; }\n .error { color: #c33; } .ok { color: #198754; } .warn { color: #a66b00; }\n code { overflow-wrap: anywhere; }\n .setup-layout { display: grid; grid-template-columns: 190px minmax(0, 1fr); gap: 32px; align-items: start; }\n .setup-nav { position: sticky; top: 24px; display: grid; gap: 3px; padding: 10px; border: 1px solid #8886; border-radius: 8px; background: Canvas; }\n .setup-nav a { padding: 7px 9px; border-radius: 5px; color: inherit; text-decoration: none; white-space: nowrap; }\n .setup-nav a:hover { background: #8882; }\n .setup-section { scroll-margin-top: 24px; }\n details.environment { margin: 0 0 24px; } details.environment summary { cursor: pointer; font-weight: 600; }\n @media (max-width: 760px) {\n body { margin-top: 24px; }\n .setup-layout { grid-template-columns: 1fr; gap: 18px; }\n .setup-nav { position: static; display: flex; overflow-x: auto; }\n .credentials { grid-template-columns: 1fr; gap: 2px; }\n .credentials dd { margin-bottom: 8px; }\n .diff-row { grid-template-columns: 1fr; }\n .diff-head { display: none; }\n .diff-value::before { display: block; margin-bottom: 2px; color: #777; font: 11px/1.4 system-ui, sans-serif; }\n .diff-current::before { content: 'Current'; }\n .diff-expected::before { content: 'Expected'; }\n }\n [hidden] { display: none !important; }\n </style>\n</head>\n<body>\n <section id=\"access\">\n <h1>AgentConnect Setup</h1>\n <p id=\"access-message\" class=\"muted\" aria-live=\"polite\">Checking Logto sign-in…</p>\n <div class=\"row\">\n <button id=\"login\" hidden>Sign in with Logto</button>\n <a id=\"open-logto\" class=\"button\" href=\"http://localhost:3002\" target=\"_blank\" rel=\"noopener\" hidden>Open Logto Console</a>\n <button id=\"show-bootstrap\" hidden>Continue setup</button>\n </div>\n\n <section id=\"bootstrap\" hidden>\n <h2>Set up sign-in</h2>\n <p id=\"bootstrap-progress\" class=\"muted\">Step 1 of 2</p>\n <div id=\"bootstrap-logto-step\" class=\"panel\">\n <h3>Connect Logto</h3>\n <p class=\"muted\">Enter the one-time Logto Management API credential. It is sealed in the deployment database and verified before continuing.</p>\n <label class=\"field\">Logto M2M App ID<input id=\"logto-app-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Logto M2M App Secret<input id=\"logto-app-secret\" type=\"password\" autocomplete=\"new-password\"></label>\n <label class=\"field\">Management API resource<input id=\"logto-management-api-resource\" type=\"url\" autocomplete=\"off\"></label>\n <p class=\"muted\">Logto Cloud uses <code>https://<tenant-id>.logto.app/api</code>. Keep <code>https://default.logto.app/api</code> for Logto OSS.</p>\n <button id=\"bootstrap-logto-submit\">Save Logto and continue</button>\n </div>\n <div id=\"bootstrap-provider-step\" hidden>\n <h3>Choose a sign-in provider</h3>\n <p class=\"muted\">Logto Management API access is ready. Configure one provider to enable sign-in.</p>\n <label class=\"field\">Sign-in provider\n <select id=\"bootstrap-provider\"><option value=\"google\">Google (works on localhost)</option><option value=\"github\">GitHub integration App</option><option id=\"bootstrap-slack-option\" value=\"slack\">Slack integration App</option></select>\n </label>\n <div id=\"bootstrap-google\" class=\"panel\">\n <h3>Google OAuth client</h3>\n <p class=\"muted\">Create a Web application client in Google Auth Platform, then paste its credentials here.</p>\n <p>Authorized JavaScript origin:</p><ul id=\"bootstrap-google-origins\" class=\"uris\"></ul>\n <p>Authorized redirect URIs:</p><ul id=\"bootstrap-google-redirects\" class=\"uris\"></ul>\n <a class=\"button\" href=\"https://console.cloud.google.com/auth/clients\" target=\"_blank\" rel=\"noopener\">Open Google settings</a>\n <label class=\"field\">Client ID<input id=\"bootstrap-google-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Client Secret<input id=\"bootstrap-google-secret\" type=\"password\" autocomplete=\"new-password\"></label>\n <button id=\"bootstrap-google-submit\">Save Google OAuth and configure Logto</button>\n </div>\n <div id=\"bootstrap-github\" class=\"panel\" hidden>\n <h3>GitHub integration App</h3>\n <p id=\"bootstrap-github-note\" class=\"muted\">This creates one complete App for both GitHub sign-in and repository integration.</p>\n <label class=\"field\">Owner\n <select id=\"bootstrap-github-owner\"><option value=\"personal\">Personal account</option><option value=\"organization\">GitHub organization</option></select>\n </label>\n <label id=\"bootstrap-github-org-field\" class=\"field\" hidden>Organization login<input id=\"bootstrap-github-org\" autocomplete=\"off\"></label>\n <label class=\"field\">App name<input id=\"bootstrap-github-name\" value=\"AgentConnect\"></label>\n <button id=\"bootstrap-github-submit\">Create GitHub App and configure Logto</button>\n </div>\n <div id=\"bootstrap-slack\" class=\"panel\" hidden>\n <h3>Slack integration App</h3>\n <p id=\"bootstrap-slack-note\" class=\"muted\">Creates one complete Slack App for workspace installation and a separate Sign in with Slack OIDC flow.</p>\n <p>Logto redirect URI:</p><ul id=\"bootstrap-slack-redirects\" class=\"uris\"></ul>\n <label class=\"field\">App name<input id=\"bootstrap-slack-name\" value=\"AgentConnect\"></label>\n <label class=\"field\">Temporary App configuration token<input id=\"bootstrap-slack-token\" type=\"password\" autocomplete=\"new-password\"></label>\n <button id=\"bootstrap-slack-submit\">Create Slack App and configure Logto</button>\n </div>\n <button id=\"bootstrap-back\">Back to Logto credentials</button>\n </div>\n </section>\n </section>\n\n <main id=\"setup\" hidden>\n <div class=\"setup-layout\">\n <nav class=\"setup-nav\" aria-label=\"Deployment settings\">\n <a href=\"#startup-section\">Startup</a>\n <a href=\"#logto-section\">Logto</a>\n <a href=\"#github-section\">GitHub</a>\n <a href=\"#gitlab-section\">GitLab</a>\n <a href=\"#gitea-section\">Gitea</a>\n <a href=\"#slack-section\">Slack</a>\n <a href=\"#linear-section\">Linear</a>\n <a href=\"#google-section\">Google</a>\n <a href=\"#telegram-section\">Telegram</a>\n <a href=\"#discord-section\">Discord</a>\n <a href=\"#azuread-section\">Microsoft</a>\n <a href=\"#googlechat-section\">Google Chat</a>\n <a href=\"#feishu-section\">Feishu</a>\n <a href=\"#lark-section\">Lark</a>\n <a href=\"#options-section\">Options</a>\n </nav>\n <div class=\"setup-content\">\n <h1>AgentConnect deployment settings</h1>\n <p class=\"muted\">Saved settings take effect after the stack is restarted.</p>\n <div class=\"row\">\n <button id=\"logout\">Log out</button>\n </div>\n <div id=\"message\" aria-live=\"polite\"></div>\n\n <section id=\"editor\" hidden>\n <details id=\"startup-section\" class=\"environment setup-section\" open>\n <summary>Startup environment</summary>\n <p class=\"notice\">Public service URLs come from <code>.env</code>. Provider callbacks below are derived from these values.</p>\n <pre id=\"startup-environment\"></pre>\n </details>\n\n <section id=\"logto-section\" class=\"setup-section\" aria-labelledby=\"logto-heading\">\n <div class=\"section-head\">\n <div><h2 id=\"logto-heading\">Logto</h2><p class=\"muted\">Authentication and administrator access.</p></div>\n <span id=\"logto-match\" class=\"badge\">Not checked</span>\n </div>\n <div class=\"panel\">\n <dl class=\"credentials\">\n <dt>Management endpoint</dt><dd class=\"value-line\"><code id=\"logto-management-endpoint\">Not configured</code><span class=\"startup-owned\">startup environment</span></dd>\n <dt>Management App ID</dt><dd class=\"value-line\"><code id=\"logto-management-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"logto\">Edit</button></dd>\n <dt>Management API resource</dt><dd class=\"value-line\"><code id=\"logto-management-resource\">Not configured</code><button class=\"edit-configuration\" data-provider=\"logto\">Edit</button></dd>\n <dt>Management App secret</dt><dd class=\"secret-line\"><span id=\"logto-management-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"logto.managementAppSecret\" data-secret-display=\"logto-management-secret-display\">Edit</button></dd>\n <dt>Sign-in endpoint</dt><dd class=\"value-line\"><code id=\"logto-browser-endpoint\">Not configured</code><span class=\"startup-owned\">startup environment</span></dd>\n <dt>SPA App ID</dt><dd class=\"value-line\"><code id=\"logto-browser-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"logto\">Edit</button></dd>\n <dt>Browser API resource</dt><dd class=\"value-line\"><code id=\"logto-browser-resource\">Not configured</code><button class=\"edit-configuration\" data-provider=\"logto\">Edit</button></dd>\n </dl>\n <div id=\"logto-edit-controls\" class=\"subsection\" hidden>\n <h3>Edit Logto configuration</h3>\n <label class=\"field\">Management App ID<input id=\"logto-edit-management-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Management API resource<input id=\"logto-edit-management-resource\" autocomplete=\"off\"></label>\n <label class=\"field\">New Management App secret<input id=\"logto-edit-management-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when management identity changes\"></label>\n <label class=\"field\">SPA App ID<input id=\"logto-edit-browser-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Browser API resource<input id=\"logto-edit-browser-resource\" autocomplete=\"off\" placeholder=\"Optional\"></label>\n <div class=\"row\"><button id=\"save-logto-configuration\">Save configuration</button><button id=\"cancel-logto-configuration\">Cancel</button></div>\n </div>\n <p id=\"logto-status\" class=\"muted\">Checking redirects and sign-in settings…</p>\n <div id=\"logto-drift\" class=\"notice\" hidden></div>\n <div class=\"row\">\n <button id=\"check-logto\">Check match</button>\n <button id=\"reconcile-logto\">Apply expected settings</button>\n <a id=\"logto-settings\" class=\"button\" target=\"_blank\" rel=\"noopener\" hidden>Open Logto Console</a>\n </div>\n </div>\n </section>\n\n <h2>Providers</h2>\n <div class=\"provider-stack\">\n <section id=\"github-section\" class=\"panel setup-section\" aria-labelledby=\"github-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"github-heading\">GitHub</h3><p class=\"muted\">Repository integration and optional Logto sign-in.</p></div>\n <span id=\"github-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>App ID</dt><dd class=\"value-line\"><code id=\"github-app-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"github\">Edit</button></dd>\n <dt>App slug</dt><dd class=\"value-line\"><code id=\"github-app-slug\">Not configured</code><button class=\"edit-configuration\" data-provider=\"github\">Edit</button></dd>\n <dt>Client ID</dt><dd class=\"value-line\"><code id=\"github-client-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"github\">Edit</button></dd>\n <dt>Webhook delivery</dt><dd class=\"value-line\"><code id=\"github-webhook-enabled\">Not configured</code><button class=\"edit-configuration\" data-provider=\"github\">Edit</button></dd>\n <dt>Client secret</dt><dd class=\"secret-line\"><span id=\"github-client-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"github.clientSecret\" data-secret-display=\"github-client-secret-display\">Edit</button></dd>\n <dt>Private key</dt><dd class=\"secret-line\"><span id=\"github-private-key-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"github.privateKeyB64\" data-secret-display=\"github-private-key-display\">Edit</button></dd>\n <dt>Webhook secret</dt><dd class=\"secret-line\"><span id=\"github-webhook-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"github.webhookSecret\" data-secret-display=\"github-webhook-secret-display\">Edit</button></dd>\n <dt>Logto connector secret</dt><dd class=\"secret-line\"><span id=\"github-logto-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"logto.githubConnectorClientSecret\" data-secret-display=\"github-logto-secret-display\">Edit</button></dd>\n </dl>\n <p id=\"github-status\" class=\"muted\"></p>\n <div id=\"github-drift\" class=\"notice\" hidden></div>\n <div id=\"github-edit-controls\" class=\"subsection\" hidden>\n <h3>Edit GitHub App identity</h3>\n <label class=\"field\">App ID<input id=\"github-edit-app-id\" inputmode=\"numeric\" autocomplete=\"off\"></label>\n <label class=\"field\">App slug<input id=\"github-edit-slug\" autocomplete=\"off\"></label>\n <label class=\"field\">Client ID<input id=\"github-edit-client-id\" autocomplete=\"off\"></label>\n <label><input id=\"github-edit-webhook-enabled\" type=\"checkbox\"> Enable Relay webhook delivery</label>\n <label class=\"field\">New client secret<input id=\"github-edit-client-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when App or Client ID changes\"></label>\n <label class=\"field\">New private key (base64)<textarea id=\"github-edit-private-key\" autocomplete=\"off\" placeholder=\"Required when App ID changes\"></textarea></label>\n <label class=\"field\">New webhook secret<input id=\"github-edit-webhook-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when an active webhook App ID changes\"></label>\n <label id=\"github-edit-logto-secret-field\" class=\"field\" hidden>New Logto connector client secret<input id=\"github-edit-logto-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when this App is also used for sign-in\"></label>\n <div class=\"row\"><button id=\"save-github-configuration\">Save configuration</button><button id=\"cancel-github-configuration\">Cancel</button></div>\n </div>\n <div id=\"github-create-controls\" class=\"subsection\">\n <label class=\"field\">Owner\n <select id=\"github-owner\"><option value=\"personal\">Personal account</option><option value=\"organization\">GitHub organization</option></select>\n </label>\n <label id=\"github-org-field\" class=\"field\" hidden>Organization login<input id=\"github-org\" autocomplete=\"off\"></label>\n <label class=\"field\">App name<input id=\"github-name\" value=\"AgentConnect\"></label>\n </div>\n <div class=\"row\">\n <button id=\"create-github\">Create GitHub App</button>\n <button id=\"connect-github-login\" hidden>Use for Logto sign-in</button>\n <button id=\"check-github\" hidden>Check match</button>\n <button id=\"clear-github\" class=\"danger\" hidden>Clear configuration</button>\n <a id=\"github-settings\" class=\"button\" target=\"_blank\" rel=\"noopener\" hidden>Open GitHub settings</a>\n </div>\n </section>\n\n <section id=\"gitlab-section\" class=\"panel setup-section\" aria-labelledby=\"gitlab-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"gitlab-heading\">GitLab</h3><p class=\"muted\">OAuth application used to administer projects on this deployment's GitLab instance.</p></div>\n <span id=\"gitlab-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>Instance</dt><dd class=\"value-line\"><code id=\"gitlab-instance\">https://gitlab.com</code></dd>\n <dt>Application ID</dt><dd class=\"value-line\"><code id=\"gitlab-client-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"gitlab\">Edit</button></dd>\n <dt>Secret</dt><dd class=\"secret-line\"><span id=\"gitlab-client-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"gitlab.clientSecret\" data-secret-display=\"gitlab-client-secret-display\">Edit</button></dd>\n </dl>\n <p id=\"gitlab-status\" class=\"muted\"></p>\n <p id=\"gitlab-probe\" class=\"muted\" hidden></p>\n <div id=\"gitlab-drift\" class=\"notice\" hidden></div>\n <p>Redirect URI:</p><ul id=\"gitlab-callbacks\" class=\"uris\"></ul>\n <p>Scopes:</p><ul id=\"gitlab-scopes\" class=\"uris\"></ul>\n <p class=\"muted\">GitLab does not expose OAuth application creation through an API. In User settings → Applications, or a group's Settings → Applications, add an application whose redirect URI is exactly the value above, keep Confidential selected, grant the scopes above, then save the generated Application ID and Secret here. GitLab shows the secret only once.</p>\n <p class=\"muted\">Use your own user applications unless you are an instance administrator registering one application for everyone; an instance-wide application lives in the Admin area instead.</p>\n <p class=\"muted\">Creating each agent's bot account later needs authority this page cannot verify — no GitLab API reports it: either connect an instance administrator (whose API token cannot act as one while Admin Mode is enabled), or, on Premium and Ultimate, turn on Admin → Settings → General → Account and limit → “Allow top-level group Owners to create service accounts”.</p>\n <div class=\"row\"><a id=\"gitlab-applications\" class=\"button\" href=\"https://gitlab.com/-/user_settings/applications\" target=\"_blank\" rel=\"noopener\">Open GitLab applications</a><a id=\"gitlab-admin-applications\" class=\"button\" href=\"https://gitlab.com/admin/applications\" target=\"_blank\" rel=\"noopener\">Open admin applications</a></div>\n <div id=\"gitlab-config-controls\" class=\"subsection\">\n <label class=\"field\">Instance base URL<input id=\"gitlab-base-url\" autocomplete=\"off\" placeholder=\"Leave empty for https://gitlab.com\"></label>\n <label class=\"field\">Application ID<input id=\"gitlab-id\" autocomplete=\"off\"></label>\n <label id=\"gitlab-initial-secret-field\" class=\"field\">Secret<input id=\"gitlab-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when the Application ID changes\"></label>\n </div>\n <div class=\"row\"><button id=\"save-gitlab\">Save GitLab application</button><button id=\"cancel-gitlab-configuration\" hidden>Cancel</button><button id=\"clear-gitlab\" class=\"danger\" hidden>Clear configuration</button></div>\n <div class=\"subsection\">\n <h4>GitLab sign-in</h4>\n <p>Add these redirect URIs and the openid, profile and email scopes to the same confidential OAuth application.</p>\n <ul id=\"gitlab-login-redirects\" class=\"uris\"></ul>\n <p id=\"gitlab-login-status\" class=\"muted\"></p>\n <div class=\"row\"><button id=\"connect-gitlab-login\">Use for Logto sign-in</button><button id=\"disconnect-gitlab-login\" hidden>Disable GitLab sign-in</button><button id=\"check-gitlab-login\" hidden>Check Logto</button></div>\n </div>\n </section>\n\n <section id=\"gitea-section\" class=\"panel setup-section\" aria-labelledby=\"gitea-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"gitea-heading\">Gitea</h3><p class=\"muted\">The Gitea instance this deployment addresses. Gitea registers no application, so the address is the whole setting.</p></div>\n <span id=\"gitea-match\" class=\"badge\">Default</span>\n </div>\n <dl class=\"credentials\">\n <dt>Instance</dt><dd class=\"value-line\"><code id=\"gitea-instance\">https://gitea.com</code><button class=\"edit-configuration\" data-provider=\"gitea\">Edit</button></dd>\n </dl>\n <p id=\"gitea-status\" class=\"muted\"></p>\n <p id=\"gitea-probe\" class=\"muted\" hidden></p>\n <p class=\"muted\">Each organization connects Gitea by pasting the personal access token of a bot user it created, in the console — nothing about that identity is a deployment setting. Gitea 1.23 or later, or Forgejo 15 or later, is required, and the address is checked against that floor when it is saved.</p>\n <p class=\"muted\">Leave the field empty for gitea.com. A self-hosted instance under a path prefix keeps the prefix. A Forgejo instance, Codeberg included, is entered the same way.</p>\n <div id=\"gitea-config-controls\" class=\"subsection\">\n <label class=\"field\">Instance base URL<input id=\"gitea-base-url\" autocomplete=\"off\" placeholder=\"Leave empty for https://gitea.com\"></label>\n </div>\n <div class=\"row\"><button id=\"save-gitea\">Save Gitea instance</button><button id=\"cancel-gitea-configuration\" hidden>Cancel</button><button id=\"clear-gitea\" class=\"danger\" hidden>Clear configuration</button></div>\n </section>\n\n <section id=\"slack-section\" class=\"panel setup-section\" aria-labelledby=\"slack-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"slack-heading\">Slack</h3><p class=\"muted\">One App for workspace integration and Logto sign-in.</p></div>\n <span id=\"slack-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>App ID</dt><dd class=\"value-line\"><code id=\"slack-app-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"slack\">Edit</button></dd>\n <dt>Client ID</dt><dd class=\"value-line\"><code id=\"slack-client-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"slack\">Edit</button></dd>\n <dt>Client secret</dt><dd class=\"secret-line\"><span id=\"slack-client-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"slack.clientSecret\" data-secret-display=\"slack-client-secret-display\">Edit</button></dd>\n <dt>Signing secret</dt><dd class=\"secret-line\"><span id=\"slack-signing-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"slack.signingSecret\" data-secret-display=\"slack-signing-secret-display\">Edit</button></dd>\n <dt>Logto sign-in</dt><dd id=\"slack-logto-status\">Not configured</dd>\n </dl>\n <p id=\"slack-status\" class=\"muted\"></p>\n <div id=\"slack-drift\" class=\"notice\" hidden></div>\n <div id=\"slack-edit-controls\" class=\"subsection\" hidden>\n <h3>Edit Slack App identity</h3>\n <label class=\"field\">App ID<input id=\"slack-edit-app-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Client ID<input id=\"slack-edit-client-id\" autocomplete=\"off\"></label>\n <label class=\"field\">New client secret<input id=\"slack-edit-client-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when identity changes\"></label>\n <label class=\"field\">New signing secret<input id=\"slack-edit-signing-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when identity changes\"></label>\n <div class=\"row\"><button id=\"save-slack-configuration\">Save configuration</button><button id=\"cancel-slack-configuration\">Cancel</button></div>\n </div>\n <div class=\"subsection\">\n <label id=\"slack-name-field\" class=\"field\">App name<input id=\"slack-name\" value=\"AgentConnect\"></label>\n <label class=\"field\">Temporary App configuration token<input id=\"slack-token\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Used only for create, check or update\"></label>\n </div>\n <div class=\"row\">\n <button id=\"create-slack\">Create Slack App</button>\n <button id=\"connect-slack-login\" hidden>Use for Logto sign-in</button>\n <button id=\"check-slack\" hidden>Check match</button>\n <button id=\"reconcile-slack\" hidden>Apply update</button>\n <button id=\"clear-slack\" class=\"danger\" hidden>Clear configuration</button>\n <a id=\"slack-settings\" class=\"button\" target=\"_blank\" rel=\"noopener\" hidden>Open Slack settings</a>\n </div>\n </section>\n\n <section id=\"linear-section\" class=\"panel setup-section\" aria-labelledby=\"linear-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"linear-heading\">Linear</h3><p class=\"muted\">One OAuth application for the whole deployment. Every workspace connects through it.</p></div>\n <span id=\"linear-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>Client ID</dt><dd class=\"value-line\"><code id=\"linear-client-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"linear\">Edit</button></dd>\n <dt>Client secret</dt><dd class=\"secret-line\"><span id=\"linear-client-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"linear.clientSecret\" data-secret-display=\"linear-client-secret-display\">Edit</button></dd>\n <dt>Webhook signing secret</dt><dd class=\"secret-line\"><span id=\"linear-signing-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"linear.signingSecret\" data-secret-display=\"linear-signing-secret-display\">Edit</button></dd>\n </dl>\n <p id=\"linear-status\" class=\"muted\"></p>\n <div id=\"linear-drift\" class=\"notice\" hidden></div>\n <p>Callback URL:</p><ul id=\"linear-callbacks\" class=\"uris\"></ul>\n <p>Webhook URL:</p><ul id=\"linear-webhooks\" class=\"uris\"></ul>\n <p class=\"muted\">Linear does not expose OAuth application creation through an API. In Settings → API → OAuth applications, create one application under the product's own name and icon, add the callback URL above, then save its Client ID, Client secret, and webhook signing secret here.</p>\n <p class=\"muted\">On the same application, enable webhooks, point them at the webhook URL above, and check the <strong>Agent session events</strong> category.</p>\n <p class=\"muted\">Until that category is enabled, agent sessions are disabled for the whole application: delegating an issue only sets the delegate badge and no session is created. Enabling it later raises a new scope, so every already-connected workspace must reconnect before webhooks arrive.</p>\n <div class=\"row\"><a id=\"linear-applications\" class=\"button\" href=\"https://linear.app/settings/api/applications\" target=\"_blank\" rel=\"noopener\">Open Linear OAuth applications</a></div>\n <div id=\"linear-config-controls\" class=\"subsection\">\n <label class=\"field\">Client ID<input id=\"linear-id\" autocomplete=\"off\"></label>\n <div id=\"linear-initial-secret-fields\">\n <label class=\"field\">Client secret<input id=\"linear-client-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when the Client ID changes\"></label>\n <label class=\"field\">Webhook signing secret<input id=\"linear-signing-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when the Client ID changes\"></label>\n </div>\n </div>\n <div class=\"row\"><button id=\"save-linear\">Save Linear application</button><button id=\"cancel-linear-configuration\" hidden>Cancel</button><button id=\"clear-linear\" class=\"danger\" hidden>Clear configuration</button></div>\n </section>\n\n <section id=\"google-section\" class=\"panel setup-section\" aria-labelledby=\"google-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"google-heading\">Google</h3><p class=\"muted\">OAuth client used by the Logto Google connector.</p></div>\n <span id=\"google-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>Client ID</dt><dd class=\"value-line\"><code id=\"google-client-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"google\">Edit</button></dd>\n <dt>Client secret</dt><dd class=\"secret-line\"><span id=\"google-client-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"logto.googleConnectorClientSecret\" data-secret-display=\"google-client-secret-display\">Edit</button></dd>\n </dl>\n <p id=\"google-status\" class=\"muted\"></p>\n <div id=\"google-drift\" class=\"notice\" hidden></div>\n <p>Authorized JavaScript origin:</p><ul id=\"google-origins\" class=\"uris\"></ul>\n <p>Authorized redirect URIs:</p><ul id=\"google-redirects\" class=\"uris\"></ul>\n <p class=\"muted\">Google does not expose OAuth client redirect settings through an API. Copy these required values into Google Auth Platform; Setup can verify only the Logto connector.</p>\n <div class=\"row\"><a class=\"button\" href=\"https://console.cloud.google.com/auth/clients\" target=\"_blank\" rel=\"noopener\">Open Google settings</a></div>\n <div id=\"google-config-controls\" class=\"subsection\">\n <label class=\"field\">Client ID<input id=\"google-id\" autocomplete=\"off\"></label>\n <label id=\"google-initial-secret-field\" class=\"field\">Client secret<input id=\"google-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when Client ID changes\"></label>\n </div>\n <div class=\"row\"><button id=\"save-google\">Save Google client</button><button id=\"cancel-google-configuration\" hidden>Cancel</button><button id=\"check-google\" hidden>Check match</button><button id=\"clear-google\" class=\"danger\" hidden>Clear configuration</button></div>\n </section>\n\n ",
|
|
67259
|
+
"<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n <title>AgentConnect deployment settings</title>\n <style>\n :root { color-scheme: light dark; font: 15px/1.5 system-ui, sans-serif; }\n body { max-width: 1280px; margin: 48px auto; padding: 0 20px 60px; }\n h1 { margin-bottom: 4px; } h2 { margin-top: 32px; } h3 { margin: 0 0 8px; }\n .muted { color: #777; } .row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }\n button, .button { padding: 8px 13px; cursor: pointer; }\n .button { border: 1px solid #8888; border-radius: 4px; color: inherit; text-decoration: none; display: inline-block; }\n input, select { width: min(520px, 100%); padding: 7px; box-sizing: border-box; }\n input[type=\"checkbox\"] { width: auto; padding: 0; }\n .field { display: grid; gap: 4px; margin: 10px 0; }\n .provider-stack { display: grid; gap: 16px; }\n .panel { border: 1px solid #8886; border-radius: 8px; padding: 16px; }\n .section-head, .provider-head { display: flex; justify-content: space-between; gap: 16px; align-items: start; }\n .section-head h2, .provider-head h3 { margin: 0; }\n .provider-head p { margin: 4px 0 0; }\n .badge { flex: none; border: 1px solid #8886; border-radius: 999px; padding: 3px 9px; font-size: 13px; }\n .badge.pass { color: #198754; border-color: #19875466; background: #19875412; }\n .badge.warn { color: #a66b00; border-color: #d99b1366; background: #d99b1315; }\n .badge.fail { color: #c33; border-color: #c333; background: #c3331111; }\n .credentials { display: grid; grid-template-columns: minmax(160px, 220px) minmax(0, 1fr); gap: 7px 16px; margin: 16px 0; }\n .credentials dt { color: #777; }\n .credentials dd { margin: 0; overflow-wrap: anywhere; }\n .credentials code { user-select: all; }\n .redacted { font-family: ui-monospace, monospace; letter-spacing: .08em; }\n .secret-line, .value-line { display: flex; gap: 10px; align-items: center; min-height: 30px; }\n .edit-secret { padding: 2px 8px; font-size: 13px; }\n .secret-editor { display: flex; gap: 8px; flex-wrap: wrap; width: 100%; }\n .secret-editor input { width: min(420px, 100%); }\n .edit-configuration { padding: 2px 8px; font-size: 13px; }\n .startup-owned { color: #777; font-size: 13px; }\n .danger { color: #c33; border-color: #c336; }\n .subsection { margin-top: 16px; padding-top: 14px; border-top: 1px solid #8883; }\n textarea { width: min(720px, 100%); min-height: 100px; padding: 7px; box-sizing: border-box; }\n .uris { margin: 8px 0; padding-left: 20px; } .uris code { user-select: all; }\n .notice { border-left: 4px solid #d99b13; padding: 8px 12px; background: #d99b1315; }\n .diff-title { display: block; margin-bottom: 8px; }\n .config-diff { display: grid; gap: 1px; border: 1px solid #8884; border-radius: 6px; overflow: hidden; background: #8884; }\n .diff-row { display: grid; grid-template-columns: minmax(140px, .8fr) minmax(0, 1.2fr) minmax(0, 1.2fr); background: Canvas; }\n .diff-row > * { min-width: 0; padding: 7px 9px; overflow-wrap: anywhere; white-space: pre-wrap; }\n .diff-head { font-size: 12px; font-weight: 600; color: #777; }\n .diff-field { font-weight: 600; }\n .diff-value { font-family: ui-monospace, monospace; font-size: 13px; }\n pre { padding: 12px; border-radius: 6px; background: #8881; overflow-x: auto; user-select: all; }\n #message { white-space: pre-wrap; padding: 10px 0; min-height: 1.5em; }\n .error { color: #c33; } .ok { color: #198754; } .warn { color: #a66b00; }\n code { overflow-wrap: anywhere; }\n .setup-layout { display: grid; grid-template-columns: 190px minmax(0, 1fr); gap: 32px; align-items: start; }\n .setup-nav { position: sticky; top: 24px; display: grid; gap: 3px; padding: 10px; border: 1px solid #8886; border-radius: 8px; background: Canvas; }\n .setup-nav a { padding: 7px 9px; border-radius: 5px; color: inherit; text-decoration: none; white-space: nowrap; }\n .setup-nav a:hover { background: #8882; }\n .setup-section { scroll-margin-top: 24px; }\n details.environment { margin: 0 0 24px; } details.environment summary { cursor: pointer; font-weight: 600; }\n @media (max-width: 760px) {\n body { margin-top: 24px; }\n .setup-layout { grid-template-columns: 1fr; gap: 18px; }\n .setup-nav { position: static; display: flex; overflow-x: auto; }\n .credentials { grid-template-columns: 1fr; gap: 2px; }\n .credentials dd { margin-bottom: 8px; }\n .diff-row { grid-template-columns: 1fr; }\n .diff-head { display: none; }\n .diff-value::before { display: block; margin-bottom: 2px; color: #777; font: 11px/1.4 system-ui, sans-serif; }\n .diff-current::before { content: 'Current'; }\n .diff-expected::before { content: 'Expected'; }\n }\n [hidden] { display: none !important; }\n </style>\n</head>\n<body>\n <section id=\"access\">\n <h1>AgentConnect Setup</h1>\n <p id=\"access-message\" class=\"muted\" aria-live=\"polite\">Checking Logto sign-in…</p>\n <div class=\"row\">\n <button id=\"login\" hidden>Sign in with Logto</button>\n <a id=\"open-logto\" class=\"button\" href=\"http://localhost:3002\" target=\"_blank\" rel=\"noopener\" hidden>Open Logto Console</a>\n <button id=\"show-bootstrap\" hidden>Continue setup</button>\n </div>\n\n <section id=\"bootstrap\" hidden>\n <h2>Set up sign-in</h2>\n <p id=\"bootstrap-progress\" class=\"muted\">Step 1 of 2</p>\n <div id=\"bootstrap-logto-step\" class=\"panel\">\n <h3>Connect Logto</h3>\n <p class=\"muted\">Enter the one-time Logto Management API credential. It is sealed in the deployment database and verified before continuing.</p>\n <label class=\"field\">Logto M2M App ID<input id=\"logto-app-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Logto M2M App Secret<input id=\"logto-app-secret\" type=\"password\" autocomplete=\"new-password\"></label>\n <label class=\"field\">Management API resource<input id=\"logto-management-api-resource\" type=\"url\" autocomplete=\"off\"></label>\n <p class=\"muted\">Logto Cloud uses <code>https://<tenant-id>.logto.app/api</code>. Keep <code>https://default.logto.app/api</code> for Logto OSS.</p>\n <button id=\"bootstrap-logto-submit\">Save Logto and continue</button>\n </div>\n <div id=\"bootstrap-provider-step\" hidden>\n <h3>Choose a sign-in provider</h3>\n <p class=\"muted\">Logto Management API access is ready. Configure one provider to enable sign-in.</p>\n <label class=\"field\">Sign-in provider\n <select id=\"bootstrap-provider\"><option value=\"google\">Google (works on localhost)</option><option value=\"github\">GitHub integration App</option><option id=\"bootstrap-slack-option\" value=\"slack\">Slack integration App</option><option value=\"sso\">Enterprise SSO (OIDC or SAML)</option></select>\n </label>\n <div id=\"bootstrap-google\" class=\"panel\">\n <h3>Google OAuth client</h3>\n <p class=\"muted\">Create a Web application client in Google Auth Platform, then paste its credentials here.</p>\n <p>Authorized JavaScript origin:</p><ul id=\"bootstrap-google-origins\" class=\"uris\"></ul>\n <p>Authorized redirect URIs:</p><ul id=\"bootstrap-google-redirects\" class=\"uris\"></ul>\n <a class=\"button\" href=\"https://console.cloud.google.com/auth/clients\" target=\"_blank\" rel=\"noopener\">Open Google settings</a>\n <label class=\"field\">Client ID<input id=\"bootstrap-google-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Client Secret<input id=\"bootstrap-google-secret\" type=\"password\" autocomplete=\"new-password\"></label>\n <button id=\"bootstrap-google-submit\">Save Google OAuth and configure Logto</button>\n </div>\n <div id=\"bootstrap-github\" class=\"panel\" hidden>\n <h3>GitHub integration App</h3>\n <p id=\"bootstrap-github-note\" class=\"muted\">This creates one complete App for both GitHub sign-in and repository integration.</p>\n <label class=\"field\">Owner\n <select id=\"bootstrap-github-owner\"><option value=\"personal\">Personal account</option><option value=\"organization\">GitHub organization</option></select>\n </label>\n <label id=\"bootstrap-github-org-field\" class=\"field\" hidden>Organization login<input id=\"bootstrap-github-org\" autocomplete=\"off\"></label>\n <label class=\"field\">App name<input id=\"bootstrap-github-name\" value=\"AgentConnect\"></label>\n <button id=\"bootstrap-github-submit\">Create GitHub App and configure Logto</button>\n </div>\n <div id=\"bootstrap-slack\" class=\"panel\" hidden>\n <h3>Slack integration App</h3>\n <p id=\"bootstrap-slack-note\" class=\"muted\">Creates one complete Slack App for workspace installation and a separate Sign in with Slack OIDC flow.</p>\n <p>Logto redirect URI:</p><ul id=\"bootstrap-slack-redirects\" class=\"uris\"></ul>\n <label class=\"field\">App name<input id=\"bootstrap-slack-name\" value=\"AgentConnect\"></label>\n <label class=\"field\">Temporary App configuration token<input id=\"bootstrap-slack-token\" type=\"password\" autocomplete=\"new-password\"></label>\n <button id=\"bootstrap-slack-submit\">Create Slack App and configure Logto</button>\n </div>\n <div id=\"bootstrap-sso\" class=\"panel\" hidden>\n <h3>Enterprise SSO</h3>\n <p class=\"muted\">Sign in through the organization's identity provider. Logto creates the connector first, so its URLs can be registered with the identity provider.</p>\n ",
|
|
67260
|
+
"\n </div>\n <button id=\"bootstrap-back\">Back to Logto credentials</button>\n </div>\n </section>\n </section>\n\n <main id=\"setup\" hidden>\n <div class=\"setup-layout\">\n <nav class=\"setup-nav\" aria-label=\"Deployment settings\">\n <a href=\"#startup-section\">Startup</a>\n <a href=\"#logto-section\">Logto</a>\n <a href=\"#sso-section\">Enterprise SSO</a>\n <a href=\"#github-section\">GitHub</a>\n <a href=\"#gitlab-section\">GitLab</a>\n <a href=\"#gitea-section\">Gitea</a>\n <a href=\"#slack-section\">Slack</a>\n <a href=\"#linear-section\">Linear</a>\n <a href=\"#google-section\">Google</a>\n <a href=\"#telegram-section\">Telegram</a>\n <a href=\"#discord-section\">Discord</a>\n <a href=\"#azuread-section\">Microsoft</a>\n <a href=\"#googlechat-section\">Google Chat</a>\n <a href=\"#feishu-section\">Feishu</a>\n <a href=\"#lark-section\">Lark</a>\n <a href=\"#options-section\">Options</a>\n </nav>\n <div class=\"setup-content\">\n <h1>AgentConnect deployment settings</h1>\n <p class=\"muted\">Saved settings take effect after the stack is restarted.</p>\n <div class=\"row\">\n <button id=\"logout\">Log out</button>\n </div>\n <div id=\"message\" aria-live=\"polite\"></div>\n\n <section id=\"editor\" hidden>\n <details id=\"startup-section\" class=\"environment setup-section\" open>\n <summary>Startup environment</summary>\n <p class=\"notice\">Public service URLs come from <code>.env</code>. Provider callbacks below are derived from these values.</p>\n <pre id=\"startup-environment\"></pre>\n </details>\n\n <section id=\"logto-section\" class=\"setup-section\" aria-labelledby=\"logto-heading\">\n <div class=\"section-head\">\n <div><h2 id=\"logto-heading\">Logto</h2><p class=\"muted\">Authentication and administrator access.</p></div>\n <span id=\"logto-match\" class=\"badge\">Not checked</span>\n </div>\n <div class=\"panel\">\n <dl class=\"credentials\">\n <dt>Management endpoint</dt><dd class=\"value-line\"><code id=\"logto-management-endpoint\">Not configured</code><span class=\"startup-owned\">startup environment</span></dd>\n <dt>Management App ID</dt><dd class=\"value-line\"><code id=\"logto-management-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"logto\">Edit</button></dd>\n <dt>Management API resource</dt><dd class=\"value-line\"><code id=\"logto-management-resource\">Not configured</code><button class=\"edit-configuration\" data-provider=\"logto\">Edit</button></dd>\n <dt>Management App secret</dt><dd class=\"secret-line\"><span id=\"logto-management-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"logto.managementAppSecret\" data-secret-display=\"logto-management-secret-display\">Edit</button></dd>\n <dt>Sign-in endpoint</dt><dd class=\"value-line\"><code id=\"logto-browser-endpoint\">Not configured</code><span class=\"startup-owned\">startup environment</span></dd>\n <dt>SPA App ID</dt><dd class=\"value-line\"><code id=\"logto-browser-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"logto\">Edit</button></dd>\n <dt>Browser API resource</dt><dd class=\"value-line\"><code id=\"logto-browser-resource\">Not configured</code><button class=\"edit-configuration\" data-provider=\"logto\">Edit</button></dd>\n </dl>\n <div id=\"logto-edit-controls\" class=\"subsection\" hidden>\n <h3>Edit Logto configuration</h3>\n <label class=\"field\">Management App ID<input id=\"logto-edit-management-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Management API resource<input id=\"logto-edit-management-resource\" autocomplete=\"off\"></label>\n <label class=\"field\">New Management App secret<input id=\"logto-edit-management-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when management identity changes\"></label>\n <label class=\"field\">SPA App ID<input id=\"logto-edit-browser-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Browser API resource<input id=\"logto-edit-browser-resource\" autocomplete=\"off\" placeholder=\"Optional\"></label>\n <div class=\"row\"><button id=\"save-logto-configuration\">Save configuration</button><button id=\"cancel-logto-configuration\">Cancel</button></div>\n </div>\n <p id=\"logto-status\" class=\"muted\">Checking redirects and sign-in settings…</p>\n <div id=\"logto-drift\" class=\"notice\" hidden></div>\n <div class=\"row\">\n <button id=\"check-logto\">Check match</button>\n <button id=\"reconcile-logto\">Apply expected settings</button>\n <a id=\"logto-settings\" class=\"button\" target=\"_blank\" rel=\"noopener\" hidden>Open Logto Console</a>\n </div>\n </div>\n </section>\n\n <h2>Providers</h2>\n <div class=\"provider-stack\">\n <section id=\"github-section\" class=\"panel setup-section\" aria-labelledby=\"github-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"github-heading\">GitHub</h3><p class=\"muted\">Repository integration and optional Logto sign-in.</p></div>\n <span id=\"github-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>App ID</dt><dd class=\"value-line\"><code id=\"github-app-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"github\">Edit</button></dd>\n <dt>App slug</dt><dd class=\"value-line\"><code id=\"github-app-slug\">Not configured</code><button class=\"edit-configuration\" data-provider=\"github\">Edit</button></dd>\n <dt>Client ID</dt><dd class=\"value-line\"><code id=\"github-client-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"github\">Edit</button></dd>\n <dt>Webhook delivery</dt><dd class=\"value-line\"><code id=\"github-webhook-enabled\">Not configured</code><button class=\"edit-configuration\" data-provider=\"github\">Edit</button></dd>\n <dt>Client secret</dt><dd class=\"secret-line\"><span id=\"github-client-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"github.clientSecret\" data-secret-display=\"github-client-secret-display\">Edit</button></dd>\n <dt>Private key</dt><dd class=\"secret-line\"><span id=\"github-private-key-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"github.privateKeyB64\" data-secret-display=\"github-private-key-display\">Edit</button></dd>\n <dt>Webhook secret</dt><dd class=\"secret-line\"><span id=\"github-webhook-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"github.webhookSecret\" data-secret-display=\"github-webhook-secret-display\">Edit</button></dd>\n <dt>Logto connector secret</dt><dd class=\"secret-line\"><span id=\"github-logto-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"logto.githubConnectorClientSecret\" data-secret-display=\"github-logto-secret-display\">Edit</button></dd>\n </dl>\n <p id=\"github-status\" class=\"muted\"></p>\n <div id=\"github-drift\" class=\"notice\" hidden></div>\n <div id=\"github-edit-controls\" class=\"subsection\" hidden>\n <h3>Edit GitHub App identity</h3>\n <label class=\"field\">App ID<input id=\"github-edit-app-id\" inputmode=\"numeric\" autocomplete=\"off\"></label>\n <label class=\"field\">App slug<input id=\"github-edit-slug\" autocomplete=\"off\"></label>\n <label class=\"field\">Client ID<input id=\"github-edit-client-id\" autocomplete=\"off\"></label>\n <label><input id=\"github-edit-webhook-enabled\" type=\"checkbox\"> Enable Relay webhook delivery</label>\n <label class=\"field\">New client secret<input id=\"github-edit-client-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when App or Client ID changes\"></label>\n <label class=\"field\">New private key (base64)<textarea id=\"github-edit-private-key\" autocomplete=\"off\" placeholder=\"Required when App ID changes\"></textarea></label>\n <label class=\"field\">New webhook secret<input id=\"github-edit-webhook-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when an active webhook App ID changes\"></label>\n <label id=\"github-edit-logto-secret-field\" class=\"field\" hidden>New Logto connector client secret<input id=\"github-edit-logto-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when this App is also used for sign-in\"></label>\n <div class=\"row\"><button id=\"save-github-configuration\">Save configuration</button><button id=\"cancel-github-configuration\">Cancel</button></div>\n </div>\n <div id=\"github-create-controls\" class=\"subsection\">\n <label class=\"field\">Owner\n <select id=\"github-owner\"><option value=\"personal\">Personal account</option><option value=\"organization\">GitHub organization</option></select>\n </label>\n <label id=\"github-org-field\" class=\"field\" hidden>Organization login<input id=\"github-org\" autocomplete=\"off\"></label>\n <label class=\"field\">App name<input id=\"github-name\" value=\"AgentConnect\"></label>\n </div>\n <div class=\"row\">\n <button id=\"create-github\">Create GitHub App</button>\n <button id=\"connect-github-login\" hidden>Use for Logto sign-in</button>\n <button id=\"check-github\" hidden>Check match</button>\n <button id=\"clear-github\" class=\"danger\" hidden>Clear configuration</button>\n <a id=\"github-settings\" class=\"button\" target=\"_blank\" rel=\"noopener\" hidden>Open GitHub settings</a>\n </div>\n </section>\n\n <section id=\"gitlab-section\" class=\"panel setup-section\" aria-labelledby=\"gitlab-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"gitlab-heading\">GitLab</h3><p class=\"muted\">OAuth application used to administer projects on this deployment's GitLab instance.</p></div>\n <span id=\"gitlab-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>Instance</dt><dd class=\"value-line\"><code id=\"gitlab-instance\">https://gitlab.com</code></dd>\n <dt>Application ID</dt><dd class=\"value-line\"><code id=\"gitlab-client-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"gitlab\">Edit</button></dd>\n <dt>Secret</dt><dd class=\"secret-line\"><span id=\"gitlab-client-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"gitlab.clientSecret\" data-secret-display=\"gitlab-client-secret-display\">Edit</button></dd>\n </dl>\n <p id=\"gitlab-status\" class=\"muted\"></p>\n <p id=\"gitlab-probe\" class=\"muted\" hidden></p>\n <div id=\"gitlab-drift\" class=\"notice\" hidden></div>\n <p>Redirect URI:</p><ul id=\"gitlab-callbacks\" class=\"uris\"></ul>\n <p>Scopes:</p><ul id=\"gitlab-scopes\" class=\"uris\"></ul>\n <p class=\"muted\">GitLab does not expose OAuth application creation through an API. In User settings → Applications, or a group's Settings → Applications, add an application whose redirect URI is exactly the value above, keep Confidential selected, grant the scopes above, then save the generated Application ID and Secret here. GitLab shows the secret only once.</p>\n <p class=\"muted\">Use your own user applications unless you are an instance administrator registering one application for everyone; an instance-wide application lives in the Admin area instead.</p>\n <p class=\"muted\">Creating each agent's bot account later needs authority this page cannot verify — no GitLab API reports it: either connect an instance administrator (whose API token cannot act as one while Admin Mode is enabled), or, on Premium and Ultimate, turn on Admin → Settings → General → Account and limit → “Allow top-level group Owners to create service accounts”.</p>\n <div class=\"row\"><a id=\"gitlab-applications\" class=\"button\" href=\"https://gitlab.com/-/user_settings/applications\" target=\"_blank\" rel=\"noopener\">Open GitLab applications</a><a id=\"gitlab-admin-applications\" class=\"button\" href=\"https://gitlab.com/admin/applications\" target=\"_blank\" rel=\"noopener\">Open admin applications</a></div>\n <div id=\"gitlab-config-controls\" class=\"subsection\">\n <label class=\"field\">Instance base URL<input id=\"gitlab-base-url\" autocomplete=\"off\" placeholder=\"Leave empty for https://gitlab.com\"></label>\n <label class=\"field\">Application ID<input id=\"gitlab-id\" autocomplete=\"off\"></label>\n <label id=\"gitlab-initial-secret-field\" class=\"field\">Secret<input id=\"gitlab-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when the Application ID changes\"></label>\n </div>\n <div class=\"row\"><button id=\"save-gitlab\">Save GitLab application</button><button id=\"cancel-gitlab-configuration\" hidden>Cancel</button><button id=\"clear-gitlab\" class=\"danger\" hidden>Clear configuration</button></div>\n <div class=\"subsection\">\n <h4>GitLab sign-in</h4>\n <p>Add these redirect URIs and the openid, profile and email scopes to the same confidential OAuth application.</p>\n <ul id=\"gitlab-login-redirects\" class=\"uris\"></ul>\n <p id=\"gitlab-login-status\" class=\"muted\"></p>\n <div class=\"row\"><button id=\"connect-gitlab-login\">Use for Logto sign-in</button><button id=\"disconnect-gitlab-login\" hidden>Disable GitLab sign-in</button><button id=\"check-gitlab-login\" hidden>Check Logto</button></div>\n </div>\n </section>\n\n <section id=\"gitea-section\" class=\"panel setup-section\" aria-labelledby=\"gitea-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"gitea-heading\">Gitea</h3><p class=\"muted\">The Gitea instance this deployment addresses. Gitea registers no application, so the address is the whole setting.</p></div>\n <span id=\"gitea-match\" class=\"badge\">Default</span>\n </div>\n <dl class=\"credentials\">\n <dt>Instance</dt><dd class=\"value-line\"><code id=\"gitea-instance\">https://gitea.com</code><button class=\"edit-configuration\" data-provider=\"gitea\">Edit</button></dd>\n </dl>\n <p id=\"gitea-status\" class=\"muted\"></p>\n <p id=\"gitea-probe\" class=\"muted\" hidden></p>\n <p class=\"muted\">Each organization connects Gitea by pasting the personal access token of a bot user it created, in the console — nothing about that identity is a deployment setting. Gitea 1.23 or later, or Forgejo 15 or later, is required, and the address is checked against that floor when it is saved.</p>\n <p class=\"muted\">Leave the field empty for gitea.com. A self-hosted instance under a path prefix keeps the prefix. A Forgejo instance, Codeberg included, is entered the same way.</p>\n <div id=\"gitea-config-controls\" class=\"subsection\">\n <label class=\"field\">Instance base URL<input id=\"gitea-base-url\" autocomplete=\"off\" placeholder=\"Leave empty for https://gitea.com\"></label>\n </div>\n <div class=\"row\"><button id=\"save-gitea\">Save Gitea instance</button><button id=\"cancel-gitea-configuration\" hidden>Cancel</button><button id=\"clear-gitea\" class=\"danger\" hidden>Clear configuration</button></div>\n </section>\n\n <section id=\"slack-section\" class=\"panel setup-section\" aria-labelledby=\"slack-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"slack-heading\">Slack</h3><p class=\"muted\">One App for workspace integration and Logto sign-in.</p></div>\n <span id=\"slack-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>App ID</dt><dd class=\"value-line\"><code id=\"slack-app-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"slack\">Edit</button></dd>\n <dt>Client ID</dt><dd class=\"value-line\"><code id=\"slack-client-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"slack\">Edit</button></dd>\n <dt>Client secret</dt><dd class=\"secret-line\"><span id=\"slack-client-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"slack.clientSecret\" data-secret-display=\"slack-client-secret-display\">Edit</button></dd>\n <dt>Signing secret</dt><dd class=\"secret-line\"><span id=\"slack-signing-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"slack.signingSecret\" data-secret-display=\"slack-signing-secret-display\">Edit</button></dd>\n <dt>Logto sign-in</dt><dd id=\"slack-logto-status\">Not configured</dd>\n </dl>\n <p id=\"slack-status\" class=\"muted\"></p>\n <div id=\"slack-drift\" class=\"notice\" hidden></div>\n <div id=\"slack-edit-controls\" class=\"subsection\" hidden>\n <h3>Edit Slack App identity</h3>\n <label class=\"field\">App ID<input id=\"slack-edit-app-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Client ID<input id=\"slack-edit-client-id\" autocomplete=\"off\"></label>\n <label class=\"field\">New client secret<input id=\"slack-edit-client-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when identity changes\"></label>\n <label class=\"field\">New signing secret<input id=\"slack-edit-signing-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when identity changes\"></label>\n <div class=\"row\"><button id=\"save-slack-configuration\">Save configuration</button><button id=\"cancel-slack-configuration\">Cancel</button></div>\n </div>\n <div class=\"subsection\">\n <label id=\"slack-name-field\" class=\"field\">App name<input id=\"slack-name\" value=\"AgentConnect\"></label>\n <label class=\"field\">Temporary App configuration token<input id=\"slack-token\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Used only for create, check or update\"></label>\n </div>\n <div class=\"row\">\n <button id=\"create-slack\">Create Slack App</button>\n <button id=\"connect-slack-login\" hidden>Use for Logto sign-in</button>\n <button id=\"check-slack\" hidden>Check match</button>\n <button id=\"reconcile-slack\" hidden>Apply update</button>\n <button id=\"clear-slack\" class=\"danger\" hidden>Clear configuration</button>\n <a id=\"slack-settings\" class=\"button\" target=\"_blank\" rel=\"noopener\" hidden>Open Slack settings</a>\n </div>\n </section>\n\n <section id=\"linear-section\" class=\"panel setup-section\" aria-labelledby=\"linear-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"linear-heading\">Linear</h3><p class=\"muted\">One OAuth application for the whole deployment. Every workspace connects through it.</p></div>\n <span id=\"linear-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>Client ID</dt><dd class=\"value-line\"><code id=\"linear-client-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"linear\">Edit</button></dd>\n <dt>Client secret</dt><dd class=\"secret-line\"><span id=\"linear-client-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"linear.clientSecret\" data-secret-display=\"linear-client-secret-display\">Edit</button></dd>\n <dt>Webhook signing secret</dt><dd class=\"secret-line\"><span id=\"linear-signing-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"linear.signingSecret\" data-secret-display=\"linear-signing-secret-display\">Edit</button></dd>\n </dl>\n <p id=\"linear-status\" class=\"muted\"></p>\n <div id=\"linear-drift\" class=\"notice\" hidden></div>\n <p>Callback URL:</p><ul id=\"linear-callbacks\" class=\"uris\"></ul>\n <p>Webhook URL:</p><ul id=\"linear-webhooks\" class=\"uris\"></ul>\n <p class=\"muted\">Linear does not expose OAuth application creation through an API. In Settings → API → OAuth applications, create one application under the product's own name and icon, add the callback URL above, then save its Client ID, Client secret, and webhook signing secret here.</p>\n <p class=\"muted\">On the same application, enable webhooks, point them at the webhook URL above, and check the <strong>Agent session events</strong> category.</p>\n <p class=\"muted\">Until that category is enabled, agent sessions are disabled for the whole application: delegating an issue only sets the delegate badge and no session is created. Enabling it later raises a new scope, so every already-connected workspace must reconnect before webhooks arrive.</p>\n <div class=\"row\"><a id=\"linear-applications\" class=\"button\" href=\"https://linear.app/settings/api/applications\" target=\"_blank\" rel=\"noopener\">Open Linear OAuth applications</a></div>\n <div id=\"linear-config-controls\" class=\"subsection\">\n <label class=\"field\">Client ID<input id=\"linear-id\" autocomplete=\"off\"></label>\n <div id=\"linear-initial-secret-fields\">\n <label class=\"field\">Client secret<input id=\"linear-client-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when the Client ID changes\"></label>\n <label class=\"field\">Webhook signing secret<input id=\"linear-signing-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when the Client ID changes\"></label>\n </div>\n </div>\n <div class=\"row\"><button id=\"save-linear\">Save Linear application</button><button id=\"cancel-linear-configuration\" hidden>Cancel</button><button id=\"clear-linear\" class=\"danger\" hidden>Clear configuration</button></div>\n </section>\n\n <section id=\"sso-section\" class=\"panel setup-section\" aria-labelledby=\"sso-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"sso-heading\">Enterprise SSO</h3><p class=\"muted\">OIDC or SAML identity provider for console sign-in.</p></div>\n <span id=\"sso-match\" class=\"badge\">Not configured</span>\n </div>\n ",
|
|
67261
|
+
"\n <div class=\"row\"><button id=\"sso-remove\" class=\"danger\" hidden>Remove SSO sign-in</button></div>\n </section>\n\n <section id=\"google-section\" class=\"panel setup-section\" aria-labelledby=\"google-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"google-heading\">Google</h3><p class=\"muted\">OAuth client used by the Logto Google connector.</p></div>\n <span id=\"google-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>Client ID</dt><dd class=\"value-line\"><code id=\"google-client-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"google\">Edit</button></dd>\n <dt>Client secret</dt><dd class=\"secret-line\"><span id=\"google-client-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"logto.googleConnectorClientSecret\" data-secret-display=\"google-client-secret-display\">Edit</button></dd>\n </dl>\n <p id=\"google-status\" class=\"muted\"></p>\n <div id=\"google-drift\" class=\"notice\" hidden></div>\n <p>Authorized JavaScript origin:</p><ul id=\"google-origins\" class=\"uris\"></ul>\n <p>Authorized redirect URIs:</p><ul id=\"google-redirects\" class=\"uris\"></ul>\n <p class=\"muted\">Google does not expose OAuth client redirect settings through an API. Copy these required values into Google Auth Platform; Setup can verify only the Logto connector.</p>\n <div class=\"row\"><a class=\"button\" href=\"https://console.cloud.google.com/auth/clients\" target=\"_blank\" rel=\"noopener\">Open Google settings</a></div>\n <div id=\"google-config-controls\" class=\"subsection\">\n <label class=\"field\">Client ID<input id=\"google-id\" autocomplete=\"off\"></label>\n <label id=\"google-initial-secret-field\" class=\"field\">Client secret<input id=\"google-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when Client ID changes\"></label>\n </div>\n <div class=\"row\"><button id=\"save-google\">Save Google client</button><button id=\"cancel-google-configuration\" hidden>Cancel</button><button id=\"check-google\" hidden>Check match</button><button id=\"clear-google\" class=\"danger\" hidden>Clear configuration</button></div>\n </section>\n\n ",
|
|
67093
67262
|
"\n\n <section id=\"googlechat-section\" class=\"panel setup-section\" aria-labelledby=\"googlechat-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"googlechat-heading\">Google Chat</h3><p class=\"muted\">One Chat app for the deployment. Each Google Workspace organization connects itself from Google Chat.</p></div>\n <span id=\"googlechat-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>Project ID</dt><dd class=\"value-line\"><code id=\"googlechat-project-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"googlechat\">Edit</button></dd>\n <dt>Project number</dt><dd class=\"value-line\"><code id=\"googlechat-project-number\">Not configured</code><button class=\"edit-configuration\" data-provider=\"googlechat\">Edit</button></dd>\n <dt>Service-account key</dt><dd class=\"value-line\"><span id=\"googlechat-key-display\" class=\"muted\">Not configured</span><button class=\"edit-configuration\" data-provider=\"googlechat\">Edit</button></dd>\n </dl>\n <p id=\"googlechat-status\" class=\"muted\"></p>\n <div id=\"googlechat-drift\" class=\"notice\" hidden></div>\n <p>HTTP endpoint URL:</p><ul id=\"googlechat-callbacks\" class=\"uris\"></ul>\n <ol class=\"muted\">\n <li>Use a Google Workspace account that may create a Cloud project and service-account keys. An organization policy can block key creation.</li>\n <li>Create a Google Cloud project for this app and enable the Google Chat API in it.</li>\n <li>On the Chat API Configuration page, build the app as a Google Workspace add-on, the default for a new app (convert an existing app first). Under Connection settings choose HTTP endpoint URL, use one URL for all triggers, and enter the URL above. Allow 1:1 messages and joining spaces, and choose who can use the app.</li>\n <li>Under Commands, add a slash command named /help with the description \"Show how to use this app\" and command ID 1; any free ID works, since AgentConnect matches the name.</li>\n <li>In the same project, create a service account and a JSON key for it, grant it the Browser role on the project, and enable the Cloud Resource Manager API; AgentConnect reads the project number with that key.</li>\n <li>Save the project ID and key here. The project number is optional: it is on the project's settings page and in the add-on's service account email, service-PROJECT_NUMBER@gcp-sa-gsuiteaddons.iam.gserviceaccount.com. When entered, it must match the number read from the key's project.</li>\n <li>Configure Google sign-in above, then add the app in Google Chat and send it a message. Each Google Workspace organization connects itself from Google Chat by signing in to the console with Google; publish the app on the Google Workspace Marketplace to reach other organizations.</li>\n </ol>\n <div class=\"row\"><a id=\"googlechat-configuration\" class=\"button\" href=\"https://console.cloud.google.com/apis/api/chat.googleapis.com/hangouts-chat\" target=\"_blank\" rel=\"noopener\">Open Chat API configuration</a></div>\n <div id=\"googlechat-config-controls\" class=\"subsection\">\n <h3 id=\"googlechat-edit-heading\" hidden>Edit Google Chat App identity</h3>\n <label class=\"field\">Project ID<input id=\"googlechat-project-id-input\" autocomplete=\"off\"></label>\n <label class=\"field\">Project number (optional)<input id=\"googlechat-project-number-input\" inputmode=\"numeric\" autocomplete=\"off\" placeholder=\"Read from the key's project\"></label>\n <label class=\"field\">Service-account key JSON<input id=\"googlechat-key\" type=\"password\" autocomplete=\"off\" placeholder=\"Required when the project ID changes\"></label>\n </div>\n <div class=\"row\"><button id=\"save-googlechat\">Save Google Chat app</button><button id=\"cancel-googlechat-configuration\" hidden>Cancel</button><button id=\"clear-googlechat\" class=\"danger\" hidden>Clear configuration</button></div>\n </section>\n\n\n <section id=\"feishu-section\" class=\"panel setup-section\" aria-labelledby=\"feishu-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"feishu-heading\">Feishu</h3><p class=\"muted\">Matches the Logto OAuth 2.0 connector named Feishu, OAuth callbacks, and published App setup.</p></div>\n <span id=\"feishu-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>App ID</dt><dd class=\"value-line\"><code id=\"feishu-app-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"feishu\">Edit</button></dd>\n <dt>App secret</dt><dd class=\"secret-line\"><span id=\"feishu-app-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"feishu.loginAppSecret\" data-secret-display=\"feishu-app-secret-display\">Edit</button></dd>\n </dl>\n <p id=\"feishu-login-status\" class=\"muted\"></p>\n <div id=\"feishu-drift\" class=\"notice\" hidden></div>\n <div id=\"feishu-config-controls\" class=\"subsection\">\n <label id=\"feishu-create-name-field\" class=\"field\">New App name<input id=\"feishu-create-name\" value=\"AgentConnect\"></label>\n <label class=\"field\">Existing App ID<input id=\"feishu-login-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Existing App secret<input id=\"feishu-login-secret\" type=\"password\" autocomplete=\"new-password\"></label>\n </div>\n <div class=\"row\">\n <button id=\"create-feishu-login-app\">Create Feishu App</button>\n <button id=\"save-feishu-login-app\">Save existing App</button>\n <button id=\"cancel-feishu-configuration\" hidden>Cancel</button>\n <button id=\"check-feishu-login-app\" hidden>Check setup</button>\n <button id=\"clear-feishu\" class=\"danger\" hidden>Clear configuration</button>\n <a id=\"feishu-settings\" class=\"button\" target=\"_blank\" rel=\"noopener\" hidden>Open Feishu settings</a>\n </div>\n </section>\n\n <section id=\"lark-section\" class=\"panel setup-section\" aria-labelledby=\"lark-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"lark-heading\">Lark</h3><p class=\"muted\">Matches the Logto OAuth 2.0 connector named Lark, OAuth callbacks, and published App setup.</p></div>\n <span id=\"lark-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>App ID</dt><dd class=\"value-line\"><code id=\"lark-app-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"lark\">Edit</button></dd>\n <dt>App secret</dt><dd class=\"secret-line\"><span id=\"lark-app-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"lark.loginAppSecret\" data-secret-display=\"lark-app-secret-display\">Edit</button></dd>\n </dl>\n <p id=\"lark-login-status\" class=\"muted\"></p>\n <div id=\"lark-drift\" class=\"notice\" hidden></div>\n <div id=\"lark-config-controls\" class=\"subsection\">\n <label id=\"lark-create-name-field\" class=\"field\">New App name<input id=\"lark-create-name\" value=\"AgentConnect\"></label>\n <label class=\"field\">Existing App ID<input id=\"lark-login-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Existing App secret<input id=\"lark-login-secret\" type=\"password\" autocomplete=\"new-password\"></label>\n </div>\n <div class=\"row\">\n <button id=\"create-lark-login-app\">Create Lark App</button>\n <button id=\"save-lark-login-app\">Save existing App</button>\n <button id=\"cancel-lark-configuration\" hidden>Cancel</button>\n <button id=\"check-lark-login-app\" hidden>Check setup</button>\n <button id=\"clear-lark\" class=\"danger\" hidden>Clear configuration</button>\n <a id=\"lark-settings\" class=\"button\" target=\"_blank\" rel=\"noopener\" hidden>Open Lark settings</a>\n </div>\n </section>\n </div>\n\n <section id=\"options-section\" class=\"setup-section\">\n <h2>Deployment options</h2>\n <div class=\"panel\">\n <label class=\"field\"><span><input id=\"preset-agents-enabled\" type=\"checkbox\"> Enable preset Agents</span></label>\n <label class=\"field\">Max organizations created per non-ADMIN user<input id=\"max-orgs-per-non-admin-user\" type=\"number\" min=\"0\" step=\"1\" value=\"1\"></label>\n <div class=\"row\"><button id=\"save-options\">Save options</button></div>\n </div>\n </section>\n </section>\n </div>\n </div>\n </main>\n\n <script>\n const api = '/api/v1';\n const tokenKey = 'agentconnect.setup.token';\n const verifierKey = 'agentconnect.setup.pkce';\n const stateKey = 'agentconnect.setup.state';\n let currentRevision = 0;\n let currentStatus = null;\n let bootstrapInfo = null;\n const el = (id) => document.getElementById(id);\n const message = (text, error = false) => {\n const target = el('setup').hidden ? el('access-message') : el('message');\n target.textContent = text;\n target.className = error ? 'error' : 'ok';\n };\n const bearer = () => {\n const token = sessionStorage.getItem(tokenKey);\n return token ? { authorization: 'Bearer ' + token } : {};\n };\n const json = async (response) => {\n const body = await response.json().catch(() => ({}));\n if (!response.ok) throw Object.assign(new Error(body.message || ('HTTP ' + response.status)), { status: response.status, code: body.code });\n return body;\n };\n const base64url = (bytes) => btoa(String.fromCharCode(...bytes)).replace(/+/g, '-').replace(///g, '_').replace(/=+$/, '');\n const random = () => base64url(crypto.getRandomValues(new Uint8Array(32)));\n const configured = (byKey, key) => Boolean(byKey.get(key) && byKey.get(key).configured);\n const loginApps = ",
|
|
67094
|
-
";\n\n function showIdentityEditors(provider, show) {\n for (const button of document.querySelectorAll('.edit-configuration[data-provider=\"' + provider + '\"]')) {\n button.hidden = !show;\n }\n }\n\n function text(id, value) {\n el(id).textContent = value === null || value === undefined || value === '' ? 'Not configured' : String(value);\n }\n\n function secretText(id, byKey, key, editable) {\n const stored = configured(byKey, key);\n const display = el(id);\n const button = document.querySelector('[data-secret-display=\"' + id + '\"]');\n const row = display.closest('.secret-line');\n const editor = row && row.querySelector('.secret-editor');\n if (editor) editor.remove();\n display.hidden = false;\n display.textContent = stored ? '***' : 'Not configured';\n display.className = stored ? 'redacted' : 'muted';\n button.hidden = !editable;\n button.textContent = stored ? 'Edit' : 'Set';\n }\n\n function match(id, state, label) {\n const target = el(id);\n target.textContent = label;\n target.className = 'badge' + (state ? ' ' + state : '');\n }\n\n async function authConfig() { return json(await fetch(api + '/auth-config')); }\n\n async function signIn() {\n const config = await authConfig();\n if (config.mode !== 'oidc') throw new Error('Save an OIDC configuration first');\n const verifier = random();\n const challenge = base64url(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))));\n const state = random();\n sessionStorage.setItem(verifierKey, verifier);\n sessionStorage.setItem(stateKey, state);\n const url = new URL(config.authorizationEndpoint);\n url.searchParams.set('client_id', config.appId);\n url.searchParams.set('redirect_uri', config.redirectUri);\n url.searchParams.set('response_type', 'code');\n url.searchParams.set('scope', 'openid profile email roles');\n url.searchParams.set('state', state);\n url.searchParams.set('code_challenge', challenge);\n url.searchParams.set('code_challenge_method', 'S256');\n if (config.resource) url.searchParams.set('resource', config.resource);\n location.assign(url);\n }\n\n async function finishSignIn() {\n const url = new URL(location.href);\n const code = url.searchParams.get('code');\n if (!code) return;\n const state = url.searchParams.get('state');\n if (!state || state !== sessionStorage.getItem(stateKey)) throw new Error('OIDC state mismatch');\n const verifier = sessionStorage.getItem(verifierKey);\n if (!verifier) throw new Error('PKCE verifier is missing; start sign-in again');\n const config = await authConfig();\n const body = new URLSearchParams({\n grant_type: 'authorization_code', code, client_id: config.appId,\n redirect_uri: config.redirectUri, code_verifier: verifier\n });\n if (config.resource) body.set('resource', config.resource);\n const tokens = await json(await fetch(config.tokenEndpoint, {\n method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body\n }));\n if (typeof tokens.id_token !== 'string') throw new Error('Logto did not return an ID token');\n sessionStorage.setItem(tokenKey, tokens.id_token);\n sessionStorage.removeItem(verifierKey);\n sessionStorage.removeItem(stateKey);\n history.replaceState({}, '', '/');\n }\n\n function renderUriList(id, values) {\n el(id).replaceChildren(...(values || []).map((value) => {\n const item = document.createElement('li');\n const code = document.createElement('code'); code.textContent = value;\n item.append(code); return item;\n }));\n }\n\n function updateBootstrapProvider() {\n const provider = el('bootstrap-provider').value;\n el('bootstrap-google').hidden = provider !== 'google';\n el('bootstrap-github').hidden = provider !== 'github';\n el('bootstrap-slack').hidden = provider !== 'slack';\n }\n\n function showBootstrapStep(step) {\n const logto = step === 'logto';\n el('bootstrap-logto-step').hidden = !logto;\n el('bootstrap-provider-step').hidden = logto;\n el('bootstrap-progress').textContent = logto ? 'Step 1 of 2' : 'Step 2 of 2';\n }\n\n function updateOwner(prefix) {\n el(prefix + '-org-field').hidden = el(prefix + '-owner').value !== 'organization';\n }\n\n function ownership(prefix) {\n if (el(prefix + '-owner').value === 'personal') return { owner: 'personal', organization: null };\n const organization = el(prefix + '-org').value.trim();\n if (!organization) throw new Error('Enter the GitHub organization login');\n return { owner: 'organization', organization };\n }\n\n function formatDiffValue(value) {\n if (value === null || value === undefined || value === '') return 'Not configured';\n if (Array.isArray(value)) return value.length ? value.map(formatDiffValue).join('\n') : '[]';\n if (typeof value === 'object') {\n return Object.entries(value).map(([key, item]) => key + ': ' + (Array.isArray(item) ? item.join(', ') : formatDiffValue(item))).join('\n');\n }\n return String(value);\n }\n\n function showDiff(id, rows, label = 'Update required') {\n const box = el(id);\n box.hidden = rows.length === 0;\n box.replaceChildren();\n if (rows.length === 0) return;\n const title = document.createElement('strong');\n title.className = 'diff-title';\n title.textContent = label;\n const grid = document.createElement('div');\n grid.className = 'config-diff';\n const head = document.createElement('div');\n head.className = 'diff-row diff-head';\n for (const text of ['Field', 'Current', 'Expected']) {\n const cell = document.createElement('span'); cell.textContent = text; head.append(cell);\n }\n grid.append(head);\n for (const item of rows) {\n const row = document.createElement('div'); row.className = 'diff-row';\n const field = document.createElement('span'); field.className = 'diff-field'; field.textContent = item.field;\n const current = document.createElement('span'); current.className = 'diff-value diff-current'; current.textContent = formatDiffValue(item.current);\n const expected = document.createElement('span'); expected.className = 'diff-value diff-expected'; expected.textContent = formatDiffValue(item.expected);\n row.append(field, current, expected); grid.append(row);\n }\n box.append(title, grid);\n }\n\n function renderStartupEnvironment() {\n const services = bootstrapInfo.services;\n const environment = [\n ['AGENTCONNECT_PUBLIC_CP_URL', services.controlPlane],\n ['AGENTCONNECT_PUBLIC_RELAY_URL', services.relay],\n ['AGENTCONNECT_PUBLIC_WEB_URL', services.web],\n ['LOGTO_ENDPOINT', bootstrapInfo.logtoEndpoint],\n ['LOGTO_MGMT_ENDPOINT', bootstrapInfo.logtoManagementEndpoint]\n ];\n el('startup-environment').textContent = environment\n .filter(([, value]) => value)\n .map(([key, value]) => key + '=' + value)\n .join('\n');\n }\n\n function renderApps(status) {\n currentStatus = status;\n const byKey = new Map((status.secrets || []).map((item) => [item.key, item]));\n const values = status.values;\n const expected = status.providerExpectations || { github: null, gitlab: null, slack: null, linear: null, googleChat: null, google: { origins: [], redirects: [] } };\n renderStartupEnvironment();\n\n const logto = values.logto;\n text('logto-management-endpoint', logto && bootstrapInfo.logtoManagementEndpoint);\n text('logto-management-id', logto && logto.managementAppId);\n text('logto-management-resource', logto && logto.managementResource);\n secretText('logto-management-secret-display', byKey, 'logto.managementAppSecret', Boolean(logto));\n text('logto-browser-endpoint', logto && logto.browser && bootstrapInfo.logtoEndpoint);\n text('logto-browser-id', values.auth.mode === 'oidc' ? values.auth.browserClient.appId : null);\n text('logto-browser-resource', logto && logto.browser && logto.browser.apiResource);\n el('logto-edit-controls').hidden = true;\n showIdentityEditors('logto', Boolean(logto && logto.browser && values.auth.mode === 'oidc'));\n match(\n 'logto-match',\n logto && configured(byKey, 'logto.managementAppSecret') ? '' : 'warn',\n logto && configured(byKey, 'logto.managementAppSecret') ? 'Ready to check' : 'Not configured'\n );\n\n el('preset-agents-enabled').checked = values.features.presetAgentsEnabled;\n el('max-orgs-per-non-admin-user').value = String(values.features.maxOrgsPerNonAdminUser);\n\n const github = values.github;\n const webhookStored = byKey.get('github.webhookSecret') && byKey.get('github.webhookSecret').configured;\n const webhookInactive = github && github.webhookEnabled === false;\n text('github-app-id', github && github.appId);\n text('github-app-slug', github && github.slug);\n text('github-client-id', github && github.clientId);\n text('github-webhook-enabled', github && (github.webhookEnabled === false ? 'Disabled' : 'Enabled'));\n el('github-edit-controls').hidden = true;\n showIdentityEditors('github', Boolean(github));\n secretText('github-client-secret-display', byKey, 'github.clientSecret', Boolean(github));\n secretText('github-private-key-display', byKey, 'github.privateKeyB64', Boolean(github));\n secretText('github-webhook-secret-display', byKey, 'github.webhookSecret', Boolean(github));\n secretText('github-logto-secret-display', byKey, 'logto.githubConnectorClientSecret', Boolean(values.logto && values.logto.githubConnector));\n el('github-status').textContent = github\n ? github.slug + ' is configured. Webhook secret: ' + (webhookStored ? 'stored' : webhookInactive ? 'not required yet' : 'missing') + '.' +\n (webhookInactive ? ' Relay webhook delivery is disabled.' : '')\n : 'Creates the complete App used for repository installation, webhooks, and optional GitHub sign-in.';\n const githubDrift = github\n ? expected.github\n ? []\n : [{ field: 'Startup public URLs', current: 'Unavailable', expected: 'Valid Web, API, and ingress URLs' }]\n : [];\n match('github-match', !github ? '' : githubDrift.length ? 'warn' : '', !github ? 'Not configured' : githubDrift.length ? 'Expected URLs changed' : 'Ready to check');\n el('github-create-controls').hidden = Boolean(github);\n el('create-github').hidden = Boolean(github);\n el('clear-github').hidden = !github;\n el('connect-github-login').hidden = !github || !values.logto || Boolean(values.logto.githubConnector);\n el('check-github').hidden = !github;\n if (github) showDiff('github-drift', githubDrift, 'Expected URLs changed since App creation');\n else el('github-drift').hidden = true;\n\n const gitlab = values.gitlab;\n const gitlabSecret = configured(byKey, 'gitlab.clientSecret');\n const expectedGitlab = expected.gitlab;\n renderUriList('gitlab-callbacks', expectedGitlab ? [expectedGitlab.callbackUrl] : []);\n renderUriList('gitlab-scopes', expectedGitlab ? expectedGitlab.scopes : []);\n text('gitlab-client-id', gitlab && gitlab.clientId);\n secretText('gitlab-client-secret-display', byKey, 'gitlab.clientSecret', Boolean(gitlab));\n showIdentityEditors('gitlab', Boolean(gitlab));\n el('gitlab-id').value = gitlab ? gitlab.clientId : '';\n text('gitlab-instance', (gitlab && gitlab.baseUrl) || 'https://gitlab.com');\n // Host-aware application links (§24.1): composed by the server against the\n // configured base, so a path-prefixed install keeps its prefix here too.\n if (expectedGitlab) {\n el('gitlab-applications').href = expectedGitlab.applicationsUrl;\n el('gitlab-admin-applications').href = expectedGitlab.adminApplicationsUrl;\n }\n el('gitlab-base-url').value = (gitlab && gitlab.baseUrl) || '';\n // A probe verdict belongs to the save that produced it, never to a reload.\n el('gitlab-probe').hidden = true;\n el('gitlab-status').textContent = gitlab\n ? gitlab.clientId + ' is configured.'\n : expectedGitlab\n ? 'Register the OAuth application on the configured GitLab instance, then save its Application ID and Secret here.'\n : 'Publishing the redirect URI needs an HTTPS API public URL.';\n showDiff('gitlab-drift', gitlab && !expectedGitlab\n ? [{ field: 'Startup public URLs', current: 'Unavailable', expected: 'An HTTPS API URL' }]\n : []);\n match('gitlab-match', gitlab && gitlabSecret ? 'warn' : '', !gitlab ? 'Not configured' : !gitlabSecret ? 'Missing secret' : \"Can't verify automatically\");\n el('gitlab-initial-secret-field').hidden = Boolean(gitlab) && gitlabSecret;\n el('gitlab-config-controls').hidden = Boolean(gitlab);\n el('save-gitlab').hidden = Boolean(gitlab);\n el('cancel-gitlab-configuration').hidden = true;\n el('clear-gitlab').hidden = !gitlab;\n const gitlabLogin = values.logto?.browser?.socialProviders.includes('gitlab');\n renderUriList('gitlab-login-redirects', expected.gitlabSignIn?.redirects || []);\n el('gitlab-login-status').textContent = gitlabLogin ? 'GitLab sign-in is configured. Use Check Logto to verify it.' : 'GitLab sign-in is not enabled.';\n el('connect-gitlab-login').hidden = Boolean(gitlabLogin);\n el('connect-gitlab-login').disabled = !gitlab || !gitlabSecret || !values.logto?.browser;\n el('disconnect-gitlab-login').hidden = !gitlabLogin;\n el('check-gitlab-login').hidden = !gitlabLogin;\n\n const gitea = values.gitea;\n text('gitea-instance', (gitea && gitea.baseUrl) || 'https://gitea.com');\n el('gitea-base-url').value = (gitea && gitea.baseUrl) || '';\n showIdentityEditors('gitea', Boolean(gitea));\n // A probe verdict belongs to the save that produced it, never to a reload.\n el('gitea-probe').hidden = true;\n el('gitea-status').textContent = gitea\n ? ((gitea.baseUrl || 'https://gitea.com') + ' is configured.')\n : 'Organizations can connect gitea.com without saving anything. Save an address only for a self-hosted instance.';\n match('gitea-match', gitea ? 'warn' : '', gitea ? \"Can't verify automatically\" : 'Default');\n el('gitea-config-controls').hidden = Boolean(gitea);\n el('save-gitea').hidden = Boolean(gitea);\n el('cancel-gitea-configuration').hidden = true;\n el('clear-gitea').hidden = !gitea;\n\n const slack = values.slack;\n text('slack-app-id', slack && slack.appId);\n text('slack-client-id', slack && slack.clientId);\n el('slack-edit-controls').hidden = true;\n showIdentityEditors('slack', Boolean(slack));\n secretText('slack-client-secret-display', byKey, 'slack.clientSecret', Boolean(slack));\n secretText('slack-signing-secret-display', byKey, 'slack.signingSecret', Boolean(slack));\n el('slack-logto-status').textContent = values.logto && values.logto.slackConnector\n ? 'Enabled; reuses the Slack client secret above.'\n : 'Not enabled.';\n el('slack-status').textContent = slack ? slack.appId + ' is configured.' : 'Creates the default AgentConnect integration manifest.';\n const slackDrift = slack\n ? expected.slack\n ? []\n : [{ field: 'Startup public URLs', current: 'Unavailable', expected: 'HTTPS Web, API, and ingress URLs' }]\n : [];\n match('slack-match', !slack ? '' : slackDrift.length ? 'warn' : '', !slack ? 'Not configured' : slackDrift.length ? 'Update required' : 'Ready to check');\n el('slack-name-field').hidden = Boolean(slack);\n el('create-slack').hidden = Boolean(slack);\n el('connect-slack-login').hidden = !slack || !values.logto || Boolean(values.logto.slackConnector) || !bootstrapInfo?.slackAvailable;\n el('clear-slack').hidden = !slack;\n el('check-slack').hidden = !slack;\n el('reconcile-slack').hidden = !slack;\n el('slack-settings').hidden = !slack;\n if (slack) {\n el('slack-settings').href = 'https://api.slack.com/apps/' + encodeURIComponent(slack.appId);\n showDiff('slack-drift', slackDrift);\n } else el('slack-drift').hidden = true;\n\n const googleChat = values.googleChat;\n const googleChatKey = configured(byKey, 'googleChat.serviceAccountKey');\n const expectedGoogleChat = expected.googleChat;\n renderUriList('googlechat-callbacks', expectedGoogleChat ? [expectedGoogleChat.callbackUrl] : []);\n text('googlechat-project-id', googleChat && googleChat.projectId);\n text('googlechat-project-number', googleChat && googleChat.projectNumber);\n el('googlechat-key-display').textContent = googleChatKey ? '***' : 'Not configured';\n el('googlechat-key-display').className = googleChatKey ? 'redacted' : 'muted';\n showIdentityEditors('googlechat', Boolean(googleChat));\n el('googlechat-project-id-input').value = googleChat ? googleChat.projectId : '';\n el('googlechat-project-number-input').value = googleChat ? googleChat.projectNumber : '';\n el('googlechat-key').value = '';\n if (expectedGoogleChat) el('googlechat-configuration').href = expectedGoogleChat.configurationUrl;\n el('googlechat-status').textContent = googleChat\n ? googleChat.projectId + ' is configured.'\n : expectedGoogleChat\n ? 'Configure the Chat app in Google Cloud Console, then save its project and service-account key here.'\n : 'Publishing the HTTP endpoint URL needs an HTTPS ingress public URL.';\n showDiff('googlechat-drift', googleChat && !expectedGoogleChat\n ? [{ field: 'Startup public URLs', current: 'Unavailable', expected: 'An HTTPS ingress URL' }]\n : []);\n match('googlechat-match', googleChat && googleChatKey ? 'pass' : googleChat ? 'warn' : '', !googleChat ? 'Not configured' : !googleChatKey ? 'Missing key' : 'Checked on save');\n el('googlechat-edit-heading').hidden = true;\n el('googlechat-config-controls').hidden = Boolean(googleChat);\n el('save-googlechat').hidden = Boolean(googleChat);\n el('cancel-googlechat-configuration').hidden = true;\n el('clear-googlechat').hidden = !googleChat;\n\n\n const linear = values.linear;\n const linearSecrets = configured(byKey, 'linear.clientSecret') && configured(byKey, 'linear.signingSecret');\n const expectedLinear = expected.linear;\n renderUriList('linear-callbacks', expectedLinear ? [expectedLinear.callbackUrl] : []);\n renderUriList('linear-webhooks', expectedLinear ? [expectedLinear.webhookUrl] : []);\n text('linear-client-id', linear && linear.clientId);\n secretText('linear-client-secret-display', byKey, 'linear.clientSecret', Boolean(linear));\n secretText('linear-signing-secret-display', byKey, 'linear.signingSecret', Boolean(linear));\n showIdentityEditors('linear', Boolean(linear));\n el('linear-id').value = linear ? linear.clientId : '';\n if (expectedLinear) el('linear-applications').href = expectedLinear.applicationsUrl;\n el('linear-status').textContent = linear\n ? linear.clientId + ' is configured.'\n : expectedLinear\n ? 'Register the OAuth application in Linear, then save its Client ID and both secrets here.'\n : 'Publishing the callback and webhook URLs needs HTTPS API and ingress public URLs.';\n showDiff('linear-drift', linear && !expectedLinear\n ? [{ field: 'Startup public URLs', current: 'Unavailable', expected: 'HTTPS API and ingress URLs' }]\n : []);\n match('linear-match', linear && linearSecrets ? 'warn' : '', !linear ? 'Not configured' : !linearSecrets ? 'Missing secret' : \"Can't verify automatically\");\n el('linear-initial-secret-fields').hidden = Boolean(linear) && linearSecrets;\n el('linear-config-controls').hidden = Boolean(linear);\n el('save-linear').hidden = Boolean(linear);\n el('cancel-linear-configuration').hidden = true;\n el('clear-linear').hidden = !linear;\n\n const google = values.logto && values.logto.googleConnector;\n const googleSecret = configured(byKey, 'logto.googleConnectorClientSecret');\n const expectedGoogle = expected.google;\n renderUriList('google-origins', expectedGoogle.origins);\n renderUriList('google-redirects', expectedGoogle.redirects);\n text('google-client-id', google && google.clientId);\n secretText('google-client-secret-display', byKey, 'logto.googleConnectorClientSecret', Boolean(google));\n showIdentityEditors('google', Boolean(google));\n el('google-id').value = google ? google.clientId : '';\n el('google-status').textContent = google ? 'Google OAuth client is configured.' : 'Create a Web application OAuth client manually, then save it here.';\n showDiff('google-drift', []);\n match('google-match', google && googleSecret ? 'warn' : '', !google ? 'Not configured' : !googleSecret ? 'Missing secret' : \"Can't verify automatically\");\n el('google-initial-secret-field').hidden = googleSecret;\n el('save-google').textContent = google ? 'Confirm callback settings' : 'Save Google client';\n el('google-config-controls').hidden = Boolean(google);\n el('save-google').hidden = Boolean(google);\n el('cancel-google-configuration').hidden = true;\n el('check-google').hidden = !google || !googleSecret;\n el('clear-google').hidden = !google;\n\n for (const [provider, app] of Object.entries(loginApps)) {\n const saved = values.logto && values.logto[provider + 'Connector'];\n const secretKey = 'logto.' + provider + 'ConnectorClientSecret';\n const hasSecret = configured(byKey, secretKey);\n renderUriList(provider + '-redirects', expected[provider].redirects);\n text(provider + '-client-id', saved && saved.clientId);\n secretText(provider + '-client-secret-display', byKey, secretKey, Boolean(saved));\n showIdentityEditors(provider, Boolean(saved));\n el(provider + '-id').value = saved ? saved.clientId : '';\n el(provider + '-status').hidden = !saved;\n el(provider + '-status').textContent = saved ? app.name + ' login application is saved.' : '';\n showDiff(provider + '-drift', []);\n match(provider + '-match', saved ? 'warn' : '', !saved ? 'Not configured' : !hasSecret ? 'Missing secret' : 'Not checked');\n el(provider + '-initial-secret-field').hidden = hasSecret;\n el(provider + '-config-controls').hidden = Boolean(saved);\n el('save-' + provider).hidden = Boolean(saved);\n el('save-' + provider).disabled = !values.logto || !values.logto.browser;\n el('cancel-' + provider + '-configuration').hidden = true;\n el('check-' + provider).hidden = !saved || !hasSecret;\n el('clear-' + provider).hidden = !saved;\n }\n const expectedTeams = expected.msTeams;\n text('azuread-teams-tenant-id', values.msTeams && values.msTeams.tenantId);\n el('azuread-teams-tenant-input').value = values.msTeams ? values.msTeams.tenantId : '';\n renderUriList('azuread-teams-endpoint', expectedTeams ? [expectedTeams.messagingEndpoint] : []);\n renderUriList('azuread-teams-redirects', expectedTeams ? [expectedTeams.connectRedirectUri] : []);\n\n const feishuSecret = byKey.get('feishu.loginAppSecret');\n const larkSecret = byKey.get('lark.loginAppSecret');\n el('feishu-login-id').value = values.feishu ? values.feishu.loginAppId : '';\n el('lark-login-id').value = values.lark ? values.lark.loginAppId : '';\n text('feishu-app-id', values.feishu && values.feishu.loginAppId);\n text('lark-app-id', values.lark && values.lark.loginAppId);\n showIdentityEditors('feishu', Boolean(values.feishu));\n showIdentityEditors('lark', Boolean(values.lark));\n secretText('feishu-app-secret-display', byKey, 'feishu.loginAppSecret', Boolean(values.feishu));\n secretText('lark-app-secret-display', byKey, 'lark.loginAppSecret', Boolean(values.lark));\n el('feishu-login-status').textContent = values.feishu && feishuSecret && feishuSecret.configured\n ? values.feishu.loginAppId + ' is configured.'\n : 'No Feishu tenant App is configured.';\n el('lark-login-status').textContent = values.lark && larkSecret && larkSecret.configured\n ? values.lark.loginAppId + ' is configured.'\n : 'No Lark tenant App is configured.';\n match('feishu-match', values.feishu && configured(byKey, 'feishu.loginAppSecret') ? '' : '', values.feishu && configured(byKey, 'feishu.loginAppSecret') ? 'Ready to check' : 'Not configured');\n match('lark-match', values.lark && configured(byKey, 'lark.loginAppSecret') ? '' : '', values.lark && configured(byKey, 'lark.loginAppSecret') ? 'Ready to check' : 'Not configured');\n showDiff('feishu-drift', []);\n showDiff('lark-drift', []);\n el('feishu-config-controls').hidden = Boolean(values.feishu);\n el('lark-config-controls').hidden = Boolean(values.lark);\n el('feishu-create-name-field').hidden = Boolean(values.feishu);\n el('lark-create-name-field').hidden = Boolean(values.lark);\n el('create-feishu-login-app').hidden = Boolean(values.feishu);\n el('create-lark-login-app').hidden = Boolean(values.lark);\n el('save-feishu-login-app').hidden = Boolean(values.feishu);\n el('save-lark-login-app').hidden = Boolean(values.lark);\n el('cancel-feishu-configuration').hidden = true;\n el('cancel-lark-configuration').hidden = true;\n el('clear-feishu').hidden = !values.feishu;\n el('clear-lark').hidden = !values.lark;\n el('check-feishu-login-app').hidden = !values.feishu || !configured(byKey, 'feishu.loginAppSecret');\n el('check-lark-login-app').hidden = !values.lark || !configured(byKey, 'lark.loginAppSecret');\n el('feishu-settings').hidden = !values.feishu;\n el('lark-settings').hidden = !values.lark;\n if (values.feishu) el('feishu-settings').href = 'https://open.feishu.cn/app/' + encodeURIComponent(values.feishu.loginAppId);\n if (values.lark) el('lark-settings').href = 'https://open.larksuite.com/app/' + encodeURIComponent(values.lark.loginAppId);\n }\n\n function requiredInput(id, label) {\n const value = el(id).value.trim();\n if (!value) throw new Error('Enter ' + label);\n return value;\n }\n\n function githubConnectorUsesDeployment(values) {\n const github = values.github;\n const connector = values.logto && values.logto.githubConnector;\n return Boolean(\n github && connector &&\n connector.appId === github.appId &&\n connector.slug === github.slug &&\n connector.clientId === github.clientId\n );\n }\n\n function beginConfigurationEdit(provider) {\n if (!currentStatus) return message('Deployment configuration is not loaded', true);\n const values = currentStatus.values;\n if (provider === 'logto') {\n const logto = values.logto;\n if (!logto || !logto.browser || values.auth.mode !== 'oidc') return;\n el('logto-edit-management-id').value = logto.managementAppId;\n el('logto-edit-management-resource').value = logto.managementResource;\n el('logto-edit-management-secret').value = '';\n el('logto-edit-browser-id').value = values.auth.browserClient.appId;\n el('logto-edit-browser-resource').value = logto.browser.apiResource || '';\n el('logto-edit-controls').hidden = false;\n el('logto-edit-management-id').focus();\n } else if (provider === 'github') {\n const github = values.github;\n if (!github) return;\n el('github-edit-app-id').value = String(github.appId);\n el('github-edit-slug').value = github.slug;\n el('github-edit-client-id').value = github.clientId || '';\n el('github-edit-webhook-enabled').checked = github.webhookEnabled !== false;\n for (const id of ['github-edit-client-secret', 'github-edit-private-key', 'github-edit-webhook-secret', 'github-edit-logto-secret']) el(id).value = '';\n el('github-edit-logto-secret-field').hidden = !githubConnectorUsesDeployment(values);\n el('github-edit-controls').hidden = false;\n el('clear-github').hidden = true;\n el('github-edit-app-id').focus();\n } else if (provider === 'slack') {\n const slack = values.slack;\n if (!slack) return;\n el('slack-edit-app-id').value = slack.appId;\n el('slack-edit-client-id').value = slack.clientId;\n el('slack-edit-client-secret').value = '';\n el('slack-edit-signing-secret').value = '';\n el('slack-edit-controls').hidden = false;\n el('clear-slack').hidden = true;\n el('slack-edit-app-id').focus();\n } else if (provider === 'gitea') {\n if (!values.gitea) return;\n el('gitea-base-url').value = values.gitea.baseUrl || '';\n el('gitea-config-controls').hidden = false;\n el('save-gitea').hidden = false;\n el('cancel-gitea-configuration').hidden = false;\n el('clear-gitea').hidden = true;\n el('gitea-base-url').focus();\n } else if (provider === 'gitlab') {\n if (!values.gitlab) return;\n el('gitlab-id').value = values.gitlab.clientId;\n el('gitlab-secret').value = '';\n el('gitlab-config-controls').hidden = false;\n el('gitlab-initial-secret-field').hidden = false;\n el('save-gitlab').hidden = false;\n el('cancel-gitlab-configuration').hidden = false;\n el('clear-gitlab').hidden = true;\n el('gitlab-id').focus();\n } else if (provider === 'googlechat') {\n if (!values.googleChat) return;\n el('googlechat-project-id-input').value = values.googleChat.projectId;\n el('googlechat-project-number-input').value = values.googleChat.projectNumber;\n el('googlechat-key').value = '';\n el('googlechat-edit-heading').hidden = false;\n el('googlechat-config-controls').hidden = false;\n el('save-googlechat').hidden = false;\n el('cancel-googlechat-configuration').hidden = false;\n el('clear-googlechat').hidden = true;\n el('googlechat-project-id-input').focus();\n } else if (provider === 'linear') {\n if (!values.linear) return;\n el('linear-id').value = values.linear.clientId;\n el('linear-client-secret').value = '';\n el('linear-signing-secret').value = '';\n el('linear-config-controls').hidden = false;\n el('linear-initial-secret-fields').hidden = false;\n el('save-linear').hidden = false;\n el('cancel-linear-configuration').hidden = false;\n el('clear-linear').hidden = true;\n el('linear-id').focus();\n } else if (provider === 'google') {\n const google = values.logto && values.logto.googleConnector;\n if (!google) return;\n el('google-id').value = google.clientId;\n el('google-secret').value = '';\n el('google-config-controls').hidden = false;\n el('google-initial-secret-field').hidden = false;\n el('save-google').hidden = false;\n el('save-google').textContent = 'Save Google client';\n el('cancel-google-configuration').hidden = false;\n el('clear-google').hidden = true;\n el('google-id').focus();\n } else if (Object.hasOwn(loginApps, provider)) {\n el(provider + '-secret').value = '';\n el(provider + '-config-controls').hidden = false;\n el(provider + '-initial-secret-field').hidden = false;\n el('save-' + provider).hidden = false;\n el('cancel-' + provider + '-configuration').hidden = false;\n el('clear-' + provider).hidden = true;\n el(provider + '-id').focus();\n } else if (provider === 'feishu' || provider === 'lark') {\n el(provider + '-config-controls').hidden = false;\n el(provider + '-create-name-field').hidden = true;\n el('create-' + provider + '-login-app').hidden = true;\n el('save-' + provider + '-login-app').hidden = false;\n el('cancel-' + provider + '-configuration').hidden = false;\n el('clear-' + provider).hidden = true;\n el(provider + '-login-secret').value = '';\n el(provider + '-login-id').focus();\n }\n showIdentityEditors(provider, false);\n }\n\n function cancelConfigurationEdit() {\n if (currentStatus) renderApps(currentStatus);\n }\n\n async function replaceConfiguration(values, secrets, successMessage) {\n const response = await json(await fetch(api + '/deployment-config', {\n method: 'PUT',\n headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ expectedRevision: currentRevision, values, ...(secrets ? { secrets } : {}) })\n }));\n await load();\n message(successMessage || 'Configuration saved. Restart AgentConnect to apply it.');\n return response;\n }\n\n async function saveLogtoConfiguration() {\n if (!currentStatus) throw new Error('Deployment configuration is not loaded');\n const values = currentStatus.values;\n const logto = values.logto;\n if (!logto || !logto.browser || values.auth.mode !== 'oidc') throw new Error('Logto is not configured');\n const managementAppId = requiredInput('logto-edit-management-id', 'the Management App ID');\n const managementResource = requiredInput('logto-edit-management-resource', 'the Management API resource');\n const browserAppId = requiredInput('logto-edit-browser-id', 'the SPA App ID');\n const browserResource = el('logto-edit-browser-resource').value.trim() || null;\n const managementIdentityChanged = managementAppId !== logto.managementAppId;\n const managementSecret = el('logto-edit-management-secret').value;\n if (managementIdentityChanged && !managementSecret) throw new Error('Enter the new Management App secret');\n const browser = { ...logto.browser, apiResource: browserResource };\n const auth = {\n ...values.auth,\n audience: browserResource || browserAppId,\n browserClient: { appId: browserAppId, apiResource: browserResource }\n };\n await replaceConfiguration(\n {\n ...values,\n auth,\n logto: { ...logto, managementAppId, managementResource, browser }\n },\n managementSecret ? { 'logto.managementAppSecret': managementSecret } : undefined,\n 'Logto configuration saved. Sign in again if the SPA identity changed, then restart AgentConnect.'\n );\n }\n\n async function saveGithubConfiguration() {\n if (!currentStatus || !currentStatus.values.github) throw new Error('GitHub is not configured');\n const values = currentStatus.values;\n const previous = values.github;\n const appId = Number(requiredInput('github-edit-app-id', 'the GitHub App ID'));\n if (!Number.isSafeInteger(appId) || appId <= 0) throw new Error('GitHub App ID must be a positive integer');\n const slug = requiredInput('github-edit-slug', 'the GitHub App slug');\n const clientId = requiredInput('github-edit-client-id', 'the GitHub Client ID');\n const appChanged = appId !== previous.appId;\n const clientChanged = clientId !== previous.clientId;\n const webhookEnabled = el('github-edit-webhook-enabled').checked;\n const webhookEnabling = webhookEnabled && previous.webhookEnabled === false;\n const connectorReused = githubConnectorUsesDeployment(values);\n const clientSecret = el('github-edit-client-secret').value;\n const privateKey = el('github-edit-private-key').value.trim();\n const webhookSecret = el('github-edit-webhook-secret').value;\n const connectorSecret = el('github-edit-logto-secret').value;\n const webhookSecretStored = currentStatus.secrets.some((secret) => secret.key === 'github.webhookSecret' && secret.configured);\n if ((appChanged || clientChanged) && !clientSecret) throw new Error('Enter the new GitHub client secret');\n if (appChanged && !privateKey) throw new Error('Enter the new GitHub private key as base64');\n if (webhookEnabled && ((appChanged && !webhookSecret) || (webhookEnabling && !webhookSecret && !webhookSecretStored))) throw new Error('Enter the new GitHub webhook secret');\n if (connectorReused && (appChanged || clientChanged) && !connectorSecret) throw new Error('Enter the new Logto connector client secret');\n const secrets = {};\n if (clientSecret) secrets['github.clientSecret'] = clientSecret;\n if (privateKey) secrets['github.privateKeyB64'] = privateKey;\n if (webhookSecret) secrets['github.webhookSecret'] = webhookSecret;\n if (connectorSecret) secrets['logto.githubConnectorClientSecret'] = connectorSecret;\n const nextLogto = connectorReused && values.logto\n ? { ...values.logto, githubConnector: { ...values.logto.githubConnector, appId, slug, clientId } }\n : values.logto;\n await replaceConfiguration(\n {\n ...values,\n github: { ...previous, appId, slug, clientId, webhookEnabled },\n ...(nextLogto ? { logto: nextLogto } : {})\n },\n Object.keys(secrets).length ? secrets : undefined,\n 'GitHub App identity saved. Restart AgentConnect to apply it.'\n );\n if (connectorSecret) {\n await reconcileLogto(true);\n await load();\n }\n }\n\n async function saveSlackConfiguration() {\n if (!currentStatus || !currentStatus.values.slack) throw new Error('Slack is not configured');\n const values = currentStatus.values;\n const previous = values.slack;\n const appId = requiredInput('slack-edit-app-id', 'the Slack App ID');\n const clientId = requiredInput('slack-edit-client-id', 'the Slack Client ID');\n const changed = appId !== previous.appId || clientId !== previous.clientId;\n const clientSecret = el('slack-edit-client-secret').value;\n const signingSecret = el('slack-edit-signing-secret').value;\n if (changed && (!clientSecret || !signingSecret)) throw new Error('Enter both the new Slack client secret and signing secret');\n const secrets = {};\n if (clientSecret) secrets['slack.clientSecret'] = clientSecret;\n if (signingSecret) secrets['slack.signingSecret'] = signingSecret;\n const nextLogto = values.logto && values.logto.slackConnector\n ? { ...values.logto, slackConnector: { ...values.logto.slackConnector, appId, clientId } }\n : values.logto;\n await replaceConfiguration(\n {\n ...values,\n slack: { ...previous, appId, clientId },\n ...(nextLogto ? { logto: nextLogto } : {})\n },\n Object.keys(secrets).length ? secrets : undefined,\n 'Slack App identity saved. Restart AgentConnect to apply it.'\n );\n if (clientSecret && nextLogto && nextLogto.slackConnector) {\n await reconcileLogto(true);\n await load();\n }\n }\n\n async function clearProvider(provider) {\n if (!currentStatus) throw new Error('Deployment configuration is not loaded');\n const label = provider === 'github' ? 'GitHub' : provider === 'gitlab' ? 'GitLab' : provider === 'gitea' ? 'Gitea' : provider === 'slack' ? 'Slack' : provider === 'googlechat' ? 'Google Chat' : provider === 'linear' ? 'Linear' : provider === 'google' ? 'Google' : provider === 'feishu' ? 'Feishu' : 'Lark';\n if (!window.confirm('Clear the saved ' + label + ' configuration and secrets?')) return;\n const values = currentStatus.values;\n let next = values;\n let secrets = {};\n if (provider === 'github') {\n const connectorReused = githubConnectorUsesDeployment(values);\n const logto = connectorReused && values.logto\n ? {\n ...values.logto,\n githubConnector: null\n }\n : values.logto;\n next = { ...values, github: null, ...(logto ? { logto } : {}) };\n secrets = {\n 'github.clientSecret': null,\n 'github.privateKeyB64': null,\n 'github.webhookSecret': null,\n ...(connectorReused ? { 'logto.githubConnectorClientSecret': null } : {})\n };\n } else if (provider === 'gitea') {\n // Gitea holds no deployment secret, so clearing the address clears the whole entry.\n next = { ...values, gitea: null };\n } else if (provider === 'gitlab') {\n await json(await fetch(api + '/configure/gitlab', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ application: null })\n }));\n await load();\n message('GitLab configuration cleared. Its setup controls are available again.');\n return;\n } else if (provider === 'slack') {\n const logto = values.logto\n ? {\n ...values.logto,\n browser: values.logto.browser\n ? { ...values.logto.browser, socialProviders: values.logto.browser.socialProviders.filter((item) => item !== 'slack') }\n : values.logto.browser,\n slackConnector: null\n }\n : values.logto;\n next = { ...values, slack: null, ...(logto ? { logto } : {}) };\n secrets = { 'slack.clientSecret': null, 'slack.signingSecret': null };\n } else if (provider === 'googlechat') {\n next = { ...values, googleChat: null };\n secrets = { 'googleChat.serviceAccountKey': null };\n } else if (provider === 'linear') {\n next = { ...values, linear: null };\n secrets = { 'linear.clientSecret': null, 'linear.signingSecret': null };\n } else if (provider === 'google') {\n if (!values.logto) return;\n next = {\n ...values,\n logto: {\n ...values.logto,\n browser: values.logto.browser\n ? { ...values.logto.browser, socialProviders: values.logto.browser.socialProviders.filter((item) => item !== 'google') }\n : values.logto.browser,\n googleConnector: null\n }\n };\n secrets = { 'logto.googleConnectorClientSecret': null };\n } else if (provider === 'feishu' || provider === 'lark') {\n next = { ...values, [provider]: null };\n secrets = { [provider + '.loginAppSecret']: null };\n }\n await replaceConfiguration(next, secrets, label + ' configuration cleared. Its setup controls are available again.');\n }\n\n async function startGithub(prefix) {\n const result = await json(await fetch(api + '/create/github/start', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({\n purpose: 'deployment',\n name: el(prefix + '-name').value,\n ownership: ownership(prefix),\n connectLogto: prefix === 'bootstrap-github'\n })\n }));\n const form = document.createElement('form');\n form.method = 'post'; form.action = result.action;\n const manifest = document.createElement('input');\n manifest.type = 'hidden'; manifest.name = 'manifest'; manifest.value = JSON.stringify(result.manifest);\n form.append(manifest); document.body.append(form);\n message('Opening GitHub to review the complete integration App.');\n form.submit();\n }\n\n async function loadBootstrapInfo() {\n bootstrapInfo = await json(await fetch(api + '/bootstrap-info'));\n el('open-logto').href = bootstrapInfo.logtoAdminEndpoint;\n el('logto-settings').href = bootstrapInfo.logtoAdminEndpoint;\n if (!el('logto-app-id').value && bootstrapInfo.logtoManagementAppId) {\n el('logto-app-id').value = bootstrapInfo.logtoManagementAppId;\n }\n renderUriList('bootstrap-google-origins', bootstrapInfo.google.javascriptOrigins);\n renderUriList('bootstrap-google-redirects', bootstrapInfo.google.redirectUris);\n renderUriList('bootstrap-slack-redirects', [bootstrapInfo.slackLoginRedirectUrl]);\n el('logto-management-api-resource').value = bootstrapInfo.logtoManagementResource;\n el('bootstrap-github-submit').disabled = !bootstrapInfo.githubAvailable;\n if (!bootstrapInfo.githubAvailable) {\n el('bootstrap-github-note').textContent = 'GitHub App creation needs valid saved Web, API, and ingress URLs.';\n } else if (!bootstrapInfo.githubWebhookActive) {\n el('bootstrap-github-note').textContent = 'Creates the complete GitHub App now without submitting the localhost webhook URL. Add it after saving reachable HTTPS ingress.';\n } else {\n el('bootstrap-github-note').textContent = 'This creates one complete App for both GitHub sign-in and repository integration.';\n }\n el('bootstrap-slack-option').disabled = !bootstrapInfo.slackAvailable;\n el('bootstrap-slack-submit').disabled = !bootstrapInfo.slackAvailable;\n el('bootstrap-slack-note').textContent = bootstrapInfo.slackAvailable\n ? 'Creates one complete Slack App. Sign-in uses a separate openid profile email flow from workspace installation.'\n : 'Slack sign-in needs all public service URLs to use HTTPS. Use Google locally or expose the stack through trusted HTTPS endpoints.';\n if (!bootstrapInfo.slackAvailable && el('bootstrap-provider').value === 'slack') {\n el('bootstrap-provider').value = 'google';\n updateBootstrapProvider();\n }\n return bootstrapInfo;\n }\n\n async function logtoBootstrapFinding() {\n const report = await json(await fetch(api + '/check/logto', { headers: bearer() }));\n for (const id of ['logto.client_credentials', 'logto.roles_read']) {\n const finding = report.findings.find((candidate) => candidate.id === id);\n if (!finding || finding.status !== 'pass') {\n return finding || { status: 'fail', message: 'Logto Management API permissions could not be verified.' };\n }\n }\n return { status: 'pass', message: 'Logto Management API access is ready.' };\n }\n\n async function showBootstrap() {\n el('bootstrap').hidden = false;\n el('show-bootstrap').hidden = true;\n const info = bootstrapInfo || await loadBootstrapInfo();\n if (!info.logtoConfigured) {\n showBootstrapStep('logto');\n return;\n }\n const finding = await logtoBootstrapFinding();\n if (finding && finding.status === 'pass') {\n showBootstrapStep('provider');\n message('Logto Management API access is ready. Choose a sign-in provider.');\n return;\n }\n showBootstrapStep('logto');\n message(finding?.message || 'Logto Management API credentials could not be verified.', true);\n }\n\n async function bootstrapLogto() {\n await json(await fetch(api + '/bootstrap/logto', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({\n managementAppId: el('logto-app-id').value,\n managementAppSecret: el('logto-app-secret').value,\n managementResource: el('logto-management-api-resource').value\n })\n }));\n el('logto-app-secret').value = '';\n await loadBootstrapInfo();\n const finding = await logtoBootstrapFinding();\n if (!finding || finding.status !== 'pass') {\n throw new Error(finding?.message || 'Logto Management API credentials could not be verified.');\n }\n showBootstrapStep('provider');\n message('Logto Management API access is ready. Choose a sign-in provider.');\n }\n\n async function bootstrapGoogle() {\n const secret = el('bootstrap-google-secret').value;\n await json(await fetch(api + '/configure/google', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ clientId: el('bootstrap-google-id').value, clientSecret: secret })\n }));\n el('bootstrap-google-secret').value = '';\n await reconcileLogto(true);\n await load();\n }\n\n async function bootstrapGithub() {\n if (!bootstrapInfo || !bootstrapInfo.githubAvailable) throw new Error('GitHub App creation needs valid saved Web, API, and ingress URLs.');\n await startGithub('bootstrap-github');\n }\n\n async function bootstrapSlack() {\n if (!bootstrapInfo || !bootstrapInfo.slackAvailable) throw new Error('Slack sign-in requires all public service URLs to use HTTPS.');\n await createSlack('bootstrap-slack', true);\n await reconcileLogto();\n await load();\n }\n\n async function createSlack(prefix = 'slack', connectLogto = false) {\n const result = await json(await fetch(api + '/create/slack', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({\n name: el(prefix + '-name').value,\n configToken: el(prefix + '-token').value,\n connectLogto\n })\n }));\n el(prefix + '-token').value = '';\n if (!connectLogto) await load();\n message('Slack App ' + result.app.id + ' was created with the default integration manifest. Restart AgentConnect to apply it.');\n }\n\n async function checkGithub() {\n const result = await json(await fetch(api + '/check/github', { headers: bearer() }));\n el('github-settings').href = result.settingsUrl;\n el('github-settings').hidden = false;\n const label = result.missing.length ? 'Update required' : \"Can't verify automatically\";\n showDiff('github-drift', result.diff || [], label);\n match('github-match', result.status === 'pass' ? 'pass' : 'warn', result.status === 'pass' ? 'Matches' : label);\n message(result.status === 'pass' ? 'GitHub App matches the expected integration manifest.' : result.missing.length ? 'GitHub App settings need an update.' : \"GitHub can't expose callback, setup, or webhook-active settings through its API; check them in GitHub.\", result.status === 'fail');\n }\n\n async function connectGithubLogin() {\n await json(await fetch(api + '/configure/github-login', { method: 'POST', headers: bearer() }));\n await reconcileLogto();\n await load();\n message('The deployment GitHub App is now also used for Logto sign-in. Update its displayed callback URLs.');\n }\n\n async function connectSlackLogin() {\n if (!currentStatus || !currentStatus.values.slack || !currentStatus.values.logto) {\n throw new Error('Save the Slack App and Logto configuration first');\n }\n const values = currentStatus.values;\n const slack = values.slack;\n const logto = values.logto;\n await replaceConfiguration(\n {\n ...values,\n logto: {\n ...logto,\n browser: logto.browser\n ? { ...logto.browser, socialProviders: [...new Set([...logto.browser.socialProviders, 'slack'])] }\n : logto.browser,\n slackConnector: {\n appId: slack.appId,\n clientId: slack.clientId\n }\n }\n },\n undefined,\n 'Slack App is ready to connect to Logto.'\n );\n await reconcileLogto();\n await load();\n message('The deployment Slack App is now also used for Logto sign-in.');\n }\n\n async function checkSlack() {\n const token = requiredInput('slack-token', 'the temporary Slack App configuration token');\n const result = await json(await fetch(api + '/check/slack', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ configToken: token })\n }));\n el('slack-token').value = '';\n showDiff('slack-drift', result.diff || []);\n match('slack-match', result.status === 'pass' ? 'pass' : 'warn', result.status === 'pass' ? 'Matches' : 'Update required');\n message(result.status === 'pass' ? 'Slack App matches the default integration manifest.' : 'Slack App settings need an update.', result.status !== 'pass');\n }\n\n async function reconcileSlack() {\n const token = requiredInput('slack-token', 'the temporary Slack App configuration token');\n const result = await json(await fetch(api + '/reconcile/slack', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ configToken: token })\n }));\n el('slack-token').value = '';\n showDiff('slack-drift', result.diff || []);\n match('slack-match', result.status === 'pass' ? 'pass' : 'warn', result.status === 'pass' ? 'Matches' : 'Update required');\n message(\n result.status !== 'pass'\n ? 'Slack App was updated but some settings still differ.'\n : result.applied.length === 0\n ? 'Slack App already matches the default integration manifest.'\n : result.permissionsUpdated\n ? 'Slack App updated. Reinstall it in each workspace to grant the added scopes.'\n : 'Slack App updated.',\n result.status !== 'pass'\n );\n }\n\n async function checkGoogle() {\n const report = await checkLogto();\n const connector = report.findings.find((finding) => finding.id === 'logto.connectors');\n const google = currentStatus && currentStatus.values.logto && currentStatus.values.logto.googleConnector;\n const connectorDiff = connector && connector.diff\n ? connector.diff.filter((item) => item.field.toLowerCase().startsWith('google'))\n : [];\n const passed = Boolean(google && connector && connectorDiff.length === 0);\n showDiff('google-drift', connectorDiff);\n match('google-match', passed ? 'pass' : 'warn', passed ? 'Logto matches' : 'Update required');\n message(\n passed\n ? \"The Logto Google connector matches. Google OAuth redirect settings can't be verified automatically.\"\n : 'The Logto Google connector needs an update.',\n !passed\n );\n }\n\n async function checkRegionalLoginApp(region) {\n const result = await json(await fetch(api + '/check/regional-login-app/' + region, { headers: bearer() }));\n const label = region === 'feishu' ? 'Feishu' : 'Lark';\n showDiff(region + '-drift', result.diff || []);\n match(region + '-match', result.status === 'pass' ? 'pass' : result.status === 'fail' ? 'fail' : 'warn', result.status === 'pass' ? 'Matches' : result.status === 'fail' ? 'Update required' : 'Could not check');\n el(region + '-login-status').textContent = result.message;\n el(region + '-login-status').className = result.status === 'pass' ? 'ok' : result.status === 'fail' ? 'warn' : 'muted';\n message(result.message || (label + ' credential check completed.'), result.status === 'fail');\n }\n\n async function saveGitlab() {\n const clientSecret = el('gitlab-secret').value;\n const baseUrl = el('gitlab-base-url').value.trim();\n const saved = await json(await fetch(api + '/configure/gitlab', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({\n application: {\n clientId: requiredInput('gitlab-id', 'the GitLab Application ID'),\n ...(clientSecret ? { clientSecret } : {}),\n ...(baseUrl ? { baseUrl } : {})\n }\n })\n }));\n el('gitlab-secret').value = '';\n if (currentStatus.values.logto?.browser?.socialProviders.includes('gitlab')) await reconcileLogto(Boolean(clientSecret));\n await load();\n showGitlabProbe(saved.probe);\n message('GitLab OAuth application saved. Restart AgentConnect to apply it.');\n }\n\n // Only the URL shape blocks the save, so every other verdict is a line to read.\n function showGitlabProbe(probe) {\n const line = el('gitlab-probe');\n line.hidden = !probe;\n line.textContent = probe ? probe.message : '';\n }\n\n async function configureGitlabLogin(enabled) {\n await json(await fetch(api + '/configure/gitlab-login', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ enabled })\n }));\n try { await reconcileLogto(enabled); } finally { await load(); }\n message('GitLab sign-in ' + (enabled ? 'configured' : 'disabled') + '. Restart AgentConnect to apply it.');\n }\n\n async function saveGitea() {\n const baseUrl = el('gitea-base-url').value.trim();\n const saved = await json(await fetch(api + '/configure/gitea', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ instance: { ...(baseUrl ? { baseUrl } : {}) } })\n }));\n await load();\n // Shape and the version floor already blocked the save, so any verdict here is a line to read.\n const line = el('gitea-probe');\n line.hidden = !saved.probe;\n line.textContent = saved.probe ? saved.probe.message : '';\n message('Gitea instance saved. Restart AgentConnect to apply it.');\n }\n\n async function saveGoogleChat() {\n const projectNumber = el('googlechat-project-number-input').value.trim();\n const application = {\n projectId: requiredInput('googlechat-project-id-input', 'the Google Cloud project ID'),\n ...(projectNumber ? { projectNumber } : {})\n };\n const serviceAccountKey = el('googlechat-key').value.trim();\n let saved;\n try {\n saved = await json(await fetch(api + '/configure/google-chat', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ application: { ...application, ...(serviceAccountKey ? { serviceAccountKey } : {}) } })\n }));\n } finally {\n // The key never stays in the page once it has been submitted, whatever the outcome.\n el('googlechat-key').value = '';\n }\n await load();\n message('Google Chat app saved. ' + saved.probe.message + '. Restart AgentConnect to apply it.');\n }\n\n async function saveLinear() {\n const clientSecret = el('linear-client-secret').value;\n const signingSecret = el('linear-signing-secret').value;\n await json(await fetch(api + '/configure/linear', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({\n application: {\n clientId: requiredInput('linear-id', 'the Linear Client ID'),\n ...(clientSecret ? { clientSecret } : {}),\n ...(signingSecret ? { signingSecret } : {})\n }\n })\n }));\n el('linear-client-secret').value = '';\n el('linear-signing-secret').value = '';\n await load();\n message('Linear OAuth application saved. Restart AgentConnect to apply it.');\n }\n\n async function saveGoogle() {\n const secret = el('google-secret').value;\n await json(await fetch(api + '/configure/google', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({\n clientId: el('google-id').value,\n ...(secret ? { clientSecret: secret } : {})\n })\n }));\n el('google-secret').value = '';\n await reconcileLogto(Boolean(secret));\n await load();\n message('Google OAuth client and Logto connector are configured. Restart AgentConnect to apply the saved settings.');\n }\n\n async function saveLoginApp(provider, clear = false) {\n const app = loginApps[provider];\n if (clear && !window.confirm('Clear the saved ' + app.name + ' login application and secret?')) return;\n const clientSecret = el(provider + '-secret').value;\n const application = clear ? null : {\n clientId: requiredInput(provider + '-id', 'the ' + app.name + ' Client ID'),\n ...(clientSecret ? { clientSecret } : {})\n };\n // Microsoft's home tenant also makes the app the deployment's Teams app; empty turns that off.\n const teams = provider === 'azuread' && !clear ? { teamsTenantId: el('azuread-teams-tenant-input').value.trim() || null } : {};\n try {\n await json(await fetch(api + '/configure/login-app/' + provider, {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ application, ...teams })\n }));\n } finally {\n el(provider + '-secret').value = '';\n }\n try {\n await reconcileLogto(Boolean(clientSecret) && !clear);\n } finally {\n await load();\n }\n message(app.name + (clear ? ' sign-in configuration cleared.' : ' login application and Logto connector configured.') + ' Restart AgentConnect to apply the saved settings.');\n }\n\n function renderLoginAppChecks(report) {\n const connectors = report.findings.find((finding) => finding.id === 'logto.connectors');\n if (currentStatus?.values.logto?.browser?.socialProviders.includes('gitlab')) {\n const diff = connectors?.diff?.filter((item) => item.field.toLowerCase().startsWith('gitlab')) || [];\n const passed = Boolean(connectors && diff.length === 0 && (connectors.status === 'pass' || connectors.diff?.length));\n el('gitlab-login-status').textContent = passed\n ? 'GitLab connector matches. Confirm the redirect URLs and OpenID scopes in GitLab.'\n : 'GitLab sign-in could not be verified. Review the Logto check above.';\n }\n for (const [provider, app] of Object.entries(loginApps)) {\n if (!currentStatus?.values.logto?.[provider + 'Connector']) continue;\n const diff = connectors?.diff?.filter((item) => item.field.toLowerCase().startsWith(provider)) || [];\n const passed = Boolean(connectors && diff.length === 0 && (connectors.status === 'pass' || connectors.diff?.length));\n showDiff(provider + '-drift', diff);\n match(provider + '-match', passed ? 'pass' : 'warn', passed ? 'Logto matches' : 'Could not verify');\n el(provider + '-status').textContent = passed\n ? app.name + ' connector matches. Confirm the redirect URLs in the provider settings.'\n : app.name + ' connector could not be verified. Review the Logto check above.';\n }\n }\n\n function regionalLoginApp(prefix) {\n const appId = el(prefix + '-login-id').value.trim();\n const appSecret = el(prefix + '-login-secret').value;\n return appId ? { appId, ...(appSecret ? { appSecret } : {}) } : null;\n }\n\n async function saveRegionalLoginApp(region) {\n const app = regionalLoginApp(region);\n if (!app) throw new Error('Enter the ' + (region === 'feishu' ? 'Feishu' : 'Lark') + ' App ID');\n await json(await fetch(api + '/configure/regional-login-app', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ region, app })\n }));\n el(region + '-login-secret').value = '';\n await load();\n message((region === 'feishu' ? 'Feishu' : 'Lark') + ' tenant App is saved. Restart AgentConnect services to apply it.');\n }\n\n async function createRegionalLoginApp(region) {\n const popup = window.open('about:blank', '_blank');\n try {\n const started = await json(await fetch(api + '/create/regional-login-app/start', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ region, name: el(region + '-create-name').value })\n }));\n if (popup) popup.location.replace(started.authorizationUrl);\n else window.open(started.authorizationUrl, '_blank', 'noopener');\n message('Approve the new ' + (region === 'feishu' ? 'Feishu' : 'Lark') + ' App in the opened page. This page will finish saving it automatically.');\n while (true) {\n const result = await json(await fetch(api + '/create/regional-login-app/' + encodeURIComponent(started.id), { headers: bearer() }));\n if (result.status === 'completed') {\n await load();\n message((region === 'feishu' ? 'Feishu' : 'Lark') + ' App ' + result.appId + ' was created and saved. Restart AgentConnect services to apply it.');\n return;\n }\n if (result.status === 'failed') throw new Error('Regional App creation ' + result.reason + '. Start it again.');\n await new Promise((resolve) => setTimeout(resolve, Math.max(500, Math.min(result.retryAfterMs || 2000, 5000))));\n }\n } catch (error) {\n if (popup) popup.close();\n throw error;\n }\n }\n\n async function reconcileLogto(refreshConnectorSecrets) {\n return json(await fetch(api + '/reconcile/logto', {\n method: 'POST',\n headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ refreshConnectorSecrets: Boolean(refreshConnectorSecrets) })\n }));\n }\n\n async function refreshDeploymentConfig() {\n const status = await json(await fetch(api + '/deployment-config', { headers: bearer() }));\n el('access').hidden = true;\n el('setup').hidden = false;\n el('editor').hidden = false;\n currentRevision = status.revision;\n renderApps(status);\n return status;\n }\n\n async function checkLogto() {\n const report = await json(await fetch(api + '/check/logto', { headers: bearer() }));\n await refreshDeploymentConfig();\n const failures = report.findings.filter((finding) => finding.status !== 'pass');\n el('logto-status').textContent = failures.length === 0\n ? 'SPA redirects, connectors, and social-only sign-in match.'\n : failures.map((finding) => finding.message).join(' ');\n el('logto-status').className = failures.length === 0 ? 'ok' : 'warn';\n match('logto-match', failures.length === 0 ? 'pass' : 'warn', failures.length === 0 ? 'Matches' : 'Update required');\n showDiff(\n 'logto-drift',\n failures.flatMap((finding) => finding.diff && finding.diff.length\n ? finding.diff\n : [{ field: finding.id.replace(/^logto./, '').replaceAll('_', ' '), current: finding.message, expected: 'Matches expected configuration' }])\n );\n el('logto-settings').hidden = failures.length === 0;\n renderLoginAppChecks(report);\n return report;\n }\n\n function githubNotice(value) {\n if (value === 'deployment-created') return 'GitHub integration App created. Its private key and webhook secret are stored.';\n if (value === 'deployment-login-created') return 'GitHub integration App created and connected to Logto sign-in.';\n if (value === 'cancelled') return 'GitHub App creation was cancelled.';\n if (value === 'expired') return 'GitHub App creation expired. Start it again.';\n if (value === 'invalid-callback') return 'GitHub returned an invalid App creation callback.';\n if (value === 'conversion-failed') return 'GitHub may have created the App, but did not return complete credentials. Delete the orphaned App and retry.';\n if (value === 'save-failed') return 'GitHub created the App, but its credentials could not be saved. Delete the orphaned App and retry.';\n return '';\n }\n\n async function load() {\n let publicAuth = await authConfig();\n const currentUrl = new URL(location.href);\n const githubResult = currentUrl.searchParams.get('github');\n const notice = githubNotice(githubResult);\n if (githubResult === 'deployment-login-created' && publicAuth.mode === 'none') {\n await reconcileLogto();\n publicAuth = await authConfig();\n }\n if (githubResult) history.replaceState({}, '', '/');\n const hasToken = Boolean(sessionStorage.getItem(tokenKey));\n el('access').hidden = false;\n el('setup').hidden = true;\n el('login').hidden = true;\n el('open-logto').hidden = true;\n el('show-bootstrap').hidden = true;\n el('editor').hidden = true;\n el('bootstrap').hidden = true;\n if (publicAuth.logtoAdminEndpoint) {\n el('open-logto').href = publicAuth.logtoAdminEndpoint;\n el('logto-settings').href = publicAuth.logtoAdminEndpoint;\n }\n if (publicAuth.mode === 'none') {\n sessionStorage.removeItem(tokenKey);\n await loadBootstrapInfo();\n el('open-logto').hidden = false;\n el('show-bootstrap').hidden = false;\n message(notice || 'Logto sign-in is not configured. Set up the initial Logto administrator first.');\n return;\n }\n if (publicAuth.mode === 'unavailable') {\n el('open-logto').hidden = false;\n message(publicAuth.message || 'Logto sign-in is unavailable. Open Logto Console to review its settings.', true);\n return;\n }\n if (!hasToken) {\n el('login').hidden = false;\n message(notice || 'Sign in with Logto to continue.');\n return;\n }\n if (publicAuth.claimAvailable) {\n try {\n await json(await fetch(api + '/bootstrap/claim', { method: 'POST', headers: bearer() }));\n sessionStorage.removeItem(tokenKey);\n el('login').hidden = false;\n message('This first user is now an ADMIN. Sign in again to refresh the role claim.');\n return;\n } catch (error) {\n if (error.status !== 401 && error.status !== 403) throw error;\n sessionStorage.removeItem(tokenKey);\n el('login').hidden = false;\n message('Sign in with Logto to continue.', true);\n return;\n }\n }\n try {\n await loadBootstrapInfo();\n await refreshDeploymentConfig();\n message(notice);\n checkLogto().catch((error) => {\n el('logto-status').textContent = error.message;\n el('logto-status').className = 'warn';\n el('logto-settings').hidden = false;\n });\n } catch (error) {\n if (error.status === 401 || error.status === 403) {\n sessionStorage.removeItem(tokenKey);\n el('login').hidden = false;\n message('Sign in with a Logto ADMIN account.', true);\n }\n else throw error;\n }\n }\n\n async function saveSecretReplacement(key, value) {\n if (!currentStatus) throw new Error('Deployment configuration is not loaded');\n if (!value) throw new Error('Enter the replacement secret');\n const refreshLogto =\n (key === 'gitlab.clientSecret' && currentStatus.values.logto?.browser?.socialProviders.includes('gitlab')) ||\n (key === 'logto.githubConnectorClientSecret' && currentStatus.values.logto && currentStatus.values.logto.githubConnector) ||\n (key === 'logto.googleConnectorClientSecret' && currentStatus.values.logto && currentStatus.values.logto.googleConnector) ||\n Object.keys(loginApps).some((provider) => key === 'logto.' + provider + 'ConnectorClientSecret' && currentStatus.values.logto?.[provider + 'Connector']) ||\n (key === 'slack.clientSecret' && currentStatus.values.logto && currentStatus.values.logto.slackConnector);\n await json(await fetch(api + '/deployment-config', {\n method: 'PUT', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ expectedRevision: currentRevision, values: currentStatus.values, secrets: { [key]: value } })\n }));\n try {\n if (refreshLogto) await reconcileLogto(true);\n } finally {\n await load();\n }\n if (refreshLogto) {\n message('Secret replaced and applied to the Logto connector. Restart AgentConnect to apply deployment changes.');\n } else {\n message('Secret replaced. Restart AgentConnect to apply it.');\n }\n }\n\n function editSecret(button) {\n if (!currentStatus) return message('Deployment configuration is not loaded', true);\n const row = button.closest('.secret-line');\n const display = el(button.dataset.secretDisplay);\n if (!row || !display || row.querySelector('.secret-editor')) return;\n const editor = document.createElement('span'); editor.className = 'secret-editor';\n const input = document.createElement('input');\n input.type = 'password'; input.autocomplete = 'new-password'; input.placeholder = 'Enter replacement secret';\n const save = document.createElement('button'); save.textContent = 'Save';\n const cancel = document.createElement('button'); cancel.textContent = 'Cancel';\n const close = () => { editor.remove(); display.hidden = false; button.hidden = false; };\n save.onclick = async () => {\n save.disabled = true;\n try { await saveSecretReplacement(button.dataset.secretKey, input.value); }\n catch (error) { save.disabled = false; message(error.message, true); }\n };\n cancel.onclick = close;\n input.onkeydown = (event) => { if (event.key === 'Enter') save.click(); if (event.key === 'Escape') close(); };\n display.hidden = true; button.hidden = true;\n editor.append(input, save, cancel); row.append(editor); input.focus();\n }\n\n async function saveOptions() {\n if (!currentStatus) throw new Error('Deployment configuration is not loaded');\n const values = {\n ...currentStatus.values,\n features: {\n presetAgentsEnabled: el('preset-agents-enabled').checked,\n maxOrgsPerNonAdminUser: Number(el('max-orgs-per-non-admin-user').value)\n }\n };\n const saved = await json(await fetch(api + '/deployment-config', {\n method: 'PUT', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ expectedRevision: currentRevision, values })\n }));\n message('Saved deployment options. Restart AgentConnect to apply them.');\n await load();\n }\n\n el('login').onclick = () => signIn().catch((error) => message(error.message, true));\n el('show-bootstrap').onclick = () => showBootstrap().catch((error) => message(error.message, true));\n el('bootstrap-provider').onchange = updateBootstrapProvider;\n el('bootstrap-github-owner').onchange = () => updateOwner('bootstrap-github');\n el('github-owner').onchange = () => updateOwner('github');\n el('bootstrap-logto-submit').onclick = () => bootstrapLogto().catch((error) => message(error.message, true));\n el('bootstrap-back').onclick = () => showBootstrapStep('logto');\n el('bootstrap-google-submit').onclick = () => bootstrapGoogle().catch((error) => message(error.message, true));\n el('bootstrap-github-submit').onclick = () => bootstrapGithub().catch((error) => message(error.message, true));\n el('bootstrap-slack-submit').onclick = () => bootstrapSlack().catch((error) => message(error.message, true));\n el('create-github').onclick = () => startGithub('github').catch((error) => message(error.message, true));\n el('save-logto-configuration').onclick = () => saveLogtoConfiguration().catch((error) => message(error.message, true));\n el('cancel-logto-configuration').onclick = cancelConfigurationEdit;\n el('save-github-configuration').onclick = () => saveGithubConfiguration().catch((error) => message(error.message, true));\n el('cancel-github-configuration').onclick = cancelConfigurationEdit;\n el('clear-github').onclick = () => clearProvider('github').catch((error) => message(error.message, true));\n el('connect-github-login').onclick = () => connectGithubLogin().catch((error) => message(error.message, true));\n el('connect-gitlab-login').onclick = () => configureGitlabLogin(true).catch((error) => message(error.message, true));\n el('disconnect-gitlab-login').onclick = () => configureGitlabLogin(false).catch((error) => message(error.message, true));\n el('check-gitlab-login').onclick = () => checkLogto().catch((error) => message(error.message, true));\n el('check-github').onclick = () => checkGithub().catch((error) => message(error.message, true));\n el('create-slack').onclick = () => createSlack('slack').catch((error) => message(error.message, true));\n el('connect-slack-login').onclick = () => connectSlackLogin().catch((error) => message(error.message, true));\n el('save-slack-configuration').onclick = () => saveSlackConfiguration().catch((error) => message(error.message, true));\n el('cancel-slack-configuration').onclick = cancelConfigurationEdit;\n el('clear-slack').onclick = () => clearProvider('slack').catch((error) => message(error.message, true));\n el('check-slack').onclick = () => checkSlack().catch((error) => message(error.message, true));\n el('reconcile-slack').onclick = () => reconcileSlack().catch((error) => message(error.message, true));\n el('save-gitea').onclick = () => saveGitea().catch((error) => message(error.message, true));\n el('cancel-gitea-configuration').onclick = cancelConfigurationEdit;\n el('clear-gitea').onclick = () => clearProvider('gitea').catch((error) => message(error.message, true));\n el('save-gitlab').onclick = () => saveGitlab().catch((error) => message(error.message, true));\n el('cancel-gitlab-configuration').onclick = cancelConfigurationEdit;\n el('clear-gitlab').onclick = () => clearProvider('gitlab').catch((error) => message(error.message, true));\n el('save-googlechat').onclick = () => saveGoogleChat().catch((error) => message(error.message, true));\n el('cancel-googlechat-configuration').onclick = cancelConfigurationEdit;\n el('clear-googlechat').onclick = () => clearProvider('googlechat').catch((error) => message(error.message, true));\n el('save-linear').onclick = () => saveLinear().catch((error) => message(error.message, true));\n el('cancel-linear-configuration').onclick = cancelConfigurationEdit;\n el('clear-linear').onclick = () => clearProvider('linear').catch((error) => message(error.message, true));\n el('save-google').onclick = () => saveGoogle().catch((error) => message(error.message, true));\n el('cancel-google-configuration').onclick = cancelConfigurationEdit;\n el('clear-google').onclick = () => clearProvider('google').catch((error) => message(error.message, true));\n el('check-google').onclick = () => checkGoogle().catch((error) => message(error.message, true));\n for (const provider of Object.keys(loginApps)) {\n el('save-' + provider).onclick = () => saveLoginApp(provider).catch((error) => message(error.message, true));\n el('cancel-' + provider + '-configuration').onclick = cancelConfigurationEdit;\n el('clear-' + provider).onclick = () => saveLoginApp(provider, true).catch((error) => message(error.message, true));\n el('check-' + provider).onclick = () => checkLogto().catch((error) => message(error.message, true));\n }\n el('create-feishu-login-app').onclick = () => createRegionalLoginApp('feishu').catch((error) => message(error.message, true));\n el('save-feishu-login-app').onclick = () => saveRegionalLoginApp('feishu').catch((error) => message(error.message, true));\n el('cancel-feishu-configuration').onclick = cancelConfigurationEdit;\n el('clear-feishu').onclick = () => clearProvider('feishu').catch((error) => message(error.message, true));\n el('check-feishu-login-app').onclick = () => checkRegionalLoginApp('feishu').catch((error) => message(error.message, true));\n el('create-lark-login-app').onclick = () => createRegionalLoginApp('lark').catch((error) => message(error.message, true));\n el('save-lark-login-app').onclick = () => saveRegionalLoginApp('lark').catch((error) => message(error.message, true));\n el('cancel-lark-configuration').onclick = cancelConfigurationEdit;\n el('clear-lark').onclick = () => clearProvider('lark').catch((error) => message(error.message, true));\n el('check-lark-login-app').onclick = () => checkRegionalLoginApp('lark').catch((error) => message(error.message, true));\n el('check-logto').onclick = () => checkLogto().catch((error) => message(error.message, true));\n el('reconcile-logto').onclick = () => reconcileLogto().then(load).catch((error) => message(error.message, true));\n el('logout').onclick = () => { sessionStorage.removeItem(tokenKey); load().catch((error) => message(error.message, true)); };\n el('save-options').onclick = () => saveOptions().catch((error) => message(error.message, true));\n for (const button of document.querySelectorAll('.edit-secret')) button.onclick = () => editSecret(button);\n for (const button of document.querySelectorAll('.edit-configuration')) button.onclick = () => beginConfigurationEdit(button.dataset.provider);\n updateBootstrapProvider();\n finishSignIn().then(load).catch((error) => message(error.message, true));\n <\/script>\n</body>\n</html>"
|
|
67263
|
+
";\n\n function showIdentityEditors(provider, show) {\n for (const button of document.querySelectorAll('.edit-configuration[data-provider=\"' + provider + '\"]')) {\n button.hidden = !show;\n }\n }\n\n function text(id, value) {\n el(id).textContent = value === null || value === undefined || value === '' ? 'Not configured' : String(value);\n }\n\n function secretText(id, byKey, key, editable) {\n const stored = configured(byKey, key);\n const display = el(id);\n const button = document.querySelector('[data-secret-display=\"' + id + '\"]');\n const row = display.closest('.secret-line');\n const editor = row && row.querySelector('.secret-editor');\n if (editor) editor.remove();\n display.hidden = false;\n display.textContent = stored ? '***' : 'Not configured';\n display.className = stored ? 'redacted' : 'muted';\n button.hidden = !editable;\n button.textContent = stored ? 'Edit' : 'Set';\n }\n\n function match(id, state, label) {\n const target = el(id);\n target.textContent = label;\n target.className = 'badge' + (state ? ' ' + state : '');\n }\n\n async function authConfig() { return json(await fetch(api + '/auth-config')); }\n\n async function signIn() {\n const config = await authConfig();\n if (config.mode !== 'oidc') throw new Error('Save an OIDC configuration first');\n const verifier = random();\n const challenge = base64url(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))));\n const state = random();\n sessionStorage.setItem(verifierKey, verifier);\n sessionStorage.setItem(stateKey, state);\n const url = new URL(config.authorizationEndpoint);\n url.searchParams.set('client_id', config.appId);\n url.searchParams.set('redirect_uri', config.redirectUri);\n url.searchParams.set('response_type', 'code');\n url.searchParams.set('scope', 'openid profile email roles');\n url.searchParams.set('state', state);\n url.searchParams.set('code_challenge', challenge);\n url.searchParams.set('code_challenge_method', 'S256');\n if (config.resource) url.searchParams.set('resource', config.resource);\n if (config.ssoConnectorId) url.searchParams.set('direct_sign_in', 'sso:' + config.ssoConnectorId);\n location.assign(url);\n }\n\n async function finishSignIn() {\n const url = new URL(location.href);\n const code = url.searchParams.get('code');\n if (!code) return;\n const state = url.searchParams.get('state');\n if (!state || state !== sessionStorage.getItem(stateKey)) throw new Error('OIDC state mismatch');\n const verifier = sessionStorage.getItem(verifierKey);\n if (!verifier) throw new Error('PKCE verifier is missing; start sign-in again');\n const config = await authConfig();\n const body = new URLSearchParams({\n grant_type: 'authorization_code', code, client_id: config.appId,\n redirect_uri: config.redirectUri, code_verifier: verifier\n });\n if (config.resource) body.set('resource', config.resource);\n const tokens = await json(await fetch(config.tokenEndpoint, {\n method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body\n }));\n if (typeof tokens.id_token !== 'string') throw new Error('Logto did not return an ID token');\n sessionStorage.setItem(tokenKey, tokens.id_token);\n sessionStorage.removeItem(verifierKey);\n sessionStorage.removeItem(stateKey);\n history.replaceState({}, '', '/');\n }\n\n function renderUriList(id, values) {\n el(id).replaceChildren(...(values || []).map((value) => {\n const item = document.createElement('li');\n const code = document.createElement('code'); code.textContent = value;\n item.append(code); return item;\n }));\n }\n\n function updateBootstrapProvider() {\n const provider = el('bootstrap-provider').value;\n el('bootstrap-google').hidden = provider !== 'google';\n el('bootstrap-github').hidden = provider !== 'github';\n el('bootstrap-slack').hidden = provider !== 'slack';\n el('bootstrap-sso').hidden = provider !== 'sso';\n }\n\n // The SSO connector each form shows: the saved one in settings, the one just created during first-run setup.\n const ssoState = {};\n\n function ssoUrls(connector) {\n const base = ((bootstrapInfo && bootstrapInfo.logtoEndpoint) || '').replace(//+$/, '');\n const id = encodeURIComponent(connector.connectorId);\n return connector.protocol === 'oidc'\n ? ['Redirect URI: ' + base + '/callback/' + id]\n : ['ACS URL: ' + base + '/api/authn/single-sign-on/saml/' + id, 'SP entity ID: ' + base + '/enterprise-sso/' + id];\n }\n\n function renderSso(prefix, connector) {\n ssoState[prefix] = connector || undefined;\n if (connector) {\n el(prefix + '-protocol').value = connector.protocol;\n el(prefix + '-name').value = connector.name;\n el(prefix + '-domains').value = (connector.domains || []).join(', ');\n renderUriList(prefix + '-urls', ssoUrls(connector));\n const oidc = connector.oidc || {};\n el(prefix + '-issuer').value = oidc.issuer || '';\n el(prefix + '-client-id').value = oidc.clientId || '';\n el(prefix + '-scope').value = oidc.scope || '';\n el(prefix + '-trust-email').checked = connector.oidc ? oidc.trustUnverifiedEmail : true;\n const saml = connector.saml || {};\n el(prefix + '-metadata-url').value = saml.metadataUrl || '';\n el(prefix + '-metadata').value = saml.metadata || '';\n el(prefix + '-email-attribute').value = (saml.attributeMapping && saml.attributeMapping.email) || '';\n }\n updateSsoProtocol(prefix);\n }\n\n function updateSsoProtocol(prefix) {\n const connector = ssoState[prefix];\n const protocol = el(prefix + '-protocol').value;\n const current = Boolean(connector && connector.protocol === protocol);\n el(prefix + '-oidc').hidden = protocol !== 'oidc';\n el(prefix + '-saml').hidden = protocol !== 'saml';\n // Another protocol is another connector, so its details wait until it is created.\n el(prefix + '-details').hidden = !current;\n el(prefix + '-create').hidden = current;\n }\n\n async function createSso(prefix) {\n const name = el(prefix + '-name').value;\n const result = await json(await fetch(api + '/create/sso', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ protocol: el(prefix + '-protocol').value, name })\n }));\n renderSso(prefix, { connectorId: result.connectorId, protocol: result.protocol, name, domains: [], oidc: null, saml: null });\n message('SSO connector created. Register its URLs with the identity provider, then enter the provider details.');\n }\n\n async function saveSso(prefix) {\n const protocol = el(prefix + '-protocol').value;\n const secret = el(prefix + '-client-secret').value;\n const metadataUrl = el(prefix + '-metadata-url').value.trim();\n const metadata = el(prefix + '-metadata').value.trim();\n const emailAttribute = el(prefix + '-email-attribute').value.trim();\n const connector = {\n name: el(prefix + '-name').value,\n domains: el(prefix + '-domains').value.split(',').map((domain) => domain.trim()).filter(Boolean),\n ...(protocol === 'oidc'\n ? { oidc: {\n issuer: el(prefix + '-issuer').value.trim(),\n clientId: el(prefix + '-client-id').value.trim(),\n ...(secret ? { clientSecret: secret } : {}),\n scope: el(prefix + '-scope').value.trim(),\n trustUnverifiedEmail: el(prefix + '-trust-email').checked\n } }\n : { saml: {\n ...(metadataUrl ? { metadataUrl } : {}),\n ...(metadata ? { metadata } : {}),\n ...(emailAttribute ? { attributeMapping: { email: emailAttribute } } : {})\n } })\n };\n await json(await fetch(api + '/configure/sso', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ connector })\n }));\n el(prefix + '-client-secret').value = '';\n await reconcileLogto(Boolean(secret));\n await load();\n }\n\n async function removeSso() {\n await json(await fetch(api + '/configure/sso', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ connector: null })\n }));\n await reconcileLogto();\n await load();\n }\n\n function showBootstrapStep(step) {\n const logto = step === 'logto';\n el('bootstrap-logto-step').hidden = !logto;\n el('bootstrap-provider-step').hidden = logto;\n el('bootstrap-progress').textContent = logto ? 'Step 1 of 2' : 'Step 2 of 2';\n }\n\n function updateOwner(prefix) {\n el(prefix + '-org-field').hidden = el(prefix + '-owner').value !== 'organization';\n }\n\n function ownership(prefix) {\n if (el(prefix + '-owner').value === 'personal') return { owner: 'personal', organization: null };\n const organization = el(prefix + '-org').value.trim();\n if (!organization) throw new Error('Enter the GitHub organization login');\n return { owner: 'organization', organization };\n }\n\n function formatDiffValue(value) {\n if (value === null || value === undefined || value === '') return 'Not configured';\n if (Array.isArray(value)) return value.length ? value.map(formatDiffValue).join('\n') : '[]';\n if (typeof value === 'object') {\n return Object.entries(value).map(([key, item]) => key + ': ' + (Array.isArray(item) ? item.join(', ') : formatDiffValue(item))).join('\n');\n }\n return String(value);\n }\n\n function showDiff(id, rows, label = 'Update required') {\n const box = el(id);\n box.hidden = rows.length === 0;\n box.replaceChildren();\n if (rows.length === 0) return;\n const title = document.createElement('strong');\n title.className = 'diff-title';\n title.textContent = label;\n const grid = document.createElement('div');\n grid.className = 'config-diff';\n const head = document.createElement('div');\n head.className = 'diff-row diff-head';\n for (const text of ['Field', 'Current', 'Expected']) {\n const cell = document.createElement('span'); cell.textContent = text; head.append(cell);\n }\n grid.append(head);\n for (const item of rows) {\n const row = document.createElement('div'); row.className = 'diff-row';\n const field = document.createElement('span'); field.className = 'diff-field'; field.textContent = item.field;\n const current = document.createElement('span'); current.className = 'diff-value diff-current'; current.textContent = formatDiffValue(item.current);\n const expected = document.createElement('span'); expected.className = 'diff-value diff-expected'; expected.textContent = formatDiffValue(item.expected);\n row.append(field, current, expected); grid.append(row);\n }\n box.append(title, grid);\n }\n\n function renderStartupEnvironment() {\n const services = bootstrapInfo.services;\n const environment = [\n ['AGENTCONNECT_PUBLIC_CP_URL', services.controlPlane],\n ['AGENTCONNECT_PUBLIC_RELAY_URL', services.relay],\n ['AGENTCONNECT_PUBLIC_WEB_URL', services.web],\n ['LOGTO_ENDPOINT', bootstrapInfo.logtoEndpoint],\n ['LOGTO_MGMT_ENDPOINT', bootstrapInfo.logtoManagementEndpoint]\n ];\n el('startup-environment').textContent = environment\n .filter(([, value]) => value)\n .map(([key, value]) => key + '=' + value)\n .join('\n');\n }\n\n function renderApps(status) {\n currentStatus = status;\n const byKey = new Map((status.secrets || []).map((item) => [item.key, item]));\n const values = status.values;\n const expected = status.providerExpectations || { github: null, gitlab: null, slack: null, linear: null, googleChat: null, google: { origins: [], redirects: [] } };\n renderStartupEnvironment();\n\n const logto = values.logto;\n text('logto-management-endpoint', logto && bootstrapInfo.logtoManagementEndpoint);\n text('logto-management-id', logto && logto.managementAppId);\n text('logto-management-resource', logto && logto.managementResource);\n secretText('logto-management-secret-display', byKey, 'logto.managementAppSecret', Boolean(logto));\n const sso = logto && logto.ssoConnector;\n renderSso('sso', sso);\n el('sso-remove').hidden = !sso;\n match('sso-match', !sso ? '' : sso.oidc || sso.saml ? 'pass' : 'warn', !sso ? 'Not configured' : sso.oidc || sso.saml ? 'Configured' : 'Needs identity provider');\n text('logto-browser-endpoint', logto && logto.browser && bootstrapInfo.logtoEndpoint);\n text('logto-browser-id', values.auth.mode === 'oidc' ? values.auth.browserClient.appId : null);\n text('logto-browser-resource', logto && logto.browser && logto.browser.apiResource);\n el('logto-edit-controls').hidden = true;\n showIdentityEditors('logto', Boolean(logto && logto.browser && values.auth.mode === 'oidc'));\n match(\n 'logto-match',\n logto && configured(byKey, 'logto.managementAppSecret') ? '' : 'warn',\n logto && configured(byKey, 'logto.managementAppSecret') ? 'Ready to check' : 'Not configured'\n );\n\n el('preset-agents-enabled').checked = values.features.presetAgentsEnabled;\n el('max-orgs-per-non-admin-user').value = String(values.features.maxOrgsPerNonAdminUser);\n\n const github = values.github;\n const webhookStored = byKey.get('github.webhookSecret') && byKey.get('github.webhookSecret').configured;\n const webhookInactive = github && github.webhookEnabled === false;\n text('github-app-id', github && github.appId);\n text('github-app-slug', github && github.slug);\n text('github-client-id', github && github.clientId);\n text('github-webhook-enabled', github && (github.webhookEnabled === false ? 'Disabled' : 'Enabled'));\n el('github-edit-controls').hidden = true;\n showIdentityEditors('github', Boolean(github));\n secretText('github-client-secret-display', byKey, 'github.clientSecret', Boolean(github));\n secretText('github-private-key-display', byKey, 'github.privateKeyB64', Boolean(github));\n secretText('github-webhook-secret-display', byKey, 'github.webhookSecret', Boolean(github));\n secretText('github-logto-secret-display', byKey, 'logto.githubConnectorClientSecret', Boolean(values.logto && values.logto.githubConnector));\n el('github-status').textContent = github\n ? github.slug + ' is configured. Webhook secret: ' + (webhookStored ? 'stored' : webhookInactive ? 'not required yet' : 'missing') + '.' +\n (webhookInactive ? ' Relay webhook delivery is disabled.' : '')\n : 'Creates the complete App used for repository installation, webhooks, and optional GitHub sign-in.';\n const githubDrift = github\n ? expected.github\n ? []\n : [{ field: 'Startup public URLs', current: 'Unavailable', expected: 'Valid Web, API, and ingress URLs' }]\n : [];\n match('github-match', !github ? '' : githubDrift.length ? 'warn' : '', !github ? 'Not configured' : githubDrift.length ? 'Expected URLs changed' : 'Ready to check');\n el('github-create-controls').hidden = Boolean(github);\n el('create-github').hidden = Boolean(github);\n el('clear-github').hidden = !github;\n el('connect-github-login').hidden = !github || !values.logto || Boolean(values.logto.githubConnector);\n el('check-github').hidden = !github;\n if (github) showDiff('github-drift', githubDrift, 'Expected URLs changed since App creation');\n else el('github-drift').hidden = true;\n\n const gitlab = values.gitlab;\n const gitlabSecret = configured(byKey, 'gitlab.clientSecret');\n const expectedGitlab = expected.gitlab;\n renderUriList('gitlab-callbacks', expectedGitlab ? [expectedGitlab.callbackUrl] : []);\n renderUriList('gitlab-scopes', expectedGitlab ? expectedGitlab.scopes : []);\n text('gitlab-client-id', gitlab && gitlab.clientId);\n secretText('gitlab-client-secret-display', byKey, 'gitlab.clientSecret', Boolean(gitlab));\n showIdentityEditors('gitlab', Boolean(gitlab));\n el('gitlab-id').value = gitlab ? gitlab.clientId : '';\n text('gitlab-instance', (gitlab && gitlab.baseUrl) || 'https://gitlab.com');\n // Host-aware application links (§24.1): composed by the server against the\n // configured base, so a path-prefixed install keeps its prefix here too.\n if (expectedGitlab) {\n el('gitlab-applications').href = expectedGitlab.applicationsUrl;\n el('gitlab-admin-applications').href = expectedGitlab.adminApplicationsUrl;\n }\n el('gitlab-base-url').value = (gitlab && gitlab.baseUrl) || '';\n // A probe verdict belongs to the save that produced it, never to a reload.\n el('gitlab-probe').hidden = true;\n el('gitlab-status').textContent = gitlab\n ? gitlab.clientId + ' is configured.'\n : expectedGitlab\n ? 'Register the OAuth application on the configured GitLab instance, then save its Application ID and Secret here.'\n : 'Publishing the redirect URI needs an HTTPS API public URL.';\n showDiff('gitlab-drift', gitlab && !expectedGitlab\n ? [{ field: 'Startup public URLs', current: 'Unavailable', expected: 'An HTTPS API URL' }]\n : []);\n match('gitlab-match', gitlab && gitlabSecret ? 'warn' : '', !gitlab ? 'Not configured' : !gitlabSecret ? 'Missing secret' : \"Can't verify automatically\");\n el('gitlab-initial-secret-field').hidden = Boolean(gitlab) && gitlabSecret;\n el('gitlab-config-controls').hidden = Boolean(gitlab);\n el('save-gitlab').hidden = Boolean(gitlab);\n el('cancel-gitlab-configuration').hidden = true;\n el('clear-gitlab').hidden = !gitlab;\n const gitlabLogin = values.logto?.browser?.socialProviders.includes('gitlab');\n renderUriList('gitlab-login-redirects', expected.gitlabSignIn?.redirects || []);\n el('gitlab-login-status').textContent = gitlabLogin ? 'GitLab sign-in is configured. Use Check Logto to verify it.' : 'GitLab sign-in is not enabled.';\n el('connect-gitlab-login').hidden = Boolean(gitlabLogin);\n el('connect-gitlab-login').disabled = !gitlab || !gitlabSecret || !values.logto?.browser;\n el('disconnect-gitlab-login').hidden = !gitlabLogin;\n el('check-gitlab-login').hidden = !gitlabLogin;\n\n const gitea = values.gitea;\n text('gitea-instance', (gitea && gitea.baseUrl) || 'https://gitea.com');\n el('gitea-base-url').value = (gitea && gitea.baseUrl) || '';\n showIdentityEditors('gitea', Boolean(gitea));\n // A probe verdict belongs to the save that produced it, never to a reload.\n el('gitea-probe').hidden = true;\n el('gitea-status').textContent = gitea\n ? ((gitea.baseUrl || 'https://gitea.com') + ' is configured.')\n : 'Organizations can connect gitea.com without saving anything. Save an address only for a self-hosted instance.';\n match('gitea-match', gitea ? 'warn' : '', gitea ? \"Can't verify automatically\" : 'Default');\n el('gitea-config-controls').hidden = Boolean(gitea);\n el('save-gitea').hidden = Boolean(gitea);\n el('cancel-gitea-configuration').hidden = true;\n el('clear-gitea').hidden = !gitea;\n\n const slack = values.slack;\n text('slack-app-id', slack && slack.appId);\n text('slack-client-id', slack && slack.clientId);\n el('slack-edit-controls').hidden = true;\n showIdentityEditors('slack', Boolean(slack));\n secretText('slack-client-secret-display', byKey, 'slack.clientSecret', Boolean(slack));\n secretText('slack-signing-secret-display', byKey, 'slack.signingSecret', Boolean(slack));\n el('slack-logto-status').textContent = values.logto && values.logto.slackConnector\n ? 'Enabled; reuses the Slack client secret above.'\n : 'Not enabled.';\n el('slack-status').textContent = slack ? slack.appId + ' is configured.' : 'Creates the default AgentConnect integration manifest.';\n const slackDrift = slack\n ? expected.slack\n ? []\n : [{ field: 'Startup public URLs', current: 'Unavailable', expected: 'HTTPS Web, API, and ingress URLs' }]\n : [];\n match('slack-match', !slack ? '' : slackDrift.length ? 'warn' : '', !slack ? 'Not configured' : slackDrift.length ? 'Update required' : 'Ready to check');\n el('slack-name-field').hidden = Boolean(slack);\n el('create-slack').hidden = Boolean(slack);\n el('connect-slack-login').hidden = !slack || !values.logto || Boolean(values.logto.slackConnector) || !bootstrapInfo?.slackAvailable;\n el('clear-slack').hidden = !slack;\n el('check-slack').hidden = !slack;\n el('reconcile-slack').hidden = !slack;\n el('slack-settings').hidden = !slack;\n if (slack) {\n el('slack-settings').href = 'https://api.slack.com/apps/' + encodeURIComponent(slack.appId);\n showDiff('slack-drift', slackDrift);\n } else el('slack-drift').hidden = true;\n\n const googleChat = values.googleChat;\n const googleChatKey = configured(byKey, 'googleChat.serviceAccountKey');\n const expectedGoogleChat = expected.googleChat;\n renderUriList('googlechat-callbacks', expectedGoogleChat ? [expectedGoogleChat.callbackUrl] : []);\n text('googlechat-project-id', googleChat && googleChat.projectId);\n text('googlechat-project-number', googleChat && googleChat.projectNumber);\n el('googlechat-key-display').textContent = googleChatKey ? '***' : 'Not configured';\n el('googlechat-key-display').className = googleChatKey ? 'redacted' : 'muted';\n showIdentityEditors('googlechat', Boolean(googleChat));\n el('googlechat-project-id-input').value = googleChat ? googleChat.projectId : '';\n el('googlechat-project-number-input').value = googleChat ? googleChat.projectNumber : '';\n el('googlechat-key').value = '';\n if (expectedGoogleChat) el('googlechat-configuration').href = expectedGoogleChat.configurationUrl;\n el('googlechat-status').textContent = googleChat\n ? googleChat.projectId + ' is configured.'\n : expectedGoogleChat\n ? 'Configure the Chat app in Google Cloud Console, then save its project and service-account key here.'\n : 'Publishing the HTTP endpoint URL needs an HTTPS ingress public URL.';\n showDiff('googlechat-drift', googleChat && !expectedGoogleChat\n ? [{ field: 'Startup public URLs', current: 'Unavailable', expected: 'An HTTPS ingress URL' }]\n : []);\n match('googlechat-match', googleChat && googleChatKey ? 'pass' : googleChat ? 'warn' : '', !googleChat ? 'Not configured' : !googleChatKey ? 'Missing key' : 'Checked on save');\n el('googlechat-edit-heading').hidden = true;\n el('googlechat-config-controls').hidden = Boolean(googleChat);\n el('save-googlechat').hidden = Boolean(googleChat);\n el('cancel-googlechat-configuration').hidden = true;\n el('clear-googlechat').hidden = !googleChat;\n\n\n const linear = values.linear;\n const linearSecrets = configured(byKey, 'linear.clientSecret') && configured(byKey, 'linear.signingSecret');\n const expectedLinear = expected.linear;\n renderUriList('linear-callbacks', expectedLinear ? [expectedLinear.callbackUrl] : []);\n renderUriList('linear-webhooks', expectedLinear ? [expectedLinear.webhookUrl] : []);\n text('linear-client-id', linear && linear.clientId);\n secretText('linear-client-secret-display', byKey, 'linear.clientSecret', Boolean(linear));\n secretText('linear-signing-secret-display', byKey, 'linear.signingSecret', Boolean(linear));\n showIdentityEditors('linear', Boolean(linear));\n el('linear-id').value = linear ? linear.clientId : '';\n if (expectedLinear) el('linear-applications').href = expectedLinear.applicationsUrl;\n el('linear-status').textContent = linear\n ? linear.clientId + ' is configured.'\n : expectedLinear\n ? 'Register the OAuth application in Linear, then save its Client ID and both secrets here.'\n : 'Publishing the callback and webhook URLs needs HTTPS API and ingress public URLs.';\n showDiff('linear-drift', linear && !expectedLinear\n ? [{ field: 'Startup public URLs', current: 'Unavailable', expected: 'HTTPS API and ingress URLs' }]\n : []);\n match('linear-match', linear && linearSecrets ? 'warn' : '', !linear ? 'Not configured' : !linearSecrets ? 'Missing secret' : \"Can't verify automatically\");\n el('linear-initial-secret-fields').hidden = Boolean(linear) && linearSecrets;\n el('linear-config-controls').hidden = Boolean(linear);\n el('save-linear').hidden = Boolean(linear);\n el('cancel-linear-configuration').hidden = true;\n el('clear-linear').hidden = !linear;\n\n const google = values.logto && values.logto.googleConnector;\n const googleSecret = configured(byKey, 'logto.googleConnectorClientSecret');\n const expectedGoogle = expected.google;\n renderUriList('google-origins', expectedGoogle.origins);\n renderUriList('google-redirects', expectedGoogle.redirects);\n text('google-client-id', google && google.clientId);\n secretText('google-client-secret-display', byKey, 'logto.googleConnectorClientSecret', Boolean(google));\n showIdentityEditors('google', Boolean(google));\n el('google-id').value = google ? google.clientId : '';\n el('google-status').textContent = google ? 'Google OAuth client is configured.' : 'Create a Web application OAuth client manually, then save it here.';\n showDiff('google-drift', []);\n match('google-match', google && googleSecret ? 'warn' : '', !google ? 'Not configured' : !googleSecret ? 'Missing secret' : \"Can't verify automatically\");\n el('google-initial-secret-field').hidden = googleSecret;\n el('save-google').textContent = google ? 'Confirm callback settings' : 'Save Google client';\n el('google-config-controls').hidden = Boolean(google);\n el('save-google').hidden = Boolean(google);\n el('cancel-google-configuration').hidden = true;\n el('check-google').hidden = !google || !googleSecret;\n el('clear-google').hidden = !google;\n\n for (const [provider, app] of Object.entries(loginApps)) {\n const saved = values.logto && values.logto[provider + 'Connector'];\n const secretKey = 'logto.' + provider + 'ConnectorClientSecret';\n const hasSecret = configured(byKey, secretKey);\n renderUriList(provider + '-redirects', expected[provider].redirects);\n text(provider + '-client-id', saved && saved.clientId);\n secretText(provider + '-client-secret-display', byKey, secretKey, Boolean(saved));\n showIdentityEditors(provider, Boolean(saved));\n el(provider + '-id').value = saved ? saved.clientId : '';\n el(provider + '-status').hidden = !saved;\n el(provider + '-status').textContent = saved ? app.name + ' login application is saved.' : '';\n showDiff(provider + '-drift', []);\n match(provider + '-match', saved ? 'warn' : '', !saved ? 'Not configured' : !hasSecret ? 'Missing secret' : 'Not checked');\n el(provider + '-initial-secret-field').hidden = hasSecret;\n el(provider + '-config-controls').hidden = Boolean(saved);\n el('save-' + provider).hidden = Boolean(saved);\n el('save-' + provider).disabled = !values.logto || !values.logto.browser;\n el('cancel-' + provider + '-configuration').hidden = true;\n el('check-' + provider).hidden = !saved || !hasSecret;\n el('clear-' + provider).hidden = !saved;\n }\n const expectedTeams = expected.msTeams;\n text('azuread-teams-tenant-id', values.msTeams && values.msTeams.tenantId);\n el('azuread-teams-tenant-input').value = values.msTeams ? values.msTeams.tenantId : '';\n renderUriList('azuread-teams-endpoint', expectedTeams ? [expectedTeams.messagingEndpoint] : []);\n renderUriList('azuread-teams-redirects', expectedTeams ? [expectedTeams.connectRedirectUri] : []);\n\n const feishuSecret = byKey.get('feishu.loginAppSecret');\n const larkSecret = byKey.get('lark.loginAppSecret');\n el('feishu-login-id').value = values.feishu ? values.feishu.loginAppId : '';\n el('lark-login-id').value = values.lark ? values.lark.loginAppId : '';\n text('feishu-app-id', values.feishu && values.feishu.loginAppId);\n text('lark-app-id', values.lark && values.lark.loginAppId);\n showIdentityEditors('feishu', Boolean(values.feishu));\n showIdentityEditors('lark', Boolean(values.lark));\n secretText('feishu-app-secret-display', byKey, 'feishu.loginAppSecret', Boolean(values.feishu));\n secretText('lark-app-secret-display', byKey, 'lark.loginAppSecret', Boolean(values.lark));\n el('feishu-login-status').textContent = values.feishu && feishuSecret && feishuSecret.configured\n ? values.feishu.loginAppId + ' is configured.'\n : 'No Feishu tenant App is configured.';\n el('lark-login-status').textContent = values.lark && larkSecret && larkSecret.configured\n ? values.lark.loginAppId + ' is configured.'\n : 'No Lark tenant App is configured.';\n match('feishu-match', values.feishu && configured(byKey, 'feishu.loginAppSecret') ? '' : '', values.feishu && configured(byKey, 'feishu.loginAppSecret') ? 'Ready to check' : 'Not configured');\n match('lark-match', values.lark && configured(byKey, 'lark.loginAppSecret') ? '' : '', values.lark && configured(byKey, 'lark.loginAppSecret') ? 'Ready to check' : 'Not configured');\n showDiff('feishu-drift', []);\n showDiff('lark-drift', []);\n el('feishu-config-controls').hidden = Boolean(values.feishu);\n el('lark-config-controls').hidden = Boolean(values.lark);\n el('feishu-create-name-field').hidden = Boolean(values.feishu);\n el('lark-create-name-field').hidden = Boolean(values.lark);\n el('create-feishu-login-app').hidden = Boolean(values.feishu);\n el('create-lark-login-app').hidden = Boolean(values.lark);\n el('save-feishu-login-app').hidden = Boolean(values.feishu);\n el('save-lark-login-app').hidden = Boolean(values.lark);\n el('cancel-feishu-configuration').hidden = true;\n el('cancel-lark-configuration').hidden = true;\n el('clear-feishu').hidden = !values.feishu;\n el('clear-lark').hidden = !values.lark;\n el('check-feishu-login-app').hidden = !values.feishu || !configured(byKey, 'feishu.loginAppSecret');\n el('check-lark-login-app').hidden = !values.lark || !configured(byKey, 'lark.loginAppSecret');\n el('feishu-settings').hidden = !values.feishu;\n el('lark-settings').hidden = !values.lark;\n if (values.feishu) el('feishu-settings').href = 'https://open.feishu.cn/app/' + encodeURIComponent(values.feishu.loginAppId);\n if (values.lark) el('lark-settings').href = 'https://open.larksuite.com/app/' + encodeURIComponent(values.lark.loginAppId);\n }\n\n function requiredInput(id, label) {\n const value = el(id).value.trim();\n if (!value) throw new Error('Enter ' + label);\n return value;\n }\n\n function githubConnectorUsesDeployment(values) {\n const github = values.github;\n const connector = values.logto && values.logto.githubConnector;\n return Boolean(\n github && connector &&\n connector.appId === github.appId &&\n connector.slug === github.slug &&\n connector.clientId === github.clientId\n );\n }\n\n function beginConfigurationEdit(provider) {\n if (!currentStatus) return message('Deployment configuration is not loaded', true);\n const values = currentStatus.values;\n if (provider === 'logto') {\n const logto = values.logto;\n if (!logto || !logto.browser || values.auth.mode !== 'oidc') return;\n el('logto-edit-management-id').value = logto.managementAppId;\n el('logto-edit-management-resource').value = logto.managementResource;\n el('logto-edit-management-secret').value = '';\n el('logto-edit-browser-id').value = values.auth.browserClient.appId;\n el('logto-edit-browser-resource').value = logto.browser.apiResource || '';\n el('logto-edit-controls').hidden = false;\n el('logto-edit-management-id').focus();\n } else if (provider === 'github') {\n const github = values.github;\n if (!github) return;\n el('github-edit-app-id').value = String(github.appId);\n el('github-edit-slug').value = github.slug;\n el('github-edit-client-id').value = github.clientId || '';\n el('github-edit-webhook-enabled').checked = github.webhookEnabled !== false;\n for (const id of ['github-edit-client-secret', 'github-edit-private-key', 'github-edit-webhook-secret', 'github-edit-logto-secret']) el(id).value = '';\n el('github-edit-logto-secret-field').hidden = !githubConnectorUsesDeployment(values);\n el('github-edit-controls').hidden = false;\n el('clear-github').hidden = true;\n el('github-edit-app-id').focus();\n } else if (provider === 'slack') {\n const slack = values.slack;\n if (!slack) return;\n el('slack-edit-app-id').value = slack.appId;\n el('slack-edit-client-id').value = slack.clientId;\n el('slack-edit-client-secret').value = '';\n el('slack-edit-signing-secret').value = '';\n el('slack-edit-controls').hidden = false;\n el('clear-slack').hidden = true;\n el('slack-edit-app-id').focus();\n } else if (provider === 'gitea') {\n if (!values.gitea) return;\n el('gitea-base-url').value = values.gitea.baseUrl || '';\n el('gitea-config-controls').hidden = false;\n el('save-gitea').hidden = false;\n el('cancel-gitea-configuration').hidden = false;\n el('clear-gitea').hidden = true;\n el('gitea-base-url').focus();\n } else if (provider === 'gitlab') {\n if (!values.gitlab) return;\n el('gitlab-id').value = values.gitlab.clientId;\n el('gitlab-secret').value = '';\n el('gitlab-config-controls').hidden = false;\n el('gitlab-initial-secret-field').hidden = false;\n el('save-gitlab').hidden = false;\n el('cancel-gitlab-configuration').hidden = false;\n el('clear-gitlab').hidden = true;\n el('gitlab-id').focus();\n } else if (provider === 'googlechat') {\n if (!values.googleChat) return;\n el('googlechat-project-id-input').value = values.googleChat.projectId;\n el('googlechat-project-number-input').value = values.googleChat.projectNumber;\n el('googlechat-key').value = '';\n el('googlechat-edit-heading').hidden = false;\n el('googlechat-config-controls').hidden = false;\n el('save-googlechat').hidden = false;\n el('cancel-googlechat-configuration').hidden = false;\n el('clear-googlechat').hidden = true;\n el('googlechat-project-id-input').focus();\n } else if (provider === 'linear') {\n if (!values.linear) return;\n el('linear-id').value = values.linear.clientId;\n el('linear-client-secret').value = '';\n el('linear-signing-secret').value = '';\n el('linear-config-controls').hidden = false;\n el('linear-initial-secret-fields').hidden = false;\n el('save-linear').hidden = false;\n el('cancel-linear-configuration').hidden = false;\n el('clear-linear').hidden = true;\n el('linear-id').focus();\n } else if (provider === 'google') {\n const google = values.logto && values.logto.googleConnector;\n if (!google) return;\n el('google-id').value = google.clientId;\n el('google-secret').value = '';\n el('google-config-controls').hidden = false;\n el('google-initial-secret-field').hidden = false;\n el('save-google').hidden = false;\n el('save-google').textContent = 'Save Google client';\n el('cancel-google-configuration').hidden = false;\n el('clear-google').hidden = true;\n el('google-id').focus();\n } else if (Object.hasOwn(loginApps, provider)) {\n el(provider + '-secret').value = '';\n el(provider + '-config-controls').hidden = false;\n el(provider + '-initial-secret-field').hidden = false;\n el('save-' + provider).hidden = false;\n el('cancel-' + provider + '-configuration').hidden = false;\n el('clear-' + provider).hidden = true;\n el(provider + '-id').focus();\n } else if (provider === 'feishu' || provider === 'lark') {\n el(provider + '-config-controls').hidden = false;\n el(provider + '-create-name-field').hidden = true;\n el('create-' + provider + '-login-app').hidden = true;\n el('save-' + provider + '-login-app').hidden = false;\n el('cancel-' + provider + '-configuration').hidden = false;\n el('clear-' + provider).hidden = true;\n el(provider + '-login-secret').value = '';\n el(provider + '-login-id').focus();\n }\n showIdentityEditors(provider, false);\n }\n\n function cancelConfigurationEdit() {\n if (currentStatus) renderApps(currentStatus);\n }\n\n async function replaceConfiguration(values, secrets, successMessage) {\n const response = await json(await fetch(api + '/deployment-config', {\n method: 'PUT',\n headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ expectedRevision: currentRevision, values, ...(secrets ? { secrets } : {}) })\n }));\n await load();\n message(successMessage || 'Configuration saved. Restart AgentConnect to apply it.');\n return response;\n }\n\n async function saveLogtoConfiguration() {\n if (!currentStatus) throw new Error('Deployment configuration is not loaded');\n const values = currentStatus.values;\n const logto = values.logto;\n if (!logto || !logto.browser || values.auth.mode !== 'oidc') throw new Error('Logto is not configured');\n const managementAppId = requiredInput('logto-edit-management-id', 'the Management App ID');\n const managementResource = requiredInput('logto-edit-management-resource', 'the Management API resource');\n const browserAppId = requiredInput('logto-edit-browser-id', 'the SPA App ID');\n const browserResource = el('logto-edit-browser-resource').value.trim() || null;\n const managementIdentityChanged = managementAppId !== logto.managementAppId;\n const managementSecret = el('logto-edit-management-secret').value;\n if (managementIdentityChanged && !managementSecret) throw new Error('Enter the new Management App secret');\n const browser = { ...logto.browser, apiResource: browserResource };\n const auth = {\n ...values.auth,\n audience: browserResource || browserAppId,\n browserClient: { appId: browserAppId, apiResource: browserResource }\n };\n await replaceConfiguration(\n {\n ...values,\n auth,\n logto: { ...logto, managementAppId, managementResource, browser }\n },\n managementSecret ? { 'logto.managementAppSecret': managementSecret } : undefined,\n 'Logto configuration saved. Sign in again if the SPA identity changed, then restart AgentConnect.'\n );\n }\n\n async function saveGithubConfiguration() {\n if (!currentStatus || !currentStatus.values.github) throw new Error('GitHub is not configured');\n const values = currentStatus.values;\n const previous = values.github;\n const appId = Number(requiredInput('github-edit-app-id', 'the GitHub App ID'));\n if (!Number.isSafeInteger(appId) || appId <= 0) throw new Error('GitHub App ID must be a positive integer');\n const slug = requiredInput('github-edit-slug', 'the GitHub App slug');\n const clientId = requiredInput('github-edit-client-id', 'the GitHub Client ID');\n const appChanged = appId !== previous.appId;\n const clientChanged = clientId !== previous.clientId;\n const webhookEnabled = el('github-edit-webhook-enabled').checked;\n const webhookEnabling = webhookEnabled && previous.webhookEnabled === false;\n const connectorReused = githubConnectorUsesDeployment(values);\n const clientSecret = el('github-edit-client-secret').value;\n const privateKey = el('github-edit-private-key').value.trim();\n const webhookSecret = el('github-edit-webhook-secret').value;\n const connectorSecret = el('github-edit-logto-secret').value;\n const webhookSecretStored = currentStatus.secrets.some((secret) => secret.key === 'github.webhookSecret' && secret.configured);\n if ((appChanged || clientChanged) && !clientSecret) throw new Error('Enter the new GitHub client secret');\n if (appChanged && !privateKey) throw new Error('Enter the new GitHub private key as base64');\n if (webhookEnabled && ((appChanged && !webhookSecret) || (webhookEnabling && !webhookSecret && !webhookSecretStored))) throw new Error('Enter the new GitHub webhook secret');\n if (connectorReused && (appChanged || clientChanged) && !connectorSecret) throw new Error('Enter the new Logto connector client secret');\n const secrets = {};\n if (clientSecret) secrets['github.clientSecret'] = clientSecret;\n if (privateKey) secrets['github.privateKeyB64'] = privateKey;\n if (webhookSecret) secrets['github.webhookSecret'] = webhookSecret;\n if (connectorSecret) secrets['logto.githubConnectorClientSecret'] = connectorSecret;\n const nextLogto = connectorReused && values.logto\n ? { ...values.logto, githubConnector: { ...values.logto.githubConnector, appId, slug, clientId } }\n : values.logto;\n await replaceConfiguration(\n {\n ...values,\n github: { ...previous, appId, slug, clientId, webhookEnabled },\n ...(nextLogto ? { logto: nextLogto } : {})\n },\n Object.keys(secrets).length ? secrets : undefined,\n 'GitHub App identity saved. Restart AgentConnect to apply it.'\n );\n if (connectorSecret) {\n await reconcileLogto(true);\n await load();\n }\n }\n\n async function saveSlackConfiguration() {\n if (!currentStatus || !currentStatus.values.slack) throw new Error('Slack is not configured');\n const values = currentStatus.values;\n const previous = values.slack;\n const appId = requiredInput('slack-edit-app-id', 'the Slack App ID');\n const clientId = requiredInput('slack-edit-client-id', 'the Slack Client ID');\n const changed = appId !== previous.appId || clientId !== previous.clientId;\n const clientSecret = el('slack-edit-client-secret').value;\n const signingSecret = el('slack-edit-signing-secret').value;\n if (changed && (!clientSecret || !signingSecret)) throw new Error('Enter both the new Slack client secret and signing secret');\n const secrets = {};\n if (clientSecret) secrets['slack.clientSecret'] = clientSecret;\n if (signingSecret) secrets['slack.signingSecret'] = signingSecret;\n const nextLogto = values.logto && values.logto.slackConnector\n ? { ...values.logto, slackConnector: { ...values.logto.slackConnector, appId, clientId } }\n : values.logto;\n await replaceConfiguration(\n {\n ...values,\n slack: { ...previous, appId, clientId },\n ...(nextLogto ? { logto: nextLogto } : {})\n },\n Object.keys(secrets).length ? secrets : undefined,\n 'Slack App identity saved. Restart AgentConnect to apply it.'\n );\n if (clientSecret && nextLogto && nextLogto.slackConnector) {\n await reconcileLogto(true);\n await load();\n }\n }\n\n async function clearProvider(provider) {\n if (!currentStatus) throw new Error('Deployment configuration is not loaded');\n const label = provider === 'github' ? 'GitHub' : provider === 'gitlab' ? 'GitLab' : provider === 'gitea' ? 'Gitea' : provider === 'slack' ? 'Slack' : provider === 'googlechat' ? 'Google Chat' : provider === 'linear' ? 'Linear' : provider === 'google' ? 'Google' : provider === 'feishu' ? 'Feishu' : 'Lark';\n if (!window.confirm('Clear the saved ' + label + ' configuration and secrets?')) return;\n const values = currentStatus.values;\n let next = values;\n let secrets = {};\n if (provider === 'github') {\n const connectorReused = githubConnectorUsesDeployment(values);\n const logto = connectorReused && values.logto\n ? {\n ...values.logto,\n githubConnector: null\n }\n : values.logto;\n next = { ...values, github: null, ...(logto ? { logto } : {}) };\n secrets = {\n 'github.clientSecret': null,\n 'github.privateKeyB64': null,\n 'github.webhookSecret': null,\n ...(connectorReused ? { 'logto.githubConnectorClientSecret': null } : {})\n };\n } else if (provider === 'gitea') {\n // Gitea holds no deployment secret, so clearing the address clears the whole entry.\n next = { ...values, gitea: null };\n } else if (provider === 'gitlab') {\n await json(await fetch(api + '/configure/gitlab', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ application: null })\n }));\n await load();\n message('GitLab configuration cleared. Its setup controls are available again.');\n return;\n } else if (provider === 'slack') {\n const logto = values.logto\n ? {\n ...values.logto,\n browser: values.logto.browser\n ? { ...values.logto.browser, socialProviders: values.logto.browser.socialProviders.filter((item) => item !== 'slack') }\n : values.logto.browser,\n slackConnector: null\n }\n : values.logto;\n next = { ...values, slack: null, ...(logto ? { logto } : {}) };\n secrets = { 'slack.clientSecret': null, 'slack.signingSecret': null };\n } else if (provider === 'googlechat') {\n next = { ...values, googleChat: null };\n secrets = { 'googleChat.serviceAccountKey': null };\n } else if (provider === 'linear') {\n next = { ...values, linear: null };\n secrets = { 'linear.clientSecret': null, 'linear.signingSecret': null };\n } else if (provider === 'google') {\n if (!values.logto) return;\n next = {\n ...values,\n logto: {\n ...values.logto,\n browser: values.logto.browser\n ? { ...values.logto.browser, socialProviders: values.logto.browser.socialProviders.filter((item) => item !== 'google') }\n : values.logto.browser,\n googleConnector: null\n }\n };\n secrets = { 'logto.googleConnectorClientSecret': null };\n } else if (provider === 'feishu' || provider === 'lark') {\n next = { ...values, [provider]: null };\n secrets = { [provider + '.loginAppSecret']: null };\n }\n await replaceConfiguration(next, secrets, label + ' configuration cleared. Its setup controls are available again.');\n }\n\n async function startGithub(prefix) {\n const result = await json(await fetch(api + '/create/github/start', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({\n purpose: 'deployment',\n name: el(prefix + '-name').value,\n ownership: ownership(prefix),\n connectLogto: prefix === 'bootstrap-github'\n })\n }));\n const form = document.createElement('form');\n form.method = 'post'; form.action = result.action;\n const manifest = document.createElement('input');\n manifest.type = 'hidden'; manifest.name = 'manifest'; manifest.value = JSON.stringify(result.manifest);\n form.append(manifest); document.body.append(form);\n message('Opening GitHub to review the complete integration App.');\n form.submit();\n }\n\n async function loadBootstrapInfo() {\n bootstrapInfo = await json(await fetch(api + '/bootstrap-info'));\n el('open-logto').href = bootstrapInfo.logtoAdminEndpoint;\n el('logto-settings').href = bootstrapInfo.logtoAdminEndpoint;\n if (!el('logto-app-id').value && bootstrapInfo.logtoManagementAppId) {\n el('logto-app-id').value = bootstrapInfo.logtoManagementAppId;\n }\n renderUriList('bootstrap-google-origins', bootstrapInfo.google.javascriptOrigins);\n renderUriList('bootstrap-google-redirects', bootstrapInfo.google.redirectUris);\n renderUriList('bootstrap-slack-redirects', [bootstrapInfo.slackLoginRedirectUrl]);\n el('logto-management-api-resource').value = bootstrapInfo.logtoManagementResource;\n el('bootstrap-github-submit').disabled = !bootstrapInfo.githubAvailable;\n if (!bootstrapInfo.githubAvailable) {\n el('bootstrap-github-note').textContent = 'GitHub App creation needs valid saved Web, API, and ingress URLs.';\n } else if (!bootstrapInfo.githubWebhookActive) {\n el('bootstrap-github-note').textContent = 'Creates the complete GitHub App now without submitting the localhost webhook URL. Add it after saving reachable HTTPS ingress.';\n } else {\n el('bootstrap-github-note').textContent = 'This creates one complete App for both GitHub sign-in and repository integration.';\n }\n el('bootstrap-slack-option').disabled = !bootstrapInfo.slackAvailable;\n el('bootstrap-slack-submit').disabled = !bootstrapInfo.slackAvailable;\n el('bootstrap-slack-note').textContent = bootstrapInfo.slackAvailable\n ? 'Creates one complete Slack App. Sign-in uses a separate openid profile email flow from workspace installation.'\n : 'Slack sign-in needs all public service URLs to use HTTPS. Use Google locally or expose the stack through trusted HTTPS endpoints.';\n if (!bootstrapInfo.slackAvailable && el('bootstrap-provider').value === 'slack') {\n el('bootstrap-provider').value = 'google';\n updateBootstrapProvider();\n }\n return bootstrapInfo;\n }\n\n async function logtoBootstrapFinding() {\n const report = await json(await fetch(api + '/check/logto', { headers: bearer() }));\n for (const id of ['logto.client_credentials', 'logto.roles_read']) {\n const finding = report.findings.find((candidate) => candidate.id === id);\n if (!finding || finding.status !== 'pass') {\n return finding || { status: 'fail', message: 'Logto Management API permissions could not be verified.' };\n }\n }\n return { status: 'pass', message: 'Logto Management API access is ready.' };\n }\n\n async function showBootstrap() {\n el('bootstrap').hidden = false;\n el('show-bootstrap').hidden = true;\n const info = bootstrapInfo || await loadBootstrapInfo();\n if (!info.logtoConfigured) {\n showBootstrapStep('logto');\n return;\n }\n const finding = await logtoBootstrapFinding();\n if (finding && finding.status === 'pass') {\n showBootstrapStep('provider');\n message('Logto Management API access is ready. Choose a sign-in provider.');\n return;\n }\n showBootstrapStep('logto');\n message(finding?.message || 'Logto Management API credentials could not be verified.', true);\n }\n\n async function bootstrapLogto() {\n await json(await fetch(api + '/bootstrap/logto', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({\n managementAppId: el('logto-app-id').value,\n managementAppSecret: el('logto-app-secret').value,\n managementResource: el('logto-management-api-resource').value\n })\n }));\n el('logto-app-secret').value = '';\n await loadBootstrapInfo();\n const finding = await logtoBootstrapFinding();\n if (!finding || finding.status !== 'pass') {\n throw new Error(finding?.message || 'Logto Management API credentials could not be verified.');\n }\n showBootstrapStep('provider');\n message('Logto Management API access is ready. Choose a sign-in provider.');\n }\n\n async function bootstrapGoogle() {\n const secret = el('bootstrap-google-secret').value;\n await json(await fetch(api + '/configure/google', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ clientId: el('bootstrap-google-id').value, clientSecret: secret })\n }));\n el('bootstrap-google-secret').value = '';\n await reconcileLogto(true);\n await load();\n }\n\n async function bootstrapGithub() {\n if (!bootstrapInfo || !bootstrapInfo.githubAvailable) throw new Error('GitHub App creation needs valid saved Web, API, and ingress URLs.');\n await startGithub('bootstrap-github');\n }\n\n async function bootstrapSlack() {\n if (!bootstrapInfo || !bootstrapInfo.slackAvailable) throw new Error('Slack sign-in requires all public service URLs to use HTTPS.');\n await createSlack('bootstrap-slack', true);\n await reconcileLogto();\n await load();\n }\n\n async function createSlack(prefix = 'slack', connectLogto = false) {\n const result = await json(await fetch(api + '/create/slack', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({\n name: el(prefix + '-name').value,\n configToken: el(prefix + '-token').value,\n connectLogto\n })\n }));\n el(prefix + '-token').value = '';\n if (!connectLogto) await load();\n message('Slack App ' + result.app.id + ' was created with the default integration manifest. Restart AgentConnect to apply it.');\n }\n\n async function checkGithub() {\n const result = await json(await fetch(api + '/check/github', { headers: bearer() }));\n el('github-settings').href = result.settingsUrl;\n el('github-settings').hidden = false;\n const label = result.missing.length ? 'Update required' : \"Can't verify automatically\";\n showDiff('github-drift', result.diff || [], label);\n match('github-match', result.status === 'pass' ? 'pass' : 'warn', result.status === 'pass' ? 'Matches' : label);\n message(result.status === 'pass' ? 'GitHub App matches the expected integration manifest.' : result.missing.length ? 'GitHub App settings need an update.' : \"GitHub can't expose callback, setup, or webhook-active settings through its API; check them in GitHub.\", result.status === 'fail');\n }\n\n async function connectGithubLogin() {\n await json(await fetch(api + '/configure/github-login', { method: 'POST', headers: bearer() }));\n await reconcileLogto();\n await load();\n message('The deployment GitHub App is now also used for Logto sign-in. Update its displayed callback URLs.');\n }\n\n async function connectSlackLogin() {\n if (!currentStatus || !currentStatus.values.slack || !currentStatus.values.logto) {\n throw new Error('Save the Slack App and Logto configuration first');\n }\n const values = currentStatus.values;\n const slack = values.slack;\n const logto = values.logto;\n await replaceConfiguration(\n {\n ...values,\n logto: {\n ...logto,\n browser: logto.browser\n ? { ...logto.browser, socialProviders: [...new Set([...logto.browser.socialProviders, 'slack'])] }\n : logto.browser,\n slackConnector: {\n appId: slack.appId,\n clientId: slack.clientId\n }\n }\n },\n undefined,\n 'Slack App is ready to connect to Logto.'\n );\n await reconcileLogto();\n await load();\n message('The deployment Slack App is now also used for Logto sign-in.');\n }\n\n async function checkSlack() {\n const token = requiredInput('slack-token', 'the temporary Slack App configuration token');\n const result = await json(await fetch(api + '/check/slack', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ configToken: token })\n }));\n el('slack-token').value = '';\n showDiff('slack-drift', result.diff || []);\n match('slack-match', result.status === 'pass' ? 'pass' : 'warn', result.status === 'pass' ? 'Matches' : 'Update required');\n message(result.status === 'pass' ? 'Slack App matches the default integration manifest.' : 'Slack App settings need an update.', result.status !== 'pass');\n }\n\n async function reconcileSlack() {\n const token = requiredInput('slack-token', 'the temporary Slack App configuration token');\n const result = await json(await fetch(api + '/reconcile/slack', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ configToken: token })\n }));\n el('slack-token').value = '';\n showDiff('slack-drift', result.diff || []);\n match('slack-match', result.status === 'pass' ? 'pass' : 'warn', result.status === 'pass' ? 'Matches' : 'Update required');\n message(\n result.status !== 'pass'\n ? 'Slack App was updated but some settings still differ.'\n : result.applied.length === 0\n ? 'Slack App already matches the default integration manifest.'\n : result.permissionsUpdated\n ? 'Slack App updated. Reinstall it in each workspace to grant the added scopes.'\n : 'Slack App updated.',\n result.status !== 'pass'\n );\n }\n\n async function checkGoogle() {\n const report = await checkLogto();\n const connector = report.findings.find((finding) => finding.id === 'logto.connectors');\n const google = currentStatus && currentStatus.values.logto && currentStatus.values.logto.googleConnector;\n const connectorDiff = connector && connector.diff\n ? connector.diff.filter((item) => item.field.toLowerCase().startsWith('google'))\n : [];\n const passed = Boolean(google && connector && connectorDiff.length === 0);\n showDiff('google-drift', connectorDiff);\n match('google-match', passed ? 'pass' : 'warn', passed ? 'Logto matches' : 'Update required');\n message(\n passed\n ? \"The Logto Google connector matches. Google OAuth redirect settings can't be verified automatically.\"\n : 'The Logto Google connector needs an update.',\n !passed\n );\n }\n\n async function checkRegionalLoginApp(region) {\n const result = await json(await fetch(api + '/check/regional-login-app/' + region, { headers: bearer() }));\n const label = region === 'feishu' ? 'Feishu' : 'Lark';\n showDiff(region + '-drift', result.diff || []);\n match(region + '-match', result.status === 'pass' ? 'pass' : result.status === 'fail' ? 'fail' : 'warn', result.status === 'pass' ? 'Matches' : result.status === 'fail' ? 'Update required' : 'Could not check');\n el(region + '-login-status').textContent = result.message;\n el(region + '-login-status').className = result.status === 'pass' ? 'ok' : result.status === 'fail' ? 'warn' : 'muted';\n message(result.message || (label + ' credential check completed.'), result.status === 'fail');\n }\n\n async function saveGitlab() {\n const clientSecret = el('gitlab-secret').value;\n const baseUrl = el('gitlab-base-url').value.trim();\n const saved = await json(await fetch(api + '/configure/gitlab', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({\n application: {\n clientId: requiredInput('gitlab-id', 'the GitLab Application ID'),\n ...(clientSecret ? { clientSecret } : {}),\n ...(baseUrl ? { baseUrl } : {})\n }\n })\n }));\n el('gitlab-secret').value = '';\n if (currentStatus.values.logto?.browser?.socialProviders.includes('gitlab')) await reconcileLogto(Boolean(clientSecret));\n await load();\n showGitlabProbe(saved.probe);\n message('GitLab OAuth application saved. Restart AgentConnect to apply it.');\n }\n\n // Only the URL shape blocks the save, so every other verdict is a line to read.\n function showGitlabProbe(probe) {\n const line = el('gitlab-probe');\n line.hidden = !probe;\n line.textContent = probe ? probe.message : '';\n }\n\n async function configureGitlabLogin(enabled) {\n await json(await fetch(api + '/configure/gitlab-login', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ enabled })\n }));\n try { await reconcileLogto(enabled); } finally { await load(); }\n message('GitLab sign-in ' + (enabled ? 'configured' : 'disabled') + '. Restart AgentConnect to apply it.');\n }\n\n async function saveGitea() {\n const baseUrl = el('gitea-base-url').value.trim();\n const saved = await json(await fetch(api + '/configure/gitea', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ instance: { ...(baseUrl ? { baseUrl } : {}) } })\n }));\n await load();\n // Shape and the version floor already blocked the save, so any verdict here is a line to read.\n const line = el('gitea-probe');\n line.hidden = !saved.probe;\n line.textContent = saved.probe ? saved.probe.message : '';\n message('Gitea instance saved. Restart AgentConnect to apply it.');\n }\n\n async function saveGoogleChat() {\n const projectNumber = el('googlechat-project-number-input').value.trim();\n const application = {\n projectId: requiredInput('googlechat-project-id-input', 'the Google Cloud project ID'),\n ...(projectNumber ? { projectNumber } : {})\n };\n const serviceAccountKey = el('googlechat-key').value.trim();\n let saved;\n try {\n saved = await json(await fetch(api + '/configure/google-chat', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ application: { ...application, ...(serviceAccountKey ? { serviceAccountKey } : {}) } })\n }));\n } finally {\n // The key never stays in the page once it has been submitted, whatever the outcome.\n el('googlechat-key').value = '';\n }\n await load();\n message('Google Chat app saved. ' + saved.probe.message + '. Restart AgentConnect to apply it.');\n }\n\n async function saveLinear() {\n const clientSecret = el('linear-client-secret').value;\n const signingSecret = el('linear-signing-secret').value;\n await json(await fetch(api + '/configure/linear', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({\n application: {\n clientId: requiredInput('linear-id', 'the Linear Client ID'),\n ...(clientSecret ? { clientSecret } : {}),\n ...(signingSecret ? { signingSecret } : {})\n }\n })\n }));\n el('linear-client-secret').value = '';\n el('linear-signing-secret').value = '';\n await load();\n message('Linear OAuth application saved. Restart AgentConnect to apply it.');\n }\n\n async function saveGoogle() {\n const secret = el('google-secret').value;\n await json(await fetch(api + '/configure/google', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({\n clientId: el('google-id').value,\n ...(secret ? { clientSecret: secret } : {})\n })\n }));\n el('google-secret').value = '';\n await reconcileLogto(Boolean(secret));\n await load();\n message('Google OAuth client and Logto connector are configured. Restart AgentConnect to apply the saved settings.');\n }\n\n async function saveLoginApp(provider, clear = false) {\n const app = loginApps[provider];\n if (clear && !window.confirm('Clear the saved ' + app.name + ' login application and secret?')) return;\n const clientSecret = el(provider + '-secret').value;\n const application = clear ? null : {\n clientId: requiredInput(provider + '-id', 'the ' + app.name + ' Client ID'),\n ...(clientSecret ? { clientSecret } : {})\n };\n // Microsoft's home tenant also makes the app the deployment's Teams app; empty turns that off.\n const teams = provider === 'azuread' && !clear ? { teamsTenantId: el('azuread-teams-tenant-input').value.trim() || null } : {};\n try {\n await json(await fetch(api + '/configure/login-app/' + provider, {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ application, ...teams })\n }));\n } finally {\n el(provider + '-secret').value = '';\n }\n try {\n await reconcileLogto(Boolean(clientSecret) && !clear);\n } finally {\n await load();\n }\n message(app.name + (clear ? ' sign-in configuration cleared.' : ' login application and Logto connector configured.') + ' Restart AgentConnect to apply the saved settings.');\n }\n\n function renderLoginAppChecks(report) {\n const connectors = report.findings.find((finding) => finding.id === 'logto.connectors');\n if (currentStatus?.values.logto?.browser?.socialProviders.includes('gitlab')) {\n const diff = connectors?.diff?.filter((item) => item.field.toLowerCase().startsWith('gitlab')) || [];\n const passed = Boolean(connectors && diff.length === 0 && (connectors.status === 'pass' || connectors.diff?.length));\n el('gitlab-login-status').textContent = passed\n ? 'GitLab connector matches. Confirm the redirect URLs and OpenID scopes in GitLab.'\n : 'GitLab sign-in could not be verified. Review the Logto check above.';\n }\n for (const [provider, app] of Object.entries(loginApps)) {\n if (!currentStatus?.values.logto?.[provider + 'Connector']) continue;\n const diff = connectors?.diff?.filter((item) => item.field.toLowerCase().startsWith(provider)) || [];\n const passed = Boolean(connectors && diff.length === 0 && (connectors.status === 'pass' || connectors.diff?.length));\n showDiff(provider + '-drift', diff);\n match(provider + '-match', passed ? 'pass' : 'warn', passed ? 'Logto matches' : 'Could not verify');\n el(provider + '-status').textContent = passed\n ? app.name + ' connector matches. Confirm the redirect URLs in the provider settings.'\n : app.name + ' connector could not be verified. Review the Logto check above.';\n }\n }\n\n function regionalLoginApp(prefix) {\n const appId = el(prefix + '-login-id').value.trim();\n const appSecret = el(prefix + '-login-secret').value;\n return appId ? { appId, ...(appSecret ? { appSecret } : {}) } : null;\n }\n\n async function saveRegionalLoginApp(region) {\n const app = regionalLoginApp(region);\n if (!app) throw new Error('Enter the ' + (region === 'feishu' ? 'Feishu' : 'Lark') + ' App ID');\n await json(await fetch(api + '/configure/regional-login-app', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ region, app })\n }));\n el(region + '-login-secret').value = '';\n await load();\n message((region === 'feishu' ? 'Feishu' : 'Lark') + ' tenant App is saved. Restart AgentConnect services to apply it.');\n }\n\n async function createRegionalLoginApp(region) {\n const popup = window.open('about:blank', '_blank');\n try {\n const started = await json(await fetch(api + '/create/regional-login-app/start', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ region, name: el(region + '-create-name').value })\n }));\n if (popup) popup.location.replace(started.authorizationUrl);\n else window.open(started.authorizationUrl, '_blank', 'noopener');\n message('Approve the new ' + (region === 'feishu' ? 'Feishu' : 'Lark') + ' App in the opened page. This page will finish saving it automatically.');\n while (true) {\n const result = await json(await fetch(api + '/create/regional-login-app/' + encodeURIComponent(started.id), { headers: bearer() }));\n if (result.status === 'completed') {\n await load();\n message((region === 'feishu' ? 'Feishu' : 'Lark') + ' App ' + result.appId + ' was created and saved. Restart AgentConnect services to apply it.');\n return;\n }\n if (result.status === 'failed') throw new Error('Regional App creation ' + result.reason + '. Start it again.');\n await new Promise((resolve) => setTimeout(resolve, Math.max(500, Math.min(result.retryAfterMs || 2000, 5000))));\n }\n } catch (error) {\n if (popup) popup.close();\n throw error;\n }\n }\n\n async function reconcileLogto(refreshConnectorSecrets) {\n return json(await fetch(api + '/reconcile/logto', {\n method: 'POST',\n headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ refreshConnectorSecrets: Boolean(refreshConnectorSecrets) })\n }));\n }\n\n async function refreshDeploymentConfig() {\n const status = await json(await fetch(api + '/deployment-config', { headers: bearer() }));\n el('access').hidden = true;\n el('setup').hidden = false;\n el('editor').hidden = false;\n currentRevision = status.revision;\n renderApps(status);\n return status;\n }\n\n async function checkLogto() {\n const report = await json(await fetch(api + '/check/logto', { headers: bearer() }));\n await refreshDeploymentConfig();\n const failures = report.findings.filter((finding) => finding.status !== 'pass');\n el('logto-status').textContent = failures.length === 0\n ? 'SPA redirects, connectors, and social-only sign-in match.'\n : failures.map((finding) => finding.message).join(' ');\n el('logto-status').className = failures.length === 0 ? 'ok' : 'warn';\n match('logto-match', failures.length === 0 ? 'pass' : 'warn', failures.length === 0 ? 'Matches' : 'Update required');\n showDiff(\n 'logto-drift',\n failures.flatMap((finding) => finding.diff && finding.diff.length\n ? finding.diff\n : [{ field: finding.id.replace(/^logto./, '').replaceAll('_', ' '), current: finding.message, expected: 'Matches expected configuration' }])\n );\n el('logto-settings').hidden = failures.length === 0;\n renderLoginAppChecks(report);\n return report;\n }\n\n function githubNotice(value) {\n if (value === 'deployment-created') return 'GitHub integration App created. Its private key and webhook secret are stored.';\n if (value === 'deployment-login-created') return 'GitHub integration App created and connected to Logto sign-in.';\n if (value === 'cancelled') return 'GitHub App creation was cancelled.';\n if (value === 'expired') return 'GitHub App creation expired. Start it again.';\n if (value === 'invalid-callback') return 'GitHub returned an invalid App creation callback.';\n if (value === 'conversion-failed') return 'GitHub may have created the App, but did not return complete credentials. Delete the orphaned App and retry.';\n if (value === 'save-failed') return 'GitHub created the App, but its credentials could not be saved. Delete the orphaned App and retry.';\n return '';\n }\n\n async function load() {\n let publicAuth = await authConfig();\n const currentUrl = new URL(location.href);\n const githubResult = currentUrl.searchParams.get('github');\n const notice = githubNotice(githubResult);\n if (githubResult === 'deployment-login-created' && publicAuth.mode === 'none') {\n await reconcileLogto();\n publicAuth = await authConfig();\n }\n if (githubResult) history.replaceState({}, '', '/');\n const hasToken = Boolean(sessionStorage.getItem(tokenKey));\n el('access').hidden = false;\n el('setup').hidden = true;\n el('login').hidden = true;\n el('open-logto').hidden = true;\n el('show-bootstrap').hidden = true;\n el('editor').hidden = true;\n el('bootstrap').hidden = true;\n if (publicAuth.logtoAdminEndpoint) {\n el('open-logto').href = publicAuth.logtoAdminEndpoint;\n el('logto-settings').href = publicAuth.logtoAdminEndpoint;\n }\n if (publicAuth.mode === 'none') {\n sessionStorage.removeItem(tokenKey);\n await loadBootstrapInfo();\n el('open-logto').hidden = false;\n el('show-bootstrap').hidden = false;\n message(notice || 'Logto sign-in is not configured. Set up the initial Logto administrator first.');\n return;\n }\n if (publicAuth.mode === 'unavailable') {\n el('open-logto').hidden = false;\n message(publicAuth.message || 'Logto sign-in is unavailable. Open Logto Console to review its settings.', true);\n return;\n }\n if (!hasToken) {\n el('login').hidden = false;\n message(notice || 'Sign in with Logto to continue.');\n return;\n }\n if (publicAuth.claimAvailable) {\n try {\n await json(await fetch(api + '/bootstrap/claim', { method: 'POST', headers: bearer() }));\n sessionStorage.removeItem(tokenKey);\n el('login').hidden = false;\n message('This first user is now an ADMIN. Sign in again to refresh the role claim.');\n return;\n } catch (error) {\n if (error.status !== 401 && error.status !== 403) throw error;\n sessionStorage.removeItem(tokenKey);\n el('login').hidden = false;\n message('Sign in with Logto to continue.', true);\n return;\n }\n }\n try {\n await loadBootstrapInfo();\n await refreshDeploymentConfig();\n message(notice);\n checkLogto().catch((error) => {\n el('logto-status').textContent = error.message;\n el('logto-status').className = 'warn';\n el('logto-settings').hidden = false;\n });\n } catch (error) {\n if (error.status === 401 || error.status === 403) {\n sessionStorage.removeItem(tokenKey);\n el('login').hidden = false;\n message('Sign in with a Logto ADMIN account.', true);\n }\n else throw error;\n }\n }\n\n async function saveSecretReplacement(key, value) {\n if (!currentStatus) throw new Error('Deployment configuration is not loaded');\n if (!value) throw new Error('Enter the replacement secret');\n const refreshLogto =\n (key === 'gitlab.clientSecret' && currentStatus.values.logto?.browser?.socialProviders.includes('gitlab')) ||\n (key === 'logto.githubConnectorClientSecret' && currentStatus.values.logto && currentStatus.values.logto.githubConnector) ||\n (key === 'logto.googleConnectorClientSecret' && currentStatus.values.logto && currentStatus.values.logto.googleConnector) ||\n Object.keys(loginApps).some((provider) => key === 'logto.' + provider + 'ConnectorClientSecret' && currentStatus.values.logto?.[provider + 'Connector']) ||\n (key === 'slack.clientSecret' && currentStatus.values.logto && currentStatus.values.logto.slackConnector);\n await json(await fetch(api + '/deployment-config', {\n method: 'PUT', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ expectedRevision: currentRevision, values: currentStatus.values, secrets: { [key]: value } })\n }));\n try {\n if (refreshLogto) await reconcileLogto(true);\n } finally {\n await load();\n }\n if (refreshLogto) {\n message('Secret replaced and applied to the Logto connector. Restart AgentConnect to apply deployment changes.');\n } else {\n message('Secret replaced. Restart AgentConnect to apply it.');\n }\n }\n\n function editSecret(button) {\n if (!currentStatus) return message('Deployment configuration is not loaded', true);\n const row = button.closest('.secret-line');\n const display = el(button.dataset.secretDisplay);\n if (!row || !display || row.querySelector('.secret-editor')) return;\n const editor = document.createElement('span'); editor.className = 'secret-editor';\n const input = document.createElement('input');\n input.type = 'password'; input.autocomplete = 'new-password'; input.placeholder = 'Enter replacement secret';\n const save = document.createElement('button'); save.textContent = 'Save';\n const cancel = document.createElement('button'); cancel.textContent = 'Cancel';\n const close = () => { editor.remove(); display.hidden = false; button.hidden = false; };\n save.onclick = async () => {\n save.disabled = true;\n try { await saveSecretReplacement(button.dataset.secretKey, input.value); }\n catch (error) { save.disabled = false; message(error.message, true); }\n };\n cancel.onclick = close;\n input.onkeydown = (event) => { if (event.key === 'Enter') save.click(); if (event.key === 'Escape') close(); };\n display.hidden = true; button.hidden = true;\n editor.append(input, save, cancel); row.append(editor); input.focus();\n }\n\n async function saveOptions() {\n if (!currentStatus) throw new Error('Deployment configuration is not loaded');\n const values = {\n ...currentStatus.values,\n features: {\n presetAgentsEnabled: el('preset-agents-enabled').checked,\n maxOrgsPerNonAdminUser: Number(el('max-orgs-per-non-admin-user').value)\n }\n };\n const saved = await json(await fetch(api + '/deployment-config', {\n method: 'PUT', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ expectedRevision: currentRevision, values })\n }));\n message('Saved deployment options. Restart AgentConnect to apply them.');\n await load();\n }\n\n el('login').onclick = () => signIn().catch((error) => message(error.message, true));\n el('show-bootstrap').onclick = () => showBootstrap().catch((error) => message(error.message, true));\n el('bootstrap-provider').onchange = updateBootstrapProvider;\n el('bootstrap-github-owner').onchange = () => updateOwner('bootstrap-github');\n el('github-owner').onchange = () => updateOwner('github');\n el('bootstrap-logto-submit').onclick = () => bootstrapLogto().catch((error) => message(error.message, true));\n el('bootstrap-back').onclick = () => showBootstrapStep('logto');\n el('bootstrap-google-submit').onclick = () => bootstrapGoogle().catch((error) => message(error.message, true));\n for (const prefix of ['bootstrap-sso', 'sso']) {\n el(prefix + '-protocol').onchange = () => updateSsoProtocol(prefix);\n el(prefix + '-create').onclick = () => createSso(prefix).catch((error) => message(error.message, true));\n el(prefix + '-submit').onclick = () => saveSso(prefix).catch((error) => message(error.message, true));\n }\n el('sso-remove').onclick = () => removeSso().catch((error) => message(error.message, true));\n el('bootstrap-github-submit').onclick = () => bootstrapGithub().catch((error) => message(error.message, true));\n el('bootstrap-slack-submit').onclick = () => bootstrapSlack().catch((error) => message(error.message, true));\n el('create-github').onclick = () => startGithub('github').catch((error) => message(error.message, true));\n el('save-logto-configuration').onclick = () => saveLogtoConfiguration().catch((error) => message(error.message, true));\n el('cancel-logto-configuration').onclick = cancelConfigurationEdit;\n el('save-github-configuration').onclick = () => saveGithubConfiguration().catch((error) => message(error.message, true));\n el('cancel-github-configuration').onclick = cancelConfigurationEdit;\n el('clear-github').onclick = () => clearProvider('github').catch((error) => message(error.message, true));\n el('connect-github-login').onclick = () => connectGithubLogin().catch((error) => message(error.message, true));\n el('connect-gitlab-login').onclick = () => configureGitlabLogin(true).catch((error) => message(error.message, true));\n el('disconnect-gitlab-login').onclick = () => configureGitlabLogin(false).catch((error) => message(error.message, true));\n el('check-gitlab-login').onclick = () => checkLogto().catch((error) => message(error.message, true));\n el('check-github').onclick = () => checkGithub().catch((error) => message(error.message, true));\n el('create-slack').onclick = () => createSlack('slack').catch((error) => message(error.message, true));\n el('connect-slack-login').onclick = () => connectSlackLogin().catch((error) => message(error.message, true));\n el('save-slack-configuration').onclick = () => saveSlackConfiguration().catch((error) => message(error.message, true));\n el('cancel-slack-configuration').onclick = cancelConfigurationEdit;\n el('clear-slack').onclick = () => clearProvider('slack').catch((error) => message(error.message, true));\n el('check-slack').onclick = () => checkSlack().catch((error) => message(error.message, true));\n el('reconcile-slack').onclick = () => reconcileSlack().catch((error) => message(error.message, true));\n el('save-gitea').onclick = () => saveGitea().catch((error) => message(error.message, true));\n el('cancel-gitea-configuration').onclick = cancelConfigurationEdit;\n el('clear-gitea').onclick = () => clearProvider('gitea').catch((error) => message(error.message, true));\n el('save-gitlab').onclick = () => saveGitlab().catch((error) => message(error.message, true));\n el('cancel-gitlab-configuration').onclick = cancelConfigurationEdit;\n el('clear-gitlab').onclick = () => clearProvider('gitlab').catch((error) => message(error.message, true));\n el('save-googlechat').onclick = () => saveGoogleChat().catch((error) => message(error.message, true));\n el('cancel-googlechat-configuration').onclick = cancelConfigurationEdit;\n el('clear-googlechat').onclick = () => clearProvider('googlechat').catch((error) => message(error.message, true));\n el('save-linear').onclick = () => saveLinear().catch((error) => message(error.message, true));\n el('cancel-linear-configuration').onclick = cancelConfigurationEdit;\n el('clear-linear').onclick = () => clearProvider('linear').catch((error) => message(error.message, true));\n el('save-google').onclick = () => saveGoogle().catch((error) => message(error.message, true));\n el('cancel-google-configuration').onclick = cancelConfigurationEdit;\n el('clear-google').onclick = () => clearProvider('google').catch((error) => message(error.message, true));\n el('check-google').onclick = () => checkGoogle().catch((error) => message(error.message, true));\n for (const provider of Object.keys(loginApps)) {\n el('save-' + provider).onclick = () => saveLoginApp(provider).catch((error) => message(error.message, true));\n el('cancel-' + provider + '-configuration').onclick = cancelConfigurationEdit;\n el('clear-' + provider).onclick = () => saveLoginApp(provider, true).catch((error) => message(error.message, true));\n el('check-' + provider).onclick = () => checkLogto().catch((error) => message(error.message, true));\n }\n el('create-feishu-login-app').onclick = () => createRegionalLoginApp('feishu').catch((error) => message(error.message, true));\n el('save-feishu-login-app').onclick = () => saveRegionalLoginApp('feishu').catch((error) => message(error.message, true));\n el('cancel-feishu-configuration').onclick = cancelConfigurationEdit;\n el('clear-feishu').onclick = () => clearProvider('feishu').catch((error) => message(error.message, true));\n el('check-feishu-login-app').onclick = () => checkRegionalLoginApp('feishu').catch((error) => message(error.message, true));\n el('create-lark-login-app').onclick = () => createRegionalLoginApp('lark').catch((error) => message(error.message, true));\n el('save-lark-login-app').onclick = () => saveRegionalLoginApp('lark').catch((error) => message(error.message, true));\n el('cancel-lark-configuration').onclick = cancelConfigurationEdit;\n el('clear-lark').onclick = () => clearProvider('lark').catch((error) => message(error.message, true));\n el('check-lark-login-app').onclick = () => checkRegionalLoginApp('lark').catch((error) => message(error.message, true));\n el('check-logto').onclick = () => checkLogto().catch((error) => message(error.message, true));\n el('reconcile-logto').onclick = () => reconcileLogto().then(load).catch((error) => message(error.message, true));\n el('logout').onclick = () => { sessionStorage.removeItem(tokenKey); load().catch((error) => message(error.message, true)); };\n el('save-options').onclick = () => saveOptions().catch((error) => message(error.message, true));\n for (const button of document.querySelectorAll('.edit-secret')) button.onclick = () => editSecret(button);\n for (const button of document.querySelectorAll('.edit-configuration')) button.onclick = () => beginConfigurationEdit(button.dataset.provider);\n updateBootstrapProvider();\n finishSignIn().then(load).catch((error) => message(error.message, true));\n <\/script>\n</body>\n</html>"
|
|
67095
67264
|
], [
|
|
67096
|
-
"<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n <title>AgentConnect deployment settings</title>\n <style>\n :root { color-scheme: light dark; font: 15px/1.5 system-ui, sans-serif; }\n body { max-width: 1280px; margin: 48px auto; padding: 0 20px 60px; }\n h1 { margin-bottom: 4px; } h2 { margin-top: 32px; } h3 { margin: 0 0 8px; }\n .muted { color: #777; } .row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }\n button, .button { padding: 8px 13px; cursor: pointer; }\n .button { border: 1px solid #8888; border-radius: 4px; color: inherit; text-decoration: none; display: inline-block; }\n input, select { width: min(520px, 100%); padding: 7px; box-sizing: border-box; }\n input[type=\"checkbox\"] { width: auto; padding: 0; }\n .field { display: grid; gap: 4px; margin: 10px 0; }\n .provider-stack { display: grid; gap: 16px; }\n .panel { border: 1px solid #8886; border-radius: 8px; padding: 16px; }\n .section-head, .provider-head { display: flex; justify-content: space-between; gap: 16px; align-items: start; }\n .section-head h2, .provider-head h3 { margin: 0; }\n .provider-head p { margin: 4px 0 0; }\n .badge { flex: none; border: 1px solid #8886; border-radius: 999px; padding: 3px 9px; font-size: 13px; }\n .badge.pass { color: #198754; border-color: #19875466; background: #19875412; }\n .badge.warn { color: #a66b00; border-color: #d99b1366; background: #d99b1315; }\n .badge.fail { color: #c33; border-color: #c333; background: #c3331111; }\n .credentials { display: grid; grid-template-columns: minmax(160px, 220px) minmax(0, 1fr); gap: 7px 16px; margin: 16px 0; }\n .credentials dt { color: #777; }\n .credentials dd { margin: 0; overflow-wrap: anywhere; }\n .credentials code { user-select: all; }\n .redacted { font-family: ui-monospace, monospace; letter-spacing: .08em; }\n .secret-line, .value-line { display: flex; gap: 10px; align-items: center; min-height: 30px; }\n .edit-secret { padding: 2px 8px; font-size: 13px; }\n .secret-editor { display: flex; gap: 8px; flex-wrap: wrap; width: 100%; }\n .secret-editor input { width: min(420px, 100%); }\n .edit-configuration { padding: 2px 8px; font-size: 13px; }\n .startup-owned { color: #777; font-size: 13px; }\n .danger { color: #c33; border-color: #c336; }\n .subsection { margin-top: 16px; padding-top: 14px; border-top: 1px solid #8883; }\n textarea { width: min(720px, 100%); min-height: 100px; padding: 7px; box-sizing: border-box; }\n .uris { margin: 8px 0; padding-left: 20px; } .uris code { user-select: all; }\n .notice { border-left: 4px solid #d99b13; padding: 8px 12px; background: #d99b1315; }\n .diff-title { display: block; margin-bottom: 8px; }\n .config-diff { display: grid; gap: 1px; border: 1px solid #8884; border-radius: 6px; overflow: hidden; background: #8884; }\n .diff-row { display: grid; grid-template-columns: minmax(140px, .8fr) minmax(0, 1.2fr) minmax(0, 1.2fr); background: Canvas; }\n .diff-row > * { min-width: 0; padding: 7px 9px; overflow-wrap: anywhere; white-space: pre-wrap; }\n .diff-head { font-size: 12px; font-weight: 600; color: #777; }\n .diff-field { font-weight: 600; }\n .diff-value { font-family: ui-monospace, monospace; font-size: 13px; }\n pre { padding: 12px; border-radius: 6px; background: #8881; overflow-x: auto; user-select: all; }\n #message { white-space: pre-wrap; padding: 10px 0; min-height: 1.5em; }\n .error { color: #c33; } .ok { color: #198754; } .warn { color: #a66b00; }\n code { overflow-wrap: anywhere; }\n .setup-layout { display: grid; grid-template-columns: 190px minmax(0, 1fr); gap: 32px; align-items: start; }\n .setup-nav { position: sticky; top: 24px; display: grid; gap: 3px; padding: 10px; border: 1px solid #8886; border-radius: 8px; background: Canvas; }\n .setup-nav a { padding: 7px 9px; border-radius: 5px; color: inherit; text-decoration: none; white-space: nowrap; }\n .setup-nav a:hover { background: #8882; }\n .setup-section { scroll-margin-top: 24px; }\n details.environment { margin: 0 0 24px; } details.environment summary { cursor: pointer; font-weight: 600; }\n @media (max-width: 760px) {\n body { margin-top: 24px; }\n .setup-layout { grid-template-columns: 1fr; gap: 18px; }\n .setup-nav { position: static; display: flex; overflow-x: auto; }\n .credentials { grid-template-columns: 1fr; gap: 2px; }\n .credentials dd { margin-bottom: 8px; }\n .diff-row { grid-template-columns: 1fr; }\n .diff-head { display: none; }\n .diff-value::before { display: block; margin-bottom: 2px; color: #777; font: 11px/1.4 system-ui, sans-serif; }\n .diff-current::before { content: 'Current'; }\n .diff-expected::before { content: 'Expected'; }\n }\n [hidden] { display: none !important; }\n </style>\n</head>\n<body>\n <section id=\"access\">\n <h1>AgentConnect Setup</h1>\n <p id=\"access-message\" class=\"muted\" aria-live=\"polite\">Checking Logto sign-in…</p>\n <div class=\"row\">\n <button id=\"login\" hidden>Sign in with Logto</button>\n <a id=\"open-logto\" class=\"button\" href=\"http://localhost:3002\" target=\"_blank\" rel=\"noopener\" hidden>Open Logto Console</a>\n <button id=\"show-bootstrap\" hidden>Continue setup</button>\n </div>\n\n <section id=\"bootstrap\" hidden>\n <h2>Set up sign-in</h2>\n <p id=\"bootstrap-progress\" class=\"muted\">Step 1 of 2</p>\n <div id=\"bootstrap-logto-step\" class=\"panel\">\n <h3>Connect Logto</h3>\n <p class=\"muted\">Enter the one-time Logto Management API credential. It is sealed in the deployment database and verified before continuing.</p>\n <label class=\"field\">Logto M2M App ID<input id=\"logto-app-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Logto M2M App Secret<input id=\"logto-app-secret\" type=\"password\" autocomplete=\"new-password\"></label>\n <label class=\"field\">Management API resource<input id=\"logto-management-api-resource\" type=\"url\" autocomplete=\"off\"></label>\n <p class=\"muted\">Logto Cloud uses <code>https://<tenant-id>.logto.app/api</code>. Keep <code>https://default.logto.app/api</code> for Logto OSS.</p>\n <button id=\"bootstrap-logto-submit\">Save Logto and continue</button>\n </div>\n <div id=\"bootstrap-provider-step\" hidden>\n <h3>Choose a sign-in provider</h3>\n <p class=\"muted\">Logto Management API access is ready. Configure one provider to enable sign-in.</p>\n <label class=\"field\">Sign-in provider\n <select id=\"bootstrap-provider\"><option value=\"google\">Google (works on localhost)</option><option value=\"github\">GitHub integration App</option><option id=\"bootstrap-slack-option\" value=\"slack\">Slack integration App</option></select>\n </label>\n <div id=\"bootstrap-google\" class=\"panel\">\n <h3>Google OAuth client</h3>\n <p class=\"muted\">Create a Web application client in Google Auth Platform, then paste its credentials here.</p>\n <p>Authorized JavaScript origin:</p><ul id=\"bootstrap-google-origins\" class=\"uris\"></ul>\n <p>Authorized redirect URIs:</p><ul id=\"bootstrap-google-redirects\" class=\"uris\"></ul>\n <a class=\"button\" href=\"https://console.cloud.google.com/auth/clients\" target=\"_blank\" rel=\"noopener\">Open Google settings</a>\n <label class=\"field\">Client ID<input id=\"bootstrap-google-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Client Secret<input id=\"bootstrap-google-secret\" type=\"password\" autocomplete=\"new-password\"></label>\n <button id=\"bootstrap-google-submit\">Save Google OAuth and configure Logto</button>\n </div>\n <div id=\"bootstrap-github\" class=\"panel\" hidden>\n <h3>GitHub integration App</h3>\n <p id=\"bootstrap-github-note\" class=\"muted\">This creates one complete App for both GitHub sign-in and repository integration.</p>\n <label class=\"field\">Owner\n <select id=\"bootstrap-github-owner\"><option value=\"personal\">Personal account</option><option value=\"organization\">GitHub organization</option></select>\n </label>\n <label id=\"bootstrap-github-org-field\" class=\"field\" hidden>Organization login<input id=\"bootstrap-github-org\" autocomplete=\"off\"></label>\n <label class=\"field\">App name<input id=\"bootstrap-github-name\" value=\"AgentConnect\"></label>\n <button id=\"bootstrap-github-submit\">Create GitHub App and configure Logto</button>\n </div>\n <div id=\"bootstrap-slack\" class=\"panel\" hidden>\n <h3>Slack integration App</h3>\n <p id=\"bootstrap-slack-note\" class=\"muted\">Creates one complete Slack App for workspace installation and a separate Sign in with Slack OIDC flow.</p>\n <p>Logto redirect URI:</p><ul id=\"bootstrap-slack-redirects\" class=\"uris\"></ul>\n <label class=\"field\">App name<input id=\"bootstrap-slack-name\" value=\"AgentConnect\"></label>\n <label class=\"field\">Temporary App configuration token<input id=\"bootstrap-slack-token\" type=\"password\" autocomplete=\"new-password\"></label>\n <button id=\"bootstrap-slack-submit\">Create Slack App and configure Logto</button>\n </div>\n <button id=\"bootstrap-back\">Back to Logto credentials</button>\n </div>\n </section>\n </section>\n\n <main id=\"setup\" hidden>\n <div class=\"setup-layout\">\n <nav class=\"setup-nav\" aria-label=\"Deployment settings\">\n <a href=\"#startup-section\">Startup</a>\n <a href=\"#logto-section\">Logto</a>\n <a href=\"#github-section\">GitHub</a>\n <a href=\"#gitlab-section\">GitLab</a>\n <a href=\"#gitea-section\">Gitea</a>\n <a href=\"#slack-section\">Slack</a>\n <a href=\"#linear-section\">Linear</a>\n <a href=\"#google-section\">Google</a>\n <a href=\"#telegram-section\">Telegram</a>\n <a href=\"#discord-section\">Discord</a>\n <a href=\"#azuread-section\">Microsoft</a>\n <a href=\"#googlechat-section\">Google Chat</a>\n <a href=\"#feishu-section\">Feishu</a>\n <a href=\"#lark-section\">Lark</a>\n <a href=\"#options-section\">Options</a>\n </nav>\n <div class=\"setup-content\">\n <h1>AgentConnect deployment settings</h1>\n <p class=\"muted\">Saved settings take effect after the stack is restarted.</p>\n <div class=\"row\">\n <button id=\"logout\">Log out</button>\n </div>\n <div id=\"message\" aria-live=\"polite\"></div>\n\n <section id=\"editor\" hidden>\n <details id=\"startup-section\" class=\"environment setup-section\" open>\n <summary>Startup environment</summary>\n <p class=\"notice\">Public service URLs come from <code>.env</code>. Provider callbacks below are derived from these values.</p>\n <pre id=\"startup-environment\"></pre>\n </details>\n\n <section id=\"logto-section\" class=\"setup-section\" aria-labelledby=\"logto-heading\">\n <div class=\"section-head\">\n <div><h2 id=\"logto-heading\">Logto</h2><p class=\"muted\">Authentication and administrator access.</p></div>\n <span id=\"logto-match\" class=\"badge\">Not checked</span>\n </div>\n <div class=\"panel\">\n <dl class=\"credentials\">\n <dt>Management endpoint</dt><dd class=\"value-line\"><code id=\"logto-management-endpoint\">Not configured</code><span class=\"startup-owned\">startup environment</span></dd>\n <dt>Management App ID</dt><dd class=\"value-line\"><code id=\"logto-management-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"logto\">Edit</button></dd>\n <dt>Management API resource</dt><dd class=\"value-line\"><code id=\"logto-management-resource\">Not configured</code><button class=\"edit-configuration\" data-provider=\"logto\">Edit</button></dd>\n <dt>Management App secret</dt><dd class=\"secret-line\"><span id=\"logto-management-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"logto.managementAppSecret\" data-secret-display=\"logto-management-secret-display\">Edit</button></dd>\n <dt>Sign-in endpoint</dt><dd class=\"value-line\"><code id=\"logto-browser-endpoint\">Not configured</code><span class=\"startup-owned\">startup environment</span></dd>\n <dt>SPA App ID</dt><dd class=\"value-line\"><code id=\"logto-browser-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"logto\">Edit</button></dd>\n <dt>Browser API resource</dt><dd class=\"value-line\"><code id=\"logto-browser-resource\">Not configured</code><button class=\"edit-configuration\" data-provider=\"logto\">Edit</button></dd>\n </dl>\n <div id=\"logto-edit-controls\" class=\"subsection\" hidden>\n <h3>Edit Logto configuration</h3>\n <label class=\"field\">Management App ID<input id=\"logto-edit-management-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Management API resource<input id=\"logto-edit-management-resource\" autocomplete=\"off\"></label>\n <label class=\"field\">New Management App secret<input id=\"logto-edit-management-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when management identity changes\"></label>\n <label class=\"field\">SPA App ID<input id=\"logto-edit-browser-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Browser API resource<input id=\"logto-edit-browser-resource\" autocomplete=\"off\" placeholder=\"Optional\"></label>\n <div class=\"row\"><button id=\"save-logto-configuration\">Save configuration</button><button id=\"cancel-logto-configuration\">Cancel</button></div>\n </div>\n <p id=\"logto-status\" class=\"muted\">Checking redirects and sign-in settings…</p>\n <div id=\"logto-drift\" class=\"notice\" hidden></div>\n <div class=\"row\">\n <button id=\"check-logto\">Check match</button>\n <button id=\"reconcile-logto\">Apply expected settings</button>\n <a id=\"logto-settings\" class=\"button\" target=\"_blank\" rel=\"noopener\" hidden>Open Logto Console</a>\n </div>\n </div>\n </section>\n\n <h2>Providers</h2>\n <div class=\"provider-stack\">\n <section id=\"github-section\" class=\"panel setup-section\" aria-labelledby=\"github-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"github-heading\">GitHub</h3><p class=\"muted\">Repository integration and optional Logto sign-in.</p></div>\n <span id=\"github-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>App ID</dt><dd class=\"value-line\"><code id=\"github-app-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"github\">Edit</button></dd>\n <dt>App slug</dt><dd class=\"value-line\"><code id=\"github-app-slug\">Not configured</code><button class=\"edit-configuration\" data-provider=\"github\">Edit</button></dd>\n <dt>Client ID</dt><dd class=\"value-line\"><code id=\"github-client-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"github\">Edit</button></dd>\n <dt>Webhook delivery</dt><dd class=\"value-line\"><code id=\"github-webhook-enabled\">Not configured</code><button class=\"edit-configuration\" data-provider=\"github\">Edit</button></dd>\n <dt>Client secret</dt><dd class=\"secret-line\"><span id=\"github-client-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"github.clientSecret\" data-secret-display=\"github-client-secret-display\">Edit</button></dd>\n <dt>Private key</dt><dd class=\"secret-line\"><span id=\"github-private-key-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"github.privateKeyB64\" data-secret-display=\"github-private-key-display\">Edit</button></dd>\n <dt>Webhook secret</dt><dd class=\"secret-line\"><span id=\"github-webhook-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"github.webhookSecret\" data-secret-display=\"github-webhook-secret-display\">Edit</button></dd>\n <dt>Logto connector secret</dt><dd class=\"secret-line\"><span id=\"github-logto-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"logto.githubConnectorClientSecret\" data-secret-display=\"github-logto-secret-display\">Edit</button></dd>\n </dl>\n <p id=\"github-status\" class=\"muted\"></p>\n <div id=\"github-drift\" class=\"notice\" hidden></div>\n <div id=\"github-edit-controls\" class=\"subsection\" hidden>\n <h3>Edit GitHub App identity</h3>\n <label class=\"field\">App ID<input id=\"github-edit-app-id\" inputmode=\"numeric\" autocomplete=\"off\"></label>\n <label class=\"field\">App slug<input id=\"github-edit-slug\" autocomplete=\"off\"></label>\n <label class=\"field\">Client ID<input id=\"github-edit-client-id\" autocomplete=\"off\"></label>\n <label><input id=\"github-edit-webhook-enabled\" type=\"checkbox\"> Enable Relay webhook delivery</label>\n <label class=\"field\">New client secret<input id=\"github-edit-client-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when App or Client ID changes\"></label>\n <label class=\"field\">New private key (base64)<textarea id=\"github-edit-private-key\" autocomplete=\"off\" placeholder=\"Required when App ID changes\"></textarea></label>\n <label class=\"field\">New webhook secret<input id=\"github-edit-webhook-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when an active webhook App ID changes\"></label>\n <label id=\"github-edit-logto-secret-field\" class=\"field\" hidden>New Logto connector client secret<input id=\"github-edit-logto-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when this App is also used for sign-in\"></label>\n <div class=\"row\"><button id=\"save-github-configuration\">Save configuration</button><button id=\"cancel-github-configuration\">Cancel</button></div>\n </div>\n <div id=\"github-create-controls\" class=\"subsection\">\n <label class=\"field\">Owner\n <select id=\"github-owner\"><option value=\"personal\">Personal account</option><option value=\"organization\">GitHub organization</option></select>\n </label>\n <label id=\"github-org-field\" class=\"field\" hidden>Organization login<input id=\"github-org\" autocomplete=\"off\"></label>\n <label class=\"field\">App name<input id=\"github-name\" value=\"AgentConnect\"></label>\n </div>\n <div class=\"row\">\n <button id=\"create-github\">Create GitHub App</button>\n <button id=\"connect-github-login\" hidden>Use for Logto sign-in</button>\n <button id=\"check-github\" hidden>Check match</button>\n <button id=\"clear-github\" class=\"danger\" hidden>Clear configuration</button>\n <a id=\"github-settings\" class=\"button\" target=\"_blank\" rel=\"noopener\" hidden>Open GitHub settings</a>\n </div>\n </section>\n\n <section id=\"gitlab-section\" class=\"panel setup-section\" aria-labelledby=\"gitlab-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"gitlab-heading\">GitLab</h3><p class=\"muted\">OAuth application used to administer projects on this deployment's GitLab instance.</p></div>\n <span id=\"gitlab-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>Instance</dt><dd class=\"value-line\"><code id=\"gitlab-instance\">https://gitlab.com</code></dd>\n <dt>Application ID</dt><dd class=\"value-line\"><code id=\"gitlab-client-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"gitlab\">Edit</button></dd>\n <dt>Secret</dt><dd class=\"secret-line\"><span id=\"gitlab-client-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"gitlab.clientSecret\" data-secret-display=\"gitlab-client-secret-display\">Edit</button></dd>\n </dl>\n <p id=\"gitlab-status\" class=\"muted\"></p>\n <p id=\"gitlab-probe\" class=\"muted\" hidden></p>\n <div id=\"gitlab-drift\" class=\"notice\" hidden></div>\n <p>Redirect URI:</p><ul id=\"gitlab-callbacks\" class=\"uris\"></ul>\n <p>Scopes:</p><ul id=\"gitlab-scopes\" class=\"uris\"></ul>\n <p class=\"muted\">GitLab does not expose OAuth application creation through an API. In User settings → Applications, or a group's Settings → Applications, add an application whose redirect URI is exactly the value above, keep Confidential selected, grant the scopes above, then save the generated Application ID and Secret here. GitLab shows the secret only once.</p>\n <p class=\"muted\">Use your own user applications unless you are an instance administrator registering one application for everyone; an instance-wide application lives in the Admin area instead.</p>\n <p class=\"muted\">Creating each agent's bot account later needs authority this page cannot verify — no GitLab API reports it: either connect an instance administrator (whose API token cannot act as one while Admin Mode is enabled), or, on Premium and Ultimate, turn on Admin → Settings → General → Account and limit → “Allow top-level group Owners to create service accounts”.</p>\n <div class=\"row\"><a id=\"gitlab-applications\" class=\"button\" href=\"https://gitlab.com/-/user_settings/applications\" target=\"_blank\" rel=\"noopener\">Open GitLab applications</a><a id=\"gitlab-admin-applications\" class=\"button\" href=\"https://gitlab.com/admin/applications\" target=\"_blank\" rel=\"noopener\">Open admin applications</a></div>\n <div id=\"gitlab-config-controls\" class=\"subsection\">\n <label class=\"field\">Instance base URL<input id=\"gitlab-base-url\" autocomplete=\"off\" placeholder=\"Leave empty for https://gitlab.com\"></label>\n <label class=\"field\">Application ID<input id=\"gitlab-id\" autocomplete=\"off\"></label>\n <label id=\"gitlab-initial-secret-field\" class=\"field\">Secret<input id=\"gitlab-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when the Application ID changes\"></label>\n </div>\n <div class=\"row\"><button id=\"save-gitlab\">Save GitLab application</button><button id=\"cancel-gitlab-configuration\" hidden>Cancel</button><button id=\"clear-gitlab\" class=\"danger\" hidden>Clear configuration</button></div>\n <div class=\"subsection\">\n <h4>GitLab sign-in</h4>\n <p>Add these redirect URIs and the openid, profile and email scopes to the same confidential OAuth application.</p>\n <ul id=\"gitlab-login-redirects\" class=\"uris\"></ul>\n <p id=\"gitlab-login-status\" class=\"muted\"></p>\n <div class=\"row\"><button id=\"connect-gitlab-login\">Use for Logto sign-in</button><button id=\"disconnect-gitlab-login\" hidden>Disable GitLab sign-in</button><button id=\"check-gitlab-login\" hidden>Check Logto</button></div>\n </div>\n </section>\n\n <section id=\"gitea-section\" class=\"panel setup-section\" aria-labelledby=\"gitea-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"gitea-heading\">Gitea</h3><p class=\"muted\">The Gitea instance this deployment addresses. Gitea registers no application, so the address is the whole setting.</p></div>\n <span id=\"gitea-match\" class=\"badge\">Default</span>\n </div>\n <dl class=\"credentials\">\n <dt>Instance</dt><dd class=\"value-line\"><code id=\"gitea-instance\">https://gitea.com</code><button class=\"edit-configuration\" data-provider=\"gitea\">Edit</button></dd>\n </dl>\n <p id=\"gitea-status\" class=\"muted\"></p>\n <p id=\"gitea-probe\" class=\"muted\" hidden></p>\n <p class=\"muted\">Each organization connects Gitea by pasting the personal access token of a bot user it created, in the console — nothing about that identity is a deployment setting. Gitea 1.23 or later, or Forgejo 15 or later, is required, and the address is checked against that floor when it is saved.</p>\n <p class=\"muted\">Leave the field empty for gitea.com. A self-hosted instance under a path prefix keeps the prefix. A Forgejo instance, Codeberg included, is entered the same way.</p>\n <div id=\"gitea-config-controls\" class=\"subsection\">\n <label class=\"field\">Instance base URL<input id=\"gitea-base-url\" autocomplete=\"off\" placeholder=\"Leave empty for https://gitea.com\"></label>\n </div>\n <div class=\"row\"><button id=\"save-gitea\">Save Gitea instance</button><button id=\"cancel-gitea-configuration\" hidden>Cancel</button><button id=\"clear-gitea\" class=\"danger\" hidden>Clear configuration</button></div>\n </section>\n\n <section id=\"slack-section\" class=\"panel setup-section\" aria-labelledby=\"slack-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"slack-heading\">Slack</h3><p class=\"muted\">One App for workspace integration and Logto sign-in.</p></div>\n <span id=\"slack-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>App ID</dt><dd class=\"value-line\"><code id=\"slack-app-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"slack\">Edit</button></dd>\n <dt>Client ID</dt><dd class=\"value-line\"><code id=\"slack-client-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"slack\">Edit</button></dd>\n <dt>Client secret</dt><dd class=\"secret-line\"><span id=\"slack-client-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"slack.clientSecret\" data-secret-display=\"slack-client-secret-display\">Edit</button></dd>\n <dt>Signing secret</dt><dd class=\"secret-line\"><span id=\"slack-signing-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"slack.signingSecret\" data-secret-display=\"slack-signing-secret-display\">Edit</button></dd>\n <dt>Logto sign-in</dt><dd id=\"slack-logto-status\">Not configured</dd>\n </dl>\n <p id=\"slack-status\" class=\"muted\"></p>\n <div id=\"slack-drift\" class=\"notice\" hidden></div>\n <div id=\"slack-edit-controls\" class=\"subsection\" hidden>\n <h3>Edit Slack App identity</h3>\n <label class=\"field\">App ID<input id=\"slack-edit-app-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Client ID<input id=\"slack-edit-client-id\" autocomplete=\"off\"></label>\n <label class=\"field\">New client secret<input id=\"slack-edit-client-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when identity changes\"></label>\n <label class=\"field\">New signing secret<input id=\"slack-edit-signing-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when identity changes\"></label>\n <div class=\"row\"><button id=\"save-slack-configuration\">Save configuration</button><button id=\"cancel-slack-configuration\">Cancel</button></div>\n </div>\n <div class=\"subsection\">\n <label id=\"slack-name-field\" class=\"field\">App name<input id=\"slack-name\" value=\"AgentConnect\"></label>\n <label class=\"field\">Temporary App configuration token<input id=\"slack-token\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Used only for create, check or update\"></label>\n </div>\n <div class=\"row\">\n <button id=\"create-slack\">Create Slack App</button>\n <button id=\"connect-slack-login\" hidden>Use for Logto sign-in</button>\n <button id=\"check-slack\" hidden>Check match</button>\n <button id=\"reconcile-slack\" hidden>Apply update</button>\n <button id=\"clear-slack\" class=\"danger\" hidden>Clear configuration</button>\n <a id=\"slack-settings\" class=\"button\" target=\"_blank\" rel=\"noopener\" hidden>Open Slack settings</a>\n </div>\n </section>\n\n <section id=\"linear-section\" class=\"panel setup-section\" aria-labelledby=\"linear-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"linear-heading\">Linear</h3><p class=\"muted\">One OAuth application for the whole deployment. Every workspace connects through it.</p></div>\n <span id=\"linear-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>Client ID</dt><dd class=\"value-line\"><code id=\"linear-client-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"linear\">Edit</button></dd>\n <dt>Client secret</dt><dd class=\"secret-line\"><span id=\"linear-client-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"linear.clientSecret\" data-secret-display=\"linear-client-secret-display\">Edit</button></dd>\n <dt>Webhook signing secret</dt><dd class=\"secret-line\"><span id=\"linear-signing-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"linear.signingSecret\" data-secret-display=\"linear-signing-secret-display\">Edit</button></dd>\n </dl>\n <p id=\"linear-status\" class=\"muted\"></p>\n <div id=\"linear-drift\" class=\"notice\" hidden></div>\n <p>Callback URL:</p><ul id=\"linear-callbacks\" class=\"uris\"></ul>\n <p>Webhook URL:</p><ul id=\"linear-webhooks\" class=\"uris\"></ul>\n <p class=\"muted\">Linear does not expose OAuth application creation through an API. In Settings → API → OAuth applications, create one application under the product's own name and icon, add the callback URL above, then save its Client ID, Client secret, and webhook signing secret here.</p>\n <p class=\"muted\">On the same application, enable webhooks, point them at the webhook URL above, and check the <strong>Agent session events</strong> category.</p>\n <p class=\"muted\">Until that category is enabled, agent sessions are disabled for the whole application: delegating an issue only sets the delegate badge and no session is created. Enabling it later raises a new scope, so every already-connected workspace must reconnect before webhooks arrive.</p>\n <div class=\"row\"><a id=\"linear-applications\" class=\"button\" href=\"https://linear.app/settings/api/applications\" target=\"_blank\" rel=\"noopener\">Open Linear OAuth applications</a></div>\n <div id=\"linear-config-controls\" class=\"subsection\">\n <label class=\"field\">Client ID<input id=\"linear-id\" autocomplete=\"off\"></label>\n <div id=\"linear-initial-secret-fields\">\n <label class=\"field\">Client secret<input id=\"linear-client-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when the Client ID changes\"></label>\n <label class=\"field\">Webhook signing secret<input id=\"linear-signing-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when the Client ID changes\"></label>\n </div>\n </div>\n <div class=\"row\"><button id=\"save-linear\">Save Linear application</button><button id=\"cancel-linear-configuration\" hidden>Cancel</button><button id=\"clear-linear\" class=\"danger\" hidden>Clear configuration</button></div>\n </section>\n\n <section id=\"google-section\" class=\"panel setup-section\" aria-labelledby=\"google-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"google-heading\">Google</h3><p class=\"muted\">OAuth client used by the Logto Google connector.</p></div>\n <span id=\"google-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>Client ID</dt><dd class=\"value-line\"><code id=\"google-client-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"google\">Edit</button></dd>\n <dt>Client secret</dt><dd class=\"secret-line\"><span id=\"google-client-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"logto.googleConnectorClientSecret\" data-secret-display=\"google-client-secret-display\">Edit</button></dd>\n </dl>\n <p id=\"google-status\" class=\"muted\"></p>\n <div id=\"google-drift\" class=\"notice\" hidden></div>\n <p>Authorized JavaScript origin:</p><ul id=\"google-origins\" class=\"uris\"></ul>\n <p>Authorized redirect URIs:</p><ul id=\"google-redirects\" class=\"uris\"></ul>\n <p class=\"muted\">Google does not expose OAuth client redirect settings through an API. Copy these required values into Google Auth Platform; Setup can verify only the Logto connector.</p>\n <div class=\"row\"><a class=\"button\" href=\"https://console.cloud.google.com/auth/clients\" target=\"_blank\" rel=\"noopener\">Open Google settings</a></div>\n <div id=\"google-config-controls\" class=\"subsection\">\n <label class=\"field\">Client ID<input id=\"google-id\" autocomplete=\"off\"></label>\n <label id=\"google-initial-secret-field\" class=\"field\">Client secret<input id=\"google-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when Client ID changes\"></label>\n </div>\n <div class=\"row\"><button id=\"save-google\">Save Google client</button><button id=\"cancel-google-configuration\" hidden>Cancel</button><button id=\"check-google\" hidden>Check match</button><button id=\"clear-google\" class=\"danger\" hidden>Clear configuration</button></div>\n </section>\n\n ",
|
|
67265
|
+
"<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n <title>AgentConnect deployment settings</title>\n <style>\n :root { color-scheme: light dark; font: 15px/1.5 system-ui, sans-serif; }\n body { max-width: 1280px; margin: 48px auto; padding: 0 20px 60px; }\n h1 { margin-bottom: 4px; } h2 { margin-top: 32px; } h3 { margin: 0 0 8px; }\n .muted { color: #777; } .row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }\n button, .button { padding: 8px 13px; cursor: pointer; }\n .button { border: 1px solid #8888; border-radius: 4px; color: inherit; text-decoration: none; display: inline-block; }\n input, select { width: min(520px, 100%); padding: 7px; box-sizing: border-box; }\n input[type=\"checkbox\"] { width: auto; padding: 0; }\n .field { display: grid; gap: 4px; margin: 10px 0; }\n .provider-stack { display: grid; gap: 16px; }\n .panel { border: 1px solid #8886; border-radius: 8px; padding: 16px; }\n .section-head, .provider-head { display: flex; justify-content: space-between; gap: 16px; align-items: start; }\n .section-head h2, .provider-head h3 { margin: 0; }\n .provider-head p { margin: 4px 0 0; }\n .badge { flex: none; border: 1px solid #8886; border-radius: 999px; padding: 3px 9px; font-size: 13px; }\n .badge.pass { color: #198754; border-color: #19875466; background: #19875412; }\n .badge.warn { color: #a66b00; border-color: #d99b1366; background: #d99b1315; }\n .badge.fail { color: #c33; border-color: #c333; background: #c3331111; }\n .credentials { display: grid; grid-template-columns: minmax(160px, 220px) minmax(0, 1fr); gap: 7px 16px; margin: 16px 0; }\n .credentials dt { color: #777; }\n .credentials dd { margin: 0; overflow-wrap: anywhere; }\n .credentials code { user-select: all; }\n .redacted { font-family: ui-monospace, monospace; letter-spacing: .08em; }\n .secret-line, .value-line { display: flex; gap: 10px; align-items: center; min-height: 30px; }\n .edit-secret { padding: 2px 8px; font-size: 13px; }\n .secret-editor { display: flex; gap: 8px; flex-wrap: wrap; width: 100%; }\n .secret-editor input { width: min(420px, 100%); }\n .edit-configuration { padding: 2px 8px; font-size: 13px; }\n .startup-owned { color: #777; font-size: 13px; }\n .danger { color: #c33; border-color: #c336; }\n .subsection { margin-top: 16px; padding-top: 14px; border-top: 1px solid #8883; }\n textarea { width: min(720px, 100%); min-height: 100px; padding: 7px; box-sizing: border-box; }\n .uris { margin: 8px 0; padding-left: 20px; } .uris code { user-select: all; }\n .notice { border-left: 4px solid #d99b13; padding: 8px 12px; background: #d99b1315; }\n .diff-title { display: block; margin-bottom: 8px; }\n .config-diff { display: grid; gap: 1px; border: 1px solid #8884; border-radius: 6px; overflow: hidden; background: #8884; }\n .diff-row { display: grid; grid-template-columns: minmax(140px, .8fr) minmax(0, 1.2fr) minmax(0, 1.2fr); background: Canvas; }\n .diff-row > * { min-width: 0; padding: 7px 9px; overflow-wrap: anywhere; white-space: pre-wrap; }\n .diff-head { font-size: 12px; font-weight: 600; color: #777; }\n .diff-field { font-weight: 600; }\n .diff-value { font-family: ui-monospace, monospace; font-size: 13px; }\n pre { padding: 12px; border-radius: 6px; background: #8881; overflow-x: auto; user-select: all; }\n #message { white-space: pre-wrap; padding: 10px 0; min-height: 1.5em; }\n .error { color: #c33; } .ok { color: #198754; } .warn { color: #a66b00; }\n code { overflow-wrap: anywhere; }\n .setup-layout { display: grid; grid-template-columns: 190px minmax(0, 1fr); gap: 32px; align-items: start; }\n .setup-nav { position: sticky; top: 24px; display: grid; gap: 3px; padding: 10px; border: 1px solid #8886; border-radius: 8px; background: Canvas; }\n .setup-nav a { padding: 7px 9px; border-radius: 5px; color: inherit; text-decoration: none; white-space: nowrap; }\n .setup-nav a:hover { background: #8882; }\n .setup-section { scroll-margin-top: 24px; }\n details.environment { margin: 0 0 24px; } details.environment summary { cursor: pointer; font-weight: 600; }\n @media (max-width: 760px) {\n body { margin-top: 24px; }\n .setup-layout { grid-template-columns: 1fr; gap: 18px; }\n .setup-nav { position: static; display: flex; overflow-x: auto; }\n .credentials { grid-template-columns: 1fr; gap: 2px; }\n .credentials dd { margin-bottom: 8px; }\n .diff-row { grid-template-columns: 1fr; }\n .diff-head { display: none; }\n .diff-value::before { display: block; margin-bottom: 2px; color: #777; font: 11px/1.4 system-ui, sans-serif; }\n .diff-current::before { content: 'Current'; }\n .diff-expected::before { content: 'Expected'; }\n }\n [hidden] { display: none !important; }\n </style>\n</head>\n<body>\n <section id=\"access\">\n <h1>AgentConnect Setup</h1>\n <p id=\"access-message\" class=\"muted\" aria-live=\"polite\">Checking Logto sign-in…</p>\n <div class=\"row\">\n <button id=\"login\" hidden>Sign in with Logto</button>\n <a id=\"open-logto\" class=\"button\" href=\"http://localhost:3002\" target=\"_blank\" rel=\"noopener\" hidden>Open Logto Console</a>\n <button id=\"show-bootstrap\" hidden>Continue setup</button>\n </div>\n\n <section id=\"bootstrap\" hidden>\n <h2>Set up sign-in</h2>\n <p id=\"bootstrap-progress\" class=\"muted\">Step 1 of 2</p>\n <div id=\"bootstrap-logto-step\" class=\"panel\">\n <h3>Connect Logto</h3>\n <p class=\"muted\">Enter the one-time Logto Management API credential. It is sealed in the deployment database and verified before continuing.</p>\n <label class=\"field\">Logto M2M App ID<input id=\"logto-app-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Logto M2M App Secret<input id=\"logto-app-secret\" type=\"password\" autocomplete=\"new-password\"></label>\n <label class=\"field\">Management API resource<input id=\"logto-management-api-resource\" type=\"url\" autocomplete=\"off\"></label>\n <p class=\"muted\">Logto Cloud uses <code>https://<tenant-id>.logto.app/api</code>. Keep <code>https://default.logto.app/api</code> for Logto OSS.</p>\n <button id=\"bootstrap-logto-submit\">Save Logto and continue</button>\n </div>\n <div id=\"bootstrap-provider-step\" hidden>\n <h3>Choose a sign-in provider</h3>\n <p class=\"muted\">Logto Management API access is ready. Configure one provider to enable sign-in.</p>\n <label class=\"field\">Sign-in provider\n <select id=\"bootstrap-provider\"><option value=\"google\">Google (works on localhost)</option><option value=\"github\">GitHub integration App</option><option id=\"bootstrap-slack-option\" value=\"slack\">Slack integration App</option><option value=\"sso\">Enterprise SSO (OIDC or SAML)</option></select>\n </label>\n <div id=\"bootstrap-google\" class=\"panel\">\n <h3>Google OAuth client</h3>\n <p class=\"muted\">Create a Web application client in Google Auth Platform, then paste its credentials here.</p>\n <p>Authorized JavaScript origin:</p><ul id=\"bootstrap-google-origins\" class=\"uris\"></ul>\n <p>Authorized redirect URIs:</p><ul id=\"bootstrap-google-redirects\" class=\"uris\"></ul>\n <a class=\"button\" href=\"https://console.cloud.google.com/auth/clients\" target=\"_blank\" rel=\"noopener\">Open Google settings</a>\n <label class=\"field\">Client ID<input id=\"bootstrap-google-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Client Secret<input id=\"bootstrap-google-secret\" type=\"password\" autocomplete=\"new-password\"></label>\n <button id=\"bootstrap-google-submit\">Save Google OAuth and configure Logto</button>\n </div>\n <div id=\"bootstrap-github\" class=\"panel\" hidden>\n <h3>GitHub integration App</h3>\n <p id=\"bootstrap-github-note\" class=\"muted\">This creates one complete App for both GitHub sign-in and repository integration.</p>\n <label class=\"field\">Owner\n <select id=\"bootstrap-github-owner\"><option value=\"personal\">Personal account</option><option value=\"organization\">GitHub organization</option></select>\n </label>\n <label id=\"bootstrap-github-org-field\" class=\"field\" hidden>Organization login<input id=\"bootstrap-github-org\" autocomplete=\"off\"></label>\n <label class=\"field\">App name<input id=\"bootstrap-github-name\" value=\"AgentConnect\"></label>\n <button id=\"bootstrap-github-submit\">Create GitHub App and configure Logto</button>\n </div>\n <div id=\"bootstrap-slack\" class=\"panel\" hidden>\n <h3>Slack integration App</h3>\n <p id=\"bootstrap-slack-note\" class=\"muted\">Creates one complete Slack App for workspace installation and a separate Sign in with Slack OIDC flow.</p>\n <p>Logto redirect URI:</p><ul id=\"bootstrap-slack-redirects\" class=\"uris\"></ul>\n <label class=\"field\">App name<input id=\"bootstrap-slack-name\" value=\"AgentConnect\"></label>\n <label class=\"field\">Temporary App configuration token<input id=\"bootstrap-slack-token\" type=\"password\" autocomplete=\"new-password\"></label>\n <button id=\"bootstrap-slack-submit\">Create Slack App and configure Logto</button>\n </div>\n <div id=\"bootstrap-sso\" class=\"panel\" hidden>\n <h3>Enterprise SSO</h3>\n <p class=\"muted\">Sign in through the organization's identity provider. Logto creates the connector first, so its URLs can be registered with the identity provider.</p>\n ",
|
|
67266
|
+
"\n </div>\n <button id=\"bootstrap-back\">Back to Logto credentials</button>\n </div>\n </section>\n </section>\n\n <main id=\"setup\" hidden>\n <div class=\"setup-layout\">\n <nav class=\"setup-nav\" aria-label=\"Deployment settings\">\n <a href=\"#startup-section\">Startup</a>\n <a href=\"#logto-section\">Logto</a>\n <a href=\"#sso-section\">Enterprise SSO</a>\n <a href=\"#github-section\">GitHub</a>\n <a href=\"#gitlab-section\">GitLab</a>\n <a href=\"#gitea-section\">Gitea</a>\n <a href=\"#slack-section\">Slack</a>\n <a href=\"#linear-section\">Linear</a>\n <a href=\"#google-section\">Google</a>\n <a href=\"#telegram-section\">Telegram</a>\n <a href=\"#discord-section\">Discord</a>\n <a href=\"#azuread-section\">Microsoft</a>\n <a href=\"#googlechat-section\">Google Chat</a>\n <a href=\"#feishu-section\">Feishu</a>\n <a href=\"#lark-section\">Lark</a>\n <a href=\"#options-section\">Options</a>\n </nav>\n <div class=\"setup-content\">\n <h1>AgentConnect deployment settings</h1>\n <p class=\"muted\">Saved settings take effect after the stack is restarted.</p>\n <div class=\"row\">\n <button id=\"logout\">Log out</button>\n </div>\n <div id=\"message\" aria-live=\"polite\"></div>\n\n <section id=\"editor\" hidden>\n <details id=\"startup-section\" class=\"environment setup-section\" open>\n <summary>Startup environment</summary>\n <p class=\"notice\">Public service URLs come from <code>.env</code>. Provider callbacks below are derived from these values.</p>\n <pre id=\"startup-environment\"></pre>\n </details>\n\n <section id=\"logto-section\" class=\"setup-section\" aria-labelledby=\"logto-heading\">\n <div class=\"section-head\">\n <div><h2 id=\"logto-heading\">Logto</h2><p class=\"muted\">Authentication and administrator access.</p></div>\n <span id=\"logto-match\" class=\"badge\">Not checked</span>\n </div>\n <div class=\"panel\">\n <dl class=\"credentials\">\n <dt>Management endpoint</dt><dd class=\"value-line\"><code id=\"logto-management-endpoint\">Not configured</code><span class=\"startup-owned\">startup environment</span></dd>\n <dt>Management App ID</dt><dd class=\"value-line\"><code id=\"logto-management-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"logto\">Edit</button></dd>\n <dt>Management API resource</dt><dd class=\"value-line\"><code id=\"logto-management-resource\">Not configured</code><button class=\"edit-configuration\" data-provider=\"logto\">Edit</button></dd>\n <dt>Management App secret</dt><dd class=\"secret-line\"><span id=\"logto-management-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"logto.managementAppSecret\" data-secret-display=\"logto-management-secret-display\">Edit</button></dd>\n <dt>Sign-in endpoint</dt><dd class=\"value-line\"><code id=\"logto-browser-endpoint\">Not configured</code><span class=\"startup-owned\">startup environment</span></dd>\n <dt>SPA App ID</dt><dd class=\"value-line\"><code id=\"logto-browser-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"logto\">Edit</button></dd>\n <dt>Browser API resource</dt><dd class=\"value-line\"><code id=\"logto-browser-resource\">Not configured</code><button class=\"edit-configuration\" data-provider=\"logto\">Edit</button></dd>\n </dl>\n <div id=\"logto-edit-controls\" class=\"subsection\" hidden>\n <h3>Edit Logto configuration</h3>\n <label class=\"field\">Management App ID<input id=\"logto-edit-management-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Management API resource<input id=\"logto-edit-management-resource\" autocomplete=\"off\"></label>\n <label class=\"field\">New Management App secret<input id=\"logto-edit-management-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when management identity changes\"></label>\n <label class=\"field\">SPA App ID<input id=\"logto-edit-browser-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Browser API resource<input id=\"logto-edit-browser-resource\" autocomplete=\"off\" placeholder=\"Optional\"></label>\n <div class=\"row\"><button id=\"save-logto-configuration\">Save configuration</button><button id=\"cancel-logto-configuration\">Cancel</button></div>\n </div>\n <p id=\"logto-status\" class=\"muted\">Checking redirects and sign-in settings…</p>\n <div id=\"logto-drift\" class=\"notice\" hidden></div>\n <div class=\"row\">\n <button id=\"check-logto\">Check match</button>\n <button id=\"reconcile-logto\">Apply expected settings</button>\n <a id=\"logto-settings\" class=\"button\" target=\"_blank\" rel=\"noopener\" hidden>Open Logto Console</a>\n </div>\n </div>\n </section>\n\n <h2>Providers</h2>\n <div class=\"provider-stack\">\n <section id=\"github-section\" class=\"panel setup-section\" aria-labelledby=\"github-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"github-heading\">GitHub</h3><p class=\"muted\">Repository integration and optional Logto sign-in.</p></div>\n <span id=\"github-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>App ID</dt><dd class=\"value-line\"><code id=\"github-app-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"github\">Edit</button></dd>\n <dt>App slug</dt><dd class=\"value-line\"><code id=\"github-app-slug\">Not configured</code><button class=\"edit-configuration\" data-provider=\"github\">Edit</button></dd>\n <dt>Client ID</dt><dd class=\"value-line\"><code id=\"github-client-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"github\">Edit</button></dd>\n <dt>Webhook delivery</dt><dd class=\"value-line\"><code id=\"github-webhook-enabled\">Not configured</code><button class=\"edit-configuration\" data-provider=\"github\">Edit</button></dd>\n <dt>Client secret</dt><dd class=\"secret-line\"><span id=\"github-client-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"github.clientSecret\" data-secret-display=\"github-client-secret-display\">Edit</button></dd>\n <dt>Private key</dt><dd class=\"secret-line\"><span id=\"github-private-key-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"github.privateKeyB64\" data-secret-display=\"github-private-key-display\">Edit</button></dd>\n <dt>Webhook secret</dt><dd class=\"secret-line\"><span id=\"github-webhook-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"github.webhookSecret\" data-secret-display=\"github-webhook-secret-display\">Edit</button></dd>\n <dt>Logto connector secret</dt><dd class=\"secret-line\"><span id=\"github-logto-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"logto.githubConnectorClientSecret\" data-secret-display=\"github-logto-secret-display\">Edit</button></dd>\n </dl>\n <p id=\"github-status\" class=\"muted\"></p>\n <div id=\"github-drift\" class=\"notice\" hidden></div>\n <div id=\"github-edit-controls\" class=\"subsection\" hidden>\n <h3>Edit GitHub App identity</h3>\n <label class=\"field\">App ID<input id=\"github-edit-app-id\" inputmode=\"numeric\" autocomplete=\"off\"></label>\n <label class=\"field\">App slug<input id=\"github-edit-slug\" autocomplete=\"off\"></label>\n <label class=\"field\">Client ID<input id=\"github-edit-client-id\" autocomplete=\"off\"></label>\n <label><input id=\"github-edit-webhook-enabled\" type=\"checkbox\"> Enable Relay webhook delivery</label>\n <label class=\"field\">New client secret<input id=\"github-edit-client-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when App or Client ID changes\"></label>\n <label class=\"field\">New private key (base64)<textarea id=\"github-edit-private-key\" autocomplete=\"off\" placeholder=\"Required when App ID changes\"></textarea></label>\n <label class=\"field\">New webhook secret<input id=\"github-edit-webhook-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when an active webhook App ID changes\"></label>\n <label id=\"github-edit-logto-secret-field\" class=\"field\" hidden>New Logto connector client secret<input id=\"github-edit-logto-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when this App is also used for sign-in\"></label>\n <div class=\"row\"><button id=\"save-github-configuration\">Save configuration</button><button id=\"cancel-github-configuration\">Cancel</button></div>\n </div>\n <div id=\"github-create-controls\" class=\"subsection\">\n <label class=\"field\">Owner\n <select id=\"github-owner\"><option value=\"personal\">Personal account</option><option value=\"organization\">GitHub organization</option></select>\n </label>\n <label id=\"github-org-field\" class=\"field\" hidden>Organization login<input id=\"github-org\" autocomplete=\"off\"></label>\n <label class=\"field\">App name<input id=\"github-name\" value=\"AgentConnect\"></label>\n </div>\n <div class=\"row\">\n <button id=\"create-github\">Create GitHub App</button>\n <button id=\"connect-github-login\" hidden>Use for Logto sign-in</button>\n <button id=\"check-github\" hidden>Check match</button>\n <button id=\"clear-github\" class=\"danger\" hidden>Clear configuration</button>\n <a id=\"github-settings\" class=\"button\" target=\"_blank\" rel=\"noopener\" hidden>Open GitHub settings</a>\n </div>\n </section>\n\n <section id=\"gitlab-section\" class=\"panel setup-section\" aria-labelledby=\"gitlab-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"gitlab-heading\">GitLab</h3><p class=\"muted\">OAuth application used to administer projects on this deployment's GitLab instance.</p></div>\n <span id=\"gitlab-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>Instance</dt><dd class=\"value-line\"><code id=\"gitlab-instance\">https://gitlab.com</code></dd>\n <dt>Application ID</dt><dd class=\"value-line\"><code id=\"gitlab-client-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"gitlab\">Edit</button></dd>\n <dt>Secret</dt><dd class=\"secret-line\"><span id=\"gitlab-client-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"gitlab.clientSecret\" data-secret-display=\"gitlab-client-secret-display\">Edit</button></dd>\n </dl>\n <p id=\"gitlab-status\" class=\"muted\"></p>\n <p id=\"gitlab-probe\" class=\"muted\" hidden></p>\n <div id=\"gitlab-drift\" class=\"notice\" hidden></div>\n <p>Redirect URI:</p><ul id=\"gitlab-callbacks\" class=\"uris\"></ul>\n <p>Scopes:</p><ul id=\"gitlab-scopes\" class=\"uris\"></ul>\n <p class=\"muted\">GitLab does not expose OAuth application creation through an API. In User settings → Applications, or a group's Settings → Applications, add an application whose redirect URI is exactly the value above, keep Confidential selected, grant the scopes above, then save the generated Application ID and Secret here. GitLab shows the secret only once.</p>\n <p class=\"muted\">Use your own user applications unless you are an instance administrator registering one application for everyone; an instance-wide application lives in the Admin area instead.</p>\n <p class=\"muted\">Creating each agent's bot account later needs authority this page cannot verify — no GitLab API reports it: either connect an instance administrator (whose API token cannot act as one while Admin Mode is enabled), or, on Premium and Ultimate, turn on Admin → Settings → General → Account and limit → “Allow top-level group Owners to create service accounts”.</p>\n <div class=\"row\"><a id=\"gitlab-applications\" class=\"button\" href=\"https://gitlab.com/-/user_settings/applications\" target=\"_blank\" rel=\"noopener\">Open GitLab applications</a><a id=\"gitlab-admin-applications\" class=\"button\" href=\"https://gitlab.com/admin/applications\" target=\"_blank\" rel=\"noopener\">Open admin applications</a></div>\n <div id=\"gitlab-config-controls\" class=\"subsection\">\n <label class=\"field\">Instance base URL<input id=\"gitlab-base-url\" autocomplete=\"off\" placeholder=\"Leave empty for https://gitlab.com\"></label>\n <label class=\"field\">Application ID<input id=\"gitlab-id\" autocomplete=\"off\"></label>\n <label id=\"gitlab-initial-secret-field\" class=\"field\">Secret<input id=\"gitlab-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when the Application ID changes\"></label>\n </div>\n <div class=\"row\"><button id=\"save-gitlab\">Save GitLab application</button><button id=\"cancel-gitlab-configuration\" hidden>Cancel</button><button id=\"clear-gitlab\" class=\"danger\" hidden>Clear configuration</button></div>\n <div class=\"subsection\">\n <h4>GitLab sign-in</h4>\n <p>Add these redirect URIs and the openid, profile and email scopes to the same confidential OAuth application.</p>\n <ul id=\"gitlab-login-redirects\" class=\"uris\"></ul>\n <p id=\"gitlab-login-status\" class=\"muted\"></p>\n <div class=\"row\"><button id=\"connect-gitlab-login\">Use for Logto sign-in</button><button id=\"disconnect-gitlab-login\" hidden>Disable GitLab sign-in</button><button id=\"check-gitlab-login\" hidden>Check Logto</button></div>\n </div>\n </section>\n\n <section id=\"gitea-section\" class=\"panel setup-section\" aria-labelledby=\"gitea-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"gitea-heading\">Gitea</h3><p class=\"muted\">The Gitea instance this deployment addresses. Gitea registers no application, so the address is the whole setting.</p></div>\n <span id=\"gitea-match\" class=\"badge\">Default</span>\n </div>\n <dl class=\"credentials\">\n <dt>Instance</dt><dd class=\"value-line\"><code id=\"gitea-instance\">https://gitea.com</code><button class=\"edit-configuration\" data-provider=\"gitea\">Edit</button></dd>\n </dl>\n <p id=\"gitea-status\" class=\"muted\"></p>\n <p id=\"gitea-probe\" class=\"muted\" hidden></p>\n <p class=\"muted\">Each organization connects Gitea by pasting the personal access token of a bot user it created, in the console — nothing about that identity is a deployment setting. Gitea 1.23 or later, or Forgejo 15 or later, is required, and the address is checked against that floor when it is saved.</p>\n <p class=\"muted\">Leave the field empty for gitea.com. A self-hosted instance under a path prefix keeps the prefix. A Forgejo instance, Codeberg included, is entered the same way.</p>\n <div id=\"gitea-config-controls\" class=\"subsection\">\n <label class=\"field\">Instance base URL<input id=\"gitea-base-url\" autocomplete=\"off\" placeholder=\"Leave empty for https://gitea.com\"></label>\n </div>\n <div class=\"row\"><button id=\"save-gitea\">Save Gitea instance</button><button id=\"cancel-gitea-configuration\" hidden>Cancel</button><button id=\"clear-gitea\" class=\"danger\" hidden>Clear configuration</button></div>\n </section>\n\n <section id=\"slack-section\" class=\"panel setup-section\" aria-labelledby=\"slack-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"slack-heading\">Slack</h3><p class=\"muted\">One App for workspace integration and Logto sign-in.</p></div>\n <span id=\"slack-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>App ID</dt><dd class=\"value-line\"><code id=\"slack-app-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"slack\">Edit</button></dd>\n <dt>Client ID</dt><dd class=\"value-line\"><code id=\"slack-client-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"slack\">Edit</button></dd>\n <dt>Client secret</dt><dd class=\"secret-line\"><span id=\"slack-client-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"slack.clientSecret\" data-secret-display=\"slack-client-secret-display\">Edit</button></dd>\n <dt>Signing secret</dt><dd class=\"secret-line\"><span id=\"slack-signing-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"slack.signingSecret\" data-secret-display=\"slack-signing-secret-display\">Edit</button></dd>\n <dt>Logto sign-in</dt><dd id=\"slack-logto-status\">Not configured</dd>\n </dl>\n <p id=\"slack-status\" class=\"muted\"></p>\n <div id=\"slack-drift\" class=\"notice\" hidden></div>\n <div id=\"slack-edit-controls\" class=\"subsection\" hidden>\n <h3>Edit Slack App identity</h3>\n <label class=\"field\">App ID<input id=\"slack-edit-app-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Client ID<input id=\"slack-edit-client-id\" autocomplete=\"off\"></label>\n <label class=\"field\">New client secret<input id=\"slack-edit-client-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when identity changes\"></label>\n <label class=\"field\">New signing secret<input id=\"slack-edit-signing-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when identity changes\"></label>\n <div class=\"row\"><button id=\"save-slack-configuration\">Save configuration</button><button id=\"cancel-slack-configuration\">Cancel</button></div>\n </div>\n <div class=\"subsection\">\n <label id=\"slack-name-field\" class=\"field\">App name<input id=\"slack-name\" value=\"AgentConnect\"></label>\n <label class=\"field\">Temporary App configuration token<input id=\"slack-token\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Used only for create, check or update\"></label>\n </div>\n <div class=\"row\">\n <button id=\"create-slack\">Create Slack App</button>\n <button id=\"connect-slack-login\" hidden>Use for Logto sign-in</button>\n <button id=\"check-slack\" hidden>Check match</button>\n <button id=\"reconcile-slack\" hidden>Apply update</button>\n <button id=\"clear-slack\" class=\"danger\" hidden>Clear configuration</button>\n <a id=\"slack-settings\" class=\"button\" target=\"_blank\" rel=\"noopener\" hidden>Open Slack settings</a>\n </div>\n </section>\n\n <section id=\"linear-section\" class=\"panel setup-section\" aria-labelledby=\"linear-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"linear-heading\">Linear</h3><p class=\"muted\">One OAuth application for the whole deployment. Every workspace connects through it.</p></div>\n <span id=\"linear-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>Client ID</dt><dd class=\"value-line\"><code id=\"linear-client-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"linear\">Edit</button></dd>\n <dt>Client secret</dt><dd class=\"secret-line\"><span id=\"linear-client-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"linear.clientSecret\" data-secret-display=\"linear-client-secret-display\">Edit</button></dd>\n <dt>Webhook signing secret</dt><dd class=\"secret-line\"><span id=\"linear-signing-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"linear.signingSecret\" data-secret-display=\"linear-signing-secret-display\">Edit</button></dd>\n </dl>\n <p id=\"linear-status\" class=\"muted\"></p>\n <div id=\"linear-drift\" class=\"notice\" hidden></div>\n <p>Callback URL:</p><ul id=\"linear-callbacks\" class=\"uris\"></ul>\n <p>Webhook URL:</p><ul id=\"linear-webhooks\" class=\"uris\"></ul>\n <p class=\"muted\">Linear does not expose OAuth application creation through an API. In Settings → API → OAuth applications, create one application under the product's own name and icon, add the callback URL above, then save its Client ID, Client secret, and webhook signing secret here.</p>\n <p class=\"muted\">On the same application, enable webhooks, point them at the webhook URL above, and check the <strong>Agent session events</strong> category.</p>\n <p class=\"muted\">Until that category is enabled, agent sessions are disabled for the whole application: delegating an issue only sets the delegate badge and no session is created. Enabling it later raises a new scope, so every already-connected workspace must reconnect before webhooks arrive.</p>\n <div class=\"row\"><a id=\"linear-applications\" class=\"button\" href=\"https://linear.app/settings/api/applications\" target=\"_blank\" rel=\"noopener\">Open Linear OAuth applications</a></div>\n <div id=\"linear-config-controls\" class=\"subsection\">\n <label class=\"field\">Client ID<input id=\"linear-id\" autocomplete=\"off\"></label>\n <div id=\"linear-initial-secret-fields\">\n <label class=\"field\">Client secret<input id=\"linear-client-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when the Client ID changes\"></label>\n <label class=\"field\">Webhook signing secret<input id=\"linear-signing-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when the Client ID changes\"></label>\n </div>\n </div>\n <div class=\"row\"><button id=\"save-linear\">Save Linear application</button><button id=\"cancel-linear-configuration\" hidden>Cancel</button><button id=\"clear-linear\" class=\"danger\" hidden>Clear configuration</button></div>\n </section>\n\n <section id=\"sso-section\" class=\"panel setup-section\" aria-labelledby=\"sso-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"sso-heading\">Enterprise SSO</h3><p class=\"muted\">OIDC or SAML identity provider for console sign-in.</p></div>\n <span id=\"sso-match\" class=\"badge\">Not configured</span>\n </div>\n ",
|
|
67267
|
+
"\n <div class=\"row\"><button id=\"sso-remove\" class=\"danger\" hidden>Remove SSO sign-in</button></div>\n </section>\n\n <section id=\"google-section\" class=\"panel setup-section\" aria-labelledby=\"google-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"google-heading\">Google</h3><p class=\"muted\">OAuth client used by the Logto Google connector.</p></div>\n <span id=\"google-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>Client ID</dt><dd class=\"value-line\"><code id=\"google-client-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"google\">Edit</button></dd>\n <dt>Client secret</dt><dd class=\"secret-line\"><span id=\"google-client-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"logto.googleConnectorClientSecret\" data-secret-display=\"google-client-secret-display\">Edit</button></dd>\n </dl>\n <p id=\"google-status\" class=\"muted\"></p>\n <div id=\"google-drift\" class=\"notice\" hidden></div>\n <p>Authorized JavaScript origin:</p><ul id=\"google-origins\" class=\"uris\"></ul>\n <p>Authorized redirect URIs:</p><ul id=\"google-redirects\" class=\"uris\"></ul>\n <p class=\"muted\">Google does not expose OAuth client redirect settings through an API. Copy these required values into Google Auth Platform; Setup can verify only the Logto connector.</p>\n <div class=\"row\"><a class=\"button\" href=\"https://console.cloud.google.com/auth/clients\" target=\"_blank\" rel=\"noopener\">Open Google settings</a></div>\n <div id=\"google-config-controls\" class=\"subsection\">\n <label class=\"field\">Client ID<input id=\"google-id\" autocomplete=\"off\"></label>\n <label id=\"google-initial-secret-field\" class=\"field\">Client secret<input id=\"google-secret\" type=\"password\" autocomplete=\"new-password\" placeholder=\"Required when Client ID changes\"></label>\n </div>\n <div class=\"row\"><button id=\"save-google\">Save Google client</button><button id=\"cancel-google-configuration\" hidden>Cancel</button><button id=\"check-google\" hidden>Check match</button><button id=\"clear-google\" class=\"danger\" hidden>Clear configuration</button></div>\n </section>\n\n ",
|
|
67097
67268
|
"\n\n <section id=\"googlechat-section\" class=\"panel setup-section\" aria-labelledby=\"googlechat-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"googlechat-heading\">Google Chat</h3><p class=\"muted\">One Chat app for the deployment. Each Google Workspace organization connects itself from Google Chat.</p></div>\n <span id=\"googlechat-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>Project ID</dt><dd class=\"value-line\"><code id=\"googlechat-project-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"googlechat\">Edit</button></dd>\n <dt>Project number</dt><dd class=\"value-line\"><code id=\"googlechat-project-number\">Not configured</code><button class=\"edit-configuration\" data-provider=\"googlechat\">Edit</button></dd>\n <dt>Service-account key</dt><dd class=\"value-line\"><span id=\"googlechat-key-display\" class=\"muted\">Not configured</span><button class=\"edit-configuration\" data-provider=\"googlechat\">Edit</button></dd>\n </dl>\n <p id=\"googlechat-status\" class=\"muted\"></p>\n <div id=\"googlechat-drift\" class=\"notice\" hidden></div>\n <p>HTTP endpoint URL:</p><ul id=\"googlechat-callbacks\" class=\"uris\"></ul>\n <ol class=\"muted\">\n <li>Use a Google Workspace account that may create a Cloud project and service-account keys. An organization policy can block key creation.</li>\n <li>Create a Google Cloud project for this app and enable the Google Chat API in it.</li>\n <li>On the Chat API Configuration page, build the app as a Google Workspace add-on, the default for a new app (convert an existing app first). Under Connection settings choose HTTP endpoint URL, use one URL for all triggers, and enter the URL above. Allow 1:1 messages and joining spaces, and choose who can use the app.</li>\n <li>Under Commands, add a slash command named /help with the description \"Show how to use this app\" and command ID 1; any free ID works, since AgentConnect matches the name.</li>\n <li>In the same project, create a service account and a JSON key for it, grant it the Browser role on the project, and enable the Cloud Resource Manager API; AgentConnect reads the project number with that key.</li>\n <li>Save the project ID and key here. The project number is optional: it is on the project's settings page and in the add-on's service account email, service-PROJECT_NUMBER@gcp-sa-gsuiteaddons.iam.gserviceaccount.com. When entered, it must match the number read from the key's project.</li>\n <li>Configure Google sign-in above, then add the app in Google Chat and send it a message. Each Google Workspace organization connects itself from Google Chat by signing in to the console with Google; publish the app on the Google Workspace Marketplace to reach other organizations.</li>\n </ol>\n <div class=\"row\"><a id=\"googlechat-configuration\" class=\"button\" href=\"https://console.cloud.google.com/apis/api/chat.googleapis.com/hangouts-chat\" target=\"_blank\" rel=\"noopener\">Open Chat API configuration</a></div>\n <div id=\"googlechat-config-controls\" class=\"subsection\">\n <h3 id=\"googlechat-edit-heading\" hidden>Edit Google Chat App identity</h3>\n <label class=\"field\">Project ID<input id=\"googlechat-project-id-input\" autocomplete=\"off\"></label>\n <label class=\"field\">Project number (optional)<input id=\"googlechat-project-number-input\" inputmode=\"numeric\" autocomplete=\"off\" placeholder=\"Read from the key's project\"></label>\n <label class=\"field\">Service-account key JSON<input id=\"googlechat-key\" type=\"password\" autocomplete=\"off\" placeholder=\"Required when the project ID changes\"></label>\n </div>\n <div class=\"row\"><button id=\"save-googlechat\">Save Google Chat app</button><button id=\"cancel-googlechat-configuration\" hidden>Cancel</button><button id=\"clear-googlechat\" class=\"danger\" hidden>Clear configuration</button></div>\n </section>\n\n\n <section id=\"feishu-section\" class=\"panel setup-section\" aria-labelledby=\"feishu-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"feishu-heading\">Feishu</h3><p class=\"muted\">Matches the Logto OAuth 2.0 connector named Feishu, OAuth callbacks, and published App setup.</p></div>\n <span id=\"feishu-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>App ID</dt><dd class=\"value-line\"><code id=\"feishu-app-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"feishu\">Edit</button></dd>\n <dt>App secret</dt><dd class=\"secret-line\"><span id=\"feishu-app-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"feishu.loginAppSecret\" data-secret-display=\"feishu-app-secret-display\">Edit</button></dd>\n </dl>\n <p id=\"feishu-login-status\" class=\"muted\"></p>\n <div id=\"feishu-drift\" class=\"notice\" hidden></div>\n <div id=\"feishu-config-controls\" class=\"subsection\">\n <label id=\"feishu-create-name-field\" class=\"field\">New App name<input id=\"feishu-create-name\" value=\"AgentConnect\"></label>\n <label class=\"field\">Existing App ID<input id=\"feishu-login-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Existing App secret<input id=\"feishu-login-secret\" type=\"password\" autocomplete=\"new-password\"></label>\n </div>\n <div class=\"row\">\n <button id=\"create-feishu-login-app\">Create Feishu App</button>\n <button id=\"save-feishu-login-app\">Save existing App</button>\n <button id=\"cancel-feishu-configuration\" hidden>Cancel</button>\n <button id=\"check-feishu-login-app\" hidden>Check setup</button>\n <button id=\"clear-feishu\" class=\"danger\" hidden>Clear configuration</button>\n <a id=\"feishu-settings\" class=\"button\" target=\"_blank\" rel=\"noopener\" hidden>Open Feishu settings</a>\n </div>\n </section>\n\n <section id=\"lark-section\" class=\"panel setup-section\" aria-labelledby=\"lark-heading\">\n <div class=\"provider-head\">\n <div><h3 id=\"lark-heading\">Lark</h3><p class=\"muted\">Matches the Logto OAuth 2.0 connector named Lark, OAuth callbacks, and published App setup.</p></div>\n <span id=\"lark-match\" class=\"badge\">Not configured</span>\n </div>\n <dl class=\"credentials\">\n <dt>App ID</dt><dd class=\"value-line\"><code id=\"lark-app-id\">Not configured</code><button class=\"edit-configuration\" data-provider=\"lark\">Edit</button></dd>\n <dt>App secret</dt><dd class=\"secret-line\"><span id=\"lark-app-secret-display\" class=\"redacted\">Not configured</span><button class=\"edit-secret\" data-secret-key=\"lark.loginAppSecret\" data-secret-display=\"lark-app-secret-display\">Edit</button></dd>\n </dl>\n <p id=\"lark-login-status\" class=\"muted\"></p>\n <div id=\"lark-drift\" class=\"notice\" hidden></div>\n <div id=\"lark-config-controls\" class=\"subsection\">\n <label id=\"lark-create-name-field\" class=\"field\">New App name<input id=\"lark-create-name\" value=\"AgentConnect\"></label>\n <label class=\"field\">Existing App ID<input id=\"lark-login-id\" autocomplete=\"off\"></label>\n <label class=\"field\">Existing App secret<input id=\"lark-login-secret\" type=\"password\" autocomplete=\"new-password\"></label>\n </div>\n <div class=\"row\">\n <button id=\"create-lark-login-app\">Create Lark App</button>\n <button id=\"save-lark-login-app\">Save existing App</button>\n <button id=\"cancel-lark-configuration\" hidden>Cancel</button>\n <button id=\"check-lark-login-app\" hidden>Check setup</button>\n <button id=\"clear-lark\" class=\"danger\" hidden>Clear configuration</button>\n <a id=\"lark-settings\" class=\"button\" target=\"_blank\" rel=\"noopener\" hidden>Open Lark settings</a>\n </div>\n </section>\n </div>\n\n <section id=\"options-section\" class=\"setup-section\">\n <h2>Deployment options</h2>\n <div class=\"panel\">\n <label class=\"field\"><span><input id=\"preset-agents-enabled\" type=\"checkbox\"> Enable preset Agents</span></label>\n <label class=\"field\">Max organizations created per non-ADMIN user<input id=\"max-orgs-per-non-admin-user\" type=\"number\" min=\"0\" step=\"1\" value=\"1\"></label>\n <div class=\"row\"><button id=\"save-options\">Save options</button></div>\n </div>\n </section>\n </section>\n </div>\n </div>\n </main>\n\n <script>\n const api = '/api/v1';\n const tokenKey = 'agentconnect.setup.token';\n const verifierKey = 'agentconnect.setup.pkce';\n const stateKey = 'agentconnect.setup.state';\n let currentRevision = 0;\n let currentStatus = null;\n let bootstrapInfo = null;\n const el = (id) => document.getElementById(id);\n const message = (text, error = false) => {\n const target = el('setup').hidden ? el('access-message') : el('message');\n target.textContent = text;\n target.className = error ? 'error' : 'ok';\n };\n const bearer = () => {\n const token = sessionStorage.getItem(tokenKey);\n return token ? { authorization: 'Bearer ' + token } : {};\n };\n const json = async (response) => {\n const body = await response.json().catch(() => ({}));\n if (!response.ok) throw Object.assign(new Error(body.message || ('HTTP ' + response.status)), { status: response.status, code: body.code });\n return body;\n };\n const base64url = (bytes) => btoa(String.fromCharCode(...bytes)).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');\n const random = () => base64url(crypto.getRandomValues(new Uint8Array(32)));\n const configured = (byKey, key) => Boolean(byKey.get(key) && byKey.get(key).configured);\n const loginApps = ",
|
|
67098
|
-
";\n\n function showIdentityEditors(provider, show) {\n for (const button of document.querySelectorAll('.edit-configuration[data-provider=\"' + provider + '\"]')) {\n button.hidden = !show;\n }\n }\n\n function text(id, value) {\n el(id).textContent = value === null || value === undefined || value === '' ? 'Not configured' : String(value);\n }\n\n function secretText(id, byKey, key, editable) {\n const stored = configured(byKey, key);\n const display = el(id);\n const button = document.querySelector('[data-secret-display=\"' + id + '\"]');\n const row = display.closest('.secret-line');\n const editor = row && row.querySelector('.secret-editor');\n if (editor) editor.remove();\n display.hidden = false;\n display.textContent = stored ? '***' : 'Not configured';\n display.className = stored ? 'redacted' : 'muted';\n button.hidden = !editable;\n button.textContent = stored ? 'Edit' : 'Set';\n }\n\n function match(id, state, label) {\n const target = el(id);\n target.textContent = label;\n target.className = 'badge' + (state ? ' ' + state : '');\n }\n\n async function authConfig() { return json(await fetch(api + '/auth-config')); }\n\n async function signIn() {\n const config = await authConfig();\n if (config.mode !== 'oidc') throw new Error('Save an OIDC configuration first');\n const verifier = random();\n const challenge = base64url(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))));\n const state = random();\n sessionStorage.setItem(verifierKey, verifier);\n sessionStorage.setItem(stateKey, state);\n const url = new URL(config.authorizationEndpoint);\n url.searchParams.set('client_id', config.appId);\n url.searchParams.set('redirect_uri', config.redirectUri);\n url.searchParams.set('response_type', 'code');\n url.searchParams.set('scope', 'openid profile email roles');\n url.searchParams.set('state', state);\n url.searchParams.set('code_challenge', challenge);\n url.searchParams.set('code_challenge_method', 'S256');\n if (config.resource) url.searchParams.set('resource', config.resource);\n location.assign(url);\n }\n\n async function finishSignIn() {\n const url = new URL(location.href);\n const code = url.searchParams.get('code');\n if (!code) return;\n const state = url.searchParams.get('state');\n if (!state || state !== sessionStorage.getItem(stateKey)) throw new Error('OIDC state mismatch');\n const verifier = sessionStorage.getItem(verifierKey);\n if (!verifier) throw new Error('PKCE verifier is missing; start sign-in again');\n const config = await authConfig();\n const body = new URLSearchParams({\n grant_type: 'authorization_code', code, client_id: config.appId,\n redirect_uri: config.redirectUri, code_verifier: verifier\n });\n if (config.resource) body.set('resource', config.resource);\n const tokens = await json(await fetch(config.tokenEndpoint, {\n method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body\n }));\n if (typeof tokens.id_token !== 'string') throw new Error('Logto did not return an ID token');\n sessionStorage.setItem(tokenKey, tokens.id_token);\n sessionStorage.removeItem(verifierKey);\n sessionStorage.removeItem(stateKey);\n history.replaceState({}, '', '/');\n }\n\n function renderUriList(id, values) {\n el(id).replaceChildren(...(values || []).map((value) => {\n const item = document.createElement('li');\n const code = document.createElement('code'); code.textContent = value;\n item.append(code); return item;\n }));\n }\n\n function updateBootstrapProvider() {\n const provider = el('bootstrap-provider').value;\n el('bootstrap-google').hidden = provider !== 'google';\n el('bootstrap-github').hidden = provider !== 'github';\n el('bootstrap-slack').hidden = provider !== 'slack';\n }\n\n function showBootstrapStep(step) {\n const logto = step === 'logto';\n el('bootstrap-logto-step').hidden = !logto;\n el('bootstrap-provider-step').hidden = logto;\n el('bootstrap-progress').textContent = logto ? 'Step 1 of 2' : 'Step 2 of 2';\n }\n\n function updateOwner(prefix) {\n el(prefix + '-org-field').hidden = el(prefix + '-owner').value !== 'organization';\n }\n\n function ownership(prefix) {\n if (el(prefix + '-owner').value === 'personal') return { owner: 'personal', organization: null };\n const organization = el(prefix + '-org').value.trim();\n if (!organization) throw new Error('Enter the GitHub organization login');\n return { owner: 'organization', organization };\n }\n\n function formatDiffValue(value) {\n if (value === null || value === undefined || value === '') return 'Not configured';\n if (Array.isArray(value)) return value.length ? value.map(formatDiffValue).join('\\n') : '[]';\n if (typeof value === 'object') {\n return Object.entries(value).map(([key, item]) => key + ': ' + (Array.isArray(item) ? item.join(', ') : formatDiffValue(item))).join('\\n');\n }\n return String(value);\n }\n\n function showDiff(id, rows, label = 'Update required') {\n const box = el(id);\n box.hidden = rows.length === 0;\n box.replaceChildren();\n if (rows.length === 0) return;\n const title = document.createElement('strong');\n title.className = 'diff-title';\n title.textContent = label;\n const grid = document.createElement('div');\n grid.className = 'config-diff';\n const head = document.createElement('div');\n head.className = 'diff-row diff-head';\n for (const text of ['Field', 'Current', 'Expected']) {\n const cell = document.createElement('span'); cell.textContent = text; head.append(cell);\n }\n grid.append(head);\n for (const item of rows) {\n const row = document.createElement('div'); row.className = 'diff-row';\n const field = document.createElement('span'); field.className = 'diff-field'; field.textContent = item.field;\n const current = document.createElement('span'); current.className = 'diff-value diff-current'; current.textContent = formatDiffValue(item.current);\n const expected = document.createElement('span'); expected.className = 'diff-value diff-expected'; expected.textContent = formatDiffValue(item.expected);\n row.append(field, current, expected); grid.append(row);\n }\n box.append(title, grid);\n }\n\n function renderStartupEnvironment() {\n const services = bootstrapInfo.services;\n const environment = [\n ['AGENTCONNECT_PUBLIC_CP_URL', services.controlPlane],\n ['AGENTCONNECT_PUBLIC_RELAY_URL', services.relay],\n ['AGENTCONNECT_PUBLIC_WEB_URL', services.web],\n ['LOGTO_ENDPOINT', bootstrapInfo.logtoEndpoint],\n ['LOGTO_MGMT_ENDPOINT', bootstrapInfo.logtoManagementEndpoint]\n ];\n el('startup-environment').textContent = environment\n .filter(([, value]) => value)\n .map(([key, value]) => key + '=' + value)\n .join('\\n');\n }\n\n function renderApps(status) {\n currentStatus = status;\n const byKey = new Map((status.secrets || []).map((item) => [item.key, item]));\n const values = status.values;\n const expected = status.providerExpectations || { github: null, gitlab: null, slack: null, linear: null, googleChat: null, google: { origins: [], redirects: [] } };\n renderStartupEnvironment();\n\n const logto = values.logto;\n text('logto-management-endpoint', logto && bootstrapInfo.logtoManagementEndpoint);\n text('logto-management-id', logto && logto.managementAppId);\n text('logto-management-resource', logto && logto.managementResource);\n secretText('logto-management-secret-display', byKey, 'logto.managementAppSecret', Boolean(logto));\n text('logto-browser-endpoint', logto && logto.browser && bootstrapInfo.logtoEndpoint);\n text('logto-browser-id', values.auth.mode === 'oidc' ? values.auth.browserClient.appId : null);\n text('logto-browser-resource', logto && logto.browser && logto.browser.apiResource);\n el('logto-edit-controls').hidden = true;\n showIdentityEditors('logto', Boolean(logto && logto.browser && values.auth.mode === 'oidc'));\n match(\n 'logto-match',\n logto && configured(byKey, 'logto.managementAppSecret') ? '' : 'warn',\n logto && configured(byKey, 'logto.managementAppSecret') ? 'Ready to check' : 'Not configured'\n );\n\n el('preset-agents-enabled').checked = values.features.presetAgentsEnabled;\n el('max-orgs-per-non-admin-user').value = String(values.features.maxOrgsPerNonAdminUser);\n\n const github = values.github;\n const webhookStored = byKey.get('github.webhookSecret') && byKey.get('github.webhookSecret').configured;\n const webhookInactive = github && github.webhookEnabled === false;\n text('github-app-id', github && github.appId);\n text('github-app-slug', github && github.slug);\n text('github-client-id', github && github.clientId);\n text('github-webhook-enabled', github && (github.webhookEnabled === false ? 'Disabled' : 'Enabled'));\n el('github-edit-controls').hidden = true;\n showIdentityEditors('github', Boolean(github));\n secretText('github-client-secret-display', byKey, 'github.clientSecret', Boolean(github));\n secretText('github-private-key-display', byKey, 'github.privateKeyB64', Boolean(github));\n secretText('github-webhook-secret-display', byKey, 'github.webhookSecret', Boolean(github));\n secretText('github-logto-secret-display', byKey, 'logto.githubConnectorClientSecret', Boolean(values.logto && values.logto.githubConnector));\n el('github-status').textContent = github\n ? github.slug + ' is configured. Webhook secret: ' + (webhookStored ? 'stored' : webhookInactive ? 'not required yet' : 'missing') + '.' +\n (webhookInactive ? ' Relay webhook delivery is disabled.' : '')\n : 'Creates the complete App used for repository installation, webhooks, and optional GitHub sign-in.';\n const githubDrift = github\n ? expected.github\n ? []\n : [{ field: 'Startup public URLs', current: 'Unavailable', expected: 'Valid Web, API, and ingress URLs' }]\n : [];\n match('github-match', !github ? '' : githubDrift.length ? 'warn' : '', !github ? 'Not configured' : githubDrift.length ? 'Expected URLs changed' : 'Ready to check');\n el('github-create-controls').hidden = Boolean(github);\n el('create-github').hidden = Boolean(github);\n el('clear-github').hidden = !github;\n el('connect-github-login').hidden = !github || !values.logto || Boolean(values.logto.githubConnector);\n el('check-github').hidden = !github;\n if (github) showDiff('github-drift', githubDrift, 'Expected URLs changed since App creation');\n else el('github-drift').hidden = true;\n\n const gitlab = values.gitlab;\n const gitlabSecret = configured(byKey, 'gitlab.clientSecret');\n const expectedGitlab = expected.gitlab;\n renderUriList('gitlab-callbacks', expectedGitlab ? [expectedGitlab.callbackUrl] : []);\n renderUriList('gitlab-scopes', expectedGitlab ? expectedGitlab.scopes : []);\n text('gitlab-client-id', gitlab && gitlab.clientId);\n secretText('gitlab-client-secret-display', byKey, 'gitlab.clientSecret', Boolean(gitlab));\n showIdentityEditors('gitlab', Boolean(gitlab));\n el('gitlab-id').value = gitlab ? gitlab.clientId : '';\n text('gitlab-instance', (gitlab && gitlab.baseUrl) || 'https://gitlab.com');\n // Host-aware application links (§24.1): composed by the server against the\n // configured base, so a path-prefixed install keeps its prefix here too.\n if (expectedGitlab) {\n el('gitlab-applications').href = expectedGitlab.applicationsUrl;\n el('gitlab-admin-applications').href = expectedGitlab.adminApplicationsUrl;\n }\n el('gitlab-base-url').value = (gitlab && gitlab.baseUrl) || '';\n // A probe verdict belongs to the save that produced it, never to a reload.\n el('gitlab-probe').hidden = true;\n el('gitlab-status').textContent = gitlab\n ? gitlab.clientId + ' is configured.'\n : expectedGitlab\n ? 'Register the OAuth application on the configured GitLab instance, then save its Application ID and Secret here.'\n : 'Publishing the redirect URI needs an HTTPS API public URL.';\n showDiff('gitlab-drift', gitlab && !expectedGitlab\n ? [{ field: 'Startup public URLs', current: 'Unavailable', expected: 'An HTTPS API URL' }]\n : []);\n match('gitlab-match', gitlab && gitlabSecret ? 'warn' : '', !gitlab ? 'Not configured' : !gitlabSecret ? 'Missing secret' : \"Can't verify automatically\");\n el('gitlab-initial-secret-field').hidden = Boolean(gitlab) && gitlabSecret;\n el('gitlab-config-controls').hidden = Boolean(gitlab);\n el('save-gitlab').hidden = Boolean(gitlab);\n el('cancel-gitlab-configuration').hidden = true;\n el('clear-gitlab').hidden = !gitlab;\n const gitlabLogin = values.logto?.browser?.socialProviders.includes('gitlab');\n renderUriList('gitlab-login-redirects', expected.gitlabSignIn?.redirects || []);\n el('gitlab-login-status').textContent = gitlabLogin ? 'GitLab sign-in is configured. Use Check Logto to verify it.' : 'GitLab sign-in is not enabled.';\n el('connect-gitlab-login').hidden = Boolean(gitlabLogin);\n el('connect-gitlab-login').disabled = !gitlab || !gitlabSecret || !values.logto?.browser;\n el('disconnect-gitlab-login').hidden = !gitlabLogin;\n el('check-gitlab-login').hidden = !gitlabLogin;\n\n const gitea = values.gitea;\n text('gitea-instance', (gitea && gitea.baseUrl) || 'https://gitea.com');\n el('gitea-base-url').value = (gitea && gitea.baseUrl) || '';\n showIdentityEditors('gitea', Boolean(gitea));\n // A probe verdict belongs to the save that produced it, never to a reload.\n el('gitea-probe').hidden = true;\n el('gitea-status').textContent = gitea\n ? ((gitea.baseUrl || 'https://gitea.com') + ' is configured.')\n : 'Organizations can connect gitea.com without saving anything. Save an address only for a self-hosted instance.';\n match('gitea-match', gitea ? 'warn' : '', gitea ? \"Can't verify automatically\" : 'Default');\n el('gitea-config-controls').hidden = Boolean(gitea);\n el('save-gitea').hidden = Boolean(gitea);\n el('cancel-gitea-configuration').hidden = true;\n el('clear-gitea').hidden = !gitea;\n\n const slack = values.slack;\n text('slack-app-id', slack && slack.appId);\n text('slack-client-id', slack && slack.clientId);\n el('slack-edit-controls').hidden = true;\n showIdentityEditors('slack', Boolean(slack));\n secretText('slack-client-secret-display', byKey, 'slack.clientSecret', Boolean(slack));\n secretText('slack-signing-secret-display', byKey, 'slack.signingSecret', Boolean(slack));\n el('slack-logto-status').textContent = values.logto && values.logto.slackConnector\n ? 'Enabled; reuses the Slack client secret above.'\n : 'Not enabled.';\n el('slack-status').textContent = slack ? slack.appId + ' is configured.' : 'Creates the default AgentConnect integration manifest.';\n const slackDrift = slack\n ? expected.slack\n ? []\n : [{ field: 'Startup public URLs', current: 'Unavailable', expected: 'HTTPS Web, API, and ingress URLs' }]\n : [];\n match('slack-match', !slack ? '' : slackDrift.length ? 'warn' : '', !slack ? 'Not configured' : slackDrift.length ? 'Update required' : 'Ready to check');\n el('slack-name-field').hidden = Boolean(slack);\n el('create-slack').hidden = Boolean(slack);\n el('connect-slack-login').hidden = !slack || !values.logto || Boolean(values.logto.slackConnector) || !bootstrapInfo?.slackAvailable;\n el('clear-slack').hidden = !slack;\n el('check-slack').hidden = !slack;\n el('reconcile-slack').hidden = !slack;\n el('slack-settings').hidden = !slack;\n if (slack) {\n el('slack-settings').href = 'https://api.slack.com/apps/' + encodeURIComponent(slack.appId);\n showDiff('slack-drift', slackDrift);\n } else el('slack-drift').hidden = true;\n\n const googleChat = values.googleChat;\n const googleChatKey = configured(byKey, 'googleChat.serviceAccountKey');\n const expectedGoogleChat = expected.googleChat;\n renderUriList('googlechat-callbacks', expectedGoogleChat ? [expectedGoogleChat.callbackUrl] : []);\n text('googlechat-project-id', googleChat && googleChat.projectId);\n text('googlechat-project-number', googleChat && googleChat.projectNumber);\n el('googlechat-key-display').textContent = googleChatKey ? '***' : 'Not configured';\n el('googlechat-key-display').className = googleChatKey ? 'redacted' : 'muted';\n showIdentityEditors('googlechat', Boolean(googleChat));\n el('googlechat-project-id-input').value = googleChat ? googleChat.projectId : '';\n el('googlechat-project-number-input').value = googleChat ? googleChat.projectNumber : '';\n el('googlechat-key').value = '';\n if (expectedGoogleChat) el('googlechat-configuration').href = expectedGoogleChat.configurationUrl;\n el('googlechat-status').textContent = googleChat\n ? googleChat.projectId + ' is configured.'\n : expectedGoogleChat\n ? 'Configure the Chat app in Google Cloud Console, then save its project and service-account key here.'\n : 'Publishing the HTTP endpoint URL needs an HTTPS ingress public URL.';\n showDiff('googlechat-drift', googleChat && !expectedGoogleChat\n ? [{ field: 'Startup public URLs', current: 'Unavailable', expected: 'An HTTPS ingress URL' }]\n : []);\n match('googlechat-match', googleChat && googleChatKey ? 'pass' : googleChat ? 'warn' : '', !googleChat ? 'Not configured' : !googleChatKey ? 'Missing key' : 'Checked on save');\n el('googlechat-edit-heading').hidden = true;\n el('googlechat-config-controls').hidden = Boolean(googleChat);\n el('save-googlechat').hidden = Boolean(googleChat);\n el('cancel-googlechat-configuration').hidden = true;\n el('clear-googlechat').hidden = !googleChat;\n\n\n const linear = values.linear;\n const linearSecrets = configured(byKey, 'linear.clientSecret') && configured(byKey, 'linear.signingSecret');\n const expectedLinear = expected.linear;\n renderUriList('linear-callbacks', expectedLinear ? [expectedLinear.callbackUrl] : []);\n renderUriList('linear-webhooks', expectedLinear ? [expectedLinear.webhookUrl] : []);\n text('linear-client-id', linear && linear.clientId);\n secretText('linear-client-secret-display', byKey, 'linear.clientSecret', Boolean(linear));\n secretText('linear-signing-secret-display', byKey, 'linear.signingSecret', Boolean(linear));\n showIdentityEditors('linear', Boolean(linear));\n el('linear-id').value = linear ? linear.clientId : '';\n if (expectedLinear) el('linear-applications').href = expectedLinear.applicationsUrl;\n el('linear-status').textContent = linear\n ? linear.clientId + ' is configured.'\n : expectedLinear\n ? 'Register the OAuth application in Linear, then save its Client ID and both secrets here.'\n : 'Publishing the callback and webhook URLs needs HTTPS API and ingress public URLs.';\n showDiff('linear-drift', linear && !expectedLinear\n ? [{ field: 'Startup public URLs', current: 'Unavailable', expected: 'HTTPS API and ingress URLs' }]\n : []);\n match('linear-match', linear && linearSecrets ? 'warn' : '', !linear ? 'Not configured' : !linearSecrets ? 'Missing secret' : \"Can't verify automatically\");\n el('linear-initial-secret-fields').hidden = Boolean(linear) && linearSecrets;\n el('linear-config-controls').hidden = Boolean(linear);\n el('save-linear').hidden = Boolean(linear);\n el('cancel-linear-configuration').hidden = true;\n el('clear-linear').hidden = !linear;\n\n const google = values.logto && values.logto.googleConnector;\n const googleSecret = configured(byKey, 'logto.googleConnectorClientSecret');\n const expectedGoogle = expected.google;\n renderUriList('google-origins', expectedGoogle.origins);\n renderUriList('google-redirects', expectedGoogle.redirects);\n text('google-client-id', google && google.clientId);\n secretText('google-client-secret-display', byKey, 'logto.googleConnectorClientSecret', Boolean(google));\n showIdentityEditors('google', Boolean(google));\n el('google-id').value = google ? google.clientId : '';\n el('google-status').textContent = google ? 'Google OAuth client is configured.' : 'Create a Web application OAuth client manually, then save it here.';\n showDiff('google-drift', []);\n match('google-match', google && googleSecret ? 'warn' : '', !google ? 'Not configured' : !googleSecret ? 'Missing secret' : \"Can't verify automatically\");\n el('google-initial-secret-field').hidden = googleSecret;\n el('save-google').textContent = google ? 'Confirm callback settings' : 'Save Google client';\n el('google-config-controls').hidden = Boolean(google);\n el('save-google').hidden = Boolean(google);\n el('cancel-google-configuration').hidden = true;\n el('check-google').hidden = !google || !googleSecret;\n el('clear-google').hidden = !google;\n\n for (const [provider, app] of Object.entries(loginApps)) {\n const saved = values.logto && values.logto[provider + 'Connector'];\n const secretKey = 'logto.' + provider + 'ConnectorClientSecret';\n const hasSecret = configured(byKey, secretKey);\n renderUriList(provider + '-redirects', expected[provider].redirects);\n text(provider + '-client-id', saved && saved.clientId);\n secretText(provider + '-client-secret-display', byKey, secretKey, Boolean(saved));\n showIdentityEditors(provider, Boolean(saved));\n el(provider + '-id').value = saved ? saved.clientId : '';\n el(provider + '-status').hidden = !saved;\n el(provider + '-status').textContent = saved ? app.name + ' login application is saved.' : '';\n showDiff(provider + '-drift', []);\n match(provider + '-match', saved ? 'warn' : '', !saved ? 'Not configured' : !hasSecret ? 'Missing secret' : 'Not checked');\n el(provider + '-initial-secret-field').hidden = hasSecret;\n el(provider + '-config-controls').hidden = Boolean(saved);\n el('save-' + provider).hidden = Boolean(saved);\n el('save-' + provider).disabled = !values.logto || !values.logto.browser;\n el('cancel-' + provider + '-configuration').hidden = true;\n el('check-' + provider).hidden = !saved || !hasSecret;\n el('clear-' + provider).hidden = !saved;\n }\n const expectedTeams = expected.msTeams;\n text('azuread-teams-tenant-id', values.msTeams && values.msTeams.tenantId);\n el('azuread-teams-tenant-input').value = values.msTeams ? values.msTeams.tenantId : '';\n renderUriList('azuread-teams-endpoint', expectedTeams ? [expectedTeams.messagingEndpoint] : []);\n renderUriList('azuread-teams-redirects', expectedTeams ? [expectedTeams.connectRedirectUri] : []);\n\n const feishuSecret = byKey.get('feishu.loginAppSecret');\n const larkSecret = byKey.get('lark.loginAppSecret');\n el('feishu-login-id').value = values.feishu ? values.feishu.loginAppId : '';\n el('lark-login-id').value = values.lark ? values.lark.loginAppId : '';\n text('feishu-app-id', values.feishu && values.feishu.loginAppId);\n text('lark-app-id', values.lark && values.lark.loginAppId);\n showIdentityEditors('feishu', Boolean(values.feishu));\n showIdentityEditors('lark', Boolean(values.lark));\n secretText('feishu-app-secret-display', byKey, 'feishu.loginAppSecret', Boolean(values.feishu));\n secretText('lark-app-secret-display', byKey, 'lark.loginAppSecret', Boolean(values.lark));\n el('feishu-login-status').textContent = values.feishu && feishuSecret && feishuSecret.configured\n ? values.feishu.loginAppId + ' is configured.'\n : 'No Feishu tenant App is configured.';\n el('lark-login-status').textContent = values.lark && larkSecret && larkSecret.configured\n ? values.lark.loginAppId + ' is configured.'\n : 'No Lark tenant App is configured.';\n match('feishu-match', values.feishu && configured(byKey, 'feishu.loginAppSecret') ? '' : '', values.feishu && configured(byKey, 'feishu.loginAppSecret') ? 'Ready to check' : 'Not configured');\n match('lark-match', values.lark && configured(byKey, 'lark.loginAppSecret') ? '' : '', values.lark && configured(byKey, 'lark.loginAppSecret') ? 'Ready to check' : 'Not configured');\n showDiff('feishu-drift', []);\n showDiff('lark-drift', []);\n el('feishu-config-controls').hidden = Boolean(values.feishu);\n el('lark-config-controls').hidden = Boolean(values.lark);\n el('feishu-create-name-field').hidden = Boolean(values.feishu);\n el('lark-create-name-field').hidden = Boolean(values.lark);\n el('create-feishu-login-app').hidden = Boolean(values.feishu);\n el('create-lark-login-app').hidden = Boolean(values.lark);\n el('save-feishu-login-app').hidden = Boolean(values.feishu);\n el('save-lark-login-app').hidden = Boolean(values.lark);\n el('cancel-feishu-configuration').hidden = true;\n el('cancel-lark-configuration').hidden = true;\n el('clear-feishu').hidden = !values.feishu;\n el('clear-lark').hidden = !values.lark;\n el('check-feishu-login-app').hidden = !values.feishu || !configured(byKey, 'feishu.loginAppSecret');\n el('check-lark-login-app').hidden = !values.lark || !configured(byKey, 'lark.loginAppSecret');\n el('feishu-settings').hidden = !values.feishu;\n el('lark-settings').hidden = !values.lark;\n if (values.feishu) el('feishu-settings').href = 'https://open.feishu.cn/app/' + encodeURIComponent(values.feishu.loginAppId);\n if (values.lark) el('lark-settings').href = 'https://open.larksuite.com/app/' + encodeURIComponent(values.lark.loginAppId);\n }\n\n function requiredInput(id, label) {\n const value = el(id).value.trim();\n if (!value) throw new Error('Enter ' + label);\n return value;\n }\n\n function githubConnectorUsesDeployment(values) {\n const github = values.github;\n const connector = values.logto && values.logto.githubConnector;\n return Boolean(\n github && connector &&\n connector.appId === github.appId &&\n connector.slug === github.slug &&\n connector.clientId === github.clientId\n );\n }\n\n function beginConfigurationEdit(provider) {\n if (!currentStatus) return message('Deployment configuration is not loaded', true);\n const values = currentStatus.values;\n if (provider === 'logto') {\n const logto = values.logto;\n if (!logto || !logto.browser || values.auth.mode !== 'oidc') return;\n el('logto-edit-management-id').value = logto.managementAppId;\n el('logto-edit-management-resource').value = logto.managementResource;\n el('logto-edit-management-secret').value = '';\n el('logto-edit-browser-id').value = values.auth.browserClient.appId;\n el('logto-edit-browser-resource').value = logto.browser.apiResource || '';\n el('logto-edit-controls').hidden = false;\n el('logto-edit-management-id').focus();\n } else if (provider === 'github') {\n const github = values.github;\n if (!github) return;\n el('github-edit-app-id').value = String(github.appId);\n el('github-edit-slug').value = github.slug;\n el('github-edit-client-id').value = github.clientId || '';\n el('github-edit-webhook-enabled').checked = github.webhookEnabled !== false;\n for (const id of ['github-edit-client-secret', 'github-edit-private-key', 'github-edit-webhook-secret', 'github-edit-logto-secret']) el(id).value = '';\n el('github-edit-logto-secret-field').hidden = !githubConnectorUsesDeployment(values);\n el('github-edit-controls').hidden = false;\n el('clear-github').hidden = true;\n el('github-edit-app-id').focus();\n } else if (provider === 'slack') {\n const slack = values.slack;\n if (!slack) return;\n el('slack-edit-app-id').value = slack.appId;\n el('slack-edit-client-id').value = slack.clientId;\n el('slack-edit-client-secret').value = '';\n el('slack-edit-signing-secret').value = '';\n el('slack-edit-controls').hidden = false;\n el('clear-slack').hidden = true;\n el('slack-edit-app-id').focus();\n } else if (provider === 'gitea') {\n if (!values.gitea) return;\n el('gitea-base-url').value = values.gitea.baseUrl || '';\n el('gitea-config-controls').hidden = false;\n el('save-gitea').hidden = false;\n el('cancel-gitea-configuration').hidden = false;\n el('clear-gitea').hidden = true;\n el('gitea-base-url').focus();\n } else if (provider === 'gitlab') {\n if (!values.gitlab) return;\n el('gitlab-id').value = values.gitlab.clientId;\n el('gitlab-secret').value = '';\n el('gitlab-config-controls').hidden = false;\n el('gitlab-initial-secret-field').hidden = false;\n el('save-gitlab').hidden = false;\n el('cancel-gitlab-configuration').hidden = false;\n el('clear-gitlab').hidden = true;\n el('gitlab-id').focus();\n } else if (provider === 'googlechat') {\n if (!values.googleChat) return;\n el('googlechat-project-id-input').value = values.googleChat.projectId;\n el('googlechat-project-number-input').value = values.googleChat.projectNumber;\n el('googlechat-key').value = '';\n el('googlechat-edit-heading').hidden = false;\n el('googlechat-config-controls').hidden = false;\n el('save-googlechat').hidden = false;\n el('cancel-googlechat-configuration').hidden = false;\n el('clear-googlechat').hidden = true;\n el('googlechat-project-id-input').focus();\n } else if (provider === 'linear') {\n if (!values.linear) return;\n el('linear-id').value = values.linear.clientId;\n el('linear-client-secret').value = '';\n el('linear-signing-secret').value = '';\n el('linear-config-controls').hidden = false;\n el('linear-initial-secret-fields').hidden = false;\n el('save-linear').hidden = false;\n el('cancel-linear-configuration').hidden = false;\n el('clear-linear').hidden = true;\n el('linear-id').focus();\n } else if (provider === 'google') {\n const google = values.logto && values.logto.googleConnector;\n if (!google) return;\n el('google-id').value = google.clientId;\n el('google-secret').value = '';\n el('google-config-controls').hidden = false;\n el('google-initial-secret-field').hidden = false;\n el('save-google').hidden = false;\n el('save-google').textContent = 'Save Google client';\n el('cancel-google-configuration').hidden = false;\n el('clear-google').hidden = true;\n el('google-id').focus();\n } else if (Object.hasOwn(loginApps, provider)) {\n el(provider + '-secret').value = '';\n el(provider + '-config-controls').hidden = false;\n el(provider + '-initial-secret-field').hidden = false;\n el('save-' + provider).hidden = false;\n el('cancel-' + provider + '-configuration').hidden = false;\n el('clear-' + provider).hidden = true;\n el(provider + '-id').focus();\n } else if (provider === 'feishu' || provider === 'lark') {\n el(provider + '-config-controls').hidden = false;\n el(provider + '-create-name-field').hidden = true;\n el('create-' + provider + '-login-app').hidden = true;\n el('save-' + provider + '-login-app').hidden = false;\n el('cancel-' + provider + '-configuration').hidden = false;\n el('clear-' + provider).hidden = true;\n el(provider + '-login-secret').value = '';\n el(provider + '-login-id').focus();\n }\n showIdentityEditors(provider, false);\n }\n\n function cancelConfigurationEdit() {\n if (currentStatus) renderApps(currentStatus);\n }\n\n async function replaceConfiguration(values, secrets, successMessage) {\n const response = await json(await fetch(api + '/deployment-config', {\n method: 'PUT',\n headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ expectedRevision: currentRevision, values, ...(secrets ? { secrets } : {}) })\n }));\n await load();\n message(successMessage || 'Configuration saved. Restart AgentConnect to apply it.');\n return response;\n }\n\n async function saveLogtoConfiguration() {\n if (!currentStatus) throw new Error('Deployment configuration is not loaded');\n const values = currentStatus.values;\n const logto = values.logto;\n if (!logto || !logto.browser || values.auth.mode !== 'oidc') throw new Error('Logto is not configured');\n const managementAppId = requiredInput('logto-edit-management-id', 'the Management App ID');\n const managementResource = requiredInput('logto-edit-management-resource', 'the Management API resource');\n const browserAppId = requiredInput('logto-edit-browser-id', 'the SPA App ID');\n const browserResource = el('logto-edit-browser-resource').value.trim() || null;\n const managementIdentityChanged = managementAppId !== logto.managementAppId;\n const managementSecret = el('logto-edit-management-secret').value;\n if (managementIdentityChanged && !managementSecret) throw new Error('Enter the new Management App secret');\n const browser = { ...logto.browser, apiResource: browserResource };\n const auth = {\n ...values.auth,\n audience: browserResource || browserAppId,\n browserClient: { appId: browserAppId, apiResource: browserResource }\n };\n await replaceConfiguration(\n {\n ...values,\n auth,\n logto: { ...logto, managementAppId, managementResource, browser }\n },\n managementSecret ? { 'logto.managementAppSecret': managementSecret } : undefined,\n 'Logto configuration saved. Sign in again if the SPA identity changed, then restart AgentConnect.'\n );\n }\n\n async function saveGithubConfiguration() {\n if (!currentStatus || !currentStatus.values.github) throw new Error('GitHub is not configured');\n const values = currentStatus.values;\n const previous = values.github;\n const appId = Number(requiredInput('github-edit-app-id', 'the GitHub App ID'));\n if (!Number.isSafeInteger(appId) || appId <= 0) throw new Error('GitHub App ID must be a positive integer');\n const slug = requiredInput('github-edit-slug', 'the GitHub App slug');\n const clientId = requiredInput('github-edit-client-id', 'the GitHub Client ID');\n const appChanged = appId !== previous.appId;\n const clientChanged = clientId !== previous.clientId;\n const webhookEnabled = el('github-edit-webhook-enabled').checked;\n const webhookEnabling = webhookEnabled && previous.webhookEnabled === false;\n const connectorReused = githubConnectorUsesDeployment(values);\n const clientSecret = el('github-edit-client-secret').value;\n const privateKey = el('github-edit-private-key').value.trim();\n const webhookSecret = el('github-edit-webhook-secret').value;\n const connectorSecret = el('github-edit-logto-secret').value;\n const webhookSecretStored = currentStatus.secrets.some((secret) => secret.key === 'github.webhookSecret' && secret.configured);\n if ((appChanged || clientChanged) && !clientSecret) throw new Error('Enter the new GitHub client secret');\n if (appChanged && !privateKey) throw new Error('Enter the new GitHub private key as base64');\n if (webhookEnabled && ((appChanged && !webhookSecret) || (webhookEnabling && !webhookSecret && !webhookSecretStored))) throw new Error('Enter the new GitHub webhook secret');\n if (connectorReused && (appChanged || clientChanged) && !connectorSecret) throw new Error('Enter the new Logto connector client secret');\n const secrets = {};\n if (clientSecret) secrets['github.clientSecret'] = clientSecret;\n if (privateKey) secrets['github.privateKeyB64'] = privateKey;\n if (webhookSecret) secrets['github.webhookSecret'] = webhookSecret;\n if (connectorSecret) secrets['logto.githubConnectorClientSecret'] = connectorSecret;\n const nextLogto = connectorReused && values.logto\n ? { ...values.logto, githubConnector: { ...values.logto.githubConnector, appId, slug, clientId } }\n : values.logto;\n await replaceConfiguration(\n {\n ...values,\n github: { ...previous, appId, slug, clientId, webhookEnabled },\n ...(nextLogto ? { logto: nextLogto } : {})\n },\n Object.keys(secrets).length ? secrets : undefined,\n 'GitHub App identity saved. Restart AgentConnect to apply it.'\n );\n if (connectorSecret) {\n await reconcileLogto(true);\n await load();\n }\n }\n\n async function saveSlackConfiguration() {\n if (!currentStatus || !currentStatus.values.slack) throw new Error('Slack is not configured');\n const values = currentStatus.values;\n const previous = values.slack;\n const appId = requiredInput('slack-edit-app-id', 'the Slack App ID');\n const clientId = requiredInput('slack-edit-client-id', 'the Slack Client ID');\n const changed = appId !== previous.appId || clientId !== previous.clientId;\n const clientSecret = el('slack-edit-client-secret').value;\n const signingSecret = el('slack-edit-signing-secret').value;\n if (changed && (!clientSecret || !signingSecret)) throw new Error('Enter both the new Slack client secret and signing secret');\n const secrets = {};\n if (clientSecret) secrets['slack.clientSecret'] = clientSecret;\n if (signingSecret) secrets['slack.signingSecret'] = signingSecret;\n const nextLogto = values.logto && values.logto.slackConnector\n ? { ...values.logto, slackConnector: { ...values.logto.slackConnector, appId, clientId } }\n : values.logto;\n await replaceConfiguration(\n {\n ...values,\n slack: { ...previous, appId, clientId },\n ...(nextLogto ? { logto: nextLogto } : {})\n },\n Object.keys(secrets).length ? secrets : undefined,\n 'Slack App identity saved. Restart AgentConnect to apply it.'\n );\n if (clientSecret && nextLogto && nextLogto.slackConnector) {\n await reconcileLogto(true);\n await load();\n }\n }\n\n async function clearProvider(provider) {\n if (!currentStatus) throw new Error('Deployment configuration is not loaded');\n const label = provider === 'github' ? 'GitHub' : provider === 'gitlab' ? 'GitLab' : provider === 'gitea' ? 'Gitea' : provider === 'slack' ? 'Slack' : provider === 'googlechat' ? 'Google Chat' : provider === 'linear' ? 'Linear' : provider === 'google' ? 'Google' : provider === 'feishu' ? 'Feishu' : 'Lark';\n if (!window.confirm('Clear the saved ' + label + ' configuration and secrets?')) return;\n const values = currentStatus.values;\n let next = values;\n let secrets = {};\n if (provider === 'github') {\n const connectorReused = githubConnectorUsesDeployment(values);\n const logto = connectorReused && values.logto\n ? {\n ...values.logto,\n githubConnector: null\n }\n : values.logto;\n next = { ...values, github: null, ...(logto ? { logto } : {}) };\n secrets = {\n 'github.clientSecret': null,\n 'github.privateKeyB64': null,\n 'github.webhookSecret': null,\n ...(connectorReused ? { 'logto.githubConnectorClientSecret': null } : {})\n };\n } else if (provider === 'gitea') {\n // Gitea holds no deployment secret, so clearing the address clears the whole entry.\n next = { ...values, gitea: null };\n } else if (provider === 'gitlab') {\n await json(await fetch(api + '/configure/gitlab', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ application: null })\n }));\n await load();\n message('GitLab configuration cleared. Its setup controls are available again.');\n return;\n } else if (provider === 'slack') {\n const logto = values.logto\n ? {\n ...values.logto,\n browser: values.logto.browser\n ? { ...values.logto.browser, socialProviders: values.logto.browser.socialProviders.filter((item) => item !== 'slack') }\n : values.logto.browser,\n slackConnector: null\n }\n : values.logto;\n next = { ...values, slack: null, ...(logto ? { logto } : {}) };\n secrets = { 'slack.clientSecret': null, 'slack.signingSecret': null };\n } else if (provider === 'googlechat') {\n next = { ...values, googleChat: null };\n secrets = { 'googleChat.serviceAccountKey': null };\n } else if (provider === 'linear') {\n next = { ...values, linear: null };\n secrets = { 'linear.clientSecret': null, 'linear.signingSecret': null };\n } else if (provider === 'google') {\n if (!values.logto) return;\n next = {\n ...values,\n logto: {\n ...values.logto,\n browser: values.logto.browser\n ? { ...values.logto.browser, socialProviders: values.logto.browser.socialProviders.filter((item) => item !== 'google') }\n : values.logto.browser,\n googleConnector: null\n }\n };\n secrets = { 'logto.googleConnectorClientSecret': null };\n } else if (provider === 'feishu' || provider === 'lark') {\n next = { ...values, [provider]: null };\n secrets = { [provider + '.loginAppSecret']: null };\n }\n await replaceConfiguration(next, secrets, label + ' configuration cleared. Its setup controls are available again.');\n }\n\n async function startGithub(prefix) {\n const result = await json(await fetch(api + '/create/github/start', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({\n purpose: 'deployment',\n name: el(prefix + '-name').value,\n ownership: ownership(prefix),\n connectLogto: prefix === 'bootstrap-github'\n })\n }));\n const form = document.createElement('form');\n form.method = 'post'; form.action = result.action;\n const manifest = document.createElement('input');\n manifest.type = 'hidden'; manifest.name = 'manifest'; manifest.value = JSON.stringify(result.manifest);\n form.append(manifest); document.body.append(form);\n message('Opening GitHub to review the complete integration App.');\n form.submit();\n }\n\n async function loadBootstrapInfo() {\n bootstrapInfo = await json(await fetch(api + '/bootstrap-info'));\n el('open-logto').href = bootstrapInfo.logtoAdminEndpoint;\n el('logto-settings').href = bootstrapInfo.logtoAdminEndpoint;\n if (!el('logto-app-id').value && bootstrapInfo.logtoManagementAppId) {\n el('logto-app-id').value = bootstrapInfo.logtoManagementAppId;\n }\n renderUriList('bootstrap-google-origins', bootstrapInfo.google.javascriptOrigins);\n renderUriList('bootstrap-google-redirects', bootstrapInfo.google.redirectUris);\n renderUriList('bootstrap-slack-redirects', [bootstrapInfo.slackLoginRedirectUrl]);\n el('logto-management-api-resource').value = bootstrapInfo.logtoManagementResource;\n el('bootstrap-github-submit').disabled = !bootstrapInfo.githubAvailable;\n if (!bootstrapInfo.githubAvailable) {\n el('bootstrap-github-note').textContent = 'GitHub App creation needs valid saved Web, API, and ingress URLs.';\n } else if (!bootstrapInfo.githubWebhookActive) {\n el('bootstrap-github-note').textContent = 'Creates the complete GitHub App now without submitting the localhost webhook URL. Add it after saving reachable HTTPS ingress.';\n } else {\n el('bootstrap-github-note').textContent = 'This creates one complete App for both GitHub sign-in and repository integration.';\n }\n el('bootstrap-slack-option').disabled = !bootstrapInfo.slackAvailable;\n el('bootstrap-slack-submit').disabled = !bootstrapInfo.slackAvailable;\n el('bootstrap-slack-note').textContent = bootstrapInfo.slackAvailable\n ? 'Creates one complete Slack App. Sign-in uses a separate openid profile email flow from workspace installation.'\n : 'Slack sign-in needs all public service URLs to use HTTPS. Use Google locally or expose the stack through trusted HTTPS endpoints.';\n if (!bootstrapInfo.slackAvailable && el('bootstrap-provider').value === 'slack') {\n el('bootstrap-provider').value = 'google';\n updateBootstrapProvider();\n }\n return bootstrapInfo;\n }\n\n async function logtoBootstrapFinding() {\n const report = await json(await fetch(api + '/check/logto', { headers: bearer() }));\n for (const id of ['logto.client_credentials', 'logto.roles_read']) {\n const finding = report.findings.find((candidate) => candidate.id === id);\n if (!finding || finding.status !== 'pass') {\n return finding || { status: 'fail', message: 'Logto Management API permissions could not be verified.' };\n }\n }\n return { status: 'pass', message: 'Logto Management API access is ready.' };\n }\n\n async function showBootstrap() {\n el('bootstrap').hidden = false;\n el('show-bootstrap').hidden = true;\n const info = bootstrapInfo || await loadBootstrapInfo();\n if (!info.logtoConfigured) {\n showBootstrapStep('logto');\n return;\n }\n const finding = await logtoBootstrapFinding();\n if (finding && finding.status === 'pass') {\n showBootstrapStep('provider');\n message('Logto Management API access is ready. Choose a sign-in provider.');\n return;\n }\n showBootstrapStep('logto');\n message(finding?.message || 'Logto Management API credentials could not be verified.', true);\n }\n\n async function bootstrapLogto() {\n await json(await fetch(api + '/bootstrap/logto', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({\n managementAppId: el('logto-app-id').value,\n managementAppSecret: el('logto-app-secret').value,\n managementResource: el('logto-management-api-resource').value\n })\n }));\n el('logto-app-secret').value = '';\n await loadBootstrapInfo();\n const finding = await logtoBootstrapFinding();\n if (!finding || finding.status !== 'pass') {\n throw new Error(finding?.message || 'Logto Management API credentials could not be verified.');\n }\n showBootstrapStep('provider');\n message('Logto Management API access is ready. Choose a sign-in provider.');\n }\n\n async function bootstrapGoogle() {\n const secret = el('bootstrap-google-secret').value;\n await json(await fetch(api + '/configure/google', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ clientId: el('bootstrap-google-id').value, clientSecret: secret })\n }));\n el('bootstrap-google-secret').value = '';\n await reconcileLogto(true);\n await load();\n }\n\n async function bootstrapGithub() {\n if (!bootstrapInfo || !bootstrapInfo.githubAvailable) throw new Error('GitHub App creation needs valid saved Web, API, and ingress URLs.');\n await startGithub('bootstrap-github');\n }\n\n async function bootstrapSlack() {\n if (!bootstrapInfo || !bootstrapInfo.slackAvailable) throw new Error('Slack sign-in requires all public service URLs to use HTTPS.');\n await createSlack('bootstrap-slack', true);\n await reconcileLogto();\n await load();\n }\n\n async function createSlack(prefix = 'slack', connectLogto = false) {\n const result = await json(await fetch(api + '/create/slack', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({\n name: el(prefix + '-name').value,\n configToken: el(prefix + '-token').value,\n connectLogto\n })\n }));\n el(prefix + '-token').value = '';\n if (!connectLogto) await load();\n message('Slack App ' + result.app.id + ' was created with the default integration manifest. Restart AgentConnect to apply it.');\n }\n\n async function checkGithub() {\n const result = await json(await fetch(api + '/check/github', { headers: bearer() }));\n el('github-settings').href = result.settingsUrl;\n el('github-settings').hidden = false;\n const label = result.missing.length ? 'Update required' : \"Can't verify automatically\";\n showDiff('github-drift', result.diff || [], label);\n match('github-match', result.status === 'pass' ? 'pass' : 'warn', result.status === 'pass' ? 'Matches' : label);\n message(result.status === 'pass' ? 'GitHub App matches the expected integration manifest.' : result.missing.length ? 'GitHub App settings need an update.' : \"GitHub can't expose callback, setup, or webhook-active settings through its API; check them in GitHub.\", result.status === 'fail');\n }\n\n async function connectGithubLogin() {\n await json(await fetch(api + '/configure/github-login', { method: 'POST', headers: bearer() }));\n await reconcileLogto();\n await load();\n message('The deployment GitHub App is now also used for Logto sign-in. Update its displayed callback URLs.');\n }\n\n async function connectSlackLogin() {\n if (!currentStatus || !currentStatus.values.slack || !currentStatus.values.logto) {\n throw new Error('Save the Slack App and Logto configuration first');\n }\n const values = currentStatus.values;\n const slack = values.slack;\n const logto = values.logto;\n await replaceConfiguration(\n {\n ...values,\n logto: {\n ...logto,\n browser: logto.browser\n ? { ...logto.browser, socialProviders: [...new Set([...logto.browser.socialProviders, 'slack'])] }\n : logto.browser,\n slackConnector: {\n appId: slack.appId,\n clientId: slack.clientId\n }\n }\n },\n undefined,\n 'Slack App is ready to connect to Logto.'\n );\n await reconcileLogto();\n await load();\n message('The deployment Slack App is now also used for Logto sign-in.');\n }\n\n async function checkSlack() {\n const token = requiredInput('slack-token', 'the temporary Slack App configuration token');\n const result = await json(await fetch(api + '/check/slack', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ configToken: token })\n }));\n el('slack-token').value = '';\n showDiff('slack-drift', result.diff || []);\n match('slack-match', result.status === 'pass' ? 'pass' : 'warn', result.status === 'pass' ? 'Matches' : 'Update required');\n message(result.status === 'pass' ? 'Slack App matches the default integration manifest.' : 'Slack App settings need an update.', result.status !== 'pass');\n }\n\n async function reconcileSlack() {\n const token = requiredInput('slack-token', 'the temporary Slack App configuration token');\n const result = await json(await fetch(api + '/reconcile/slack', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ configToken: token })\n }));\n el('slack-token').value = '';\n showDiff('slack-drift', result.diff || []);\n match('slack-match', result.status === 'pass' ? 'pass' : 'warn', result.status === 'pass' ? 'Matches' : 'Update required');\n message(\n result.status !== 'pass'\n ? 'Slack App was updated but some settings still differ.'\n : result.applied.length === 0\n ? 'Slack App already matches the default integration manifest.'\n : result.permissionsUpdated\n ? 'Slack App updated. Reinstall it in each workspace to grant the added scopes.'\n : 'Slack App updated.',\n result.status !== 'pass'\n );\n }\n\n async function checkGoogle() {\n const report = await checkLogto();\n const connector = report.findings.find((finding) => finding.id === 'logto.connectors');\n const google = currentStatus && currentStatus.values.logto && currentStatus.values.logto.googleConnector;\n const connectorDiff = connector && connector.diff\n ? connector.diff.filter((item) => item.field.toLowerCase().startsWith('google'))\n : [];\n const passed = Boolean(google && connector && connectorDiff.length === 0);\n showDiff('google-drift', connectorDiff);\n match('google-match', passed ? 'pass' : 'warn', passed ? 'Logto matches' : 'Update required');\n message(\n passed\n ? \"The Logto Google connector matches. Google OAuth redirect settings can't be verified automatically.\"\n : 'The Logto Google connector needs an update.',\n !passed\n );\n }\n\n async function checkRegionalLoginApp(region) {\n const result = await json(await fetch(api + '/check/regional-login-app/' + region, { headers: bearer() }));\n const label = region === 'feishu' ? 'Feishu' : 'Lark';\n showDiff(region + '-drift', result.diff || []);\n match(region + '-match', result.status === 'pass' ? 'pass' : result.status === 'fail' ? 'fail' : 'warn', result.status === 'pass' ? 'Matches' : result.status === 'fail' ? 'Update required' : 'Could not check');\n el(region + '-login-status').textContent = result.message;\n el(region + '-login-status').className = result.status === 'pass' ? 'ok' : result.status === 'fail' ? 'warn' : 'muted';\n message(result.message || (label + ' credential check completed.'), result.status === 'fail');\n }\n\n async function saveGitlab() {\n const clientSecret = el('gitlab-secret').value;\n const baseUrl = el('gitlab-base-url').value.trim();\n const saved = await json(await fetch(api + '/configure/gitlab', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({\n application: {\n clientId: requiredInput('gitlab-id', 'the GitLab Application ID'),\n ...(clientSecret ? { clientSecret } : {}),\n ...(baseUrl ? { baseUrl } : {})\n }\n })\n }));\n el('gitlab-secret').value = '';\n if (currentStatus.values.logto?.browser?.socialProviders.includes('gitlab')) await reconcileLogto(Boolean(clientSecret));\n await load();\n showGitlabProbe(saved.probe);\n message('GitLab OAuth application saved. Restart AgentConnect to apply it.');\n }\n\n // Only the URL shape blocks the save, so every other verdict is a line to read.\n function showGitlabProbe(probe) {\n const line = el('gitlab-probe');\n line.hidden = !probe;\n line.textContent = probe ? probe.message : '';\n }\n\n async function configureGitlabLogin(enabled) {\n await json(await fetch(api + '/configure/gitlab-login', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ enabled })\n }));\n try { await reconcileLogto(enabled); } finally { await load(); }\n message('GitLab sign-in ' + (enabled ? 'configured' : 'disabled') + '. Restart AgentConnect to apply it.');\n }\n\n async function saveGitea() {\n const baseUrl = el('gitea-base-url').value.trim();\n const saved = await json(await fetch(api + '/configure/gitea', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ instance: { ...(baseUrl ? { baseUrl } : {}) } })\n }));\n await load();\n // Shape and the version floor already blocked the save, so any verdict here is a line to read.\n const line = el('gitea-probe');\n line.hidden = !saved.probe;\n line.textContent = saved.probe ? saved.probe.message : '';\n message('Gitea instance saved. Restart AgentConnect to apply it.');\n }\n\n async function saveGoogleChat() {\n const projectNumber = el('googlechat-project-number-input').value.trim();\n const application = {\n projectId: requiredInput('googlechat-project-id-input', 'the Google Cloud project ID'),\n ...(projectNumber ? { projectNumber } : {})\n };\n const serviceAccountKey = el('googlechat-key').value.trim();\n let saved;\n try {\n saved = await json(await fetch(api + '/configure/google-chat', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ application: { ...application, ...(serviceAccountKey ? { serviceAccountKey } : {}) } })\n }));\n } finally {\n // The key never stays in the page once it has been submitted, whatever the outcome.\n el('googlechat-key').value = '';\n }\n await load();\n message('Google Chat app saved. ' + saved.probe.message + '. Restart AgentConnect to apply it.');\n }\n\n async function saveLinear() {\n const clientSecret = el('linear-client-secret').value;\n const signingSecret = el('linear-signing-secret').value;\n await json(await fetch(api + '/configure/linear', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({\n application: {\n clientId: requiredInput('linear-id', 'the Linear Client ID'),\n ...(clientSecret ? { clientSecret } : {}),\n ...(signingSecret ? { signingSecret } : {})\n }\n })\n }));\n el('linear-client-secret').value = '';\n el('linear-signing-secret').value = '';\n await load();\n message('Linear OAuth application saved. Restart AgentConnect to apply it.');\n }\n\n async function saveGoogle() {\n const secret = el('google-secret').value;\n await json(await fetch(api + '/configure/google', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({\n clientId: el('google-id').value,\n ...(secret ? { clientSecret: secret } : {})\n })\n }));\n el('google-secret').value = '';\n await reconcileLogto(Boolean(secret));\n await load();\n message('Google OAuth client and Logto connector are configured. Restart AgentConnect to apply the saved settings.');\n }\n\n async function saveLoginApp(provider, clear = false) {\n const app = loginApps[provider];\n if (clear && !window.confirm('Clear the saved ' + app.name + ' login application and secret?')) return;\n const clientSecret = el(provider + '-secret').value;\n const application = clear ? null : {\n clientId: requiredInput(provider + '-id', 'the ' + app.name + ' Client ID'),\n ...(clientSecret ? { clientSecret } : {})\n };\n // Microsoft's home tenant also makes the app the deployment's Teams app; empty turns that off.\n const teams = provider === 'azuread' && !clear ? { teamsTenantId: el('azuread-teams-tenant-input').value.trim() || null } : {};\n try {\n await json(await fetch(api + '/configure/login-app/' + provider, {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ application, ...teams })\n }));\n } finally {\n el(provider + '-secret').value = '';\n }\n try {\n await reconcileLogto(Boolean(clientSecret) && !clear);\n } finally {\n await load();\n }\n message(app.name + (clear ? ' sign-in configuration cleared.' : ' login application and Logto connector configured.') + ' Restart AgentConnect to apply the saved settings.');\n }\n\n function renderLoginAppChecks(report) {\n const connectors = report.findings.find((finding) => finding.id === 'logto.connectors');\n if (currentStatus?.values.logto?.browser?.socialProviders.includes('gitlab')) {\n const diff = connectors?.diff?.filter((item) => item.field.toLowerCase().startsWith('gitlab')) || [];\n const passed = Boolean(connectors && diff.length === 0 && (connectors.status === 'pass' || connectors.diff?.length));\n el('gitlab-login-status').textContent = passed\n ? 'GitLab connector matches. Confirm the redirect URLs and OpenID scopes in GitLab.'\n : 'GitLab sign-in could not be verified. Review the Logto check above.';\n }\n for (const [provider, app] of Object.entries(loginApps)) {\n if (!currentStatus?.values.logto?.[provider + 'Connector']) continue;\n const diff = connectors?.diff?.filter((item) => item.field.toLowerCase().startsWith(provider)) || [];\n const passed = Boolean(connectors && diff.length === 0 && (connectors.status === 'pass' || connectors.diff?.length));\n showDiff(provider + '-drift', diff);\n match(provider + '-match', passed ? 'pass' : 'warn', passed ? 'Logto matches' : 'Could not verify');\n el(provider + '-status').textContent = passed\n ? app.name + ' connector matches. Confirm the redirect URLs in the provider settings.'\n : app.name + ' connector could not be verified. Review the Logto check above.';\n }\n }\n\n function regionalLoginApp(prefix) {\n const appId = el(prefix + '-login-id').value.trim();\n const appSecret = el(prefix + '-login-secret').value;\n return appId ? { appId, ...(appSecret ? { appSecret } : {}) } : null;\n }\n\n async function saveRegionalLoginApp(region) {\n const app = regionalLoginApp(region);\n if (!app) throw new Error('Enter the ' + (region === 'feishu' ? 'Feishu' : 'Lark') + ' App ID');\n await json(await fetch(api + '/configure/regional-login-app', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ region, app })\n }));\n el(region + '-login-secret').value = '';\n await load();\n message((region === 'feishu' ? 'Feishu' : 'Lark') + ' tenant App is saved. Restart AgentConnect services to apply it.');\n }\n\n async function createRegionalLoginApp(region) {\n const popup = window.open('about:blank', '_blank');\n try {\n const started = await json(await fetch(api + '/create/regional-login-app/start', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ region, name: el(region + '-create-name').value })\n }));\n if (popup) popup.location.replace(started.authorizationUrl);\n else window.open(started.authorizationUrl, '_blank', 'noopener');\n message('Approve the new ' + (region === 'feishu' ? 'Feishu' : 'Lark') + ' App in the opened page. This page will finish saving it automatically.');\n while (true) {\n const result = await json(await fetch(api + '/create/regional-login-app/' + encodeURIComponent(started.id), { headers: bearer() }));\n if (result.status === 'completed') {\n await load();\n message((region === 'feishu' ? 'Feishu' : 'Lark') + ' App ' + result.appId + ' was created and saved. Restart AgentConnect services to apply it.');\n return;\n }\n if (result.status === 'failed') throw new Error('Regional App creation ' + result.reason + '. Start it again.');\n await new Promise((resolve) => setTimeout(resolve, Math.max(500, Math.min(result.retryAfterMs || 2000, 5000))));\n }\n } catch (error) {\n if (popup) popup.close();\n throw error;\n }\n }\n\n async function reconcileLogto(refreshConnectorSecrets) {\n return json(await fetch(api + '/reconcile/logto', {\n method: 'POST',\n headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ refreshConnectorSecrets: Boolean(refreshConnectorSecrets) })\n }));\n }\n\n async function refreshDeploymentConfig() {\n const status = await json(await fetch(api + '/deployment-config', { headers: bearer() }));\n el('access').hidden = true;\n el('setup').hidden = false;\n el('editor').hidden = false;\n currentRevision = status.revision;\n renderApps(status);\n return status;\n }\n\n async function checkLogto() {\n const report = await json(await fetch(api + '/check/logto', { headers: bearer() }));\n await refreshDeploymentConfig();\n const failures = report.findings.filter((finding) => finding.status !== 'pass');\n el('logto-status').textContent = failures.length === 0\n ? 'SPA redirects, connectors, and social-only sign-in match.'\n : failures.map((finding) => finding.message).join(' ');\n el('logto-status').className = failures.length === 0 ? 'ok' : 'warn';\n match('logto-match', failures.length === 0 ? 'pass' : 'warn', failures.length === 0 ? 'Matches' : 'Update required');\n showDiff(\n 'logto-drift',\n failures.flatMap((finding) => finding.diff && finding.diff.length\n ? finding.diff\n : [{ field: finding.id.replace(/^logto\\./, '').replaceAll('_', ' '), current: finding.message, expected: 'Matches expected configuration' }])\n );\n el('logto-settings').hidden = failures.length === 0;\n renderLoginAppChecks(report);\n return report;\n }\n\n function githubNotice(value) {\n if (value === 'deployment-created') return 'GitHub integration App created. Its private key and webhook secret are stored.';\n if (value === 'deployment-login-created') return 'GitHub integration App created and connected to Logto sign-in.';\n if (value === 'cancelled') return 'GitHub App creation was cancelled.';\n if (value === 'expired') return 'GitHub App creation expired. Start it again.';\n if (value === 'invalid-callback') return 'GitHub returned an invalid App creation callback.';\n if (value === 'conversion-failed') return 'GitHub may have created the App, but did not return complete credentials. Delete the orphaned App and retry.';\n if (value === 'save-failed') return 'GitHub created the App, but its credentials could not be saved. Delete the orphaned App and retry.';\n return '';\n }\n\n async function load() {\n let publicAuth = await authConfig();\n const currentUrl = new URL(location.href);\n const githubResult = currentUrl.searchParams.get('github');\n const notice = githubNotice(githubResult);\n if (githubResult === 'deployment-login-created' && publicAuth.mode === 'none') {\n await reconcileLogto();\n publicAuth = await authConfig();\n }\n if (githubResult) history.replaceState({}, '', '/');\n const hasToken = Boolean(sessionStorage.getItem(tokenKey));\n el('access').hidden = false;\n el('setup').hidden = true;\n el('login').hidden = true;\n el('open-logto').hidden = true;\n el('show-bootstrap').hidden = true;\n el('editor').hidden = true;\n el('bootstrap').hidden = true;\n if (publicAuth.logtoAdminEndpoint) {\n el('open-logto').href = publicAuth.logtoAdminEndpoint;\n el('logto-settings').href = publicAuth.logtoAdminEndpoint;\n }\n if (publicAuth.mode === 'none') {\n sessionStorage.removeItem(tokenKey);\n await loadBootstrapInfo();\n el('open-logto').hidden = false;\n el('show-bootstrap').hidden = false;\n message(notice || 'Logto sign-in is not configured. Set up the initial Logto administrator first.');\n return;\n }\n if (publicAuth.mode === 'unavailable') {\n el('open-logto').hidden = false;\n message(publicAuth.message || 'Logto sign-in is unavailable. Open Logto Console to review its settings.', true);\n return;\n }\n if (!hasToken) {\n el('login').hidden = false;\n message(notice || 'Sign in with Logto to continue.');\n return;\n }\n if (publicAuth.claimAvailable) {\n try {\n await json(await fetch(api + '/bootstrap/claim', { method: 'POST', headers: bearer() }));\n sessionStorage.removeItem(tokenKey);\n el('login').hidden = false;\n message('This first user is now an ADMIN. Sign in again to refresh the role claim.');\n return;\n } catch (error) {\n if (error.status !== 401 && error.status !== 403) throw error;\n sessionStorage.removeItem(tokenKey);\n el('login').hidden = false;\n message('Sign in with Logto to continue.', true);\n return;\n }\n }\n try {\n await loadBootstrapInfo();\n await refreshDeploymentConfig();\n message(notice);\n checkLogto().catch((error) => {\n el('logto-status').textContent = error.message;\n el('logto-status').className = 'warn';\n el('logto-settings').hidden = false;\n });\n } catch (error) {\n if (error.status === 401 || error.status === 403) {\n sessionStorage.removeItem(tokenKey);\n el('login').hidden = false;\n message('Sign in with a Logto ADMIN account.', true);\n }\n else throw error;\n }\n }\n\n async function saveSecretReplacement(key, value) {\n if (!currentStatus) throw new Error('Deployment configuration is not loaded');\n if (!value) throw new Error('Enter the replacement secret');\n const refreshLogto =\n (key === 'gitlab.clientSecret' && currentStatus.values.logto?.browser?.socialProviders.includes('gitlab')) ||\n (key === 'logto.githubConnectorClientSecret' && currentStatus.values.logto && currentStatus.values.logto.githubConnector) ||\n (key === 'logto.googleConnectorClientSecret' && currentStatus.values.logto && currentStatus.values.logto.googleConnector) ||\n Object.keys(loginApps).some((provider) => key === 'logto.' + provider + 'ConnectorClientSecret' && currentStatus.values.logto?.[provider + 'Connector']) ||\n (key === 'slack.clientSecret' && currentStatus.values.logto && currentStatus.values.logto.slackConnector);\n await json(await fetch(api + '/deployment-config', {\n method: 'PUT', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ expectedRevision: currentRevision, values: currentStatus.values, secrets: { [key]: value } })\n }));\n try {\n if (refreshLogto) await reconcileLogto(true);\n } finally {\n await load();\n }\n if (refreshLogto) {\n message('Secret replaced and applied to the Logto connector. Restart AgentConnect to apply deployment changes.');\n } else {\n message('Secret replaced. Restart AgentConnect to apply it.');\n }\n }\n\n function editSecret(button) {\n if (!currentStatus) return message('Deployment configuration is not loaded', true);\n const row = button.closest('.secret-line');\n const display = el(button.dataset.secretDisplay);\n if (!row || !display || row.querySelector('.secret-editor')) return;\n const editor = document.createElement('span'); editor.className = 'secret-editor';\n const input = document.createElement('input');\n input.type = 'password'; input.autocomplete = 'new-password'; input.placeholder = 'Enter replacement secret';\n const save = document.createElement('button'); save.textContent = 'Save';\n const cancel = document.createElement('button'); cancel.textContent = 'Cancel';\n const close = () => { editor.remove(); display.hidden = false; button.hidden = false; };\n save.onclick = async () => {\n save.disabled = true;\n try { await saveSecretReplacement(button.dataset.secretKey, input.value); }\n catch (error) { save.disabled = false; message(error.message, true); }\n };\n cancel.onclick = close;\n input.onkeydown = (event) => { if (event.key === 'Enter') save.click(); if (event.key === 'Escape') close(); };\n display.hidden = true; button.hidden = true;\n editor.append(input, save, cancel); row.append(editor); input.focus();\n }\n\n async function saveOptions() {\n if (!currentStatus) throw new Error('Deployment configuration is not loaded');\n const values = {\n ...currentStatus.values,\n features: {\n presetAgentsEnabled: el('preset-agents-enabled').checked,\n maxOrgsPerNonAdminUser: Number(el('max-orgs-per-non-admin-user').value)\n }\n };\n const saved = await json(await fetch(api + '/deployment-config', {\n method: 'PUT', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ expectedRevision: currentRevision, values })\n }));\n message('Saved deployment options. Restart AgentConnect to apply them.');\n await load();\n }\n\n el('login').onclick = () => signIn().catch((error) => message(error.message, true));\n el('show-bootstrap').onclick = () => showBootstrap().catch((error) => message(error.message, true));\n el('bootstrap-provider').onchange = updateBootstrapProvider;\n el('bootstrap-github-owner').onchange = () => updateOwner('bootstrap-github');\n el('github-owner').onchange = () => updateOwner('github');\n el('bootstrap-logto-submit').onclick = () => bootstrapLogto().catch((error) => message(error.message, true));\n el('bootstrap-back').onclick = () => showBootstrapStep('logto');\n el('bootstrap-google-submit').onclick = () => bootstrapGoogle().catch((error) => message(error.message, true));\n el('bootstrap-github-submit').onclick = () => bootstrapGithub().catch((error) => message(error.message, true));\n el('bootstrap-slack-submit').onclick = () => bootstrapSlack().catch((error) => message(error.message, true));\n el('create-github').onclick = () => startGithub('github').catch((error) => message(error.message, true));\n el('save-logto-configuration').onclick = () => saveLogtoConfiguration().catch((error) => message(error.message, true));\n el('cancel-logto-configuration').onclick = cancelConfigurationEdit;\n el('save-github-configuration').onclick = () => saveGithubConfiguration().catch((error) => message(error.message, true));\n el('cancel-github-configuration').onclick = cancelConfigurationEdit;\n el('clear-github').onclick = () => clearProvider('github').catch((error) => message(error.message, true));\n el('connect-github-login').onclick = () => connectGithubLogin().catch((error) => message(error.message, true));\n el('connect-gitlab-login').onclick = () => configureGitlabLogin(true).catch((error) => message(error.message, true));\n el('disconnect-gitlab-login').onclick = () => configureGitlabLogin(false).catch((error) => message(error.message, true));\n el('check-gitlab-login').onclick = () => checkLogto().catch((error) => message(error.message, true));\n el('check-github').onclick = () => checkGithub().catch((error) => message(error.message, true));\n el('create-slack').onclick = () => createSlack('slack').catch((error) => message(error.message, true));\n el('connect-slack-login').onclick = () => connectSlackLogin().catch((error) => message(error.message, true));\n el('save-slack-configuration').onclick = () => saveSlackConfiguration().catch((error) => message(error.message, true));\n el('cancel-slack-configuration').onclick = cancelConfigurationEdit;\n el('clear-slack').onclick = () => clearProvider('slack').catch((error) => message(error.message, true));\n el('check-slack').onclick = () => checkSlack().catch((error) => message(error.message, true));\n el('reconcile-slack').onclick = () => reconcileSlack().catch((error) => message(error.message, true));\n el('save-gitea').onclick = () => saveGitea().catch((error) => message(error.message, true));\n el('cancel-gitea-configuration').onclick = cancelConfigurationEdit;\n el('clear-gitea').onclick = () => clearProvider('gitea').catch((error) => message(error.message, true));\n el('save-gitlab').onclick = () => saveGitlab().catch((error) => message(error.message, true));\n el('cancel-gitlab-configuration').onclick = cancelConfigurationEdit;\n el('clear-gitlab').onclick = () => clearProvider('gitlab').catch((error) => message(error.message, true));\n el('save-googlechat').onclick = () => saveGoogleChat().catch((error) => message(error.message, true));\n el('cancel-googlechat-configuration').onclick = cancelConfigurationEdit;\n el('clear-googlechat').onclick = () => clearProvider('googlechat').catch((error) => message(error.message, true));\n el('save-linear').onclick = () => saveLinear().catch((error) => message(error.message, true));\n el('cancel-linear-configuration').onclick = cancelConfigurationEdit;\n el('clear-linear').onclick = () => clearProvider('linear').catch((error) => message(error.message, true));\n el('save-google').onclick = () => saveGoogle().catch((error) => message(error.message, true));\n el('cancel-google-configuration').onclick = cancelConfigurationEdit;\n el('clear-google').onclick = () => clearProvider('google').catch((error) => message(error.message, true));\n el('check-google').onclick = () => checkGoogle().catch((error) => message(error.message, true));\n for (const provider of Object.keys(loginApps)) {\n el('save-' + provider).onclick = () => saveLoginApp(provider).catch((error) => message(error.message, true));\n el('cancel-' + provider + '-configuration').onclick = cancelConfigurationEdit;\n el('clear-' + provider).onclick = () => saveLoginApp(provider, true).catch((error) => message(error.message, true));\n el('check-' + provider).onclick = () => checkLogto().catch((error) => message(error.message, true));\n }\n el('create-feishu-login-app').onclick = () => createRegionalLoginApp('feishu').catch((error) => message(error.message, true));\n el('save-feishu-login-app').onclick = () => saveRegionalLoginApp('feishu').catch((error) => message(error.message, true));\n el('cancel-feishu-configuration').onclick = cancelConfigurationEdit;\n el('clear-feishu').onclick = () => clearProvider('feishu').catch((error) => message(error.message, true));\n el('check-feishu-login-app').onclick = () => checkRegionalLoginApp('feishu').catch((error) => message(error.message, true));\n el('create-lark-login-app').onclick = () => createRegionalLoginApp('lark').catch((error) => message(error.message, true));\n el('save-lark-login-app').onclick = () => saveRegionalLoginApp('lark').catch((error) => message(error.message, true));\n el('cancel-lark-configuration').onclick = cancelConfigurationEdit;\n el('clear-lark').onclick = () => clearProvider('lark').catch((error) => message(error.message, true));\n el('check-lark-login-app').onclick = () => checkRegionalLoginApp('lark').catch((error) => message(error.message, true));\n el('check-logto').onclick = () => checkLogto().catch((error) => message(error.message, true));\n el('reconcile-logto').onclick = () => reconcileLogto().then(load).catch((error) => message(error.message, true));\n el('logout').onclick = () => { sessionStorage.removeItem(tokenKey); load().catch((error) => message(error.message, true)); };\n el('save-options').onclick = () => saveOptions().catch((error) => message(error.message, true));\n for (const button of document.querySelectorAll('.edit-secret')) button.onclick = () => editSecret(button);\n for (const button of document.querySelectorAll('.edit-configuration')) button.onclick = () => beginConfigurationEdit(button.dataset.provider);\n updateBootstrapProvider();\n finishSignIn().then(load).catch((error) => message(error.message, true));\n <\/script>\n</body>\n</html>"
|
|
67099
|
-
])), Object.entries(LOGTO_LOGIN_APPS).map(([provider, app]) => String.raw`
|
|
67269
|
+
";\n\n function showIdentityEditors(provider, show) {\n for (const button of document.querySelectorAll('.edit-configuration[data-provider=\"' + provider + '\"]')) {\n button.hidden = !show;\n }\n }\n\n function text(id, value) {\n el(id).textContent = value === null || value === undefined || value === '' ? 'Not configured' : String(value);\n }\n\n function secretText(id, byKey, key, editable) {\n const stored = configured(byKey, key);\n const display = el(id);\n const button = document.querySelector('[data-secret-display=\"' + id + '\"]');\n const row = display.closest('.secret-line');\n const editor = row && row.querySelector('.secret-editor');\n if (editor) editor.remove();\n display.hidden = false;\n display.textContent = stored ? '***' : 'Not configured';\n display.className = stored ? 'redacted' : 'muted';\n button.hidden = !editable;\n button.textContent = stored ? 'Edit' : 'Set';\n }\n\n function match(id, state, label) {\n const target = el(id);\n target.textContent = label;\n target.className = 'badge' + (state ? ' ' + state : '');\n }\n\n async function authConfig() { return json(await fetch(api + '/auth-config')); }\n\n async function signIn() {\n const config = await authConfig();\n if (config.mode !== 'oidc') throw new Error('Save an OIDC configuration first');\n const verifier = random();\n const challenge = base64url(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))));\n const state = random();\n sessionStorage.setItem(verifierKey, verifier);\n sessionStorage.setItem(stateKey, state);\n const url = new URL(config.authorizationEndpoint);\n url.searchParams.set('client_id', config.appId);\n url.searchParams.set('redirect_uri', config.redirectUri);\n url.searchParams.set('response_type', 'code');\n url.searchParams.set('scope', 'openid profile email roles');\n url.searchParams.set('state', state);\n url.searchParams.set('code_challenge', challenge);\n url.searchParams.set('code_challenge_method', 'S256');\n if (config.resource) url.searchParams.set('resource', config.resource);\n if (config.ssoConnectorId) url.searchParams.set('direct_sign_in', 'sso:' + config.ssoConnectorId);\n location.assign(url);\n }\n\n async function finishSignIn() {\n const url = new URL(location.href);\n const code = url.searchParams.get('code');\n if (!code) return;\n const state = url.searchParams.get('state');\n if (!state || state !== sessionStorage.getItem(stateKey)) throw new Error('OIDC state mismatch');\n const verifier = sessionStorage.getItem(verifierKey);\n if (!verifier) throw new Error('PKCE verifier is missing; start sign-in again');\n const config = await authConfig();\n const body = new URLSearchParams({\n grant_type: 'authorization_code', code, client_id: config.appId,\n redirect_uri: config.redirectUri, code_verifier: verifier\n });\n if (config.resource) body.set('resource', config.resource);\n const tokens = await json(await fetch(config.tokenEndpoint, {\n method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body\n }));\n if (typeof tokens.id_token !== 'string') throw new Error('Logto did not return an ID token');\n sessionStorage.setItem(tokenKey, tokens.id_token);\n sessionStorage.removeItem(verifierKey);\n sessionStorage.removeItem(stateKey);\n history.replaceState({}, '', '/');\n }\n\n function renderUriList(id, values) {\n el(id).replaceChildren(...(values || []).map((value) => {\n const item = document.createElement('li');\n const code = document.createElement('code'); code.textContent = value;\n item.append(code); return item;\n }));\n }\n\n function updateBootstrapProvider() {\n const provider = el('bootstrap-provider').value;\n el('bootstrap-google').hidden = provider !== 'google';\n el('bootstrap-github').hidden = provider !== 'github';\n el('bootstrap-slack').hidden = provider !== 'slack';\n el('bootstrap-sso').hidden = provider !== 'sso';\n }\n\n // The SSO connector each form shows: the saved one in settings, the one just created during first-run setup.\n const ssoState = {};\n\n function ssoUrls(connector) {\n const base = ((bootstrapInfo && bootstrapInfo.logtoEndpoint) || '').replace(/\\/+$/, '');\n const id = encodeURIComponent(connector.connectorId);\n return connector.protocol === 'oidc'\n ? ['Redirect URI: ' + base + '/callback/' + id]\n : ['ACS URL: ' + base + '/api/authn/single-sign-on/saml/' + id, 'SP entity ID: ' + base + '/enterprise-sso/' + id];\n }\n\n function renderSso(prefix, connector) {\n ssoState[prefix] = connector || undefined;\n if (connector) {\n el(prefix + '-protocol').value = connector.protocol;\n el(prefix + '-name').value = connector.name;\n el(prefix + '-domains').value = (connector.domains || []).join(', ');\n renderUriList(prefix + '-urls', ssoUrls(connector));\n const oidc = connector.oidc || {};\n el(prefix + '-issuer').value = oidc.issuer || '';\n el(prefix + '-client-id').value = oidc.clientId || '';\n el(prefix + '-scope').value = oidc.scope || '';\n el(prefix + '-trust-email').checked = connector.oidc ? oidc.trustUnverifiedEmail : true;\n const saml = connector.saml || {};\n el(prefix + '-metadata-url').value = saml.metadataUrl || '';\n el(prefix + '-metadata').value = saml.metadata || '';\n el(prefix + '-email-attribute').value = (saml.attributeMapping && saml.attributeMapping.email) || '';\n }\n updateSsoProtocol(prefix);\n }\n\n function updateSsoProtocol(prefix) {\n const connector = ssoState[prefix];\n const protocol = el(prefix + '-protocol').value;\n const current = Boolean(connector && connector.protocol === protocol);\n el(prefix + '-oidc').hidden = protocol !== 'oidc';\n el(prefix + '-saml').hidden = protocol !== 'saml';\n // Another protocol is another connector, so its details wait until it is created.\n el(prefix + '-details').hidden = !current;\n el(prefix + '-create').hidden = current;\n }\n\n async function createSso(prefix) {\n const name = el(prefix + '-name').value;\n const result = await json(await fetch(api + '/create/sso', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ protocol: el(prefix + '-protocol').value, name })\n }));\n renderSso(prefix, { connectorId: result.connectorId, protocol: result.protocol, name, domains: [], oidc: null, saml: null });\n message('SSO connector created. Register its URLs with the identity provider, then enter the provider details.');\n }\n\n async function saveSso(prefix) {\n const protocol = el(prefix + '-protocol').value;\n const secret = el(prefix + '-client-secret').value;\n const metadataUrl = el(prefix + '-metadata-url').value.trim();\n const metadata = el(prefix + '-metadata').value.trim();\n const emailAttribute = el(prefix + '-email-attribute').value.trim();\n const connector = {\n name: el(prefix + '-name').value,\n domains: el(prefix + '-domains').value.split(',').map((domain) => domain.trim()).filter(Boolean),\n ...(protocol === 'oidc'\n ? { oidc: {\n issuer: el(prefix + '-issuer').value.trim(),\n clientId: el(prefix + '-client-id').value.trim(),\n ...(secret ? { clientSecret: secret } : {}),\n scope: el(prefix + '-scope').value.trim(),\n trustUnverifiedEmail: el(prefix + '-trust-email').checked\n } }\n : { saml: {\n ...(metadataUrl ? { metadataUrl } : {}),\n ...(metadata ? { metadata } : {}),\n ...(emailAttribute ? { attributeMapping: { email: emailAttribute } } : {})\n } })\n };\n await json(await fetch(api + '/configure/sso', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ connector })\n }));\n el(prefix + '-client-secret').value = '';\n await reconcileLogto(Boolean(secret));\n await load();\n }\n\n async function removeSso() {\n await json(await fetch(api + '/configure/sso', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ connector: null })\n }));\n await reconcileLogto();\n await load();\n }\n\n function showBootstrapStep(step) {\n const logto = step === 'logto';\n el('bootstrap-logto-step').hidden = !logto;\n el('bootstrap-provider-step').hidden = logto;\n el('bootstrap-progress').textContent = logto ? 'Step 1 of 2' : 'Step 2 of 2';\n }\n\n function updateOwner(prefix) {\n el(prefix + '-org-field').hidden = el(prefix + '-owner').value !== 'organization';\n }\n\n function ownership(prefix) {\n if (el(prefix + '-owner').value === 'personal') return { owner: 'personal', organization: null };\n const organization = el(prefix + '-org').value.trim();\n if (!organization) throw new Error('Enter the GitHub organization login');\n return { owner: 'organization', organization };\n }\n\n function formatDiffValue(value) {\n if (value === null || value === undefined || value === '') return 'Not configured';\n if (Array.isArray(value)) return value.length ? value.map(formatDiffValue).join('\\n') : '[]';\n if (typeof value === 'object') {\n return Object.entries(value).map(([key, item]) => key + ': ' + (Array.isArray(item) ? item.join(', ') : formatDiffValue(item))).join('\\n');\n }\n return String(value);\n }\n\n function showDiff(id, rows, label = 'Update required') {\n const box = el(id);\n box.hidden = rows.length === 0;\n box.replaceChildren();\n if (rows.length === 0) return;\n const title = document.createElement('strong');\n title.className = 'diff-title';\n title.textContent = label;\n const grid = document.createElement('div');\n grid.className = 'config-diff';\n const head = document.createElement('div');\n head.className = 'diff-row diff-head';\n for (const text of ['Field', 'Current', 'Expected']) {\n const cell = document.createElement('span'); cell.textContent = text; head.append(cell);\n }\n grid.append(head);\n for (const item of rows) {\n const row = document.createElement('div'); row.className = 'diff-row';\n const field = document.createElement('span'); field.className = 'diff-field'; field.textContent = item.field;\n const current = document.createElement('span'); current.className = 'diff-value diff-current'; current.textContent = formatDiffValue(item.current);\n const expected = document.createElement('span'); expected.className = 'diff-value diff-expected'; expected.textContent = formatDiffValue(item.expected);\n row.append(field, current, expected); grid.append(row);\n }\n box.append(title, grid);\n }\n\n function renderStartupEnvironment() {\n const services = bootstrapInfo.services;\n const environment = [\n ['AGENTCONNECT_PUBLIC_CP_URL', services.controlPlane],\n ['AGENTCONNECT_PUBLIC_RELAY_URL', services.relay],\n ['AGENTCONNECT_PUBLIC_WEB_URL', services.web],\n ['LOGTO_ENDPOINT', bootstrapInfo.logtoEndpoint],\n ['LOGTO_MGMT_ENDPOINT', bootstrapInfo.logtoManagementEndpoint]\n ];\n el('startup-environment').textContent = environment\n .filter(([, value]) => value)\n .map(([key, value]) => key + '=' + value)\n .join('\\n');\n }\n\n function renderApps(status) {\n currentStatus = status;\n const byKey = new Map((status.secrets || []).map((item) => [item.key, item]));\n const values = status.values;\n const expected = status.providerExpectations || { github: null, gitlab: null, slack: null, linear: null, googleChat: null, google: { origins: [], redirects: [] } };\n renderStartupEnvironment();\n\n const logto = values.logto;\n text('logto-management-endpoint', logto && bootstrapInfo.logtoManagementEndpoint);\n text('logto-management-id', logto && logto.managementAppId);\n text('logto-management-resource', logto && logto.managementResource);\n secretText('logto-management-secret-display', byKey, 'logto.managementAppSecret', Boolean(logto));\n const sso = logto && logto.ssoConnector;\n renderSso('sso', sso);\n el('sso-remove').hidden = !sso;\n match('sso-match', !sso ? '' : sso.oidc || sso.saml ? 'pass' : 'warn', !sso ? 'Not configured' : sso.oidc || sso.saml ? 'Configured' : 'Needs identity provider');\n text('logto-browser-endpoint', logto && logto.browser && bootstrapInfo.logtoEndpoint);\n text('logto-browser-id', values.auth.mode === 'oidc' ? values.auth.browserClient.appId : null);\n text('logto-browser-resource', logto && logto.browser && logto.browser.apiResource);\n el('logto-edit-controls').hidden = true;\n showIdentityEditors('logto', Boolean(logto && logto.browser && values.auth.mode === 'oidc'));\n match(\n 'logto-match',\n logto && configured(byKey, 'logto.managementAppSecret') ? '' : 'warn',\n logto && configured(byKey, 'logto.managementAppSecret') ? 'Ready to check' : 'Not configured'\n );\n\n el('preset-agents-enabled').checked = values.features.presetAgentsEnabled;\n el('max-orgs-per-non-admin-user').value = String(values.features.maxOrgsPerNonAdminUser);\n\n const github = values.github;\n const webhookStored = byKey.get('github.webhookSecret') && byKey.get('github.webhookSecret').configured;\n const webhookInactive = github && github.webhookEnabled === false;\n text('github-app-id', github && github.appId);\n text('github-app-slug', github && github.slug);\n text('github-client-id', github && github.clientId);\n text('github-webhook-enabled', github && (github.webhookEnabled === false ? 'Disabled' : 'Enabled'));\n el('github-edit-controls').hidden = true;\n showIdentityEditors('github', Boolean(github));\n secretText('github-client-secret-display', byKey, 'github.clientSecret', Boolean(github));\n secretText('github-private-key-display', byKey, 'github.privateKeyB64', Boolean(github));\n secretText('github-webhook-secret-display', byKey, 'github.webhookSecret', Boolean(github));\n secretText('github-logto-secret-display', byKey, 'logto.githubConnectorClientSecret', Boolean(values.logto && values.logto.githubConnector));\n el('github-status').textContent = github\n ? github.slug + ' is configured. Webhook secret: ' + (webhookStored ? 'stored' : webhookInactive ? 'not required yet' : 'missing') + '.' +\n (webhookInactive ? ' Relay webhook delivery is disabled.' : '')\n : 'Creates the complete App used for repository installation, webhooks, and optional GitHub sign-in.';\n const githubDrift = github\n ? expected.github\n ? []\n : [{ field: 'Startup public URLs', current: 'Unavailable', expected: 'Valid Web, API, and ingress URLs' }]\n : [];\n match('github-match', !github ? '' : githubDrift.length ? 'warn' : '', !github ? 'Not configured' : githubDrift.length ? 'Expected URLs changed' : 'Ready to check');\n el('github-create-controls').hidden = Boolean(github);\n el('create-github').hidden = Boolean(github);\n el('clear-github').hidden = !github;\n el('connect-github-login').hidden = !github || !values.logto || Boolean(values.logto.githubConnector);\n el('check-github').hidden = !github;\n if (github) showDiff('github-drift', githubDrift, 'Expected URLs changed since App creation');\n else el('github-drift').hidden = true;\n\n const gitlab = values.gitlab;\n const gitlabSecret = configured(byKey, 'gitlab.clientSecret');\n const expectedGitlab = expected.gitlab;\n renderUriList('gitlab-callbacks', expectedGitlab ? [expectedGitlab.callbackUrl] : []);\n renderUriList('gitlab-scopes', expectedGitlab ? expectedGitlab.scopes : []);\n text('gitlab-client-id', gitlab && gitlab.clientId);\n secretText('gitlab-client-secret-display', byKey, 'gitlab.clientSecret', Boolean(gitlab));\n showIdentityEditors('gitlab', Boolean(gitlab));\n el('gitlab-id').value = gitlab ? gitlab.clientId : '';\n text('gitlab-instance', (gitlab && gitlab.baseUrl) || 'https://gitlab.com');\n // Host-aware application links (§24.1): composed by the server against the\n // configured base, so a path-prefixed install keeps its prefix here too.\n if (expectedGitlab) {\n el('gitlab-applications').href = expectedGitlab.applicationsUrl;\n el('gitlab-admin-applications').href = expectedGitlab.adminApplicationsUrl;\n }\n el('gitlab-base-url').value = (gitlab && gitlab.baseUrl) || '';\n // A probe verdict belongs to the save that produced it, never to a reload.\n el('gitlab-probe').hidden = true;\n el('gitlab-status').textContent = gitlab\n ? gitlab.clientId + ' is configured.'\n : expectedGitlab\n ? 'Register the OAuth application on the configured GitLab instance, then save its Application ID and Secret here.'\n : 'Publishing the redirect URI needs an HTTPS API public URL.';\n showDiff('gitlab-drift', gitlab && !expectedGitlab\n ? [{ field: 'Startup public URLs', current: 'Unavailable', expected: 'An HTTPS API URL' }]\n : []);\n match('gitlab-match', gitlab && gitlabSecret ? 'warn' : '', !gitlab ? 'Not configured' : !gitlabSecret ? 'Missing secret' : \"Can't verify automatically\");\n el('gitlab-initial-secret-field').hidden = Boolean(gitlab) && gitlabSecret;\n el('gitlab-config-controls').hidden = Boolean(gitlab);\n el('save-gitlab').hidden = Boolean(gitlab);\n el('cancel-gitlab-configuration').hidden = true;\n el('clear-gitlab').hidden = !gitlab;\n const gitlabLogin = values.logto?.browser?.socialProviders.includes('gitlab');\n renderUriList('gitlab-login-redirects', expected.gitlabSignIn?.redirects || []);\n el('gitlab-login-status').textContent = gitlabLogin ? 'GitLab sign-in is configured. Use Check Logto to verify it.' : 'GitLab sign-in is not enabled.';\n el('connect-gitlab-login').hidden = Boolean(gitlabLogin);\n el('connect-gitlab-login').disabled = !gitlab || !gitlabSecret || !values.logto?.browser;\n el('disconnect-gitlab-login').hidden = !gitlabLogin;\n el('check-gitlab-login').hidden = !gitlabLogin;\n\n const gitea = values.gitea;\n text('gitea-instance', (gitea && gitea.baseUrl) || 'https://gitea.com');\n el('gitea-base-url').value = (gitea && gitea.baseUrl) || '';\n showIdentityEditors('gitea', Boolean(gitea));\n // A probe verdict belongs to the save that produced it, never to a reload.\n el('gitea-probe').hidden = true;\n el('gitea-status').textContent = gitea\n ? ((gitea.baseUrl || 'https://gitea.com') + ' is configured.')\n : 'Organizations can connect gitea.com without saving anything. Save an address only for a self-hosted instance.';\n match('gitea-match', gitea ? 'warn' : '', gitea ? \"Can't verify automatically\" : 'Default');\n el('gitea-config-controls').hidden = Boolean(gitea);\n el('save-gitea').hidden = Boolean(gitea);\n el('cancel-gitea-configuration').hidden = true;\n el('clear-gitea').hidden = !gitea;\n\n const slack = values.slack;\n text('slack-app-id', slack && slack.appId);\n text('slack-client-id', slack && slack.clientId);\n el('slack-edit-controls').hidden = true;\n showIdentityEditors('slack', Boolean(slack));\n secretText('slack-client-secret-display', byKey, 'slack.clientSecret', Boolean(slack));\n secretText('slack-signing-secret-display', byKey, 'slack.signingSecret', Boolean(slack));\n el('slack-logto-status').textContent = values.logto && values.logto.slackConnector\n ? 'Enabled; reuses the Slack client secret above.'\n : 'Not enabled.';\n el('slack-status').textContent = slack ? slack.appId + ' is configured.' : 'Creates the default AgentConnect integration manifest.';\n const slackDrift = slack\n ? expected.slack\n ? []\n : [{ field: 'Startup public URLs', current: 'Unavailable', expected: 'HTTPS Web, API, and ingress URLs' }]\n : [];\n match('slack-match', !slack ? '' : slackDrift.length ? 'warn' : '', !slack ? 'Not configured' : slackDrift.length ? 'Update required' : 'Ready to check');\n el('slack-name-field').hidden = Boolean(slack);\n el('create-slack').hidden = Boolean(slack);\n el('connect-slack-login').hidden = !slack || !values.logto || Boolean(values.logto.slackConnector) || !bootstrapInfo?.slackAvailable;\n el('clear-slack').hidden = !slack;\n el('check-slack').hidden = !slack;\n el('reconcile-slack').hidden = !slack;\n el('slack-settings').hidden = !slack;\n if (slack) {\n el('slack-settings').href = 'https://api.slack.com/apps/' + encodeURIComponent(slack.appId);\n showDiff('slack-drift', slackDrift);\n } else el('slack-drift').hidden = true;\n\n const googleChat = values.googleChat;\n const googleChatKey = configured(byKey, 'googleChat.serviceAccountKey');\n const expectedGoogleChat = expected.googleChat;\n renderUriList('googlechat-callbacks', expectedGoogleChat ? [expectedGoogleChat.callbackUrl] : []);\n text('googlechat-project-id', googleChat && googleChat.projectId);\n text('googlechat-project-number', googleChat && googleChat.projectNumber);\n el('googlechat-key-display').textContent = googleChatKey ? '***' : 'Not configured';\n el('googlechat-key-display').className = googleChatKey ? 'redacted' : 'muted';\n showIdentityEditors('googlechat', Boolean(googleChat));\n el('googlechat-project-id-input').value = googleChat ? googleChat.projectId : '';\n el('googlechat-project-number-input').value = googleChat ? googleChat.projectNumber : '';\n el('googlechat-key').value = '';\n if (expectedGoogleChat) el('googlechat-configuration').href = expectedGoogleChat.configurationUrl;\n el('googlechat-status').textContent = googleChat\n ? googleChat.projectId + ' is configured.'\n : expectedGoogleChat\n ? 'Configure the Chat app in Google Cloud Console, then save its project and service-account key here.'\n : 'Publishing the HTTP endpoint URL needs an HTTPS ingress public URL.';\n showDiff('googlechat-drift', googleChat && !expectedGoogleChat\n ? [{ field: 'Startup public URLs', current: 'Unavailable', expected: 'An HTTPS ingress URL' }]\n : []);\n match('googlechat-match', googleChat && googleChatKey ? 'pass' : googleChat ? 'warn' : '', !googleChat ? 'Not configured' : !googleChatKey ? 'Missing key' : 'Checked on save');\n el('googlechat-edit-heading').hidden = true;\n el('googlechat-config-controls').hidden = Boolean(googleChat);\n el('save-googlechat').hidden = Boolean(googleChat);\n el('cancel-googlechat-configuration').hidden = true;\n el('clear-googlechat').hidden = !googleChat;\n\n\n const linear = values.linear;\n const linearSecrets = configured(byKey, 'linear.clientSecret') && configured(byKey, 'linear.signingSecret');\n const expectedLinear = expected.linear;\n renderUriList('linear-callbacks', expectedLinear ? [expectedLinear.callbackUrl] : []);\n renderUriList('linear-webhooks', expectedLinear ? [expectedLinear.webhookUrl] : []);\n text('linear-client-id', linear && linear.clientId);\n secretText('linear-client-secret-display', byKey, 'linear.clientSecret', Boolean(linear));\n secretText('linear-signing-secret-display', byKey, 'linear.signingSecret', Boolean(linear));\n showIdentityEditors('linear', Boolean(linear));\n el('linear-id').value = linear ? linear.clientId : '';\n if (expectedLinear) el('linear-applications').href = expectedLinear.applicationsUrl;\n el('linear-status').textContent = linear\n ? linear.clientId + ' is configured.'\n : expectedLinear\n ? 'Register the OAuth application in Linear, then save its Client ID and both secrets here.'\n : 'Publishing the callback and webhook URLs needs HTTPS API and ingress public URLs.';\n showDiff('linear-drift', linear && !expectedLinear\n ? [{ field: 'Startup public URLs', current: 'Unavailable', expected: 'HTTPS API and ingress URLs' }]\n : []);\n match('linear-match', linear && linearSecrets ? 'warn' : '', !linear ? 'Not configured' : !linearSecrets ? 'Missing secret' : \"Can't verify automatically\");\n el('linear-initial-secret-fields').hidden = Boolean(linear) && linearSecrets;\n el('linear-config-controls').hidden = Boolean(linear);\n el('save-linear').hidden = Boolean(linear);\n el('cancel-linear-configuration').hidden = true;\n el('clear-linear').hidden = !linear;\n\n const google = values.logto && values.logto.googleConnector;\n const googleSecret = configured(byKey, 'logto.googleConnectorClientSecret');\n const expectedGoogle = expected.google;\n renderUriList('google-origins', expectedGoogle.origins);\n renderUriList('google-redirects', expectedGoogle.redirects);\n text('google-client-id', google && google.clientId);\n secretText('google-client-secret-display', byKey, 'logto.googleConnectorClientSecret', Boolean(google));\n showIdentityEditors('google', Boolean(google));\n el('google-id').value = google ? google.clientId : '';\n el('google-status').textContent = google ? 'Google OAuth client is configured.' : 'Create a Web application OAuth client manually, then save it here.';\n showDiff('google-drift', []);\n match('google-match', google && googleSecret ? 'warn' : '', !google ? 'Not configured' : !googleSecret ? 'Missing secret' : \"Can't verify automatically\");\n el('google-initial-secret-field').hidden = googleSecret;\n el('save-google').textContent = google ? 'Confirm callback settings' : 'Save Google client';\n el('google-config-controls').hidden = Boolean(google);\n el('save-google').hidden = Boolean(google);\n el('cancel-google-configuration').hidden = true;\n el('check-google').hidden = !google || !googleSecret;\n el('clear-google').hidden = !google;\n\n for (const [provider, app] of Object.entries(loginApps)) {\n const saved = values.logto && values.logto[provider + 'Connector'];\n const secretKey = 'logto.' + provider + 'ConnectorClientSecret';\n const hasSecret = configured(byKey, secretKey);\n renderUriList(provider + '-redirects', expected[provider].redirects);\n text(provider + '-client-id', saved && saved.clientId);\n secretText(provider + '-client-secret-display', byKey, secretKey, Boolean(saved));\n showIdentityEditors(provider, Boolean(saved));\n el(provider + '-id').value = saved ? saved.clientId : '';\n el(provider + '-status').hidden = !saved;\n el(provider + '-status').textContent = saved ? app.name + ' login application is saved.' : '';\n showDiff(provider + '-drift', []);\n match(provider + '-match', saved ? 'warn' : '', !saved ? 'Not configured' : !hasSecret ? 'Missing secret' : 'Not checked');\n el(provider + '-initial-secret-field').hidden = hasSecret;\n el(provider + '-config-controls').hidden = Boolean(saved);\n el('save-' + provider).hidden = Boolean(saved);\n el('save-' + provider).disabled = !values.logto || !values.logto.browser;\n el('cancel-' + provider + '-configuration').hidden = true;\n el('check-' + provider).hidden = !saved || !hasSecret;\n el('clear-' + provider).hidden = !saved;\n }\n const expectedTeams = expected.msTeams;\n text('azuread-teams-tenant-id', values.msTeams && values.msTeams.tenantId);\n el('azuread-teams-tenant-input').value = values.msTeams ? values.msTeams.tenantId : '';\n renderUriList('azuread-teams-endpoint', expectedTeams ? [expectedTeams.messagingEndpoint] : []);\n renderUriList('azuread-teams-redirects', expectedTeams ? [expectedTeams.connectRedirectUri] : []);\n\n const feishuSecret = byKey.get('feishu.loginAppSecret');\n const larkSecret = byKey.get('lark.loginAppSecret');\n el('feishu-login-id').value = values.feishu ? values.feishu.loginAppId : '';\n el('lark-login-id').value = values.lark ? values.lark.loginAppId : '';\n text('feishu-app-id', values.feishu && values.feishu.loginAppId);\n text('lark-app-id', values.lark && values.lark.loginAppId);\n showIdentityEditors('feishu', Boolean(values.feishu));\n showIdentityEditors('lark', Boolean(values.lark));\n secretText('feishu-app-secret-display', byKey, 'feishu.loginAppSecret', Boolean(values.feishu));\n secretText('lark-app-secret-display', byKey, 'lark.loginAppSecret', Boolean(values.lark));\n el('feishu-login-status').textContent = values.feishu && feishuSecret && feishuSecret.configured\n ? values.feishu.loginAppId + ' is configured.'\n : 'No Feishu tenant App is configured.';\n el('lark-login-status').textContent = values.lark && larkSecret && larkSecret.configured\n ? values.lark.loginAppId + ' is configured.'\n : 'No Lark tenant App is configured.';\n match('feishu-match', values.feishu && configured(byKey, 'feishu.loginAppSecret') ? '' : '', values.feishu && configured(byKey, 'feishu.loginAppSecret') ? 'Ready to check' : 'Not configured');\n match('lark-match', values.lark && configured(byKey, 'lark.loginAppSecret') ? '' : '', values.lark && configured(byKey, 'lark.loginAppSecret') ? 'Ready to check' : 'Not configured');\n showDiff('feishu-drift', []);\n showDiff('lark-drift', []);\n el('feishu-config-controls').hidden = Boolean(values.feishu);\n el('lark-config-controls').hidden = Boolean(values.lark);\n el('feishu-create-name-field').hidden = Boolean(values.feishu);\n el('lark-create-name-field').hidden = Boolean(values.lark);\n el('create-feishu-login-app').hidden = Boolean(values.feishu);\n el('create-lark-login-app').hidden = Boolean(values.lark);\n el('save-feishu-login-app').hidden = Boolean(values.feishu);\n el('save-lark-login-app').hidden = Boolean(values.lark);\n el('cancel-feishu-configuration').hidden = true;\n el('cancel-lark-configuration').hidden = true;\n el('clear-feishu').hidden = !values.feishu;\n el('clear-lark').hidden = !values.lark;\n el('check-feishu-login-app').hidden = !values.feishu || !configured(byKey, 'feishu.loginAppSecret');\n el('check-lark-login-app').hidden = !values.lark || !configured(byKey, 'lark.loginAppSecret');\n el('feishu-settings').hidden = !values.feishu;\n el('lark-settings').hidden = !values.lark;\n if (values.feishu) el('feishu-settings').href = 'https://open.feishu.cn/app/' + encodeURIComponent(values.feishu.loginAppId);\n if (values.lark) el('lark-settings').href = 'https://open.larksuite.com/app/' + encodeURIComponent(values.lark.loginAppId);\n }\n\n function requiredInput(id, label) {\n const value = el(id).value.trim();\n if (!value) throw new Error('Enter ' + label);\n return value;\n }\n\n function githubConnectorUsesDeployment(values) {\n const github = values.github;\n const connector = values.logto && values.logto.githubConnector;\n return Boolean(\n github && connector &&\n connector.appId === github.appId &&\n connector.slug === github.slug &&\n connector.clientId === github.clientId\n );\n }\n\n function beginConfigurationEdit(provider) {\n if (!currentStatus) return message('Deployment configuration is not loaded', true);\n const values = currentStatus.values;\n if (provider === 'logto') {\n const logto = values.logto;\n if (!logto || !logto.browser || values.auth.mode !== 'oidc') return;\n el('logto-edit-management-id').value = logto.managementAppId;\n el('logto-edit-management-resource').value = logto.managementResource;\n el('logto-edit-management-secret').value = '';\n el('logto-edit-browser-id').value = values.auth.browserClient.appId;\n el('logto-edit-browser-resource').value = logto.browser.apiResource || '';\n el('logto-edit-controls').hidden = false;\n el('logto-edit-management-id').focus();\n } else if (provider === 'github') {\n const github = values.github;\n if (!github) return;\n el('github-edit-app-id').value = String(github.appId);\n el('github-edit-slug').value = github.slug;\n el('github-edit-client-id').value = github.clientId || '';\n el('github-edit-webhook-enabled').checked = github.webhookEnabled !== false;\n for (const id of ['github-edit-client-secret', 'github-edit-private-key', 'github-edit-webhook-secret', 'github-edit-logto-secret']) el(id).value = '';\n el('github-edit-logto-secret-field').hidden = !githubConnectorUsesDeployment(values);\n el('github-edit-controls').hidden = false;\n el('clear-github').hidden = true;\n el('github-edit-app-id').focus();\n } else if (provider === 'slack') {\n const slack = values.slack;\n if (!slack) return;\n el('slack-edit-app-id').value = slack.appId;\n el('slack-edit-client-id').value = slack.clientId;\n el('slack-edit-client-secret').value = '';\n el('slack-edit-signing-secret').value = '';\n el('slack-edit-controls').hidden = false;\n el('clear-slack').hidden = true;\n el('slack-edit-app-id').focus();\n } else if (provider === 'gitea') {\n if (!values.gitea) return;\n el('gitea-base-url').value = values.gitea.baseUrl || '';\n el('gitea-config-controls').hidden = false;\n el('save-gitea').hidden = false;\n el('cancel-gitea-configuration').hidden = false;\n el('clear-gitea').hidden = true;\n el('gitea-base-url').focus();\n } else if (provider === 'gitlab') {\n if (!values.gitlab) return;\n el('gitlab-id').value = values.gitlab.clientId;\n el('gitlab-secret').value = '';\n el('gitlab-config-controls').hidden = false;\n el('gitlab-initial-secret-field').hidden = false;\n el('save-gitlab').hidden = false;\n el('cancel-gitlab-configuration').hidden = false;\n el('clear-gitlab').hidden = true;\n el('gitlab-id').focus();\n } else if (provider === 'googlechat') {\n if (!values.googleChat) return;\n el('googlechat-project-id-input').value = values.googleChat.projectId;\n el('googlechat-project-number-input').value = values.googleChat.projectNumber;\n el('googlechat-key').value = '';\n el('googlechat-edit-heading').hidden = false;\n el('googlechat-config-controls').hidden = false;\n el('save-googlechat').hidden = false;\n el('cancel-googlechat-configuration').hidden = false;\n el('clear-googlechat').hidden = true;\n el('googlechat-project-id-input').focus();\n } else if (provider === 'linear') {\n if (!values.linear) return;\n el('linear-id').value = values.linear.clientId;\n el('linear-client-secret').value = '';\n el('linear-signing-secret').value = '';\n el('linear-config-controls').hidden = false;\n el('linear-initial-secret-fields').hidden = false;\n el('save-linear').hidden = false;\n el('cancel-linear-configuration').hidden = false;\n el('clear-linear').hidden = true;\n el('linear-id').focus();\n } else if (provider === 'google') {\n const google = values.logto && values.logto.googleConnector;\n if (!google) return;\n el('google-id').value = google.clientId;\n el('google-secret').value = '';\n el('google-config-controls').hidden = false;\n el('google-initial-secret-field').hidden = false;\n el('save-google').hidden = false;\n el('save-google').textContent = 'Save Google client';\n el('cancel-google-configuration').hidden = false;\n el('clear-google').hidden = true;\n el('google-id').focus();\n } else if (Object.hasOwn(loginApps, provider)) {\n el(provider + '-secret').value = '';\n el(provider + '-config-controls').hidden = false;\n el(provider + '-initial-secret-field').hidden = false;\n el('save-' + provider).hidden = false;\n el('cancel-' + provider + '-configuration').hidden = false;\n el('clear-' + provider).hidden = true;\n el(provider + '-id').focus();\n } else if (provider === 'feishu' || provider === 'lark') {\n el(provider + '-config-controls').hidden = false;\n el(provider + '-create-name-field').hidden = true;\n el('create-' + provider + '-login-app').hidden = true;\n el('save-' + provider + '-login-app').hidden = false;\n el('cancel-' + provider + '-configuration').hidden = false;\n el('clear-' + provider).hidden = true;\n el(provider + '-login-secret').value = '';\n el(provider + '-login-id').focus();\n }\n showIdentityEditors(provider, false);\n }\n\n function cancelConfigurationEdit() {\n if (currentStatus) renderApps(currentStatus);\n }\n\n async function replaceConfiguration(values, secrets, successMessage) {\n const response = await json(await fetch(api + '/deployment-config', {\n method: 'PUT',\n headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ expectedRevision: currentRevision, values, ...(secrets ? { secrets } : {}) })\n }));\n await load();\n message(successMessage || 'Configuration saved. Restart AgentConnect to apply it.');\n return response;\n }\n\n async function saveLogtoConfiguration() {\n if (!currentStatus) throw new Error('Deployment configuration is not loaded');\n const values = currentStatus.values;\n const logto = values.logto;\n if (!logto || !logto.browser || values.auth.mode !== 'oidc') throw new Error('Logto is not configured');\n const managementAppId = requiredInput('logto-edit-management-id', 'the Management App ID');\n const managementResource = requiredInput('logto-edit-management-resource', 'the Management API resource');\n const browserAppId = requiredInput('logto-edit-browser-id', 'the SPA App ID');\n const browserResource = el('logto-edit-browser-resource').value.trim() || null;\n const managementIdentityChanged = managementAppId !== logto.managementAppId;\n const managementSecret = el('logto-edit-management-secret').value;\n if (managementIdentityChanged && !managementSecret) throw new Error('Enter the new Management App secret');\n const browser = { ...logto.browser, apiResource: browserResource };\n const auth = {\n ...values.auth,\n audience: browserResource || browserAppId,\n browserClient: { appId: browserAppId, apiResource: browserResource }\n };\n await replaceConfiguration(\n {\n ...values,\n auth,\n logto: { ...logto, managementAppId, managementResource, browser }\n },\n managementSecret ? { 'logto.managementAppSecret': managementSecret } : undefined,\n 'Logto configuration saved. Sign in again if the SPA identity changed, then restart AgentConnect.'\n );\n }\n\n async function saveGithubConfiguration() {\n if (!currentStatus || !currentStatus.values.github) throw new Error('GitHub is not configured');\n const values = currentStatus.values;\n const previous = values.github;\n const appId = Number(requiredInput('github-edit-app-id', 'the GitHub App ID'));\n if (!Number.isSafeInteger(appId) || appId <= 0) throw new Error('GitHub App ID must be a positive integer');\n const slug = requiredInput('github-edit-slug', 'the GitHub App slug');\n const clientId = requiredInput('github-edit-client-id', 'the GitHub Client ID');\n const appChanged = appId !== previous.appId;\n const clientChanged = clientId !== previous.clientId;\n const webhookEnabled = el('github-edit-webhook-enabled').checked;\n const webhookEnabling = webhookEnabled && previous.webhookEnabled === false;\n const connectorReused = githubConnectorUsesDeployment(values);\n const clientSecret = el('github-edit-client-secret').value;\n const privateKey = el('github-edit-private-key').value.trim();\n const webhookSecret = el('github-edit-webhook-secret').value;\n const connectorSecret = el('github-edit-logto-secret').value;\n const webhookSecretStored = currentStatus.secrets.some((secret) => secret.key === 'github.webhookSecret' && secret.configured);\n if ((appChanged || clientChanged) && !clientSecret) throw new Error('Enter the new GitHub client secret');\n if (appChanged && !privateKey) throw new Error('Enter the new GitHub private key as base64');\n if (webhookEnabled && ((appChanged && !webhookSecret) || (webhookEnabling && !webhookSecret && !webhookSecretStored))) throw new Error('Enter the new GitHub webhook secret');\n if (connectorReused && (appChanged || clientChanged) && !connectorSecret) throw new Error('Enter the new Logto connector client secret');\n const secrets = {};\n if (clientSecret) secrets['github.clientSecret'] = clientSecret;\n if (privateKey) secrets['github.privateKeyB64'] = privateKey;\n if (webhookSecret) secrets['github.webhookSecret'] = webhookSecret;\n if (connectorSecret) secrets['logto.githubConnectorClientSecret'] = connectorSecret;\n const nextLogto = connectorReused && values.logto\n ? { ...values.logto, githubConnector: { ...values.logto.githubConnector, appId, slug, clientId } }\n : values.logto;\n await replaceConfiguration(\n {\n ...values,\n github: { ...previous, appId, slug, clientId, webhookEnabled },\n ...(nextLogto ? { logto: nextLogto } : {})\n },\n Object.keys(secrets).length ? secrets : undefined,\n 'GitHub App identity saved. Restart AgentConnect to apply it.'\n );\n if (connectorSecret) {\n await reconcileLogto(true);\n await load();\n }\n }\n\n async function saveSlackConfiguration() {\n if (!currentStatus || !currentStatus.values.slack) throw new Error('Slack is not configured');\n const values = currentStatus.values;\n const previous = values.slack;\n const appId = requiredInput('slack-edit-app-id', 'the Slack App ID');\n const clientId = requiredInput('slack-edit-client-id', 'the Slack Client ID');\n const changed = appId !== previous.appId || clientId !== previous.clientId;\n const clientSecret = el('slack-edit-client-secret').value;\n const signingSecret = el('slack-edit-signing-secret').value;\n if (changed && (!clientSecret || !signingSecret)) throw new Error('Enter both the new Slack client secret and signing secret');\n const secrets = {};\n if (clientSecret) secrets['slack.clientSecret'] = clientSecret;\n if (signingSecret) secrets['slack.signingSecret'] = signingSecret;\n const nextLogto = values.logto && values.logto.slackConnector\n ? { ...values.logto, slackConnector: { ...values.logto.slackConnector, appId, clientId } }\n : values.logto;\n await replaceConfiguration(\n {\n ...values,\n slack: { ...previous, appId, clientId },\n ...(nextLogto ? { logto: nextLogto } : {})\n },\n Object.keys(secrets).length ? secrets : undefined,\n 'Slack App identity saved. Restart AgentConnect to apply it.'\n );\n if (clientSecret && nextLogto && nextLogto.slackConnector) {\n await reconcileLogto(true);\n await load();\n }\n }\n\n async function clearProvider(provider) {\n if (!currentStatus) throw new Error('Deployment configuration is not loaded');\n const label = provider === 'github' ? 'GitHub' : provider === 'gitlab' ? 'GitLab' : provider === 'gitea' ? 'Gitea' : provider === 'slack' ? 'Slack' : provider === 'googlechat' ? 'Google Chat' : provider === 'linear' ? 'Linear' : provider === 'google' ? 'Google' : provider === 'feishu' ? 'Feishu' : 'Lark';\n if (!window.confirm('Clear the saved ' + label + ' configuration and secrets?')) return;\n const values = currentStatus.values;\n let next = values;\n let secrets = {};\n if (provider === 'github') {\n const connectorReused = githubConnectorUsesDeployment(values);\n const logto = connectorReused && values.logto\n ? {\n ...values.logto,\n githubConnector: null\n }\n : values.logto;\n next = { ...values, github: null, ...(logto ? { logto } : {}) };\n secrets = {\n 'github.clientSecret': null,\n 'github.privateKeyB64': null,\n 'github.webhookSecret': null,\n ...(connectorReused ? { 'logto.githubConnectorClientSecret': null } : {})\n };\n } else if (provider === 'gitea') {\n // Gitea holds no deployment secret, so clearing the address clears the whole entry.\n next = { ...values, gitea: null };\n } else if (provider === 'gitlab') {\n await json(await fetch(api + '/configure/gitlab', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ application: null })\n }));\n await load();\n message('GitLab configuration cleared. Its setup controls are available again.');\n return;\n } else if (provider === 'slack') {\n const logto = values.logto\n ? {\n ...values.logto,\n browser: values.logto.browser\n ? { ...values.logto.browser, socialProviders: values.logto.browser.socialProviders.filter((item) => item !== 'slack') }\n : values.logto.browser,\n slackConnector: null\n }\n : values.logto;\n next = { ...values, slack: null, ...(logto ? { logto } : {}) };\n secrets = { 'slack.clientSecret': null, 'slack.signingSecret': null };\n } else if (provider === 'googlechat') {\n next = { ...values, googleChat: null };\n secrets = { 'googleChat.serviceAccountKey': null };\n } else if (provider === 'linear') {\n next = { ...values, linear: null };\n secrets = { 'linear.clientSecret': null, 'linear.signingSecret': null };\n } else if (provider === 'google') {\n if (!values.logto) return;\n next = {\n ...values,\n logto: {\n ...values.logto,\n browser: values.logto.browser\n ? { ...values.logto.browser, socialProviders: values.logto.browser.socialProviders.filter((item) => item !== 'google') }\n : values.logto.browser,\n googleConnector: null\n }\n };\n secrets = { 'logto.googleConnectorClientSecret': null };\n } else if (provider === 'feishu' || provider === 'lark') {\n next = { ...values, [provider]: null };\n secrets = { [provider + '.loginAppSecret']: null };\n }\n await replaceConfiguration(next, secrets, label + ' configuration cleared. Its setup controls are available again.');\n }\n\n async function startGithub(prefix) {\n const result = await json(await fetch(api + '/create/github/start', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({\n purpose: 'deployment',\n name: el(prefix + '-name').value,\n ownership: ownership(prefix),\n connectLogto: prefix === 'bootstrap-github'\n })\n }));\n const form = document.createElement('form');\n form.method = 'post'; form.action = result.action;\n const manifest = document.createElement('input');\n manifest.type = 'hidden'; manifest.name = 'manifest'; manifest.value = JSON.stringify(result.manifest);\n form.append(manifest); document.body.append(form);\n message('Opening GitHub to review the complete integration App.');\n form.submit();\n }\n\n async function loadBootstrapInfo() {\n bootstrapInfo = await json(await fetch(api + '/bootstrap-info'));\n el('open-logto').href = bootstrapInfo.logtoAdminEndpoint;\n el('logto-settings').href = bootstrapInfo.logtoAdminEndpoint;\n if (!el('logto-app-id').value && bootstrapInfo.logtoManagementAppId) {\n el('logto-app-id').value = bootstrapInfo.logtoManagementAppId;\n }\n renderUriList('bootstrap-google-origins', bootstrapInfo.google.javascriptOrigins);\n renderUriList('bootstrap-google-redirects', bootstrapInfo.google.redirectUris);\n renderUriList('bootstrap-slack-redirects', [bootstrapInfo.slackLoginRedirectUrl]);\n el('logto-management-api-resource').value = bootstrapInfo.logtoManagementResource;\n el('bootstrap-github-submit').disabled = !bootstrapInfo.githubAvailable;\n if (!bootstrapInfo.githubAvailable) {\n el('bootstrap-github-note').textContent = 'GitHub App creation needs valid saved Web, API, and ingress URLs.';\n } else if (!bootstrapInfo.githubWebhookActive) {\n el('bootstrap-github-note').textContent = 'Creates the complete GitHub App now without submitting the localhost webhook URL. Add it after saving reachable HTTPS ingress.';\n } else {\n el('bootstrap-github-note').textContent = 'This creates one complete App for both GitHub sign-in and repository integration.';\n }\n el('bootstrap-slack-option').disabled = !bootstrapInfo.slackAvailable;\n el('bootstrap-slack-submit').disabled = !bootstrapInfo.slackAvailable;\n el('bootstrap-slack-note').textContent = bootstrapInfo.slackAvailable\n ? 'Creates one complete Slack App. Sign-in uses a separate openid profile email flow from workspace installation.'\n : 'Slack sign-in needs all public service URLs to use HTTPS. Use Google locally or expose the stack through trusted HTTPS endpoints.';\n if (!bootstrapInfo.slackAvailable && el('bootstrap-provider').value === 'slack') {\n el('bootstrap-provider').value = 'google';\n updateBootstrapProvider();\n }\n return bootstrapInfo;\n }\n\n async function logtoBootstrapFinding() {\n const report = await json(await fetch(api + '/check/logto', { headers: bearer() }));\n for (const id of ['logto.client_credentials', 'logto.roles_read']) {\n const finding = report.findings.find((candidate) => candidate.id === id);\n if (!finding || finding.status !== 'pass') {\n return finding || { status: 'fail', message: 'Logto Management API permissions could not be verified.' };\n }\n }\n return { status: 'pass', message: 'Logto Management API access is ready.' };\n }\n\n async function showBootstrap() {\n el('bootstrap').hidden = false;\n el('show-bootstrap').hidden = true;\n const info = bootstrapInfo || await loadBootstrapInfo();\n if (!info.logtoConfigured) {\n showBootstrapStep('logto');\n return;\n }\n const finding = await logtoBootstrapFinding();\n if (finding && finding.status === 'pass') {\n showBootstrapStep('provider');\n message('Logto Management API access is ready. Choose a sign-in provider.');\n return;\n }\n showBootstrapStep('logto');\n message(finding?.message || 'Logto Management API credentials could not be verified.', true);\n }\n\n async function bootstrapLogto() {\n await json(await fetch(api + '/bootstrap/logto', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({\n managementAppId: el('logto-app-id').value,\n managementAppSecret: el('logto-app-secret').value,\n managementResource: el('logto-management-api-resource').value\n })\n }));\n el('logto-app-secret').value = '';\n await loadBootstrapInfo();\n const finding = await logtoBootstrapFinding();\n if (!finding || finding.status !== 'pass') {\n throw new Error(finding?.message || 'Logto Management API credentials could not be verified.');\n }\n showBootstrapStep('provider');\n message('Logto Management API access is ready. Choose a sign-in provider.');\n }\n\n async function bootstrapGoogle() {\n const secret = el('bootstrap-google-secret').value;\n await json(await fetch(api + '/configure/google', {\n method: 'POST', headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ clientId: el('bootstrap-google-id').value, clientSecret: secret })\n }));\n el('bootstrap-google-secret').value = '';\n await reconcileLogto(true);\n await load();\n }\n\n async function bootstrapGithub() {\n if (!bootstrapInfo || !bootstrapInfo.githubAvailable) throw new Error('GitHub App creation needs valid saved Web, API, and ingress URLs.');\n await startGithub('bootstrap-github');\n }\n\n async function bootstrapSlack() {\n if (!bootstrapInfo || !bootstrapInfo.slackAvailable) throw new Error('Slack sign-in requires all public service URLs to use HTTPS.');\n await createSlack('bootstrap-slack', true);\n await reconcileLogto();\n await load();\n }\n\n async function createSlack(prefix = 'slack', connectLogto = false) {\n const result = await json(await fetch(api + '/create/slack', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({\n name: el(prefix + '-name').value,\n configToken: el(prefix + '-token').value,\n connectLogto\n })\n }));\n el(prefix + '-token').value = '';\n if (!connectLogto) await load();\n message('Slack App ' + result.app.id + ' was created with the default integration manifest. Restart AgentConnect to apply it.');\n }\n\n async function checkGithub() {\n const result = await json(await fetch(api + '/check/github', { headers: bearer() }));\n el('github-settings').href = result.settingsUrl;\n el('github-settings').hidden = false;\n const label = result.missing.length ? 'Update required' : \"Can't verify automatically\";\n showDiff('github-drift', result.diff || [], label);\n match('github-match', result.status === 'pass' ? 'pass' : 'warn', result.status === 'pass' ? 'Matches' : label);\n message(result.status === 'pass' ? 'GitHub App matches the expected integration manifest.' : result.missing.length ? 'GitHub App settings need an update.' : \"GitHub can't expose callback, setup, or webhook-active settings through its API; check them in GitHub.\", result.status === 'fail');\n }\n\n async function connectGithubLogin() {\n await json(await fetch(api + '/configure/github-login', { method: 'POST', headers: bearer() }));\n await reconcileLogto();\n await load();\n message('The deployment GitHub App is now also used for Logto sign-in. Update its displayed callback URLs.');\n }\n\n async function connectSlackLogin() {\n if (!currentStatus || !currentStatus.values.slack || !currentStatus.values.logto) {\n throw new Error('Save the Slack App and Logto configuration first');\n }\n const values = currentStatus.values;\n const slack = values.slack;\n const logto = values.logto;\n await replaceConfiguration(\n {\n ...values,\n logto: {\n ...logto,\n browser: logto.browser\n ? { ...logto.browser, socialProviders: [...new Set([...logto.browser.socialProviders, 'slack'])] }\n : logto.browser,\n slackConnector: {\n appId: slack.appId,\n clientId: slack.clientId\n }\n }\n },\n undefined,\n 'Slack App is ready to connect to Logto.'\n );\n await reconcileLogto();\n await load();\n message('The deployment Slack App is now also used for Logto sign-in.');\n }\n\n async function checkSlack() {\n const token = requiredInput('slack-token', 'the temporary Slack App configuration token');\n const result = await json(await fetch(api + '/check/slack', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ configToken: token })\n }));\n el('slack-token').value = '';\n showDiff('slack-drift', result.diff || []);\n match('slack-match', result.status === 'pass' ? 'pass' : 'warn', result.status === 'pass' ? 'Matches' : 'Update required');\n message(result.status === 'pass' ? 'Slack App matches the default integration manifest.' : 'Slack App settings need an update.', result.status !== 'pass');\n }\n\n async function reconcileSlack() {\n const token = requiredInput('slack-token', 'the temporary Slack App configuration token');\n const result = await json(await fetch(api + '/reconcile/slack', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ configToken: token })\n }));\n el('slack-token').value = '';\n showDiff('slack-drift', result.diff || []);\n match('slack-match', result.status === 'pass' ? 'pass' : 'warn', result.status === 'pass' ? 'Matches' : 'Update required');\n message(\n result.status !== 'pass'\n ? 'Slack App was updated but some settings still differ.'\n : result.applied.length === 0\n ? 'Slack App already matches the default integration manifest.'\n : result.permissionsUpdated\n ? 'Slack App updated. Reinstall it in each workspace to grant the added scopes.'\n : 'Slack App updated.',\n result.status !== 'pass'\n );\n }\n\n async function checkGoogle() {\n const report = await checkLogto();\n const connector = report.findings.find((finding) => finding.id === 'logto.connectors');\n const google = currentStatus && currentStatus.values.logto && currentStatus.values.logto.googleConnector;\n const connectorDiff = connector && connector.diff\n ? connector.diff.filter((item) => item.field.toLowerCase().startsWith('google'))\n : [];\n const passed = Boolean(google && connector && connectorDiff.length === 0);\n showDiff('google-drift', connectorDiff);\n match('google-match', passed ? 'pass' : 'warn', passed ? 'Logto matches' : 'Update required');\n message(\n passed\n ? \"The Logto Google connector matches. Google OAuth redirect settings can't be verified automatically.\"\n : 'The Logto Google connector needs an update.',\n !passed\n );\n }\n\n async function checkRegionalLoginApp(region) {\n const result = await json(await fetch(api + '/check/regional-login-app/' + region, { headers: bearer() }));\n const label = region === 'feishu' ? 'Feishu' : 'Lark';\n showDiff(region + '-drift', result.diff || []);\n match(region + '-match', result.status === 'pass' ? 'pass' : result.status === 'fail' ? 'fail' : 'warn', result.status === 'pass' ? 'Matches' : result.status === 'fail' ? 'Update required' : 'Could not check');\n el(region + '-login-status').textContent = result.message;\n el(region + '-login-status').className = result.status === 'pass' ? 'ok' : result.status === 'fail' ? 'warn' : 'muted';\n message(result.message || (label + ' credential check completed.'), result.status === 'fail');\n }\n\n async function saveGitlab() {\n const clientSecret = el('gitlab-secret').value;\n const baseUrl = el('gitlab-base-url').value.trim();\n const saved = await json(await fetch(api + '/configure/gitlab', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({\n application: {\n clientId: requiredInput('gitlab-id', 'the GitLab Application ID'),\n ...(clientSecret ? { clientSecret } : {}),\n ...(baseUrl ? { baseUrl } : {})\n }\n })\n }));\n el('gitlab-secret').value = '';\n if (currentStatus.values.logto?.browser?.socialProviders.includes('gitlab')) await reconcileLogto(Boolean(clientSecret));\n await load();\n showGitlabProbe(saved.probe);\n message('GitLab OAuth application saved. Restart AgentConnect to apply it.');\n }\n\n // Only the URL shape blocks the save, so every other verdict is a line to read.\n function showGitlabProbe(probe) {\n const line = el('gitlab-probe');\n line.hidden = !probe;\n line.textContent = probe ? probe.message : '';\n }\n\n async function configureGitlabLogin(enabled) {\n await json(await fetch(api + '/configure/gitlab-login', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ enabled })\n }));\n try { await reconcileLogto(enabled); } finally { await load(); }\n message('GitLab sign-in ' + (enabled ? 'configured' : 'disabled') + '. Restart AgentConnect to apply it.');\n }\n\n async function saveGitea() {\n const baseUrl = el('gitea-base-url').value.trim();\n const saved = await json(await fetch(api + '/configure/gitea', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ instance: { ...(baseUrl ? { baseUrl } : {}) } })\n }));\n await load();\n // Shape and the version floor already blocked the save, so any verdict here is a line to read.\n const line = el('gitea-probe');\n line.hidden = !saved.probe;\n line.textContent = saved.probe ? saved.probe.message : '';\n message('Gitea instance saved. Restart AgentConnect to apply it.');\n }\n\n async function saveGoogleChat() {\n const projectNumber = el('googlechat-project-number-input').value.trim();\n const application = {\n projectId: requiredInput('googlechat-project-id-input', 'the Google Cloud project ID'),\n ...(projectNumber ? { projectNumber } : {})\n };\n const serviceAccountKey = el('googlechat-key').value.trim();\n let saved;\n try {\n saved = await json(await fetch(api + '/configure/google-chat', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ application: { ...application, ...(serviceAccountKey ? { serviceAccountKey } : {}) } })\n }));\n } finally {\n // The key never stays in the page once it has been submitted, whatever the outcome.\n el('googlechat-key').value = '';\n }\n await load();\n message('Google Chat app saved. ' + saved.probe.message + '. Restart AgentConnect to apply it.');\n }\n\n async function saveLinear() {\n const clientSecret = el('linear-client-secret').value;\n const signingSecret = el('linear-signing-secret').value;\n await json(await fetch(api + '/configure/linear', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({\n application: {\n clientId: requiredInput('linear-id', 'the Linear Client ID'),\n ...(clientSecret ? { clientSecret } : {}),\n ...(signingSecret ? { signingSecret } : {})\n }\n })\n }));\n el('linear-client-secret').value = '';\n el('linear-signing-secret').value = '';\n await load();\n message('Linear OAuth application saved. Restart AgentConnect to apply it.');\n }\n\n async function saveGoogle() {\n const secret = el('google-secret').value;\n await json(await fetch(api + '/configure/google', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({\n clientId: el('google-id').value,\n ...(secret ? { clientSecret: secret } : {})\n })\n }));\n el('google-secret').value = '';\n await reconcileLogto(Boolean(secret));\n await load();\n message('Google OAuth client and Logto connector are configured. Restart AgentConnect to apply the saved settings.');\n }\n\n async function saveLoginApp(provider, clear = false) {\n const app = loginApps[provider];\n if (clear && !window.confirm('Clear the saved ' + app.name + ' login application and secret?')) return;\n const clientSecret = el(provider + '-secret').value;\n const application = clear ? null : {\n clientId: requiredInput(provider + '-id', 'the ' + app.name + ' Client ID'),\n ...(clientSecret ? { clientSecret } : {})\n };\n // Microsoft's home tenant also makes the app the deployment's Teams app; empty turns that off.\n const teams = provider === 'azuread' && !clear ? { teamsTenantId: el('azuread-teams-tenant-input').value.trim() || null } : {};\n try {\n await json(await fetch(api + '/configure/login-app/' + provider, {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ application, ...teams })\n }));\n } finally {\n el(provider + '-secret').value = '';\n }\n try {\n await reconcileLogto(Boolean(clientSecret) && !clear);\n } finally {\n await load();\n }\n message(app.name + (clear ? ' sign-in configuration cleared.' : ' login application and Logto connector configured.') + ' Restart AgentConnect to apply the saved settings.');\n }\n\n function renderLoginAppChecks(report) {\n const connectors = report.findings.find((finding) => finding.id === 'logto.connectors');\n if (currentStatus?.values.logto?.browser?.socialProviders.includes('gitlab')) {\n const diff = connectors?.diff?.filter((item) => item.field.toLowerCase().startsWith('gitlab')) || [];\n const passed = Boolean(connectors && diff.length === 0 && (connectors.status === 'pass' || connectors.diff?.length));\n el('gitlab-login-status').textContent = passed\n ? 'GitLab connector matches. Confirm the redirect URLs and OpenID scopes in GitLab.'\n : 'GitLab sign-in could not be verified. Review the Logto check above.';\n }\n for (const [provider, app] of Object.entries(loginApps)) {\n if (!currentStatus?.values.logto?.[provider + 'Connector']) continue;\n const diff = connectors?.diff?.filter((item) => item.field.toLowerCase().startsWith(provider)) || [];\n const passed = Boolean(connectors && diff.length === 0 && (connectors.status === 'pass' || connectors.diff?.length));\n showDiff(provider + '-drift', diff);\n match(provider + '-match', passed ? 'pass' : 'warn', passed ? 'Logto matches' : 'Could not verify');\n el(provider + '-status').textContent = passed\n ? app.name + ' connector matches. Confirm the redirect URLs in the provider settings.'\n : app.name + ' connector could not be verified. Review the Logto check above.';\n }\n }\n\n function regionalLoginApp(prefix) {\n const appId = el(prefix + '-login-id').value.trim();\n const appSecret = el(prefix + '-login-secret').value;\n return appId ? { appId, ...(appSecret ? { appSecret } : {}) } : null;\n }\n\n async function saveRegionalLoginApp(region) {\n const app = regionalLoginApp(region);\n if (!app) throw new Error('Enter the ' + (region === 'feishu' ? 'Feishu' : 'Lark') + ' App ID');\n await json(await fetch(api + '/configure/regional-login-app', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ region, app })\n }));\n el(region + '-login-secret').value = '';\n await load();\n message((region === 'feishu' ? 'Feishu' : 'Lark') + ' tenant App is saved. Restart AgentConnect services to apply it.');\n }\n\n async function createRegionalLoginApp(region) {\n const popup = window.open('about:blank', '_blank');\n try {\n const started = await json(await fetch(api + '/create/regional-login-app/start', {\n method: 'POST', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ region, name: el(region + '-create-name').value })\n }));\n if (popup) popup.location.replace(started.authorizationUrl);\n else window.open(started.authorizationUrl, '_blank', 'noopener');\n message('Approve the new ' + (region === 'feishu' ? 'Feishu' : 'Lark') + ' App in the opened page. This page will finish saving it automatically.');\n while (true) {\n const result = await json(await fetch(api + '/create/regional-login-app/' + encodeURIComponent(started.id), { headers: bearer() }));\n if (result.status === 'completed') {\n await load();\n message((region === 'feishu' ? 'Feishu' : 'Lark') + ' App ' + result.appId + ' was created and saved. Restart AgentConnect services to apply it.');\n return;\n }\n if (result.status === 'failed') throw new Error('Regional App creation ' + result.reason + '. Start it again.');\n await new Promise((resolve) => setTimeout(resolve, Math.max(500, Math.min(result.retryAfterMs || 2000, 5000))));\n }\n } catch (error) {\n if (popup) popup.close();\n throw error;\n }\n }\n\n async function reconcileLogto(refreshConnectorSecrets) {\n return json(await fetch(api + '/reconcile/logto', {\n method: 'POST',\n headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ refreshConnectorSecrets: Boolean(refreshConnectorSecrets) })\n }));\n }\n\n async function refreshDeploymentConfig() {\n const status = await json(await fetch(api + '/deployment-config', { headers: bearer() }));\n el('access').hidden = true;\n el('setup').hidden = false;\n el('editor').hidden = false;\n currentRevision = status.revision;\n renderApps(status);\n return status;\n }\n\n async function checkLogto() {\n const report = await json(await fetch(api + '/check/logto', { headers: bearer() }));\n await refreshDeploymentConfig();\n const failures = report.findings.filter((finding) => finding.status !== 'pass');\n el('logto-status').textContent = failures.length === 0\n ? 'SPA redirects, connectors, and social-only sign-in match.'\n : failures.map((finding) => finding.message).join(' ');\n el('logto-status').className = failures.length === 0 ? 'ok' : 'warn';\n match('logto-match', failures.length === 0 ? 'pass' : 'warn', failures.length === 0 ? 'Matches' : 'Update required');\n showDiff(\n 'logto-drift',\n failures.flatMap((finding) => finding.diff && finding.diff.length\n ? finding.diff\n : [{ field: finding.id.replace(/^logto\\./, '').replaceAll('_', ' '), current: finding.message, expected: 'Matches expected configuration' }])\n );\n el('logto-settings').hidden = failures.length === 0;\n renderLoginAppChecks(report);\n return report;\n }\n\n function githubNotice(value) {\n if (value === 'deployment-created') return 'GitHub integration App created. Its private key and webhook secret are stored.';\n if (value === 'deployment-login-created') return 'GitHub integration App created and connected to Logto sign-in.';\n if (value === 'cancelled') return 'GitHub App creation was cancelled.';\n if (value === 'expired') return 'GitHub App creation expired. Start it again.';\n if (value === 'invalid-callback') return 'GitHub returned an invalid App creation callback.';\n if (value === 'conversion-failed') return 'GitHub may have created the App, but did not return complete credentials. Delete the orphaned App and retry.';\n if (value === 'save-failed') return 'GitHub created the App, but its credentials could not be saved. Delete the orphaned App and retry.';\n return '';\n }\n\n async function load() {\n let publicAuth = await authConfig();\n const currentUrl = new URL(location.href);\n const githubResult = currentUrl.searchParams.get('github');\n const notice = githubNotice(githubResult);\n if (githubResult === 'deployment-login-created' && publicAuth.mode === 'none') {\n await reconcileLogto();\n publicAuth = await authConfig();\n }\n if (githubResult) history.replaceState({}, '', '/');\n const hasToken = Boolean(sessionStorage.getItem(tokenKey));\n el('access').hidden = false;\n el('setup').hidden = true;\n el('login').hidden = true;\n el('open-logto').hidden = true;\n el('show-bootstrap').hidden = true;\n el('editor').hidden = true;\n el('bootstrap').hidden = true;\n if (publicAuth.logtoAdminEndpoint) {\n el('open-logto').href = publicAuth.logtoAdminEndpoint;\n el('logto-settings').href = publicAuth.logtoAdminEndpoint;\n }\n if (publicAuth.mode === 'none') {\n sessionStorage.removeItem(tokenKey);\n await loadBootstrapInfo();\n el('open-logto').hidden = false;\n el('show-bootstrap').hidden = false;\n message(notice || 'Logto sign-in is not configured. Set up the initial Logto administrator first.');\n return;\n }\n if (publicAuth.mode === 'unavailable') {\n el('open-logto').hidden = false;\n message(publicAuth.message || 'Logto sign-in is unavailable. Open Logto Console to review its settings.', true);\n return;\n }\n if (!hasToken) {\n el('login').hidden = false;\n message(notice || 'Sign in with Logto to continue.');\n return;\n }\n if (publicAuth.claimAvailable) {\n try {\n await json(await fetch(api + '/bootstrap/claim', { method: 'POST', headers: bearer() }));\n sessionStorage.removeItem(tokenKey);\n el('login').hidden = false;\n message('This first user is now an ADMIN. Sign in again to refresh the role claim.');\n return;\n } catch (error) {\n if (error.status !== 401 && error.status !== 403) throw error;\n sessionStorage.removeItem(tokenKey);\n el('login').hidden = false;\n message('Sign in with Logto to continue.', true);\n return;\n }\n }\n try {\n await loadBootstrapInfo();\n await refreshDeploymentConfig();\n message(notice);\n checkLogto().catch((error) => {\n el('logto-status').textContent = error.message;\n el('logto-status').className = 'warn';\n el('logto-settings').hidden = false;\n });\n } catch (error) {\n if (error.status === 401 || error.status === 403) {\n sessionStorage.removeItem(tokenKey);\n el('login').hidden = false;\n message('Sign in with a Logto ADMIN account.', true);\n }\n else throw error;\n }\n }\n\n async function saveSecretReplacement(key, value) {\n if (!currentStatus) throw new Error('Deployment configuration is not loaded');\n if (!value) throw new Error('Enter the replacement secret');\n const refreshLogto =\n (key === 'gitlab.clientSecret' && currentStatus.values.logto?.browser?.socialProviders.includes('gitlab')) ||\n (key === 'logto.githubConnectorClientSecret' && currentStatus.values.logto && currentStatus.values.logto.githubConnector) ||\n (key === 'logto.googleConnectorClientSecret' && currentStatus.values.logto && currentStatus.values.logto.googleConnector) ||\n Object.keys(loginApps).some((provider) => key === 'logto.' + provider + 'ConnectorClientSecret' && currentStatus.values.logto?.[provider + 'Connector']) ||\n (key === 'slack.clientSecret' && currentStatus.values.logto && currentStatus.values.logto.slackConnector);\n await json(await fetch(api + '/deployment-config', {\n method: 'PUT', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ expectedRevision: currentRevision, values: currentStatus.values, secrets: { [key]: value } })\n }));\n try {\n if (refreshLogto) await reconcileLogto(true);\n } finally {\n await load();\n }\n if (refreshLogto) {\n message('Secret replaced and applied to the Logto connector. Restart AgentConnect to apply deployment changes.');\n } else {\n message('Secret replaced. Restart AgentConnect to apply it.');\n }\n }\n\n function editSecret(button) {\n if (!currentStatus) return message('Deployment configuration is not loaded', true);\n const row = button.closest('.secret-line');\n const display = el(button.dataset.secretDisplay);\n if (!row || !display || row.querySelector('.secret-editor')) return;\n const editor = document.createElement('span'); editor.className = 'secret-editor';\n const input = document.createElement('input');\n input.type = 'password'; input.autocomplete = 'new-password'; input.placeholder = 'Enter replacement secret';\n const save = document.createElement('button'); save.textContent = 'Save';\n const cancel = document.createElement('button'); cancel.textContent = 'Cancel';\n const close = () => { editor.remove(); display.hidden = false; button.hidden = false; };\n save.onclick = async () => {\n save.disabled = true;\n try { await saveSecretReplacement(button.dataset.secretKey, input.value); }\n catch (error) { save.disabled = false; message(error.message, true); }\n };\n cancel.onclick = close;\n input.onkeydown = (event) => { if (event.key === 'Enter') save.click(); if (event.key === 'Escape') close(); };\n display.hidden = true; button.hidden = true;\n editor.append(input, save, cancel); row.append(editor); input.focus();\n }\n\n async function saveOptions() {\n if (!currentStatus) throw new Error('Deployment configuration is not loaded');\n const values = {\n ...currentStatus.values,\n features: {\n presetAgentsEnabled: el('preset-agents-enabled').checked,\n maxOrgsPerNonAdminUser: Number(el('max-orgs-per-non-admin-user').value)\n }\n };\n const saved = await json(await fetch(api + '/deployment-config', {\n method: 'PUT', headers: { 'content-type': 'application/json', ...bearer() },\n body: JSON.stringify({ expectedRevision: currentRevision, values })\n }));\n message('Saved deployment options. Restart AgentConnect to apply them.');\n await load();\n }\n\n el('login').onclick = () => signIn().catch((error) => message(error.message, true));\n el('show-bootstrap').onclick = () => showBootstrap().catch((error) => message(error.message, true));\n el('bootstrap-provider').onchange = updateBootstrapProvider;\n el('bootstrap-github-owner').onchange = () => updateOwner('bootstrap-github');\n el('github-owner').onchange = () => updateOwner('github');\n el('bootstrap-logto-submit').onclick = () => bootstrapLogto().catch((error) => message(error.message, true));\n el('bootstrap-back').onclick = () => showBootstrapStep('logto');\n el('bootstrap-google-submit').onclick = () => bootstrapGoogle().catch((error) => message(error.message, true));\n for (const prefix of ['bootstrap-sso', 'sso']) {\n el(prefix + '-protocol').onchange = () => updateSsoProtocol(prefix);\n el(prefix + '-create').onclick = () => createSso(prefix).catch((error) => message(error.message, true));\n el(prefix + '-submit').onclick = () => saveSso(prefix).catch((error) => message(error.message, true));\n }\n el('sso-remove').onclick = () => removeSso().catch((error) => message(error.message, true));\n el('bootstrap-github-submit').onclick = () => bootstrapGithub().catch((error) => message(error.message, true));\n el('bootstrap-slack-submit').onclick = () => bootstrapSlack().catch((error) => message(error.message, true));\n el('create-github').onclick = () => startGithub('github').catch((error) => message(error.message, true));\n el('save-logto-configuration').onclick = () => saveLogtoConfiguration().catch((error) => message(error.message, true));\n el('cancel-logto-configuration').onclick = cancelConfigurationEdit;\n el('save-github-configuration').onclick = () => saveGithubConfiguration().catch((error) => message(error.message, true));\n el('cancel-github-configuration').onclick = cancelConfigurationEdit;\n el('clear-github').onclick = () => clearProvider('github').catch((error) => message(error.message, true));\n el('connect-github-login').onclick = () => connectGithubLogin().catch((error) => message(error.message, true));\n el('connect-gitlab-login').onclick = () => configureGitlabLogin(true).catch((error) => message(error.message, true));\n el('disconnect-gitlab-login').onclick = () => configureGitlabLogin(false).catch((error) => message(error.message, true));\n el('check-gitlab-login').onclick = () => checkLogto().catch((error) => message(error.message, true));\n el('check-github').onclick = () => checkGithub().catch((error) => message(error.message, true));\n el('create-slack').onclick = () => createSlack('slack').catch((error) => message(error.message, true));\n el('connect-slack-login').onclick = () => connectSlackLogin().catch((error) => message(error.message, true));\n el('save-slack-configuration').onclick = () => saveSlackConfiguration().catch((error) => message(error.message, true));\n el('cancel-slack-configuration').onclick = cancelConfigurationEdit;\n el('clear-slack').onclick = () => clearProvider('slack').catch((error) => message(error.message, true));\n el('check-slack').onclick = () => checkSlack().catch((error) => message(error.message, true));\n el('reconcile-slack').onclick = () => reconcileSlack().catch((error) => message(error.message, true));\n el('save-gitea').onclick = () => saveGitea().catch((error) => message(error.message, true));\n el('cancel-gitea-configuration').onclick = cancelConfigurationEdit;\n el('clear-gitea').onclick = () => clearProvider('gitea').catch((error) => message(error.message, true));\n el('save-gitlab').onclick = () => saveGitlab().catch((error) => message(error.message, true));\n el('cancel-gitlab-configuration').onclick = cancelConfigurationEdit;\n el('clear-gitlab').onclick = () => clearProvider('gitlab').catch((error) => message(error.message, true));\n el('save-googlechat').onclick = () => saveGoogleChat().catch((error) => message(error.message, true));\n el('cancel-googlechat-configuration').onclick = cancelConfigurationEdit;\n el('clear-googlechat').onclick = () => clearProvider('googlechat').catch((error) => message(error.message, true));\n el('save-linear').onclick = () => saveLinear().catch((error) => message(error.message, true));\n el('cancel-linear-configuration').onclick = cancelConfigurationEdit;\n el('clear-linear').onclick = () => clearProvider('linear').catch((error) => message(error.message, true));\n el('save-google').onclick = () => saveGoogle().catch((error) => message(error.message, true));\n el('cancel-google-configuration').onclick = cancelConfigurationEdit;\n el('clear-google').onclick = () => clearProvider('google').catch((error) => message(error.message, true));\n el('check-google').onclick = () => checkGoogle().catch((error) => message(error.message, true));\n for (const provider of Object.keys(loginApps)) {\n el('save-' + provider).onclick = () => saveLoginApp(provider).catch((error) => message(error.message, true));\n el('cancel-' + provider + '-configuration').onclick = cancelConfigurationEdit;\n el('clear-' + provider).onclick = () => saveLoginApp(provider, true).catch((error) => message(error.message, true));\n el('check-' + provider).onclick = () => checkLogto().catch((error) => message(error.message, true));\n }\n el('create-feishu-login-app').onclick = () => createRegionalLoginApp('feishu').catch((error) => message(error.message, true));\n el('save-feishu-login-app').onclick = () => saveRegionalLoginApp('feishu').catch((error) => message(error.message, true));\n el('cancel-feishu-configuration').onclick = cancelConfigurationEdit;\n el('clear-feishu').onclick = () => clearProvider('feishu').catch((error) => message(error.message, true));\n el('check-feishu-login-app').onclick = () => checkRegionalLoginApp('feishu').catch((error) => message(error.message, true));\n el('create-lark-login-app').onclick = () => createRegionalLoginApp('lark').catch((error) => message(error.message, true));\n el('save-lark-login-app').onclick = () => saveRegionalLoginApp('lark').catch((error) => message(error.message, true));\n el('cancel-lark-configuration').onclick = cancelConfigurationEdit;\n el('clear-lark').onclick = () => clearProvider('lark').catch((error) => message(error.message, true));\n el('check-lark-login-app').onclick = () => checkRegionalLoginApp('lark').catch((error) => message(error.message, true));\n el('check-logto').onclick = () => checkLogto().catch((error) => message(error.message, true));\n el('reconcile-logto').onclick = () => reconcileLogto().then(load).catch((error) => message(error.message, true));\n el('logout').onclick = () => { sessionStorage.removeItem(tokenKey); load().catch((error) => message(error.message, true)); };\n el('save-options').onclick = () => saveOptions().catch((error) => message(error.message, true));\n for (const button of document.querySelectorAll('.edit-secret')) button.onclick = () => editSecret(button);\n for (const button of document.querySelectorAll('.edit-configuration')) button.onclick = () => beginConfigurationEdit(button.dataset.provider);\n updateBootstrapProvider();\n finishSignIn().then(load).catch((error) => message(error.message, true));\n <\/script>\n</body>\n</html>"
|
|
67270
|
+
])), ssoForm("bootstrap-sso"), ssoForm("sso"), Object.entries(LOGTO_LOGIN_APPS).map(([provider, app]) => String.raw`
|
|
67100
67271
|
<section id="${provider}-section" class="panel setup-section" aria-labelledby="${provider}-heading">
|
|
67101
67272
|
<div class="provider-head">
|
|
67102
67273
|
<div><h3 id="${provider}-heading">${app.name}</h3><p class="muted">Login app shared by all organizations. Agents keep their own chat bots.</p></div>
|
|
@@ -67142,16 +67313,36 @@ const MANAGED_APP_TAG_VALUE = {
|
|
|
67142
67313
|
* deployments whose Logto predates it.
|
|
67143
67314
|
*/
|
|
67144
67315
|
const DEMO_CONNECTOR_IDS = ["logto-social-demo", "logto-sms"];
|
|
67316
|
+
/** The one connector Setup manages; a fixed internal name lets a retried create find it again. */
|
|
67317
|
+
const MANAGED_SSO_CONNECTOR_NAME = "agentconnect-sso";
|
|
67145
67318
|
var LogtoManagementError = class extends Error {
|
|
67146
67319
|
code;
|
|
67147
67320
|
status;
|
|
67148
|
-
|
|
67321
|
+
detail;
|
|
67322
|
+
constructor(code, message, status, detail) {
|
|
67149
67323
|
super(message);
|
|
67150
67324
|
this.code = code;
|
|
67151
67325
|
this.status = status;
|
|
67326
|
+
this.detail = detail;
|
|
67152
67327
|
this.name = "LogtoManagementError";
|
|
67153
67328
|
}
|
|
67154
67329
|
};
|
|
67330
|
+
function parseSsoConnector(value) {
|
|
67331
|
+
const row = asRecord(value);
|
|
67332
|
+
if (typeof row.id !== "string" || typeof row.providerName !== "string") return null;
|
|
67333
|
+
return {
|
|
67334
|
+
id: row.id,
|
|
67335
|
+
providerName: row.providerName,
|
|
67336
|
+
connectorName: typeof row.connectorName === "string" ? row.connectorName : "",
|
|
67337
|
+
domains: stringArray(row.domains),
|
|
67338
|
+
branding: asRecord(row.branding),
|
|
67339
|
+
config: asRecord(row.config)
|
|
67340
|
+
};
|
|
67341
|
+
}
|
|
67342
|
+
/** Logto stores exactly the config it was sent, so a setting left in Logto but cleared here is a mismatch; a masked secret only has to be present. */
|
|
67343
|
+
function ssoConfigMatches(current, desired) {
|
|
67344
|
+
return [.../* @__PURE__ */ new Set([...Object.keys(current), ...Object.keys(desired)])].every((key) => key === "clientSecret" ? typeof current[key] === "string" === key in desired : isDeepStrictEqual(current[key], desired[key]));
|
|
67345
|
+
}
|
|
67155
67346
|
function asRecord(value) {
|
|
67156
67347
|
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
67157
67348
|
}
|
|
@@ -67403,7 +67594,9 @@ var LogtoAdminClaimClient = class {
|
|
|
67403
67594
|
addDiff(signInExperienceDiff, "Sign-up verification", signInExperience.signUp.verify ?? null, false);
|
|
67404
67595
|
addDiff(signInExperienceDiff, "Social providers", signInExperience.socialSignInConnectorTargets, desired.socialProviders, sameStrings(signInExperience.socialSignInConnectorTargets, desired.socialProviders));
|
|
67405
67596
|
addDiff(signInExperienceDiff, "Sign-in mode", signInExperience.signInMode ?? null, "SignInAndRegister");
|
|
67597
|
+
addDiff(signInExperienceDiff, "Single sign-on", signInExperience.singleSignOnEnabled, desired.sso !== void 0);
|
|
67406
67598
|
return {
|
|
67599
|
+
sso: desired.sso ? await this.inspectSsoConnector(desired.sso) : null,
|
|
67407
67600
|
application: {
|
|
67408
67601
|
id: application?.id ?? null,
|
|
67409
67602
|
exists: application !== void 0,
|
|
@@ -67456,23 +67649,119 @@ var LogtoAdminClaimClient = class {
|
|
|
67456
67649
|
diff
|
|
67457
67650
|
};
|
|
67458
67651
|
}),
|
|
67459
|
-
signInExperienceMatches: this.signInExperienceMatches(signInExperience, desired.socialProviders),
|
|
67652
|
+
signInExperienceMatches: this.signInExperienceMatches(signInExperience, desired.socialProviders, desired.sso !== void 0),
|
|
67460
67653
|
signInExperienceDiff
|
|
67461
67654
|
};
|
|
67462
67655
|
}
|
|
67463
67656
|
async reconcileSetup(desired, options = {}) {
|
|
67464
67657
|
const application = await this.ensureApplication(desired);
|
|
67465
67658
|
const connectors = await this.ensureConnectors(desired, options.refreshConnectorSecrets ?? false);
|
|
67466
|
-
const
|
|
67659
|
+
const sso = desired.sso ? await this.ensureSsoConnector(desired.sso, options.refreshConnectorSecrets ?? false) : null;
|
|
67660
|
+
const signInExperienceChanged = await this.ensureSignInExperience(desired.socialProviders, desired.sso !== void 0);
|
|
67467
67661
|
const adminRoleCreated = (await this.ensureAdminRole()).created;
|
|
67468
67662
|
return {
|
|
67469
|
-
changed: application.changed || connectors.some((connector) => connector.changed) || signInExperienceChanged || adminRoleCreated,
|
|
67663
|
+
changed: application.changed || connectors.some((connector) => connector.changed) || (sso?.changed ?? false) || signInExperienceChanged || adminRoleCreated,
|
|
67470
67664
|
application,
|
|
67471
67665
|
connectors,
|
|
67666
|
+
sso,
|
|
67472
67667
|
signInExperienceChanged,
|
|
67473
67668
|
adminRoleCreated
|
|
67474
67669
|
};
|
|
67475
67670
|
}
|
|
67671
|
+
/** Create the managed SSO connector without IdP settings, or find the one a retried create left; Logto mints its id. */
|
|
67672
|
+
async createSsoConnector(input) {
|
|
67673
|
+
const existing = (await this.listSsoConnectors()).find((connector) => connector.connectorName === MANAGED_SSO_CONNECTOR_NAME);
|
|
67674
|
+
if (existing?.providerName === input.providerName) return {
|
|
67675
|
+
id: existing.id,
|
|
67676
|
+
created: false
|
|
67677
|
+
};
|
|
67678
|
+
if (existing) await this.request(`/api/sso-connectors/${encodeURIComponent(existing.id)}`, { method: "DELETE" });
|
|
67679
|
+
const created = parseSsoConnector(await (await this.ssoRequest("/api/sso-connectors", "POST", {
|
|
67680
|
+
providerName: input.providerName,
|
|
67681
|
+
connectorName: MANAGED_SSO_CONNECTOR_NAME,
|
|
67682
|
+
branding: { displayName: input.displayName }
|
|
67683
|
+
})).json().catch(() => null));
|
|
67684
|
+
if (!created) throw new LogtoManagementError("LOGTO_UNAVAILABLE", "Logto returned an invalid SSO connector");
|
|
67685
|
+
return {
|
|
67686
|
+
id: created.id,
|
|
67687
|
+
created: true
|
|
67688
|
+
};
|
|
67689
|
+
}
|
|
67690
|
+
async inspectSsoConnector(desired) {
|
|
67691
|
+
const connector = await this.getSsoConnector(desired.connectorId);
|
|
67692
|
+
if (!connector) return {
|
|
67693
|
+
id: desired.connectorId,
|
|
67694
|
+
exists: false,
|
|
67695
|
+
matches: false,
|
|
67696
|
+
diff: [{
|
|
67697
|
+
field: "SSO connector",
|
|
67698
|
+
current: "Missing",
|
|
67699
|
+
expected: desired.connectorId
|
|
67700
|
+
}]
|
|
67701
|
+
};
|
|
67702
|
+
const diff = [];
|
|
67703
|
+
addDiff(diff, "SSO protocol", connector.providerName, desired.providerName);
|
|
67704
|
+
addDiff(diff, "SSO display name", connector.branding.displayName ?? null, desired.displayName);
|
|
67705
|
+
addDiff(diff, "SSO email domains", connector.domains, desired.domains, sameStrings(connector.domains, desired.domains));
|
|
67706
|
+
if (!ssoConfigMatches(connector.config, desired.config)) diff.push({
|
|
67707
|
+
field: "SSO identity provider settings",
|
|
67708
|
+
current: "Different",
|
|
67709
|
+
expected: "Configured"
|
|
67710
|
+
});
|
|
67711
|
+
return {
|
|
67712
|
+
id: connector.id,
|
|
67713
|
+
exists: true,
|
|
67714
|
+
matches: diff.length === 0,
|
|
67715
|
+
diff
|
|
67716
|
+
};
|
|
67717
|
+
}
|
|
67718
|
+
async ensureSsoConnector(desired, refreshSecret) {
|
|
67719
|
+
const connector = await this.getSsoConnector(desired.connectorId);
|
|
67720
|
+
if (!connector) throw new LogtoManagementError("SSO_CONNECTOR_MISSING", "the SSO connector no longer exists in Logto; create it again and give the identity provider its new URLs");
|
|
67721
|
+
if (connector.providerName !== desired.providerName) throw new LogtoManagementError("SSO_CONNECTOR_REJECTED", `the Logto SSO connector uses ${connector.providerName}, not ${desired.providerName}`);
|
|
67722
|
+
if (!refreshSecret && connector.branding.displayName === desired.displayName && sameStrings(connector.domains, desired.domains) && ssoConfigMatches(connector.config, desired.config)) return {
|
|
67723
|
+
id: connector.id,
|
|
67724
|
+
changed: false
|
|
67725
|
+
};
|
|
67726
|
+
await this.ssoRequest(`/api/sso-connectors/${encodeURIComponent(connector.id)}`, "PATCH", {
|
|
67727
|
+
config: desired.config,
|
|
67728
|
+
domains: desired.domains,
|
|
67729
|
+
branding: {
|
|
67730
|
+
...connector.branding,
|
|
67731
|
+
displayName: desired.displayName
|
|
67732
|
+
}
|
|
67733
|
+
});
|
|
67734
|
+
return {
|
|
67735
|
+
id: connector.id,
|
|
67736
|
+
changed: true
|
|
67737
|
+
};
|
|
67738
|
+
}
|
|
67739
|
+
async listSsoConnectors() {
|
|
67740
|
+
const rows = await this.getJson("/api/sso-connectors");
|
|
67741
|
+
if (!Array.isArray(rows)) throw new LogtoManagementError("LOGTO_UNAVAILABLE", "Logto returned invalid SSO connectors");
|
|
67742
|
+
return rows.map(parseSsoConnector).filter((connector) => connector !== null);
|
|
67743
|
+
}
|
|
67744
|
+
async getSsoConnector(id) {
|
|
67745
|
+
try {
|
|
67746
|
+
return parseSsoConnector(await this.getJson(`/api/sso-connectors/${encodeURIComponent(id)}`)) ?? void 0;
|
|
67747
|
+
} catch (error) {
|
|
67748
|
+
if (error instanceof LogtoManagementError && error.status === 404) return void 0;
|
|
67749
|
+
throw error;
|
|
67750
|
+
}
|
|
67751
|
+
}
|
|
67752
|
+
/** Logto validates SSO settings against the IdP itself, so its refusal carries the reason the operator needs. */
|
|
67753
|
+
async ssoRequest(path, method, body) {
|
|
67754
|
+
try {
|
|
67755
|
+
return await this.request(path, {
|
|
67756
|
+
method,
|
|
67757
|
+
headers: { "content-type": "application/json" },
|
|
67758
|
+
body: JSON.stringify(body)
|
|
67759
|
+
});
|
|
67760
|
+
} catch (error) {
|
|
67761
|
+
if (!(error instanceof LogtoManagementError) || !error.status || error.status >= 500) throw error;
|
|
67762
|
+
throw new LogtoManagementError("SSO_CONNECTOR_REJECTED", `Logto rejected the SSO connector settings${error.detail ? `: ${error.detail.replace(/\.+$/, "")}` : ""}. An identity provider on a private network also needs its address in Logto's SSRF_ALLOWED_ADDRESSES.`, error.status);
|
|
67763
|
+
}
|
|
67764
|
+
}
|
|
67476
67765
|
async assign(subject) {
|
|
67477
67766
|
const { role } = await this.ensureAdminRole();
|
|
67478
67767
|
await this.request(`/api/users/${encodeURIComponent(subject)}/roles`, {
|
|
@@ -67675,18 +67964,19 @@ var LogtoAdminClaimClient = class {
|
|
|
67675
67964
|
signIn: asRecord(value.signIn),
|
|
67676
67965
|
signUp: asRecord(value.signUp),
|
|
67677
67966
|
socialSignInConnectorTargets: stringArray(value.socialSignInConnectorTargets),
|
|
67967
|
+
singleSignOnEnabled: value.singleSignOnEnabled === true,
|
|
67678
67968
|
signInMode: value.signInMode
|
|
67679
67969
|
};
|
|
67680
67970
|
}
|
|
67681
|
-
signInExperienceMatches(current, targets) {
|
|
67971
|
+
signInExperienceMatches(current, targets, sso) {
|
|
67682
67972
|
const methods = current.signIn.methods;
|
|
67683
67973
|
const identifiers = current.signUp.identifiers;
|
|
67684
67974
|
const secondaryIdentifiers = current.signUp.secondaryIdentifiers;
|
|
67685
|
-
return Array.isArray(methods) && methods.length === 0 && Array.isArray(identifiers) && identifiers.length === 0 && current.signUp.password === false && current.signUp.verify === false && (secondaryIdentifiers === void 0 || Array.isArray(secondaryIdentifiers) && secondaryIdentifiers.length === 0) && current.signInMode === "SignInAndRegister" && sameStrings(current.socialSignInConnectorTargets, targets);
|
|
67975
|
+
return Array.isArray(methods) && methods.length === 0 && Array.isArray(identifiers) && identifiers.length === 0 && current.signUp.password === false && current.signUp.verify === false && (secondaryIdentifiers === void 0 || Array.isArray(secondaryIdentifiers) && secondaryIdentifiers.length === 0) && current.signInMode === "SignInAndRegister" && sameStrings(current.socialSignInConnectorTargets, targets) && current.singleSignOnEnabled === sso;
|
|
67686
67976
|
}
|
|
67687
|
-
async ensureSignInExperience(targets) {
|
|
67977
|
+
async ensureSignInExperience(targets, sso) {
|
|
67688
67978
|
const current = await this.getSignInExperience();
|
|
67689
|
-
if (this.signInExperienceMatches(current, targets)) return false;
|
|
67979
|
+
if (this.signInExperienceMatches(current, targets, sso)) return false;
|
|
67690
67980
|
await this.request("/api/sign-in-exp", {
|
|
67691
67981
|
method: "PATCH",
|
|
67692
67982
|
headers: { "content-type": "application/json" },
|
|
@@ -67699,6 +67989,7 @@ var LogtoAdminClaimClient = class {
|
|
|
67699
67989
|
secondaryIdentifiers: []
|
|
67700
67990
|
},
|
|
67701
67991
|
socialSignInConnectorTargets: targets,
|
|
67992
|
+
singleSignOnEnabled: sso,
|
|
67702
67993
|
signInMode: "SignInAndRegister"
|
|
67703
67994
|
})
|
|
67704
67995
|
});
|
|
@@ -67738,7 +68029,10 @@ var LogtoAdminClaimClient = class {
|
|
|
67738
68029
|
} catch (error) {
|
|
67739
68030
|
throw new LogtoManagementError("LOGTO_UNAVAILABLE", `Logto request failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
67740
68031
|
}
|
|
67741
|
-
if (!response.ok)
|
|
68032
|
+
if (!response.ok) {
|
|
68033
|
+
const body = asRecord(await response.json().catch(() => null));
|
|
68034
|
+
throw new LogtoManagementError("LOGTO_UNAVAILABLE", `Logto request failed: HTTP ${response.status}`, response.status, typeof body.message === "string" ? body.message : void 0);
|
|
68035
|
+
}
|
|
67742
68036
|
return response;
|
|
67743
68037
|
}
|
|
67744
68038
|
async accessToken() {
|
|
@@ -67837,6 +68131,32 @@ const ConfigureGoogleBody = strictObject({
|
|
|
67837
68131
|
clientId: string().trim().min(1).max(500),
|
|
67838
68132
|
clientSecret: string().min(1).max(1e4).optional()
|
|
67839
68133
|
});
|
|
68134
|
+
const SsoProtocol = _enum(["oidc", "saml"]);
|
|
68135
|
+
const SsoName = string().trim().min(1).max(64);
|
|
68136
|
+
const CreateSsoBody = strictObject({
|
|
68137
|
+
protocol: SsoProtocol,
|
|
68138
|
+
name: SsoName
|
|
68139
|
+
});
|
|
68140
|
+
const ConfigureSsoBody = strictObject({ connector: strictObject({
|
|
68141
|
+
name: SsoName,
|
|
68142
|
+
domains: array(string().trim().min(1).max(253)).max(50).default([]),
|
|
68143
|
+
oidc: strictObject({
|
|
68144
|
+
issuer: string().trim().min(1).max(2e3),
|
|
68145
|
+
clientId: string().trim().min(1).max(500),
|
|
68146
|
+
clientSecret: string().min(1).max(1e4).optional(),
|
|
68147
|
+
scope: string().trim().max(500).default(""),
|
|
68148
|
+
trustUnverifiedEmail: boolean()
|
|
68149
|
+
}).optional(),
|
|
68150
|
+
saml: strictObject({
|
|
68151
|
+
metadataUrl: string().trim().min(1).max(2e3).optional(),
|
|
68152
|
+
metadata: string().trim().min(1).max(2e5).optional(),
|
|
68153
|
+
attributeMapping: strictObject({
|
|
68154
|
+
id: string().trim().min(1).max(500).optional(),
|
|
68155
|
+
email: string().trim().min(1).max(500).optional(),
|
|
68156
|
+
name: string().trim().min(1).max(500).optional()
|
|
68157
|
+
}).optional()
|
|
68158
|
+
}).optional()
|
|
68159
|
+
}).nullable() });
|
|
67840
68160
|
const LoginAppProvider = _enum([
|
|
67841
68161
|
"telegram",
|
|
67842
68162
|
"discord",
|
|
@@ -67943,6 +68263,50 @@ function logtoConfig(record, endpoint) {
|
|
|
67943
68263
|
resource: record.values.logto.managementResource
|
|
67944
68264
|
};
|
|
67945
68265
|
}
|
|
68266
|
+
/** The SSO connector's IdP settings in Logto's shape; undefined until the operator has supplied them. */
|
|
68267
|
+
function ssoDesired(record) {
|
|
68268
|
+
const connector = record.values.logto?.ssoConnector;
|
|
68269
|
+
if (!connector) return void 0;
|
|
68270
|
+
const base = {
|
|
68271
|
+
connectorId: connector.connectorId,
|
|
68272
|
+
displayName: connector.name,
|
|
68273
|
+
domains: connector.domains
|
|
68274
|
+
};
|
|
68275
|
+
if (connector.protocol === "oidc") {
|
|
68276
|
+
const clientSecret = record.secrets["logto.ssoConnectorClientSecret"];
|
|
68277
|
+
if (!connector.oidc || !clientSecret) return void 0;
|
|
68278
|
+
const { issuer, clientId, scope, trustUnverifiedEmail } = connector.oidc;
|
|
68279
|
+
return {
|
|
68280
|
+
...base,
|
|
68281
|
+
providerName: "OIDC",
|
|
68282
|
+
config: {
|
|
68283
|
+
issuer,
|
|
68284
|
+
clientId,
|
|
68285
|
+
clientSecret,
|
|
68286
|
+
...scope ? { scope } : {},
|
|
68287
|
+
trustUnverifiedEmail
|
|
68288
|
+
}
|
|
68289
|
+
};
|
|
68290
|
+
}
|
|
68291
|
+
if (!connector.saml) return void 0;
|
|
68292
|
+
const { metadataUrl, metadata, attributeMapping } = connector.saml;
|
|
68293
|
+
return {
|
|
68294
|
+
...base,
|
|
68295
|
+
providerName: "SAML",
|
|
68296
|
+
config: {
|
|
68297
|
+
...metadataUrl ? { metadataUrl } : { metadata },
|
|
68298
|
+
...Object.keys(attributeMapping).length > 0 ? { attributeMapping } : {}
|
|
68299
|
+
}
|
|
68300
|
+
};
|
|
68301
|
+
}
|
|
68302
|
+
/** Where the IdP sends people back: OIDC needs the callback, SAML the ACS URL and SP entity ID. */
|
|
68303
|
+
function ssoServiceProviderUrls(endpoint, connectorId, protocol) {
|
|
68304
|
+
const id = encodeURIComponent(connectorId);
|
|
68305
|
+
return protocol === "oidc" ? { redirectUri: appendPath(endpoint, `/callback/${id}`) } : {
|
|
68306
|
+
assertionConsumerServiceUrl: appendPath(endpoint, `/api/authn/single-sign-on/saml/${id}`),
|
|
68307
|
+
entityId: appendPath(endpoint, `/enterprise-sso/${id}`)
|
|
68308
|
+
};
|
|
68309
|
+
}
|
|
67946
68310
|
function appendPath(origin, path) {
|
|
67947
68311
|
return new URL(path, `${origin.replace(/\/+$/, "")}/`).toString();
|
|
67948
68312
|
}
|
|
@@ -67981,11 +68345,17 @@ function logtoSetup(record, setupUrl, services, endpoint) {
|
|
|
67981
68345
|
const applicationId = auth.mode === "oidc" ? auth.browserClient.appId : void 0;
|
|
67982
68346
|
const webOrigin = new URL(services.web).origin;
|
|
67983
68347
|
const setupOrigin = new URL(setupUrl).origin;
|
|
68348
|
+
const sso = ssoDesired(record);
|
|
67984
68349
|
return {
|
|
67985
68350
|
endpoint: resolvedEndpoint,
|
|
67986
68351
|
apiResource: browser.apiResource,
|
|
67987
68352
|
socialProviders: [...browser.socialProviders],
|
|
68353
|
+
sso: sso ? {
|
|
68354
|
+
connectorId: sso.connectorId,
|
|
68355
|
+
name: sso.displayName
|
|
68356
|
+
} : null,
|
|
67988
68357
|
desired: {
|
|
68358
|
+
...sso ? { sso } : {},
|
|
67989
68359
|
...applicationId ? { applicationId } : {},
|
|
67990
68360
|
applicationName: browser.applicationName,
|
|
67991
68361
|
redirectUris: [appendPath(webOrigin, "/auth/callback"), appendPath(setupOrigin, "/auth/callback")],
|
|
@@ -68194,7 +68564,7 @@ function buildSetupServer(deps, options = {}) {
|
|
|
68194
68564
|
if (error instanceof DeploymentConfigConflictError) return problem(reply, 409, error.message, error.code);
|
|
68195
68565
|
if (error instanceof DeploymentConfigGitlabBaseUrlLockedError) return problem(reply, 409, error.message, error.code);
|
|
68196
68566
|
if (error instanceof DeploymentConfigGiteaBaseUrlLockedError) return problem(reply, 409, error.message, error.code);
|
|
68197
|
-
if (error instanceof LogtoManagementError) return problem(reply, error.code === "LOGTO_UNAVAILABLE" ? 502 : error.code === "SOCIAL_CONNECTOR_UNSUPPORTED" ? 400 : 409, error.message, error.code);
|
|
68567
|
+
if (error instanceof LogtoManagementError) return problem(reply, error.code === "LOGTO_UNAVAILABLE" ? 502 : error.code === "SOCIAL_CONNECTOR_UNSUPPORTED" || error.code === "SSO_CONNECTOR_REJECTED" ? 400 : 409, error.message, error.code);
|
|
68198
68568
|
request.log.error({ err: error }, "setup-server request failed");
|
|
68199
68569
|
return problem(reply, 500, "internal server error");
|
|
68200
68570
|
});
|
|
@@ -68218,6 +68588,7 @@ function buildSetupServer(deps, options = {}) {
|
|
|
68218
68588
|
endpoint: logtoEndpoint,
|
|
68219
68589
|
appId: auth.browserClient.appId,
|
|
68220
68590
|
resource: auth.browserClient.apiResource,
|
|
68591
|
+
ssoConnectorId: auth.socialProviders.length === 0 && auth.sso ? auth.sso.connectorId : null,
|
|
68221
68592
|
redirectUri: new URL("/auth/callback", deps.publicUrl).toString(),
|
|
68222
68593
|
...endpoints
|
|
68223
68594
|
};
|
|
@@ -68997,6 +69368,60 @@ function buildSetupServer(deps, options = {}) {
|
|
|
68997
69368
|
restartRequired: false
|
|
68998
69369
|
};
|
|
68999
69370
|
});
|
|
69371
|
+
app.post("/api/v1/create/sso", { preHandler: requireConfigurationAccess }, async (request, reply) => {
|
|
69372
|
+
const parsed = CreateSsoBody.safeParse(request.body);
|
|
69373
|
+
if (!parsed.success) return problem(reply, 400, "an SSO protocol (oidc or saml) and a display name are required");
|
|
69374
|
+
return serializeMutation(async () => {
|
|
69375
|
+
const runtime = await deps.store.getRuntime(["logto.managementAppSecret"]);
|
|
69376
|
+
const config = logtoConfig(runtime, logtoManagementEndpoint);
|
|
69377
|
+
if (!runtime || !config) return problem(reply, 409, "save the Logto Management API configuration first");
|
|
69378
|
+
if (!runtime.values.logto?.browser) return problem(reply, 409, "save Logto browser configuration first");
|
|
69379
|
+
const { protocol, name } = parsed.data;
|
|
69380
|
+
const created = await (deps.makeLogtoSetupClient?.(config) ?? new LogtoAdminClaimClient(config)).createSsoConnector({
|
|
69381
|
+
providerName: protocol === "oidc" ? "OIDC" : "SAML",
|
|
69382
|
+
displayName: name
|
|
69383
|
+
});
|
|
69384
|
+
const kept = runtime.values.logto.ssoConnector;
|
|
69385
|
+
return {
|
|
69386
|
+
revision: (kept?.connectorId === created.id && kept.protocol === protocol ? null : await deps.store.replace({
|
|
69387
|
+
expectedRevision: runtime.revision,
|
|
69388
|
+
...logtoSsoConnectorCreatedPut(runtime, {
|
|
69389
|
+
connectorId: created.id,
|
|
69390
|
+
protocol,
|
|
69391
|
+
name
|
|
69392
|
+
})
|
|
69393
|
+
}))?.revision ?? runtime.revision,
|
|
69394
|
+
connectorId: created.id,
|
|
69395
|
+
protocol,
|
|
69396
|
+
...ssoServiceProviderUrls(logtoEndpoint, created.id, protocol)
|
|
69397
|
+
};
|
|
69398
|
+
});
|
|
69399
|
+
});
|
|
69400
|
+
app.post("/api/v1/configure/sso", { preHandler: requireConfigurationAccess }, async (request, reply) => {
|
|
69401
|
+
const parsed = ConfigureSsoBody.safeParse(request.body);
|
|
69402
|
+
if (!parsed.success) return problem(reply, 400, "invalid SSO connector settings");
|
|
69403
|
+
return serializeMutation(async () => {
|
|
69404
|
+
const current = await deps.store.getAdmin();
|
|
69405
|
+
if (!current) return problem(reply, 409, "save deployment settings before configuring SSO");
|
|
69406
|
+
let put;
|
|
69407
|
+
try {
|
|
69408
|
+
put = logtoSsoConnectorPut(current, parsed.data.connector);
|
|
69409
|
+
} catch (error) {
|
|
69410
|
+
const issue = error instanceof ZodError ? error.issues[0] : void 0;
|
|
69411
|
+
return problem(reply, 400, issue ? `${issue.path.slice(2).join(".") || "SSO connector"} ${issue.message}` : error instanceof Error ? error.message : "invalid SSO connector settings");
|
|
69412
|
+
}
|
|
69413
|
+
const saved = await deps.store.replace({
|
|
69414
|
+
expectedRevision: current.revision,
|
|
69415
|
+
...put
|
|
69416
|
+
});
|
|
69417
|
+
const connector = saved.values.logto?.ssoConnector;
|
|
69418
|
+
return {
|
|
69419
|
+
revision: saved.revision,
|
|
69420
|
+
restartRequired: true,
|
|
69421
|
+
...connector ? ssoServiceProviderUrls(logtoEndpoint, connector.connectorId, connector.protocol) : {}
|
|
69422
|
+
};
|
|
69423
|
+
});
|
|
69424
|
+
});
|
|
69000
69425
|
app.post("/api/v1/reconcile/logto", { preHandler: requireConfigurationAccess }, async (request, reply) => serializeMutation(async () => {
|
|
69001
69426
|
const parsed = ReconcileLogtoBody.safeParse(request.body ?? {});
|
|
69002
69427
|
if (!parsed.success) return problem(reply, 400, "invalid Logto reconciliation options");
|
|
@@ -69007,6 +69432,7 @@ function buildSetupServer(deps, options = {}) {
|
|
|
69007
69432
|
"logto.telegramConnectorClientSecret",
|
|
69008
69433
|
"logto.discordConnectorClientSecret",
|
|
69009
69434
|
"logto.azureadConnectorClientSecret",
|
|
69435
|
+
"logto.ssoConnectorClientSecret",
|
|
69010
69436
|
"slack.clientSecret",
|
|
69011
69437
|
"github.clientSecret",
|
|
69012
69438
|
"gitlab.clientSecret"
|
|
@@ -69020,7 +69446,7 @@ function buildSetupServer(deps, options = {}) {
|
|
|
69020
69446
|
const github = runtime.values.github;
|
|
69021
69447
|
const githubClientId = github?.clientId;
|
|
69022
69448
|
const githubSecret = runtime.secrets["github.clientSecret"];
|
|
69023
|
-
if (setup.socialProviders.length === 0 && runtime.values.auth.mode === "oidc" && runtime.values.auth.socialProviders.includes("github") && github !== null && typeof githubClientId === "string" && githubSecret !== void 0) {
|
|
69449
|
+
if (setup.socialProviders.length === 0 && setup.sso === null && runtime.values.auth.mode === "oidc" && runtime.values.auth.socialProviders.includes("github") && github !== null && typeof githubClientId === "string" && githubSecret !== void 0) {
|
|
69024
69450
|
const put = logtoGithubConnectorPut(runtime, {
|
|
69025
69451
|
appId: String(github.appId),
|
|
69026
69452
|
slug: github.slug,
|
|
@@ -69037,7 +69463,7 @@ function buildSetupServer(deps, options = {}) {
|
|
|
69037
69463
|
setup = logtoSetup(runtime, deps.publicUrl, localAuthBootstrap.services, logtoEndpoint);
|
|
69038
69464
|
if (!setup) return problem(reply, 409, "save Logto browser desired state before creating Logto resources");
|
|
69039
69465
|
}
|
|
69040
|
-
if (setup.socialProviders.length === 0) return problem(reply, 409, "configure a Logto sign-in provider before creating Logto resources");
|
|
69466
|
+
if (setup.socialProviders.length === 0 && setup.sso === null) return problem(reply, 409, "configure a Logto sign-in provider or an SSO connector before creating Logto resources");
|
|
69041
69467
|
const reconciled = await (deps.makeLogtoSetupClient?.(config) ?? new LogtoAdminClaimClient(config)).reconcileSetup(setup.desired, { refreshConnectorSecrets: parsed.data.refreshConnectorSecrets });
|
|
69042
69468
|
const auth = {
|
|
69043
69469
|
mode: "oidc",
|
|
@@ -69046,7 +69472,8 @@ function buildSetupServer(deps, options = {}) {
|
|
|
69046
69472
|
appId: reconciled.application.id,
|
|
69047
69473
|
apiResource: setup.apiResource
|
|
69048
69474
|
},
|
|
69049
|
-
socialProviders: setup.socialProviders
|
|
69475
|
+
socialProviders: setup.socialProviders,
|
|
69476
|
+
sso: setup.sso
|
|
69050
69477
|
};
|
|
69051
69478
|
const nextValues = {
|
|
69052
69479
|
...runtime.values,
|
|
@@ -69066,6 +69493,7 @@ function buildSetupServer(deps, options = {}) {
|
|
|
69066
69493
|
restartRequired: changed,
|
|
69067
69494
|
application: reconciled.application,
|
|
69068
69495
|
connectors: reconciled.connectors,
|
|
69496
|
+
sso: reconciled.sso,
|
|
69069
69497
|
signInExperienceChanged: reconciled.signInExperienceChanged,
|
|
69070
69498
|
adminRoleCreated: reconciled.adminRoleCreated
|
|
69071
69499
|
};
|
|
@@ -69080,6 +69508,7 @@ function buildSetupServer(deps, options = {}) {
|
|
|
69080
69508
|
"logto.telegramConnectorClientSecret",
|
|
69081
69509
|
"logto.discordConnectorClientSecret",
|
|
69082
69510
|
"logto.azureadConnectorClientSecret",
|
|
69511
|
+
"logto.ssoConnectorClientSecret",
|
|
69083
69512
|
"gitlab.clientSecret",
|
|
69084
69513
|
"slack.clientSecret"
|
|
69085
69514
|
]);
|
|
@@ -69246,6 +69675,16 @@ function buildSetupServer(deps, options = {}) {
|
|
|
69246
69675
|
message: `Missing or mismatched Logto social connectors: ${invalidConnectors.join(", ")}.`,
|
|
69247
69676
|
diff: setupInspection.connectors.filter((connector) => !connector.exists || !connector.matches).flatMap((connector) => connector.diff)
|
|
69248
69677
|
});
|
|
69678
|
+
if (setupInspection.sso) findings.push(setupInspection.sso.matches ? {
|
|
69679
|
+
id: "logto.sso_connector",
|
|
69680
|
+
status: "pass",
|
|
69681
|
+
message: `Logto SSO connector ${setupInspection.sso.id} matches its saved identity provider.`
|
|
69682
|
+
} : {
|
|
69683
|
+
id: "logto.sso_connector",
|
|
69684
|
+
status: "fail",
|
|
69685
|
+
message: setupInspection.sso.exists ? "The Logto SSO connector differs from its saved identity provider." : "The Logto SSO connector no longer exists; create it again.",
|
|
69686
|
+
diff: setupInspection.sso.diff
|
|
69687
|
+
});
|
|
69249
69688
|
findings.push(setupInspection.signInExperienceMatches ? {
|
|
69250
69689
|
id: "logto.sign_in_experience",
|
|
69251
69690
|
status: "pass",
|