Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 54398837e6 | |||
| 989b41735f | |||
| a88b8a824b |
+208
-23
@@ -36,21 +36,138 @@ function normalizeRole(rawRole) {
|
|||||||
switch (String(rawRole || "").trim().toLowerCase()) {
|
switch (String(rawRole || "").trim().toLowerCase()) {
|
||||||
case "password":
|
case "password":
|
||||||
return "password";
|
return "password";
|
||||||
default:
|
case "username":
|
||||||
return "username";
|
return "username";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function lowerJoined(values) {
|
||||||
|
return values
|
||||||
|
.map((value) => String(value || "").trim().toLowerCase())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldHintText(input) {
|
||||||
|
if (!input || typeof input !== "object") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const labels = input.labels ? Array.from(input.labels).map((label) => label.textContent || "") : [];
|
||||||
|
return lowerJoined([
|
||||||
|
input.getAttribute?.("type"),
|
||||||
|
input.getAttribute?.("name"),
|
||||||
|
input.getAttribute?.("id"),
|
||||||
|
input.autocomplete,
|
||||||
|
input.getAttribute?.("autocomplete"),
|
||||||
|
input.getAttribute?.("placeholder"),
|
||||||
|
input.getAttribute?.("aria-label"),
|
||||||
|
...labels
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function textLikeInputType(type) {
|
||||||
|
switch (String(type || "").toLowerCase()) {
|
||||||
|
case "":
|
||||||
|
case "text":
|
||||||
|
case "email":
|
||||||
|
case "tel":
|
||||||
|
case "number":
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hintMatches(text, patterns) {
|
||||||
|
return patterns.some((pattern) => pattern.test(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopeHintText(scope) {
|
||||||
|
const isDocumentScope = typeof document !== "undefined" && scope === document;
|
||||||
|
if ((!scope || typeof scope !== "object") || (!isDocumentScope && typeof scope.querySelectorAll !== "function" && typeof scope.getAttribute !== "function")) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const attrText = isDocumentScope ? "" : lowerJoined([
|
||||||
|
scope.getAttribute?.("id"),
|
||||||
|
scope.getAttribute?.("name"),
|
||||||
|
scope.getAttribute?.("class"),
|
||||||
|
scope.getAttribute?.("action"),
|
||||||
|
scope.getAttribute?.("aria-label")
|
||||||
|
]);
|
||||||
|
const headingText = lowerJoined(Array.from(scope.querySelectorAll?.("button, h1, h2, h3, h4, legend, label, [role='button']") || [])
|
||||||
|
.slice(0, 8)
|
||||||
|
.map((element) => element.textContent || ""));
|
||||||
|
return lowerJoined([attrText, headingText]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAuthFlowSignals(usernameInput, scope) {
|
||||||
|
if (usernameInput) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return hintMatches(scopeHintText(scope), authScopePatterns);
|
||||||
|
}
|
||||||
|
|
||||||
|
const usernameHintPatterns = [
|
||||||
|
/\buser(name|id)?\b/,
|
||||||
|
/\blog[\s_-]?in\b/,
|
||||||
|
/\bsign[\s_-]?in\b/,
|
||||||
|
/\bemail\b/,
|
||||||
|
/\be-mail\b/,
|
||||||
|
/\baccount\b/,
|
||||||
|
/\bmember\b/,
|
||||||
|
/\bidentifier\b/
|
||||||
|
];
|
||||||
|
|
||||||
|
const nonLoginHintPatterns = [
|
||||||
|
/\bsearch\b/,
|
||||||
|
/\bquery\b/,
|
||||||
|
/\bfilter\b/,
|
||||||
|
/\bcomment\b/,
|
||||||
|
/\bmessage\b/,
|
||||||
|
/\bcontact\b/,
|
||||||
|
/\bcity\b/,
|
||||||
|
/\bstate\b/,
|
||||||
|
/\bpostal\b/,
|
||||||
|
/\bzip\b/,
|
||||||
|
/\bcoupon\b/,
|
||||||
|
/\bpromo\b/,
|
||||||
|
/\bnewsletter\b/,
|
||||||
|
/\bsubscribe\b/
|
||||||
|
];
|
||||||
|
|
||||||
|
const authScopePatterns = [
|
||||||
|
/\blog[\s_-]?in\b/,
|
||||||
|
/\bsign[\s_-]?in\b/,
|
||||||
|
/\bauth\b/,
|
||||||
|
/\bpassword\b/,
|
||||||
|
/\bpasscode\b/,
|
||||||
|
/\b2fa\b/,
|
||||||
|
/\btwo[\s-]?factor\b/,
|
||||||
|
/\bverify\b/,
|
||||||
|
/\baccount\b/
|
||||||
|
];
|
||||||
|
|
||||||
function describeFieldRole(input) {
|
function describeFieldRole(input) {
|
||||||
const type = String(input?.getAttribute?.("type") || "").toLowerCase();
|
const type = String(input?.getAttribute?.("type") || "").toLowerCase();
|
||||||
if (type === "password") {
|
if (type === "password") {
|
||||||
return "password";
|
return "password";
|
||||||
}
|
}
|
||||||
const autocomplete = String(input?.autocomplete || "").toLowerCase();
|
if (!textLikeInputType(type)) {
|
||||||
if (autocomplete.includes("username") || autocomplete.includes("email")) {
|
return "";
|
||||||
|
}
|
||||||
|
const hints = fieldHintText(input);
|
||||||
|
if (!hints) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (hintMatches(hints, nonLoginHintPatterns)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (hintMatches(hints, usernameHintPatterns)) {
|
||||||
return "username";
|
return "username";
|
||||||
}
|
}
|
||||||
return "username";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function isUsernameCandidate(input) {
|
function isUsernameCandidate(input) {
|
||||||
@@ -102,6 +219,40 @@ function firstVisibleUsername(scope) {
|
|||||||
return visibleInputs(scope).find(isUsernameCandidate) || null;
|
return visibleInputs(scope).find(isUsernameCandidate) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function authFlowCandidate(anchorInput) {
|
||||||
|
const scope = (typeof HTMLFormElement !== "undefined" && anchorInput?.form instanceof HTMLFormElement ? anchorInput.form : document);
|
||||||
|
const scopeInputs = resolveFormInputs(anchorInput);
|
||||||
|
const passwordInput = scopeInputs.find(isPasswordCandidate) || null;
|
||||||
|
if (!passwordInput) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const associated = associatedFieldsForAnchor(anchorInput || passwordInput);
|
||||||
|
if (!hasAuthFlowSignals(associated.usernameInput, scope)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
usernameInput: associated.usernameInput,
|
||||||
|
passwordInput,
|
||||||
|
anchorInput: anchorInput || passwordInput,
|
||||||
|
scope
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function loginCandidates() {
|
||||||
|
const candidates = [];
|
||||||
|
for (const passwordInput of visibleInputs(document).filter(isPasswordCandidate)) {
|
||||||
|
const candidate = authFlowCandidate(passwordInput);
|
||||||
|
if (!candidate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (candidates.some((existing) => existing.passwordInput === candidate.passwordInput)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
candidates.push(candidate);
|
||||||
|
}
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
function associatedFieldsForAnchor(anchorInput) {
|
function associatedFieldsForAnchor(anchorInput) {
|
||||||
const scopeInputs = resolveFormInputs(anchorInput);
|
const scopeInputs = resolveFormInputs(anchorInput);
|
||||||
const passwordInput = scopeInputs.find(isPasswordCandidate) || firstVisiblePassword(document);
|
const passwordInput = scopeInputs.find(isPasswordCandidate) || firstVisiblePassword(document);
|
||||||
@@ -121,11 +272,11 @@ function associatedFieldsForAnchor(anchorInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildFieldDescriptor(input, role) {
|
function buildFieldDescriptor(input, role) {
|
||||||
if (!(input instanceof HTMLInputElement)) {
|
if (typeof HTMLInputElement === "undefined" || !(input instanceof HTMLInputElement)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const normalizedRole = normalizeRole(role || describeFieldRole(input));
|
const normalizedRole = normalizeRole(role || describeFieldRole(input));
|
||||||
const form = input.form instanceof HTMLFormElement ? input.form : null;
|
const form = typeof HTMLFormElement !== "undefined" && input.form instanceof HTMLFormElement ? input.form : null;
|
||||||
const scope = form || document;
|
const scope = form || document;
|
||||||
const inputs = visibleInputs(scope);
|
const inputs = visibleInputs(scope);
|
||||||
const fieldIndex = inputs.indexOf(input);
|
const fieldIndex = inputs.indexOf(input);
|
||||||
@@ -145,6 +296,9 @@ function resolveFieldDescriptor(descriptor) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const normalizedRole = normalizeRole(descriptor.role);
|
const normalizedRole = normalizeRole(descriptor.role);
|
||||||
|
if (!normalizedRole) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const forms = Array.from(document.forms || []);
|
const forms = Array.from(document.forms || []);
|
||||||
const form = Number.isInteger(descriptor.formIndex) && descriptor.formIndex >= 0 ? forms[descriptor.formIndex] || null : null;
|
const form = Number.isInteger(descriptor.formIndex) && descriptor.formIndex >= 0 ? forms[descriptor.formIndex] || null : null;
|
||||||
const scope = form || document;
|
const scope = form || document;
|
||||||
@@ -200,20 +354,22 @@ function chooseFillTargets(targetDescriptor) {
|
|||||||
function scanLoginFields() {
|
function scanLoginFields() {
|
||||||
const activeElement = document.activeElement instanceof HTMLInputElement ? document.activeElement : null;
|
const activeElement = document.activeElement instanceof HTMLInputElement ? document.activeElement : null;
|
||||||
const activeUsable = activeElement && isVisibleInput(activeElement) ? activeElement : null;
|
const activeUsable = activeElement && isVisibleInput(activeElement) ? activeElement : null;
|
||||||
const targets = chooseFillTargets(buildFieldDescriptor(activeUsable, describeFieldRole(activeUsable)));
|
const explicitRole = describeFieldRole(activeUsable);
|
||||||
const anchorInput = activeUsable || targets.passwordInput || targets.usernameInput;
|
const activeTargets = activeUsable ? authFlowCandidate(activeUsable) : null;
|
||||||
const focusTarget = buildFieldDescriptor(anchorInput, describeFieldRole(anchorInput));
|
const candidates = loginCandidates();
|
||||||
const allVisible = visibleInputs(document);
|
const chosen = activeTargets || candidates[0] || null;
|
||||||
const roles = allVisible
|
const anchorInput = activeUsable || chosen?.passwordInput || chosen?.usernameInput || null;
|
||||||
.filter((input) => isUsernameCandidate(input) || isPasswordCandidate(input))
|
const focusRole = explicitRole || describeFieldRole(anchorInput);
|
||||||
.map((input) => {
|
const focusTarget = anchorInput ? buildFieldDescriptor(anchorInput, focusRole) : null;
|
||||||
const descriptor = buildFieldDescriptor(input, describeFieldRole(input));
|
const roles = candidates.map((candidate) => {
|
||||||
return `${descriptor.formIndex}:${descriptor.fieldIndex}:${descriptor.role}`;
|
const passwordDescriptor = buildFieldDescriptor(candidate.passwordInput, "password");
|
||||||
|
const usernameDescriptor = candidate.usernameInput ? buildFieldDescriptor(candidate.usernameInput, "username") : null;
|
||||||
|
return `${passwordDescriptor.formIndex}:${passwordDescriptor.fieldIndex}:password:${usernameDescriptor ? `${usernameDescriptor.formIndex}:${usernameDescriptor.fieldIndex}` : "-"}`;
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
pageHasLoginForm: Boolean(targets.usernameInput || targets.passwordInput),
|
pageHasLoginForm: Boolean(chosen),
|
||||||
usernameInput: targets.usernameInput,
|
usernameInput: chosen?.usernameInput || null,
|
||||||
passwordInput: targets.passwordInput,
|
passwordInput: chosen?.passwordInput || null,
|
||||||
anchorInput,
|
anchorInput,
|
||||||
focusTarget,
|
focusTarget,
|
||||||
signature: roles.join("|")
|
signature: roles.join("|")
|
||||||
@@ -265,8 +421,8 @@ function inlineMatchSummary(match) {
|
|||||||
return parts.join(" · ") || "No username";
|
return parts.join(" · ") || "No username";
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldShowInlineOverlay(state, hasTarget, suppressed) {
|
function shouldShowInlineOverlay(state, hasTarget, suppressed, idleHidden) {
|
||||||
if (suppressed || !hasTarget) {
|
if (suppressed || idleHidden || !hasTarget) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return Boolean(
|
return Boolean(
|
||||||
@@ -286,7 +442,11 @@ const contentTestExports = {
|
|||||||
chooseFillTargets,
|
chooseFillTargets,
|
||||||
inlineMatchSummary,
|
inlineMatchSummary,
|
||||||
domainLabel,
|
domainLabel,
|
||||||
shouldShowInlineOverlay
|
shouldShowInlineOverlay,
|
||||||
|
fieldHintText,
|
||||||
|
scopeHintText,
|
||||||
|
hasAuthFlowSignals,
|
||||||
|
authFlowCandidate
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isNodeTestEnv) {
|
if (isNodeTestEnv) {
|
||||||
@@ -303,7 +463,9 @@ if (isNodeTestEnv) {
|
|||||||
};
|
};
|
||||||
let chooserOpen = false;
|
let chooserOpen = false;
|
||||||
let inlineSuppressed = false;
|
let inlineSuppressed = false;
|
||||||
|
let inlineIdleHidden = false;
|
||||||
let refreshTimer = null;
|
let refreshTimer = null;
|
||||||
|
let idleHideTimer = null;
|
||||||
let lastReportedSignature = "";
|
let lastReportedSignature = "";
|
||||||
let lastReportedTarget = "";
|
let lastReportedTarget = "";
|
||||||
|
|
||||||
@@ -464,6 +626,24 @@ if (isNodeTestEnv) {
|
|||||||
dock.dataset.open = "false";
|
dock.dataset.open = "false";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearIdleHideTimer() {
|
||||||
|
if (idleHideTimer !== null) {
|
||||||
|
clearTimeout(idleHideTimer);
|
||||||
|
idleHideTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshInlineLifetime(shouldShow) {
|
||||||
|
clearIdleHideTimer();
|
||||||
|
if (!shouldShow || chooserOpen || pageState.pendingFill) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
idleHideTimer = window.setTimeout(() => {
|
||||||
|
inlineIdleHidden = true;
|
||||||
|
hideDock();
|
||||||
|
}, 15000);
|
||||||
|
}
|
||||||
|
|
||||||
function positionDock() {
|
function positionDock() {
|
||||||
const anchor = currentTarget();
|
const anchor = currentTarget();
|
||||||
if (!anchor || dock.style.display === "none") {
|
if (!anchor || dock.style.display === "none") {
|
||||||
@@ -537,9 +717,10 @@ if (isNodeTestEnv) {
|
|||||||
|
|
||||||
function renderInlineState() {
|
function renderInlineState() {
|
||||||
const target = currentTarget();
|
const target = currentTarget();
|
||||||
const shouldShow = shouldShowInlineOverlay(pageState, Boolean(target), inlineSuppressed);
|
const shouldShow = shouldShowInlineOverlay(pageState, Boolean(target), inlineSuppressed, inlineIdleHidden);
|
||||||
|
|
||||||
if (!shouldShow) {
|
if (!shouldShow) {
|
||||||
|
clearIdleHideTimer();
|
||||||
hideDock();
|
hideDock();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -558,17 +739,21 @@ if (isNodeTestEnv) {
|
|||||||
dock.dataset.open = chooserOpen ? "true" : "false";
|
dock.dataset.open = chooserOpen ? "true" : "false";
|
||||||
renderMatches();
|
renderMatches();
|
||||||
positionDock();
|
positionDock();
|
||||||
|
refreshInlineLifetime(shouldShow);
|
||||||
}
|
}
|
||||||
|
|
||||||
function reportFieldState(force) {
|
function reportFieldState(force) {
|
||||||
const scan = scanLoginFields();
|
const scan = scanLoginFields();
|
||||||
|
const nextTarget = JSON.stringify(scan.focusTarget || null);
|
||||||
|
if (scan.signature !== lastReportedSignature || nextTarget !== lastReportedTarget) {
|
||||||
|
inlineIdleHidden = false;
|
||||||
|
}
|
||||||
pageState = {
|
pageState = {
|
||||||
...pageState,
|
...pageState,
|
||||||
pageHasLoginForm: scan.pageHasLoginForm,
|
pageHasLoginForm: scan.pageHasLoginForm,
|
||||||
focusTarget: scan.focusTarget
|
focusTarget: scan.focusTarget
|
||||||
};
|
};
|
||||||
renderInlineState();
|
renderInlineState();
|
||||||
const nextTarget = JSON.stringify(scan.focusTarget || null);
|
|
||||||
if (!force && scan.signature === lastReportedSignature && nextTarget === lastReportedTarget) {
|
if (!force && scan.signature === lastReportedSignature && nextTarget === lastReportedTarget) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,68 @@ test("domainLabel tolerates invalid URLs", () => {
|
|||||||
assert.equal(content.domainLabel("not-a-url"), "");
|
assert.equal(content.domainLabel("not-a-url"), "");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("describeFieldRole only treats explicit account fields as usernames", () => {
|
||||||
|
const loginField = {
|
||||||
|
autocomplete: "username",
|
||||||
|
labels: [],
|
||||||
|
getAttribute(name) {
|
||||||
|
const attrs = {
|
||||||
|
type: "email",
|
||||||
|
id: "crew-email",
|
||||||
|
name: "email",
|
||||||
|
placeholder: "Email address",
|
||||||
|
"aria-label": "Email address"
|
||||||
|
};
|
||||||
|
return attrs[name] || "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const searchField = {
|
||||||
|
autocomplete: "",
|
||||||
|
labels: [],
|
||||||
|
getAttribute(name) {
|
||||||
|
const attrs = {
|
||||||
|
type: "text",
|
||||||
|
id: "site-search",
|
||||||
|
name: "query",
|
||||||
|
placeholder: "Search casino news",
|
||||||
|
"aria-label": "Search"
|
||||||
|
};
|
||||||
|
return attrs[name] || "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(content.describeFieldRole(loginField), "username");
|
||||||
|
assert.equal(content.describeFieldRole(searchField), "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hasAuthFlowSignals rejects generic password scopes and accepts sign-in scopes", () => {
|
||||||
|
const genericScope = {
|
||||||
|
getAttribute() {
|
||||||
|
return "";
|
||||||
|
},
|
||||||
|
querySelectorAll() {
|
||||||
|
return [{ textContent: "Confirm shipment" }];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const signInScope = {
|
||||||
|
getAttribute(name) {
|
||||||
|
const attrs = {
|
||||||
|
id: "signin-panel",
|
||||||
|
name: "signin",
|
||||||
|
action: "/session"
|
||||||
|
};
|
||||||
|
return attrs[name] || "";
|
||||||
|
},
|
||||||
|
querySelectorAll() {
|
||||||
|
return [{ textContent: "Sign in to the Bellagio vault" }];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(content.hasAuthFlowSignals(null, genericScope), false);
|
||||||
|
assert.equal(content.hasAuthFlowSignals(null, signInScope), true);
|
||||||
|
assert.equal(content.hasAuthFlowSignals({ id: "danny-ocean" }, genericScope), true);
|
||||||
|
});
|
||||||
|
|
||||||
test("shouldShowInlineOverlay hides the page overlay after it is suppressed", () => {
|
test("shouldShowInlineOverlay hides the page overlay after it is suppressed", () => {
|
||||||
const state = {
|
const state = {
|
||||||
pageHasLoginForm: true,
|
pageHasLoginForm: true,
|
||||||
@@ -29,5 +91,19 @@ test("shouldShowInlineOverlay hides the page overlay after it is suppressed", ()
|
|||||||
};
|
};
|
||||||
|
|
||||||
assert.equal(content.shouldShowInlineOverlay(state, true, false), true);
|
assert.equal(content.shouldShowInlineOverlay(state, true, false), true);
|
||||||
assert.equal(content.shouldShowInlineOverlay(state, true, true), false);
|
assert.equal(content.shouldShowInlineOverlay(state, true, true, false), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shouldShowInlineOverlay hides the page overlay after idle expiry", () => {
|
||||||
|
const state = {
|
||||||
|
pageHasLoginForm: true,
|
||||||
|
configured: true,
|
||||||
|
success: true,
|
||||||
|
status: { locked: false },
|
||||||
|
matches: [{ id: "rusty-ryan" }],
|
||||||
|
pendingFill: false
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(content.shouldShowInlineOverlay(state, true, false, false), true);
|
||||||
|
assert.equal(content.shouldShowInlineOverlay(state, true, false, true), false);
|
||||||
});
|
});
|
||||||
|
|||||||
+92
-15
@@ -32,6 +32,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const entriesRootLabel = "Root"
|
const entriesRootLabel = "Root"
|
||||||
|
const templatesRootLabel = "Templates"
|
||||||
|
|
||||||
type CurrentSession interface {
|
type CurrentSession interface {
|
||||||
Current() (vault.Model, error)
|
Current() (vault.Model, error)
|
||||||
@@ -402,8 +403,8 @@ func (s *State) ChildGroups() ([]string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if s.Section != SectionEntries {
|
if s.Section != SectionEntries {
|
||||||
if s.Section == SectionTemplates && len(s.CurrentPath) == 0 {
|
if s.Section == SectionTemplates {
|
||||||
return childGroups(s.entriesForSection(model), []string{"Templates"}), nil
|
return vaultview.VaultTemplates(model).ChildGroups(templatesViewPath(s.CurrentPath)), nil
|
||||||
}
|
}
|
||||||
return childGroups(s.entriesForSection(model), s.CurrentPath), nil
|
return childGroups(s.entriesForSection(model), s.CurrentPath), nil
|
||||||
}
|
}
|
||||||
@@ -452,7 +453,7 @@ func (s *State) currentModel() (vault.Model, error) {
|
|||||||
func (s *State) entriesForSection(model vault.Model) []vault.Entry {
|
func (s *State) entriesForSection(model vault.Model) []vault.Entry {
|
||||||
switch s.Section {
|
switch s.Section {
|
||||||
case SectionTemplates:
|
case SectionTemplates:
|
||||||
return slices.Clone(model.Templates)
|
return logicalTemplateEntries(vaultview.VaultTemplates(model).EntriesUnderPath(nil))
|
||||||
case SectionRecycleBin:
|
case SectionRecycleBin:
|
||||||
return logicalEntries(vaultview.VaultRecycleBin(model).EntriesUnderPath(nil))
|
return logicalEntries(vaultview.VaultRecycleBin(model).EntriesUnderPath(nil))
|
||||||
case SectionAPITokens, SectionAPIAudit, SectionAbout:
|
case SectionAPITokens, SectionAPIAudit, SectionAbout:
|
||||||
@@ -466,9 +467,7 @@ func (s State) SearchPathContext(entry vault.Entry) string {
|
|||||||
path := slices.Clone(entry.Path)
|
path := slices.Clone(entry.Path)
|
||||||
switch s.Section {
|
switch s.Section {
|
||||||
case SectionTemplates:
|
case SectionTemplates:
|
||||||
if len(path) == 0 || path[0] != "Templates" {
|
path = logicalTemplatePath(path)
|
||||||
path = append([]string{"Templates"}, path...)
|
|
||||||
}
|
|
||||||
case SectionRecycleBin:
|
case SectionRecycleBin:
|
||||||
path = append([]string{"Recycle Bin"}, logicalEntriesPath(path)...)
|
path = append([]string{"Recycle Bin"}, logicalEntriesPath(path)...)
|
||||||
case SectionEntries:
|
case SectionEntries:
|
||||||
@@ -555,6 +554,26 @@ func logicalEntriesPath(path []string) []string {
|
|||||||
return append([]string{entriesRootLabel}, append([]string(nil), path...)...)
|
return append([]string{entriesRootLabel}, append([]string(nil), path...)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func logicalTemplatePath(path []string) []string {
|
||||||
|
if len(path) == 0 {
|
||||||
|
return []string{templatesRootLabel}
|
||||||
|
}
|
||||||
|
if path[0] == templatesRootLabel {
|
||||||
|
return append([]string(nil), path...)
|
||||||
|
}
|
||||||
|
return append([]string{templatesRootLabel}, append([]string(nil), path...)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func templatesViewPath(path []string) []string {
|
||||||
|
if len(path) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if path[0] == templatesRootLabel {
|
||||||
|
return append([]string(nil), path[1:]...)
|
||||||
|
}
|
||||||
|
return append([]string(nil), path...)
|
||||||
|
}
|
||||||
|
|
||||||
func entriesViewPathForModel(model vault.Model, path []string) []string {
|
func entriesViewPathForModel(model vault.Model, path []string) []string {
|
||||||
if len(path) == 0 {
|
if len(path) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -590,6 +609,25 @@ func logicalEntries(entries []vault.Entry) []vault.Entry {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func logicalTemplateEntry(entry vault.Entry) vault.Entry {
|
||||||
|
entry.Path = logicalTemplatePath(entry.Path)
|
||||||
|
for i := range entry.History {
|
||||||
|
entry.History[i] = logicalTemplateEntry(entry.History[i])
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
func logicalTemplateEntries(entries []vault.Entry) []vault.Entry {
|
||||||
|
if len(entries) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]vault.Entry, len(entries))
|
||||||
|
for i := range entries {
|
||||||
|
out[i] = logicalTemplateEntry(entries[i])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func entryForModel(model vault.Model, entry vault.Entry) vault.Entry {
|
func entryForModel(model vault.Model, entry vault.Entry) vault.Entry {
|
||||||
entry.Path = entriesViewPathForModel(model, entry.Path)
|
entry.Path = entriesViewPathForModel(model, entry.Path)
|
||||||
for i := range entry.History {
|
for i := range entry.History {
|
||||||
@@ -598,6 +636,14 @@ func entryForModel(model vault.Model, entry vault.Entry) vault.Entry {
|
|||||||
return entry
|
return entry
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func templateEntryForModel(entry vault.Entry) vault.Entry {
|
||||||
|
entry.Path = templatesViewPath(entry.Path)
|
||||||
|
for i := range entry.History {
|
||||||
|
entry.History[i] = templateEntryForModel(entry.History[i])
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
func usesPhysicalEntriesRoot(model vault.Model) bool {
|
func usesPhysicalEntriesRoot(model vault.Model) bool {
|
||||||
if len(model.Entries) == 0 && len(model.Groups) == 0 && len(model.RecycleBin) == 0 {
|
if len(model.Entries) == 0 && len(model.Groups) == 0 && len(model.RecycleBin) == 0 {
|
||||||
return true
|
return true
|
||||||
@@ -663,6 +709,33 @@ func childGroups(entries []vault.Entry, path []string) []string {
|
|||||||
return groups
|
return groups
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sectionGroupView(model vault.Model, section Section) vaultview.View {
|
||||||
|
switch section {
|
||||||
|
case SectionTemplates:
|
||||||
|
return vaultview.VaultTemplates(model)
|
||||||
|
default:
|
||||||
|
return vaultview.VaultRoot(model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sectionGroupViewPath(model vault.Model, section Section, path []string) []string {
|
||||||
|
switch section {
|
||||||
|
case SectionTemplates:
|
||||||
|
return templatesViewPath(path)
|
||||||
|
default:
|
||||||
|
return entriesViewPathForModel(model, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sectionGroupLogicalPath(model vault.Model, section Section, path []string) []string {
|
||||||
|
switch section {
|
||||||
|
case SectionTemplates:
|
||||||
|
return logicalTemplatePath(path)
|
||||||
|
default:
|
||||||
|
return logicalEntriesPathForModel(model, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *State) DeleteSelectedEntry() error {
|
func (s *State) DeleteSelectedEntry() error {
|
||||||
session, ok := s.Session.(MutableSession)
|
session, ok := s.Session.(MutableSession)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -730,7 +803,7 @@ func (s *State) UpsertTemplate(entry vault.Entry) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
model.UpsertTemplate(entry)
|
model.UpsertTemplate(vaultview.VaultTemplates(model).ToPhysicalEntry(templateEntryForModel(entry)))
|
||||||
session.Replace(model)
|
session.Replace(model)
|
||||||
s.SelectedEntryID = entry.ID
|
s.SelectedEntryID = entry.ID
|
||||||
return s.markDirtyAndAutoSave()
|
return s.markDirtyAndAutoSave()
|
||||||
@@ -1124,7 +1197,8 @@ func (s *State) CreateGroup(name string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
model.CreateGroup(vaultview.VaultRoot(model).ToPhysicalPath(entriesViewPathForModel(model, s.CurrentPath)), name)
|
view := sectionGroupView(model, s.Section)
|
||||||
|
model.CreateGroup(view.ToPhysicalPath(sectionGroupViewPath(model, s.Section, s.CurrentPath)), name)
|
||||||
session.Replace(model)
|
session.Replace(model)
|
||||||
return s.markDirtyAndAutoSave()
|
return s.markDirtyAndAutoSave()
|
||||||
}
|
}
|
||||||
@@ -1138,15 +1212,16 @@ func (s *State) MoveCurrentGroup(parent []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
current := logicalEntriesPathForModel(model, s.CurrentPath)
|
view := sectionGroupView(model, s.Section)
|
||||||
currentViewPath := entriesViewPathForModel(model, current)
|
current := sectionGroupLogicalPath(model, s.Section, s.CurrentPath)
|
||||||
parentViewPath := entriesViewPathForModel(model, parent)
|
currentViewPath := sectionGroupViewPath(model, s.Section, current)
|
||||||
if err := model.MoveGroup(vaultview.VaultRoot(model).ToPhysicalPath(currentViewPath), vaultview.VaultRoot(model).ToPhysicalPath(parentViewPath)); err != nil {
|
parentViewPath := sectionGroupViewPath(model, s.Section, parent)
|
||||||
|
if err := model.MoveGroup(view.ToPhysicalPath(currentViewPath), view.ToPhysicalPath(parentViewPath)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
session.Replace(model)
|
session.Replace(model)
|
||||||
if len(currentViewPath) > 0 {
|
if len(currentViewPath) > 0 {
|
||||||
s.CurrentPath = logicalEntriesPathForModel(model, append(append([]string(nil), parentViewPath...), currentViewPath[len(currentViewPath)-1]))
|
s.CurrentPath = sectionGroupLogicalPath(model, s.Section, append(append([]string(nil), parentViewPath...), currentViewPath[len(currentViewPath)-1]))
|
||||||
}
|
}
|
||||||
return s.markDirtyAndAutoSave()
|
return s.markDirtyAndAutoSave()
|
||||||
}
|
}
|
||||||
@@ -1162,7 +1237,8 @@ func (s *State) RenameCurrentGroup(newName string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := model.RenameGroup(vaultview.VaultRoot(model).ToPhysicalPath(entriesViewPathForModel(model, s.CurrentPath)), newName); err != nil {
|
view := sectionGroupView(model, s.Section)
|
||||||
|
if err := model.RenameGroup(view.ToPhysicalPath(sectionGroupViewPath(model, s.Section, s.CurrentPath)), newName); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1203,7 +1279,8 @@ func (s *State) DeleteCurrentGroup() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := model.DeleteGroup(vaultview.VaultRoot(model).ToPhysicalPath(entriesViewPathForModel(model, s.CurrentPath))); err != nil {
|
view := sectionGroupView(model, s.Section)
|
||||||
|
if err := model.DeleteGroup(view.ToPhysicalPath(sectionGroupViewPath(model, s.Section, s.CurrentPath))); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+154
-25
@@ -7,6 +7,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const KeepassRoot = "keepass"
|
const KeepassRoot = "keepass"
|
||||||
|
const TemplatesRoot = "Templates"
|
||||||
|
|
||||||
// View projects the physical vault model into a logical tree for a specific
|
// View projects the physical vault model into a logical tree for a specific
|
||||||
// product surface.
|
// product surface.
|
||||||
@@ -28,7 +29,13 @@ func Vault(model vault.Model) View {
|
|||||||
// VaultRoot returns the logical main-vault view rooted at the physical
|
// VaultRoot returns the logical main-vault view rooted at the physical
|
||||||
// keepass storage group.
|
// keepass storage group.
|
||||||
func VaultRoot(model vault.Model) View {
|
func VaultRoot(model vault.Model) View {
|
||||||
return rootView{model: model, rooted: usesKeepassRoot(model)}
|
return prefixedView{model: model, root: KeepassRoot, rooted: usesTopLevelRoot(model, KeepassRoot)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// VaultTemplates returns the logical templates view rooted at the physical
|
||||||
|
// Templates storage group.
|
||||||
|
func VaultTemplates(model vault.Model) View {
|
||||||
|
return templatesView{model: model}
|
||||||
}
|
}
|
||||||
|
|
||||||
// VaultRecycleBin returns the logical recycle-bin view.
|
// VaultRecycleBin returns the logical recycle-bin view.
|
||||||
@@ -68,47 +75,48 @@ func (v physicalView) FromPhysicalEntry(entry vault.Entry) vault.Entry {
|
|||||||
return cloneEntry(entry)
|
return cloneEntry(entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
type rootView struct {
|
type prefixedView struct {
|
||||||
model vault.Model
|
model vault.Model
|
||||||
|
root string
|
||||||
rooted bool
|
rooted bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v rootView) ChildGroups(path []string) []string {
|
func (v prefixedView) ChildGroups(path []string) []string {
|
||||||
return v.model.ChildGroups(v.ToPhysicalPath(path))
|
return v.model.ChildGroups(v.ToPhysicalPath(path))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v rootView) EntriesInPath(path []string) []vault.Entry {
|
func (v prefixedView) EntriesInPath(path []string) []vault.Entry {
|
||||||
return v.mapEntries(v.model.EntriesInPath(v.ToPhysicalPath(path)))
|
return v.mapEntries(v.model.EntriesInPath(v.ToPhysicalPath(path)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v rootView) EntriesUnderPath(path []string) []vault.Entry {
|
func (v prefixedView) EntriesUnderPath(path []string) []vault.Entry {
|
||||||
return v.mapEntries(v.model.EntriesUnderPath(v.ToPhysicalPath(path)))
|
return v.mapEntries(v.model.EntriesUnderPath(v.ToPhysicalPath(path)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v rootView) ToPhysicalPath(path []string) []string {
|
func (v prefixedView) ToPhysicalPath(path []string) []string {
|
||||||
if !v.rooted {
|
if !v.rooted {
|
||||||
return clonePath(path)
|
return clonePath(path)
|
||||||
}
|
}
|
||||||
if len(path) == 0 {
|
if len(path) == 0 {
|
||||||
return []string{KeepassRoot}
|
return []string{v.root}
|
||||||
}
|
}
|
||||||
return append([]string{KeepassRoot}, clonePath(path)...)
|
return append([]string{v.root}, clonePath(path)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v rootView) FromPhysicalPath(path []string) []string {
|
func (v prefixedView) FromPhysicalPath(path []string) []string {
|
||||||
if !v.rooted {
|
if !v.rooted {
|
||||||
return clonePath(path)
|
return clonePath(path)
|
||||||
}
|
}
|
||||||
if len(path) == 0 {
|
if len(path) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if path[0] != KeepassRoot {
|
if path[0] != v.root {
|
||||||
return clonePath(path)
|
return clonePath(path)
|
||||||
}
|
}
|
||||||
return clonePath(path[1:])
|
return clonePath(path[1:])
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v rootView) ToPhysicalEntry(entry vault.Entry) vault.Entry {
|
func (v prefixedView) ToPhysicalEntry(entry vault.Entry) vault.Entry {
|
||||||
entry = cloneEntry(entry)
|
entry = cloneEntry(entry)
|
||||||
entry.Path = v.ToPhysicalPath(entry.Path)
|
entry.Path = v.ToPhysicalPath(entry.Path)
|
||||||
for i := range entry.History {
|
for i := range entry.History {
|
||||||
@@ -117,7 +125,7 @@ func (v rootView) ToPhysicalEntry(entry vault.Entry) vault.Entry {
|
|||||||
return entry
|
return entry
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v rootView) FromPhysicalEntry(entry vault.Entry) vault.Entry {
|
func (v prefixedView) FromPhysicalEntry(entry vault.Entry) vault.Entry {
|
||||||
entry = cloneEntry(entry)
|
entry = cloneEntry(entry)
|
||||||
entry.Path = v.FromPhysicalPath(entry.Path)
|
entry.Path = v.FromPhysicalPath(entry.Path)
|
||||||
for i := range entry.History {
|
for i := range entry.History {
|
||||||
@@ -126,7 +134,7 @@ func (v rootView) FromPhysicalEntry(entry vault.Entry) vault.Entry {
|
|||||||
return entry
|
return entry
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v rootView) mapEntries(entries []vault.Entry) []vault.Entry {
|
func (v prefixedView) mapEntries(entries []vault.Entry) []vault.Entry {
|
||||||
out := make([]vault.Entry, 0, len(entries))
|
out := make([]vault.Entry, 0, len(entries))
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
out = append(out, v.FromPhysicalEntry(entry))
|
out = append(out, v.FromPhysicalEntry(entry))
|
||||||
@@ -138,6 +146,89 @@ type recycleBinView struct {
|
|||||||
model vault.Model
|
model vault.Model
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type templatesView struct {
|
||||||
|
model vault.Model
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v templatesView) ChildGroups(path []string) []string {
|
||||||
|
return groupChildren(templateGroupPaths(v.model), v.EntriesUnderPath(nil), path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v templatesView) EntriesInPath(path []string) []vault.Entry {
|
||||||
|
return entriesInPath(v.EntriesUnderPath(nil), path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v templatesView) EntriesUnderPath(path []string) []vault.Entry {
|
||||||
|
var out []vault.Entry
|
||||||
|
for _, entry := range v.model.Templates {
|
||||||
|
if len(path) > len(entry.Path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
physical := entry.Path
|
||||||
|
if len(physical) > 0 && physical[0] == TemplatesRoot {
|
||||||
|
physical = physical[1:]
|
||||||
|
}
|
||||||
|
if len(path) > len(physical) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !slices.Equal(physical[:len(path)], path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item := cloneEntry(entry)
|
||||||
|
item.Path = clonePath(physical)
|
||||||
|
for i := range item.History {
|
||||||
|
item.History[i].Path = v.FromPhysicalPath(item.History[i].Path)
|
||||||
|
}
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
slices.SortFunc(out, func(a, b vault.Entry) int {
|
||||||
|
switch {
|
||||||
|
case a.Title < b.Title:
|
||||||
|
return -1
|
||||||
|
case a.Title > b.Title:
|
||||||
|
return 1
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v templatesView) ToPhysicalPath(path []string) []string {
|
||||||
|
if len(path) == 0 {
|
||||||
|
return []string{TemplatesRoot}
|
||||||
|
}
|
||||||
|
return append([]string{TemplatesRoot}, clonePath(path)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v templatesView) FromPhysicalPath(path []string) []string {
|
||||||
|
if len(path) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if path[0] != TemplatesRoot {
|
||||||
|
return clonePath(path)
|
||||||
|
}
|
||||||
|
return clonePath(path[1:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v templatesView) ToPhysicalEntry(entry vault.Entry) vault.Entry {
|
||||||
|
entry = cloneEntry(entry)
|
||||||
|
entry.Path = v.ToPhysicalPath(entry.Path)
|
||||||
|
for i := range entry.History {
|
||||||
|
entry.History[i].Path = v.ToPhysicalPath(entry.History[i].Path)
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v templatesView) FromPhysicalEntry(entry vault.Entry) vault.Entry {
|
||||||
|
entry = cloneEntry(entry)
|
||||||
|
entry.Path = v.FromPhysicalPath(entry.Path)
|
||||||
|
for i := range entry.History {
|
||||||
|
entry.History[i].Path = v.FromPhysicalPath(entry.History[i].Path)
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
func (v recycleBinView) ChildGroups(path []string) []string {
|
func (v recycleBinView) ChildGroups(path []string) []string {
|
||||||
return childGroups(v.model.RecycleBin, path)
|
return childGroups(v.model.RecycleBin, path)
|
||||||
}
|
}
|
||||||
@@ -187,6 +278,10 @@ func (v recycleBinView) FromPhysicalEntry(entry vault.Entry) vault.Entry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func childGroups(entries []vault.Entry, path []string) []string {
|
func childGroups(entries []vault.Entry, path []string) []string {
|
||||||
|
return groupChildren(nil, entries, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func groupChildren(groupPaths [][]string, entries []vault.Entry, path []string) []string {
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
var groups []string
|
var groups []string
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
@@ -206,6 +301,23 @@ func childGroups(entries []vault.Entry, path []string) []string {
|
|||||||
seen[group] = true
|
seen[group] = true
|
||||||
groups = append(groups, group)
|
groups = append(groups, group)
|
||||||
}
|
}
|
||||||
|
for _, groupPath := range groupPaths {
|
||||||
|
if len(path) > len(groupPath) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !slices.Equal(groupPath[:len(path)], path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(groupPath) == len(path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
group := groupPath[len(path)]
|
||||||
|
if seen[group] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[group] = true
|
||||||
|
groups = append(groups, group)
|
||||||
|
}
|
||||||
slices.Sort(groups)
|
slices.Sort(groups)
|
||||||
return groups
|
return groups
|
||||||
}
|
}
|
||||||
@@ -275,22 +387,39 @@ func clonePath(path []string) []string {
|
|||||||
return slices.Clone(path)
|
return slices.Clone(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
func usesKeepassRoot(model vault.Model) bool {
|
func templateGroupPaths(model vault.Model) [][]string {
|
||||||
if len(model.Entries) == 0 && len(model.Groups) == 0 && len(model.RecycleBin) == 0 {
|
var out [][]string
|
||||||
return true
|
|
||||||
}
|
|
||||||
for _, group := range model.Groups {
|
for _, group := range model.Groups {
|
||||||
if len(group) > 0 && group[0] == KeepassRoot {
|
if len(group) == 0 || group[0] != TemplatesRoot {
|
||||||
return true
|
continue
|
||||||
}
|
}
|
||||||
|
out = append(out, clonePath(group[1:]))
|
||||||
}
|
}
|
||||||
for _, entry := range model.Entries {
|
return out
|
||||||
if len(entry.Path) > 0 && entry.Path[0] == KeepassRoot {
|
}
|
||||||
return true
|
|
||||||
|
func usesTopLevelRoot(model vault.Model, root string) bool {
|
||||||
|
if len(model.Entries) == 0 && len(model.Groups) == 0 && len(model.RecycleBin) == 0 {
|
||||||
|
return root == KeepassRoot
|
||||||
}
|
}
|
||||||
}
|
return groupsUseRoot(model.Groups, root) ||
|
||||||
for _, entry := range model.RecycleBin {
|
entriesUseRoot(model.Entries, root) ||
|
||||||
if len(entry.Path) > 0 && entry.Path[0] == KeepassRoot {
|
entriesUseRoot(model.Templates, root) ||
|
||||||
|
entriesUseRoot(model.RecycleBin, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
func groupsUseRoot(groups [][]string, root string) bool {
|
||||||
|
for _, group := range groups {
|
||||||
|
if len(group) > 0 && group[0] == root {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func entriesUseRoot(entries []vault.Entry, root string) bool {
|
||||||
|
for _, entry := range entries {
|
||||||
|
if len(entry.Path) > 0 && entry.Path[0] == root {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,44 @@ func TestVaultRecycleBinProjectsRecycleTree(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestVaultTemplatesProjectsTemplatesStorageRoot(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
model := vault.Model{
|
||||||
|
Templates: []vault.Entry{
|
||||||
|
{ID: "website-login", Title: "Website Login", Path: []string{"Templates", "Web"}},
|
||||||
|
{ID: "ssh-login", Title: "SSH Login", Path: []string{"Templates", "Infra"}},
|
||||||
|
},
|
||||||
|
Groups: [][]string{
|
||||||
|
{"Templates"},
|
||||||
|
{"Templates", "Infra"},
|
||||||
|
{"Templates", "Web"},
|
||||||
|
{"keepass"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
view := VaultTemplates(model)
|
||||||
|
|
||||||
|
if got := view.ChildGroups(nil); !slices.Equal(got, []string{"Infra", "Web"}) {
|
||||||
|
t.Fatalf("VaultTemplates(model).ChildGroups(nil) = %v, want [Infra Web]", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotEntries := view.EntriesInPath([]string{"Web"})
|
||||||
|
if len(gotEntries) != 1 || !slices.Equal(gotEntries[0].Path, []string{"Web"}) {
|
||||||
|
t.Fatalf("VaultTemplates(model).EntriesInPath([Web]) = %#v, want logical path [Web]", gotEntries)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := view.ToPhysicalPath(nil); !slices.Equal(got, []string{"Templates"}) {
|
||||||
|
t.Fatalf("VaultTemplates(model).ToPhysicalPath(nil) = %v, want [Templates]", got)
|
||||||
|
}
|
||||||
|
if got := view.ToPhysicalPath([]string{"Web"}); !slices.Equal(got, []string{"Templates", "Web"}) {
|
||||||
|
t.Fatalf("VaultTemplates(model).ToPhysicalPath([Web]) = %v, want [Templates Web]", got)
|
||||||
|
}
|
||||||
|
if got := view.FromPhysicalPath([]string{"Templates", "Web"}); !slices.Equal(got, []string{"Web"}) {
|
||||||
|
t.Fatalf("VaultTemplates(model).FromPhysicalPath([Templates Web]) = %v, want [Web]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestVaultReturnsPhysicalPathsUnchanged(t *testing.T) {
|
func TestVaultReturnsPhysicalPathsUnchanged(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user