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 = `
+
+
+
+ `
+
+ 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
+
+
-
-