conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

commit 5f8b53de26012cc3e3ae1c69b9050a56d8f41cb7
parent 750c0f9231576c846255bdc8b87e4cf865c92d7e
Author: finwo <finwo@pm.me>
Date:   Sun, 27 Sep 2026 18:48:42 +0200

Redirect only private-only screens to the login page

Diffstat:
Mweb/src/component/layout.tsx | 16+++++++---------
Mweb/src/main.tsx | 17+++++++++++------
Mweb/src/util/api.ts | 36++++++++++++++----------------------
Mweb/src/util/auth.ts | 5+++++
4 files changed, 37 insertions(+), 37 deletions(-)

diff --git a/web/src/component/layout.tsx b/web/src/component/layout.tsx @@ -5,13 +5,11 @@ import * as auth from '../util/auth'; import { ApiError } from '../util/api'; import { ago, stamp } from '../util/format'; -export const session = { - user: null as { id: string; username: string; role: string } | null, -}; +export { session } from '../util/auth'; // Mirrors canManageProject on the server. export function manages(project: { owner_id?: string | null } | null): boolean { - const user = session.user; + const user = auth.session.user; if (!user || !project) return false; if (user.role === 'admin') return true; return project.owner_id != null && project.owner_id === user.id; @@ -30,14 +28,14 @@ export const Chrome: m.Component = { <span class="brand">conductor</span> {Link('/jobs', 'jobs')} {Link('/projects', 'projects')} - {session.user ? Link('/workers', 'workers') : null} - {session.user?.role === 'admin' ? Link('/users', 'users') : null} - {session.user ? Link('/sessions', 'sessions') : null} + {auth.session.user ? Link('/workers', 'workers') : null} + {auth.session.user?.role === 'admin' ? Link('/users', 'users') : null} + {auth.session.user ? Link('/sessions', 'sessions') : null} </nav> <div class="who"> - {session.user + {auth.session.user ? [ - <span>{session.user.username}</span>, + <span>{auth.session.user.username}</span>, <button class="link" onclick={() => auth.signOut()}>sign out</button>, ] : <button class="link" onclick={() => auth.signIn()}>sign in</button>} diff --git a/web/src/main.tsx b/web/src/main.tsx @@ -14,8 +14,11 @@ import { Users, Sessions } from './screen/users'; const root = document.body; -function wrap(screen: m.Component): m.RouteResolver { - return { render: () => m(Chrome, m(screen)) }; +function wrap(screen: m.Component, needsUser = false): m.RouteResolver { + return { + onmatch: () => (needsUser && !session.user ? auth.signIn() : undefined), + render: () => m(Chrome, m(screen)), + }; } const routes: m.RouteDefs = { @@ -25,10 +28,10 @@ const routes: m.RouteDefs = { '/projects': wrap(Projects), '/projects/:project': wrap(Project), '/projects/:project/jobs': wrap(Jobs), - '/workers': wrap(Workers), - '/workers/:worker': wrap(Worker), - '/users': wrap(Users), - '/sessions': wrap(Sessions), + '/workers': wrap(Workers, true), + '/workers/:worker': wrap(Worker, true), + '/users': wrap(Users, true), + '/sessions': wrap(Sessions, true), }; function fail(error: unknown) { @@ -60,6 +63,8 @@ async function start() { m.route.prefix = '#!'; m.route(root, '/jobs', routes); if (resume) m.route.set(resume.replace(/^#!/, '')); + + setInterval(() => { if (!document.hidden) m.redraw(); }, 1000); } start(); diff --git a/web/src/util/api.ts b/web/src/util/api.ts @@ -43,20 +43,21 @@ async function send(url: string, options: Options, token: string | null): Promis }); } +async function authorized(url: string, options: Options): Promise<Response> { + const res = await send(url, options, await bearer()); + if (res.status !== 401 || !signedIn()) return res; + + try { + await refresh(); + } catch { + forget(); + } + return send(url, options, await bearer()); +} + export async function request<T>(path: string, options: Options = {}): Promise<T> { const url = path.startsWith('http') ? path : urlFor(path, options.query); - let res = await send(url, options, await bearer()); - - // One retry: the token may have been accepted a moment ago and expired - // since, and that is not worth showing anyone. - if (res.status === 401 && signedIn()) { - try { - await refresh(); - res = await send(url, options, await bearer()); - } catch { - forget(); - } - } + const res = await authorized(url, options); if (res.status === 204) return undefined as T; @@ -74,16 +75,7 @@ export async function request<T>(path: string, options: Options = {}): Promise<T // The envelope's pagination, flattened onto the rows it describes. export async function page<T>(path: string, options: Options = {}): Promise<Page<T>> { const url = path.startsWith('http') ? path : urlFor(path, options.query); - let res = await send(url, options, await bearer()); - - if (res.status === 401 && signedIn()) { - try { - await refresh(); - res = await send(url, options, await bearer()); - } catch { - forget(); - } - } + const res = await authorized(url, options); const body = await res.json().catch(() => ({})); if (!res.ok || body.ok === false) throw new ApiError(res.status, body.error ?? {}); diff --git a/web/src/util/auth.ts b/web/src/util/auth.ts @@ -17,6 +17,10 @@ export type Discovery = { scopes?: string; }; +export const session = { + user: null as { id: string; username: string; role: string } | null, +}; + let discovery: Discovery | null = null; let accessToken: string | null = null; let expiresAt = 0; @@ -175,6 +179,7 @@ export async function bearer(): Promise<string | null> { export function forget(): void { accessToken = null; expiresAt = 0; + session.user = null; localStorage.removeItem(STORE); localStorage.removeItem(IDENTITY); }