Skip to content

Commit 001db84

Browse files
committed
fix: scope actionable users to workspaces
1 parent 6b6c8aa commit 001db84

19 files changed

Lines changed: 328 additions & 127 deletions

frontend/src/lib/editors/MilkdownEditor.svelte

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -812,6 +812,7 @@
812812
bind:open={mentionPickerOpen}
813813
query={mentionQuery}
814814
position={mentionPosition}
815+
{workspaceId}
815816
{isPersonalWorkspace}
816817
onSelect={handleMentionSelect}
817818
onCancel={handleMentionCancel}

frontend/src/lib/features/items/Comments.svelte

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
// Get shortcut configuration (use same as description save)
2727
const submitShortcut = getShortcut('description', 'save');
2828
29-
let { itemId, isPersonalWorkspace = false, isPortalRequest = false, enableInternalComments = false, onCommentsLoaded } = $props();
29+
let { itemId, workspaceId = null, isPersonalWorkspace = false, isPortalRequest = false, enableInternalComments = false, onCommentsLoaded } = $props();
3030
3131
let comments = $state([]);
3232
let newCommentContent = $state('');
@@ -537,6 +537,7 @@
537537
showToolbar={true}
538538
compact={true}
539539
{itemId}
540+
{workspaceId}
540541
{isPersonalWorkspace}
541542
/>
542543
<div class="flex items-center justify-between mt-3">
@@ -605,6 +606,7 @@
605606
compact={true}
606607
testId="comment-composer"
607608
{itemId}
609+
{workspaceId}
608610
{isPersonalWorkspace}
609611
/>
610612
</div>

frontend/src/lib/features/items/ItemDetailDescription.svelte

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@
155155
placeholder={t('items.enterDescription')}
156156
showToolbar={true}
157157
itemId={item.id}
158+
workspaceId={item.workspace_id}
158159
onImageInsert={handleImageInsert}
159160
/>
160161
<div class="flex items-center gap-2">

