web: start: login et logout

Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
This commit is contained in:
GnomeZworc 2026-05-30 00:00:33 +02:00
commit 027ac11870
Signed by: nicolas.boufideline
GPG key ID: 4406BBBF8845D632
7 changed files with 330 additions and 17 deletions

View file

@ -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"
]

View file

@ -1,6 +1,7 @@
// API service component.
//
// Declare once in the shell:
// Declare after login-gate in the shell:
// <login-gate></login-gate>
// <api-client></api-client>
//
// 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 <login-gate>.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) {

View file

@ -0,0 +1,211 @@
// Auth service component.
//
// Declare before api-client in the shell:
// <login-gate></login-gate>
//
// Exposes:
// gate.ready → Promise<credentials> — 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 <oidc-gate> 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 = `
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
.overlay {
position: fixed;
inset: 0;
background: #181825;
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.card {
background: #1e1e2e;
border: 1px solid #313244;
border-radius: 12px;
padding: 36px 40px;
width: 100%;
max-width: 420px;
display: flex;
flex-direction: column;
gap: 24px;
}
.header {
display: flex;
flex-direction: column;
gap: 6px;
}
.header h1 {
font-size: 20px;
font-weight: 600;
color: #89b4fa;
letter-spacing: 0.02em;
}
.header p {
font-size: 13px;
color: #6c7086;
}
.fields {
display: flex;
flex-direction: column;
gap: 14px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
label {
font-size: 12px;
font-weight: 500;
color: #a6adc8;
text-transform: uppercase;
letter-spacing: 0.06em;
}
input {
background: #181825;
border: 1px solid #313244;
border-radius: 6px;
padding: 9px 12px;
color: #cdd6f4;
font-size: 14px;
font-family: monospace;
outline: none;
transition: border-color 0.15s;
width: 100%;
}
input:focus {
border-color: #89b4fa;
}
input::placeholder {
color: #45475a;
}
button {
background: #89b4fa;
color: #1e1e2e;
border: none;
border-radius: 6px;
padding: 10px 16px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
width: 100%;
}
button:hover { background: #b4d0fa; }
</style>
<div class="overlay">
<div class="card">
<div class="header">
<h1>two connect</h1>
<p>Enter your Netbox and agent details to continue.</p>
</div>
<form class="fields" id="form" autocomplete="on">
<div class="field">
<label>Netbox URL</label>
<input id="netbox_url" type="url" placeholder="http://netbox.local"
value="${defaults.netbox_url ?? ''}" required />
</div>
<div class="field">
<label>Netbox Token</label>
<input id="netbox_token" type="password" placeholder="your-api-token"
value="${defaults.netbox_token ?? ''}" required />
</div>
<div class="field">
<label>Agent URL</label>
<input id="agent_url" type="url" placeholder="http://127.0.0.1:8080"
value="${defaults.agent_url ?? ''}" required />
</div>
<button type="submit">Connect</button>
</form>
</div>
</div>
`
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)

View file

@ -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."
}

View file

@ -0,0 +1,75 @@
// Logout button — drop anywhere in the DOM.
//
// <logout-button></logout-button> full button with label
// <logout-button compact></logout-button> icon only (for tight spaces)
//
// Delegates to <login-gate>.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 = `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>`
class LogoutButton extends HTMLElement {
connectedCallback() {
this.attachShadow({ mode: 'open' })
const compact = this.hasAttribute('compact')
this.shadowRoot.innerHTML = `
<style>
:host { display: inline-flex; }
button {
display: inline-flex;
align-items: center;
gap: 7px;
background: transparent;
border: 1px solid #313244;
border-radius: 6px;
padding: ${compact ? '7px' : '7px 12px'};
color: #a6adc8;
font-size: 13px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
cursor: pointer;
transition: background 0.12s, color 0.12s, border-color 0.12s;
}
button:hover {
background: #313244;
color: #f38ba8;
border-color: #f38ba8;
}
.icon { display: flex; align-items: center; }
.label { ${compact ? 'display: none;' : ''} }
</style>
<button type="button" title="Log out">
<span class="icon">${ICON}</span>
<span class="label">Log out</span>
</button>
`
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)

View file

@ -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."
}

View file

@ -39,6 +39,10 @@
color: #6c7086;
}
header .spacer {
flex: 1;
}
.layout {
display: flex;
flex: 1;
@ -70,13 +74,16 @@
</head>
<body>
<login-gate></login-gate>
<api-client></api-client>
<header>
<h1>two</h1>
<span>network orchestrator</span>
<div class="spacer"></div>
<logout-button></logout-button>
</header>
<api-client></api-client>
<div class="layout">
<side-nav></side-nav>