diff --git a/web/components.json b/web/components.json index 6dae454..4d6c2ee 100644 --- a/web/components.json +++ b/web/components.json @@ -1,5 +1,7 @@ [ + "components/login-gate/login-gate.js", "components/api-client/api-client.js", "components/side-nav/side-nav.js", + "components/logout-button/logout-button.js", "components/date-display/date-display.js" ] diff --git a/web/components/api-client/api-client.js b/web/components/api-client/api-client.js index d9eab75..15dfc3e 100644 --- a/web/components/api-client/api-client.js +++ b/web/components/api-client/api-client.js @@ -1,6 +1,7 @@ // API service component. // -// Declare once in the shell: +// Declare after login-gate in the shell: +// // // // Use from any other component: @@ -11,10 +12,8 @@ // await api.agent.delete('/vpcs/vp-admin') // await api.agent.waitFor('/vms/i-test1', 'started') // -// Phase 2: swap this component for one that points to the orchestrator. -// No other component changes. - -import { config } from '../../core/config.js' +// Credentials come from .ready — no direct config.json dependency. +// Phase 2: swap login-gate for oidc-gate → this component unchanged. export class ApiError extends Error { constructor(origin, status, message) { @@ -25,12 +24,23 @@ export class ApiError extends Error { } class ApiClient extends HTMLElement { - connectedCallback() { + // Resolves once login-gate is ready (or immediately if no gate in DOM). + #credentials = null + + async connectedCallback() { this.style.display = 'none' + + const gate = document.querySelector('login-gate') + this.#credentials = gate ? await gate.ready : null + this.netbox = this.#buildNetbox() this.agent = this.#buildAgent() } + get creds() { + return this.#credentials ?? {} + } + // ── Internal ────────────────────────────────────────────────────────────── async #request(origin, url, options = {}) { @@ -59,12 +69,12 @@ class ApiClient extends HTMLElement { #buildNetbox() { const headers = () => ({ - 'Authorization': `Token ${config.netbox_token}`, + 'Authorization': `Token ${this.creds.netbox_token}`, 'Accept': 'application/json', }) const url = (path, params = {}) => { - const u = new URL(path, config.netbox_url + '/api/') + const u = new URL(path, this.creds.netbox_url + '/api/') Object.entries(params).forEach(([k, v]) => { if (v != null) u.searchParams.set(k, v) }) @@ -72,7 +82,6 @@ class ApiClient extends HTMLElement { } return { - // Returns results array. Netbox paginates; limit=1000 covers most cases. list: (path, params = {}) => this.#request('netbox', url(path, { limit: 1000, ...params }), { headers: headers() }) .then(d => d?.results ?? []), @@ -90,7 +99,7 @@ class ApiClient extends HTMLElement { ...(body ? { 'Content-Type': 'application/json' } : {}), }) - const url = path => new URL(path, config.agent_url + '/').toString() + const url = path => new URL(path, this.creds.agent_url + '/').toString() const req = (method, path, body) => this.#request('agent', url(path), { @@ -100,12 +109,11 @@ class ApiClient extends HTMLElement { }) return { - get: path => req('GET', path), - list: path => req('GET', path), - post: (path, b) => req('POST', path, b), - delete: path => req('DELETE', path), + get: path => req('GET', path), + list: path => req('GET', path), + post: (path, b) => req('POST', path, b), + delete: path => req('DELETE', path), - // Poll path until resource.state === desired or timeout. waitFor: async (path, desired, { timeout = 120_000, interval = 2_000 } = {}) => { const deadline = Date.now() + timeout while (Date.now() < deadline) { diff --git a/web/components/login-gate/login-gate.js b/web/components/login-gate/login-gate.js new file mode 100644 index 0000000..247fa06 --- /dev/null +++ b/web/components/login-gate/login-gate.js @@ -0,0 +1,211 @@ +// Auth service component. +// +// Declare before api-client in the shell: +// +// +// Exposes: +// gate.ready → Promise — awaited by api-client before any call +// gate.credentials → current credentials or null +// gate.logout() → clears session and reloads +// +// Credentials shape (extensible for Phase 2): +// { +// netbox_url: string, +// netbox_token: string, +// agent_url: string, +// } +// +// Phase 2: swap for that resolves ready with a JWT — api-client unchanged. + +const SESSION_KEY = 'two:credentials' + +class LoginGate extends HTMLElement { + #resolve = null + + get credentials() { + const raw = sessionStorage.getItem(SESSION_KEY) + return raw ? JSON.parse(raw) : null + } + + logout() { + sessionStorage.removeItem(SESSION_KEY) + window.location.reload() + } + + async connectedCallback() { + this.style.display = 'none' + + this.ready = new Promise(resolve => { this.#resolve = resolve }) + + const stored = this.credentials + if (stored) { + this.#resolve(stored) + return + } + + // Load config.json defaults to pre-fill the form + let defaults = {} + try { + const r = await fetch('./config.json') + if (r.ok) defaults = await r.json() + } catch { /* no defaults */ } + + this.#renderOverlay(defaults) + } + + #renderOverlay(defaults) { + const overlay = document.createElement('div') + overlay.attachShadow({ mode: 'open' }) + overlay.shadowRoot.innerHTML = ` + + +
+
+
+

