@handled-ai/design-system 0.18.52 → 0.19.0-rc.0
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/components/badge.d.ts +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/case-panel-activity-timeline.d.ts +2 -0
- package/dist/components/case-panel-activity-timeline.js +22 -1
- package/dist/components/case-panel-activity-timeline.js.map +1 -1
- package/dist/components/comment-composer.d.ts +29 -0
- package/dist/components/comment-composer.js +102 -0
- package/dist/components/comment-composer.js.map +1 -0
- package/dist/components/conversation-panel.d.ts +95 -0
- package/dist/components/conversation-panel.js +636 -0
- package/dist/components/conversation-panel.js.map +1 -0
- package/dist/components/data-table-filter.d.ts +18 -1
- package/dist/components/data-table-filter.js +20 -6
- package/dist/components/data-table-filter.js.map +1 -1
- package/dist/components/entity-panel.js +5 -5
- package/dist/components/entity-panel.js.map +1 -1
- package/dist/components/owner-chips.d.ts +59 -0
- package/dist/components/owner-chips.js +256 -0
- package/dist/components/owner-chips.js.map +1 -0
- package/dist/components/pill.d.ts +1 -1
- package/dist/components/tabs.d.ts +1 -1
- package/dist/components/timeline-activity.d.ts +7 -0
- package/dist/components/timeline-activity.js +22 -1
- package/dist/components/timeline-activity.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/internal/safe-html.d.ts +11 -0
- package/dist/internal/safe-html.js +222 -0
- package/dist/internal/safe-html.js.map +1 -0
- package/package.json +1 -1
- package/src/components/__tests__/comment-composer.test.tsx +57 -0
- package/src/components/__tests__/conversation-panel.test.tsx +157 -0
- package/src/components/__tests__/data-table-filter.test.tsx +72 -0
- package/src/components/__tests__/entity-metadata-grid.test.tsx +27 -1
- package/src/components/__tests__/owner-chips.test.tsx +100 -0
- package/src/components/__tests__/timeline-activity.test.tsx +55 -0
- package/src/components/case-panel-activity-timeline.tsx +20 -0
- package/src/components/comment-composer.tsx +119 -0
- package/src/components/conversation-panel.tsx +790 -0
- package/src/components/data-table-filter.tsx +53 -10
- package/src/components/entity-panel.tsx +7 -5
- package/src/components/owner-chips.tsx +335 -0
- package/src/components/timeline-activity.tsx +37 -3
- package/src/index.ts +3 -0
- package/src/internal/__tests__/safe-html.test.ts +53 -0
- package/src/internal/safe-html.ts +284 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
const DANGEROUS_BLOCK_TAGS = new Set([
|
|
2
|
+
"script",
|
|
3
|
+
"style",
|
|
4
|
+
"iframe",
|
|
5
|
+
"object",
|
|
6
|
+
"embed",
|
|
7
|
+
"svg",
|
|
8
|
+
"math",
|
|
9
|
+
"template",
|
|
10
|
+
"noscript",
|
|
11
|
+
"textarea",
|
|
12
|
+
"select",
|
|
13
|
+
])
|
|
14
|
+
|
|
15
|
+
const ALLOWED_TAGS = new Set([
|
|
16
|
+
"a",
|
|
17
|
+
"b",
|
|
18
|
+
"blockquote",
|
|
19
|
+
"br",
|
|
20
|
+
"code",
|
|
21
|
+
"del",
|
|
22
|
+
"div",
|
|
23
|
+
"em",
|
|
24
|
+
"hr",
|
|
25
|
+
"i",
|
|
26
|
+
"img",
|
|
27
|
+
"li",
|
|
28
|
+
"ol",
|
|
29
|
+
"p",
|
|
30
|
+
"pre",
|
|
31
|
+
"s",
|
|
32
|
+
"span",
|
|
33
|
+
"strong",
|
|
34
|
+
"table",
|
|
35
|
+
"tbody",
|
|
36
|
+
"td",
|
|
37
|
+
"th",
|
|
38
|
+
"thead",
|
|
39
|
+
"tr",
|
|
40
|
+
"u",
|
|
41
|
+
"ul",
|
|
42
|
+
])
|
|
43
|
+
|
|
44
|
+
const VOID_TAGS = new Set(["br", "hr", "img"])
|
|
45
|
+
const SAFE_GLOBAL_ATTRS = new Set(["aria-label", "role", "title"])
|
|
46
|
+
const SAFE_URL_PROTOCOLS = new Set(["http:", "https:", "mailto:", "tel:"])
|
|
47
|
+
|
|
48
|
+
function escapeHtml(value: string): string {
|
|
49
|
+
return value
|
|
50
|
+
.replace(/&/g, "&")
|
|
51
|
+
.replace(/</g, "<")
|
|
52
|
+
.replace(/>/g, ">")
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function escapeAttribute(value: string): string {
|
|
56
|
+
return escapeHtml(value).replace(/"/g, """)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function safeCodePoint(value: number): string {
|
|
60
|
+
return Number.isInteger(value) && value >= 0 && value <= 0x10ffff ? String.fromCodePoint(value) : ""
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function decodeHtmlEntities(value: string): string {
|
|
64
|
+
const namedEntities: Record<string, string> = {
|
|
65
|
+
amp: "&",
|
|
66
|
+
apos: "'",
|
|
67
|
+
colon: ":",
|
|
68
|
+
gt: ">",
|
|
69
|
+
lt: "<",
|
|
70
|
+
newline: "\n",
|
|
71
|
+
quot: '"',
|
|
72
|
+
tab: "\t",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let decoded = value
|
|
76
|
+
for (let i = 0; i < 4; i += 1) {
|
|
77
|
+
const next = decoded
|
|
78
|
+
.replace(/&#x([0-9a-f]+);?/gi, (_match, hex: string) => {
|
|
79
|
+
const codePoint = Number.parseInt(hex, 16)
|
|
80
|
+
return safeCodePoint(codePoint)
|
|
81
|
+
})
|
|
82
|
+
.replace(/&#(\d+);?/g, (_match, decimal: string) => {
|
|
83
|
+
const codePoint = Number.parseInt(decimal, 10)
|
|
84
|
+
return safeCodePoint(codePoint)
|
|
85
|
+
})
|
|
86
|
+
.replace(/&([a-z]+);/gi, (match, name: string) => namedEntities[name.toLowerCase()] ?? match)
|
|
87
|
+
|
|
88
|
+
if (next === decoded) return decoded
|
|
89
|
+
decoded = next
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return decoded
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isSafeUrl(value: string): boolean {
|
|
96
|
+
const decoded = decodeHtmlEntities(value).replace(/[\u0000-\u001f\u007f\s]+/g, "").trim()
|
|
97
|
+
if (!decoded) return false
|
|
98
|
+
if (decoded.startsWith("//")) return false
|
|
99
|
+
if (decoded.startsWith("#") || decoded.startsWith("/") || decoded.startsWith("./") || decoded.startsWith("../")) {
|
|
100
|
+
return true
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
return SAFE_URL_PROTOCOLS.has(new URL(decoded, "https://handled.local").protocol)
|
|
105
|
+
} catch {
|
|
106
|
+
return false
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function sanitizeClassName(value: string): string | null {
|
|
111
|
+
const safeTokens = value
|
|
112
|
+
.split(/\s+/)
|
|
113
|
+
.map((token) => token.trim())
|
|
114
|
+
.filter((token) => /^[A-Za-z0-9_-]+$/.test(token))
|
|
115
|
+
|
|
116
|
+
return safeTokens.length ? safeTokens.join(" ") : null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function sanitizeAttribute(tagName: string, name: string, value: string): string | null {
|
|
120
|
+
const attr = name.toLowerCase()
|
|
121
|
+
|
|
122
|
+
if (
|
|
123
|
+
attr.startsWith("on") ||
|
|
124
|
+
attr === "style" ||
|
|
125
|
+
attr === "srcdoc" ||
|
|
126
|
+
attr === "formaction" ||
|
|
127
|
+
attr === "xlink:href" ||
|
|
128
|
+
attr === "xmlns"
|
|
129
|
+
) {
|
|
130
|
+
return null
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (attr === "class") {
|
|
134
|
+
const safeClassName = sanitizeClassName(value)
|
|
135
|
+
return safeClassName ? `class="${escapeAttribute(safeClassName)}"` : null
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (SAFE_GLOBAL_ATTRS.has(attr) || attr.startsWith("aria-")) {
|
|
139
|
+
return `${attr}="${escapeAttribute(value)}"`
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (tagName === "a" && attr === "href" && isSafeUrl(value)) {
|
|
143
|
+
return `href="${escapeAttribute(value)}"`
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (tagName === "img" && attr === "src" && isSafeUrl(value)) {
|
|
147
|
+
return `src="${escapeAttribute(value)}"`
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (tagName === "img" && (attr === "alt" || attr === "width" || attr === "height")) {
|
|
151
|
+
return `${attr}="${escapeAttribute(value)}"`
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if ((tagName === "td" || tagName === "th") && (attr === "colspan" || attr === "rowspan")) {
|
|
155
|
+
return `${attr}="${escapeAttribute(value)}"`
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return null
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function sanitizeAttributes(tagName: string, rawAttributes = ""): string {
|
|
162
|
+
const attributes: string[] = []
|
|
163
|
+
const attrPattern = /([A-Za-z_:][-A-Za-z0-9_:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g
|
|
164
|
+
let match: RegExpExecArray | null
|
|
165
|
+
|
|
166
|
+
while ((match = attrPattern.exec(rawAttributes)) !== null) {
|
|
167
|
+
const [, name, doubleQuotedValue, singleQuotedValue, unquotedValue] = match
|
|
168
|
+
const value = doubleQuotedValue ?? singleQuotedValue ?? unquotedValue ?? ""
|
|
169
|
+
const safeAttribute = sanitizeAttribute(tagName, name, value)
|
|
170
|
+
if (safeAttribute) attributes.push(safeAttribute)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (tagName === "a" && attributes.some((attr) => attr.startsWith("href="))) {
|
|
174
|
+
attributes.push('target="_blank"', 'rel="noopener noreferrer"')
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return attributes.length ? ` ${attributes.join(" ")}` : ""
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function findTagEnd(html: string, startIndex: number): number {
|
|
181
|
+
let quote: '"' | "'" | null = null
|
|
182
|
+
|
|
183
|
+
for (let i = startIndex + 1; i < html.length; i += 1) {
|
|
184
|
+
const char = html[i]
|
|
185
|
+
if (quote) {
|
|
186
|
+
if (char === quote) quote = null
|
|
187
|
+
continue
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (char === '"' || char === "'") {
|
|
191
|
+
quote = char
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (char === ">") return i
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return -1
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function parseTag(rawTag: string): { closing: boolean; name: string; attributes: string } | null {
|
|
202
|
+
const match = rawTag.match(/^<\s*(\/)?\s*([A-Za-z][A-Za-z0-9:-]*)\b([\s\S]*?)\/?>$/)
|
|
203
|
+
if (!match) return null
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
closing: !!match[1],
|
|
207
|
+
name: match[2].toLowerCase(),
|
|
208
|
+
attributes: match[3] ?? "",
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function findDangerousClose(html: string, tagName: string, fromIndex: number): number {
|
|
213
|
+
const closePattern = new RegExp(`</\\s*${tagName}\\s*>`, "ig")
|
|
214
|
+
closePattern.lastIndex = fromIndex
|
|
215
|
+
const match = closePattern.exec(html)
|
|
216
|
+
return match ? closePattern.lastIndex : -1
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Conservative, deterministic sanitizer for user/email supplied HTML rendered by
|
|
221
|
+
* design-system components. It keeps common email formatting tags while removing
|
|
222
|
+
* executable tags, event handlers, inline styles, and unsafe URLs. This stays
|
|
223
|
+
* dependency-free for the shared package and intentionally favors stripping
|
|
224
|
+
* ambiguous email content over preserving every possible HTML feature.
|
|
225
|
+
*/
|
|
226
|
+
export function sanitizeHtml(html: string): string {
|
|
227
|
+
let output = ""
|
|
228
|
+
let cursor = 0
|
|
229
|
+
|
|
230
|
+
while (cursor < html.length) {
|
|
231
|
+
const tagStart = html.indexOf("<", cursor)
|
|
232
|
+
if (tagStart === -1) {
|
|
233
|
+
output += html.slice(cursor)
|
|
234
|
+
break
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
output += html.slice(cursor, tagStart)
|
|
238
|
+
|
|
239
|
+
if (html.startsWith("<!--", tagStart)) {
|
|
240
|
+
const commentEnd = html.indexOf("-->", tagStart + 4)
|
|
241
|
+
cursor = commentEnd === -1 ? html.length : commentEnd + 3
|
|
242
|
+
continue
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const tagEnd = findTagEnd(html, tagStart)
|
|
246
|
+
if (tagEnd === -1) {
|
|
247
|
+
output += escapeHtml(html.slice(tagStart))
|
|
248
|
+
break
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const rawTag = html.slice(tagStart, tagEnd + 1)
|
|
252
|
+
const parsed = parseTag(rawTag)
|
|
253
|
+
if (!parsed) {
|
|
254
|
+
cursor = tagEnd + 1
|
|
255
|
+
continue
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (DANGEROUS_BLOCK_TAGS.has(parsed.name)) {
|
|
259
|
+
const closeEnd = parsed.closing ? -1 : findDangerousClose(html, parsed.name, tagEnd + 1)
|
|
260
|
+
cursor = closeEnd === -1 ? tagEnd + 1 : closeEnd
|
|
261
|
+
continue
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (ALLOWED_TAGS.has(parsed.name)) {
|
|
265
|
+
if (parsed.closing) {
|
|
266
|
+
if (!VOID_TAGS.has(parsed.name)) output += `</${parsed.name}>`
|
|
267
|
+
} else {
|
|
268
|
+
output += `<${parsed.name}${sanitizeAttributes(parsed.name, parsed.attributes)}>`
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
cursor = tagEnd + 1
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return output
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function htmlToTextSnippet(html: string, maxLength = 140): string {
|
|
279
|
+
return sanitizeHtml(html)
|
|
280
|
+
.replace(/<[^>]+>/g, " ")
|
|
281
|
+
.replace(/\s+/g, " ")
|
|
282
|
+
.trim()
|
|
283
|
+
.slice(0, maxLength)
|
|
284
|
+
}
|