@@ -0,0 +1,216 @@
|
||||
# Home-Lab Directory / Inventory — Design Spec
|
||||
|
||||
Status: **Draft / agreed direction** (no code yet)
|
||||
Owner: wmantly
|
||||
Last updated: 2026-07-02
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview & goals
|
||||
|
||||
The SSO app manages LDAP groups that gate access across the home lab:
|
||||
|
||||
- `app_*` — applications (Home Assistant, Gitea, Emby, …)
|
||||
- `host_*` — machines (Proxmox nodes, LXC containers, VMs, bare metal)
|
||||
|
||||
A bootstrap script joins each Debian container to LDAP and grants SSH by group
|
||||
membership. Access control works — but the groups are **bare**. In LDAP a group
|
||||
(`models/group_ldap.js`) only carries `cn`, `description`, `member`, `owner`.
|
||||
There is:
|
||||
|
||||
- **No metadata** — no URL, IP, FQDN, port, icon, or notes per app/host.
|
||||
- **No relationships** — nothing links a **Proxmox host → the container running on
|
||||
it → the service the container provides → the LDAP group that gates access**.
|
||||
|
||||
Consequences:
|
||||
|
||||
- **Users can't see what they have access to or how to reach it.** Today
|
||||
`views/profile.ejs` hardcodes a static "Services" list (Emby, Git, Proxmox…)
|
||||
with fixed URLs that are unrelated to the viewer's actual group membership.
|
||||
- **CI/CD and service discovery have no source of truth.** Pipelines and tooling
|
||||
can't answer "which host runs service X", "what's the IP of `host_ct101`", or
|
||||
"which group grants access to this".
|
||||
|
||||
### Goals
|
||||
|
||||
1. A machine-readable **discovery API** (build this first) that exposes apps/hosts,
|
||||
their metadata, and the infra graph — for CI/CD, dynamic inventory, and dashboards.
|
||||
2. Answer, per user, **"what can I access and how do I reach it"** (later dashboard,
|
||||
powered by the same API).
|
||||
3. Model the **full graph**: Proxmox host ← container/VM ← service/app.
|
||||
|
||||
### Non-goals (v1)
|
||||
|
||||
- No write/admin UI yet (management comes in a later phase).
|
||||
- No auto-provisioning/sync from Proxmox yet (manual population first).
|
||||
- No change to how access is granted — **LDAP remains the access-control truth.**
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture
|
||||
|
||||
Two stores, one join key.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ SSO app (this repo) │
|
||||
│ │
|
||||
Identity │ LDAP ──────────────► access-control truth │
|
||||
& access │ (users, app_*/host_* groups, membership) │
|
||||
│ │ │
|
||||
│ │ join on group cn │
|
||||
│ ▼ │
|
||||
Inventory │ SQL ──────────────► metadata + graph │
|
||||
│ (resources, edges, resource↔group links) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ /api/discovery/* (read-first API) │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **LDAP = access truth.** Who is in `app_homeassistant` / `host_ct101` stays in
|
||||
LDAP, unchanged. Read via `Group.list(user.dn)` (`models/group_ldap.js`).
|
||||
- **SQL = inventory truth.** Metadata and the host→container→service graph.
|
||||
- **Join key = group `cn`.** A SQL resource references the LDAP group(s) that gate
|
||||
it by name (`app_*` / `host_*`). "Who can access resource X" is a join:
|
||||
LDAP membership ∩ `resource_group`.
|
||||
|
||||
### Tech choice (to confirm)
|
||||
|
||||
- **PostgreSQL** recommended (JSONB for flexible metadata, real relational edges).
|
||||
**SQLite** is a lighter alternative acceptable for a single-node home lab.
|
||||
- Thin query layer: `node-postgres` (`pg`) directly, or `knex` for migrations +
|
||||
query building. This is the app's **first SQL dependency** (today it uses LDAP +
|
||||
Redis via `model-redis`); the directory is a self-contained module and should not
|
||||
disturb the existing stores.
|
||||
|
||||
---
|
||||
|
||||
## 3. Data model (full graph)
|
||||
|
||||
Three tables. Metadata is a JSONB bag so fields can evolve without migrations;
|
||||
common query fields can be promoted to columns later.
|
||||
|
||||
### `resource` — a node in the graph
|
||||
| column | type | notes |
|
||||
|--------------|-------------|-------|
|
||||
| `id` | uuid / pk | |
|
||||
| `kind` | enum | `proxmox_node` \| `container` \| `vm` \| `bare_metal` \| `service` |
|
||||
| `name` | text | display name ("Home Assistant", "ct101") |
|
||||
| `slug` | text unique | url-safe id used by the API |
|
||||
| `description`| text | free text |
|
||||
| `metadata` | jsonb | `{ url, icon, fqdn, ip, port, tags[], … }` |
|
||||
| `created_at` / `updated_at` | timestamptz | |
|
||||
|
||||
### `resource_edge` — directed relationships (the graph)
|
||||
| column | type | notes |
|
||||
|--------------|--------|-------|
|
||||
| `parent_id` | fk → resource | |
|
||||
| `child_id` | fk → resource | |
|
||||
| `relation` | enum | `runs_on` \| `hosts` \| `exposes` \| `depends_on` |
|
||||
|
||||
Represents host←container←service (`hosts`/`runs_on`) and service→service
|
||||
(`depends_on`). Directed edges (not a single `parent_id` column) so a node can have
|
||||
multiple parents/children and multiple relation types.
|
||||
|
||||
### `resource_group` — link a resource to the LDAP group(s) that gate it
|
||||
| column | type | notes |
|
||||
|---------------|--------|-------|
|
||||
| `resource_id` | fk → resource | |
|
||||
| `group_cn` | text | LDAP group name, `app_*` / `host_*` |
|
||||
| `access_level`| enum | `user` \| `admin` \| `owner` |
|
||||
|
||||
This is the bridge to auth. It lets one resource be gated by several groups
|
||||
(e.g. `app_gitea` for users, `host_ct_gitea` for shell/admin).
|
||||
|
||||
**Naming:** the `app_*` / `host_*` prefix convention is retained in LDAP; in SQL the
|
||||
distinction is captured explicitly by `resource.kind` + `resource_group`, so the API
|
||||
never has to parse group-name prefixes.
|
||||
|
||||
### Example
|
||||
```
|
||||
resource: pve1 (proxmox_node) ── hosts ──▶ ct101 (container) ── exposes ──▶ gitea (service)
|
||||
│
|
||||
resource_group: gitea ↔ app_gitea (user), ct101 ↔ host_ct101 (user), pve1 ↔ host_pve1 (admin)
|
||||
```
|
||||
A user in `app_gitea` sees the Gitea service + how to reach it; a user in
|
||||
`host_ct101` additionally sees SSH to the container; `host_pve1` sees the node.
|
||||
|
||||
---
|
||||
|
||||
## 4. Discovery API (v1 — read-first)
|
||||
|
||||
JSON. Mounted at `/api/discovery` behind `middleware.auth` (see §5).
|
||||
|
||||
| method & path | purpose |
|
||||
|---------------|---------|
|
||||
| `GET /api/discovery/resources?kind=&tag=&group=&parent=` | Filtered list of nodes + metadata. |
|
||||
| `GET /api/discovery/resources/:slug` | One node with its edges (parents + children). |
|
||||
| `GET /api/discovery/graph[?root=<slug>]` | Whole graph, or the subtree under a root. |
|
||||
| `GET /api/discovery/me` | Only the resources the **caller** is entitled to — LDAP membership (`Group.list(user.dn)`) ∩ `resource_group`. Powers the future dashboard and the "what can I access" question. |
|
||||
|
||||
### CI/CD-friendly output (optional formats, same data)
|
||||
- **Ansible dynamic inventory** shape (`?format=ansible`): groups of hosts with
|
||||
`ansible_host`/vars pulled from `metadata`.
|
||||
- **Dashboard** shape (Homepage/Glance): flat list of services with `href`, `icon`,
|
||||
`description`.
|
||||
|
||||
Write endpoints (POST/PUT/DELETE) are **out of scope for v1**; population is manual
|
||||
(SQL seed / migration) until the admin UI phase.
|
||||
|
||||
---
|
||||
|
||||
## 5. AuthN / AuthZ
|
||||
|
||||
- **Interactive users:** existing session auth — `middleware.auth` validating the
|
||||
`auth-token` header (an `AuthToken`, `models/token.js`). No change.
|
||||
- **CI/CD (machine) access:** the app does **not yet** have a long-lived service
|
||||
token — `AuthToken` is session-oriented. **Proposed small addition:** a
|
||||
`ServiceToken` subclass in `models/token.js` (mirrors `AuthToken`/`ImpersonationToken`),
|
||||
long-lived, read-only, passed in the same `auth-token` header. Track as its own
|
||||
task; the discovery API should assume it exists but degrade to normal auth tokens
|
||||
until then.
|
||||
- **Read visibility (decision to confirm):** either (a) any authenticated user may
|
||||
read all resource metadata and only `/me` is filtered, or (b) list endpoints are
|
||||
themselves filtered to entitlement. Recommend **(a)** for a home lab — simpler,
|
||||
and infra metadata isn't secret — with `/me` as the personalized view.
|
||||
- **Management (later):** gate write/admin endpoints behind a new
|
||||
`app_sso_directory_admin` LDAP group, mirroring the existing
|
||||
`app_sso_oauth_admin` pattern (`routes/oauth_client.js` + `utils/permission.js`).
|
||||
|
||||
---
|
||||
|
||||
## 6. Integration points with the existing app
|
||||
|
||||
- **Replace the hardcoded Services list** in `views/profile.ejs` (lines ~82–126)
|
||||
with a render of `GET /api/discovery/me`.
|
||||
- **Reuse the group selector** `app.ui.groupSelect` (`public/js/app.js`) for linking
|
||||
resources ↔ LDAP groups in the future admin UI — no new group-picker needed.
|
||||
- **Join convention:** `resource_group.group_cn` must equal an LDAP group `cn`
|
||||
exactly; the discovery layer never invents groups, it only references existing ones.
|
||||
- **Reuse `utils/permission.byGroup`** for the admin gate in the management phase.
|
||||
|
||||
---
|
||||
|
||||
## 7. Roadmap
|
||||
|
||||
1. **v1 — Discovery API** (this spec's focus): SQL schema + migrations, read models,
|
||||
`/api/discovery/*` endpoints, `ServiceToken` for CI/CD.
|
||||
2. **v2 — "My Access" dashboard**: swap `profile.ejs`'s static list for `/me`.
|
||||
3. **v3 — Admin CRUD UI**: manage resources/edges/group links (reusing `app.ui`
|
||||
widgets and the `oauth_clients.ejs` card+modal pattern); gated by
|
||||
`app_sso_directory_admin`.
|
||||
4. **v4 — Sync from Proxmox** (optional): auto-populate nodes/containers/VMs from the
|
||||
Proxmox API so inventory stays current without manual entry.
|
||||
|
||||
---
|
||||
|
||||
## 8. Open questions
|
||||
|
||||
1. **DB engine:** PostgreSQL (recommended) vs SQLite for a single-node lab.
|
||||
2. **Population:** manual seed vs Proxmox pull for v1 (spec assumes manual).
|
||||
3. **Metadata mirroring:** should any metadata be written back to the LDAP group
|
||||
`description` so LDAP-only external consumers see it? **Default: no** — keep LDAP
|
||||
for auth, SQL for inventory.
|
||||
4. **Read-visibility policy:** confirm option (a) vs (b) in §5.
|
||||
5. **Service token scope:** read-only globally, or per-token resource/kind scoping?
|
||||
@@ -44,3 +44,70 @@ a:hover, button:hover, div.form-group:hover, header nav a{
|
||||
transform: scale(1.05); /* Standard syntax */
|
||||
z-index: 999999;
|
||||
}
|
||||
|
||||
/* ── Reusable tag / token input (app.ui.tagInput / groupSelect) ────────────── */
|
||||
.tag-input{
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: .25rem;
|
||||
min-height: calc(1.5em + .75rem + 2px);
|
||||
height: auto;
|
||||
cursor: text;
|
||||
}
|
||||
.tag-input .tag-chips{
|
||||
display: contents;
|
||||
}
|
||||
.tag-input .tag-chip{
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .35rem;
|
||||
background-color: #0d6efd;
|
||||
color: #fff;
|
||||
font-size: .85em;
|
||||
font-weight: 500;
|
||||
padding: .25rem .5rem;
|
||||
border-radius: .5rem;
|
||||
}
|
||||
.tag-input .tag-chip .tag-remove{
|
||||
color: #fff;
|
||||
opacity: .8;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tag-input .tag-chip .tag-remove:hover{ opacity: 1; transform: none; }
|
||||
.tag-input .tag-typeahead{
|
||||
flex: 1 1 8ch;
|
||||
min-width: 8ch;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
padding: .15rem 0;
|
||||
}
|
||||
.tag-input .tag-menu{
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1080;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(0,0,0,.15);
|
||||
border-radius: .375rem;
|
||||
box-shadow: 0 .5rem 1rem rgba(0,0,0,.15);
|
||||
max-height: 12rem;
|
||||
overflow-y: auto;
|
||||
margin-top: .15rem;
|
||||
}
|
||||
.tag-input .tag-option{
|
||||
padding: .35rem .6rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tag-input .tag-option:hover,
|
||||
.tag-input .tag-option.active{
|
||||
background-color: #0d6efd;
|
||||
color: #fff;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
@@ -128,6 +128,130 @@ app.group = (function(app){
|
||||
return {list, get, remove}
|
||||
})(app)
|
||||
|
||||
// Reusable UI widgets. app.ui.tagInput is a generic tag/token field with
|
||||
// autocomplete; app.ui.groupSelect is a tagInput preloaded with every LDAP
|
||||
// group, meant to be dropped in anywhere group selection is needed.
|
||||
app.ui = (function(app){
|
||||
|
||||
// All LDAP group CNs, fetched once and shared across every group selector.
|
||||
var _groupsPromise = null;
|
||||
function loadGroups(){
|
||||
if(!_groupsPromise){
|
||||
_groupsPromise = new Promise(function(resolve){
|
||||
app.group.list(function(error, data){
|
||||
if(error || !data || !data.results){ resolve([]); return; }
|
||||
resolve(data.results.map(function(g){ return g.cn || g; }).filter(Boolean).sort());
|
||||
});
|
||||
});
|
||||
}
|
||||
return _groupsPromise;
|
||||
}
|
||||
// Drop the cache (e.g. after a group is created) so the next selector refetches.
|
||||
function refreshGroups(){ _groupsPromise = null; return loadGroups(); }
|
||||
|
||||
// opts: { values, options, freeSolo, placeholder, name, separator }
|
||||
// Returns a handle: { get, set, add, clear, setOptions, element }.
|
||||
function tagInput(mount, opts){
|
||||
opts = opts || {};
|
||||
var separator = opts.separator != null ? opts.separator : '\n';
|
||||
var values = (opts.values || []).slice();
|
||||
var options = (opts.options || []).slice();
|
||||
var freeSolo = opts.freeSolo !== false; // default: allow custom entries
|
||||
|
||||
var $mount = $(mount).addClass('tag-input form-control shadow').empty();
|
||||
var $chips = $('<span class="tag-chips"></span>');
|
||||
var $input = $('<input type="text" class="tag-typeahead" autocomplete="off">')
|
||||
.attr('placeholder', opts.placeholder || 'Type to add…');
|
||||
var $menu = $('<div class="tag-menu"></div>').hide();
|
||||
var $hidden = $('<input type="hidden">').attr('name', opts.name || '');
|
||||
$mount.append($chips, $input, $menu, $hidden);
|
||||
|
||||
function syncHidden(){
|
||||
var joined = values.join(separator);
|
||||
$hidden.val(joined).attr('value', joined);
|
||||
$mount.trigger('change');
|
||||
}
|
||||
function renderChips(){
|
||||
$chips.empty();
|
||||
values.forEach(function(v){
|
||||
var $remove = $('<a class="tag-remove" href="#">×</a>').on('click', function(e){
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
values = values.filter(function(x){ return x !== v; });
|
||||
renderChips(); syncHidden();
|
||||
});
|
||||
$chips.append($('<span class="tag-chip badge"></span>').text(v).append(' ', $remove));
|
||||
});
|
||||
}
|
||||
function suggestions(){
|
||||
var q = ($input.val() || '').toLowerCase();
|
||||
return options.filter(function(o){
|
||||
return values.indexOf(o) === -1 && o.toLowerCase().indexOf(q) !== -1;
|
||||
}).slice(0, 10);
|
||||
}
|
||||
function showMenu(){
|
||||
var items = suggestions();
|
||||
if(!items.length){ return hideMenu(); }
|
||||
$menu.empty();
|
||||
items.forEach(function(o){
|
||||
$('<div class="tag-option"></div>').text(o).on('mousedown', function(e){
|
||||
e.preventDefault(); add(o);
|
||||
}).appendTo($menu);
|
||||
});
|
||||
$menu.show();
|
||||
}
|
||||
function hideMenu(){ $menu.hide(); }
|
||||
function add(val){
|
||||
val = (val || '').trim();
|
||||
if(!val){ return; }
|
||||
if(!freeSolo && options.indexOf(val) === -1){ return; } // reject invalid
|
||||
if(values.indexOf(val) === -1){ values.push(val); renderChips(); syncHidden(); }
|
||||
$input.val(''); hideMenu();
|
||||
}
|
||||
|
||||
$input.on('input focus', showMenu);
|
||||
$input.on('keydown', function(e){
|
||||
if(e.key === 'Enter' || e.key === ','){
|
||||
e.preventDefault();
|
||||
add(freeSolo ? $input.val() : (suggestions()[0] || ''));
|
||||
}else if(e.key === 'Backspace' && !$input.val() && values.length){
|
||||
values.pop(); renderChips(); syncHidden();
|
||||
}
|
||||
});
|
||||
$input.on('blur', function(){ setTimeout(hideMenu, 150); });
|
||||
$mount.on('click', function(e){
|
||||
if(e.target === $mount[0] || e.target === $chips[0]){ $input.focus(); }
|
||||
});
|
||||
|
||||
renderChips(); syncHidden();
|
||||
|
||||
return {
|
||||
get: function(){ return values.slice(); },
|
||||
set: function(v){ values = (v || []).slice(); renderChips(); syncHidden(); },
|
||||
add: add,
|
||||
clear: function(){ values = []; renderChips(); syncHidden(); },
|
||||
setOptions: function(o){ options = (o || []).slice(); },
|
||||
element: $mount,
|
||||
};
|
||||
}
|
||||
|
||||
// Universal group selector. Preloads all LDAP groups for autocomplete.
|
||||
function groupSelect(mount, opts){
|
||||
opts = opts || {};
|
||||
var handle = tagInput(mount, {
|
||||
name: opts.name || 'groups',
|
||||
values: opts.values || [],
|
||||
options: [],
|
||||
freeSolo: opts.freeSolo !== false,
|
||||
separator: opts.separator != null ? opts.separator : '\n',
|
||||
placeholder: opts.placeholder || 'Type a group name…',
|
||||
});
|
||||
loadGroups().then(function(groups){ handle.setOptions(groups); });
|
||||
return handle;
|
||||
}
|
||||
|
||||
return { tagInput: tagInput, groupSelect: groupSelect, loadGroups: loadGroups, refreshGroups: refreshGroups };
|
||||
})(app);
|
||||
|
||||
app.oauthClient = (function(app){
|
||||
function list(callack){
|
||||
return app.api.get('oauth/client/', function(error, data){
|
||||
|
||||
@@ -1,5 +1,55 @@
|
||||
<%- include('top') %>
|
||||
|
||||
<!-- Edit modal -->
|
||||
<div class="modal fade" id="editModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="fa-solid fa-pen-to-square"></i> Edit OAuth Client</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="card-header actionMessage mb-3" style="display:none"></div>
|
||||
<input type="hidden" id="edit-client-id">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Name</label>
|
||||
<input type="text" id="edit-name" class="form-control shadow">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<input type="text" id="edit-description" class="form-control shadow">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Redirect URIs <small class="text-muted">(one per line)</small></label>
|
||||
<textarea id="edit-redirect_uris" class="form-control shadow font-monospace" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Scopes</label>
|
||||
<div id="edit-scopes"></div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Restrict to Groups <small class="text-muted">(optional)</small></label>
|
||||
<div id="edit-allowed_groups"></div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col">
|
||||
<label class="form-label">Access Token TTL <small class="text-muted">(seconds)</small></label>
|
||||
<input type="number" id="edit-access_ttl" class="form-control shadow" min="60">
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="form-label">Refresh Token TTL <small class="text-muted">(seconds)</small></label>
|
||||
<input type="number" id="edit-refresh_ttl" class="form-control shadow" min="3600">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveEdit(this)"><i class="fa-solid fa-floppy-disk"></i> Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Secret modal -->
|
||||
<div class="modal fade" id="secretModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
@@ -27,7 +77,16 @@
|
||||
<script type="text/javascript">
|
||||
app.auth.forceLogin('app_sso_oauth_admin');
|
||||
|
||||
// The scopes this provider actually understands (see routes/oauth.js discovery).
|
||||
var VALID_SCOPES = ['openid', 'profile', 'email', 'groups'];
|
||||
var DEFAULT_SCOPES = ['openid', 'profile', 'email', 'groups'];
|
||||
|
||||
var secretModal = new bootstrap.Modal(document.getElementById('secretModal'));
|
||||
var editModal = new bootstrap.Modal(document.getElementById('editModal'));
|
||||
|
||||
// Widget handles + a lookup of the latest client data (for the edit modal).
|
||||
var createScopes, createGroups, editScopes, editGroups;
|
||||
var clientsById = {};
|
||||
|
||||
function showSecret(secret){
|
||||
document.getElementById('secretValue').value = secret;
|
||||
@@ -60,6 +119,7 @@
|
||||
}
|
||||
|
||||
function processClient(client){
|
||||
clientsById[client.client_id] = client; // keep raw data for the edit modal
|
||||
client.scopes_display = (client.scopes || []).join(' ');
|
||||
client.allowed_groups_display = (client.allowed_groups || []).join(', ');
|
||||
client.has_group_restriction = (client.allowed_groups || []).length > 0;
|
||||
@@ -98,11 +158,67 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Open the edit modal pre-filled from the client's current values.
|
||||
function editClient(client_id){
|
||||
var c = clientsById[client_id];
|
||||
if(!c) return;
|
||||
$('#edit-client-id').val(client_id);
|
||||
$('#edit-name').val(c.name || '');
|
||||
$('#edit-description').val(c.description || '');
|
||||
$('#edit-redirect_uris').val((c.redirect_uris || []).join('\n'));
|
||||
$('#edit-access_ttl').val((c.token_lifetime || {}).access_token || 3600);
|
||||
$('#edit-refresh_ttl').val((c.token_lifetime || {}).refresh_token || 2592000);
|
||||
|
||||
// (Re)build the tag widgets fresh each open so they reflect this client.
|
||||
editScopes = app.ui.tagInput('#edit-scopes', {
|
||||
values: c.scopes || [], options: VALID_SCOPES, freeSolo: false,
|
||||
separator: ' ', placeholder: 'Add a scope…',
|
||||
});
|
||||
editGroups = app.ui.groupSelect('#edit-allowed_groups', {
|
||||
values: c.allowed_groups || [], placeholder: 'Type a group name…',
|
||||
});
|
||||
editModal.show();
|
||||
}
|
||||
|
||||
function saveEdit(btn){
|
||||
var $msg = $('#editModal .actionMessage');
|
||||
var payload = {
|
||||
client_id: $('#edit-client-id').val(),
|
||||
name: $('#edit-name').val(),
|
||||
description: $('#edit-description').val(),
|
||||
redirect_uris: $('#edit-redirect_uris').val().split('\n').map(function(s){ return s.trim(); }).filter(Boolean),
|
||||
scopes: editScopes.get(),
|
||||
allowed_groups: editGroups.get(),
|
||||
token_lifetime: {
|
||||
access_token: Number($('#edit-access_ttl').val()) || 3600,
|
||||
refresh_token: Number($('#edit-refresh_ttl').val()) || 2592000,
|
||||
},
|
||||
};
|
||||
app.oauthClient.update(payload, function(error, data){
|
||||
if(error){
|
||||
app.util.actionMessage((data && data.message) || 'Update failed.', $msg.parent(), 'danger');
|
||||
return;
|
||||
}
|
||||
editModal.hide();
|
||||
tableAJAX();
|
||||
});
|
||||
}
|
||||
|
||||
$(document).ready(function(){
|
||||
tableAJAX();
|
||||
|
||||
// Initialise the create-form tag widgets.
|
||||
createScopes = app.ui.tagInput('#create-scopes', {
|
||||
name: 'scopes', values: DEFAULT_SCOPES, options: VALID_SCOPES,
|
||||
freeSolo: false, separator: ' ', placeholder: 'Add a scope…',
|
||||
});
|
||||
createGroups = app.ui.groupSelect('#create-allowed_groups', {
|
||||
name: 'allowed_groups', values: [], placeholder: 'Type a group name…',
|
||||
});
|
||||
|
||||
// After a successful create, reset the widgets too (form reset ignores them).
|
||||
$('form[action="oauth/client/"]').attr('evalAJAX',
|
||||
'showSecret(data.client_secret); tableAJAX(); $form.trigger("reset");'
|
||||
'showSecret(data.client_secret); tableAJAX(); $form.trigger("reset"); createScopes.set(DEFAULT_SCOPES); createGroups.clear();'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -157,13 +273,12 @@
|
||||
placeholder="https://ha.example.com/auth/external/callback" validate=":1"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Scopes <small class="text-muted">(space-separated)</small></label>
|
||||
<input type="text" class="form-control shadow" name="scopes" value="openid profile email groups">
|
||||
<label class="form-label">Scopes</label>
|
||||
<div id="create-scopes"></div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Restrict to Groups <small class="text-muted">(one per line, optional)</small></label>
|
||||
<textarea class="form-control shadow font-monospace" name="allowed_groups" rows="2"
|
||||
placeholder="app_homeassistant app_sso_admin"></textarea>
|
||||
<label class="form-label">Restrict to Groups <small class="text-muted">(optional)</small></label>
|
||||
<div id="create-allowed_groups"></div>
|
||||
<small class="text-muted">Leave empty to allow any user. If set, only members of a listed LDAP group can log in.</small>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
@@ -236,6 +351,11 @@
|
||||
</dl>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<button type="button"
|
||||
onclick="editClient('{{client_id}}')"
|
||||
class="btn btn-primary btn-sm">
|
||||
<i class="fa-solid fa-pen-to-square"></i> Edit
|
||||
</button>
|
||||
<button type="button"
|
||||
onclick="rotateSecret('{{client_id}}', '{{name}}', this)"
|
||||
class="btn btn-warning btn-sm">
|
||||
|
||||
Reference in New Issue
Block a user