two — connect

+

Enter your Netbox and agent details to continue.

+
+ +
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+
+ ` + + document.body.appendChild(overlay) + + const form = overlay.shadowRoot.getElementById('form') + + form.addEventListener('submit', e => { + e.preventDefault() + + const credentials = { + netbox_url: overlay.shadowRoot.getElementById('netbox_url').value.replace(/\/$/, ''), + netbox_token: overlay.shadowRoot.getElementById('netbox_token').value.trim(), + agent_url: overlay.shadowRoot.getElementById('agent_url').value.replace(/\/$/, ''), + } + + sessionStorage.setItem(SESSION_KEY, JSON.stringify(credentials)) + overlay.remove() + this.#resolve(credentials) + }) + } +} + +customElements.define('login-gate', LoginGate) diff --git a/web/components/login-gate/manifest.json b/web/components/login-gate/manifest.json new file mode 100644 index 0000000..0dfe4cc --- /dev/null +++ b/web/components/login-gate/manifest.json @@ -0,0 +1,5 @@ +{ + "tag": "login-gate", + "version": "0.1.0", + "description": "Auth service component. Exposes a 'ready' Promise that resolves with credentials. Shows a login overlay if no session exists." +} diff --git a/web/components/logout-button/logout-button.js b/web/components/logout-button/logout-button.js new file mode 100644 index 0000000..45aa80f --- /dev/null +++ b/web/components/logout-button/logout-button.js @@ -0,0 +1,75 @@ +// Logout button — drop anywhere in the DOM. +// +// full button with label +// icon only (for tight spaces) +// +// Delegates to .logout(). Works regardless of where it sits +// (header, members panel, dropdown…) since it resolves the gate from the document. +// +// Phase 2: if login-gate is swapped for oidc-gate, this still works as long as +// the auth component exposes a logout() method (see #gate()). + +const ICON = `` + +class LogoutButton extends HTMLElement { + connectedCallback() { + this.attachShadow({ mode: 'open' }) + + const compact = this.hasAttribute('compact') + + this.shadowRoot.innerHTML = ` + + + + ` + + this.shadowRoot.querySelector('button') + .addEventListener('click', () => this.#logout()) + } + + // Resolves the auth component. Today: login-gate. Tomorrow: any element + // exposing logout() (oidc-gate, oauth-gate…). + #gate() { + return document.querySelector('login-gate, [data-auth-gate]') + } + + #logout() { + const gate = this.#gate() + if (gate?.logout) { + gate.logout() + } else { + console.warn('logout-button: no auth gate with logout() found in document') + } + } +} + +customElements.define('logout-button', LogoutButton) diff --git a/web/components/logout-button/manifest.json b/web/components/logout-button/manifest.json new file mode 100644 index 0000000..0abc50b --- /dev/null +++ b/web/components/logout-button/manifest.json @@ -0,0 +1,5 @@ +{ + "tag": "logout-button", + "version": "0.1.0", + "description": "Drop-anywhere button that clears the session via login-gate.logout(). Reusable in header, members panel, etc." +} diff --git a/web/index.html b/web/index.html index a9301aa..5d82bdc 100644 --- a/web/index.html +++ b/web/index.html @@ -39,6 +39,10 @@ color: #6c7086; } + header .spacer { + flex: 1; + } + .layout { display: flex; flex: 1; @@ -70,13 +74,16 @@ + + +

two

network orchestrator +
+
- -