feat(extension): show when Thingport Grab can't sign in

A session token outlives a password change, so a stale saved password
only surfaced days later as failing imports. Check the connection by
signing in (at most every 10 minutes) at browser start, before showing
the in-page icon, and when the popup opens.

When it fails, the icon shows a "not connected" badge whose dialog says
whether the credentials were rejected or the instance didn't answer,
and opens the popup's edit form. The toolbar icon gets a badge too.
Concurrent calls with an expired token now share one sign-in.
This commit is contained in:
Tautvydas Derzinskas committed 2026-10-12 03:50:46 +02:00
1 parent 44c687b3ae
commit 623cf25f2c
10 files changed
+229 -29

No files matched your search

+75 -4
View File
@@ -2,7 +2,8 @@
// the instance URL live in one place. The host permission granted at setup bypasses CORS.
import type { LoginResult } from "../shared/api";
import { apiUrl } from "../shared/storage";
import type { Connection } from "../shared/messages";
import { STORAGE_KEYS, apiUrl } from "../shared/storage";
import { isMakerworldUrl } from "../shared/urls";
import { getStoredConfig, isConfigured, type ConfiguredConfig } from "./config";
import { getLiveMakerworldCookie, maybeSyncMakerworldCookie } from "./makerworldCookie";
@@ -22,6 +23,26 @@ async function errorDetail(res: Response, fallback: string): Promise<string> {
return fallback;
}
/** The instance turned the saved email/password down (401/403), as opposed to not answering. */
export class LoginRejectedError extends Error {
/** The instance's own message, without advice added for where it's shown. */
constructor(
message: string,
readonly detail = message,
) {
super(message);
}
}
// A JWT outlives a password change, so only signing in proves the saved password still works.
const CONNECTION_RECHECK_MS = 10 * 60 * 1000;
/** Mirrored on the toolbar icon, so a broken connection shows before the user reaches a provider. */
function setConnectionBadge(connected: boolean): void {
void chrome.action.setBadgeText({ text: connected ? "" : "!" }).catch(() => undefined);
if (!connected) void chrome.action.setBadgeBackgroundColor({ color: "#d32f2f" }).catch(() => undefined);
}
/** Also called once on a 401, so a password change doesn't require reopening the popup. */
export async function loginAndStoreToken(credentials: Credentials): Promise<string> {
const res = await fetch(apiUrl(credentials.instanceUrl, "/login"), {
@@ -29,13 +50,28 @@ export async function loginAndStoreToken(credentials: Credentials): Promise<stri
headers: { ...CLIENT_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({ email: credentials.email, password: credentials.password }),
});
if (!res.ok) throw new Error(await errorDetail(res, "Could not sign in to this Thingport instance"));
if (!res.ok) {
const detail = await errorDetail(res, `Could not sign in to this Thingport instance (${res.status})`);
throw res.status === 401 || res.status === 403 ? new LoginRejectedError(detail) : new Error(detail);
}
const data = (await res.json()) as LoginResult;
const tokenExpiresAt = Date.now() + data.expires_in * 1000;
await chrome.storage.local.set({ token: data.token, tokenExpiresAt, isAdmin: data.user?.role === "ADMIN" });
const isAdmin = data.user?.role === "ADMIN";
// isAdmin is a config key: rewriting it re-initializes every open provider tab.
const stored = await getStoredConfig();
await chrome.storage.local.set({
token: data.token,
tokenExpiresAt,
loginVerifiedAt: Date.now(),
...(stored.isAdmin === isAdmin ? {} : { isAdmin }),
});
setConnectionBadge(true);
return data.token;
}
// Shared, so a burst of calls with an expired token signs in once rather than once per call.
let pendingLogin: Promise<string> | null = null;
export async function ensureToken(config: ConfiguredConfig, { forceRefresh = false } = {}): Promise<string> {
if (
!forceRefresh &&
@@ -45,7 +81,42 @@ export async function ensureToken(config: ConfiguredConfig, { forceRefresh = fal
) {
return config.token;
}
return loginAndStoreToken(config);
pendingLogin ??= loginAndStoreToken(config)
.catch((err: unknown) => {
void chrome.storage.local.remove(STORAGE_KEYS.loginVerifiedAt);
setConnectionBadge(false);
// A silent re-login has no form to show the error next to, so say where to fix it.
if (err instanceof LoginRejectedError) {
throw new LoginRejectedError(
`Thingport rejected the email or password saved in Thingport Grab (${err.message}). ` +
"Open the extension popup, click the instance and sign in again.",
err.detail,
);
}
throw err;
})
.finally(() => {
pendingLogin = null;
});
return pendingLogin;
}
/** Signs in at most every few minutes; a failed check isn't remembered, so the next one retries. */
export async function checkConnection(): Promise<Connection> {
const config = await getStoredConfig();
if (!isConfigured(config) || config.disabled) {
setConnectionBadge(true);
return { connected: true };
}
// Credentials only change through saveConfig, which signs in and so resets this.
if (config.loginVerifiedAt && Date.now() - config.loginVerifiedAt < CONNECTION_RECHECK_MS) return { connected: true };
try {
await ensureToken(config, { forceRefresh: true });
return { connected: true };
} catch (err) {
const detail = err instanceof LoginRejectedError ? err.detail : err instanceof Error ? err.message : String(err);
return { connected: false, reason: err instanceof LoginRejectedError ? "credentials" : "unreachable", detail };
}
}
export async function requireConfig(): Promise<ConfiguredConfig> {
+6 -1
View File
@@ -2,7 +2,7 @@
// and every request to the Thingport instance.
import { listen, type BackgroundMessages } from "../shared/messages";
import { apiCall } from "./api";
import { apiCall, checkConnection } from "./api";
import { getState, saveConfig, setDisabled } from "./config";
import { armDownloadCapture, awaitDownloadCapture, disarmDownloadCapture } from "./downloadCapture";
import { importSingle, queueImport } from "./importJobs";
@@ -29,6 +29,7 @@ listen<BackgroundMessages>({
SAVE_CONFIG: (payload) => saveConfig(payload),
SET_DISABLED: ({ disabled }) => setDisabled(disabled),
GET_RECENT_IMPORTS: () => getRecentImports(),
CHECK_CONNECTION: () => checkConnection(),
OPEN_SETUP: (_payload, sender) => openSetup(sender.tab?.id),
SET_TAB_ICON_STATE: ({ active }, sender) => setTabIconState(senderTabId(sender), active),
API_CALL: ({ method, path, body }) => apiCall(method, path, body),
@@ -56,6 +57,10 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
if (changeInfo.status === "complete") void advanceJob(tabId);
});
// Before the user reaches a provider page, so the toolbar badge already shows a broken connection.
chrome.runtime.onStartup.addListener(() => void checkConnection());
chrome.runtime.onInstalled.addListener(() => void checkConnection());
chrome.tabs.onRemoved.addListener((tabId) => {
void dropJobIfForTab(tabId);
});
+26
View File
@@ -81,6 +81,32 @@ async function init(): Promise<void> {
return;
}
// Before anything else asks the instance, so a stale password or a down instance shows as such
// rather than as every model looking new.
const connectionRes = await send("CHECK_CONNECTION");
if (isStale()) return;
if (connectionRes?.ok && !connectionRes.data.connected) {
const problem = connectionRes.data;
const root = mountHost();
mountFab(root, {
variant: "disconnected",
label: "Thingport Grab can't sign in to your Thingport instance",
title: "Thingport Grab can't sign in to your Thingport instance",
onClick: () =>
openSetupModal(root, {
problem,
instanceUrl: stateRes.data.instanceUrl,
// A failed check isn't cached, so this signs in again.
onRetry: () => {
unmount();
void init();
},
}),
});
reportTabIconState(false);
return;
}
let library: LibraryState | null = null;
if (classification.kind === "single") {
try {
+52 -16
View File
@@ -1,22 +1,15 @@
// The grayed-out icon's dialog. Setup happens in an extension page: saving needs a host-permission
// prompt, and the password never enters this page's DOM.
// The grayed-out icon's dialog: not set up yet, or set up but unable to sign in. Setup happens in an
// extension page: saving needs a host-permission prompt, and the password never enters this page's
// DOM.
import { send } from "../shared/messages";
import { send, type Connection } from "../shared/messages";
import { fillIcons } from "../shared/icon";
import { escapeHtml } from "./runtime";
let open: { backdrop: HTMLElement; onKeydown: (event: KeyboardEvent) => void } | null = null;
type Disconnected = Extract<Connection, { connected: false }>;
export function openSetupModal(root: ShadowRoot): void {
if (open) return;
const backdrop = document.createElement("div");
backdrop.className = "tg-modal-backdrop";
backdrop.innerHTML = `
<div class="tg-modal" role="dialog" aria-modal="true" aria-labelledby="tg-setup-title">
<button class="tg-close" type="button" aria-label="Close">×</button>
<div class="tg-modal__header">
<span class="tg-modal__icon" data-icon></span>
<div class="tg-title" id="tg-setup-title">Connect Thingport Grab</div>
</div>
function setupBodyHtml(): string {
return `
<div class="tg-hint">
To import from this page, Thingport Grab needs to know where your Thingport instance is
and how to sign in to it:
@@ -29,8 +22,46 @@ export function openSetupModal(root: ShadowRoot): void {
They're stored only in this browser's extension storage and sent only to your own
instance. You can also open this any time from the Thingport Grab icon in your browser
toolbar.
</div>`;
}
function disconnectedBodyHtml(problem: Disconnected, instanceUrl: string): string {
const instance = `<strong>${escapeHtml(instanceUrl.replace(/^https?:\/\//i, ""))}</strong>`;
const cause =
problem.reason === "credentials"
? `${instance} turned down the email and password saved in Thingport Grab. They were probably
changed in Thingport since -- sign in again with the current ones.`
: `${instance} didn't answer. It may not be running, or not be reachable from this network
right now. If it moved, update its URL.`;
return `
<div class="tg-hint">${cause}</div>
<div class="tg-hint tg-modal__detail">${escapeHtml(problem.detail)}</div>`;
}
let open: { backdrop: HTMLElement; onKeydown: (event: KeyboardEvent) => void } | null = null;
/** `problem` set: configured, but signing in failed. `onRetry` re-checks after it's fixed. */
export function openSetupModal(
root: ShadowRoot,
disconnected?: { problem: Disconnected; instanceUrl: string; onRetry: () => void },
): void {
if (open) return;
const backdrop = document.createElement("div");
backdrop.className = "tg-modal-backdrop";
backdrop.innerHTML = `
<div class="tg-modal" role="dialog" aria-modal="true" aria-labelledby="tg-setup-title">
<button class="tg-close" type="button" aria-label="Close">×</button>
<div class="tg-modal__header">
<span class="tg-modal__icon" data-icon></span>
<div class="tg-title" id="tg-setup-title">
${disconnected ? "Thingport Grab can't sign in" : "Connect Thingport Grab"}
</div>
</div>
<button class="tg-btn" type="button" data-action="open-setup">Open setup</button>
${disconnected ? disconnectedBodyHtml(disconnected.problem, disconnected.instanceUrl) : setupBodyHtml()}
<button class="tg-btn" type="button" data-action="open-setup">
${disconnected ? "Edit connection" : "Open setup"}
</button>
${disconnected ? `<button class="tg-btn tg-btn--secondary" type="button" data-action="retry">Try again</button>` : ""}
<div class="tg-hint tg-modal__status" hidden></div>
</div>
`;
@@ -49,6 +80,11 @@ export function openSetupModal(root: ShadowRoot): void {
: "Couldn't open setup automatically -- click the Thingport Grab icon in your browser toolbar (it may be under the Extensions menu).";
});
backdrop.querySelector("[data-action=retry]")?.addEventListener("click", () => {
closeSetupModal();
disconnected?.onRetry();
});
const onKeydown = (event: KeyboardEvent) => {
if (event.key === "Escape") closeSetupModal();
};
+6 -1
View File
@@ -31,7 +31,12 @@ export function unmountHost(): void {
contentEl = null;
}
type FabOptions = { variant?: "in-library" | "inactive"; label: string; title?: string; onClick: () => void };
type FabOptions = {
variant?: "in-library" | "inactive" | "disconnected";
label: string;
title?: string;
onClick: () => void;
};
export function mountFab(root: ShadowRoot, { variant, label, title, onClick }: FabOptions): void {
const button = document.createElement("button");
+19 -1
View File
@@ -52,7 +52,8 @@
// Supported page, extension not configured yet (see setupModal.ts). Grayed out so it doesn't
// read as "ready to import", but still clearly the Thingport icon.
&--inactive {
&--inactive,
&--disconnected {
background: t.c("surface-sunken");
box-shadow: 0 2px 10px t.c("shadow");
@@ -65,4 +66,21 @@
opacity: 0.75;
}
}
// Set up, but signing in failed (see setupModal.ts): grayed out like --inactive, plus a red dot.
&--disconnected::after {
content: "!";
position: absolute;
top: -2px;
right: -2px;
width: 20px;
height: 20px;
box-sizing: border-box;
border: 2px solid t.c("surface");
border-radius: 50%;
background: t.c("danger");
color: t.c("on-accent");
font: 700 11px/16px t.$font-stack;
text-align: center;
}
}
+11 -1
View File
@@ -1,4 +1,4 @@
// The grayed-out icon's setup dialog (see setupModal.ts).
// The grayed-out icon's setup or can't-sign-in dialog (see setupModal.ts).
@use "../../styles/tokens" as t;
@use "../../styles/mixins" as m;
@@ -55,6 +55,16 @@
margin-top: 14px;
}
.tg-btn + .tg-btn {
margin-top: t.$space-2;
}
// The instance's own error, for when the explanation above doesn't fit.
&__detail {
word-break: break-word;
font-size: 11px;
}
&__status {
margin: 10px 0 0;
}
+26 -5
View File
@@ -2,7 +2,7 @@
// then the enable switch and recent imports.
import { fillIcons } from "../shared/icon";
import { send, type ExtensionState } from "../shared/messages";
import { send, type Connection, type ExtensionState } from "../shared/messages";
import { normalizeInstanceUrl } from "../shared/storage";
import { clearError, els, showError } from "./dom";
import { loadRecentImports } from "./recentImports";
@@ -44,14 +44,35 @@ function showSetupForm(prefillUrl = "", prefillEmail = ""): void {
els.instanceUrlInput.focus();
}
async function refresh(): Promise<void> {
function connectionProblem(connection: Extract<Connection, { connected: false }>): string {
return connection.reason === "credentials"
? `Your Thingport instance turned down the saved email and password -- they were probably changed there. Sign in again with the current ones. (${connection.detail})`
: `Your Thingport instance didn't answer -- it may not be running or reachable right now. Check its URL, or try again later. (${connection.detail})`;
}
/** Opens on the edit form when the saved connection no longer signs in. `checkConnection` is off
* after Cancel, which then shows the problem above the configured view instead. */
async function refresh({ checkConnection = true } = {}): Promise<void> {
const res = await send("GET_STATE");
if (!res?.ok) {
showError(res?.error ?? "The extension background isn't responding");
return;
}
if (res.data.configured) showConfiguredView(res.data);
else showSetupForm();
if (!res.data.configured) {
showSetupForm();
return;
}
showConfiguredView(res.data);
if (res.data.disabled) return;
const connection = await send("CHECK_CONNECTION");
if (!connection?.ok || connection.data.connected) return;
if (checkConnection) {
showSetupForm(res.data.instanceUrl, res.data.email);
showError(connectionProblem(connection.data));
(connection.data.reason === "credentials" ? els.passwordInput : els.instanceUrlInput).focus();
} else {
showError(connectionProblem(connection.data), els.configuredError);
}
}
els.editInstanceBtn.addEventListener("click", async () => {
@@ -59,7 +80,7 @@ els.editInstanceBtn.addEventListener("click", async () => {
showSetupForm(res?.ok ? res.data.instanceUrl : "", res?.ok ? res.data.email : "");
});
els.cancelBtn.addEventListener("click", () => void refresh());
els.cancelBtn.addEventListener("click", () => void refresh({ checkConnection: false }));
els.enabledSwitch.addEventListener("change", async () => {
clearError(els.configuredError);
+5
View File
@@ -12,6 +12,10 @@ export type ExtensionState = {
email: string;
};
/** Whether the saved instance URL, email and password still sign in. */
export type Connection =
{ connected: true } | { connected: false; reason: "credentials" | "unreachable"; detail: string };
/** The signed-in account's recent imports, as the popup shows them. */
export type RecentImport = {
printId: string;
@@ -71,6 +75,7 @@ export type BackgroundMessages = {
SAVE_CONFIG: { payload: { instanceUrl: string; email: string; password: string }; result: null };
SET_DISABLED: { payload: { disabled: boolean }; result: null };
GET_RECENT_IMPORTS: { payload: void; result: RecentImport[] };
CHECK_CONNECTION: { payload: void; result: Connection };
OPEN_SETUP: { payload: void; result: "popup" | "tab" };
SET_TAB_ICON_STATE: { payload: { active: boolean }; result: null };
API_CALL: { payload: ApiCallPayload; result: unknown };
+3
View File
@@ -9,6 +9,8 @@ export const STORAGE_KEYS = {
isAdmin: "isAdmin",
token: "token",
tokenExpiresAt: "tokenExpiresAt",
// Last successful sign-in; the connection check signs in again once it's a few minutes old.
loginVerifiedAt: "loginVerifiedAt",
// Lets the cookie sync skip a redundant PATCH.
lastSyncedMakerworldCookie: "lastSyncedMakerworldCookie",
} as const;
@@ -21,6 +23,7 @@ export type StoredConfig = {
isAdmin?: boolean;
token?: string;
tokenExpiresAt?: number;
loginVerifiedAt?: number;
lastSyncedMakerworldCookie?: string;
};