mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 06:09:56 -05:00
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:
1 parent
44c687b3ae
commit
623cf25f2c
10 files changed
+229
-29
No files matched your search
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user