@preventive/triage 1.0.0-alpha.21 → 1.0.0-alpha.23
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/common/bundle-source-links.d.ts +5 -0
- package/common/bundle-source-links.js +82 -0
- package/common/github-issue-labels.d.ts +1 -0
- package/common/github-issue-labels.js +11 -0
- package/common/managed/routes.js +3 -0
- package/common/server-info.ts +1 -0
- package/out/client-managed-import.js +1 -1
- package/out/client-managed.js +74 -58
- package/out/client-sync.js +3 -3
- package/out/github-issue-authorized.html +3 -0
- package/out/github-issue-authorized.js +1 -0
- package/out/graph.js +6 -6
- package/out/prism.js +2 -2
- package/out/view.css +1 -1
- package/out/view.js +186 -159
- package/package.json +12 -1
- package/server-e2e/app.ts +2 -1
- package/server-e2e/config.ts +2 -0
- package/server-managed/config.ts +9 -16
- package/server-managed/db-methods.ts +52 -21
- package/server-managed/db-neon.ts +6 -1
- package/server-managed/db-schema.ts +1 -0
- package/server-managed/db.ts +2 -1
- package/server-managed/github-app.ts +107 -49
- package/server-managed/github-errors.ts +28 -0
- package/server-managed/github-issue-oauth.ts +48 -0
- package/server-managed/github-issues.ts +152 -0
- package/server-managed/github-oauth.ts +7 -23
- package/server-managed/http.ts +181 -9
- package/server-managed/managed-issues.ts +47 -0
- package/server-managed/report-sources.ts +4 -2
- package/server-managed/repository-browser.ts +127 -0
- package/server-managed/workspace-shares.ts +3 -3
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { BundleDetails } from './bundle-metadata.js'
|
|
2
|
+
|
|
3
|
+
export function bundleSourceImports(details: BundleDetails, sources?: Map<string, string>): Map<string, Map<string, string | null>>
|
|
4
|
+
export function sourceLinkResolver(sources: Map<string, string>, parent: string, imports?: Map<string, Map<string, string | null>> | null): (specifier: string) => string | null
|
|
5
|
+
export function bundleSourceLinkResolver(details: BundleDetails, parent: string): (specifier: string) => string | null
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { bundleSourcesAsMap } from './bundle-sources.js'
|
|
2
|
+
|
|
3
|
+
const indexes = new WeakMap()
|
|
4
|
+
|
|
5
|
+
// Normalize only path segments. Keep sourcemap URL roots (webpack:/// etc.)
|
|
6
|
+
// and leading ../ segments, and never guess extensions or basename matches.
|
|
7
|
+
function normalizePath(path) {
|
|
8
|
+
const match = /^([a-z][a-z\d+.-]*:\/\/[^/]*)(.*)$/iu.exec(path)
|
|
9
|
+
const prefix = match?.[1] ?? ''
|
|
10
|
+
const rest = match?.[2] ?? path
|
|
11
|
+
const absolute = rest.startsWith('/')
|
|
12
|
+
const parts = []
|
|
13
|
+
for (const part of rest.split('/')) {
|
|
14
|
+
if (!part || part === '.') continue
|
|
15
|
+
if (part === '..' && parts.length > 0 && parts.at(-1) !== '..') parts.pop()
|
|
16
|
+
else if (part === '..' && absolute && parts.length === 0) return null
|
|
17
|
+
else parts.push(part)
|
|
18
|
+
}
|
|
19
|
+
return prefix + (absolute ? '/' : '') + parts.join('/')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function buildIndex(sources) {
|
|
23
|
+
const paths = new Map()
|
|
24
|
+
for (const path of sources.keys()) {
|
|
25
|
+
const normalized = normalizePath(path)
|
|
26
|
+
if (normalized === null) continue
|
|
27
|
+
// Distinct source keys with the same normalized path are ambiguous.
|
|
28
|
+
paths.set(normalized, paths.has(normalized) ? null : path)
|
|
29
|
+
}
|
|
30
|
+
return paths
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Merge conditions before linking. When exporting a restricted source set,
|
|
34
|
+
// include only visible parents and replace every unavailable target with null:
|
|
35
|
+
// its path must not leak, and relative fallback must still remain blocked.
|
|
36
|
+
export function bundleSourceImports(details, sources = bundleSourcesAsMap(details)) {
|
|
37
|
+
const imports = new Map()
|
|
38
|
+
if (details?.kind === 'stasis') {
|
|
39
|
+
for (const byParent of details.bundle?.imports?.values() ?? []) {
|
|
40
|
+
for (const [parent, specifiers] of byParent) {
|
|
41
|
+
if (!sources.has(parent)) continue
|
|
42
|
+
if (!imports.has(parent)) imports.set(parent, new Map())
|
|
43
|
+
const merged = imports.get(parent)
|
|
44
|
+
for (const [specifier, target] of specifiers) {
|
|
45
|
+
const resolved = typeof target === 'string' && sources.has(target) ? target : null
|
|
46
|
+
// A platform object, missing file, or disagreement under any
|
|
47
|
+
// condition permanently blocks this specifier, including fallback.
|
|
48
|
+
merged.set(specifier, merged.has(specifier) && merged.get(specifier) !== resolved ? null : resolved)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return imports
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function sourceLinkResolver(sources, parent, imports = null) {
|
|
57
|
+
let index = indexes.get(sources)
|
|
58
|
+
if (!index) { index = buildIndex(sources); indexes.set(sources, index) }
|
|
59
|
+
const recorded = imports?.get(parent)
|
|
60
|
+
const directory = parent.slice(0, parent.lastIndexOf('/') + 1)
|
|
61
|
+
return (specifier) => {
|
|
62
|
+
if (typeof specifier !== 'string' || /\p{Cc}/u.test(specifier)) return null
|
|
63
|
+
if (recorded?.has(specifier)) {
|
|
64
|
+
const target = recorded.get(specifier)
|
|
65
|
+
return typeof target === 'string' && sources.has(target) ? target : null
|
|
66
|
+
}
|
|
67
|
+
if (!specifier.startsWith('./') && !specifier.startsWith('../')) return null
|
|
68
|
+
const name = specifier.slice(specifier.lastIndexOf('/') + 1)
|
|
69
|
+
const dot = name.lastIndexOf('.')
|
|
70
|
+
if (dot <= 0 || dot === name.length - 1) return null
|
|
71
|
+
return index.get(normalizePath(directory + specifier)) ?? null
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const bundleImports = new WeakMap()
|
|
76
|
+
|
|
77
|
+
export function bundleSourceLinkResolver(details, parent) {
|
|
78
|
+
const sources = bundleSourcesAsMap(details)
|
|
79
|
+
let imports = bundleImports.get(sources)
|
|
80
|
+
if (!imports) { imports = bundleSourceImports(details, sources); bundleImports.set(sources, imports) }
|
|
81
|
+
return sourceLinkResolver(sources, parent, imports)
|
|
82
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function newIssueLabels(isSecurity?: boolean, configured?: string): string[]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Shared by GitHub's prefilled form and managed API issue creation.
|
|
2
|
+
export function newIssueLabels(isSecurity = false, configured = '') {
|
|
3
|
+
const seen = new Set()
|
|
4
|
+
return ['deepview', ...(isSecurity ? ['security'] : []), ...configured.split(',')]
|
|
5
|
+
.map(label => label.trim()).filter(label => {
|
|
6
|
+
const key = label.toLowerCase()
|
|
7
|
+
if (!label || seen.has(key)) return false
|
|
8
|
+
seen.add(key)
|
|
9
|
+
return true
|
|
10
|
+
})
|
|
11
|
+
}
|
package/common/managed/routes.js
CHANGED
|
@@ -34,6 +34,7 @@ export function managedRoutePath(route) {
|
|
|
34
34
|
}
|
|
35
35
|
if (Object.hasOwn(MANAGED_PAGES, route.view)) {
|
|
36
36
|
const path = MANAGED_PAGES[route.view]
|
|
37
|
+
if (route.view === 'manage-bundles' && Number.isSafeInteger(route.createRepoId) && route.createRepoId > 0) return `${path}?createRepo=${route.createRepoId}`
|
|
37
38
|
if (route.view === 'manage-scans' && route.bundleId) return `${path}?bundle=${encodeURIComponent(route.bundleId)}`
|
|
38
39
|
return route.view === 'manage-history' && route.actor ? `${path}?actor=${encodeURIComponent(route.actor)}` : path
|
|
39
40
|
}
|
|
@@ -53,7 +54,9 @@ export function parseManagedRoute(url) {
|
|
|
53
54
|
if (path === '/' || path === '/index.html') return { view: 'home' }
|
|
54
55
|
const view = Object.keys(MANAGED_PAGES).find(key => MANAGED_PAGES[key] === path)
|
|
55
56
|
if (view) {
|
|
57
|
+
const createRepoId = Number(url.searchParams.get('createRepo'))
|
|
56
58
|
return { view,
|
|
59
|
+
...(view === 'manage-bundles' && Number.isSafeInteger(createRepoId) && createRepoId > 0 ? { createRepoId } : {}),
|
|
57
60
|
...(view === 'manage-history' && url.searchParams.get('actor') ? { actor: url.searchParams.get('actor') } : {}),
|
|
58
61
|
...(view === 'manage-scans' && url.searchParams.get('bundle') ? { bundleId: url.searchParams.get('bundle') } : {}),
|
|
59
62
|
}
|
package/common/server-info.ts
CHANGED
|
@@ -25,6 +25,7 @@ export interface ServerInfo {
|
|
|
25
25
|
mode: ServerMode | 'managed+e2e' | 'e2e+managed'
|
|
26
26
|
managed: ManagedServerInfo | null
|
|
27
27
|
deepviewScanServer?: string
|
|
28
|
+
githubNewIssueLabels?: string
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
// The mode-probe route. A client GETs this to learn a server's protocol up
|
|
@@ -51,7 +51,7 @@ ${i}`:i)}return e}function Pi(t){let e=new Map;for(let n of ne(t)){let i=Se(n);i
|
|
|
51
51
|
|
|
52
52
|
`),i={id:t.finding_url,file:e,line:"?",severity:(t.severity||"medium").toLowerCase(),description:n,repo:{github:t.repository}};return t.commit_hash&&(i.commitHash=t.commit_hash),t.detected_at&&(i.detectedAt=t.detected_at),t.committed_at&&(i.committedAt=t.committed_at),i}var Yi=[["deepview-md",_n],["deepsec",tn],["piolium",Cn],["claude-security",En]];function Mn(t){return Array.isArray(t?.findings)?t.findings:Array.isArray(t?.groups)?t.groups:null}function On(t,e){return typeof e=="string"&&/\.csv$/iu.test(e)?"codex":Ie(t).format}function Ie(t){let e;try{let n=JSON.parse(t);return Mn(n)?{data:n,format:"json",reason:null}:{data:null,format:null,reason:"JSON, but not a report: no findings array"}}catch(n){e=n}for(let[n,i]of Yi){let r=i(t);if(r)return{data:r,format:n,reason:null}}return{data:null,format:null,reason:`Not JSON, and not a recognized markdown format. (JSON error: ${e.message})`}}function Zi(t){return t.flat().filter(e=>e&&typeof e=="object")}async function Qi(t){let e=t.filter(i=>!i.id);if(e.length===0)return;let n=await Promise.all(e.map(Ln));e.forEach((i,r)=>{n[r]&&(i.id=n[r])})}async function Nn(t){let{data:e,format:n}=Ie(t);if(!e)return null;let i=Zi(Mn(e));return await Qi(i),{format:n,data:e,findings:i}}function lt(t,e){let n=t.trimStart().startsWith("[")?Xt(t):null;if(n)return{data:{source:"links",findings:[],links:n.groups},format:"links",reason:null};let i=Ie(t);if(i.data!=null||On(t,e)!=="codex")return i;try{return{data:{type:"security",source:"codex-security",findings:ct(t).flatMap(o=>o.data.findings)},format:"codex",reason:null}}catch(r){return{data:null,format:null,reason:r instanceof Error?r.message:"Invalid Codex CSV"}}}function Bn(t,e){let n=lt(t,e);return n.data==null?Promise.resolve(null):Nn(JSON.stringify(n.data))}function Un(t){return t==="inprogress"||t==="fixed"||t==="invalid"||t==="deleted"?t:void 0}function eo(t){if(t)return Un(t.triage)??(t.deleted?"deleted":void 0)}function to(t){return t?!t.color&&!eo(t)&&!t.comment&&!t.fix&&!(t.ignoredReports&&t.ignoredReports.length>0)&&t.flagged===void 0:!0}function Dn(t){if(!t||typeof t!="object")return;let e=t,n={};typeof e.color=="string"&&e.color&&(n.color=e.color);let i=Un(e.triage)??(e.deleted?"deleted":void 0);if(i&&(n.triage=i),typeof e.comment=="string"&&e.comment&&(n.comment=e.comment),typeof e.fix=="string"&&e.fix&&(n.fix=e.fix),typeof e.flagged=="boolean"&&(n.flagged=e.flagged),Array.isArray(e.ignoredReports)){let r=e.ignoredReports.filter(o=>typeof o=="string"&&o.length>0);r.length>0&&(n.ignoredReports=r)}return to(n)?void 0:n}var no=["inprogress","fixed","invalid","deleted"];function ro(t){return typeof t=="string"&&no.includes(t)}function Hn(t){if(t===null)return null;if(typeof t!="object"||Array.isArray(t))return"invalid";let e=t,n={},i=e.color;if(i!=null){if(typeof i!="string"||i.length>50)return"invalid";i!==""&&(n.color=i)}let r=e.triage;if(r!=null){if(!ro(r))return"invalid";n.triage=r}for(let s of["comment","fix"]){let a=e[s];if(a!=null){if(typeof a!="string"||a.length>1e4)return"invalid";a!==""&&(n[s]=a)}}let o=e.flagged;if(o!=null){if(typeof o!="boolean")return"invalid";n.flagged=o}return n}function Pn(t){if(typeof t!="string")return{ok:!0,path:null};if(/\p{Cc}/u.test(t)||t.includes("\\")||t!==t.trim())return{ok:!1};let e=[];for(let i of t.split("/"))if(!(i===""||i===".")){if(i===".."||i!==i.trim())return{ok:!1};e.push(i)}let n=e.join("/");return n.length>500?{ok:!1}:{ok:!0,path:n===""?null:n}}async function jn(t,e){let n=new Uint8Array(await t.arrayBuffer());return me(n)?e({tryPassword:i=>Ve(n,i)}):n[0]===31&&n[1]===139?Ve(n):ye(new TextDecoder("utf-8",{fatal:!0}).decode(n))}async function Vn(t,e){let n=new Map,i=[];for(let s of t.reports){if(typeof s?.name!="string"||!s.name||typeof s.content!="string")throw new Error("Every report must have a name and content.");let a=lt(s.content,s.name);if(!a.data)throw new Error(`${s.name}: ${a.reason??"Unsupported report"}`);let c=re(a.data),l=c??re(s)??re({repo:{github:t.repoUrls?.[s.name]}}),d=e.find(h=>h.fullName.toLowerCase()===l?.toLowerCase())?.repoId??null,u=Pn(a.data.repo?.directory);if(!u.ok)throw new Error(`${s.name}: invalid repository directory`);let p=a.format==="links"?[]:(await Bn(s.content,s.name))?.findings??[];for(let h of p)h.id&&n.set(h.id,h);i.push({...s,repoId:d,directory:u.path??"",github:l,embedded:c,ids:[...new Set(p.map(h=>h.id).filter(Boolean))],uploaded:null})}let r=Object.create(null);if(t.triage!=null&&(typeof t.triage!="object"||Array.isArray(t.triage)))throw new Error("Invalid workspace triage.");for(let[s,a]of Object.entries(t.triage??{})){if(!n.has(s))continue;let c=Dn(a);if(!c)continue;let{ignoredReports:l,...d}=c;Object.keys(d).length!==0&&(r[s]=d)}let o=[];for(let s of t.bundleBlobs??[]){let a=Uint8Array.fromBase64(s.data),c=`sha512-${new Uint8Array(await crypto.subtle.digest("SHA-512",a)).toBase64()}`;if(c!==s.integrity)throw new Error(`${s.name}: bundle integrity mismatch`);o.push({name:s.name,integrity:c,bytes:a,uploaded:null})}return{name:t.workspace.name.slice(0,100),reports:i,bundles:o,triage:r,lookup:n,references:t.bundles??[],team:null,completed:new Set,importedIds:new Set}}function oo(t,e){let n=[],i=Object.create(null);for(let[r,o]of Object.entries(t)){let s=e[r];if(!s)throw new Error("The server did not return the requested triage snapshot.");let a=s.entry??{},c=i[r]={...a};delete c.comment;for(let[l,d]of Object.entries(o))if(l==="comment"){if(s.comments.some(u=>u.body===d))continue;s.comments.length>0?n.push({id:r,property:l,local:s.comments.map(u=>u.body).join(`
|
|
53
53
|
|
|
54
|
-
`),imported:d}):c.comment=d}else if(a[l]!==void 0&&a[l]!==d){let u=p=>l==="flagged"?p?"flagged":"not flagged":p;n.push({id:r,property:l,local:u(a[l]),imported:u(d)})}else c[l]=d}return{entries:i,conflicts:n}}function ut(t,e,n){async function i(r,o,s={}){n?.throwIfAborted();let a=await t(r,{credentials:"same-origin",signal:n,...o===void 0?{}:{method:"POST",body:o instanceof File?o:JSON.stringify(o)},headers:{...o instanceof File?{}:{"content-type":"application/json"},"x-csrf-token":e.csrfToken,...s}});if(n?.throwIfAborted(),a.status===409)return{conflict:!0};if(!a.ok)throw new Error(`Import request failed (HTTP ${a.status}). You can retry the remaining steps.`);let c=await a.json();return n?.throwIfAborted(),c}return{send:i}}async function so(t,e,n,i,r){r?.throwIfAborted();let{entries:o,conflicts:s}=oo(t,e);if(s.length===0)return o;let a=await i(s,n,{title:"Triage conflicts on import",intro:"disagree with stored triage on",trailingNote:"Imported comments are added alongside existing comments. Other choices apply to these finding IDs across all teams.",importedSideLabel:"Apply imported"});if(r?.throwIfAborted(),!a)throw new Error("Triage conflict resolution was cancelled.");for(let c of s){let l=a[`${c.id}:${c.property}`];if(!["local","imported"].includes(l))throw new Error("Resolve every triage conflict before importing.");l==="imported"&&(o[c.id][c.property]=t[c.id][c.property])}return o}function ao(t,e){let n=[],i={entries:{},expected:{}};for(let[r,o]of Object.entries(t)){let s={entries:{...i.entries,[r]:o},expected:{...i.expected,[r]:e[r].version}};if(new TextEncoder().encode(JSON.stringify(s)).length<=262144){i=s;continue}if(Object.keys(i.entries).length===0)throw new Error("This triage entry is too large to import.");n.push(i),i={entries:{[r]:o},expected:{[r]:e[r].version}}}return Object.keys(i.entries).length>0&&n.push(i),n}async function co(t,e,{api:n,resolveConflicts:i,signal:r}){let o=e.ids.filter(a=>Object.hasOwn(t.triage,a)&&!t.importedIds.has(a)),s=`/api/admin/reports/${encodeURIComponent(e.uploaded.id)}/import-triage`;for(let a=0;a<o.length;a+=200){let c=o.slice(a,a+200);for(let l=0;l<5&&c.length>0;l++){r?.throwIfAborted();let{snapshots:d}=await n.send(s,{findingIds:c}),u=Object.fromEntries(c.map(h=>[h,t.triage[h]])),p=await so(u,d,t.lookup,i,r);for(let h of ao(p,d)){if(r?.throwIfAborted(),(await n.send(s,h)).conflict)break;Object.keys(h.entries).forEach(f=>t.importedIds.add(f))}c=c.filter(h=>!t.importedIds.has(h))}if(c.length>0)throw new Error("Stored triage keeps changing. Retry to resolve it against the latest values.")}}async function lo(t,e,n,i,r){if(t.bundles.length===0&&t.references.length===0)return;let o=r??(await n.send("/api/admin/bundles")).bundles,s=new Set(t.bundles.map(c=>c.uploaded.id));if([...s].some(c=>!o.some(l=>l.id===c)))throw new Error("Could not verify the uploaded source bundles. Retry the import.");let a=new Set(t.references);for(let c of o){if(!s.has(c.id)&&!a.has(c.integrity))continue;let l=c.repoId??e;c.repoId==null&&await i(`bundle-repo:${c.id}`,()=>n.send("/api/admin/bundles/set-repo",{bundleId:c.id,repoId:l}));let d=c.repoId==null?"":c.repoDirectory??"";await i(`repo:${l}:${d}`,()=>n.send("/api/admin/teams/set-repo",{teamId:t.team.id,repoId:l,path:d}))}}async function Gn(t,{api:e,session:n,defaultRepo:i,includeTriage:r,resolveConflicts:o,signal:s,progress:a=()=>{}}){if(n?.role!=="admin"||!n.csrfToken)throw new Error("An administrator session is required.");if(!t.name.trim()||t.name.trim().length>100)throw new Error("Enter a team name of up to 100 characters.");if(r)for(let[u,p]of Object.entries(t.triage)){if(!u||u.length>100)throw new Error(`Triage finding IDs must be between 1 and ${100} characters. Choose Skip triage to import the files without it.`);if(Hn(p)==="invalid")throw new Error(`Triage for ${u} exceeds the managed server limits. Choose Skip triage to import the files without it.`)}s?.throwIfAborted();let c=null;if(t.bundles.length===0){let u=new Set(t.references);c=u.size>0?(await e.send("/api/admin/bundles")).bundles.filter(p=>u.has(p.integrity)):[]}if(t.reports.length===0&&t.bundles.length===0&&c.length===0)throw new Error(t.references.length>0?"None of the referenced source bundles are available on the server. Export with bundle bytes included and try again.":"This workspace contains no report or bundle files.");for(let u of t.reports){if(u.embedded&&u.repoId==null)throw new Error(`Connect ${u.embedded} in Repositories before importing ${u.name}.`);if((u.repoId??i)==null)throw new Error(`Choose a repository for ${u.name}.`)}if((t.bundles.length>0||c.some(u=>u.repoId==null))&&i==null)throw new Error("Choose a repository for the source bundles.");let l=()=>s?.throwIfAborted(),d=async(u,p)=>{l(),t.completed.has(u)||(await p(),t.completed.add(u))};if(l(),!t.team){let u=await e.send("/api/admin/teams",{name:t.name.trim()});if(u.conflict)throw new Error("A team already has this name. Choose another name.");t.team=u}await d("member",()=>e.send("/api/admin/teams/set-member",{teamId:t.team.id,userId:n.id,security:!0,dependencies:!0}));for(let u of t.bundles)l(),a(`Importing ${u.name}\u2026`),u.uploaded||(u.uploaded=await e.send("/api/admin/bundles",new File([u.bytes],u.name),{"x-bundle-filename":encodeURIComponent(u.name),"x-repo-id":String(i)}));await lo(t,i,e,d,c);for(let u of t.reports){l(),a(`Importing ${u.name}\u2026`);let p=u.repoId??i;u.uploaded||(u.uploaded=await e.send("/api/admin/reports",new File([u.content],u.name),{"x-report-filename":encodeURIComponent(u.name),"x-repo-id":String(p),"x-repo-directory":encodeURIComponent(u.directory)})),await d(`repo:${p}:${u.directory}`,()=>e.send("/api/admin/teams/set-repo",{teamId:t.team.id,repoId:p,path:u.directory})),r&&await co(t,u,{api:e,resolveConflicts:o,signal:s}),await d(`publish:${u.uploaded.id}`,()=>e.send("/api/admin/reports/set-visible",{reportId:u.uploaded.id,visible:!0}))}return t.team}function Ce(t){let e=()=>t.isEncryptionEnabled()&&!t.isUnlocked();async function n(){let r=JSON.parse(await t.hydrateKey("deepview.workspaces")||"[]");return(Array.isArray(r)?r:r.workspaces??[]).filter(o=>typeof o.id=="string"&&typeof o.name=="string")}async function i(r,o){let s=!1,a=[t.onVaultStateChange,t.onFileMutated,t.onBundleMutated].map(l=>l(()=>{s=!0})),c=()=>{if(o?.throwIfAborted(),e()||s)throw new Error("Local data was locked or changed. Unlock it and select the workspace again.")};try{c();let l=await r(c);return c(),l}finally{a.forEach(l=>l())}}return{get locked(){return e()},unlock:r=>t.unlockEncryption(r),list:({signal:r}={})=>i(async()=>(await n()).map(o=>({id:o.id,name:o.name})),r),read:(r,{signal:o}={})=>i(async s=>{let a=(await n()).find(f=>f.id===r);if(!a)throw new Error("This local workspace no longer exists.");let c=JSON.stringify(a),l=JSON.parse(await t.hydrateKey("deepview.repoUrls")||"{}"),d=[];for(let f of a.reports??[]){s();let g=await t.withStoredItem("report",f,()=>t.readFile(f));s(),d.push({name:f,content:g,...l[f]?{repo:{github:l[f]}}:{}})}let u=await t.listBundles(),p=[];for(let f of a.bundles??[]){s();let g=u.find(_=>_.integrity===f);if(!g)continue;let b=await t.withStoredItem("bundle",f,()=>t.readBundle(f));s(),p.push({integrity:f,name:g.name,data:b.toBase64()})}let h=await t.readTriageBlob()??{};if(JSON.stringify((await n()).find(f=>f.id===r))!==c)throw new Error("The local workspace changed. Select it again.");return{version:1,workspace:{id:r,name:a.name,privateKey:""},reports:d,bundles:a.bundles??[],bundleBlobs:p,triage:h,repoUrls:l}},o)}}function uo(t,e){if(customElements.get("managed-admin-import"))return;class n extends t{static styles=[V(Ot),V(Nt)];static properties={localDeps:{attribute:!1},_busy:{state:!0},_error:{state:!0},_message:{state:!0},_plan:{state:!0},_catalog:{state:!0},_localOpen:{state:!0},_workspaces:{state:!0},_localId:{state:!0},_triage:{state:!0},_repo:{state:!0},_drag:{state:!0}};constructor(){super(),this._busy=!1,this._error="",this._message="",this._plan=null,this._catalog=null,this._workspaces=[],this._localOpen=!1,this._localId="",this._triage="",this._repo=null,this._files=[],this._drag=!1,this._local=!1,this._over=r=>{r.dataTransfer?.types?.includes("Files")&&(r.preventDefault(),this._drag=!0)},this._drop=r=>{r.dataTransfer?.types?.includes("Files")&&(r.preventDefault(),r.stopPropagation(),this._drag=!1,this._busy||(this._files.push(...r.dataTransfer.files),this._nextFile()))},this._localChanged=()=>{this._workspaces=[],this._localId="",this._local&&!this._unlocking&&(this._operation?.abort(),this._plan=null,this._message="",this._error="Local data changed or was locked. Choose the workspace again.")},this._blur=()=>{this._readingLocal&&this._localChanged()}}connectedCallback(){super.connectedCallback(),this.addEventListener("dragover",this._over),this.addEventListener("drop",this._drop),globalThis.addEventListener("blur",this._blur),globalThis.addEventListener("storage",this._localChanged),this._offVault=this.localDeps?.onVaultStateChange(this._localChanged),this._loadCatalog()}disconnectedCallback(){this._operation?.abort(),this._offVault?.(),this._plan=null,this._files=[],this.removeEventListener("dragover",this._over),this.removeEventListener("drop",this._drop),globalThis.removeEventListener("blur",this._blur),globalThis.removeEventListener("storage",this._localChanged),super.disconnectedCallback()}updated(r){r.has("session")&&(this._role!=="admin"||r.get("session")?.id!==this.session?.id)&&(this._operation?.abort(),this._plan=null,this._files=[])}async _action(r){if(this._busy||this._role!=="admin")return;let o=this._operation=new AbortController,s=AbortSignal.any([o.signal,this.appState.sessionController.signal]);this._busy=!0,this._error="";try{await r(s),s.throwIfAborted()}catch(a){s.aborted||(this._error=String(a?.message??a))}finally{this._operation===o&&(this._busy=!1)}}async _loadCatalog(){if(this._role==="admin")try{await this._loadCollection("workspace-import:catalog","import options",async r=>{let o=ut(e,this.session,r),[s,a,c]=await Promise.all([o.send("/api/admin/reports"),o.send("/api/admin/teams"),o.send("/api/admin/bundles")]);return{repos:s.repos,teams:a.teams,bundles:c.bundles}},r=>{this._catalog=r})}catch(r){this._error=String(r?.message??r)}}_browse(){let r=document.createElement("input");r.type="file",r.multiple=!0,r.accept=".enc,.gz,.json",r.addEventListener("change",()=>{this._files.push(...r.files),this._nextFile()},{once:!0}),r.click()}async _prepare(r,o){if(!r)return;if(this._catalog||await this._loadCatalog(),o.throwIfAborted(),!this._catalog)throw new Error("Could not load repositories. Try again.");let s=await Vn(r,this._catalog.repos);o.throwIfAborted();let a=new Set(this._catalog.teams.map(u=>u.name)),c=s.name||"Imported workspace",l=2;for(;a.has(s.name);){let u=` (${l++})`;s.name=c.slice(0,100-u.length)+u}let d=new Set(s.reports.map(u=>u.repoId).filter(u=>u!=null));this._repo=d.size===1?[...d][0]:null,this._triage=Object.keys(s.triage).length>0?"":"skip",this._plan=s,this._message=""}async _nextFile(){if(this._files.length===0||this._busy)return;let r=this._files.shift();this._local=!1,this._plan=null,await this._action(async o=>{let s=await jn(r,this.promptPassword);o.throwIfAborted(),await this._prepare(s,o)})}async _unlock(r,o){this._unlocking=!0;try{return!r.locked||await r.unlock({signal:o})}finally{this._unlocking=!1}}async _chooseLocal(){this._localOpen=!0,await this._action(async r=>{let o=Ce(this.localDeps);await this._unlock(o,r)&&(r.throwIfAborted(),this._workspaces=await o.list({signal:r}))})}async _readLocal(){if(!this._localId)return;let r=this._localId;this._local=!0,this._plan=null,await this._action(async o=>{let s=Ce(this.localDeps);if(await this._unlock(s,o)){this._readingLocal=!0;try{await this._prepare(await s.read(r,{signal:o}),o)}finally{this._readingLocal=!1}}})}async _import(){!this._plan||!this._triage||!this._csrf||(await this._action(async r=>{if(this._local&&Ce(this.localDeps).locked)throw new Error("Unlock local data and choose the workspace again.");let o=this._plan,s=["teams","reports","bundles","history","workspace-import","scan-sources","repo-impact"];try{let a=await this.appState.mutate(()=>Gn(o,{api:ut(e,this.session,r),session:this.session,defaultRepo:this._repo,includeTriage:this._triage==="include",resolveConflicts:this.resolveConflicts,signal:r,progress:c=>{this._message=c}}),s);this._catalog.teams.push(a),this._message=`Imported into ${a.name}.`,this._plan=null}finally{this.appState.invalidate(s),o.team&&this.dispatchEvent(new CustomEvent("managed-import-complete",{bubbles:!0,composed:!0}))}}),!this._plan&&!this._error&&await this._nextFile())}render(){if(this._role!=="admin")return m;let r=this._plan,o=r?Object.keys(r.triage).length:0,s=new Set([...r?.references??[],...r?.bundles.map(l=>l.integrity)??[]]),a=(this._catalog?.bundles??[]).filter(l=>s.has(l.integrity)),c=r?.references.filter(l=>!r.bundles.some(d=>d.integrity===l)).length??0;return w`<div class="wrap" @dragleave="${()=>{this._drag=!1}}">${Mt("manage-import",this._role,this.allowShare)}<h1>Import workspace</h1><p class="intro">Create a new team from a workspace export or a workspace stored in this browser.</p><section class="${`workspace-drop ${this._drag?"dragging":""}`}" aria-label="Workspace files"><strong>Drop workspace files here</strong><span>Encrypted exports, JSON, or compressed JSON. Encrypted files prompt for a password.</span><div class="actions"><button type="button" class="btn" ?disabled="${this._busy||!this._catalog}" @click="${()=>this._browse()}">Choose files</button> <button type="button" class="btn" ?disabled="${this._busy}" @click="${()=>this._chooseLocal()}">Choose local workspace</button></div></section>${this._localOpen?w`<section class="local-choice"><label for="local-workspace">Local workspace</label> <select id="local-workspace" .value="${this._localId}" ?disabled="${this._busy}" @change="${l=>{this._localId=l.target.value}}"><option value="">Choose a workspace…</option>${this._workspaces.map(l=>w`<option value="${l.id}">${l.name}</option>`)}</select> <button type="button" class="btn" ?disabled="${this._busy||!this._localId}" @click="${()=>this._readLocal()}">Select workspace</button> ${!this._busy&&this._workspaces.length===0?w`<p>No unlocked local workspaces. Choose local workspace to unlock or refresh.</p>`:m}</section>`:m} ${r?w`<section class="import-preview" aria-label="Import options">${r.team?w`<p>Team ${r.team.name} has been created. Retry continues its remaining import steps.</p>`:m} <label for="import-team">New team name</label><input id="import-team" maxlength="100" .value="${r.name}" ?disabled="${this._busy||!!r.team}" @input="${l=>{r.name=l.target.value,this.requestUpdate()}}"><p>${r.reports.length} reports / links · ${s.size} source bundles · ${o} triaged findings</p><label for="import-repo">Repository for files and source bundles without an existing repository</label> <select id="import-repo" .value="${String(this._repo??"")}" ?disabled="${this._busy||!!r.team}" @change="${l=>{this._repo=l.target.value?Number(l.target.value):null}}"><option value="" ?selected="${this._repo==null}">Choose a repository…</option>${(this._catalog?.repos??[]).map(l=>w`<option value="${String(l.repoId)}" ?selected="${this._repo===l.repoId}">${l.fullName}</option>`)}</select><p>Declared report repositories and directories are preserved. The new team receives those repository paths; other published reports in the same paths are also visible to that team.</p><ul>${r.reports.map(l=>w`<li>${l.name}<span>${l.github??"Uses selected repository"}${l.embedded&&l.repoId==null?" \u2014 connect this repository first":""}</span></li>`)}</ul>${a.length>0?w`<p>Existing source bundles keep their repository locations. The new team also receives access to those directories. Unassigned bundles use the selected repository.</p><ul>${a.map(l=>w`<li>${l.filename}<span>${l.repoFullName?`${l.repoFullName}${l.repoDirectory?`/${l.repoDirectory}`:""}`:"Uses selected repository"}</span></li>`)}</ul>`:m} ${c?w`<p>${c} referenced source bundles have no bytes in this export. Reports can use matching bundles already on the server.</p>`:m} ${o?w`<fieldset ?disabled="${this._busy||!!r.team}"><legend>Import triage?</legend><label><input type="radio" name="import-triage" value="include" ?checked="${this._triage==="include"}" @change="${()=>{this._triage="include"}}"> Include triage and comments</label> <label><input type="radio" name="import-triage" value="skip" ?checked="${this._triage==="skip"}" @change="${()=>{this._triage="skip"}}"> Skip triage</label><p>Conflicting values prompt for resolution. Triage stays shared by finding ID across teams. Per-report local ignores are not imported.</p></fieldset>`:m} <button type="button" class="btn primary" ?disabled="${this._busy||!this._triage||!this._csrf}" @click="${()=>this._import()}">${this._busy?"Importing\u2026":r.team?"Retry remaining import":"Import workspace"}</button></section>`:m} ${this._message?w`<p role="status">${this._message}</p>`:m} ${this._error?w`<p class="error" role="alert">${this._error}${this._catalog?m:w`<button class="btn" @click="${()=>this._loadCatalog()}">Retry</button>`}</p>`:m} ${this._files.length>0&&!this._busy?w`<button class="btn" @click="${()=>this._nextFile()}">Next workspace file (${this._files.length} waiting)</button>`:m}</div>`}}customElements.define("managed-admin-import",n)}export{uo as registerWorkspaceImport};
|
|
54
|
+
`),imported:d}):c.comment=d}else if(a[l]!==void 0&&a[l]!==d){let u=p=>l==="flagged"?p?"flagged":"not flagged":p;n.push({id:r,property:l,local:u(a[l]),imported:u(d)})}else c[l]=d}return{entries:i,conflicts:n}}function ut(t,e,n){async function i(r,o,s={}){n?.throwIfAborted();let a=await t(r,{credentials:"same-origin",signal:n,...o===void 0?{}:{method:"POST",body:o instanceof File?o:JSON.stringify(o)},headers:{...o instanceof File?{}:{"content-type":"application/json"},"x-csrf-token":e.csrfToken,...s}});if(n?.throwIfAborted(),a.status===409)return{conflict:!0};if(!a.ok)throw new Error(`Import request failed (HTTP ${a.status}). You can retry the remaining steps.`);let c=await a.json();return n?.throwIfAborted(),c}return{send:i}}async function so(t,e,n,i,r){r?.throwIfAborted();let{entries:o,conflicts:s}=oo(t,e);if(s.length===0)return o;let a=await i(s,n,{title:"Triage conflicts on import",intro:"disagree with stored triage on",trailingNote:"Imported comments are added alongside existing comments. Other choices apply to these finding IDs across all teams.",importedSideLabel:"Apply imported"});if(r?.throwIfAborted(),!a)throw new Error("Triage conflict resolution was cancelled.");for(let c of s){let l=a[`${c.id}:${c.property}`];if(!["local","imported"].includes(l))throw new Error("Resolve every triage conflict before importing.");l==="imported"&&(o[c.id][c.property]=t[c.id][c.property])}return o}function ao(t,e){let n=[],i={entries:{},expected:{}};for(let[r,o]of Object.entries(t)){let s={entries:{...i.entries,[r]:o},expected:{...i.expected,[r]:e[r].version}};if(new TextEncoder().encode(JSON.stringify(s)).length<=262144){i=s;continue}if(Object.keys(i.entries).length===0)throw new Error("This triage entry is too large to import.");n.push(i),i={entries:{[r]:o},expected:{[r]:e[r].version}}}return Object.keys(i.entries).length>0&&n.push(i),n}async function co(t,e,{api:n,resolveConflicts:i,signal:r}){let o=e.ids.filter(a=>Object.hasOwn(t.triage,a)&&!t.importedIds.has(a)),s=`/api/admin/reports/${encodeURIComponent(e.uploaded.id)}/import-triage`;for(let a=0;a<o.length;a+=200){let c=o.slice(a,a+200);for(let l=0;l<5&&c.length>0;l++){r?.throwIfAborted();let{snapshots:d}=await n.send(s,{findingIds:c}),u=Object.fromEntries(c.map(h=>[h,t.triage[h]])),p=await so(u,d,t.lookup,i,r);for(let h of ao(p,d)){if(r?.throwIfAborted(),(await n.send(s,h)).conflict)break;Object.keys(h.entries).forEach(f=>t.importedIds.add(f))}c=c.filter(h=>!t.importedIds.has(h))}if(c.length>0)throw new Error("Stored triage keeps changing. Retry to resolve it against the latest values.")}}async function lo(t,e,n,i,r,o){if(t.bundles.length===0&&t.references.length===0)return;let s=r??(await n.send("/api/admin/bundles")).bundles,a=new Set(t.bundles.map(l=>l.uploaded.id));if([...a].some(l=>!s.some(d=>d.id===l)))throw new Error("Could not verify the uploaded source bundles. Retry the import.");let c=new Set(t.references);for(let l of s){if(!a.has(l.id)&&!c.has(l.integrity))continue;let d=!o.has(l.repoId),u=d?e:l.repoId;d&&await i(`bundle-repo:${l.id}:${u}`,()=>n.send("/api/admin/bundles/set-repo",{bundleId:l.id,repoId:u}));let p=d?"":l.repoDirectory??"";await i(`repo:${u}:${p}`,()=>n.send("/api/admin/teams/set-repo",{teamId:t.team.id,repoId:u,path:p}))}}async function Gn(t,{api:e,session:n,defaultRepo:i,includeTriage:r,resolveConflicts:o,signal:s,progress:a=()=>{}}){if(n?.role!=="admin"||!n.csrfToken)throw new Error("An administrator session is required.");if(!t.name.trim()||t.name.trim().length>100)throw new Error("Enter a team name of up to 100 characters.");if(r)for(let[p,h]of Object.entries(t.triage)){if(!p||p.length>100)throw new Error(`Triage finding IDs must be between 1 and ${100} characters. Choose Skip triage to import the files without it.`);if(Hn(h)==="invalid")throw new Error(`Triage for ${p} exceeds the managed server limits. Choose Skip triage to import the files without it.`)}s?.throwIfAborted();let c=null;if(t.bundles.length===0){let p=new Set(t.references);c=p.size>0?(await e.send("/api/admin/bundles")).bundles.filter(h=>p.has(h.integrity)):[]}if(t.reports.length===0&&t.bundles.length===0&&c.length===0)throw new Error(t.references.length>0?"None of the referenced source bundles are available on the server. Export with bundle bytes included and try again.":"This workspace contains no report or bundle files.");let l=new Set((await e.send("/api/admin/repositories/browsable")).repos.map(p=>p.repoId));for(let p of t.reports){if(p.embedded&&!l.has(p.repoId))throw new Error(`Connect ${p.embedded} in Repositories before importing ${p.name}.`);if(!l.has(p.repoId)&&!l.has(i))throw new Error(`Choose an active repository for ${p.name}.`)}if((t.bundles.length>0||c.some(p=>!l.has(p.repoId)))&&!l.has(i))throw new Error("Choose an active repository for the source bundles.");let d=()=>s?.throwIfAborted(),u=async(p,h)=>{d(),t.completed.has(p)||(await h(),t.completed.add(p))};if(d(),!t.team){let p=await e.send("/api/admin/teams",{name:t.name.trim()});if(p.conflict)throw new Error("A team already has this name. Choose another name.");t.team=p}await u("member",()=>e.send("/api/admin/teams/set-member",{teamId:t.team.id,userId:n.id,security:!0,dependencies:!0}));for(let p of t.bundles)d(),a(`Importing ${p.name}\u2026`),p.uploaded||(p.uploaded=await e.send("/api/admin/bundles",new File([p.bytes],p.name),{"x-bundle-filename":encodeURIComponent(p.name),"x-repo-id":String(i)}));await lo(t,i,e,u,c,l);for(let p of t.reports){d(),a(`Importing ${p.name}\u2026`);let h=l.has(p.repoId)?p.repoId:i;if(!p.uploaded){let b=await e.send("/api/admin/reports",new File([p.content],p.name),{"x-report-filename":encodeURIComponent(p.name),"x-repo-id":String(h),"x-repo-directory":encodeURIComponent(p.directory)});if(b.conflict)throw new Error(`Could not reuse the stored report ${p.name}. Retry the import.`);p.uploaded=b}let f=p.uploaded;l.has(f.repoId)||await u(`report-repo:${f.id}:${h}:${p.directory}`,async()=>{if((await e.send("/api/admin/reports/set-repo",{reportId:f.id,repoId:h,directory:p.directory})).conflict)throw new Error(`Could not assign the stored report ${p.name} to its repository.`);f.repoId=h,f.repoDirectory=p.directory});let g=f.repoDirectory??"";await u(`repo:${f.repoId}:${g}`,()=>e.send("/api/admin/teams/set-repo",{teamId:t.team.id,repoId:f.repoId,path:g})),r&&await co(t,p,{api:e,resolveConflicts:o,signal:s}),await u(`publish:${p.uploaded.id}`,()=>e.send("/api/admin/reports/set-visible",{reportId:p.uploaded.id,visible:!0}))}return t.team}function Ce(t){let e=()=>t.isEncryptionEnabled()&&!t.isUnlocked();async function n(){let r=JSON.parse(await t.hydrateKey("deepview.workspaces")||"[]");return(Array.isArray(r)?r:r.workspaces??[]).filter(o=>typeof o.id=="string"&&typeof o.name=="string")}async function i(r,o){let s=!1,a=[t.onVaultStateChange,t.onFileMutated,t.onBundleMutated].map(l=>l(()=>{s=!0})),c=()=>{if(o?.throwIfAborted(),e()||s)throw new Error("Local data was locked or changed. Unlock it and select the workspace again.")};try{c();let l=await r(c);return c(),l}finally{a.forEach(l=>l())}}return{get locked(){return e()},unlock:r=>t.unlockEncryption(r),list:({signal:r}={})=>i(async()=>(await n()).map(o=>({id:o.id,name:o.name})),r),read:(r,{signal:o}={})=>i(async s=>{let a=(await n()).find(f=>f.id===r);if(!a)throw new Error("This local workspace no longer exists.");let c=JSON.stringify(a),l=JSON.parse(await t.hydrateKey("deepview.repoUrls")||"{}"),d=[];for(let f of a.reports??[]){s();let g=await t.withStoredItem("report",f,()=>t.readFile(f));s(),d.push({name:f,content:g,...l[f]?{repo:{github:l[f]}}:{}})}let u=await t.listBundles(),p=[];for(let f of a.bundles??[]){s();let g=u.find(_=>_.integrity===f);if(!g)continue;let b=await t.withStoredItem("bundle",f,()=>t.readBundle(f));s(),p.push({integrity:f,name:g.name,data:b.toBase64()})}let h=await t.readTriageBlob()??{};if(JSON.stringify((await n()).find(f=>f.id===r))!==c)throw new Error("The local workspace changed. Select it again.");return{version:1,workspace:{id:r,name:a.name,privateKey:""},reports:d,bundles:a.bundles??[],bundleBlobs:p,triage:h,repoUrls:l}},o)}}function uo(t,e){if(customElements.get("managed-admin-import"))return;class n extends t{static styles=[V(Ot),V(Nt)];static properties={localDeps:{attribute:!1},_busy:{state:!0},_error:{state:!0},_message:{state:!0},_plan:{state:!0},_catalog:{state:!0},_localOpen:{state:!0},_workspaces:{state:!0},_localId:{state:!0},_triage:{state:!0},_repo:{state:!0},_drag:{state:!0}};constructor(){super(),this._busy=!1,this._error="",this._message="",this._plan=null,this._catalog=null,this._workspaces=[],this._localOpen=!1,this._localId="",this._triage="",this._repo=null,this._files=[],this._drag=!1,this._local=!1,this._over=r=>{r.dataTransfer?.types?.includes("Files")&&(r.preventDefault(),this._drag=!0)},this._drop=r=>{r.dataTransfer?.types?.includes("Files")&&(r.preventDefault(),r.stopPropagation(),this._drag=!1,this._busy||(this._files.push(...r.dataTransfer.files),this._nextFile()))},this._localChanged=()=>{this._workspaces=[],this._localId="",this._local&&!this._unlocking&&(this._operation?.abort(),this._plan=null,this._message="",this._error="Local data changed or was locked. Choose the workspace again.")},this._blur=()=>{this._readingLocal&&this._localChanged()}}connectedCallback(){super.connectedCallback(),this.addEventListener("dragover",this._over),this.addEventListener("drop",this._drop),globalThis.addEventListener("blur",this._blur),globalThis.addEventListener("storage",this._localChanged),this._offVault=this.localDeps?.onVaultStateChange(this._localChanged),this._loadCatalog()}disconnectedCallback(){this._operation?.abort(),this._offVault?.(),this._plan=null,this._files=[],this.removeEventListener("dragover",this._over),this.removeEventListener("drop",this._drop),globalThis.removeEventListener("blur",this._blur),globalThis.removeEventListener("storage",this._localChanged),super.disconnectedCallback()}updated(r){r.has("session")&&(this._role!=="admin"||r.get("session")?.id!==this.session?.id)&&(this._operation?.abort(),this._plan=null,this._files=[])}async _action(r){if(this._busy||this._role!=="admin")return;let o=this._operation=new AbortController,s=AbortSignal.any([o.signal,this.appState.sessionController.signal]);this._busy=!0,this._error="";try{await r(s),s.throwIfAborted()}catch(a){s.aborted||(this._error=String(a?.message??a))}finally{this._operation===o&&(this._busy=!1)}}async _loadCatalog(){if(this._role==="admin")try{await this._loadCollection("workspace-import:catalog","import options",async r=>{let o=ut(e,this.session,r),[s,a,c]=await Promise.all([o.send("/api/admin/reports"),o.send("/api/admin/teams"),o.send("/api/admin/bundles")]);return{repos:s.repos,teams:a.teams,bundles:c.bundles}},r=>{this._catalog=r})}catch(r){this._error=String(r?.message??r)}}_browse(){let r=document.createElement("input");r.type="file",r.multiple=!0,r.accept=".enc,.gz,.json",r.addEventListener("change",()=>{this._files.push(...r.files),this._nextFile()},{once:!0}),r.click()}async _prepare(r,o){if(!r)return;if(this._catalog||await this._loadCatalog(),o.throwIfAborted(),!this._catalog)throw new Error("Could not load repositories. Try again.");let s=await Vn(r,this._catalog.repos);o.throwIfAborted();let a=new Set(this._catalog.teams.map(u=>u.name)),c=s.name||"Imported workspace",l=2;for(;a.has(s.name);){let u=` (${l++})`;s.name=c.slice(0,100-u.length)+u}let d=new Set(s.reports.map(u=>u.repoId).filter(u=>u!=null));this._repo=d.size===1?[...d][0]:null,this._triage=Object.keys(s.triage).length>0?"":"skip",this._plan=s,this._message=""}async _nextFile(){if(this._files.length===0||this._busy)return;let r=this._files.shift();this._local=!1,this._plan=null,await this._action(async o=>{let s=await jn(r,this.promptPassword);o.throwIfAborted(),await this._prepare(s,o)})}async _unlock(r,o){this._unlocking=!0;try{return!r.locked||await r.unlock({signal:o})}finally{this._unlocking=!1}}async _chooseLocal(){this._localOpen=!0,await this._action(async r=>{let o=Ce(this.localDeps);await this._unlock(o,r)&&(r.throwIfAborted(),this._workspaces=await o.list({signal:r}))})}async _readLocal(){if(!this._localId)return;let r=this._localId;this._local=!0,this._plan=null,await this._action(async o=>{let s=Ce(this.localDeps);if(await this._unlock(s,o)){this._readingLocal=!0;try{await this._prepare(await s.read(r,{signal:o}),o)}finally{this._readingLocal=!1}}})}async _import(){!this._plan||!this._triage||!this._csrf||(await this._action(async r=>{if(this._local&&Ce(this.localDeps).locked)throw new Error("Unlock local data and choose the workspace again.");let o=this._plan,s=["teams","reports","bundles","history","workspace-import","scan-sources","repo-impact"];try{let a=await this.appState.mutate(()=>Gn(o,{api:ut(e,this.session,r),session:this.session,defaultRepo:this._repo,includeTriage:this._triage==="include",resolveConflicts:this.resolveConflicts,signal:r,progress:c=>{this._message=c}}),s);this._catalog.teams.push(a),this._message=`Imported into ${a.name}.`,this._plan=null}finally{this.appState.invalidate(s),o.team&&this.dispatchEvent(new CustomEvent("managed-import-complete",{bubbles:!0,composed:!0}))}}),!this._plan&&!this._error&&await this._nextFile())}render(){if(this._role!=="admin")return m;let r=this._plan,o=r?Object.keys(r.triage).length:0,s=new Set([...r?.references??[],...r?.bundles.map(l=>l.integrity)??[]]),a=(this._catalog?.bundles??[]).filter(l=>s.has(l.integrity)),c=r?.references.filter(l=>!r.bundles.some(d=>d.integrity===l)).length??0;return w`<div class="wrap" @dragleave="${()=>{this._drag=!1}}">${Mt("manage-import",this._role,this.allowShare)}<h1>Import workspace</h1><p class="intro">Create a new team from a workspace export or a workspace stored in this browser.</p><section class="${`workspace-drop ${this._drag?"dragging":""}`}" aria-label="Workspace files"><strong>Drop workspace files here</strong><span>Encrypted exports, JSON, or compressed JSON. Encrypted files prompt for a password.</span><div class="actions"><button type="button" class="btn" ?disabled="${this._busy||!this._catalog}" @click="${()=>this._browse()}">Choose files</button> <button type="button" class="btn" ?disabled="${this._busy}" @click="${()=>this._chooseLocal()}">Choose local workspace</button></div></section>${this._localOpen?w`<section class="local-choice"><label for="local-workspace">Local workspace</label> <select id="local-workspace" .value="${this._localId}" ?disabled="${this._busy}" @change="${l=>{this._localId=l.target.value}}"><option value="">Choose a workspace…</option>${this._workspaces.map(l=>w`<option value="${l.id}">${l.name}</option>`)}</select> <button type="button" class="btn" ?disabled="${this._busy||!this._localId}" @click="${()=>this._readLocal()}">Select workspace</button> ${!this._busy&&this._workspaces.length===0?w`<p>No unlocked local workspaces. Choose local workspace to unlock or refresh.</p>`:m}</section>`:m} ${r?w`<section class="import-preview" aria-label="Import options">${r.team?w`<p>Team ${r.team.name} has been created. Retry continues its remaining import steps.</p>`:m} <label for="import-team">New team name</label><input id="import-team" maxlength="100" .value="${r.name}" ?disabled="${this._busy||!!r.team}" @input="${l=>{r.name=l.target.value,this.requestUpdate()}}"><p>${r.reports.length} reports / links · ${s.size} source bundles · ${o} triaged findings</p><label for="import-repo">Repository for files and source bundles without an active repository</label> <select id="import-repo" .value="${String(this._repo??"")}" ?disabled="${this._busy||!!r.team}" @change="${l=>{this._repo=l.target.value?Number(l.target.value):null}}"><option value="" ?selected="${this._repo==null}">Choose a repository…</option>${(this._catalog?.repos??[]).map(l=>w`<option value="${String(l.repoId)}" ?selected="${this._repo===l.repoId}">${l.fullName}</option>`)}</select><p>Declared report repositories and directories are preserved. Stored reports without an active repository use the selected repository. The new team receives those repository paths; other published reports in the same paths are also visible to that team.</p><ul>${r.reports.map(l=>w`<li>${l.name}<span>${l.github??"Uses selected repository"}${l.embedded&&l.repoId==null?" \u2014 connect this repository first":""}</span></li>`)}</ul>${a.length>0?w`<p>Existing source bundles in active repositories keep their locations. The new team also receives access to those directories. Unassigned bundles and bundles in inactive repositories use the selected repository.</p><ul>${a.map(l=>w`<li>${l.filename}<span>${l.repoFullName?`${l.repoFullName}${l.repoDirectory?`/${l.repoDirectory}`:""}`:"Uses selected repository"}</span></li>`)}</ul>`:m} ${c?w`<p>${c} referenced source bundles have no bytes in this export. Reports can use matching bundles already on the server.</p>`:m} ${o?w`<fieldset ?disabled="${this._busy||!!r.team}"><legend>Import triage?</legend><label><input type="radio" name="import-triage" value="include" ?checked="${this._triage==="include"}" @change="${()=>{this._triage="include"}}"> Include triage and comments</label> <label><input type="radio" name="import-triage" value="skip" ?checked="${this._triage==="skip"}" @change="${()=>{this._triage="skip"}}"> Skip triage</label><p>Conflicting values prompt for resolution. Triage stays shared by finding ID across teams. Per-report local ignores are not imported.</p></fieldset>`:m} <button type="button" class="btn primary" ?disabled="${this._busy||!this._triage||!this._csrf}" @click="${()=>this._import()}">${this._busy?"Importing\u2026":r.team?"Retry remaining import":"Import workspace"}</button></section>`:m} ${this._message?w`<p role="status">${this._message}</p>`:m} ${this._error?w`<p class="error" role="alert">${this._error}${this._catalog?m:w`<button class="btn" @click="${()=>this._loadCatalog()}">Retry</button>`}</p>`:m} ${this._files.length>0&&!this._busy?w`<button class="btn" @click="${()=>this._nextFile()}">Next workspace file (${this._files.length} waiting)</button>`:m}</div>`}}customElements.define("managed-admin-import",n)}export{uo as registerWorkspaceImport};
|
|
55
55
|
/*! Bundled license information:
|
|
56
56
|
|
|
57
57
|
@lit/reactive-element/css-tag.js:
|