fix(calendar): one input for the auth code, and a louder warning

Two things reported after testing the flow.

There were two boxes and no way to tell which to use. The config
template's string branch dispatches widgets from an allow-list of names,
and anything missing from it falls through to a plain input type=text --
so the field rendered both the widget's own box and a stray one for the
same key. google-oauth is now on that list, which is all the widget ever
needed to render in place of the fallback rather than beside it.

And the warning that the redirect page fails to load was small grey text
under a link, which is where it is least likely to be read. It is now an
amber callout that leads with "The next page will fail to load. That is
expected." The failure lands at exactly the moment the user has to act
on it, and it looks precisely like the flow breaking rather than
working. The paste box is labelled too, rather than relying on a
placeholder that vanishes on focus.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
This commit is contained in:
ChuckBuilds
2026-08-13 09:12:56 -04:00
co-authored by Claude Opus 5
parent 7685e94ca5
commit 1b9ecc0f19
3 changed files with 53 additions and 7 deletions
@@ -81,6 +81,35 @@ class TestTheRoutesExistAtAll:
assert "'/plugins/calendar/list-calendars'" in source assert "'/plugins/calendar/list-calendars'" in source
assert "'/plugins/calendar/authenticate'" in source assert "'/plugins/calendar/authenticate'" in source
def test_the_oauth_widget_is_dispatched_not_rendered_as_a_text_box(self):
# The string branch of the config template dispatches on an allow-list
# of widget names; anything missing from it silently falls through to a
# plain <input type="text">. That produced two boxes on the calendar
# page -- the widget's own, and a stray one for the same field -- and
# no way to tell which to paste into.
template = (Path(project_root)
/ 'web_interface/templates/v3/partials/plugin_config.html'
).read_text(encoding='utf-8')
allow_list_line = [ln for ln in template.splitlines()
if "str_widget in [" in ln]
assert allow_list_line, "the string widget allow-list moved"
assert "'google-oauth'" in allow_list_line[0], allow_list_line[0]
def test_the_widget_script_is_served(self):
base = (Path(project_root) / 'web_interface/templates/v3/base.html'
).read_text(encoding='utf-8')
assert 'widgets/google-oauth.js' in base
def test_the_failed_page_is_called_out_loudly(self):
# The loopback redirect lands on a browser error page at exactly the
# moment the user has to act. In small grey text it gets missed and the
# flow reads as broken while it is working.
widget = (Path(project_root)
/ 'web_interface/static/v3/js/widgets/google-oauth.js'
).read_text(encoding='utf-8')
assert 'expected' in widget.lower()
assert 'amber' in widget, "the warning is not visually distinguished"
class TestItSaysWhatIsWrong: class TestItSaysWhatIsWrong:
def test_listing_without_a_token_asks_for_step_2(self, client): def test_listing_without_a_token_asks_for_step_2(self, client):
@@ -66,12 +66,28 @@
link.className = 'text-blue-400 underline text-sm break-all'; link.className = 'text-blue-400 underline text-sm break-all';
link.textContent = 'Open the Google consent screen'; link.textContent = 'Open the Google consent screen';
const hint = document.createElement('p'); // Deliberately loud. After consent the browser is redirected to a
hint.className = 'text-xs text-gray-400 mt-2'; // loopback address nothing is listening on, so it lands on a
hint.textContent = // browser error page -- which reads as a failure at exactly the
'After approving, your browser will try to open a page that fails ' // moment the user has to act on it. Said quietly in grey it gets
+ 'to load. That is expected. Copy its full address from the bar ' // missed, and the flow looks broken when it is working.
+ 'and paste it below.'; const hint = document.createElement('div');
hint.className =
'mt-3 p-3 rounded-md border border-amber-500/60 bg-amber-500/10';
hint.innerHTML =
'<p class="text-sm text-amber-300 font-semibold">'
+ '<i class="fas fa-triangle-exclamation"></i> '
+ 'The next page will fail to load. That is expected.</p>'
+ '<p class="text-xs text-amber-200/90 mt-1">'
+ 'After you approve access, Google sends your browser to '
+ '<code>127.0.0.1</code>, where nothing is running \u2014 so you will see '
+ '"This site can\u2019t be reached" or similar. Nothing has gone wrong. '
+ 'Copy the <strong>entire address</strong> out of the address bar '
+ '(it contains <code>?code=...</code>) and paste it in the box below.</p>';
const codeLabel = document.createElement('label');
codeLabel.className = 'block text-xs text-gray-300 mt-3';
codeLabel.textContent = 'Paste the address from that failed page here:';
const codeInput = document.createElement('input'); const codeInput = document.createElement('input');
codeInput.type = 'text'; codeInput.type = 'text';
@@ -150,6 +166,7 @@
step2.appendChild(link); step2.appendChild(link);
step2.appendChild(hint); step2.appendChild(hint);
step2.appendChild(codeLabel);
step2.appendChild(codeInput); step2.appendChild(codeInput);
step2.appendChild(finishBtn); step2.appendChild(finishBtn);
@@ -815,7 +815,7 @@
<i class="fas fa-info-circle mr-1"></i> <i class="fas fa-info-circle mr-1"></i>
Changes in the file manager save immediately — no need to click Save Configuration. Changes in the file manager save immediately — no need to click Save Configuration.
</p> </p>
{% elif str_widget in ['text-input', 'textarea', 'select-dropdown', 'toggle-switch', 'radio-group', 'date-picker', 'time-picker', 'slider', 'color-picker', 'email-input', 'url-input', 'password-input', 'font-selector', 'file-upload-single', 'plugin-file-manager'] %} {% elif str_widget in ['text-input', 'textarea', 'select-dropdown', 'toggle-switch', 'radio-group', 'date-picker', 'time-picker', 'slider', 'color-picker', 'email-input', 'url-input', 'password-input', 'font-selector', 'file-upload-single', 'plugin-file-manager', 'google-oauth'] %}
{# Render widget container #} {# Render widget container #}
<div id="{{ field_id }}_container" class="{{ str_widget }}-container"></div> <div id="{{ field_id }}_container" class="{{ str_widget }}-container"></div>
<script> <script>