diff --git a/test/web_interface/test_calendar_oauth_endpoints.py b/test/web_interface/test_calendar_oauth_endpoints.py
new file mode 100644
index 00000000..e7208cb8
--- /dev/null
+++ b/test/web_interface/test_calendar_oauth_endpoints.py
@@ -0,0 +1,212 @@
+"""Tests the calendar plugin's OAuth and calendar-listing endpoints.
+
+The plugin's config UI advertised a three-step setup, but only step 1 existed
+on the server. Step 3's picker fetched /api/v3/plugins/calendar/list-calendars,
+which was never registered, so Flask fell through to the global 404 handler and
+the user saw "Resource not found" — with nothing to say which resource. Step 2
+had no endpoint either, and no field in the schema at all, even though the
+plugin ships calendar_registration.py written expressly for a web-driven
+two-step flow.
+
+These cover the two new routes: that they exist, that they fail with something
+actionable rather than a bare 404, and that the shapes the widgets consume are
+what the server actually sends.
+"""
+
+import json
+import pickle
+import sys
+from pathlib import Path
+
+import pytest
+
+project_root = Path(__file__).parent.parent.parent
+sys.path.insert(0, str(project_root))
+
+from web_interface.blueprints import api_v3 as mod # noqa: E402
+
+
+@pytest.fixture
+def client(monkeypatch, tmp_path):
+ """A test client whose calendar plugin lives in tmp_path."""
+ from flask import Flask
+
+ plugin_dir = tmp_path / 'calendar'
+ plugin_dir.mkdir()
+
+ app = Flask(__name__)
+ app.register_blueprint(mod.api_v3, url_prefix='/api/v3')
+ app.config['TESTING'] = True
+ monkeypatch.setattr(mod, '_calendar_plugin_dir', lambda: plugin_dir)
+ with app.test_client() as c:
+ c.plugin_dir = plugin_dir
+ yield c
+
+
+@pytest.fixture
+def uninstalled(monkeypatch):
+ from flask import Flask
+
+ app = Flask(__name__)
+ app.register_blueprint(mod.api_v3, url_prefix='/api/v3')
+ app.config['TESTING'] = True
+ monkeypatch.setattr(mod, '_calendar_plugin_dir', lambda: None)
+ with app.test_client() as c:
+ yield c
+
+
+class TestTheRoutesExistAtAll:
+ """The original bug: the URLs the widgets call were not registered."""
+
+ def test_list_calendars_is_routed(self, client):
+ response = client.get('/api/v3/plugins/calendar/list-calendars')
+ # Reaching the handler is the whole point; what it then says about
+ # missing setup is TestItSaysWhatIsWrong's business.
+ assert response.status_code != 404, "still unrouted"
+ assert response.get_json()['message'] != 'Resource not found'
+
+ def test_authenticate_is_routed(self, client):
+ response = client.post('/api/v3/plugins/calendar/authenticate', json={})
+ assert response.status_code != 404, "still unrouted"
+ assert response.get_json()['message'] != 'Resource not found'
+
+ def test_both_urls_match_what_the_widgets_request(self):
+ # The widgets hardcode these; a rename on either side reintroduces the
+ # original bug silently.
+ picker = Path(project_root) / 'web_interface/static/v3/js/widgets/google-calendar-picker.js'
+ oauth = Path(project_root) / 'web_interface/static/v3/js/widgets/google-oauth.js'
+ assert '/api/v3/plugins/calendar/list-calendars' in picker.read_text(encoding='utf-8')
+ assert '/api/v3/plugins/calendar/authenticate' in oauth.read_text(encoding='utf-8')
+ source = (Path(project_root) / 'web_interface/blueprints/api_v3.py').read_text(encoding='utf-8')
+ assert "'/plugins/calendar/list-calendars'" in source
+ assert "'/plugins/calendar/authenticate'" in source
+
+
+class TestItSaysWhatIsWrong:
+ def test_listing_without_a_token_asks_for_step_2(self, client):
+ response = client.get('/api/v3/plugins/calendar/list-calendars')
+ assert response.status_code == 400
+ body = response.get_json()
+ assert body['status'] == 'error'
+ assert 'step 2' in body['message'].lower(), body['message']
+
+ def test_authenticating_without_credentials_asks_for_step_1(self, client):
+ response = client.post('/api/v3/plugins/calendar/authenticate', json={})
+ assert response.status_code == 400
+ assert 'step 1' in response.get_json()['message'].lower()
+
+ def test_an_uninstalled_plugin_says_so(self, uninstalled):
+ for response in (
+ uninstalled.get('/api/v3/plugins/calendar/list-calendars'),
+ uninstalled.post('/api/v3/plugins/calendar/authenticate', json={}),
+ ):
+ assert response.status_code == 404
+ # A 404 here is honest -- but it must name the plugin, not read as
+ # the generic "Resource not found" that started this.
+ assert 'not installed' in response.get_json()['message'].lower()
+
+
+class TestTheScriptRunner:
+ def test_it_returns_the_json_the_script_prints(self, tmp_path):
+ script = tmp_path / 'calendar_registration.py'
+ script.write_text(
+ 'print(\'{"status": "success", "auth_url": "https://x"}\')\n',
+ encoding='utf-8')
+ payload, error = mod._run_calendar_registration(tmp_path, '')
+ assert error is None
+ assert payload['auth_url'] == 'https://x'
+
+ def test_it_ignores_noise_before_the_json(self, tmp_path):
+ # An import warning or a library writing to stdout would otherwise
+ # make the last-line parse fail.
+ script = tmp_path / 'calendar_registration.py'
+ script.write_text(
+ 'print("some library warning")\n'
+ 'print(\'{"status": "success"}\')\n', encoding='utf-8')
+ payload, error = mod._run_calendar_registration(tmp_path, '')
+ assert error is None and payload['status'] == 'success'
+
+ def test_it_passes_stdin_through(self, tmp_path):
+ script = tmp_path / 'calendar_registration.py'
+ script.write_text(
+ 'import sys, json\n'
+ 'print(json.dumps({"status": "success", "got": sys.stdin.read().strip()}))\n',
+ encoding='utf-8')
+ payload, _ = mod._run_calendar_registration(tmp_path, 'http://127.0.0.1/?code=abc')
+ assert payload['got'] == 'http://127.0.0.1/?code=abc'
+
+ def test_a_missing_script_is_reported(self, tmp_path):
+ payload, error = mod._run_calendar_registration(tmp_path, '')
+ assert payload is None
+ assert 'script not found' in error.lower()
+
+ def test_output_that_is_not_json_is_reported_with_context(self, tmp_path):
+ script = tmp_path / 'calendar_registration.py'
+ script.write_text('import sys\nsys.stderr.write("boom\\n")\n', encoding='utf-8')
+ payload, error = mod._run_calendar_registration(tmp_path, '')
+ assert payload is None
+ assert 'no result' in error.lower()
+ assert 'boom' in error
+
+
+class TestListingShape:
+ """The picker reads cal.id, cal.summary and cal.primary."""
+
+ def _authenticate(self, client, monkeypatch, items):
+ creds = type('C', (), {'expired': False, 'refresh_token': None, 'valid': True})()
+ (client.plugin_dir / 'token.pickle').write_bytes(pickle.dumps({'x': 1}))
+ monkeypatch.setattr(mod.pickle if hasattr(mod, 'pickle') else pickle,
+ 'loads', lambda *a, **k: creds, raising=False)
+
+ import types
+ fake_pickle = types.SimpleNamespace(load=lambda f: creds, dump=lambda *a: None)
+ fake_build = lambda *a, **k: types.SimpleNamespace(
+ calendarList=lambda: types.SimpleNamespace(
+ list=lambda: types.SimpleNamespace(
+ execute=lambda: {'items': items})))
+
+ real_import = __builtins__['__import__'] if isinstance(__builtins__, dict) \
+ else __builtins__.__import__
+
+ def fake_import(name, *args, **kwargs):
+ if name == 'pickle':
+ return fake_pickle
+ if name == 'google.auth.transport.requests':
+ return types.SimpleNamespace(Request=object)
+ if name == 'googleapiclient.discovery':
+ return types.SimpleNamespace(build=fake_build)
+ return real_import(name, *args, **kwargs)
+
+ monkeypatch.setattr('builtins.__import__', fake_import)
+
+ def test_it_returns_id_summary_and_primary(self, client, monkeypatch):
+ self._authenticate(client, monkeypatch, [
+ {'id': 'b@x', 'summary': 'Work'},
+ {'id': 'a@x', 'summary': 'Personal', 'primary': True},
+ ])
+ body = client.get('/api/v3/plugins/calendar/list-calendars').get_json()
+ assert body['status'] == 'success'
+ assert {c['id'] for c in body['calendars']} == {'a@x', 'b@x'}
+ assert all(set(c) == {'id', 'summary', 'primary'} for c in body['calendars'])
+
+ def test_the_primary_calendar_comes_first(self, client, monkeypatch):
+ # Short list, but the one the user wants is almost always their own.
+ self._authenticate(client, monkeypatch, [
+ {'id': 'z@x', 'summary': 'Aardvarks'},
+ {'id': 'a@x', 'summary': 'Zebras', 'primary': True},
+ ])
+ body = client.get('/api/v3/plugins/calendar/list-calendars').get_json()
+ assert body['calendars'][0]['id'] == 'a@x'
+ assert body['calendars'][0]['primary'] is True
+
+ def test_a_calendar_without_a_name_still_lists(self, client, monkeypatch):
+ self._authenticate(client, monkeypatch, [{'id': 'noname@x'}])
+ body = client.get('/api/v3/plugins/calendar/list-calendars').get_json()
+ assert body['calendars'][0]['summary'] == 'noname@x'
+
+ def test_entries_without_an_id_are_dropped(self, client, monkeypatch):
+ # Nothing could be selected by such a row, and the checkbox value
+ # would be undefined.
+ self._authenticate(client, monkeypatch, [{'summary': 'ghost'}, {'id': 'real@x'}])
+ body = client.get('/api/v3/plugins/calendar/list-calendars').get_json()
+ assert [c['id'] for c in body['calendars']] == ['real@x']
diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py
index 6062b85d..5043d840 100644
--- a/web_interface/blueprints/api_v3.py
+++ b/web_interface/blueprints/api_v3.py
@@ -7312,6 +7312,186 @@ def upload_calendar_credentials():
logger.error('Error in upload_calendar_credentials', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
+def _calendar_plugin_dir() -> Optional[Path]:
+ """Where the calendar plugin is installed, or None if it is not."""
+ if api_v3.plugin_manager:
+ plugin_dir = api_v3.plugin_manager.get_plugin_directory('calendar')
+ else:
+ plugin_dir = PROJECT_ROOT / 'plugins' / 'calendar'
+ if not plugin_dir:
+ return None
+ plugin_dir = Path(plugin_dir)
+ return plugin_dir if plugin_dir.exists() else None
+
+
+def _run_calendar_registration(plugin_dir: Path, stdin_payload: str):
+ """Run the plugin's OAuth script and return the JSON object it prints.
+
+ The script decides between web and terminal mode by whether stdin is a
+ tty, so it must be given a pipe. It emits one JSON object on stdout; the
+ last parsable line is taken, because an import warning or a library's
+ stderr redirection can land in front of it.
+
+ Returns (payload, error_message). Exactly one is None.
+ """
+ script = plugin_dir / 'calendar_registration.py'
+ if not script.exists():
+ return None, 'Authentication script not found in the calendar plugin'
+
+ try:
+ result = subprocess.run( # nosec B603 - fixed script path inside the plugin dir
+ [sys.executable, str(script)],
+ input=stdin_payload,
+ capture_output=True,
+ text=True,
+ timeout=120,
+ cwd=str(plugin_dir),
+ )
+ except subprocess.TimeoutExpired:
+ return None, 'Authentication timed out after 120s'
+ except OSError as e:
+ return None, 'Could not run the authentication script: %s' % e
+
+ for line in reversed((result.stdout or '').splitlines()):
+ line = line.strip()
+ if not line:
+ continue
+ try:
+ payload = json.loads(line)
+ except json.JSONDecodeError:
+ continue
+ if isinstance(payload, dict):
+ return payload, None
+
+ detail = (result.stderr or result.stdout or '').strip()[:300]
+ return None, 'Authentication script produced no result%s' % (
+ ': %s' % detail if detail else '')
+
+
+@api_v3.route('/plugins/calendar/authenticate', methods=['POST'])
+def authenticate_calendar():
+ """Google OAuth for the calendar plugin, in the two steps it requires.
+
+ Step 1 (no body) returns the consent URL to open. Step 2 posts back the
+ URL Google redirected to -- it fails to load, because the redirect points
+ at a loopback address nothing is listening on, but the address bar carries
+ the authorization code -- and the script exchanges it for a token.
+
+ Two calls rather than one because the user has to visit Google in between.
+ The script persists the PKCE verifier from step 1 for step 2 to reuse; the
+ exchange fails with "Missing code verifier" otherwise.
+ """
+ try:
+ plugin_dir = _calendar_plugin_dir()
+ if plugin_dir is None:
+ return jsonify({
+ 'status': 'error',
+ 'message': 'The calendar plugin is not installed'
+ }), 404
+
+ if not (plugin_dir / 'credentials.json').exists():
+ return jsonify({
+ 'status': 'error',
+ 'message': ('No credentials.json yet. Upload your Google OAuth '
+ 'client file first (Step 1).')
+ }), 400
+
+ data = request.get_json(silent=True) or {}
+ redirect_url = (data.get('redirect_url') or data.get('code') or '').strip()
+
+ payload, error = _run_calendar_registration(plugin_dir, redirect_url)
+ if error:
+ return jsonify({'status': 'error', 'message': error}), 500
+ if payload.get('status') != 'success':
+ # The script's own diagnosis is more useful than anything that
+ # could be reconstructed here.
+ return jsonify(payload), 400
+ return jsonify(payload)
+
+ except Exception as e:
+ logger.error('Error in authenticate_calendar', exc_info=True)
+ return jsonify({'status': 'error',
+ 'message': 'An error occurred; see logs for details',
+ 'details': describe_exception(e)}), 500
+
+
+@api_v3.route('/plugins/calendar/list-calendars', methods=['GET'])
+def list_calendar_calendars():
+ """The calendars this account can see, for the config picker.
+
+ Reads the token the OAuth flow wrote rather than shelling out again: the
+ picker is used interactively and a subprocess per click is slower than the
+ API call it would be wrapping.
+ """
+ try:
+ plugin_dir = _calendar_plugin_dir()
+ if plugin_dir is None:
+ return jsonify({
+ 'status': 'error',
+ 'message': 'The calendar plugin is not installed'
+ }), 404
+
+ token_file = plugin_dir / 'token.pickle'
+ if not token_file.exists():
+ return jsonify({
+ 'status': 'error',
+ 'message': ('Not authenticated with Google yet. Complete Step 2 '
+ 'first, then load your calendars.')
+ }), 400
+
+ try:
+ import pickle
+ from google.auth.transport.requests import Request as GoogleRequest
+ from googleapiclient.discovery import build as build_google_service
+ except ImportError as e:
+ return jsonify({
+ 'status': 'error',
+ 'message': ('The Google API libraries are not installed. Install '
+ "the calendar plugin's requirements.txt. (%s)" % e)
+ }), 500
+
+ with open(token_file, 'rb') as handle:
+ # Written only by this plugin's own OAuth flow, into its own
+ # directory, and read here exactly as the plugin itself reads it.
+ creds = pickle.load(handle) # nosec B301 - locally generated token
+
+ if creds and creds.expired and creds.refresh_token:
+ creds.refresh(GoogleRequest())
+ with open(token_file, 'wb') as handle:
+ pickle.dump(creds, handle)
+ os.chmod(token_file, 0o600)
+
+ if not creds or not creds.valid:
+ return jsonify({
+ 'status': 'error',
+ 'message': ('Stored Google credentials are no longer valid. '
+ 'Run Step 2 again to re-authenticate.')
+ }), 400
+
+ service = build_google_service('calendar', 'v3', credentials=creds)
+ entries = service.calendarList().list().execute().get('items', [])
+
+ calendars = [{
+ 'id': entry.get('id'),
+ # The picker labels each row with summary and falls back to the id
+ # only in its own display, so send something either way.
+ 'summary': entry.get('summary') or entry.get('id'),
+ 'primary': bool(entry.get('primary', False)),
+ } for entry in entries if entry.get('id')]
+
+ # Primary first, then alphabetically: the list is usually short but the
+ # one the user wants is almost always their own calendar.
+ calendars.sort(key=lambda c: (not c['primary'], c['summary'].lower()))
+
+ return jsonify({'status': 'success', 'calendars': calendars})
+
+ except Exception as e:
+ logger.error('Error in list_calendar_calendars', exc_info=True)
+ return jsonify({'status': 'error',
+ 'message': 'An error occurred; see logs for details',
+ 'details': describe_exception(e)}), 500
+
+
@api_v3.route('/plugins/assets/delete', methods=['POST'])
def delete_plugin_asset():
"""Delete an asset file for a plugin"""
diff --git a/web_interface/static/v3/js/widgets/google-oauth.js b/web_interface/static/v3/js/widgets/google-oauth.js
new file mode 100644
index 00000000..e288adb7
--- /dev/null
+++ b/web_interface/static/v3/js/widgets/google-oauth.js
@@ -0,0 +1,167 @@
+/**
+ * Google OAuth Widget
+ *
+ * Step 2 of the calendar plugin's setup, between uploading the OAuth client
+ * file and picking calendars. Google will not let a headless device complete
+ * consent on its own, so the flow is necessarily two calls with a human in
+ * between:
+ *
+ * 1. POST /api/v3/plugins/calendar/authenticate with no body
+ * -> { auth_url } to open in a browser
+ * 2. the browser lands on a loopback address that fails to load; its URL
+ * carries the authorization code. POST it back as redirect_url
+ * -> the server exchanges it and writes token.pickle
+ *
+ * The failed page in step 2 is expected and is worth saying out loud, because
+ * it looks exactly like something went wrong.
+ *
+ * @module GoogleOAuthWidget
+ */
+
+(function () {
+ 'use strict';
+
+ if (typeof window.LEDMatrixWidgets === 'undefined') {
+ console.error('[GoogleOAuthWidget] LEDMatrixWidgets registry not found. Load registry.js first.');
+ return;
+ }
+
+ const ENDPOINT = '/api/v3/plugins/calendar/authenticate';
+
+ window.LEDMatrixWidgets.register('google-oauth', {
+ name: 'Google OAuth Widget',
+ version: '1.0.0',
+
+ /**
+ * @param {HTMLElement} container
+ * @param {Object} config - schema config (unused)
+ * @param {*} value - unused; this widget stores nothing
+ * @param {Object} options - { fieldId, pluginId, name }
+ */
+ render: function (container, config, value, options) {
+ const fieldId = options.fieldId;
+
+ // Nothing is stored in config by this step -- the result is
+ // token.pickle on the device -- but the form still expects a field.
+ const hidden = document.createElement('input');
+ hidden.type = 'hidden';
+ hidden.id = fieldId + '_hidden';
+ hidden.name = options.name;
+ hidden.value = value || '';
+
+ const startBtn = document.createElement('button');
+ startBtn.type = 'button';
+ startBtn.className = 'px-3 py-1.5 text-sm rounded-md bg-blue-600 hover:bg-blue-700 text-white';
+ startBtn.innerHTML = ' Connect Google Account';
+
+ const status = document.createElement('p');
+ status.className = 'text-xs text-gray-400 mt-2';
+
+ const step2 = document.createElement('div');
+ step2.className = 'mt-3 hidden';
+
+ const link = document.createElement('a');
+ link.target = '_blank';
+ link.rel = 'noopener noreferrer';
+ link.className = 'text-blue-400 underline text-sm break-all';
+ link.textContent = 'Open the Google consent screen';
+
+ const hint = document.createElement('p');
+ hint.className = 'text-xs text-gray-400 mt-2';
+ hint.textContent =
+ 'After approving, your browser will try to open a page that fails '
+ + 'to load. That is expected. Copy its full address from the bar '
+ + 'and paste it below.';
+
+ const codeInput = document.createElement('input');
+ codeInput.type = 'text';
+ codeInput.placeholder = 'http://127.0.0.1/?code=...';
+ codeInput.className =
+ 'mt-2 block w-full px-3 py-2 text-sm border border-gray-600 '
+ + 'rounded-md bg-gray-800 text-gray-100';
+
+ const finishBtn = document.createElement('button');
+ finishBtn.type = 'button';
+ finishBtn.className = 'mt-2 px-3 py-1.5 text-sm rounded-md bg-green-600 hover:bg-green-700 text-white';
+ finishBtn.innerHTML = ' Finish Authentication';
+
+ function say(message, kind) {
+ status.textContent = message;
+ status.className = 'text-xs mt-2 ' + (
+ kind === 'error' ? 'text-red-400'
+ : kind === 'success' ? 'text-green-400'
+ : 'text-gray-400');
+ }
+
+ function post(body) {
+ return fetch(ENDPOINT, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body || {})
+ }).then(function (r) {
+ return r.json().catch(function () {
+ // A non-JSON body here means the request never reached
+ // the handler -- worth saying so rather than "undefined".
+ return { status: 'error', message: 'Server returned ' + r.status };
+ });
+ });
+ }
+
+ startBtn.addEventListener('click', function () {
+ startBtn.disabled = true;
+ say('Requesting a consent link...');
+ post({}).then(function (data) {
+ startBtn.disabled = false;
+ if (data.status !== 'success' || !data.auth_url) {
+ say(data.message || 'Could not start authentication.', 'error');
+ return;
+ }
+ link.href = data.auth_url;
+ step2.classList.remove('hidden');
+ say(data.message || 'Open the link, approve, then paste the address back.');
+ }).catch(function (err) {
+ startBtn.disabled = false;
+ say('Request failed: ' + err.message, 'error');
+ });
+ });
+
+ finishBtn.addEventListener('click', function () {
+ const pasted = codeInput.value.trim();
+ if (!pasted) {
+ say('Paste the address your browser was redirected to.', 'error');
+ return;
+ }
+ finishBtn.disabled = true;
+ say('Exchanging the code with Google...');
+ post({ redirect_url: pasted }).then(function (data) {
+ finishBtn.disabled = false;
+ if (data.status !== 'success') {
+ say(data.message || 'Authentication failed.', 'error');
+ return;
+ }
+ say(data.message || 'Authenticated.', 'success');
+ step2.classList.add('hidden');
+ codeInput.value = '';
+ }).catch(function (err) {
+ finishBtn.disabled = false;
+ say('Request failed: ' + err.message, 'error');
+ });
+ });
+
+ step2.appendChild(link);
+ step2.appendChild(hint);
+ step2.appendChild(codeInput);
+ step2.appendChild(finishBtn);
+
+ container.appendChild(hidden);
+ container.appendChild(startBtn);
+ container.appendChild(status);
+ container.appendChild(step2);
+ },
+
+ getValue: function (fieldId) {
+ const hidden = document.getElementById(fieldId + '_hidden');
+ return hidden ? hidden.value : '';
+ }
+ });
+})();
diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html
index 28ed48b9..fb4cfa6a 100644
--- a/web_interface/templates/v3/base.html
+++ b/web_interface/templates/v3/base.html
@@ -987,6 +987,7 @@
+