From 32475bdf34b835040e60b9e8725ae65b46789f85 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 13 Feb 2026 15:17:19 -0700 Subject: [PATCH] feat: add rNotes Web Clipper browser extension (TASK-6) Chrome extension (Manifest V3) forked from PKMN Quick Capture: - Clip pages, text selections, links, and images to rNotes - Notebook selection dropdown with remembered last-used - Tags input for organizing clips - Context menu integration (right-click to save) - EncryptID auth via token paste flow - Image upload through /api/uploads then IMAGE note creation - Amber/orange theme matching rNotes design Also updates signin page to show extension token when ?extension=true query param is present. Co-Authored-By: Claude Opus 4.6 --- browser-extension/background.js | 247 ++++++++++++++++++++++++ browser-extension/icons/icon-128.png | Bin 0 -> 837 bytes browser-extension/icons/icon-16.png | Bin 0 -> 185 bytes browser-extension/icons/icon-48.png | Bin 0 -> 349 bytes browser-extension/manifest.json | 37 ++++ browser-extension/options.html | 231 +++++++++++++++++++++++ browser-extension/options.js | 179 ++++++++++++++++++ browser-extension/popup.html | 223 ++++++++++++++++++++++ browser-extension/popup.js | 269 +++++++++++++++++++++++++++ src/app/auth/signin/page.tsx | 39 +++- 10 files changed, 1220 insertions(+), 5 deletions(-) create mode 100644 browser-extension/background.js create mode 100644 browser-extension/icons/icon-128.png create mode 100644 browser-extension/icons/icon-16.png create mode 100644 browser-extension/icons/icon-48.png create mode 100644 browser-extension/manifest.json create mode 100644 browser-extension/options.html create mode 100644 browser-extension/options.js create mode 100644 browser-extension/popup.html create mode 100644 browser-extension/popup.js diff --git a/browser-extension/background.js b/browser-extension/background.js new file mode 100644 index 0000000..31120cb --- /dev/null +++ b/browser-extension/background.js @@ -0,0 +1,247 @@ +const DEFAULT_HOST = 'https://rnotes.online'; + +// --- Context Menu Setup --- + +chrome.runtime.onInstalled.addListener(() => { + chrome.contextMenus.create({ + id: 'clip-page', + title: 'Clip page to rNotes', + contexts: ['page'], + }); + + chrome.contextMenus.create({ + id: 'save-link', + title: 'Save link to rNotes', + contexts: ['link'], + }); + + chrome.contextMenus.create({ + id: 'save-image', + title: 'Save image to rNotes', + contexts: ['image'], + }); + + chrome.contextMenus.create({ + id: 'clip-selection', + title: 'Clip selection to rNotes', + contexts: ['selection'], + }); +}); + +// --- Helpers --- + +async function getSettings() { + const result = await chrome.storage.sync.get(['rnotesHost']); + return { + host: result.rnotesHost || DEFAULT_HOST, + }; +} + +async function getToken() { + const result = await chrome.storage.local.get(['encryptid_token']); + return result.encryptid_token || null; +} + +async function getDefaultNotebook() { + const result = await chrome.storage.local.get(['lastNotebookId']); + return result.lastNotebookId || null; +} + +function showNotification(title, message) { + chrome.notifications.create({ + type: 'basic', + iconUrl: 'icons/icon-128.png', + title: title, + message: message, + }); +} + +async function createNote(data) { + const token = await getToken(); + if (!token) { + showNotification('rNotes Error', 'Not signed in. Open extension settings to sign in.'); + return; + } + + const settings = await getSettings(); + const notebookId = await getDefaultNotebook(); + + const body = { + title: data.title, + content: data.content, + type: data.type || 'CLIP', + url: data.url, + }; + + if (notebookId) body.notebookId = notebookId; + if (data.fileUrl) body.fileUrl = data.fileUrl; + if (data.mimeType) body.mimeType = data.mimeType; + if (data.fileSize) body.fileSize = data.fileSize; + + const response = await fetch(`${settings.host}/api/notes`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`${response.status}: ${text}`); + } + + return response.json(); +} + +async function uploadImage(imageUrl) { + const token = await getToken(); + const settings = await getSettings(); + + // Fetch the image + const imgResponse = await fetch(imageUrl); + const blob = await imgResponse.blob(); + + // Extract filename + let filename; + try { + const urlPath = new URL(imageUrl).pathname; + filename = urlPath.split('/').pop() || `image-${Date.now()}.jpg`; + } catch { + filename = `image-${Date.now()}.jpg`; + } + + // Upload to rNotes + const formData = new FormData(); + formData.append('file', blob, filename); + + const response = await fetch(`${settings.host}/api/uploads`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + }, + body: formData, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Upload failed: ${response.status} ${text}`); + } + + return response.json(); +} + +// --- Context Menu Handler --- + +chrome.contextMenus.onClicked.addListener(async (info, tab) => { + try { + switch (info.menuItemId) { + case 'clip-page': { + // Get page HTML + let content = ''; + try { + const [result] = await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + func: () => document.body.innerHTML, + }); + content = result?.result || ''; + } catch { + content = `

