From ac7c7c7d59a6b162976e783c14fc730816ec2dea Mon Sep 17 00:00:00 2001 From: xcaliber Date: Mon, 23 Feb 2026 19:31:33 +0000 Subject: [PATCH] Changeset 0.9.2 (#52) --- .gitea/workflows/ci.yaml | 7 + CHANGELOG.md | 29 +++++ README.md | 9 +- ROADMAP.md | 3 +- VERSION | 2 +- k8s/backend.yaml | 6 + server/.env.example | 7 + server/config/config.go | 4 + .../database/migrations/002_ci_username.sql | 14 ++ server/handlers/admin.go | 37 +++++- server/handlers/auth.go | 86 +++++++++++- server/handlers/teams.go | 122 ++++++++++++++++++ server/main.go | 7 + server/store/postgres/catalog.go | 5 +- server/store/postgres/user.go | 38 +++++- src/css/styles.css | 14 +- src/favicon-256.png | Bin 0 -> 10738 bytes src/favicon-32.png | Bin 1028 -> 1243 bytes src/favicon.ico | Bin 0 -> 682 bytes src/favicon.svg | 61 +++++++-- src/index.html | 40 +++--- src/js/api.js | 10 ++ src/js/ui.js | 80 +++++++++++- src/manifest.json | 4 +- src/sw.js | 3 +- 25 files changed, 534 insertions(+), 54 deletions(-) create mode 100644 server/database/migrations/002_ci_username.sql create mode 100644 src/favicon-256.png create mode 100644 src/favicon.ico diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index dcc5c85..f23d9ce 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -30,6 +30,7 @@ # # Required Gitea Variables: # REGISTRY, NAMESPACE, DOMAIN, POSTGRES_HOST +# SEED_USERS (optional) — CSV: "user:pass:role,..." for dev/test seed accounts # # Required Gitea Secrets: # POSTGRES_USER, POSTGRES_PASSWORD @@ -414,6 +415,7 @@ jobs: SW_ADMIN_USER: ${{ secrets.SWITCHBOARD_ADMIN_USERNAME }} SW_ADMIN_PASS: ${{ secrets.SWITCHBOARD_ADMIN_PASSWORD }} SW_ADMIN_EMAIL: ${{ secrets.SWITCHBOARD_ADMIN_EMAIL }} + SEED_USERS: ${{ vars.SEED_USERS }} run: | kubectl create secret generic switchboard-db-credentials \ --namespace=${NAMESPACE} \ @@ -428,6 +430,11 @@ jobs: --from-literal=email="${SW_ADMIN_EMAIL}" \ --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 env: ENVIRONMENT: ${{ steps.env.outputs.ENVIRONMENT }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 4db56c3..c76f313 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,35 @@ All notable changes to Chat Switchboard. +## [0.9.2] — 2026-02-23 + +### Added +- **Collapsible code blocks**: Code blocks over 15 lines auto-collapse into + `
` 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 ### Removed diff --git a/README.md b/README.md index 80243c2..4962c4d 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ All configuration via environment variables. See `server/.env.example` for the f | `JWT_SECRET` | (required) | Token signing key | | `SWITCHBOARD_ADMIN_USERNAME` | ` ` | Bootstrap admin username | | `SWITCHBOARD_ADMIN_PASSWORD` | ` ` | Bootstrap admin password | +| `SEED_USERS` | ` ` | Dev/test only: `user:pass:role,user2:pass2:role2` | ## Deployment @@ -90,8 +91,8 @@ docker compose --profile dev up # include Adminer DB UI Build and push both images: ```bash -docker build -f server/Dockerfile -t your-registry/switchboard-api:0.9.1 server/ -docker build -f Dockerfile.frontend -t your-registry/switchboard-fe:0.9.1 . +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.2 . ``` **Backend deployment:** @@ -99,7 +100,7 @@ docker build -f Dockerfile.frontend -t your-registry/switchboard-fe:0.9.1 . ```yaml containers: - name: api - image: your-registry/switchboard-api:0.9.1 + image: your-registry/switchboard-api:0.9.2 ports: - containerPort: 8080 env: @@ -119,7 +120,7 @@ containers: ```yaml containers: - name: frontend - image: your-registry/switchboard-fe:0.9.1 + image: your-registry/switchboard-fe:0.9.2 ports: - containerPort: 80 env: diff --git a/ROADMAP.md b/ROADMAP.md index cecbefb..8bb7d83 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -115,12 +115,13 @@ Polish that doesn't need new infrastructure. Ship fast, stabilize. - [x] HTML preview for generated content (sandboxed iframe toggle) - [x] Token count estimate in input area (before send) - [x] "Conversation is getting long" warning (context % thresholds) +- [x] New switchboard panel favicon (animated SVG + PNG + ICO) **Hardening** - [x] Proxy interception detection (Content-Type checks, actionable error messages) - [x] Environment injection to frontend (`window.__ENV__`) - [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) --- diff --git a/VERSION b/VERSION index f514a2f..f76f913 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.1 \ No newline at end of file +0.9.2 \ No newline at end of file diff --git a/k8s/backend.yaml b/k8s/backend.yaml index b7f2d00..f2bc441 100644 --- a/k8s/backend.yaml +++ b/k8s/backend.yaml @@ -83,6 +83,12 @@ spec: name: switchboard-admin key: email optional: true + - name: SEED_USERS + valueFrom: + secretKeyRef: + name: switchboard-seed-users + key: users + optional: true resources: requests: memory: "${BE_MEMORY_REQUEST}" diff --git a/server/.env.example b/server/.env.example index 39c7a7d..11087d7 100644 --- a/server/.env.example +++ b/server/.env.example @@ -26,6 +26,13 @@ JWT_ISSUER=chat-switchboard SWITCHBOARD_ADMIN_USERNAME= 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_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8080 diff --git a/server/config/config.go b/server/config/config.go index a0c7a64..0ad996b 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -18,6 +18,9 @@ type Config struct { AdminUsername string AdminPassword string AdminEmail string + + // Seed users (dev/test only) — CSV: "user:pass:role,user2:pass2:role2" + SeedUsers string } // Load reads configuration from environment variables. @@ -35,6 +38,7 @@ func Load() *Config { AdminUsername: getEnv("SWITCHBOARD_ADMIN_USERNAME", ""), AdminPassword: getEnv("SWITCHBOARD_ADMIN_PASSWORD", ""), AdminEmail: getEnv("SWITCHBOARD_ADMIN_EMAIL", ""), + SeedUsers: getEnv("SEED_USERS", ""), } } diff --git a/server/database/migrations/002_ci_username.sql b/server/database/migrations/002_ci_username.sql new file mode 100644 index 0000000..4f18dc9 --- /dev/null +++ b/server/database/migrations/002_ci_username.sql @@ -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)); diff --git a/server/handlers/admin.go b/server/handlers/admin.go index 3cc982e..88f6703 100644 --- a/server/handlers/admin.go +++ b/server/handlers/admin.go @@ -6,6 +6,7 @@ import ( "log" "net/http" "strconv" + "strings" "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" @@ -71,8 +72,8 @@ func (h *AdminHandler) CreateUser(c *gin.Context) { } user := &models.User{ - Username: req.Username, - Email: req.Email, + Username: strings.ToLower(req.Username), + Email: strings.ToLower(req.Email), PasswordHash: string(hash), Role: role, IsActive: true, @@ -243,7 +244,20 @@ func (h *AdminHandler) ListGlobalConfigs(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list configs"}) 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) { @@ -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}) - 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) { diff --git a/server/handlers/auth.go b/server/handlers/auth.go index 41b5ca7..38fbb9e 100644 --- a/server/handlers/auth.go +++ b/server/handlers/auth.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "log" "net/http" + "strings" "time" "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") user := &models.User{ - Username: req.Username, - Email: req.Email, + Username: strings.ToLower(req.Username), + Email: strings.ToLower(req.Email), PasswordHash: string(hash), Role: models.UserRoleUser, IsActive: defaultActive, @@ -269,8 +270,8 @@ func BootstrapAdmin(cfg *config.Config, s store.Stores) { } user := &models.User{ - Username: cfg.AdminUsername, - Email: email, + Username: strings.ToLower(cfg.AdminUsername), + Email: strings.ToLower(email), PasswordHash: string(hash), Role: models.UserRoleAdmin, IsActive: true, @@ -283,6 +284,83 @@ func BootstrapAdmin(cfg *config.Config, s store.Stores) { 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. func IsRegistrationEnabled(s store.Stores) bool { val, _ := s.Policies.GetBool(context.Background(), "allow_registration") diff --git a/server/handlers/teams.go b/server/handlers/teams.go index 1cba519..45f5324 100644 --- a/server/handlers/teams.go +++ b/server/handlers/teams.go @@ -631,3 +631,125 @@ func enforcePrivateProviderPolicy(userID, configID string) error { } 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}) +} diff --git a/server/main.go b/server/main.go index 3ba8992..99232cf 100644 --- a/server/main.go +++ b/server/main.go @@ -38,6 +38,9 @@ func main() { // Bootstrap admin from env (K8s secret) — upserts on every restart handlers.BootstrapAdmin(cfg, stores) + + // Seed additional users from env (dev/test only, skipped in production) + handlers.SeedUsers(cfg, stores) } defer database.Close() @@ -194,6 +197,10 @@ func main() { teamScoped.DELETE("/providers/:id", teams.DeleteTeamProvider) 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 teamPersonas := handlers.NewPersonaHandler(stores) teamScoped.GET("/presets", teamPersonas.ListTeamPersonas) diff --git a/server/store/postgres/catalog.go b/server/store/postgres/catalog.go index 9bbdfb8..5f3c62e 100644 --- a/server/store/postgres/catalog.go +++ b/server/store/postgres/catalog.go @@ -29,10 +29,7 @@ func (s *CatalogStore) UpsertFromSync(ctx context.Context, providerConfigID stri now := time.Now() for _, e := range entries { capsJSON := ToJSON(e.Capabilities) - var pricingJSON []byte - if e.Pricing != nil { - pricingJSON = ToJSON(e.Pricing) - } + pricingJSON := ToJSON(e.Pricing) // nil → "{}", always valid JSONB var existingID string err := DB.QueryRowContext(ctx, diff --git a/server/store/postgres/user.go b/server/store/postgres/user.go index 63c243e..a22b874 100644 --- a/server/store/postgres/user.go +++ b/server/store/postgres/user.go @@ -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) { - 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) { - 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) { @@ -42,7 +74,7 @@ func (s *UserStore) GetByLogin(ctx context.Context, login string) (*models.User, 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 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.Role, &u.IsActive, &settingsJSON, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt, ) diff --git a/src/css/styles.css b/src/css/styles.css index 48904f3..5d952f8 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -97,12 +97,14 @@ a:hover { text-decoration: underline; } background: none; border: none; color: var(--text); cursor: pointer; font-size: 13px; white-space: nowrap; transition: background var(--transition); width: 100%; + position: relative; } .sb-brand:hover { background: var(--bg-hover); } -.brand-logo { font-size: 18px; flex-shrink: 0; display: inline; } -.brand-collapse { flex-shrink: 0; display: none; color: var(--text-2); } -.sb-brand:hover .brand-logo { display: none; } -.sb-brand:hover .brand-collapse { display: inline; } +.brand-logo { flex-shrink: 0; width: 18px; height: 18px; display: grid; place-items: center; } +.brand-logo-img { width: 18px; height: 18px; object-fit: contain; border-radius: 3px; transition: opacity var(--transition); } +.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-logo-img { opacity: 0; } +.sb-brand:hover .brand-collapse { opacity: 1; } .brand-text { font-weight: 600; font-size: 14px; } .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-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 .brand-collapse { left: 50%; transform: translateX(-50%); } .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 .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; } .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 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-wordmark { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.03em; line-height: 1.1; } .hero-wordmark span { color: var(--accent); } -.brand-logo-img { width: 18px; height: 18px; object-fit: contain; border-radius: 3px; vertical-align: middle; } + .hero-headline { font-size: 2.4rem; font-weight: 700; line-height: 1.15; letter-spacing: -0.03em; margin-bottom: 1rem; diff --git a/src/favicon-256.png b/src/favicon-256.png new file mode 100644 index 0000000000000000000000000000000000000000..ff0595a35e860b05b40a0e9599e366f456052830 GIT binary patch literal 10738 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 literal 0 HcmV?d00001 diff --git a/src/favicon-32.png b/src/favicon-32.png index e1ce1b6a538861d9069136be64b818760a9a4b12..ddab172027ed1049f8ada6ee53cc1ad4951676a9 100644 GIT binary patch delta 1224 zcmV;(1ULJH2-^veBYy;7ip7{`C}{jpzk3*8oID6}SA2nV7V<3ULI zFeaRogLv>DC%t*`%FTH5q6bV&Oo&FjSYpzI5=;!nG?3INiLs%4wzQPCyF2?ivoo)U z?auD(j7YSo&*Z(l``dZ`&&T`B3;(f6Nt-85yfUHq(m_dPaDOYD1c*nP&}OGDUtW5m zfLD(m9T$@Sbs+p*zUL1hN%~d@5YW(6r6|ktbr0>WnVFeVOu%cClly$!AL_NnPCp2? z!f(t}RrM{?_+7JGho`2lIRI+sM<4HEty+I(&H+5nQxM|0Kp39`uLH=?u6f}5yIa1G z5aO9G*Yi{%cz^l$@#90G0L7OMx}KjC0{)HVx^7m%eW_alNn}J27($4|)HRwejb;-7 z(=afN3q>$rHAY}%%Vo;576N74mi^t!Sfutj&Dhl4(?bwUG zwcDtQ5`W?5AA2!w{;?O~dRI(9w+*{bI%VP9xPZHSGo;+*n>aTvkWN_#q| zb4xDgXPpSwZS;gD6!9J;m(9@fOFa7KUDW&@5Q5gmJu+#VTs9N(xon0tKj6(TSJ;== z5JGT!qeVKEig3NYVgfp4=(oXv9GSFDv$+9)F@L-rUDINGhbuPQSr{qkiCn*R1tg_l z({xouS9?$FKjV7$`$~}f2iNmxy6q-2bS;eR?{bMH5D9{X5UiKVRO=125etCzhYl&r zB%jMhCgFOiOs(FaklF>na%mB}kL&dn69Dm1T&`59<5BqFAo^IQ9pxQ|)sKIrT&a@H zq<=y#@Fd=Q@f@SMaRBa@Zt>am4=`$0eTei zcZ>5}UAPqCqAE7C*-b#a2~)O-UkOE5$~GwnH9q<297E}y z2q9RlKE$#NQnnqxXY`L^8BvmDr6Ghc`_q_B+gPT7=d=LOw&l=NWix3a#?`$dNPmzY z$R=_CvLrPnA(guV0`v>70 muq6USuquT1b0_WpEBgbY#FXCI?dk&n0000 zTU*-FqCla7N*BRMaYN8}VPr9Crc7egO^w-{7e;5im|-TGY|G@vRHKO>7e?p2m}FbF z(Cq7C(?w0Du#Jx{MU&QbpaYSxg)%CoAIFQ1%10qBH1NMW&wqQ)`TyScInVPR!A&`x zPBk1hyav1o+zS}Vt;T>ZDfxV8$a`cuC;%Le+OkA~6Ci7IY`TyR8Vunrfj}UlIGs*) zh0Osrg~P_L0W^BOV>_^Q1n;X&VPNu#ov-|qF0S1 zTu<(#ptK5r(6FD2M|aVG;mEwbmINTBt_=19U^Cm_o500jO#&zavnNI;S-w~n?J;?c;m0p>NLbTZn? z$!lohk|sI*$M)Tfj|7)(o)LheRe9hAH#)tMaetC|ALu!eb-P|Nz}$NGo^;P@*ZVv> z=nMY5WPnA_K)QO5MatZjN1d)320GVdL2ZzB`&S#$o4 zgn!T|Iw~D?D2j$k$2xQxHS6;*?+X-G+?UFGG%~@j2X-u2??0U1&*<1700zB<`>Qvg zSF^j#r#}Nyf|PW(@8!nmz=DgTu?X#F-6Y!+=yZD4-u-x10BQdPw({f+{n|ipMw`?) z8!0QQ!fGlePiNrU<2$nfs2Kxc&Ui&mC1^tmh+kI696xf&o?DzM(y_6XM~V%0Chk?j!ka>mq31JwI;sw d`Fv9V{{bY{J2K)I;&1=}002ovPDHLkV1nq}?b84N diff --git a/src/favicon.ico b/src/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..975d0610f9ac93398830549029d04ed5675fe9a1 GIT binary patch literal 682 zcmV;b0#*G00096201yxW0000W0F(j%02TlM0EtjeM-2)Z3IG5A4M|8uQUCw|5C8xG z5C{eU001BJ|6u?C0$WK$K~#90eUm+FR6!JmpSd4%?{#;x`O-)Pi=e@%MPvme zSfmlt2fyzk*C zy47ej!qL%z$-oiQr=;y=)#2eUOG`^_00l66^ZGq0+t13joB_gq0ywN6c%MVihAZN}}k~@Yt!#!HL1avxn*a87I;i(K(DUXsz*-`Y$W2y^!uU zCq3o0wX`J^aTsIyo>keKxX<^jiZK>rER~|kgUOsq(Zm>wh{LrdbjphY06`EZ>>swM z%|1gPKZ`zjk^MK10eC)F=lqyvdR+7HRg1xT4e7*(2jB#pI0E0tG}izy%{2nwC-8k% zw+sNQTZX{*apG_t$EZ8AGt)aeyB}>?0WAR<_6*^V>vTXnYM3~MdcT2xx&%IIBGTEkk~ z>+cEB&7hU$)E6eG&WuxEn4p#Bw9=gUsYR}xo@IV&kye`H#6dTM&IGQr1>aMc&4W&e zn+N!w!uJ&4_BH_cwzq-rsa`*Tn!9~t>PPeEYEhUFAkA{Dc7#T8Ea{v@n&kv8rc`;4 z#ZhF&$3~{TV?$$K<2YJ#A~+F}NHd@!l1TG63u8ILA{}nDN^e~O%v`^EJIjg}d6u0M zobGo6N-3K}(YlV~CoAtiyi~s*o<7C - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/index.html b/src/index.html index 539e711..47fb1f7 100644 --- a/src/index.html +++ b/src/index.html @@ -9,10 +9,12 @@ Chat Switchboard - - - - + + + + + + @@ -32,7 +34,7 @@