@alliance-droid/status-feedback-system 4.2.4 → 4.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -4
- package/README.md +457 -365
- package/dist/api.d.ts +9 -2
- package/dist/api.js +4 -2
- package/dist/components/board/FeedbackItem.svelte +26 -5
- package/dist/components/board/FeedbackList.svelte +2 -2
- package/dist/components/board/FeedbackSidebar.svelte +11 -5
- package/dist/components/board/StatusBadgeMenu.svelte +32 -14
- package/dist/components/board/capabilities.d.ts +4 -0
- package/dist/components/board/capabilities.js +10 -0
- package/dist/components/board/state.svelte.d.ts +3 -3
- package/dist/components/board/state.svelte.js +32 -59
- package/dist/components/board/status-state.svelte.d.ts +10 -0
- package/dist/components/board/status-state.svelte.js +41 -0
- package/dist/sveltekit/server.js +9 -4
- package/package.json +70 -70
package/dist/api.d.ts
CHANGED
|
@@ -47,11 +47,16 @@ export interface BoardItem {
|
|
|
47
47
|
adminResponse: string | null;
|
|
48
48
|
responseCount: number;
|
|
49
49
|
createdAt: number;
|
|
50
|
+
/** Server-authorized consumer-board status control for this item. */
|
|
51
|
+
canChangeStatus?: boolean;
|
|
52
|
+
/** This item is awaiting a product decision or reply from a board manager. */
|
|
53
|
+
needsInput?: boolean;
|
|
50
54
|
}
|
|
51
55
|
export interface BoardData {
|
|
52
56
|
items: BoardItem[];
|
|
53
57
|
total: number;
|
|
54
58
|
categories: BoardCategory[];
|
|
59
|
+
canManageStatus?: boolean;
|
|
55
60
|
}
|
|
56
61
|
/** A status a privileged user may move an item to, and whether it needs a reason. */
|
|
57
62
|
export interface BoardTransition {
|
|
@@ -78,6 +83,8 @@ export interface BoardStatusUpdate {
|
|
|
78
83
|
id: string;
|
|
79
84
|
status: string;
|
|
80
85
|
publicStatus: PublicStatus;
|
|
86
|
+
canChangeStatus?: boolean;
|
|
87
|
+
needsInput?: boolean;
|
|
81
88
|
}
|
|
82
89
|
export interface StatusClient {
|
|
83
90
|
/** Get current system statuses */
|
|
@@ -103,11 +110,11 @@ export interface StatusClient {
|
|
|
103
110
|
/** Get public feedback board */
|
|
104
111
|
getBoard(options?: {
|
|
105
112
|
category?: string;
|
|
106
|
-
status?: 'open' | 'resolved' | 'all';
|
|
113
|
+
status?: 'open' | 'resolved' | 'all' | 'needs_input';
|
|
107
114
|
sort?: 'votes' | 'newest';
|
|
108
115
|
limit?: number;
|
|
109
116
|
offset?: number;
|
|
110
|
-
}): Promise<BoardData>;
|
|
117
|
+
}, token?: string | null): Promise<BoardData>;
|
|
111
118
|
/**
|
|
112
119
|
* Status transitions the token's user may make on a board item. `token` must
|
|
113
120
|
* be an Alliance OIDC **access token** whose audience covers
|
package/dist/api.js
CHANGED
|
@@ -75,7 +75,7 @@ export function createStatusClient(config) {
|
|
|
75
75
|
body: JSON.stringify(data),
|
|
76
76
|
});
|
|
77
77
|
},
|
|
78
|
-
async getBoard(options) {
|
|
78
|
+
async getBoard(options, token) {
|
|
79
79
|
const params = new URLSearchParams();
|
|
80
80
|
if (options?.category)
|
|
81
81
|
params.set('category', options.category);
|
|
@@ -88,7 +88,9 @@ export function createStatusClient(config) {
|
|
|
88
88
|
if (options?.offset)
|
|
89
89
|
params.set('offset', String(options.offset));
|
|
90
90
|
const qs = params.toString();
|
|
91
|
-
return request(`/api/board${qs ? `?${qs}` : ''}
|
|
91
|
+
return request(`/api/board${qs ? `?${qs}` : ''}`, token
|
|
92
|
+
? { headers: { Authorization: `Bearer ${token}` } }
|
|
93
|
+
: undefined);
|
|
92
94
|
},
|
|
93
95
|
async getBoardTransitions(feedbackId, token) {
|
|
94
96
|
return request(`/api/board/feedback/${feedbackId}/transitions`, {
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
import type { BoardItem } from '../../api.js';
|
|
4
4
|
import type { FeedbackBoardState } from './state.svelte.js';
|
|
5
5
|
import { timeAgo } from './utils.js';
|
|
6
|
-
import StatusBadgeMenu from './StatusBadgeMenu.svelte';
|
|
6
|
+
import StatusBadgeMenu from './StatusBadgeMenu.svelte';
|
|
7
|
+
import { canChangeItemStatus } from './capabilities.js';
|
|
7
8
|
|
|
8
9
|
interface Props {
|
|
9
10
|
board: FeedbackBoardState;
|
|
@@ -13,11 +14,13 @@
|
|
|
13
14
|
|
|
14
15
|
let { board, item, onclick }: Props = $props();
|
|
15
16
|
|
|
16
|
-
let isClickable = $derived(!!onclick);
|
|
17
|
+
let isClickable = $derived(!!onclick);
|
|
18
|
+
let canChangeStatus = $derived(canChangeItemStatus(board, item));
|
|
17
19
|
</script>
|
|
18
20
|
|
|
19
21
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
20
|
-
<article
|
|
22
|
+
<article
|
|
23
|
+
class:sf-editable={canChangeStatus}
|
|
21
24
|
class="rounded-xl border border-border bg-surface p-4 transition-all {isClickable ? 'cursor-pointer hover:border-border-strong hover:bg-surface-hover' : ''}"
|
|
22
25
|
role={isClickable ? 'button' : undefined}
|
|
23
26
|
tabindex={isClickable ? 0 : undefined}
|
|
@@ -75,7 +78,12 @@
|
|
|
75
78
|
<StatusBadgeMenu {board} {item} />
|
|
76
79
|
</div>
|
|
77
80
|
|
|
78
|
-
<!-- Meta -->
|
|
81
|
+
<!-- Meta -->
|
|
82
|
+
{#if canChangeStatus}
|
|
83
|
+
<p class="sf-action-hint">
|
|
84
|
+
{item.needsInput ? 'Needs your input' : 'You can change this status'}
|
|
85
|
+
</p>
|
|
86
|
+
{/if}
|
|
79
87
|
<div class="mt-3 flex items-center gap-3 text-xs text-foreground-tertiary">
|
|
80
88
|
{#if item.email}
|
|
81
89
|
<span class="inline-flex min-w-0 flex-1 items-center gap-1.5">
|
|
@@ -103,7 +111,20 @@
|
|
|
103
111
|
</div>
|
|
104
112
|
</article>
|
|
105
113
|
|
|
106
|
-
<style>
|
|
114
|
+
<style>
|
|
115
|
+
.sf-editable {
|
|
116
|
+
border-color: #a78bfa;
|
|
117
|
+
border-inline-start: 4px solid #8b5cf6;
|
|
118
|
+
}
|
|
119
|
+
.sf-action-hint {
|
|
120
|
+
color: #6d28d9;
|
|
121
|
+
background: #f3e8ff;
|
|
122
|
+
border-radius: 0.25rem;
|
|
123
|
+
padding: 0.15rem 0.4rem;
|
|
124
|
+
margin-top: 0.75rem;
|
|
125
|
+
font-size: 0.75rem;
|
|
126
|
+
width: fit-content;
|
|
127
|
+
}
|
|
107
128
|
.line-clamp-1,
|
|
108
129
|
.line-clamp-2 {
|
|
109
130
|
display: -webkit-box;
|
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
{:else if board.items.length === 0}
|
|
33
33
|
<div class="text-center py-12 text-foreground-tertiary">
|
|
34
34
|
<i class="fa-solid fa-inbox text-3xl"></i>
|
|
35
|
-
<p class="mt-2">No feedback yet</p>
|
|
36
|
-
<p class="text-sm">Be the first to submit
|
|
35
|
+
<p class="mt-2">{board.statusFilter === 'needs_input' ? 'No feedback needs your input' : 'No feedback yet'}</p>
|
|
36
|
+
<p class="text-sm">{board.statusFilter === 'needs_input' ? 'You’re all caught up. Choose another status to see more feedback.' : 'Be the first to submit!'}</p>
|
|
37
37
|
</div>
|
|
38
38
|
{:else}
|
|
39
39
|
<div class="flex flex-col gap-2">
|
|
@@ -8,11 +8,12 @@
|
|
|
8
8
|
|
|
9
9
|
let { board }: Props = $props();
|
|
10
10
|
|
|
11
|
-
const statusOptions = [
|
|
11
|
+
const statusOptions = $derived([
|
|
12
12
|
{ value: "open", label: "Open" },
|
|
13
13
|
{ value: "resolved", label: "Done" },
|
|
14
|
-
{ value: "all", label: "All" },
|
|
15
|
-
|
|
14
|
+
{ value: "all", label: "All" },
|
|
15
|
+
...(board.canManageStatus ? [{ value: "needs_input", label: "Needs my input" }] : []),
|
|
16
|
+
]);
|
|
16
17
|
|
|
17
18
|
const sortOptions = [
|
|
18
19
|
{ value: "votes", label: "Most Votes" },
|
|
@@ -60,7 +61,10 @@
|
|
|
60
61
|
|
|
61
62
|
<Divider class="my-2" />
|
|
62
63
|
|
|
63
|
-
<div class="sf-filter-group">
|
|
64
|
+
<div class="sf-filter-group">
|
|
65
|
+
{#if board.canManageStatus}
|
|
66
|
+
<p class="sf-editable-legend"><span aria-hidden="true"></span>Purple marks statuses you can change.</p>
|
|
67
|
+
{/if}
|
|
64
68
|
<Select
|
|
65
69
|
label="Status"
|
|
66
70
|
class="rounded-lg border-0 bg-surface-hover"
|
|
@@ -76,7 +80,9 @@
|
|
|
76
80
|
</div>
|
|
77
81
|
</aside>
|
|
78
82
|
|
|
79
|
-
<style>
|
|
83
|
+
<style>
|
|
84
|
+
.sf-editable-legend { font-size: 0.75rem; display: flex; gap: 0.5rem; align-items: center; }
|
|
85
|
+
.sf-editable-legend span { width: 0.6rem; height: 0.6rem; background: #8b5cf6; border-radius: 50%; flex-shrink: 0; }
|
|
80
86
|
/* Spacing/padding is bundled in the component (not Tailwind utilities) so it
|
|
81
87
|
applies regardless of whether the consuming app's Tailwind generates these
|
|
82
88
|
classes. gap → space between the Status and Sort By selects; the 1.25rem
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Status badge that doubles as a status-change control for privileged users.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* Items without an authorized board decision have a read-only badge.
|
|
6
|
+
* Editable items have a purple button: clicking it
|
|
7
7
|
* loads the transitions they may make and shows them in a menu; picking one
|
|
8
8
|
* applies it (prompting for a reason first when the transition requires one).
|
|
9
9
|
*
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* library's Select uses.
|
|
14
14
|
*
|
|
15
15
|
* Used in both the detail panel and the list row, so a ProductOwner can change
|
|
16
|
-
* status without opening the item. Transitions
|
|
16
|
+
* status without opening the item. Transitions refresh on demand (on each open)
|
|
17
17
|
* unless `loadOnMount` is set — the list stays lazy so it doesn't fire a
|
|
18
18
|
* request per row.
|
|
19
19
|
*/
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
import { onMount } from 'svelte';
|
|
22
22
|
import type { BoardItem, BoardTransition } from '../../api.js';
|
|
23
23
|
import type { FeedbackBoardState } from './state.svelte.js';
|
|
24
|
-
import { getStatusBadgeVariant, internalStatusLabel } from './utils.js';
|
|
24
|
+
import { getStatusBadgeVariant, internalStatusLabel } from './utils.js';
|
|
25
|
+
import { canChangeItemStatus } from './capabilities.js';
|
|
25
26
|
|
|
26
27
|
interface Props {
|
|
27
28
|
board: FeedbackBoardState;
|
|
@@ -44,7 +45,8 @@
|
|
|
44
45
|
let menuEl: HTMLDivElement | undefined = $state();
|
|
45
46
|
|
|
46
47
|
// Read from the board's shared cache so detail + list reuse the same fetch.
|
|
47
|
-
let txns = $derived(board.transitions[item.id]
|
|
48
|
+
let txns = $derived(board.transitions[item.id]?.current.status === item.status ? board.transitions[item.id] : null);
|
|
49
|
+
let canChange = $derived(canChangeItemStatus(board, item));
|
|
48
50
|
// Everyone sees the item's real granular status — `item.status` is present on
|
|
49
51
|
// every board item (the board endpoint returns it), so all rows render their
|
|
50
52
|
// actual status on first paint, no privilege or transitions fetch required.
|
|
@@ -131,7 +133,7 @@
|
|
|
131
133
|
reason = '';
|
|
132
134
|
// Open immediately so the spinner is visible while transitions load.
|
|
133
135
|
menuOpen = true;
|
|
134
|
-
|
|
136
|
+
await reload();
|
|
135
137
|
}
|
|
136
138
|
|
|
137
139
|
async function reload() {
|
|
@@ -185,18 +187,20 @@
|
|
|
185
187
|
}
|
|
186
188
|
</script>
|
|
187
189
|
|
|
188
|
-
{#if
|
|
190
|
+
{#if canChange}
|
|
189
191
|
<button
|
|
190
192
|
bind:this={triggerEl}
|
|
191
193
|
type="button"
|
|
192
|
-
class="inline-flex items-center gap-1.5 rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 disabled:opacity-60"
|
|
193
|
-
onclick={toggleMenu}
|
|
194
|
+
class="sf-status-editable inline-flex items-center gap-1.5 rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 disabled:opacity-60"
|
|
195
|
+
onclick={toggleMenu}
|
|
196
|
+
onkeydown={(e) => e.stopPropagation()}
|
|
194
197
|
aria-haspopup="listbox"
|
|
195
198
|
aria-expanded={menuOpen}
|
|
196
199
|
disabled={applying}
|
|
197
|
-
title="
|
|
198
|
-
|
|
199
|
-
|
|
200
|
+
title="You can change this status"
|
|
201
|
+
aria-label="Change status: {badgeLabel}"
|
|
202
|
+
>
|
|
203
|
+
<span>{badgeLabel}</span>
|
|
200
204
|
<i class="fa-solid fa-chevron-down text-[0.625rem] text-foreground-tertiary" aria-hidden="true"></i>
|
|
201
205
|
</button>
|
|
202
206
|
|
|
@@ -233,7 +237,7 @@
|
|
|
233
237
|
</Button>
|
|
234
238
|
</div>
|
|
235
239
|
</div>
|
|
236
|
-
{:else if txns && txns.targets.length > 0}
|
|
240
|
+
{:else if txns && txns.targets.length > 0 && !loadFailed}
|
|
237
241
|
{#each txns.targets as target (target.status)}
|
|
238
242
|
<button
|
|
239
243
|
type="button"
|
|
@@ -268,4 +272,18 @@
|
|
|
268
272
|
<Badge variant={getStatusBadgeVariant(item.publicStatus)} size="sm">
|
|
269
273
|
{badgeLabel}
|
|
270
274
|
</Badge>
|
|
271
|
-
{/if}
|
|
275
|
+
{/if}
|
|
276
|
+
|
|
277
|
+
<style>
|
|
278
|
+
.sf-status-editable {
|
|
279
|
+
flex-shrink: 0;
|
|
280
|
+
white-space: nowrap;
|
|
281
|
+
color: #6d28d9;
|
|
282
|
+
background: #f3e8ff;
|
|
283
|
+
border: 1px solid #a78bfa;
|
|
284
|
+
padding: 0.2rem 0.5rem;
|
|
285
|
+
font-size: 0.75rem;
|
|
286
|
+
font-weight: 600;
|
|
287
|
+
}
|
|
288
|
+
.sf-status-editable:hover { background: #e9d5ff; }
|
|
289
|
+
</style>
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { BoardItem } from '../../api.js';
|
|
2
|
+
import type { FeedbackBoardState } from './state.svelte.js';
|
|
3
|
+
/** Cached transitions, when current, refine the capabilities returned with the list. */
|
|
4
|
+
export declare function canChangeItemStatus(board: FeedbackBoardState, item: BoardItem): boolean;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Cached transitions, when current, refine the capabilities returned with the list. */
|
|
2
|
+
export function canChangeItemStatus(board, item) {
|
|
3
|
+
if (!board.canManageStatus)
|
|
4
|
+
return false;
|
|
5
|
+
const transitions = board.transitions[item.id];
|
|
6
|
+
if (transitions?.current.status === item.status) {
|
|
7
|
+
return transitions.canManage && transitions.targets.length > 0;
|
|
8
|
+
}
|
|
9
|
+
return item.canChangeStatus === true;
|
|
10
|
+
}
|
|
@@ -24,10 +24,10 @@ export interface FeedbackBoardOptions {
|
|
|
24
24
|
* Returns the current user's Alliance OIDC **access token** — whose audience
|
|
25
25
|
* covers `status-feedback-admin` (an id token is rejected by the backend) — or
|
|
26
26
|
* null if they aren't signed in / not privileged. When provided, privileged
|
|
27
|
-
* users (Admin / ProductOwner) can
|
|
27
|
+
* users (Admin / ProductOwner) can accept, reopen, or decline feedback. Called
|
|
28
28
|
* on demand so the host controls token refresh.
|
|
29
29
|
*/
|
|
30
|
-
getAuthToken?: () => string | Promise<string | null>;
|
|
30
|
+
getAuthToken?: () => string | null | Promise<string | null>;
|
|
31
31
|
/** Items per page. Default: 50 */
|
|
32
32
|
pageSize?: number;
|
|
33
33
|
}
|
|
@@ -63,7 +63,7 @@ export interface FeedbackBoardState {
|
|
|
63
63
|
/**
|
|
64
64
|
* Whether the current user may manage status — resolved against the backend
|
|
65
65
|
* (valid token AND a qualifying role + project access), not merely whether a
|
|
66
|
-
* token getter was provided.
|
|
66
|
+
* token getter was provided. Resolved on each board read, including empty pages,
|
|
67
67
|
* so UI can gate the status control by actual privilege.
|
|
68
68
|
*/
|
|
69
69
|
readonly canManageStatus: boolean;
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* <FeedbackSidebar {board} />
|
|
7
7
|
* <FeedbackList {board} />
|
|
8
8
|
*/
|
|
9
|
+
import { createBoardStatusState } from './status-state.svelte.js';
|
|
9
10
|
import { FEEDBACK_CATEGORY_ICONS } from '../../types.js';
|
|
10
11
|
import { createStatusClient, } from '../../api.js';
|
|
11
12
|
export function createFeedbackBoard(options) {
|
|
@@ -25,10 +26,8 @@ export function createFeedbackBoard(options) {
|
|
|
25
26
|
let votedItems = $state({});
|
|
26
27
|
let voteErrors = $state({});
|
|
27
28
|
let loadMoreError = $state('');
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
let statusPrivilege = $state(false);
|
|
31
|
-
let privilegeProbed = false;
|
|
29
|
+
const statusState = createBoardStatusState(client, getAuthToken);
|
|
30
|
+
let requestVersion = 0;
|
|
32
31
|
// Response cache
|
|
33
32
|
const responseCache = {};
|
|
34
33
|
// Track in-flight votes to prevent duplicate requests. A plain Set is correct
|
|
@@ -36,6 +35,9 @@ export function createFeedbackBoard(options) {
|
|
|
36
35
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
37
36
|
const pendingVotes = new Set();
|
|
38
37
|
async function load() {
|
|
38
|
+
const version = ++requestVersion;
|
|
39
|
+
loadingMore = false;
|
|
40
|
+
loadMoreError = '';
|
|
39
41
|
loading = true;
|
|
40
42
|
error = '';
|
|
41
43
|
try {
|
|
@@ -45,22 +47,28 @@ export function createFeedbackBoard(options) {
|
|
|
45
47
|
sort: sortBy,
|
|
46
48
|
limit: pageSize,
|
|
47
49
|
offset: 0,
|
|
48
|
-
});
|
|
50
|
+
}, await statusState.resolveToken());
|
|
51
|
+
if (version !== requestVersion)
|
|
52
|
+
return;
|
|
49
53
|
items = data.items;
|
|
50
54
|
total = data.total;
|
|
51
55
|
categories = data.categories;
|
|
52
|
-
|
|
56
|
+
statusState.reset(data.canManageStatus ?? false);
|
|
53
57
|
}
|
|
54
58
|
catch (e) {
|
|
59
|
+
if (version !== requestVersion)
|
|
60
|
+
return;
|
|
55
61
|
error = e instanceof Error ? e.message : 'Failed to load';
|
|
56
62
|
}
|
|
57
63
|
finally {
|
|
58
|
-
|
|
64
|
+
if (version === requestVersion)
|
|
65
|
+
loading = false;
|
|
59
66
|
}
|
|
60
67
|
}
|
|
61
68
|
async function loadMore() {
|
|
62
|
-
if (loadingMore || items.length >= total)
|
|
69
|
+
if (loading || loadingMore || items.length >= total)
|
|
63
70
|
return;
|
|
71
|
+
const version = requestVersion;
|
|
64
72
|
loadingMore = true;
|
|
65
73
|
loadMoreError = '';
|
|
66
74
|
try {
|
|
@@ -70,15 +78,21 @@ export function createFeedbackBoard(options) {
|
|
|
70
78
|
sort: sortBy,
|
|
71
79
|
limit: pageSize,
|
|
72
80
|
offset: items.length,
|
|
73
|
-
});
|
|
81
|
+
}, await statusState.resolveToken());
|
|
82
|
+
if (version !== requestVersion)
|
|
83
|
+
return;
|
|
74
84
|
items = [...items, ...data.items];
|
|
75
85
|
total = data.total;
|
|
86
|
+
statusState.reset(data.canManageStatus ?? false);
|
|
76
87
|
}
|
|
77
88
|
catch (e) {
|
|
89
|
+
if (version !== requestVersion)
|
|
90
|
+
return;
|
|
78
91
|
loadMoreError = e instanceof Error ? e.message : 'Failed to load more';
|
|
79
92
|
}
|
|
80
93
|
finally {
|
|
81
|
-
|
|
94
|
+
if (version === requestVersion)
|
|
95
|
+
loadingMore = false;
|
|
82
96
|
}
|
|
83
97
|
}
|
|
84
98
|
// Auto-reload when filters change
|
|
@@ -167,51 +181,8 @@ export function createFeedbackBoard(options) {
|
|
|
167
181
|
if (item)
|
|
168
182
|
item.responseCount += 1;
|
|
169
183
|
}
|
|
170
|
-
async function resolveToken() {
|
|
171
|
-
if (!getAuthToken)
|
|
172
|
-
return null;
|
|
173
|
-
try {
|
|
174
|
-
return (await getAuthToken()) ?? null;
|
|
175
|
-
}
|
|
176
|
-
catch {
|
|
177
|
-
return null;
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
async function loadTransitions(itemId) {
|
|
181
|
-
const token = await resolveToken();
|
|
182
|
-
if (!token)
|
|
183
|
-
return null;
|
|
184
|
-
try {
|
|
185
|
-
const result = await client.getBoardTransitions(itemId, token);
|
|
186
|
-
transitions = { ...transitions, [itemId]: result };
|
|
187
|
-
// The backend tells us whether this user may manage status (200 either
|
|
188
|
-
// way — no 403 console noise for ordinary signed-in users).
|
|
189
|
-
statusPrivilege = result.canManage;
|
|
190
|
-
return result;
|
|
191
|
-
}
|
|
192
|
-
catch {
|
|
193
|
-
// 401 (no/invalid token) or network error → no control.
|
|
194
|
-
return null;
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
/**
|
|
198
|
-
* One-time probe so the UI can gate the status control by *actual* privilege
|
|
199
|
-
* (valid token + qualifying role + project access), not merely token presence.
|
|
200
|
-
* Loads the first item's transitions once; a non-null result confirms the
|
|
201
|
-
* backend authorized the user. No token / no items / a 403 leaves it `false`.
|
|
202
|
-
*/
|
|
203
|
-
async function ensureStatusPrivilege() {
|
|
204
|
-
if (privilegeProbed || !getAuthToken)
|
|
205
|
-
return;
|
|
206
|
-
const sample = items[0];
|
|
207
|
-
if (!sample)
|
|
208
|
-
return;
|
|
209
|
-
privilegeProbed = true;
|
|
210
|
-
// loadTransitions sets statusPrivilege from the result's canManage flag.
|
|
211
|
-
await loadTransitions(sample.id);
|
|
212
|
-
}
|
|
213
184
|
async function setStatus(itemId, status, reason) {
|
|
214
|
-
const token = await resolveToken();
|
|
185
|
+
const token = await statusState.resolveToken();
|
|
215
186
|
if (!token)
|
|
216
187
|
throw new Error('Authentication required to set status');
|
|
217
188
|
const result = await client.updateBoardStatus(itemId, { status, reason }, token);
|
|
@@ -219,9 +190,11 @@ export function createFeedbackBoard(options) {
|
|
|
219
190
|
if (item) {
|
|
220
191
|
item.status = result.status;
|
|
221
192
|
item.publicStatus = result.publicStatus;
|
|
193
|
+
item.canChangeStatus = result.canChangeStatus;
|
|
194
|
+
item.needsInput = result.needsInput;
|
|
222
195
|
}
|
|
223
|
-
//
|
|
224
|
-
await
|
|
196
|
+
// Re-query to keep the current filter, counts, and pagination accurate.
|
|
197
|
+
await load();
|
|
225
198
|
}
|
|
226
199
|
return {
|
|
227
200
|
get client() { return client; },
|
|
@@ -249,9 +222,9 @@ export function createFeedbackBoard(options) {
|
|
|
249
222
|
getResponses,
|
|
250
223
|
submitFeedback,
|
|
251
224
|
submitResponse,
|
|
252
|
-
get canManageStatus() { return
|
|
253
|
-
get transitions() { return transitions; },
|
|
254
|
-
loadTransitions,
|
|
225
|
+
get canManageStatus() { return statusState.canManage; },
|
|
226
|
+
get transitions() { return statusState.transitions; },
|
|
227
|
+
loadTransitions: statusState.loadTransitions,
|
|
255
228
|
setStatus,
|
|
256
229
|
getCategoryIcon(name) {
|
|
257
230
|
return (categoryConfig[name]?.icon ||
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Server-authorized status capabilities and transition cache for a single board. */
|
|
2
|
+
import type { BoardTransitions, StatusClient } from '../../api.js';
|
|
3
|
+
import type { FeedbackBoardOptions } from './state.svelte.js';
|
|
4
|
+
export declare function createBoardStatusState(client: StatusClient, getAuthToken: FeedbackBoardOptions['getAuthToken']): {
|
|
5
|
+
readonly canManage: boolean;
|
|
6
|
+
readonly transitions: Record<string, BoardTransitions>;
|
|
7
|
+
resolveToken: () => Promise<string | null>;
|
|
8
|
+
loadTransitions: (itemId: string) => Promise<BoardTransitions | null>;
|
|
9
|
+
reset(allowed: boolean): void;
|
|
10
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export function createBoardStatusState(client, getAuthToken) {
|
|
2
|
+
let canManage = $state(false);
|
|
3
|
+
let transitions = $state({});
|
|
4
|
+
let generation = 0;
|
|
5
|
+
async function resolveToken() {
|
|
6
|
+
try {
|
|
7
|
+
return (await getAuthToken?.()) ?? null;
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
async function loadTransitions(itemId) {
|
|
14
|
+
const currentGeneration = generation;
|
|
15
|
+
const token = await resolveToken();
|
|
16
|
+
if (!token)
|
|
17
|
+
return null;
|
|
18
|
+
try {
|
|
19
|
+
const result = await client.getBoardTransitions(itemId, token);
|
|
20
|
+
if (currentGeneration !== generation)
|
|
21
|
+
return null;
|
|
22
|
+
transitions = { ...transitions, [itemId]: result };
|
|
23
|
+
canManage = result.canManage;
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
get canManage() { return canManage; },
|
|
32
|
+
get transitions() { return transitions; },
|
|
33
|
+
resolveToken,
|
|
34
|
+
loadTransitions,
|
|
35
|
+
reset(allowed) {
|
|
36
|
+
generation += 1;
|
|
37
|
+
canManage = allowed;
|
|
38
|
+
transitions = {};
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
package/dist/sveltekit/server.js
CHANGED
|
@@ -64,11 +64,15 @@ async function handleProxyRequest(event, options) {
|
|
|
64
64
|
if (route.forwardBearerToken) {
|
|
65
65
|
const authorization = event.request.headers.get('authorization');
|
|
66
66
|
if (!authorization?.toLowerCase().startsWith('bearer ')) {
|
|
67
|
-
if (route.
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
if (route.forwardBearerToken !== 'optional') {
|
|
68
|
+
if (route.operation === 'transitions')
|
|
69
|
+
return statusManagementUnavailable();
|
|
70
|
+
return feedbackError(401, 'Status feedback access token required');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
headers.set('Authorization', authorization);
|
|
70
75
|
}
|
|
71
|
-
headers.set('Authorization', authorization);
|
|
72
76
|
}
|
|
73
77
|
const method = event.request.method.toUpperCase();
|
|
74
78
|
let body;
|
|
@@ -137,6 +141,7 @@ function getRouteSpec(method, path) {
|
|
|
137
141
|
upstreamPath: '/api/board',
|
|
138
142
|
bodyKind: 'none',
|
|
139
143
|
allowedSearchParams: BOARD_QUERY_KEYS,
|
|
144
|
+
forwardBearerToken: 'optional',
|
|
140
145
|
};
|
|
141
146
|
}
|
|
142
147
|
if (normalizedMethod === 'POST' && normalizedPath === 'api/board/vote') {
|