Compare commits
2 commits
cf8661ff7e
...
d1ef83e949
| Author | SHA1 | Date | |
|---|---|---|---|
|
d1ef83e949 |
|||
|
027ac11870 |
10 changed files with 536 additions and 17 deletions
177
web/build.sh
Executable file
177
web/build.sh
Executable file
|
|
@ -0,0 +1,177 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build the web UI component set from components.yml.
|
||||||
|
#
|
||||||
|
# ./build.sh build (download remote components + generate components.json)
|
||||||
|
# ./build.sh --check validate manifest and entry files without downloading
|
||||||
|
#
|
||||||
|
# Reads components.yml, downloads each remote component into components/<name>/
|
||||||
|
# at the requested ref, then generates components.json (runtime load list) and
|
||||||
|
# components.lock.json (resolved commit pins).
|
||||||
|
#
|
||||||
|
# Dependencies: yq (mikefarah/yq v4), jq, curl
|
||||||
|
# Optional env:
|
||||||
|
# GIT_TOKEN forge token for private repos (sent as "Authorization: token …")
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
WEB_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
MANIFEST="${WEB_DIR}/components.yml"
|
||||||
|
COMPONENTS_DIR="${WEB_DIR}/components"
|
||||||
|
OUTPUT="${WEB_DIR}/components.json"
|
||||||
|
LOCKFILE="${WEB_DIR}/components.lock.json"
|
||||||
|
GIT_TOKEN="${GIT_TOKEN:-}"
|
||||||
|
|
||||||
|
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
|
||||||
|
log() { echo -e "${GREEN}[+]${NC} $*"; }
|
||||||
|
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
|
||||||
|
info() { echo -e "${BLUE}[i]${NC} $*"; }
|
||||||
|
die() { echo -e "${RED}[-]${NC} $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
CHECK_ONLY=false
|
||||||
|
[[ "${1:-}" == "--check" ]] && CHECK_ONLY=true
|
||||||
|
|
||||||
|
# ── Dependency + manifest guards ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
command -v yq &>/dev/null || die "yq is required (mikefarah/yq v4)"
|
||||||
|
command -v jq &>/dev/null || die "jq is required"
|
||||||
|
command -v curl &>/dev/null || die "curl is required"
|
||||||
|
[[ -f "$MANIFEST" ]] || die "manifest not found: $MANIFEST"
|
||||||
|
|
||||||
|
# ── Forge API helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
api_get() {
|
||||||
|
local url="$1"
|
||||||
|
local args=(-sf -H "Accept: application/json")
|
||||||
|
[[ -n "$GIT_TOKEN" ]] && args+=(-H "Authorization: token ${GIT_TOKEN}")
|
||||||
|
curl "${args[@]}" "$url" || die "API request failed: $url"
|
||||||
|
}
|
||||||
|
|
||||||
|
raw_dl() {
|
||||||
|
local url="$1" out="$2"
|
||||||
|
local args=(-sfL)
|
||||||
|
[[ -n "$GIT_TOKEN" ]] && args+=(-H "Authorization: token ${GIT_TOKEN}")
|
||||||
|
curl "${args[@]}" "$url" -o "$out" || die "download failed: $url"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Split a repo URL into "server owner repo".
|
||||||
|
# https://git.g3e.fr/team-reseau/vpc-panel → https://git.g3e.fr team-reseau vpc-panel
|
||||||
|
parse_repo_url() {
|
||||||
|
local url="$1"
|
||||||
|
url="${url%.git}"; url="${url%/}"
|
||||||
|
local proto="${url%%://*}"
|
||||||
|
local rest="${url#*://}"
|
||||||
|
local host="${rest%%/*}"
|
||||||
|
local path="${rest#*/}"
|
||||||
|
[[ "$path" == "$rest" || -z "$path" ]] && die "invalid repo URL (need owner/repo): $1"
|
||||||
|
local owner="${path%/*}" repo="${path##*/}"
|
||||||
|
[[ -z "$owner" || -z "$repo" ]] && die "invalid repo URL (need owner/repo): $1"
|
||||||
|
echo "${proto}://${host}" "$owner" "$repo"
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_commit() {
|
||||||
|
local server="$1" owner="$2" repo="$3" ref="$4"
|
||||||
|
api_get "${server}/api/v1/repos/${owner}/${repo}/commits?sha=${ref}&limit=1" \
|
||||||
|
| jq -r '.[0].sha // empty'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Recursively download a repo path (relative to repo root) into dest dir.
|
||||||
|
download_path() {
|
||||||
|
local server="$1" owner="$2" repo="$3" ref="$4" rpath="$5" dest="$6"
|
||||||
|
|
||||||
|
local api
|
||||||
|
if [[ -z "$rpath" ]]; then
|
||||||
|
api="${server}/api/v1/repos/${owner}/${repo}/contents?ref=${ref}"
|
||||||
|
else
|
||||||
|
api="${server}/api/v1/repos/${owner}/${repo}/contents/${rpath}?ref=${ref}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local listing
|
||||||
|
listing="$(api_get "$api")"
|
||||||
|
|
||||||
|
mkdir -p "$dest"
|
||||||
|
while IFS= read -r entry; do
|
||||||
|
local type name dl path
|
||||||
|
type="$(jq -r '.type' <<<"$entry")"
|
||||||
|
name="$(jq -r '.name' <<<"$entry")"
|
||||||
|
dl="$( jq -r '.download_url // ""' <<<"$entry")"
|
||||||
|
path="$(jq -r '.path' <<<"$entry")"
|
||||||
|
|
||||||
|
if [[ "$type" == "dir" ]]; then
|
||||||
|
download_path "$server" "$owner" "$repo" "$ref" "$path" "${dest}/${name}"
|
||||||
|
else
|
||||||
|
[[ -z "$dl" ]] && die "no download_url for ${path} in ${owner}/${repo}@${ref}"
|
||||||
|
raw_dl "$dl" "${dest}/${name}"
|
||||||
|
fi
|
||||||
|
done < <(echo "$listing" | jq -c 'if type=="array" then .[] else . end')
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Download WASM libs ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# ── Read manifest ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
mapfile -t LOCALS < <(yq '(.local // [])[]' "$MANIFEST")
|
||||||
|
mapfile -t REMOTES < <(yq '(.components // [])[] | [.name, .repo, (.ref // "main")] | join("|")' "$MANIFEST")
|
||||||
|
|
||||||
|
# Ordered list of load paths for components.json
|
||||||
|
declare -a LOAD_PATHS=()
|
||||||
|
|
||||||
|
# ── Validate locals ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
for name in "${LOCALS[@]}"; do
|
||||||
|
[[ -z "$name" ]] && continue
|
||||||
|
entry="${COMPONENTS_DIR}/${name}/${name}.js"
|
||||||
|
if [[ ! -f "$entry" ]]; then
|
||||||
|
die "local component '${name}' missing entry file: components/${name}/${name}.js"
|
||||||
|
fi
|
||||||
|
LOAD_PATHS+=("components/${name}/${name}.js")
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── Download remotes ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
LOCK_ENTRIES="[]"
|
||||||
|
|
||||||
|
for spec in "${REMOTES[@]}"; do
|
||||||
|
[[ -z "$spec" ]] && continue
|
||||||
|
IFS='|' read -r name repo ref <<<"$spec"
|
||||||
|
[[ -z "$name" || -z "$repo" ]] && die "manifest entry missing name or repo: '$spec'"
|
||||||
|
|
||||||
|
read -r server owner gitrepo <<<"$(parse_repo_url "$repo")"
|
||||||
|
dest="${COMPONENTS_DIR}/${name}"
|
||||||
|
|
||||||
|
if [[ "$CHECK_ONLY" == true ]]; then
|
||||||
|
info "would fetch ${name} from ${repo}@${ref}"
|
||||||
|
commit="$(resolve_commit "$server" "$owner" "$gitrepo" "$ref" || true)"
|
||||||
|
[[ -z "$commit" ]] && warn " could not resolve ref '${ref}' in ${owner}/${gitrepo}"
|
||||||
|
LOAD_PATHS+=("components/${name}/${name}.js")
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "fetching ${name} ← ${owner}/${gitrepo}@${ref}"
|
||||||
|
commit="$(resolve_commit "$server" "$owner" "$gitrepo" "$ref")"
|
||||||
|
[[ -z "$commit" ]] && die "cannot resolve ref '${ref}' in ${owner}/${gitrepo}"
|
||||||
|
|
||||||
|
rm -rf "$dest"
|
||||||
|
download_path "$server" "$owner" "$gitrepo" "$ref" "" "$dest"
|
||||||
|
|
||||||
|
entry="${dest}/${name}.js"
|
||||||
|
[[ -f "$entry" ]] || die "component '${name}' has no ${name}.js at repo root"
|
||||||
|
|
||||||
|
LOAD_PATHS+=("components/${name}/${name}.js")
|
||||||
|
LOCK_ENTRIES="$(jq \
|
||||||
|
--arg name "$name" --arg repo "$repo" --arg ref "$ref" --arg commit "$commit" \
|
||||||
|
'. + [{name:$name, repo:$repo, ref:$ref, commit:$commit}]' <<<"$LOCK_ENTRIES")"
|
||||||
|
info " pinned ${commit:0:12}"
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── Generate components.json ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if [[ "$CHECK_ONLY" == true ]]; then
|
||||||
|
log "check passed: ${#LOAD_PATHS[@]} components, manifest valid"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\n' "${LOAD_PATHS[@]}" | jq -R . | jq -s . > "$OUTPUT"
|
||||||
|
echo "$LOCK_ENTRIES" | jq '.' > "$LOCKFILE"
|
||||||
|
|
||||||
|
log "wrote ${OUTPUT} (${#LOAD_PATHS[@]} components)"
|
||||||
|
log "wrote ${LOCKFILE} ($(jq 'length' <<<"$LOCK_ENTRIES") remote pins)"
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
[
|
[
|
||||||
|
"components/login-gate/login-gate.js",
|
||||||
"components/api-client/api-client.js",
|
"components/api-client/api-client.js",
|
||||||
"components/side-nav/side-nav.js",
|
"components/side-nav/side-nav.js",
|
||||||
|
"components/logout-button/logout-button.js",
|
||||||
"components/date-display/date-display.js"
|
"components/date-display/date-display.js"
|
||||||
]
|
]
|
||||||
|
|
|
||||||
1
web/components.lock.json
Normal file
1
web/components.lock.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
[]
|
||||||
25
web/components.yml
Normal file
25
web/components.yml
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
# Component manifest — source of truth for the web UI.
|
||||||
|
#
|
||||||
|
# Edit this file, then run ./build.sh to:
|
||||||
|
# - download every remote component into components/<name>/
|
||||||
|
# - generate components.json (the runtime load list) in declared order
|
||||||
|
#
|
||||||
|
# Load order matters: service components (login-gate, api-client) must come
|
||||||
|
# before the components that consume them.
|
||||||
|
|
||||||
|
# Local components shipped inside this repo. Not downloaded — listed here only
|
||||||
|
# so build.sh can place them in the generated load order.
|
||||||
|
local:
|
||||||
|
- login-gate
|
||||||
|
- api-client
|
||||||
|
- side-nav
|
||||||
|
- logout-button
|
||||||
|
- date-display
|
||||||
|
|
||||||
|
# Remote components fetched from git at build time.
|
||||||
|
# Each is its own repo; its root must contain <name>.js and manifest.json.
|
||||||
|
#
|
||||||
|
# - name: vpc-panel # → components/vpc-panel/
|
||||||
|
# repo: https://git.g3e.fr/team-reseau/vpc-panel
|
||||||
|
# ref: v1.2.0 # tag, branch or commit (default: main)
|
||||||
|
components: []
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
// API service component.
|
// API service component.
|
||||||
//
|
//
|
||||||
// Declare once in the shell:
|
// Declare after login-gate in the shell:
|
||||||
|
// <login-gate></login-gate>
|
||||||
// <api-client></api-client>
|
// <api-client></api-client>
|
||||||
//
|
//
|
||||||
// Use from any other component:
|
// Use from any other component:
|
||||||
|
|
@ -11,10 +12,8 @@
|
||||||
// await api.agent.delete('/vpcs/vp-admin')
|
// await api.agent.delete('/vpcs/vp-admin')
|
||||||
// await api.agent.waitFor('/vms/i-test1', 'started')
|
// await api.agent.waitFor('/vms/i-test1', 'started')
|
||||||
//
|
//
|
||||||
// Phase 2: swap this component for one that points to the orchestrator.
|
// Credentials come from <login-gate>.ready — no direct config.json dependency.
|
||||||
// No other component changes.
|
// Phase 2: swap login-gate for oidc-gate → this component unchanged.
|
||||||
|
|
||||||
import { config } from '../../core/config.js'
|
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
constructor(origin, status, message) {
|
constructor(origin, status, message) {
|
||||||
|
|
@ -25,12 +24,25 @@ export class ApiError extends Error {
|
||||||
}
|
}
|
||||||
|
|
||||||
class ApiClient extends HTMLElement {
|
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'
|
this.style.display = 'none'
|
||||||
|
|
||||||
|
// Support login-gate (static token) and biscuit-gate (attenuated token).
|
||||||
|
const gate = document.querySelector('login-gate')
|
||||||
|
this.#credentials = gate ? await gate.ready : null
|
||||||
|
|
||||||
this.netbox = this.#buildNetbox()
|
this.netbox = this.#buildNetbox()
|
||||||
this.agent = this.#buildAgent()
|
this.agent = this.#buildAgent()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
get creds() {
|
||||||
|
return this.#credentials ?? {}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Internal ──────────────────────────────────────────────────────────────
|
// ── Internal ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async #request(origin, url, options = {}) {
|
async #request(origin, url, options = {}) {
|
||||||
|
|
@ -59,12 +71,12 @@ class ApiClient extends HTMLElement {
|
||||||
|
|
||||||
#buildNetbox() {
|
#buildNetbox() {
|
||||||
const headers = () => ({
|
const headers = () => ({
|
||||||
'Authorization': `Token ${config.netbox_token}`,
|
'Authorization': `Token ${this.creds.netbox_token}`,
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
})
|
})
|
||||||
|
|
||||||
const url = (path, params = {}) => {
|
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]) => {
|
Object.entries(params).forEach(([k, v]) => {
|
||||||
if (v != null) u.searchParams.set(k, v)
|
if (v != null) u.searchParams.set(k, v)
|
||||||
})
|
})
|
||||||
|
|
@ -72,7 +84,6 @@ class ApiClient extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Returns results array. Netbox paginates; limit=1000 covers most cases.
|
|
||||||
list: (path, params = {}) =>
|
list: (path, params = {}) =>
|
||||||
this.#request('netbox', url(path, { limit: 1000, ...params }), { headers: headers() })
|
this.#request('netbox', url(path, { limit: 1000, ...params }), { headers: headers() })
|
||||||
.then(d => d?.results ?? []),
|
.then(d => d?.results ?? []),
|
||||||
|
|
@ -90,7 +101,7 @@ class ApiClient extends HTMLElement {
|
||||||
...(body ? { 'Content-Type': 'application/json' } : {}),
|
...(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) =>
|
const req = (method, path, body) =>
|
||||||
this.#request('agent', url(path), {
|
this.#request('agent', url(path), {
|
||||||
|
|
@ -100,12 +111,11 @@ class ApiClient extends HTMLElement {
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
get: path => req('GET', path),
|
get: path => req('GET', path),
|
||||||
list: path => req('GET', path),
|
list: path => req('GET', path),
|
||||||
post: (path, b) => req('POST', path, b),
|
post: (path, b) => req('POST', path, b),
|
||||||
delete: path => req('DELETE', path),
|
delete: path => req('DELETE', path),
|
||||||
|
|
||||||
// Poll path until resource.state === desired or timeout.
|
|
||||||
waitFor: async (path, desired, { timeout = 120_000, interval = 2_000 } = {}) => {
|
waitFor: async (path, desired, { timeout = 120_000, interval = 2_000 } = {}) => {
|
||||||
const deadline = Date.now() + timeout
|
const deadline = Date.now() + timeout
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
|
|
|
||||||
211
web/components/login-gate/login-gate.js
Normal file
211
web/components/login-gate/login-gate.js
Normal 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)
|
||||||
5
web/components/login-gate/manifest.json
Normal file
5
web/components/login-gate/manifest.json
Normal 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."
|
||||||
|
}
|
||||||
75
web/components/logout-button/logout-button.js
Normal file
75
web/components/logout-button/logout-button.js
Normal 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)
|
||||||
5
web/components/logout-button/manifest.json
Normal file
5
web/components/logout-button/manifest.json
Normal 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."
|
||||||
|
}
|
||||||
|
|
@ -39,6 +39,10 @@
|
||||||
color: #6c7086;
|
color: #6c7086;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
header .spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.layout {
|
.layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
@ -70,19 +74,23 @@
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
<login-gate></login-gate>
|
||||||
|
<api-client></api-client>
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
<h1>two</h1>
|
<h1>two</h1>
|
||||||
<span>network orchestrator</span>
|
<span>network orchestrator</span>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<logout-button></logout-button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<api-client></api-client>
|
|
||||||
|
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
<side-nav></side-nav>
|
<side-nav></side-nav>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<div id="error"></div>
|
<div id="error"></div>
|
||||||
<date-display></date-display>
|
<date-display></date-display>
|
||||||
|
<biscuit-debug></biscuit-debug>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue