Changeset 0.9.2 (#52)

This commit is contained in:
2026-02-23 19:31:33 +00:00
parent febcbde3d7
commit ac7c7c7d59
25 changed files with 534 additions and 54 deletions

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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:

View File

@@ -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)
--- ---

View File

@@ -1 +1 @@
0.9.1 0.9.2

View File

@@ -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}"

View File

@@ -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

View File

@@ -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", ""),
} }
} }

View 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));

View File

@@ -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) {

View File

@@ -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")

View File

@@ -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})
}

View File

@@ -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)

View File

@@ -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,

View File

@@ -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,
) )

View File

@@ -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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 682 B

View File

@@ -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

View File

@@ -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>

View File

@@ -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'); },

View File

@@ -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;

View File

@@ -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"
}, },

View File

@@ -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',
]; ];