feat: the site's Nextcloud is filled in, not typed — the settings page is one button
nextcloud.url (or NEXTCLOUD_URL) names the site's Nextcloud. With it set, the address field is hidden and the page reads "Sign in with Nextcloud", with the app-password route underneath for whoever needs it; both routes use the default when no address is given. Without it, nothing changes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
parent
28877ba0c4
commit
d893452d9a
7 changed files with 59 additions and 4 deletions
|
|
@ -124,7 +124,9 @@
|
|||
<p>Save generated notes and teaching material to your own Nextcloud.</p>
|
||||
<div id="nc-status">Not connected</div>
|
||||
|
||||
<div class="form-group">
|
||||
<!-- Shown only when the site has not set its own Nextcloud; then
|
||||
there is nothing to type and the button below is the whole form. -->
|
||||
<div class="form-group" id="nc-url-group">
|
||||
<label for="nc-url">Nextcloud address</label>
|
||||
<input type="text" id="nc-url" placeholder="https://cloud.example.com">
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,23 @@
|
|||
statusEl.appendChild(document.createTextNode(' as ' + (user || '')));
|
||||
}
|
||||
|
||||
// The site's Nextcloud, when there is one: the address is filled in and its
|
||||
// field hidden, so the page reads "Sign in with Nextcloud" and nothing else.
|
||||
function loadNextcloudDefault() {
|
||||
fetch('/api/nextcloud/config', { headers: getAuthHeaders() })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
var group = document.getElementById('nc-url-group');
|
||||
var url = document.getElementById('nc-url');
|
||||
if (!data || !data.defaultUrl || !group || !url) return;
|
||||
if (!url.value.trim()) url.value = data.defaultUrl;
|
||||
group.classList.add('hidden');
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
function loadNextcloudStatus() {
|
||||
loadNextcloudDefault();
|
||||
fetch('/api/auth/me', { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
|
|
|
|||
|
|
@ -929,7 +929,7 @@ router.put('/config/:key(*)', async function(req, res) {
|
|||
}
|
||||
|
||||
// Security: only allow known key prefixes
|
||||
var allowed = ['announcement.', 'feature.', 'email.', 'prompt.', 'registration_enabled', 'site.', 'smtp.', 'models.', 'tts.', 'stt.', 'clinical_assistant.', 'my_resources.'];
|
||||
var allowed = ['announcement.', 'feature.', 'email.', 'prompt.', 'registration_enabled', 'site.', 'smtp.', 'models.', 'tts.', 'stt.', 'clinical_assistant.', 'my_resources.', 'nextcloud.'];
|
||||
var isAllowed = allowed.some(function(p) { return key === p || key.startsWith(p); });
|
||||
if (!isAllowed) {
|
||||
return res.status(400).json({ error: 'Unknown config key' });
|
||||
|
|
|
|||
|
|
@ -40,9 +40,24 @@ function sweepLoginFlows() {
|
|||
for (var [key, flow] of loginFlows) if (flow.expires < now) loginFlows.delete(key);
|
||||
}
|
||||
|
||||
// The site's own Nextcloud. When it is set, nobody types an address: the
|
||||
// settings page shows "Sign in with Nextcloud" and nothing else, with the
|
||||
// app-password route underneath for whoever needs it. Someone may still
|
||||
// connect a different server through the API; the default only fills the
|
||||
// blank.
|
||||
async function defaultNextcloudUrl() {
|
||||
var configured = String(await db.getSetting('nextcloud.url') || process.env.NEXTCLOUD_URL || '').trim();
|
||||
return configured.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
router.get('/nextcloud/config', authMiddleware, async function (req, res) {
|
||||
try { res.json({ success: true, defaultUrl: await defaultNextcloudUrl() }); }
|
||||
catch (e) { res.status(500).json({ error: 'Request failed' }); }
|
||||
});
|
||||
|
||||
router.post('/nextcloud/login-flow/start', authMiddleware, async function (req, res) {
|
||||
try {
|
||||
var cleanUrl = String(req.body.nextcloudUrl || '').trim().replace(/\/+$/, '');
|
||||
var cleanUrl = String(req.body.nextcloudUrl || '').trim().replace(/\/+$/, '') || await defaultNextcloudUrl();
|
||||
if (!cleanUrl) return res.status(400).json({ error: 'Enter your Nextcloud address' });
|
||||
await assertSafeHttpsUrl(cleanUrl, 'Nextcloud URL');
|
||||
|
||||
|
|
@ -143,6 +158,7 @@ router.post('/nextcloud/login-flow/poll', authMiddleware, async function (req, r
|
|||
router.post('/nextcloud/connect', authMiddleware, async function(req, res) {
|
||||
try {
|
||||
var { nextcloudUrl, username, appPassword, folder } = req.body;
|
||||
nextcloudUrl = String(nextcloudUrl || '').trim() || await defaultNextcloudUrl();
|
||||
if (!nextcloudUrl || !username || !appPassword) return res.status(400).json({ error: 'All fields required' });
|
||||
|
||||
var cleanUrl = nextcloudUrl.replace(/\/+$/, '');
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@ var parameters = {
|
|||
|
||||
var operations = {
|
||||
// ── Speech ──────────────────────────────────────────────────────────
|
||||
'GET /api/nextcloud/config': {
|
||||
summary: 'The site\'s Nextcloud address, if one is set',
|
||||
description: 'nextcloud.url (or NEXTCLOUD_URL). When present the settings page hides the address field and the sign-in and app-password routes use it when none is given.'
|
||||
},
|
||||
'PUT /api/admin/config/tts/default': {
|
||||
summary: 'Choose the default speech model and voice',
|
||||
description: 'Sets tts.model and tts.voice together and puts the model on the speech roster (tts.roster) if it is not there. Refused when the model does not accept the voice; the error lists the voices it does accept.',
|
||||
|
|
|
|||
|
|
@ -237,3 +237,20 @@ test('citation quality is measured on the server, where answer and sources both
|
|||
assert.match(route, /tracker\.store\(req\.user\.id, question, result, sources\);/);
|
||||
});
|
||||
|
||||
|
||||
test('the site\'s Nextcloud is filled in for people, not typed by them', () => {
|
||||
// With nextcloud.url set, the settings page shows "Sign in with Nextcloud"
|
||||
// and nothing else; the app-password route sits underneath for whoever
|
||||
// needs it. Without it, the address field is there as before.
|
||||
const route = read('src/routes/nextcloud.js');
|
||||
assert.match(route, /async function defaultNextcloudUrl\(\)/);
|
||||
assert.match(route, /db\.getSetting\('nextcloud\.url'\) \|\| process\.env\.NEXTCLOUD_URL/);
|
||||
assert.match(route, /router\.get\('\/nextcloud\/config', authMiddleware/);
|
||||
assert.match(route, /replace\(\/\\\/\+\$\/, ''\) \|\| await defaultNextcloudUrl\(\);/, 'the login flow falls back to it');
|
||||
assert.match(route, /nextcloudUrl = String\(nextcloudUrl \|\| ''\)\.trim\(\) \|\| await defaultNextcloudUrl\(\);/, 'and so does the app-password route');
|
||||
assert.match(read('src/routes/adminConfig.js'), /'my_resources\.', 'nextcloud\.'\]/, 'an admin can set it');
|
||||
const js = read('public/js/nextcloud.js');
|
||||
assert.match(js, /fetch\('\/api\/nextcloud\/config'/);
|
||||
assert.match(js, /group\.classList\.add\('hidden'\)/);
|
||||
assert.match(read('public/components/settings.html'), /id="nc-url-group"/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ test('the reviewer is admin-chosen, off by default, and runs once per change', (
|
|||
// Still one pass. The verification runs on the result, never in a loop.
|
||||
assert.equal((refine.match(/deckReview\.review\(/g) || []).length, 1);
|
||||
// The key has to be writable, or saving it silently does nothing.
|
||||
assert.match(read('src/routes/adminConfig.js'), /'clinical_assistant\.', 'my_resources\.'\]/);
|
||||
assert.match(read('src/routes/adminConfig.js'), /'clinical_assistant\.', 'my_resources\.', 'nextcloud\.'\]/);
|
||||
// And the image can actually rasterise a deck.
|
||||
assert.match(read('Dockerfile'), /poppler-utils/);
|
||||
assert.match(read('src/utils/deckReview.js'), /'pdftoppm', \['-png', '-r', String\(RENDER_DPI\)/);
|
||||
|
|
|
|||
Loading…
Reference in a new issue