pgkiln 0.57.0 → 0.58.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 +1 -1
- package/docs/guide/03-builder.md +6 -3
- package/docs/guide/12-development.md +2 -1
- package/package.json +1 -1
- package/src/builder/i18n/de.ts +340 -0
- package/src/builder/i18n/en.ts +340 -0
- package/src/builder/i18n/es.ts +340 -0
- package/src/builder/i18n/fr.ts +340 -0
- package/src/builder/i18n/nl.ts +340 -0
- package/src/builder/i18n/pt.ts +340 -0
- package/src/builder/routes.ts +88 -87
- package/src/builder/shared.ts +86 -77
package/src/builder/shared.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { dataLoadDefExtras } from './dataload.ts';
|
|
|
16
16
|
import { appHeader, back, BASE, designerUrl, csrf, developer, flash, input, region, select, send, shell, type Req } from './ui.ts';
|
|
17
17
|
import { endSessions, grantAccess, roleHints, roleHintsHtml, splitRoles } from './users.ts';
|
|
18
18
|
import { appOr404, componentForm, lookups, saveComponent } from './forms.ts';
|
|
19
|
+
import { tr, type BuilderTranslate } from './i18n.ts';
|
|
19
20
|
import { catalogPicker } from './rest-catalogs.ts';
|
|
20
21
|
import { COMPONENT_SETTINGS, componentSetting, componentSettingsFrom, type ComponentSettings } from '../runtime/component-settings.ts';
|
|
21
22
|
|
|
@@ -23,14 +24,14 @@ import { COMPONENT_SETTINGS, componentSetting, componentSettingsFrom, type Compo
|
|
|
23
24
|
// items and processes, report layouts, and access control.
|
|
24
25
|
|
|
25
26
|
/** A list's entries, with links to edit them and to add one. */
|
|
26
|
-
function listExtras(appId: number, list: any, entries: any[]): Raw {
|
|
27
|
+
function listExtras(appId: number, list: any, entries: any[], tx: BuilderTranslate): Raw {
|
|
27
28
|
const own = entries.filter((e) => e.list_name === list.name);
|
|
28
|
-
return html`<h3
|
|
29
|
-
${list.type === 'sql' ? html`<p class="muted"
|
|
29
|
+
return html`<h3>${tx('sc.entries')}</h3>
|
|
30
|
+
${list.type === 'sql' ? html`<p class="muted">${tx('sc.list_sql')}</p>` : ''}
|
|
30
31
|
${own.length
|
|
31
32
|
? html`<ul class="tree">${own.map((e) => html`<li><a href="?c=list_entry-${e.id}">${icon(e.icon ?? 'chevron')}<span>${e.parent_id ? '↳ ' : ''}${e.label}</span>${e.target_page ? html`<span class="kind">p${e.target_page}</span>` : e.target_url ? html`<span class="kind">url</span>` : ''}</a></li>`)}</ul>`
|
|
32
|
-
: html`<p class="muted"
|
|
33
|
-
<p><a class="btn" href="?new=list_entry&list=${encodeURIComponent(list.name)}"
|
|
33
|
+
: html`<p class="muted">${tx('sc.no_entries')}</p>`}
|
|
34
|
+
<p><a class="btn" href="?new=list_entry&list=${encodeURIComponent(list.name)}">${tx('sc.add_entry')}</a></p>`;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
/** (sprint 74) A row's second column in a component list. */
|
|
@@ -53,6 +54,10 @@ function detailOf(kind: string, r: any): string {
|
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
interface ScItem { label: string; href: string; kind?: string }
|
|
57
|
+
/** (sprint 124) A Shared Components link's text in the builder language: key sc.item.<its English label as a slug>. */
|
|
58
|
+
const itemLabel = (tx: BuilderTranslate, label: string) => tx.dyn(`sc.item.${label.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '')}`, label);
|
|
59
|
+
/** (sprint 124) A component type's name, singular or plural, in the builder language (comp.<kind>[.plural]). */
|
|
60
|
+
const kindName = (tx: BuilderTranslate, kind: string, plural = false) => tx.dyn(`comp.${kind}${plural ? '.plural' : ''}`, plural ? COMPONENTS[kind].plural : COMPONENTS[kind].label);
|
|
56
61
|
/**
|
|
57
62
|
* (sprint 74) The groups of the Shared Components page, like APEX's. Component types pgkiln keeps in Settings
|
|
58
63
|
* open the right Settings tab; a few APEX types have no counterpart (docs/apex-feature-parity.md says why).
|
|
@@ -80,6 +85,7 @@ export async function sharedRoutes(app: FastifyInstance) {
|
|
|
80
85
|
app.get(`${BASE}/apps/:id/breadcrumbs`, async (req: Req, reply) => {
|
|
81
86
|
const s = await developer(req, reply);
|
|
82
87
|
if (!s) return;
|
|
88
|
+
const tx = tr(s);
|
|
83
89
|
const a = await appOr404(req.params.id);
|
|
84
90
|
if (!a) return reply.code(404).send('Not found');
|
|
85
91
|
const pages = (await owner.query<{ id: number; page_no: number; name: string; parent_page: number | null }>('select id, page_no, name, parent_page from meta.page where app_id = $1 order by page_no', [a.id])).rows;
|
|
@@ -93,26 +99,27 @@ export async function sharedRoutes(app: FastifyInstance) {
|
|
|
93
99
|
})}</ul>`;
|
|
94
100
|
};
|
|
95
101
|
const main = html`${appHeader(a, 'shared', s)}
|
|
96
|
-
${region('
|
|
97
|
-
return send(reply, s, shell(s, '
|
|
102
|
+
${region(tx('sc.item.breadcrumbs'), html`<p class="muted u-mt0">${tx('sc.breadcrumbs_help')}</p>${tree(null, new Set())}`)}`;
|
|
103
|
+
return send(reply, s, shell(s, tx('sc.item.breadcrumbs'), [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Shared Components', `${BASE}/apps/${a.id}/shared`], [tx('sc.item.breadcrumbs')]], main));
|
|
98
104
|
});
|
|
99
105
|
|
|
100
106
|
// (105) Component Settings (APEX): the application's defaults of its built-in component types
|
|
101
107
|
app.get(`${BASE}/apps/:id/component-settings`, async (req: Req, reply) => {
|
|
102
108
|
const s = await developer(req, reply);
|
|
103
109
|
if (!s) return;
|
|
110
|
+
const tx = tr(s);
|
|
104
111
|
const a = await appOr404(req.params.id);
|
|
105
112
|
if (!a) return reply.code(404).send('Not found');
|
|
106
113
|
const cur = (a.component_settings ?? {}) as ComponentSettings;
|
|
107
114
|
const groups = [...new Set(COMPONENT_SETTINGS.map((x) => x.group))];
|
|
108
115
|
const main = html`${appHeader(a, 'shared', s)}
|
|
109
|
-
${region('
|
|
116
|
+
${region(tx('sc.item.component_settings'), html`<p class="muted u-mt0">${tx('sc.cs_help')}</p>
|
|
110
117
|
<form method="post" action="${BASE}/apps/${a.id}/component-settings">${csrf(s)}
|
|
111
118
|
${groups.map((g) => html`<fieldset class="u-mb1"><legend>${g}</legend>${COMPONENT_SETTINGS.filter((x) => x.group === g).map((x) =>
|
|
112
|
-
input(x.key, x.label, cur[x.key] ?? '', { type: 'number', placeholder: String(x.def), help:
|
|
113
|
-
<div class="buttons"><button class="btn btn-hot"
|
|
119
|
+
input(x.key, x.label, cur[x.key] ?? '', { type: 'number', placeholder: String(x.def), help: tx('sc.cs_now', { help: x.help, min: x.min, max: x.max.toLocaleString(tx.lang), now: componentSetting(cur, x.key).toLocaleString(tx.lang) }) }))}</fieldset>`)}
|
|
120
|
+
<div class="buttons"><button class="btn btn-hot">${tx('sc.save')}</button></div>
|
|
114
121
|
</form>`)}`;
|
|
115
|
-
return send(reply, s, shell(s, '
|
|
122
|
+
return send(reply, s, shell(s, tx('sc.item.component_settings'), [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Shared Components', `${BASE}/apps/${a.id}/shared`], [tx('sc.item.component_settings')]], main));
|
|
116
123
|
});
|
|
117
124
|
|
|
118
125
|
app.post(`${BASE}/apps/:id/component-settings`, async (req: Req, reply) => {
|
|
@@ -124,7 +131,7 @@ export async function sharedRoutes(app: FastifyInstance) {
|
|
|
124
131
|
if (problems.length) flash(s, problems.join(' '), 'error');
|
|
125
132
|
else {
|
|
126
133
|
await owner.query('update meta.app set component_settings = $2 where id = $1', [a.id, JSON.stringify(settings)]);
|
|
127
|
-
flash(s, '
|
|
134
|
+
flash(s, tr(s)('sc.cs_saved'));
|
|
128
135
|
}
|
|
129
136
|
return back(reply, s, `${BASE}/apps/${a.id}/component-settings`);
|
|
130
137
|
});
|
|
@@ -140,6 +147,7 @@ export async function sharedRoutes(app: FastifyInstance) {
|
|
|
140
147
|
app.get(`${BASE}/apps/:id/shared`, async (req: Req, reply) => {
|
|
141
148
|
const s = await developer(req, reply);
|
|
142
149
|
if (!s) return;
|
|
150
|
+
const tx = tr(s);
|
|
143
151
|
const a = await appOr404(req.params.id);
|
|
144
152
|
if (!a) return reply.code(404).send('Not found');
|
|
145
153
|
const lk = await lookups(a.id);
|
|
@@ -164,10 +172,10 @@ export async function sharedRoutes(app: FastifyInstance) {
|
|
|
164
172
|
const counts: Record<string, number> = Object.fromEntries(SHARED.map((k) => [k, rows[k].length]));
|
|
165
173
|
counts.access = users.length;
|
|
166
174
|
const main = html`${appHeader(a, 'shared', s)}
|
|
167
|
-
<div class="sc-head"><p class="muted u-m0"
|
|
175
|
+
<div class="sc-head"><p class="muted u-m0">${tx('sc.intro', { app: a.name })}</p><a class="btn" href="?view=tree">${icon('list')} ${tx('sc.tree_view')}</a></div>
|
|
168
176
|
<div class="sc-grid">${sharedGroups(a.id).map((g) => html`<section class="region region-standard sc-group" aria-labelledby="sc-${g.key}">
|
|
169
|
-
<header class="region-header"><h2 id="sc-${g.key}">${icon(g.icon)} ${g.title}</h2></header>
|
|
170
|
-
<ul class="sc-links">${g.items.map((it) => html`<li><a href="${it.href}">${it.label}</a>${it.kind && counts[it.kind] !== undefined ? html`<span class="sc-count" aria-label="${counts[it.kind]} ${it.label}">${counts[it.kind]}</span>` : ''}</li>`)}</ul>
|
|
177
|
+
<header class="region-header"><h2 id="sc-${g.key}">${icon(g.icon)} ${tx.dyn(`sc.group.${g.key}`, g.title)}</h2></header>
|
|
178
|
+
<ul class="sc-links">${g.items.map((it) => html`<li><a href="${it.href}">${itemLabel(tx, it.label)}</a>${it.kind && counts[it.kind] !== undefined ? html`<span class="sc-count" aria-label="${counts[it.kind]} ${itemLabel(tx, it.label)}">${counts[it.kind]}</span>` : ''}</li>`)}</ul>
|
|
171
179
|
</section>`)}</div>`;
|
|
172
180
|
return send(reply, s, shell(s, 'Shared Components', [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Shared Components']], main));
|
|
173
181
|
}
|
|
@@ -181,26 +189,27 @@ export async function sharedRoutes(app: FastifyInstance) {
|
|
|
181
189
|
.filter((x) => !q || x.name.toLowerCase().includes(q.toLowerCase()))
|
|
182
190
|
.sort((x, y) => (desc ? -1 : 1) * x.name.localeCompare(y.name, 'en', { numeric: true }));
|
|
183
191
|
const main = html`${appHeader(a, 'shared', s)}
|
|
184
|
-
<div class="sc-list-head"><h1>${
|
|
192
|
+
<div class="sc-list-head"><h1>${kindName(tx, type, true)}</h1><a class="btn btn-hot" href="?new=${type}">${icon('plus')} ${tx('sc.create')}</a></div>
|
|
185
193
|
<form method="get" class="search u-mwnone u-mb1" role="search"><input type="hidden" name="type" value="${type}">
|
|
186
|
-
<div class="field"><label class="label" for="f_q"
|
|
187
|
-
<button class="btn u-selfend">${icon('search')}
|
|
194
|
+
<div class="field"><label class="label" for="f_q">${tx('sc.search')}</label><input id="f_q" name="q" type="search" value="${q}" placeholder="${tx('sc.name')}" data-filter-rows="sc-rows"></div>
|
|
195
|
+
<button class="btn u-selfend">${icon('search')} ${tx('common.go')}</button></form>
|
|
188
196
|
<div class="table-wrap"><table class="report"><thead><tr>
|
|
189
|
-
<th scope="col" aria-sort="${desc ? 'descending' : 'ascending'}"><a href="?type=${type}${q ? `&q=${encodeURIComponent(q)}` : ''}${desc ? '' : '&sort=desc'}"
|
|
190
|
-
<th scope="col"
|
|
197
|
+
<th scope="col" aria-sort="${desc ? 'descending' : 'ascending'}"><a href="?type=${type}${q ? `&q=${encodeURIComponent(q)}` : ''}${desc ? '' : '&sort=desc'}">${tx('sc.name')} ${desc ? '▼' : '▲'}</a></th>
|
|
198
|
+
<th scope="col">${tx('sc.details')}</th></tr></thead>
|
|
191
199
|
<tbody id="sc-rows">${shown.length
|
|
192
|
-
? shown.map(({ r, name }) => html`<tr><td data-label="
|
|
193
|
-
<td data-label="
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
200
|
+
? shown.map(({ r, name }) => html`<tr><td data-label="${tx('sc.name')}"><a href="?c=${type}-${r.id}">${icon(type === 'nav_entry' ? (r.icon ?? 'chevron') : spec.icon)} ${r.parent_id ? '↳ ' : ''}${name}</a></td>
|
|
201
|
+
<td data-label="${tx('sc.details')}">${detailOf(type, r)}</td></tr>`)
|
|
202
|
+
// English lower-cases the type in the sentence; other languages keep the name as it is (German nouns)
|
|
203
|
+
: html`<tr><td colspan="2" class="empty">${q ? tx('sc.nothing_matches') : tx('sc.none_yet', { what: tx.lang === 'en' ? spec.plural.toLowerCase() : kindName(tx, type, true) })}</td></tr>`}</tbody></table></div>
|
|
204
|
+
<p class="muted">${tx('sc.count', { n: shown.length, total: rows[type].length })}</p>`;
|
|
205
|
+
return send(reply, s, shell(s, kindName(tx, type, true), [...crumbsBase, [kindName(tx, type, true)]], main));
|
|
197
206
|
}
|
|
198
207
|
let editor: Raw;
|
|
199
208
|
if (newKind && SHARED.includes(newKind)) {
|
|
200
209
|
const spec = COMPONENTS[newKind];
|
|
201
210
|
// "Add entry" from a list fills in the list, "Add action" from an automation the automation (and the next sequence)
|
|
202
211
|
const prefill = newKind === 'list_entry' && req.query.list ? { list_name: String(req.query.list).toUpperCase() } : newKind === 'automation_action' && req.query.automation ? actionPrefill(String(req.query.automation), rows.automation_action) : {};
|
|
203
|
-
editor = region(
|
|
212
|
+
editor = region(tx('sc.new', { what: tx.lang === 'en' ? spec.label.toLowerCase() : kindName(tx, newKind) }), componentForm(spec, newKind, { seq: 10, ...spec.defaults, ...prefill }, lk, `${BASE}/apps/${a.id}/shared/${newKind}`, s, tx('sc.create')));
|
|
204
213
|
if (newKind === 'template_component') editor = html`${editor}${templateImport(a.id, s)}`;
|
|
205
214
|
if (newKind === 'rest_source') editor = html`${await catalogPicker(a.id, s)}${editor}`;
|
|
206
215
|
} else if (selKind && SHARED.includes(selKind)) {
|
|
@@ -209,86 +218,86 @@ export async function sharedRoutes(app: FastifyInstance) {
|
|
|
209
218
|
// a workflow definition's form edits its development version (src/builder/workflows.ts)
|
|
210
219
|
const [formSpec, formRow] = row && selKind === 'workflow_definition' ? workflowForm(spec, row) : [spec, row];
|
|
211
220
|
editor = row
|
|
212
|
-
? region(`${
|
|
213
|
-
${selKind === 'report_layout' ? layoutExtras(a.id, row, s) : selKind === 'automation' ? await automationExtras(a.id, row, s, rows.automation_action) : selKind === 'automation_action' ? actionExtras(row, rows.automation) : selKind === 'document_template' ? documentExtras(a.id, row) : selKind === 'workflow_definition' ? await workflowExtras(a.id, row, s, req.query) : selKind === 'rest_module' ? restExtras(a, row) : selKind === 'template_component' ? templateExtras(a.id, row, req.query) : selKind === 'web_credential' ? credentialExtras(a.id, row, s) : selKind === 'rest_source' ? await restSourceExtras(a.id, row, s) : selKind === 'data_load_def' ? dataLoadDefExtras(row) : selKind === 'list' ? listExtras(a.id, row, rows.list_entry) : selKind === 'supporting_script' ? html`<p class="u-mt1"><a class="btn" href="${BASE}/apps/${a.id}/supporting-objects">${icon('play')}
|
|
221
|
+
? region(`${kindName(tx, selKind)}: ${spec.summary(row)}`, html`${componentForm(formSpec, selKind, formRow, lk, `${BASE}/apps/${a.id}/shared/${selKind}/${row.id}`, s, tx('sc.save'))}
|
|
222
|
+
${selKind === 'report_layout' ? layoutExtras(a.id, row, s) : selKind === 'automation' ? await automationExtras(a.id, row, s, rows.automation_action) : selKind === 'automation_action' ? actionExtras(row, rows.automation) : selKind === 'document_template' ? documentExtras(a.id, row) : selKind === 'workflow_definition' ? await workflowExtras(a.id, row, s, req.query) : selKind === 'rest_module' ? restExtras(a, row) : selKind === 'template_component' ? templateExtras(a.id, row, req.query) : selKind === 'web_credential' ? credentialExtras(a.id, row, s) : selKind === 'rest_source' ? await restSourceExtras(a.id, row, s) : selKind === 'data_load_def' ? dataLoadDefExtras(row) : selKind === 'list' ? listExtras(a.id, row, rows.list_entry, tx) : selKind === 'supporting_script' ? html`<p class="u-mt1"><a class="btn" href="${BASE}/apps/${a.id}/supporting-objects">${icon('play')} ${tx('sc.run_supporting')}</a></p>` : ''}
|
|
214
223
|
${await usedInPanel(a.id, selKind, row)}
|
|
215
224
|
${await subscriptionNote(a.id, selKind, row, s)}
|
|
216
|
-
<form method="post" action="${BASE}/apps/${a.id}/shared/${selKind}/${row.id}/delete" class="danger-zone">${csrf(s)}<button class="btn btn-danger" data-confirm="
|
|
217
|
-
: html`<p
|
|
225
|
+
<form method="post" action="${BASE}/apps/${a.id}/shared/${selKind}/${row.id}/delete" class="danger-zone">${csrf(s)}<button class="btn btn-danger" data-confirm="${tx('sc.delete_confirm', { what: tx.lang === 'en' ? spec.label.toLowerCase() : kindName(tx, selKind) })}">${tx('sc.delete')}</button></form>`)
|
|
226
|
+
: html`<p>${tx('sc.not_found')}</p>`;
|
|
218
227
|
} else {
|
|
219
228
|
const accounts = (await owner.query('select username from meta.account where active order by lower(username) limit 2000')).rows;
|
|
220
229
|
const appHints = (await roleHints([a.id])).get(a.id) ?? [];
|
|
221
230
|
const groupRoles = (await owner.query('select group_name, role from meta.app_group_role where app_id = $1 order by 1, 2', [a.id])).rows;
|
|
222
|
-
const groupMap = html`<h3
|
|
223
|
-
<p class="muted u-mt0"
|
|
231
|
+
const groupMap = html`<h3>${tx('sc.groups_title')}</h3>
|
|
232
|
+
<p class="muted u-mt0">${tx('sc.groups_help')}</p>
|
|
224
233
|
${groupRoles.length
|
|
225
234
|
? html`<div class="chips">${groupRoles.map((g) => html`<span class="chip">${g.group_name} → <b>${g.role}</b>
|
|
226
|
-
<form method="post" action="${BASE}/apps/${a.id}/groups/delete" class="u-inline">${csrf(s)}<input type="hidden" name="group_name" value="${g.group_name}"><input type="hidden" name="role" value="${g.role}"><button class="link-button" aria-label="
|
|
227
|
-
: html`<p class="muted"
|
|
235
|
+
<form method="post" action="${BASE}/apps/${a.id}/groups/delete" class="u-inline">${csrf(s)}<input type="hidden" name="group_name" value="${g.group_name}"><input type="hidden" name="role" value="${g.role}"><button class="link-button" aria-label="${tx('sc.remove_mapping', { group: g.group_name, role: g.role })}">×</button></form></span>`)}</div>`
|
|
236
|
+
: html`<p class="muted">${tx('sc.no_groups')}</p>`}
|
|
228
237
|
<form method="post" action="${BASE}/apps/${a.id}/groups">${csrf(s)}
|
|
229
|
-
<div class="form-grid">${input('group_name', '
|
|
230
|
-
<div class="buttons"><button class="btn"
|
|
238
|
+
<div class="form-grid">${input('group_name', tx('sc.group_label'), '', { required: true, placeholder: tx('sc.group_ph') })}${input('role', tx('sc.role_label'), '', { required: true, placeholder: tx('sc.role_ph') })}</div>
|
|
239
|
+
<div class="buttons"><button class="btn">${tx('sc.add_mapping')}</button></div>
|
|
231
240
|
</form>`;
|
|
232
|
-
editor = region('
|
|
241
|
+
editor = region(tx('sc.access'), html`
|
|
233
242
|
<form method="post" action="${BASE}/apps/${a.id}/access" class="search u-mwnone u-mb1">${csrf(s)}
|
|
234
|
-
${select('access_control', '
|
|
235
|
-
['assigned', '
|
|
236
|
-
['any_user', '
|
|
243
|
+
${select('access_control', tx('sc.who'), a.access_control, [
|
|
244
|
+
['assigned', tx('sc.who.assigned')],
|
|
245
|
+
['any_user', tx('sc.who.any')],
|
|
237
246
|
])}
|
|
238
|
-
<button class="btn u-selfend"
|
|
247
|
+
<button class="btn u-selfend">${tx('sc.save')}</button>
|
|
239
248
|
</form>
|
|
240
|
-
<p class="muted"
|
|
241
|
-
<div class="table-wrap"><table class="report report-reflow"><thead><tr><th
|
|
249
|
+
<p class="muted">${tx.rich('sc.access_help', { directory: html`<a href="${BASE}/users">${tx('sc.directory')}</a>`, app: html`<b>${a.name}</b>`, fn: html`<code>meta.has_role()</code>` })}</p>
|
|
250
|
+
<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('sc.account')}</th><th>${tx('sc.roles_in_app')}</th><th>${tx('sc.last_signin')}</th><th></th></tr></thead><tbody>
|
|
242
251
|
${users.length
|
|
243
252
|
? users.map((u) => html`<tr>
|
|
244
|
-
<td data-label="
|
|
245
|
-
<td data-label="
|
|
246
|
-
<input name="roles" value="${u.roles.join(', ')}" aria-label="
|
|
253
|
+
<td data-label="${tx('sc.account')}"><a href="${BASE}/users/${u.id}">${u.username}</a>${u.display_name ? html` <span class="muted">${u.display_name}</span>` : ''}${u.active ? '' : html` <b>${tx('sc.inactive')}</b>`}</td>
|
|
254
|
+
<td data-label="${tx('sc.roles')}"><form method="post" action="${BASE}/apps/${a.id}/access/${u.id}" class="search roles-form u-m0 u-mwnone">${csrf(s)}
|
|
255
|
+
<input name="roles" value="${u.roles.join(', ')}" aria-label="${tx('sc.roles_of', { user: u.username })}" placeholder="${tx('sc.no_roles')}"><button class="btn">${tx('sc.save')}</button>
|
|
247
256
|
${roleHintsHtml(appHints, 'Add')}</form></td>
|
|
248
|
-
<td data-label="
|
|
249
|
-
<td data-label=""><form method="post" action="${BASE}/apps/${a.id}/access/${u.id}/revoke">${csrf(s)}<button class="link-button" data-confirm="
|
|
257
|
+
<td data-label="${tx('sc.last_signin')}">${u.last_login_at ? String(u.last_login_at).slice(0, 16) : '—'}</td>
|
|
258
|
+
<td data-label=""><form method="post" action="${BASE}/apps/${a.id}/access/${u.id}/revoke">${csrf(s)}<button class="link-button" data-confirm="${tx('sc.revoke_confirm', { user: u.username, app: a.name })}">${tx('sc.revoke')}</button></form></td>
|
|
250
259
|
</tr>`)
|
|
251
|
-
: html`<tr><td colspan="4" class="empty"
|
|
260
|
+
: html`<tr><td colspan="4" class="empty">${tx('sc.no_access')}</td></tr>`}
|
|
252
261
|
</tbody></table></div>
|
|
253
262
|
${groupMap}
|
|
254
|
-
<h3
|
|
263
|
+
<h3>${tx('sc.grant')}</h3>
|
|
255
264
|
<form method="post" action="${BASE}/apps/${a.id}/access">${csrf(s)}
|
|
256
265
|
<div class="form-grid">
|
|
257
|
-
<div class="field"><label class="label" for="f_grant_user"
|
|
258
|
-
<input id="f_grant_user" name="username" list="accounts-list" required autocomplete="off" placeholder="
|
|
266
|
+
<div class="field"><label class="label" for="f_grant_user">${tx('sc.account')}</label>
|
|
267
|
+
<input id="f_grant_user" name="username" list="accounts-list" required autocomplete="off" placeholder="${tx('sc.username_ph')}">
|
|
259
268
|
<datalist id="accounts-list">${accounts.map((x) => html`<option value="${x.username}"></option>`)}</datalist>
|
|
260
|
-
<small class="help"
|
|
261
|
-
<div class="field"><label class="label" for="f_roles"
|
|
262
|
-
<input id="f_roles" name="roles" placeholder="
|
|
269
|
+
<small class="help">${tx('sc.existing')} <a href="${BASE}/users">${tx('sc.create_in_users')}</a></small></div>
|
|
270
|
+
<div class="field"><label class="label" for="f_roles">${tx('sc.roles')}</label>
|
|
271
|
+
<input id="f_roles" name="roles" placeholder="${tx('sc.roles_ph')}">
|
|
263
272
|
${roleHintsHtml(appHints)}</div>
|
|
264
273
|
</div>
|
|
265
|
-
<div class="buttons"><button class="btn btn-hot"
|
|
274
|
+
<div class="buttons"><button class="btn btn-hot">${tx('sc.grant')}</button></div>
|
|
266
275
|
</form>`);
|
|
267
276
|
}
|
|
268
277
|
|
|
269
278
|
const tree = html`<ul class="tree">
|
|
270
|
-
<li class="group"
|
|
271
|
-
<li><a href="${BASE}/apps/${a.id}/shared"${!selKind && !newKind ? raw(' aria-current="page"') : ''}>${icon('users')}<span
|
|
272
|
-
<li class="group"
|
|
273
|
-
<li><a href="${BASE}/apps/${a.id}/globalization">${icon('file')}<span
|
|
274
|
-
<li class="group"
|
|
275
|
-
<li><a href="${BASE}/apps/${a.id}/static-files">${icon('file')}<span
|
|
276
|
-
<li><a href="${BASE}/apps/${a.id}/plugins">${icon('layers')}<span
|
|
277
|
-
<li class="group"
|
|
278
|
-
<li><a href="${BASE}/apps/${a.id}/subscriptions">${icon('layers')}<span
|
|
279
|
+
<li class="group">${tx('sc.tree.security')}</li>
|
|
280
|
+
<li><a href="${BASE}/apps/${a.id}/shared"${!selKind && !newKind ? raw(' aria-current="page"') : ''}>${icon('users')}<span>${tx('sc.access')}</span><span class="kind">${users.length}</span></a></li>
|
|
281
|
+
<li class="group">${tx('sc.tree.globalization')}</li>
|
|
282
|
+
<li><a href="${BASE}/apps/${a.id}/globalization">${icon('file')}<span>${tx('sc.tree.translations')}</span><span class="kind">${[a.language, ...(a.languages ?? [])].join(', ')}</span></a></li>
|
|
283
|
+
<li class="group">${tx('sc.tree.files')}</li>
|
|
284
|
+
<li><a href="${BASE}/apps/${a.id}/static-files">${icon('file')}<span>${tx('sc.tree.static')}</span></a></li>
|
|
285
|
+
<li><a href="${BASE}/apps/${a.id}/plugins">${icon('layers')}<span>${tx('sc.item.plug_ins')}</span></a></li>
|
|
286
|
+
<li class="group">${tx('sc.tree.subscriptions')}</li>
|
|
287
|
+
<li><a href="${BASE}/apps/${a.id}/subscriptions">${icon('layers')}<span>${tx('sc.tree.subscribers')}</span>${a.app_type && a.app_type !== 'standard' ? html`<span class="kind">${a.app_type}</span>` : ''}</a></li>
|
|
279
288
|
${SHARED.map((kind) => {
|
|
280
289
|
const spec = COMPONENTS[kind];
|
|
281
|
-
return html`<li class="group">${
|
|
290
|
+
return html`<li class="group">${kindName(tx, kind, true)}<a href="?new=${kind}" aria-label="${tx('sc.add_kind', { what: kindName(tx, kind) })}">${tx('sc.add')}</a></li>
|
|
282
291
|
${rows[kind].map((r) => html`<li><a href="?c=${kind}-${r.id}"${selKind === kind && selId === String(r.id) ? raw(' aria-current="page"') : ''}>${icon(kind === 'nav_entry' ? (r.icon ?? 'chevron') : spec.icon)}<span>${r.parent_id ? '↳ ' : ''}${spec.summary(r)}</span>${
|
|
283
292
|
kind === 'nav_entry' && r.target_page ? html`<span class="kind">p${r.target_page}</span>` : kind === 'authz_scheme' ? html`<span class="kind">${r.type}</span>` : kind === 'app_process' ? html`<span class="kind">${r.point}</span>` : kind === 'report_layout' ? html`<span class="kind">${r.paper}${r.is_default ? ' · default' : ''}</span>` : kind === 'automation' ? html`<span class="kind">${r.enabled ? (r.last_status === 'error' || r.last_status === 'warning' ? r.last_status : r.schedule) : 'off'}</span>` : kind === 'web_credential' ? html`<span class="kind">${r.type}${r.secret_enc || !needsSecret(r) ? '' : ' · no secret'}</span>` : kind === 'rest_source' ? html`<span class="kind">${r.method}${r.sync_table ? ' · sync' : ''}</span>` : kind === 'list' ? html`<span class="kind">${r.type}</span>` : kind === 'supporting_script' ? html`<span class="kind">${r.kind}</span>` : kind === 'search_config' ? html`<span class="kind">${r.source_type}</span>` : ''
|
|
284
|
-
}</a></li>`)}${kind === 'supporting_script' ? html`<li><a href="${BASE}/apps/${a.id}/supporting-objects">${icon('play')}<span
|
|
293
|
+
}</a></li>`)}${kind === 'supporting_script' ? html`<li><a href="${BASE}/apps/${a.id}/supporting-objects">${icon('play')}<span>${tx('sc.review_run')}</span></a></li>` : ''}`;
|
|
285
294
|
})}
|
|
286
295
|
</ul>`;
|
|
287
296
|
|
|
288
297
|
const main = html`${appHeader(a, 'shared', s)}
|
|
289
298
|
<div class="designer"><aside class="region region-standard">${tree}</aside><div>${editor}</div></div>`;
|
|
290
299
|
const kindShown = selKind && SHARED.includes(selKind) ? selKind : newKind && SHARED.includes(newKind) ? newKind : '';
|
|
291
|
-
return send(reply, s, shell(s, 'Shared Components', kindShown ? [...crumbsBase, [
|
|
300
|
+
return send(reply, s, shell(s, 'Shared Components', kindShown ? [...crumbsBase, [kindName(tx, kindShown, true), `${BASE}/apps/${a.id}/shared?type=${kindShown}`], [selKind ? tx('sc.edit') : tx('sc.create')]] : [...crumbsBase.slice(0, 2), ['Shared Components']], main));
|
|
292
301
|
});
|
|
293
302
|
|
|
294
303
|
app.post(`${BASE}/apps/:id/shared/:kind/:cid?`, async (req: Req, reply) => {
|
|
@@ -298,7 +307,7 @@ export async function sharedRoutes(app: FastifyInstance) {
|
|
|
298
307
|
if (!SHARED.includes(kind)) return reply.code(404).send('Unknown component type');
|
|
299
308
|
try {
|
|
300
309
|
const newId = await saveComponent(kind, 'app_id', id, cid, req.body ?? {});
|
|
301
|
-
flash(s,
|
|
310
|
+
flash(s, tr(s)('sc.saved', { what: kindName(tr(s), kind) }));
|
|
302
311
|
return back(reply, s, `${BASE}/apps/${id}/shared?c=${kind}-${newId}`);
|
|
303
312
|
} catch (e) {
|
|
304
313
|
flash(s, (e as Error).message, 'error');
|
|
@@ -312,7 +321,7 @@ export async function sharedRoutes(app: FastifyInstance) {
|
|
|
312
321
|
const { id, kind, cid } = req.params;
|
|
313
322
|
if (!SHARED.includes(kind)) return reply.code(404).send('Unknown component type');
|
|
314
323
|
await owner.query(`delete from ${COMPONENTS[kind].table} where id = $1 and app_id = $2`, [cid, id]);
|
|
315
|
-
flash(s,
|
|
324
|
+
flash(s, tr(s)('sc.deleted', { what: kindName(tr(s), kind) }));
|
|
316
325
|
return back(reply, s, `${BASE}/apps/${id}/shared`);
|
|
317
326
|
});
|
|
318
327
|
|
|
@@ -324,12 +333,12 @@ export async function sharedRoutes(app: FastifyInstance) {
|
|
|
324
333
|
try {
|
|
325
334
|
if (b.access_control) {
|
|
326
335
|
await owner.query('update meta.app set access_control = $2 where id = $1', [req.params.id, b.access_control === 'any_user' ? 'any_user' : 'assigned']);
|
|
327
|
-
flash(s, '
|
|
336
|
+
flash(s, tr(s)('sc.access_saved'));
|
|
328
337
|
} else {
|
|
329
338
|
const acc = await owner.one('select id from meta.account where lower(username) = lower($1)', [b.username?.trim() ?? '']);
|
|
330
|
-
if (!acc) throw new Error(
|
|
339
|
+
if (!acc) throw new Error(tr(s)('sc.no_account', { user: b.username }));
|
|
331
340
|
await grantAccess(req.params.id, acc.id, splitRoles(b.roles));
|
|
332
|
-
flash(s,
|
|
341
|
+
flash(s, tr(s)('sc.granted', { user: b.username }));
|
|
333
342
|
}
|
|
334
343
|
} catch (e) {
|
|
335
344
|
flash(s, (e as Error).message, 'error');
|
|
@@ -344,7 +353,7 @@ export async function sharedRoutes(app: FastifyInstance) {
|
|
|
344
353
|
await owner.query('insert into meta.app_group_role (app_id, group_name, role) values ($1, $2, $3) on conflict do nothing', [
|
|
345
354
|
req.params.id, b.group_name?.trim(), b.role?.trim().toLowerCase(),
|
|
346
355
|
]);
|
|
347
|
-
flash(s, '
|
|
356
|
+
flash(s, tr(s)('sc.mapping_added'));
|
|
348
357
|
return back(reply, s, `${BASE}/apps/${req.params.id}/shared`);
|
|
349
358
|
});
|
|
350
359
|
|
|
@@ -352,7 +361,7 @@ export async function sharedRoutes(app: FastifyInstance) {
|
|
|
352
361
|
const s = await developer(req, reply);
|
|
353
362
|
if (!s) return;
|
|
354
363
|
await owner.query('delete from meta.app_group_role where app_id = $1 and group_name = $2 and role = $3', [req.params.id, req.body?.group_name, req.body?.role]);
|
|
355
|
-
flash(s, '
|
|
364
|
+
flash(s, tr(s)('sc.mapping_removed'));
|
|
356
365
|
return back(reply, s, `${BASE}/apps/${req.params.id}/shared`);
|
|
357
366
|
});
|
|
358
367
|
|
|
@@ -360,7 +369,7 @@ export async function sharedRoutes(app: FastifyInstance) {
|
|
|
360
369
|
const s = await developer(req, reply);
|
|
361
370
|
if (!s) return;
|
|
362
371
|
await grantAccess(req.params.id, req.params.accountId, splitRoles(req.body?.roles));
|
|
363
|
-
flash(s, '
|
|
372
|
+
flash(s, tr(s)('sc.roles_saved'));
|
|
364
373
|
return back(reply, s, `${BASE}/apps/${req.params.id}/shared`);
|
|
365
374
|
});
|
|
366
375
|
|
|
@@ -369,7 +378,7 @@ export async function sharedRoutes(app: FastifyInstance) {
|
|
|
369
378
|
if (!s) return;
|
|
370
379
|
await owner.query('delete from meta.app_access where app_id = $1 and account_id = $2', [req.params.id, req.params.accountId]);
|
|
371
380
|
await endSessions(req.params.accountId, req.params.id);
|
|
372
|
-
flash(s, '
|
|
381
|
+
flash(s, tr(s)('sc.revoked'));
|
|
373
382
|
return back(reply, s, `${BASE}/apps/${req.params.id}/shared`);
|
|
374
383
|
});
|
|
375
384
|
|