heyquilt 0.0.1 → 0.3.106
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/LICENSE +85 -0
- package/README.md +633 -2
- package/RELEASES.md +862 -0
- package/assets/logo.svg +1 -0
- package/bin/quilt.js +849 -0
- package/desktop/main.js +315 -0
- package/package.json +163 -7
- package/src/account.js +591 -0
- package/src/admit-policy.js +32 -0
- package/src/agent-join.js +370 -0
- package/src/agent-task-workflow.js +176 -0
- package/src/agents/actions.js +85 -0
- package/src/agents/claude-code.js +187 -0
- package/src/agents/common.js +21 -0
- package/src/agents/cursor-transcripts.js +177 -0
- package/src/agents/cursor.js +404 -0
- package/src/agents/index.js +69 -0
- package/src/ai-vm-signin.js +110 -0
- package/src/ai-vm.js +522 -0
- package/src/api/access.js +75 -0
- package/src/api/activity.js +181 -0
- package/src/api/agent-auth.js +136 -0
- package/src/api/agent-placement.js +157 -0
- package/src/api/audit.js +83 -0
- package/src/api/auth.js +17 -0
- package/src/api/domains.js +30 -0
- package/src/api/file-paths.js +40 -0
- package/src/api/file-store.js +99 -0
- package/src/api/fly-machines.js +133 -0
- package/src/api/github-app.js +172 -0
- package/src/api/http.js +34 -0
- package/src/api/invite-email.js +53 -0
- package/src/api/issues.js +110 -0
- package/src/api/join-text.js +78 -0
- package/src/api/mailer.js +47 -0
- package/src/api/memory-store.js +1219 -0
- package/src/api/org-access.js +54 -0
- package/src/api/permissions.js +71 -0
- package/src/api/relay-cluster.js +545 -0
- package/src/api/relay-machines.js +92 -0
- package/src/api/releases.js +87 -0
- package/src/api/routes/access-types.js +54 -0
- package/src/api/routes/agent-invites.js +115 -0
- package/src/api/routes/agents.js +252 -0
- package/src/api/routes/ai-vm.js +495 -0
- package/src/api/routes/github.js +380 -0
- package/src/api/routes/grants.js +70 -0
- package/src/api/routes/invites.js +188 -0
- package/src/api/routes/issues.js +47 -0
- package/src/api/routes/join.js +167 -0
- package/src/api/routes/members.js +110 -0
- package/src/api/routes/orgs.js +179 -0
- package/src/api/routes/relay-cluster.js +90 -0
- package/src/api/routes/relay.js +76 -0
- package/src/api/routes/secrets.js +448 -0
- package/src/api/routes/session-invites.js +119 -0
- package/src/api/routes/sessions.js +92 -0
- package/src/api/routes/teams.js +132 -0
- package/src/api/routes/workspace-agents.js +332 -0
- package/src/api/routes/workspace-files.js +277 -0
- package/src/api/routes/workspace-invites.js +196 -0
- package/src/api/routes/workspaces.js +232 -0
- package/src/api/sandbox-keys.js +57 -0
- package/src/api/secrets-access.js +93 -0
- package/src/api/server.js +654 -0
- package/src/api/slugs.js +20 -0
- package/src/api/supabase-store.js +875 -0
- package/src/api/team-access.js +30 -0
- package/src/api/tokens.js +31 -0
- package/src/api/workspace-access.js +62 -0
- package/src/api/workspace-reach.js +60 -0
- package/src/autowork-tools.js +166 -0
- package/src/autowork.js +375 -0
- package/src/blobstore.js +99 -0
- package/src/boxes.js +37 -0
- package/src/branchdocs.js +235 -0
- package/src/branches.js +194 -0
- package/src/catchup.js +111 -0
- package/src/chat-archive.js +85 -0
- package/src/chat-links.js +923 -0
- package/src/clash.js +128 -0
- package/src/cloud-ai.js +50 -0
- package/src/commit-request.js +59 -0
- package/src/commit.js +213 -0
- package/src/connection.js +569 -0
- package/src/control.js +248 -0
- package/src/conversation.js +125 -0
- package/src/doctor.js +156 -0
- package/src/duties.js +303 -0
- package/src/editors.js +228 -0
- package/src/fsutil.js +97 -0
- package/src/git-hook.js +48 -0
- package/src/git.js +147 -0
- package/src/github-commit.js +320 -0
- package/src/gitignore.js +49 -0
- package/src/gitstate.js +924 -0
- package/src/history.js +307 -0
- package/src/hooks.js +371 -0
- package/src/hosted-agents.js +24 -0
- package/src/hosted-client.js +112 -0
- package/src/identity.js +99 -0
- package/src/inbox.js +237 -0
- package/src/integrations.js +221 -0
- package/src/key-cli.js +115 -0
- package/src/largefiles.js +91 -0
- package/src/legacy.js +25 -0
- package/src/mcp-proxy.js +45 -0
- package/src/mcp-room.js +32 -0
- package/src/mcp.js +1258 -0
- package/src/merge3.js +240 -0
- package/src/merges.js +148 -0
- package/src/pass-source.js +183 -0
- package/src/passes.js +64 -0
- package/src/pathrules.js +134 -0
- package/src/persona.js +101 -0
- package/src/presence.js +360 -0
- package/src/private-file.js +41 -0
- package/src/procs.js +70 -0
- package/src/project-rules.js +178 -0
- package/src/protocol.js +92 -0
- package/src/quiet-warnings.js +9 -0
- package/src/relay-ai-vm.js +82 -0
- package/src/relay-ask.js +141 -0
- package/src/relay-ci.js +111 -0
- package/src/relay-clash.js +214 -0
- package/src/relay-cluster.js +454 -0
- package/src/relay-commit.js +887 -0
- package/src/relay-filekey.js +46 -0
- package/src/relay-git.js +274 -0
- package/src/relay-githttp.js +166 -0
- package/src/relay-github.js +109 -0
- package/src/relay-lease.js +137 -0
- package/src/relay-lfs.js +37 -0
- package/src/relay-mcp.js +1691 -0
- package/src/relay-metrics.js +140 -0
- package/src/relay-outbound.js +1273 -0
- package/src/relay-sync.js +716 -0
- package/src/relay-turn-sync.js +506 -0
- package/src/relay-upstream.js +804 -0
- package/src/relay-webhooks.js +111 -0
- package/src/releases.js +138 -0
- package/src/report.js +129 -0
- package/src/runner.js +307 -0
- package/src/same-edit.js +18 -0
- package/src/secret-import.js +55 -0
- package/src/secret-names.js +26 -0
- package/src/secret-run.js +80 -0
- package/src/secrets-client.js +56 -0
- package/src/server.js +4214 -0
- package/src/session-access.js +170 -0
- package/src/session-name.js +15 -0
- package/src/session.js +7446 -0
- package/src/settings.js +46 -0
- package/src/setup.js +184 -0
- package/src/shell-path.js +46 -0
- package/src/status.js +197 -0
- package/src/summarize.js +109 -0
- package/src/sync-words.js +42 -0
- package/src/task-comments.js +92 -0
- package/src/tasks.js +670 -0
- package/src/textdiff.js +20 -0
- package/src/turn-sync-plan.js +145 -0
- package/src/turn-sync.js +212 -0
- package/src/ui/access-form.js +37 -0
- package/src/ui/agent-env-key.js +76 -0
- package/src/ui/agent-guide.js +127 -0
- package/src/ui/agent-kinds.js +175 -0
- package/src/ui/agent-place.js +265 -0
- package/src/ui/app.css +2494 -0
- package/src/ui/app.js +546 -0
- package/src/ui/board.js +465 -0
- package/src/ui/boxes.js +76 -0
- package/src/ui/branches.js +111 -0
- package/src/ui/catchup.js +77 -0
- package/src/ui/changes.js +159 -0
- package/src/ui/chat.js +266 -0
- package/src/ui/cloud-ai-model.js +108 -0
- package/src/ui/cloud-ai.js +221 -0
- package/src/ui/common.js +433 -0
- package/src/ui/csv.js +21 -0
- package/src/ui/diffview.js +74 -0
- package/src/ui/feed-convs.js +39 -0
- package/src/ui/feed.js +174 -0
- package/src/ui/files.js +579 -0
- package/src/ui/fileview.js +150 -0
- package/src/ui/git-rule.js +60 -0
- package/src/ui/home.js +1322 -0
- package/src/ui/index.html +16 -0
- package/src/ui/invite.js +97 -0
- package/src/ui/invites.js +79 -0
- package/src/ui/loom-model.js +516 -0
- package/src/ui/loom.css +365 -0
- package/src/ui/loom.js +786 -0
- package/src/ui/mark.js +104 -0
- package/src/ui/rail-model.js +75 -0
- package/src/ui/rail.js +173 -0
- package/src/ui/releases.js +182 -0
- package/src/ui/sandbox-keys-model.js +53 -0
- package/src/ui/sandbox-keys.js +132 -0
- package/src/ui/schedule.js +156 -0
- package/src/ui/secret-rules.js +255 -0
- package/src/ui/secrets-view.js +162 -0
- package/src/ui/secrets.js +283 -0
- package/src/ui/session.js +3292 -0
- package/src/ui/signin.js +75 -0
- package/src/ui/sounds.js +110 -0
- package/src/ui/tool-logo.js +86 -0
- package/src/ui/tree.js +281 -0
- package/src/ui/workspaces.js +547 -0
- package/src/ui-server.js +1381 -0
- package/src/update-check.js +67 -0
- package/src/upstream.js +192 -0
- package/src/vault/crypto.js +96 -0
- package/src/vault/kms.js +61 -0
- package/src/vault/main.js +33 -0
- package/src/vault/server.js +279 -0
- package/src/vault/sigv4.js +38 -0
- package/src/webhooks.js +235 -0
- package/src/workspace-cli.js +62 -0
- package/src/workspace-tools.js +340 -0
- package/index.js +0 -2
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// The Secrets card (mockup docs/mockups/2026-10-18-secrets.html, option B), as HTML from the
|
|
2
|
+
// API's view of one scope and the card's own state. Pure: secrets.js draws it and binds it in
|
|
3
|
+
// the app; tests read it. A value never appears here: the API has none to give, and the value
|
|
4
|
+
// a person types stays in its input (secrets.js sets it back after a redraw, never in markup).
|
|
5
|
+
import { FORMATS, FORMAT_IDS, formatLabel } from './secret-rules.js'
|
|
6
|
+
|
|
7
|
+
export const LEVEL_WORDS = { org: 'Team', workspace: 'Workspace', session: 'Session' }
|
|
8
|
+
const ORDER = ['org', 'workspace', 'session']
|
|
9
|
+
const LOCK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>'
|
|
10
|
+
const AVATAR_COLORS = ['#24706b', '#8a5a08', '#5b4bb7', '#2f5d8a', '#a8263e', '#2f6b40']
|
|
11
|
+
|
|
12
|
+
export const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]))
|
|
13
|
+
|
|
14
|
+
/** "2 min ago", "1 h ago", "today", a date; "never" for nothing. */
|
|
15
|
+
export function usedText (ts, now = Date.now()) {
|
|
16
|
+
if (!ts) return 'never'
|
|
17
|
+
const s = Math.max(0, Math.round((now - ts) / 1000))
|
|
18
|
+
if (s < 60) return 'just now'
|
|
19
|
+
if (s < 3600) return `${Math.round(s / 60)} min ago`
|
|
20
|
+
if (s < 6 * 3600) return `${Math.round(s / 3600)} h ago`
|
|
21
|
+
if (new Date(ts).toDateString() === new Date(now).toDateString()) return 'today'
|
|
22
|
+
return new Date(ts).toLocaleDateString([], { day: 'numeric', month: 'short' })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The levels a place has, Team first: [{ type, id, label }]. */
|
|
26
|
+
export function levelTabs (view) {
|
|
27
|
+
return [...(view?.levels || [])].sort((a, b) => ORDER.indexOf(a.type) - ORDER.indexOf(b.type)).map((l) => ({ type: l.type, id: l.id, label: LEVEL_WORDS[l.type], name: l.name }))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** What the inherit switch says: "Workspace and team secrets here", "Team secrets here". */
|
|
31
|
+
export function inheritLabel (view) {
|
|
32
|
+
const above = (view.levels || []).slice(1).map((l) => l.type)
|
|
33
|
+
const words = above.map((t) => (t === 'org' ? 'team' : 'workspace'))
|
|
34
|
+
if (!words.length) return ''
|
|
35
|
+
const list = words.length === 1 ? words[0] : `${words.slice(0, -1).join(', ')} and ${words.at(-1)}`
|
|
36
|
+
return `${list[0].toUpperCase()}${list.slice(1)} secrets here`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** "X-Token: Token {value}" as a format, or null. */
|
|
40
|
+
export function parseCustomFormat (text) {
|
|
41
|
+
const m = /^\s*([A-Za-z0-9-]{1,64})\s*:\s*(.*\{value\}.*)$/.exec(String(text || ''))
|
|
42
|
+
return m ? { in: 'header', name: m[1].toLowerCase(), template: m[2].trim() } : null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const sw = (on, attrs, label, { disabled = false, small = false } = {}) =>
|
|
46
|
+
`<button type="button" class="sx-sw${small ? ' sm' : ''}${on ? ' on' : ''}" role="switch" aria-checked="${on}" ${attrs}${disabled ? ' disabled' : ''}><i aria-hidden="true"></i><span>${label}</span></button>`
|
|
47
|
+
|
|
48
|
+
const formatOptions = (selected) => [...FORMAT_IDS.map((id) => `<option value="${id}"${selected === id ? ' selected' : ''}>${esc(FORMATS[id].label)}</option>`), `<option value="custom"${selected === 'custom' ? ' selected' : ''}>A header of your own…</option>`].join('')
|
|
49
|
+
|
|
50
|
+
function avatars (view) {
|
|
51
|
+
const s = view.settings
|
|
52
|
+
const list = s.agents === 'all' ? view.agentChoices : view.agentChoices.filter((a) => s.agents.includes(a.id))
|
|
53
|
+
if (!s.agentsOn) return '<span class="sx-faint">off</span>'
|
|
54
|
+
if (s.agents === 'all') return `<span class="sx-faint">All agents${list.length ? ` (${list.length})` : ''}</span>`
|
|
55
|
+
if (!list.length) return '<span class="sx-faint">none chosen</span>'
|
|
56
|
+
return `<span class="sx-who">${list.slice(0, 5).map((a, i) => `<span class="sx-av" title="${esc(a.name)}" style="background:${AVATAR_COLORS[i % AVATAR_COLORS.length]}">${esc([...a.name][0] || '?')}</span>`).join('')}${list.length > 5 ? `<span class="sx-faint">+${list.length - 5}</span>` : ''}</span>`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function inheritRight (view) {
|
|
60
|
+
const s = view.settings
|
|
61
|
+
if (s.inheritOff) return `<span class="sx-faint">off${s.inheritOffAt ? ` since ${new Date(s.inheritOffAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}` : ''}${s.inheritOffBy ? ` by ${esc(s.inheritOffBy)}` : ''}</span>`
|
|
62
|
+
const parents = (view.levels || []).slice(1)
|
|
63
|
+
const n = (view.inherited || []).length
|
|
64
|
+
return `<span class="sx-faint">${esc(parents.map((l) => l.name).filter(Boolean).join(' · '))}${parents.length ? ' · ' : ''}${n} secret${n === 1 ? '' : 's'}</span>`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function chooseHtml (view, ui) {
|
|
68
|
+
const sel = ui.chooseSel
|
|
69
|
+
const all = sel === 'all'
|
|
70
|
+
return `<div class="sx-choose" data-choose-panel>
|
|
71
|
+
<label class="sx-check"><input type="radio" name="sx-choose" value="all" ${all ? 'checked' : ''} data-choose-all> All agents here, and ones added later</label>
|
|
72
|
+
<label class="sx-check"><input type="radio" name="sx-choose" value="some" ${all ? '' : 'checked'} data-choose-some> Only these:</label>
|
|
73
|
+
<div class="sx-choose-list">${view.agentChoices.length ? view.agentChoices.map((a) => `<label class="sx-check"><input type="checkbox" data-choose-agent="${esc(a.id)}" ${!all && sel.includes(a.id) ? 'checked' : ''} ${all ? 'disabled' : ''}> ${esc(a.name)}</label>`).join('') : '<span class="sx-faint">No agents here yet.</span>'}</div>
|
|
74
|
+
<span class="sx-choose-acts"><button type="button" class="btn sm ghost" data-choose-cancel>Cancel</button><button type="button" class="btn sm primary" data-choose-save>Save</button></span>
|
|
75
|
+
</div>`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function importHtml (ui) {
|
|
79
|
+
const im = ui.import
|
|
80
|
+
if (im.loading) return '<div class="sx-import"><span class="sx-faint">Looking for .env files…</span></div>'
|
|
81
|
+
if (im.done) {
|
|
82
|
+
return `<div class="sx-import"><div class="sx-import-done">
|
|
83
|
+
<span>Saved ${im.done.saved.length} secret${im.done.saved.length === 1 ? '' : 's'} from <code>${esc(im.done.rel)}</code>. Your project reaches them through <code>quilt run</code> now${im.done.deleted ? ', and the file is deleted.' : ': the file is no longer needed.'}</span>
|
|
84
|
+
<span class="sx-grow"></span>${im.done.deleted ? '' : `<button type="button" class="btn sm danger" data-import-delete>Delete ${esc(im.done.rel)}</button><button type="button" class="btn sm ghost" data-import-close>Keep it</button>`}${im.done.deleted ? '<button type="button" class="btn sm ghost" data-import-close>Done</button>' : ''}
|
|
85
|
+
</div></div>`
|
|
86
|
+
}
|
|
87
|
+
if (!im.files.length) return `<div class="sx-import"><div class="sx-import-done"><span>${esc(im.empty || 'No .env files in this session\'s folder.')}</span><span class="sx-grow"></span><button type="button" class="btn sm ghost" data-import-close>Close</button></div></div>`
|
|
88
|
+
const f = im.files[im.fileIdx]
|
|
89
|
+
const picked = im.rows.filter((r) => r.pick && !r.skip).length
|
|
90
|
+
return `<div class="sx-import">
|
|
91
|
+
<div class="sx-import-head"><b>Import</b>${im.files.length > 1 ? `<select class="input sx-in" data-import-file aria-label=".env file">${im.files.map((x, i) => `<option value="${i}"${i === im.fileIdx ? ' selected' : ''}>${esc(x.rel)}</option>`).join('')}</select>` : `<code>${esc(f.rel)}</code>`}<span class="sx-faint">Names and where each goes. The values go from this computer straight to Quilt's vault.</span></div>
|
|
92
|
+
<div class="sx-import-rows">${im.rows.map((r, i) => r.skip
|
|
93
|
+
? `<div class="sx-import-row skip"><span class="sx-name">${esc(r.name)}</span><span class="sx-faint">${esc(r.skip)}</span></div>`
|
|
94
|
+
: `<div class="sx-import-row"><label class="sx-check"><input type="checkbox" data-import-pick="${i}" ${r.pick ? 'checked' : ''}><span class="sx-name">${esc(r.name)}</span></label><input class="input sx-in mono" data-import-host="${i}" value="${esc(r.upstream)}" placeholder="sends to: api.example.com" aria-label="Where ${esc(r.name)} is sent"><select class="input sx-in" data-import-format="${i}" aria-label="How ${esc(r.name)} is sent">${formatOptions(r.format)}</select>${sw(r.who !== 'people', `data-import-who="${i}"`, r.who === 'people' ? 'People only' : 'Agents', { small: true })}</div>`).join('')}</div>
|
|
95
|
+
<div class="sx-import-foot"><span class="sx-grow"></span><button type="button" class="btn sm ghost" data-import-close>Cancel</button><button type="button" class="btn sm primary" data-import-save ${picked ? '' : 'disabled'}>Save ${picked} secret${picked === 1 ? '' : 's'}</button></div>
|
|
96
|
+
</div>`
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function rowHtml (r, view, ui, inherited) {
|
|
100
|
+
const manage = view.canManage
|
|
101
|
+
const usedNow = inherited && r.off ? '—' : usedText(r.usedAt, ui.now)
|
|
102
|
+
// In a narrow card the digest and when it was used go under the name (their columns hide).
|
|
103
|
+
const name = `<td class="sx-name">${esc(r.name)}${inherited ? ` <span class="sx-lvl ${r.level}${r.overridden ? ' over' : ''}">${LEVEL_WORDS[r.level]}</span>` : ''}<span class="sx-sub">${esc(r.digest)} · ${inherited && r.off ? 'off here' : usedNow === 'never' ? 'never used' : `used ${esc(usedNow)}`}</span></td>`
|
|
104
|
+
const who = `<td class="sx-who-td">${sw(r.who !== 'people', inherited ? 'data-who-view' : `data-who="${esc(r.name)}"`, r.who === 'people' ? 'People only' : 'Agents', { small: true, disabled: inherited || !manage })}</td>`
|
|
105
|
+
const used = `<td class="sx-hide-m"><span class="sx-status${r.usedAt && !(inherited && r.off) ? '' : ' unused'}">${esc(usedNow)}</span></td>`
|
|
106
|
+
let act = ''
|
|
107
|
+
if (!inherited && manage) {
|
|
108
|
+
act = ui.confirmDelete === r.name
|
|
109
|
+
? ''
|
|
110
|
+
: `<button type="button" class="btn sm" data-replace="${esc(r.name)}">Replace</button><button type="button" class="btn sm danger" data-delete="${esc(r.name)}">Delete</button>`
|
|
111
|
+
} else if (inherited && r.off) act = '<span class="sx-faint">off here</span>'
|
|
112
|
+
else if (inherited && manage) act = `<button type="button" class="btn sm" data-override="${esc(r.name)}">Override</button>`
|
|
113
|
+
// Delete asks in place, on a line of its own under the secret.
|
|
114
|
+
const confirm = !inherited && manage && ui.confirmDelete === r.name
|
|
115
|
+
? `<tr class="sx-confirm-row"><td colspan="6"><div class="sx-confirm-in"><span class="sx-confirm">Delete ${esc(r.name)}? Nothing can use it after, and its value is gone for good.</span><span class="sx-confirm-acts"><button type="button" class="btn sm ghost" data-delete-no>Keep</button><button type="button" class="btn sm danger" data-delete-yes="${esc(r.name)}">Delete</button></span></div></td></tr>`
|
|
116
|
+
: ''
|
|
117
|
+
return `<tr class="${inherited && r.off ? 'sx-off' : ''}${ui.flash === r.name ? ' sx-flash' : ''}">${name}<td class="sx-hide-m mono sx-faint">${esc(r.digest)}</td><td class="sx-host-td"><span class="sx-host" title="${esc(r.formatLabel || formatLabel(r.format))}">${esc(r.upstream)}</span></td>${who}${used}<td class="sx-act"><span class="sx-acts">${act}</span></td></tr>${confirm}`
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The whole card for `view` (GET /v1/secrets/:scope/:id) and the card's state `ui`. */
|
|
121
|
+
export function secretsCardHtml (view, ui = {}) {
|
|
122
|
+
ui = { now: Date.now(), tabs: [], form: {}, ...ui }
|
|
123
|
+
const tabs = ui.tabs.length ? ui.tabs : levelTabs(view)
|
|
124
|
+
const cur = view ? view.scope.type : ui.cur?.type
|
|
125
|
+
const seg = tabs.length > 1 ? `<span class="sx-seg" role="tablist" aria-label="Secrets for">${tabs.map((t) => `<button type="button" role="tab" aria-selected="${t.type === cur}" class="${t.type === cur ? 'on' : ''}" data-level="${t.type}" data-level-id="${esc(t.id)}">${t.label}</button>`).join('')}</span>` : `<span class="sx-lvl ${cur}">${LEVEL_WORDS[cur] || ''}</span>`
|
|
126
|
+
const head = `<div class="sx-head">${LOCK}<span class="sx-title">Secrets</span>${seg}${view && view.scope.name ? `<span class="sx-faint sx-scope">${esc(view.scope.name)}</span>` : ''}<span class="sx-grow"></span>${view && view.canManage && ui.canImport ? `<button type="button" class="btn sm" data-import ${ui.import ? 'disabled' : ''}>Import .env</button>` : ''}</div>`
|
|
127
|
+
if (!view) return `<div class="sx">${head}<div class="sx-empty">${ui.error ? `<span class="sx-err">${esc(ui.error)}</span>` : '<span class="sx-faint">Loading…</span>'}</div></div>`
|
|
128
|
+
|
|
129
|
+
const manage = view.canManage
|
|
130
|
+
const inheritable = (view.levels || []).length > 1
|
|
131
|
+
const s = view.settings
|
|
132
|
+
const controls = `<div class="sx-controls">
|
|
133
|
+
${inheritable ? `<div>${sw(!s.inheritOff, 'data-inherit', `<b>${esc(inheritLabel(view))}</b>`, { disabled: !manage })}<span class="sx-grow"></span>${inheritRight(view)}</div>` : ''}
|
|
134
|
+
<div>${sw(s.agentsOn, 'data-agents-on', '<b>Agents can use secrets</b>', { disabled: !manage })}<span class="sx-grow"></span>${avatars(view)}${manage && s.agentsOn ? `<button type="button" class="btn sm ghost" data-choose ${ui.choose ? 'aria-expanded="true"' : ''}>Choose</button>` : ''}</div>
|
|
135
|
+
</div>`
|
|
136
|
+
const off = s.inheritOff && view.offCount ? `<div class="sx-off-note"><span>${view.offCount} ${esc(inheritLabel(view).replace(/ here$/, '').replace(/^./, (c) => c.toLowerCase()))} ${view.offCount === 1 ? 'is' : 'are'} off ${view.scope.type === 'session' ? 'in this session' : 'in this workspace'}. Nothing here can use them.</span><span class="sx-grow"></span>${manage ? '<button type="button" class="btn sm" data-inherit-on>Turn back on</button>' : ''}</div>` : ''
|
|
137
|
+
const f = ui.form
|
|
138
|
+
const mode = ui.mode && ui.mode.name ? ui.mode : null
|
|
139
|
+
const add = manage ? `<form class="sx-new" data-add autocomplete="off">
|
|
140
|
+
<input class="input sx-in mono" name="name" value="${esc(f.name || '')}" placeholder="NAME" aria-label="Name" spellcheck="false" autocapitalize="characters" ${mode && mode.kind === 'replace' ? 'readonly' : ''}>
|
|
141
|
+
<input class="input sx-in mono" name="value" type="password" placeholder="${mode && mode.kind === 'replace' ? `new value for ${esc(mode.name)}` : 'value'}" aria-label="Value" autocomplete="new-password" spellcheck="false">
|
|
142
|
+
<input class="input sx-in mono" name="upstream" value="${esc(f.upstream || '')}" placeholder="sends to: api.stripe.com" aria-label="Where it is sent (its one host)" spellcheck="false">
|
|
143
|
+
<select class="input sx-in" name="format" aria-label="How it is sent">${formatOptions(f.format || 'bearer')}</select>
|
|
144
|
+
${f.format === 'custom' ? `<input class="input sx-in mono sx-custom" name="custom" value="${esc(f.custom || '')}" placeholder="X-Token: Token {value}" aria-label="Your header, with {value}">` : ''}
|
|
145
|
+
<span class="sx-new-acts">${mode ? '<button type="button" class="btn sm ghost" data-add-cancel>Cancel</button>' : ''}<button type="submit" class="btn sm primary" ${ui.busy ? 'disabled' : ''}>${mode && mode.kind === 'replace' ? 'Replace' : mode && mode.kind === 'override' ? 'Override' : 'Add'}</button></span>
|
|
146
|
+
</form>` : ''
|
|
147
|
+
const msg = ui.msg ? `<div class="sx-msg ${ui.msg.kind || ''}" role="status">${esc(ui.msg.text)}</div>` : ''
|
|
148
|
+
const rows = [...view.secrets.map((r) => rowHtml(r, view, ui, false)), ...(view.inherited || []).map((r) => rowHtml(r, view, ui, true))]
|
|
149
|
+
const table = rows.length
|
|
150
|
+
? `<div class="sx-scroll"><table class="sx-tbl"><thead><tr><th>Name</th><th class="sx-hide-m">Digest</th><th>Sent to</th><th>Who can use</th><th class="sx-hide-m">Used</th><th><span class="sx-sr">Actions</span></th></tr></thead><tbody>${rows.join('')}</tbody></table></div>`
|
|
151
|
+
: `<div class="sx-empty"><span class="sx-faint">No secrets ${view.scope.type === 'org' ? 'for the team' : `in this ${view.scope.type}`} yet.${manage ? ' Add one above: its value goes straight to Quilt\'s vault and is never shown again, to anyone.' : ''}</span></div>`
|
|
152
|
+
const foot = `<div class="sx-foot sx-faint">Commands use them through <code>quilt run -- <command></code>; nobody can read a value back. ${manage ? 'Replace or delete only.' : ''}</div>`
|
|
153
|
+
return `<div class="sx">${head}${controls}${ui.choose ? chooseHtml(view, ui) : ''}${off}${ui.import ? importHtml(ui) : ''}${add}${msg}${table}${foot}</div>`
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The inline bar asking a person to allow a people-only secret for one run of a command. */
|
|
157
|
+
export function asksBarHtml (asks) {
|
|
158
|
+
if (!asks || !asks.length) return ''
|
|
159
|
+
const a = asks[0]
|
|
160
|
+
const names = a.names.join(', ')
|
|
161
|
+
return `<div class="ask-bar-in" role="alert">${LOCK}<span class="ask-text"><b>Allow ${esc(names)}</b> (people only) for one run of <code>${esc(a.label || 'a command')}</code>${a.sessionName ? ` in ${esc(a.sessionName)}` : ''}?</span>${asks.length > 1 ? `<span class="ask-more">+${asks.length - 1} more</span>` : ''}<span class="sx-grow"></span><button type="button" class="btn sm ghost ask-deny" data-ask-deny="${esc(a.runId)}">Deny</button><button type="button" class="btn sm ask-allow" data-ask-allow="${esc(a.runId)}">Allow</button></div>`
|
|
162
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// The Secrets card in the app (Session settings → Secrets, and a workspace's page), and the bar
|
|
2
|
+
// that asks a person to allow a people-only secret for one run. The HTML is secrets-view.js's;
|
|
3
|
+
// this file loads, binds and saves. A typed value lives in its input and in `typed` here until it
|
|
4
|
+
// goes to the app's server, which sends it straight to the vault; it is never put in markup.
|
|
5
|
+
import { api, toast } from './common.js'
|
|
6
|
+
import { secretsCardHtml, levelTabs, parseCustomFormat, asksBarHtml } from './secrets-view.js'
|
|
7
|
+
import { cleanSecretName, FORMAT_IDS, formatId } from './secret-rules.js'
|
|
8
|
+
|
|
9
|
+
const views = new Map() // `${type}:${id}` -> the last view, so a redraw shows it at once
|
|
10
|
+
const urlOf = (type, id, rest = '') => `/api/secrets/${type}/${encodeURIComponent(id)}${rest}`
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Draws the card into `root` for one scope ({ type, id }: a session's room, a workspace's id, a
|
|
14
|
+
* team's slug). `importFrom` says where .env files may be read from ({ sessionId } or
|
|
15
|
+
* { workspaceId }); without it there is no Import .env. Answers { refresh, destroy }.
|
|
16
|
+
*/
|
|
17
|
+
export function mountSecrets (root, { type, id, importFrom = null }) {
|
|
18
|
+
const ui = { tabs: [], cur: { type, id }, form: { format: 'bearer' }, mode: null, choose: false, chooseSel: 'all', import: null, msg: null, busy: false, confirmDelete: null, canImport: !!importFrom, error: '', flash: '' }
|
|
19
|
+
let view = views.get(`${type}:${id}`) || null
|
|
20
|
+
if (view) ui.tabs = levelTabs(view)
|
|
21
|
+
let typed = ''
|
|
22
|
+
let alive = true
|
|
23
|
+
let fresh = false
|
|
24
|
+
|
|
25
|
+
/** What the add row holds now (the value only in memory). */
|
|
26
|
+
function capture () {
|
|
27
|
+
const f = root.querySelector('[data-add]')
|
|
28
|
+
if (!f) return
|
|
29
|
+
ui.form = { ...ui.form, name: f.elements.name.value, upstream: f.elements.upstream.value, format: f.elements.format.value, custom: f.elements.custom ? f.elements.custom.value : ui.form.custom }
|
|
30
|
+
typed = f.elements.value.value
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function draw ({ focus } = {}) {
|
|
34
|
+
if (!alive) return
|
|
35
|
+
const had = root.contains(document.activeElement) ? document.activeElement.getAttribute('name') : null
|
|
36
|
+
// Keep what was typed, unless the form was just cleared on purpose.
|
|
37
|
+
if (fresh) fresh = false
|
|
38
|
+
else capture()
|
|
39
|
+
root.innerHTML = secretsCardHtml(view, { ...ui, now: Date.now() })
|
|
40
|
+
const f = root.querySelector('[data-add]')
|
|
41
|
+
if (f) f.elements.value.value = typed
|
|
42
|
+
const want = focus || had
|
|
43
|
+
if (want && f && f.elements[want]) f.elements[want].focus()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function load () {
|
|
47
|
+
try {
|
|
48
|
+
view = await api('GET', urlOf(ui.cur.type, ui.cur.id))
|
|
49
|
+
views.set(`${ui.cur.type}:${ui.cur.id}`, view)
|
|
50
|
+
if (!ui.tabs.length) ui.tabs = levelTabs(view)
|
|
51
|
+
ui.error = ''
|
|
52
|
+
} catch (err) {
|
|
53
|
+
ui.error = err.status === 503 ? 'Secrets are not switched on for your account\'s server yet.' : err.message
|
|
54
|
+
view = null
|
|
55
|
+
}
|
|
56
|
+
draw()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const say = (text, kind = '') => { ui.msg = text ? { text, kind } : null }
|
|
60
|
+
const resetForm = () => { ui.form = { format: 'bearer' }; ui.mode = null; typed = ''; fresh = true }
|
|
61
|
+
const scopeUrl = (rest) => urlOf(ui.cur.type, ui.cur.id, rest)
|
|
62
|
+
|
|
63
|
+
async function act (fn) {
|
|
64
|
+
if (ui.busy) return
|
|
65
|
+
ui.busy = true
|
|
66
|
+
try { await fn() } catch (err) { say(err.message, 'bad') } finally { ui.busy = false; draw() }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const setSettings = (patch) => act(async () => { view = await api('POST', scopeUrl('/settings'), patch); views.set(`${ui.cur.type}:${ui.cur.id}`, view); say('') })
|
|
70
|
+
|
|
71
|
+
function formatOf (f) {
|
|
72
|
+
if (f.format === 'custom') {
|
|
73
|
+
const c = parseCustomFormat(f.custom)
|
|
74
|
+
if (!c) throw new Error('Write your header like X-Token: Token {value}')
|
|
75
|
+
return c
|
|
76
|
+
}
|
|
77
|
+
return FORMAT_IDS.includes(f.format) ? f.format : 'bearer'
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function save () {
|
|
81
|
+
capture()
|
|
82
|
+
const f = ui.form
|
|
83
|
+
let name
|
|
84
|
+
try { name = cleanSecretName(f.name) } catch (err) { say(err.message, 'bad'); return draw({ focus: 'name' }) }
|
|
85
|
+
if (!typed) { say(`Type ${name}'s value.`, 'bad'); return draw({ focus: 'value' }) }
|
|
86
|
+
let format
|
|
87
|
+
try { format = formatOf(f) } catch (err) { say(err.message, 'bad'); return draw({ focus: 'custom' }) }
|
|
88
|
+
const had = view.secrets.find((s) => s.name === name)
|
|
89
|
+
await act(async () => {
|
|
90
|
+
const r = await api('POST', scopeUrl('/save'), { entries: [{ name, upstream: f.upstream, format, who: had ? had.who : 'agents' }], values: { [name]: typed } })
|
|
91
|
+
typed = ''
|
|
92
|
+
resetForm()
|
|
93
|
+
const d = r.saved && r.saved[0]
|
|
94
|
+
say(`${had ? 'Replaced' : 'Saved'} ${name}${d ? ` (${d.digest})` : ''}. Nobody can see its value again, you included.`, 'ok')
|
|
95
|
+
ui.flash = name
|
|
96
|
+
view = await api('GET', urlOf(ui.cur.type, ui.cur.id))
|
|
97
|
+
views.set(`${ui.cur.type}:${ui.cur.id}`, view)
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --------------------------------------------------------------- Import .env --
|
|
102
|
+
async function openImport () {
|
|
103
|
+
ui.import = { loading: true, files: [], rows: [], fileIdx: 0 }
|
|
104
|
+
draw()
|
|
105
|
+
try {
|
|
106
|
+
const r = await api('POST', '/api/secrets/env-scan', importFrom)
|
|
107
|
+
ui.import = { files: r.files, fileIdx: 0, rows: [], empty: r.empty || '' }
|
|
108
|
+
pickFile(0)
|
|
109
|
+
} catch (err) { ui.import = null; say(err.message, 'bad') }
|
|
110
|
+
draw()
|
|
111
|
+
}
|
|
112
|
+
function pickFile (i) {
|
|
113
|
+
const f = ui.import.files[i]
|
|
114
|
+
ui.import.fileIdx = i
|
|
115
|
+
ui.import.rows = f ? f.entries.map((e) => ({ name: e.name, upstream: e.upstream || '', format: e.format || 'bearer', who: 'agents', skip: e.skip || '', pick: !e.skip && !!e.upstream })) : []
|
|
116
|
+
}
|
|
117
|
+
function captureImport () {
|
|
118
|
+
if (!ui.import) return
|
|
119
|
+
root.querySelectorAll('[data-import-host]').forEach((el) => { ui.import.rows[+el.dataset.importHost].upstream = el.value })
|
|
120
|
+
root.querySelectorAll('[data-import-format]').forEach((el) => { ui.import.rows[+el.dataset.importFormat].format = el.value })
|
|
121
|
+
}
|
|
122
|
+
async function importSave () {
|
|
123
|
+
captureImport()
|
|
124
|
+
const im = ui.import
|
|
125
|
+
const rows = im.rows.filter((r) => r.pick && !r.skip)
|
|
126
|
+
if (rows.some((r) => r.format === 'custom')) { say('An imported secret uses one of the listed formats; add a header of your own one at a time.', 'bad'); return draw() }
|
|
127
|
+
const file = im.files[im.fileIdx]
|
|
128
|
+
await act(async () => {
|
|
129
|
+
const r = await api('POST', '/api/secrets/env-import', { ...importFrom, scope: ui.cur.type, id: ui.cur.id, file: file.path, entries: rows.map((x) => ({ name: x.name, upstream: x.upstream, format: x.format, who: x.who })) })
|
|
130
|
+
ui.import = { ...im, done: { rel: file.rel, path: file.path, saved: r.saved, deleted: false } }
|
|
131
|
+
say('')
|
|
132
|
+
view = await api('GET', urlOf(ui.cur.type, ui.cur.id))
|
|
133
|
+
views.set(`${ui.cur.type}:${ui.cur.id}`, view)
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// --------------------------------------------------------------- events --
|
|
138
|
+
async function onClick (e) {
|
|
139
|
+
const t = (sel) => e.target.closest(sel)
|
|
140
|
+
let b
|
|
141
|
+
if ((b = t('[data-level]'))) {
|
|
142
|
+
if (b.classList.contains('on')) return
|
|
143
|
+
capture(); resetForm(); ui.choose = false; ui.import = null; ui.confirmDelete = null; say('')
|
|
144
|
+
ui.cur = { type: b.dataset.level, id: b.dataset.levelId }
|
|
145
|
+
view = views.get(`${ui.cur.type}:${ui.cur.id}`) || null
|
|
146
|
+
draw()
|
|
147
|
+
return load()
|
|
148
|
+
}
|
|
149
|
+
if (!view) return
|
|
150
|
+
if ((b = t('[data-inherit]')) && !b.disabled) return setSettings({ inheritOff: !view.settings.inheritOff })
|
|
151
|
+
if (t('[data-inherit-on]')) return setSettings({ inheritOff: false })
|
|
152
|
+
if ((b = t('[data-agents-on]')) && !b.disabled) { ui.choose = false; return setSettings({ agentsOn: !view.settings.agentsOn }) }
|
|
153
|
+
if (t('[data-choose]')) { ui.choose = !ui.choose; ui.chooseSel = view.settings.agents === 'all' ? 'all' : [...view.settings.agents]; return draw() }
|
|
154
|
+
if (t('[data-choose-cancel]')) { ui.choose = false; return draw() }
|
|
155
|
+
if (t('[data-choose-save]')) { ui.choose = false; return setSettings({ agents: ui.chooseSel }) }
|
|
156
|
+
if ((b = t('[data-who]')) && !b.disabled) {
|
|
157
|
+
const r = view.secrets.find((s) => s.name === b.dataset.who)
|
|
158
|
+
return act(async () => { view = await api('POST', scopeUrl('/who'), { name: r.name, who: r.who === 'people' ? 'agents' : 'people' }); say('') })
|
|
159
|
+
}
|
|
160
|
+
if ((b = t('[data-replace]')) || (b = t('[data-override]'))) {
|
|
161
|
+
const replace = !!b.dataset.replace
|
|
162
|
+
const name = replace ? b.dataset.replace : b.dataset.override
|
|
163
|
+
const r = (replace ? view.secrets : view.inherited).find((s) => s.name === name)
|
|
164
|
+
ui.mode = { kind: replace ? 'replace' : 'override', name }
|
|
165
|
+
const fid = formatId(r.format)
|
|
166
|
+
ui.form = { name, upstream: r.upstream, format: fid || 'custom', custom: fid ? '' : `${r.format.name}: ${r.format.template}` }
|
|
167
|
+
typed = ''
|
|
168
|
+
fresh = true
|
|
169
|
+
say(replace ? `A new value for ${name}. Where it goes and how can change with it.` : `${name} for this ${ui.cur.type === 'org' ? 'team' : ui.cur.type} only: it overrides the ${r.level === 'org' ? 'team' : r.level}'s.`)
|
|
170
|
+
return draw({ focus: 'value' })
|
|
171
|
+
}
|
|
172
|
+
if (t('[data-add-cancel]')) { resetForm(); say(''); return draw() }
|
|
173
|
+
if ((b = t('[data-delete]'))) { ui.confirmDelete = b.dataset.delete; return draw() }
|
|
174
|
+
if (t('[data-delete-no]')) { ui.confirmDelete = null; return draw() }
|
|
175
|
+
if ((b = t('[data-delete-yes]'))) {
|
|
176
|
+
const name = b.dataset.deleteYes
|
|
177
|
+
ui.confirmDelete = null
|
|
178
|
+
return act(async () => { view = await api('POST', scopeUrl('/delete'), { name }); say(`Deleted ${name}.`, 'ok') })
|
|
179
|
+
}
|
|
180
|
+
if (t('[data-import]')) return openImport()
|
|
181
|
+
if (t('[data-import-close]')) { ui.import = null; return draw() }
|
|
182
|
+
if ((b = t('[data-import-who]'))) { captureImport(); const r = ui.import.rows[+b.dataset.importWho]; r.who = r.who === 'people' ? 'agents' : 'people'; return draw() }
|
|
183
|
+
if (t('[data-import-save]')) return importSave()
|
|
184
|
+
if (t('[data-import-delete]')) {
|
|
185
|
+
const d = ui.import.done
|
|
186
|
+
return act(async () => { await api('POST', '/api/secrets/env-delete', { ...importFrom, file: d.path }); d.deleted = true; toast(`Deleted ${d.rel}`) })
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function onChange (e) {
|
|
191
|
+
const el = e.target
|
|
192
|
+
if (el.matches('[data-choose-all]')) { ui.chooseSel = 'all'; return draw() }
|
|
193
|
+
if (el.matches('[data-choose-some]')) { ui.chooseSel = view.agentChoices.map((a) => a.id); return draw() }
|
|
194
|
+
if (el.matches('[data-choose-agent]')) {
|
|
195
|
+
const set = new Set(ui.chooseSel === 'all' ? [] : ui.chooseSel)
|
|
196
|
+
if (el.checked) set.add(el.dataset.chooseAgent); else set.delete(el.dataset.chooseAgent)
|
|
197
|
+
ui.chooseSel = [...set]
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
if (el.matches('[data-add] [name=format]')) { capture(); return draw({ focus: el.value === 'custom' ? 'custom' : 'format' }) }
|
|
201
|
+
if (el.matches('[data-import-file]')) { pickFile(+el.value); return draw() }
|
|
202
|
+
if (el.matches('[data-import-pick]')) { captureImport(); ui.import.rows[+el.dataset.importPick].pick = el.checked; return draw() }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function onInput (e) {
|
|
206
|
+
// Names are capitals: typed as such.
|
|
207
|
+
if (e.target.matches('[data-add] [name=name]')) {
|
|
208
|
+
const s = e.target.selectionStart
|
|
209
|
+
e.target.value = e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, '_')
|
|
210
|
+
e.target.setSelectionRange(s, s)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function onSubmit (e) {
|
|
215
|
+
if (!e.target.matches('[data-add]')) return
|
|
216
|
+
e.preventDefault()
|
|
217
|
+
save()
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
root.addEventListener('click', onClick)
|
|
221
|
+
root.addEventListener('change', onChange)
|
|
222
|
+
root.addEventListener('input', onInput)
|
|
223
|
+
root.addEventListener('submit', onSubmit)
|
|
224
|
+
draw()
|
|
225
|
+
load()
|
|
226
|
+
return {
|
|
227
|
+
refresh: load,
|
|
228
|
+
destroy () { alive = false; typed = ''; root.removeEventListener('click', onClick); root.removeEventListener('change', onChange); root.removeEventListener('input', onInput); root.removeEventListener('submit', onSubmit) }
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ------------------------------------------------- people-only asks, app-wide --
|
|
233
|
+
let asking = false
|
|
234
|
+
let asks = []
|
|
235
|
+
|
|
236
|
+
function drawAsks () {
|
|
237
|
+
let bar = document.getElementById('ask-bar')
|
|
238
|
+
const show = asks.length > 0
|
|
239
|
+
document.body.classList.toggle('has-asks', show)
|
|
240
|
+
if (!show) { bar?.remove(); return }
|
|
241
|
+
if (!bar) {
|
|
242
|
+
bar = document.createElement('div')
|
|
243
|
+
bar.id = 'ask-bar'
|
|
244
|
+
bar.addEventListener('click', async (e) => {
|
|
245
|
+
const b = e.target.closest('[data-ask-allow],[data-ask-deny]')
|
|
246
|
+
if (!b) return
|
|
247
|
+
const allow = !!b.dataset.askAllow
|
|
248
|
+
const runId = allow ? b.dataset.askAllow : b.dataset.askDeny
|
|
249
|
+
b.disabled = true
|
|
250
|
+
try {
|
|
251
|
+
await api('POST', `/api/secret-asks/${encodeURIComponent(runId)}`, { allow })
|
|
252
|
+
asks = asks.filter((a) => a.runId !== runId)
|
|
253
|
+
toast(allow ? 'Allowed for that run' : 'Denied')
|
|
254
|
+
} catch (err) { toast(err.message) }
|
|
255
|
+
drawAsks()
|
|
256
|
+
})
|
|
257
|
+
document.body.prepend(bar)
|
|
258
|
+
}
|
|
259
|
+
bar.innerHTML = asksBarHtml(asks)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Watches for runs of yours waiting for you to allow a people-only secret (a held request that
|
|
264
|
+
* the API answers when one comes, or after 25 seconds), and shows them in the bar. Once per page.
|
|
265
|
+
*/
|
|
266
|
+
export function watchSecretAsks () {
|
|
267
|
+
if (asking) return
|
|
268
|
+
asking = true
|
|
269
|
+
const loop = async () => {
|
|
270
|
+
let wait = 2000
|
|
271
|
+
try {
|
|
272
|
+
const r = await api('GET', '/api/secret-asks?wait=1')
|
|
273
|
+
asks = r.asks || []
|
|
274
|
+
drawAsks()
|
|
275
|
+
wait = asks.length ? 3000 : 250
|
|
276
|
+
} catch (err) {
|
|
277
|
+
// Secrets off on the server, or signed out: look again much later.
|
|
278
|
+
wait = err.status === 503 || err.status === 404 ? 10 * 60 * 1000 : 15000
|
|
279
|
+
}
|
|
280
|
+
setTimeout(loop, wait)
|
|
281
|
+
}
|
|
282
|
+
loop()
|
|
283
|
+
}
|