Skip to content
Merged
10 changes: 10 additions & 0 deletions dashboard/app/dashboard/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@ export default async function DashboardLayout({
<main className="relative z-1 flex-1">
<PageTransition>{children}</PageTransition>
</main>
<footer className="relative z-10 flex items-center justify-center border-t border-border bg-bg/95 px-6 py-3 backdrop-blur">
<a
href="https://github.com/BODMAT/worktrace"
target="_blank"
rel="noopener noreferrer"
className="text-[10px] font-bold tracking-widest text-muted transition-colors hover:text-cyan"
>
GITHUB · RELEASES
</a>
</footer>
</div>
);
}
9 changes: 7 additions & 2 deletions dashboard/app/dashboard/reports/api-key-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ export function ApiKeyModal({ open, status, onClose, onSaved }: Props) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);

const trimmed = value.trim();
const keyFormatOk = trimmed.startsWith("gsk_") && trimmed.length >= 20;

useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", onKey);
Expand Down Expand Up @@ -105,7 +108,9 @@ export function ApiKeyModal({ open, status, onClose, onSaved }: Props) {
className="mb-2 w-full rounded border border-border bg-bg px-2 py-1.5 text-xs text-text outline-none transition-colors focus:border-purple disabled:opacity-50"
/>

{error ? (
{trimmed.length > 0 && !trimmed.startsWith("gsk_") ? (
<p className="mb-2 text-[11px] text-pink">Key must start with gsk_</p>
) : error ? (
<p className="mb-2 text-[11px] text-pink">{error}</p>
) : null}

Expand Down Expand Up @@ -135,7 +140,7 @@ export function ApiKeyModal({ open, status, onClose, onSaved }: Props) {
<button
type="button"
onClick={() => commit(value)}
disabled={busy || value.trim().length < 8}
disabled={busy || !keyFormatOk}
className="cursor-pointer rounded border border-cyan bg-cyan/10 px-3 py-1.5 text-[10px] font-bold tracking-widest text-cyan transition-colors hover:bg-cyan/20 disabled:cursor-not-allowed disabled:opacity-50"
>
{busy ? "SAVING…" : "SAVE"}
Expand Down
15 changes: 13 additions & 2 deletions dashboard/app/dashboard/reports/report-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,14 @@ function loadCache(key: string): CachedReport | null {
}

function saveCache(key: string, data: CachedReport): void {
try { localStorage.setItem(key, JSON.stringify(data)); } catch { /* quota */ }
try {
localStorage.setItem(key, JSON.stringify(data));
} catch (err) {
if (err instanceof DOMException && (err.name === "QuotaExceededError" || err.name === "NS_ERROR_DOM_QUOTA_REACHED")) {
throw err; // let caller notify the user
}
// other DOMExceptions are unexpected — swallow silently
}
}

function fmtCreatedAt(iso: string): string {
Expand Down Expand Up @@ -146,7 +153,11 @@ export function ReportForm({ userEmail }: { userEmail: string }) {
try {
const result = await generateReport(input);
const createdAt = new Date().toISOString();
saveCache(reportCacheKey(userEmail, range, from, to), { result, createdAt });
try {
saveCache(reportCacheKey(userEmail, range, from, to), { result, createdAt });
} catch {
toast.error("Report generated but couldn't be cached — browser storage is full.");
}
setStatus({ kind: "success", result, createdAt });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to generate report";
Expand Down
47 changes: 30 additions & 17 deletions extension/src/background/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,17 +48,20 @@ function isExpired(expiresAt: number): boolean {
return Date.now() >= expiresAt - EXPIRY_BUFFER_MS;
}

function decodeEmail(jwt: string): string | null {
function decodeJwtPayload(jwt: string): { email?: string; exp?: number } | null {
try {
const payloadB64 = jwt.split(".")[1];
if (!payloadB64) return null;
const payload = JSON.parse(atob(payloadB64)) as { email?: string };
return payload.email ?? null;
return JSON.parse(atob(payloadB64)) as { email?: string; exp?: number };
} catch {
return null;
}
}

function decodeEmail(jwt: string): string | null {
return decodeJwtPayload(jwt)?.email ?? null;
}

// ─── Google OAuth ──────────────────────────────────────────────────────────────

async function launchGoogleOAuth(): Promise<string> {
Expand Down Expand Up @@ -101,9 +104,9 @@ async function exchangeForJWT(googleIdToken: string): Promise<StoredAuth> {
}

const { token } = await res.json() as { token: string };
const payloadB64 = token.split(".")[1] ?? "";
const { exp } = JSON.parse(atob(payloadB64)) as { exp: number };
return { jwt: token, jwtExpiresAt: exp * 1000 };
const payload = decodeJwtPayload(token);
if (!payload?.exp) throw new Error("JWT missing exp claim");
return { jwt: token, jwtExpiresAt: payload.exp * 1000 };
}

// ─── Core ──────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -163,6 +166,17 @@ async function tryEndDbSession(dbSessionId: string): Promise<void> {
}
}

// ─── Music helpers ────────────────────────────────────────────────────────────

const TRACK_SOURCE_DOMAINS: Record<TrackInfo["source"], string> = {
"youtube-music": "music.youtube.com",
"soundcloud": "soundcloud.com",
};

async function isTrackBlocked(track: TrackInfo): Promise<boolean> {
return isDomainBlocked(`https://${TRACK_SOURCE_DOMAINS[track.source]}`);
}

// ─── Music: pull track from active tab ────────────────────────────────────────

// Returns current playbackTime from the music tab without any side effects.
Expand Down Expand Up @@ -204,10 +218,7 @@ async function queryActiveTabForTrack(): Promise<TrackInfo | null> {
const track = await chrome.tabs.sendMessage(tab.id, { type: "TRACK_REQUEST" }) as TrackInfo | null;
if (track) {
// Blocklist check before storing or persisting to DB
const sourceUrl = `https://${
track.source === "youtube-music" ? "music.youtube.com" : "soundcloud.com"
}`;
const blocked = await isDomainBlocked(sourceUrl);
const blocked = await isTrackBlocked(track);
if (blocked) return null;

await chrome.storage.local.set({ currentTrack: track });
Expand Down Expand Up @@ -355,7 +366,11 @@ chrome.runtime.onMessage.addListener(
session: { ...session, elapsedMs: getElapsedMs(session) },
});
// Fire-and-forget DB session creation — retried by sync alarm if it fails
if (!session.dbSessionId) void ensureDbSession();
if (!session.dbSessionId) {
ensureDbSession().catch((err) =>
console.warn("[worktrace] ensureDbSession failed:", err),
);
}
})
.catch((err: unknown) =>
sendResponse({
Expand Down Expand Up @@ -494,13 +509,8 @@ chrome.runtime.onMessage.addListener(
const track = message.payload;

// Background-level blocklist check: second line of defence after content script
// Derive the domain from the known source → hostname mapping
const trackSourceDomain: Record<TrackInfo["source"], string> = {
"youtube-music": "music.youtube.com",
"soundcloud": "soundcloud.com",
};
void (async () => {
const blocked = await isDomainBlocked(`https://${trackSourceDomain[track.source]}`);
const blocked = await isTrackBlocked(track);
if (blocked) return;

void chrome.storage.local.set({ currentTrack: track });
Expand Down Expand Up @@ -620,6 +630,9 @@ chrome.runtime.onMessage.addListener(
return true;
}

// Exhaustiveness guard — TypeScript compile error if a new message type is added without a handler
const _exhaustive: never = message;
void _exhaustive;
return false;
},
);
Expand Down
136 changes: 77 additions & 59 deletions extension/src/background/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ let apiFetchFn: ApiFetch | null = null;
let ensureDbSessionFn: EnsureDbSession | null = null;
let checkAuthFn: CheckAuth | null = null;

// Re-entry guard: the 30s alarm and the inline SESSION_STOP flush can overlap.
// Without this, two concurrent flushes read the same queue, POST the same
// events (event.create has no idempotency key → permanent duplicates), and
// race on writing `remaining` back. The SW is single-threaded, so a synchronous
// check before the first await is sufficient to serialise.
let flushing = false;

export function initSync(deps: {
apiFetch: ApiFetch;
ensureDbSession: EnsureDbSession;
Expand Down Expand Up @@ -56,70 +63,81 @@ async function setStatus(patch: Partial<SyncStatus>): Promise<void> {
export async function flush(): Promise<void> {
if (!apiFetchFn || !ensureDbSessionFn || !checkAuthFn) return;

// Skip flush entirely if the user is not authenticated — avoids ERR_CONNECTION_REFUSED spam
const isAuth = await checkAuthFn();
if (!isAuth) return;

const session = await getSession();
if (!session) return;

const dbSessionId = session.dbSessionId ?? (await ensureDbSessionFn());
if (!dbSessionId) {
await setStatus({ lastError: "DB session not available" });
return;
}

const r = await chrome.storage.local.get(PENDING_KEY);
const queue = (r[PENDING_KEY] as PendingEvent[] | undefined) ?? [];
if (queue.length === 0) return;

const batch = queue.slice(0, BATCH_SIZE);
let sentCount = 0;
let lastError: string | null = null;

for (let i = 0; i < batch.length; i++) {
const event = batch[i]!;
let res: Response;
try {
res = await apiFetchFn("/api/v1/events", {
method: "POST",
body: JSON.stringify({ ...event, sessionId: dbSessionId }),
});
} catch (err) {
// Network error — keep this event and everything after it for the next flush
lastError = err instanceof Error ? err.message : "Network error";
break;
}

if (res.ok) {
sentCount++;
continue;
// Bail synchronously if another flush is already in flight (see `flushing` above).
if (flushing) return;
flushing = true;
try {
// Skip flush entirely if the user is not authenticated — avoids ERR_CONNECTION_REFUSED spam
const isAuth = await checkAuthFn();
if (!isAuth) return;

const session = await getSession();
if (!session) return;

const dbSessionId = session.dbSessionId ?? (await ensureDbSessionFn());
if (!dbSessionId) {
await setStatus({ lastError: "DB session not available" });
return;
}

if (res.status === 401) {
// Token rotated/expired — stop, queue waits for next login
lastError = "Unauthorized — please sign in again";
const r = await chrome.storage.local.get(PENDING_KEY);
const queue = (r[PENDING_KEY] as PendingEvent[] | undefined) ?? [];
if (queue.length === 0) return;

const batch = queue.slice(0, BATCH_SIZE);
let sentCount = 0;
let lastError: string | null = null;

for (let i = 0; i < batch.length; i++) {
const event = batch[i]!;
let res: Response;
try {
res = await apiFetchFn("/api/v1/events", {
method: "POST",
body: JSON.stringify({ ...event, sessionId: dbSessionId }),
});
} catch (err) {
// Network error — keep this event and everything after it for the next flush
lastError = err instanceof Error ? err.message : "Network error";
break;
}

if (res.ok) {
sentCount++;
continue;
}

if (res.status === 401) {
// Token rotated/expired — stop, queue waits for next login
lastError = "Unauthorized — please sign in again";
break;
}

if (res.status >= 400 && res.status < 500) {
// Poisonous payload — drop it, keep going so it can't block the queue
sentCount++;
lastError = `Dropped invalid event (HTTP ${String(res.status)})`;
continue;
}

// 5xx — retry next alarm
lastError = `Server error (HTTP ${String(res.status)})`;
break;
}

if (res.status >= 400 && res.status < 500) {
// Poisonous payload — drop it, keep going so it can't block the queue
sentCount++;
lastError = `Dropped invalid event (HTTP ${String(res.status)})`;
continue;
if (sentCount > 0) {
// Re-read the queue: events may have been appended by content scripts
// while we were awaiting the network. Only drop what we actually sent.
const fresh = await chrome.storage.local.get(PENDING_KEY);
const current = (fresh[PENDING_KEY] as PendingEvent[] | undefined) ?? [];
const remaining = current.slice(sentCount);
await chrome.storage.local.set({ [PENDING_KEY]: remaining });
}

// 5xx — retry next alarm
lastError = `Server error (HTTP ${String(res.status)})`;
break;
await setStatus({
lastSyncedAt: sentCount > 0 ? Date.now() : (await getStatus()).lastSyncedAt,
lastError,
});
} finally {
flushing = false;
}

if (sentCount > 0) {
const remaining = queue.slice(sentCount);
await chrome.storage.local.set({ [PENDING_KEY]: remaining });
}
await setStatus({
lastSyncedAt: sentCount > 0 ? Date.now() : (await getStatus()).lastSyncedAt,
lastError,
});
}
2 changes: 1 addition & 1 deletion extension/src/popup/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<div class="popup__logo">
<span class="popup__logo-icon">⬡</span>
<div class="popup__logo-stack">
<span class="popup__logo-text">WORKTRACE</span>
<a href="https://worktrace-ecru.vercel.app" class="popup__logo-text" target="_blank" rel="noopener noreferrer">WORKTRACE</a>
<span class="popup__user-email" id="user-email" hidden></span>
</div>
</div>
Expand Down
1 change: 1 addition & 0 deletions extension/src/popup/popup.css
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ body {
font-weight: 700;
color: var(--c-cyan);
text-shadow: 0 0 6px color-mix(in srgb, var(--c-cyan) 60%, transparent);
text-decoration: none;
}

.popup__user-email {
Expand Down
Loading