Skip to content

Commit 3d3e655

Browse files
committed
feat: support for passwords
1 parent faecb94 commit 3d3e655

3 files changed

Lines changed: 108 additions & 4 deletions

File tree

web/src/__tests__/contributions-view.test.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,4 +504,49 @@ describe('createContributionsView — projectName auto-load', () => {
504504
await Promise.resolve();
505505
expect(global.fetch).not.toHaveBeenCalled();
506506
});
507+
508+
it('fetches history when a draft with a project name is restored (no full reload)', async () => {
509+
const draftRows = [{ name: 'Alice Smith', isFirst: false, ...Object.fromEntries(
510+
['Conceptualization','Methodology','Software','Validation','Formal analysis',
511+
'Investigation','Resources','Data curation','Writing \u2013 original draft',
512+
'Writing \u2013 review & editing','Visualization','Supervision',
513+
'Project Administration','Funding Acquisition'].map(c => [c, 'None'])
514+
) }];
515+
sessionStorage.setItem('contributions:draft', JSON.stringify({
516+
rows: draftRows,
517+
projectName: 'my-project',
518+
assetNames: '',
519+
projectLocked: false,
520+
projectPassword: '',
521+
authorSources: {},
522+
authorOrcids: {},
523+
authorAffIds: {},
524+
affiliations: [],
525+
loadedAssetNames: [],
526+
sections: [],
527+
creditDescriptions: {},
528+
creditLinkedSections: {},
529+
selectedAuthor: null,
530+
doi: '',
531+
}));
532+
533+
// history=true fetch should be called; full project GET should not
534+
global.fetch = vi.fn().mockResolvedValue({
535+
ok: true,
536+
status: 200,
537+
json: async () => [],
538+
});
539+
540+
createContributionsView({ projectName: 'my-project' });
541+
await Promise.resolve();
542+
543+
expect(global.fetch).toHaveBeenCalledWith(
544+
expect.stringContaining('history=true'),
545+
);
546+
// Should NOT re-fetch the full project data
547+
const fullProjectCalls = global.fetch.mock.calls.filter(
548+
([url]) => !url.includes('history=true'),
549+
);
550+
expect(fullProjectCalls).toHaveLength(0);
551+
});
507552
});

web/src/contributions/preview.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,7 +544,9 @@ export function createPreview(container, authors) {
544544
function detectDarkMode() {
545545
const html = document.documentElement;
546546
if (html.getAttribute('data-theme') === 'dark') return true;
547+
if (html.getAttribute('data-theme') === 'light') return false;
547548
if (html.classList.contains('dark')) return true;
549+
if (html.classList.contains('light')) return false;
548550
return window.matchMedia('(prefers-color-scheme: dark)').matches;
549551
}
550552

web/src/contributions/view.js

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -581,6 +581,8 @@ export function createContributionsView(options = {}) {
581581

582582
let projectLocked = false;
583583
let projectPassword = '';
584+
/** True when the server-side model has a password set (loaded via GET). */
585+
let serverLocked = false;
584586

585587
/** @type {HTMLElement|null} Currently-selected history bubble. */
586588
let selectedHistoryBubble = null;
@@ -840,7 +842,7 @@ export function createContributionsView(options = {}) {
840842
projectLocked,
841843
projectPassword,
842844
rows, authorSources, authorOrcids, authorAffIds, affiliations, loadedAssetNames,
843-
sections, creditDescriptions, creditLinkedSections, selectedAuthor, doi,
845+
sections, creditDescriptions, creditLinkedSections, selectedAuthor, doi, serverLocked,
844846
}));
845847
} catch (_) {}
846848
}
@@ -1668,6 +1670,22 @@ export function createContributionsView(options = {}) {
16681670
syncUrl();
16691671
endpointStatus.textContent = `\u2713 Loaded \u201c${project}\u201d \u2014 ${loadedRows.length} contributor(s).`;
16701672
endpointStatus.className = 'contributions-endpoint-status status-success';
1673+
1674+
// Apply server-side locked state
1675+
serverLocked = data.locked === true;
1676+
if (serverLocked) {
1677+
projectLocked = true;
1678+
projectLockedCheckbox.checked = true;
1679+
projectLockedCheckbox.disabled = true;
1680+
pwPasswordRow.style.display = '';
1681+
projectPasswordInput.placeholder = 'Password required to save';
1682+
projectPassword = '';
1683+
projectPasswordInput.value = '';
1684+
} else {
1685+
projectLockedCheckbox.disabled = false;
1686+
projectPasswordInput.placeholder = 'Set a password';
1687+
}
1688+
16711689
// Collapse assets section \u2014 irrelevant when working with a loaded project
16721690
assetsOpen = false;
16731691
assetsBody.style.display = 'none';
@@ -1683,6 +1701,21 @@ export function createContributionsView(options = {}) {
16831701
}
16841702
}
16851703

