Sign in through an OpenID Connect provider, and from a terminal
terdut can now sign people in through any OIDC provider (written against Authentik), and let groups at the provider decide who may sign in, which teams they belong to and whether they administer the install. Password login keeps working alongside it; TERDUT_PASSWORD_LOGIN=false turns it off, and is refused at startup unless SSO is configured. With no TERDUT_OIDC_* setting nothing changes, so every existing install behaves as before. Identity is (issuer, subject), never email or username: those are mutable at the provider and a recycled address must not inherit an account. An existing user is linked by email only when the provider marks it verified, or TERDUT_OIDC_TRUST_EMAIL is set, which Authentik needs. Group grants are marked source='oidc' on team_members and users, and the sync changes only those rows. Hand-made memberships and administrators are left alone, and the sync bypasses the last-owner and last-admin guards because the provider is the source of truth for what it grants. Editing managed access by hand is refused with 409, since the next sign-in would undo it. The web UI badges it as SSO and disables the controls. Groups are read only at sign-in, so an SSO session carries a hard ceiling (sessions.max_expires_at, 12h by default) that sliding never extends. There is no refresh token, which means API keys of somebody removed at the provider stay valid until an administrator disables the user. That is accepted and documented, not fixed. A client with no browser, the TUI over SSH, signs in with a device code run by terdut itself (POST /api/oidc/device and /device/token), so the terminal never talks to the provider and ends up with the ordinary terdut_session cookie. Only a browser session can approve a code; an API key cannot. /device?code= sends a signed-out visitor through sign-in and back, which is what oidc_logins.next is for. oauth2 is pinned to v0.36.0: v0.37 needs Go 1.26 and the Dockerfile builds on 1.25. Migrations 011 and 012 add tables and defaulted columns only.
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func read(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
sub, err := fs.Sub(files, "static")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := fs.ReadFile(sub, name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// The sign-in button has to be a link the browser navigates, not script: the
|
||||
// CSP's connect-src is 'self', so a fetch to the identity provider is blocked,
|
||||
// and it is a redirect to the provider that the server answers.
|
||||
func TestLoginPageOffersSSOAsAPlainLink(t *testing.T) {
|
||||
html := read(t, "index.html")
|
||||
if !regexp.MustCompile(`<a[^>]*id="sso-link"[^>]*href="/api/oidc/login"|<a[^>]*href="/api/oidc/login"[^>]*id="sso-link"`).MatchString(html) {
|
||||
t.Error("index.html has no <a id=sso-link href=/api/oidc/login>")
|
||||
}
|
||||
if !strings.Contains(html, `id="password-login"`) {
|
||||
t.Error("the password fields must sit in #password-login so a server can hide them")
|
||||
}
|
||||
}
|
||||
|
||||
// Every code the server can put in ?sso_error= must have a message, or a
|
||||
// refused person sees a generic failure and cannot tell what to ask for.
|
||||
func TestLoginExplainsEverySSOError(t *testing.T) {
|
||||
js := read(t, "js/app.js")
|
||||
for _, code := range []string{
|
||||
"denied", "expired", "failed", "unavailable",
|
||||
"not_allowed", "no_email", "email_conflict", "disabled",
|
||||
} {
|
||||
if !regexp.MustCompile(`\b` + code + `:`).MatchString(js) {
|
||||
t.Errorf("app.js has no message for sso_error=%s", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The page a terminal's prompt links to has to be reachable as a route, or the
|
||||
// link 404s into the queue and the code is never seen.
|
||||
func TestDevicePageIsRoutedAndCallsTheApprovalAPI(t *testing.T) {
|
||||
if !strings.Contains(read(t, "index.html"), `id="view-device"`) {
|
||||
t.Error("index.html has no #view-device section")
|
||||
}
|
||||
app := read(t, "js/app.js")
|
||||
if !strings.Contains(app, "name === 'device'") || !strings.Contains(app, "device: {") {
|
||||
t.Error("app.js does not route /device")
|
||||
}
|
||||
// The SSO button must carry the page asked for through the provider.
|
||||
if !strings.Contains(app, "/api/oidc/login?next=") {
|
||||
t.Error("the SSO link does not carry next=")
|
||||
}
|
||||
dev := read(t, "js/device.js")
|
||||
for _, want := range []string{"approveDevice", "denyDevice"} {
|
||||
if !strings.Contains(dev, want) {
|
||||
t.Errorf("device.js never calls %s", want)
|
||||
}
|
||||
}
|
||||
api := read(t, "js/api.js")
|
||||
for _, want := range []string{"/oidc/device/approve", "/oidc/device/deny"} {
|
||||
if !strings.Contains(api, want) {
|
||||
t.Errorf("api.js has no call to %s", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SSO-managed access must be marked in every view that edits it.
|
||||
func TestManagedAccessIsMarkedWhereItIsEdited(t *testing.T) {
|
||||
for _, file := range []string{"js/team.js", "js/adminteam.js", "js/adminuser.js", "js/admin.js"} {
|
||||
js := read(t, file)
|
||||
if !strings.Contains(js, "ssoBadge") || !strings.Contains(js, "SSO_MANAGED") {
|
||||
t.Errorf("%s does not mark or explain SSO-managed access", file)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,6 +137,13 @@ h1, h2, h3 { margin: 0; line-height: 1.25; }
|
||||
.login-brand { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
|
||||
.login-brand h1 { font-size: 24px; letter-spacing: -0.01em; }
|
||||
.login-hint { color: var(--faint); font-size: 13px; margin: 4px 0 0; }
|
||||
.login-password { display: grid; gap: 14px; }
|
||||
/* "or" between the single sign-on button and the password form. */
|
||||
.login-divider {
|
||||
display: flex; align-items: center; gap: 10px; margin: 0;
|
||||
color: var(--faint); font-size: 13px;
|
||||
}
|
||||
.login-divider::before, .login-divider::after { content: ''; flex: 1; height: 1px; background: var(--border); }
|
||||
|
||||
label { display: grid; gap: 6px; }
|
||||
label > span { font-size: 13px; font-weight: 600; color: var(--muted); }
|
||||
@@ -327,6 +334,16 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
||||
background: var(--surface-2); border: 1px solid var(--border);
|
||||
color: var(--muted); font-size: 12px; white-space: nowrap;
|
||||
}
|
||||
/* The page a terminal's sign-in prompt links to. */
|
||||
.device-card { max-width: 420px; margin: 24px auto; padding: 20px; display: grid; gap: 14px; }
|
||||
.device-code {
|
||||
margin: 0; padding: 14px; text-align: center;
|
||||
font: 700 30px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
letter-spacing: 0.12em;
|
||||
background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||
}
|
||||
/* Access the identity provider's groups grant. The tint says "not yours to edit here". */
|
||||
.row-team.sso { margin-left: 6px; background: var(--accent-soft); border-color: transparent; color: var(--accent); }
|
||||
.row.resolved .row-title { color: var(--muted); }
|
||||
|
||||
.sev-critical { --sev: var(--crit); }
|
||||
|
||||
@@ -25,20 +25,29 @@
|
||||
<img src="/icon.svg" alt="" width="40" height="40">
|
||||
<h1>terdut</h1>
|
||||
</div>
|
||||
<label>
|
||||
<span>Username</span>
|
||||
<input name="username" autocomplete="username" autocapitalize="none" spellcheck="false" required>
|
||||
</label>
|
||||
<label>
|
||||
<span>Password</span>
|
||||
<input name="password" type="password" autocomplete="current-password" required>
|
||||
</label>
|
||||
<p class="form-error" role="alert" hidden></p>
|
||||
<button class="btn btn-primary btn-block" type="submit">Sign in</button>
|
||||
<p class="login-hint">No password yet? Ask an admin to set one, or run
|
||||
<code>PUT /api/users/{id}/password</code> with your API key.</p>
|
||||
<p class="login-hint" id="signup-link" hidden>
|
||||
No account? <a href="/signup">Create one</a>.</p>
|
||||
<!-- Why a sign-in failed, when the identity provider sent the browser back
|
||||
here with ?sso_error=. Kept apart from the password form's own error. -->
|
||||
<p class="form-error" id="sso-error" role="alert" hidden></p>
|
||||
<!-- A plain link, not a fetch: the browser has to navigate to the provider,
|
||||
and the page's CSP allows no connection to anywhere else. -->
|
||||
<a class="btn btn-primary btn-block" id="sso-link" href="/api/oidc/login" hidden>Sign in with SSO</a>
|
||||
<p class="login-divider" id="login-or" hidden><span>or</span></p>
|
||||
<div class="login-password" id="password-login">
|
||||
<label>
|
||||
<span>Username</span>
|
||||
<input name="username" autocomplete="username" autocapitalize="none" spellcheck="false" required>
|
||||
</label>
|
||||
<label>
|
||||
<span>Password</span>
|
||||
<input name="password" type="password" autocomplete="current-password" required>
|
||||
</label>
|
||||
<p class="form-error" role="alert" hidden></p>
|
||||
<button class="btn btn-primary btn-block" type="submit">Sign in</button>
|
||||
<p class="login-hint">No password yet? Ask an admin to set one, or run
|
||||
<code>PUT /api/users/{id}/password</code> with your API key.</p>
|
||||
<p class="login-hint" id="signup-link" hidden>
|
||||
No account? <a href="/signup">Create one</a>.</p>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Sign-up. Shown instead of the login card at /signup, and only offers
|
||||
@@ -142,6 +151,8 @@
|
||||
which the Team tab cannot show for a team you are not a member of. -->
|
||||
<section id="view-adminteam" class="view view-page" data-view="adminteam" hidden></section>
|
||||
<section id="view-more" class="view view-page" data-view="more" hidden></section>
|
||||
<!-- Approve a terminal's sign-in, at /device?code=...: the page its prompt links to. -->
|
||||
<section id="view-device" class="view view-page" data-view="device" hidden></section>
|
||||
</div>
|
||||
|
||||
<dialog id="sheet" class="sheet"></dialog>
|
||||
|
||||
@@ -26,8 +26,7 @@ function render() {
|
||||
h('div', { class: 'page-head' }, h('h2', { text: 'Notifications' })),
|
||||
notifyForm(user),
|
||||
|
||||
h('div', { class: 'page-head' }, h('h2', { text: hasPassword ? 'Change password' : 'Set a password' })),
|
||||
passwordForm(user, hasPassword),
|
||||
...passwordSection(user, hasPassword),
|
||||
|
||||
h('div', { class: 'only-desktop' },
|
||||
h('div', { class: 'page-head' }, h('h2', { text: 'Keyboard' })),
|
||||
@@ -119,6 +118,24 @@ function notifyForm(user) {
|
||||
return form;
|
||||
}
|
||||
|
||||
// With password login switched off a password opens nothing, so somebody who
|
||||
// has none is not asked to make one. Somebody who does keeps the form: it is
|
||||
// how they change or get rid of a credential the server still remembers.
|
||||
function passwordSection(user, hasPassword) {
|
||||
if (!hasPassword && state.auth.password_login === false) {
|
||||
const name = state.auth.oidc?.name || 'single sign-on';
|
||||
return [
|
||||
h('div', { class: 'page-head' }, h('h2', { text: 'Password' })),
|
||||
h('div', { class: 'card' },
|
||||
h('p', { class: 'muted', text: `You sign in with ${name}, and this server has turned password login off.` })),
|
||||
];
|
||||
}
|
||||
return [
|
||||
h('div', { class: 'page-head' }, h('h2', { text: hasPassword ? 'Change password' : 'Set a password' })),
|
||||
passwordForm(user, hasPassword),
|
||||
];
|
||||
}
|
||||
|
||||
function passwordForm(user, hasPassword) {
|
||||
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
|
||||
const ok = h('p', { class: 'form-ok', role: 'status', hidden: true });
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// gate.
|
||||
|
||||
import * as api from './api.js';
|
||||
import { h, clear, spinner, confirm, menuCard } from './ui.js';
|
||||
import { h, clear, spinner, confirm, menuCard, ssoBadge, SSO_MANAGED } from './ui.js';
|
||||
import { state, myID } from './state.js';
|
||||
|
||||
const view = () => document.getElementById('view-admin');
|
||||
@@ -197,7 +197,9 @@ function usersCard() {
|
||||
u.disabled_at && h('span', { class: 'row-team', text: 'disabled' }),
|
||||
self && h('span', { class: 'you', text: 'you' })),
|
||||
h('td', { class: 'muted', text: u.email }),
|
||||
h('td', {}, u.is_admin ? h('span', { class: 'row-team', text: 'admin' }) : null),
|
||||
h('td', {},
|
||||
u.is_admin ? h('span', { class: 'row-team', text: 'admin' }) : null,
|
||||
u.is_admin && u.admin_source === 'oidc' ? ssoBadge() : null),
|
||||
h('td', {},
|
||||
// Neither action is offered for your own account: the server refuses
|
||||
// both, and an enabled-looking button that always fails is worse than
|
||||
@@ -206,6 +208,9 @@ function usersCard() {
|
||||
class: 'btn-sm',
|
||||
type: 'button',
|
||||
text: u.is_admin ? 'Revoke admin' : 'Make admin',
|
||||
// The server refuses to revoke what the groups grant.
|
||||
disabled: ssoAdmin(u),
|
||||
title: ssoAdmin(u) ? SSO_MANAGED : null,
|
||||
onclick: () => setAdmin(u, !u.is_admin),
|
||||
}),
|
||||
!self && h('button', {
|
||||
@@ -255,6 +260,8 @@ function invitePointer() {
|
||||
);
|
||||
}
|
||||
|
||||
const ssoAdmin = (u) => u.is_admin && u.admin_source === 'oidc';
|
||||
|
||||
async function setAdmin(user, next) {
|
||||
if (next && !(await confirm({
|
||||
title: `Make ${user.username} an administrator?`,
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
// gate.
|
||||
|
||||
import * as api from './api.js';
|
||||
import { h, clear, spinner, confirm, toast, icon } from './ui.js';
|
||||
import { h, clear, spinner, confirm, toast, icon, ssoBadge, SSO_MANAGED } from './ui.js';
|
||||
import { state } from './state.js';
|
||||
import { navigate } from './app.js';
|
||||
import { when } from './format.js';
|
||||
@@ -166,17 +166,22 @@ function membersCard() {
|
||||
// Unlike the Team tab's own member list, the name is a link: that
|
||||
// person's page is where the rest of them lives.
|
||||
h('td', {}, h('a', { class: 'row-link', href: `/admin/users/${m.user_id}`, text: m.username })),
|
||||
h('td', { class: 'muted small', text: m.role }),
|
||||
h('td', { class: 'muted small' }, m.role, m.source === 'oidc' && ssoBadge()),
|
||||
h('td', { class: 'row-actions' },
|
||||
h('button', {
|
||||
class: 'btn-sm', type: 'button',
|
||||
text: m.role === 'owner' ? 'Make member' : 'Make owner',
|
||||
// The server refuses to edit a membership the groups grant.
|
||||
disabled: m.source === 'oidc',
|
||||
title: m.source === 'oidc' ? SSO_MANAGED : null,
|
||||
// The same endpoint both ways: adding is an upsert on the role.
|
||||
onclick: () => act(() =>
|
||||
api.addTeamMember(teamID, m.user_id, m.role === 'owner' ? 'member' : 'owner')),
|
||||
}),
|
||||
h('button', {
|
||||
class: 'btn-sm danger', type: 'button', text: 'Remove',
|
||||
disabled: m.source === 'oidc',
|
||||
title: m.source === 'oidc' ? SSO_MANAGED : null,
|
||||
// The server refuses the last owner with a 409, which act() shows.
|
||||
onclick: () => act(() => api.removeTeamMember(teamID, m.user_id)),
|
||||
}),
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
// enforcement of them.
|
||||
|
||||
import * as api from './api.js';
|
||||
import { h, clear, spinner, confirm, toast, icon } from './ui.js';
|
||||
import { h, clear, spinner, confirm, toast, icon, ssoBadge, SSO_MANAGED } from './ui.js';
|
||||
import { state, myID } from './state.js';
|
||||
import { navigate } from './app.js';
|
||||
import { when } from './format.js';
|
||||
@@ -96,6 +96,7 @@ function identityCard() {
|
||||
h('div', { class: 'user-head' },
|
||||
h('h2', { text: u.username }),
|
||||
u.is_admin && h('span', { class: 'row-team', text: 'admin' }),
|
||||
u.is_admin && u.admin_source === 'oidc' && ssoBadge(),
|
||||
u.disabled_at && h('span', { class: 'row-team', text: 'disabled' }),
|
||||
self && h('span', { class: 'you', text: 'you' })),
|
||||
h('dl', { class: 'user-facts' },
|
||||
@@ -110,6 +111,9 @@ function identityCard() {
|
||||
!self && h('button', {
|
||||
class: 'btn', type: 'button',
|
||||
text: u.is_admin ? 'Revoke admin' : 'Make admin',
|
||||
// The server refuses to revoke what the groups grant.
|
||||
disabled: u.is_admin && u.admin_source === 'oidc',
|
||||
title: u.is_admin && u.admin_source === 'oidc' ? SSO_MANAGED : null,
|
||||
onclick: () => setAdmin(!u.is_admin),
|
||||
}),
|
||||
!self && h('button', {
|
||||
@@ -162,16 +166,21 @@ function teamsCard() {
|
||||
// Not a link: the Team tab always shows the viewer's own team, so
|
||||
// sending them there from somebody else's membership would be a lie.
|
||||
h('td', {}, h('strong', { text: t.name })),
|
||||
h('td', { class: 'muted small', text: t.role }),
|
||||
h('td', { class: 'muted small' }, t.role, t.source === 'oidc' && ssoBadge()),
|
||||
h('td', { class: 'row-actions' },
|
||||
h('button', {
|
||||
class: 'btn-sm', type: 'button',
|
||||
text: t.role === 'owner' ? 'Make member' : 'Make owner',
|
||||
// The server refuses to edit a membership the groups grant.
|
||||
disabled: t.source === 'oidc',
|
||||
title: t.source === 'oidc' ? SSO_MANAGED : null,
|
||||
onclick: () => act(() =>
|
||||
api.addTeamMember(t.id, userID, t.role === 'owner' ? 'member' : 'owner')),
|
||||
}),
|
||||
h('button', {
|
||||
class: 'btn-sm danger', type: 'button', text: 'Remove',
|
||||
disabled: t.source === 'oidc',
|
||||
title: t.source === 'oidc' ? SSO_MANAGED : null,
|
||||
// The server refuses the last owner with a 409, which act() shows.
|
||||
onclick: () => act(() => api.removeTeamMember(t.id, userID)),
|
||||
}),
|
||||
|
||||
@@ -59,8 +59,13 @@ async function call(method, path, { query, body, signal } = {}) {
|
||||
|
||||
// session
|
||||
export const me = () => call('GET', '/me');
|
||||
// How this server can be signed in to: { password_login, oidc: { enabled, name } }.
|
||||
export const authConfig = () => call('GET', '/auth/config');
|
||||
export const login = (username, password) => call('POST', '/login', { body: { username, password } });
|
||||
export const logout = () => call('POST', '/logout');
|
||||
// Approve or refuse a sign-in a terminal started; code is what it is showing.
|
||||
export const approveDevice = (code) => call('POST', '/oidc/device/approve', { body: { user_code: code } });
|
||||
export const denyDevice = (code) => call('POST', '/oidc/device/deny', { body: { user_code: code } });
|
||||
export const setPassword = (userID, password, currentPassword) =>
|
||||
call('PUT', `/users/${userID}/password`, { body: { password, current_password: currentPassword } });
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import * as team from './team.js';
|
||||
import * as admin from './admin.js';
|
||||
import * as adminuser from './adminuser.js';
|
||||
import * as adminteam from './adminteam.js';
|
||||
import * as device from './device.js';
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
@@ -30,6 +31,8 @@ const SECTIONS = {
|
||||
adminuser: { title: 'User', view: adminuser, nav: 'admin' },
|
||||
adminteam: { title: 'Team', view: adminteam, nav: 'admin' },
|
||||
more: { title: 'Account', view: account },
|
||||
// Reached by link from a terminal's sign-in prompt, not from the nav.
|
||||
device: { title: 'Sign in a terminal', view: device },
|
||||
};
|
||||
|
||||
// The mobile hamburger menu's contents — the same sections the desktop
|
||||
@@ -61,7 +64,7 @@ function parseRoute(pathname) {
|
||||
if (t) return { section: 'admin', tab: t.tab };
|
||||
const tt = team.TABS.find((x) => x.path === `/${name}`);
|
||||
if (tt) return { section: 'team', tab: tt.tab };
|
||||
if (name === 'oncall' || name === 'alerts' || name === 'stats' || name === 'more') return { section: name };
|
||||
if (name === 'oncall' || name === 'alerts' || name === 'stats' || name === 'more' || name === 'device') return { section: name };
|
||||
return { section: 'queue', incident: null };
|
||||
}
|
||||
|
||||
@@ -227,6 +230,7 @@ async function boot() {
|
||||
$('login-form').addEventListener('submit', onLogin);
|
||||
$('signup-form').addEventListener('submit', onSignup);
|
||||
$('menu-btn').addEventListener('click', openNavMenu);
|
||||
ssoErrorCode = takeSSOError();
|
||||
|
||||
// /signup is the one route that works without a session.
|
||||
if (location.pathname.replace(/\/$/, '') === '/signup') {
|
||||
@@ -236,6 +240,9 @@ async function boot() {
|
||||
}
|
||||
|
||||
try {
|
||||
// Before anything renders: the account view decides from it whether a
|
||||
// password is worth offering to set.
|
||||
await loadAuthConfig();
|
||||
state.me = await api.me();
|
||||
await loadTeams();
|
||||
// The Admin tab exists only for an administrator. Somebody who types /admin
|
||||
@@ -331,17 +338,87 @@ async function onSignup(e) {
|
||||
}
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
// Why single sign-on sent the browser back, by the code the server puts in
|
||||
// ?sso_error=. The provider's name is the one the administrator configured.
|
||||
function ssoErrorText(code, name) {
|
||||
const sso = name || 'single sign-on';
|
||||
return {
|
||||
denied: `Signing in with ${sso} was cancelled or refused.`,
|
||||
expired: 'That sign-in expired or was already used. Try again.',
|
||||
failed: `Signing in with ${sso} failed. Try again, and tell an administrator if it keeps happening.`,
|
||||
unavailable: `${sso} could not be reached. Try again in a moment.`,
|
||||
not_allowed: 'Your account is not allowed to use terdut. Ask an administrator to add you to the right group.',
|
||||
no_email: `${sso} did not send an email address for you, which terdut needs.`,
|
||||
email_conflict: 'An account with your email address already exists and could not be linked to this sign-in. Ask an administrator.',
|
||||
disabled: 'Your account is disabled. Ask an administrator.',
|
||||
}[code] || `Signing in with ${sso} failed.`;
|
||||
}
|
||||
|
||||
// Reads how the server can be signed in to. An older server has no such
|
||||
// endpoint, and one that cannot be asked is treated as offering passwords only:
|
||||
// the form that always existed is better than a blank page.
|
||||
async function loadAuthConfig() {
|
||||
try {
|
||||
state.auth = await api.authConfig();
|
||||
} catch {
|
||||
/* keep the last answer, or the defaults */
|
||||
}
|
||||
return state.auth;
|
||||
}
|
||||
|
||||
// The reason the last single sign-on attempt failed, read once at boot. It is
|
||||
// held here rather than re-read from the address because showLogin runs more
|
||||
// than once on the way to the form (the 401 from /api/me reaches it through the
|
||||
// API layer and again through boot's own catch), and only the first would see it.
|
||||
let ssoErrorCode = null;
|
||||
|
||||
// Takes ?sso_error= off the address, so a reload does not repeat the message.
|
||||
function takeSSOError() {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const code = params.get('sso_error');
|
||||
if (code === null) return null;
|
||||
params.delete('sso_error');
|
||||
const query = params.toString();
|
||||
history.replaceState(null, '', location.pathname + (query ? `?${query}` : '') + location.hash);
|
||||
return code;
|
||||
}
|
||||
|
||||
async function showLogin() {
|
||||
poll.stop();
|
||||
ui.closeSheet(null);
|
||||
reset();
|
||||
$('boot').hidden = true;
|
||||
$('app').hidden = true;
|
||||
$('login').hidden = false;
|
||||
$('signup-form').hidden = true;
|
||||
|
||||
// Ask before showing anything, so the form does not flash the password
|
||||
// fields at somebody whose server has turned them off.
|
||||
const auth = await loadAuthConfig();
|
||||
const sso = auth.oidc?.enabled ? auth.oidc : null;
|
||||
const passwords = auth.password_login !== false;
|
||||
|
||||
const link = $('sso-link');
|
||||
link.hidden = !sso;
|
||||
if (sso) {
|
||||
link.textContent = `Sign in with ${sso.name || 'SSO'}`;
|
||||
// Come back to the page that was asked for: a link to /device?code=... has
|
||||
// to survive the trip through the provider. The server only honours paths
|
||||
// on this server, and ignores the front page.
|
||||
const here = location.pathname + location.search;
|
||||
link.href = here === '/' ? '/api/oidc/login' : `/api/oidc/login?next=${encodeURIComponent(here)}`;
|
||||
}
|
||||
$('login-or').hidden = !(sso && passwords);
|
||||
$('password-login').hidden = !passwords;
|
||||
|
||||
const ssoErr = $('sso-error');
|
||||
ssoErr.hidden = ssoErrorCode === null;
|
||||
if (ssoErrorCode !== null) ssoErr.textContent = ssoErrorText(ssoErrorCode, sso?.name);
|
||||
|
||||
$('boot').hidden = true;
|
||||
$('login').hidden = false;
|
||||
$('login-form').hidden = false;
|
||||
const form = $('login-form');
|
||||
form.querySelector('.form-error').hidden = true;
|
||||
form.querySelector('#password-login .form-error').hidden = true;
|
||||
if (!passwords) return;
|
||||
// Only offer the door that is open. Somebody without an invite on an
|
||||
// invite-only server should be told, not sent to a form that refuses them.
|
||||
api.signupInfo().then((info) => {
|
||||
@@ -354,9 +431,13 @@ function showLogin() {
|
||||
async function onLogin(e) {
|
||||
e.preventDefault();
|
||||
const form = e.currentTarget;
|
||||
const err = form.querySelector('.form-error');
|
||||
// Not the first .form-error: that one is the single sign-on message above.
|
||||
const err = form.querySelector('#password-login .form-error');
|
||||
const btn = form.querySelector('button[type=submit]');
|
||||
err.hidden = true;
|
||||
// Whatever single sign-on said is about the last attempt, not this one.
|
||||
ssoErrorCode = null;
|
||||
$('sso-error').hidden = true;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
state.me = await api.login(form.username.value.trim(), form.password.value);
|
||||
@@ -380,6 +461,7 @@ export async function signOut() {
|
||||
}
|
||||
|
||||
function showApp() {
|
||||
ssoErrorCode = null;
|
||||
$('boot').hidden = true;
|
||||
$('login').hidden = true;
|
||||
$('app').hidden = false;
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// Approving a sign-in that a terminal started, at /device?code=XXXX-XXXX.
|
||||
//
|
||||
// The terminal (the TUI) shows a code and a link to this page. Whoever opens it
|
||||
// is already signed in — by the provider or by password, whichever the login
|
||||
// page offered — and is asked to approve. Approving hands that terminal a
|
||||
// session for *this* account, so the page names the account and the code, and
|
||||
// tells anybody who did not start this to refuse.
|
||||
|
||||
import * as api from './api.js';
|
||||
import { h, clear } from './ui.js';
|
||||
import { state } from './state.js';
|
||||
import { navigate } from './app.js';
|
||||
|
||||
const view = () => document.getElementById('view-device');
|
||||
|
||||
// What has been decided for the code on screen, so a re-render does not offer
|
||||
// to approve it a second time.
|
||||
let outcome = null; // { code, approved }
|
||||
|
||||
export function show() {
|
||||
render();
|
||||
}
|
||||
|
||||
function render() {
|
||||
const code = new URLSearchParams(location.search).get('code') || '';
|
||||
if (!code) return clear(view(), enterCode());
|
||||
if (outcome && outcome.code === code) return clear(view(), decided(outcome.approved));
|
||||
return clear(view(), confirmCard(code));
|
||||
}
|
||||
|
||||
// Reached without a code, for somebody who typed the address by hand.
|
||||
function enterCode() {
|
||||
const input = h('input', {
|
||||
name: 'code', autocomplete: 'off', autocapitalize: 'characters', spellcheck: 'false',
|
||||
placeholder: 'XXXX-XXXX', required: true,
|
||||
});
|
||||
const form = h('form', { class: 'card device-card' },
|
||||
h('h2', { text: 'Sign in a terminal' }),
|
||||
h('p', { class: 'muted', text: 'Enter the code the terminal is showing.' }),
|
||||
h('label', {}, h('span', { text: 'Code' }), input),
|
||||
h('button', { class: 'btn btn-primary', type: 'submit', text: 'Continue' }));
|
||||
form.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
navigate(`/device?code=${encodeURIComponent(input.value.trim())}`);
|
||||
});
|
||||
return form;
|
||||
}
|
||||
|
||||
function confirmCard(code) {
|
||||
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
|
||||
const approve = h('button', { class: 'btn btn-primary', type: 'button', text: 'Approve' });
|
||||
const refuse = h('button', { class: 'btn', type: 'button', text: 'Refuse' });
|
||||
|
||||
const decide = (approved) => async () => {
|
||||
err.hidden = true;
|
||||
approve.disabled = refuse.disabled = true;
|
||||
try {
|
||||
await (approved ? api.approveDevice(code) : api.denyDevice(code));
|
||||
outcome = { code, approved };
|
||||
render();
|
||||
} catch (ex) {
|
||||
err.textContent = ex.message;
|
||||
err.hidden = false;
|
||||
approve.disabled = refuse.disabled = false;
|
||||
}
|
||||
};
|
||||
approve.addEventListener('click', decide(true));
|
||||
refuse.addEventListener('click', decide(false));
|
||||
|
||||
return h('div', { class: 'card device-card' },
|
||||
h('h2', { text: 'Sign in a terminal?' }),
|
||||
h('p', {}, 'A terminal is asking to sign in as ', h('strong', { text: state.me.user.username }),
|
||||
'. Check that this code matches the one it is showing:'),
|
||||
h('p', { class: 'device-code', text: code }),
|
||||
h('p', { class: 'muted small',
|
||||
text: 'Only approve a sign-in you started yourself. Whoever is approved here acts as you.' }),
|
||||
err,
|
||||
h('div', { class: 'row-actions' }, approve, refuse));
|
||||
}
|
||||
|
||||
function decided(approved) {
|
||||
return h('div', { class: 'card device-card' },
|
||||
h('h2', { text: approved ? 'Approved' : 'Refused' }),
|
||||
h('p', { class: 'muted', text: approved
|
||||
? 'You can go back to your terminal. It signs in within a few seconds.'
|
||||
: 'That terminal will not be signed in.' }),
|
||||
h('a', { class: 'btn', href: '/', text: 'Go to the queue' }));
|
||||
}
|
||||
@@ -5,6 +5,9 @@ import * as api from './api.js';
|
||||
|
||||
export const state = {
|
||||
me: null, // { user, has_password }
|
||||
// How the server can be signed in to. The defaults are an older server's
|
||||
// answer: passwords, no single sign-on.
|
||||
auth: { password_login: true, oidc: { enabled: false, name: '' } },
|
||||
open: [], // the default queue: open, not snoozed
|
||||
teams: [], // the teams the viewer belongs to, each with their role
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
// than no form, but it is not the thing enforcing anything.
|
||||
|
||||
import * as api from './api.js';
|
||||
import { h, clear, spinner, confirm, icon, openSheet, closeSheet, menuCard, badge, labelChip } from './ui.js';
|
||||
import { h, clear, spinner, confirm, icon, openSheet, closeSheet, menuCard, badge, labelChip, ssoBadge, SSO_MANAGED } from './ui.js';
|
||||
import { state, currentTeam, users as allUsers, myID } from './state.js';
|
||||
import { isoDate, addDays, mondayOf, isoWeek, initial, ago, when, duration } from './format.js';
|
||||
|
||||
@@ -1058,19 +1058,24 @@ function membersCard() {
|
||||
h('strong', { text: m.username }),
|
||||
m.user_id === myID() && h('span', { class: 'muted small', text: ' (you)' }),
|
||||
m.problem && h('div', { class: 'target-problem', text: m.problem })),
|
||||
h('td', { class: 'muted small', text: m.role }),
|
||||
h('td', { class: 'muted small' }, m.role, m.source === 'oidc' && ssoBadge()),
|
||||
h('td', { class: 'muted small' }, shiftCell(m)),
|
||||
h('td', { class: 'muted small' }, timeCell(m.last_active_at)),
|
||||
h('td', { class: 'muted small' },
|
||||
h('span', { title: when(m.joined_at), text: ago(m.joined_at) })),
|
||||
h('td', {}, isOwner() && h('div', { class: 'row-actions' },
|
||||
h('button', {
|
||||
class: 'btn-sm', type: 'button', text: 'Edit', onclick: () => openEditMember(m),
|
||||
class: 'btn-sm', type: 'button', text: 'Edit',
|
||||
// The server refuses to edit a membership the groups grant.
|
||||
disabled: m.source === 'oidc',
|
||||
title: m.source === 'oidc' ? SSO_MANAGED : null,
|
||||
onclick: () => openEditMember(m),
|
||||
}),
|
||||
h('button', {
|
||||
class: 'btn-sm danger', type: 'button', text: 'Remove',
|
||||
disabled: lastOwner,
|
||||
title: lastOwner ? 'A team needs an owner. Make somebody else one first.' : null,
|
||||
disabled: lastOwner || m.source === 'oidc',
|
||||
title: m.source === 'oidc' ? SSO_MANAGED
|
||||
: lastOwner ? 'A team needs an owner. Make somebody else one first.' : null,
|
||||
onclick: async () => {
|
||||
if (!(await confirm({
|
||||
title: `Remove ${m.username}?`,
|
||||
|
||||
@@ -170,6 +170,15 @@ export function badge(text, cls = '') {
|
||||
return h('span', { class: `badge ${cls}`, text });
|
||||
}
|
||||
|
||||
// Access granted by the identity provider's groups. The server refuses to edit
|
||||
// it by hand (it would be undone at the next sign-in), so the controls that
|
||||
// would try are disabled with this as their reason.
|
||||
export const SSO_MANAGED = 'Managed by single sign-on. Change the person’s groups in the identity provider.';
|
||||
|
||||
export function ssoBadge(text = 'SSO') {
|
||||
return h('span', { class: 'row-team sso', title: SSO_MANAGED, text });
|
||||
}
|
||||
|
||||
export function labelChip(k, v) {
|
||||
return h('span', { class: 'label', title: `${k}=${v}` }, h('span', { text: k }), h('span', { text: v }));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user