pgkiln 0.72.0 → 0.74.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/db/migrations/140_item_region_types.sql +27 -0
- package/db/migrations/141_workspace_resources.sql +115 -0
- package/docs/apex-feature-parity.md +91 -12
- package/docs/guide/01-installation.md +1 -0
- package/docs/guide/03-builder.md +47 -4
- package/docs/guide/04-pages-and-regions.md +41 -1
- package/docs/guide/05-items.md +43 -3
- package/docs/guide/11-from-apex.md +3 -0
- package/docs/guide/12-development.md +25 -3
- package/docs/guide/16-files.md +29 -0
- package/docs/guide/19-rest-data-sources.md +41 -3
- package/examples/hr/hr_75_item_region_types.sql +81 -0
- package/examples/hr/hr_76_remote_servers.sql +48 -0
- package/package.json +1 -1
- package/public/app.css +48 -0
- package/public/app.js +377 -1
- package/src/ai/service.ts +10 -1
- package/src/app.ts +2 -0
- package/src/builder/advisor.ts +18 -2
- package/src/builder/ai.ts +29 -3
- package/src/builder/arrange.ts +7 -0
- package/src/builder/components.ts +5 -5
- package/src/builder/designer.ts +9 -3
- package/src/builder/home.ts +3 -0
- package/src/builder/i18n/de.ts +6 -0
- package/src/builder/i18n/en.ts +6 -0
- package/src/builder/i18n/es.ts +6 -0
- package/src/builder/i18n/fr.ts +6 -0
- package/src/builder/i18n/it.ts +6 -0
- package/src/builder/i18n/ja.ts +6 -0
- package/src/builder/i18n/ko.ts +6 -0
- package/src/builder/i18n/nl.ts +6 -0
- package/src/builder/i18n/pt.ts +6 -0
- package/src/builder/i18n/zh-cn.ts +6 -0
- package/src/builder/i18n/zh-tw.ts +6 -0
- package/src/builder/network-access.ts +33 -0
- package/src/builder/region-help.ts +8 -2
- package/src/builder/rest-catalogs.ts +22 -14
- package/src/builder/routes.ts +5 -1
- package/src/builder/static-files.ts +14 -4
- package/src/builder/websources.ts +12 -6
- package/src/builder/workflows.ts +2 -1
- package/src/builder/workspace-resources.ts +528 -0
- package/src/i18n/ar.ts +13 -0
- package/src/i18n/cs.ts +13 -0
- package/src/i18n/da.ts +13 -0
- package/src/i18n/de.ts +13 -0
- package/src/i18n/el.ts +13 -0
- package/src/i18n/es.ts +13 -0
- package/src/i18n/fi.ts +13 -0
- package/src/i18n/fr.ts +13 -0
- package/src/i18n/he.ts +13 -0
- package/src/i18n/it.ts +13 -0
- package/src/i18n/ja.ts +13 -0
- package/src/i18n/ko.ts +13 -0
- package/src/i18n/nb.ts +13 -0
- package/src/i18n/pl.ts +13 -0
- package/src/i18n/pt.ts +13 -0
- package/src/i18n/ru.ts +13 -0
- package/src/i18n/sv.ts +13 -0
- package/src/i18n/tr.ts +13 -0
- package/src/i18n/uk.ts +13 -0
- package/src/i18n/zh.ts +13 -0
- package/src/i18n.ts +26 -0
- package/src/metadata.ts +22 -5
- package/src/remote-servers.ts +133 -0
- package/src/restcatalog.ts +21 -10
- package/src/restsync.ts +1 -1
- package/src/runtime/context.ts +2 -0
- package/src/runtime/items.ts +32 -3
- package/src/runtime/page-regions.ts +50 -0
- package/src/runtime/region-cache.ts +1 -1
- package/src/runtime/regions.ts +11 -0
- package/src/runtime/render.ts +3 -17
- package/src/runtime/report.ts +55 -3
- package/src/runtime/static-files.ts +46 -17
- package/src/runtime/template-components.ts +2 -1
- package/src/websources.ts +43 -18
- package/src/workspace-export.ts +80 -1
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyReply } from 'fastify';
|
|
2
|
+
import { owner } from '../db.ts';
|
|
3
|
+
import { html } from '../html.ts';
|
|
4
|
+
import { icon } from '../icons.ts';
|
|
5
|
+
import { scopeForWorkspace } from '../network-acl.ts';
|
|
6
|
+
import { ENV_PREFIX, serverProblems, serverUrl, type RemoteServer } from '../remote-servers.ts';
|
|
7
|
+
import { readMultipart } from '../runtime/files.ts';
|
|
8
|
+
import { includable, STATIC_MAX, STATIC_TYPES, staticType } from '../runtime/static-files.ts';
|
|
9
|
+
import { encryptSecret, secretKeyConfigured } from '../secrets.ts';
|
|
10
|
+
import { clientIp, logActivity, type Session } from '../session.ts';
|
|
11
|
+
import { scopeProblem, urlProblem } from '../webclient.ts';
|
|
12
|
+
import { clearResponseCache, clearTokens, credentialProblems, GRANT_TYPES } from '../websources.ts';
|
|
13
|
+
import { back, BASE, csrf, developer, flash, input, region, select, send, shell, type Body, type Req } from './ui.ts';
|
|
14
|
+
import { workspaceAdmin } from './workspaces.ts';
|
|
15
|
+
|
|
16
|
+
// (sprint 141, AP-7) Workspace utilities → Static workspace files, Remote
|
|
17
|
+
// servers and Web credentials (APEX: Static Workspace Files, Remote Servers,
|
|
18
|
+
// workspace Web Credentials), for the developer's current workspace. Its
|
|
19
|
+
// developers see them; its administrators (and the installation's) change
|
|
20
|
+
// them. Nothing here reaches another workspace: every query is bound to the
|
|
21
|
+
// current workspace's id.
|
|
22
|
+
//
|
|
23
|
+
// /builder/workspace-files CSS, JavaScript, images and fonts every application of the workspace may
|
|
24
|
+
// load (#WORKSPACE_FILES#name; served at /a/<alias>/workspace-files/<name>)
|
|
25
|
+
// /builder/remote-servers named base URLs (src/remote-servers.ts) and what uses them
|
|
26
|
+
// /builder/workspace-credentials web credentials the applications use by name; secrets write-only
|
|
27
|
+
|
|
28
|
+
const FILES = `${BASE}/workspace-files`;
|
|
29
|
+
const SERVERS = `${BASE}/remote-servers`;
|
|
30
|
+
const CREDS = `${BASE}/workspace-credentials`;
|
|
31
|
+
const crumbs = (title: string): [string, string?][] => [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], [title]];
|
|
32
|
+
const id = (v: unknown) => (/^\d{1,9}$/.test(String(v ?? '')) ? Number(v) : null);
|
|
33
|
+
const when = (d: Date | string | null) => (d ? new Date(d).toISOString().slice(0, 16).replace('T', ' ') : '');
|
|
34
|
+
const size = (n: number) => (n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`);
|
|
35
|
+
const editable = (name: string) => /^text\/|^application\/json$/.test(staticType(name) ?? '');
|
|
36
|
+
|
|
37
|
+
interface Ctx {
|
|
38
|
+
s: Session;
|
|
39
|
+
ws: { id: number; name: string };
|
|
40
|
+
admin: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The developer's current workspace and whether they manage it; null after answering (no workspace). */
|
|
44
|
+
async function context(req: Req, reply: FastifyReply, title: string): Promise<Ctx | null> {
|
|
45
|
+
const s = await developer(req, reply);
|
|
46
|
+
if (!s) return null;
|
|
47
|
+
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">You are not a developer of any workspace yet.</div>`));
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
return { s, ws: { id: s.workspace.id, name: s.workspace.name }, admin: await workspaceAdmin(s.username, s.workspace.id) };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** For changes: the workspace's administrators only (403 otherwise). */
|
|
55
|
+
async function manager(req: Req, reply: FastifyReply, title: string): Promise<Ctx | null> {
|
|
56
|
+
const c = await context(req, reply, title);
|
|
57
|
+
if (!c) return null;
|
|
58
|
+
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">Only the workspace's administrators change these.</div>`));
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const readOnlyNote = (c: Ctx) => (c.admin ? '' : html`<div class="alert alert-info" role="status">You can see these; the workspace's administrators change them.</div>`);
|
|
64
|
+
|
|
65
|
+
// ====================================================================== static workspace files
|
|
66
|
+
|
|
67
|
+
/** The applications of the workspace that load a file (every page, or some pages). */
|
|
68
|
+
async function fileUsers(wsId: number) {
|
|
69
|
+
const rows = (await owner.query<{ name: string; alias: string; pages: number[] | null }>(
|
|
70
|
+
`select substr(u.n, 18) as name, a.alias, null::int[] as pages
|
|
71
|
+
from meta.app a, unnest(a.static_includes) u(n) where meta.app_workspace(a.id) = $1 and u.n like '#WORKSPACE\\_FILES#%'
|
|
72
|
+
union all
|
|
73
|
+
select substr(u.n, 18), a.alias, array_agg(p.page_no order by p.page_no)
|
|
74
|
+
from meta.app a join meta.page p on p.app_id = a.id, unnest(p.static_includes) u(n)
|
|
75
|
+
where meta.app_workspace(a.id) = $1 and u.n like '#WORKSPACE\\_FILES#%' group by 1, 2`, [wsId])).rows;
|
|
76
|
+
const out = new Map<string, string[]>();
|
|
77
|
+
for (const r of rows) out.set(r.name, [...(out.get(r.name) ?? []), r.pages ? `${r.alias} (page ${r.pages.join(', ')})` : r.alias]);
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function filesPage(c: Ctx, query: Record<string, string | undefined>) {
|
|
82
|
+
const { s, ws } = c;
|
|
83
|
+
const files = (await owner.query<{ name: string; mime: string; bytes: string; changed: Date; updated_by: string | null }>(
|
|
84
|
+
'select name, mime, octet_length(content) as bytes, updated_at as changed, updated_by from meta.workspace_file where workspace_id = $1 order by name', [ws.id])).rows;
|
|
85
|
+
const users = await fileUsers(ws.id);
|
|
86
|
+
const editName = c.admin && query.edit && files.some((f) => f.name === query.edit) && editable(query.edit) ? query.edit : null;
|
|
87
|
+
const creating = c.admin && query.new === '1';
|
|
88
|
+
const content = editName
|
|
89
|
+
? (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
|
+
const editor = editName || creating
|
|
92
|
+
? region(editName ? `Edit ${editName}` : 'New file', html`<form method="post" action="${FILES}/save">${csrf(s)}
|
|
93
|
+
${editName ? html`<input type="hidden" name="original" value="${editName}">` : ''}
|
|
94
|
+
<div class="form-grid">${input('name', 'File name', editName ?? '', { required: true, placeholder: 'brand.css', help: 'Letters, digits, _, - and . (e.g. brand.css, common.js).' })}</div>
|
|
95
|
+
<div class="field" data-wide><label class="label" for="f_content">Content</label>
|
|
96
|
+
<textarea id="f_content" name="content" class="code" rows="18" spellcheck="false">${content}</textarea></div>
|
|
97
|
+
<div class="buttons"><a class="btn" href="${FILES}">Cancel</a><button class="btn btn-hot">${icon('check')} Save</button></div>
|
|
98
|
+
</form>`)
|
|
99
|
+
: '';
|
|
100
|
+
return html`<div class="title-row"><h1>Static workspace files</h1></div>
|
|
101
|
+
<p class="muted u-mt0">CSS, JavaScript, JSON, images and fonts that every application of ${ws.name} may use: an application loads one on
|
|
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>
|
|
106
|
+
${readOnlyNote(c)}
|
|
107
|
+
${editor}
|
|
108
|
+
${region('Files', html`${files.length
|
|
109
|
+
? html`<div class="table-wrap"><table class="report report-reflow">
|
|
110
|
+
<thead><tr><th scope="col">Name</th><th scope="col">Type</th><th scope="col" class="num">Size</th><th scope="col">Loaded by</th><th scope="col">Changed (UTC)</th>${c.admin ? html`<th scope="col">Actions</th>` : ''}</tr></thead>
|
|
111
|
+
<tbody>${files.map((f) => html`<tr>
|
|
112
|
+
<td data-label="Name" class="u-break"><a href="${FILES}/file/${encodeURIComponent(f.name)}" target="_blank" rel="noopener">${f.name}</a></td>
|
|
113
|
+
<td data-label="Type">${f.mime}</td><td class="num" data-label="Size">${size(Number(f.bytes))}</td>
|
|
114
|
+
<td data-label="Loaded by" class="u-break">${includable(f.name) ? (users.get(f.name)?.join(', ') || html`<span class="muted">-</span>`) : ''}</td>
|
|
115
|
+
<td data-label="Changed">${when(f.changed)}${f.updated_by ? html` <span class="muted">${f.updated_by}</span>` : ''}</td>
|
|
116
|
+
${c.admin ? html`<td data-label="Actions" class="nowrap">${editable(f.name) ? html`<a class="btn btn-small" href="${FILES}?edit=${encodeURIComponent(f.name)}">Edit</a> ` : ''}
|
|
117
|
+
<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="Delete ${f.name}? Applications that load it leave it out." aria-label="Delete ${f.name}">${icon('trash')}</button></form></td>` : ''}
|
|
119
|
+
</tr>`)}</tbody></table></div>`
|
|
120
|
+
: html`<p class="muted">No files yet.</p>`}
|
|
121
|
+
${c.admin ? html`<div class="buttons u-mt1"><a class="btn" href="${FILES}?new=1">${icon('plus')} New file</a></div>
|
|
122
|
+
<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="Files to upload" accept="${Object.keys(STATIC_TYPES).map((x) => `.${x}`).join(',')}">
|
|
124
|
+
<button class="btn">${icon('upload')} Upload</button>
|
|
125
|
+
<small class="help">A file with the same name is replaced.</small>
|
|
126
|
+
</form>` : ''}`)}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Insert or replace a file of the workspace. */
|
|
130
|
+
const storeFile = (wsId: number, name: string, type: string, data: Buffer, by: string | null) =>
|
|
131
|
+
owner.query(
|
|
132
|
+
`insert into meta.workspace_file (workspace_id, name, mime, content, updated_by) values ($1, $2, $3, $4, $5)
|
|
133
|
+
on conflict (workspace_id, name) do update set mime = excluded.mime, content = excluded.content, updated_by = excluded.updated_by, updated_at = now()`,
|
|
134
|
+
[wsId, name, type, data, by]);
|
|
135
|
+
|
|
136
|
+
// ====================================================================== remote servers
|
|
137
|
+
|
|
138
|
+
interface ServerRow extends RemoteServer {
|
|
139
|
+
updated_at: Date;
|
|
140
|
+
updated_by: string | null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** What uses each server of the workspace: REST data sources (by application), catalogs, AI services. */
|
|
144
|
+
async function serverUsers(wsId: number) {
|
|
145
|
+
const rows = (await owner.query<{ server: string; what: string }>(
|
|
146
|
+
`select s.server, 'REST data source ' || a.alias || '.' || s.name as what
|
|
147
|
+
from meta.rest_source s join meta.app a on a.id = s.app_id where s.server is not null and meta.app_workspace(a.id) = $1
|
|
148
|
+
union all
|
|
149
|
+
select c.server, 'catalog ' || c.name from meta.rest_catalog c where c.workspace_id = $1 and c.server is not null
|
|
150
|
+
union all
|
|
151
|
+
select r.static_id, 'AI service ' || x.name from meta.ai_service x join meta.remote_server r on r.id = x.server_id where r.workspace_id = $1
|
|
152
|
+
order by 1, 2`, [wsId])).rows;
|
|
153
|
+
const out = new Map<string, string[]>();
|
|
154
|
+
for (const r of rows) out.set(r.server, [...(out.get(r.server) ?? []), r.what]);
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function serversPage(c: Ctx, query: Record<string, string | undefined>) {
|
|
159
|
+
const { s, ws } = c;
|
|
160
|
+
const servers = (await owner.query<ServerRow>('select * from meta.remote_server where workspace_id = $1 order by static_id', [ws.id])).rows;
|
|
161
|
+
const users = await serverUsers(ws.id);
|
|
162
|
+
const scope = await scopeForWorkspace(ws.id, 'check');
|
|
163
|
+
const editing = c.admin && query.edit ? servers.find((x) => String(x.id) === query.edit) ?? null : null;
|
|
164
|
+
const creating = c.admin && query.new === '1';
|
|
165
|
+
const v: Partial<ServerRow> = editing ?? {};
|
|
166
|
+
const verdict = (x: RemoteServer) => {
|
|
167
|
+
const u = serverUrl(x);
|
|
168
|
+
if (!u.url) return html`<span class="tag tag-error">${u.problem}</span>`;
|
|
169
|
+
let url: URL;
|
|
170
|
+
try {
|
|
171
|
+
url = new URL(u.url);
|
|
172
|
+
} catch {
|
|
173
|
+
return html`<span class="tag tag-error">not a valid URL</span>`;
|
|
174
|
+
}
|
|
175
|
+
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">may be called</span>`;
|
|
177
|
+
};
|
|
178
|
+
const form = editing || creating
|
|
179
|
+
? region(editing ? `Remote server ${editing.static_id}` : 'New remote server', html`<form method="post" action="${SERVERS}">${csrf(s)}
|
|
180
|
+
${editing ? html`<input type="hidden" name="id" value="${editing.id}">` : ''}
|
|
181
|
+
<div class="form-grid">
|
|
182
|
+
${input('static_id', 'Static id', v.static_id ?? '', { required: true, placeholder: 'CRM', help: 'Upper case letters, digits and _: REST data sources, catalogs and AI services name the server by it. Renaming it moves this workspace\'s sources and catalogs along.' })}
|
|
183
|
+
${input('name', 'Name', v.name ?? '', { required: true, placeholder: 'CRM system' })}
|
|
184
|
+
${input('base_url', 'Base URL (this installation)', v.base_url ?? '', { placeholder: 'https://crm.example.com/api', help: 'No trailing slash, query or fragment. Sources add their path after it.' })}
|
|
185
|
+
${input('env_var', 'Or from the environment variable', v.env_var ?? '', { placeholder: `${ENV_PREFIX}CRM`, help: `When the server's environment sets it, it wins over the base URL (one value per environment: development, test, production). Only ${ENV_PREFIX}… names.` })}
|
|
186
|
+
${input('credential', 'Default web credential', v.credential ?? '', { placeholder: 'e.g. CRM_API', help: 'Sources on this server that name no credential of their own use this one (an application\'s credential of that name, else the workspace\'s).' })}
|
|
187
|
+
<div class="field" data-wide><label class="label" for="f_description">Description</label><textarea id="f_description" name="description" rows="2">${v.description ?? ''}</textarea></div>
|
|
188
|
+
</div>
|
|
189
|
+
<div class="buttons"><a class="btn" href="${SERVERS}">Cancel</a><button class="btn btn-hot">${icon('check')} Save</button></div></form>
|
|
190
|
+
${editing ? html`<form method="post" action="${SERVERS}/${editing.id}/delete" class="danger-zone">${csrf(s)}
|
|
191
|
+
<button class="btn btn-danger" data-confirm="Delete remote server ${editing.static_id}? Sources on it fail until a server gets its static id again.">Delete server</button></form>` : ''}`, '', 'server-form')
|
|
192
|
+
: '';
|
|
193
|
+
return html`<div class="title-row"><h1>Remote servers</h1>${c.admin ? html`<a class="btn" href="${SERVERS}?new=1#server-form">${icon('plus')} New server</a>` : ''}</div>
|
|
194
|
+
<p class="muted u-mt0">Named base URLs of ${ws.name}: a REST data source, a REST source catalog or an AI service names a server and a path
|
|
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>
|
|
198
|
+
${readOnlyNote(c)}
|
|
199
|
+
${form}
|
|
200
|
+
${region('Servers', html`<div class="table-wrap"><table class="report report-reflow">
|
|
201
|
+
<thead><tr><th>Server</th><th>URL now</th><th>Network access</th><th>Default credential</th><th>Used by</th></tr></thead>
|
|
202
|
+
<tbody>${servers.length ? servers.map((x) => {
|
|
203
|
+
const u = serverUrl(x);
|
|
204
|
+
return html`<tr>
|
|
205
|
+
<td data-label="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>
|
|
206
|
+
<td data-label="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>
|
|
207
|
+
<td data-label="Network access">${verdict(x)}</td>
|
|
208
|
+
<td data-label="Default credential">${x.credential ?? html`<span class="muted">none</span>`}</td>
|
|
209
|
+
<td data-label="Used by" class="u-break">${users.get(x.static_id)?.join(', ') || html`<span class="muted">nothing yet</span>`}</td></tr>`;
|
|
210
|
+
}) : html`<tr><td colspan="5" class="empty">No remote servers yet.</td></tr>`}</tbody></table></div>`)}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ====================================================================== workspace web credentials
|
|
214
|
+
|
|
215
|
+
interface CredentialRow {
|
|
216
|
+
id: number;
|
|
217
|
+
name: string;
|
|
218
|
+
description: string | null;
|
|
219
|
+
type: string;
|
|
220
|
+
username: string | null;
|
|
221
|
+
header_name: string | null;
|
|
222
|
+
token_url: string | null;
|
|
223
|
+
scope: string | null;
|
|
224
|
+
valid_for: string[];
|
|
225
|
+
grant_type: string;
|
|
226
|
+
oauth_username: string | null;
|
|
227
|
+
has_secret: boolean;
|
|
228
|
+
has_password: boolean;
|
|
229
|
+
has_refresh: boolean;
|
|
230
|
+
updated_at: Date;
|
|
231
|
+
updated_by: string | null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Never the encrypted columns themselves: only whether they are set. */
|
|
235
|
+
const CRED_COLUMNS = `id, name, description, type, username, header_name, token_url, scope, valid_for, grant_type, oauth_username,
|
|
236
|
+
secret_enc is not null as has_secret, password_enc is not null as has_password, refresh_token_enc is not null as has_refresh, updated_at, updated_by`;
|
|
237
|
+
|
|
238
|
+
async function credentialsPage(c: Ctx, query: Record<string, string | undefined>) {
|
|
239
|
+
const { s, ws } = c;
|
|
240
|
+
const creds = (await owner.query<CredentialRow>(`select ${CRED_COLUMNS} from meta.workspace_credential where workspace_id = $1 order by name`, [ws.id])).rows;
|
|
241
|
+
// applications with a credential of the same name (theirs wins)
|
|
242
|
+
const shadowed = (await owner.query<{ name: string; apps: string }>(
|
|
243
|
+
`select c.name, string_agg(a.alias, ', ' order by a.alias) as apps from meta.web_credential c join meta.app a on a.id = c.app_id
|
|
244
|
+
where meta.app_workspace(a.id) = $1 and c.name in (select name from meta.workspace_credential where workspace_id = $1) group by 1`, [ws.id])).rows;
|
|
245
|
+
const editing = c.admin && query.edit ? creds.find((x) => String(x.id) === query.edit) ?? null : null;
|
|
246
|
+
const creating = c.admin && query.new === '1';
|
|
247
|
+
const v: Partial<CredentialRow> = editing ?? { type: 'basic', grant_type: 'client_credentials' };
|
|
248
|
+
const secret = (name: string, label: string, stored: boolean | undefined, help: string) =>
|
|
249
|
+
html`${input(name, label, '', { type: 'password', auto: 'new-password', help: `${help} ${stored ? 'One is stored (encrypted): it is never shown; type a new one to replace it.' : 'Write-only: never shown again or exported.'}` })}
|
|
250
|
+
${stored ? html`<div class="field"><label class="check"><input type="checkbox" name="remove_${name}" value="true"> Remove the stored ${label.toLowerCase()}</label></div>` : ''}`;
|
|
251
|
+
const form = editing || creating
|
|
252
|
+
? region(editing ? `Web credential ${editing.name}` : 'New web credential', html`<form method="post" action="${CREDS}">${csrf(s)}
|
|
253
|
+
${editing ? html`<input type="hidden" name="id" value="${editing.id}">` : ''}
|
|
254
|
+
${secretKeyConfigured() ? '' : html`<div class="alert alert-error" role="alert">The server has no <code>PGKILN_SECRET_KEY</code>: secrets can't be stored until it is set.</div>`}
|
|
255
|
+
<div class="form-grid">
|
|
256
|
+
${input('name', 'Name', v.name ?? '', { required: true, placeholder: 'CRM_API', help: 'The applications of the workspace use it by this name (an application\'s own credential of the same name wins).' })}
|
|
257
|
+
${select('type', 'Authentication', v.type ?? 'basic', ['basic', 'header', 'bearer', 'oauth2', 'aws_sigv4'], 'basic: user name + password · header: an HTTP header with the secret (API key) · bearer: Authorization: Bearer <secret> · oauth2: client credentials, password or refresh token grant · aws_sigv4: an S3-compatible object store.')}
|
|
258
|
+
${input('username', 'User name / client id / access key id', v.username ?? '')}
|
|
259
|
+
${select('grant_type', 'Grant type (oauth2)', v.grant_type ?? 'client_credentials', GRANT_TYPES)}
|
|
260
|
+
${input('oauth_username', 'OAuth2 user name (password grant)', v.oauth_username ?? '')}
|
|
261
|
+
${input('header_name', 'Header name (header)', v.header_name ?? '', { placeholder: 'X-API-Key' })}
|
|
262
|
+
${input('token_url', 'Token URL (oauth2)', v.token_url ?? '', { placeholder: 'https://login.example.com/oauth/token' })}
|
|
263
|
+
${input('scope', 'Scope (oauth2) / region (aws_sigv4)', v.scope ?? '')}
|
|
264
|
+
${input('valid_for', 'Valid for URLs', (v.valid_for ?? []).join(', '), { placeholder: 'https://crm.example.com/api/', help: 'Comma separated URL prefixes: the credential is only ever sent to these. Recommended.' })}
|
|
265
|
+
${secret('secret', 'Secret', v.has_secret, 'The password, header value, token, client secret or secret access key.')}
|
|
266
|
+
${secret('password', 'OAuth2 password', v.has_password, 'The password grant: the OAuth2 user\'s password.')}
|
|
267
|
+
${secret('refresh_token', 'Refresh token', v.has_refresh, 'The refresh token grant: a refresh token to start with.')}
|
|
268
|
+
<div class="field" data-wide><label class="label" for="f_description">Description</label><textarea id="f_description" name="description" rows="2">${v.description ?? ''}</textarea></div>
|
|
269
|
+
</div>
|
|
270
|
+
<div class="buttons"><a class="btn" href="${CREDS}">Cancel</a><button class="btn btn-hot">${icon('check')} Save</button></div></form>
|
|
271
|
+
${editing ? html`<form method="post" action="${CREDS}/${editing.id}/delete" class="danger-zone">${csrf(s)}
|
|
272
|
+
<button class="btn btn-danger" data-confirm="Delete web credential ${editing.name}? Applications that use it fail until one of that name exists.">Delete credential</button></form>` : ''}`, '', 'credential-form')
|
|
273
|
+
: '';
|
|
274
|
+
return html`<div class="title-row"><h1>Workspace web credentials</h1>${c.admin ? html`<a class="btn" href="${CREDS}?new=1#credential-form">${icon('plus')} New credential</a>` : ''}</div>
|
|
275
|
+
<p class="muted u-mt0">Web credentials of ${ws.name} that its applications use by name in REST data sources, the Invoke API process,
|
|
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>
|
|
278
|
+
${readOnlyNote(c)}
|
|
279
|
+
${form}
|
|
280
|
+
${region('Credentials', html`<div class="table-wrap"><table class="report report-reflow">
|
|
281
|
+
<thead><tr><th>Name</th><th>Authentication</th><th>Secret</th><th>Valid for</th><th>Overridden by</th><th>Changed (UTC)</th></tr></thead>
|
|
282
|
+
<tbody>${creds.length ? creds.map((x) => html`<tr>
|
|
283
|
+
<td data-label="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>
|
|
284
|
+
<td data-label="Authentication">${x.type}${x.type === 'oauth2' ? html` <span class="muted">(${x.grant_type})</span>` : ''}</td>
|
|
285
|
+
<td data-label="Secret">${x.has_secret || x.has_password || x.has_refresh ? 'stored (encrypted)' : html`<span class="muted">none yet</span>`}</td>
|
|
286
|
+
<td data-label="Valid for" class="u-break">${x.valid_for.length ? x.valid_for.join(', ') : html`<span class="muted">any URL</span>`}</td>
|
|
287
|
+
<td data-label="Overridden by" class="u-break">${shadowed.find((r) => r.name === x.name)?.apps ?? html`<span class="muted">-</span>`}</td>
|
|
288
|
+
<td data-label="Changed">${when(x.updated_at)}${x.updated_by ? html` <span class="muted">${x.updated_by}</span>` : ''}</td></tr>`)
|
|
289
|
+
: html`<tr><td colspan="6" class="empty">No workspace web credentials yet.</td></tr>`}</tbody></table></div>`)}`;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ====================================================================== routes
|
|
293
|
+
|
|
294
|
+
export async function workspaceResourceRoutes(app: FastifyInstance) {
|
|
295
|
+
// ---------------------------------------------------------------- files
|
|
296
|
+
app.get(FILES, async (req: Req, reply) => {
|
|
297
|
+
const c = await context(req, reply, 'Static workspace files');
|
|
298
|
+
if (!c) return;
|
|
299
|
+
return send(reply, c.s, shell(c.s, 'Static workspace files', crumbs('Static workspace files'), await filesPage(c, req.query ?? {})));
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// a file for the workspace's developers, with the headers the applications serve it with
|
|
303
|
+
app.get(`${FILES}/file/:name`, async (req: Req, reply) => {
|
|
304
|
+
const c = await context(req, reply, 'Static workspace files');
|
|
305
|
+
if (!c) return;
|
|
306
|
+
const type = staticType(String(req.params.name));
|
|
307
|
+
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;
|
|
308
|
+
if (!f) return reply.code(404).type('text/plain').send('Not found');
|
|
309
|
+
reply.type(type!.startsWith('text/') ? `${type}; charset=utf-8` : type!).header('x-content-type-options', 'nosniff').header('cache-control', 'no-store')
|
|
310
|
+
// a script or a document opened here never runs on the builder's origin
|
|
311
|
+
.header('content-security-policy', `default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox`);
|
|
312
|
+
if (/^(text\/javascript|application\/json)/.test(type!)) reply.header('content-disposition', 'inline');
|
|
313
|
+
return reply.send(f.content);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
app.post(`${FILES}/upload`, async (req: Req, reply) => {
|
|
317
|
+
let uploads: { filename: string; data: Buffer; truncated: boolean }[] = [];
|
|
318
|
+
if (req.isMultipart()) {
|
|
319
|
+
const parsed = await readMultipart(req, STATIC_MAX / 1024 / 1024);
|
|
320
|
+
req.body = parsed.body as Body;
|
|
321
|
+
uploads = parsed.lists.get('file') ?? [];
|
|
322
|
+
}
|
|
323
|
+
const c = await manager(req, reply, 'Static workspace files');
|
|
324
|
+
if (!c) return;
|
|
325
|
+
const { s } = c;
|
|
326
|
+
if (!uploads.length) {
|
|
327
|
+
flash(s, 'Choose one or more files.', 'error');
|
|
328
|
+
return back(reply, s, FILES);
|
|
329
|
+
}
|
|
330
|
+
const saved: string[] = [];
|
|
331
|
+
for (const u of uploads) {
|
|
332
|
+
const name = u.filename.split(/[\\/]/).pop()!.trim();
|
|
333
|
+
const type = staticType(name);
|
|
334
|
+
if (u.truncated) flash(s, `${name} is larger than ${size(STATIC_MAX)}.`, 'error');
|
|
335
|
+
else if (!type) flash(s, `${name}: not an allowed name or type (letters, digits, _, - and .; ${Object.keys(STATIC_TYPES).join(', ')}).`, 'error');
|
|
336
|
+
else {
|
|
337
|
+
await storeFile(c.ws.id, name, type, u.data, s.username);
|
|
338
|
+
saved.push(name);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (saved.length) {
|
|
342
|
+
await logActivity({ appId: null, username: s.username, event: 'workspace', ip: clientIp(req), detail: `workspace ${c.ws.id} files uploaded: ${saved.join(', ')}`.slice(0, 1000) });
|
|
343
|
+
flash(s, `Uploaded: ${saved.join(', ')}.`);
|
|
344
|
+
}
|
|
345
|
+
return back(reply, s, FILES);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
app.post(`${FILES}/save`, async (req: Req, reply) => {
|
|
349
|
+
const c = await manager(req, reply, 'Static workspace files');
|
|
350
|
+
if (!c) return;
|
|
351
|
+
const { s } = c;
|
|
352
|
+
const b = req.body ?? {};
|
|
353
|
+
const name = String(b.name ?? '').trim();
|
|
354
|
+
const original = b.original ? String(b.original) : null;
|
|
355
|
+
const type = staticType(name);
|
|
356
|
+
const content = Buffer.from(String(b.content ?? '').replace(/\r\n/g, '\n'), 'utf8');
|
|
357
|
+
if (!type || !editable(name)) {
|
|
358
|
+
flash(s, 'The name must end in a text type (.js, .css, .json, .txt, .csv, .md, .map) and use letters, digits, _, - and . only.', 'error');
|
|
359
|
+
return back(reply, s, original ? `${FILES}?edit=${encodeURIComponent(original)}` : `${FILES}?new=1`);
|
|
360
|
+
}
|
|
361
|
+
if (content.length > STATIC_MAX) {
|
|
362
|
+
flash(s, `The file is larger than ${size(STATIC_MAX)}.`, 'error');
|
|
363
|
+
return back(reply, s, FILES);
|
|
364
|
+
}
|
|
365
|
+
try {
|
|
366
|
+
if (original && original !== name) {
|
|
367
|
+
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
|
+
[c.ws.id, original, name, type, content, s.username]);
|
|
369
|
+
if (!r.rowCount) throw new Error('No such file.');
|
|
370
|
+
} else await storeFile(c.ws.id, name, type, content, s.username);
|
|
371
|
+
await logActivity({ appId: null, username: s.username, event: 'workspace', ip: clientIp(req), detail: `workspace ${c.ws.id} file saved: ${name}` });
|
|
372
|
+
flash(s, `${name} saved.`);
|
|
373
|
+
return back(reply, s, `${FILES}?edit=${encodeURIComponent(name)}`);
|
|
374
|
+
} catch (e) {
|
|
375
|
+
flash(s, (e as Error).message.includes('workspace_file_workspace_id_name_key') ? `There is a file ${name} already.` : (e as Error).message, 'error');
|
|
376
|
+
return back(reply, s, FILES);
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
app.post(`${FILES}/delete`, async (req: Req, reply) => {
|
|
381
|
+
const c = await manager(req, reply, 'Static workspace files');
|
|
382
|
+
if (!c) return;
|
|
383
|
+
const name = String(req.body?.name ?? '');
|
|
384
|
+
const r = await owner.query('delete from meta.workspace_file where workspace_id = $1 and name = $2', [c.ws.id, name]);
|
|
385
|
+
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.` : 'No such file.', r.rowCount ? 'ok' : 'error');
|
|
387
|
+
return back(reply, c.s, FILES);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// ---------------------------------------------------------------- remote servers
|
|
391
|
+
app.get(SERVERS, async (req: Req, reply) => {
|
|
392
|
+
const c = await context(req, reply, 'Remote servers');
|
|
393
|
+
if (!c) return;
|
|
394
|
+
return send(reply, c.s, shell(c.s, 'Remote servers', crumbs('Remote servers'), await serversPage(c, req.query ?? {})));
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
app.post(SERVERS, async (req: Req, reply) => {
|
|
398
|
+
const c = await manager(req, reply, 'Remote servers');
|
|
399
|
+
if (!c) return;
|
|
400
|
+
const { s } = c;
|
|
401
|
+
const b = req.body ?? {};
|
|
402
|
+
const text = (k: string) => String(b[k] ?? '').trim() || null;
|
|
403
|
+
const v = {
|
|
404
|
+
static_id: String(b.static_id ?? '').trim().toUpperCase(), name: String(b.name ?? '').trim(), description: text('description'),
|
|
405
|
+
base_url: text('base_url')?.replace(/\/+$/, '') ?? null, env_var: text('env_var')?.toUpperCase() ?? null, credential: text('credential')?.toUpperCase() ?? null,
|
|
406
|
+
};
|
|
407
|
+
const editId = id(b.id);
|
|
408
|
+
const retry = editId ? `${SERVERS}?edit=${editId}#server-form` : `${SERVERS}?new=1#server-form`;
|
|
409
|
+
const problems = serverProblems(v);
|
|
410
|
+
if (problems.length) {
|
|
411
|
+
flash(s, problems.join(' '), 'error');
|
|
412
|
+
return back(reply, s, retry);
|
|
413
|
+
}
|
|
414
|
+
try {
|
|
415
|
+
await owner.tx(async (db) => {
|
|
416
|
+
if (editId) {
|
|
417
|
+
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('No such remote server.');
|
|
419
|
+
await db.query(
|
|
420
|
+
`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
|
+
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]);
|
|
422
|
+
// a renamed server: this workspace's sources and catalogs follow it
|
|
423
|
+
if (old.static_id !== v.static_id) {
|
|
424
|
+
await db.query('update meta.rest_source x set server = $3 from meta.app a where a.id = x.app_id and meta.app_workspace(a.id) = $1 and x.server = $2', [c.ws.id, old.static_id, v.static_id]);
|
|
425
|
+
await db.query('update meta.rest_catalog set server = $3 where workspace_id = $1 and server = $2', [c.ws.id, old.static_id, v.static_id]);
|
|
426
|
+
}
|
|
427
|
+
} else
|
|
428
|
+
await db.query(
|
|
429
|
+
`insert into meta.remote_server (workspace_id, static_id, name, description, base_url, env_var, credential, updated_by) values ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
|
430
|
+
[c.ws.id, v.static_id, v.name, v.description, v.base_url, v.env_var, v.credential, s.username]);
|
|
431
|
+
});
|
|
432
|
+
// cached REST responses were fetched from the old URL
|
|
433
|
+
clearResponseCache();
|
|
434
|
+
await logActivity({ appId: null, username: s.username, event: 'workspace', ip: clientIp(req),
|
|
435
|
+
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, `Remote server ${v.static_id} saved.`);
|
|
437
|
+
return back(reply, s, SERVERS);
|
|
438
|
+
} catch (e) {
|
|
439
|
+
flash(s, (e as Error).message.includes('remote_server_workspace_id_static_id_key') ? `This workspace has a remote server ${v.static_id} already.` : (e as Error).message, 'error');
|
|
440
|
+
return back(reply, s, retry);
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
app.post(`${SERVERS}/:sid/delete`, async (req: Req, reply) => {
|
|
445
|
+
const c = await manager(req, reply, 'Remote servers');
|
|
446
|
+
if (!c) return;
|
|
447
|
+
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
|
+
if (r) {
|
|
449
|
+
clearResponseCache();
|
|
450
|
+
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
|
+
}
|
|
452
|
+
flash(c.s, r ? `Remote server ${r.static_id} deleted.` : 'No such remote server.', r ? 'ok' : 'error');
|
|
453
|
+
return back(reply, c.s, SERVERS);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
// ---------------------------------------------------------------- web credentials
|
|
457
|
+
app.get(CREDS, async (req: Req, reply) => {
|
|
458
|
+
const c = await context(req, reply, 'Workspace web credentials');
|
|
459
|
+
if (!c) return;
|
|
460
|
+
return send(reply, c.s, shell(c.s, 'Workspace web credentials', crumbs('Workspace web credentials'), await credentialsPage(c, req.query ?? {})));
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
app.post(CREDS, async (req: Req, reply) => {
|
|
464
|
+
const c = await manager(req, reply, 'Workspace web credentials');
|
|
465
|
+
if (!c) return;
|
|
466
|
+
const { s } = c;
|
|
467
|
+
const b = req.body ?? {};
|
|
468
|
+
const text = (k: string) => String(b[k] ?? '').trim() || null;
|
|
469
|
+
const editId = id(b.id);
|
|
470
|
+
const retry = editId ? `${CREDS}?edit=${editId}#credential-form` : `${CREDS}?new=1#credential-form`;
|
|
471
|
+
const v = {
|
|
472
|
+
name: String(b.name ?? '').trim().toUpperCase(), description: text('description'),
|
|
473
|
+
type: ['basic', 'header', 'bearer', 'oauth2', 'aws_sigv4'].includes(String(b.type)) ? String(b.type) : 'basic',
|
|
474
|
+
username: text('username'), header_name: text('header_name'), token_url: text('token_url'), scope: text('scope'),
|
|
475
|
+
valid_for: [...new Set(String(b.valid_for ?? '').split(',').map((x) => x.trim()).filter(Boolean))],
|
|
476
|
+
grant_type: GRANT_TYPES.includes(String(b.grant_type)) ? String(b.grant_type) : 'client_credentials', oauth_username: text('oauth_username'),
|
|
477
|
+
};
|
|
478
|
+
const problems = [...(/^[A-Z][A-Z0-9_]{0,59}$/.test(v.name) ? [] : ['The name is upper case letters, digits and _, starting with a letter, e.g. CRM_API.']), ...credentialProblems(v)];
|
|
479
|
+
if (problems.length) {
|
|
480
|
+
flash(s, problems.join(' '), 'error');
|
|
481
|
+
return back(reply, s, retry);
|
|
482
|
+
}
|
|
483
|
+
try {
|
|
484
|
+
// secrets: encrypted here; empty keeps the stored one, "remove" clears it
|
|
485
|
+
const enc = (k: string) => (b[k] ? encryptSecret(String(b[k])) : null);
|
|
486
|
+
const secrets = { secret_enc: enc('secret'), password_enc: enc('password'), refresh_token_enc: enc('refresh_token') };
|
|
487
|
+
const remove = { secret_enc: b.remove_secret === 'true', password_enc: b.remove_password === 'true', refresh_token_enc: b.remove_refresh_token === 'true' };
|
|
488
|
+
const cols = ['name', 'description', 'type', 'username', 'header_name', 'token_url', 'scope', 'valid_for', 'grant_type', 'oauth_username'] as const;
|
|
489
|
+
const vals = cols.map((k) => v[k]);
|
|
490
|
+
if (editId) {
|
|
491
|
+
const sets = (Object.keys(secrets) as (keyof typeof secrets)[]).map((k, i) =>
|
|
492
|
+
`${k} = case when $${14 + i * 2}::text is not null then $${14 + i * 2} when $${15 + i * 2} then null else ${k} end`);
|
|
493
|
+
const r = await owner.query(
|
|
494
|
+
`update meta.workspace_credential set ${cols.map((k, i) => `${k} = $${i + 3}`).join(', ')}, updated_by = $13, updated_at = now(),
|
|
495
|
+
${sets.join(', ')}, token_refreshed_at = case when $18::text is not null or $19 then null else token_refreshed_at end
|
|
496
|
+
where id = $1 and workspace_id = $2`,
|
|
497
|
+
[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('No such web credential.');
|
|
499
|
+
clearTokens(`w${editId}`);
|
|
500
|
+
} else
|
|
501
|
+
await owner.query(
|
|
502
|
+
`insert into meta.workspace_credential (workspace_id, ${cols.join(', ')}, updated_by, secret_enc, password_enc, refresh_token_enc)
|
|
503
|
+
values ($1, ${cols.map((_, i) => `$${i + 2}`).join(', ')}, $12, $13, $14, $15)`,
|
|
504
|
+
[c.ws.id, ...vals, s.username, secrets.secret_enc, secrets.password_enc, secrets.refresh_token_enc]);
|
|
505
|
+
clearResponseCache();
|
|
506
|
+
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, `Web credential ${v.name} saved.`);
|
|
508
|
+
return back(reply, s, CREDS);
|
|
509
|
+
} catch (e) {
|
|
510
|
+
flash(s, (e as Error).message.includes('workspace_credential_workspace_id_name_key') ? `This workspace has a web credential ${v.name} already.` : (e as Error).message, 'error');
|
|
511
|
+
return back(reply, s, retry);
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
app.post(`${CREDS}/:cid/delete`, async (req: Req, reply) => {
|
|
516
|
+
const c = await manager(req, reply, 'Workspace web credentials');
|
|
517
|
+
if (!c) return;
|
|
518
|
+
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
|
+
if (r) {
|
|
520
|
+
clearTokens(`w${r.id}`);
|
|
521
|
+
clearResponseCache();
|
|
522
|
+
await logActivity({ appId: null, username: c.s.username, event: 'workspace', ip: clientIp(req), detail: `workspace ${c.ws.id} web credential ${r.name} deleted` });
|
|
523
|
+
}
|
|
524
|
+
flash(c.s, r ? `Web credential ${r.name} deleted.` : 'No such web credential.', r ? 'ok' : 'error');
|
|
525
|
+
return back(reply, c.s, CREDS);
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
|
package/src/i18n/ar.ts
CHANGED
|
@@ -424,6 +424,19 @@ export const ar: Record<MessageKey, string> = {
|
|
|
424
424
|
'lov.loading': "جارٍ تحميل المزيد…",
|
|
425
425
|
'da.wait': "يرجى الانتظار…",
|
|
426
426
|
'lov.no_rows': "لم يُعثر على شيء.",
|
|
427
|
+
'shuttle.available': "المتاحة",
|
|
428
|
+
'shuttle.chosen': "المحددة",
|
|
429
|
+
'shuttle.move': "نقل",
|
|
430
|
+
'shuttle.move_all': "نقل الكل",
|
|
431
|
+
'shuttle.remove': "إزالة",
|
|
432
|
+
'shuttle.remove_all': "إزالة الكل",
|
|
433
|
+
'shuttle.up': "نقل لأعلى",
|
|
434
|
+
'shuttle.down': "نقل لأسفل",
|
|
435
|
+
'shuttle.top': "نقل إلى الأعلى",
|
|
436
|
+
'shuttle.bottom': "نقل إلى الأسفل",
|
|
437
|
+
'shuttle.filter': "تصفية",
|
|
438
|
+
'region.breadcrumb': "مسار التنقل",
|
|
439
|
+
'region.no_help': "لا تتوفر تعليمات لهذه الصفحة.",
|
|
427
440
|
'search.did_you_mean': "هل تقصد:",
|
|
428
441
|
'error.lov_value': "{label}: اختر قيمة من القائمة.",
|
|
429
442
|
'item.yes': "نعم",
|
package/src/i18n/cs.ts
CHANGED
|
@@ -424,6 +424,19 @@ export const cs: Record<MessageKey, string> = {
|
|
|
424
424
|
'lov.loading': "Načítání dalších…",
|
|
425
425
|
'da.wait': "Čekejte prosím…",
|
|
426
426
|
'lov.no_rows': "Nic nenalezeno.",
|
|
427
|
+
'shuttle.available': "Dostupné",
|
|
428
|
+
'shuttle.chosen': "Vybrané",
|
|
429
|
+
'shuttle.move': "Přesunout",
|
|
430
|
+
'shuttle.move_all': "Přesunout vše",
|
|
431
|
+
'shuttle.remove': "Odebrat",
|
|
432
|
+
'shuttle.remove_all': "Odebrat vše",
|
|
433
|
+
'shuttle.up': "Posunout nahoru",
|
|
434
|
+
'shuttle.down': "Posunout dolů",
|
|
435
|
+
'shuttle.top': "Přesunout na začátek",
|
|
436
|
+
'shuttle.bottom': "Přesunout na konec",
|
|
437
|
+
'shuttle.filter': "Filtrovat",
|
|
438
|
+
'region.breadcrumb': "Drobečková navigace",
|
|
439
|
+
'region.no_help': "Pro tuto stránku není k dispozici nápověda.",
|
|
427
440
|
'search.did_you_mean': "Měli jste na mysli:",
|
|
428
441
|
'error.lov_value': "{label}: vyberte hodnotu ze seznamu.",
|
|
429
442
|
'item.yes': "Ano",
|
package/src/i18n/da.ts
CHANGED
|
@@ -424,6 +424,19 @@ export const da: Record<MessageKey, string> = {
|
|
|
424
424
|
'lov.loading': "Indlæser flere…",
|
|
425
425
|
'da.wait': "Vent venligst…",
|
|
426
426
|
'lov.no_rows': "Intet fundet.",
|
|
427
|
+
'shuttle.available': "Tilgængelige",
|
|
428
|
+
'shuttle.chosen': "Valgte",
|
|
429
|
+
'shuttle.move': "Flyt",
|
|
430
|
+
'shuttle.move_all': "Flyt alle",
|
|
431
|
+
'shuttle.remove': "Fjern",
|
|
432
|
+
'shuttle.remove_all': "Fjern alle",
|
|
433
|
+
'shuttle.up': "Flyt op",
|
|
434
|
+
'shuttle.down': "Flyt ned",
|
|
435
|
+
'shuttle.top': "Flyt øverst",
|
|
436
|
+
'shuttle.bottom': "Flyt nederst",
|
|
437
|
+
'shuttle.filter': "Filtrer",
|
|
438
|
+
'region.breadcrumb': "Brødkrummesti",
|
|
439
|
+
'region.no_help': "Der er ingen hjælp til denne side.",
|
|
427
440
|
'search.did_you_mean': "Mente du:",
|
|
428
441
|
'error.lov_value': "{label}: vælg en værdi på listen.",
|
|
429
442
|
'item.yes': "Ja",
|
package/src/i18n/de.ts
CHANGED
|
@@ -421,6 +421,19 @@ export const de: Record<MessageKey, string> = {
|
|
|
421
421
|
'lov.loading': 'Weitere werden geladen…',
|
|
422
422
|
'da.wait': 'Bitte warten…',
|
|
423
423
|
'lov.no_rows': 'Nichts gefunden.',
|
|
424
|
+
'shuttle.available': 'Verfügbar',
|
|
425
|
+
'shuttle.chosen': 'Ausgewählt',
|
|
426
|
+
'shuttle.move': 'Verschieben',
|
|
427
|
+
'shuttle.move_all': 'Alle verschieben',
|
|
428
|
+
'shuttle.remove': 'Entfernen',
|
|
429
|
+
'shuttle.remove_all': 'Alle entfernen',
|
|
430
|
+
'shuttle.up': 'Nach oben',
|
|
431
|
+
'shuttle.down': 'Nach unten',
|
|
432
|
+
'shuttle.top': 'An den Anfang',
|
|
433
|
+
'shuttle.bottom': 'An das Ende',
|
|
434
|
+
'shuttle.filter': 'Filtern',
|
|
435
|
+
'region.breadcrumb': 'Navigationspfad',
|
|
436
|
+
'region.no_help': 'Für diese Seite ist keine Hilfe verfügbar.',
|
|
424
437
|
'search.did_you_mean': "Meinten Sie:",
|
|
425
438
|
'error.lov_value': '{label}: Wählen Sie einen Wert aus der Liste.',
|
|
426
439
|
'item.yes': 'Ja',
|