1704+
/**
1705+
* Hash a plaintext password with SHA-256 and return a hex string.
1706+
* Passwords are never sent in plaintext over the wire.
1707+
*
1708+
* @param {string} password
1709+
* @returns {Promise<string>}
1710+
*/
1711+
async function hashPassword(password) {
1712+
const encoded = new TextEncoder().encode(password);
1713+
const hashBuffer = await crypto.subtle.digest('SHA-256', encoded);
1714+
return Array.from(new Uint8Array(hashBuffer))
1715+
.map(b => b.toString(16).padStart(2, '0'))
1716+
.join('');
1717+
}
1718+
16861719
async function saveToServer() {
16871720
const project = projectNameInput.value.trim();
16881721
if (!project) {
@@ -1696,7 +1729,11 @@ export function createContributionsView(options = {}) {
16961729
endpointStatus.className = 'contributions-endpoint-status status-loading';
16971730
try {
16981731
const payload = toEndpointPayload(rows, project, { authorOrcids, authorAffIds, affiliations, sections, creditDescriptions, creditLinkedSections, assets: loadedAssetNames, doi });
1699-
const url = `${CONTRIBUTIONS_API_BASE}/contributions/post?project=${encodeURIComponent(project)}`;
1732+
let url = `${CONTRIBUTIONS_API_BASE}/contributions/post?project=${encodeURIComponent(project)}`;
1733+
if (projectPassword) {
1734+
const hashed = await hashPassword(projectPassword);
1735+
url += `&password=${encodeURIComponent(hashed)}`;
1736+
}
17001737
const res = await fetch(url, {
17011738
method: 'POST',
17021739
headers: { 'Content-Type': 'application/json' },
@@ -1728,7 +1765,11 @@ export function createContributionsView(options = {}) {
17281765
function updateProjectButtons() {
17291766
const hasProject = projectNameInput.value.trim().length > 0;
17301767
getBtn.disabled = !hasProject;
1731-
postBtn.disabled = !hasProject || rows.length === 0;
1768+
const hasRows = rows.length > 0;
1769+
// When server has a password, require the user to enter one before saving
1770+
const passwordRequired = serverLocked;
1771+
const hasPassword = projectPasswordInput.value.trim().length > 0;
1772+
postBtn.disabled = !hasProject || !hasRows || (passwordRequired && !hasPassword);
17321773
}
17331774

17341775
// -------------------------------------------------------------------------
@@ -1776,6 +1817,7 @@ export function createContributionsView(options = {}) {
17761817
});
17771818
projectPasswordInput.addEventListener('input', () => {
17781819
projectPassword = projectPasswordInput.value;
1820+
updateProjectButtons();
17791821
saveDraft();
17801822
});
17811823

@@ -1846,14 +1888,24 @@ export function createContributionsView(options = {}) {
18461888
const raw = sessionStorage.getItem(DRAFT_KEY);
18471889
if (raw) {
18481890
const draft = JSON.parse(raw);
1849-
if (draft.rows?.length > 0) {
1891+
// If the URL specifies a project that differs from the draft, discard the
1892+
// draft and treat the URL as ground truth.
1893+
const draftProject = (draft.projectName || '').trim();
1894+
if (projectName && draftProject && projectName !== draftProject) {
1895+
sessionStorage.removeItem(DRAFT_KEY);
1896+
} else if (draft.rows?.length > 0) {
18501897
assetInput.value = draft.assetNames || '';
18511898
projectNameInput.value = draft.projectName || '';
18521899
if (draft.projectLocked) {
18531900
projectLocked = true;
18541901
projectLockedCheckbox.checked = true;
18551902
pwPasswordRow.style.display = '';
18561903
}
1904+
if (draft.serverLocked) {
1905+
serverLocked = true;
1906+
projectLockedCheckbox.disabled = true;
1907+
projectPasswordInput.placeholder = 'Password required to save';
1908+
}
18571909
if (draft.projectPassword) {
18581910
projectPassword = draft.projectPassword;
18591911
projectPasswordInput.value = draft.projectPassword;
@@ -1887,6 +1939,11 @@ export function createContributionsView(options = {}) {
18871939

18881940
if (projectName && !draftRestored) {
18891941
Promise.resolve().then(loadFromServer);
1942+
} else if (draftRestored) {
1943+
const draftProject = projectNameInput.value.trim();
1944+
if (draftProject) {
1945+
Promise.resolve().then(() => fetchHistory(draftProject));
1946+
}
18901947
}
18911948

18921949
updateProjectButtons();

0 commit comments

Comments
 (0)