feat: 添加密码管理功能,包括 API、数据库支持和前端界面
This commit is contained in:
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "extension",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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": ["<all_urls>"]
|
||||
"host_permissions": ["<all_urls>"],
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["content.js"],
|
||||
"run_at": "document_idle",
|
||||
"type": "module"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
21
apps/extension/src/background.js
Normal file
21
apps/extension/src/background.js
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
245
apps/extension/src/content/main.js
Normal file
245
apps/extension/src/content/main.js
Normal file
@@ -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 = `<div class="bb-cred-user">${c.username}</div><div class="bb-cred-site">${c.siteOrigin}</div>`;
|
||||
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();
|
||||
@@ -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) || ""
|
||||
};
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ async function submit() {
|
||||
|
||||
<div class="form">
|
||||
<input v-model="email" class="input" placeholder="邮箱" autocomplete="email" />
|
||||
<input v-model="password" class="input" placeholder="密码(至少 8 位)" type="password" autocomplete="current-password" />
|
||||
<input v-model="password" class="input" placeholder="密码(至少 8 位)" type="password" autocomplete="current-password" @keydown.enter.prevent="submit" />
|
||||
<button class="btn" :disabled="loading" @click="submit">提交</button>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
@@ -79,7 +79,7 @@ onMounted(load);
|
||||
|
||||
<div class="form">
|
||||
<input v-model="title" class="input" placeholder="标题" />
|
||||
<input v-model="url" class="input" placeholder="链接" />
|
||||
<input v-model="url" class="input" placeholder="链接" @keydown.enter.prevent="add" />
|
||||
<button class="btn" @click="add">添加</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { apiFetch } from "../lib/api";
|
||||
import { getToken } from "../lib/extStorage";
|
||||
import { clearPendingCredential, getPendingCredential, getToken } from "../lib/extStorage";
|
||||
|
||||
const view = ref("list"); // add | list
|
||||
|
||||
@@ -10,6 +10,10 @@ const loggedIn = computed(() => Boolean(token.value));
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const savePromptOpen = ref(false);
|
||||
const pendingCredential = ref(null);
|
||||
const saveBusy = ref(false);
|
||||
const saveStatus = ref("");
|
||||
|
||||
function openOptions() {
|
||||
if (typeof chrome !== "undefined" && chrome.runtime?.openOptionsPage) {
|
||||
@@ -30,6 +34,46 @@ async function refreshAuth() {
|
||||
token.value = await getToken();
|
||||
}
|
||||
|
||||
async function loadPendingCredential() {
|
||||
const pending = await getPendingCredential();
|
||||
pendingCredential.value = pending;
|
||||
if (pending) savePromptOpen.value = true;
|
||||
}
|
||||
|
||||
async function cancelSavePrompt() {
|
||||
savePromptOpen.value = false;
|
||||
pendingCredential.value = null;
|
||||
await clearPendingCredential();
|
||||
}
|
||||
|
||||
async function confirmSavePrompt() {
|
||||
error.value = "";
|
||||
saveStatus.value = "";
|
||||
if (!pendingCredential.value) return;
|
||||
if (!loggedIn.value) {
|
||||
error.value = "请先在『更多操作』里登录";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
saveBusy.value = true;
|
||||
await apiFetch("/credentials", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
siteOrigin: pendingCredential.value.siteOrigin,
|
||||
username: pendingCredential.value.username,
|
||||
password: pendingCredential.value.password
|
||||
})
|
||||
});
|
||||
saveStatus.value = "已保存账号密码";
|
||||
await cancelSavePrompt();
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
} finally {
|
||||
saveBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// folders + bookmarks
|
||||
const q = ref("");
|
||||
const folders = ref([]);
|
||||
@@ -188,6 +232,7 @@ onMounted(async () => {
|
||||
await refreshAuth();
|
||||
await prepareAddCurrent();
|
||||
if (loggedIn.value) await loadAll();
|
||||
await loadPendingCredential();
|
||||
});
|
||||
|
||||
watch(
|
||||
@@ -219,6 +264,7 @@ watch(
|
||||
|
||||
<p v-if="error" class="alert">{{ error }}</p>
|
||||
<p v-if="addStatus" class="ok">{{ addStatus }}</p>
|
||||
<p v-if="saveStatus" class="ok">{{ saveStatus }}</p>
|
||||
|
||||
<section v-if="view === 'add'" class="card">
|
||||
<div class="cardTitle">一键添加书签</div>
|
||||
@@ -228,7 +274,7 @@ watch(
|
||||
<input v-model="addTitle" class="input" placeholder="标题" />
|
||||
|
||||
<label class="label">链接</label>
|
||||
<input v-model="addUrl" class="input" placeholder="https://..." />
|
||||
<input v-model="addUrl" class="input" placeholder="https://..." @keydown.enter.prevent="submitAddCurrent" />
|
||||
|
||||
<label class="label">文件夹(不选则未分组)</label>
|
||||
<select v-model="addFolderId" class="input" :disabled="!loggedIn">
|
||||
@@ -276,7 +322,7 @@ watch(
|
||||
class="input"
|
||||
placeholder="文件夹名称"
|
||||
:disabled="!loggedIn || folderBusy"
|
||||
@keyup.enter="createFolder"
|
||||
@keydown.enter.prevent="createFolder"
|
||||
/>
|
||||
<div class="dialogActions">
|
||||
<button class="btn btn--secondary" type="button" @click="folderModalOpen = false" :disabled="folderBusy">取消</button>
|
||||
@@ -287,6 +333,29 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="savePromptOpen" class="modal" @click.self="cancelSavePrompt">
|
||||
<div class="dialog" role="dialog" aria-modal="true" aria-label="记住密码">
|
||||
<div class="dialogTitle">记住密码</div>
|
||||
<div class="muted" style="margin-top: 4px;">
|
||||
{{ pendingCredential?.reason === 'update'
|
||||
? '检测到密码与保存的不一致,是否更新密码?'
|
||||
: '检测到新账号登录提交,是否保存新账号密码?' }}
|
||||
</div>
|
||||
<div class="subCard" style="margin-top: 10px;">
|
||||
<div class="subTitle">网站</div>
|
||||
<div class="muted">{{ pendingCredential?.siteOrigin || '-' }}</div>
|
||||
<div class="subTitle" style="margin-top: 8px;">账号</div>
|
||||
<div>{{ pendingCredential?.username || '-' }}</div>
|
||||
<div class="subTitle" style="margin-top: 8px;">密码</div>
|
||||
<div class="muted">{{ pendingCredential?.password ? '••••••••' : '-' }}</div>
|
||||
</div>
|
||||
<div class="dialogActions">
|
||||
<button class="btn btn--secondary" type="button" @click="cancelSavePrompt">取消</button>
|
||||
<button class="btn" type="button" :disabled="saveBusy" @click="confirmSavePrompt">确认记住</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="muted" style="margin-top: 10px;">加载中…</div>
|
||||
|
||||
<div v-if="loggedIn" class="tree">
|
||||
@@ -335,6 +404,7 @@ watch(
|
||||
<style scoped>
|
||||
.wrap{
|
||||
width: 380px;
|
||||
min-height: 120vh;
|
||||
padding: 12px;
|
||||
font-family: ui-sans-serif, system-ui;
|
||||
color: var(--bb-text);
|
||||
@@ -488,7 +558,11 @@ button:disabled{ opacity: 0.6; cursor: not-allowed; }
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
}
|
||||
.bmTitle{ font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.bmUrl{ font-size: 12px; color: rgba(19, 78, 74, 0.72); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 4px; }
|
||||
.bmTitle{ font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; display: block; }
|
||||
.bmUrl{ font-size: 12px; color: rgba(19, 78, 74, 0.72); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 4px; min-width: 0; display: block; }
|
||||
</style>
|
||||
|
||||
@@ -9,7 +9,14 @@ export default defineConfig({
|
||||
rollupOptions: {
|
||||
input: {
|
||||
popup: path.resolve(__dirname, "popup.html"),
|
||||
options: path.resolve(__dirname, "options.html")
|
||||
options: path.resolve(__dirname, "options.html"),
|
||||
background: path.resolve(__dirname, "src/background.js"),
|
||||
content: path.resolve(__dirname, "src/content/main.js")
|
||||
},
|
||||
output: {
|
||||
entryFileNames: "[name].js",
|
||||
chunkFileNames: "assets/[name].js",
|
||||
assetFileNames: "assets/[name][extname]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
17
apps/server/migrations/0004_credentials.sql
Normal file
17
apps/server/migrations/0004_credentials.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
create table if not exists credentials (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid not null references users(id) on delete cascade,
|
||||
site_origin text not null,
|
||||
username text not null,
|
||||
password_enc text not null,
|
||||
password_iv text not null,
|
||||
password_tag text not null,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create unique index if not exists idx_credentials_user_origin_username
|
||||
on credentials (user_id, site_origin, username);
|
||||
|
||||
create index if not exists idx_credentials_user_origin
|
||||
on credentials (user_id, site_origin);
|
||||
@@ -2,11 +2,11 @@
|
||||
"name": "@browser-bookmark/server",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.4",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"dev": "node --watch src/index.js",
|
||||
"build": "node -c src/index.js && node -c src/routes/auth.routes.js && node -c src/routes/bookmarks.routes.js && node -c src/routes/folders.routes.js && node -c src/routes/importExport.routes.js && node -c src/routes/sync.routes.js",
|
||||
"build": "node -c src/index.js && node -c src/routes/auth.routes.js && node -c src/routes/bookmarks.routes.js && node -c src/routes/folders.routes.js && node -c src/routes/importExport.routes.js && node -c src/routes/sync.routes.js && node -c src/routes/credentials.routes.js",
|
||||
"test": "node --test",
|
||||
"lint": "eslint .",
|
||||
"db:migrate": "node src/migrate.js",
|
||||
|
||||
115
apps/server/src/__tests__/credentials.api.test.js
Normal file
115
apps/server/src/__tests__/credentials.api.test.js
Normal file
@@ -0,0 +1,115 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildApp } from "../app.js";
|
||||
|
||||
const key = process.env.CREDENTIAL_MASTER_KEY;
|
||||
const jwtSecret = process.env.AUTH_JWT_SECRET;
|
||||
const keyValid = (() => {
|
||||
if (!key) return false;
|
||||
try {
|
||||
return Buffer.from(key, "base64").length === 32;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
if (!keyValid || !jwtSecret) {
|
||||
test("credentials api: skipped (missing/invalid env)", () => {
|
||||
assert.ok(true);
|
||||
});
|
||||
} else {
|
||||
test("credentials api: create and list with plaintext", async () => {
|
||||
const app = await buildApp();
|
||||
|
||||
const email = `api-${Date.now()}@example.com`;
|
||||
const password = "password123";
|
||||
|
||||
const registerRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/register",
|
||||
payload: { email, password }
|
||||
});
|
||||
assert.equal(registerRes.statusCode, 200);
|
||||
const registerBody = registerRes.json();
|
||||
const token = registerBody.token;
|
||||
|
||||
const createRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/credentials",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
siteOrigin: "https://example.com",
|
||||
username: "user1",
|
||||
password: "secret"
|
||||
}
|
||||
});
|
||||
assert.equal(createRes.statusCode, 200);
|
||||
|
||||
const listRes = await app.inject({
|
||||
method: "GET",
|
||||
url: "/credentials?siteOrigin=https://example.com",
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(listRes.statusCode, 200);
|
||||
const listBody = listRes.json();
|
||||
assert.equal(listBody.length, 1);
|
||||
assert.equal(listBody[0].password, null);
|
||||
|
||||
const listPwdRes = await app.inject({
|
||||
method: "GET",
|
||||
url: "/credentials?siteOrigin=https://example.com&includePassword=true",
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(listPwdRes.statusCode, 200);
|
||||
const listPwdBody = listPwdRes.json();
|
||||
assert.equal(listPwdBody[0].password, "secret");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test("credentials api: admin access", async () => {
|
||||
const adminEmail = `admin-${Date.now()}@example.com`;
|
||||
const userEmail = `user-${Date.now()}@example.com`;
|
||||
process.env.ADMIN_EMAIL = adminEmail;
|
||||
|
||||
const app = await buildApp();
|
||||
|
||||
const adminRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/register",
|
||||
payload: { email: adminEmail, password: "password123" }
|
||||
});
|
||||
const adminToken = adminRes.json().token;
|
||||
|
||||
const userRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/register",
|
||||
payload: { email: userEmail, password: "password123" }
|
||||
});
|
||||
const userId = userRes.json().user.id;
|
||||
const userToken = userRes.json().token;
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/credentials",
|
||||
headers: { Authorization: `Bearer ${userToken}` },
|
||||
payload: {
|
||||
siteOrigin: "https://example.com",
|
||||
username: "user2",
|
||||
password: "secret2"
|
||||
}
|
||||
});
|
||||
|
||||
const adminList = await app.inject({
|
||||
method: "GET",
|
||||
url: `/admin/users/${userId}/credentials?includePassword=true`,
|
||||
headers: { Authorization: `Bearer ${adminToken}` }
|
||||
});
|
||||
|
||||
assert.equal(adminList.statusCode, 200);
|
||||
const adminBody = adminList.json();
|
||||
assert.equal(adminBody[0].password, "secret2");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
}
|
||||
66
apps/server/src/__tests__/credentials.test.js
Normal file
66
apps/server/src/__tests__/credentials.test.js
Normal file
@@ -0,0 +1,66 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createPool } from "../db.js";
|
||||
import { encryptPassword, decryptPassword } from "../lib/crypto.js";
|
||||
|
||||
const key = process.env.CREDENTIAL_MASTER_KEY;
|
||||
const keyValid = (() => {
|
||||
if (!key) return false;
|
||||
try {
|
||||
return Buffer.from(key, "base64").length === 32;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
if (!keyValid) {
|
||||
test("credentials: skipped (missing CREDENTIAL_MASTER_KEY)", () => {
|
||||
assert.ok(true);
|
||||
});
|
||||
} else {
|
||||
test("credentials: encrypt/decrypt", () => {
|
||||
const enc = encryptPassword("secret", key);
|
||||
const dec = decryptPassword({ cipherText: enc.cipherText, iv: enc.iv, tag: enc.tag }, key);
|
||||
assert.equal(dec, "secret");
|
||||
});
|
||||
|
||||
test("credentials: insert/select", async (t) => {
|
||||
const pool = createPool();
|
||||
const email = `test-${Date.now()}@example.com`;
|
||||
let userId = "";
|
||||
|
||||
t.after(async () => {
|
||||
if (userId) {
|
||||
await pool.query("delete from users where id=$1", [userId]);
|
||||
}
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
const userRes = await pool.query(
|
||||
"insert into users (email, password_hash) values ($1, $2) returning id",
|
||||
[email, "hash"]
|
||||
);
|
||||
userId = userRes.rows[0].id;
|
||||
|
||||
const enc = encryptPassword("pass123", key);
|
||||
const ins = await pool.query(
|
||||
"insert into credentials (user_id, site_origin, username, password_enc, password_iv, password_tag) values ($1,$2,$3,$4,$5,$6) returning *",
|
||||
[userId, "https://example.com", "user1", enc.cipherText, enc.iv, enc.tag]
|
||||
);
|
||||
|
||||
assert.equal(ins.rows[0].site_origin, "https://example.com");
|
||||
|
||||
const res = await pool.query(
|
||||
"select * from credentials where user_id=$1 and site_origin=$2",
|
||||
[userId, "https://example.com"]
|
||||
);
|
||||
|
||||
assert.equal(res.rowCount, 1);
|
||||
const dec = decryptPassword({
|
||||
cipherText: res.rows[0].password_enc,
|
||||
iv: res.rows[0].password_iv,
|
||||
tag: res.rows[0].password_tag
|
||||
}, key);
|
||||
assert.equal(dec, "pass123");
|
||||
});
|
||||
}
|
||||
90
apps/server/src/app.js
Normal file
90
apps/server/src/app.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import Fastify from "fastify";
|
||||
import cors from "@fastify/cors";
|
||||
import multipart from "@fastify/multipart";
|
||||
import jwt from "@fastify/jwt";
|
||||
import { getConfig } from "./config.js";
|
||||
import { createPool } from "./db.js";
|
||||
import { authRoutes } from "./routes/auth.routes.js";
|
||||
import { adminRoutes } from "./routes/admin.routes.js";
|
||||
import { foldersRoutes } from "./routes/folders.routes.js";
|
||||
import { bookmarksRoutes } from "./routes/bookmarks.routes.js";
|
||||
import { importExportRoutes } from "./routes/importExport.routes.js";
|
||||
import { syncRoutes } from "./routes/sync.routes.js";
|
||||
import { credentialsRoutes } from "./routes/credentials.routes.js";
|
||||
|
||||
export async function buildApp() {
|
||||
const app = Fastify({ logger: true });
|
||||
|
||||
// Plugins
|
||||
const config = getConfig();
|
||||
await app.register(cors, {
|
||||
origin: config.corsOrigins,
|
||||
credentials: true,
|
||||
methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: ["Content-Type", "Authorization", "Accept"]
|
||||
});
|
||||
await app.register(multipart);
|
||||
|
||||
const jwtSecret = process.env.AUTH_JWT_SECRET;
|
||||
if (!jwtSecret) {
|
||||
throw new Error("AUTH_JWT_SECRET is required");
|
||||
}
|
||||
await app.register(jwt, { secret: jwtSecret });
|
||||
|
||||
const pool = createPool();
|
||||
app.decorate("pg", pool);
|
||||
|
||||
// Detect optional DB features (for backwards compatibility when migrations haven't run yet).
|
||||
async function hasColumn(tableName, columnName) {
|
||||
try {
|
||||
const r = await app.pg.query(
|
||||
"select 1 from information_schema.columns where table_schema=current_schema() and table_name=$1 and column_name=$2 limit 1",
|
||||
[tableName, columnName]
|
||||
);
|
||||
return r.rowCount > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const folderSortOrderSupported = await hasColumn("bookmark_folders", "sort_order");
|
||||
const bookmarkSortOrderSupported = await hasColumn("bookmarks", "sort_order");
|
||||
|
||||
app.decorate("features", {
|
||||
folderSortOrder: folderSortOrderSupported,
|
||||
bookmarkSortOrder: bookmarkSortOrderSupported
|
||||
});
|
||||
|
||||
app.decorate("authenticate", async (req, reply) => {
|
||||
try {
|
||||
await req.jwtVerify();
|
||||
} catch (err) {
|
||||
reply.code(401);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
app.setErrorHandler((err, _req, reply) => {
|
||||
const statusCode = err.statusCode || 500;
|
||||
reply.code(statusCode).send({ message: err.message || "server error" });
|
||||
});
|
||||
|
||||
app.get("/health", async () => ({ ok: true }));
|
||||
|
||||
// Routes
|
||||
app.decorate("config", config);
|
||||
|
||||
await authRoutes(app);
|
||||
await adminRoutes(app);
|
||||
await foldersRoutes(app);
|
||||
await bookmarksRoutes(app);
|
||||
await importExportRoutes(app);
|
||||
await syncRoutes(app);
|
||||
await credentialsRoutes(app);
|
||||
|
||||
app.addHook("onClose", async (instance) => {
|
||||
await instance.pg.end();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -25,7 +25,9 @@ export function getConfig() {
|
||||
const adminEmail = String(process.env.ADMIN_EMAIL || "").trim().toLowerCase();
|
||||
const corsOriginsRaw = String(process.env.CORS_ORIGINS || "").trim();
|
||||
const corsOrigins = corsOriginsRaw
|
||||
? corsOriginsRaw.split(",").map((item) => item.trim()).filter(Boolean)
|
||||
? (corsOriginsRaw === "*"
|
||||
? true
|
||||
: corsOriginsRaw.split(",").map((item) => item.trim()).filter(Boolean))
|
||||
: true;
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,87 +1,5 @@
|
||||
import Fastify from "fastify";
|
||||
import cors from "@fastify/cors";
|
||||
import multipart from "@fastify/multipart";
|
||||
import jwt from "@fastify/jwt";
|
||||
import { getConfig } from "./config.js";
|
||||
import { createPool } from "./db.js";
|
||||
import { authRoutes } from "./routes/auth.routes.js";
|
||||
import { adminRoutes } from "./routes/admin.routes.js";
|
||||
import { foldersRoutes } from "./routes/folders.routes.js";
|
||||
import { bookmarksRoutes } from "./routes/bookmarks.routes.js";
|
||||
import { importExportRoutes } from "./routes/importExport.routes.js";
|
||||
import { syncRoutes } from "./routes/sync.routes.js";
|
||||
import { buildApp } from "./app.js";
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
|
||||
// Plugins
|
||||
const config = getConfig();
|
||||
await app.register(cors, {
|
||||
origin: config.corsOrigins,
|
||||
credentials: true,
|
||||
methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: ["Content-Type", "Authorization", "Accept"]
|
||||
});
|
||||
await app.register(multipart);
|
||||
|
||||
const jwtSecret = process.env.AUTH_JWT_SECRET;
|
||||
if (!jwtSecret) {
|
||||
throw new Error("AUTH_JWT_SECRET is required");
|
||||
}
|
||||
await app.register(jwt, { secret: jwtSecret });
|
||||
|
||||
const pool = createPool();
|
||||
app.decorate("pg", pool);
|
||||
|
||||
// Detect optional DB features (for backwards compatibility when migrations haven't run yet).
|
||||
async function hasColumn(tableName, columnName) {
|
||||
try {
|
||||
const r = await app.pg.query(
|
||||
"select 1 from information_schema.columns where table_schema=current_schema() and table_name=$1 and column_name=$2 limit 1",
|
||||
[tableName, columnName]
|
||||
);
|
||||
return r.rowCount > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const folderSortOrderSupported = await hasColumn("bookmark_folders", "sort_order");
|
||||
const bookmarkSortOrderSupported = await hasColumn("bookmarks", "sort_order");
|
||||
|
||||
app.decorate("features", {
|
||||
folderSortOrder: folderSortOrderSupported,
|
||||
bookmarkSortOrder: bookmarkSortOrderSupported
|
||||
});
|
||||
|
||||
app.decorate("authenticate", async (req, reply) => {
|
||||
try {
|
||||
await req.jwtVerify();
|
||||
} catch (err) {
|
||||
reply.code(401);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
app.setErrorHandler((err, _req, reply) => {
|
||||
const statusCode = err.statusCode || 500;
|
||||
reply.code(statusCode).send({ message: err.message || "server error" });
|
||||
});
|
||||
|
||||
app.get("/health", async () => ({ ok: true }));
|
||||
|
||||
// Routes
|
||||
app.decorate("config", config);
|
||||
|
||||
await authRoutes(app);
|
||||
await adminRoutes(app);
|
||||
await foldersRoutes(app);
|
||||
await bookmarksRoutes(app);
|
||||
await importExportRoutes(app);
|
||||
await syncRoutes(app);
|
||||
|
||||
app.addHook("onClose", async (instance) => {
|
||||
await instance.pg.end();
|
||||
});
|
||||
|
||||
const { serverPort } = config;
|
||||
const app = await buildApp();
|
||||
const { serverPort } = app.config;
|
||||
await app.listen({ port: serverPort, host: "0.0.0.0" });
|
||||
|
||||
41
apps/server/src/lib/crypto.js
Normal file
41
apps/server/src/lib/crypto.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import crypto from "node:crypto";
|
||||
import { httpError } from "./httpErrors.js";
|
||||
|
||||
const ALGO = "aes-256-gcm";
|
||||
|
||||
function loadKey(keyBase64) {
|
||||
const raw = String(keyBase64 || "").trim();
|
||||
if (!raw) throw httpError(500, "CREDENTIAL_MASTER_KEY is required");
|
||||
const key = Buffer.from(raw, "base64");
|
||||
if (key.length !== 32) throw httpError(500, "CREDENTIAL_MASTER_KEY must be 32 bytes base64");
|
||||
return key;
|
||||
}
|
||||
|
||||
export function encryptPassword(plaintext, keyBase64) {
|
||||
const key = loadKey(keyBase64);
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv(ALGO, key, iv);
|
||||
const enc = Buffer.concat([cipher.update(String(plaintext || ""), "utf8"), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
return {
|
||||
cipherText: enc.toString("base64"),
|
||||
iv: iv.toString("base64"),
|
||||
tag: tag.toString("base64")
|
||||
};
|
||||
}
|
||||
|
||||
export function decryptPassword({ cipherText, iv, tag }, keyBase64) {
|
||||
const key = loadKey(keyBase64);
|
||||
try {
|
||||
const decipher = crypto.createDecipheriv(ALGO, key, Buffer.from(iv, "base64"));
|
||||
decipher.setAuthTag(Buffer.from(tag, "base64"));
|
||||
const out = Buffer.concat([
|
||||
decipher.update(Buffer.from(cipherText, "base64")),
|
||||
decipher.final()
|
||||
]);
|
||||
return out.toString("utf8");
|
||||
} catch {
|
||||
throw httpError(500, "failed to decrypt credential");
|
||||
}
|
||||
}
|
||||
13
apps/server/src/lib/reauth.js
Normal file
13
apps/server/src/lib/reauth.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { httpError } from "./httpErrors.js";
|
||||
|
||||
export async function verifyReauthToken(app, userId, token) {
|
||||
if (!token) throw httpError(403, "reauth required");
|
||||
try {
|
||||
const payload = await app.jwt.verify(token);
|
||||
if (!payload?.reauth) throw httpError(403, "reauth invalid");
|
||||
if (payload.sub !== userId) throw httpError(403, "reauth invalid");
|
||||
return payload;
|
||||
} catch {
|
||||
throw httpError(403, "reauth invalid");
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,16 @@ import { computeUrlHash, normalizeUrl } from "@browser-bookmark/shared";
|
||||
import { httpError } from "../lib/httpErrors.js";
|
||||
import { requireAdmin, isAdminEmail } from "../lib/admin.js";
|
||||
import { bookmarkRowToDto, folderRowToDto, userRowToDto } from "../lib/rows.js";
|
||||
import { encryptPassword, decryptPassword } from "../lib/crypto.js";
|
||||
|
||||
function normalizeOrigin(input) {
|
||||
try {
|
||||
const u = new URL(String(input || "").trim());
|
||||
return u.origin;
|
||||
} catch {
|
||||
throw httpError(400, "siteOrigin invalid");
|
||||
}
|
||||
}
|
||||
|
||||
function toUserDtoWithAdminOverride(app, row) {
|
||||
const dto = userRowToDto(row);
|
||||
@@ -65,6 +75,125 @@ export async function adminRoutes(app) {
|
||||
}
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/admin/users/:id/credentials",
|
||||
{ preHandler: [async (req) => requireAdmin(app, req)] },
|
||||
async (req) => {
|
||||
const userId = req.params?.id;
|
||||
if (!userId) throw httpError(400, "user id required");
|
||||
|
||||
const includePassword = String(req.query?.includePassword || "").toLowerCase() === "true";
|
||||
const res = await app.pg.query(
|
||||
"select * from credentials where user_id=$1 order by updated_at desc",
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (!includePassword) {
|
||||
return res.rows.map((r) => ({
|
||||
id: r.id,
|
||||
userId: r.user_id,
|
||||
siteOrigin: r.site_origin,
|
||||
username: r.username,
|
||||
password: null,
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at
|
||||
}));
|
||||
}
|
||||
|
||||
const key = process.env.CREDENTIAL_MASTER_KEY;
|
||||
return res.rows.map((r) => ({
|
||||
id: r.id,
|
||||
userId: r.user_id,
|
||||
siteOrigin: r.site_origin,
|
||||
username: r.username,
|
||||
password: decryptPassword({
|
||||
cipherText: r.password_enc,
|
||||
iv: r.password_iv,
|
||||
tag: r.password_tag
|
||||
}, key),
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at
|
||||
}));
|
||||
}
|
||||
);
|
||||
|
||||
app.patch(
|
||||
"/admin/users/:userId/credentials/:credentialId",
|
||||
{ preHandler: [async (req) => requireAdmin(app, req)] },
|
||||
async (req) => {
|
||||
const userId = req.params?.userId;
|
||||
const credentialId = req.params?.credentialId;
|
||||
if (!userId || !credentialId) throw httpError(400, "userId and credentialId required");
|
||||
|
||||
const body = req.body || {};
|
||||
const sets = [];
|
||||
const params = [];
|
||||
let i = 1;
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, "siteOrigin")) {
|
||||
const origin = normalizeOrigin(body.siteOrigin);
|
||||
sets.push(`site_origin=$${i++}`);
|
||||
params.push(origin);
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, "username")) {
|
||||
const name = String(body.username || "").trim();
|
||||
if (!name) throw httpError(400, "username required");
|
||||
sets.push(`username=$${i++}`);
|
||||
params.push(name);
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, "password")) {
|
||||
const pwd = String(body.password || "");
|
||||
if (!pwd) throw httpError(400, "password required");
|
||||
const key = process.env.CREDENTIAL_MASTER_KEY;
|
||||
const enc = encryptPassword(pwd, key);
|
||||
sets.push(`password_enc=$${i++}`);
|
||||
params.push(enc.cipherText);
|
||||
sets.push(`password_iv=$${i++}`);
|
||||
params.push(enc.iv);
|
||||
sets.push(`password_tag=$${i++}`);
|
||||
params.push(enc.tag);
|
||||
}
|
||||
|
||||
if (sets.length === 0) throw httpError(400, "no fields to update");
|
||||
|
||||
params.push(credentialId, userId);
|
||||
const res = await app.pg.query(
|
||||
`update credentials set ${sets.join(", ")}, updated_at=now() where id=$${i++} and user_id=$${i} returning *`,
|
||||
params
|
||||
);
|
||||
if (!res.rows[0]) throw httpError(404, "credential not found");
|
||||
|
||||
return {
|
||||
id: res.rows[0].id,
|
||||
userId: res.rows[0].user_id,
|
||||
siteOrigin: res.rows[0].site_origin,
|
||||
username: res.rows[0].username,
|
||||
password: null,
|
||||
createdAt: res.rows[0].created_at,
|
||||
updatedAt: res.rows[0].updated_at
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
app.delete(
|
||||
"/admin/users/:userId/credentials/:credentialId",
|
||||
{ preHandler: [async (req) => requireAdmin(app, req)] },
|
||||
async (req) => {
|
||||
const userId = req.params?.userId;
|
||||
const credentialId = req.params?.credentialId;
|
||||
if (!userId || !credentialId) throw httpError(400, "userId and credentialId required");
|
||||
|
||||
const res = await app.pg.query(
|
||||
"delete from credentials where id=$1 and user_id=$2 returning id",
|
||||
[credentialId, userId]
|
||||
);
|
||||
if (!res.rows[0]) throw httpError(404, "credential not found");
|
||||
return { ok: true };
|
||||
}
|
||||
);
|
||||
|
||||
app.delete(
|
||||
"/admin/users/:userId/bookmarks/:bookmarkId",
|
||||
{ preHandler: [async (req) => requireAdmin(app, req)] },
|
||||
|
||||
@@ -71,4 +71,5 @@ export async function authRoutes(app) {
|
||||
return toUserDtoWithAdminOverride(app, row);
|
||||
}
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
151
apps/server/src/routes/credentials.routes.js
Normal file
151
apps/server/src/routes/credentials.routes.js
Normal file
@@ -0,0 +1,151 @@
|
||||
import { httpError } from "../lib/httpErrors.js";
|
||||
import { encryptPassword, decryptPassword } from "../lib/crypto.js";
|
||||
|
||||
function normalizeOrigin(input) {
|
||||
try {
|
||||
const u = new URL(String(input || "").trim());
|
||||
return u.origin;
|
||||
} catch {
|
||||
throw httpError(400, "siteOrigin invalid");
|
||||
}
|
||||
}
|
||||
|
||||
function credentialRowToDto(row, { includePassword = false, passwordPlain = null } = {}) {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
siteOrigin: row.site_origin,
|
||||
username: row.username,
|
||||
password: includePassword ? passwordPlain : null,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
export async function credentialsRoutes(app) {
|
||||
app.get(
|
||||
"/credentials",
|
||||
{ preHandler: [app.authenticate] },
|
||||
async (req) => {
|
||||
const userId = req.user.sub;
|
||||
const siteOrigin = req.query?.siteOrigin ? normalizeOrigin(req.query.siteOrigin) : null;
|
||||
const includePassword = String(req.query?.includePassword || "").toLowerCase() === "true";
|
||||
|
||||
const params = [userId];
|
||||
let where = "where user_id=$1";
|
||||
if (siteOrigin) {
|
||||
params.push(siteOrigin);
|
||||
where += ` and site_origin=$${params.length}`;
|
||||
}
|
||||
|
||||
const res = await app.pg.query(
|
||||
`select * from credentials ${where} order by updated_at desc`,
|
||||
params
|
||||
);
|
||||
|
||||
if (!includePassword) return res.rows.map((r) => credentialRowToDto(r));
|
||||
|
||||
const key = process.env.CREDENTIAL_MASTER_KEY;
|
||||
return res.rows.map((r) => {
|
||||
const passwordPlain = decryptPassword({
|
||||
cipherText: r.password_enc,
|
||||
iv: r.password_iv,
|
||||
tag: r.password_tag
|
||||
}, key);
|
||||
return credentialRowToDto(r, { includePassword: true, passwordPlain });
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/credentials",
|
||||
{ preHandler: [app.authenticate] },
|
||||
async (req) => {
|
||||
const userId = req.user.sub;
|
||||
const { siteOrigin, username, password } = req.body || {};
|
||||
if (!siteOrigin) throw httpError(400, "siteOrigin required");
|
||||
if (!username) throw httpError(400, "username required");
|
||||
if (!password) throw httpError(400, "password required");
|
||||
|
||||
const origin = normalizeOrigin(siteOrigin);
|
||||
const key = process.env.CREDENTIAL_MASTER_KEY;
|
||||
const enc = encryptPassword(password, key);
|
||||
|
||||
const res = await app.pg.query(
|
||||
`insert into credentials (user_id, site_origin, username, password_enc, password_iv, password_tag)
|
||||
values ($1,$2,$3,$4,$5,$6)
|
||||
on conflict (user_id, site_origin, username)
|
||||
do update set password_enc=excluded.password_enc, password_iv=excluded.password_iv, password_tag=excluded.password_tag, updated_at=now()
|
||||
returning *`,
|
||||
[userId, origin, username, enc.cipherText, enc.iv, enc.tag]
|
||||
);
|
||||
|
||||
return credentialRowToDto(res.rows[0]);
|
||||
}
|
||||
);
|
||||
|
||||
app.patch(
|
||||
"/credentials/:id",
|
||||
{ preHandler: [app.authenticate] },
|
||||
async (req) => {
|
||||
const userId = req.user.sub;
|
||||
const id = req.params?.id;
|
||||
const body = req.body || {};
|
||||
|
||||
const sets = [];
|
||||
const params = [];
|
||||
let i = 1;
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, "siteOrigin")) {
|
||||
const origin = normalizeOrigin(body.siteOrigin);
|
||||
sets.push(`site_origin=$${i++}`);
|
||||
params.push(origin);
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, "username")) {
|
||||
const name = String(body.username || "").trim();
|
||||
if (!name) throw httpError(400, "username required");
|
||||
sets.push(`username=$${i++}`);
|
||||
params.push(name);
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, "password")) {
|
||||
const pwd = String(body.password || "");
|
||||
if (!pwd) throw httpError(400, "password required");
|
||||
const key = process.env.CREDENTIAL_MASTER_KEY;
|
||||
const enc = encryptPassword(pwd, key);
|
||||
sets.push(`password_enc=$${i++}`);
|
||||
params.push(enc.cipherText);
|
||||
sets.push(`password_iv=$${i++}`);
|
||||
params.push(enc.iv);
|
||||
sets.push(`password_tag=$${i++}`);
|
||||
params.push(enc.tag);
|
||||
}
|
||||
|
||||
if (sets.length === 0) throw httpError(400, "no fields to update");
|
||||
|
||||
params.push(id, userId);
|
||||
const res = await app.pg.query(
|
||||
`update credentials set ${sets.join(", ")}, updated_at=now() where id=$${i++} and user_id=$${i} returning *`,
|
||||
params
|
||||
);
|
||||
if (!res.rows[0]) throw httpError(404, "credential not found");
|
||||
return credentialRowToDto(res.rows[0]);
|
||||
}
|
||||
);
|
||||
|
||||
app.delete(
|
||||
"/credentials/:id",
|
||||
{ preHandler: [app.authenticate] },
|
||||
async (req) => {
|
||||
const userId = req.user.sub;
|
||||
const id = req.params?.id;
|
||||
const res = await app.pg.query(
|
||||
"delete from credentials where id=$1 and user_id=$2 returning id",
|
||||
[id, userId]
|
||||
);
|
||||
if (!res.rows[0]) throw httpError(404, "credential not found");
|
||||
return { ok: true };
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -89,7 +89,7 @@ router.afterEach(() => {
|
||||
<template>
|
||||
<header class="nav" data-bb-menu>
|
||||
<div class="navInner">
|
||||
<RouterLink to="/" class="brand">
|
||||
<RouterLink to="/my" class="brand">
|
||||
<span class="brandMark">X</span>
|
||||
<span class="brandText">云书签</span>
|
||||
</RouterLink>
|
||||
@@ -108,6 +108,10 @@ router.afterEach(() => {
|
||||
|
||||
<div v-if="menuOpen" class="menu" role="menu">
|
||||
<RouterLink class="menuItem menuItem--import" to="/import" role="menuitem">导入 / 导出</RouterLink>
|
||||
<a class="menuItem" href="http://mark.cloud-xl.top:9527/extension-dist.zip" role="menuitem" download>
|
||||
下载插件
|
||||
</a>
|
||||
<RouterLink class="menuItem menuItem--password" to="/passwords" role="menuitem">密码管理</RouterLink>
|
||||
<RouterLink v-if="isAdmin" class="menuItem" to="/admin" role="menuitem">管理用户</RouterLink>
|
||||
<button class="menuItem danger" type="button" role="menuitem" @click="logout">退出登录</button>
|
||||
</div>
|
||||
@@ -186,5 +190,6 @@ router.afterEach(() => {
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.menuItem--import { display: none; }
|
||||
.menuItem--password { display: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -87,6 +87,7 @@ async function submit() {
|
||||
placeholder="至少 8 位"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
@keydown.enter.prevent="submit"
|
||||
/>
|
||||
</label>
|
||||
|
||||
|
||||
@@ -635,7 +635,7 @@ onBeforeUnmount(() => {
|
||||
<BbModal v-model="addModalOpen" title="添加书签">
|
||||
<div class="bb-modalForm">
|
||||
<input v-model="title" class="bb-input" placeholder="标题" />
|
||||
<input v-model="url" class="bb-input" placeholder="链接(https://...)" />
|
||||
<input v-model="url" class="bb-input" placeholder="链接(https://...)" @keydown.enter.prevent="add" />
|
||||
<BbSelect v-model="folderId" :options="folderOptions" />
|
||||
<BbSelect v-model="visibility" :options="visibilityOptions" />
|
||||
<div class="bb-row" style="justify-content: flex-end; gap: 10px;">
|
||||
@@ -648,7 +648,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<BbModal v-if="loggedIn" v-model="folderModalOpen" title="新建文件夹">
|
||||
<div class="bb-modalForm">
|
||||
<input v-model="folderName" class="bb-input" placeholder="新文件夹名称" />
|
||||
<input v-model="folderName" class="bb-input" placeholder="新文件夹名称" @keydown.enter.prevent="createFolder" />
|
||||
<BbSelect v-model="folderVisibility" :options="folderVisibilityOptions" />
|
||||
<div class="bb-row" style="justify-content: flex-end; gap: 10px;">
|
||||
<button class="bb-btn bb-btn--secondary" type="button" @click="folderModalOpen = false">取消</button>
|
||||
@@ -720,7 +720,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<div v-else class="edit">
|
||||
<input v-model="editTitle" class="bb-input" placeholder="标题" />
|
||||
<input v-model="editUrl" class="bb-input" placeholder="链接" />
|
||||
<input v-model="editUrl" class="bb-input" placeholder="链接" @keydown.enter.prevent="saveEdit(b.id)" />
|
||||
<BbSelect v-model="editFolderId" :options="folderOptions" />
|
||||
<BbSelect v-model="editVisibility" :options="visibilityOptions" />
|
||||
<div class="actions">
|
||||
@@ -805,7 +805,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<div v-else class="edit">
|
||||
<input v-model="editTitle" class="bb-input" placeholder="标题" />
|
||||
<input v-model="editUrl" class="bb-input" placeholder="链接" />
|
||||
<input v-model="editUrl" class="bb-input" placeholder="链接" @keydown.enter.prevent="saveEdit(b.id)" />
|
||||
<BbSelect v-model="editFolderId" :options="folderOptions" />
|
||||
<BbSelect v-model="editVisibility" :options="visibilityOptions" />
|
||||
<div class="actions">
|
||||
|
||||
266
apps/web/src/pages/PasswordManagerPage.vue
Normal file
266
apps/web/src/pages/PasswordManagerPage.vue
Normal file
@@ -0,0 +1,266 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import BbModal from "../components/BbModal.vue";
|
||||
import BbSelect from "../components/BbSelect.vue";
|
||||
import { apiFetch, ensureMe, tokenRef, userRef } from "../lib/api";
|
||||
|
||||
const loggedIn = computed(() => Boolean(tokenRef.value));
|
||||
const isAdmin = computed(() => userRef.value?.role === "admin");
|
||||
|
||||
const isDesktop = ref(true);
|
||||
function syncViewport() {
|
||||
isDesktop.value = window.innerWidth >= 960;
|
||||
}
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const items = ref([]);
|
||||
|
||||
const users = ref([]);
|
||||
const selectedUserId = ref("");
|
||||
const selectedUser = computed(() => users.value.find((u) => u.id === selectedUserId.value) || null);
|
||||
|
||||
const showPasswords = ref(false);
|
||||
const revealPasswords = computed(() => isAdmin.value || showPasswords.value);
|
||||
|
||||
function loadSessionFlag() {
|
||||
try {
|
||||
showPasswords.value = sessionStorage.getItem("bb_show_passwords") === "1";
|
||||
} catch {
|
||||
showPasswords.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleShowPasswords() {
|
||||
showPasswords.value = !showPasswords.value;
|
||||
try {
|
||||
sessionStorage.setItem("bb_show_passwords", showPasswords.value ? "1" : "0");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
if (!isAdmin.value) return;
|
||||
users.value = await apiFetch("/admin/users");
|
||||
if (!selectedUserId.value && users.value.length) selectedUserId.value = users.value[0].id;
|
||||
}
|
||||
|
||||
async function loadCredentials() {
|
||||
if (!loggedIn.value) return;
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
|
||||
try {
|
||||
if (isAdmin.value) {
|
||||
if (!selectedUserId.value) {
|
||||
items.value = [];
|
||||
return;
|
||||
}
|
||||
const qs = revealPasswords.value ? "?includePassword=true" : "";
|
||||
items.value = await apiFetch(`/admin/users/${selectedUserId.value}/credentials${qs}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const qs = revealPasswords.value ? "?includePassword=true" : "";
|
||||
items.value = await apiFetch(`/credentials${qs}`);
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const editOpen = ref(false);
|
||||
const editId = ref("");
|
||||
const editSiteOrigin = ref("");
|
||||
const editUsername = ref("");
|
||||
const editPassword = ref("");
|
||||
const editBusy = ref(false);
|
||||
|
||||
function startEdit(item) {
|
||||
editId.value = item.id;
|
||||
editSiteOrigin.value = item.siteOrigin || "";
|
||||
editUsername.value = item.username || "";
|
||||
editPassword.value = "";
|
||||
editOpen.value = true;
|
||||
}
|
||||
|
||||
async function submitEdit() {
|
||||
const payload = {
|
||||
siteOrigin: editSiteOrigin.value.trim(),
|
||||
username: editUsername.value.trim()
|
||||
};
|
||||
if (editPassword.value) payload.password = editPassword.value;
|
||||
|
||||
editBusy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
if (isAdmin.value) {
|
||||
await apiFetch(`/admin/users/${selectedUserId.value}/credentials/${editId.value}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
} else {
|
||||
await apiFetch(`/credentials/${editId.value}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
editOpen.value = false;
|
||||
await loadCredentials();
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
} finally {
|
||||
editBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const confirmOpen = ref(false);
|
||||
const pendingDeleteId = ref("");
|
||||
const deleteBusy = ref(false);
|
||||
|
||||
function askDelete(id) {
|
||||
pendingDeleteId.value = id;
|
||||
confirmOpen.value = true;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!pendingDeleteId.value) return;
|
||||
deleteBusy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
if (isAdmin.value) {
|
||||
await apiFetch(`/admin/users/${selectedUserId.value}/credentials/${pendingDeleteId.value}`, { method: "DELETE" });
|
||||
} else {
|
||||
await apiFetch(`/credentials/${pendingDeleteId.value}`, { method: "DELETE" });
|
||||
}
|
||||
confirmOpen.value = false;
|
||||
pendingDeleteId.value = "";
|
||||
await loadCredentials();
|
||||
} catch (e) {
|
||||
error.value = e.message || String(e);
|
||||
} finally {
|
||||
deleteBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function cancelDelete() {
|
||||
confirmOpen.value = false;
|
||||
pendingDeleteId.value = "";
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
syncViewport();
|
||||
window.addEventListener("resize", syncViewport);
|
||||
loadSessionFlag();
|
||||
if (loggedIn.value) await ensureMe();
|
||||
await loadUsers();
|
||||
await loadCredentials();
|
||||
});
|
||||
|
||||
watch([() => selectedUserId.value, () => showPasswords.value], async () => {
|
||||
await loadCredentials();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("resize", syncViewport);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bb-page">
|
||||
<div class="bb-pageHeader">
|
||||
<div>
|
||||
<h1 style="margin: 0;">密码管理</h1>
|
||||
<div class="bb-muted" style="margin-top: 4px;">仅 PC 显示。可管理已保存的网站账号密码。</div>
|
||||
</div>
|
||||
|
||||
<div class="bb-row" style="gap: 8px;">
|
||||
<button v-if="!isAdmin" class="bb-btn bb-btn--secondary" type="button" @click="toggleShowPasswords">
|
||||
{{ showPasswords ? '隐藏明文' : '显示明文' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isDesktop" class="bb-empty" style="margin-top: 12px;">
|
||||
<div style="font-weight: 900;">仅 PC 可见</div>
|
||||
<div class="bb-muted" style="margin-top: 6px;">请在电脑端访问此页面。</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="bb-card" style="margin-top: 12px;">
|
||||
<div v-if="isAdmin" class="bb-row" style="gap: 10px; margin-bottom: 12px;">
|
||||
<div style="min-width: 220px; flex: 1;">
|
||||
<BbSelect
|
||||
v-model="selectedUserId"
|
||||
:options="users.map((u) => ({ value: u.id, label: u.email }))"
|
||||
placeholder="选择用户"
|
||||
/>
|
||||
</div>
|
||||
<div class="bb-muted">
|
||||
{{ selectedUser ? `当前:${selectedUser.email}` : '请选择用户' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="bb-alert bb-alert--error">{{ error }}</p>
|
||||
<p v-else-if="loading" class="bb-muted">加载中…</p>
|
||||
|
||||
<div v-else-if="!items.length" class="bb-empty" style="margin-top: 12px;">
|
||||
<div style="font-weight: 700;">暂无记录</div>
|
||||
<div class="bb-muted" style="margin-top: 6px;">没有保存的账号密码。</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="bb-credList">
|
||||
<div v-for="c in items" :key="c.id" class="bb-card bb-card--interactive bb-credItem">
|
||||
<div class="bb-credRow">
|
||||
<div class="bb-credMeta">
|
||||
<div class="bb-credSite">{{ c.siteOrigin }}</div>
|
||||
<div class="bb-credUser">账号:{{ c.username }}</div>
|
||||
</div>
|
||||
<div class="bb-credActions">
|
||||
<button class="bb-btn bb-btn--secondary" type="button" @click="startEdit(c)">编辑</button>
|
||||
<button class="bb-btn bb-btn--danger" type="button" @click="askDelete(c.id)">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bb-credPwd">
|
||||
<span class="bb-muted">密码:</span>
|
||||
<span v-if="revealPasswords">{{ c.password || '(空)' }}</span>
|
||||
<span v-else class="bb-muted">已隐藏</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BbModal v-model="editOpen" title="编辑账号" max-width="560px">
|
||||
<div class="bb-modalForm">
|
||||
<input v-model="editSiteOrigin" class="bb-input" placeholder="网站来源 (https://example.com)" />
|
||||
<input v-model="editUsername" class="bb-input" placeholder="账号" />
|
||||
<input v-model="editPassword" class="bb-input" type="password" placeholder="新密码(可留空不改)" @keydown.enter.prevent="submitEdit" />
|
||||
<div class="bb-row" style="justify-content: flex-end; gap: 10px;">
|
||||
<button class="bb-btn bb-btn--secondary" type="button" @click="editOpen = false">取消</button>
|
||||
<button class="bb-btn" type="button" :disabled="editBusy" @click="submitEdit">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</BbModal>
|
||||
|
||||
<BbModal :model-value="confirmOpen" title="删除账号密码" max-width="520px" @update:model-value="(v) => (v ? (confirmOpen = true) : cancelDelete())">
|
||||
<div class="bb-muted" style="line-height: 1.6;">确定删除该记录?</div>
|
||||
<div class="bb-row" style="justify-content: flex-end; gap: 10px; margin-top: 14px;">
|
||||
<button class="bb-btn bb-btn--secondary" type="button" @click="cancelDelete">取消</button>
|
||||
<button class="bb-btn bb-btn--danger" type="button" :disabled="deleteBusy" @click="confirmDelete">删除</button>
|
||||
</div>
|
||||
</BbModal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bb-credList { display: grid; gap: 10px; }
|
||||
.bb-credItem { display: grid; gap: 8px; }
|
||||
.bb-credRow { display: flex; gap: 12px; justify-content: space-between; align-items: center; flex-wrap: wrap; }
|
||||
.bb-credMeta { display: grid; gap: 4px; min-width: 0; }
|
||||
.bb-credSite { font-weight: 900; word-break: break-all; }
|
||||
.bb-credUser { font-size: 13px; }
|
||||
.bb-credActions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.bb-credPwd { font-size: 13px; }
|
||||
</style>
|
||||
@@ -139,7 +139,7 @@ onMounted(loadFolders);
|
||||
<BbModal v-if="loggedIn" v-model="addModalOpen" title="添加书签">
|
||||
<div class="bb-publicAdd" style="margin-top: 2px;">
|
||||
<input v-model="addTitle" class="bb-input" placeholder="标题" />
|
||||
<input v-model="addUrl" class="bb-input" placeholder="链接(https://...)" />
|
||||
<input v-model="addUrl" class="bb-input" placeholder="链接(https://...)" @keydown.enter.prevent="addBookmark" />
|
||||
<BbSelect
|
||||
v-model="addFolderId"
|
||||
:options="folderOptions"
|
||||
|
||||
@@ -4,6 +4,7 @@ import LoginPage from "./pages/LoginPage.vue";
|
||||
import MyPage from "./pages/MyPage.vue";
|
||||
import ImportExportPage from "./pages/ImportExportPage.vue";
|
||||
import AdminPage from "./pages/AdminPage.vue";
|
||||
import PasswordManagerPage from "./pages/PasswordManagerPage.vue";
|
||||
import { ensureMe, tokenRef } from "./lib/api";
|
||||
|
||||
export const router = createRouter({
|
||||
@@ -13,14 +14,16 @@ export const router = createRouter({
|
||||
{ path: "/login", component: LoginPage },
|
||||
{ path: "/my", component: MyPage },
|
||||
{ path: "/import", component: ImportExportPage },
|
||||
{ path: "/admin", component: AdminPage }
|
||||
{ path: "/admin", component: AdminPage },
|
||||
{ path: "/passwords", component: PasswordManagerPage }
|
||||
]
|
||||
});
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
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 } };
|
||||
|
||||
@@ -86,3 +86,15 @@ AI 创建项目时,默认使用以下结构;如项目类型不适用,可
|
||||
- 未明确要求时:
|
||||
- 不引入与约束无关的“额外页面/功能/组件/花哨配置”。
|
||||
- 保持最小可用、可验证、可维护的实现。
|
||||
|
||||
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);否则不得修改。
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
@@ -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 }`
|
||||
12
openspec/changes/archive/2026-01-22-add-dnd-sorting/tasks.md
Normal file
12
openspec/changes/archive/2026-01-22-add-dnd-sorting/tasks.md
Normal file
@@ -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
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
64
openspec/specs/password-manager/spec.md
Normal file
64
openspec/specs/password-manager/spec.md
Normal file
@@ -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
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user