Changeset 0.9.2 (#52)
This commit is contained in:
@@ -30,6 +30,7 @@
|
|||||||
#
|
#
|
||||||
# Required Gitea Variables:
|
# Required Gitea Variables:
|
||||||
# REGISTRY, NAMESPACE, DOMAIN, POSTGRES_HOST
|
# REGISTRY, NAMESPACE, DOMAIN, POSTGRES_HOST
|
||||||
|
# SEED_USERS (optional) — CSV: "user:pass:role,..." for dev/test seed accounts
|
||||||
#
|
#
|
||||||
# Required Gitea Secrets:
|
# Required Gitea Secrets:
|
||||||
# POSTGRES_USER, POSTGRES_PASSWORD
|
# POSTGRES_USER, POSTGRES_PASSWORD
|
||||||
@@ -414,6 +415,7 @@ jobs:
|
|||||||
SW_ADMIN_USER: ${{ secrets.SWITCHBOARD_ADMIN_USERNAME }}
|
SW_ADMIN_USER: ${{ secrets.SWITCHBOARD_ADMIN_USERNAME }}
|
||||||
SW_ADMIN_PASS: ${{ secrets.SWITCHBOARD_ADMIN_PASSWORD }}
|
SW_ADMIN_PASS: ${{ secrets.SWITCHBOARD_ADMIN_PASSWORD }}
|
||||||
SW_ADMIN_EMAIL: ${{ secrets.SWITCHBOARD_ADMIN_EMAIL }}
|
SW_ADMIN_EMAIL: ${{ secrets.SWITCHBOARD_ADMIN_EMAIL }}
|
||||||
|
SEED_USERS: ${{ vars.SEED_USERS }}
|
||||||
run: |
|
run: |
|
||||||
kubectl create secret generic switchboard-db-credentials \
|
kubectl create secret generic switchboard-db-credentials \
|
||||||
--namespace=${NAMESPACE} \
|
--namespace=${NAMESPACE} \
|
||||||
@@ -428,6 +430,11 @@ jobs:
|
|||||||
--from-literal=email="${SW_ADMIN_EMAIL}" \
|
--from-literal=email="${SW_ADMIN_EMAIL}" \
|
||||||
--dry-run=client -o yaml | kubectl apply -f -
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
|
||||||
|
kubectl create secret generic switchboard-seed-users \
|
||||||
|
--namespace=${NAMESPACE} \
|
||||||
|
--from-literal=users="${SEED_USERS}" \
|
||||||
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
|
||||||
- name: Render and apply manifests
|
- name: Render and apply manifests
|
||||||
env:
|
env:
|
||||||
ENVIRONMENT: ${{ steps.env.outputs.ENVIRONMENT }}
|
ENVIRONMENT: ${{ steps.env.outputs.ENVIRONMENT }}
|
||||||
|
|||||||
29
CHANGELOG.md
29
CHANGELOG.md
@@ -2,6 +2,35 @@
|
|||||||
|
|
||||||
All notable changes to Chat Switchboard.
|
All notable changes to Chat Switchboard.
|
||||||
|
|
||||||
|
## [0.9.2] — 2026-02-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Collapsible code blocks**: Code blocks over 15 lines auto-collapse into
|
||||||
|
`<details>` with language and line count summary. Language label shown in
|
||||||
|
top-left corner of all code blocks.
|
||||||
|
- **HTML preview**: "Preview" button on HTML code blocks opens a sandboxed
|
||||||
|
iframe (`allow-scripts`, no `allow-same-origin`). Auto-detected for
|
||||||
|
untagged blocks via heuristic.
|
||||||
|
- **Token count estimate**: Live token counter below input area showing
|
||||||
|
approximate tokens and context usage percentage when model has `max_context`.
|
||||||
|
- **Context length warning**: Dismissable banner above input at 75% (yellow)
|
||||||
|
and 90% (red) context usage with guidance to start a new chat.
|
||||||
|
- **Proxy interception detection**: `_parseJSON()` checks Content-Type before
|
||||||
|
`.json()` on all API paths including streaming. Typed `proxyBlocked` errors
|
||||||
|
with proxy page title extraction and actionable splash messages.
|
||||||
|
- **Environment injection**: `window.__ENV__` wired through entrypoint, k8s,
|
||||||
|
and index.html for dev/test/production gating.
|
||||||
|
- **Enhanced diagnostics**: `NET:PROXY` log type, Content-Type capture in
|
||||||
|
fetch interceptor, environment info in export header and state snapshot.
|
||||||
|
- **Team admin audit scoping**: New `GET /api/v1/teams/:teamId/audit` and
|
||||||
|
`/audit/actions` endpoints scoped to team members. Activity Log section
|
||||||
|
in team manage panel with filter dropdown and pagination.
|
||||||
|
- **New favicon**: Switchboard panel design with provider-colored jacks.
|
||||||
|
Animated SVG (rotating plugs), 32px PNG, 256px PNG, and ICO.
|
||||||
|
- **Seed users** (dev/test only): `SEED_USERS=user:pass:role,...` env var
|
||||||
|
pre-creates active users on startup. Ignored in production. K8s secret
|
||||||
|
`switchboard-seed-users` wired in backend manifest.
|
||||||
|
|
||||||
## [0.9.1] — 2026-02-23
|
## [0.9.1] — 2026-02-23
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ All configuration via environment variables. See `server/.env.example` for the f
|
|||||||
| `JWT_SECRET` | (required) | Token signing key |
|
| `JWT_SECRET` | (required) | Token signing key |
|
||||||
| `SWITCHBOARD_ADMIN_USERNAME` | ` ` | Bootstrap admin username |
|
| `SWITCHBOARD_ADMIN_USERNAME` | ` ` | Bootstrap admin username |
|
||||||
| `SWITCHBOARD_ADMIN_PASSWORD` | ` ` | Bootstrap admin password |
|
| `SWITCHBOARD_ADMIN_PASSWORD` | ` ` | Bootstrap admin password |
|
||||||
|
| `SEED_USERS` | ` ` | Dev/test only: `user:pass:role,user2:pass2:role2` |
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
@@ -90,8 +91,8 @@ docker compose --profile dev up # include Adminer DB UI
|
|||||||
Build and push both images:
|
Build and push both images:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -f server/Dockerfile -t your-registry/switchboard-api:0.9.1 server/
|
docker build -f server/Dockerfile -t your-registry/switchboard-api:0.9.2 server/
|
||||||
docker build -f Dockerfile.frontend -t your-registry/switchboard-fe:0.9.1 .
|
docker build -f Dockerfile.frontend -t your-registry/switchboard-fe:0.9.2 .
|
||||||
```
|
```
|
||||||
|
|
||||||
**Backend deployment:**
|
**Backend deployment:**
|
||||||
@@ -99,7 +100,7 @@ docker build -f Dockerfile.frontend -t your-registry/switchboard-fe:0.9.1 .
|
|||||||
```yaml
|
```yaml
|
||||||
containers:
|
containers:
|
||||||
- name: api
|
- name: api
|
||||||
image: your-registry/switchboard-api:0.9.1
|
image: your-registry/switchboard-api:0.9.2
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8080
|
- containerPort: 8080
|
||||||
env:
|
env:
|
||||||
@@ -119,7 +120,7 @@ containers:
|
|||||||
```yaml
|
```yaml
|
||||||
containers:
|
containers:
|
||||||
- name: frontend
|
- name: frontend
|
||||||
image: your-registry/switchboard-fe:0.9.1
|
image: your-registry/switchboard-fe:0.9.2
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 80
|
- containerPort: 80
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -115,12 +115,13 @@ Polish that doesn't need new infrastructure. Ship fast, stabilize.
|
|||||||
- [x] HTML preview for generated content (sandboxed iframe toggle)
|
- [x] HTML preview for generated content (sandboxed iframe toggle)
|
||||||
- [x] Token count estimate in input area (before send)
|
- [x] Token count estimate in input area (before send)
|
||||||
- [x] "Conversation is getting long" warning (context % thresholds)
|
- [x] "Conversation is getting long" warning (context % thresholds)
|
||||||
|
- [x] New switchboard panel favicon (animated SVG + PNG + ICO)
|
||||||
|
|
||||||
**Hardening**
|
**Hardening**
|
||||||
- [x] Proxy interception detection (Content-Type checks, actionable error messages)
|
- [x] Proxy interception detection (Content-Type checks, actionable error messages)
|
||||||
- [x] Environment injection to frontend (`window.__ENV__`)
|
- [x] Environment injection to frontend (`window.__ENV__`)
|
||||||
- [x] Enhanced debug diagnostics (NET:PROXY log type, Content-Type capture, env info)
|
- [x] Enhanced debug diagnostics (NET:PROXY log type, Content-Type capture, env info)
|
||||||
- [ ] Team admin audit scoping (see only their team's audit entries)
|
- [x] Team admin audit scoping (see only their team's audit entries)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,12 @@ spec:
|
|||||||
name: switchboard-admin
|
name: switchboard-admin
|
||||||
key: email
|
key: email
|
||||||
optional: true
|
optional: true
|
||||||
|
- name: SEED_USERS
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: switchboard-seed-users
|
||||||
|
key: users
|
||||||
|
optional: true
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
memory: "${BE_MEMORY_REQUEST}"
|
memory: "${BE_MEMORY_REQUEST}"
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ JWT_ISSUER=chat-switchboard
|
|||||||
SWITCHBOARD_ADMIN_USERNAME=
|
SWITCHBOARD_ADMIN_USERNAME=
|
||||||
SWITCHBOARD_ADMIN_PASSWORD=
|
SWITCHBOARD_ADMIN_PASSWORD=
|
||||||
|
|
||||||
|
# ── Seed Users (dev/test only) ──────────────
|
||||||
|
# Pre-create users on startup. Ignored in production.
|
||||||
|
# Format: user:pass:role,user2:pass2:role2 (role = admin|user, default: user)
|
||||||
|
# Idempotent: existing usernames are skipped.
|
||||||
|
# K8s: create secret "switchboard-seed-users" with key "users"
|
||||||
|
SEED_USERS=
|
||||||
|
|
||||||
# ── CORS ─────────────────────────────────────
|
# ── CORS ─────────────────────────────────────
|
||||||
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8080
|
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8080
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ type Config struct {
|
|||||||
AdminUsername string
|
AdminUsername string
|
||||||
AdminPassword string
|
AdminPassword string
|
||||||
AdminEmail string
|
AdminEmail string
|
||||||
|
|
||||||
|
// Seed users (dev/test only) — CSV: "user:pass:role,user2:pass2:role2"
|
||||||
|
SeedUsers string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads configuration from environment variables.
|
// Load reads configuration from environment variables.
|
||||||
@@ -35,6 +38,7 @@ func Load() *Config {
|
|||||||
AdminUsername: getEnv("SWITCHBOARD_ADMIN_USERNAME", ""),
|
AdminUsername: getEnv("SWITCHBOARD_ADMIN_USERNAME", ""),
|
||||||
AdminPassword: getEnv("SWITCHBOARD_ADMIN_PASSWORD", ""),
|
AdminPassword: getEnv("SWITCHBOARD_ADMIN_PASSWORD", ""),
|
||||||
AdminEmail: getEnv("SWITCHBOARD_ADMIN_EMAIL", ""),
|
AdminEmail: getEnv("SWITCHBOARD_ADMIN_EMAIL", ""),
|
||||||
|
SeedUsers: getEnv("SEED_USERS", ""),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
14
server/database/migrations/002_ci_username.sql
Normal file
14
server/database/migrations/002_ci_username.sql
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
-- 002_ci_username.sql
|
||||||
|
-- Case-insensitive unique indexes for username and email.
|
||||||
|
-- Replaces the default UNIQUE constraint (which is case-sensitive).
|
||||||
|
|
||||||
|
-- Normalize existing rows to lowercase first
|
||||||
|
UPDATE users SET username = LOWER(username) WHERE username != LOWER(username);
|
||||||
|
UPDATE users SET email = LOWER(email) WHERE email != LOWER(email);
|
||||||
|
|
||||||
|
-- Drop old case-sensitive unique constraints and add case-insensitive indexes
|
||||||
|
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_username_key;
|
||||||
|
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_email_key;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS users_username_ci ON users (LOWER(username));
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS users_email_ci ON users (LOWER(email));
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
@@ -71,8 +72,8 @@ func (h *AdminHandler) CreateUser(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
user := &models.User{
|
user := &models.User{
|
||||||
Username: req.Username,
|
Username: strings.ToLower(req.Username),
|
||||||
Email: req.Email,
|
Email: strings.ToLower(req.Email),
|
||||||
PasswordHash: string(hash),
|
PasswordHash: string(hash),
|
||||||
Role: role,
|
Role: role,
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
@@ -243,7 +244,20 @@ func (h *AdminHandler) ListGlobalConfigs(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list configs"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list configs"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"configs": cfgs})
|
|
||||||
|
// Redact API keys but expose has_key flag for UI
|
||||||
|
type configWithKey struct {
|
||||||
|
models.ProviderConfig
|
||||||
|
HasKey bool `json:"has_key"`
|
||||||
|
}
|
||||||
|
out := make([]configWithKey, len(cfgs))
|
||||||
|
for i, cfg := range cfgs {
|
||||||
|
out[i] = configWithKey{
|
||||||
|
ProviderConfig: cfg,
|
||||||
|
HasKey: cfg.APIKeyEnc != "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"configs": out})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AdminHandler) CreateGlobalConfig(c *gin.Context) {
|
func (h *AdminHandler) CreateGlobalConfig(c *gin.Context) {
|
||||||
@@ -293,7 +307,22 @@ func (h *AdminHandler) CreateGlobalConfig(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h.auditLog(c, "provider.create", "provider_config", cfg.ID, gin.H{"name": cfg.Name, "provider": cfg.Provider})
|
h.auditLog(c, "provider.create", "provider_config", cfg.ID, gin.H{"name": cfg.Name, "provider": cfg.Provider})
|
||||||
c.JSON(http.StatusCreated, cfg)
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
|
"id": cfg.ID,
|
||||||
|
"scope": cfg.Scope,
|
||||||
|
"name": cfg.Name,
|
||||||
|
"provider": cfg.Provider,
|
||||||
|
"endpoint": cfg.Endpoint,
|
||||||
|
"model_default": cfg.ModelDefault,
|
||||||
|
"config": cfg.Config,
|
||||||
|
"headers": cfg.Headers,
|
||||||
|
"settings": cfg.Settings,
|
||||||
|
"is_active": cfg.IsActive,
|
||||||
|
"is_private": cfg.IsPrivate,
|
||||||
|
"has_key": cfg.APIKeyEnc != "",
|
||||||
|
"created_at": cfg.CreatedAt,
|
||||||
|
"updated_at": cfg.UpdatedAt,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AdminHandler) UpdateGlobalConfig(c *gin.Context) {
|
func (h *AdminHandler) UpdateGlobalConfig(c *gin.Context) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -77,8 +78,8 @@ func (h *AuthHandler) Register(c *gin.Context) {
|
|||||||
defaultActive, _ := h.stores.Policies.GetBool(c.Request.Context(), "default_user_active")
|
defaultActive, _ := h.stores.Policies.GetBool(c.Request.Context(), "default_user_active")
|
||||||
|
|
||||||
user := &models.User{
|
user := &models.User{
|
||||||
Username: req.Username,
|
Username: strings.ToLower(req.Username),
|
||||||
Email: req.Email,
|
Email: strings.ToLower(req.Email),
|
||||||
PasswordHash: string(hash),
|
PasswordHash: string(hash),
|
||||||
Role: models.UserRoleUser,
|
Role: models.UserRoleUser,
|
||||||
IsActive: defaultActive,
|
IsActive: defaultActive,
|
||||||
@@ -269,8 +270,8 @@ func BootstrapAdmin(cfg *config.Config, s store.Stores) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
user := &models.User{
|
user := &models.User{
|
||||||
Username: cfg.AdminUsername,
|
Username: strings.ToLower(cfg.AdminUsername),
|
||||||
Email: email,
|
Email: strings.ToLower(email),
|
||||||
PasswordHash: string(hash),
|
PasswordHash: string(hash),
|
||||||
Role: models.UserRoleAdmin,
|
Role: models.UserRoleAdmin,
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
@@ -283,6 +284,83 @@ func BootstrapAdmin(cfg *config.Config, s store.Stores) {
|
|||||||
log.Printf(" ✅ Admin user '%s' created", cfg.AdminUsername)
|
log.Printf(" ✅ Admin user '%s' created", cfg.AdminUsername)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SeedUsers creates or updates users from the SEED_USERS env var.
|
||||||
|
// Format: "user:pass:role,user2:pass2:role2" where role is "admin" or "user".
|
||||||
|
// Upsert: existing users get their password and role refreshed on every restart.
|
||||||
|
// Gated to non-production environments.
|
||||||
|
func SeedUsers(cfg *config.Config, s store.Stores) {
|
||||||
|
if cfg.SeedUsers == "" {
|
||||||
|
log.Printf(" ℹ SEED_USERS not set, skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Environment == "production" {
|
||||||
|
log.Printf("⚠ SEED_USERS ignored in production environment")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
entries := strings.Split(cfg.SeedUsers, ",")
|
||||||
|
log.Printf(" 🌱 SEED_USERS: %d entries to process", len(entries))
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
entry = strings.TrimSpace(entry)
|
||||||
|
if entry == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.SplitN(entry, ":", 3)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
log.Printf("⚠ Seed user skipped (bad format, want user:pass[:role]): %q", entry)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
username := strings.ToLower(strings.TrimSpace(parts[0]))
|
||||||
|
password := strings.TrimSpace(parts[1])
|
||||||
|
role := models.UserRoleUser
|
||||||
|
if len(parts) >= 3 && strings.TrimSpace(parts[2]) == "admin" {
|
||||||
|
role = models.UserRoleAdmin
|
||||||
|
}
|
||||||
|
|
||||||
|
if username == "" || password == "" {
|
||||||
|
log.Printf("⚠ Seed user skipped (empty username or password)")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("⚠ Seed user '%s' skipped (hash error): %v", username, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert: update password+role if user exists, create if not
|
||||||
|
existing, _ := s.Users.GetByUsername(ctx, username)
|
||||||
|
if existing != nil {
|
||||||
|
s.Users.Update(ctx, existing.ID, map[string]interface{}{
|
||||||
|
"password_hash": string(hash),
|
||||||
|
"role": role,
|
||||||
|
"is_active": true,
|
||||||
|
})
|
||||||
|
log.Printf(" 🌱 Seed user '%s' updated (role=%s)", username, role)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
user := &models.User{
|
||||||
|
Username: username,
|
||||||
|
Email: username + "@switchboard.local",
|
||||||
|
PasswordHash: string(hash),
|
||||||
|
Role: role,
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.Users.Create(ctx, user); err != nil {
|
||||||
|
log.Printf("⚠ Seed user '%s' failed: %v", username, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf(" 🌱 Seed user '%s' created (role=%s, active=true)", username, role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// IsRegistrationEnabled checks the platform policy.
|
// IsRegistrationEnabled checks the platform policy.
|
||||||
func IsRegistrationEnabled(s store.Stores) bool {
|
func IsRegistrationEnabled(s store.Stores) bool {
|
||||||
val, _ := s.Policies.GetBool(context.Background(), "allow_registration")
|
val, _ := s.Policies.GetBool(context.Background(), "allow_registration")
|
||||||
|
|||||||
@@ -631,3 +631,125 @@ func enforcePrivateProviderPolicy(userID, configID string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Team Audit Log (scoped to team members) ─
|
||||||
|
|
||||||
|
// ListTeamAuditLog returns paginated audit entries where the actor is a member
|
||||||
|
// of the specified team. Team admins see only their team's activity; system
|
||||||
|
// admins see everything (but the scoping still applies via the same query).
|
||||||
|
// GET /api/v1/teams/:teamId/audit?page=1&per_page=50&action=...&actor_id=...&resource_type=...
|
||||||
|
func (h *TeamHandler) ListTeamAuditLog(c *gin.Context) {
|
||||||
|
teamID := c.Param("teamId")
|
||||||
|
page, perPage, offset := parsePagination(c)
|
||||||
|
|
||||||
|
// Build filter clauses — always scoped to team members
|
||||||
|
where := "WHERE al.actor_id IN (SELECT user_id FROM team_members WHERE team_id = $1)"
|
||||||
|
args := []interface{}{teamID}
|
||||||
|
argN := 2
|
||||||
|
|
||||||
|
if action := c.Query("action"); action != "" {
|
||||||
|
where += " AND al.action = $" + strconv.Itoa(argN)
|
||||||
|
args = append(args, action)
|
||||||
|
argN++
|
||||||
|
}
|
||||||
|
if actorID := c.Query("actor_id"); actorID != "" {
|
||||||
|
where += " AND al.actor_id = $" + strconv.Itoa(argN)
|
||||||
|
args = append(args, actorID)
|
||||||
|
argN++
|
||||||
|
}
|
||||||
|
if rt := c.Query("resource_type"); rt != "" {
|
||||||
|
where += " AND al.resource_type = $" + strconv.Itoa(argN)
|
||||||
|
args = append(args, rt)
|
||||||
|
argN++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count
|
||||||
|
var total int
|
||||||
|
countArgs := make([]interface{}, len(args))
|
||||||
|
copy(countArgs, args)
|
||||||
|
err := database.DB.QueryRow(`SELECT COUNT(*) FROM audit_log al `+where, countArgs...).Scan(&total)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "count failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query with actor name join
|
||||||
|
query := `
|
||||||
|
SELECT al.id, al.actor_id, COALESCE(u.username, '') as actor_name,
|
||||||
|
al.action, al.resource_type, al.resource_id,
|
||||||
|
COALESCE(al.metadata::text, '{}'), al.ip_address, al.created_at
|
||||||
|
FROM audit_log al
|
||||||
|
LEFT JOIN users u ON al.actor_id = u.id
|
||||||
|
` + where + `
|
||||||
|
ORDER BY al.created_at DESC
|
||||||
|
LIMIT $` + strconv.Itoa(argN) + ` OFFSET $` + strconv.Itoa(argN+1)
|
||||||
|
args = append(args, perPage, offset)
|
||||||
|
|
||||||
|
rows, err := database.DB.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type entry struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ActorID *string `json:"actor_id"`
|
||||||
|
ActorName *string `json:"actor_name"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
ResourceType string `json:"resource_type"`
|
||||||
|
ResourceID *string `json:"resource_id"`
|
||||||
|
Metadata string `json:"metadata"`
|
||||||
|
IPAddress *string `json:"ip_address"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := make([]entry, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var e entry
|
||||||
|
var actorName sql.NullString
|
||||||
|
if err := rows.Scan(&e.ID, &e.ActorID, &actorName, &e.Action,
|
||||||
|
&e.ResourceType, &e.ResourceID, &e.Metadata, &e.IPAddress, &e.CreatedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if actorName.Valid {
|
||||||
|
e.ActorName = &actorName.String
|
||||||
|
}
|
||||||
|
entries = append(entries, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"data": entries,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"per_page": perPage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTeamAuditActions returns distinct action names for audit entries within
|
||||||
|
// the team scope, for filter dropdowns.
|
||||||
|
// GET /api/v1/teams/:teamId/audit/actions
|
||||||
|
func (h *TeamHandler) ListTeamAuditActions(c *gin.Context) {
|
||||||
|
teamID := c.Param("teamId")
|
||||||
|
|
||||||
|
rows, err := database.DB.Query(`
|
||||||
|
SELECT DISTINCT al.action
|
||||||
|
FROM audit_log al
|
||||||
|
WHERE al.actor_id IN (SELECT user_id FROM team_members WHERE team_id = $1)
|
||||||
|
ORDER BY al.action ASC
|
||||||
|
`, teamID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"actions": []string{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
actions := make([]string, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var a string
|
||||||
|
if rows.Scan(&a) == nil {
|
||||||
|
actions = append(actions, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"actions": actions})
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ func main() {
|
|||||||
|
|
||||||
// Bootstrap admin from env (K8s secret) — upserts on every restart
|
// Bootstrap admin from env (K8s secret) — upserts on every restart
|
||||||
handlers.BootstrapAdmin(cfg, stores)
|
handlers.BootstrapAdmin(cfg, stores)
|
||||||
|
|
||||||
|
// Seed additional users from env (dev/test only, skipped in production)
|
||||||
|
handlers.SeedUsers(cfg, stores)
|
||||||
}
|
}
|
||||||
defer database.Close()
|
defer database.Close()
|
||||||
|
|
||||||
@@ -194,6 +197,10 @@ func main() {
|
|||||||
teamScoped.DELETE("/providers/:id", teams.DeleteTeamProvider)
|
teamScoped.DELETE("/providers/:id", teams.DeleteTeamProvider)
|
||||||
teamScoped.GET("/providers/:id/models", teams.ListTeamProviderModels)
|
teamScoped.GET("/providers/:id/models", teams.ListTeamProviderModels)
|
||||||
|
|
||||||
|
// Team audit log (scoped to team members)
|
||||||
|
teamScoped.GET("/audit", teams.ListTeamAuditLog)
|
||||||
|
teamScoped.GET("/audit/actions", teams.ListTeamAuditActions)
|
||||||
|
|
||||||
// Team personas
|
// Team personas
|
||||||
teamPersonas := handlers.NewPersonaHandler(stores)
|
teamPersonas := handlers.NewPersonaHandler(stores)
|
||||||
teamScoped.GET("/presets", teamPersonas.ListTeamPersonas)
|
teamScoped.GET("/presets", teamPersonas.ListTeamPersonas)
|
||||||
|
|||||||
@@ -29,10 +29,7 @@ func (s *CatalogStore) UpsertFromSync(ctx context.Context, providerConfigID stri
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
capsJSON := ToJSON(e.Capabilities)
|
capsJSON := ToJSON(e.Capabilities)
|
||||||
var pricingJSON []byte
|
pricingJSON := ToJSON(e.Pricing) // nil → "{}", always valid JSONB
|
||||||
if e.Pricing != nil {
|
|
||||||
pricingJSON = ToJSON(e.Pricing)
|
|
||||||
}
|
|
||||||
|
|
||||||
var existingID string
|
var existingID string
|
||||||
err := DB.QueryRowContext(ctx,
|
err := DB.QueryRowContext(ctx,
|
||||||
|
|||||||
@@ -28,11 +28,43 @@ func (s *UserStore) GetByID(ctx context.Context, id string) (*models.User, error
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserStore) GetByUsername(ctx context.Context, username string) (*models.User, error) {
|
func (s *UserStore) GetByUsername(ctx context.Context, username string) (*models.User, error) {
|
||||||
return s.getBy(ctx, "username", username)
|
var u models.User
|
||||||
|
var displayName, avatarURL sql.NullString
|
||||||
|
var settingsJSON []byte
|
||||||
|
err := DB.QueryRowContext(ctx, `
|
||||||
|
SELECT id, username, email, password_hash, display_name, avatar_url,
|
||||||
|
role, is_active, settings, created_at, updated_at, last_login_at
|
||||||
|
FROM users WHERE LOWER(username) = LOWER($1)`, username).Scan(
|
||||||
|
&u.ID, &u.Username, &u.Email, &u.PasswordHash, &displayName, &avatarURL,
|
||||||
|
&u.Role, &u.IsActive, &settingsJSON, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
u.DisplayName = NullableString(displayName)
|
||||||
|
u.AvatarURL = NullableString(avatarURL)
|
||||||
|
ScanJSON(settingsJSON, &u.Settings)
|
||||||
|
return &u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserStore) GetByEmail(ctx context.Context, email string) (*models.User, error) {
|
func (s *UserStore) GetByEmail(ctx context.Context, email string) (*models.User, error) {
|
||||||
return s.getBy(ctx, "email", email)
|
var u models.User
|
||||||
|
var displayName, avatarURL sql.NullString
|
||||||
|
var settingsJSON []byte
|
||||||
|
err := DB.QueryRowContext(ctx, `
|
||||||
|
SELECT id, username, email, password_hash, display_name, avatar_url,
|
||||||
|
role, is_active, settings, created_at, updated_at, last_login_at
|
||||||
|
FROM users WHERE LOWER(email) = LOWER($1)`, email).Scan(
|
||||||
|
&u.ID, &u.Username, &u.Email, &u.PasswordHash, &displayName, &avatarURL,
|
||||||
|
&u.Role, &u.IsActive, &settingsJSON, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
u.DisplayName = NullableString(displayName)
|
||||||
|
u.AvatarURL = NullableString(avatarURL)
|
||||||
|
ScanJSON(settingsJSON, &u.Settings)
|
||||||
|
return &u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserStore) GetByLogin(ctx context.Context, login string) (*models.User, error) {
|
func (s *UserStore) GetByLogin(ctx context.Context, login string) (*models.User, error) {
|
||||||
@@ -42,7 +74,7 @@ func (s *UserStore) GetByLogin(ctx context.Context, login string) (*models.User,
|
|||||||
err := DB.QueryRowContext(ctx, `
|
err := DB.QueryRowContext(ctx, `
|
||||||
SELECT id, username, email, password_hash, display_name, avatar_url,
|
SELECT id, username, email, password_hash, display_name, avatar_url,
|
||||||
role, is_active, settings, created_at, updated_at, last_login_at
|
role, is_active, settings, created_at, updated_at, last_login_at
|
||||||
FROM users WHERE username = $1 OR email = $1`, login).Scan(
|
FROM users WHERE LOWER(username) = LOWER($1) OR LOWER(email) = LOWER($1)`, login).Scan(
|
||||||
&u.ID, &u.Username, &u.Email, &u.PasswordHash, &displayName, &avatarURL,
|
&u.ID, &u.Username, &u.Email, &u.PasswordHash, &displayName, &avatarURL,
|
||||||
&u.Role, &u.IsActive, &settingsJSON, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt,
|
&u.Role, &u.IsActive, &settingsJSON, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -97,12 +97,14 @@ a:hover { text-decoration: underline; }
|
|||||||
background: none; border: none; color: var(--text);
|
background: none; border: none; color: var(--text);
|
||||||
cursor: pointer; font-size: 13px; white-space: nowrap;
|
cursor: pointer; font-size: 13px; white-space: nowrap;
|
||||||
transition: background var(--transition); width: 100%;
|
transition: background var(--transition); width: 100%;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
.sb-brand:hover { background: var(--bg-hover); }
|
.sb-brand:hover { background: var(--bg-hover); }
|
||||||
.brand-logo { font-size: 18px; flex-shrink: 0; display: inline; }
|
.brand-logo { flex-shrink: 0; width: 18px; height: 18px; display: grid; place-items: center; }
|
||||||
.brand-collapse { flex-shrink: 0; display: none; color: var(--text-2); }
|
.brand-logo-img { width: 18px; height: 18px; object-fit: contain; border-radius: 3px; transition: opacity var(--transition); }
|
||||||
.sb-brand:hover .brand-logo { display: none; }
|
.brand-collapse { flex-shrink: 0; width: 0; overflow: hidden; color: var(--text-2); opacity: 0; transition: opacity var(--transition); position: absolute; left: 10px; }
|
||||||
.sb-brand:hover .brand-collapse { display: inline; }
|
.sb-brand:hover .brand-logo-img { opacity: 0; }
|
||||||
|
.sb-brand:hover .brand-collapse { opacity: 1; }
|
||||||
.brand-text { font-weight: 600; font-size: 14px; }
|
.brand-text { font-weight: 600; font-size: 14px; }
|
||||||
|
|
||||||
.sb-btn {
|
.sb-btn {
|
||||||
@@ -123,6 +125,7 @@ a:hover { text-decoration: underline; }
|
|||||||
.sidebar.collapsed .sidebar-top { padding: 10px 0 8px; align-items: center; }
|
.sidebar.collapsed .sidebar-top { padding: 10px 0 8px; align-items: center; }
|
||||||
.sidebar.collapsed .sidebar-bottom { padding: 8px 0; display: flex; flex-direction: column; align-items: center; }
|
.sidebar.collapsed .sidebar-bottom { padding: 8px 0; display: flex; flex-direction: column; align-items: center; }
|
||||||
.sidebar.collapsed .sb-brand { justify-content: center; padding: 8px 0; gap: 0; }
|
.sidebar.collapsed .sb-brand { justify-content: center; padding: 8px 0; gap: 0; }
|
||||||
|
.sidebar.collapsed .brand-collapse { left: 50%; transform: translateX(-50%); }
|
||||||
.sidebar.collapsed .sb-btn { justify-content: center; padding: 8px 0; gap: 0; }
|
.sidebar.collapsed .sb-btn { justify-content: center; padding: 8px 0; gap: 0; }
|
||||||
.sidebar.collapsed .split-btn-main { justify-content: center; padding: 8px 0; gap: 0; }
|
.sidebar.collapsed .split-btn-main { justify-content: center; padding: 8px 0; gap: 0; }
|
||||||
.sidebar.collapsed .user-btn { justify-content: center; padding: 6px 0; gap: 0; }
|
.sidebar.collapsed .user-btn { justify-content: center; padding: 6px 0; gap: 0; }
|
||||||
@@ -666,6 +669,7 @@ a:hover { text-decoration: underline; }
|
|||||||
justify-content: center; height: 100%; color: var(--text-3); gap: 0.25rem;
|
justify-content: center; height: 100%; color: var(--text-3); gap: 0.25rem;
|
||||||
}
|
}
|
||||||
.empty-logo { font-size: 3rem; margin-bottom: 0.5rem; }
|
.empty-logo { font-size: 3rem; margin-bottom: 0.5rem; }
|
||||||
|
.empty-logo-img { width: 64px; height: 64px; object-fit: contain; }
|
||||||
.empty-state h2 { font-size: 1.25rem; font-weight: 600; color: var(--text-2); }
|
.empty-state h2 { font-size: 1.25rem; font-weight: 600; color: var(--text-2); }
|
||||||
.empty-state p { font-size: 0.85rem; }
|
.empty-state p { font-size: 0.85rem; }
|
||||||
|
|
||||||
@@ -811,7 +815,7 @@ button { font-family: var(--font); cursor: pointer; }
|
|||||||
.hero-logo-img { width: 48px; height: 48px; object-fit: contain; border-radius: 8px; }
|
.hero-logo-img { width: 48px; height: 48px; object-fit: contain; border-radius: 8px; }
|
||||||
.hero-wordmark { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.03em; line-height: 1.1; }
|
.hero-wordmark { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.03em; line-height: 1.1; }
|
||||||
.hero-wordmark span { color: var(--accent); }
|
.hero-wordmark span { color: var(--accent); }
|
||||||
.brand-logo-img { width: 18px; height: 18px; object-fit: contain; border-radius: 3px; vertical-align: middle; }
|
|
||||||
.hero-headline {
|
.hero-headline {
|
||||||
font-size: 2.4rem; font-weight: 700; line-height: 1.15;
|
font-size: 2.4rem; font-weight: 700; line-height: 1.15;
|
||||||
letter-spacing: -0.03em; margin-bottom: 1rem;
|
letter-spacing: -0.03em; margin-bottom: 1rem;
|
||||||
|
|||||||
BIN
src/favicon-256.png
Normal file
BIN
src/favicon-256.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.2 KiB |
BIN
src/favicon.ico
Normal file
BIN
src/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 682 B |
@@ -1,10 +1,53 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||||
<rect width="64" height="64" rx="12" fill="#1a1a2e"/>
|
<defs>
|
||||||
<!-- Arrow 1: bottom-left → top-right -->
|
<linearGradient id="panel" x1="0" y1="0" x2="0" y2="1">
|
||||||
<path d="M12 48 L36 16" stroke="#6c9fff" stroke-width="5.5" stroke-linecap="round" fill="none"/>
|
<stop offset="0%" stop-color="#3d3935"/>
|
||||||
<polygon points="42,12 34,14 38,22" fill="#6c9fff"/>
|
<stop offset="100%" stop-color="#252220"/>
|
||||||
<!-- Arrow 2: top-left → bottom-right (gap at crossover) -->
|
</linearGradient>
|
||||||
<path d="M12 16 L24 30" stroke="#a78bfa" stroke-width="5.5" stroke-linecap="round" fill="none"/>
|
</defs>
|
||||||
<path d="M30 38 L36 48" stroke="#a78bfa" stroke-width="5.5" stroke-linecap="round" fill="none"/>
|
|
||||||
<polygon points="42,52 34,50 38,42" fill="#a78bfa"/>
|
<!-- Panel background -->
|
||||||
|
<rect x="4" y="4" width="56" height="56" rx="10" fill="url(#panel)" stroke="#4a4540" stroke-width="1.5"/>
|
||||||
|
|
||||||
|
<!-- Static jack holes (dark rings) -->
|
||||||
|
<circle cx="22" cy="23" r="8" fill="#111"/>
|
||||||
|
<circle cx="42" cy="23" r="8" fill="#111"/>
|
||||||
|
<circle cx="42" cy="43" r="8" fill="#111"/>
|
||||||
|
<circle cx="22" cy="43" r="8" fill="#111"/>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Cascading color rotation (clockwise: TL → TR → BR → BL)
|
||||||
|
dur=12s, each cascade round=3s, fade=0.4s out + 0.4s in
|
||||||
|
As dot N fades in, dot N+1 begins fading out (overlap)
|
||||||
|
|
||||||
|
Blue=#2D7DD2 Orange=#E8852E Green=#2EAA4E Purple=#9B59B6
|
||||||
|
-->
|
||||||
|
|
||||||
|
<!-- TL: Blue → Purple → Green → Orange (offset 0s) -->
|
||||||
|
<circle cx="22" cy="23" r="5.5" fill="#2D7DD2">
|
||||||
|
<animate attributeName="fill" dur="12s" repeatCount="indefinite"
|
||||||
|
keyTimes="0;0.0333;0.0667;0.25;0.2833;0.3167;0.5;0.5333;0.5667;0.75;0.7833;0.8167;1"
|
||||||
|
values="#2D7DD2;#111;#9B59B6;#9B59B6;#111;#2EAA4E;#2EAA4E;#111;#E8852E;#E8852E;#111;#2D7DD2;#2D7DD2"/>
|
||||||
|
</circle>
|
||||||
|
|
||||||
|
<!-- TR: Orange → Blue → Purple → Green (offset 0.4s) -->
|
||||||
|
<circle cx="42" cy="23" r="5.5" fill="#E8852E">
|
||||||
|
<animate attributeName="fill" dur="12s" repeatCount="indefinite"
|
||||||
|
keyTimes="0;0.0333;0.0667;0.1;0.2833;0.3167;0.35;0.5333;0.5667;0.6;0.7833;0.8167;0.85;1"
|
||||||
|
values="#E8852E;#E8852E;#111;#2D7DD2;#2D7DD2;#111;#9B59B6;#9B59B6;#111;#2EAA4E;#2EAA4E;#111;#E8852E;#E8852E"/>
|
||||||
|
</circle>
|
||||||
|
|
||||||
|
<!-- BR: Purple → Green → Orange → Blue (offset 0.8s) -->
|
||||||
|
<circle cx="42" cy="43" r="5.5" fill="#9B59B6">
|
||||||
|
<animate attributeName="fill" dur="12s" repeatCount="indefinite"
|
||||||
|
keyTimes="0;0.0667;0.1;0.1333;0.3167;0.35;0.3833;0.5667;0.6;0.6333;0.8167;0.85;0.8833;1"
|
||||||
|
values="#9B59B6;#9B59B6;#111;#2EAA4E;#2EAA4E;#111;#E8852E;#E8852E;#111;#2D7DD2;#2D7DD2;#111;#9B59B6;#9B59B6"/>
|
||||||
|
</circle>
|
||||||
|
|
||||||
|
<!-- BL: Green → Orange → Blue → Purple (offset 1.2s) -->
|
||||||
|
<circle cx="22" cy="43" r="5.5" fill="#2EAA4E">
|
||||||
|
<animate attributeName="fill" dur="12s" repeatCount="indefinite"
|
||||||
|
keyTimes="0;0.1;0.1333;0.1667;0.35;0.3833;0.4167;0.6;0.6333;0.6667;0.85;0.8833;0.9167;1"
|
||||||
|
values="#2EAA4E;#2EAA4E;#111;#E8852E;#E8852E;#111;#2D7DD2;#2D7DD2;#111;#9B59B6;#9B59B6;#111;#2EAA4E;#2EAA4E"/>
|
||||||
|
</circle>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 642 B After Width: | Height: | Size: 2.5 KiB |
@@ -9,10 +9,12 @@
|
|||||||
<script>window.__ENV__ = '%%ENVIRONMENT%%'; if (window.__ENV__.includes('%%')) window.__ENV__ = 'development';</script>
|
<script>window.__ENV__ = '%%ENVIRONMENT%%'; if (window.__ENV__.includes('%%')) window.__ENV__ = 'development';</script>
|
||||||
<script>try { window.__BRANDING__ = %%BRANDING_JSON%%; } catch(e) { window.__BRANDING__ = {}; }</script>
|
<script>try { window.__BRANDING__ = %%BRANDING_JSON%%; } catch(e) { window.__BRANDING__ = {}; }</script>
|
||||||
<title>Chat Switchboard</title>
|
<title>Chat Switchboard</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="favicon.svg?v=%%APP_VERSION%%">
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">
|
<link rel="icon" type="image/x-icon" href="favicon.ico?v=%%APP_VERSION%%">
|
||||||
<link rel="apple-touch-icon" sizes="192x192" href="favicon-192.png">
|
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png?v=%%APP_VERSION%%">
|
||||||
<link rel="manifest" href="manifest.json">
|
<link rel="icon" type="image/png" sizes="256x256" href="favicon-256.png?v=%%APP_VERSION%%">
|
||||||
|
<link rel="apple-touch-icon" sizes="256x256" href="favicon-256.png?v=%%APP_VERSION%%">
|
||||||
|
<link rel="manifest" href="manifest.json?v=%%APP_VERSION%%">
|
||||||
<meta name="theme-color" content="#0e0e10">
|
<meta name="theme-color" content="#0e0e10">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
@@ -32,7 +34,7 @@
|
|||||||
<aside class="sidebar" id="sidebar">
|
<aside class="sidebar" id="sidebar">
|
||||||
<div class="sidebar-top">
|
<div class="sidebar-top">
|
||||||
<button class="sb-brand" id="sidebarToggle" title="Toggle sidebar">
|
<button class="sb-brand" id="sidebarToggle" title="Toggle sidebar">
|
||||||
<span class="brand-logo" id="brandSidebarLogo">🔀</span>
|
<span class="brand-logo" id="brandSidebarLogo"><img src="favicon-32.png" class="brand-logo-img" alt=""></span>
|
||||||
<svg class="brand-collapse" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg>
|
<svg class="brand-collapse" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg>
|
||||||
<span class="sb-label brand-text" id="brandSidebarText">Chat Switchboard</span>
|
<span class="sb-label brand-text" id="brandSidebarText">Chat Switchboard</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -128,7 +130,7 @@
|
|||||||
|
|
||||||
<div class="messages" id="chatMessages">
|
<div class="messages" id="chatMessages">
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="empty-logo">🔀</div>
|
<div class="empty-logo"><img src="favicon-256.png" class="empty-logo-img" alt=""></div>
|
||||||
<h2>Chat Switchboard</h2>
|
<h2>Chat Switchboard</h2>
|
||||||
<p>Select a model and start chatting</p>
|
<p>Select a model and start chatting</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -168,14 +170,7 @@
|
|||||||
<div class="hero-content">
|
<div class="hero-content">
|
||||||
<div class="hero-logo-row">
|
<div class="hero-logo-row">
|
||||||
<div class="hero-logo-mark" id="brandLogo">
|
<div class="hero-logo-mark" id="brandLogo">
|
||||||
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
<img src="favicon-256.png" alt="Chat Switchboard" style="width:100%;height:100%;border-radius:12px">
|
||||||
<rect width="64" height="64" rx="12" fill="#1a1a2e"/>
|
|
||||||
<path d="M12 48 L36 16" stroke="#6c9fff" stroke-width="5.5" stroke-linecap="round" fill="none"/>
|
|
||||||
<polygon points="42,12 34,14 38,22" fill="#6c9fff"/>
|
|
||||||
<path d="M12 16 L24 30" stroke="#a78bfa" stroke-width="5.5" stroke-linecap="round" fill="none"/>
|
|
||||||
<path d="M30 38 L36 48" stroke="#a78bfa" stroke-width="5.5" stroke-linecap="round" fill="none"/>
|
|
||||||
<polygon points="42,52 34,50 38,42" fill="#a78bfa"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-wordmark" id="brandWordmark">Chat <span>Switchboard</span></div>
|
<div class="hero-wordmark" id="brandWordmark">Chat <span>Switchboard</span></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -183,7 +178,7 @@
|
|||||||
<p class="hero-sub" id="brandTagline">Route conversations to OpenAI, Anthropic, and any OpenAI-compatible provider from a single, self-hosted chat interface. Bring your own keys. Keep your data.</p>
|
<p class="hero-sub" id="brandTagline">Route conversations to OpenAI, Anthropic, and any OpenAI-compatible provider from a single, self-hosted chat interface. Bring your own keys. Keep your data.</p>
|
||||||
<div class="hero-features" id="brandPills">
|
<div class="hero-features" id="brandPills">
|
||||||
<div class="hero-pill accent"><span class="pill-icon">⚡</span> Streaming responses</div>
|
<div class="hero-pill accent"><span class="pill-icon">⚡</span> Streaming responses</div>
|
||||||
<div class="hero-pill purple"><span class="pill-icon">🔀</span> Multi-provider routing</div>
|
<div class="hero-pill purple"><span class="pill-icon"><img src="favicon-32.png" style="width:14px;height:14px;vertical-align:middle" alt=""></span> Multi-provider routing</div>
|
||||||
<div class="hero-pill"><span class="pill-icon">🔑</span> Bring your own API keys</div>
|
<div class="hero-pill"><span class="pill-icon">🔑</span> Bring your own API keys</div>
|
||||||
<div class="hero-pill"><span class="pill-icon">🏠</span> Self-hosted</div>
|
<div class="hero-pill"><span class="pill-icon">🏠</span> Self-hosted</div>
|
||||||
<div class="hero-pill"><span class="pill-icon">✈️</span> Air-gap ready</div>
|
<div class="hero-pill"><span class="pill-icon">✈️</span> Air-gap ready</div>
|
||||||
@@ -392,6 +387,21 @@
|
|||||||
<div id="settingsTeamAddPreset" style="display:none;margin-top:8px" class="admin-inline-form"></div>
|
<div id="settingsTeamAddPreset" style="display:none;margin-top:8px" class="admin-inline-form"></div>
|
||||||
<button class="btn-small" id="settingsTeamAddPresetBtn" style="margin-top:6px">+ New Team Preset</button>
|
<button class="btn-small" id="settingsTeamAddPresetBtn" style="margin-top:6px">+ New Team Preset</button>
|
||||||
</section>
|
</section>
|
||||||
|
<!-- Team Activity (audit scoped to team members) -->
|
||||||
|
<section class="settings-section">
|
||||||
|
<h4 style="font-size:13px;margin-bottom:6px">Activity Log</h4>
|
||||||
|
<div class="audit-filters" style="margin-bottom:8px">
|
||||||
|
<select id="teamAuditFilterAction" style="font-size:12px;padding:3px 6px" onchange="UI.loadTeamAuditLog()">
|
||||||
|
<option value="">All actions</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="settingsTeamAudit" style="max-height:300px;overflow-y:auto"></div>
|
||||||
|
<div id="teamAuditPagination" class="audit-pagination" style="display:none">
|
||||||
|
<button class="btn-small" id="teamAuditPrevBtn" onclick="UI.loadTeamAuditLog(UI._teamAuditPage - 1)">← Prev</button>
|
||||||
|
<span id="teamAuditPageInfo" style="font-size:11px;color:var(--text-3)"></span>
|
||||||
|
<button class="btn-small" id="teamAuditNextBtn" onclick="UI.loadTeamAuditLog(UI._teamAuditPage + 1)">Next →</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -395,6 +395,16 @@ const API = {
|
|||||||
teamUpdateProvider(teamId, providerId, updates) { return this._put(`/api/v1/teams/${teamId}/providers/${providerId}`, updates); },
|
teamUpdateProvider(teamId, providerId, updates) { return this._put(`/api/v1/teams/${teamId}/providers/${providerId}`, updates); },
|
||||||
teamDeleteProvider(teamId, providerId) { return this._del(`/api/v1/teams/${teamId}/providers/${providerId}`); },
|
teamDeleteProvider(teamId, providerId) { return this._del(`/api/v1/teams/${teamId}/providers/${providerId}`); },
|
||||||
teamListProviderModels(teamId, providerId) { return this._get(`/api/v1/teams/${teamId}/providers/${providerId}/models`); },
|
teamListProviderModels(teamId, providerId) { return this._get(`/api/v1/teams/${teamId}/providers/${providerId}/models`); },
|
||||||
|
teamListAudit(teamId, params) {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (params?.page) q.set('page', params.page);
|
||||||
|
if (params?.per_page) q.set('per_page', params.per_page);
|
||||||
|
if (params?.action) q.set('action', params.action);
|
||||||
|
if (params?.actor_id) q.set('actor_id', params.actor_id);
|
||||||
|
if (params?.resource_type) q.set('resource_type', params.resource_type);
|
||||||
|
return this._get(`/api/v1/teams/${teamId}/audit?${q}`);
|
||||||
|
},
|
||||||
|
teamListAuditActions(teamId) { return this._get(`/api/v1/teams/${teamId}/audit/actions`); },
|
||||||
|
|
||||||
// ── User Presets ─────────────────────────
|
// ── User Presets ─────────────────────────
|
||||||
listPresets() { return this._get('/api/v1/presets'); },
|
listPresets() { return this._get('/api/v1/presets'); },
|
||||||
|
|||||||
80
src/js/ui.js
80
src/js/ui.js
@@ -334,7 +334,7 @@ const UI = {
|
|||||||
showEmptyState() {
|
showEmptyState() {
|
||||||
document.getElementById('chatMessages').innerHTML = `
|
document.getElementById('chatMessages').innerHTML = `
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="empty-logo">🔀</div>
|
<div class="empty-logo"><img src="favicon-256.png" class="empty-logo-img" alt=""></div>
|
||||||
<h2>Chat Switchboard</h2>
|
<h2>Chat Switchboard</h2>
|
||||||
<p>Select a model and start chatting</p>
|
<p>Select a model and start chatting</p>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -927,6 +927,7 @@ const UI = {
|
|||||||
|
|
||||||
async openTeamManage(teamId, teamName) {
|
async openTeamManage(teamId, teamName) {
|
||||||
UI._managingTeamId = teamId;
|
UI._managingTeamId = teamId;
|
||||||
|
UI._teamAuditPage = 1;
|
||||||
document.getElementById('settingsTeamPicker').style.display = 'none';
|
document.getElementById('settingsTeamPicker').style.display = 'none';
|
||||||
const manage = document.getElementById('settingsTeamManage');
|
const manage = document.getElementById('settingsTeamManage');
|
||||||
manage.style.display = '';
|
manage.style.display = '';
|
||||||
@@ -935,10 +936,14 @@ const UI = {
|
|||||||
document.getElementById('settingsTeamAddPreset').style.display = 'none';
|
document.getElementById('settingsTeamAddPreset').style.display = 'none';
|
||||||
document.getElementById('settingsTeamAddProvider').style.display = 'none';
|
document.getElementById('settingsTeamAddProvider').style.display = 'none';
|
||||||
document.getElementById('settingsTeamEditProvider').style.display = 'none';
|
document.getElementById('settingsTeamEditProvider').style.display = 'none';
|
||||||
|
// Reset team audit filter
|
||||||
|
const af = document.getElementById('teamAuditFilterAction');
|
||||||
|
if (af) { af.innerHTML = '<option value="">All actions</option>'; }
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
UI.loadTeamManageMembers(teamId),
|
UI.loadTeamManageMembers(teamId),
|
||||||
UI.loadTeamManageProviders(teamId),
|
UI.loadTeamManageProviders(teamId),
|
||||||
UI.loadTeamManagePresets(teamId),
|
UI.loadTeamManagePresets(teamId),
|
||||||
|
UI.loadTeamAuditLog(1),
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1021,6 +1026,79 @@ const UI = {
|
|||||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
_teamAuditPage: 1,
|
||||||
|
_teamAuditPerPage: 20,
|
||||||
|
|
||||||
|
async loadTeamAuditLog(page) {
|
||||||
|
const teamId = UI._managingTeamId;
|
||||||
|
if (!teamId) return;
|
||||||
|
if (page) UI._teamAuditPage = page;
|
||||||
|
|
||||||
|
const el = document.getElementById('settingsTeamAudit');
|
||||||
|
if (!el) return;
|
||||||
|
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||||
|
|
||||||
|
// Populate action filter on first load
|
||||||
|
const actionSel = document.getElementById('teamAuditFilterAction');
|
||||||
|
if (actionSel && actionSel.options.length <= 1) {
|
||||||
|
try {
|
||||||
|
const resp = await API.teamListAuditActions(teamId);
|
||||||
|
(resp.actions || []).forEach(a => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = a;
|
||||||
|
opt.textContent = a;
|
||||||
|
actionSel.appendChild(opt);
|
||||||
|
});
|
||||||
|
} catch (e) { /* optional */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
page: UI._teamAuditPage,
|
||||||
|
per_page: UI._teamAuditPerPage,
|
||||||
|
action: document.getElementById('teamAuditFilterAction')?.value || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await API.teamListAudit(teamId, params);
|
||||||
|
const entries = resp.data || [];
|
||||||
|
const total = resp.total || 0;
|
||||||
|
const totalPages = Math.ceil(total / UI._teamAuditPerPage);
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
el.innerHTML = '<div class="empty-hint">No activity recorded for this team</div>';
|
||||||
|
} else {
|
||||||
|
el.innerHTML = entries.map(e => {
|
||||||
|
const ts = new Date(e.created_at);
|
||||||
|
const timeStr = ts.toLocaleDateString() + ' ' + ts.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
|
||||||
|
const actor = e.actor_name || 'system';
|
||||||
|
const meta = UI._formatAuditMeta(e.metadata);
|
||||||
|
return `<div class="audit-row">
|
||||||
|
<div class="audit-main">
|
||||||
|
<span class="audit-action">${esc(e.action)}</span>
|
||||||
|
<span class="audit-actor">${esc(actor)}</span>
|
||||||
|
<span class="audit-resource">${esc(e.resource_type)}${e.resource_id ? ':' + esc(e.resource_id).slice(0,8) : ''}</span>
|
||||||
|
</div>
|
||||||
|
<div class="audit-meta">
|
||||||
|
${meta ? `<span class="audit-details">${meta}</span>` : ''}
|
||||||
|
<span class="audit-time">${timeStr}</span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
const pgEl = document.getElementById('teamAuditPagination');
|
||||||
|
if (totalPages > 1) {
|
||||||
|
pgEl.style.display = 'flex';
|
||||||
|
document.getElementById('teamAuditPageInfo').textContent = `Page ${UI._teamAuditPage} of ${totalPages} (${total} entries)`;
|
||||||
|
document.getElementById('teamAuditPrevBtn').disabled = UI._teamAuditPage <= 1;
|
||||||
|
document.getElementById('teamAuditNextBtn').disabled = UI._teamAuditPage >= totalPages;
|
||||||
|
} else {
|
||||||
|
pgEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||||
|
},
|
||||||
|
|
||||||
async loadTeamPresetModelDropdown(teamId) {
|
async loadTeamPresetModelDropdown(teamId) {
|
||||||
const sel = document.getElementById('teamPreset_model');
|
const sel = document.getElementById('teamPreset_model');
|
||||||
if (!sel) return;
|
if (!sel) return;
|
||||||
|
|||||||
@@ -15,8 +15,8 @@
|
|||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "favicon-192.png",
|
"src": "favicon-256.png",
|
||||||
"sizes": "192x192",
|
"sizes": "256x256",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any maskable"
|
"purpose": "any maskable"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ const SHELL_FILES = [
|
|||||||
'./vendor/marked.min.js',
|
'./vendor/marked.min.js',
|
||||||
'./vendor/purify.min.js',
|
'./vendor/purify.min.js',
|
||||||
'./favicon.svg',
|
'./favicon.svg',
|
||||||
|
'./favicon.ico',
|
||||||
'./favicon-32.png',
|
'./favicon-32.png',
|
||||||
'./favicon-192.png',
|
'./favicon-256.png',
|
||||||
'./manifest.json',
|
'./manifest.json',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user