diff --git a/docs/authentication.md b/docs/authentication.md
index 631029ad..c18616da 100644
--- a/docs/authentication.md
+++ b/docs/authentication.md
@@ -171,6 +171,10 @@ no request ever leaving the browser.
## Registration invitations
+`registration_invite_only` sits between open and closed registration: people may
+register, but only with a code. It is subordinate to `registration_enabled` — with
+registration disabled entirely, nobody can register, code or not.
+
`registration_invites` holds codes that let someone register while
`registration_invite_only` is on. Only the hash is stored; the code is shown
once, at creation.
diff --git a/docs/features-explained.md b/docs/features-explained.md
index aa4c2de7..a9dde22b 100644
--- a/docs/features-explained.md
+++ b/docs/features-explained.md
@@ -28,7 +28,20 @@ The voice preview button calls LiteLLM TTS and plays the returned audio in the b
## Nextcloud WebDAV
-Users can connect a Nextcloud account with an app password and export generated notes to it.
+Two ways to connect. **Sign in with Nextcloud** uses Nextcloud's own Login Flow
+v2: the person enters their server address, signs in on Nextcloud itself the way
+they normally do — SSO, 2FA, a password manager — and Nextcloud generates an app
+password for this app. We never see their real password and they never have to
+find the app-password screen. An app password can still be pasted in directly,
+under *Use an app password instead*.
+
+The poll token Nextcloud issues is a credential, so the polling happens on the
+server; the browser holds only an opaque handle bound to its own account. Both
+URLs the server hands back are checked and must be on the host the person typed.
+
+Once connected, generated notes and rendered resources can be saved to it. A
+resource is sent as the **rendered file** — a PowerPoint or Word document,
+exactly what the download would have produced — not as text.
## Documents And S3
diff --git a/migrations/1780900000000_drop-webdav-learning-path.js b/migrations/1780900000000_drop-webdav-learning-path.js
new file mode 100644
index 00000000..5cf6f55f
--- /dev/null
+++ b/migrations/1780900000000_drop-webdav-learning-path.js
@@ -0,0 +1,13 @@
+// users.webdav_learning_path was the folder the Learning Hub file browser opened
+// first. The browser went with Learning Hub, the Settings field that set it has
+// gone, and nothing reads the column. Nextcloud itself stays — connect,
+// disconnect, and exporting a note or a rendered resource all use
+// nextcloud_folder, which is a different column and still in use.
+
+exports.up = async function (pgm) {
+ pgm.sql('ALTER TABLE users DROP COLUMN IF EXISTS webdav_learning_path');
+};
+
+exports.down = async function (pgm) {
+ pgm.sql('ALTER TABLE users ADD COLUMN IF NOT EXISTS webdav_learning_path TEXT DEFAULT NULL');
+};
diff --git a/public/components/admin.html b/public/components/admin.html
index 1c663b16..806b3d8e 100644
--- a/public/components/admin.html
+++ b/public/components/admin.html
@@ -30,7 +30,6 @@
Require an invitation code to register
-
Sits between open and closed registration. With registration disabled entirely, nobody can register even with a code.
diff --git a/public/components/settings.html b/public/components/settings.html
index b466cda9..de22f6b0 100644
--- a/public/components/settings.html
+++ b/public/components/settings.html
@@ -121,31 +121,36 @@
Nextcloud Integration
-
Export generated documents to your Nextcloud.
+
Save generated notes and teaching material to your own Nextcloud.
Not connected
+
- Nextcloud URL
+ Nextcloud address
-
- Username
-
-
-
- App Password
-
- Go to Nextcloud → Settings → Security → Create new app password
-
-
Connect
-
Disconnect
-
-
Learning Hub — Default Browse Path
-
Folder opened first when picking files for AI content generation (e.g. /Medical-Resources)
-
-
-
Save Path
+
+
+
+ Sign in with Nextcloud
+
+
+
+
+ Use an app password instead
+
+ Username
+
-
+
+ App password
+
+ Nextcloud → Settings → Security → Create new app password
+
+
Connect
+
+
+
Disconnect
diff --git a/public/js/nextcloud.js b/public/js/nextcloud.js
index 4c5ef659..6bdff768 100644
--- a/public/js/nextcloud.js
+++ b/public/js/nextcloud.js
@@ -16,21 +16,14 @@
fetch('/api/auth/me', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
- var pathSection = document.getElementById('nc-webdav-path-section');
if (data.user && data.user.nextcloud_url) {
setNextcloudConnectedStatus(data.user.nextcloud_url, data.user.nextcloud_user);
document.getElementById('nc-url').value = data.user.nextcloud_url;
document.getElementById('nc-user').value = data.user.nextcloud_user;
document.getElementById('btn-nc-disconnect').classList.remove('hidden');
- if (pathSection) {
- pathSection.classList.remove('hidden');
- var pathEl = document.getElementById('nc-webdav-path');
- if (pathEl) pathEl.value = data.user.webdav_learning_path || '';
- }
} else {
document.getElementById('nc-status').textContent = 'Not connected';
document.getElementById('btn-nc-disconnect').classList.add('hidden');
- if (pathSection) pathSection.classList.add('hidden');
}
});
}
@@ -70,13 +63,60 @@
.then(function() { showToast('Disconnected', 'info'); loadNextcloudStatus(); });
});
- document.getElementById('btn-nc-save-path').addEventListener('click', function() {
- var p = (document.getElementById('nc-webdav-path').value || '').trim();
- fetch('/api/user/webdav-path', {
- method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ path: p })
- }).then(function(r) { return r.json(); })
- .then(function(d) { d.success ? showToast('Path saved', 'success') : showToast(d.error || 'Failed', 'error'); })
- .catch(function() { showToast('Failed to save', 'error'); });
+ // ── Sign in with Nextcloud ────────────────────────────────────────────
+ // Nextcloud's own login flow. The tab is opened from the click itself, before
+ // any await, or a popup blocker eats it — the request that fetches the URL is
+ // allowed to finish afterwards and point the already-open tab at it.
+ var pollTimer = null;
+
+ function stopPolling(message, tone) {
+ if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
+ var status = document.getElementById('nc-login-flow-status');
+ if (status) {
+ status.textContent = message || '';
+ status.style.color = tone === 'bad' ? 'var(--red)' : tone === 'good' ? 'var(--green)' : 'var(--g600)';
+ }
+ }
+
+ document.getElementById('btn-nc-login-flow').addEventListener('click', function () {
+ var url = (document.getElementById('nc-url').value || '').trim();
+ if (!url) { showToast('Enter your Nextcloud address first', 'error'); return; }
+
+ var tab = window.open('', '_blank'); // claimed while the click is still trusted
+ stopPolling('Opening Nextcloud…');
+
+ fetch('/api/nextcloud/login-flow/start', {
+ method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ nextcloudUrl: url })
+ })
+ .then(function (r) { return r.json(); })
+ .then(function (data) {
+ if (!data.success) throw new Error(data.error || 'Could not start sign-in');
+ if (tab) tab.location.href = data.loginUrl; else window.open(data.loginUrl, '_blank');
+ stopPolling('Waiting for you to finish signing in…');
+
+ var until = Date.now() + 10 * 60 * 1000;
+ pollTimer = setInterval(function () {
+ if (Date.now() > until) return stopPolling('Sign-in timed out. Try again.', 'bad');
+ fetch('/api/nextcloud/login-flow/poll', {
+ method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ handle: data.handle })
+ })
+ .then(function (r) { return r.json(); })
+ .then(function (result) {
+ if (result.connected) {
+ stopPolling('Connected as ' + result.username + '.', 'good');
+ loadNextcloudStatus();
+ showToast('Nextcloud connected', 'success');
+ } else if (!result.success) {
+ stopPolling(result.error || 'Sign-in failed.', 'bad');
+ }
+ })
+ .catch(function () { /* a dropped poll is not a failed sign-in */ });
+ }, 3000);
+ })
+ .catch(function (err) {
+ if (tab) tab.close();
+ stopPolling(err.message, 'bad');
+ });
});
console.log('✅ Nextcloud module loaded');
diff --git a/server.js b/server.js
index 5e1a0903..f4faaeb1 100644
--- a/server.js
+++ b/server.js
@@ -348,18 +348,6 @@ app.use('/api', require('./src/routes/dontMiss'));
app.use('/api', require('./src/routes/patientEducation'));
app.use('/api/user', require('./src/routes/userPreferences'));
-// User-level preference: the Nextcloud folder this account browses from.
-(function() {
- var { authMiddleware } = require('./src/middleware/auth');
- var db = require('./src/db/database');
- app.post('/api/user/webdav-path', authMiddleware, require('./src/utils/policy').requireFeature('nextcloud'), async function(req, res) {
- try {
- await db.run('UPDATE users SET webdav_learning_path = ? WHERE id = ?', [req.body.path || null, req.user.id]);
- res.json({ success: true });
- } catch(e) { res.status(500).json({ error: e.message }); }
- });
-})();
-
app.get('/', (req, res) => {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
diff --git a/src/db/database.js b/src/db/database.js
index eaf69947..261d2e09 100644
--- a/src/db/database.js
+++ b/src/db/database.js
@@ -226,9 +226,6 @@ async function initDatabase() {
// Add updated_by column to app_settings if upgrading
try { await client.query("ALTER TABLE app_settings ADD COLUMN IF NOT EXISTS updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL"); } catch(e) {}
- // The Nextcloud folder this account browses from. Named for the Learning
- // Hub it was added for; kept because Settings still uses it to pick a folder.
- try { await client.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS webdav_learning_path TEXT DEFAULT NULL"); } catch(e) {}
// Add user preferences for STT model and TTS voice
try { await client.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS stt_model TEXT DEFAULT NULL"); } catch(e) {}
try { await client.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS tts_voice TEXT DEFAULT NULL"); } catch(e) {}
diff --git a/src/routes/nextcloud.js b/src/routes/nextcloud.js
index db389be2..b71b4508 100644
--- a/src/routes/nextcloud.js
+++ b/src/routes/nextcloud.js
@@ -14,6 +14,129 @@ function davRoot(baseUrl, username) {
return baseUrl + '/remote.php/dav/files/' + encodeURIComponent(username);
}
+// ── Login Flow v2 ───────────────────────────────────────────
+// Connecting without asking anyone to find the app-password screen.
+//
+// Nextcloud's own flow: we ask the server to start one, it gives back a URL and
+// a poll token. The person opens that URL, signs in the way they normally do —
+// SSO, 2FA, a password manager — and Nextcloud generates an app password for
+// us. We never see their real password, and they never have to know what an app
+// password is.
+//
+// The poll token is a credential, so the polling happens here rather than in the
+// browser. The browser holds only an opaque handle bound to its own account.
+//
+// In memory, not in the database: a flow lives for minutes, and a restart
+// mid-login is a retry, not a loss. One flow per account at a time — starting a
+// second replaces the first, which is what "I clicked it again" means.
+var loginFlows = new Map();
+var LOGIN_FLOW_TTL_MS = 20 * 60 * 1000; // Nextcloud expires its side at ~20 minutes
+
+function sweepLoginFlows() {
+ var now = Date.now();
+ for (var [key, flow] of loginFlows) if (flow.expires < now) loginFlows.delete(key);
+}
+
+router.post('/nextcloud/login-flow/start', authMiddleware, async function (req, res) {
+ try {
+ var cleanUrl = String(req.body.nextcloudUrl || '').trim().replace(/\/+$/, '');
+ if (!cleanUrl) return res.status(400).json({ error: 'Enter your Nextcloud address' });
+ await assertSafeHttpsUrl(cleanUrl, 'Nextcloud URL');
+
+ var started = await axios({
+ method: 'POST', url: cleanUrl + '/index.php/login/v2',
+ headers: { 'User-Agent': 'Ped-AI' }, timeout: 15000, maxRedirects: 0
+ });
+ var poll = started.data && started.data.poll;
+ var loginUrl = started.data && started.data.login;
+ if (!poll || typeof poll.token !== 'string' || typeof poll.endpoint !== 'string' || typeof loginUrl !== 'string') {
+ return res.status(502).json({ error: 'That address did not answer like a Nextcloud server.' });
+ }
+
+ // The server chooses both URLs, so both are checked. An endpoint pointing
+ // somewhere else would turn this into a request-forgery gadget aimed at
+ // whatever it named, with our credentials attached.
+ await assertSafeHttpsUrl(poll.endpoint, 'Nextcloud poll endpoint');
+ await assertSafeHttpsUrl(loginUrl, 'Nextcloud login URL');
+ var origin = new URL(cleanUrl).host;
+ if (new URL(poll.endpoint).host !== origin || new URL(loginUrl).host !== origin) {
+ return res.status(502).json({ error: 'That server pointed the login somewhere else. Not continuing.' });
+ }
+
+ sweepLoginFlows();
+ var handle = require('crypto').randomUUID();
+ loginFlows.set(handle, {
+ owner: req.user.id, url: cleanUrl, token: poll.token, endpoint: poll.endpoint,
+ folder: String(req.body.folder || '/PediatricScribe').replace(/\/+$/, ''),
+ expires: Date.now() + LOGIN_FLOW_TTL_MS
+ });
+ // One at a time per account.
+ for (var [key, flow] of loginFlows) if (flow.owner === req.user.id && key !== handle) loginFlows.delete(key);
+
+ res.json({ success: true, handle: handle, loginUrl: loginUrl });
+ } catch (err) {
+ if (err.statusCode) return res.status(err.statusCode).json({ error: err.message });
+ return res.status(502).json({ error: 'Could not reach that Nextcloud server.' });
+ }
+});
+
+router.post('/nextcloud/login-flow/poll', authMiddleware, async function (req, res) {
+ try {
+ sweepLoginFlows();
+ var flow = loginFlows.get(String(req.body.handle || ''));
+ // Bound to the account that started it: a handle is not a bearer token.
+ if (!flow || flow.owner !== req.user.id) {
+ return res.status(410).json({ error: 'That sign-in attempt has expired. Start again.' });
+ }
+
+ var answer;
+ try {
+ answer = await axios({
+ method: 'POST', url: flow.endpoint,
+ data: 'token=' + encodeURIComponent(flow.token),
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ timeout: 15000, maxRedirects: 0
+ });
+ } catch (e) {
+ // 404 is Nextcloud's "not finished yet", which is the normal case while
+ // someone is still typing their password.
+ var status = e.response && e.response.status;
+ if (status === 404) return res.json({ success: true, pending: true });
+ throw e;
+ }
+
+ var appPassword = answer.data && answer.data.appPassword;
+ var loginName = answer.data && answer.data.loginName;
+ if (!appPassword || !loginName) return res.json({ success: true, pending: true });
+
+ // Nextcloud returns the server it actually signed them into; trust that over
+ // what was typed, but only once it has passed the same checks.
+ var serverUrl = String(answer.data.server || flow.url).replace(/\/+$/, '');
+ await assertSafeHttpsUrl(serverUrl, 'Nextcloud URL');
+
+ var folder = flow.folder || '/PediatricScribe';
+ var parts = folder.split('/').filter(Boolean);
+ var walked = '';
+ for (var part of parts) {
+ walked += '/' + part;
+ try {
+ await axios({ method: 'MKCOL', url: davRoot(serverUrl, loginName) + walked + '/',
+ auth: { username: loginName, password: appPassword }, timeout: 15000, maxRedirects: 0 });
+ } catch (e) {}
+ }
+
+ await db.run('UPDATE users SET nextcloud_url = ?, nextcloud_user = ?, nextcloud_token = ?, nextcloud_folder = ? WHERE id = ?',
+ [serverUrl, loginName, cryptoUtil.encryptString(appPassword), folder, req.user.id]);
+ loginFlows.delete(String(req.body.handle));
+
+ logger.audit(req.user.id, 'nextcloud_connect', 'Connected Nextcloud via login flow', req, { category: 'integration' });
+ res.json({ success: true, connected: true, username: loginName, folder: folder });
+ } catch (err) {
+ if (err.statusCode) return res.status(err.statusCode).json({ error: err.message });
+ return res.status(502).json({ error: 'Could not finish connecting to Nextcloud.' });
+ }
+});
+
router.post('/nextcloud/connect', authMiddleware, async function(req, res) {
try {
var { nextcloudUrl, username, appPassword, folder } = req.body;
diff --git a/test/nextcloud-login-flow.test.js b/test/nextcloud-login-flow.test.js
new file mode 100644
index 00000000..e834471f
--- /dev/null
+++ b/test/nextcloud-login-flow.test.js
@@ -0,0 +1,90 @@
+// Nextcloud's own Login Flow v2: the person signs in on Nextcloud however they
+// normally do — SSO, 2FA, a password manager — and Nextcloud hands back an app
+// password it generated. We never see their real password and they never have
+// to find the app-password screen.
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+
+const read = f => fs.readFileSync(path.join(__dirname, '..', f), 'utf8');
+const route = read('src/routes/nextcloud.js');
+const ui = read('public/js/nextcloud.js');
+const start = route.slice(route.indexOf("router.post('/nextcloud/login-flow/start'"),
+ route.indexOf("router.post('/nextcloud/login-flow/poll'"));
+const poll = route.slice(route.indexOf("router.post('/nextcloud/login-flow/poll'"),
+ route.indexOf("router.post('/nextcloud/connect'"));
+
+test('the URLs the server hands back are checked, not followed on trust', () => {
+ // The remote server chooses both the login URL and the poll endpoint. An
+ // endpoint pointing elsewhere would make this a request-forgery gadget aimed
+ // at whatever it named, with our credentials attached.
+ assert.match(start, /assertSafeHttpsUrl\(cleanUrl, 'Nextcloud URL'\)/);
+ assert.match(start, /assertSafeHttpsUrl\(poll\.endpoint, 'Nextcloud poll endpoint'\)/);
+ assert.match(start, /assertSafeHttpsUrl\(loginUrl, 'Nextcloud login URL'\)/);
+ // And both must be on the host the person actually typed.
+ assert.match(start, /new URL\(poll\.endpoint\)\.host !== origin \|\| new URL\(loginUrl\)\.host !== origin/);
+ assert.match(start, /pointed the login somewhere else/);
+});
+
+test('the poll token never reaches the browser', () => {
+ // It is a credential. The browser gets an opaque handle instead.
+ assert.match(start, /res\.json\(\{ success: true, handle: handle, loginUrl: loginUrl \}\)/);
+ assert.doesNotMatch(start, /res\.json\([^)]*token/);
+ const flowHandler = ui.slice(ui.indexOf('btn-nc-login-flow'));
+ assert.match(flowHandler, /handle: data\.handle/);
+ // The manual fallback above it does send an app password — that is its whole
+ // point. This path must not.
+ assert.doesNotMatch(flowHandler, /poll\.token|appPassword/);
+});
+
+test('a handle belongs to the account that started the flow', () => {
+ assert.match(poll, /!flow \|\| flow\.owner !== req\.user\.id/);
+ assert.match(poll, /a handle is not a bearer token/);
+});
+
+test('"not finished yet" is a normal answer, not a failure', () => {
+ // Nextcloud answers 404 while the person is still typing their password.
+ assert.match(poll, /if \(status === 404\) return res\.json\(\{ success: true, pending: true \}\)/);
+});
+
+test('the server Nextcloud reports is re-checked before it is stored', () => {
+ assert.match(poll, /answer\.data\.server \|\| flow\.url/);
+ assert.match(poll, /assertSafeHttpsUrl\(serverUrl, 'Nextcloud URL'\)/);
+});
+
+test('the app password is encrypted at rest, like every other credential here', () => {
+ assert.match(poll, /cryptoUtil\.encryptString\(appPassword\)/);
+});
+
+test('a flow expires, and starting again replaces the old one', () => {
+ assert.match(route, /LOGIN_FLOW_TTL_MS = 20 \* 60 \* 1000/);
+ assert.match(route, /function sweepLoginFlows\(\)/);
+ assert.match(start, /flow\.owner === req\.user\.id && key !== handle\) loginFlows\.delete\(key\)/);
+});
+
+test('the tab is opened while the click is still trusted', () => {
+ // Opening after an await is what a popup blocker stops.
+ const handler = ui.slice(ui.indexOf("btn-nc-login-flow"));
+ const open = handler.indexOf("window.open('', '_blank')");
+ const fetchAt = handler.indexOf("fetch('/api/nextcloud/login-flow/start'");
+ assert.ok(open > -1 && open < fetchAt, 'the tab must be claimed before the request');
+ assert.match(handler, /a popup blocker eats it|still trusted/);
+});
+
+test('polling stops: on success, on failure, and on a deadline', () => {
+ const handler = ui.slice(ui.indexOf("btn-nc-login-flow"));
+ assert.match(handler, /Date\.now\(\) > until\) return stopPolling/);
+ assert.match(handler, /if \(result\.connected\)/);
+ // stopPolling owns the interval, and is what every exit calls.
+ assert.match(ui, /function stopPolling\(message, tone\)[\s\S]{0,160}clearInterval\(pollTimer\)/);
+ // A dropped poll is not a failed sign-in.
+ assert.match(handler, /a dropped poll is not a failed sign-in/);
+});
+
+test('the app-password path is still there, as the fallback', () => {
+ assert.match(route, /router\.post\('\/nextcloud\/connect'/);
+ const settings = read('public/components/settings.html');
+ assert.match(settings, /Use an app password instead/);
+ assert.match(settings, /id="btn-nc-login-flow"/);
+});
diff --git a/test/policy-flows.test.js b/test/policy-flows.test.js
index 21356b64..e3878707 100644
--- a/test/policy-flows.test.js
+++ b/test/policy-flows.test.js
@@ -355,8 +355,7 @@ test('feature routes deny before data/provider access; user status is nonsensiti
for (const name of ['read_aloud', 'nextcloud', 'memories']) f.state.settings['feature.' + name] = 'false';
const endpoints = [
['POST', '/api/text-to-speech'], ['POST', '/api/nextcloud/connect'], ['POST', '/api/nextcloud/export'], ['POST', '/api/nextcloud/disconnect'],
- ['GET', '/api/memories'], ['GET', '/api/memories/context'], ['POST', '/api/memories'], ['PUT', '/api/memories/1'], ['DELETE', '/api/memories/1'],
- ['POST', '/api/user/webdav-path']
+ ['GET', '/api/memories'], ['GET', '/api/memories/context'], ['POST', '/api/memories'], ['PUT', '/api/memories/1'], ['DELETE', '/api/memories/1']
];
for (const [method, route] of endpoints) {
const response = await request(route, { method, authenticated: true, body: method === 'GET' ? undefined : { webdavPath: '/synthetic.txt', text: 'Synthetic' } });
@@ -457,7 +456,6 @@ test('enabled personal Nextcloud and memory CRUD remain usable through actual ha
for (const [route, body] of [
['/api/nextcloud/connect', { nextcloudUrl: 'https://cloud.example', username: 'synthetic', appPassword: 'synthetic-token' }],
['/api/nextcloud/export', { content: 'Synthetic document', type: 'note' }],
- ['/api/user/webdav-path', { path: '/Synthetic' }],
['/api/nextcloud/disconnect', {}]
]) {
const response = await request(route, { method: 'POST', role: 'user', body });