Standardize user lookup and improve OKF history
This commit is contained in:
parent
076f7a042a
commit
8ce36ea540
12
TODO.md
12
TODO.md
@ -4,7 +4,7 @@ This file tracks larger Lumi work that cannot safely be completed in one pass. K
|
|||||||
|
|
||||||
## OKF Knowledge System
|
## OKF Knowledge System
|
||||||
|
|
||||||
Current state on `experimental-okf` as of 2026-06-18: first basic standalone OKF plugin pass is implemented locally and not pushed. The plugin provides SQLite-backed entries, sanitized Markdown, logged-in browsing, role-gated details, admin/editor management, per-user OKF permission grants, workflow actions, and version history. File-backed OKF directories, indexing, AI prompt integration, and feedback-to-correction flow remain for later passes.
|
Current state on `experimental-okf` as of 2026-06-18: standalone OKF plugin work is implemented locally and not pushed. The plugin provides SQLite-backed entries, sanitized Markdown, logged-in browsing, role-gated details, admin/editor management, per-user OKF permission grants through the shared core user lookup, workflow actions, version history, version restore, and role-preview tooling. File-backed OKF directories, indexing, AI prompt integration, and feedback-to-correction flow remain for later passes.
|
||||||
|
|
||||||
### Implemented Locally
|
### Implemented Locally
|
||||||
|
|
||||||
@ -24,6 +24,11 @@ Current state on `experimental-okf` as of 2026-06-18: first basic standalone OKF
|
|||||||
- `edit_review_implement`
|
- `edit_review_implement`
|
||||||
- Added workflow actions for review, publish, archive, restore-as-draft, and soft delete.
|
- Added workflow actions for review, publish, archive, restore-as-draft, and soft delete.
|
||||||
- Added version snapshots for create/update/workflow changes.
|
- Added version snapshots for create/update/workflow changes.
|
||||||
|
- Added restore-from-version service/action/UI with a new audit version recorded for restores.
|
||||||
|
- Added admin role preview cards for user, moderator, and admin/editor visibility.
|
||||||
|
- Added shared core user lookup support through `/api/users/search` and `[data-user-lookup]`.
|
||||||
|
- Migrated OKF permission grants, Lumi AI access grants, moderation target fields, Economy banking transfers, and Economy balance adjustment fields to the shared user lookup.
|
||||||
|
- Fixed the moderation target internal username search by removing its page-local picker and using the shared live lookup.
|
||||||
- Added a local `global.lumiFrameworks.okf.search(...)` integration point for later AI context provider wiring.
|
- Added a local `global.lumiFrameworks.okf.search(...)` integration point for later AI context provider wiring.
|
||||||
- Added `plugins/okf/tests/verify.js`.
|
- Added `plugins/okf/tests/verify.js`.
|
||||||
|
|
||||||
@ -36,8 +41,7 @@ Current state on `experimental-okf` as of 2026-06-18: first basic standalone OKF
|
|||||||
- Add changed-file reindexing where practical.
|
- Add changed-file reindexing where practical.
|
||||||
- Enforce retrieval priority for corrections, community OKF, plugin OKF, and core OKF.
|
- Enforce retrieval priority for corrections, community OKF, plugin OKF, and core OKF.
|
||||||
- Add safe placeholder resolution for generated OKF references.
|
- Add safe placeholder resolution for generated OKF references.
|
||||||
- Add restore-from-version UI/action.
|
- Add richer category/tag management and Markdown preview/editor polish.
|
||||||
- Add richer category/tag management and role-preview tools.
|
|
||||||
- Add correction-file creation from reviewed feedback after file-backed OKF storage exists.
|
- Add correction-file creation from reviewed feedback after file-backed OKF storage exists.
|
||||||
- Add update preservation tests/notes for OKF file paths once those paths exist.
|
- Add update preservation tests/notes for OKF file paths once those paths exist.
|
||||||
|
|
||||||
@ -160,6 +164,7 @@ Current state on `experimental-okf` as of 2026-06-18: first basic standalone OKF
|
|||||||
- Ensure labels and helper text are suitable for non-technical admins without removing important admin-level specificity.
|
- Ensure labels and helper text are suitable for non-technical admins without removing important admin-level specificity.
|
||||||
- Review localization/translation keys if present so simplified wording remains consistent across languages.
|
- Review localization/translation keys if present so simplified wording remains consistent across languages.
|
||||||
- IMPORTANT: on mobile, the navbar extends the viewport vertically, meaning users can only see as far down as "log in with discord". The navbar needs to be the full height of the viewport, and ensure all contained elements are visible. Should contents overflow the viewport, integrate a very slim and minimalistic scrollbar.
|
- IMPORTANT: on mobile, the navbar extends the viewport vertically, meaning users can only see as far down as "log in with discord". The navbar needs to be the full height of the viewport, and ensure all contained elements are visible. Should contents overflow the viewport, integrate a very slim and minimalistic scrollbar.
|
||||||
|
- /plugins/moderation -> User notes should be deletable by admins
|
||||||
|
|
||||||
## Core Feedback System
|
## Core Feedback System
|
||||||
|
|
||||||
@ -575,6 +580,7 @@ Current state on `experimental-feedback-system` as of 2026-06-18: the core feedb
|
|||||||
|
|
||||||
## Done
|
## Done
|
||||||
|
|
||||||
|
- 2026-06-18: Continued `experimental-okf` locally: added shared core live user lookup, migrated OKF/Lumi AI/Moderation/Economy user-selection fields to it, fixed the moderation target search field, added OKF restore-from-version, and added OKF role-preview cards. No repo push yet.
|
||||||
- 2026-06-18: Started `experimental-okf` with a standalone OKF plugin: role-gated SQLite entries, sanitized Markdown browsing, admin/editor management, per-user OKF grants, workflow actions, version snapshots, and first verification coverage. No repo push yet.
|
- 2026-06-18: Started `experimental-okf` with a standalone OKF plugin: role-gated SQLite entries, sanitized Markdown browsing, admin/editor management, per-user OKF grants, workflow actions, version snapshots, and first verification coverage. No repo push yet.
|
||||||
- 2026-06-18: Emergency patched `/admin/navigation` so the Unassigned items pool stays sticky while editing sections on desktop, and aligned Save navigation / Reset to default actions in a shared horizontal Lumi button group.
|
- 2026-06-18: Emergency patched `/admin/navigation` so the Unassigned items pool stays sticky while editing sections on desktop, and aligned Save navigation / Reset to default actions in a shared horizontal Lumi button group.
|
||||||
- 2026-06-18: Removed feedback conversion actions from the experimental feedback system, added Finalize & Close/Reopen/Delete admin actions, made delete hard-remove feedback records/comments/notes/history/screenshots, kept admin feedback rows collapsed by default, and replaced browser tab capture screenshots with upload/clipboard-paste attachments.
|
- 2026-06-18: Removed feedback conversion actions from the experimental feedback system, added Finalize & Close/Reopen/Delete admin actions, made delete hard-remove feedback records/comments/notes/history/screenshots, kept admin feedback rows collapsed by default, and replaced browser tab capture screenshots with upload/clipboard-paste attachments.
|
||||||
|
|||||||
@ -495,16 +495,19 @@ module.exports = {
|
|||||||
if (!req.session.user || !req.session.user.isMod) {
|
if (!req.session.user || !req.session.user.isMod) {
|
||||||
return deny(res);
|
return deny(res);
|
||||||
}
|
}
|
||||||
|
const targetUserId = (req.body.target_user_id || "").trim();
|
||||||
const targetName = (req.body.username || "").trim();
|
const targetName = (req.body.username || "").trim();
|
||||||
const amount = parseSignedAmount(req.body.amount);
|
const amount = parseSignedAmount(req.body.amount);
|
||||||
if (!targetName || !Number.isFinite(amount)) {
|
if ((!targetUserId && !targetName) || !Number.isFinite(amount)) {
|
||||||
req.session.flash = {
|
req.session.flash = {
|
||||||
type: "error",
|
type: "error",
|
||||||
message: "Username and amount are required."
|
message: "Username and amount are required."
|
||||||
};
|
};
|
||||||
return res.redirect(`/plugins/${PLUGIN_ID}`);
|
return res.redirect(`/plugins/${PLUGIN_ID}`);
|
||||||
}
|
}
|
||||||
const target = findUserByInternalName(db, targetName);
|
const target = targetUserId
|
||||||
|
? db.prepare("SELECT id, internal_username FROM user_profiles WHERE id = ?").get(targetUserId)
|
||||||
|
: findUserByInternalName(db, targetName);
|
||||||
if (!target) {
|
if (!target) {
|
||||||
req.session.flash = { type: "error", message: "User not found." };
|
req.session.flash = { type: "error", message: "User not found." };
|
||||||
return res.redirect(`/plugins/${PLUGIN_ID}`);
|
return res.redirect(`/plugins/${PLUGIN_ID}`);
|
||||||
@ -626,16 +629,13 @@ module.exports = {
|
|||||||
limit: 1000
|
limit: 1000
|
||||||
});
|
});
|
||||||
const funds = listFunds(db).filter((fund) => fund.status === "active");
|
const funds = listFunds(db).filter((fund) => fund.status === "active");
|
||||||
const userDirectory = listUserDirectory(db);
|
|
||||||
|
|
||||||
res.render(path.join(__dirname, "views", "banking.ejs"), {
|
res.render(path.join(__dirname, "views", "banking.ejs"), {
|
||||||
title: config.banking.label,
|
title: config.banking.label,
|
||||||
config,
|
config,
|
||||||
user,
|
user,
|
||||||
userStats,
|
userStats,
|
||||||
transactions,
|
transactions,
|
||||||
funds,
|
funds
|
||||||
userDirectory
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -647,7 +647,7 @@ module.exports = {
|
|||||||
const targetName = (req.body.username || "").trim();
|
const targetName = (req.body.username || "").trim();
|
||||||
const amount = parseAmount(req.body.amount);
|
const amount = parseAmount(req.body.amount);
|
||||||
const note = (req.body.note || "").trim();
|
const note = (req.body.note || "").trim();
|
||||||
if (!targetName || !Number.isFinite(amount)) {
|
if ((!targetUserId && !targetName) || !Number.isFinite(amount)) {
|
||||||
req.session.flash = {
|
req.session.flash = {
|
||||||
type: "error",
|
type: "error",
|
||||||
message: "Recipient and amount are required."
|
message: "Recipient and amount are required."
|
||||||
@ -662,7 +662,9 @@ module.exports = {
|
|||||||
};
|
};
|
||||||
return res.redirect("/profile/banking");
|
return res.redirect("/profile/banking");
|
||||||
}
|
}
|
||||||
const target = findUserByInternalName(db, targetName.replace(/^@/, ""));
|
const target = targetUserId
|
||||||
|
? db.prepare("SELECT id, internal_username FROM user_profiles WHERE id = ?").get(targetUserId)
|
||||||
|
: findUserByInternalName(db, targetName.replace(/^@/, ""));
|
||||||
if (!target) {
|
if (!target) {
|
||||||
req.session.flash = { type: "error", message: "User not found." };
|
req.session.flash = { type: "error", message: "User not found." };
|
||||||
return res.redirect("/profile/banking");
|
return res.redirect("/profile/banking");
|
||||||
@ -2462,57 +2464,6 @@ function listFunds(db) {
|
|||||||
.all();
|
.all();
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatProviderLabel(provider) {
|
|
||||||
const normalized = (provider || "").toLowerCase();
|
|
||||||
const map = {
|
|
||||||
discord: "Discord",
|
|
||||||
twitch: "Twitch",
|
|
||||||
twitch_login: "Twitch",
|
|
||||||
youtube: "YouTube",
|
|
||||||
youtube_name: "YouTube",
|
|
||||||
economy_name: "Internal"
|
|
||||||
};
|
|
||||||
if (map[normalized]) {
|
|
||||||
return map[normalized];
|
|
||||||
}
|
|
||||||
if (!normalized) {
|
|
||||||
return "Account";
|
|
||||||
}
|
|
||||||
return normalized.charAt(0).toUpperCase() + normalized.slice(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function listUserDirectory(db) {
|
|
||||||
const rows = db
|
|
||||||
.prepare(
|
|
||||||
"SELECT user_profiles.id AS user_id, user_profiles.internal_username AS internal_username, " +
|
|
||||||
"user_identities.provider AS provider, user_identities.display_name AS display_name, " +
|
|
||||||
"user_identities.provider_user_id AS provider_user_id " +
|
|
||||||
"FROM user_profiles " +
|
|
||||||
"LEFT JOIN user_identities ON user_identities.user_id = user_profiles.id " +
|
|
||||||
"ORDER BY user_profiles.internal_username"
|
|
||||||
)
|
|
||||||
.all();
|
|
||||||
const map = new Map();
|
|
||||||
rows.forEach((row) => {
|
|
||||||
if (!map.has(row.user_id)) {
|
|
||||||
map.set(row.user_id, {
|
|
||||||
id: row.user_id,
|
|
||||||
internal: row.internal_username || "",
|
|
||||||
identities: []
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (row.provider) {
|
|
||||||
const display = row.display_name || row.provider_user_id || "";
|
|
||||||
map.get(row.user_id).identities.push({
|
|
||||||
provider: row.provider,
|
|
||||||
label: formatProviderLabel(row.provider),
|
|
||||||
display
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return Array.from(map.values());
|
|
||||||
}
|
|
||||||
|
|
||||||
function findFund(db, name) {
|
function findFund(db, name) {
|
||||||
return db
|
return db
|
||||||
.prepare("SELECT * FROM economy_pots WHERE lower(name) = lower(?)")
|
.prepare("SELECT * FROM economy_pots WHERE lower(name) = lower(?)")
|
||||||
|
|||||||
@ -67,73 +67,6 @@
|
|||||||
.uuid-chip:hover {
|
.uuid-chip:hover {
|
||||||
border-color: var(--ink-soft);
|
border-color: var(--ink-soft);
|
||||||
}
|
}
|
||||||
.user-search {
|
|
||||||
display: grid;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.user-results {
|
|
||||||
display: grid;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
.user-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 6px 10px;
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: var(--surface-2);
|
|
||||||
}
|
|
||||||
.user-row.is-selected {
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
.user-main {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.user-name {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.user-pills {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.user-pill {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--surface-3);
|
|
||||||
color: var(--ink-soft);
|
|
||||||
}
|
|
||||||
.user-expand {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--ink-soft);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
.user-details {
|
|
||||||
display: none;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
color: var(--ink-soft);
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
.user-row.is-open .user-details {
|
|
||||||
display: inline;
|
|
||||||
}
|
|
||||||
.user-select {
|
|
||||||
background: var(--accent);
|
|
||||||
color: var(--accent-ink);
|
|
||||||
border: none;
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 4px 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
.tx-note-details {
|
.tx-note-details {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@ -199,11 +132,13 @@
|
|||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>Transfer to another user</h2>
|
<h2>Transfer to another user</h2>
|
||||||
<form method="post" action="/profile/banking/transfer" class="form-grid">
|
<form method="post" action="/profile/banking/transfer" class="form-grid">
|
||||||
<div class="field user-search">
|
<div class="field lumi-user-lookup" data-user-lookup>
|
||||||
<label>Recipient username</label>
|
<label>Recipient username</label>
|
||||||
<input name="username" id="banking-username" placeholder="Search by username or linked account" autocomplete="off" />
|
<input type="search" name="username" placeholder="Search by username or linked account" autocomplete="off" data-user-lookup-search />
|
||||||
<div class="user-results" id="banking-results"></div>
|
<input type="hidden" name="recipient_user_id" data-user-lookup-id />
|
||||||
<span class="hint">Matches show the platform(s) where the username appears. Expand to see linked accounts.</span>
|
<div class="lumi-user-lookup-results" data-user-lookup-results hidden></div>
|
||||||
|
<div class="lumi-user-lookup-preview" data-user-lookup-preview hidden></div>
|
||||||
|
<span class="hint">Search internal usernames or linked platform accounts.</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Amount</label>
|
<label>Amount</label>
|
||||||
@ -357,125 +292,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<script>
|
|
||||||
(() => {
|
|
||||||
const users = <%- JSON.stringify(userDirectory || []) %>;
|
|
||||||
const input = document.getElementById("banking-username");
|
|
||||||
const results = document.getElementById("banking-results");
|
|
||||||
if (!input || !results) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalize = (value) => (value || "").toString().toLowerCase();
|
|
||||||
|
|
||||||
const buildMatch = (user, term) => {
|
|
||||||
const internal = user.internal || "";
|
|
||||||
const internalMatch = normalize(internal).includes(term);
|
|
||||||
const identityMatches = (user.identities || []).filter((identity) =>
|
|
||||||
normalize(identity.display).includes(term)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!term) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const display =
|
|
||||||
internalMatch ? internal : identityMatches[0]?.display || internal;
|
|
||||||
const pills = [];
|
|
||||||
if (internalMatch) {
|
|
||||||
pills.push("Internal");
|
|
||||||
}
|
|
||||||
identityMatches.forEach((identity) => {
|
|
||||||
if (!pills.includes(identity.label)) {
|
|
||||||
pills.push(identity.label);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: user.id,
|
|
||||||
display,
|
|
||||||
internal,
|
|
||||||
pills,
|
|
||||||
identities: user.identities || []
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderResults = (term) => {
|
|
||||||
results.innerHTML = "";
|
|
||||||
if (!term) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const matches = users
|
|
||||||
.map((user) => buildMatch(user, term))
|
|
||||||
.filter(Boolean)
|
|
||||||
.slice(0, 8);
|
|
||||||
|
|
||||||
if (!matches.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
matches.forEach((match) => {
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "user-row";
|
|
||||||
row.dataset.userId = match.id;
|
|
||||||
const details = match.identities
|
|
||||||
.map(
|
|
||||||
(identity) =>
|
|
||||||
`<span>${identity.label}: ${identity.display}</span>`
|
|
||||||
)
|
|
||||||
.join(" · ");
|
|
||||||
const displayText = match.display || match.internal || "";
|
|
||||||
row.innerHTML = `
|
|
||||||
<div class="user-main">
|
|
||||||
<span class="user-name">${displayText}</span>
|
|
||||||
<div class="user-pills">
|
|
||||||
${match.pills.map((pill) => `<span class="user-pill">${pill}</span>`).join("")}
|
|
||||||
</div>
|
|
||||||
${
|
|
||||||
match.identities.length
|
|
||||||
? `<button type="button" class="user-expand">Linked</button>`
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
<span class="user-details">${details}</span>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="user-select">Select</button>
|
|
||||||
`;
|
|
||||||
results.appendChild(row);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const setSelected = (value) => {
|
|
||||||
input.value = value;
|
|
||||||
results.querySelectorAll(".user-row").forEach((row) => {
|
|
||||||
row.classList.toggle(
|
|
||||||
"is-selected",
|
|
||||||
row.querySelector(".user-name")?.textContent === value
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
input.addEventListener("input", () => {
|
|
||||||
renderResults(normalize(input.value));
|
|
||||||
});
|
|
||||||
|
|
||||||
results.addEventListener("click", (event) => {
|
|
||||||
const row = event.target.closest(".user-row");
|
|
||||||
if (!row) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.target.closest(".user-expand")) {
|
|
||||||
row.classList.toggle("is-open");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.target.closest(".user-select")) {
|
|
||||||
const name = row.querySelector(".user-name")?.textContent?.trim();
|
|
||||||
if (name) {
|
|
||||||
setSelected(name);
|
|
||||||
results.innerHTML = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<%- include("../../../src/web/views/partials/layout-bottom") %>
|
<%- include("../../../src/web/views/partials/layout-bottom") %>
|
||||||
|
|||||||
@ -537,9 +537,12 @@
|
|||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>Adjust user balance</h2>
|
<h2>Adjust user balance</h2>
|
||||||
<form method="post" action="/plugins/economy-framework/accounts/adjust" class="form-grid">
|
<form method="post" action="/plugins/economy-framework/accounts/adjust" class="form-grid">
|
||||||
<div class="field">
|
<div class="field lumi-user-lookup" data-user-lookup>
|
||||||
<label>Username</label>
|
<label>Username</label>
|
||||||
<input name="username" />
|
<input type="search" name="username" autocomplete="off" placeholder="Search internal username or linked account" data-user-lookup-search />
|
||||||
|
<input type="hidden" name="target_user_id" data-user-lookup-id />
|
||||||
|
<div class="lumi-user-lookup-results" data-user-lookup-results hidden></div>
|
||||||
|
<div class="lumi-user-lookup-preview" data-user-lookup-preview hidden></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Amount (use negative to remove)</label>
|
<label>Amount (use negative to remove)</label>
|
||||||
|
|||||||
@ -266,7 +266,13 @@ function scoreRoute(route, terms) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function augmentedRoutes(index) {
|
function augmentedRoutes(index) {
|
||||||
return [...(index?.routes || [])].filter((route) => route.method === "GET");
|
return [...(index?.routes || [])].filter(isUserFacingRoute);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUserFacingRoute(route) {
|
||||||
|
if (!route || route.method !== "GET" || typeof route.path !== "string") return false;
|
||||||
|
if (!route.path.startsWith("/")) return false;
|
||||||
|
return !route.path.startsWith("/api/") && !route.path.includes("/api/");
|
||||||
}
|
}
|
||||||
|
|
||||||
function verifiedRoutePaths(index = loadIndex()) {
|
function verifiedRoutePaths(index = loadIndex()) {
|
||||||
|
|||||||
@ -728,12 +728,6 @@ module.exports = {
|
|||||||
return flash(req, res, "error", error.message);
|
return flash(req, res, "error", error.message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
router.get("/api/users/search", (req, res) => {
|
|
||||||
if (!req.session.user?.isAdmin) return res.status(403).json({ error: "Access denied." });
|
|
||||||
const query = cleanText(req.query.q, 120);
|
|
||||||
if (query.length < 2) return res.json({ users: [] });
|
|
||||||
return res.json({ users: searchKnownUsers(db, query) });
|
|
||||||
});
|
|
||||||
router.post("/models/:id/delete", (req, res) => {
|
router.post("/models/:id/delete", (req, res) => {
|
||||||
if (!req.session.user?.isAdmin) return denied(res);
|
if (!req.session.user?.isAdmin) return denied(res);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -69,10 +69,6 @@
|
|||||||
.ai-access-form { display: grid; grid-template-columns: minmax(260px, 420px) 170px 220px; align-items: end; gap: 12px; max-width: 920px; margin-top: 16px; }
|
.ai-access-form { display: grid; grid-template-columns: minmax(260px, 420px) 170px 220px; align-items: end; gap: 12px; max-width: 920px; margin-top: 16px; }
|
||||||
.ai-access-form .field { min-width: 0; }
|
.ai-access-form .field { min-width: 0; }
|
||||||
.ai-user-picker { position: relative; grid-column: span 2; }
|
.ai-user-picker { position: relative; grid-column: span 2; }
|
||||||
.ai-user-results { position: absolute; z-index: 8; top: 100%; left: 0; right: 0; max-height: 240px; overflow: auto; border: 1px solid var(--border); background: var(--card); box-shadow: 0 8px 22px rgb(0 0 0 / 16%); }
|
|
||||||
.ai-user-result { display: block; width: 100%; padding: 9px 10px; border: 0; border-bottom: 1px solid var(--border); background: transparent; color: var(--ink); text-align: left; cursor: pointer; }
|
|
||||||
.ai-user-result:hover, .ai-user-result:focus { background: var(--surface-2); }
|
|
||||||
.ai-user-preview { margin-top: 6px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface-2); color: var(--ink-soft); font-size: 12px; overflow-wrap: anywhere; }
|
|
||||||
.ai-fieldset { display: flex; flex-wrap: wrap; gap: 10px 20px; margin: 0; padding: 12px; border: 1px solid var(--border); border-radius: 7px; }
|
.ai-fieldset { display: flex; flex-wrap: wrap; gap: 10px 20px; margin: 0; padding: 12px; border: 1px solid var(--border); border-radius: 7px; }
|
||||||
.ai-fieldset legend { padding: 0 5px; font-weight: 700; }
|
.ai-fieldset legend { padding: 0 5px; font-weight: 700; }
|
||||||
.ai-fieldset label { display: flex; align-items: center; gap: 6px; }
|
.ai-fieldset label { display: flex; align-items: center; gap: 6px; }
|
||||||
|
|||||||
@ -215,13 +215,8 @@
|
|||||||
refreshCapacity();
|
refreshCapacity();
|
||||||
}
|
}
|
||||||
if (accessForm) {
|
if (accessForm) {
|
||||||
const search = accessForm.querySelector("[data-user-search]");
|
|
||||||
const userId = accessForm.querySelector("[data-user-id]");
|
|
||||||
const results = accessForm.querySelector("[data-user-results]");
|
|
||||||
const preview = accessForm.querySelector("[data-user-preview]");
|
|
||||||
const action = accessForm.querySelector("[data-access-action]");
|
const action = accessForm.querySelector("[data-access-action]");
|
||||||
const timeoutField = accessForm.querySelector("[data-timeout-field]");
|
const timeoutField = accessForm.querySelector("[data-timeout-field]");
|
||||||
let searchTimer = null;
|
|
||||||
|
|
||||||
const updateTimeoutVisibility = () => {
|
const updateTimeoutVisibility = () => {
|
||||||
const visible = action.value === "timeout";
|
const visible = action.value === "timeout";
|
||||||
@ -230,57 +225,6 @@
|
|||||||
input.required = visible;
|
input.required = visible;
|
||||||
if (!visible) input.value = "";
|
if (!visible) input.value = "";
|
||||||
};
|
};
|
||||||
const selectUser = (user) => {
|
|
||||||
userId.value = user.id;
|
|
||||||
search.value = user.username;
|
|
||||||
const identities = user.identities.map((identity) =>
|
|
||||||
`${identity.provider}: ${identity.display_name || identity.provider_user_id}`).join(" | ");
|
|
||||||
preview.textContent = `${user.username} | ${user.id}${identities ? ` | ${identities}` : ""}`;
|
|
||||||
preview.hidden = false;
|
|
||||||
results.hidden = true;
|
|
||||||
};
|
|
||||||
const renderUsers = (users) => {
|
|
||||||
results.replaceChildren();
|
|
||||||
for (const user of users) {
|
|
||||||
const button = document.createElement("button");
|
|
||||||
button.type = "button";
|
|
||||||
button.className = "ai-user-result";
|
|
||||||
const identity = user.identities[0];
|
|
||||||
button.textContent = identity
|
|
||||||
? `${user.username} | ${identity.display_name || identity.provider_user_id} (${identity.provider})`
|
|
||||||
: `${user.username} | ${user.id}`;
|
|
||||||
button.addEventListener("click", () => selectUser(user));
|
|
||||||
results.append(button);
|
|
||||||
}
|
|
||||||
results.hidden = !users.length;
|
|
||||||
};
|
|
||||||
search.addEventListener("input", () => {
|
|
||||||
userId.value = "";
|
|
||||||
preview.hidden = true;
|
|
||||||
window.clearTimeout(searchTimer);
|
|
||||||
const query = search.value.trim();
|
|
||||||
if (query.length < 2) {
|
|
||||||
results.hidden = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
searchTimer = window.setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/plugins/lumi_ai/api/users/search?q=${encodeURIComponent(query)}`, { cache: "no-store" });
|
|
||||||
const data = await response.json();
|
|
||||||
renderUsers(response.ok ? data.users || [] : []);
|
|
||||||
} catch {
|
|
||||||
renderUsers([]);
|
|
||||||
}
|
|
||||||
}, 180);
|
|
||||||
});
|
|
||||||
accessForm.addEventListener("submit", (event) => {
|
|
||||||
if (!userId.value) {
|
|
||||||
event.preventDefault();
|
|
||||||
search.setCustomValidity("Select a known Lumi user.");
|
|
||||||
search.reportValidity();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
search.addEventListener("input", () => search.setCustomValidity(""));
|
|
||||||
action.addEventListener("change", updateTimeoutVisibility);
|
action.addEventListener("change", updateTimeoutVisibility);
|
||||||
updateTimeoutVisibility();
|
updateTimeoutVisibility();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1369,7 +1369,8 @@ async function run() {
|
|||||||
const assistantStyles = fs.readFileSync(require("path").join(PLUGIN_ROOT, "public", "assistant.css"), "utf8");
|
const assistantStyles = fs.readFileSync(require("path").join(PLUGIN_ROOT, "public", "assistant.css"), "utf8");
|
||||||
const assistantPanel = fs.readFileSync(require("path").join(PLUGIN_ROOT, "views", "assistant-panel.ejs"), "utf8");
|
const assistantPanel = fs.readFileSync(require("path").join(PLUGIN_ROOT, "views", "assistant-panel.ejs"), "utf8");
|
||||||
const pluginIndex = fs.readFileSync(require("path").join(PLUGIN_ROOT, "index.js"), "utf8");
|
const pluginIndex = fs.readFileSync(require("path").join(PLUGIN_ROOT, "index.js"), "utf8");
|
||||||
assert(accessSettingsTemplate.includes("data-user-search"));
|
assert(accessSettingsTemplate.includes("data-user-lookup"));
|
||||||
|
assert(accessSettingsTemplate.includes("data-user-lookup-search"));
|
||||||
assert(accessSettingsTemplate.includes("data-timeout-field hidden"));
|
assert(accessSettingsTemplate.includes("data-timeout-field hidden"));
|
||||||
assert(settingsScript.includes('action.value === "timeout"'));
|
assert(settingsScript.includes('action.value === "timeout"'));
|
||||||
assert(assistantScript.includes("window.innerHeight / 6"));
|
assert(assistantScript.includes("window.innerHeight / 6"));
|
||||||
|
|||||||
@ -498,12 +498,12 @@
|
|||||||
<section class="ai-band" id="ai-access">
|
<section class="ai-band" id="ai-access">
|
||||||
<div class="ai-section-heading"><div><h2>User AI access</h2><p>Bans and timeouts apply to WebUI and platform commands.</p></div></div>
|
<div class="ai-section-heading"><div><h2>User AI access</h2><p>Bans and timeouts apply to WebUI and platform commands.</p></div></div>
|
||||||
<form method="post" action="/plugins/lumi_ai/access-control" class="ai-access-form" data-ai-access-form>
|
<form method="post" action="/plugins/lumi_ai/access-control" class="ai-access-form" data-ai-access-form>
|
||||||
<div class="field ai-user-picker">
|
<div class="field ai-user-picker lumi-user-lookup" data-user-lookup>
|
||||||
<label>User</label>
|
<label>User</label>
|
||||||
<input type="search" autocomplete="off" placeholder="Search name, username, platform ID, or user ID" data-user-search />
|
<input type="search" autocomplete="off" placeholder="Search name, username, platform ID, or user ID" data-user-lookup-search />
|
||||||
<input type="hidden" name="user_id" required data-user-id />
|
<input type="hidden" name="user_id" required data-user-lookup-id />
|
||||||
<div class="ai-user-results" data-user-results hidden></div>
|
<div class="lumi-user-lookup-results" data-user-lookup-results hidden></div>
|
||||||
<div class="ai-user-preview" data-user-preview hidden></div>
|
<div class="lumi-user-lookup-preview" data-user-lookup-preview hidden></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field"><label>Action</label><select name="action" data-access-action><option value="ban">Ban</option><option value="timeout">Timeout</option><option value="remove">Remove restriction</option></select></div>
|
<div class="field"><label>Action</label><select name="action" data-access-action><option value="ban">Ban</option><option value="timeout">Timeout</option><option value="remove">Remove restriction</option></select></div>
|
||||||
<div class="field" data-timeout-field hidden><label>Timeout until</label><input type="datetime-local" name="timeout_until" /></div>
|
<div class="field" data-timeout-field hidden><label>Timeout until</label><input type="datetime-local" name="timeout_until" /></div>
|
||||||
|
|||||||
@ -81,7 +81,6 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
const isAdmin = Boolean(req.session.user?.isAdmin);
|
const isAdmin = Boolean(req.session.user?.isAdmin);
|
||||||
const isMod = Boolean(req.session.user?.isAdmin || req.session.user?.isMod);
|
const isMod = Boolean(req.session.user?.isAdmin || req.session.user?.isMod);
|
||||||
const userDirectory = listUserDirectory(db);
|
|
||||||
const actions = listActions(db, { limit: 500 });
|
const actions = listActions(db, { limit: 500 });
|
||||||
const actionEvidence = listEvidenceForActions(
|
const actionEvidence = listEvidenceForActions(
|
||||||
db,
|
db,
|
||||||
@ -94,7 +93,6 @@ module.exports = {
|
|||||||
title: "Moderation Center",
|
title: "Moderation Center",
|
||||||
isAdmin,
|
isAdmin,
|
||||||
isMod,
|
isMod,
|
||||||
userDirectory,
|
|
||||||
actions,
|
actions,
|
||||||
actionEvidence,
|
actionEvidence,
|
||||||
notes,
|
notes,
|
||||||
@ -409,29 +407,18 @@ function ensureBanPot(db) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function listUserDirectory(db) {
|
|
||||||
const users = db.prepare("SELECT id, internal_username FROM user_profiles ORDER BY internal_username").all();
|
|
||||||
const identities = db
|
|
||||||
.prepare("SELECT user_id, provider, provider_user_id, display_name FROM user_identities")
|
|
||||||
.all();
|
|
||||||
const map = new Map();
|
|
||||||
users.forEach((user) => {
|
|
||||||
map.set(user.id, { id: user.id, internal: user.internal_username, identities: [] });
|
|
||||||
});
|
|
||||||
identities.forEach((row) => {
|
|
||||||
if (!map.has(row.user_id)) {
|
|
||||||
map.set(row.user_id, { id: row.user_id, internal: row.user_id, identities: [] });
|
|
||||||
}
|
|
||||||
map.get(row.user_id).identities.push({
|
|
||||||
label: row.provider,
|
|
||||||
id: row.provider_user_id,
|
|
||||||
display: row.display_name || row.provider_user_id
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return Array.from(map.values());
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveTarget(db, body) {
|
function resolveTarget(db, body) {
|
||||||
|
const selectedUserId = (body.target_user_id || "").trim();
|
||||||
|
if (selectedUserId) {
|
||||||
|
const user = db
|
||||||
|
.prepare("SELECT id, internal_username FROM user_profiles WHERE id = ?")
|
||||||
|
.get(selectedUserId);
|
||||||
|
if (user) {
|
||||||
|
const subjectId = getOrCreateSubjectByUser(db, user.id, user.internal_username);
|
||||||
|
syncSubjectIdentities(db, subjectId, user.id);
|
||||||
|
return { subjectId, internalUserId: user.id };
|
||||||
|
}
|
||||||
|
}
|
||||||
const internal = (body.target_username || "").trim();
|
const internal = (body.target_username || "").trim();
|
||||||
if (internal) {
|
if (internal) {
|
||||||
const user = db
|
const user = db
|
||||||
|
|||||||
@ -117,73 +117,6 @@
|
|||||||
.ban-pot strong {
|
.ban-pot strong {
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
}
|
}
|
||||||
.user-search {
|
|
||||||
display: grid;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.user-results {
|
|
||||||
display: grid;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
.user-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 6px 10px;
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: var(--surface-3);
|
|
||||||
}
|
|
||||||
.user-row.is-selected {
|
|
||||||
border-color: var(--sea);
|
|
||||||
}
|
|
||||||
.user-main {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.user-name {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.user-pills {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.user-pill {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--surface-3);
|
|
||||||
color: var(--ink-soft);
|
|
||||||
}
|
|
||||||
.user-expand {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--ink-soft);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
.user-details {
|
|
||||||
display: none;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
color: var(--ink-soft);
|
|
||||||
margin-left: 4px;
|
|
||||||
}
|
|
||||||
.user-row.is-open .user-details {
|
|
||||||
display: inline;
|
|
||||||
}
|
|
||||||
.user-select {
|
|
||||||
background: var(--sea);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 4px 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
@ -206,10 +139,12 @@
|
|||||||
</summary>
|
</summary>
|
||||||
<div class="moderation-row-body">
|
<div class="moderation-row-body">
|
||||||
<form method="post" action="/plugins/moderation/actions" enctype="multipart/form-data" class="form-grid" data-duration-group>
|
<form method="post" action="/plugins/moderation/actions" enctype="multipart/form-data" class="form-grid" data-duration-group>
|
||||||
<div class="field full user-search">
|
<div class="field full lumi-user-lookup" data-user-lookup>
|
||||||
<label>Target internal username (optional)</label>
|
<label>Target internal username (optional)</label>
|
||||||
<input name="target_username" id="moderation-target-username" placeholder="ookamikuntv" autocomplete="off" />
|
<input type="search" name="target_username" placeholder="Search internal username or linked account" autocomplete="off" data-user-lookup-search />
|
||||||
<div class="user-results" id="moderation-target-results"></div>
|
<input type="hidden" name="target_user_id" data-user-lookup-id />
|
||||||
|
<div class="lumi-user-lookup-results" data-user-lookup-results hidden></div>
|
||||||
|
<div class="lumi-user-lookup-preview" data-user-lookup-preview hidden></div>
|
||||||
<span class="hint">Search internal usernames or linked accounts.</span>
|
<span class="hint">Search internal usernames or linked accounts.</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@ -394,10 +329,12 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="/plugins/moderation/notes" class="form-grid">
|
<form method="post" action="/plugins/moderation/notes" class="form-grid">
|
||||||
<div class="field full user-search">
|
<div class="field full lumi-user-lookup" data-user-lookup>
|
||||||
<label>Target internal username (optional)</label>
|
<label>Target internal username (optional)</label>
|
||||||
<input name="target_username" id="moderation-note-username" placeholder="ookamikuntv" autocomplete="off" />
|
<input type="search" name="target_username" placeholder="Search internal username or linked account" autocomplete="off" data-user-lookup-search />
|
||||||
<div class="user-results" id="moderation-note-results"></div>
|
<input type="hidden" name="target_user_id" data-user-lookup-id />
|
||||||
|
<div class="lumi-user-lookup-results" data-user-lookup-results hidden></div>
|
||||||
|
<div class="lumi-user-lookup-preview" data-user-lookup-preview hidden></div>
|
||||||
<span class="hint">Search by internal username or linked account.</span>
|
<span class="hint">Search by internal username or linked account.</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@ -446,112 +383,6 @@
|
|||||||
|
|
||||||
document.querySelectorAll("[data-duration-group]").forEach(attachDurationToggle);
|
document.querySelectorAll("[data-duration-group]").forEach(attachDurationToggle);
|
||||||
|
|
||||||
const users = <%- JSON.stringify(userDirectory || []) %>;
|
|
||||||
const normalize = (value) => (value || "").toString().toLowerCase();
|
|
||||||
|
|
||||||
const buildMatch = (user, term) => {
|
|
||||||
const internal = user.internal || "";
|
|
||||||
const internalMatch = normalize(internal).includes(term);
|
|
||||||
const identityMatches = (user.identities || []).filter((identity) =>
|
|
||||||
normalize(identity.display).includes(term)
|
|
||||||
);
|
|
||||||
if (!term) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const display = internalMatch ? internal : identityMatches[0]?.display || internal;
|
|
||||||
const pills = [];
|
|
||||||
if (internalMatch) {
|
|
||||||
pills.push("Internal");
|
|
||||||
}
|
|
||||||
identityMatches.forEach((identity) => {
|
|
||||||
if (!pills.includes(identity.label)) {
|
|
||||||
pills.push(identity.label);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
id: user.id,
|
|
||||||
display,
|
|
||||||
internal,
|
|
||||||
pills,
|
|
||||||
identities: user.identities || []
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const bindUserLookup = ({ input, results }) => {
|
|
||||||
if (!input || !results) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const renderResults = (term) => {
|
|
||||||
results.innerHTML = "";
|
|
||||||
if (!term) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const matches = users
|
|
||||||
.map((user) => buildMatch(user, term))
|
|
||||||
.filter(Boolean)
|
|
||||||
.slice(0, 8);
|
|
||||||
if (!matches.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
matches.forEach((match) => {
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "user-row";
|
|
||||||
row.dataset.userId = match.id;
|
|
||||||
row.dataset.internal = match.internal;
|
|
||||||
const details = match.identities
|
|
||||||
.map((identity) => `${identity.label}: ${identity.display}`)
|
|
||||||
.join(" · ");
|
|
||||||
const internalLabel = match.internal && match.internal !== match.display
|
|
||||||
? `Internal: ${match.internal}`
|
|
||||||
: "";
|
|
||||||
row.innerHTML = `
|
|
||||||
<div class="user-main">
|
|
||||||
<span class="user-name">${match.display || match.internal || ""}</span>
|
|
||||||
<div class="user-pills">
|
|
||||||
${match.pills.map((pill) => `<span class="user-pill">${pill}</span>`).join("")}
|
|
||||||
</div>
|
|
||||||
${match.identities.length ? '<button type="button" class="user-expand">Linked</button>' : ""}
|
|
||||||
<span class="user-details">${[internalLabel, details].filter(Boolean).join(" · ")}</span>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="user-select">Select</button>
|
|
||||||
`;
|
|
||||||
results.appendChild(row);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
input.addEventListener("input", () => {
|
|
||||||
renderResults(normalize(input.value));
|
|
||||||
});
|
|
||||||
|
|
||||||
results.addEventListener("click", (event) => {
|
|
||||||
const row = event.target.closest(".user-row");
|
|
||||||
if (!row) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.target.closest(".user-expand")) {
|
|
||||||
row.classList.toggle("is-open");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.target.closest(".user-select")) {
|
|
||||||
const internal = row.dataset.internal;
|
|
||||||
if (internal) {
|
|
||||||
input.value = internal;
|
|
||||||
results.innerHTML = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
bindUserLookup({
|
|
||||||
input: document.getElementById("moderation-target-username"),
|
|
||||||
results: document.getElementById("moderation-target-results")
|
|
||||||
});
|
|
||||||
|
|
||||||
bindUserLookup({
|
|
||||||
input: document.getElementById("moderation-note-username"),
|
|
||||||
results: document.getElementById("moderation-note-results")
|
|
||||||
});
|
|
||||||
|
|
||||||
const modal = document.querySelector("[data-note-modal]");
|
const modal = document.querySelector("[data-note-modal]");
|
||||||
const openButton = document.querySelector("[data-note-open]");
|
const openButton = document.querySelector("[data-note-open]");
|
||||||
const closeButtons = modal?.querySelectorAll("[data-note-close]") || [];
|
const closeButtons = modal?.querySelectorAll("[data-note-close]") || [];
|
||||||
|
|||||||
@ -265,6 +265,59 @@ function listVersions(db, entryId) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function restoreVersion(db, entryId, versionNumber, actor, note = "") {
|
||||||
|
const access = accessForUser(db, actor);
|
||||||
|
if (!access.canImplement) throw new Error("OKF restore permission is required.");
|
||||||
|
const current = db.prepare("SELECT * FROM okf_entries WHERE id = ? AND deleted_at IS NULL").get(entryId);
|
||||||
|
if (!current) throw new Error("OKF entry was not found.");
|
||||||
|
const version = db
|
||||||
|
.prepare("SELECT * FROM okf_versions WHERE entry_id = ? AND version_number = ?")
|
||||||
|
.get(entryId, Number(versionNumber));
|
||||||
|
const snapshot = parseJsonObject(version?.next_json);
|
||||||
|
if (!version || !snapshot.title || !snapshot.slug) {
|
||||||
|
throw new Error("OKF version snapshot is not restorable.");
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
const restored = {
|
||||||
|
...current,
|
||||||
|
slug: normalizeSlug(snapshot.slug),
|
||||||
|
title: cleanText(snapshot.title, 180),
|
||||||
|
category: cleanText(snapshot.category, 120),
|
||||||
|
tags_json: JSON.stringify(splitList(snapshot.tags || [])),
|
||||||
|
aliases_json: JSON.stringify(splitList(snapshot.aliases || [])),
|
||||||
|
summary: cleanText(snapshot.summary, 800),
|
||||||
|
user_markdown: cleanText(snapshot.user_markdown, 24000),
|
||||||
|
moderator_markdown: cleanText(snapshot.moderator_markdown, 24000),
|
||||||
|
admin_markdown: cleanText(snapshot.admin_markdown, 24000),
|
||||||
|
ai_facts_markdown: cleanText(snapshot.ai_facts_markdown, 24000),
|
||||||
|
source_links_json: JSON.stringify(cleanLinkList(snapshot.source_links || [])),
|
||||||
|
visibility: normalizeChoice(snapshot.visibility, VISIBILITY_VALUES, "user"),
|
||||||
|
status: normalizeChoice(snapshot.status, STATUS_VALUES, "draft"),
|
||||||
|
review_state: normalizeChoice(snapshot.review_state, REVIEW_STATES, "draft"),
|
||||||
|
updated_by: String(actor.id),
|
||||||
|
updated_at: now,
|
||||||
|
reviewed_by: snapshot.review_state === "approved" ? current.reviewed_by : null,
|
||||||
|
published_by: snapshot.status === "published" ? current.published_by : null,
|
||||||
|
reviewed_at: snapshot.review_state === "approved" ? current.reviewed_at : null,
|
||||||
|
published_at: snapshot.status === "published" ? current.published_at : null,
|
||||||
|
archived_at: snapshot.status === "archived" ? (current.archived_at || now) : null,
|
||||||
|
deleted_at: null
|
||||||
|
};
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE okf_entries SET slug = @slug, title = @title, category = @category, tags_json = @tags_json, aliases_json = @aliases_json, summary = @summary, user_markdown = @user_markdown, moderator_markdown = @moderator_markdown, admin_markdown = @admin_markdown, ai_facts_markdown = @ai_facts_markdown, source_links_json = @source_links_json, visibility = @visibility, status = @status, review_state = @review_state, updated_by = @updated_by, updated_at = @updated_at, reviewed_by = @reviewed_by, published_by = @published_by, reviewed_at = @reviewed_at, published_at = @published_at, archived_at = @archived_at, deleted_at = @deleted_at WHERE id = @id"
|
||||||
|
).run(restored);
|
||||||
|
addVersion(
|
||||||
|
db,
|
||||||
|
restored.id,
|
||||||
|
"restore_version",
|
||||||
|
current,
|
||||||
|
restored,
|
||||||
|
actor,
|
||||||
|
note || `Restored version ${Number(versionNumber)}.`
|
||||||
|
);
|
||||||
|
return normalizeEntry(restored);
|
||||||
|
}
|
||||||
|
|
||||||
function grantPermission(db, values, actor) {
|
function grantPermission(db, values, actor) {
|
||||||
if (!actor?.isAdmin) throw new Error("Only admins can grant OKF permissions.");
|
if (!actor?.isAdmin) throw new Error("Only admins can grant OKF permissions.");
|
||||||
const userId = String(values.user_id || "").trim();
|
const userId = String(values.user_id || "").trim();
|
||||||
@ -295,10 +348,6 @@ function listPermissions(db) {
|
|||||||
.all();
|
.all();
|
||||||
}
|
}
|
||||||
|
|
||||||
function listUsers(db) {
|
|
||||||
return db.prepare("SELECT id, internal_username FROM user_profiles ORDER BY internal_username COLLATE NOCASE").all();
|
|
||||||
}
|
|
||||||
|
|
||||||
function searchForAi(db, query, user, limit = 5) {
|
function searchForAi(db, query, user, limit = 5) {
|
||||||
const access = accessForUser(db, user);
|
const access = accessForUser(db, user);
|
||||||
if (!access.authenticated) return [];
|
if (!access.authenticated) return [];
|
||||||
@ -483,10 +532,10 @@ module.exports = {
|
|||||||
grantPermission,
|
grantPermission,
|
||||||
listEntries,
|
listEntries,
|
||||||
listPermissions,
|
listPermissions,
|
||||||
listUsers,
|
|
||||||
listVersions,
|
listVersions,
|
||||||
okfPermissionForUser,
|
okfPermissionForUser,
|
||||||
revokePermission,
|
revokePermission,
|
||||||
|
restoreVersion,
|
||||||
searchForAi,
|
searchForAi,
|
||||||
setEntryWorkflow,
|
setEntryWorkflow,
|
||||||
updateEntry
|
updateEntry
|
||||||
|
|||||||
@ -12,9 +12,9 @@ const {
|
|||||||
grantPermission,
|
grantPermission,
|
||||||
listEntries,
|
listEntries,
|
||||||
listPermissions,
|
listPermissions,
|
||||||
listUsers,
|
|
||||||
listVersions,
|
listVersions,
|
||||||
revokePermission,
|
revokePermission,
|
||||||
|
restoreVersion,
|
||||||
searchForAi,
|
searchForAi,
|
||||||
setEntryWorkflow,
|
setEntryWorkflow,
|
||||||
updateEntry
|
updateEntry
|
||||||
@ -95,6 +95,25 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post("/admin/entries/:slug/versions/:version/restore", requireOkfManagement(db), (req, res) => {
|
||||||
|
try {
|
||||||
|
const selected = getEditableEntry(db, req.params.slug);
|
||||||
|
if (!selected) throw new Error("OKF entry was not found.");
|
||||||
|
const entry = restoreVersion(
|
||||||
|
db,
|
||||||
|
selected.id,
|
||||||
|
req.params.version,
|
||||||
|
req.session.user,
|
||||||
|
req.body.note || ""
|
||||||
|
);
|
||||||
|
req.session.flash = { type: "success", message: "OKF version restored." };
|
||||||
|
res.redirect(`/plugins/${PLUGIN_ID}/admin?edit=${encodeURIComponent(entry.slug)}`);
|
||||||
|
} catch (error) {
|
||||||
|
req.session.flash = { type: "error", message: error.message };
|
||||||
|
res.redirect(`/plugins/${PLUGIN_ID}/admin?edit=${encodeURIComponent(req.params.slug)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.post("/admin/permissions", requireAdmin, (req, res) => {
|
router.post("/admin/permissions", requireAdmin, (req, res) => {
|
||||||
try {
|
try {
|
||||||
grantPermission(db, req.body, req.session.user);
|
grantPermission(db, req.body, req.session.user);
|
||||||
@ -165,7 +184,6 @@ function renderAdmin(req, res, db) {
|
|||||||
selected,
|
selected,
|
||||||
versions,
|
versions,
|
||||||
permissions: listPermissions(db),
|
permissions: listPermissions(db),
|
||||||
users: listUsers(db),
|
|
||||||
levels: PERMISSION_LEVELS,
|
levels: PERMISSION_LEVELS,
|
||||||
statuses: STATUS_VALUES,
|
statuses: STATUS_VALUES,
|
||||||
visibilityValues: VISIBILITY_VALUES,
|
visibilityValues: VISIBILITY_VALUES,
|
||||||
|
|||||||
@ -10,6 +10,7 @@ const {
|
|||||||
listPermissions,
|
listPermissions,
|
||||||
listVersions,
|
listVersions,
|
||||||
revokePermission,
|
revokePermission,
|
||||||
|
restoreVersion,
|
||||||
searchForAi,
|
searchForAi,
|
||||||
setEntryWorkflow,
|
setEntryWorkflow,
|
||||||
updateEntry
|
updateEntry
|
||||||
@ -86,9 +87,14 @@ updateEntry(db, "draft-from-editor", {
|
|||||||
}, editor);
|
}, editor);
|
||||||
assert.equal(listVersions(db, proposed.id).length, 2);
|
assert.equal(listVersions(db, proposed.id).length, 2);
|
||||||
|
|
||||||
|
const restored = restoreVersion(db, proposed.id, 1, admin, "Test restore.");
|
||||||
|
assert.equal(restored.summary, "Editor draft");
|
||||||
|
assert.equal(restored.user_markdown, "Draft content.");
|
||||||
|
assert.equal(listVersions(db, proposed.id).length, 3);
|
||||||
|
|
||||||
setEntryWorkflow(db, "draft-from-editor", "review", admin);
|
setEntryWorkflow(db, "draft-from-editor", "review", admin);
|
||||||
setEntryWorkflow(db, "draft-from-editor", "publish", admin);
|
setEntryWorkflow(db, "draft-from-editor", "publish", admin);
|
||||||
assert.equal(getEntryBySlug(db, "draft-from-editor", user).summary, "Updated editor draft");
|
assert.equal(getEntryBySlug(db, "draft-from-editor", user).summary, "Editor draft");
|
||||||
|
|
||||||
assert(searchForAi(db, "currency", mod).some((item) => item.slug === "currency-basics"));
|
assert(searchForAi(db, "currency", mod).some((item) => item.slug === "currency-basics"));
|
||||||
assert.equal(searchForAi(db, "admin-only economy", user).length, 0);
|
assert.equal(searchForAi(db, "admin-only economy", user).length, 0);
|
||||||
|
|||||||
@ -174,6 +174,34 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<% if (selected) { %>
|
<% if (selected) { %>
|
||||||
|
<details class="feedback-metadata">
|
||||||
|
<summary>Role preview</summary>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<article class="stat-card">
|
||||||
|
<span class="stat-label">User view</span>
|
||||||
|
<strong><%= selected.title %></strong>
|
||||||
|
<p><%= selected.summary || "No summary provided." %></p>
|
||||||
|
<div class="feedback-copy-block"><%- renderMarkdown(selected.user_markdown || "_No user-facing answer yet._") %></div>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card">
|
||||||
|
<span class="stat-label">Moderator view</span>
|
||||||
|
<strong><%= selected.title %></strong>
|
||||||
|
<p><%= selected.summary || "No summary provided." %></p>
|
||||||
|
<div class="feedback-copy-block"><%- renderMarkdown(selected.user_markdown || "_No user-facing answer yet._") %></div>
|
||||||
|
<div class="feedback-copy-block"><%- renderMarkdown(selected.moderator_markdown || "_No moderator/support details yet._") %></div>
|
||||||
|
</article>
|
||||||
|
<article class="stat-card">
|
||||||
|
<span class="stat-label">Admin/editor view</span>
|
||||||
|
<strong><%= selected.title %></strong>
|
||||||
|
<p><%= selected.summary || "No summary provided." %></p>
|
||||||
|
<div class="feedback-copy-block"><%- renderMarkdown(selected.user_markdown || "_No user-facing answer yet._") %></div>
|
||||||
|
<div class="feedback-copy-block"><%- renderMarkdown(selected.moderator_markdown || "_No moderator/support details yet._") %></div>
|
||||||
|
<div class="feedback-copy-block"><%- renderMarkdown(selected.admin_markdown || "_No admin/internal details yet._") %></div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<p class="hint">AI facts are stored separately for future role-aware retrieval and are not shown in normal user/mod previews.</p>
|
||||||
|
</details>
|
||||||
|
|
||||||
<div class="button-group centered">
|
<div class="button-group centered">
|
||||||
<% if (okfAccess.canReview) { %>
|
<% if (okfAccess.canReview) { %>
|
||||||
<form method="post" action="/plugins/okf/admin/entries/<%= selected.slug %>/review" class="inline-form">
|
<form method="post" action="/plugins/okf/admin/entries/<%= selected.slug %>/review" class="inline-form">
|
||||||
@ -209,6 +237,12 @@
|
|||||||
<summary>Snapshot</summary>
|
<summary>Snapshot</summary>
|
||||||
<pre><%= JSON.stringify(version.next, null, 2) %></pre>
|
<pre><%= JSON.stringify(version.next, null, 2) %></pre>
|
||||||
</details>
|
</details>
|
||||||
|
<% if (okfAccess.canImplement) { %>
|
||||||
|
<form method="post" action="/plugins/okf/admin/entries/<%= selected.slug %>/versions/<%= version.version_number %>/restore" class="inline-form" data-confirm-mode="modal" data-confirm-text="Restore this OKF entry to version <%= version.version_number %>? A new version will be recorded for the restore.">
|
||||||
|
<input type="hidden" name="note" value="Restored from version <%= version.version_number %>." />
|
||||||
|
<button class="button subtle" type="submit">Restore this version</button>
|
||||||
|
</form>
|
||||||
|
<% } %>
|
||||||
</article>
|
</article>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
<% } %>
|
<% } %>
|
||||||
@ -225,14 +259,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="/plugins/okf/admin/permissions" class="form-grid">
|
<form method="post" action="/plugins/okf/admin/permissions" class="form-grid">
|
||||||
<div class="field">
|
<div class="field lumi-user-lookup" data-user-lookup>
|
||||||
<label>User</label>
|
<label>User</label>
|
||||||
<select name="user_id" required>
|
<input type="search" autocomplete="off" placeholder="Search name, username, platform ID, or user ID" data-user-lookup-search />
|
||||||
<option value="">Select user</option>
|
<input type="hidden" name="user_id" required data-user-lookup-id />
|
||||||
<% users.forEach((user) => { %>
|
<div class="lumi-user-lookup-results" data-user-lookup-results hidden></div>
|
||||||
<option value="<%= user.id %>"><%= user.internal_username %> (<%= user.id %>)</option>
|
<div class="lumi-user-lookup-preview" data-user-lookup-preview hidden></div>
|
||||||
<% }) %>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Permission</label>
|
<label>Permission</label>
|
||||||
|
|||||||
@ -209,6 +209,39 @@ function listUsersWithIdentities() {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function searchKnownUsers(query, { limit = 30 } = {}) {
|
||||||
|
const value = String(query || "").trim().replace(/[%_]/g, "");
|
||||||
|
if (value.length < 2) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const pattern = `%${value}%`;
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
"SELECT DISTINCT p.id, p.internal_username, i.provider, i.provider_user_id, i.display_name " +
|
||||||
|
"FROM user_profiles p LEFT JOIN user_identities i ON i.user_id = p.id " +
|
||||||
|
"WHERE p.id LIKE ? OR p.internal_username LIKE ? OR i.provider_user_id LIKE ? OR i.display_name LIKE ? " +
|
||||||
|
"ORDER BY p.internal_username LIMIT ?"
|
||||||
|
)
|
||||||
|
.all(pattern, pattern, pattern, pattern, Math.max(1, Math.min(Number(limit) || 30, 50)));
|
||||||
|
return [...rows.reduce((map, row) => {
|
||||||
|
const user = map.get(row.id) || {
|
||||||
|
id: row.id,
|
||||||
|
username: row.internal_username,
|
||||||
|
internal_username: row.internal_username,
|
||||||
|
identities: []
|
||||||
|
};
|
||||||
|
if (row.provider) {
|
||||||
|
user.identities.push({
|
||||||
|
provider: row.provider,
|
||||||
|
provider_user_id: row.provider_user_id,
|
||||||
|
display_name: row.display_name
|
||||||
|
});
|
||||||
|
}
|
||||||
|
map.set(row.id, user);
|
||||||
|
return map;
|
||||||
|
}, new Map()).values()];
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
generateUniqueUsername,
|
generateUniqueUsername,
|
||||||
getUserProfileById,
|
getUserProfileById,
|
||||||
@ -216,5 +249,6 @@ module.exports = {
|
|||||||
ensureUserForIdentity,
|
ensureUserForIdentity,
|
||||||
linkIdentityToUser,
|
linkIdentityToUser,
|
||||||
updateInternalUsername,
|
updateInternalUsername,
|
||||||
listUsersWithIdentities
|
listUsersWithIdentities,
|
||||||
|
searchKnownUsers
|
||||||
};
|
};
|
||||||
|
|||||||
@ -48,6 +48,98 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-user-lookup]").forEach((lookup) => {
|
||||||
|
const search = lookup.querySelector("[data-user-lookup-search]");
|
||||||
|
const userId = lookup.querySelector("[data-user-lookup-id]");
|
||||||
|
const results = lookup.querySelector("[data-user-lookup-results]");
|
||||||
|
const preview = lookup.querySelector("[data-user-lookup-preview]");
|
||||||
|
const endpoint = lookup.dataset.userLookupEndpoint || "/api/users/search";
|
||||||
|
const fillSelector = lookup.dataset.userLookupFill || "";
|
||||||
|
const fillInput = fillSelector ? lookup.closest("form")?.querySelector(fillSelector) || document.querySelector(fillSelector) : null;
|
||||||
|
let timer = null;
|
||||||
|
let controller = null;
|
||||||
|
|
||||||
|
if (!search || !userId || !results) return;
|
||||||
|
|
||||||
|
const identityLabel = (identity) => {
|
||||||
|
if (!identity) return "";
|
||||||
|
const provider = identity.provider || "account";
|
||||||
|
return `${provider}: ${identity.display_name || identity.provider_user_id || ""}`.trim();
|
||||||
|
};
|
||||||
|
const selectUser = (user) => {
|
||||||
|
const username = user.username || user.internal_username || user.id;
|
||||||
|
userId.value = user.id || "";
|
||||||
|
search.value = username;
|
||||||
|
if (fillInput && fillInput !== userId) fillInput.value = username;
|
||||||
|
const identities = (user.identities || []).map(identityLabel).filter(Boolean).join(" | ");
|
||||||
|
if (preview) {
|
||||||
|
preview.textContent = `${username} | ${user.id}${identities ? ` | ${identities}` : ""}`;
|
||||||
|
preview.hidden = false;
|
||||||
|
}
|
||||||
|
results.hidden = true;
|
||||||
|
results.replaceChildren();
|
||||||
|
search.setCustomValidity("");
|
||||||
|
lookup.dispatchEvent(new CustomEvent("lumi:user-selected", { bubbles: true, detail: { user } }));
|
||||||
|
};
|
||||||
|
const renderUsers = (users) => {
|
||||||
|
results.replaceChildren();
|
||||||
|
users.forEach((user) => {
|
||||||
|
const username = user.username || user.internal_username || user.id;
|
||||||
|
const identity = (user.identities || [])[0];
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "lumi-user-lookup-result";
|
||||||
|
button.textContent = identity
|
||||||
|
? `${username} | ${identity.display_name || identity.provider_user_id} (${identity.provider})`
|
||||||
|
: `${username} | ${user.id}`;
|
||||||
|
button.addEventListener("click", () => selectUser(user));
|
||||||
|
results.append(button);
|
||||||
|
});
|
||||||
|
results.hidden = !users.length;
|
||||||
|
};
|
||||||
|
const clearSelection = () => {
|
||||||
|
userId.value = "";
|
||||||
|
if (preview) preview.hidden = true;
|
||||||
|
search.setCustomValidity("");
|
||||||
|
};
|
||||||
|
|
||||||
|
search.addEventListener("input", () => {
|
||||||
|
clearSelection();
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
controller?.abort();
|
||||||
|
const query = search.value.trim();
|
||||||
|
if (query.length < 2) {
|
||||||
|
results.hidden = true;
|
||||||
|
results.replaceChildren();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
timer = window.setTimeout(async () => {
|
||||||
|
controller = new AbortController();
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${endpoint}?q=${encodeURIComponent(query)}`, {
|
||||||
|
cache: "no-store",
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
const payload = await response.json();
|
||||||
|
renderUsers(response.ok ? payload.users || [] : []);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name !== "AbortError") renderUsers([]);
|
||||||
|
}
|
||||||
|
}, 180);
|
||||||
|
});
|
||||||
|
|
||||||
|
lookup.closest("form")?.addEventListener("submit", (event) => {
|
||||||
|
if (userId.required && !userId.value) {
|
||||||
|
event.preventDefault();
|
||||||
|
search.setCustomValidity("Select a known Lumi user from the search results.");
|
||||||
|
search.reportValidity();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener("click", (event) => {
|
||||||
|
if (!lookup.contains(event.target)) results.hidden = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
media.addEventListener?.("change", () => {
|
media.addEventListener?.("change", () => {
|
||||||
body.classList.remove("sidebar-open");
|
body.classList.remove("sidebar-open");
|
||||||
if (media.matches) {
|
if (media.matches) {
|
||||||
|
|||||||
@ -375,6 +375,56 @@ button:disabled {
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lumi-user-lookup {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lumi-user-lookup-results {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 20;
|
||||||
|
top: calc(100% + 0.25rem);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
max-height: 15rem;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--lumi-border);
|
||||||
|
border-radius: var(--lumi-radius-md);
|
||||||
|
background: var(--lumi-card);
|
||||||
|
box-shadow: var(--lumi-shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lumi-user-lookup-result {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid var(--lumi-border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--lumi-ink);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lumi-user-lookup-result:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lumi-user-lookup-result:hover,
|
||||||
|
.lumi-user-lookup-result:focus {
|
||||||
|
background: var(--lumi-surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lumi-user-lookup-preview {
|
||||||
|
margin-top: var(--lumi-space-1);
|
||||||
|
padding: 0.5rem 0.65rem;
|
||||||
|
border: 1px solid var(--lumi-border);
|
||||||
|
border-radius: var(--lumi-radius-sm);
|
||||||
|
background: var(--lumi-surface-2);
|
||||||
|
color: var(--lumi-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.lumi-state-btn-spinner {
|
.lumi-state-btn-spinner {
|
||||||
width: 1em;
|
width: 1em;
|
||||||
height: 1em;
|
height: 1em;
|
||||||
|
|||||||
@ -66,7 +66,8 @@ const {
|
|||||||
getUserProfileById,
|
getUserProfileById,
|
||||||
getUserIdentities,
|
getUserIdentities,
|
||||||
updateInternalUsername,
|
updateInternalUsername,
|
||||||
listUsersWithIdentities
|
listUsersWithIdentities,
|
||||||
|
searchKnownUsers
|
||||||
} = require("../services/users");
|
} = require("../services/users");
|
||||||
const {
|
const {
|
||||||
getPlugins,
|
getPlugins,
|
||||||
@ -2678,6 +2679,13 @@ function createWebServer({ loadPlugins, discordClient }) {
|
|||||||
res.status(400).json({ error: error.message });
|
res.status(400).json({ error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
app.get("/api/users/search", requireAuth, (req, res) => {
|
||||||
|
res.set("Cache-Control", "no-store");
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
users: searchKnownUsers(req.query.q, { limit: req.query.limit || 30 })
|
||||||
|
});
|
||||||
|
});
|
||||||
app.get("/api/feedback/similar", requireAuth, (req, res) => {
|
app.get("/api/feedback/similar", requireAuth, (req, res) => {
|
||||||
try {
|
try {
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user