feat: enhance authentication handling and UI feedback in SurfSense plugin

- Introduced an authentication block mechanism to prevent repeated invalid token submissions.
- Updated notification logic to provide clearer feedback on API token status.
- Refactored status visuals for better clarity and consistency in the user interface.
- Improved connection handling in the sync engine to ensure robust error management.
This commit is contained in:
Anish Sarkar 2026-04-25 01:07:02 +05:30
parent e84dc87c5b
commit 1c18735d38
7 changed files with 42 additions and 49 deletions

View file

@ -159,7 +159,7 @@ async def _extract_binary_attachment_markdown(
logger.warning("obsidian attachment payload had invalid base64: %s", payload.path) logger.warning("obsidian attachment payload had invalid base64: %s", payload.path)
return "", {"attachment_extraction_status": "invalid_binary_payload"} return "", {"attachment_extraction_status": "invalid_binary_payload"}
suffix = f".{payload.extension.lstrip('.')}" if payload.extension else "" suffix = f".{payload.extension.lstrip('.')}"
temp_path: str | None = None temp_path: str | None = None
filename = payload.path.rsplit("/", 1)[-1] or payload.name filename = payload.path.rsplit("/", 1)[-1] or payload.name
try: try:

View file

@ -1,4 +1,4 @@
import { Notice, requestUrl, type RequestUrlParam, type RequestUrlResponse } from "obsidian"; import { requestUrl, type RequestUrlParam, type RequestUrlResponse } from "obsidian";
import type { import type {
ConnectResponse, ConnectResponse,
DeleteAck, DeleteAck,
@ -72,8 +72,11 @@ export interface ApiClientOptions {
onAuthError?: () => void; onAuthError?: () => void;
} }
const AUTH_BLOCK_MS = 60_000;
export class SurfSenseApiClient { export class SurfSenseApiClient {
private readonly opts: ApiClientOptions; private readonly opts: ApiClientOptions;
private authBlockedUntil = 0;
constructor(opts: ApiClientOptions) { constructor(opts: ApiClientOptions) {
this.opts = opts; this.opts = opts;
@ -83,6 +86,10 @@ export class SurfSenseApiClient {
Object.assign(this.opts, partial); Object.assign(this.opts, partial);
} }
resetAuthBlock(): void {
this.authBlockedUntil = 0;
}
async health(): Promise<HealthResponse> { async health(): Promise<HealthResponse> {
return await this.request<HealthResponse>("GET", "/api/v1/obsidian/health"); return await this.request<HealthResponse>("GET", "/api/v1/obsidian/health");
} }
@ -198,6 +205,9 @@ export class SurfSenseApiClient {
if (!token) { if (!token) {
throw new AuthError("Missing API token. Open SurfSense settings to paste one."); throw new AuthError("Missing API token. Open SurfSense settings to paste one.");
} }
if (Date.now() < this.authBlockedUntil) {
throw new AuthError("Token rejected. Paste a fresh one in settings.");
}
const headers: Record<string, string> = { const headers: Record<string, string> = {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
Accept: "application/json", Accept: "application/json",
@ -224,8 +234,8 @@ export class SurfSenseApiClient {
const detail = extractDetail(resp); const detail = extractDetail(resp);
if (resp.status === 401) { if (resp.status === 401) {
this.authBlockedUntil = Date.now() + AUTH_BLOCK_MS;
this.opts.onAuthError?.(); this.opts.onAuthError?.();
new Notice("Surfsense: token expired or invalid. Paste a fresh token in settings.");
throw new AuthError(detail || "Unauthorized"); throw new AuthError(detail || "Unauthorized");
} }

View file

@ -25,6 +25,7 @@ export default class SurfSensePlugin extends Plugin {
private settingTab: SurfSenseSettingTab | null = null; private settingTab: SurfSenseSettingTab | null = null;
private statusListeners = new Set<() => void>(); private statusListeners = new Set<() => void>();
private reconcileTimerId: number | null = null; private reconcileTimerId: number | null = null;
private lastAuthToastAt = 0;
async onload() { async onload() {
await this.loadSettings(); await this.loadSettings();
@ -34,6 +35,7 @@ export default class SurfSensePlugin extends Plugin {
this.api = new SurfSenseApiClient({ this.api = new SurfSenseApiClient({
getServerUrl: () => this.settings.serverUrl, getServerUrl: () => this.settings.serverUrl,
getToken: () => this.settings.apiToken, getToken: () => this.settings.apiToken,
onAuthError: () => this.notifyAuthError(),
}); });
this.queue = new PersistentQueue(this.settings.queue ?? [], { this.queue = new PersistentQueue(this.settings.queue ?? [], {
@ -239,6 +241,13 @@ export default class SurfSensePlugin extends Plugin {
for (const fn of this.statusListeners) fn(); for (const fn of this.statusListeners) fn();
} }
private notifyAuthError(): void {
const now = Date.now();
if (now - this.lastAuthToastAt < 10_000) return;
this.lastAuthToastAt = now;
new Notice("Surfsense: API token expired or invalid. Paste a fresh token in settings.", 8000);
}
async loadSettings() { async loadSettings() {
const data = (await this.loadData()) as Partial<SurfsensePluginSettings> | null; const data = (await this.loadData()) as Partial<SurfsensePluginSettings> | null;
this.settings = { this.settings = {

View file

@ -75,6 +75,7 @@ export class SurfSenseSettingTab extends PluginSettingTab {
} }
this.plugin.settings.apiToken = next; this.plugin.settings.apiToken = next;
await this.plugin.saveSettings(); await this.plugin.saveSettings();
this.plugin.api.resetAuthBlock();
}); });
}) })
.addButton((btn) => .addButton((btn) =>
@ -299,7 +300,7 @@ export class SurfSenseSettingTab extends PluginSettingTab {
const kind = this.plugin.lastStatus.kind; const kind = this.plugin.lastStatus.kind;
if (kind === "auth-error") { if (kind === "auth-error") {
return { icon: "lock", label: "Token invalid or expired", tone: "err" }; return { icon: "lock", label: "API token invalid or expired", tone: "err" };
} }
if (kind === "error") { if (kind === "error") {
return { icon: "alert-circle", label: "Connection error", tone: "err" }; return { icon: "alert-circle", label: "Connection error", tone: "err" };
@ -381,10 +382,7 @@ export class SurfSenseSettingTab extends PluginSettingTab {
} }
private handleApiError(err: unknown): void { private handleApiError(err: unknown): void {
if (err instanceof AuthError) { if (err instanceof AuthError) return;
new Notice(`SurfSense: ${err.message}`);
return;
}
new Notice( new Notice(
`SurfSense: request failed — ${(err as Error).message ?? "unknown error"}`, `SurfSense: request failed — ${(err as Error).message ?? "unknown error"}`,
); );

View file

@ -15,11 +15,11 @@ interface StatusVisual {
} }
const VISUALS: Record<StatusKind, StatusVisual> = { const VISUALS: Record<StatusKind, StatusVisual> = {
idle: { icon: "check-circle", label: "Synced", cls: "surfsense-status--ok" }, idle: { icon: "check-circle", label: "Synced", cls: "" },
syncing: { icon: "refresh-ccw", label: "Syncing", cls: "surfsense-status--syncing" }, syncing: { icon: "refresh-ccw", label: "Syncing", cls: "" },
queued: { icon: "upload", label: "Queued", cls: "surfsense-status--syncing" }, queued: { icon: "clock", label: "Queued", cls: "" },
offline: { icon: "wifi-off", label: "Offline", cls: "surfsense-status--warn" }, offline: { icon: "wifi-off", label: "Offline", cls: "" },
"auth-error": { icon: "lock", label: "Auth error", cls: "surfsense-status--err" }, "auth-error": { icon: "user-x", label: "Auth error", cls: "surfsense-status--err" },
error: { icon: "alert-circle", label: "Error", cls: "surfsense-status--err" }, error: { icon: "alert-circle", label: "Error", cls: "surfsense-status--err" },
}; };
@ -42,13 +42,8 @@ export class StatusBar {
update(state: StatusState): void { update(state: StatusState): void {
const visual = VISUALS[state.kind]; const visual = VISUALS[state.kind];
this.el.removeClass( this.el.removeClass("surfsense-status--err");
"surfsense-status--ok", if (visual.cls) this.el.addClass(visual.cls);
"surfsense-status--syncing",
"surfsense-status--warn",
"surfsense-status--err",
);
this.el.addClass(visual.cls);
setIcon(this.icon, visual.icon); setIcon(this.icon, visual.icon);
let label = `SurfSense: ${visual.label}`; let label = `SurfSense: ${visual.label}`;

View file

@ -120,11 +120,11 @@ export class SyncEngine {
* (Re)register the vault. Adopts server's `vault_id` in case fingerprint * (Re)register the vault. Adopts server's `vault_id` in case fingerprint
* dedup routed us to an existing row from another device. * dedup routed us to an existing row from another device.
*/ */
async ensureConnected(): Promise<void> { async ensureConnected(): Promise<boolean> {
const settings = this.deps.getSettings(); const settings = this.deps.getSettings();
if (!settings.searchSpaceId) { if (!settings.searchSpaceId) {
this.setStatus("idle", "Pick a search space in settings."); this.setStatus("idle", "Pick a search space in settings.");
return; return false;
} }
this.setStatus("syncing", "Connecting to SurfSense"); this.setStatus("syncing", "Connecting to SurfSense");
try { try {
@ -141,8 +141,10 @@ export class SyncEngine {
s.connectorId = resp.connector_id; s.connectorId = resp.connector_id;
}); });
this.setStatus(this.queueStatusKind(), this.statusDetail()); this.setStatus(this.queueStatusKind(), this.statusDetail());
return true;
} catch (err) { } catch (err) {
this.handleStartupError(err); this.handleStartupError(err);
return false;
} }
} }
@ -238,7 +240,8 @@ export class SyncEngine {
if (this.deps.queue.size === 0) return; if (this.deps.queue.size === 0) return;
// Shared gate for every flush trigger so the first /sync can't race /connect. // Shared gate for every flush trigger so the first /sync can't race /connect.
if (!this.deps.getSettings().connectorId) { if (!this.deps.getSettings().connectorId) {
await this.ensureConnected(); const connected = await this.ensureConnected();
if (!connected) return;
if (!this.deps.getSettings().connectorId) return; if (!this.deps.getSettings().connectorId) return;
} }
this.setStatus("syncing", `Syncing ${this.deps.queue.size} item(s)…`); this.setStatus("syncing", `Syncing ${this.deps.queue.size} item(s)…`);
@ -409,7 +412,8 @@ export class SyncEngine {
// Re-handshake first: if the vault grew enough to match another // Re-handshake first: if the vault grew enough to match another
// device's fingerprint, the server merges and routes us to the // device's fingerprint, the server merges and routes us to the
// survivor row, which the /manifest call below then uses. // survivor row, which the /manifest call below then uses.
await this.ensureConnected(); const connected = await this.ensureConnected();
if (!connected) return;
const refreshed = this.deps.getSettings(); const refreshed = this.deps.getSettings();
if (!refreshed.connectorId) return; if (!refreshed.connectorId) return;
@ -552,7 +556,8 @@ export class SyncEngine {
} }
private classifyAndStatus(err: unknown, prefix: string): void { private classifyAndStatus(err: unknown, prefix: string): void {
this.classify(err); const verdict = this.classify(err);
if (verdict === "stop") return;
this.setStatus(this.queueStatusKind(), `${prefix}: ${(err as Error).message}`); this.setStatus(this.queueStatusKind(), `${prefix}: ${(err as Error).message}`);
} }

View file

@ -22,18 +22,6 @@
height: 14px; height: 14px;
} }
.surfsense-status--ok .surfsense-status__icon {
color: var(--color-green);
}
.surfsense-status--syncing .surfsense-status__icon {
color: var(--color-blue);
}
.surfsense-status--warn .surfsense-status__icon {
color: var(--color-yellow);
}
.surfsense-status--err .surfsense-status__icon { .surfsense-status--err .surfsense-status__icon {
color: var(--color-red); color: var(--color-red);
} }
@ -55,18 +43,6 @@
height: 14px; height: 14px;
} }
.surfsense-connection-indicator--ok {
color: var(--color-green);
}
.surfsense-connection-indicator--syncing {
color: var(--color-blue);
}
.surfsense-connection-indicator--warn {
color: var(--color-yellow);
}
.surfsense-connection-indicator--err { .surfsense-connection-indicator--err {
color: var(--color-red); color: var(--color-red);
} }