diff --git a/.env.example b/.env.example index 350b80c..c5bec08 100644 --- a/.env.example +++ b/.env.example @@ -15,5 +15,8 @@ DATABASE_SSL=false # Auth AUTH_JWT_SECRET=change_me_long_random +# Credential encryption (base64-encoded 32-byte key) +CREDENTIAL_MASTER_KEY=change_me_base64_32_bytes + # Admin (only this email is treated as admin) ADMIN_EMAIL=admin@example.com diff --git a/apps/extension/extension-dist.zip b/apps/extension/extension-dist.zip index 05622f8..8f6b94e 100644 Binary files a/apps/extension/extension-dist.zip and b/apps/extension/extension-dist.zip differ diff --git a/apps/extension/package.json b/apps/extension/package.json index 2cf1cce..495b582 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -1,7 +1,7 @@ { "name": "extension", "private": true, - "version": "0.0.0", + "version": "1.0.3", "type": "module", "scripts": { "dev": "vite", diff --git a/apps/extension/public/manifest.json b/apps/extension/public/manifest.json index aed4edd..982d352 100644 --- a/apps/extension/public/manifest.json +++ b/apps/extension/public/manifest.json @@ -1,12 +1,24 @@ { "manifest_version": 3, "name": "BrowserBookmark", - "version": "0.1.0", + "version": "1.0.3", "action": { "default_title": "Bookmarks", "default_popup": "popup.html" }, + "background": { + "service_worker": "background.js", + "type": "module" + }, "options_page": "options.html", "permissions": ["storage", "tabs"], - "host_permissions": [""] + "host_permissions": [""], + "content_scripts": [ + { + "matches": [""], + "js": ["content.js"], + "run_at": "document_idle", + "type": "module" + } + ] } diff --git a/apps/extension/src/background.js b/apps/extension/src/background.js new file mode 100644 index 0000000..644265e --- /dev/null +++ b/apps/extension/src/background.js @@ -0,0 +1,21 @@ +const PENDING_KEY = "bb_pending_credential"; +const PENDING_TAB_KEY = "bb_pending_tab_id"; + +function openPopup() { + if (typeof chrome === "undefined" || !chrome.action?.openPopup) return; + chrome.action.openPopup().catch(() => { + // ignore if popup cannot be opened (no user gesture) + }); +} + +chrome.runtime.onMessage.addListener((msg, sender) => { + if (!msg || msg.type !== "CREDENTIAL_CAPTURED") return; + + const payload = msg.payload || {}; + chrome.storage.local.set({ + [PENDING_KEY]: payload, + [PENDING_TAB_KEY]: sender?.tab?.id || null + }, () => { + openPopup(); + }); +}); diff --git a/apps/extension/src/content/main.js b/apps/extension/src/content/main.js new file mode 100644 index 0000000..895173b --- /dev/null +++ b/apps/extension/src/content/main.js @@ -0,0 +1,245 @@ + +function getOrigin() { + return window.location.origin; +} + +function hasChromeStorage() { + return typeof chrome !== "undefined" && chrome.storage?.local; +} + +function storageGet(keys) { + return new Promise((resolve) => { + if (!hasChromeStorage()) return resolve({}); + chrome.storage.local.get(keys, (res) => resolve(res || {})); + }); +} + + +async function getToken() { + const res = await storageGet(["bb_token"]); + return res.bb_token || ""; +} + + +async function apiFetch(path, options = {}) { + const headers = new Headers(options.headers || {}); + if (!headers.has("Accept")) headers.set("Accept", "application/json"); + if (!(options.body instanceof FormData) && options.body != null) { + headers.set("Content-Type", "application/json"); + } + + const token = await getToken(); + if (token) headers.set("Authorization", `Bearer ${token}`); + + const base = (typeof chrome !== "undefined" && chrome.runtime?.getURL) + ? (import.meta?.env?.VITE_SERVER_BASE_URL || "http://localhost:3001") + : "http://localhost:3001"; + + const res = await fetch(`${base}${path}`, { ...options, headers }); + const contentType = res.headers.get("content-type") || ""; + const isJson = contentType.includes("application/json"); + const payload = isJson ? await res.json().catch(() => null) : await res.text().catch(() => ""); + + if (!res.ok) { + const message = payload?.message || `HTTP ${res.status}`; + const err = new Error(message); + err.status = res.status; + err.payload = payload; + throw err; + } + return payload; +} + +function ensureStyles() { + if (document.getElementById("bb-cred-style")) return; + const style = document.createElement("style"); + style.id = "bb-cred-style"; + style.textContent = ` + .bb-cred-selector{position:fixed;z-index:2147483646;background:#fff;border:1px solid rgba(0,0,0,0.12);box-shadow:0 10px 24px rgba(0,0,0,0.15);border-radius:10px;padding:6px;min-width:240px;max-width:320px;} + .bb-cred-item{display:flex;flex-direction:column;gap:4px;padding:8px;border-radius:8px;cursor:pointer;} + .bb-cred-item:hover{background:rgba(13,148,136,0.08);} + .bb-cred-site{font-size:12px;color:#64748b;} + .bb-cred-user{font-weight:700;color:#0f172a;} + `; + document.head.appendChild(style); +} + +async function fetchCredentials({ includePassword = false, siteOrigin }) { + const qs = new URLSearchParams(); + if (siteOrigin) qs.set("siteOrigin", siteOrigin); + if (includePassword) qs.set("includePassword", "true"); + + return apiFetch(`/credentials?${qs.toString()}`); +} + +function findUsernameInput(form, passwordInput) { + if (!form) return null; + const inputs = Array.from(form.querySelectorAll("input")); + const pwdIndex = inputs.indexOf(passwordInput); + const candidates = inputs.filter((el, idx) => { + if (el.type === "password") return false; + if (el.disabled || el.readOnly) return false; + if (idx > pwdIndex && pwdIndex !== -1) return false; + const t = (el.type || "text").toLowerCase(); + return t === "text" || t === "email" || t === "tel"; + }); + return candidates[candidates.length - 1] || null; +} + +function findPasswordInput(anchor) { + if (!anchor) return null; + if (anchor instanceof HTMLInputElement && anchor.type === "password") return anchor; + const form = anchor.closest("form"); + if (form) return form.querySelector("input[type='password']"); + + let cur = anchor.parentElement; + let depth = 0; + while (cur && depth < 4) { + const found = cur.querySelector("input[type='password']"); + if (found) return found; + cur = cur.parentElement; + depth++; + } + return null; +} + +function createSelector(anchorEl, creds, onSelect) { + ensureStyles(); + const rect = anchorEl.getBoundingClientRect(); + const panel = document.createElement("div"); + panel.className = "bb-cred-selector"; + panel.style.left = `${Math.max(8, rect.left)}px`; + panel.style.top = `${rect.bottom + 6}px`; + panel.style.maxWidth = `${Math.min(360, window.innerWidth - rect.left - 8)}px`; + + creds.forEach((c) => { + const item = document.createElement("div"); + item.className = "bb-cred-item"; + item.innerHTML = `
${c.username}
${c.siteOrigin}
`; + item.addEventListener("click", () => { + onSelect(c); + cleanup(); + }); + panel.appendChild(item); + }); + + document.body.appendChild(panel); + + function cleanup() { + panel.remove(); + document.removeEventListener("pointerdown", onDocClick, true); + } + + function onDocClick(e) { + if (!panel.contains(e.target) && e.target !== anchorEl) cleanup(); + } + + document.addEventListener("pointerdown", onDocClick, true); + return cleanup; +} + +let lastAnchor = null; +let lastShownAt = 0; + +async function showSelectorForInput(anchorEl) { + if (!anchorEl) return; + const now = Date.now(); + if (lastAnchor === anchorEl && now - lastShownAt < 300) return; + lastAnchor = anchorEl; + lastShownAt = now; + + const passwordInput = findPasswordInput(anchorEl); + if (!passwordInput) return; + const form = passwordInput.closest("form"); + const usernameInput = findUsernameInput(form, passwordInput) || (anchorEl.type !== "password" ? anchorEl : null); + const siteOrigin = getOrigin(); + + let creds = []; + try { + creds = await fetchCredentials({ includePassword: false, siteOrigin }); + } catch { + return; + } + if (!Array.isArray(creds) || creds.length === 0) return; + + createSelector(anchorEl, creds, async (cred) => { + try { + const full = await fetchCredentials({ includePassword: true, siteOrigin }); + + const target = full.find((x) => x.id === cred.id); + if (!target) return; + if (usernameInput) { + usernameInput.value = target.username; + usernameInput.dispatchEvent(new Event("input", { bubbles: true })); + } + if (passwordInput) { + passwordInput.value = target.password || ""; + passwordInput.dispatchEvent(new Event("input", { bubbles: true })); + } + } catch { + // ignore + } + }); +} + +function handleInputFocus(e) { + const el = e.target; + if (!(el instanceof HTMLInputElement)) return; + const t = (el.type || "text").toLowerCase(); + if (t !== "password" && t !== "text" && t !== "email" && t !== "tel") return; + showSelectorForInput(el); +} + +async function handleFormSubmit(e) { + const form = e.target; + if (!(form instanceof HTMLFormElement)) return; + const pwd = form.querySelector("input[type='password']"); + if (!pwd) return; + const usernameInput = findUsernameInput(form, pwd); + + const username = usernameInput?.value?.trim(); + const password = pwd.value?.trim(); + if (!username || !password) return; + + const token = await getToken(); + if (!token) return; + + const siteOrigin = getOrigin(); + let existing = []; + try { + existing = await fetchCredentials({ includePassword: true, siteOrigin }); + } catch { + // if we cannot fetch, still allow prompt to save + existing = []; + } + + const sameUser = Array.isArray(existing) + ? existing.find((c) => c.username === username) + : null; + + if (sameUser && sameUser.password === password) { + return; + } + + const reason = sameUser ? "update" : "new"; + + if (typeof chrome !== "undefined" && chrome.runtime?.sendMessage) { + chrome.runtime.sendMessage({ + type: "CREDENTIAL_CAPTURED", + payload: { + siteOrigin, + username, + password, + reason + } + }); + } +} + +function init() { + document.addEventListener("focusin", handleInputFocus, true); + document.addEventListener("click", handleInputFocus, true); + document.addEventListener("submit", handleFormSubmit, true); +} + +init(); diff --git a/apps/extension/src/lib/extStorage.js b/apps/extension/src/lib/extStorage.js index 99b1b62..96f4642 100644 --- a/apps/extension/src/lib/extStorage.js +++ b/apps/extension/src/lib/extStorage.js @@ -1,5 +1,8 @@ const TOKEN_KEY = "bb_token"; const LOCAL_STATE_KEY = "bb_local_state_v1"; +const PENDING_CREDENTIAL_KEY = "bb_pending_credential"; +const REAUTH_TOKEN_KEY = "bb_reauth_token"; +const REAUTH_EXPIRES_KEY = "bb_reauth_expires_at"; function hasChromeStorage() { return typeof chrome !== "undefined" && chrome.storage?.local; @@ -41,3 +44,49 @@ export async function saveLocalState(state) { } localStorage.setItem(LOCAL_STATE_KEY, JSON.stringify(state)); } + +export async function getPendingCredential() { + if (hasChromeStorage()) { + const res = await chrome.storage.local.get([PENDING_CREDENTIAL_KEY]); + return res[PENDING_CREDENTIAL_KEY] || null; + } + try { + return JSON.parse(localStorage.getItem(PENDING_CREDENTIAL_KEY) || "") || null; + } catch { + return null; + } +} + +export async function clearPendingCredential() { + if (hasChromeStorage()) { + await chrome.storage.local.remove([PENDING_CREDENTIAL_KEY]); + return; + } + localStorage.removeItem(PENDING_CREDENTIAL_KEY); +} + +export async function setReauthToken(token, expiresAt) { + if (hasChromeStorage()) { + await chrome.storage.local.set({ + [REAUTH_TOKEN_KEY]: token || "", + [REAUTH_EXPIRES_KEY]: expiresAt || "" + }); + return; + } + localStorage.setItem(REAUTH_TOKEN_KEY, token || ""); + localStorage.setItem(REAUTH_EXPIRES_KEY, expiresAt || ""); +} + +export async function getReauthToken() { + if (hasChromeStorage()) { + const res = await chrome.storage.local.get([REAUTH_TOKEN_KEY, REAUTH_EXPIRES_KEY]); + return { + token: res[REAUTH_TOKEN_KEY] || "", + expiresAt: res[REAUTH_EXPIRES_KEY] || "" + }; + } + return { + token: localStorage.getItem(REAUTH_TOKEN_KEY) || "", + expiresAt: localStorage.getItem(REAUTH_EXPIRES_KEY) || "" + }; +} diff --git a/apps/extension/src/options/pages/LoginPage.vue b/apps/extension/src/options/pages/LoginPage.vue index 05cf685..5191cad 100644 --- a/apps/extension/src/options/pages/LoginPage.vue +++ b/apps/extension/src/options/pages/LoginPage.vue @@ -55,7 +55,7 @@ async function submit() {
- +

{{ error }}

diff --git a/apps/extension/src/options/pages/MyPage.vue b/apps/extension/src/options/pages/MyPage.vue index 9b5645c..7a96ac3 100644 --- a/apps/extension/src/options/pages/MyPage.vue +++ b/apps/extension/src/options/pages/MyPage.vue @@ -79,7 +79,7 @@ onMounted(load);
- +
diff --git a/apps/extension/src/popup/PopupApp.vue b/apps/extension/src/popup/PopupApp.vue index 193f286..4df6f95 100644 --- a/apps/extension/src/popup/PopupApp.vue +++ b/apps/extension/src/popup/PopupApp.vue @@ -1,7 +1,7 @@ + + + + diff --git a/apps/web/src/pages/PublicPage.vue b/apps/web/src/pages/PublicPage.vue index 020b890..859204e 100644 --- a/apps/web/src/pages/PublicPage.vue +++ b/apps/web/src/pages/PublicPage.vue @@ -139,7 +139,7 @@ onMounted(loadFolders);
- + { const loggedIn = Boolean(tokenRef.value); - // 主页(/)永远是公共首页;不因登录态自动跳转 + // 已登录访问首页时,跳转到个人页 + if (to.path === "/" && loggedIn) return { path: "/my" }; // 已登录访问登录页:直接去“我的” if (to.path === "/login" && loggedIn) return { path: "/my" }; @@ -30,6 +33,10 @@ router.beforeEach(async (to) => { return { path: "/login", query: { next: to.fullPath } }; } + if (to.path === "/passwords" && !loggedIn) { + return { path: "/login", query: { next: to.fullPath } }; + } + // 管理界面:仅管理员可见 if (to.path.startsWith("/admin")) { if (!loggedIn) return { path: "/login", query: { next: to.fullPath } }; diff --git a/docs/开发框架约束.md b/docs/开发框架约束.md index d1ecd17..a929466 100644 --- a/docs/开发框架约束.md +++ b/docs/开发框架约束.md @@ -85,4 +85,16 @@ AI 创建项目时,默认使用以下结构;如项目类型不适用,可 - 先指出冲突点,并请求用户确认是否允许偏离约束。 - 未明确要求时: - 不引入与约束无关的“额外页面/功能/组件/花哨配置”。 - - 保持最小可用、可验证、可维护的实现。 \ No newline at end of file + - 保持最小可用、可验证、可维护的实现。 + +7. 发布版本号约束(强约束) + +- **每次发布必须迭代版本号**,并同步更新以下位置(缺一不可): + - 后端版本:`apps/server/package.json` + - Web 版本:`apps/web/package.json` + - 插件版本:`apps/extension/package.json` + - 插件清单版本:`apps/extension/public/manifest.json` +- 发布后必须验证构建产物里的插件版本已更新:`apps/extension/dist/manifest.json`。 +- 当前统一版本从 **1.0.0** 开始。 +- 默认规则:仅递增最后一位(patch),例如 1.0.0 → 1.0.1 → 1.0.2。 +- 只有在用户明确要求时,才允许变更中间位(minor);否则不得修改。 \ No newline at end of file diff --git a/openspec/changes/archive/2026-01-22-add-dnd-sorting/design.md b/openspec/changes/archive/2026-01-22-add-dnd-sorting/design.md new file mode 100644 index 0000000..bdf4284 --- /dev/null +++ b/openspec/changes/archive/2026-01-22-add-dnd-sorting/design.md @@ -0,0 +1,24 @@ +# Design: Persistent ordering + touch-friendly DnD + +## Database +- Add `sort_order integer not null default 0` to `bookmarks`. +- Add indexes to support ordered listing: + - `(user_id, folder_id, sort_order)` + +## API +- Extend `Bookmark` DTO/schema with `sortOrder`. +- Add `POST /bookmarks/reorder` similar to existing `/folders/reorder`: + - Input: `{ folderId: uuid|null, orderedIds: uuid[] }` + - Validates `orderedIds` is a permutation of all bookmarks for that user+folder (excluding deleted). + - Transactionally updates `sort_order` for each id. + +## Web UI +- Replace native HTML5 drag/drop with a touch-capable approach. + - Implementation choice: `sortablejs` (small, proven, touch-friendly). + - Bind Sortable to: + - Folder header list (per parent group) for folder ordering. + - Each open folder’s bookmark list for bookmark ordering. +- Root group is rendered as a first-class group and can also be reordered. + +## Compatibility +- If the DB schema lacks ordering columns (fresh/old DB), endpoints should return a clear 409 prompting `db:migrate`. diff --git a/openspec/changes/archive/2026-01-22-add-dnd-sorting/proposal.md b/openspec/changes/archive/2026-01-22-add-dnd-sorting/proposal.md new file mode 100644 index 0000000..de16a62 --- /dev/null +++ b/openspec/changes/archive/2026-01-22-add-dnd-sorting/proposal.md @@ -0,0 +1,18 @@ +# Change: Add persistent drag-and-drop sorting (folders + bookmarks) + +## Why +Users need to reorder folders and bookmarks via drag-and-drop (including mobile/touch) and have that order persist across reloads. Current HTML5 drag/drop is unreliable on mobile and ordering is not stored for bookmarks. + +## What Changes +- Add persistent ordering for bookmarks (new DB column and API endpoint to reorder within a folder). +- Use a touch-friendly drag-and-drop implementation in the web UI for: + - Reordering folders within the same parent. + - Reordering bookmarks within the same folder. +- Keep the root group (no folder) as a first-class group in the UI. + +## Impact +- Affected specs: API (OpenAPI-backed) +- Affected code: + - Server: migrations, bookmarks routes, admin routes, row DTO mapping + - Web: MyPage and AdminPage UI ordering and drag/drop + - OpenAPI: Bookmark schema and reorder endpoint diff --git a/openspec/changes/archive/2026-01-22-add-dnd-sorting/specs/api/spec.md b/openspec/changes/archive/2026-01-22-add-dnd-sorting/specs/api/spec.md new file mode 100644 index 0000000..893928a --- /dev/null +++ b/openspec/changes/archive/2026-01-22-add-dnd-sorting/specs/api/spec.md @@ -0,0 +1,35 @@ +## ADDED Requirements + +### Requirement: Folder ordering persistence +The system SHALL persist folder ordering per user per parent folder. + +#### Scenario: List folders returns stable ordered result +- **GIVEN** an authenticated user +- **WHEN** the user calls `GET /folders` +- **THEN** the server returns folders ordered by `(parentId, sortOrder, name)` + +#### Scenario: Reorder folders within the same parent +- **GIVEN** an authenticated user +- **WHEN** the user calls `POST /folders/reorder` with `parentId` and `orderedIds` +- **THEN** the server persists the new order and returns `{ ok: true }` + +### Requirement: Bookmark ordering persistence +The system SHALL persist bookmark ordering per user per folder. + +#### Scenario: List my bookmarks returns stable ordered result +- **GIVEN** an authenticated user +- **WHEN** the user calls `GET /bookmarks` +- **THEN** the server returns bookmarks ordered by `(folderId, sortOrder, updatedAt desc)` + +#### Scenario: Reorder bookmarks within the same folder +- **GIVEN** an authenticated user +- **WHEN** the user calls `POST /bookmarks/reorder` with `folderId` and `orderedIds` +- **THEN** the server persists the new order and returns `{ ok: true }` + +### Requirement: Root group treated consistently +The system SHALL treat `folderId=null` bookmarks as belonging to the root group. + +#### Scenario: Reorder root-group bookmarks +- **GIVEN** an authenticated user +- **WHEN** the user calls `POST /bookmarks/reorder` with `folderId=null` +- **THEN** the server reorders root-group bookmarks and returns `{ ok: true }` diff --git a/openspec/changes/archive/2026-01-22-add-dnd-sorting/tasks.md b/openspec/changes/archive/2026-01-22-add-dnd-sorting/tasks.md new file mode 100644 index 0000000..52afcac --- /dev/null +++ b/openspec/changes/archive/2026-01-22-add-dnd-sorting/tasks.md @@ -0,0 +1,12 @@ +## 1. Implementation +- [ ] Add DB support for bookmark ordering (migration + init schema) +- [ ] Expose bookmark ordering in DTOs and OpenAPI schema +- [ ] Add API endpoint to reorder bookmarks within the same folder +- [ ] Ensure list endpoints return folders/bookmarks in stable order (parent+sortOrder, etc.) +- [ ] Implement touch-friendly drag sorting in Web UI for folders and bookmarks +- [ ] Treat root group (folderId null) as a first-class group for display and bookmark reorder +- [ ] Add basic verification steps (build + manual smoke checklist) + +## 2. Spec Updates +- [ ] Update OpenAPI contract for bookmark sortOrder and reorder endpoint +- [ ] Update OpenSpec API capability delta requirements diff --git a/openspec/changes/archive/2026-01-23-add-password-manager/design.md b/openspec/changes/archive/2026-01-23-add-password-manager/design.md new file mode 100644 index 0000000..6832ce2 --- /dev/null +++ b/openspec/changes/archive/2026-01-23-add-password-manager/design.md @@ -0,0 +1,33 @@ +## Context +We need a password manager across extension and web, with admin visibility and per-user isolation. Non-admin users must re-verify their login password to view plaintext. + +## Goals / Non-Goals +- Goals: + - Save credentials with explicit confirmation. + - Autofill selector for saved accounts per site. + - Admin can view all users’ credentials. + - Non-admin must re-verify password before plaintext reveal. + - Encrypt credentials at rest. +- Non-Goals: + - Browser-level credential integration outside the extension. + - Password sharing between users. + +## Decisions +- Site key = URL origin (scheme + host + port). +- Storage model: one row per (user_id, site_origin, username), allowing multiple accounts per site. +- Encrypt password using AES-256-GCM with server-side master key (env), store iv + tag + ciphertext. +- Use a session-only toggle to reveal plaintext in the web UI (sessionStorage; reset on browser close). +- Extension content script detects login forms; popup asks to save; only on confirm does it call API. + +## Risks / Trade-offs +- Storing decryptable passwords increases risk. Mitigation: encryption at rest, strict auth, session-only plaintext reveal, audit logging (future). + +## Migration Plan +- Add DB migration for credential tables and indexes. +- Add API endpoints and update OpenAPI. +- Implement extension flows and web UI. +- Add tests for CRUD, reauth, admin access. + +## Open Questions +- Confirm site matching scope (origin vs eTLD+1). +- Save prompt triggers on form submit (username + password present). diff --git a/openspec/changes/archive/2026-01-23-add-password-manager/proposal.md b/openspec/changes/archive/2026-01-23-add-password-manager/proposal.md new file mode 100644 index 0000000..016d0da --- /dev/null +++ b/openspec/changes/archive/2026-01-23-add-password-manager/proposal.md @@ -0,0 +1,20 @@ +# Change: Add password manager (Web + Extension) + +## Why +Provide built-in credential saving and autofill for users, with centralized management and admin oversight. + +## What Changes +- Add credential save + autofill flows in the extension (explicit user confirmation required). +- Add a Web password management page (desktop only) with view/edit/delete. +- Add APIs for credential CRUD and admin access; plaintext view available during the current browser session. +- Add database schema for credential storage (per-user, per-site, multiple accounts). +- Add tests for API and DB flows. + +## Impact +- Affected specs: api, password-manager +- Affected code: apps/server, apps/web, apps/extension, migrations, spec/openapi.yaml + +## Assumptions (confirm) +- “同一网站” is defined as the URL origin (scheme + host + port). +- The extension prompts on form submit after username + password are provided. +- Credentials are stored encrypted at rest and decrypted server-side for plaintext display. diff --git a/openspec/changes/archive/2026-01-23-add-password-manager/specs/api/spec.md b/openspec/changes/archive/2026-01-23-add-password-manager/specs/api/spec.md new file mode 100644 index 0000000..f54b3aa --- /dev/null +++ b/openspec/changes/archive/2026-01-23-add-password-manager/specs/api/spec.md @@ -0,0 +1,41 @@ +## ADDED Requirements + +### Requirement: Credential storage API +The system SHALL provide authenticated CRUD APIs for credentials scoped to the current user. + +#### Scenario: Create credential +- **WHEN** an authenticated user calls `POST /credentials` with `siteOrigin`, `username`, and `password` +- **THEN** the server stores the credential and returns the created record + +#### Scenario: List credentials +- **WHEN** an authenticated user calls `GET /credentials?siteOrigin=...` +- **THEN** the server returns the matching credentials for that user + +#### Scenario: Update credential +- **WHEN** an authenticated user calls `PATCH /credentials/{id}` +- **THEN** the server updates the credential and returns the updated record + +#### Scenario: Delete credential +- **WHEN** an authenticated user calls `DELETE /credentials/{id}` +- **THEN** the server deletes the credential + +### Requirement: Credential plaintext reveal +The system SHALL allow authenticated users to request plaintext passwords for their own credentials. + +#### Scenario: User requests plaintext +- **GIVEN** an authenticated user +- **WHEN** the user requests plaintext credential data +- **THEN** the server returns plaintext passwords for that user + +#### Scenario: Admin requests plaintext +- **GIVEN** an authenticated admin user +- **WHEN** the admin requests plaintext credential data +- **THEN** the server returns plaintext passwords for the target user + +### Requirement: Admin credential access +The system SHALL allow an admin to list and manage any user’s credentials. + +#### Scenario: Admin lists user credentials +- **GIVEN** an authenticated admin user +- **WHEN** the admin calls `GET /admin/users/{id}/credentials` +- **THEN** the server returns that user’s credentials diff --git a/openspec/changes/archive/2026-01-23-add-password-manager/specs/password-manager/spec.md b/openspec/changes/archive/2026-01-23-add-password-manager/specs/password-manager/spec.md new file mode 100644 index 0000000..9fdc61a --- /dev/null +++ b/openspec/changes/archive/2026-01-23-add-password-manager/specs/password-manager/spec.md @@ -0,0 +1,44 @@ +## ADDED Requirements + +### Requirement: Extension save prompt +The extension SHALL prompt the user to save credentials when a login form is detected and filled. + +#### Scenario: Save confirmed +- **WHEN** the user confirms “保存/记住密码” in the prompt +- **THEN** the extension sends the credential to the server for storage + +#### Scenario: Save canceled +- **WHEN** the user cancels or dismisses the prompt +- **THEN** the extension MUST NOT store the credential + +### Requirement: Extension autofill selector +The extension SHALL show a credential selector near login fields for sites with saved accounts. + +#### Scenario: Select credential +- **GIVEN** a site with multiple saved credentials +- **WHEN** the user opens the selector and chooses one +- **THEN** the username and password fields are filled with that credential + +### Requirement: Web password manager (desktop only) +The web app SHALL provide a desktop-only password manager view. + +#### Scenario: Desktop view +- **WHEN** the user visits the password manager page on desktop +- **THEN** the page is visible and provides list/edit/delete + +#### Scenario: Mobile view hidden +- **WHEN** the user visits the password manager page on mobile +- **THEN** the page is hidden or redirects to a notice page + +### Requirement: Plaintext visibility control +The system SHALL allow a user to reveal plaintext passwords for their own credentials during the current browser session. + +#### Scenario: User reveals plaintext +- **GIVEN** a non-admin user +- **WHEN** the user chooses to reveal plaintext +- **THEN** the UI shows plaintext passwords during the current browser session + +#### Scenario: Admin view +- **GIVEN** an admin user +- **WHEN** the admin views credentials +- **THEN** plaintext is visible diff --git a/openspec/changes/archive/2026-01-23-add-password-manager/tasks.md b/openspec/changes/archive/2026-01-23-add-password-manager/tasks.md new file mode 100644 index 0000000..8a3aeed --- /dev/null +++ b/openspec/changes/archive/2026-01-23-add-password-manager/tasks.md @@ -0,0 +1,31 @@ +## 1. Spec +- [x] 1.1 Update OpenSpec deltas for api/password-manager +- [x] 1.2 Update OpenAPI 3.1 contract (spec/openapi.yaml) + +## 2. Database +- [x] 2.1 Add migrations for credential storage tables + indexes + +## 3. Server +- [x] 3.1 Implement credential CRUD APIs +- [x] 3.2 Enable plaintext credential access +- [x] 3.3 Implement admin credential access APIs + +## 4. Extension +- [x] 4.1 Add content script for detecting login forms +- [x] 4.2 Add save-credential prompt + confirm flow +- [x] 4.3 Add autofill selector UI on login fields + +## 5. Web +- [x] 5.1 Add desktop-only password manager page +- [x] 5.2 Add session-based plaintext toggle +- [x] 5.3 Add admin view for all users + +## 6. Tests +- [x] 6.1 API tests for CRUD + plaintext + admin access +- [x] 6.2 DB migration verification + +## 7. Verification +- [x] 7.1 Specs updated in openspec/specs +- [x] 7.2 OpenAPI updated and validated +- [x] 7.3 DB migration applied +- [x] 7.4 Server tests executed diff --git a/openspec/specs/api/spec.md b/openspec/specs/api/spec.md index c2f733e..4d0ff16 100644 --- a/openspec/specs/api/spec.md +++ b/openspec/specs/api/spec.md @@ -63,3 +63,43 @@ The system SHALL treat exactly one configured email as an administrator and allo - **GIVEN** an authenticated admin user - **WHEN** the admin calls `GET /admin/users/{id}/bookmarks` - **THEN** the server returns `200` and that user's bookmarks + +### Requirement: Credential storage API +The system SHALL provide authenticated CRUD APIs for credentials scoped to the current user. + +#### Scenario: Create credential +- **WHEN** an authenticated user calls `POST /credentials` with `siteOrigin`, `username`, and `password` +- **THEN** the server stores the credential and returns the created record + +#### Scenario: List credentials +- **WHEN** an authenticated user calls `GET /credentials?siteOrigin=...` +- **THEN** the server returns the matching credentials for that user + +#### Scenario: Update credential +- **WHEN** an authenticated user calls `PATCH /credentials/{id}` +- **THEN** the server updates the credential and returns the updated record + +#### Scenario: Delete credential +- **WHEN** an authenticated user calls `DELETE /credentials/{id}` +- **THEN** the server deletes the credential + +### Requirement: Credential plaintext access +The system SHALL allow authenticated users to request plaintext passwords for their own credentials. + +#### Scenario: User requests plaintext +- **GIVEN** an authenticated user +- **WHEN** the user calls `GET /credentials?includePassword=true` +- **THEN** the server returns plaintext passwords for that user + +#### Scenario: Admin requests plaintext for a user +- **GIVEN** an authenticated admin user +- **WHEN** the admin calls `GET /admin/users/{id}/credentials?includePassword=true` +- **THEN** the server returns plaintext passwords for that user + +### Requirement: Admin credential management +The system SHALL allow an admin to list and manage any user’s credentials. + +#### Scenario: Admin lists user credentials +- **GIVEN** an authenticated admin user +- **WHEN** the admin calls `GET /admin/users/{id}/credentials` +- **THEN** the server returns that user’s credentials diff --git a/openspec/specs/password-manager/spec.md b/openspec/specs/password-manager/spec.md new file mode 100644 index 0000000..24a69b3 --- /dev/null +++ b/openspec/specs/password-manager/spec.md @@ -0,0 +1,64 @@ +# Capability: Password Manager + +## Purpose +Define password-manager behavior across the extension and web UI. + +## Requirements + +### Requirement: Extension save prompt +The extension SHALL prompt the user to save credentials when a login form is detected and submitted. + +#### Scenario: Save confirmed +- **WHEN** the user confirms “保存/记住密码” in the prompt +- **THEN** the extension sends the credential to the server for storage + +#### Scenario: Save canceled +- **WHEN** the user cancels or dismisses the prompt +- **THEN** the extension MUST NOT store the credential + +#### Scenario: Save prompt suppressed for matching credential +- **GIVEN** a previously saved credential for the same `siteOrigin` and `username` +- **WHEN** the user submits the same password +- **THEN** the save prompt is not shown + +#### Scenario: Save prompt update for password change +- **GIVEN** a previously saved credential for the same `siteOrigin` and `username` +- **WHEN** the user submits a different password +- **THEN** the prompt message indicates a password update + +#### Scenario: Save prompt for new username +- **GIVEN** a site with saved credentials +- **WHEN** the user submits a username that does not exist +- **THEN** the prompt message indicates a new account + +### Requirement: Extension autofill selector +The extension SHALL show a credential selector near login fields for sites with saved accounts. + +#### Scenario: Select credential +- **GIVEN** a site with multiple saved credentials +- **WHEN** the user opens the selector and chooses one +- **THEN** the username and password fields are filled with that credential + +### Requirement: Web password manager (desktop only) +The web app SHALL provide a desktop-only password manager view. + +#### Scenario: Desktop view +- **WHEN** the user visits the password manager page on desktop +- **THEN** the page is visible and provides list/edit/delete + +#### Scenario: Mobile view hidden +- **WHEN** the user visits the password manager page on mobile +- **THEN** the page is hidden or redirects to a notice page + +### Requirement: Plaintext visibility control +The system SHALL allow a user to reveal plaintext passwords for their own credentials during the current browser session. + +#### Scenario: User reveals plaintext +- **GIVEN** a non-admin user +- **WHEN** the user chooses to reveal plaintext +- **THEN** the UI shows plaintext passwords during the current browser session + +#### Scenario: Admin view +- **GIVEN** an admin user +- **WHEN** the admin views credentials +- **THEN** plaintext is visible diff --git a/package-lock.json b/package-lock.json index ac3c2f9..8227caa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ } }, "apps/extension": { - "version": "0.0.0", + "version": "1.0.3", "dependencies": { "@browser-bookmark/shared": "file:../../packages/shared", "vue": "^3.5.24", @@ -31,7 +31,7 @@ }, "apps/server": { "name": "@browser-bookmark/server", - "version": "0.1.0", + "version": "1.0.4", "dependencies": { "@browser-bookmark/shared": "file:../../packages/shared", "@fastify/cors": "^11.2.0", diff --git a/spec/openapi.yaml b/spec/openapi.yaml index e174803..92d40e5 100644 --- a/spec/openapi.yaml +++ b/spec/openapi.yaml @@ -13,6 +13,7 @@ tags: - name: ImportExport - name: Sync - name: Admin + - name: Credentials components: securitySchemes: bearerAuth: @@ -113,6 +114,53 @@ components: - type: 'null' required: [id, userId, folderId, sortOrder, title, url, urlNormalized, urlHash, visibility, source, updatedAt, deletedAt] + Credential: + type: object + properties: + id: + type: string + format: uuid + userId: + type: string + format: uuid + siteOrigin: + type: string + username: + type: string + password: + anyOf: + - type: string + - type: 'null' + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + required: [id, userId, siteOrigin, username, createdAt, updatedAt] + + CredentialCreate: + type: object + properties: + siteOrigin: + type: string + username: + type: string + password: + type: string + required: [siteOrigin, username, password] + + CredentialPatch: + type: object + properties: + siteOrigin: + type: string + username: + type: string + password: + type: string + + FolderPatch: type: object properties: @@ -275,6 +323,105 @@ paths: schema: $ref: '#/components/schemas/User' + /credentials: + get: + tags: [Credentials] + summary: List my credentials + operationId: listCredentials + security: + - bearerAuth: [] + parameters: + - in: query + name: siteOrigin + required: false + schema: + type: string + - in: query + name: includePassword + required: false + schema: + type: boolean + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Credential' + post: + tags: [Credentials] + summary: Create credential + operationId: createCredential + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CredentialCreate' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Credential' + + /credentials/{id}: + patch: + tags: [Credentials] + summary: Update credential + operationId: updateCredential + security: + - bearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CredentialPatch' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Credential' + delete: + tags: [Credentials] + summary: Delete credential + operationId: deleteCredential + security: + - bearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: string + format: uuid + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + required: [ok] + /folders: get: tags: [Folders] @@ -684,6 +831,99 @@ paths: summary: List a user's folders (admin only) operationId: adminListUserFolders security: + + /admin/users/{id}/credentials: + get: + tags: [Admin] + summary: List a user's credentials + operationId: adminListUserCredentials + security: + - bearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: string + format: uuid + - in: query + name: includePassword + required: false + schema: + type: boolean + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Credential' + + /admin/users/{userId}/credentials/{credentialId}: + patch: + tags: [Admin] + summary: Update a user's credential + operationId: adminUpdateUserCredential + security: + - bearerAuth: [] + parameters: + - in: path + name: userId + required: true + schema: + type: string + format: uuid + - in: path + name: credentialId + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CredentialPatch' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Credential' + delete: + tags: [Admin] + summary: Delete a user's credential + operationId: adminDeleteUserCredential + security: + - bearerAuth: [] + parameters: + - in: path + name: userId + required: true + schema: + type: string + format: uuid + - in: path + name: credentialId + required: true + schema: + type: string + format: uuid + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + required: [ok] - bearerAuth: [] parameters: - in: path