@alliance-droid/status-feedback-system 4.3.1 → 4.3.3

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/dist/api.d.ts CHANGED
@@ -47,6 +47,8 @@ export interface BoardItem {
47
47
  adminResponse: string | null;
48
48
  responseCount: number;
49
49
  createdAt: number;
50
+ /** Safe, own-origin URL of the reporter's pasted screenshot (server-validated), or null. */
51
+ screenshotUrl?: string | null;
50
52
  /** Server-authorized consumer-board status control for this item. */
51
53
  canChangeStatus?: boolean;
52
54
  /** This item is awaiting a product decision or reply from a board manager. */
@@ -1,9 +1,10 @@
1
1
  <script lang="ts">
2
- import { Spinner, Button, Textarea, Divider } from '@alliance-droid/svelte-component-library';
2
+ import { Spinner, Button, Textarea, Divider } from '@alliance-droid/svelte-component-library';
3
3
  import type { BoardItem } from '../../api.js';
4
4
  import type { FeedbackResponse } from '../../types.js';
5
5
  import type { FeedbackBoardState } from './state.svelte.js';
6
6
  import { timeAgo } from './utils.js';
7
+ import { safeScreenshotSrc } from './screenshot-src.js';
7
8
  import StatusBadgeMenu from './StatusBadgeMenu.svelte';
8
9
 
9
10
  interface Props {
@@ -13,6 +14,10 @@
13
14
 
14
15
  let { board, item }: Props = $props();
15
16
 
17
+ // Client-side scheme allowlist over the server-provided URL (defense-in-depth
18
+ // for a reusable widget that puts this in an <a href>).
19
+ let screenshotSrc = $derived(safeScreenshotSrc(item.screenshotUrl));
20
+
16
21
  let responses = $state<FeedbackResponse[]>([]);
17
22
  let loadingResponses = $state(true);
18
23
 
@@ -101,27 +106,27 @@
101
106
 
102
107
  let isAuthenticated = $derived(!!board.userEmail);
103
108
 
104
- $effect(() => {
105
- const itemId = item.id;
106
- let cancelled = false;
107
-
108
- // Consumers commonly keep the detail rail mounted and replace its item prop.
109
- // Clear the old thread immediately so it is never shown under the new item.
110
- responses = [];
111
- loadingResponses = true;
112
-
113
- void board.getResponses(itemId).then((nextResponses) => {
114
- // A slower request for the previous selection must not overwrite the
115
- // currently selected item's responses.
116
- if (cancelled) return;
117
- responses = nextResponses;
118
- loadingResponses = false;
119
- });
120
-
121
- return () => {
122
- cancelled = true;
123
- };
124
- });
109
+ $effect(() => {
110
+ const itemId = item.id;
111
+ let cancelled = false;
112
+
113
+ // Consumers commonly keep the detail rail mounted and replace its item prop.
114
+ // Clear the old thread immediately so it is never shown under the new item.
115
+ responses = [];
116
+ loadingResponses = true;
117
+
118
+ void board.getResponses(itemId).then((nextResponses) => {
119
+ // A slower request for the previous selection must not overwrite the
120
+ // currently selected item's responses.
121
+ if (cancelled) return;
122
+ responses = nextResponses;
123
+ loadingResponses = false;
124
+ });
125
+
126
+ return () => {
127
+ cancelled = true;
128
+ };
129
+ });
125
130
 
126
131
  async function handleSubmitReply() {
127
132
  if (!replyMessage.trim() || !isAuthenticated) return;
@@ -187,6 +192,16 @@
187
192
  <h3 class="text-base font-semibold text-foreground">{item.title}</h3>
188
193
  {/if}
189
194
  <p class="text-sm leading-relaxed text-foreground whitespace-pre-wrap">{item.message}</p>
195
+ {#if screenshotSrc}
196
+ <a href={screenshotSrc} target="_blank" rel="noopener noreferrer" class="inline-block" title="Open full screenshot">
197
+ <img
198
+ src={screenshotSrc}
199
+ alt="Attached screenshot"
200
+ loading="lazy"
201
+ class="max-h-72 w-auto rounded-md border border-border object-contain"
202
+ />
203
+ </a>
204
+ {/if}
190
205
  <div class="flex items-center gap-1.5 pt-1 text-xs text-foreground-tertiary" aria-label="{item.votes} votes">
191
206
  <i class="fa-solid fa-arrow-up" aria-hidden="true"></i>
192
207
  <span class="font-medium text-foreground-secondary">{item.votes}</span>
@@ -3,8 +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';
7
- import { canChangeItemStatus } from './capabilities.js';
6
+ import StatusBadgeMenu from './StatusBadgeMenu.svelte';
7
+ import { canChangeItemStatus } from './capabilities.js';
8
8
 
9
9
  interface Props {
10
10
  board: FeedbackBoardState;
@@ -14,13 +14,12 @@
14
14
 
15
15
  let { board, item, onclick }: Props = $props();
16
16
 
17
- let isClickable = $derived(!!onclick);
18
- let canChangeStatus = $derived(canChangeItemStatus(board, item));
17
+ let isClickable = $derived(!!onclick);
18
+ let canChangeStatus = $derived(canChangeItemStatus(board, item));
19
19
  </script>
20
20
 
21
21
  <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
22
- <article
23
- class:sf-editable={canChangeStatus}
22
+ <article
24
23
  class="rounded-xl border border-border bg-surface p-4 transition-all {isClickable ? 'cursor-pointer hover:border-border-strong hover:bg-surface-hover' : ''}"
25
24
  role={isClickable ? 'button' : undefined}
26
25
  tabindex={isClickable ? 0 : undefined}
@@ -78,12 +77,10 @@
78
77
  <StatusBadgeMenu {board} {item} />
79
78
  </div>
80
79
 
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}
80
+ <!-- Meta -->
81
+ {#if canChangeStatus && item.needsInput}
82
+ <p class="sf-action-hint">Needs your input</p>
83
+ {/if}
87
84
  <div class="mt-3 flex items-center gap-3 text-xs text-foreground-tertiary">
88
85
  {#if item.email}
89
86
  <span class="inline-flex min-w-0 flex-1 items-center gap-1.5">
@@ -111,20 +108,16 @@
111
108
  </div>
112
109
  </article>
113
110
 
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
- }
111
+ <style>
112
+ .sf-action-hint {
113
+ color: #6d28d9;
114
+ background: #f3e8ff;
115
+ border-radius: 0.25rem;
116
+ padding: 0.15rem 0.4rem;
117
+ margin-top: 0.75rem;
118
+ font-size: 0.75rem;
119
+ width: fit-content;
120
+ }
128
121
  .line-clamp-1,
129
122
  .line-clamp-2 {
130
123
  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">{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>
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,12 +8,12 @@
8
8
 
9
9
  let { board }: Props = $props();
10
10
 
11
- const statusOptions = $derived([
11
+ const statusOptions = $derived([
12
12
  { value: "open", label: "Open" },
13
13
  { value: "resolved", label: "Done" },
14
- { value: "all", label: "All" },
15
- ...(board.canManageStatus ? [{ value: "needs_input", label: "Needs my input" }] : []),
16
- ]);
14
+ { value: "all", label: "All" },
15
+ ...(board.canManageStatus ? [{ value: "needs_input", label: "Needs my input" }] : []),
16
+ ]);
17
17
 
18
18
  const sortOptions = [
19
19
  { value: "votes", label: "Most Votes" },
@@ -61,10 +61,10 @@
61
61
 
62
62
  <Divider class="my-2" />
63
63
 
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
+ <div class="sf-filter-group">
65
+ {#if board.canManageStatus}
66
+ <p class="sf-editable-legend"><span aria-hidden="true"></span>Purple status controls mark statuses you can change.</p>
67
+ {/if}
68
68
  <Select
69
69
  label="Status"
70
70
  class="rounded-lg border-0 bg-surface-hover"
@@ -80,9 +80,9 @@
80
80
  </div>
81
81
  </aside>
82
82
 
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; }
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; }
86
86
  /* Spacing/padding is bundled in the component (not Tailwind utilities) so it
87
87
  applies regardless of whether the consuming app's Tailwind generates these
88
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
- * Items without an authorized board decision have a read-only badge.
6
- * Editable items have a purple button: clicking it
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 refresh on demand (on each open)
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,8 +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';
25
- import { canChangeItemStatus } from './capabilities.js';
24
+ import { getStatusBadgeVariant, internalStatusLabel } from './utils.js';
25
+ import { canChangeItemStatus } from './capabilities.js';
26
26
 
27
27
  interface Props {
28
28
  board: FeedbackBoardState;
@@ -45,8 +45,8 @@
45
45
  let menuEl: HTMLDivElement | undefined = $state();
46
46
 
47
47
  // Read from the board's shared cache so detail + list reuse the same fetch.
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
+ let txns = $derived(board.transitions[item.id]?.current.status === item.status ? board.transitions[item.id] : null);
49
+ let canChange = $derived(canChangeItemStatus(board, item));
50
50
  // Everyone sees the item's real granular status — `item.status` is present on
51
51
  // every board item (the board endpoint returns it), so all rows render their
52
52
  // actual status on first paint, no privilege or transitions fetch required.
@@ -133,7 +133,7 @@
133
133
  reason = '';
134
134
  // Open immediately so the spinner is visible while transitions load.
135
135
  menuOpen = true;
136
- await reload();
136
+ await reload();
137
137
  }
138
138
 
139
139
  async function reload() {
@@ -187,20 +187,20 @@
187
187
  }
188
188
  </script>
189
189
 
190
- {#if canChange}
190
+ {#if canChange}
191
191
  <button
192
192
  bind:this={triggerEl}
193
193
  type="button"
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
+ 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()}
197
197
  aria-haspopup="listbox"
198
198
  aria-expanded={menuOpen}
199
199
  disabled={applying}
200
- title="You can change this status"
201
- aria-label="Change status: {badgeLabel}"
202
- >
203
- <span>{badgeLabel}</span>
200
+ title="You can change this status"
201
+ aria-label="Change status: {badgeLabel}"
202
+ >
203
+ <span>{badgeLabel}</span>
204
204
  <i class="fa-solid fa-chevron-down text-[0.625rem] text-foreground-tertiary" aria-hidden="true"></i>
205
205
  </button>
206
206
 
@@ -237,7 +237,7 @@
237
237
  </Button>
238
238
  </div>
239
239
  </div>
240
- {:else if txns && txns.targets.length > 0 && !loadFailed}
240
+ {:else if txns && txns.targets.length > 0 && !loadFailed}
241
241
  {#each txns.targets as target (target.status)}
242
242
  <button
243
243
  type="button"
@@ -272,18 +272,18 @@
272
272
  <Badge variant={getStatusBadgeVariant(item.publicStatus)} size="sm">
273
273
  {badgeLabel}
274
274
  </Badge>
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>
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,16 @@
1
+ /**
2
+ * Client-side allowlist for a board item's screenshot URL.
3
+ *
4
+ * Defense-in-depth: `screenshotUrl` is validated server-side (status-feedback-
5
+ * admin), but this is a reusable package consumed by multiple apps, and
6
+ * `<FeedbackDetail>` puts the value in an `<a href>` — a script-execution
7
+ * surface. A shared widget must not be the single layer that trusts an upstream
8
+ * it can't guarantee, so validate the scheme here too: allow only a safe image
9
+ * source (an `https` URL, or a base64 raster-image data URL) and reject
10
+ * `javascript:`, `data:text/html`, `data:image/svg+xml` (SVG can execute
11
+ * script), plain `http:`, and non-strings.
12
+ *
13
+ * Origin restriction (limiting `https` to the blob container) is enforced
14
+ * server-side, where the configured origin is known — the client can't see it.
15
+ */
16
+ export declare function safeScreenshotSrc(value: unknown): string | null;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Client-side allowlist for a board item's screenshot URL.
3
+ *
4
+ * Defense-in-depth: `screenshotUrl` is validated server-side (status-feedback-
5
+ * admin), but this is a reusable package consumed by multiple apps, and
6
+ * `<FeedbackDetail>` puts the value in an `<a href>` — a script-execution
7
+ * surface. A shared widget must not be the single layer that trusts an upstream
8
+ * it can't guarantee, so validate the scheme here too: allow only a safe image
9
+ * source (an `https` URL, or a base64 raster-image data URL) and reject
10
+ * `javascript:`, `data:text/html`, `data:image/svg+xml` (SVG can execute
11
+ * script), plain `http:`, and non-strings.
12
+ *
13
+ * Origin restriction (limiting `https` to the blob container) is enforced
14
+ * server-side, where the configured origin is known — the client can't see it.
15
+ */
16
+ export function safeScreenshotSrc(value) {
17
+ if (typeof value !== 'string')
18
+ return null;
19
+ if (/^data:image\/(?:png|jpeg|gif|webp);base64,/i.test(value))
20
+ return value;
21
+ if (/^https:\/\//i.test(value))
22
+ return value;
23
+ return null;
24
+ }
@@ -24,7 +24,7 @@ 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 accept, reopen, or decline feedback. Called
27
+ * users (Admin / ProductOwner) can accept, approve QA/UAT, reopen, or decline feedback. Called
28
28
  * on demand so the host controls token refresh.
29
29
  */
30
30
  getAuthToken?: () => string | null | Promise<string | null>;
package/package.json CHANGED
@@ -1,70 +1,70 @@
1
- {
2
- "name": "@alliance-droid/status-feedback-system",
3
- "version": "4.3.1",
4
- "type": "module",
5
- "description": "Drop-in system status pages and user feedback forms for SvelteKit apps",
6
- "svelte": "./dist/index.js",
7
- "types": "./dist/index.d.ts",
8
- "exports": {
9
- ".": {
10
- "types": "./dist/index.d.ts",
11
- "svelte": "./dist/index.js"
12
- },
13
- "./sveltekit/server": {
14
- "types": "./dist/sveltekit/server.d.ts",
15
- "default": "./dist/sveltekit/server.js"
16
- }
17
- },
18
- "files": [
19
- "dist",
20
- "!dist/**/*.test.*",
21
- "CHANGELOG.md"
22
- ],
23
- "scripts": {
24
- "build": "svelte-package -o dist && publint",
25
- "lint": "eslint src",
26
- "check": "svelte-check --tsconfig ./tsconfig.json",
27
- "test": "vitest run",
28
- "test:watch": "vitest",
29
- "prepublishOnly": "npm run build"
30
- },
31
- "peerDependencies": {
32
- "@alliance-droid/svelte-component-library": ">=1.3.2",
33
- "svelte": "^5.55.7"
34
- },
35
- "devDependencies": {
36
- "@alliance-droid/svelte-component-library": "^1.20.2",
37
- "@eslint/js": "^10.0.1",
38
- "@sveltejs/package": "^2.5.8",
39
- "@sveltejs/vite-plugin-svelte": "^6.2.4",
40
- "@testing-library/jest-dom": "^6.9.1",
41
- "@testing-library/svelte": "^5.4.2",
42
- "eslint": "^10.10.0",
43
- "eslint-plugin-svelte": "^3.23.0",
44
- "globals": "^17.12.0",
45
- "jsdom": "^29.1.1",
46
- "publint": "^0.3.24",
47
- "svelte": "^5.57.0",
48
- "svelte-check": "^4.7.6",
49
- "typescript": "^5.9.3",
50
- "typescript-eslint": "^8.70.0",
51
- "vite": "^7.3.6",
52
- "vitest": "^4.1.11"
53
- },
54
- "keywords": [
55
- "svelte",
56
- "sveltekit",
57
- "status-page",
58
- "feedback",
59
- "uptime"
60
- ],
61
- "author": "Alliance Technical Group",
62
- "license": "UNLICENSED",
63
- "publishConfig": {
64
- "access": "restricted"
65
- },
66
- "repository": {
67
- "type": "git",
68
- "url": "git+https://alliance.ghe.com/alliance/status-feedback-system.git"
69
- }
70
- }
1
+ {
2
+ "name": "@alliance-droid/status-feedback-system",
3
+ "version": "4.3.3",
4
+ "type": "module",
5
+ "description": "Drop-in system status pages and user feedback forms for SvelteKit apps",
6
+ "svelte": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "svelte": "./dist/index.js"
12
+ },
13
+ "./sveltekit/server": {
14
+ "types": "./dist/sveltekit/server.d.ts",
15
+ "default": "./dist/sveltekit/server.js"
16
+ }
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "!dist/**/*.test.*",
21
+ "CHANGELOG.md"
22
+ ],
23
+ "scripts": {
24
+ "build": "svelte-package -o dist && publint",
25
+ "lint": "eslint src",
26
+ "check": "svelte-check --tsconfig ./tsconfig.json",
27
+ "test": "vitest run",
28
+ "test:watch": "vitest",
29
+ "prepublishOnly": "npm run build"
30
+ },
31
+ "peerDependencies": {
32
+ "@alliance-droid/svelte-component-library": ">=1.3.2",
33
+ "svelte": "^5.55.7"
34
+ },
35
+ "devDependencies": {
36
+ "@alliance-droid/svelte-component-library": "^1.20.2",
37
+ "@eslint/js": "^10.0.1",
38
+ "@sveltejs/package": "^2.5.8",
39
+ "@sveltejs/vite-plugin-svelte": "^6.2.4",
40
+ "@testing-library/jest-dom": "^6.9.1",
41
+ "@testing-library/svelte": "^5.4.2",
42
+ "eslint": "^10.10.0",
43
+ "eslint-plugin-svelte": "^3.23.0",
44
+ "globals": "^17.12.0",
45
+ "jsdom": "^29.1.1",
46
+ "publint": "^0.3.24",
47
+ "svelte": "^5.57.0",
48
+ "svelte-check": "^4.7.6",
49
+ "typescript": "^5.9.3",
50
+ "typescript-eslint": "^8.70.0",
51
+ "vite": "^7.3.6",
52
+ "vitest": "^4.1.11"
53
+ },
54
+ "keywords": [
55
+ "svelte",
56
+ "sveltekit",
57
+ "status-page",
58
+ "feedback",
59
+ "uptime"
60
+ ],
61
+ "author": "Alliance Technical Group",
62
+ "license": "UNLICENSED",
63
+ "publishConfig": {
64
+ "access": "restricted"
65
+ },
66
+ "repository": {
67
+ "type": "git",
68
+ "url": "git+https://alliance.ghe.com/alliance/status-feedback-system.git"
69
+ }
70
+ }