Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- Added roaming personal channel order, so a sidebar reordered on one device appears in the same order on every other device signed in to the same account, with localStorage still serving the pre-paint cache and the offline fallback. Clearing the order on one device clears it on the others, saves for one workspace are sent one at a time so the newest order is the one that lands, and positions past the 500-id roaming cap stay on the device that made them.
- Updated Virtua, Wrangler, transitive build dependencies, the pinned AWS CLI, and the PostgreSQL CI service while retaining the 48-hour release-age gate and supported runtime minimums.
- Preserve generated JavaScript literals during web builds, preventing whitespace cleanup from corrupting syntax grammars and Svelte class handling.
- Hide OpenClaw ID sign-in when the server has not configured it, and show the correct sign-in guidance for OpenClaw-only servers.
Expand Down
12 changes: 11 additions & 1 deletion apps/api/internal/httpapi/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) {
}
preferences, err := s.store.GetAppearancePreferences(r.Context(), act.user.ID)
payload := currentUserPayload{User: act.user, AppearancePreferences: preferences}
if err == nil {
payload.SidebarPreferences, err = s.store.GetSidebarPreferences(r.Context(), act.user.ID)
}
if err == nil {
payload.PasswordEnrolled, err = s.passwordEnrolled(r.Context(), act.user.ID)
}
Expand All @@ -42,6 +45,7 @@ func (s *Server) updateMe(w http.ResponseWriter, r *http.Request) {
AvatarURL *string `json:"avatar_url"`
NotificationSettings *store.NotificationSettings `json:"notification_settings"`
AppearancePreferences *store.AppearancePreferencesPatch `json:"appearance_preferences"`
SidebarPreferences *store.SidebarPreferencesPatch `json:"sidebar_preferences"`
}
if err := readJSON(w, r, &body); err != nil {
writeError(w, http.StatusBadRequest, err)
Expand All @@ -54,8 +58,13 @@ func (s *Server) updateMe(w http.ResponseWriter, r *http.Request) {
AvatarURL: body.AvatarURL,
NotificationSettings: body.NotificationSettings,
AppearancePreferences: body.AppearancePreferences,
SidebarPreferences: body.SidebarPreferences,
})
payload := currentUserPayload{User: updated.User, AppearancePreferences: updated.AppearancePreferences}
payload := currentUserPayload{
User: updated.User,
AppearancePreferences: updated.AppearancePreferences,
SidebarPreferences: updated.SidebarPreferences,
}
if err == nil {
payload.PasswordEnrolled, err = s.passwordEnrolled(r.Context(), updated.User.ID)
}
Expand All @@ -65,6 +74,7 @@ func (s *Server) updateMe(w http.ResponseWriter, r *http.Request) {
type currentUserPayload struct {
store.User
AppearancePreferences *store.AppearancePreferences `json:"appearance_preferences,omitempty"`
SidebarPreferences *store.SidebarPreferences `json:"sidebar_preferences,omitempty"`
PasswordEnrolled bool `json:"password_enrolled"`
}

Expand Down
2 changes: 2 additions & 0 deletions apps/api/internal/httpapi/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ func writeStoreError(w http.ResponseWriter, err error) {
writeError(w, http.StatusForbidden, err)
case errors.Is(err, store.ErrNotWorkspaceManager):
writeError(w, http.StatusForbidden, err)
case errors.Is(err, store.ErrNotWorkspaceMember):
writeError(w, http.StatusForbidden, err)
case errors.Is(err, store.ErrWorkspaceOwnerRequired):
writeError(w, http.StatusForbidden, err)
case errors.Is(err, store.ErrBotOwnerRequired):
Expand Down
218 changes: 218 additions & 0 deletions apps/api/internal/httpapi/sidebar_preferences_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
package httpapi

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"

"github.com/openclaw/clickclack/apps/api/internal/realtime"
"github.com/openclaw/clickclack/apps/api/internal/store"
sqlitestore "github.com/openclaw/clickclack/apps/api/internal/store/sqlite"
)

type currentUserResponse = struct {
User currentUserPayload `json:"user"`
}

// The sidebar order roams with the account, so /api/me carries it beside the
// appearance snapshot and each section patches independently.
func TestSidebarPreferencesRoamWithTheAccount(t *testing.T) {
t.Parallel()
ctx := context.Background()
dataDir := t.TempDir()
st, err := sqlitestore.Open("sqlite://" + filepath.Join(dataDir, "clickclack.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = st.Close() })
if err := st.Migrate(ctx); err != nil {
t.Fatal(err)
}
owner, err := st.EnsureBootstrap(ctx, "Owner", "sidebar-owner@example.com")
if err != nil {
t.Fatal(err)
}
workspaces, err := st.ListWorkspaces(ctx, owner.ID)
if err != nil {
t.Fatal(err)
}
workspaceID := workspaces[0].ID
first, _, err := st.CreateChannel(ctx, store.CreateChannelInput{WorkspaceID: workspaceID, Name: "aa-order", Kind: "public", UserID: owner.ID})
if err != nil {
t.Fatal(err)
}
second, _, err := st.CreateChannel(ctx, store.CreateChannelInput{WorkspaceID: workspaceID, Name: "zz-order", Kind: "public", UserID: owner.ID})
if err != nil {
t.Fatal(err)
}
server := newSidebarTestServer(t, st, dataDir)

empty := getJSON[currentUserResponse](t, server.URL+"/api/me")
if empty.User.SidebarPreferences != nil {
t.Fatalf("a fresh account reported a sidebar order: %#v", empty.User.SidebarPreferences)
}

dark := patchJSON[currentUserResponse](t, server.URL+"/api/me", map[string]any{
"appearance_preferences": map[string]any{"color_mode": "dark"},
})
if dark.User.AppearancePreferences == nil || dark.User.AppearancePreferences.ColorMode != "dark" {
t.Fatalf("appearance patch did not apply: %#v", dark.User.AppearancePreferences)
}

// A sidebar patch leaves appearance alone, drops an id that is not a
// channel of this workspace, and keeps the caller's order.
saved := patchJSON[currentUserResponse](t, server.URL+"/api/me", map[string]any{
"sidebar_preferences": map[string]any{
"channel_order": map[string]any{workspaceID: []string{second.ID, "chn_stranger", first.ID}},
},
})
assertSidebarOrder(t, saved.User.SidebarPreferences, workspaceID, second.ID, first.ID)
if saved.User.AppearancePreferences == nil || saved.User.AppearancePreferences.ColorMode != "dark" {
t.Fatalf("a sidebar patch changed appearance: %#v", saved.User.AppearancePreferences)
}

persisted := getJSON[currentUserResponse](t, server.URL+"/api/me")
assertSidebarOrder(t, persisted.User.SidebarPreferences, workspaceID, second.ID, first.ID)

// An appearance patch leaves the sidebar order alone.
light := patchJSON[currentUserResponse](t, server.URL+"/api/me", map[string]any{
"appearance_preferences": map[string]any{"color_mode": "light"},
})
if light.User.AppearancePreferences == nil || light.User.AppearancePreferences.ColorMode != "light" {
t.Fatalf("appearance patch did not apply: %#v", light.User.AppearancePreferences)
}
assertSidebarOrder(t, light.User.SidebarPreferences, workspaceID, second.ID, first.ID)

// A profile-only patch leaves both sections alone.
renamed := patchJSON[currentUserResponse](t, server.URL+"/api/me", map[string]any{"display_name": "Renamed Owner"})
if renamed.User.DisplayName != "Renamed Owner" {
t.Fatalf("profile patch did not apply: %#v", renamed.User.User)
}
assertSidebarOrder(t, renamed.User.SidebarPreferences, workspaceID, second.ID, first.ID)
if renamed.User.AppearancePreferences == nil || renamed.User.AppearancePreferences.ColorMode != "light" {
t.Fatalf("profile patch changed appearance: %#v", renamed.User.AppearancePreferences)
}

// An empty list clears that workspace, and the cleared workspace keeps its
// key. A client that cached the old order needs to see the clear; a
// response that simply omitted the workspace would look identical to one
// that never saved an order, and the cache would win on the next load.
cleared := patchJSON[currentUserResponse](t, server.URL+"/api/me", map[string]any{
"sidebar_preferences": map[string]any{"channel_order": map[string]any{workspaceID: []string{}}},
})
assertClearedSidebarOrder(t, cleared.User.SidebarPreferences, workspaceID)

afterClear := getJSON[currentUserResponse](t, server.URL+"/api/me")
assertClearedSidebarOrder(t, afterClear.User.SidebarPreferences, workspaceID)
assertClearedSidebarOrderJSON(t, server.URL+"/api/me", workspaceID)
}

func TestSidebarPreferencesRejectNonMembers(t *testing.T) {
t.Parallel()
ctx := context.Background()
dataDir := t.TempDir()
st, err := sqlitestore.Open("sqlite://" + filepath.Join(dataDir, "clickclack.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = st.Close() })
if err := st.Migrate(ctx); err != nil {
t.Fatal(err)
}
owner, err := st.EnsureBootstrap(ctx, "Owner", "sidebar-stranger-owner@example.com")
if err != nil {
t.Fatal(err)
}
workspaces, err := st.ListWorkspaces(ctx, owner.ID)
if err != nil {
t.Fatal(err)
}
private, err := st.CreateWorkspace(ctx, store.CreateWorkspaceInput{Name: "Private", Slug: "private"}, owner.ID)
if err != nil {
t.Fatal(err)
}
channel, _, err := st.CreateChannel(ctx, store.CreateChannelInput{WorkspaceID: private.ID, Name: "aa-private", Kind: "public", UserID: owner.ID})
if err != nil {
t.Fatal(err)
}
stranger, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Stranger", Email: "sidebar-stranger@example.com"})
if err != nil {
t.Fatal(err)
}
if err := st.AddWorkspaceMember(ctx, workspaces[0].ID, stranger.ID, "member"); err != nil {
t.Fatal(err)
}
server := newSidebarTestServer(t, st, dataDir)

body := `{"sidebar_preferences":{"channel_order":{"` + private.ID + `":["` + channel.ID + `"]}}}`
expectStatusAsUser(t, stranger.ID, http.MethodPatch, server.URL+"/api/me", strings.NewReader(body), http.StatusForbidden)

after := getJSONAsUser[currentUserResponse](t, stranger.ID, server.URL+"/api/me")
if after.User.SidebarPreferences != nil {
t.Fatalf("a rejected patch stored an order: %#v", after.User.SidebarPreferences)
}
}

// assertClearedSidebarOrder pins the difference a client depends on: a cleared
// workspace keeps its key and holds an empty list, where a workspace that never
// saved an order is absent.
func assertClearedSidebarOrder(t *testing.T, preferences *store.SidebarPreferences, workspaceID string) {
t.Helper()
if preferences == nil {
t.Fatalf("clearing the only workspace dropped the snapshot for %s", workspaceID)
}
got, ok := preferences.ChannelOrder[workspaceID]
if !ok {
t.Fatalf("cleared workspace %s lost its key: %#v", workspaceID, preferences.ChannelOrder)
}
if len(got) != 0 {
t.Fatalf("cleared workspace %s kept an order: %#v", workspaceID, got)
}
}

// assertClearedSidebarOrderJSON reads the wire bytes, because a nil slice would
// satisfy the typed assertion above and still reach the browser as null.
func assertClearedSidebarOrderJSON(t *testing.T, url, workspaceID string) {
t.Helper()
payload := getJSON[struct {
User struct {
SidebarPreferences struct {
ChannelOrder map[string]json.RawMessage `json:"channel_order"`
} `json:"sidebar_preferences"`
} `json:"user"`
}](t, url)
raw, ok := payload.User.SidebarPreferences.ChannelOrder[workspaceID]
if !ok {
t.Fatalf("cleared workspace %s is missing from the response body", workspaceID)
}
if string(raw) != "[]" {
t.Fatalf("cleared workspace %s serialized as %s, want []", workspaceID, raw)
}
}

func newSidebarTestServer(t *testing.T, st *sqlitestore.Store, dataDir string) *httptest.Server {
t.Helper()
server := httptest.NewServer(New(st, realtime.NewHub(), Options{UploadDir: filepath.Join(dataDir, "uploads")}).Handler())
t.Cleanup(server.Close)
return server
}

func assertSidebarOrder(t *testing.T, preferences *store.SidebarPreferences, workspaceID string, want ...string) {
t.Helper()
if preferences == nil {
t.Fatalf("expected a sidebar snapshot for %s", workspaceID)
}
got := preferences.ChannelOrder[workspaceID]
if len(got) != len(want) {
t.Fatalf("unexpected order for %s: %#v", workspaceID, got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("unexpected order for %s: %#v", workspaceID, got)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS user_sidebar_channel_order (
user_id TEXT NOT NULL,
workspace_id TEXT NOT NULL,
channel_ids TEXT NOT NULL DEFAULT '[]',
updated_at TEXT NOT NULL,
PRIMARY KEY (user_id, workspace_id),
FOREIGN KEY (workspace_id, user_id) REFERENCES workspace_members(workspace_id, user_id) ON DELETE CASCADE
);
18 changes: 17 additions & 1 deletion apps/api/internal/store/postgres/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,13 @@ func (s *Store) UpdateCurrentUser(ctx context.Context, input store.UpdateCurrent
return store.CurrentUserState{}, err
}
}
var sidebarPatch store.SidebarPreferencesPatch
if input.SidebarPreferences != nil {
sidebarPatch, err = store.NormalizeSidebarPreferencesPatch(*input.SidebarPreferences)
if err != nil {
return store.CurrentUserState{}, err
}
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return store.CurrentUserState{}, err
Expand Down Expand Up @@ -353,6 +360,11 @@ func (s *Store) UpdateCurrentUser(ctx context.Context, input store.UpdateCurrent
return store.CurrentUserState{}, err
}
}
if input.SidebarPreferences != nil && !store.SidebarPreferencesPatchEmpty(sidebarPatch) {
if err := updateSidebarPreferences(ctx, qtx, input.UserID, sidebarPatch, now()); err != nil {
return store.CurrentUserState{}, err
}
}
if err := tx.Commit(); err != nil {
return store.CurrentUserState{}, err
}
Expand All @@ -364,7 +376,11 @@ func (s *Store) UpdateCurrentUser(ctx context.Context, input store.UpdateCurrent
if err != nil {
return store.CurrentUserState{}, err
}
return store.CurrentUserState{User: user, AppearancePreferences: preferences}, nil
sidebar, err := s.GetSidebarPreferences(ctx, input.UserID)
if err != nil {
return store.CurrentUserState{}, err
}
return store.CurrentUserState{User: user, AppearancePreferences: preferences, SidebarPreferences: sidebar}, nil
}

func profileUpdateError(err error) error {
Expand Down
Loading
Loading