- server/policy_overrides.mjs: persistent group/action override store - Group toggle: disabling a group forces all its actions to auto-deny without touching individual overrides; re-enabling restores them intact - Action overrides: cycle auto-accept/queue/auto-deny/clear per action - Effective policy resolution in index.mjs: group > action override > hardcoded default - auth.mjs: add check_can_manage_policies (requires canApprove.length > 0) - Three new endpoints: GET /policies, POST /policies/group/:group, POST /policies/action/:action - client/conduit.mjs: get_policies, set_group_policy, set_action_policy - ccc-queue.mjs: policy view (Tab to switch); group/action panel with space/e/←/→ - actions.mjs: group fields on desktop/email/calendar actions; list-actions includes group - config.example.json: add policy_overrides key - Bump version to 1.3.0 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
351 lines
11 KiB
JavaScript
Executable File
351 lines
11 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
import blessed from 'blessed';
|
|
import { load_client_config } from '../client/config.mjs';
|
|
import { create_conduit_client } from '../client/conduit.mjs';
|
|
|
|
const POLL_INTERVAL = 2000;
|
|
const VALID_POLICIES = ['auto-accept', 'queue', 'auto-deny'];
|
|
|
|
const { username, secret, url } = load_client_config(process.argv);
|
|
const client = create_conduit_client(username, secret, url);
|
|
|
|
// ── State ─────────────────────────────────────────────────────────────────────
|
|
|
|
class App_State {
|
|
constructor() {
|
|
this.view = 'queue'; // 'queue' | 'policies'
|
|
this.queue_items = [];
|
|
this.policy_state = null; // { groups, action_overrides, effective }
|
|
this.all_actions = []; // from list-actions, includes group field
|
|
this.left_focused = true; // in policies view: which panel has focus
|
|
this.sel_group = 0;
|
|
this.sel_action = 0;
|
|
}
|
|
|
|
known_groups() {
|
|
return [...new Set(this.all_actions.map(a => a.group).filter(Boolean))].sort();
|
|
}
|
|
|
|
actions_in_group(group) {
|
|
return this.all_actions.filter(a => a.group === group);
|
|
}
|
|
|
|
current_group() {
|
|
return this.known_groups()[this.sel_group] ?? null;
|
|
}
|
|
|
|
current_action() {
|
|
const group = this.current_group();
|
|
if (!group) { return null; }
|
|
return this.actions_in_group(group)[this.sel_action] ?? null;
|
|
}
|
|
}
|
|
|
|
const state = new App_State();
|
|
|
|
// ── Layout ────────────────────────────────────────────────────────────────────
|
|
|
|
const screen = blessed.screen({ smartCSR: true, title: 'ccc-queue' });
|
|
|
|
const left_box = blessed.list({
|
|
top: 0, left: 0, width: '40%', height: '100%-3',
|
|
border: { type: 'line' },
|
|
scrollable: true, keys: true, vi: true, mouse: true, tags: true,
|
|
style: {
|
|
selected: { bg: 'blue', fg: 'white', bold: true },
|
|
border: { fg: 'cyan' },
|
|
label: { fg: 'cyan', bold: true },
|
|
},
|
|
});
|
|
|
|
const detail_box = blessed.box({
|
|
top: 0, left: '40%', width: '60%', height: '100%-3',
|
|
border: { type: 'line' },
|
|
label: ' Details ',
|
|
scrollable: true, alwaysScroll: true, tags: true,
|
|
style: { border: { fg: 'cyan' }, label: { fg: 'cyan', bold: true } },
|
|
});
|
|
|
|
const actions_list = blessed.list({
|
|
top: 0, left: '40%', width: '60%', height: '100%-3',
|
|
border: { type: 'line' },
|
|
label: ' Actions ',
|
|
scrollable: true, keys: true, vi: true, mouse: true, tags: true,
|
|
style: {
|
|
selected: { bg: 'blue', fg: 'white', bold: true },
|
|
border: { fg: 'cyan' },
|
|
label: { fg: 'cyan', bold: true },
|
|
},
|
|
});
|
|
|
|
const status_bar = blessed.box({
|
|
bottom: 0, left: 0, width: '100%', height: 3,
|
|
border: { type: 'line' }, tags: true,
|
|
style: { border: { fg: 'grey' } },
|
|
});
|
|
|
|
screen.append(left_box);
|
|
screen.append(detail_box);
|
|
screen.append(actions_list);
|
|
screen.append(status_bar);
|
|
|
|
// ── Rendering ─────────────────────────────────────────────────────────────────
|
|
|
|
function policy_color(policy) {
|
|
if (policy === 'auto-deny') { return 'red'; }
|
|
if (policy === 'auto-accept') { return 'green'; }
|
|
if (policy === 'queue') { return 'yellow'; }
|
|
return 'grey';
|
|
}
|
|
|
|
function set_status(text, is_error = false) {
|
|
const msg_color = is_error ? 'red' : 'green';
|
|
let keys;
|
|
if (state.view === 'queue') {
|
|
keys = `{grey-fg}[y]{/grey-fg} approve {grey-fg}[n]{/grey-fg} deny ` +
|
|
`{grey-fg}[r]{/grey-fg} refresh {grey-fg}[tab]{/grey-fg} policies {grey-fg}[q]{/grey-fg} quit`;
|
|
} else if (state.left_focused) {
|
|
keys = `{grey-fg}[space]{/grey-fg} toggle group {grey-fg}[→]{/grey-fg} edit actions ` +
|
|
`{grey-fg}[tab]{/grey-fg} queue {grey-fg}[q]{/grey-fg} quit`;
|
|
} else {
|
|
keys = `{grey-fg}[e]{/grey-fg} cycle policy {grey-fg}[←]{/grey-fg} groups ` +
|
|
`{grey-fg}[tab]{/grey-fg} queue {grey-fg}[q]{/grey-fg} quit`;
|
|
}
|
|
status_bar.setContent(` {${msg_color}-fg}${text}{/${msg_color}-fg} ${keys}`);
|
|
screen.render();
|
|
}
|
|
|
|
function render_queue() {
|
|
left_box.setLabel(' Pending Actions ');
|
|
detail_box.show();
|
|
actions_list.hide();
|
|
|
|
const sel = left_box.selected ?? 0;
|
|
left_box.clearItems();
|
|
if (state.queue_items.length === 0) {
|
|
left_box.addItem('{grey-fg}(no pending items){/grey-fg}');
|
|
} else {
|
|
for (const item of state.queue_items) {
|
|
left_box.addItem(`{yellow-fg}${item.id.slice(0, 8)}{/yellow-fg} ${item.action}`);
|
|
}
|
|
left_box.select(Math.min(sel, state.queue_items.length - 1));
|
|
}
|
|
|
|
const item = state.queue_items[left_box.selected] ?? null;
|
|
if (!item) {
|
|
detail_box.setContent('{grey-fg}No item selected{/grey-fg}');
|
|
} else {
|
|
const lines = [
|
|
`{bold}Action:{/bold} ${item.action}`,
|
|
`{bold}ID:{/bold} ${item.id}`,
|
|
`{bold}Submitted by:{/bold} ${item.submitted_by}`,
|
|
`{bold}Created:{/bold} ${item.created_at}`,
|
|
'',
|
|
'{bold}Params:{/bold}',
|
|
];
|
|
for (const [k, v] of Object.entries(item.params ?? {})) {
|
|
lines.push(` {cyan-fg}${k}{/cyan-fg}: ${v}`);
|
|
}
|
|
detail_box.setContent(lines.join('\n'));
|
|
}
|
|
|
|
left_box.focus();
|
|
screen.render();
|
|
}
|
|
|
|
function render_policies() {
|
|
left_box.setLabel(' Groups ');
|
|
detail_box.hide();
|
|
actions_list.show();
|
|
|
|
const groups = state.known_groups();
|
|
|
|
// Left: groups
|
|
left_box.clearItems();
|
|
if (groups.length === 0) {
|
|
left_box.addItem('{grey-fg}(no groups defined){/grey-fg}');
|
|
} else {
|
|
for (const g of groups) {
|
|
const disabled = state.policy_state?.groups?.[g] ?? false;
|
|
const dot = disabled ? '{red-fg}[○]{/red-fg}' : '{green-fg}[●]{/green-fg}';
|
|
const label = disabled ? `{grey-fg}${g} (off){/grey-fg}` : g;
|
|
left_box.addItem(`${dot} ${label}`);
|
|
}
|
|
left_box.select(Math.min(state.sel_group, groups.length - 1));
|
|
}
|
|
|
|
// Right: actions in selected group
|
|
const group = state.current_group();
|
|
const group_disabled = group ? (state.policy_state?.groups?.[group] ?? false) : false;
|
|
const acts = group ? state.actions_in_group(group) : [];
|
|
|
|
actions_list.clearItems();
|
|
if (acts.length === 0) {
|
|
actions_list.addItem('{grey-fg}(no actions){/grey-fg}');
|
|
} else {
|
|
for (const a of acts) {
|
|
const effective = state.policy_state?.effective?.[a.action] ?? a.policy;
|
|
const has_override = state.policy_state?.action_overrides?.[a.action] !== undefined;
|
|
const color = group_disabled ? 'grey' : policy_color(effective);
|
|
const marker = has_override ? ' {white-fg}*{/white-fg}' : '';
|
|
actions_list.addItem(
|
|
`{${color}-fg}${effective.padEnd(12)}{/${color}-fg}${marker} ${a.action}`
|
|
);
|
|
}
|
|
actions_list.select(Math.min(state.sel_action, acts.length - 1));
|
|
}
|
|
|
|
// Highlight focused panel
|
|
if (state.left_focused) {
|
|
left_box.style.border.fg = 'cyan';
|
|
actions_list.style.border.fg = 'grey';
|
|
left_box.focus();
|
|
} else {
|
|
left_box.style.border.fg = 'grey';
|
|
actions_list.style.border.fg = 'cyan';
|
|
actions_list.focus();
|
|
}
|
|
|
|
screen.render();
|
|
}
|
|
|
|
function render() {
|
|
if (state.view === 'queue') {
|
|
render_queue();
|
|
} else {
|
|
render_policies();
|
|
}
|
|
}
|
|
|
|
// ── Data fetching ─────────────────────────────────────────────────────────────
|
|
|
|
async function refresh() {
|
|
try {
|
|
const [queue_result, policy_result, actions_result] = await Promise.all([
|
|
client.get_queue(),
|
|
client.get_policies(),
|
|
client.list_actions(),
|
|
]);
|
|
state.queue_items = Array.isArray(queue_result) ? queue_result : [];
|
|
state.policy_state = policy_result;
|
|
state.all_actions = actions_result?.result ?? [];
|
|
render();
|
|
set_status(`Last refresh: ${new Date().toLocaleTimeString()}`);
|
|
} catch (err) {
|
|
set_status(`Refresh failed: ${err.message}`, true);
|
|
}
|
|
}
|
|
|
|
// ── Queue operations ──────────────────────────────────────────────────────────
|
|
|
|
async function decide(decision) {
|
|
const item = state.queue_items[left_box.selected];
|
|
if (!item) { return; }
|
|
set_status(`Sending ${decision} for ${item.id.slice(0, 8)}…`);
|
|
try {
|
|
await client.resolve_queue_item(item.id, decision);
|
|
await refresh();
|
|
} catch (err) {
|
|
set_status(`Failed: ${err.message}`, true);
|
|
}
|
|
}
|
|
|
|
// ── Policy operations ─────────────────────────────────────────────────────────
|
|
|
|
async function toggle_group() {
|
|
const group = state.current_group();
|
|
if (!group) { return; }
|
|
const currently_disabled = state.policy_state?.groups?.[group] ?? false;
|
|
set_status(`Toggling group '${group}'…`);
|
|
try {
|
|
await client.set_group_policy(group, !currently_disabled);
|
|
await refresh();
|
|
} catch (err) {
|
|
set_status(`Failed: ${err.message}`, true);
|
|
}
|
|
}
|
|
|
|
async function cycle_action_policy() {
|
|
const act = state.current_action();
|
|
if (!act) { return; }
|
|
const current_override = state.policy_state?.action_overrides?.[act.action] ?? null;
|
|
// Cycle: auto-accept → queue → auto-deny → null (clear) → auto-accept → …
|
|
const next_idx = current_override === null
|
|
? 0
|
|
: (VALID_POLICIES.indexOf(current_override) + 1) % (VALID_POLICIES.length + 1);
|
|
const next = next_idx < VALID_POLICIES.length ? VALID_POLICIES[next_idx] : null;
|
|
set_status(`Setting '${act.action}' → ${next ?? '(default)'}…`);
|
|
try {
|
|
await client.set_action_policy(act.action, next);
|
|
await refresh();
|
|
} catch (err) {
|
|
set_status(`Failed: ${err.message}`, true);
|
|
}
|
|
}
|
|
|
|
// ── Key bindings ──────────────────────────────────────────────────────────────
|
|
|
|
screen.key(['q', 'C-c'], () => process.exit(0));
|
|
screen.key('r', refresh);
|
|
|
|
screen.key('tab', () => {
|
|
state.view = state.view === 'queue' ? 'policies' : 'queue';
|
|
state.left_focused = true;
|
|
render();
|
|
set_status(`Switched to ${state.view} view`);
|
|
});
|
|
|
|
// Queue view
|
|
screen.key('y', () => { if (state.view === 'queue') { decide('approve'); } });
|
|
screen.key('n', () => { if (state.view === 'queue') { decide('deny'); } });
|
|
|
|
// Policy view
|
|
screen.key('space', () => {
|
|
if (state.view === 'policies' && state.left_focused) { toggle_group(); }
|
|
});
|
|
screen.key('e', () => {
|
|
if (state.view === 'policies' && !state.left_focused) { cycle_action_policy(); }
|
|
});
|
|
screen.key('right', () => {
|
|
if (state.view === 'policies' && state.left_focused) {
|
|
state.left_focused = false;
|
|
state.sel_action = 0;
|
|
render();
|
|
set_status('');
|
|
}
|
|
});
|
|
screen.key('left', () => {
|
|
if (state.view === 'policies' && !state.left_focused) {
|
|
state.left_focused = true;
|
|
render();
|
|
set_status('');
|
|
}
|
|
});
|
|
|
|
// Sync selection state from list navigation
|
|
left_box.on('select item', () => {
|
|
if (state.view === 'queue') {
|
|
render_queue();
|
|
} else {
|
|
state.sel_group = left_box.selected ?? 0;
|
|
state.sel_action = 0;
|
|
render_policies();
|
|
}
|
|
screen.render();
|
|
});
|
|
|
|
actions_list.on('select item', () => {
|
|
if (state.view === 'policies') {
|
|
state.sel_action = actions_list.selected ?? 0;
|
|
screen.render();
|
|
}
|
|
});
|
|
|
|
// ── Boot ──────────────────────────────────────────────────────────────────────
|
|
|
|
left_box.focus();
|
|
set_status('Connecting…');
|
|
refresh();
|
|
const poll = setInterval(refresh, POLL_INTERVAL);
|
|
poll.unref();
|