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)
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
filename = payload.path.rsplit("/", 1)[-1] or payload.name
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 {
ConnectResponse,
DeleteAck,
@ -72,8 +72,11 @@ export interface ApiClientOptions {
onAuthError?: () => void;
}
const AUTH_BLOCK_MS = 60_000;
export class SurfSenseApiClient {
private readonly opts: ApiClientOptions;
private authBlockedUntil = 0;
constructor(opts: ApiClientOptions) {
this.opts = opts;
@ -83,6 +86,10 @@ export class SurfSenseApiClient {
Object.assign(this.opts, partial);
}
resetAuthBlock(): void {
this.authBlockedUntil = 0;
}
async health(): Promise<HealthResponse> {
return await this.request<HealthResponse>("GET", "/api/v1/obsidian/health");
}
@ -198,6 +205,9 @@ export class SurfSenseApiClient {
if (!token) {
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> = {
Authorization: `Bearer ${token}`,
Accept: "application/json",
@ -224,8 +234,8 @@ export class SurfSenseApiClient {
const detail = extractDetail(resp);
if (resp.status === 401) {
this.authBlockedUntil = Date.now() + AUTH_BLOCK_MS;
this.opts.onAuthError?.();
new Notice("Surfsense: token expired or invalid. Paste a fresh token in settings.");
throw new AuthError(detail || "Unauthorized");
}

View file

@ -25,6 +25,7 @@ export default class SurfSensePlugin extends Plugin {
private settingTab: SurfSenseSettingTab | null = null;
private statusListeners = new Set<() => void>();
private reconcileTimerId: number | null = null;
private lastAuthToastAt = 0;
async onload() {
await this.loadSettings();
@ -34,6 +35,7 @@ export default class SurfSensePlugin extends Plugin {
this.api = new SurfSenseApiClient({
getServerUrl: () => this.settings.serverUrl,
getToken: () => this.settings.apiToken,
onAuthError: () => this.notifyAuthError(),
});
this.queue = new PersistentQueue(this.settings.queue ?? [], {
@ -239,6 +241,13 @@ export default class SurfSensePlugin extends Plugin {
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() {
const data = (await this.loadData()) as Partial<SurfsensePluginSettings> | null;
this.settings = {

View file

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

View file

@ -15,11 +15,11 @@ interface StatusVisual {
}
const VISUALS: Record<StatusKind, StatusVisual> = {
idle: { icon: "check-circle", label: "Synced", cls: "surfsense-status--ok" },
syncing: { icon: "refresh-ccw", label: "Syncing", cls: "surfsense-status--syncing" },
queued: { icon: "upload", label: "Queued", cls: "surfsense-status--syncing" },
offline: { icon: "wifi-off", label: "Offline", cls: "surfsense-status--warn" },
"auth-error": { icon: "lock", label: "Auth error", cls: "surfsense-status--err" },
idle: { icon: "check-circle", label: "Synced", cls: "" },
syncing: { icon: "refresh-ccw", label: "Syncing", cls: "" },
queued: { icon: "clock", label: "Queued", cls: "" },
offline: { icon: "wifi-off", label: "Offline", cls: "" },
"auth-error": { icon: "user-x", label: "Auth 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 {
const visual = VISUALS[state.kind];
this.el.removeClass(
"surfsense-status--ok",
"surfsense-status--syncing",
"surfsense-status--warn",
"surfsense-status--err",
);
this.el.addClass(visual.cls);
this.el.removeClass("surfsense-status--err");
if (visual.cls) this.el.addClass(visual.cls);
setIcon(this.icon, visual.icon);
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
* dedup routed us to an existing row from another device.
*/
async ensureConnected(): Promise<void> {
async ensureConnected(): Promise<boolean> {
const settings = this.deps.getSettings();
if (!settings.searchSpaceId) {
this.setStatus("idle", "Pick a search space in settings.");
return;
return false;
}
this.setStatus("syncing", "Connecting to SurfSense");
try {
@ -141,8 +141,10 @@ export class SyncEngine {
s.connectorId = resp.connector_id;
});
this.setStatus(this.queueStatusKind(), this.statusDetail());
return true;
} catch (err) {
this.handleStartupError(err);
return false;
}
}
@ -238,7 +240,8 @@ export class SyncEngine {
if (this.deps.queue.size === 0) return;
// Shared gate for every flush trigger so the first /sync can't race /connect.
if (!this.deps.getSettings().connectorId) {
await this.ensureConnected();
const connected = await this.ensureConnected();
if (!connected) return;
if (!this.deps.getSettings().connectorId) return;
}
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
// device's fingerprint, the server merges and routes us to the
// 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();
if (!refreshed.connectorId) return;
@ -552,7 +556,8 @@ export class SyncEngine {
}
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}`);
}

View file

@ -22,18 +22,6 @@
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 {
color: var(--color-red);
}
@ -55,18 +43,6 @@
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 {
color: var(--color-red);
}