pgkiln 0.84.0 → 0.85.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/apex-feature-parity.md +2 -2
- package/docs/guide/03-builder.md +14 -5
- package/docs/guide/12-development.md +6 -1
- package/package.json +1 -1
- package/src/app.ts +9 -0
- package/src/builder/ai-agents.ts +86 -85
- package/src/builder/ai-builder.ts +30 -30
- package/src/builder/ai.ts +93 -92
- package/src/builder/api.ts +47 -47
- package/src/builder/app-groups.ts +33 -32
- package/src/builder/appfromfile.ts +57 -58
- package/src/builder/appsheets.ts +33 -32
- package/src/builder/appwizard.ts +65 -65
- package/src/builder/arrange.ts +23 -22
- package/src/builder/assistant.ts +39 -38
- package/src/builder/automations.ts +32 -31
- package/src/builder/availability.ts +24 -24
- package/src/builder/backups.ts +30 -31
- package/src/builder/blueprints.ts +62 -61
- package/src/builder/create-page.ts +52 -50
- package/src/builder/cross-app.ts +37 -35
- package/src/builder/dataload.ts +2 -2
- package/src/builder/datasets.ts +107 -106
- package/src/builder/dependencies.ts +20 -19
- package/src/builder/designer.ts +36 -36
- package/src/builder/diagnostics.ts +58 -57
- package/src/builder/features.ts +39 -35
- package/src/builder/feedback.ts +23 -20
- package/src/builder/find-workspace.ts +28 -24
- package/src/builder/form-designer.ts +36 -35
- package/src/builder/gallery.ts +25 -29
- package/src/builder/globalization.ts +50 -49
- package/src/builder/i18n/de.ts +3453 -0
- package/src/builder/i18n/en.ts +3453 -0
- package/src/builder/i18n/es.ts +3453 -0
- package/src/builder/i18n/fr.ts +3453 -0
- package/src/builder/i18n/it.ts +3453 -0
- package/src/builder/i18n/ja.ts +3453 -0
- package/src/builder/i18n/ko.ts +3453 -0
- package/src/builder/i18n/nl.ts +3453 -0
- package/src/builder/i18n/pt.ts +3453 -0
- package/src/builder/i18n/zh-cn.ts +3453 -0
- package/src/builder/i18n/zh-tw.ts +3453 -0
- package/src/builder/i18n.ts +17 -3
- package/src/builder/json-sources.ts +63 -62
- package/src/builder/layouts.ts +12 -11
- package/src/builder/locks.ts +29 -28
- package/src/builder/login-page.ts +26 -25
- package/src/builder/mail.ts +148 -147
- package/src/builder/newapp.ts +15 -12
- package/src/builder/plugins.ts +47 -44
- package/src/builder/process-jobs.ts +11 -10
- package/src/builder/pwa.ts +22 -21
- package/src/builder/remote-databases.ts +55 -56
- package/src/builder/report-settings.ts +1 -1
- package/src/builder/reporter.ts +43 -42
- package/src/builder/rest-catalogs.ts +87 -86
- package/src/builder/rest.ts +9 -8
- package/src/builder/routes.ts +27 -27
- package/src/builder/search.ts +14 -13
- package/src/builder/static-files.ts +31 -32
- package/src/builder/subscriptions.ts +47 -45
- package/src/builder/supporting.ts +23 -18
- package/src/builder/team.ts +179 -179
- package/src/builder/telemetry.ts +59 -62
- package/src/builder/templates.ts +66 -65
- package/src/builder/themeroller.ts +60 -63
- package/src/builder/ui-defaults.ts +21 -20
- package/src/builder/ui.ts +2 -1
- package/src/builder/upgrade.ts +25 -24
- package/src/builder/utilities.ts +48 -47
- package/src/builder/vector-providers.ts +67 -66
- package/src/builder/websources.ts +43 -42
- package/src/builder/wizards.ts +95 -89
- package/src/builder/workflow-migrate.ts +30 -31
- package/src/builder/workflows.ts +40 -39
- package/src/builder/workingcopies.ts +41 -49
- package/src/builder/workspace-backups.ts +21 -20
- package/src/builder/workspace-mail.ts +17 -16
- package/src/builder/workspace-resources.ts +104 -112
- package/src/builder/workspace-transfer.ts +34 -26
|
@@ -12,6 +12,7 @@ import { scopeProblem, urlProblem } from '../webclient.ts';
|
|
|
12
12
|
import { clearResponseCache, clearTokens, credentialProblems, GRANT_TYPES } from '../websources.ts';
|
|
13
13
|
import { back, BASE, csrf, developer, flash, input, region, select, send, shell, type Body, type Req } from './ui.ts';
|
|
14
14
|
import { workspaceAdmin } from './workspaces.ts';
|
|
15
|
+
import { scopedTx as tx } from './i18n.ts';
|
|
15
16
|
|
|
16
17
|
// (sprint 141, AP-7) Workspace utilities → Static workspace files, Remote
|
|
17
18
|
// servers and Web credentials (APEX: Static Workspace Files, Remote Servers,
|
|
@@ -28,7 +29,7 @@ import { workspaceAdmin } from './workspaces.ts';
|
|
|
28
29
|
const FILES = `${BASE}/workspace-files`;
|
|
29
30
|
const SERVERS = `${BASE}/remote-servers`;
|
|
30
31
|
const CREDS = `${BASE}/workspace-credentials`;
|
|
31
|
-
const crumbs = (title: string): [string, string?][] => [['
|
|
32
|
+
const crumbs = (title: string): [string, string?][] => [[tx('wres.app_builder'), BASE], [tx('wres.workspace_utilities'), `${BASE}/utilities`], [title]];
|
|
32
33
|
const id = (v: unknown) => (/^\d{1,9}$/.test(String(v ?? '')) ? Number(v) : null);
|
|
33
34
|
const when = (d: Date | string | null) => (d ? new Date(d).toISOString().slice(0, 16).replace('T', ' ') : '');
|
|
34
35
|
const size = (n: number) => (n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`);
|
|
@@ -45,7 +46,7 @@ async function context(req: Req, reply: FastifyReply, title: string): Promise<Ct
|
|
|
45
46
|
const s = await developer(req, reply);
|
|
46
47
|
if (!s) return null;
|
|
47
48
|
if (!s.workspace) {
|
|
48
|
-
await send(reply.code(403), s, shell(s, title, crumbs(title), html`<h1 class="u-mb1">${title}</h1><div class="alert alert-error" role="alert"
|
|
49
|
+
await send(reply.code(403), s, shell(s, title, crumbs(title), html`<h1 class="u-mb1">${title}</h1><div class="alert alert-error" role="alert">${tx('wres.you_are_not_a_developer')}</div>`));
|
|
49
50
|
return null;
|
|
50
51
|
}
|
|
51
52
|
return { s, ws: { id: s.workspace.id, name: s.workspace.name }, admin: await workspaceAdmin(s.username, s.workspace.id) };
|
|
@@ -56,11 +57,11 @@ async function manager(req: Req, reply: FastifyReply, title: string): Promise<Ct
|
|
|
56
57
|
const c = await context(req, reply, title);
|
|
57
58
|
if (!c) return null;
|
|
58
59
|
if (c.admin) return c;
|
|
59
|
-
await send(reply.code(403), c.s, shell(c.s, title, crumbs(title), html`<h1 class="u-mb1">${title}</h1><div class="alert alert-error" role="alert"
|
|
60
|
+
await send(reply.code(403), c.s, shell(c.s, title, crumbs(title), html`<h1 class="u-mb1">${title}</h1><div class="alert alert-error" role="alert">${tx('wres.only_the_workspace_s_administrators')}</div>`));
|
|
60
61
|
return null;
|
|
61
62
|
}
|
|
62
63
|
|
|
63
|
-
const readOnlyNote = (c: Ctx) => (c.admin ? '' : html`<div class="alert alert-info" role="status"
|
|
64
|
+
const readOnlyNote = (c: Ctx) => (c.admin ? '' : html`<div class="alert alert-info" role="status">${tx('wres.you_can_see_these_the')}</div>`);
|
|
64
65
|
|
|
65
66
|
// ====================================================================== static workspace files
|
|
66
67
|
|
|
@@ -89,40 +90,36 @@ async function filesPage(c: Ctx, query: Record<string, string | undefined>) {
|
|
|
89
90
|
? (await owner.one<{ content: Buffer }>('select content from meta.workspace_file where workspace_id = $1 and name = $2', [ws.id, editName]))!.content.toString('utf8')
|
|
90
91
|
: '';
|
|
91
92
|
const editor = editName || creating
|
|
92
|
-
? region(editName ?
|
|
93
|
+
? region(editName ? tx('wres.edit_named', { name: editName }) : tx('wres.new_file'), html`<form method="post" action="${FILES}/save">${csrf(s)}
|
|
93
94
|
${editName ? html`<input type="hidden" name="original" value="${editName}">` : ''}
|
|
94
|
-
<div class="form-grid">${input('name', '
|
|
95
|
-
<div class="field" data-wide><label class="label" for="f_content"
|
|
95
|
+
<div class="form-grid">${input('name', tx('wres.file_name'), editName ?? '', { required: true, placeholder: 'brand.css', help: tx('wres.letters_digits_and_e_g') })}</div>
|
|
96
|
+
<div class="field" data-wide><label class="label" for="f_content">${tx('wres.content')}</label>
|
|
96
97
|
<textarea id="f_content" name="content" class="code" rows="18" spellcheck="false">${content}</textarea></div>
|
|
97
|
-
<div class="buttons"><a class="btn" href="${FILES}"
|
|
98
|
+
<div class="buttons"><a class="btn" href="${FILES}">${tx('wres.cancel')}</a><button class="btn btn-hot">${icon('check')} ${tx('wres.save')}</button></div>
|
|
98
99
|
</form>`)
|
|
99
100
|
: '';
|
|
100
|
-
return html`<div class="title-row"><h1
|
|
101
|
-
<p class="muted u-mt0"
|
|
102
|
-
every page with <code>#WORKSPACE_FILES#<i>name</i></code> in Shared Components → Static application files → Every page loads (a page:
|
|
103
|
-
Page → Appearance), and templates, HTML and links use <code>#WORKSPACE_FILES#</code> for their URL. Each application serves them from its
|
|
104
|
-
own address (<code>/a/<i>alias</i>/workspace-files/<i>name</i></code>), never another workspace's. Up to ${size(STATIC_MAX)} per file; no HTML.
|
|
105
|
-
They travel with the workspace export.</p>
|
|
101
|
+
return html`<div class="title-row"><h1>${tx('wres.static_workspace_files')}</h1></div>
|
|
102
|
+
<p class="muted u-mt0">${tx.rich('wres.files_intro', { workspace: ws.name, tag: html`<code>#WORKSPACE_FILES#<i>name</i></code>`, sub: html`<code>#WORKSPACE_FILES#</code>`, path: html`<code>/a/<i>alias</i>/workspace-files/<i>name</i></code>`, size: size(STATIC_MAX) })}</p>
|
|
106
103
|
${readOnlyNote(c)}
|
|
107
104
|
${editor}
|
|
108
|
-
${region('
|
|
105
|
+
${region(tx('wres.files'), html`${files.length
|
|
109
106
|
? html`<div class="table-wrap"><table class="report report-reflow">
|
|
110
|
-
<thead><tr><th scope="col"
|
|
107
|
+
<thead><tr><th scope="col">${tx('wres.name')}</th><th scope="col">${tx('wres.type')}</th><th scope="col" class="num">${tx('wres.size')}</th><th scope="col">${tx('wres.loaded_by')}</th><th scope="col">${tx('wres.changed_utc')}</th>${c.admin ? html`<th scope="col">${tx('wres.actions')}</th>` : ''}</tr></thead>
|
|
111
108
|
<tbody>${files.map((f) => html`<tr>
|
|
112
|
-
<td data-label="
|
|
113
|
-
<td data-label="
|
|
114
|
-
<td data-label="
|
|
115
|
-
<td data-label="
|
|
116
|
-
${c.admin ? html`<td data-label="
|
|
109
|
+
<td data-label="${tx('wres.name')}" class="u-break"><a href="${FILES}/file/${encodeURIComponent(f.name)}" target="_blank" rel="noopener">${f.name}</a></td>
|
|
110
|
+
<td data-label="${tx('wres.type')}">${f.mime}</td><td class="num" data-label="${tx('wres.size')}">${size(Number(f.bytes))}</td>
|
|
111
|
+
<td data-label="${tx('wres.loaded_by')}" class="u-break">${includable(f.name) ? (users.get(f.name)?.join(', ') || html`<span class="muted">-</span>`) : ''}</td>
|
|
112
|
+
<td data-label="${tx('wres.changed')}">${when(f.changed)}${f.updated_by ? html` <span class="muted">${f.updated_by}</span>` : ''}</td>
|
|
113
|
+
${c.admin ? html`<td data-label="${tx('wres.actions')}" class="nowrap">${editable(f.name) ? html`<a class="btn btn-small" href="${FILES}?edit=${encodeURIComponent(f.name)}">${tx('wres.edit')}</a> ` : ''}
|
|
117
114
|
<form method="post" action="${FILES}/delete" class="inline">${csrf(s)}<input type="hidden" name="name" value="${f.name}">
|
|
118
|
-
<button class="btn btn-small btn-danger" data-confirm="
|
|
115
|
+
<button class="btn btn-small btn-danger" data-confirm="${tx('wres.delete_file_confirm', { name: f.name })}" aria-label="${tx('wres.delete_named', { name: f.name })}">${icon('trash')}</button></form></td>` : ''}
|
|
119
116
|
</tr>`)}</tbody></table></div>`
|
|
120
|
-
: html`<p class="muted"
|
|
121
|
-
${c.admin ? html`<div class="buttons u-mt1"><a class="btn" href="${FILES}?new=1">${icon('plus')}
|
|
117
|
+
: html`<p class="muted">${tx('wres.no_files_yet')}</p>`}
|
|
118
|
+
${c.admin ? html`<div class="buttons u-mt1"><a class="btn" href="${FILES}?new=1">${icon('plus')} ${tx('wres.new_file')}</a></div>
|
|
122
119
|
<form method="post" action="${FILES}/upload" enctype="multipart/form-data" class="search u-mwnone u-mt1">${csrf(s)}
|
|
123
|
-
<input type="file" name="file" multiple aria-label="
|
|
124
|
-
<button class="btn">${icon('upload')}
|
|
125
|
-
<small class="help"
|
|
120
|
+
<input type="file" name="file" multiple aria-label="${tx('wres.files_to_upload')}" accept="${Object.keys(STATIC_TYPES).map((x) => `.${x}`).join(',')}">
|
|
121
|
+
<button class="btn">${icon('upload')} ${tx('wres.upload')}</button>
|
|
122
|
+
<small class="help">${tx('wres.a_file_with_the_same')}</small>
|
|
126
123
|
</form>` : ''}`)}`;
|
|
127
124
|
}
|
|
128
125
|
|
|
@@ -170,44 +167,41 @@ async function serversPage(c: Ctx, query: Record<string, string | undefined>) {
|
|
|
170
167
|
try {
|
|
171
168
|
url = new URL(u.url);
|
|
172
169
|
} catch {
|
|
173
|
-
return html`<span class="tag tag-error"
|
|
170
|
+
return html`<span class="tag tag-error">${tx('wres.not_a_valid_url')}</span>`;
|
|
174
171
|
}
|
|
175
172
|
const problem = urlProblem(url) ?? scopeProblem(scope, url);
|
|
176
|
-
return problem ? html`<span class="tag tag-error">refused</span> <span class="muted small">${problem}</span>` : html`<span class="tag tag-ok"
|
|
173
|
+
return problem ? html`<span class="tag tag-error">refused</span> <span class="muted small">${problem}</span>` : html`<span class="tag tag-ok">${tx('wres.may_be_called')}</span>`;
|
|
177
174
|
};
|
|
178
175
|
const form = editing || creating
|
|
179
|
-
? region(editing ?
|
|
176
|
+
? region(editing ? tx('wres.server_title', { id: editing.static_id }) : tx('wres.new_remote_server'), html`<form method="post" action="${SERVERS}">${csrf(s)}
|
|
180
177
|
${editing ? html`<input type="hidden" name="id" value="${editing.id}">` : ''}
|
|
181
178
|
<div class="form-grid">
|
|
182
|
-
${input('static_id', '
|
|
183
|
-
${input('name', '
|
|
184
|
-
${input('base_url', '
|
|
185
|
-
${input('env_var', '
|
|
186
|
-
${input('credential', '
|
|
187
|
-
<div class="field" data-wide><label class="label" for="f_description"
|
|
179
|
+
${input('static_id', tx('wres.static_id'), v.static_id ?? '', { required: true, placeholder: 'CRM', help: tx('wres.upper_case_letters_digits_and') })}
|
|
180
|
+
${input('name', tx('wres.name'), v.name ?? '', { required: true, placeholder: tx('wres.crm_system') })}
|
|
181
|
+
${input('base_url', tx('wres.base_url_this_installation'), v.base_url ?? '', { placeholder: tx('wres.https_crm_example_com_api'), help: tx('wres.no_trailing_slash_query_or') })}
|
|
182
|
+
${input('env_var', tx('wres.or_from_the_environment_variable'), v.env_var ?? '', { placeholder: `${ENV_PREFIX}CRM`, help: tx('wres.env_help', { prefix: ENV_PREFIX }) })}
|
|
183
|
+
${input('credential', tx('wres.default_web_credential'), v.credential ?? '', { placeholder: tx('wres.e_g_crm_api'), help: tx('wres.sources_on_this_server_that') })}
|
|
184
|
+
<div class="field" data-wide><label class="label" for="f_description">${tx('wres.description')}</label><textarea id="f_description" name="description" rows="2">${v.description ?? ''}</textarea></div>
|
|
188
185
|
</div>
|
|
189
|
-
<div class="buttons"><a class="btn" href="${SERVERS}"
|
|
186
|
+
<div class="buttons"><a class="btn" href="${SERVERS}">${tx('wres.cancel')}</a><button class="btn btn-hot">${icon('check')} ${tx('wres.save')}</button></div></form>
|
|
190
187
|
${editing ? html`<form method="post" action="${SERVERS}/${editing.id}/delete" class="danger-zone">${csrf(s)}
|
|
191
|
-
<button class="btn btn-danger" data-confirm="
|
|
188
|
+
<button class="btn btn-danger" data-confirm="${tx('wres.delete_server_confirm', { id: editing.static_id })}">${tx('wres.delete_server')}</button></form>` : ''}`, '', 'server-form')
|
|
192
189
|
: '';
|
|
193
|
-
return html`<div class="title-row"><h1
|
|
194
|
-
<p class="muted u-mt0"
|
|
195
|
-
instead of a full URL, so moving from development to production changes one value here (or one environment variable), and every source on
|
|
196
|
-
the server follows at once. The URL still passes every check a full URL does: the installation's allow-list, this workspace's
|
|
197
|
-
<a href="${BASE}/network">network access</a> list and the address checks when the call is made.</p>
|
|
190
|
+
return html`<div class="title-row"><h1>${tx('wres.remote_servers')}</h1>${c.admin ? html`<a class="btn" href="${SERVERS}?new=1#server-form">${icon('plus')} ${tx('wres.new_server')}</a>` : ''}</div>
|
|
191
|
+
<p class="muted u-mt0">${tx.rich('wres.servers_intro', { workspace: ws.name, network: html`<a href="${BASE}/network">${tx('wres.network_access')}</a>` })}</p>
|
|
198
192
|
${readOnlyNote(c)}
|
|
199
193
|
${form}
|
|
200
|
-
${region('
|
|
201
|
-
<thead><tr><th
|
|
194
|
+
${region(tx('wres.servers'), html`<div class="table-wrap"><table class="report report-reflow">
|
|
195
|
+
<thead><tr><th>${tx('wres.server')}</th><th>${tx('wres.url_now')}</th><th>${tx('wres.network_access_2')}</th><th>${tx('wres.default_credential')}</th><th>${tx('wres.used_by')}</th></tr></thead>
|
|
202
196
|
<tbody>${servers.length ? servers.map((x) => {
|
|
203
197
|
const u = serverUrl(x);
|
|
204
198
|
return html`<tr>
|
|
205
|
-
<td data-label="
|
|
206
|
-
<td data-label="
|
|
207
|
-
<td data-label="
|
|
208
|
-
<td data-label="
|
|
209
|
-
<td data-label="
|
|
210
|
-
}) : html`<tr><td colspan="5" class="empty"
|
|
199
|
+
<td data-label="${tx('wres.server')}">${c.admin ? html`<a href="${SERVERS}?edit=${x.id}#server-form">${x.static_id}</a>` : x.static_id}<br><span class="muted small">${x.name}</span></td>
|
|
200
|
+
<td data-label="${tx('wres.url_now')}" class="u-break">${u.url ? html`<code>${u.url}</code>` : ''}<br><span class="muted small">${u.from === 'env' ? `from ${x.env_var}` : x.env_var ? `stored (${x.env_var} is not set)` : 'stored'}</span></td>
|
|
201
|
+
<td data-label="${tx('wres.network_access_2')}">${verdict(x)}</td>
|
|
202
|
+
<td data-label="${tx('wres.default_credential')}">${x.credential ?? html`<span class="muted">none</span>`}</td>
|
|
203
|
+
<td data-label="${tx('wres.used_by')}" class="u-break">${users.get(x.static_id)?.join(', ') || html`<span class="muted">${tx('wres.nothing_yet')}</span>`}</td></tr>`;
|
|
204
|
+
}) : html`<tr><td colspan="5" class="empty">${tx('wres.no_remote_servers_yet')}</td></tr>`}</tbody></table></div>`)}`;
|
|
211
205
|
}
|
|
212
206
|
|
|
213
207
|
// ====================================================================== workspace web credentials
|
|
@@ -246,47 +240,45 @@ async function credentialsPage(c: Ctx, query: Record<string, string | undefined>
|
|
|
246
240
|
const creating = c.admin && query.new === '1';
|
|
247
241
|
const v: Partial<CredentialRow> = editing ?? { type: 'basic', grant_type: 'client_credentials' };
|
|
248
242
|
const secret = (name: string, label: string, stored: boolean | undefined, help: string) =>
|
|
249
|
-
html`${input(name, label, '', { type: 'password', auto: 'new-password', help: `${help} ${stored ? '
|
|
250
|
-
${stored ? html`<div class="field"><label class="check"><input type="checkbox" name="remove_${name}" value="true">
|
|
243
|
+
html`${input(name, label, '', { type: 'password', auto: 'new-password', help: `${help} ${stored ? tx('wres.one_is_stored_encrypted_it') : tx('wres.write_only_never_shown_again')}` })}
|
|
244
|
+
${stored ? html`<div class="field"><label class="check"><input type="checkbox" name="remove_${name}" value="true"> ${tx('wres.remove_stored', { what: label.toLowerCase() })}</label></div>` : ''}`;
|
|
251
245
|
const form = editing || creating
|
|
252
|
-
? region(editing ?
|
|
246
|
+
? region(editing ? tx('wres.credential_title', { name: editing.name }) : tx('wres.new_web_credential'), html`<form method="post" action="${CREDS}">${csrf(s)}
|
|
253
247
|
${editing ? html`<input type="hidden" name="id" value="${editing.id}">` : ''}
|
|
254
|
-
${secretKeyConfigured() ? '' : html`<div class="alert alert-error" role="alert"
|
|
248
|
+
${secretKeyConfigured() ? '' : html`<div class="alert alert-error" role="alert">${tx.code('wres.no_secret_key')}</div>`}
|
|
255
249
|
<div class="form-grid">
|
|
256
|
-
${input('name', '
|
|
257
|
-
${select('type', '
|
|
258
|
-
${input('username', '
|
|
259
|
-
${select('grant_type', '
|
|
260
|
-
${input('oauth_username', '
|
|
261
|
-
${input('header_name', '
|
|
262
|
-
${input('token_url', '
|
|
263
|
-
${input('scope', '
|
|
264
|
-
${input('valid_for', '
|
|
265
|
-
${secret('secret', '
|
|
266
|
-
${secret('password', 'OAuth2 password', v.has_password, '
|
|
267
|
-
${secret('refresh_token', '
|
|
268
|
-
<div class="field" data-wide><label class="label" for="f_description"
|
|
250
|
+
${input('name', tx('wres.name'), v.name ?? '', { required: true, placeholder: 'CRM_API', help: tx('wres.the_applications_of_the_workspace') })}
|
|
251
|
+
${select('type', tx('wres.authentication'), v.type ?? 'basic', ['basic', 'header', 'bearer', 'oauth2', 'aws_sigv4'], tx('wres.type_help'))}
|
|
252
|
+
${input('username', tx('wres.user_name_client_id_access'), v.username ?? '')}
|
|
253
|
+
${select('grant_type', tx('wres.grant_type_oauth2'), v.grant_type ?? 'client_credentials', GRANT_TYPES)}
|
|
254
|
+
${input('oauth_username', tx('wres.oauth2_user_name_password_grant'), v.oauth_username ?? '')}
|
|
255
|
+
${input('header_name', tx('wres.header_name_header'), v.header_name ?? '', { placeholder: tx('wres.x_api_key') })}
|
|
256
|
+
${input('token_url', tx('wres.token_url_oauth2'), v.token_url ?? '', { placeholder: tx('wres.https_login_example_com_oauth') })}
|
|
257
|
+
${input('scope', tx('wres.scope_oauth2_region_aws_sigv4'), v.scope ?? '')}
|
|
258
|
+
${input('valid_for', tx('wres.valid_for_urls'), (v.valid_for ?? []).join(', '), { placeholder: tx('wres.https_crm_example_com_api_2'), help: tx('wres.comma_separated_url_prefixes_the') })}
|
|
259
|
+
${secret('secret', tx('wres.secret'), v.has_secret, tx('wres.the_password_header_value_token'))}
|
|
260
|
+
${secret('password', 'OAuth2 password', v.has_password, tx('wres.the_password_grant_the_oauth2'))}
|
|
261
|
+
${secret('refresh_token', tx('wres.refresh_token'), v.has_refresh, tx('wres.the_refresh_token_grant_a'))}
|
|
262
|
+
<div class="field" data-wide><label class="label" for="f_description">${tx('wres.description')}</label><textarea id="f_description" name="description" rows="2">${v.description ?? ''}</textarea></div>
|
|
269
263
|
</div>
|
|
270
|
-
<div class="buttons"><a class="btn" href="${CREDS}"
|
|
264
|
+
<div class="buttons"><a class="btn" href="${CREDS}">${tx('wres.cancel')}</a><button class="btn btn-hot">${icon('check')} ${tx('wres.save')}</button></div></form>
|
|
271
265
|
${editing ? html`<form method="post" action="${CREDS}/${editing.id}/delete" class="danger-zone">${csrf(s)}
|
|
272
|
-
<button class="btn btn-danger" data-confirm="
|
|
266
|
+
<button class="btn btn-danger" data-confirm="${tx('wres.delete_credential_confirm', { name: editing.name })}">${tx('wres.delete_credential')}</button></form>` : ''}`, '', 'credential-form')
|
|
273
267
|
: '';
|
|
274
|
-
return html`<div class="title-row"><h1
|
|
275
|
-
<p class="muted u-mt0"
|
|
276
|
-
remote servers and object storage, so a key is entered once for the workspace. An application's own credential (Shared Components →
|
|
277
|
-
Web credentials) with the same name wins. Secrets are encrypted with the server's key, never shown again and never exported.</p>
|
|
268
|
+
return html`<div class="title-row"><h1>${tx('wres.workspace_web_credentials')}</h1>${c.admin ? html`<a class="btn" href="${CREDS}?new=1#credential-form">${icon('plus')} ${tx('wres.new_credential')}</a>` : ''}</div>
|
|
269
|
+
<p class="muted u-mt0">${tx('wres.credentials_intro', { workspace: ws.name })}</p>
|
|
278
270
|
${readOnlyNote(c)}
|
|
279
271
|
${form}
|
|
280
|
-
${region('
|
|
281
|
-
<thead><tr><th
|
|
272
|
+
${region(tx('wres.credentials'), html`<div class="table-wrap"><table class="report report-reflow">
|
|
273
|
+
<thead><tr><th>${tx('wres.name')}</th><th>${tx('wres.authentication')}</th><th>${tx('wres.secret')}</th><th>${tx('wres.valid_for')}</th><th>${tx('wres.overridden_by')}</th><th>${tx('wres.changed_utc')}</th></tr></thead>
|
|
282
274
|
<tbody>${creds.length ? creds.map((x) => html`<tr>
|
|
283
|
-
<td data-label="
|
|
284
|
-
<td data-label="
|
|
285
|
-
<td data-label="
|
|
286
|
-
<td data-label="
|
|
287
|
-
<td data-label="
|
|
288
|
-
<td data-label="
|
|
289
|
-
: html`<tr><td colspan="6" class="empty"
|
|
275
|
+
<td data-label="${tx('wres.name')}">${c.admin ? html`<a href="${CREDS}?edit=${x.id}#credential-form">${x.name}</a>` : x.name}${x.description ? html`<br><span class="muted small">${x.description}</span>` : ''}</td>
|
|
276
|
+
<td data-label="${tx('wres.authentication')}">${x.type}${x.type === 'oauth2' ? html` <span class="muted">(${x.grant_type})</span>` : ''}</td>
|
|
277
|
+
<td data-label="${tx('wres.secret')}">${x.has_secret || x.has_password || x.has_refresh ? 'stored (encrypted)' : html`<span class="muted">${tx('wres.none_yet')}</span>`}</td>
|
|
278
|
+
<td data-label="${tx('wres.valid_for')}" class="u-break">${x.valid_for.length ? x.valid_for.join(', ') : html`<span class="muted">${tx('wres.any_url')}</span>`}</td>
|
|
279
|
+
<td data-label="${tx('wres.overridden_by')}" class="u-break">${shadowed.find((r) => r.name === x.name)?.apps ?? html`<span class="muted">-</span>`}</td>
|
|
280
|
+
<td data-label="${tx('wres.changed')}">${when(x.updated_at)}${x.updated_by ? html` <span class="muted">${x.updated_by}</span>` : ''}</td></tr>`)
|
|
281
|
+
: html`<tr><td colspan="6" class="empty">${tx('wres.no_workspace_web_credentials_yet')}</td></tr>`}</tbody></table></div>`)}`;
|
|
290
282
|
}
|
|
291
283
|
|
|
292
284
|
// ====================================================================== routes
|
|
@@ -294,14 +286,14 @@ async function credentialsPage(c: Ctx, query: Record<string, string | undefined>
|
|
|
294
286
|
export async function workspaceResourceRoutes(app: FastifyInstance) {
|
|
295
287
|
// ---------------------------------------------------------------- files
|
|
296
288
|
app.get(FILES, async (req: Req, reply) => {
|
|
297
|
-
const c = await context(req, reply, '
|
|
289
|
+
const c = await context(req, reply, tx('wres.files_title'));
|
|
298
290
|
if (!c) return;
|
|
299
|
-
return send(reply, c.s, shell(c.s, '
|
|
291
|
+
return send(reply, c.s, shell(c.s, tx('wres.files_title'), crumbs(tx('wres.files_title')), await filesPage(c, req.query ?? {})));
|
|
300
292
|
});
|
|
301
293
|
|
|
302
294
|
// a file for the workspace's developers, with the headers the applications serve it with
|
|
303
295
|
app.get(`${FILES}/file/:name`, async (req: Req, reply) => {
|
|
304
|
-
const c = await context(req, reply, '
|
|
296
|
+
const c = await context(req, reply, tx('wres.files_title'));
|
|
305
297
|
if (!c) return;
|
|
306
298
|
const type = staticType(String(req.params.name));
|
|
307
299
|
const f = type ? await owner.one<{ content: Buffer }>('select content from meta.workspace_file where workspace_id = $1 and name = $2', [c.ws.id, req.params.name]) : null;
|
|
@@ -320,11 +312,11 @@ export async function workspaceResourceRoutes(app: FastifyInstance) {
|
|
|
320
312
|
req.body = parsed.body as Body;
|
|
321
313
|
uploads = parsed.lists.get('file') ?? [];
|
|
322
314
|
}
|
|
323
|
-
const c = await manager(req, reply, '
|
|
315
|
+
const c = await manager(req, reply, tx('wres.files_title'));
|
|
324
316
|
if (!c) return;
|
|
325
317
|
const { s } = c;
|
|
326
318
|
if (!uploads.length) {
|
|
327
|
-
flash(s, '
|
|
319
|
+
flash(s, tx('wres.choose_one_or_more_files'), 'error');
|
|
328
320
|
return back(reply, s, FILES);
|
|
329
321
|
}
|
|
330
322
|
const saved: string[] = [];
|
|
@@ -346,7 +338,7 @@ export async function workspaceResourceRoutes(app: FastifyInstance) {
|
|
|
346
338
|
});
|
|
347
339
|
|
|
348
340
|
app.post(`${FILES}/save`, async (req: Req, reply) => {
|
|
349
|
-
const c = await manager(req, reply, '
|
|
341
|
+
const c = await manager(req, reply, tx('wres.files_title'));
|
|
350
342
|
if (!c) return;
|
|
351
343
|
const { s } = c;
|
|
352
344
|
const b = req.body ?? {};
|
|
@@ -355,47 +347,47 @@ export async function workspaceResourceRoutes(app: FastifyInstance) {
|
|
|
355
347
|
const type = staticType(name);
|
|
356
348
|
const content = Buffer.from(String(b.content ?? '').replace(/\r\n/g, '\n'), 'utf8');
|
|
357
349
|
if (!type || !editable(name)) {
|
|
358
|
-
flash(s, '
|
|
350
|
+
flash(s, tx('wres.the_name_must_end_in'), 'error');
|
|
359
351
|
return back(reply, s, original ? `${FILES}?edit=${encodeURIComponent(original)}` : `${FILES}?new=1`);
|
|
360
352
|
}
|
|
361
353
|
if (content.length > STATIC_MAX) {
|
|
362
|
-
flash(s,
|
|
354
|
+
flash(s, tx('wres.too_large', { size: size(STATIC_MAX) }), 'error');
|
|
363
355
|
return back(reply, s, FILES);
|
|
364
356
|
}
|
|
365
357
|
try {
|
|
366
358
|
if (original && original !== name) {
|
|
367
359
|
const r = await owner.query('update meta.workspace_file set name = $3, mime = $4, content = $5, updated_by = $6, updated_at = now() where workspace_id = $1 and name = $2',
|
|
368
360
|
[c.ws.id, original, name, type, content, s.username]);
|
|
369
|
-
if (!r.rowCount) throw new Error('
|
|
361
|
+
if (!r.rowCount) throw new Error(tx('wres.no_such_file'));
|
|
370
362
|
} else await storeFile(c.ws.id, name, type, content, s.username);
|
|
371
363
|
await logActivity({ appId: null, username: s.username, event: 'workspace', ip: clientIp(req), detail: `workspace ${c.ws.id} file saved: ${name}` });
|
|
372
364
|
flash(s, `${name} saved.`);
|
|
373
365
|
return back(reply, s, `${FILES}?edit=${encodeURIComponent(name)}`);
|
|
374
366
|
} catch (e) {
|
|
375
|
-
flash(s, (e as Error).message.includes('workspace_file_workspace_id_name_key') ?
|
|
367
|
+
flash(s, (e as Error).message.includes('workspace_file_workspace_id_name_key') ? tx('wres.file_exists', { name }) : (e as Error).message, 'error');
|
|
376
368
|
return back(reply, s, FILES);
|
|
377
369
|
}
|
|
378
370
|
});
|
|
379
371
|
|
|
380
372
|
app.post(`${FILES}/delete`, async (req: Req, reply) => {
|
|
381
|
-
const c = await manager(req, reply, '
|
|
373
|
+
const c = await manager(req, reply, tx('wres.files_title'));
|
|
382
374
|
if (!c) return;
|
|
383
375
|
const name = String(req.body?.name ?? '');
|
|
384
376
|
const r = await owner.query('delete from meta.workspace_file where workspace_id = $1 and name = $2', [c.ws.id, name]);
|
|
385
377
|
if (r.rowCount) await logActivity({ appId: null, username: c.s.username, event: 'workspace', ip: clientIp(req), detail: `workspace ${c.ws.id} file deleted: ${name}` });
|
|
386
|
-
flash(c.s, r.rowCount ? `${name} deleted.` : '
|
|
378
|
+
flash(c.s, r.rowCount ? `${name} deleted.` : tx('wres.no_such_file'), r.rowCount ? 'ok' : 'error');
|
|
387
379
|
return back(reply, c.s, FILES);
|
|
388
380
|
});
|
|
389
381
|
|
|
390
382
|
// ---------------------------------------------------------------- remote servers
|
|
391
383
|
app.get(SERVERS, async (req: Req, reply) => {
|
|
392
|
-
const c = await context(req, reply, '
|
|
384
|
+
const c = await context(req, reply, tx('wres.servers_title'));
|
|
393
385
|
if (!c) return;
|
|
394
|
-
return send(reply, c.s, shell(c.s, '
|
|
386
|
+
return send(reply, c.s, shell(c.s, tx('wres.servers_title'), crumbs(tx('wres.servers_title')), await serversPage(c, req.query ?? {})));
|
|
395
387
|
});
|
|
396
388
|
|
|
397
389
|
app.post(SERVERS, async (req: Req, reply) => {
|
|
398
|
-
const c = await manager(req, reply, '
|
|
390
|
+
const c = await manager(req, reply, tx('wres.servers_title'));
|
|
399
391
|
if (!c) return;
|
|
400
392
|
const { s } = c;
|
|
401
393
|
const b = req.body ?? {};
|
|
@@ -415,7 +407,7 @@ export async function workspaceResourceRoutes(app: FastifyInstance) {
|
|
|
415
407
|
await owner.tx(async (db) => {
|
|
416
408
|
if (editId) {
|
|
417
409
|
const old = (await db.query<{ static_id: string }>('select static_id from meta.remote_server where id = $1 and workspace_id = $2 for update', [editId, c.ws.id])).rows[0];
|
|
418
|
-
if (!old) throw new Error('
|
|
410
|
+
if (!old) throw new Error(tx('wres.no_such_remote_server'));
|
|
419
411
|
await db.query(
|
|
420
412
|
`update meta.remote_server set static_id = $3, name = $4, description = $5, base_url = $6, env_var = $7, credential = $8, updated_by = $9, updated_at = now()
|
|
421
413
|
where id = $1 and workspace_id = $2`, [editId, c.ws.id, v.static_id, v.name, v.description, v.base_url, v.env_var, v.credential, s.username]);
|
|
@@ -433,35 +425,35 @@ export async function workspaceResourceRoutes(app: FastifyInstance) {
|
|
|
433
425
|
clearResponseCache();
|
|
434
426
|
await logActivity({ appId: null, username: s.username, event: 'workspace', ip: clientIp(req),
|
|
435
427
|
detail: `workspace ${c.ws.id} remote server ${v.static_id} ${editId ? 'changed' : 'added'}: ${v.base_url ?? ''}${v.env_var ? ` (${v.env_var})` : ''}`.slice(0, 1000) });
|
|
436
|
-
flash(s,
|
|
428
|
+
flash(s, tx('wres.server_saved', { id: v.static_id }));
|
|
437
429
|
return back(reply, s, SERVERS);
|
|
438
430
|
} catch (e) {
|
|
439
|
-
flash(s, (e as Error).message.includes('remote_server_workspace_id_static_id_key') ?
|
|
431
|
+
flash(s, (e as Error).message.includes('remote_server_workspace_id_static_id_key') ? tx('wres.server_exists', { id: v.static_id }) : (e as Error).message, 'error');
|
|
440
432
|
return back(reply, s, retry);
|
|
441
433
|
}
|
|
442
434
|
});
|
|
443
435
|
|
|
444
436
|
app.post(`${SERVERS}/:sid/delete`, async (req: Req, reply) => {
|
|
445
|
-
const c = await manager(req, reply, '
|
|
437
|
+
const c = await manager(req, reply, tx('wres.servers_title'));
|
|
446
438
|
if (!c) return;
|
|
447
439
|
const r = await owner.one<{ static_id: string }>('delete from meta.remote_server where id = $1 and workspace_id = $2 returning static_id', [id(req.params.sid), c.ws.id]);
|
|
448
440
|
if (r) {
|
|
449
441
|
clearResponseCache();
|
|
450
442
|
await logActivity({ appId: null, username: c.s.username, event: 'workspace', ip: clientIp(req), detail: `workspace ${c.ws.id} remote server ${r.static_id} deleted` });
|
|
451
443
|
}
|
|
452
|
-
flash(c.s, r ?
|
|
444
|
+
flash(c.s, r ? tx('wres.server_deleted', { id: r.static_id }) : tx('wres.no_such_remote_server'), r ? 'ok' : 'error');
|
|
453
445
|
return back(reply, c.s, SERVERS);
|
|
454
446
|
});
|
|
455
447
|
|
|
456
448
|
// ---------------------------------------------------------------- web credentials
|
|
457
449
|
app.get(CREDS, async (req: Req, reply) => {
|
|
458
|
-
const c = await context(req, reply, '
|
|
450
|
+
const c = await context(req, reply, tx('wres.credentials_title'));
|
|
459
451
|
if (!c) return;
|
|
460
|
-
return send(reply, c.s, shell(c.s, '
|
|
452
|
+
return send(reply, c.s, shell(c.s, tx('wres.credentials_title'), crumbs(tx('wres.credentials_title')), await credentialsPage(c, req.query ?? {})));
|
|
461
453
|
});
|
|
462
454
|
|
|
463
455
|
app.post(CREDS, async (req: Req, reply) => {
|
|
464
|
-
const c = await manager(req, reply, '
|
|
456
|
+
const c = await manager(req, reply, tx('wres.credentials_title'));
|
|
465
457
|
if (!c) return;
|
|
466
458
|
const { s } = c;
|
|
467
459
|
const b = req.body ?? {};
|
|
@@ -475,7 +467,7 @@ export async function workspaceResourceRoutes(app: FastifyInstance) {
|
|
|
475
467
|
valid_for: [...new Set(String(b.valid_for ?? '').split(',').map((x) => x.trim()).filter(Boolean))],
|
|
476
468
|
grant_type: GRANT_TYPES.includes(String(b.grant_type)) ? String(b.grant_type) : 'client_credentials', oauth_username: text('oauth_username'),
|
|
477
469
|
};
|
|
478
|
-
const problems = [...(/^[A-Z][A-Z0-9_]{0,59}$/.test(v.name) ? [] : ['
|
|
470
|
+
const problems = [...(/^[A-Z][A-Z0-9_]{0,59}$/.test(v.name) ? [] : [tx('wres.the_name_is_upper_case')]), ...credentialProblems(v)];
|
|
479
471
|
if (problems.length) {
|
|
480
472
|
flash(s, problems.join(' '), 'error');
|
|
481
473
|
return back(reply, s, retry);
|
|
@@ -495,7 +487,7 @@ export async function workspaceResourceRoutes(app: FastifyInstance) {
|
|
|
495
487
|
${sets.join(', ')}, token_refreshed_at = case when $18::text is not null or $19 then null else token_refreshed_at end
|
|
496
488
|
where id = $1 and workspace_id = $2`,
|
|
497
489
|
[editId, c.ws.id, ...vals, s.username, secrets.secret_enc, remove.secret_enc, secrets.password_enc, remove.password_enc, secrets.refresh_token_enc, remove.refresh_token_enc]);
|
|
498
|
-
if (!r.rowCount) throw new Error('
|
|
490
|
+
if (!r.rowCount) throw new Error(tx('wres.no_such_web_credential'));
|
|
499
491
|
clearTokens(`w${editId}`);
|
|
500
492
|
} else
|
|
501
493
|
await owner.query(
|
|
@@ -504,16 +496,16 @@ export async function workspaceResourceRoutes(app: FastifyInstance) {
|
|
|
504
496
|
[c.ws.id, ...vals, s.username, secrets.secret_enc, secrets.password_enc, secrets.refresh_token_enc]);
|
|
505
497
|
clearResponseCache();
|
|
506
498
|
await logActivity({ appId: null, username: s.username, event: 'workspace', ip: clientIp(req), detail: `workspace ${c.ws.id} web credential ${v.name} ${editId ? 'changed' : 'added'}` });
|
|
507
|
-
flash(s,
|
|
499
|
+
flash(s, tx('wres.credential_saved', { name: v.name }));
|
|
508
500
|
return back(reply, s, CREDS);
|
|
509
501
|
} catch (e) {
|
|
510
|
-
flash(s, (e as Error).message.includes('workspace_credential_workspace_id_name_key') ?
|
|
502
|
+
flash(s, (e as Error).message.includes('workspace_credential_workspace_id_name_key') ? tx('wres.credential_exists', { name: v.name }) : (e as Error).message, 'error');
|
|
511
503
|
return back(reply, s, retry);
|
|
512
504
|
}
|
|
513
505
|
});
|
|
514
506
|
|
|
515
507
|
app.post(`${CREDS}/:cid/delete`, async (req: Req, reply) => {
|
|
516
|
-
const c = await manager(req, reply, '
|
|
508
|
+
const c = await manager(req, reply, tx('wres.credentials_title'));
|
|
517
509
|
if (!c) return;
|
|
518
510
|
const r = await owner.one<{ id: number; name: string }>('delete from meta.workspace_credential where id = $1 and workspace_id = $2 returning id, name', [id(req.params.cid), c.ws.id]);
|
|
519
511
|
if (r) {
|
|
@@ -521,7 +513,7 @@ export async function workspaceResourceRoutes(app: FastifyInstance) {
|
|
|
521
513
|
clearResponseCache();
|
|
522
514
|
await logActivity({ appId: null, username: c.s.username, event: 'workspace', ip: clientIp(req), detail: `workspace ${c.ws.id} web credential ${r.name} deleted` });
|
|
523
515
|
}
|
|
524
|
-
flash(c.s, r ?
|
|
516
|
+
flash(c.s, r ? tx('wres.credential_deleted', { name: r.name }) : tx('wres.no_such_web_credential'), r ? 'ok' : 'error');
|
|
525
517
|
return back(reply, c.s, CREDS);
|
|
526
518
|
});
|
|
527
519
|
}
|