pgkiln 0.58.0 → 0.60.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/README.md +3 -0
- package/SECURITY.md +2 -0
- package/db/migrations/128_developer_mfa.sql +48 -0
- package/docs/apex-feature-parity.md +6 -3
- package/docs/guide/03-builder.md +56 -2
- package/docs/guide/08-security.md +4 -0
- package/docs/guide/18-cli.md +9 -0
- package/docs/guide/20-ai-agents.md +18 -0
- package/package.json +1 -1
- package/public/builder.css +8 -0
- package/src/builder/i18n/de.ts +401 -0
- package/src/builder/i18n/en.ts +401 -0
- package/src/builder/i18n/es.ts +401 -0
- package/src/builder/i18n/fr.ts +401 -0
- package/src/builder/i18n/nl.ts +401 -0
- package/src/builder/i18n/pt.ts +401 -0
- package/src/builder/instance.ts +2 -0
- package/src/builder/routes.ts +149 -36
- package/src/builder/security.ts +261 -0
- package/src/builder/shared.ts +2 -2
- package/src/builder/ui.ts +13 -1
- package/src/builder/users.ts +123 -118
- package/src/builder/workspaces.ts +143 -135
- package/src/cli/main.ts +51 -0
- package/src/mfa.ts +260 -0
package/src/builder/routes.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { appOr404 } from './forms.ts';
|
|
|
19
19
|
import { docToFiles, filesToZip } from '../appfiles.ts';
|
|
20
20
|
import { homeRoutes, rememberApp } from './home.ts';
|
|
21
21
|
import { currentWorkspace, placeApp, workshopDb, workspaceRoutes } from './workspaces.ts';
|
|
22
|
+
import { securityRoutes } from './security.ts';
|
|
22
23
|
import { instanceRoutes } from './instance.ts';
|
|
23
24
|
import { saveTimeZoneSettings, timeZoneSettings } from './globalization.ts';
|
|
24
25
|
import { WIZARD_KINDS, wizardRoutes, wizardTables } from './wizards.ts';
|
|
@@ -26,6 +27,7 @@ import { appFromFileRoutes } from './appfromfile.ts';
|
|
|
26
27
|
import { clearDevbar } from '../runtime/devbar.ts';
|
|
27
28
|
import { EMAIL } from './find-workspace.ts';
|
|
28
29
|
import { cookieLanguage, LANG_COOKIE, tr, translatorFor, validBuilderLanguage } from './i18n.ts';
|
|
30
|
+
import { checkSecondFactor, codesLeft, hasFactor, leavesTooFewAdmins } from '../mfa.ts';
|
|
29
31
|
import { chooseLanguage, langCookieOptions, languageRoutes, signInLanguageLinks } from './language.ts';
|
|
30
32
|
|
|
31
33
|
// Builder pages: sign-in, workspace and app home, settings, activity and
|
|
@@ -148,27 +150,124 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
148
150
|
flash(s, tx(workspace ? 'signin.invalid_ws' : 'signin.invalid'), 'error');
|
|
149
151
|
return back(reply, s, again);
|
|
150
152
|
}
|
|
153
|
+
// (sprint 126, SEC-2) a developer with a second factor: the password was right, the session comes after the code
|
|
154
|
+
if (await hasFactor(dev.username)) {
|
|
155
|
+
s.state.__MFA = JSON.stringify({ u: dev.username, ws: ws ? String(ws.id) : null, w: workspace, next, remember: req.body?.remember === '1', weak: password === 'admin' || password === dev.username, at: Date.now() });
|
|
156
|
+
await saveState(s);
|
|
157
|
+
return reply.redirect(`${BASE}/login/2fa`, 303);
|
|
158
|
+
}
|
|
159
|
+
return finishSignIn(req, reply, s, { username: dev.username, language: dev.language }, { ws: ws ? String(ws.id) : null, workspace, next, remember: req.body?.remember === '1', weak: password === 'admin' || password === dev.username, picked, detail: 'builder' });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
/** The session after a successful sign-in (password, and the second factor when the developer has one). */
|
|
163
|
+
async function finishSignIn(req: Req, reply: Parameters<typeof back>[0], s: Awaited<ReturnType<typeof getSession>>, dev: { username: string; language: string | null },
|
|
164
|
+
o: { ws: string | null; workspace: string; next: string; remember: boolean; weak: boolean; picked: ReturnType<typeof validBuilderLanguage>; detail: string }) {
|
|
165
|
+
const ip = clientIp(req);
|
|
166
|
+
const { picked, next, workspace } = o;
|
|
151
167
|
await destroySession(reply, s, BASE);
|
|
152
168
|
const ns = await createSession(reply, null, BASE, dev.username);
|
|
153
169
|
// the builder theme chosen earlier on this device
|
|
154
170
|
const theme = validTheme(req.cookies?.[THEME_COOKIE]);
|
|
155
171
|
if (theme) ns.state.__BTHEME = theme;
|
|
156
|
-
if (ws) ns.state.__WS =
|
|
172
|
+
if (o.ws) ns.state.__WS = o.ws;
|
|
157
173
|
// (sprint 121) the builder language: a link chosen on the sign-in page is stored for the developer; else theirs
|
|
158
174
|
if (picked) await chooseLanguage(reply, ns, picked);
|
|
159
175
|
else {
|
|
160
176
|
ns.state.__BLANG = validBuilderLanguage(dev.language) ?? 'en';
|
|
161
177
|
if (dev.language && ns.state.__BLANG !== 'en') reply.setCookie(LANG_COOKIE, ns.state.__BLANG, langCookieOptions());
|
|
162
178
|
}
|
|
163
|
-
if (
|
|
179
|
+
if (o.weak) ns.state.__WEAK = '1';
|
|
164
180
|
await saveState(ns);
|
|
165
|
-
if (
|
|
181
|
+
if (o.remember)
|
|
166
182
|
reply.setCookie(REMEMBER_COOKIE, Buffer.from(JSON.stringify({ w: workspace, u: dev.username })).toString('base64url'), {
|
|
167
183
|
path: BASE, httpOnly: true, sameSite: 'lax', maxAge: 365 * 24 * 3600, secure: process.env.COOKIE_SECURE === 'true',
|
|
168
184
|
});
|
|
169
185
|
else reply.clearCookie(REMEMBER_COOKIE, { path: BASE });
|
|
170
|
-
logActivity({ username: dev.username, event: 'login', ip, detail:
|
|
186
|
+
logActivity({ username: dev.username, event: 'login', ip, detail: o.detail });
|
|
171
187
|
return reply.redirect(next, 303);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// (sprint 126, SEC-2) the second step: a code from the authenticator app, or a recovery code
|
|
191
|
+
const MFA_SECONDS = 300;
|
|
192
|
+
const pendingMfa = (s: { state: Record<string, any> }) => {
|
|
193
|
+
try {
|
|
194
|
+
const p = JSON.parse(String(s.state.__MFA ?? ''));
|
|
195
|
+
return p && typeof p.u === 'string' && Date.now() - Number(p.at) < MFA_SECONDS * 1000 ? p as { u: string; ws: string | null; w: string; next: string; remember: boolean; weak: boolean } : null;
|
|
196
|
+
} catch {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
app.get(`${BASE}/login/2fa`, async (req: Req, reply) => {
|
|
201
|
+
const s = await getSession(req, reply, null, BASE);
|
|
202
|
+
if (s.username) return reply.redirect(`${BASE}/home`);
|
|
203
|
+
const p = pendingMfa(s);
|
|
204
|
+
const tx = translatorFor(validBuilderLanguage(s.state.__BLANG) ?? cookieLanguage(req));
|
|
205
|
+
if (!p) {
|
|
206
|
+
delete s.state.__MFA;
|
|
207
|
+
flash(s, tx('mfa.expired'), 'error');
|
|
208
|
+
return back(reply, s, `${BASE}/login`);
|
|
209
|
+
}
|
|
210
|
+
const error = takeFlash(s, '__ERROR');
|
|
211
|
+
await saveState(s);
|
|
212
|
+
return reply.type('text/html').send(documentShell(
|
|
213
|
+
`${tx('mfa.step_title')} · ${tx('shell.builder')}`,
|
|
214
|
+
html`<main class="signin">
|
|
215
|
+
<section class="signin-hero" aria-label="pgkiln">
|
|
216
|
+
<span class="signin-logo" aria-hidden="true">${brandMark()}</span>
|
|
217
|
+
<h1 class="signin-title">pgkiln</h1>
|
|
218
|
+
<p class="signin-tagline">${tx('signin.tagline')}</p>
|
|
219
|
+
</section>
|
|
220
|
+
<section class="signin-panel">
|
|
221
|
+
<form method="post" class="signin-form">
|
|
222
|
+
<h2>${tx('mfa.step_title')}</h2>
|
|
223
|
+
<p class="muted u-mt0">${tx('mfa.step_intro', { user: p.u })}</p>
|
|
224
|
+
${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
|
|
225
|
+
${csrf(s)}
|
|
226
|
+
<div class="field signin-field"><label class="label" for="f_code">${tx('mfa.code')}</label>
|
|
227
|
+
<div class="signin-input">${icon('key')}<input id="f_code" name="code" required autocomplete="one-time-code" autofocus maxlength="20" spellcheck="false" autocapitalize="off"></div>
|
|
228
|
+
<small class="help">${tx('mfa.code_help')}</small></div>
|
|
229
|
+
<button class="btn btn-hot signin-submit">${tx('mfa.verify')}</button>
|
|
230
|
+
<p class="muted small signin-hint"><a href="${BASE}/login">${tx('mfa.cancel')}</a> · ${tx('mfa.locked_out')}</p>
|
|
231
|
+
</form>
|
|
232
|
+
</section>
|
|
233
|
+
</main>`,
|
|
234
|
+
'login-body ide-login signin-body', {}, builderHead(),
|
|
235
|
+
{ theme: validTheme(req.cookies?.[THEME_COOKIE]) ?? 'dark', lang: tx.lang },
|
|
236
|
+
));
|
|
237
|
+
});
|
|
238
|
+
app.post(`${BASE}/login/2fa`, async (req: Req, reply) => {
|
|
239
|
+
const s = await getSession(req, reply, null, BASE);
|
|
240
|
+
const ip = clientIp(req);
|
|
241
|
+
const picked = validBuilderLanguage(s.state.__BLANG);
|
|
242
|
+
const tx = translatorFor(picked ?? cookieLanguage(req));
|
|
243
|
+
const p = pendingMfa(s);
|
|
244
|
+
if (req.body?.__csrf !== s.csrf_token || !p) {
|
|
245
|
+
delete s.state.__MFA;
|
|
246
|
+
flash(s, tx(p ? 'signin.expired' : 'mfa.expired'), 'error');
|
|
247
|
+
return back(reply, s, `${BASE}/login`);
|
|
248
|
+
}
|
|
249
|
+
if (await loginThrottled(null, p.u, ip)) {
|
|
250
|
+
logActivity({ username: p.u, event: 'login_locked', ip, detail: 'builder 2fa' });
|
|
251
|
+
delete s.state.__MFA;
|
|
252
|
+
flash(s, tx('signin.throttled'), 'error');
|
|
253
|
+
return back(reply, s, `${BASE}/login`);
|
|
254
|
+
}
|
|
255
|
+
const ok = await checkSecondFactor(p.u, String(req.body?.code ?? '').slice(0, 40));
|
|
256
|
+
if (!ok) {
|
|
257
|
+
// counts with the failed passwords (lockout after the same number of attempts)
|
|
258
|
+
await logActivity({ username: p.u, event: 'login_failed', ip, detail: 'builder 2fa' });
|
|
259
|
+
flash(s, tx('mfa.wrong'), 'error');
|
|
260
|
+
return back(reply, s, `${BASE}/login/2fa`);
|
|
261
|
+
}
|
|
262
|
+
const dev = await owner.one<{ username: string; language: string | null }>('select username, language from meta.developer where username = $1', [p.u]);
|
|
263
|
+
if (!dev) return back(reply, s, `${BASE}/login`);
|
|
264
|
+
if (ok.kind === 'recovery') {
|
|
265
|
+
const left = await codesLeft(p.u);
|
|
266
|
+
await logActivity({ username: p.u, event: '2fa_recovery_used', ip, detail: `${left} left` });
|
|
267
|
+
// to the Security page: one code fewer, maybe time for new ones
|
|
268
|
+
return finishSignIn(req, reply, s, dev, { ws: p.ws, workspace: p.w, next: `${BASE}/security`, remember: p.remember, weak: p.weak, picked, detail: 'builder 2fa (recovery code)' });
|
|
269
|
+
}
|
|
270
|
+
return finishSignIn(req, reply, s, dev, { ws: p.ws, workspace: p.w, next: p.next, remember: p.remember, weak: p.weak, picked, detail: 'builder 2fa' });
|
|
172
271
|
});
|
|
173
272
|
|
|
174
273
|
app.post(`${BASE}/logout`, async (req: Req, reply) => {
|
|
@@ -205,6 +304,7 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
205
304
|
await languageRoutes(app);
|
|
206
305
|
await workspaceRoutes(app);
|
|
207
306
|
await instanceRoutes(app);
|
|
307
|
+
await securityRoutes(app);
|
|
208
308
|
await wizardRoutes(app);
|
|
209
309
|
await appFromFileRoutes(app);
|
|
210
310
|
|
|
@@ -686,40 +786,45 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
686
786
|
app.get(`${BASE}/developers`, async (req: Req, reply) => {
|
|
687
787
|
const s = await developer(req, reply);
|
|
688
788
|
if (!s) return;
|
|
689
|
-
const devs = (await owner.query(
|
|
789
|
+
const devs = (await owner.query(`select username, is_admin, email, exists (select 1 from meta.developer_factor f where f.username = d.username) as mfa
|
|
790
|
+
from meta.developer d order by 1`)).rows;
|
|
690
791
|
const admin = devs.some((d) => d.username === s.username && d.is_admin);
|
|
691
792
|
const me = devs.find((d) => d.username === s.username);
|
|
692
|
-
const
|
|
793
|
+
const tx = tr(s);
|
|
794
|
+
const main = html`<h1 class="u-mb1">${tx('dev.title')}</h1>
|
|
693
795
|
<div class="columns">
|
|
694
|
-
${region('
|
|
796
|
+
${region(tx('dev.change_pw'), html`<form method="post" action="${BASE}/developers/password">${csrf(s)}
|
|
695
797
|
<div class="form-grid">
|
|
696
|
-
${input('current', '
|
|
697
|
-
${input('password', '
|
|
798
|
+
${input('current', tx('dev.current'), '', { type: 'password', required: true, auto: 'current-password' })}
|
|
799
|
+
${input('password', tx('dev.new'), '', { type: 'password', required: true, auto: 'new-password', help: tx('dev.min8') })}
|
|
698
800
|
</div>
|
|
699
|
-
<div class="buttons"><button class="btn btn-hot"
|
|
700
|
-
${region('
|
|
701
|
-
<div class="form-grid">${input('email', '
|
|
702
|
-
<div class="buttons"><button class="btn"
|
|
703
|
-
${region('
|
|
704
|
-
<p class="muted u-mt0"
|
|
705
|
-
<div class="table-wrap"><table class="report report-reflow"><thead><tr><th
|
|
706
|
-
${devs.map((d) => html`<tr><td data-label="
|
|
707
|
-
<td data-label="
|
|
708
|
-
? html`<form method="post" action="${BASE}/developers/email" class="search u-m0 u-mwnone">${csrf(s)}<input type="hidden" name="username" value="${d.username}"><input name="email" type="email" value="${d.email ?? ''}" aria-label="
|
|
709
|
-
: html`<span class="wrap-anywhere">${d.email ?? ''}</span>`}</td
|
|
710
|
-
<
|
|
711
|
-
|
|
801
|
+
<div class="buttons"><button class="btn btn-hot">${tx('dev.change_btn')}</button></div></form>`)}
|
|
802
|
+
${region(tx('dev.your_email'), html`<form method="post" action="${BASE}/developers/email">${csrf(s)}<input type="hidden" name="username" value="${s.username}">
|
|
803
|
+
<div class="form-grid">${input('email', tx('dev.email'), me?.email ?? '', { type: 'email', auto: 'email', help: tx('dev.email_help') })}</div>
|
|
804
|
+
<div class="buttons"><button class="btn">${tx('dev.save')}</button></div></form>`)}
|
|
805
|
+
${region(tx('dev.accounts'), html`
|
|
806
|
+
<p class="muted u-mt0">${tx('dev.accounts_help')}</p>
|
|
807
|
+
<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('dev.username')}</th><th>${tx('dev.role')}</th><th>${tx('dev.email_col')}</th><th>${tx('mfa.column')}</th><th></th></tr></thead><tbody>
|
|
808
|
+
${devs.map((d) => html`<tr><td data-label="${tx('dev.username')}">${d.username}${d.username === s.username ? ` ${tx('dev.you')}` : ''}</td><td data-label="${tx('dev.role')}">${d.is_admin ? tx('dev.administrator') : tx('dev.developer')}</td>
|
|
809
|
+
<td data-label="${tx('dev.email_col')}">${admin && d.username !== s.username
|
|
810
|
+
? html`<form method="post" action="${BASE}/developers/email" class="search u-m0 u-mwnone">${csrf(s)}<input type="hidden" name="username" value="${d.username}"><input name="email" type="email" value="${d.email ?? ''}" aria-label="${tx('dev.email_of', { user: d.username })}" placeholder="${tx('dev.none')}"><button class="btn">${tx('dev.save')}</button></form>`
|
|
811
|
+
: html`<span class="wrap-anywhere">${d.email ?? ''}</span>`}</td>
|
|
812
|
+
<td data-label="${tx('mfa.column')}">${d.mfa ? html`${icon('lock')} ${tx('mfa.on')}${admin && d.username !== s.username ? html` <details class="menu u-inline"><summary class="link-button">${tx('mfa.reset')}</summary><div class="menu-panel"><form method="post" action="${BASE}/developers/reset-2fa" class="menu-section">${csrf(s)}<input type="hidden" name="username" value="${d.username}">
|
|
813
|
+
${input(`reason_${d.username}`.replace(/[^\w]/g, '_'), tx('mfa.reason'), '', { required: true, help: tx('mfa.reset_help', { user: d.username }) })}
|
|
814
|
+
<button class="btn btn-sm btn-danger">${tx('mfa.reset_btn')}</button></form></div></details>` : ''}` : html`<span class="muted">${tx('mfa.off')}</span>`}</td><td>${d.username === s.username || !admin ? '' : html`
|
|
815
|
+
<form method="post" action="${BASE}/developers/admin" class="u-inline">${csrf(s)}<input type="hidden" name="username" value="${d.username}"><input type="hidden" name="is_admin" value="${d.is_admin ? 'false' : 'true'}"><button class="link-button">${d.is_admin ? tx('dev.make_dev') : tx('dev.make_admin')}</button></form>
|
|
816
|
+
· <form method="post" action="${BASE}/developers/delete" class="u-inline">${csrf(s)}<input type="hidden" name="username" value="${d.username}"><button class="link-button" data-confirm="${tx('dev.remove_confirm', { user: d.username })}">${tx('dev.remove')}</button></form>`}</td></tr>`)}
|
|
712
817
|
</tbody></table></div>
|
|
713
818
|
${admin
|
|
714
|
-
? html`<h3
|
|
819
|
+
? html`<h3>${tx('dev.add')}</h3>
|
|
715
820
|
<form method="post" action="${BASE}/developers">${csrf(s)}
|
|
716
|
-
<div class="form-grid">${input('username', '
|
|
717
|
-
<div class="field"><label class="check"><input type="checkbox" name="is_admin" value="true">
|
|
718
|
-
<div class="buttons"><button class="btn btn-hot"
|
|
821
|
+
<div class="form-grid">${input('username', tx('dev.username'), '', { required: true })}${input('password', tx('dev.password'), '', { type: 'password', required: true, auto: 'new-password' })}${input('email', tx('dev.email'), '', { type: 'email', help: tx('dev.optional_email') })}</div>
|
|
822
|
+
<div class="field"><label class="check"><input type="checkbox" name="is_admin" value="true"> ${tx('dev.administrator')}</label></div>
|
|
823
|
+
<div class="buttons"><button class="btn btn-hot">${tx('dev.add')}</button></div>
|
|
719
824
|
</form>`
|
|
720
|
-
: html`<p class="muted"
|
|
825
|
+
: html`<p class="muted">${tx('dev.admins_only')}</p>`}`)}
|
|
721
826
|
</div>`;
|
|
722
|
-
return send(reply, s, shell(s, '
|
|
827
|
+
return send(reply, s, shell(s, tx('dev.title'), [['Developers']], main, 'developers'));
|
|
723
828
|
});
|
|
724
829
|
|
|
725
830
|
app.post(`${BASE}/developers`, async (req: Req, reply) => {
|
|
@@ -732,7 +837,7 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
732
837
|
const ws = currentWorkspace(s);
|
|
733
838
|
await owner.tx(async (c) => {
|
|
734
839
|
const email = String(req.body?.email ?? '').trim();
|
|
735
|
-
if (email && !EMAIL.test(email)) throw new Error('
|
|
840
|
+
if (email && !EMAIL.test(email)) throw new Error(tr(s)('dev.err.email'));
|
|
736
841
|
await c.query('insert into meta.developer (username, password_hash, is_admin, email) values ($1, meta.hash_password($2), $3, $4)', [req.body?.username?.trim(), req.body?.password, req.body?.is_admin === 'true', email || null]);
|
|
737
842
|
// (064) a new developer works in the current workspace; more on Workspace utilities → Workspaces
|
|
738
843
|
// (the insert trigger put them in Default)
|
|
@@ -741,7 +846,7 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
741
846
|
await c.query('insert into meta.workspace_member (workspace_id, username) values ($1, $2)', [ws, req.body?.username?.trim()]);
|
|
742
847
|
}
|
|
743
848
|
});
|
|
744
|
-
flash(s, ws > 0 ?
|
|
849
|
+
flash(s, ws > 0 ? tr(s)('dev.added_ws', { name: s.workspace!.name }) : tr(s)('dev.added'));
|
|
745
850
|
} catch (e) {
|
|
746
851
|
flash(s, (e as Error).message, 'error');
|
|
747
852
|
}
|
|
@@ -759,11 +864,11 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
759
864
|
where username = $1 and password_hash = crypt($2, password_hash)`,
|
|
760
865
|
[s.username, req.body?.current ?? '', req.body?.password],
|
|
761
866
|
);
|
|
762
|
-
if (r.rowCount !== 1) throw new Error('
|
|
867
|
+
if (r.rowCount !== 1) throw new Error(tr(s)('dev.err.current'));
|
|
763
868
|
// End all other builder sessions of this developer.
|
|
764
869
|
await owner.query('delete from meta.session where app_id is null and username = $1 and id <> $2', [s.username, s.id]);
|
|
765
870
|
delete s.state.__WEAK;
|
|
766
|
-
flash(s, '
|
|
871
|
+
flash(s, tr(s)('dev.pw_changed'));
|
|
767
872
|
} catch (e) {
|
|
768
873
|
flash(s, (e as Error).message, 'error');
|
|
769
874
|
}
|
|
@@ -777,10 +882,10 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
777
882
|
const who = String(req.body?.username ?? s.username);
|
|
778
883
|
if (who !== s.username && !(await isAdmin(s.username))) return reply.code(403).send('Only administrators change other developers\' addresses.');
|
|
779
884
|
const email = String(req.body?.email ?? '').trim();
|
|
780
|
-
if (email && (!EMAIL.test(email) || email.length > 254)) flash(s, '
|
|
885
|
+
if (email && (!EMAIL.test(email) || email.length > 254)) flash(s, tr(s)('dev.err.email'), 'error');
|
|
781
886
|
else {
|
|
782
887
|
const r = await owner.query('update meta.developer set email = $2 where username = $1', [who, email || null]);
|
|
783
|
-
flash(s, r.rowCount ? '
|
|
888
|
+
flash(s, tr(s)(r.rowCount ? 'dev.email_saved' : 'dev.not_found'), r.rowCount ? 'ok' : 'error');
|
|
784
889
|
}
|
|
785
890
|
return back(reply, s, `${BASE}/developers`);
|
|
786
891
|
});
|
|
@@ -790,8 +895,12 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
790
895
|
if (!s) return;
|
|
791
896
|
if (!(await isAdmin(s.username))) return reply.code(403).send('Only administrators change roles.');
|
|
792
897
|
if (req.body?.username && req.body.username !== s.username) {
|
|
898
|
+
if (req.body.is_admin !== 'true' && (await leavesTooFewAdmins(req.body.username))) {
|
|
899
|
+
flash(s, tr(s)('mfa.keep_admins'), 'error');
|
|
900
|
+
return back(reply, s, `${BASE}/developers`);
|
|
901
|
+
}
|
|
793
902
|
await owner.query('update meta.developer set is_admin = $2 where username = $1', [req.body.username, req.body.is_admin === 'true']);
|
|
794
|
-
flash(s, '
|
|
903
|
+
flash(s, tr(s)('dev.role_changed'));
|
|
795
904
|
}
|
|
796
905
|
return back(reply, s, `${BASE}/developers`);
|
|
797
906
|
});
|
|
@@ -801,9 +910,13 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
801
910
|
if (!s) return;
|
|
802
911
|
if (!(await isAdmin(s.username))) return reply.code(403).send('Only administrators remove developers.');
|
|
803
912
|
if (req.body?.username && req.body.username !== s.username) {
|
|
913
|
+
if (await leavesTooFewAdmins(req.body.username)) {
|
|
914
|
+
flash(s, tr(s)('mfa.keep_admins'), 'error');
|
|
915
|
+
return back(reply, s, `${BASE}/developers`);
|
|
916
|
+
}
|
|
804
917
|
await owner.query('delete from meta.developer where username = $1', [req.body.username]);
|
|
805
918
|
await owner.query('delete from meta.session where app_id is null and username = $1', [req.body.username]);
|
|
806
|
-
flash(s, '
|
|
919
|
+
flash(s, tr(s)('dev.removed'));
|
|
807
920
|
}
|
|
808
921
|
return back(reply, s, `${BASE}/developers`);
|
|
809
922
|
});
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import { owner } from '../db.ts';
|
|
3
|
+
import { html, raw, type Raw } from '../html.ts';
|
|
4
|
+
import { icon } from '../icons.ts';
|
|
5
|
+
import {
|
|
6
|
+
addTotp, adminEmails, adminFactors, codesLeft, confirmRecoveryCodes, factorsOf, hasFactor, matchTotp, MFA_POLICIES, mfaPolicy, newRecoveryCodes, newTotpSecret,
|
|
7
|
+
notifyMfa, otpauthUri, policyProblem, requiredFor, resetFactors, setPolicy, unbase32, type MfaPolicy,
|
|
8
|
+
} from '../mfa.ts';
|
|
9
|
+
import { qrSvg } from '../qrcode.ts';
|
|
10
|
+
import { secretKeyConfigured } from '../secrets.ts';
|
|
11
|
+
import { clientIp, logActivity, type Session } from '../session.ts';
|
|
12
|
+
import { tr } from './i18n.ts';
|
|
13
|
+
import { isAdmin } from './locks.ts';
|
|
14
|
+
import { back, BASE, csrf, developer, flash, input, region, send, shell, type Req } from './ui.ts';
|
|
15
|
+
|
|
16
|
+
// (sprint 126, SEC-2 and SEC-6) Developer › Security: a developer's second factors (authenticator apps, TOTP)
|
|
17
|
+
// and recovery codes; the instance's policy (Instance settings); an administrator's reset of another
|
|
18
|
+
// developer's factors (Developers). Adding an app: a new secret is kept in the session until a code from the
|
|
19
|
+
// app confirms it, then stored encrypted with the instance key. Recovery codes are shown once; they count
|
|
20
|
+
// only after "I saved them". Removing a factor asks for the password, and is refused when it would leave a
|
|
21
|
+
// developer the policy covers without one, or the instance without enough administrators who have one.
|
|
22
|
+
|
|
23
|
+
const SECRET_MINUTES = 10;
|
|
24
|
+
|
|
25
|
+
async function verifyPassword(username: string, password: string) {
|
|
26
|
+
return !!(await owner.one('select 1 as ok from meta.developer where username = $1 and crypt($2, password_hash) = password_hash', [username, password]));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function securityRoutes(app: FastifyInstance) {
|
|
30
|
+
app.get(`${BASE}/security`, async (req: Req, reply) => {
|
|
31
|
+
const s = await developer(req, reply);
|
|
32
|
+
if (!s) return;
|
|
33
|
+
const tx = tr(s);
|
|
34
|
+
const me = s.username!;
|
|
35
|
+
const [factors, left, pol, admin] = await Promise.all([factorsOf(me), codesLeft(me), mfaPolicy(), isAdmin(me)]);
|
|
36
|
+
const required = requiredFor(pol.policy, admin);
|
|
37
|
+
const pending = (() => {
|
|
38
|
+
try {
|
|
39
|
+
const p = JSON.parse(String(s.state.__TOTP_NEW ?? ''));
|
|
40
|
+
return p && Date.now() - p.at < SECRET_MINUTES * 60_000 ? (p as { secret: string; at: number }) : null;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
})();
|
|
45
|
+
const shownCodes: string[] | null = (() => {
|
|
46
|
+
try {
|
|
47
|
+
const c = JSON.parse(String(s.state.__CODES_NEW ?? ''));
|
|
48
|
+
return Array.isArray(c) ? c : null;
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
})();
|
|
53
|
+
delete s.state.__MFA_DUE; // the page says it itself
|
|
54
|
+
const when = (v: string | null) => (v ? String(v).slice(0, 16).replace('T', ' ') : '—');
|
|
55
|
+
const setup = pending
|
|
56
|
+
? html`<form method="post" action="${BASE}/security/totp/confirm">${csrf(s)}
|
|
57
|
+
<ol class="mfa-steps">
|
|
58
|
+
<li>${tx('mfa.scan')}</li>
|
|
59
|
+
<li>${tx('mfa.or_enter')} <code class="mfa-secret">${pending.secret.replace(/(.{4})/g, '$1 ').trim()}</code></li>
|
|
60
|
+
<li>${tx('mfa.enter_code')}</li>
|
|
61
|
+
</ol>
|
|
62
|
+
<div class="mfa-qr">${raw(qrSvg(otpauthUri(pending.secret, me), { label: tx('mfa.qr_label'), px: 4 }) ?? '')}</div>
|
|
63
|
+
<div class="form-grid">
|
|
64
|
+
${input('name', tx('mfa.app_name'), tx('mfa.app_default'), { required: true })}
|
|
65
|
+
<div class="field"><label class="label" for="f_code">${tx('mfa.code')}</label><input id="f_code" name="code" required inputmode="numeric" pattern="[0-9 ]{6,7}" autocomplete="one-time-code" maxlength="7"></div>
|
|
66
|
+
</div>
|
|
67
|
+
<div class="buttons"><button class="btn btn-hot">${tx('mfa.confirm_app')}</button></div></form>
|
|
68
|
+
<form method="post" action="${BASE}/security/totp/cancel">${csrf(s)}<button class="link-button">${tx('mfa.cancel_setup')}</button></form>`
|
|
69
|
+
: html`<form method="post" action="${BASE}/security/totp/start">${csrf(s)}
|
|
70
|
+
<div class="buttons"><button class="btn${factors.length ? '' : ' btn-hot'}"${secretKeyConfigured() ? '' : raw(' disabled')}>${icon('plus')} ${tx('mfa.add_app')}</button></div>
|
|
71
|
+
${secretKeyConfigured() ? '' : html`<p class="alert alert-error" role="alert">${tx('mfa.no_key')}</p>`}</form>`;
|
|
72
|
+
const main = html`<h1 class="u-mb1">${tx('mfa.title')}</h1>
|
|
73
|
+
<p class="muted u-mt0">${tx('mfa.intro')}</p>
|
|
74
|
+
${required && !factors.length ? html`<div class="alert alert-warning" role="alert">${tx('mfa.required_you')}</div>` : ''}
|
|
75
|
+
${shownCodes
|
|
76
|
+
? region(tx('mfa.codes_title'), html`<p>${tx('mfa.codes_once')}</p>
|
|
77
|
+
<ol class="mfa-codes">${shownCodes.map((c) => html`<li><code>${c}</code></li>`)}</ol>
|
|
78
|
+
<form method="post" action="${BASE}/security/codes/confirm">${csrf(s)}
|
|
79
|
+
<label class="check"><input type="checkbox" name="saved" value="1" required> ${tx('mfa.codes_saved')}</label>
|
|
80
|
+
<div class="buttons"><button class="btn btn-hot">${tx('mfa.codes_done')}</button></div></form>`, '', 'codes')
|
|
81
|
+
: ''}
|
|
82
|
+
<div class="columns">
|
|
83
|
+
${region(tx('mfa.apps'), html`${factors.length
|
|
84
|
+
? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('mfa.app_name')}</th><th>${tx('mfa.added')}</th><th>${tx('mfa.last_used')}</th><th></th></tr></thead><tbody>
|
|
85
|
+
${factors.map((f) => html`<tr><td data-label="${tx('mfa.app_name')}">${icon('key')} ${f.name}</td><td data-label="${tx('mfa.added')}">${when(f.created_at)}</td><td data-label="${tx('mfa.last_used')}">${when(f.last_used_at)}</td>
|
|
86
|
+
<td><details class="menu"><summary class="btn btn-sm">${tx('mfa.remove')}</summary><div class="menu-panel"><form method="post" action="${BASE}/security/factor/${f.id}/delete" class="menu-section">${csrf(s)}
|
|
87
|
+
${input(`password_${f.id}`, tx('mfa.your_password'), '', { type: 'password', required: true, auto: 'current-password' })}
|
|
88
|
+
<button class="btn btn-sm btn-danger">${tx('mfa.remove_app')}</button></form></div></details></td></tr>`)}
|
|
89
|
+
</tbody></table></div>`
|
|
90
|
+
: html`<p class="muted">${tx('mfa.no_apps')}</p>`}
|
|
91
|
+
${setup}`)}
|
|
92
|
+
${region(tx('mfa.codes_title'), html`<p class="u-mt0">${factors.length || left ? tx('mfa.codes_left', { n: left }) : tx('mfa.codes_after')}</p>
|
|
93
|
+
${factors.length && left < 3 ? html`<p class="alert alert-warning" role="status">${tx('mfa.codes_low')}</p>` : ''}
|
|
94
|
+
${factors.length ? html`<form method="post" action="${BASE}/security/codes">${csrf(s)}<button class="btn" data-confirm="${tx('mfa.codes_new_confirm')}">${tx('mfa.codes_new')}</button></form>` : ''}`)}
|
|
95
|
+
</div>`;
|
|
96
|
+
return send(reply, s, shell(s, tx('mfa.title'), [[tx('mfa.title')]], main, 'developers'));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
app.post(`${BASE}/security/totp/start`, async (req: Req, reply) => {
|
|
100
|
+
const s = await developer(req, reply);
|
|
101
|
+
if (!s) return;
|
|
102
|
+
if (!secretKeyConfigured()) return back(reply, s, `${BASE}/security`);
|
|
103
|
+
s.state.__TOTP_NEW = JSON.stringify({ secret: newTotpSecret(), at: Date.now() });
|
|
104
|
+
return back(reply, s, `${BASE}/security`);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
app.post(`${BASE}/security/totp/cancel`, async (req: Req, reply) => {
|
|
108
|
+
const s = await developer(req, reply);
|
|
109
|
+
if (!s) return;
|
|
110
|
+
delete s.state.__TOTP_NEW;
|
|
111
|
+
return back(reply, s, `${BASE}/security`);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
app.post(`${BASE}/security/totp/confirm`, async (req: Req, reply) => {
|
|
115
|
+
const s = await developer(req, reply);
|
|
116
|
+
if (!s) return;
|
|
117
|
+
const tx = tr(s);
|
|
118
|
+
let pending: { secret: string; at: number } | null = null;
|
|
119
|
+
try {
|
|
120
|
+
pending = JSON.parse(String(s.state.__TOTP_NEW ?? ''));
|
|
121
|
+
} catch {
|
|
122
|
+
pending = null;
|
|
123
|
+
}
|
|
124
|
+
if (!pending || Date.now() - pending.at > SECRET_MINUTES * 60_000) {
|
|
125
|
+
delete s.state.__TOTP_NEW;
|
|
126
|
+
flash(s, tx('mfa.setup_expired'), 'error');
|
|
127
|
+
return back(reply, s, `${BASE}/security`);
|
|
128
|
+
}
|
|
129
|
+
const step = matchTotp(unbase32(pending.secret), String(req.body?.code ?? '').replace(/\s/g, ''), null);
|
|
130
|
+
if (step === null) {
|
|
131
|
+
flash(s, tx('mfa.wrong_setup'), 'error');
|
|
132
|
+
return back(reply, s, `${BASE}/security`);
|
|
133
|
+
}
|
|
134
|
+
const first = !(await hasFactor(s.username!));
|
|
135
|
+
await addTotp(s.username!, String(req.body?.name ?? ''), pending.secret, step);
|
|
136
|
+
delete s.state.__TOTP_NEW;
|
|
137
|
+
delete s.state.__MFA_DUE;
|
|
138
|
+
await logActivity({ username: s.username, event: '2fa_enrolled', ip: clientIp(req), detail: 'authenticator app' });
|
|
139
|
+
// the first app: recovery codes at once (they count only once the developer saved them)
|
|
140
|
+
if (first || !(await codesLeft(s.username!))) s.state.__CODES_NEW = JSON.stringify(await newRecoveryCodes(s.username!));
|
|
141
|
+
flash(s, tx('mfa.app_added'));
|
|
142
|
+
return back(reply, s, `${BASE}/security#codes`);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
app.post(`${BASE}/security/codes`, async (req: Req, reply) => {
|
|
146
|
+
const s = await developer(req, reply);
|
|
147
|
+
if (!s) return;
|
|
148
|
+
if (!(await hasFactor(s.username!))) return back(reply, s, `${BASE}/security`);
|
|
149
|
+
s.state.__CODES_NEW = JSON.stringify(await newRecoveryCodes(s.username!));
|
|
150
|
+
await logActivity({ username: s.username, event: '2fa_codes', ip: clientIp(req), detail: 'new recovery codes' });
|
|
151
|
+
return back(reply, s, `${BASE}/security#codes`);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
app.post(`${BASE}/security/codes/confirm`, async (req: Req, reply) => {
|
|
155
|
+
const s = await developer(req, reply);
|
|
156
|
+
if (!s) return;
|
|
157
|
+
if (req.body?.saved !== '1') return back(reply, s, `${BASE}/security#codes`);
|
|
158
|
+
await confirmRecoveryCodes(s.username!);
|
|
159
|
+
delete s.state.__CODES_NEW;
|
|
160
|
+
flash(s, tr(s)('mfa.codes_kept'));
|
|
161
|
+
return back(reply, s, `${BASE}/security`);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
app.post(`${BASE}/security/factor/:id(^\\d{1,9}$)/delete`, async (req: Req, reply) => {
|
|
165
|
+
const s = await developer(req, reply);
|
|
166
|
+
if (!s) return;
|
|
167
|
+
const tx = tr(s);
|
|
168
|
+
const me = s.username!;
|
|
169
|
+
const id = Number(req.params.id);
|
|
170
|
+
const f = await owner.one('select id from meta.developer_factor where id = $1 and username = $2', [id, me]);
|
|
171
|
+
if (!f) return reply.code(404).send('Not found');
|
|
172
|
+
if (!(await verifyPassword(me, String(req.body?.[`password_${id}`] ?? '')))) {
|
|
173
|
+
await logActivity({ username: me, event: 'login_failed', ip: clientIp(req), detail: 'builder (removing a second factor)' });
|
|
174
|
+
flash(s, tx('mfa.wrong_password'), 'error');
|
|
175
|
+
return back(reply, s, `${BASE}/security`);
|
|
176
|
+
}
|
|
177
|
+
const last = (await factorsOf(me)).length === 1;
|
|
178
|
+
if (last) {
|
|
179
|
+
const pol = await mfaPolicy();
|
|
180
|
+
const admin = await isAdmin(me);
|
|
181
|
+
if (requiredFor(pol.policy, admin)) {
|
|
182
|
+
flash(s, tx('mfa.cannot_remove_required'), 'error');
|
|
183
|
+
return back(reply, s, `${BASE}/security`);
|
|
184
|
+
}
|
|
185
|
+
// (SEC-6) never fewer administrators with a factor than the policy needs
|
|
186
|
+
if (admin && pol.policy !== 'off') {
|
|
187
|
+
const { admins, enrolled } = await adminFactors();
|
|
188
|
+
if (enrolled - 1 < Math.min(2, admins)) {
|
|
189
|
+
flash(s, tx('mfa.cannot_remove_admin'), 'error');
|
|
190
|
+
return back(reply, s, `${BASE}/security`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
await owner.query('delete from meta.developer_factor where id = $1 and username = $2', [id, me]);
|
|
195
|
+
if (last) await owner.query('delete from meta.developer_recovery_code where username = $1', [me]);
|
|
196
|
+
await logActivity({ username: me, event: '2fa_removed', ip: clientIp(req), detail: last ? 'the last authenticator app (recovery codes removed)' : 'an authenticator app' });
|
|
197
|
+
flash(s, tx('mfa.removed'));
|
|
198
|
+
return back(reply, s, `${BASE}/security`);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// (SEC-6) an administrator resets another developer's second factors: logged, the developer and the administrators told
|
|
202
|
+
app.post(`${BASE}/developers/reset-2fa`, async (req: Req, reply) => {
|
|
203
|
+
const s = await developer(req, reply);
|
|
204
|
+
if (!s) return;
|
|
205
|
+
const tx = tr(s);
|
|
206
|
+
if (!(await isAdmin(s.username))) return reply.code(403).send('Only administrators reset second factors.');
|
|
207
|
+
const who = String(req.body?.username ?? '');
|
|
208
|
+
const reason = String(req.body?.[`reason_${who.replace(/[^\w]/g, '_')}`] ?? req.body?.reason ?? '').trim().slice(0, 200);
|
|
209
|
+
if (who === s.username) return reply.code(400).send('Use your own Security page.');
|
|
210
|
+
if (!reason) {
|
|
211
|
+
flash(s, tx('mfa.reason_needed'), 'error');
|
|
212
|
+
return back(reply, s, `${BASE}/developers`);
|
|
213
|
+
}
|
|
214
|
+
if (!(await resetFactors(who))) return reply.code(404).send('Not found');
|
|
215
|
+
await logActivity({ username: s.username, event: '2fa_reset', ip: clientIp(req), detail: `${who}: ${reason}` });
|
|
216
|
+
const dev = await owner.one<{ email: string | null }>('select email from meta.developer where username = $1', [who]);
|
|
217
|
+
await notifyMfa([dev?.email ?? '', ...(await adminEmails())], `pgkiln: two-factor sign-in of ${who} reset`,
|
|
218
|
+
`${s.username} removed the second factors of developer ${who} (reason: ${reason}). ${who} signs in with the password only until they set up an authenticator app again.`);
|
|
219
|
+
flash(s, tx('mfa.reset_done', { user: who }));
|
|
220
|
+
return back(reply, s, `${BASE}/developers`);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// (SEC-2, SEC-6) the instance's policy: off, administrators or all developers, with a grace period
|
|
224
|
+
app.post(`${BASE}/instance/mfa`, async (req: Req, reply) => {
|
|
225
|
+
const s = await developer(req, reply);
|
|
226
|
+
if (!s) return;
|
|
227
|
+
const tx = tr(s);
|
|
228
|
+
if (!(await isAdmin(s.username))) return reply.code(403).send('Only administrators change the policy.');
|
|
229
|
+
const policy = MFA_POLICIES.includes(req.body?.policy as MfaPolicy) ? (req.body!.policy as MfaPolicy) : null;
|
|
230
|
+
const grace = Number(req.body?.grace_days);
|
|
231
|
+
if (!policy || !Number.isInteger(grace) || grace < 0 || grace > 90) return reply.code(400).send('policy is off, admins or all; grace_days 0 to 90');
|
|
232
|
+
if (await policyProblem(policy)) {
|
|
233
|
+
flash(s, tx('mfa.policy_needs_admins'), 'error');
|
|
234
|
+
return back(reply, s, `${BASE}/instance#mfa`);
|
|
235
|
+
}
|
|
236
|
+
await setPolicy(policy, grace);
|
|
237
|
+
await logActivity({ username: s.username, event: '2fa_policy', ip: clientIp(req), detail: `${policy}, grace ${grace} days` });
|
|
238
|
+
await notifyMfa(await adminEmails(), `pgkiln: two-factor sign-in policy is now ${policy}`, `${s.username} set the two-factor policy to ${policy} (grace period ${grace} days).`);
|
|
239
|
+
flash(s, tx('mfa.policy_saved'));
|
|
240
|
+
return back(reply, s, `${BASE}/instance#mfa`);
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The Instance settings region for the policy (administrators). */
|
|
245
|
+
export async function policyRegion(s: Session): Promise<Raw> {
|
|
246
|
+
const tx = tr(s);
|
|
247
|
+
const [pol, af, without] = await Promise.all([
|
|
248
|
+
mfaPolicy(), adminFactors(),
|
|
249
|
+
owner.query<{ username: string; is_admin: boolean }>('select username, is_admin from meta.developer d where not exists (select 1 from meta.developer_factor f where f.username = d.username) order by username'),
|
|
250
|
+
]);
|
|
251
|
+
const opt = (v: MfaPolicy) => html`<option value="${v}"${pol.policy === v ? raw(' selected') : ''}>${tx(({ off: 'mfa.policy_off', admins: 'mfa.policy_admins', all: 'mfa.policy_all' } as const)[v])}</option>`;
|
|
252
|
+
return region(tx('mfa.policy_title'), html`<form method="post" action="${BASE}/instance/mfa">${csrf(s)}
|
|
253
|
+
<div class="form-grid">
|
|
254
|
+
<div class="field"><label class="label" for="f_policy">${tx('mfa.policy_label')}</label><select id="f_policy" name="policy">${MFA_POLICIES.map(opt)}</select></div>
|
|
255
|
+
${input('grace_days', tx('mfa.grace'), pol.graceDays, { type: 'number', help: tx('mfa.grace_help') })}
|
|
256
|
+
</div>
|
|
257
|
+
<p class="muted small">${tx('mfa.policy_help', { enrolled: af.enrolled, admins: af.admins })}</p>
|
|
258
|
+
<div class="buttons"><button class="btn btn-hot">${tx('mfa.policy_save')}</button></div></form>
|
|
259
|
+
${without.rows.length ? html`<p class="small">${tx('mfa.without', { list: without.rows.map((d) => `${d.username}${d.is_admin ? ' (admin)' : ''}`).join(', ') })}</p>` : ''}
|
|
260
|
+
<p class="muted small">${tx('mfa.cli_note')}</p>`, '', 'mfa');
|
|
261
|
+
}
|
package/src/builder/shared.ts
CHANGED
|
@@ -253,7 +253,7 @@ export async function sharedRoutes(app: FastifyInstance) {
|
|
|
253
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
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
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>
|
|
256
|
-
${roleHintsHtml(appHints, '
|
|
256
|
+
${roleHintsHtml(appHints, tx('us.hints_add'), tx)}</form></td>
|
|
257
257
|
<td data-label="${tx('sc.last_signin')}">${u.last_login_at ? String(u.last_login_at).slice(0, 16) : '—'}</td>
|
|
258
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>
|
|
259
259
|
</tr>`)
|
|
@@ -269,7 +269,7 @@ export async function sharedRoutes(app: FastifyInstance) {
|
|
|
269
269
|
<small class="help">${tx('sc.existing')} <a href="${BASE}/users">${tx('sc.create_in_users')}</a></small></div>
|
|
270
270
|
<div class="field"><label class="label" for="f_roles">${tx('sc.roles')}</label>
|
|
271
271
|
<input id="f_roles" name="roles" placeholder="${tx('sc.roles_ph')}">
|
|
272
|
-
${roleHintsHtml(appHints)}</div>
|
|
272
|
+
${roleHintsHtml(appHints, undefined, tx)}</div>
|
|
273
273
|
</div>
|
|
274
274
|
<div class="buttons"><button class="btn btn-hot">${tx('sc.grant')}</button></div>
|
|
275
275
|
</form>`);
|