Skip to content

Commit d9fa007

Browse files
DavidsonGomesclaude
andcommitted
release: v0.17.0 — multi-file tabs + integration drawer + agent permissions + S3 backups
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8df5d46 commit d9fa007

25 files changed

+1969
-116
lines changed

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.17.0] - 2026-04-12
9+
10+
### Added
11+
12+
- **Multi-file tabs in Workspace** — open multiple files simultaneously with a tab bar. Tabs persist in localStorage across page refreshes. Per-tab dirty state, editor content, and mode tracking. Middle-click to close, unsaved changes confirmation.
13+
- **Integration config drawer** — integration cards are now clickable, opening a side drawer with integration-specific form fields (masked API keys with reveal toggle). Save writes to `.env` with safe merge. OAuth integrations show "Connect" button instead. Backend test endpoint (`POST /api/integrations/<name>/test`) with real connectivity tests for Stripe, Omie, Evolution API, and Todoist.
14+
- **Agent-level permissions** — new `agent_access` field on roles with 4 modes: all, by layer (business/engineering), per-agent selection, or none. Locked agents appear with reduced opacity + lock icon in the dashboard. Direct URL access to locked agents shows "Acesso restrito" page. 38 agents mapped across business (17) and engineering (21) layers.
15+
- **S3 backup browser** — new "Remote Backups (S3)" section on the Backups page lists existing backups in the configured S3 bucket. Download directly from S3. New backend endpoints `GET /api/backups/s3` and `GET /api/backups/s3/<key>/download`.
16+
- **Backup storage provider config** — collapsible "Storage Provider" panel on the Backups page with S3 Bucket, Access Key, Secret Key, and Region fields (masked with reveal toggle).
17+
- **Copy file path button** — click "Copiar" in the file path bar to copy the full path to clipboard.
18+
19+
### Changed
20+
21+
- **Tree view preserves state on refresh** — when page reloads, all ancestor folders of the selected file auto-expand to restore the navigation context.
22+
- **Config page removed** — redundant with the new Integration drawer. `.env` vars for dashboard credentials (DASHBOARD_API_TOKEN) are no longer editable from the frontend (security improvement).
23+
- **Logo consistency** — Login and Setup pages now use the official `EVO_NEXUS.png` logo instead of a generic inline SVG.
24+
25+
### Fixed
26+
27+
- **DB migration for agent_access** — auto-migrate adds `agent_access_json` column to existing SQLite databases on startup (ALTER TABLE before seed_roles).
28+
829
## [0.16.0] - 2026-04-12
930

1031
### Added

cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@evoapi/evo-nexus",
3-
"version": "0.16.0",
3+
"version": "0.17.0",
44
"description": "Unofficial open source toolkit for Claude Code — AI-powered business operating system",
55
"keywords": [
66
"claude-code",

dashboard/backend/app.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,19 @@
4848
db.create_all()
4949
db.session.execute(db.text("PRAGMA journal_mode=WAL"))
5050
db.session.commit()
51+
52+
# --- Auto-migrate: add new columns to existing tables ---
53+
import sqlite3 as _sqlite3
54+
_db_path = app.config["SQLALCHEMY_DATABASE_URI"].replace("sqlite:///", "")
55+
_conn = _sqlite3.connect(_db_path)
56+
_cur = _conn.cursor()
57+
_existing_cols = {row[1] for row in _cur.execute("PRAGMA table_info(roles)").fetchall()}
58+
if "agent_access_json" not in _existing_cols:
59+
_cur.execute("ALTER TABLE roles ADD COLUMN agent_access_json TEXT DEFAULT '{\"mode\": \"all\"}'")
60+
_conn.commit()
61+
_conn.close()
62+
# --- End auto-migrate ---
63+
5164
seed_roles()
5265
seed_systems()
5366
# Sync trigger definitions from YAML config

dashboard/backend/models.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,14 +92,60 @@ def to_dict(self):
9292
"mempalace": ["view", "manage"],
9393
}
9494

