Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1236220302 | |||
| e7d1b53ebf |
@@ -413,6 +413,7 @@ jobs:
|
|||||||
echo "MEMORY_LIMIT=512Mi" >> "$GITHUB_OUTPUT"
|
echo "MEMORY_LIMIT=512Mi" >> "$GITHUB_OUTPUT"
|
||||||
echo "CPU_REQUEST=50m" >> "$GITHUB_OUTPUT"
|
echo "CPU_REQUEST=50m" >> "$GITHUB_OUTPUT"
|
||||||
echo "CPU_LIMIT=250m" >> "$GITHUB_OUTPUT"
|
echo "CPU_LIMIT=250m" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "BUNDLED_PACKAGES=*" >> "$GITHUB_OUTPUT"
|
||||||
echo "env_label=dev (PR #${{ gitea.event.pull_request.number }})" >> "$GITHUB_OUTPUT"
|
echo "env_label=dev (PR #${{ gitea.event.pull_request.number }})" >> "$GITHUB_OUTPUT"
|
||||||
elif [[ "${{ gitea.ref }}" == refs/tags/v* ]]; then
|
elif [[ "${{ gitea.ref }}" == refs/tags/v* ]]; then
|
||||||
VERSION="${{ gitea.ref_name }}"
|
VERSION="${{ gitea.ref_name }}"
|
||||||
@@ -429,6 +430,7 @@ jobs:
|
|||||||
echo "MEMORY_LIMIT=512Mi" >> "$GITHUB_OUTPUT"
|
echo "MEMORY_LIMIT=512Mi" >> "$GITHUB_OUTPUT"
|
||||||
echo "CPU_REQUEST=100m" >> "$GITHUB_OUTPUT"
|
echo "CPU_REQUEST=100m" >> "$GITHUB_OUTPUT"
|
||||||
echo "CPU_LIMIT=500m" >> "$GITHUB_OUTPUT"
|
echo "CPU_LIMIT=500m" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "BUNDLED_PACKAGES=notes,chat,chat-core,mermaid-renderer,schedules" >> "$GITHUB_OUTPUT"
|
||||||
echo "is_release=true" >> "$GITHUB_OUTPUT"
|
echo "is_release=true" >> "$GITHUB_OUTPUT"
|
||||||
echo "env_label=production (${VERSION})" >> "$GITHUB_OUTPUT"
|
echo "env_label=production (${VERSION})" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
@@ -444,6 +446,7 @@ jobs:
|
|||||||
echo "MEMORY_LIMIT=512Mi" >> "$GITHUB_OUTPUT"
|
echo "MEMORY_LIMIT=512Mi" >> "$GITHUB_OUTPUT"
|
||||||
echo "CPU_REQUEST=50m" >> "$GITHUB_OUTPUT"
|
echo "CPU_REQUEST=50m" >> "$GITHUB_OUTPUT"
|
||||||
echo "CPU_LIMIT=250m" >> "$GITHUB_OUTPUT"
|
echo "CPU_LIMIT=250m" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "BUNDLED_PACKAGES=notes,chat,chat-core" >> "$GITHUB_OUTPUT"
|
||||||
echo "env_label=test (main)" >> "$GITHUB_OUTPUT"
|
echo "env_label=test (main)" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -670,6 +673,7 @@ jobs:
|
|||||||
STORAGE_CLASS: ${{ vars.STORAGE_CLASS }}
|
STORAGE_CLASS: ${{ vars.STORAGE_CLASS }}
|
||||||
STORAGE_SIZE: ${{ vars.STORAGE_SIZE || '10Gi' }}
|
STORAGE_SIZE: ${{ vars.STORAGE_SIZE || '10Gi' }}
|
||||||
STORAGE_BACKEND: ${{ vars.STORAGE_BACKEND || 'pvc' }}
|
STORAGE_BACKEND: ${{ vars.STORAGE_BACKEND || 'pvc' }}
|
||||||
|
BUNDLED_PACKAGES: ${{ steps.setup.outputs.BUNDLED_PACKAGES }}
|
||||||
run: |
|
run: |
|
||||||
# Render PVC first (must exist before backend references it)
|
# Render PVC first (must exist before backend references it)
|
||||||
if [[ -n "${STORAGE_CLASS}" ]]; then
|
if [[ -n "${STORAGE_CLASS}" ]]; then
|
||||||
|
|||||||
52
CHANGELOG.md
52
CHANGELOG.md
@@ -2,6 +2,58 @@
|
|||||||
|
|
||||||
All notable changes to Armature are documented here.
|
All notable changes to Armature are documented here.
|
||||||
|
|
||||||
|
## v0.6.18 — CI Bundle Wiring
|
||||||
|
|
||||||
|
Wire `BUNDLED_PACKAGES` env var into the Gitea CI pipeline so each
|
||||||
|
environment gets the correct package set at boot.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **CI: dev deploys** — `BUNDLED_PACKAGES=*` (install all, matches docker-compose default).
|
||||||
|
- **CI: test deploys** — `BUNDLED_PACKAGES=notes,chat,chat-core` (core surfaces only).
|
||||||
|
- **CI: prod deploys** — `BUNDLED_PACKAGES=notes,chat,chat-core,mermaid-renderer,schedules`.
|
||||||
|
- **docker-compose.yml** — default `BUNDLED_PACKAGES` changed from empty to `*` (install all for local dev).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **TestBundledInstall_DefaultAllowlist** — updated test assertions to match v0.6.17's empty default set (was still expecting `notes` in curated defaults).
|
||||||
|
|
||||||
|
## v0.6.17 — Bug Fixes & Welcome Logic
|
||||||
|
|
||||||
|
Fixes broken UI interactions (folder creation, team member add), dropdown
|
||||||
|
overflow, welcome surface auto-disable, and bare-install default behavior.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Notes "Add folder" button** — `prompt()` replaced with `sw.prompt()`
|
||||||
|
so the dialog renders correctly in the extension iframe sandbox.
|
||||||
|
- **Admin "Add team members"** — user list API returns `{data:[…]}`; handler
|
||||||
|
now unwraps the envelope (`Array.isArray(u) ? u : u.data`) so the user
|
||||||
|
picker populates.
|
||||||
|
- **Package filter dropdown overflow** — removed `right:0` constraint on
|
||||||
|
`.sw-dropdown__list`, added `min-width:max-content` and `overflow-x:hidden`
|
||||||
|
so option labels ("Extension", "Workflow") render fully without a scrollbar.
|
||||||
|
- **Admin actions cell wrapping** — switched `.admin-actions-cell` from
|
||||||
|
`white-space:nowrap` to flexbox with `flex-wrap:wrap; gap:4px` so buttons
|
||||||
|
don't overflow on narrow viewports.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Welcome surface auto-disable** — welcome page now redirects to `/` when
|
||||||
|
any non-core extension surface is installed. Removed from the topbar
|
||||||
|
navigation surface list so it never appears alongside real surfaces.
|
||||||
|
- **Zero default bundled packages** — `defaultBundledPackages` map is now
|
||||||
|
empty. Fresh installs start bare; use `BUNDLED_PACKAGES` env var to control
|
||||||
|
what gets auto-installed per environment (`*` for all, comma-separated list
|
||||||
|
for selective).
|
||||||
|
- **Bundled filter logic** — `nil` (from `*`) means install all; empty map
|
||||||
|
(default) means install nothing. Previous code treated both as "install all".
|
||||||
|
- **User menu conditional items** — Docs, Settings, and Team Admin menu
|
||||||
|
entries only appear when those surfaces are actually enabled, not assumed.
|
||||||
|
- **SDK imperative host mount** — `ToastContainer` and `DialogStack` are now
|
||||||
|
auto-mounted by the SDK boot sequence for extension surfaces that lack an
|
||||||
|
AppShell, preventing missing toast/dialog hosts.
|
||||||
|
|
||||||
## v0.6.16 — Usability Survey Gate
|
## v0.6.16 — Usability Survey Gate
|
||||||
|
|
||||||
Machine-auditable UI quality gate. Four new audit scripts, a structured survey
|
Machine-auditable UI quality gate. Four new audit scripts, a structured survey
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ services:
|
|||||||
EXT_ALLOW_PRIVATE_IPS: ${EXT_ALLOW_PRIVATE_IPS:-true}
|
EXT_ALLOW_PRIVATE_IPS: ${EXT_ALLOW_PRIVATE_IPS:-true}
|
||||||
LOG_FORMAT: ${LOG_FORMAT:-text}
|
LOG_FORMAT: ${LOG_FORMAT:-text}
|
||||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
BUNDLED_PACKAGES: ${BUNDLED_PACKAGES:-}
|
BUNDLED_PACKAGES: ${BUNDLED_PACKAGES:-*}
|
||||||
# Dev seed users — ignored if ENVIRONMENT=production
|
# Dev seed users — ignored if ENVIRONMENT=production
|
||||||
SEED_USERS: ${SEED_USERS:-alice:password123:user,bob:password456:user,charlie:password789:user}
|
SEED_USERS: ${SEED_USERS:-alice:password123:user,bob:password456:user,charlie:password789:user}
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -1622,7 +1622,7 @@
|
|||||||
setShowUnfiled(true);
|
setShowUnfiled(true);
|
||||||
}
|
}
|
||||||
async function handleCreateFolder(parentId) {
|
async function handleCreateFolder(parentId) {
|
||||||
var name = prompt('Folder name:');
|
var name = await sw.prompt('Folder name:');
|
||||||
if (!name || !name.trim()) return;
|
if (!name || !name.trim()) return;
|
||||||
try {
|
try {
|
||||||
await api.post('/folders', { name: name.trim(), parent_id: parentId || '' });
|
await api.post('/folders', { name: name.trim(), parent_id: parentId || '' });
|
||||||
|
|||||||
@@ -747,6 +747,10 @@ func (h *PackageHandler) ListEnabledSurfaces(c *gin.Context) {
|
|||||||
if p.Type != "surface" && p.Type != "full" {
|
if p.Type != "surface" && p.Type != "full" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// Welcome is a fallback surface, not a navigable destination
|
||||||
|
if p.ID == "welcome" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
route, _ := p.Manifest["route"].(string)
|
route, _ := p.Manifest["route"].(string)
|
||||||
icon, _ := p.Manifest["icon"].(string)
|
icon, _ := p.Manifest["icon"].(string)
|
||||||
enabled = append(enabled, navSurface{
|
enabled = append(enabled, navSurface{
|
||||||
|
|||||||
@@ -22,18 +22,12 @@ import (
|
|||||||
"armature/triggers"
|
"armature/triggers"
|
||||||
)
|
)
|
||||||
|
|
||||||
// defaultBundledPackages is the curated set of packages installed by default.
|
// defaultBundledPackages is empty — fresh installs start bare.
|
||||||
// Other packages still ship in the Docker image but require BUNDLED_PACKAGES
|
// Use BUNDLED_PACKAGES env var to control what gets installed per environment:
|
||||||
// to be set explicitly (or "*" for all).
|
// dev: BUNDLED_PACKAGES=*
|
||||||
//
|
// test: BUNDLED_PACKAGES=notes,chat,chat-core
|
||||||
// Recommended production override: BUNDLED_PACKAGES=notes,chat,chat-core,mermaid-renderer,schedules
|
// prod: BUNDLED_PACKAGES=notes,chat,chat-core,mermaid-renderer,schedules
|
||||||
var defaultBundledPackages = map[string]bool{
|
var defaultBundledPackages = map[string]bool{}
|
||||||
"notes": true,
|
|
||||||
"chat": true,
|
|
||||||
"chat-core": true,
|
|
||||||
"mermaid-renderer": true,
|
|
||||||
"schedules": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// InstallBundledPackages scans bundledDir for .pkg archives and installs
|
// InstallBundledPackages scans bundledDir for .pkg archives and installs
|
||||||
// any that don't already exist in the database. Called once at startup
|
// any that don't already exist in the database. Called once at startup
|
||||||
@@ -74,7 +68,8 @@ func InstallBundledPackages(bundledDir, packagesDir, allowlist string, stores st
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check allowlist by filename (strip .pkg extension = package ID)
|
// Check allowlist by filename (strip .pkg extension = package ID)
|
||||||
if len(allowed) > 0 {
|
// nil = install all ("*"), empty map = install nothing (empty default set)
|
||||||
|
if allowed != nil {
|
||||||
pkgName := strings.TrimSuffix(entry.Name(), ".pkg")
|
pkgName := strings.TrimSuffix(entry.Name(), ".pkg")
|
||||||
if !allowed[pkgName] {
|
if !allowed[pkgName] {
|
||||||
filtered++
|
filtered++
|
||||||
|
|||||||
@@ -441,7 +441,8 @@ func TestExportPackage_NotFound(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestBundledInstall_DefaultAllowlist verifies the curated default set behavior.
|
// TestBundledInstall_DefaultAllowlist verifies that an empty allowlist
|
||||||
|
// installs nothing (defaultBundledPackages is empty since v0.6.17).
|
||||||
func TestBundledInstall_DefaultAllowlist(t *testing.T) {
|
func TestBundledInstall_DefaultAllowlist(t *testing.T) {
|
||||||
stores := newTestStores(t)
|
stores := newTestStores(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -449,7 +450,7 @@ func TestBundledInstall_DefaultAllowlist(t *testing.T) {
|
|||||||
bundledDir := t.TempDir()
|
bundledDir := t.TempDir()
|
||||||
packagesDir := t.TempDir()
|
packagesDir := t.TempDir()
|
||||||
|
|
||||||
// Create packages: one in default set, one not
|
// Create packages — neither should be installed with empty defaults
|
||||||
buildTestPkg(t, bundledDir, map[string]any{
|
buildTestPkg(t, bundledDir, map[string]any{
|
||||||
"id": "notes", "title": "Notes", "type": "surface", "version": "1.0.0",
|
"id": "notes", "title": "Notes", "type": "surface", "version": "1.0.0",
|
||||||
})
|
})
|
||||||
@@ -457,19 +458,17 @@ func TestBundledInstall_DefaultAllowlist(t *testing.T) {
|
|||||||
"id": "regex-tester", "title": "Regex Tester", "type": "extension", "version": "1.0.0",
|
"id": "regex-tester", "title": "Regex Tester", "type": "extension", "version": "1.0.0",
|
||||||
})
|
})
|
||||||
|
|
||||||
// Empty allowlist → curated defaults
|
// Empty allowlist → empty default set → nothing installed
|
||||||
InstallBundledPackages(bundledDir, packagesDir, "", stores, nil)
|
InstallBundledPackages(bundledDir, packagesDir, "", stores, nil)
|
||||||
|
|
||||||
// "notes" is in the default set → should be installed
|
pkg, _ := stores.Packages.Get(ctx, "notes")
|
||||||
pkg, err := stores.Packages.Get(ctx, "notes")
|
if pkg != nil {
|
||||||
if err != nil || pkg == nil {
|
t.Fatal("notes should NOT be installed (default set is empty)")
|
||||||
t.Fatal("notes should be installed (in default set)")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// "regex-tester" is NOT in the default set → should be filtered
|
|
||||||
pkg, _ = stores.Packages.Get(ctx, "regex-tester")
|
pkg, _ = stores.Packages.Get(ctx, "regex-tester")
|
||||||
if pkg != nil {
|
if pkg != nil {
|
||||||
t.Fatal("regex-tester should NOT be installed (not in default set)")
|
t.Fatal("regex-tester should NOT be installed (default set is empty)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -494,6 +494,10 @@ func (e *Engine) RegisterPageRoutes(base *gin.RouterGroup, mw PageRouteMiddlewar
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
handler := e.RenderSurface(s.ID)
|
handler := e.RenderSurface(s.ID)
|
||||||
|
// Welcome auto-redirects to / when extension surfaces exist
|
||||||
|
if s.ID == "welcome" {
|
||||||
|
handler = e.welcomeRedirectIfSurfaces(handler)
|
||||||
|
}
|
||||||
registerRoutes(group, s, handler)
|
registerRoutes(group, s, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -176,6 +176,28 @@ func (e *Engine) surfacePath(ctx context.Context, id string) string {
|
|||||||
return "/s/" + id
|
return "/s/" + id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// welcomeRedirectIfSurfaces wraps a welcome surface handler so that
|
||||||
|
// /welcome auto-redirects to / when any extension surface is installed.
|
||||||
|
// This means the welcome page only renders for truly empty deployments.
|
||||||
|
func (e *Engine) welcomeRedirectIfSurfaces(inner gin.HandlerFunc) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if e.stores.Packages != nil {
|
||||||
|
if pkgs, err := e.stores.Packages.List(c.Request.Context()); err == nil {
|
||||||
|
for _, p := range pkgs {
|
||||||
|
if p.Source == "core" || !p.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p.Type == "surface" || p.Type == "full" {
|
||||||
|
c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+"/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inner(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// disabledRedirect returns a handler that redirects to /.
|
// disabledRedirect returns a handler that redirects to /.
|
||||||
// The DefaultSurfaceRedirect handler will resolve to the appropriate surface.
|
// The DefaultSurfaceRedirect handler will resolve to the appropriate surface.
|
||||||
func (e *Engine) disabledRedirect() gin.HandlerFunc {
|
func (e *Engine) disabledRedirect() gin.HandlerFunc {
|
||||||
|
|||||||
@@ -288,7 +288,7 @@
|
|||||||
.admin-user-name { font-weight: 600; font-size: 13px; }
|
.admin-user-name { font-weight: 600; font-size: 13px; }
|
||||||
.admin-user-handle { font-size: 11px; color: var(--text-3); margin-top: 1px; }
|
.admin-user-handle { font-size: 11px; color: var(--text-3); margin-top: 1px; }
|
||||||
.admin-user-time { color: var(--text-3); font-size: 12px; font-family: var(--mono); }
|
.admin-user-time { color: var(--text-3); font-size: 12px; font-family: var(--mono); }
|
||||||
.admin-actions-cell { white-space: nowrap; text-align: right; }
|
.admin-actions-cell { display: flex; flex-wrap: wrap; gap: 4px; justify-content: flex-end; }
|
||||||
|
|
||||||
/* ── Provider Cards ──────────────────────── */
|
/* ── Provider Cards ──────────────────────── */
|
||||||
.provider-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: var(--sp-4); }
|
.provider-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: var(--sp-4); }
|
||||||
|
|||||||
@@ -274,8 +274,9 @@
|
|||||||
.sw-dropdown__value { flex: 1; text-align: left; }
|
.sw-dropdown__value { flex: 1; text-align: left; }
|
||||||
.sw-dropdown__chevron { color: var(--text-3); font-size: 0.7em; }
|
.sw-dropdown__chevron { color: var(--text-3); font-size: 0.7em; }
|
||||||
.sw-dropdown__list {
|
.sw-dropdown__list {
|
||||||
position: absolute; top: calc(100% + 4px); left: 0; right: 0;
|
position: absolute; top: calc(100% + 4px); left: 0;
|
||||||
z-index: 6000; max-height: 240px; overflow-y: auto;
|
min-width: max-content;
|
||||||
|
z-index: 6000; max-height: 240px; overflow-y: auto; overflow-x: hidden;
|
||||||
background: var(--bg-surface); border: 1px solid var(--border);
|
background: var(--bg-surface); border: 1px solid var(--border);
|
||||||
border-radius: var(--radius); box-shadow: var(--shadow-lg);
|
border-radius: var(--radius); box-shadow: var(--shadow-lg);
|
||||||
animation: sw-fade-in 0.1s ease;
|
animation: sw-fade-in 0.1s ease;
|
||||||
|
|||||||
@@ -193,7 +193,24 @@ export async function boot() {
|
|||||||
events.connect(BASE + '/ws');
|
events.connect(BASE + '/ws');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 10. Signal ready
|
// 10. Mount imperative hosts (Toast, DialogStack) if not already present.
|
||||||
|
// Core surfaces mount these via AppShell; extension surfaces need SDK to do it.
|
||||||
|
try {
|
||||||
|
const hostId = 'sw-imperative-hosts';
|
||||||
|
if (!document.getElementById(hostId)) {
|
||||||
|
const mount = document.createElement('div');
|
||||||
|
mount.id = hostId;
|
||||||
|
document.body.appendChild(mount);
|
||||||
|
const { ToastContainer } = await import('../primitives/toast.js');
|
||||||
|
const { DialogStack } = await import('../shell/dialog-stack.js');
|
||||||
|
const { render } = preact;
|
||||||
|
render(html`<${ToastContainer} /><${DialogStack} />`, mount);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[sw] Imperative host mount failed:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11. Signal ready
|
||||||
events.emit('sdk.ready', {}, { localOnly: true });
|
events.emit('sdk.ready', {}, { localOnly: true });
|
||||||
document.dispatchEvent(new CustomEvent('sw:ready', { detail: { sw } }));
|
document.dispatchEvent(new CustomEvent('sw:ready', { detail: { sw } }));
|
||||||
console.log(`[sw] SDK v${sw._sdk} ready`);
|
console.log(`[sw] SDK v${sw._sdk} ready`);
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ function _currentSurface() {
|
|||||||
|
|
||||||
export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
|
export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [extSurfaces, setExtSurfaces] = useState([]);
|
const [allSurfaces, setAllSurfaces] = useState([]);
|
||||||
const btnRef = useRef(null);
|
const btnRef = useRef(null);
|
||||||
|
|
||||||
const sw = window.sw;
|
const sw = window.sw;
|
||||||
@@ -35,17 +35,19 @@ export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
|
|||||||
const authenticated = sw?.auth?.isAuthenticated;
|
const authenticated = sw?.auth?.isAuthenticated;
|
||||||
|
|
||||||
// Fetch installed surfaces once on mount.
|
// Fetch installed surfaces once on mount.
|
||||||
// Filter out core surfaces that have dedicated menu entries below.
|
const CORE_IDS = new Set(['admin', 'settings', 'team-admin', 'workflow', 'workflow-landing', 'docs', 'welcome']);
|
||||||
const CORE_IDS = new Set(['admin', 'settings', 'team-admin', 'workflow', 'workflow-landing', 'docs']);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sw?.api?.surfaces?.list) return;
|
if (!sw?.api?.surfaces?.list) return;
|
||||||
sw.api.surfaces.list().then(data => {
|
sw.api.surfaces.list().then(data => {
|
||||||
const raw = Array.isArray(data) ? data : data?.data || [];
|
const raw = Array.isArray(data) ? data : data?.data || [];
|
||||||
setExtSurfaces(raw.filter(s => !CORE_IDS.has(s.id)));
|
setAllSurfaces(raw);
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}, [authenticated]);
|
}, [authenticated]);
|
||||||
|
|
||||||
|
const extSurfaces = allSurfaces.filter(s => !CORE_IDS.has(s.id));
|
||||||
|
const enabledIds = new Set(allSurfaces.map(s => s.id));
|
||||||
|
|
||||||
const items = useMemo(() => {
|
const items = useMemo(() => {
|
||||||
const current = _currentSurface();
|
const current = _currentSurface();
|
||||||
const list = [];
|
const list = [];
|
||||||
@@ -68,13 +70,13 @@ export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Standard items ─────────────────────
|
// ── Standard items ─────────────────────
|
||||||
// Docs — skip if current surface IS docs
|
// Docs — only if enabled and not current
|
||||||
if (current !== 'docs') {
|
if (current !== 'docs' && enabledIds.has('docs')) {
|
||||||
list.push({ label: 'Docs', action: 'docs', icon: '\ud83d\udcd6' });
|
list.push({ label: 'Docs', action: 'docs', icon: '\ud83d\udcd6' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settings — skip if current surface IS settings
|
// Settings — only if enabled and not current
|
||||||
if (current !== 'settings') {
|
if (current !== 'settings' && enabledIds.has('settings')) {
|
||||||
list.push({ label: 'Settings', action: 'settings', icon: '\u2699\ufe0f' });
|
list.push({ label: 'Settings', action: 'settings', icon: '\u2699\ufe0f' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +87,7 @@ export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
|
|||||||
|
|
||||||
// Team Admin — requires admin role on at least one team
|
// Team Admin — requires admin role on at least one team
|
||||||
const hasTeamAdmin = sw?.auth?.teams?.some(t => t.my_role === 'admin');
|
const hasTeamAdmin = sw?.auth?.teams?.some(t => t.my_role === 'admin');
|
||||||
if (authenticated && hasTeamAdmin && current !== 'team-admin') {
|
if (authenticated && hasTeamAdmin && current !== 'team-admin' && enabledIds.has('team-admin')) {
|
||||||
list.push({ label: 'Team Admin', action: 'team-admin', icon: '\ud83d\udc65' });
|
list.push({ label: 'Team Admin', action: 'team-admin', icon: '\ud83d\udc65' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +100,7 @@ export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
|
|||||||
list.push({ label: 'Sign Out', action: 'sign-out' });
|
list.push({ label: 'Sign Out', action: 'sign-out' });
|
||||||
|
|
||||||
return list;
|
return list;
|
||||||
}, [user, authenticated, extraItems, extSurfaces]);
|
}, [user, authenticated, extraItems, extSurfaces, enabledIds]);
|
||||||
|
|
||||||
function handleSelect(action) {
|
function handleSelect(action) {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export default function TeamsSection() {
|
|||||||
sw.api.admin.users.list(),
|
sw.api.admin.users.list(),
|
||||||
]);
|
]);
|
||||||
setMembers(m || []);
|
setMembers(m || []);
|
||||||
const uList = u || [];
|
const uList = Array.isArray(u) ? u : (u && u.data) || [];
|
||||||
setAllUsers(uList);
|
setAllUsers(uList);
|
||||||
} catch (e) { sw.toast(e.message, 'error'); }
|
} catch (e) { sw.toast(e.message, 'error'); }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user