Clipped from ${tab.url}

`; + } + + await createNote({ + title: tab.title || 'Untitled Clip', + content: content, + type: 'CLIP', + url: tab.url, + }); + + showNotification('Page Clipped', `"${tab.title}" saved to rNotes`); + break; + } + + case 'save-link': { + const linkUrl = info.linkUrl; + const linkText = info.selectionText || linkUrl; + + await createNote({ + title: linkText, + content: `

${linkText}

Found on: ${tab.title}

`, + type: 'BOOKMARK', + url: linkUrl, + }); + + showNotification('Link Saved', `Bookmark saved to rNotes`); + break; + } + + case 'save-image': { + const imageUrl = info.srcUrl; + + // Upload the image first + const upload = await uploadImage(imageUrl); + + // Create IMAGE note with file reference + await createNote({ + title: `Image from ${tab.title || 'page'}`, + content: `

Clipped image

Source: ${tab.title}

`, + type: 'IMAGE', + url: tab.url, + fileUrl: upload.url, + mimeType: upload.mimeType, + fileSize: upload.size, + }); + + showNotification('Image Saved', `Image saved to rNotes`); + break; + } + + case 'clip-selection': { + // Get selection HTML + let content = ''; + try { + const [result] = await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + func: () => { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) return ''; + const range = selection.getRangeAt(0); + const div = document.createElement('div'); + div.appendChild(range.cloneContents()); + return div.innerHTML; + }, + }); + content = result?.result || ''; + } catch { + content = `

${info.selectionText || ''}

`; + } + + if (!content && info.selectionText) { + content = `

${info.selectionText}

