diff --git a/README.md b/README.md index b25d937..e99bb5d 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,38 @@ still accepted — it's treated as a one-IdP list. ClickHouse needs a matching `` per issuer; it validates each inbound JWT against whichever one matches the token's `iss`, so no extra CH wiring is required to offer several. +### Credentials login (username / password) + +Alongside SSO, the sign-in screen offers a **ClickHouse username + password** +path (HTTP Basic). It is shown by default; set top-level `"basic_login": false` +in `config.json` to hide it and force SSO-only. A deployment with no OAuth at all +can ship a credentials-only config (no `idps`): + +```json +{ "basic_login": true } +``` + +Credentials authenticate against the **serving host** by default. The login +screen's **Advanced → Server address** field can aim the credential path at a +**different** `host:port` (a bare host defaults to `https://…:8443`); SSO always +stays on the serving host. The same-origin path needs no extra setup, but a +**cross-origin** target has two requirements: + +- **The SPA's own CSP.** `deploy/http_handlers.xml` sets `connect-src 'self'` + (+ the IdP origins). The browser will block a query POST to any other origin + until you add that origin to `connect-src` — otherwise the request never + leaves the page. +- **The target ClickHouse must allow CORS** for this origin: answer the + `Authorization`-header preflight (`OPTIONS`) and return + `Access-Control-Allow-Origin`. ClickHouse's `add_http_cors_header` covers the + actual request; the preflight may need handler/proxy configuration on that + server. + +The password is held in `sessionStorage` for the tab session (same lifetime as +the OAuth token) and sent as `Authorization: Basic base64(user:password)`. A +wrong password is surfaced on the login screen — the connect probe runs a +`SELECT 1` before entering the workbench. + ### Security headers `deploy/http_handlers.xml` sends a strict **Content-Security-Policy** plus diff --git a/deploy/config.json.example b/deploy/config.json.example index 55ce227..393a579 100644 --- a/deploy/config.json.example +++ b/deploy/config.json.example @@ -1,4 +1,5 @@ { + "basic_login": true, "idps": [ { "id": "google", diff --git a/src/core/target.js b/src/core/target.js new file mode 100644 index 0000000..ee73e87 --- /dev/null +++ b/src/core/target.js @@ -0,0 +1,28 @@ +// Pure helper for the credentials login path: turn a user-typed server address +// into a clean origin to POST queries at. No DOM, no globals — unit-testable. + +/** + * Resolve a host:port (or full URL) the user typed into a ClickHouse origin. + * + * '' → currentOrigin (blank = use the serving host) + * 'ch.example' → 'https://ch.example:8443' (default scheme+port) + * 'ch.example:9000' → 'https://ch.example:9000' (explicit port kept) + * 'http://ch.example:8123' → 'http://ch.example:8123' (explicit scheme kept) + * + * Defaults to HTTPS and ClickHouse's 8443 only for the bare-host case. Anything + * unparseable falls back to currentOrigin so we never POST somewhere bogus. + */ +export function resolveTarget(hostInput, currentOrigin) { + const raw = String(hostInput || '').trim(); + if (!raw) return currentOrigin; + // With an explicit scheme, trust it as-is; otherwise default to https and + // append :8443 when no port was given. + const urlStr = /^https?:\/\//i.test(raw) + ? raw + : 'https://' + (raw.includes(':') ? raw : raw + ':8443'); + try { + return new URL(urlStr).origin; + } catch { + return currentOrigin; + } +} diff --git a/src/main.js b/src/main.js index 091279b..fe23a3c 100644 --- a/src/main.js +++ b/src/main.js @@ -7,7 +7,6 @@ import { createApp } from './ui/app.js'; import { handleKeydown } from './ui/shortcuts.js'; import { exchangeCodeForTokens, bearerFromTokens } from './net/oauth.js'; import { decodeSqlFromHash } from './core/share.js'; -import { isTokenExpired } from './core/jwt.js'; export async function bootstrap(app, env) { const loc = env.location; @@ -61,10 +60,12 @@ export async function bootstrap(app, env) { hist.replaceState(null, '', loc.pathname + loc.search); } - if (app.token && !isTokenExpired(app.token, 0)) { + if (app.isSignedIn()) { + // Signed in either via a valid OAuth token or a restored basic session. ss.removeItem('oauth_shared_sql'); // consumed // Resolve config first so the header shows the real CH identity (the // ch_auth=basic username, not the raw email claim) on first paint. + // (ensureConfig is a no-op in basic mode.) await app.ensureConfig(); app.renderApp(); } else { diff --git a/src/net/oauth-config.js b/src/net/oauth-config.js index 199efa8..8275a53 100644 --- a/src/net/oauth-config.js +++ b/src/net/oauth-config.js @@ -47,9 +47,12 @@ function normalizeEntry(e) { } /** - * Fetch config.json and normalize to `{ idps: [descriptor, ...] }`. Accepts a - * list (`{ idps: [...] }`) or a single bare object (legacy) wrapped into one - * entry. Throws if no usable IdP is present. + * Fetch config.json and normalize to `{ idps: [descriptor, ...], basicLogin }`. + * Accepts a list (`{ idps: [...] }`) or a single bare object (legacy) wrapped + * into one entry. An IdP-less config (no `idps`, no `issuer`) is valid — it + * describes a credentials-only deployment, so `idps` comes back empty rather + * than throwing. `basicLogin` (top-level `basic_login`, default true) lets an + * SSO-only deployment hide the username/password path. * @param {(url: string, init?: object) => Promise} fetchFn * @param {string} basePath e.g. location.pathname ('/sql') */ @@ -58,9 +61,11 @@ export async function loadConfigDoc(fetchFn, basePath = '') { const cfgResp = await fetchFn(cfgUrl, { cache: 'no-store' }); if (!cfgResp.ok) throw new Error('GET ' + cfgUrl + ': ' + cfgResp.status); const cfg = await cfgResp.json(); - const list = Array.isArray(cfg.idps) ? cfg.idps : [cfg]; - if (!list.length) throw new Error('config.json has no IdPs'); - return { idps: list.map(normalizeEntry) }; + // A list, a legacy bare IdP object, or neither (credentials-only → no IdPs). + const list = Array.isArray(cfg.idps) + ? cfg.idps + : (cfg.issuer || cfg.client_id) ? [cfg] : []; + return { idps: list.map(normalizeEntry), basicLogin: cfg.basic_login !== false }; } /** diff --git a/src/styles.css b/src/styles.css index aadad08..358a993 100644 --- a/src/styles.css +++ b/src/styles.css @@ -80,19 +80,22 @@ body { .login-screen { flex: 1; display: flex; align-items: center; justify-content: center; + padding: 24px; background: - radial-gradient(circle at 30% 10%, rgba(0, 121, 173, .08), transparent 50%), - radial-gradient(circle at 80% 90%, rgba(146, 225, 216, .05), transparent 50%), + radial-gradient(700px 380px at 50% -8%, color-mix(in oklab, var(--accent) 13%, transparent), transparent 70%), var(--bg); } +.login-screen .mono { font-family: var(--mono); } .login-card { width: 380px; padding: 40px 36px 36px; background: var(--bg-header); border: 1px solid var(--border); border-radius: 12px; - box-shadow: 0 20px 60px rgba(0, 0, 0, .25); + box-shadow: 0 24px 70px rgba(0, 0, 0, .35); text-align: center; } +/* Wider, left-aligned card for the SSO + credentials layout. */ +.login-card-wide { width: 400px; max-width: 100%; padding: 30px 30px 22px; border-radius: 16px; text-align: left; } .login-logo { width: 48px; height: 48px; margin: 0 auto 14px; @@ -102,30 +105,91 @@ body { color: #fff; font-weight: 700; font-size: 22px; letter-spacing: -0.02em; } -.login-title { font-size: 20px; font-weight: 600; color: var(--fg); margin-bottom: 6px; } -.login-sub { font-size: 13px; color: var(--fg-mute); margin-bottom: 22px; } -.login-env { - display: inline-flex; align-items: center; gap: 6px; - padding: 4px 10px; background: var(--bg-chip); - border-radius: 12px; - font-family: var(--mono); font-size: 11px; color: var(--fg-mute); - margin-bottom: 24px; -} -.login-env::before { - content: ''; width: 6px; height: 6px; border-radius: 4px; - background: #22c55e; box-shadow: 0 0 6px #22c55e; +.login-card-wide .login-logo { width: 30px; height: 30px; margin: 0; border-radius: 8px; font-size: 15px; } + +.login-brand { display: flex; align-items: center; gap: 10px; margin-bottom: 22px; } +.login-brand-text { display: flex; flex-direction: column; line-height: 1.2; } +.login-brand-name { font-size: 14px; font-weight: 600; color: var(--fg); } +.login-brand-sub { font-size: 11px; color: var(--fg-faint); } +.login-h1 { font-size: 19px; font-weight: 600; letter-spacing: -.3px; color: var(--fg); margin-bottom: 4px; } +.login-sub { font-size: 12.5px; color: var(--fg-mute); margin-bottom: 20px; line-height: 1.5; } + +/* SSO section */ +.login-sso { display: flex; flex-direction: column; gap: 8px; } +.login-sso-note { + display: flex; align-items: center; justify-content: center; gap: 5px; + margin-top: 7px; font-size: 11px; color: var(--fg-faint); } -.login-actions { display: flex; flex-direction: column; gap: 8px; } +.login-sso-note .mono { color: var(--fg-mute); } + +/* Buttons (primary / ghost) */ .login-btn { - width: 100%; height: 40px; padding: 0 16px; - background: var(--accent); color: #fff; - border: none; border-radius: 6px; + width: 100%; height: 42px; padding: 0 16px; + display: flex; align-items: center; justify-content: center; gap: 9px; + border: 1px solid transparent; border-radius: 9px; font-family: inherit; font-size: 13.5px; font-weight: 600; - cursor: pointer; transition: filter .12s, transform .04s; + cursor: pointer; transition: background .14s, border-color .14s, filter .12s, opacity .14s, transform .04s; } -.login-btn:hover { filter: brightness(1.06); } +.login-btn.btn-primary { background: var(--accent); color: #fff; } +.login-btn.btn-primary:hover { filter: brightness(1.06); } +.login-btn.btn-ghost { background: transparent; color: var(--fg); border-color: var(--border); } +.login-btn.btn-ghost:hover { background: var(--bg-hover); } .login-btn:active { transform: translateY(0.5px); } -.login-btn:disabled { opacity: .5; cursor: wait; } +.login-btn:disabled { opacity: .45; cursor: not-allowed; } +.login-btn:disabled:active { transform: none; } + +/* Divider */ +.login-divider { display: flex; align-items: center; gap: 12px; margin: 18px 0; } +.login-divider::before, .login-divider::after { content: ''; flex: 1; height: 1px; background: var(--border-faint); } +.login-divider span { font-size: 10.5px; color: var(--fg-faint); text-transform: uppercase; letter-spacing: .07em; } + +/* Credential fields */ +.login-creds { display: flex; flex-direction: column; } +.login-field { display: flex; flex-direction: column; margin-bottom: 13px; } +.login-lbl { font-size: 11.5px; font-weight: 500; color: var(--fg-mute); margin-bottom: 6px; } +.login-input { + width: 100%; height: 38px; padding: 0 11px; + background: var(--bg-input); border: 1px solid var(--border); + border-radius: 8px; color: var(--fg); font-size: 13px; outline: none; + font-family: inherit; transition: border-color .12s, box-shadow .12s; +} +.login-input.mono { font-family: var(--mono); font-size: 12.5px; } +.login-input::placeholder { color: var(--fg-faint); } +.login-input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent) 22%, transparent); } +.login-input-wrap { position: relative; } +.login-input-wrap .login-input { padding-right: 38px; } +.login-eye { + position: absolute; right: 6px; top: 50%; transform: translateY(-50%); + width: 28px; height: 28px; border: none; background: transparent; + color: var(--fg-faint); cursor: pointer; border-radius: 6px; + display: flex; align-items: center; justify-content: center; +} +.login-eye:hover { background: var(--bg-hover); color: var(--fg-mute); } + +/* Advanced disclosure */ +.login-advanced { margin-bottom: 16px; } +.login-disc { + appearance: none; background: transparent; border: none; cursor: pointer; padding: 4px 0; + color: var(--fg-mute); font-family: inherit; font-size: 12px; font-weight: 500; + display: flex; align-items: center; gap: 6px; +} +.login-disc:hover { color: var(--fg); } +.login-disc-chev { display: flex; transition: transform .15s; } +.login-adv-field { margin-top: 10px; display: flex; flex-direction: column; } +.login-hint { font-size: 11px; color: var(--fg-faint); margin-top: 6px; line-height: 1.5; } + +/* Live target summary */ +.login-target { + margin-top: 14px; padding: 9px 11px; + background: var(--bg-input); border: 1px solid var(--border-faint); + border-radius: 8px; font-family: var(--mono); font-size: 11px; color: var(--fg-mute); + display: flex; align-items: center; gap: 7px; line-height: 1.4; +} +.login-target .lt-dot { width: 6px; height: 6px; border-radius: 3px; background: #22c55e; box-shadow: 0 0 6px #22c55e; flex-shrink: 0; } +.login-target .lt-key { color: var(--fg-faint); } +.login-target .lt-host { color: var(--fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.login-target .lt-as { color: var(--fg-faint); } + .login-error { margin-top: 14px; padding: 8px 12px; background: var(--error-bg); @@ -134,7 +198,12 @@ body { font-size: 12px; text-align: left; white-space: pre-wrap; word-break: break-word; } -.login-foot { margin-top: 18px; font-size: 11px; color: var(--fg-faint); font-family: var(--mono); } +.login-foot { + margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border-faint); + display: flex; align-items: center; font-size: 11px; color: var(--fg-faint); +} +.login-foot-link { display: flex; align-items: center; gap: 5px; color: var(--fg-mute); } +.login-foot-link:hover { color: var(--fg); } /* ------------ header ------------ */ .app-header { diff --git a/src/ui/app.js b/src/ui/app.js index db68e99..b002eaf 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -12,6 +12,7 @@ import { import { saveJSON, saveStr } from '../core/storage.js'; import { decodeJwtPayload, isTokenExpired } from '../core/jwt.js'; import { sqlString, inferQueryName, shortVersion, userShortName } from '../core/format.js'; +import { resolveTarget } from '../core/target.js'; import { buildExportDoc, parseImportDoc } from '../core/saved-io.js'; import { toTSV, toCSV } from '../core/export.js'; import { newResult, applyStreamLine } from '../core/stream.js'; @@ -47,6 +48,15 @@ export function createApp(env = {}) { refreshToken: ss.getItem('oauth_refresh_token'), }; + // Two ways to be signed in: OAuth (a JWT bearer, the default) or 'basic' — + // a ClickHouse username/password sent as Authorization: Basic, optionally + // against another host. A live basic session is restored from sessionStorage + // (ch_basic_*), mirroring how the OAuth token is restored above. + app.authMode = ss.getItem('ch_basic_auth') ? 'basic' : 'oauth'; + const basicCreds = () => ss.getItem('ch_basic_auth'); + const basicUser = () => ss.getItem('ch_basic_user') || ''; + const originHost = (o) => { try { return new URL(o).host; } catch { return ''; } }; + // config.json may list several IdPs. Fetch the doc once; resolve OIDC // discovery per selected IdP. The chosen IdP id is persisted so it survives // the OAuth redirect (like oauth_state) and drives token exchange/refresh. @@ -69,9 +79,13 @@ export function createApp(env = {}) { app.savePref = (name, value) => saveStr(KEYS[name], String(value)); // --- identity ---------------------------------------------------------- - app.host = () => loc.host || 'clickhouse'; + app.host = () => (app.authMode === 'basic' + ? originHost(chCtx.origin) || 'clickhouse' + : loc.host || 'clickhouse'); app.activeTab = () => activeTab(app.state); - app.isSignedIn = () => !!app.token && !isTokenExpired(app.token, 0); + app.isSignedIn = () => (app.authMode === 'basic' + ? !!basicCreds() + : !!app.token && !isTokenExpired(app.token, 0)); // The CH-facing identity for the current token — what currentUser() will be: // for ch_auth=basic it's the Basic username (honouring basicUserClaim); for // bearer it's the email the token-processor keys on. Shared by authHeader and @@ -81,7 +95,9 @@ export function createApp(env = {}) { || p.email || p.preferred_username || p.sub || ''; } app.chUsername = chUsername; - app.email = () => chUsername(decodeJwtPayload(app.token)); + app.email = () => (app.authMode === 'basic' + ? basicUser() + : chUsername(decodeJwtPayload(app.token))); function setTokens(id, refresh) { app.token = id; @@ -99,7 +115,10 @@ export function createApp(env = {}) { app.token = null; app.refreshToken = null; app.idpId = null; - ['oauth_id_token', 'oauth_refresh_token', 'oauth_verifier', 'oauth_state', 'oauth_idp'].forEach((k) => ss.removeItem(k)); + app.authMode = 'oauth'; + chCtx.origin = loc.origin; + ['oauth_id_token', 'oauth_refresh_token', 'oauth_verifier', 'oauth_state', 'oauth_idp', + 'ch_basic_auth', 'ch_basic_user', 'ch_basic_origin'].forEach((k) => ss.removeItem(k)); } app.setTokens = setTokens; app.clearTokens = clearTokens; @@ -124,6 +143,9 @@ export function createApp(env = {}) { } async function refresh() { + // Basic credentials don't expire and can't be refreshed; a surviving 401 + // means the password is wrong → authedFetch falls through to onSignedOut. + if (app.authMode === 'basic') return false; const cfg = await resolveConfig(); const tokens = await oauth.refreshTokens(fetchFn, cfg, app.refreshToken); const bearer = oauth.bearerFromTokens(tokens, cfg.bearer); @@ -133,6 +155,8 @@ export function createApp(env = {}) { } async function getToken() { + // In basic mode the stored credential is the "token" authedFetch carries. + if (app.authMode === 'basic') return basicCreds(); if (!app.token) return null; if (!isTokenExpired(app.token)) return app.token; if (await refresh()) return app.token; @@ -149,13 +173,17 @@ export function createApp(env = {}) { // default chain. Lets one IdP map to a CH username distinct from another's. app.basicUserClaim = ''; function authHeader(token) { + // Basic mode: `token` is already base64(user:pass) — send it verbatim. + if (app.authMode === 'basic') return 'Basic ' + token; if (app.chAuth !== 'basic') return 'Bearer ' + token; const user = chUsername(decodeJwtPayload(token)); return 'Basic ' + btoa(unescape(encodeURIComponent(user + ':' + token))); } const chCtx = { fetch: fetchFn, - origin: loc.origin, + // Where queries POST: the serving origin for OAuth, or the (possibly + // cross-origin) target chosen at credential sign-in for basic mode. + origin: app.authMode === 'basic' ? (ss.getItem('ch_basic_origin') || loc.origin) : loc.origin, getToken, refresh, authHeader, @@ -172,6 +200,8 @@ export function createApp(env = {}) { // Fail-soft: if config can't be loaded we keep the current mode (bearer) // rather than blocking the query. async function ensureConfig() { + // Basic mode needs no OAuth config — the auth scheme is fixed. + if (app.authMode === 'basic') return null; try { const cfg = await resolveConfig(); app.chAuth = cfg.chAuth; @@ -183,6 +213,33 @@ export function createApp(env = {}) { } app.ensureConfig = ensureConfig; + // --- credentials (HTTP Basic) sign-in ---------------------------------- + // Validate a ClickHouse username/password against `host` (blank → the serving + // host) with a probe query, then commit the session and enter the workbench. + // The probe uses a throwaway ctx so a bad password surfaces CH's own reason + // here (rejected as a thrown Error) instead of auto-rendering the login. + async function connect({ username, password, host }) { + const user = String(username || '').trim(); + const target = resolveTarget(host, loc.origin); + const creds = btoa(unescape(encodeURIComponent(user + ':' + (password || '')))); + const probeCtx = { + fetch: fetchFn, + origin: target, + getToken: async () => creds, + authHeader: () => 'Basic ' + creds, + refresh: async () => false, + onSignedOut: (detail) => { throw new Error(detail || 'Authentication failed'); }, + }; + await ch.queryJson(probeCtx, 'SELECT 1'); + // Probe passed → commit the session and switch the live ctx to the target. + app.authMode = 'basic'; + ss.setItem('ch_basic_auth', creds); + ss.setItem('ch_basic_user', user); + ss.setItem('ch_basic_origin', target); + chCtx.origin = target; + app.renderApp(); + } + // --- data loaders ------------------------------------------------------ app.loadVersion = async () => { try { @@ -557,6 +614,7 @@ export function createApp(env = {}) { closeTab: (id) => closeTab(app, id), loadIntoNewTab: (name, sql, savedId) => loadIntoNewTab(app, name, sql, savedId), login: (idpId) => login(idpId), + connect, share, copyResult, exportResult, diff --git a/src/ui/icons.js b/src/ui/icons.js index 05d62b3..765f358 100644 --- a/src/ui/icons.js +++ b/src/ui/icons.js @@ -62,6 +62,12 @@ export const Icon = { download: () => iconEl('', 12, 12), upload: () => iconEl('', 12, 12), logout: () => iconEl('', 12, 12), + // Login-screen glyphs (SSO shield, password eye, host server, connect arrow). + shield: () => iconEl('', 14, 14, 1.4), + eye: () => iconEl('', 14, 14, 1.4), + eyeOff: () => iconEl('', 14, 14, 1.4), + server: () => iconEl('', 12, 12, 1.3), + arrow: () => svg('M2 6h7.5M7 3.5L9.5 6 7 8.5', 12, 12, { stroke: 1.6 }), // Same glyph as the JSON view tab so the Format button's { } matches it. braces: () => svg('M4 1.5C2.5 1.5 2.5 3 2.5 4S2.5 5 1.5 6c1 1 1 2 1 2s0 1.5 1.5 1.5M8 1.5c1.5 0 1.5 1.5 1.5 2.5s0 1 1 2c-1 1-1 2-1 2s0 1.5-1.5 1.5', 12, 12), bookmark: () => iconEl('', 12, 12, 1.3), diff --git a/src/ui/login.js b/src/ui/login.js index ef668e4..d3fa739 100644 --- a/src/ui/login.js +++ b/src/ui/login.js @@ -1,61 +1,189 @@ -// The sign-in screen. With several configured IdPs it shows one button per -// provider; with a single IdP (or a legacy single-object config) it shows one -// plain "Sign in" button. +// The sign-in screen. Two auth paths, encoded directly in the UI: +// • SSO — the existing OAuth flow, bound to the serving host. One button per +// configured IdP (a single IdP shows one "Continue with SSO"). Hidden when +// no IdP is configured. +// • Credentials — a ClickHouse username/password (HTTP Basic), optionally +// against another host via the "Advanced" disclosure. Hidden when the +// deployment sets `basic_login: false`. +// When both username and password are non-empty the UI flips: Connect becomes +// the primary button and SSO demotes to a secondary outline — visually encoding +// "credentials are used instead of SSO". A live "Target" row always resolves the +// combined state (effective host + as / via SSO). import { h } from './dom.js'; - -// A sign-in button carrying the disable → "Redirecting…" → restore-on-error -// flow. `idpId` is undefined for the single-IdP default (login() picks the only -// one); otherwise it selects that provider. -function signInButton(app, label, idpId) { - return h('button', { - class: 'login-btn', - onclick: async (e) => { - const btn = e.currentTarget; - btn.disabled = true; - btn.textContent = 'Redirecting…'; - try { - await app.actions.login(idpId); - } catch (err) { - btn.disabled = false; - btn.textContent = label; - app.showLogin(String((err && err.message) || err)); - } - }, - }, label); -} +import { Icon } from './icons.js'; /** - * Render the login screen into `root`. `app` provides: - * host() — environment label - * actions.login(id?) — start the OAuth flow for IdP `id` (async, may throw) - * loadIdps() — resolve the configured IdP list (async) - * showLogin(msg) — re-render with an error message + * Render the login screen into `app.root`. `app` provides: + * host() — the serving host (where SSO authenticates) + * actions.login(id?) — start the OAuth flow for IdP `id` (async) + * actions.connect({...}) — credential sign-in; renders the app on success + * loadIdps() — resolve { idps, basicLogin } (async) + * showLogin(msg) — re-render with an error message */ export function renderLogin(app, errorMsg) { - const root = app.root; - const actions = h('div', { class: 'login-actions' }, signInButton(app, 'Sign in')); - root.replaceChildren( - h('div', { class: 'login-screen' }, - h('div', { class: 'login-card' }, - h('div', { class: 'login-logo' }, 'A'), - h('div', { class: 'login-title' }, 'Altinity SQL Browser'), - h('div', { class: 'login-sub' }, 'Sign in to continue'), - h('div', { class: 'login-env' }, app.host()), - actions, - errorMsg ? h('div', { class: 'login-error' }, errorMsg) : null, - h('div', { class: 'login-foot' }, 'OAuth · OIDC discovery'), - ), - ), - ); - // With multiple IdPs, swap the single button for one button per provider. - if (app.loadIdps) { - app.loadIdps().then(({ idps }) => { - if (idps && idps.length > 1) { - actions.replaceChildren( - ...idps.map((idp) => signInButton(app, 'Sign in with ' + idp.label, idp.id)), - ); - } - }).catch(() => { /* keep the single button; a click surfaces the config error */ }); + const cur = app.host(); + let busy = null; // 'sso' | 'creds' — guards against double-submit + let showPw = false; + let advOpen = false; + let ssoBtns = []; + + const hasCreds = () => userInput.value.trim().length > 0 && passInput.value.length > 0; + + // --- credential fields --- + const fld = (over) => h('input', { + class: 'login-input mono', type: 'text', spellcheck: 'false', autocomplete: 'off', + oninput: update, onkeydown: onCredsKey, ...over, + }); + const userInput = fld({ placeholder: 'default' }); + const passInput = fld({ type: 'password', placeholder: '••••••••' }); + const hostInput = fld({ placeholder: cur + ':8443' }); + + const eyeBtn = h('button', { + class: 'login-eye', type: 'button', tabindex: '-1', title: 'Show password', + onclick: () => { + showPw = !showPw; + passInput.type = showPw ? 'text' : 'password'; + eyeBtn.title = showPw ? 'Hide password' : 'Show password'; + eyeBtn.replaceChildren(showPw ? Icon.eyeOff() : Icon.eye()); + }, + }, Icon.eye()); + + // --- advanced (host override) --- + const advChev = h('span', { class: 'login-disc-chev' }, Icon.chevDown()); + const advField = h('div', { class: 'login-adv-field', style: { display: 'none' } }, + h('label', { class: 'login-lbl' }, 'Server address (host:port)'), + hostInput, + h('div', { class: 'login-hint' }, + 'Leave blank to use this server. A custom host applies to credential sign-in only — SSO always authenticates on ', + h('span', { class: 'mono' }, cur), '.')); + const advToggle = h('button', { + class: 'login-disc', type: 'button', + onclick: () => { + advOpen = !advOpen; + advField.style.display = advOpen ? '' : 'none'; + advChev.style.transform = advOpen ? 'rotate(0deg)' : 'rotate(-90deg)'; + }, + }, advChev, h('span', null, 'Advanced — connect to another server')); + advChev.style.transform = 'rotate(-90deg)'; + + // --- connect button + live target row --- + const connectBtn = h('button', { class: 'login-btn btn-ghost', disabled: true, onclick: doConnect }, + h('span', null, 'Connect'), Icon.arrow()); + const targetHostEl = h('span', { class: 'lt-host' }, cur); + const targetAsEl = h('span', { class: 'lt-as' }, 'via SSO'); + + // --- SSO section (populated async once the IdP list resolves) --- + const ssoSection = h('div', { class: 'login-sso' }); + const divider = h('div', { class: 'login-divider', style: { display: 'none' } }, + h('span', null, 'or use credentials')); + + const credSection = h('div', { class: 'login-creds' }, + divider, + h('div', { class: 'login-field' }, h('label', { class: 'login-lbl' }, 'Username'), userInput), + h('div', { class: 'login-field' }, + h('label', { class: 'login-lbl' }, 'Password'), + h('div', { class: 'login-input-wrap' }, passInput, eyeBtn)), + h('div', { class: 'login-advanced' }, advToggle, advField), + connectBtn, + h('div', { class: 'login-target' }, + h('span', { class: 'lt-dot' }), + h('span', { class: 'lt-key' }, 'Target'), + targetHostEl, + h('span', { style: { flex: '1' } }), + targetAsEl)); + + const card = h('div', { class: 'login-card login-card-wide' }, + h('div', { class: 'login-brand' }, + h('div', { class: 'login-logo' }, 'A'), + h('div', { class: 'login-brand-text' }, + h('div', { class: 'login-brand-name' }, 'Altinity SQL Browser'), + h('div', { class: 'login-brand-sub mono' }, 'ClickHouse query console'))), + h('div', { class: 'login-h1' }, 'Sign in'), + h('div', { class: 'login-sub' }, 'Use single sign-on for this server, or connect with ClickHouse credentials.'), + ssoSection, + credSection, + errorMsg ? h('div', { class: 'login-error' }, errorMsg) : null, + h('div', { class: 'login-foot' }, + h('a', { + class: 'login-foot-link', href: 'https://github.com/Altinity/altinity-sql-browser', + target: '_blank', rel: 'noopener noreferrer', + }, Icon.github(), h('span', null, 'Source')), + h('span', { style: { flex: '1' } }), + h('span', { class: 'mono login-foot-ver' }, 'OAuth · credentials'))); + + app.root.replaceChildren(h('div', { class: 'login-screen' }, card)); + update(); + + // Resolve the configured IdPs (and the basic_login flag) and reconcile which + // sections are shown. On failure keep credentials visible (fail-open — OAuth + // can't work without config anyway) and show no SSO. + app.loadIdps().then(({ idps, basicLogin }) => { + if (basicLogin === false) credSection.remove(); + populateSso(idps); + divider.style.display = (ssoBtns.length && basicLogin !== false) ? '' : 'none'; + update(); + }).catch(() => { /* no config → credentials only */ }); + + function populateSso(idps) { + ssoBtns = []; + if (!idps || !idps.length) return; + const mk = (idpId, label) => { + const b = h('button', { class: 'login-btn btn-primary', onclick: () => doSso(idpId, b) }, + Icon.shield(), h('span', null, label)); + ssoBtns.push(b); + return b; + }; + const btns = idps.length === 1 + ? [mk(idps[0].id, 'Continue with SSO')] + : idps.map((i) => mk(i.id, 'Continue with ' + i.label)); + ssoSection.replaceChildren( + ...btns, + h('div', { class: 'login-sso-note' }, + Icon.server(), h('span', null, 'Authenticates on '), h('span', { class: 'mono' }, cur))); + } + + // Keep the primary/secondary swap, Connect enablement, and target row in sync + // with the field values — updated in place so focus/caret are preserved. + function update() { + const has = hasCreds(); + connectBtn.classList.toggle('btn-primary', has); + connectBtn.classList.toggle('btn-ghost', !has); + connectBtn.disabled = !has || !!busy; + for (const b of ssoBtns) { + b.classList.toggle('btn-primary', !has); + b.classList.toggle('btn-ghost', has); + } + targetHostEl.textContent = hostInput.value.trim() || cur; + targetAsEl.textContent = has ? 'as ' + userInput.value.trim() : 'via SSO'; + } + + function onCredsKey(e) { if (e.key === 'Enter' && hasCreds()) doConnect(); } + + async function doConnect() { + if (busy || !hasCreds()) return; + busy = 'creds'; + connectBtn.disabled = true; + connectBtn.replaceChildren(h('span', null, 'Connecting…')); + try { + await app.actions.connect({ username: userInput.value, password: passInput.value, host: hostInput.value }); + // success → connect() renders the workbench, replacing this screen. + } catch (err) { + busy = null; + app.showLogin(String((err && err.message) || err)); + } + } + + async function doSso(idpId, btn) { + if (busy) return; + busy = 'sso'; + btn.disabled = true; + btn.replaceChildren(h('span', null, 'Redirecting…')); + try { + await app.actions.login(idpId); + } catch (err) { + busy = null; + app.showLogin(String((err && err.message) || err)); + } } } diff --git a/tests/helpers/fake-app.js b/tests/helpers/fake-app.js index ad6a5d5..eb781b5 100644 --- a/tests/helpers/fake-app.js +++ b/tests/helpers/fake-app.js @@ -24,6 +24,7 @@ export function makeApp(over = {}) { signOut: vi.fn(), loadVersion: vi.fn(), loadSchema: vi.fn(), + loadIdps: async () => ({ idps: [], basicLogin: true }), dom: { qtabsInner: document.createElement('div'), schemaList: document.createElement('div'), @@ -40,6 +41,7 @@ export function makeApp(over = {}) { closeTab: vi.fn(), loadIntoNewTab: vi.fn(), login: vi.fn(), + connect: vi.fn(), share: vi.fn(), copyResult: vi.fn(), exportResult: vi.fn(), diff --git a/tests/unit/app.test.js b/tests/unit/app.test.js index c0506e6..91ceffa 100644 --- a/tests/unit/app.test.js +++ b/tests/unit/app.test.js @@ -432,6 +432,93 @@ describe('auth flows', () => { }); }); +describe('credentials (basic) sign-in', () => { + const creds = btoa('demo:demo'); + const basicSession = { ch_basic_auth: creds, ch_basic_user: 'demo', ch_basic_origin: 'https://gh.example:8443' }; + + it('restores a basic session from sessionStorage', () => { + const app = createApp(env({ sessionStorage: memSession(basicSession) })); + expect(app.authMode).toBe('basic'); + expect(app.isSignedIn()).toBe(true); + expect(app.email()).toBe('demo'); + expect(app.host()).toBe('gh.example:8443'); + expect(app.chCtx.origin).toBe('https://gh.example:8443'); + }); + it('falls back to the serving origin when no stored target is present', () => { + const app = createApp(env({ sessionStorage: memSession({ ch_basic_auth: creds, ch_basic_user: 'demo' }) })); + expect(app.chCtx.origin).toBe('https://ch.example'); + }); + it('host falls back to "clickhouse" for an unparseable stored origin', () => { + const app = createApp(env({ sessionStorage: memSession({ ...basicSession, ch_basic_origin: 'not a url' }) })); + expect(app.host()).toBe('clickhouse'); + }); + it('basic ctx seams: getToken=creds, authHeader=Basic, refresh=false, ensureConfig=no-op', async () => { + const app = createApp(env({ sessionStorage: memSession(basicSession) })); + expect(await app.chCtx.getToken()).toBe(creds); + expect(app.chCtx.authHeader(creds)).toBe('Basic ' + creds); + expect(await app.chCtx.refresh()).toBe(false); + expect(await app.ensureConfig()).toBeNull(); + }); + it('queries carry the Basic header to the target origin', async () => { + const e = env({ + sessionStorage: memSession(basicSession), + fetch: makeFetch([[(u, sql) => /version\(\)/.test(sql), resp({ json: { data: [{ v: '26.3.1' }] } })]]), + }); + const app = createApp(e); + await app.loadVersion(); + const [url, init] = e.fetch.mock.calls[0]; + expect(url.startsWith('https://gh.example:8443')).toBe(true); + expect(init.headers.Authorization).toBe('Basic ' + creds); + }); + it('connect() probes SELECT 1, commits the session, renders the app (blank host → same origin)', async () => { + const e = env({ + sessionStorage: memSession({}), + fetch: makeFetch([[(u, sql) => /SELECT 1/.test(sql), resp({ json: { data: [{ '1': 1 }] } })]]), + }); + const app = createApp(e); + expect(app.authMode).toBe('oauth'); + await app.actions.connect({ username: 'demo', password: 'demo', host: '' }); + expect(app.authMode).toBe('basic'); + expect(e.sessionStorage.getItem('ch_basic_auth')).toBe(creds); + expect(e.sessionStorage.getItem('ch_basic_user')).toBe('demo'); + expect(e.sessionStorage.getItem('ch_basic_origin')).toBe('https://ch.example'); + expect(app.chCtx.origin).toBe('https://ch.example'); + expect(app.root.querySelector('.app-header')).not.toBeNull(); + const probe = e.fetch.mock.calls.find(([, init]) => init && init.body === 'SELECT 1'); + expect(probe[1].headers.Authorization).toBe('Basic ' + creds); + }); + it('connect() targets a custom host via resolveTarget', async () => { + const e = env({ + sessionStorage: memSession({}), + fetch: makeFetch([[(u, sql) => /SELECT 1/.test(sql), resp({ json: { data: [] } })]]), + }); + const app = createApp(e); + await app.actions.connect({ username: 'u', password: 'p', host: 'other.example:9000' }); + expect(app.chCtx.origin).toBe('https://other.example:9000'); + expect(e.sessionStorage.getItem('ch_basic_origin')).toBe('https://other.example:9000'); + }); + it('connect() rejects on bad credentials without committing a session', async () => { + const e = env({ + sessionStorage: memSession({}), + fetch: makeFetch([[(u, sql) => /SELECT 1/.test(sql), resp({ ok: false, status: 403, text: 'Code: 516. Authentication failed' })]]), + }); + const app = createApp(e); + await expect(app.actions.connect({ username: 'demo', password: 'wrong', host: '' })).rejects.toThrow(); + expect(app.authMode).toBe('oauth'); + expect(e.sessionStorage.getItem('ch_basic_auth')).toBeNull(); + }); + it('signing out of a basic session resets mode, origin, and stored creds', () => { + const e = env({ sessionStorage: memSession(basicSession) }); + const app = createApp(e); + app.renderApp(); + app.signOut(); + expect(app.authMode).toBe('oauth'); + expect(app.chCtx.origin).toBe('https://ch.example'); + expect(e.sessionStorage.getItem('ch_basic_auth')).toBeNull(); + expect(app.root.querySelector('.login-screen')).not.toBeNull(); + }); +}); + describe('share + star + columns', () => { it('share copies a link to the clipboard', async () => { const e = env({ window: { history: { replaceState: vi.fn() }, navigator: {} } }); diff --git a/tests/unit/login.test.js b/tests/unit/login.test.js index 0e1c48c..61a0ca3 100644 --- a/tests/unit/login.test.js +++ b/tests/unit/login.test.js @@ -2,79 +2,260 @@ import { describe, it, expect, vi } from 'vitest'; import { renderLogin } from '../../src/ui/login.js'; import { makeApp } from '../helpers/fake-app.js'; -describe('renderLogin', () => { - it('renders the card with host + no error', () => { - const app = makeApp(); +const tick = () => new Promise((r) => setTimeout(r, 0)); +const click = (el) => el.dispatchEvent(new Event('click', { bubbles: true })); +function type(input, value) { + input.value = value; + input.dispatchEvent(new Event('input')); +} +// makeApp defaults loadIdps → { idps: [], basicLogin: true }. Override per test. +function appWith(over = {}) { + const base = makeApp(); + return makeApp({ ...over, actions: { ...base.actions, ...(over.actions || {}) } }); +} + +describe('renderLogin — structure', () => { + it('renders brand, headings, credentials, target row, and footer', () => { + const app = appWith(); renderLogin(app); - expect(app.root.querySelector('.login-title').textContent).toContain('Altinity'); - expect(app.root.querySelector('.login-env').textContent).toBe('test.host'); + expect(app.root.querySelector('.login-brand-name').textContent).toContain('Altinity'); + expect(app.root.querySelector('.login-h1').textContent).toBe('Sign in'); + expect(app.root.querySelectorAll('.login-input')).toHaveLength(3); // user, pass, host + expect(app.root.querySelector('.login-target .lt-as').textContent).toBe('via SSO'); + expect(app.root.querySelector('.login-foot-link[href*="github.com"]')).not.toBeNull(); expect(app.root.querySelector('.login-error')).toBeNull(); }); - it('renders an error message when given', () => { - const app = makeApp(); + it('shows an error message when given', () => { + const app = appWith(); renderLogin(app, 'boom'); expect(app.root.querySelector('.login-error').textContent).toBe('boom'); }); - it('sign-in click calls login()', async () => { - const app = makeApp({ actions: { ...makeApp().actions, login: vi.fn(async () => {}) } }); + it('uses the host for the target row and the host placeholder', () => { + const app = appWith({ host: () => 'ch.demo' }); + renderLogin(app); + expect(app.root.querySelector('.login-target .lt-host').textContent).toBe('ch.demo'); + const hostInput = app.root.querySelectorAll('.login-input')[2]; + expect(hostInput.getAttribute('placeholder')).toBe('ch.demo:8443'); + }); +}); + +describe('renderLogin — SSO section', () => { + it('no IdPs → no SSO button, divider hidden, credentials present', async () => { + const app = appWith({ loadIdps: async () => ({ idps: [], basicLogin: true }) }); + renderLogin(app); + await tick(); + expect(app.root.querySelectorAll('.login-sso .login-btn')).toHaveLength(0); + expect(app.root.querySelector('.login-divider').style.display).toBe('none'); + expect(app.root.querySelector('.login-creds')).not.toBeNull(); + }); + it('one IdP → a single "Continue with SSO" button + divider shown', async () => { + const app = appWith({ loadIdps: async () => ({ idps: [{ id: 'g', label: 'Google' }], basicLogin: true }) }); + renderLogin(app); + await tick(); + const btns = [...app.root.querySelectorAll('.login-sso .login-btn')]; + expect(btns.map((b) => b.textContent)).toEqual(['Continue with SSO']); + expect(app.root.querySelector('.login-divider').style.display).toBe(''); + expect(app.root.querySelector('.login-sso-note').textContent).toContain('Authenticates on'); + }); + it('multiple IdPs → one button per provider', async () => { + const app = appWith({ loadIdps: async () => ({ idps: [{ id: 'g', label: 'Google' }, { id: 'a', label: 'Acme' }], basicLogin: true }) }); + renderLogin(app); + await tick(); + const btns = [...app.root.querySelectorAll('.login-sso .login-btn')]; + expect(btns.map((b) => b.textContent)).toEqual(['Continue with Google', 'Continue with Acme']); + }); + it('basic_login:false removes the credentials section', async () => { + const app = appWith({ loadIdps: async () => ({ idps: [{ id: 'g', label: 'Google' }], basicLogin: false }) }); + renderLogin(app); + await tick(); + expect(app.root.querySelector('.login-creds')).toBeNull(); + expect(app.root.querySelectorAll('.login-sso .login-btn')).toHaveLength(1); + }); + it('config load failure keeps credentials and shows no SSO', async () => { + const app = appWith({ loadIdps: async () => { throw new Error('no config'); } }); + renderLogin(app); + await tick(); + expect(app.root.querySelector('.login-creds')).not.toBeNull(); + expect(app.root.querySelectorAll('.login-sso .login-btn')).toHaveLength(0); + }); +}); + +describe('renderLogin — credentials reactivity', () => { + it('typing both fields flips Connect to primary and demotes SSO to ghost', async () => { + const app = appWith({ loadIdps: async () => ({ idps: [{ id: 'g', label: 'Google' }], basicLogin: true }) }); + renderLogin(app); + await tick(); + const [user, pass] = app.root.querySelectorAll('.login-input'); + const connect = app.root.querySelector('.login-creds .login-btn'); + const sso = app.root.querySelector('.login-sso .login-btn'); + expect(connect.classList.contains('btn-ghost')).toBe(true); + expect(connect.disabled).toBe(true); + type(user, 'default'); + type(pass, 'secret'); + expect(connect.classList.contains('btn-primary')).toBe(true); + expect(connect.disabled).toBe(false); + expect(sso.classList.contains('btn-ghost')).toBe(true); + expect(app.root.querySelector('.lt-as').textContent).toBe('as default'); + }); + it('the host field drives the target host', () => { + const app = appWith(); + renderLogin(app); + const host = app.root.querySelectorAll('.login-input')[2]; + type(host, 'other:9000'); + expect(app.root.querySelector('.lt-host').textContent).toBe('other:9000'); + }); + it('password show/hide toggles the input type', () => { + const app = appWith(); + renderLogin(app); + const pass = app.root.querySelectorAll('.login-input')[1]; + const eye = app.root.querySelector('.login-eye'); + expect(pass.type).toBe('password'); + click(eye); + expect(pass.type).toBe('text'); + expect(eye.title).toBe('Hide password'); + click(eye); + expect(pass.type).toBe('password'); + }); + it('advanced disclosure toggles the host field', () => { + const app = appWith(); renderLogin(app); - const btn = app.root.querySelector('.login-btn'); - btn.dispatchEvent(new Event('click')); - await Promise.resolve(); - expect(app.actions.login).toHaveBeenCalled(); - expect(btn.textContent).toBe('Redirecting…'); + const advField = app.root.querySelector('.login-adv-field'); + const toggle = app.root.querySelector('.login-disc'); + expect(advField.style.display).toBe('none'); + click(toggle); + expect(advField.style.display).toBe(''); + click(toggle); + expect(advField.style.display).toBe('none'); }); - const tick = () => new Promise((r) => setTimeout(r, 0)); +}); - it('sign-in failure restores button + shows error', async () => { +describe('renderLogin — connect flow', () => { + it('Connect calls actions.connect with the field values', async () => { + const connect = vi.fn(async () => {}); + const app = appWith({ actions: { connect } }); + renderLogin(app); + const [user, pass, host] = app.root.querySelectorAll('.login-input'); + type(user, ' default '); + type(pass, 'pw'); + type(host, 'h:1'); + click(app.root.querySelector('.login-creds .login-btn')); + await tick(); + expect(connect).toHaveBeenCalledWith({ username: ' default ', password: 'pw', host: 'h:1' }); + }); + it('Enter in a field submits when both credentials are present', async () => { + const connect = vi.fn(async () => {}); + const app = appWith({ actions: { connect } }); + renderLogin(app); + const [user, pass] = app.root.querySelectorAll('.login-input'); + type(user, 'u'); type(pass, 'p'); + pass.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + await tick(); + expect(connect).toHaveBeenCalled(); + }); + it('Enter without both credentials does nothing; other keys ignored', async () => { + const connect = vi.fn(async () => {}); + const app = appWith({ actions: { connect } }); + renderLogin(app); + const [user] = app.root.querySelectorAll('.login-input'); + type(user, 'only-user'); + user.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + user.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })); + await tick(); + expect(connect).not.toHaveBeenCalled(); + }); + it('clicking Connect with empty fields is a no-op', async () => { + const connect = vi.fn(async () => {}); + const app = appWith({ actions: { connect } }); + renderLogin(app); + click(app.root.querySelector('.login-creds .login-btn')); + await tick(); + expect(connect).not.toHaveBeenCalled(); + }); + it('connect failure surfaces the error via showLogin', async () => { const showLogin = vi.fn(); - const login = vi.fn(async () => { throw new Error('nope'); }); - const app = makeApp({ showLogin, actions: { ...makeApp().actions, login } }); + const connect = vi.fn(async () => { throw new Error('wrong password'); }); + const app = appWith({ showLogin, actions: { connect } }); renderLogin(app); - const btn = app.root.querySelector('.login-btn'); - btn.dispatchEvent(new Event('click')); + const [user, pass] = app.root.querySelectorAll('.login-input'); + type(user, 'u'); type(pass, 'bad'); + click(app.root.querySelector('.login-creds .login-btn')); await tick(); - expect(showLogin).toHaveBeenCalledWith('nope'); - expect(btn.disabled).toBe(false); - expect(btn.textContent).toBe('Sign in'); + expect(showLogin).toHaveBeenCalledWith('wrong password'); }); - it('failure with a non-Error value stringifies it', async () => { + it('connect failure with a non-Error value stringifies it', async () => { const showLogin = vi.fn(); - const login = vi.fn(async () => { throw 'rawstr'; }); - const app = makeApp({ showLogin, actions: { ...makeApp().actions, login } }); + const connect = vi.fn(async () => { throw 'nope'; }); + const app = appWith({ showLogin, actions: { connect } }); renderLogin(app); - const btn = app.root.querySelector('.login-btn'); - btn.dispatchEvent(new Event('click')); + const [user, pass] = app.root.querySelectorAll('.login-input'); + type(user, 'u'); type(pass, 'p'); + click(app.root.querySelector('.login-creds .login-btn')); await tick(); - expect(showLogin).toHaveBeenCalledWith('rawstr'); + expect(showLogin).toHaveBeenCalledWith('nope'); }); + it('ignores a second Connect while one is in flight', async () => { + let resolve; + const connect = vi.fn(() => new Promise((r) => { resolve = r; })); + const app = appWith({ actions: { connect } }); + renderLogin(app); + const [user, pass] = app.root.querySelectorAll('.login-input'); + type(user, 'u'); type(pass, 'p'); + const btn = app.root.querySelector('.login-creds .login-btn'); + click(btn); + expect(btn.textContent).toBe('Connecting…'); + click(btn); // busy → ignored + expect(connect).toHaveBeenCalledTimes(1); + resolve(); + await tick(); + }); +}); - it('multiple IdPs → one button per provider, clicking passes the IdP id', async () => { +describe('renderLogin — SSO flow', () => { + function ssoApp(over = {}) { + return appWith({ loadIdps: async () => ({ idps: [{ id: 'g', label: 'Google' }], basicLogin: true }), ...over }); + } + it('clicking SSO calls login(id) and shows Redirecting…', async () => { const login = vi.fn(async () => {}); - const app = makeApp({ - actions: { ...makeApp().actions, login }, - loadIdps: async () => ({ idps: [{ id: 'g', label: 'Google' }, { id: 'a', label: 'Acme SSO' }] }), - }); + const app = ssoApp({ actions: { login } }); + renderLogin(app); + await tick(); + const sso = app.root.querySelector('.login-sso .login-btn'); + click(sso); + expect(sso.textContent).toBe('Redirecting…'); + await tick(); + expect(login).toHaveBeenCalledWith('g'); + }); + it('SSO failure surfaces the error via showLogin', async () => { + const showLogin = vi.fn(); + const login = vi.fn(async () => { throw new Error('redirect failed'); }); + const app = ssoApp({ showLogin, actions: { login } }); renderLogin(app); await tick(); - const btns = [...app.root.querySelectorAll('.login-btn')]; - expect(btns.map((b) => b.textContent)).toEqual(['Sign in with Google', 'Sign in with Acme SSO']); - btns[1].dispatchEvent(new Event('click')); + click(app.root.querySelector('.login-sso .login-btn')); await tick(); - expect(login).toHaveBeenCalledWith('a'); + expect(showLogin).toHaveBeenCalledWith('redirect failed'); }); - it('a single IdP keeps the lone Sign in button', async () => { - const app = makeApp({ loadIdps: async () => ({ idps: [{ id: 'g', label: 'Google' }] }) }); + it('SSO failure with a non-Error value stringifies it', async () => { + const showLogin = vi.fn(); + const login = vi.fn(async () => { throw 'sso-raw'; }); + const app = ssoApp({ showLogin, actions: { login } }); renderLogin(app); await tick(); - const btns = [...app.root.querySelectorAll('.login-btn')]; - expect(btns).toHaveLength(1); - expect(btns[0].textContent).toBe('Sign in'); + click(app.root.querySelector('.login-sso .login-btn')); + await tick(); + expect(showLogin).toHaveBeenCalledWith('sso-raw'); }); - it('keeps the single button when the IdP list fails to load', async () => { - const app = makeApp({ loadIdps: async () => { throw new Error('no config'); } }); + it('ignores a second SSO click while one is in flight', async () => { + let resolve; + const login = vi.fn(() => new Promise((r) => { resolve = r; })); + const app = ssoApp({ actions: { login } }); renderLogin(app); await tick(); - expect(app.root.querySelectorAll('.login-btn')).toHaveLength(1); + const sso = app.root.querySelector('.login-sso .login-btn'); + click(sso); + click(sso); // busy → ignored + expect(login).toHaveBeenCalledTimes(1); + resolve(); + await tick(); }); }); diff --git a/tests/unit/main.test.js b/tests/unit/main.test.js index 61002c4..b369f21 100644 --- a/tests/unit/main.test.js +++ b/tests/unit/main.test.js @@ -16,7 +16,9 @@ function fakeApp(over = {}) { setTokens: vi.fn(function (id) { this.token = id; }), renderApp: vi.fn(), showLogin: vi.fn(), - isSignedIn: vi.fn(() => false), + // Default mirrors the real controller: signed in iff a token is held. + // Tests that exercise a basic session override this directly. + isSignedIn() { return !!this.token; }, ...over, }; } @@ -45,6 +47,15 @@ describe('bootstrap', () => { expect(app.renderApp).toHaveBeenCalled(); }); + it('renders the app for a restored basic session (no token)', async () => { + // A credentials session has no OAuth token; isSignedIn() carries it. + const app = fakeApp({ token: null, isSignedIn: () => true }); + const out = await bootstrap(app, fakeEnv()); + expect(app.ensureConfig).toHaveBeenCalled(); + expect(app.renderApp).toHaveBeenCalled(); + expect(out.signedIn).toBe(true); + }); + it('exchanges the OAuth code on a valid callback', async () => { const app = fakeApp(); const env = fakeEnv({ diff --git a/tests/unit/oauth-config.test.js b/tests/unit/oauth-config.test.js index 11fc586..3f60acd 100644 --- a/tests/unit/oauth-config.test.js +++ b/tests/unit/oauth-config.test.js @@ -84,8 +84,17 @@ describe('loadConfigDoc', () => { it('throws when an IdP lacks issuer/client_id', async () => { await expect(docOf({ issuer: 'x' })).rejects.toThrow('missing issuer or client_id'); }); - it('throws when the idps list is empty', async () => { - await expect(docOf({ idps: [] })).rejects.toThrow('no IdPs'); + it('returns no IdPs for an empty list (credentials-only deployment)', async () => { + expect(await docOf({ idps: [] })).toEqual([]); + }); + it('returns no IdPs for an IdP-less config (no idps, no issuer)', async () => { + expect(await docOf({ basic_login: true })).toEqual([]); + }); + it('defaults basicLogin to true and honours an explicit false', async () => { + const load = (body) => loadConfigDoc(fetcher([[/config\.json$/, resp(true, body)]]), '/sql'); + expect((await load({ idps: [] })).basicLogin).toBe(true); + expect((await load({ basic_login: false, idps: [] })).basicLogin).toBe(false); + expect((await load({ issuer: 'https://i', client_id: 'c' })).basicLogin).toBe(true); }); }); diff --git a/tests/unit/target.test.js b/tests/unit/target.test.js new file mode 100644 index 0000000..149a00c --- /dev/null +++ b/tests/unit/target.test.js @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import { resolveTarget } from '../../src/core/target.js'; + +const ORIGIN = 'https://serving.example'; + +describe('resolveTarget', () => { + it('blank input → the serving origin (same-origin)', () => { + expect(resolveTarget('', ORIGIN)).toBe(ORIGIN); + expect(resolveTarget(' ', ORIGIN)).toBe(ORIGIN); + expect(resolveTarget(null, ORIGIN)).toBe(ORIGIN); + expect(resolveTarget(undefined, ORIGIN)).toBe(ORIGIN); + }); + it('bare host → https + default :8443', () => { + expect(resolveTarget('ch.example', ORIGIN)).toBe('https://ch.example:8443'); + }); + it('host:port → https, explicit port kept', () => { + expect(resolveTarget('ch.example:9000', ORIGIN)).toBe('https://ch.example:9000'); + }); + it('explicit scheme is honoured (and no default port added)', () => { + expect(resolveTarget('http://ch.example:8123', ORIGIN)).toBe('http://ch.example:8123'); + expect(resolveTarget('https://ch.example', ORIGIN)).toBe('https://ch.example'); + }); + it('trims surrounding whitespace before parsing', () => { + expect(resolveTarget(' ch.example:9000 ', ORIGIN)).toBe('https://ch.example:9000'); + }); + it('strips any path/query, returning just the origin', () => { + expect(resolveTarget('ch.example:9000/foo?x=1', ORIGIN)).toBe('https://ch.example:9000'); + }); + it('unparseable input falls back to the serving origin', () => { + expect(resolveTarget('::::', ORIGIN)).toBe(ORIGIN); + }); +});