95+
# Agent layer mapping (file-stem names)
96+
AGENT_LAYERS: dict[str, str] = {
97+
# Business layer
98+
"clawdia-assistant": "business",
99+
"flux-finance": "business",
100+
"atlas-project": "business",
101+
"kai-personal-assistant": "business",
102+
"pulse-community": "business",
103+
"sage-strategy": "business",
104+
"pixel-social-media": "business",
105+
"nex-sales": "business",
106+
"mentor-courses": "business",
107+
"lumen-learning": "business",
108+
"oracle": "business",
109+
"mako-marketing": "business",
110+
"aria-hr": "business",
111+
"zara-cs": "business",
112+
"lex-legal": "business",
113+
"nova-product": "business",
114+
"dex-data": "business",
115+
# Engineering layer
116+
"apex-architect": "engineering",
117+
"echo-analyst": "engineering",
118+
"compass-planner": "engineering",
119+
"raven-critic": "engineering",
120+
"lens-reviewer": "engineering",
121+
"zen-simplifier": "engineering",
122+
"vault-security": "engineering",
123+
"bolt-executor": "engineering",
124+
"hawk-debugger": "engineering",
125+
"grid-tester": "engineering",
126+
"probe-qa": "engineering",
127+
"oath-verifier": "engineering",
128+
"trail-tracer": "engineering",
129+
"flow-git": "engineering",
130+
"scroll-docs": "engineering",
131+
"canvas-designer": "engineering",
132+
"prism-scientist": "engineering",
133+
"helm-conductor": "engineering",
134+
"mirror-retro": "engineering",
135+
"scout-explorer": "engineering",
136+
"quill-writer": "engineering",
137+
}
138+
95139
# Default permissions for built-in roles (used when seeding)
96140
BUILTIN_ROLES = {
97141
"admin": {
98142
"description": "Full access to all resources",
99143
"permissions": {r: actions[:] for r, actions in ALL_RESOURCES.items()},
144+
"agent_access": {"mode": "all"},
100145
},
101146
"operator": {
102147
"description": "Can view and execute, but not manage users or audit",
148+
"agent_access": {"mode": "all"},
103149
"permissions": {
104150
"chat": ["view", "execute"],
105151
"services": ["view", "execute"],
@@ -121,6 +167,7 @@ def to_dict(self):
121167
},
122168
"viewer": {
123169
"description": "Read-only access to dashboards",
170+
"agent_access": {"mode": "none"},
124171
"permissions": {
125172
"workspace": ["view"],
126173
"agents": ["view"],
@@ -319,6 +366,7 @@ class Role(db.Model):
319366
name = db.Column(db.String(50), unique=True, nullable=False)
320367
description = db.Column(db.String(200))
321368
permissions_json = db.Column(db.Text, nullable=False, default="{}")
369+
agent_access_json = db.Column(db.Text, nullable=True, default='{"mode": "all"}')
322370
is_builtin = db.Column(db.Boolean, default=False)
323371
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
324372

@@ -333,12 +381,25 @@ def permissions(self) -> dict:
333381
def permissions(self, value: dict):
334382
self.permissions_json = json.dumps(value)
335383

384+
@property
385+
def agent_access(self) -> dict:
386+
try:
387+
result = json.loads(self.agent_access_json) if self.agent_access_json else None
388+
return result if result else {"mode": "all"}
389+
except (json.JSONDecodeError, TypeError):
390+
return {"mode": "all"}
391+
392+
@agent_access.setter
393+
def agent_access(self, value: dict):
394+
self.agent_access_json = json.dumps(value)
395+
336396
def to_dict(self):
337397
return {
338398
"id": self.id,
339399
"name": self.name,
340400
"description": self.description,
341401
"permissions": self.permissions,
402+
"agent_access": self.agent_access,
342403
"is_builtin": self.is_builtin,
343404
"created_at": self.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ") if self.created_at else None,
344405
}
@@ -366,13 +427,19 @@ def seed_roles():
366427
if resource not in current:
367428
current[resource] = actions
368429
existing.permissions = current
430+
431+
# --- Migração agent_access: set default for existing roles ---
432+
if existing.agent_access_json is None:
433+
existing.agent_access = config.get("agent_access", {"mode": "all"})
434+
# --- fim migração ---
369435
else:
370436
role = Role(
371437
name=name,
372438
description=config["description"],
373439
is_builtin=True,
374440
)
375441
role.permissions = config["permissions"]
442+
role.agent_access = config.get("agent_access", {"mode": "all"})
376443
db.session.add(role)
377444
db.session.commit()
378445

@@ -407,11 +474,43 @@ def get_role_permissions(role_name: str) -> dict:
407474
return {}
408475

409476

477+
def get_role_agent_access(role_name: str) -> dict:
478+
"""Get agent_access config for a role from DB, fallback to builtin defaults."""
479+
role = Role.query.filter_by(name=role_name).first()
480+
if role:
481+
return role.agent_access
482+
builtin = BUILTIN_ROLES.get(role_name)
483+
if builtin:
484+
return builtin.get("agent_access", {"mode": "all"})
485+
return {"mode": "all"}
486+
487+
410488
def has_permission(role: str, resource: str, action: str) -> bool:
411489
perms = get_role_permissions(role)
412490
return action in perms.get(resource, [])
413491

414492

493+
def has_agent_access(role_name: str, agent_name: str) -> bool:
494+
"""Check if a role has access to a specific agent."""
495+
if role_name == "admin":
496+
return True
497+
config = get_role_agent_access(role_name)
498+
mode = config.get("mode", "all")
499+
if mode == "all":
500+
return True
501+
if mode == "none":
502+
return False
503+
if mode == "selected":
504+
agents = config.get("agents", [])
505+
return agent_name in agents
506+
if mode == "layer":
507+
layers = config.get("layers", [])
508+
agent_layer = AGENT_LAYERS.get(agent_name)
509+
return agent_layer is not None and agent_layer in layers
510+
# Unknown mode — default to all
511+
return True
512+
513+
415514
def audit(user, action: str, resource: str = None, detail: str = None):
416515
"""Log an action to the audit trail."""
417516
from flask import request

dashboard/backend/routes/agents.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
"""Agents endpoint — list agents, their config and memory."""
22

33
from flask import Blueprint, jsonify, abort, Response
4+
from flask_login import login_required, current_user
45
from routes._helpers import WORKSPACE, safe_read, parse_frontmatter, file_info
6+
from models import has_agent_access
57

68
bp = Blueprint("agents", __name__)
79

@@ -17,10 +19,12 @@ def _count_memory(name: str) -> int:
1719

1820

1921
@bp.route("/api/agents")
22+
@login_required
2023
def list_agents():
2124
if not AGENTS_DIR.is_dir():
2225
return jsonify([])
2326
agents = []
27+
role = current_user.role if current_user.is_authenticated else "viewer"
2428
for f in sorted(AGENTS_DIR.iterdir()):
2529
if f.suffix.lower() == ".md" and f.is_file():
2630
content = safe_read(f) or ""
@@ -31,6 +35,7 @@ def list_agents():
3135
"description": fm.get("description", ""),
3236
"memory_count": _count_memory(name),
3337
"custom": name.startswith("custom-"),
38+
"locked": not has_agent_access(role, name),
3439
}
3540
if fm.get("color"):
3641
entry["color"] = fm["color"]

dashboard/backend/routes/auth_routes.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from functools import wraps
55
from flask import Blueprint, request, jsonify, abort
66
from flask_login import login_user, logout_user, login_required, current_user
7-
from models import db, User, AuditLog, Role, has_permission, audit, needs_setup, get_role_permissions, ALL_RESOURCES
7+
from models import db, User, AuditLog, Role, has_permission, audit, needs_setup, get_role_permissions, get_role_agent_access, ALL_RESOURCES, AGENT_LAYERS
88

99
bp = Blueprint("auth", __name__)
1010

@@ -188,9 +188,11 @@ def logout():
188188
@login_required
189189
def me():
190190
perms = get_role_permissions(current_user.role)
191+
agent_access = get_role_agent_access(current_user.role)
191192
return jsonify({
192193
"user": current_user.to_dict(),
193194
"permissions": perms,
195+
"agent_access": agent_access,
194196
})
195197

196198

@@ -340,6 +342,14 @@ def list_resources():
340342
return jsonify(ALL_RESOURCES)
341343

342344

345+
@bp.route("/api/roles/agent-layers")
346+
@login_required
347+
@require_permission("users", "view")
348+
def list_agent_layers():
349+
"""Return AGENT_LAYERS mapping for frontend grouping."""
350+
return jsonify(AGENT_LAYERS)
351+
352+
343353
@bp.route("/api/roles", methods=["POST"])
344354
@login_required
345355
@require_permission("users", "manage")
@@ -358,6 +368,7 @@ def create_role():
358368

359369
role = Role(name=name, description=description)
360370
role.permissions = permissions
371+
role.agent_access = data.get("agent_access", {"mode": "all"})
361372
db.session.add(role)
362373
db.session.commit()
363374

@@ -376,6 +387,8 @@ def update_role(role_id):
376387
role.description = data["description"]
377388
if "permissions" in data:
378389
role.permissions = data["permissions"]
390+
if "agent_access" in data:
391+
role.agent_access = data["agent_access"]
379392
if "name" in data and not role.is_builtin:
380393
new_name = data["name"].strip().lower()
381394
if new_name != role.name and Role.query.filter_by(name=new_name).first():

dashboard/backend/routes/backups.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,66 @@ def delete_backup(filename):
158158
return jsonify({"status": "deleted"})
159159

160160

161+
@bp.route("/api/backups/s3")
162+
def list_s3_backups():
163+
"""List backup files stored in S3."""
164+
denied = _require("config", "view")
165+
if denied:
166+
return denied
167+
168+
bucket = os.environ.get("BACKUP_S3_BUCKET")
169+
if not bucket:
170+
return jsonify({"backups": [], "error": "S3 not configured"})
171+
172+
try:
173+
import boto3
174+
except ImportError:
175+
return jsonify({"backups": [], "error": "boto3 not installed"})
176+
177+
try:
178+
s3 = boto3.client("s3")
179+
prefix = os.environ.get("BACKUP_S3_PREFIX", "")
180+
response = s3.list_objects_v2(Bucket=bucket, Prefix=prefix)
181+
backups = []
182+
for obj in response.get("Contents", []):
183+
key = obj["Key"]
184+
if not key.endswith(".zip"):
185+
continue
186+
backups.append({
187+
"key": key,
188+
"filename": key.rsplit("/", 1)[-1],
189+
"size": obj["Size"],
190+
"modified": obj["LastModified"].isoformat(),
191+
})
192+
backups.sort(key=lambda x: x["modified"], reverse=True)
193+
return jsonify({"backups": backups, "total": len(backups)})
194+
except Exception as e:
195+
return jsonify({"backups": [], "error": str(e)})
196+
197+
198+
@bp.route("/api/backups/s3/<path:key>/download")
199+
def download_s3_backup(key):
200+
"""Download a backup from S3 to local backups dir, then serve it."""
201+
denied = _require("config", "manage")
202+
if denied:
203+
return denied
204+
205+
bucket = os.environ.get("BACKUP_S3_BUCKET")
206+
if not bucket:
207+
return jsonify({"error": "S3 not configured"}), 400
208+
209+
try:
210+
import boto3
211+
s3 = boto3.client("s3")
212+
filename = key.rsplit("/", 1)[-1]
213+
local_path = BACKUPS_DIR / filename
214+
BACKUPS_DIR.mkdir(parents=True, exist_ok=True)
215+
s3.download_file(bucket, key, str(local_path))
216+
return send_file(str(local_path), as_attachment=True, download_name=filename)
217+
except Exception as e:
218+
return jsonify({"error": str(e)}), 500
219+
220+
161221
@bp.route("/api/backups/config")
162222
def backup_config():
163223
"""Return backup configuration (S3 status, etc)."""

0 commit comments

Comments
 (0)