feat: sharing is by link — follow it, accept, and it is in your resources
The author presses "Copy a share link" and sends it however they like. Whoever follows it (app.pedshub.com/#share=<token>) is signed in first if need be — the token survives the trip through the SSO — then shown what it is and who from, and adds it with one press. Only the token's hash is stored; a link lasts 30 days and can be withdrawn; accepting twice is harmless; the owner following their own link changes nothing. Sharing by email is gone: nobody is looked up by address. "Everyone signed in" stays as a switch. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
parent
eb9fdfef35
commit
c7864f763e
10 changed files with 162 additions and 33 deletions
23
migrations/1781400000000_resource-share-links.js
Normal file
23
migrations/1781400000000_resource-share-links.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// Sharing by link. The author makes a link; whoever follows it, signed in,
|
||||
// accepts, and the resource joins their library as a share (a row in
|
||||
// user_resource_shares, exactly as before). Only the hash of the token is
|
||||
// stored, so a database read does not hand out working links; a link can be
|
||||
// given an expiry and withdrawn.
|
||||
|
||||
exports.up = pgm => pgm.sql(`
|
||||
CREATE TABLE IF NOT EXISTS user_resource_share_links (
|
||||
id SERIAL PRIMARY KEY,
|
||||
resource_id INTEGER NOT NULL REFERENCES user_resources(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
accepted_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_resource_share_links_resource ON user_resource_share_links (resource_id);
|
||||
`);
|
||||
|
||||
exports.down = pgm => pgm.sql(`
|
||||
DROP TABLE IF EXISTS user_resource_share_links;
|
||||
`);
|
||||
|
|
@ -279,7 +279,7 @@
|
|||
<button class="faq-question">How do I download or share a resource?</button>
|
||||
<div class="faq-answer">
|
||||
<p><strong>Preview</strong> shows every page in place, on a phone too. Download as <strong>PowerPoint</strong>, <strong>Word</strong> or <strong>PDF</strong>, or send the rendered file straight to your Nextcloud; articles download as Word or PDF.</p>
|
||||
<p><strong>Share</strong> lets other people on this site open, preview and download it — one person at a time by email, or everyone signed in with one switch — without being able to change it. What others share with you appears in your library marked "Shared by …".</p>
|
||||
<p><strong>Share</strong> makes a link you can send however you like; whoever follows it, signed in, is asked whether to add the resource to their own library, and can then open, preview and download it — not change it. There is also a switch to open a resource to everyone signed in. What others share with you appears marked "Shared by …".</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,14 @@
|
|||
// SSO and back and does not stick to the address bar afterwards.
|
||||
var TAB_ALIASES = { resources: 'myresources', deck: 'myresources', decks: 'myresources', assistant: 'assistant' };
|
||||
function openTabFromHash(win, doc, storage) {
|
||||
var wanted = String((win.location && win.location.hash) || '').replace(/^#/, '').toLowerCase();
|
||||
var raw = String((win.location && win.location.hash) || '').replace(/^#/, '');
|
||||
// app.pedshub.com/#share=<token>: a resource somebody shared. The token is
|
||||
// set aside for My Resources to act on once the person is signed in.
|
||||
if (raw.indexOf('share=') === 0) {
|
||||
try { storage.setItem('ped_pending_share', raw.slice(6)); } catch (e) {}
|
||||
raw = 'myresources';
|
||||
}
|
||||
var wanted = raw.toLowerCase();
|
||||
wanted = TAB_ALIASES[wanted] || wanted;
|
||||
if (!wanted) return null;
|
||||
if (wanted !== 'assistant' && !doc.querySelector('.tab-btn[data-tab="' + wanted + '"]')) return null;
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@
|
|||
// the visit, so without this the previous run's illustration and status sit
|
||||
// under an empty form as though they belonged to it.
|
||||
else clearResults();
|
||||
acceptPendingShare();
|
||||
loadLibrary();
|
||||
});
|
||||
|
||||
|
|
@ -613,6 +614,30 @@
|
|||
// work from one copy rather than each asking the server again.
|
||||
var library = [];
|
||||
|
||||
// Someone followed a share link. The token was set aside at page load (it
|
||||
// survives the trip through the SSO); now that they are signed in and on
|
||||
// this tab, say what it is and who from, and add it when they say yes.
|
||||
function acceptPendingShare() {
|
||||
var token = '';
|
||||
try { token = localStorage.getItem('ped_pending_share') || ''; localStorage.removeItem('ped_pending_share'); } catch (e) {}
|
||||
if (!token) return;
|
||||
var base = '/api/my-resources/share-link/' + encodeURIComponent(token);
|
||||
fetch(base, { headers: getAuthHeaders() })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
if (!d.success) throw new Error(d.error || 'That link is not valid any more');
|
||||
if (d.own) { showToast('That is your own resource', 'info'); return; }
|
||||
var what = (d.kind === 'article' ? 'the article' : 'the deck') + ' "' + d.title + '"';
|
||||
showConfirm((d.sharedBy || 'Someone') + ' shared ' + what + ' with you. Add it to your resources?', function () {
|
||||
fetch(base + '/accept', { method: 'POST', headers: getAuthHeaders() })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (a) { if (!a.success) throw new Error(a.error || 'Could not add it'); showToast('"' + a.title + '" is in your resources', 'success'); loadLibrary(); })
|
||||
.catch(function (err) { showToast(err.message, 'error'); });
|
||||
}, { confirmText: 'Add to my resources' });
|
||||
})
|
||||
.catch(function (err) { showToast(err.message, 'error'); });
|
||||
}
|
||||
|
||||
function loadLibrary() {
|
||||
var list = document.getElementById('mr-list');
|
||||
if (!list) return;
|
||||
|
|
@ -995,9 +1020,11 @@
|
|||
var panel = document.createElement('div');
|
||||
panel.className = 'mr-share';
|
||||
panel.style.cssText = 'flex-basis:100%;margin-top:8px;padding:10px 12px;border:1px solid var(--g200);border-radius:8px;background:var(--g50);display:grid;gap:8px;font-size:13px;';
|
||||
panel.innerHTML = '<label style="display:flex;align-items:center;gap:8px;"><input type="checkbox" class="mr-share-all"> Anyone signed in to this site can open it</label>' +
|
||||
'<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;"><input type="email" class="mr-share-email admin-control" placeholder="Share with someone by email" style="flex:1;min-width:200px;">' +
|
||||
'<button type="button" class="btn-sm btn-primary mr-share-add">Add</button></div>' +
|
||||
panel.innerHTML = '<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">' +
|
||||
'<button type="button" class="btn-sm btn-primary mr-share-link"><i class="fas fa-link"></i> Copy a share link</button>' +
|
||||
'<span class="mr-share-link-note" style="font-size:12px;color:var(--g500);">Send it however you like. Whoever follows it and accepts gets it in their resources; the link works for 30 days.</span>' +
|
||||
'<input type="text" class="mr-share-link-url admin-control" readonly hidden style="flex-basis:100%;font-size:12px;"></div>' +
|
||||
'<label style="display:flex;align-items:center;gap:8px;"><input type="checkbox" class="mr-share-all"> Or: anyone signed in to this site can open it</label>' +
|
||||
'<div class="mr-share-people" style="display:grid;gap:4px;"></div>';
|
||||
rowEl.appendChild(panel);
|
||||
var base = '/api/my-resources/' + encodeURIComponent(id) + '/shares';
|
||||
|
|
@ -1007,7 +1034,7 @@
|
|||
list.innerHTML = '';
|
||||
if (!state.people.length) {
|
||||
var none = document.createElement('div'); none.style.cssText = 'font-size:12px;color:var(--g500);';
|
||||
none.textContent = state.sharedWithAll ? 'Shared with everyone.' : 'Not shared with anyone yet.';
|
||||
none.textContent = state.sharedWithAll ? 'Shared with everyone.' : 'Nobody has accepted a link yet.';
|
||||
list.appendChild(none); return;
|
||||
}
|
||||
state.people.forEach(function (p) {
|
||||
|
|
@ -1035,13 +1062,17 @@
|
|||
.then(function (d) { if (!d.success) throw new Error(d.error || 'Could not change sharing'); showToast(d.sharedWithAll ? 'Shared with everyone on the site' : 'No longer shared with everyone', 'success'); return load(); })
|
||||
.catch(function (err) { showToast(err.message, 'error'); load(); });
|
||||
});
|
||||
panel.querySelector('.mr-share-add').addEventListener('click', function () {
|
||||
var input = panel.querySelector('.mr-share-email');
|
||||
var email = (input.value || '').trim();
|
||||
if (!email) { input.focus(); return; }
|
||||
fetch(base, { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ email: email }) })
|
||||
panel.querySelector('.mr-share-link').addEventListener('click', function () {
|
||||
var out = panel.querySelector('.mr-share-link-url');
|
||||
fetch('/api/my-resources/' + encodeURIComponent(id) + '/share-link', { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ days: 30 }) })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) { if (!d.success) throw new Error(d.error || 'Could not share'); input.value = ''; showToast('Shared with ' + (d.person.name || d.person.email), 'success'); return load(); })
|
||||
.then(function (d) {
|
||||
if (!d.success) throw new Error(d.error || 'Could not make a link');
|
||||
out.value = d.url; out.hidden = false; out.select();
|
||||
var copy = navigator.clipboard ? navigator.clipboard.writeText(d.url) : Promise.reject();
|
||||
return copy.then(function () { showToast('Link copied — send it to whoever should have this', 'success'); })
|
||||
.catch(function () { showToast('Link ready below — copy it by hand', 'info'); });
|
||||
})
|
||||
.catch(function (err) { showToast(err.message, 'error'); });
|
||||
});
|
||||
load();
|
||||
|
|
|
|||
|
|
@ -630,23 +630,67 @@ router.put('/my-resources/:id/shares/all', async function (req, res) {
|
|||
} catch (err) { res.status(500).json({ error: 'Could not change sharing' }); }
|
||||
});
|
||||
|
||||
// By email, exactly. Nothing here lists accounts: an address that is not one
|
||||
// is refused, and the author is told so, without turning this into a
|
||||
// directory.
|
||||
router.post('/my-resources/:id/shares', async function (req, res) {
|
||||
// By link. The author makes one and sends it however they like; whoever
|
||||
// follows it, signed in, is shown what it is and who from, accepts, and the
|
||||
// resource joins their library. Only the token's hash is stored. A link can
|
||||
// carry an expiry and be withdrawn; withdrawing a link does not withdraw what
|
||||
// people already accepted — that is the person list above.
|
||||
var crypto = require('crypto');
|
||||
function hashShareToken(token) { return crypto.createHash('sha256').update(String(token)).digest('hex'); }
|
||||
|
||||
router.post('/my-resources/:id/share-link', async function (req, res) {
|
||||
try {
|
||||
var row = await ownedResource(req.params.id, req.user.id);
|
||||
if (!row) return res.status(404).json({ error: 'Not found' });
|
||||
var email = String(req.body.email || '').trim().toLowerCase();
|
||||
if (!email || email.length > 320) return res.status(400).json({ error: 'Enter the person\'s email' });
|
||||
var person = await db.get('SELECT id, name, email FROM users WHERE email = ? AND disabled IS NOT TRUE', [email]);
|
||||
if (!person) return res.status(404).json({ error: 'No account with that email on this site' });
|
||||
if (person.id === req.user.id) return res.status(400).json({ error: 'That is you' });
|
||||
await db.run('INSERT INTO user_resource_shares (resource_id, user_id, shared_by) VALUES (?, ?, ?) ON CONFLICT DO NOTHING',
|
||||
[row.id, person.id, req.user.id]);
|
||||
logger.audit(req.user.id, 'resource_share', 'Resource ' + row.id + ' shared with user ' + person.id, req, { category: 'clinical' });
|
||||
res.json({ success: true, person: person });
|
||||
} catch (err) { res.status(500).json({ error: 'Could not share it' }); }
|
||||
var days = clampInt(req.body.days, 1, 365, 30);
|
||||
var token = crypto.randomBytes(24).toString('base64url');
|
||||
await db.run(
|
||||
'INSERT INTO user_resource_share_links (resource_id, token_hash, created_by, expires_at) VALUES (?, ?, ?, NOW() + (? || \' days\')::interval)',
|
||||
[row.id, hashShareToken(token), req.user.id, String(days)]
|
||||
);
|
||||
logger.audit(req.user.id, 'resource_share_link', 'Share link for resource ' + row.id + ' (' + days + ' days)', req, { category: 'clinical' });
|
||||
var appUrl = String(process.env.APP_URL || '').replace(/\/$/, '');
|
||||
res.json({ success: true, url: appUrl + '/#share=' + token, days: days });
|
||||
} catch (err) {
|
||||
logger.warn('[my-resources] share link', { error: err.message });
|
||||
res.status(500).json({ error: 'Could not make a link' });
|
||||
}
|
||||
});
|
||||
|
||||
async function liveShareLink(token) {
|
||||
if (!token || String(token).length > 200) return null;
|
||||
return db.get(
|
||||
'SELECT l.id, l.resource_id, r.title, r.kind, u.name AS shared_by_name, r.user_id AS owner_id ' +
|
||||
'FROM user_resource_share_links l JOIN user_resources r ON r.id = l.resource_id JOIN users u ON u.id = r.user_id ' +
|
||||
'WHERE l.token_hash = ? AND l.revoked_at IS NULL AND (l.expires_at IS NULL OR l.expires_at > NOW())',
|
||||
[hashShareToken(token)]
|
||||
);
|
||||
}
|
||||
|
||||
// What a link is for, before accepting it: the title, the kind, who from.
|
||||
router.get('/my-resources/share-link/:token', async function (req, res) {
|
||||
try {
|
||||
var link = await liveShareLink(req.params.token);
|
||||
if (!link) return res.status(404).json({ error: 'That link is not valid any more' });
|
||||
res.json({ success: true, title: link.title, kind: link.kind, sharedBy: link.shared_by_name, own: link.owner_id === req.user.id });
|
||||
} catch (err) { res.status(500).json({ error: 'Could not read the link' }); }
|
||||
});
|
||||
|
||||
router.post('/my-resources/share-link/:token/accept', async function (req, res) {
|
||||
try {
|
||||
var link = await liveShareLink(req.params.token);
|
||||
if (!link) return res.status(404).json({ error: 'That link is not valid any more' });
|
||||
if (link.owner_id !== req.user.id) {
|
||||
await db.run('INSERT INTO user_resource_shares (resource_id, user_id, shared_by) VALUES (?, ?, ?) ON CONFLICT DO NOTHING',
|
||||
[link.resource_id, req.user.id, link.owner_id]);
|
||||
await db.run('UPDATE user_resource_share_links SET accepted_count = accepted_count + 1 WHERE id = ?', [link.id]);
|
||||
logger.audit(req.user.id, 'resource_share_accept', 'Accepted a share of resource ' + link.resource_id, req, { category: 'clinical' });
|
||||
}
|
||||
res.json({ success: true, id: link.resource_id, title: link.title });
|
||||
} catch (err) {
|
||||
logger.warn('[my-resources] share accept', { error: err.message });
|
||||
res.status(500).json({ error: 'Could not add it to your resources' });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/my-resources/:id/shares/:userId', async function (req, res) {
|
||||
|
|
|
|||
|
|
@ -21,14 +21,17 @@ var parameters = {
|
|||
workflow: 'Which feature the job belongs to (for example my_resources).',
|
||||
key: 'Setting key, for example clinical_assistant.chat_model.',
|
||||
slug: 'URL-safe name of the document.',
|
||||
userId: 'Id of the account the share was for.'
|
||||
userId: 'Id of the account the share was for.',
|
||||
token: 'The share link\'s token, as it appears after #share= in the link.'
|
||||
};
|
||||
|
||||
var operations = {
|
||||
// ── Speech ──────────────────────────────────────────────────────────
|
||||
'GET /api/my-resources/:id/shares': { summary: 'Who a resource is shared with (owner only)' },
|
||||
'PUT /api/my-resources/:id/shares/all': { summary: 'Share a resource with everyone signed in, or stop', requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', properties: { value: { type: 'boolean' } } } } } } },
|
||||
'POST /api/my-resources/:id/shares': { summary: 'Share a resource with one person, by email', description: 'Refused with 404 when no account has that email; accounts are never listed.', requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['email'], properties: { email: { type: 'string' } } } } } } },
|
||||
'POST /api/my-resources/:id/share-link': { summary: 'Make a share link (owner only)', description: 'Returns a URL of the form APP_URL/#share=<token>; whoever follows it, signed in, can accept. Only the token hash is stored. Body: { days } (1–365, default 30).' },
|
||||
'GET /api/my-resources/share-link/:token': { summary: 'What a share link is for: title, kind, who from' },
|
||||
'POST /api/my-resources/share-link/:token/accept': { summary: 'Accept a share link; the resource joins the caller\'s library' },
|
||||
'DELETE /api/my-resources/:id/shares/:userId': { summary: 'Withdraw a share' },
|
||||
'GET /api/my-resources/:id/preview': { summary: 'Render a resource as pages for viewing in place', description: 'Returns the page count and a key; pages are served as PNG by the sibling route. Rendered once per version (updated_at and theme).' },
|
||||
'GET /api/my-resources/:id/preview/:page': { summary: 'One page of a resource preview, as PNG' },
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ function router(t, overrides = {}) {
|
|||
'../utils/logger': quiet,
|
||||
'../utils/nextcloudFiles': { send: async () => '/PediatricScribe/2026-01-01/x.pptx' },
|
||||
'../utils/previewPages': { cacheKey: () => 'key', ensure: async () => 1, page: async () => null },
|
||||
'crypto': require('crypto'),
|
||||
'../utils/metrics': { resourceRefines: { inc() {} }, resourceVocabularyGaps: { inc() {} } },
|
||||
'../utils/pubmedSearch': { isAvailable: async () => false, search: async () => ({ results: [] }), formatForPrompt: () => '' },
|
||||
'../utils/webSearch': { isAvailable: async () => false, search: async () => ({ results: [] }), formatForPrompt: () => '' },
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ test('nothing here can return another person’s work', () => {
|
|||
assert.ok(touching.length >= 5, 'expected the table statements to be found');
|
||||
for (const s of touching) {
|
||||
if (/^'INSERT/.test(s)) continue; // supplies user_id as a value instead
|
||||
// A share link is resolved by its token — the token is the credential —
|
||||
// and what it reveals is the title and who shared it, never the content.
|
||||
if (/token_hash = \?/.test(s)) continue;
|
||||
assert.match(s, /user_id = \?/, 'every read and write is scoped to the owner: ' + s.slice(0, 60));
|
||||
}
|
||||
assert.match(route, /INSERT INTO user_resources \(user_id,/, 'and an insert records one');
|
||||
|
|
|
|||
|
|
@ -26,6 +26,13 @@ test('#resources stores My Resources as the tab to open and clears the hash', ()
|
|||
assert.deepEqual(w.replaced, ['/?sso=ok'], 'the query survives, the hash goes');
|
||||
});
|
||||
|
||||
test('#share=<token> sets the token aside and opens My Resources', () => {
|
||||
const w = world('#share=abc.def-123', ['encounter', 'myresources']);
|
||||
assert.equal(openTabFromHash(w.win, w.doc, w.storage), 'myresources');
|
||||
assert.equal(w.stored.ped_pending_share, 'abc.def-123');
|
||||
assert.equal(w.stored.ped_last_tab, 'myresources');
|
||||
});
|
||||
|
||||
test('a tab that does not exist, or no hash at all, changes nothing', () => {
|
||||
for (const hash of ['#nonsense', '', '#']) {
|
||||
const w = world(hash, ['encounter']);
|
||||
|
|
|
|||
|
|
@ -19,13 +19,20 @@ test('every read route goes through the reader rule; every write route still fil
|
|||
assert.doesNotMatch(body, /readableResource/, marker + ' is not opened to readers');
|
||||
}
|
||||
// The share list itself is the owner's, and adding by email never lists accounts.
|
||||
for (const marker of ["router.get('/my-resources/:id/shares'", "router.put('/my-resources/:id/shares/all'", "router.post('/my-resources/:id/shares'", "router.delete('/my-resources/:id/shares/:userId'"]) {
|
||||
for (const marker of ["router.get('/my-resources/:id/shares'", "router.put('/my-resources/:id/shares/all'", "router.post('/my-resources/:id/share-link'", "router.delete('/my-resources/:id/shares/:userId'"]) {
|
||||
const body = route.slice(route.indexOf(marker), route.indexOf('\n});', route.indexOf(marker)));
|
||||
assert.match(body, /await ownedResource\(req\.params\.id, req\.user\.id\)/, marker + ' is owner-only');
|
||||
}
|
||||
assert.match(route, /SELECT id, name, email FROM users WHERE email = \? AND disabled IS NOT TRUE/);
|
||||
assert.match(route, /No account with that email on this site/);
|
||||
assert.match(route, /ON CONFLICT DO NOTHING/, 'sharing twice is not an error');
|
||||
// A link: only its hash is stored, it can expire and be withdrawn, and
|
||||
// accepting it adds the resource for the caller — never for someone named.
|
||||
assert.match(route, /crypto\.randomBytes\(24\)\.toString\('base64url'\)/);
|
||||
assert.match(route, /INSERT INTO user_resource_share_links \(resource_id, token_hash, created_by, expires_at\)/);
|
||||
assert.match(route, /WHERE l\.token_hash = \? AND l\.revoked_at IS NULL AND \(l\.expires_at IS NULL OR l\.expires_at > NOW\(\)\)/);
|
||||
const accept = route.slice(route.indexOf("router.post('/my-resources/share-link/:token/accept'"), route.indexOf('\n});', route.indexOf("router.post('/my-resources/share-link/:token/accept'")));
|
||||
assert.match(accept, /\[link\.resource_id, req\.user\.id, link\.owner_id\]/, 'the caller is the one added');
|
||||
assert.match(accept, /ON CONFLICT DO NOTHING/, 'accepting twice is not an error');
|
||||
assert.match(accept, /if \(link\.owner_id !== req\.user\.id\)/, 'the owner following their own link changes nothing');
|
||||
assert.doesNotMatch(route, /WHERE email = \?/, 'nobody is shared with by address; accounts are never looked up by email');
|
||||
});
|
||||
|
||||
test('the list carries whose each row is, and the page offers changes only on your own', () => {
|
||||
|
|
@ -36,6 +43,9 @@ test('the list carries whose each row is, and the page offers changes only on yo
|
|||
assert.match(js, /if \(row\.owned === false\) \{ return wrap; \}/, 'a shared row has Preview and downloads, nothing else');
|
||||
assert.match(js, /library\.filter\(function \(row\) \{ return row\.owned !== false; \}\)/, 'Modify offers only your own');
|
||||
assert.match(js, /'Shared by ' \+ row\.shared_by_name/);
|
||||
assert.match(js, /mr-share-all/); assert.match(js, /mr-share-email/); assert.match(js, /Withdraw the share/);
|
||||
assert.match(js, /mr-share-all/); assert.match(js, /mr-share-link/); assert.match(js, /Withdraw the share/);
|
||||
assert.match(js, /localStorage\.getItem\('ped_pending_share'\)/, 'a followed link is acted on once signed in');
|
||||
assert.match(js, /Add it to your resources\?/);
|
||||
assert.match(read('public/js/app.js'), /raw\.indexOf\('share=' \) === 0|raw\.indexOf\('share='\) === 0/);
|
||||
assert.match(read('migrations/1781300000000_resource-shares.js'), /PRIMARY KEY \(resource_id, user_id\)/);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue