diff --git a/README.md b/README.md
index c45d400..5899d5e 100644
--- a/README.md
+++ b/README.md
@@ -186,6 +186,15 @@ offers them as a **Saved connection** dropdown on the login screen:
- A connection carrying clickhouse-client's OAuth keys (`oauth-url`,
`oauth-client-id`, `oauth-audience`) → an OAuth sign-in against that cluster.
+A connection with `1`
+(a self-signed or wrong-host TLS cert, common on dev tenants) is flagged in the
+picker. The browser refuses to `fetch()` such a host and JavaScript can't
+override that, so when you select it the login screen surfaces a one-time step:
+open the cluster in a new tab and accept its certificate, after which the SPA can
+reach it for the rest of the browser session. For an OAuth connection the sign-in
+redirect is held behind a **Continue** button so the cert is trusted before any
+post-login query hits the cluster.
+
You can also ignore the picker and type a host/user/password by hand (host: include
the scheme, e.g. `http://localhost:8123`; a bare host defaults to
`https://:8443`).
diff --git a/build/local.py b/build/local.py
index 7771291..f5e4b2f 100644
--- a/build/local.py
+++ b/build/local.py
@@ -13,6 +13,11 @@
`oauth-client-id`, optional `oauth-client-secret` for a Web client like Google,
`oauth-audience`) → an OAuth sign-in against that cluster.
+ A connection with `1`
+ is flagged `insecure` in config.json. The browser can't skip TLS validation
+ from fetch(), so the login screen walks the user through trusting the cert
+ once (opening the cluster in a tab) before connecting.
+
npm run local # build + serve, then open http://localhost:8900/sql
For OAuth connections you also register `http://localhost:8900/sql` as a redirect
@@ -54,9 +59,24 @@ def build_config():
if not name or not hostname:
continue
secure = _text(conn, "secure").lower() in ("1", "true", "yes")
+ # A self-signed / wrong-host TLS cert. The browser can't bypass cert
+ # validation from fetch(), so the SPA can't honour this on its own — it
+ # flags the connection and walks the user through trusting the cert once
+ # (see populateHosts in src/ui/login.js).
+ insecure = _text(conn, "accept-invalid-certificate", "accept_invalid_certificate").lower() in ("1", "true", "yes")
http_port = _text(conn, "http_port", "http-port")
scheme = "https" if secure else "http"
- url = f"{scheme}://{hostname}:{http_port}" if http_port else f"{scheme}://{hostname}"
+ # Default to ClickHouse's HTTP-interface ports (8443 TLS / 8123 plain), NOT
+ # 443/80 — mirrors the SPA's resolveTarget for a bare host. Managed endpoints
+ # often park an auth gateway on 443 (a browser GET 302s to an SSO login), so
+ # 443 wouldn't reach ClickHouse; 8443 is the direct HTTPS interface. Set an
+ # explicit to override (e.g. 443 for a proxy that fronts the HTTP
+ # interface there with no gateway).
+ # Don't double-append when already carries a port (clickhouse-client
+ # accepts host:port), else 'h:9000' would become 'h:9000:8443'.
+ tail = hostname.rsplit(":", 1)
+ has_port = len(tail) == 2 and tail[1].isdigit()
+ url = f"{scheme}://{hostname}" if has_port else f"{scheme}://{hostname}:{http_port or ('8443' if secure else '8123')}"
oauth_url = _text(conn, "oauth-url", "oauth_url")
oauth_client = _text(conn, "oauth-client-id", "oauth_client_id")
oauth_secret = _text(conn, "oauth-client-secret", "oauth_client_secret")
@@ -72,10 +92,11 @@ def build_config():
"bearer": "access_token" if oauth_aud else "id_token",
})
seen.add(name)
- hosts.append({"label": name, "url": url, "auth": "oauth", "idp": name})
+ hosts.append({"label": name, "url": url, "auth": "oauth", "idp": name, "insecure": insecure})
else:
hosts.append({"label": name, "url": url, "auth": "basic",
- "user": _text(conn, "user"), "password": _text(conn, "password")})
+ "user": _text(conn, "user"), "password": _text(conn, "password"),
+ "insecure": insecure})
return json.dumps({"basic_login": True, "idps": idps, "hosts": hosts}).encode()
diff --git a/src/net/oauth-config.js b/src/net/oauth-config.js
index 31b1677..f7d8c5b 100644
--- a/src/net/oauth-config.js
+++ b/src/net/oauth-config.js
@@ -69,9 +69,11 @@ function normalizeEntry(e) {
/**
* Map one raw `hosts[]` entry to a saved-connection descriptor for the login
- * picker: `{ label, url, auth, user, password, idp }`. `auth` is 'oauth' (sign in
- * via the named `idp`, querying `url` cross-origin) or 'basic' (prefill the
- * credentials form with `url`/`user`/`password`).
+ * picker: `{ label, url, auth, user, password, idp, insecure }`. `auth` is 'oauth'
+ * (sign in via the named `idp`, querying `url` cross-origin) or 'basic' (prefill
+ * the credentials form with `url`/`user`/`password`). `insecure` flags an
+ * accept-invalid-certificate host — the browser can't reach it until the user
+ * trusts the cert, so the picker surfaces that step (see renderLogin).
*/
function normalizeHost(h) {
const e = h || {};
@@ -82,6 +84,7 @@ function normalizeHost(h) {
user: e.user || '',
password: e.password || '',
idp: e.idp || '',
+ insecure: !!e.insecure,
};
}
diff --git a/src/styles.css b/src/styles.css
index 3f33b3e..1da0562 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -1421,3 +1421,23 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); }
background: none; border: none; color: var(--error-fg);
cursor: pointer; font-size: 16px; line-height: 1; padding: 0 4px;
}
+
+/* Insecure (accept-invalid-certificate) saved connection: trust-the-cert step
+ shown under the picker. */
+.login-cert-warn {
+ margin-top: 8px; padding: 10px 12px;
+ display: flex; flex-direction: column; gap: 8px;
+ background: var(--error-bg); border: 1px solid var(--error-bd);
+ border-radius: 8px;
+}
+.login-cert-msg {
+ display: flex; gap: 8px; align-items: flex-start;
+ font-size: 11.5px; line-height: 1.5; color: var(--fg);
+}
+.login-cert-msg svg { flex: none; margin-top: 1px; color: var(--error-fg); }
+.login-cert-link {
+ display: inline-flex; align-items: center; gap: 6px; align-self: flex-start;
+ font-size: 11.5px; color: var(--accent); text-decoration: none;
+}
+.login-cert-link:hover { text-decoration: underline; }
+.login-cert-go { margin-top: 2px; }
diff --git a/src/ui/login.js b/src/ui/login.js
index 56bd927..928a7e2 100644
--- a/src/ui/login.js
+++ b/src/ui/login.js
@@ -102,9 +102,13 @@ export function renderLogin(app, errorMsg) {
// --- saved-connection picker (populated async; shown only when config lists hosts) ---
let pickHosts = [];
const hostPicker = h('select', { class: 'login-picker mono', onchange: onPickHost });
+ // Shown only for an `insecure` (accept-invalid-certificate) connection — the
+ // browser can't be reached until its cert is trusted (see showCertWarn).
+ const certWarn = h('div', { class: 'login-cert-warn', style: { display: 'none' } });
const pickerSection = h('div', { class: 'login-field login-picker-field', style: { display: 'none' } },
h('label', { class: 'login-lbl' }, 'Saved connection'),
- hostPicker);
+ hostPicker,
+ certWarn);
// Footer tag adapts to which methods are available (set by applyChrome once
// the IdP list / basic_login flag resolve). The brand block is heading enough,
@@ -185,11 +189,19 @@ export function renderLogin(app, errorMsg) {
}
// Pick a saved connection: a basic one prefills the credentials form (+ reveals
- // the host); an oauth one starts the SSO flow against that cluster.
+ // the host); an oauth one starts the SSO flow against that cluster. An
+ // `insecure` (accept-invalid-certificate) connection first surfaces the
+ // cert-trust step — and, for oauth, holds the redirect behind a Continue button
+ // so the cert is trusted before any post-login query reaches the cluster.
function onPickHost() {
+ clearCertWarn();
if (hostPicker.value === '') return;
const hh = pickHosts[Number(hostPicker.value)];
- if (hh.auth === 'oauth') { pickOAuth(hh); return; }
+ if (hh.insecure) showCertWarn(hh);
+ if (hh.auth === 'oauth') {
+ if (!hh.insecure) pickOAuth(hh); // insecure → wait for the warning's Continue button
+ return;
+ }
hostInput.value = hh.url;
userInput.value = hh.user;
passInput.value = hh.password;
@@ -197,7 +209,42 @@ export function renderLogin(app, errorMsg) {
update();
}
+ // The browser refuses to fetch() a host with an untrusted TLS cert and JS can't
+ // override that — so for an `insecure` connection we point the user at the
+ // cluster to accept the cert once (per browser session). For oauth, the redirect
+ // is gated behind Continue so the post-login queries don't hit an untrusted host.
+ function showCertWarn(hh) {
+ const kids = [
+ h('div', { class: 'login-cert-msg' }, Icon.shield(),
+ h('span', null, 'This connection uses a self-signed or otherwise invalid TLS certificate. '
+ + 'Your browser blocks it until you open it once and click through the warning to trust the cert.')),
+ h('a', { class: 'login-cert-link mono', href: hh.url, target: '_blank', rel: 'noopener noreferrer' },
+ h('span', null, 'Open ' + hh.label + ' to accept its certificate'), Icon.arrow()),
+ // The opened host often 302-redirects (an auth gateway → its own login, a
+ // status page, etc.) once the handshake succeeds — that lands you somewhere
+ // unrelated, but the cert is already trusted by then. Say so, so the redirect
+ // doesn't read as a failure: close that tab and come back here.
+ h('div', { class: 'login-hint' },
+ 'A certificate prompt should appear — choose proceed/accept. Any login or status '
+ + 'page it then redirects to is expected and unrelated; just close that tab, return here, and connect.'),
+ ];
+ if (hh.auth === 'oauth') {
+ const go = h('button', { class: 'login-btn btn-primary login-cert-go',
+ onclick: () => { go.disabled = true; pickOAuth(hh); } },
+ Icon.shield(), h('span', null, 'Continue — sign in'));
+ kids.push(go);
+ }
+ certWarn.replaceChildren(...kids);
+ certWarn.style.display = '';
+ }
+
+ function clearCertWarn() {
+ certWarn.replaceChildren();
+ certWarn.style.display = 'none';
+ }
+
async function pickOAuth(hh) {
+ if (busy) return; // reachable from the cert panel's Continue button — guard re-entry like doSso
busy = 'sso';
hostPicker.disabled = true;
try {
diff --git a/tests/unit/login.test.js b/tests/unit/login.test.js
index 68835fc..f779cf1 100644
--- a/tests/unit/login.test.js
+++ b/tests/unit/login.test.js
@@ -110,6 +110,71 @@ describe('renderLogin — host picker', () => {
});
});
+describe('renderLogin — insecure (accept-invalid-certificate) hosts', () => {
+ const insecureHosts = [
+ { label: 'audit', url: 'https://support-a.tenant-a.dev.altinity.cloud', auth: 'basic', user: 'mcp', password: 'pw', idp: '', insecure: true },
+ { label: 'audit-oauth', url: 'https://support-a.tenant-a.dev.altinity.cloud', auth: 'oauth', user: '', password: '', idp: 'google', insecure: true },
+ ];
+ const withInsecure = (over = {}) => appWith({ loadIdps: async () => ({ idps: [], basicLogin: true, hosts: insecureHosts }), ...over });
+
+ it('basic insecure host: prefills the form and shows the cert-trust step with an open-cluster link (no Continue button)', async () => {
+ const login = vi.fn();
+ const app = withInsecure({ actions: { login } });
+ renderLogin(app); await tick();
+ selectHost(app.root, '0');
+ const [user, , host] = app.root.querySelectorAll('.login-input');
+ expect([host.value, user.value]).toEqual(['https://support-a.tenant-a.dev.altinity.cloud', 'mcp']);
+ const warn = app.root.querySelector('.login-cert-warn');
+ expect(warn.style.display).toBe('');
+ const link = warn.querySelector('.login-cert-link');
+ expect(link.getAttribute('href')).toBe('https://support-a.tenant-a.dev.altinity.cloud');
+ expect(link.textContent).toContain('Open audit');
+ expect(warn.querySelector('.login-cert-go')).toBeNull(); // basic: no SSO redirect to gate
+ expect(login).not.toHaveBeenCalled();
+ });
+
+ it('oauth insecure host: shows the cert step + Continue and does NOT auto-redirect until Continue is clicked', async () => {
+ const login = vi.fn(async () => {});
+ const app = withInsecure({ actions: { login } });
+ renderLogin(app); await tick();
+ selectHost(app.root, '1');
+ expect(login).not.toHaveBeenCalled(); // gated behind the cert-trust step
+ const go = app.root.querySelector('.login-cert-go');
+ expect(go).not.toBeNull();
+ click(go);
+ expect(login).toHaveBeenCalledWith('google', 'https://support-a.tenant-a.dev.altinity.cloud');
+ });
+
+ it('oauth insecure host: Continue guards against double-submit (disables itself + busy gate)', async () => {
+ // login stays pending so `busy` is still 'sso' on the second click.
+ const login = vi.fn(() => new Promise(() => {}));
+ const app = withInsecure({ actions: { login } });
+ renderLogin(app); await tick();
+ selectHost(app.root, '1');
+ const go = app.root.querySelector('.login-cert-go');
+ click(go);
+ expect(go.disabled).toBe(true);
+ click(go); // re-entry blocked by the busy guard in pickOAuth
+ expect(login).toHaveBeenCalledTimes(1);
+ });
+
+ it('clears the cert step when switching to the placeholder or a normal connection', async () => {
+ const app = appWith({ loadIdps: async () => ({ idps: [], basicLogin: true, hosts: [
+ ...insecureHosts,
+ { label: 'plain', url: 'http://localhost:8123', auth: 'basic', user: 'default', password: 'pw', idp: '' },
+ ] }) });
+ renderLogin(app); await tick();
+ selectHost(app.root, '0');
+ expect(app.root.querySelector('.login-cert-warn').style.display).toBe('');
+ selectHost(app.root, '');
+ expect(app.root.querySelector('.login-cert-warn').style.display).toBe('none');
+ selectHost(app.root, '0');
+ expect(app.root.querySelector('.login-cert-warn').style.display).toBe('');
+ selectHost(app.root, '2'); // a normal (secure) basic connection
+ expect(app.root.querySelector('.login-cert-warn').style.display).toBe('none');
+ });
+});
+
describe('renderLogin — SSO section', () => {
it('no IdPs → no SSO button, divider hidden, credentials present', async () => {
const app = appWith({ loadIdps: async () => ({ idps: [], basicLogin: true }) });
diff --git a/tests/unit/oauth-config.test.js b/tests/unit/oauth-config.test.js
index ad0c54c..09597c3 100644
--- a/tests/unit/oauth-config.test.js
+++ b/tests/unit/oauth-config.test.js
@@ -127,13 +127,25 @@ describe('loadConfigDoc hosts', () => {
{ label: 'antalya', url: 'https://antalya.demo.altinity.cloud', auth: 'oauth', idp: 'google' },
],
});
- expect(hosts[0]).toEqual({ label: 'demo', url: 'http://localhost:8123', auth: 'basic', user: 'default', password: 'pw', idp: '' });
- expect(hosts[1]).toEqual({ label: 'antalya', url: 'https://antalya.demo.altinity.cloud', auth: 'oauth', user: '', password: '', idp: 'google' });
+ expect(hosts[0]).toEqual({ label: 'demo', url: 'http://localhost:8123', auth: 'basic', user: 'default', password: 'pw', idp: '', insecure: false });
+ expect(hosts[1]).toEqual({ label: 'antalya', url: 'https://antalya.demo.altinity.cloud', auth: 'oauth', user: '', password: '', idp: 'google', insecure: false });
});
it('falls back the label to the url and defaults missing fields', async () => {
const { hosts } = await load({ idps: [], hosts: [{ url: 'http://h:8123' }] });
- expect(hosts[0]).toEqual({ label: 'http://h:8123', url: 'http://h:8123', auth: 'basic', user: '', password: '', idp: '' });
+ expect(hosts[0]).toEqual({ label: 'http://h:8123', url: 'http://h:8123', auth: 'basic', user: '', password: '', idp: '', insecure: false });
+ });
+
+ it('carries the accept-invalid-certificate flag through as `insecure`', async () => {
+ const { hosts } = await load({
+ idps: [],
+ hosts: [
+ { label: 'audit', url: 'https://support-a.dev.altinity.cloud', user: 'mcp', insecure: true },
+ { label: 'plain', url: 'http://localhost:8123' },
+ ],
+ });
+ expect(hosts[0].insecure).toBe(true);
+ expect(hosts[1].insecure).toBe(false);
});
});