frontend/src/lib/features/items/ItemDetailTabs.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -367,7 +367,7 @@
367367
<!-- Tab Content -->
368368
<div class="pt-6">
369369
{#if tab === 'comments'}
370-
<Comments itemId={item.id} isPersonalWorkspace={workspace?.is_personal} isPortalRequest={!!item.request_type_id} enableInternalComments={workspace?.internal_comments_enabled} onCommentsLoaded={handleCommentsLoaded} />
370+
<Comments itemId={item.id} workspaceId={item.workspace_id} isPersonalWorkspace={workspace?.is_personal} isPortalRequest={!!item.request_type_id} enableInternalComments={workspace?.internal_comments_enabled} onCommentsLoaded={handleCommentsLoaded} />
371371
{:else if tab === 'details'}
372372
<div class="grid gap-8" data-testid="item-details-overview">
373373
<section class="overflow-hidden rounded-xl border border-[var(--ds-border)] bg-[var(--ds-surface-raised)]" aria-label={t('items.healthOverview')}>

frontend/src/lib/features/personal/PersonalTaskDetail.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -457,7 +457,7 @@
457457
{/if}
458458
</div>
459459
</div>
460-
<Comments itemId={item.id} isPersonalWorkspace={true} onCommentsLoaded={handleCommentsLoaded} />
460+
<Comments itemId={item.id} workspaceId={item.workspace_id} isPersonalWorkspace={true} onCommentsLoaded={handleCommentsLoaded} />
461461
</div>
462462
{/if}
463463
{/snippet}

frontend/src/lib/mobile/MobileItemDetail.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -584,7 +584,7 @@
584584
<!-- Comments. Keyed on itemId so it reloads when navigating item → item. -->
585585
<section class="comments">
586586
{#key itemId}
587-
<Comments {itemId} onCommentsLoaded={() => {}} />
587+
<Comments {itemId} workspaceId={item.workspace_id} onCommentsLoaded={() => {}} />
588588
{/key}
589589
</section>
590590
</div>

frontend/src/lib/pickers/MentionPicker.svelte

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
query = '',
1616
position = { x: 0, y: 0 },
1717
open = $bindable(false),
18+
workspaceId = null,
1819
isPersonalWorkspace = false,
1920
onSelect = null,
2021
onCancel = null
@@ -23,6 +24,8 @@
2324
// State
2425
let users = $state([]);
2526
let loading = $state(false);
27+
let loadedWorkspaceId = $state(undefined);
28+
let loadRequest = 0;
2629
let highlightedIndex = $state(0);
2730
let containerElement = $state(null);
2831
// Measured menu size, used to keep the picker fully on-screen.
@@ -31,10 +34,11 @@
3134
3235
// Editors keep the picker mounted while it is closed. Defer the user
3336
// catalog until somebody actually types an @ mention; pages with several
34-
// editors otherwise issue one identical /users request per editor.
37+
// editors otherwise issue one identical roster request per editor.
3538
$effect(() => {
3639
const shouldLoad = open;
37-
if (shouldLoad) untrack(() => void loadUsers());
40+
const requestedWorkspaceId = workspaceId;
41+
if (shouldLoad) untrack(() => void loadUsers(requestedWorkspaceId));
3842
});
3943
4044
// Re-measure after result changes so the transient menu clamp stays accurate.
@@ -82,17 +86,29 @@
8286
{ capture: true }
8387
);
8488
85-
async function loadUsers() {
86-
if (loading || users.length > 0) return;
89+
async function loadUsers(requestedWorkspaceId) {
90+
const scopedWorkspaceId = requestedWorkspaceId || null;
91+
if (users.length > 0 && loadedWorkspaceId === scopedWorkspaceId) return;
92+
93+
const request = ++loadRequest;
8794
try {
8895
loading = true;
89-
users = await api.getUsers() || [];
96+
const roster = scopedWorkspaceId
97+
? await api.getAssignableUsers(scopedWorkspaceId)
98+
: await api.getUsers();
99+
if (request !== loadRequest) return;
100+
101+
// The backend roster is shared with assignment and already enforces
102+
// workspace access plus ready agent bindings.
103+
users = roster || [];
104+
loadedWorkspaceId = scopedWorkspaceId;
90105
} catch (err) {
106+
if (request !== loadRequest) return;
91107
if (err?.name === 'AbortError') return;
92108
console.error('Failed to load users:', err);
93109
users = [];
94110
} finally {
95-
loading = false;
111+
if (request === loadRequest) loading = false;
96112
}
97113
}
98114

internal/handlers/users.go

Lines changed: 24 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import (
77
"log/slog"
88
"math/big"
99
"net/http"
10-
"strconv"
1110

1211
"windshift/internal/logger"
1312
"windshift/internal/models"
@@ -28,13 +27,12 @@ type UserHandler struct {
2827
offboardUser func(id int) error
2928
deactivateCascade func(id int) (services.AgentDeactivationResult, error)
3029
invalidateSessions func(id int)
31-
agentPresence *services.AgentPresenceService // optional; nil when the agent harness is off
30+
workspaceUsers *services.WorkspaceUserResolver
3231
}
3332

34-
// SetAgentPresenceService wires the optional presence resolver used to
35-
// decorate agent users in the assignable-users response (WI-272).
36-
func (h *UserHandler) SetAgentPresenceService(s *services.AgentPresenceService) {
37-
h.agentPresence = s
33+
// SetWorkspaceUserResolver wires the shared picker and validation roster.
34+
func (h *UserHandler) SetWorkspaceUserResolver(resolver *services.WorkspaceUserResolver) {
35+
h.workspaceUsers = resolver
3836
}
3937

4038
func (h *UserHandler) invalidateUserSessions(userID int) {
@@ -770,44 +768,34 @@ func (h *UserHandler) ResetPassword(w http.ResponseWriter, r *http.Request) {
770768
respondJSONOK(w, response)
771769
}
772770

773-
// GetAssignable returns only active users with limited fields for assignment
774-
// pickers. Agent users are decorated with a workspace-scoped presence signal
775-
// (online/offline/local/unbound, WI-272) so assigners can see whether
776-
// assigning to the agent would actually start a run.
771+
// GetAssignable returns the shared mention and assignment roster. Every user
772+
// can view the workspace; agents also have a ready binding there.
777773
func (h *UserHandler) GetAssignable(w http.ResponseWriter, r *http.Request) {
778-
if _, ok := RequireAuth(w, r); !ok {
774+
currentUser, ok := RequireAuth(w, r)
775+
if !ok {
779776
return
780777
}
781-
782-
users, err := h.userSvc.ListAll()
778+
workspaceID, ok := requireIDParam(w, r, "workspaceId")
779+
if !ok {
780+
return
781+
}
782+
canView, err := h.permissionService.HasWorkspacePermission(currentUser.ID, workspaceID, models.PermissionItemView)
783783
if err != nil {
784784
respondInternalError(w, r, err)
785785
return
786786
}
787-
788-
// Presence enrichment is best-effort: a resolver failure degrades to
789-
// undecorated users, never a failed picker.
790-
var presence map[int]string
791-
if h.agentPresence != nil {
792-
if workspaceID, err := strconv.Atoi(r.PathValue("workspaceId")); err == nil && workspaceID > 0 {
793-
if presence, err = h.agentPresence.ForWorkspace(r.Context(), workspaceID); err != nil {
794-
slog.Warn("assignable users: resolve agent presence", "workspace_id", workspaceID, "error", err)
795-
presence = nil
796-
}
797-
}
787+
if !canView {
788+
respondNotFound(w, r, "workspace")
789+
return
798790
}
799-
800-
for i := range users {
801-
users[i].Email = ""
802-
users[i].Timezone = ""
803-
users[i].Language = ""
804-
if users[i].IsAgent && presence != nil {
805-
if p, ok := presence[users[i].ID]; ok {
806-
users[i].AgentPresence = p
807-
} else {
808-
users[i].AgentPresence = services.AgentPresenceUnbound
809-
}
810-
}
791+
if h.workspaceUsers == nil {
792+
respondInternalError(w, r, errors.New("workspace user resolver is not configured"))
793+
return
794+
}
795+
users, err := h.workspaceUsers.List(r.Context(), workspaceID)
796+
if err != nil {
797+
respondInternalError(w, r, err)
798+
return
811799
}
812800

813801
respondJSONOK(w, users)

internal/handlers/workspace_bootstrap.go

Lines changed: 3 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -191,32 +191,10 @@ func emptyHomepageLayout() models.WorkspaceHomepageLayout {
191191
}
192192

193193
func (h *WorkspaceBootstrapHandler) listAssignableUsers(ctx context.Context, workspaceID int) ([]models.User, error) {
194-
users, err := h.users.userSvc.ListAll()
195-
if err != nil {
196-
return nil, err
197-
}
198-
199-
var presence map[int]string
200-
if h.users.agentPresence != nil {
201-
presence, err = h.users.agentPresence.ForWorkspace(ctx, workspaceID)
202-
if err != nil {
203-
slog.Warn("workspace bootstrap: resolve agent presence", "workspace_id", workspaceID, "error", err)
204-
presence = nil
205-
}
206-
}
207-
for i := range users {
208-
users[i].Email = ""
209-
users[i].Timezone = ""
210-
users[i].Language = ""
211-
if users[i].IsAgent && presence != nil {
212-
if value, ok := presence[users[i].ID]; ok {
213-
users[i].AgentPresence = value
214-
} else {
215-
users[i].AgentPresence = services.AgentPresenceUnbound
216-
}
217-
}
194+
if h.users.workspaceUsers == nil {
195+
return nil, errors.New("workspace user resolver is not configured")
218196
}
219-
return users, nil
197+
return h.users.workspaceUsers.List(ctx, workspaceID)
220198
}
221199

222200
func (h *WorkspaceBootstrapHandler) listMilestones(userID, workspaceID int) ([]models.Milestone, error) {

internal/models/user.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,8 @@ type User struct {
4242
AgentProvenance string `json:"agent_provenance,omitempty"`
4343
OAuthClientID *int `json:"oauth_client_id,omitempty"`
4444
// AgentPresence is a transient, workspace-scoped availability signal for
45-
// assignment pickers (WI-272): online | offline | local | unbound. Never
46-
// persisted; populated only by the assignable-users endpoint.
45+
// actionable-user pickers (WI-272): online | offline | local. Unbound agents
46+
// are omitted from workspace rosters.
4747
AgentPresence string `json:"agent_presence,omitempty"`
4848
}
4949

0 commit comments

Comments
 (0)