`; + } + + await createNote({ + title: `Selection from ${tab.title || 'page'}`, + content: content, + type: 'CLIP', + url: tab.url, + }); + + showNotification('Selection Clipped', `Saved to rNotes`); + break; + } + } + } catch (err) { + console.error('Context menu action failed:', err); + showNotification('rNotes Error', err.message || 'Failed to save'); + } +}); + +// --- Message Handler (from popup) --- + +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message.type === 'notify') { + showNotification(message.title, message.message); + } +}); diff --git a/browser-extension/icons/icon-128.png b/browser-extension/icons/icon-128.png new file mode 100644 index 0000000000000000000000000000000000000000..1e296f93e2ad8879b405f99027910b72426addd4 GIT binary patch literal 837 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrVCM03aSW-L^Y*rVp0J~Y!$;Gu zV#cWH+CTYX8ig+e?DTYP4GIi&UAZD7=3)WUx&yL0JAGzzt9UHjG-Zb54^+hW%s)u#7vV|;O+n*0Gx zZqJT{OUZiM8*aZf`}wo>zWROpr?(HL)Ni=*=($*3yjIIfH-n$i2hL@S*S~l^Z?`9# zK;ip&uj&#eF}|^W*0s!s(S_lZF@ptjgE7O7?pypjcJAZ3?|c8={+iwIe;=H^>q;@B zY2V)0yYebO2fVdx&*Bex{p9VPd-XLRzpZ4fsr&u-NwU-`H;#wG3%(tC&NGu~=kk{9 z+jDoH5OCto-uvTsa6)?Js~K0#M}|La$CH$M_q zDE+!U=FgNnE*X1YXs+}ODB@~iH`sR6-u<`W6rKeybQgSGH-FKSO1`_y6~^SzvJQ?#$!ePOA48YaZc~EuMGRtHt?!LTz)6x8$z2$@8Wz&)v`7VdUE_b7x%^Tmbp8}e9RqRBj uK};|AieI8x9RHs`nWEx335=HhVKwSJI%Dzgqxrz>!QkoY=d#Wzp$P!dEpYGv literal 0 HcmV?d00001 diff --git a/browser-extension/icons/icon-16.png b/browser-extension/icons/icon-16.png new file mode 100644 index 0000000000000000000000000000000000000000..62b0620d2dea8414e163c060721932d494cb44f3 GIT binary patch literal 185 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`4W2HJAr*6y6Amy31qJYP0j_Y{$Ky#g+Z^1-K(Fu+Cb(Ip^r|g=U}o&EuFahzc+rKETcVo~es@ViNO* hXRI|0)5JB{7|i|~H7?({GaTp^22WQ%mvv4FO#r4T3MVaQm^9=oxzv@!qjUI&l65pSn0 z`8GeV>=A=QRZiObGjH1L66d&G*>hr@bj_|KKUb+7XK2`!#pu&L!73rhhT+L-AxX1l z)8I!YRbD-YHyg9IzwzlVcZ}V-IQGLop>76qr31Yj1f7z#w^H_R zY{+D~UQyrT(jYDP;8-=|Pric3e+7jY^MC@Cy+v@g;Dcw|SuZ6EpYipGF*d!!&>{O^ r(t;bP0l+XkKs-TbP literal 0 HcmV?d00001 diff --git a/browser-extension/manifest.json b/browser-extension/manifest.json new file mode 100644 index 0000000..315fc00 --- /dev/null +++ b/browser-extension/manifest.json @@ -0,0 +1,37 @@ +{ + "manifest_version": 3, + "name": "rNotes Web Clipper", + "version": "1.0.0", + "description": "Clip pages, text, links, and images to rNotes.online", + "permissions": [ + "activeTab", + "contextMenus", + "storage", + "notifications" + ], + "host_permissions": [ + "https://rnotes.online/*", + "https://encryptid.jeffemmett.com/*", + "*://*/*" + ], + "action": { + "default_popup": "popup.html", + "default_icon": { + "16": "icons/icon-16.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + } + }, + "icons": { + "16": "icons/icon-16.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + }, + "background": { + "service_worker": "background.js" + }, + "options_ui": { + "page": "options.html", + "open_in_tab": false + } +} diff --git a/browser-extension/options.html b/browser-extension/options.html new file mode 100644 index 0000000..9946840 --- /dev/null +++ b/browser-extension/options.html @@ -0,0 +1,231 @@ + + + + + + + +

rNotes Web Clipper Settings

+ + +
+

Connection

+
+ + +
The URL of your rNotes instance
+
+
+ + +
+

Authentication

+
+ Not signed in +
+ +
+
+ + +
Opens rNotes in a new tab. Sign in with your passkey.
+
+
+ + +
After signing in, copy the extension token and paste it here.
+
+
+ +
+
+ + +
+ + +
+

Default Notebook

+
+ + +
Pre-selected notebook when clipping
+
+
+ + +
+ + +
+ +
+ + + + diff --git a/browser-extension/options.js b/browser-extension/options.js new file mode 100644 index 0000000..55858c5 --- /dev/null +++ b/browser-extension/options.js @@ -0,0 +1,179 @@ +const DEFAULT_HOST = 'https://rnotes.online'; + +// --- Helpers --- + +function decodeToken(token) { + try { + const payload = JSON.parse(atob(token.split('.')[1])); + if (payload.exp && payload.exp * 1000 < Date.now()) { + return null; + } + return payload; + } catch { + return null; + } +} + +function showStatus(message, type) { + const el = document.getElementById('status'); + el.textContent = message; + el.className = `status ${type}`; + if (type === 'success') { + setTimeout(() => { el.className = 'status'; }, 3000); + } +} + +// --- Auth UI --- + +async function updateAuthUI() { + const { encryptid_token } = await chrome.storage.local.get(['encryptid_token']); + const claims = encryptid_token ? decodeToken(encryptid_token) : null; + + const authStatus = document.getElementById('authStatus'); + const loginSection = document.getElementById('loginSection'); + const loggedInSection = document.getElementById('loggedInSection'); + + if (claims) { + const username = claims.username || claims.sub?.slice(0, 20) || 'Authenticated'; + authStatus.textContent = `Signed in as ${username}`; + authStatus.className = 'auth-status authed'; + loginSection.style.display = 'none'; + loggedInSection.style.display = 'block'; + } else { + authStatus.textContent = 'Not signed in'; + authStatus.className = 'auth-status not-authed'; + loginSection.style.display = 'block'; + loggedInSection.style.display = 'none'; + } +} + +async function populateNotebooks() { + const { encryptid_token } = await chrome.storage.local.get(['encryptid_token']); + if (!encryptid_token) return; + + const host = document.getElementById('host').value.replace(/\/+$/, '') || DEFAULT_HOST; + + try { + const response = await fetch(`${host}/api/notebooks`, { + headers: { 'Authorization': `Bearer ${encryptid_token}` }, + }); + + if (!response.ok) return; + + const notebooks = await response.json(); + const select = document.getElementById('defaultNotebook'); + + // Clear existing options (keep first) + while (select.options.length > 1) { + select.remove(1); + } + + for (const nb of notebooks) { + const option = document.createElement('option'); + option.value = nb.id; + option.textContent = nb.title; + select.appendChild(option); + } + + // Restore saved default + const { lastNotebookId } = await chrome.storage.local.get(['lastNotebookId']); + if (lastNotebookId) { + select.value = lastNotebookId; + } + } catch (err) { + console.error('Failed to load notebooks:', err); + } +} + +// --- Load settings --- + +async function loadSettings() { + const result = await chrome.storage.sync.get(['rnotesHost']); + document.getElementById('host').value = result.rnotesHost || DEFAULT_HOST; + + await updateAuthUI(); + await populateNotebooks(); +} + +// --- Event handlers --- + +// Open rNotes sign-in +document.getElementById('openSigninBtn').addEventListener('click', () => { + const host = document.getElementById('host').value.replace(/\/+$/, '') || DEFAULT_HOST; + chrome.tabs.create({ url: `${host}/auth/signin?extension=true` }); +}); + +// Save token +document.getElementById('saveTokenBtn').addEventListener('click', async () => { + const tokenInput = document.getElementById('tokenInput').value.trim(); + + if (!tokenInput) { + showStatus('Please paste a token', 'error'); + return; + } + + const claims = decodeToken(tokenInput); + if (!claims) { + showStatus('Invalid or expired token', 'error'); + return; + } + + await chrome.storage.local.set({ encryptid_token: tokenInput }); + document.getElementById('tokenInput').value = ''; + + showStatus(`Signed in as ${claims.username || claims.sub}`, 'success'); + await updateAuthUI(); + await populateNotebooks(); +}); + +// Logout +document.getElementById('logoutBtn').addEventListener('click', async () => { + await chrome.storage.local.remove(['encryptid_token']); + showStatus('Signed out', 'success'); + await updateAuthUI(); +}); + +// Save settings +document.getElementById('saveBtn').addEventListener('click', async () => { + const host = document.getElementById('host').value.trim().replace(/\/+$/, ''); + const notebookId = document.getElementById('defaultNotebook').value; + + await chrome.storage.sync.set({ rnotesHost: host || DEFAULT_HOST }); + await chrome.storage.local.set({ lastNotebookId: notebookId }); + + showStatus('Settings saved', 'success'); +}); + +// Test connection +document.getElementById('testBtn').addEventListener('click', async () => { + const host = document.getElementById('host').value.trim().replace(/\/+$/, '') || DEFAULT_HOST; + const { encryptid_token } = await chrome.storage.local.get(['encryptid_token']); + + try { + const headers = {}; + if (encryptid_token) { + headers['Authorization'] = `Bearer ${encryptid_token}`; + } + + const response = await fetch(`${host}/api/notebooks`, { headers }); + + if (response.ok) { + const data = await response.json(); + showStatus(`Connected! Found ${data.length || 0} notebooks.`, 'success'); + } else if (response.status === 401) { + showStatus('Connected but not authenticated. Sign in first.', 'error'); + } else { + showStatus(`Connection failed: ${response.status}`, 'error'); + } + } catch (err) { + showStatus(`Cannot connect: ${err.message}`, 'error'); + } +}); + +// Default notebook change +document.getElementById('defaultNotebook').addEventListener('change', async (e) => { + await chrome.storage.local.set({ lastNotebookId: e.target.value }); +}); + +// Init +document.addEventListener('DOMContentLoaded', loadSettings); diff --git a/browser-extension/popup.html b/browser-extension/popup.html new file mode 100644 index 0000000..d0db5ac --- /dev/null +++ b/browser-extension/popup.html @@ -0,0 +1,223 @@ + + + + + + + +
+ rNotes Clipper + ... +
+ + + +
+
Loading...
+
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + + + + + diff --git a/browser-extension/popup.js b/browser-extension/popup.js new file mode 100644 index 0000000..c0fa35d --- /dev/null +++ b/browser-extension/popup.js @@ -0,0 +1,269 @@ +const DEFAULT_HOST = 'https://rnotes.online'; + +let currentTab = null; +let selectedText = ''; +let selectedHtml = ''; + +// --- Helpers --- + +async function getSettings() { + const result = await chrome.storage.sync.get(['rnotesHost']); + return { + host: result.rnotesHost || DEFAULT_HOST, + }; +} + +async function getToken() { + const result = await chrome.storage.local.get(['encryptid_token']); + return result.encryptid_token || null; +} + +function decodeToken(token) { + try { + const payload = JSON.parse(atob(token.split('.')[1])); + // Check expiry + if (payload.exp && payload.exp * 1000 < Date.now()) { + return null; // expired + } + return payload; + } catch { + return null; + } +} + +function parseTags(tagString) { + if (!tagString || !tagString.trim()) return []; + return tagString.split(',').map(t => t.trim().toLowerCase()).filter(Boolean); +} + +function showStatus(message, type) { + const el = document.getElementById('status'); + el.textContent = message; + el.className = `status ${type}`; + if (type === 'success') { + setTimeout(() => { el.className = 'status'; }, 3000); + } +} + +// --- API calls --- + +async function createNote(data) { + const token = await getToken(); + const settings = await getSettings(); + + const body = { + title: data.title, + content: data.content, + type: data.type || 'CLIP', + url: data.url, + }; + + const notebookId = document.getElementById('notebook').value; + if (notebookId) body.notebookId = notebookId; + + const tags = parseTags(document.getElementById('tags').value); + if (tags.length > 0) body.tags = tags; + + const response = await fetch(`${settings.host}/api/notes`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`${response.status}: ${text}`); + } + + return response.json(); +} + +async function fetchNotebooks() { + const token = await getToken(); + const settings = await getSettings(); + + const response = await fetch(`${settings.host}/api/notebooks`, { + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + + if (!response.ok) return []; + const data = await response.json(); + return Array.isArray(data) ? data : []; +} + +// --- UI --- + +async function populateNotebooks() { + const select = document.getElementById('notebook'); + try { + const notebooks = await fetchNotebooks(); + // Keep the "No notebook" option + for (const nb of notebooks) { + const option = document.createElement('option'); + option.value = nb.id; + option.textContent = nb.title; + select.appendChild(option); + } + + // Restore last used notebook + const { lastNotebookId } = await chrome.storage.local.get(['lastNotebookId']); + if (lastNotebookId) { + select.value = lastNotebookId; + } + } catch (err) { + console.error('Failed to load notebooks:', err); + } +} + +// Save last used notebook when changed +function setupNotebookMemory() { + document.getElementById('notebook').addEventListener('change', (e) => { + chrome.storage.local.set({ lastNotebookId: e.target.value }); + }); +} + +async function init() { + // Get current tab + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + currentTab = tab; + + // Display page info + document.getElementById('pageTitle').textContent = tab.title || 'Untitled'; + document.getElementById('pageUrl').textContent = tab.url || ''; + + // Check auth + const token = await getToken(); + const claims = token ? decodeToken(token) : null; + + if (!claims) { + document.getElementById('userStatus').textContent = 'Not signed in'; + document.getElementById('userStatus').classList.add('not-authed'); + document.getElementById('authWarning').style.display = 'block'; + return; + } + + document.getElementById('userStatus').textContent = claims.username || claims.sub?.slice(0, 16) || 'Authenticated'; + document.getElementById('authWarning').style.display = 'none'; + + // Enable buttons + document.getElementById('clipPageBtn').disabled = false; + + // Load notebooks + await populateNotebooks(); + setupNotebookMemory(); + + // Detect text selection + try { + const [result] = await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + func: () => { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { + return { text: '', html: '' }; + } + const range = selection.getRangeAt(0); + const div = document.createElement('div'); + div.appendChild(range.cloneContents()); + return { text: selection.toString(), html: div.innerHTML }; + }, + }); + + if (result?.result?.text) { + selectedText = result.result.text; + selectedHtml = result.result.html; + document.getElementById('clipSelectionBtn').disabled = false; + } + } catch (err) { + // Can't access some pages (chrome://, etc.) + console.warn('Cannot access page content:', err); + } +} + +// --- Event handlers --- + +document.getElementById('clipPageBtn').addEventListener('click', async () => { + const btn = document.getElementById('clipPageBtn'); + btn.disabled = true; + showStatus('Clipping page...', 'loading'); + + try { + // Get page HTML content + let pageContent = ''; + try { + const [result] = await chrome.scripting.executeScript({ + target: { tabId: currentTab.id }, + func: () => document.body.innerHTML, + }); + pageContent = result?.result || ''; + } catch { + // Fallback: just use URL as content + pageContent = `

Clipped from ${currentTab.url}

`; + } + + const note = await createNote({ + title: currentTab.title || 'Untitled Clip', + content: pageContent, + type: 'CLIP', + url: currentTab.url, + }); + + showStatus(`Clipped! Note saved.`, 'success'); + + // Notify background worker + chrome.runtime.sendMessage({ + type: 'notify', + title: 'Page Clipped', + message: `"${currentTab.title}" saved to rNotes`, + }); + } catch (err) { + showStatus(`Error: ${err.message}`, 'error'); + } finally { + btn.disabled = false; + } +}); + +document.getElementById('clipSelectionBtn').addEventListener('click', async () => { + const btn = document.getElementById('clipSelectionBtn'); + btn.disabled = true; + showStatus('Clipping selection...', 'loading'); + + try { + const content = selectedHtml || `

${selectedText}

`; + const note = await createNote({ + title: `Selection from ${currentTab.title || 'page'}`, + content: content, + type: 'CLIP', + url: currentTab.url, + }); + + showStatus(`Selection clipped!`, 'success'); + + chrome.runtime.sendMessage({ + type: 'notify', + title: 'Selection Clipped', + message: `Saved to rNotes`, + }); + } catch (err) { + showStatus(`Error: ${err.message}`, 'error'); + } finally { + btn.disabled = false; + } +}); + +document.getElementById('optionsLink').addEventListener('click', (e) => { + e.preventDefault(); + chrome.runtime.openOptionsPage(); +}); + +document.getElementById('openSettings')?.addEventListener('click', (e) => { + e.preventDefault(); + chrome.runtime.openOptionsPage(); +}); + +// Init on load +document.addEventListener('DOMContentLoaded', init); diff --git a/src/app/auth/signin/page.tsx b/src/app/auth/signin/page.tsx index 435c8d6..7b4c172 100644 --- a/src/app/auth/signin/page.tsx +++ b/src/app/auth/signin/page.tsx @@ -9,26 +9,28 @@ function SignInForm() { const router = useRouter(); const searchParams = useSearchParams(); const returnUrl = searchParams.get('returnUrl') || '/'; + const isExtension = searchParams.get('extension') === 'true'; const { isAuthenticated, loading: authLoading, login, register } = useEncryptID(); const [mode, setMode] = useState<'signin' | 'register'>('signin'); const [username, setUsername] = useState(''); const [error, setError] = useState(''); const [busy, setBusy] = useState(false); + const [tokenCopied, setTokenCopied] = useState(false); - // Redirect if already authenticated + // Redirect if already authenticated (skip if extension mode — show token instead) useEffect(() => { - if (isAuthenticated && !authLoading) { + if (isAuthenticated && !authLoading && !isExtension) { router.push(returnUrl); } - }, [isAuthenticated, authLoading, router, returnUrl]); + }, [isAuthenticated, authLoading, router, returnUrl, isExtension]); const handleSignIn = async () => { setError(''); setBusy(true); try { await login(); - router.push(returnUrl); + if (!isExtension) router.push(returnUrl); } catch (err) { setError(err instanceof Error ? err.message : 'Sign in failed. Make sure you have a registered passkey.'); } finally { @@ -45,7 +47,7 @@ function SignInForm() { setBusy(true); try { await register(username.trim()); - router.push(returnUrl); + if (!isExtension) router.push(returnUrl); } catch (err) { setError(err instanceof Error ? err.message : 'Registration failed.'); } finally { @@ -162,6 +164,33 @@ function SignInForm() { )} + {/* Extension token display */} + {isExtension && isAuthenticated && ( +
+

Extension Token

+

+ Copy this token and paste it in the rNotes Web Clipper extension settings. +

+