0.1.98 — Casdoor bind / unlink (post-phase-3 hardening)

Closes the account-linking gap left after 0.1.97. The phase-2 exchange endpoint already auto-links by email-iexact during sign-in; this round adds a manual bind/unlink path for the cases that can't auto-resolve:

  1. The Casdoor email differs from the Notechondria email.
  2. The user wants to deliberately disconnect.

Backend

New endpoints

  • POST /api/v1/auth/casdoor/bind/ (auth required). Body {"code": "<authz>"}. Exchanges the code, verifies the JWT, links Creator.casdoor_sub to request.user. Returns 409 when the sub is already on a different Creator (no silent transfer). Returns the standard auth_payload on success.
  • DELETE /api/v1/auth/casdoor/unlink/ (auth required). Idempotent. Clears Creator.casdoor_sub. Does NOT log the legacy session out. Returns {"casdoor_linked": false, "was_linked": <bool>}.

Settings surface

Settings.casdoor_linked (bool) added to the GET payload so the Connected Accounts UI can render the right state without an extra round-trip.

Tests (creators.tests.CasdoorAuthTests)

7 new cases added on top of the 8 from 0.1.96 — all 15 pass:

  • test_bind_endpoint_requires_auth
  • test_bind_endpoint_returns_503_in_shadow_mode
  • test_bind_endpoint_rejects_conflicting_sub — patches _build_sdk + verify_token to assert the 409 branch and confirm the calling user's casdoor_sub is NOT mutated.
  • test_bind_endpoint_happy_path_persists_link — same patch pattern, asserts 200 + casdoor_sub is persisted + auth_payload shape is returned.
  • test_unlink_endpoint_clears_sub
  • test_unlink_endpoint_idempotent
  • test_settings_payload_exposes_casdoor_linked

Dependency-pin guard

pip install casdoor>=1.41 accidentally pulled Django==6.0.4, which broke the legacy from django.utils.timezone import utc import in creators/migrations/0007_auto_20231213_1554.py. The fix on the dev side was pip install Django==4.2.10; the pin in backend/requirements.txt is unchanged. Operator action: if a deploy ends up with Django 5+ for any reason, the legacy migration will refuse to load. Long-term fix tracked under "Casdoor migration phase 5: cleanup" — the legacy migrations get folded once the Casdoor cutover removes their dependents.

Frontend

Shared

  • AuthClient gains casdoorBind(token, code) + casdoorUnlink(token). Editor / portal / planner each implement both via the same shape they use for the existing bind / unlink endpoints.
  • AppShellOAuthMixin.handleOAuthCallback reshaped: the state=casdoor branch now dispatches on intent instead of short-circuiting straight to the exchange endpoint. intent == 'bind' calls casdoorBind (and refuses to fall through to the legacy provider login when the session token is missing — same shape as the Google/GitHub bind guard).

Per-app

  • _ConnectedAccountsSection (3 forks: editor's settings_sections.dart, portal's + planner's settings.dart) gains an onBindCasdoor + onUnlinkCasdoor + casdoorLinked triple. Each renders a "Casdoor SSO" ListTile with shield-outlined leading icon and Link / Switch / Unlink affordances on the right.
  • _SettingsPage on each app gains the matching props, forwarding widget.settings?['casdoor_linked'] == true into the section.
  • app_shell (or editor's build_helpers.dart) constructs the callbacks when _casdoorConfigured && _token != null. Unlink fires the DELETE then locally flips _settings['casdoor_linked'] = false + refreshState() so the row updates without a Settings refetch.

Verification

  • 15/15 CasdoorAuthTests pass under settings_test.
  • flutter analyze clean across editor / portal / planner — zero new errors / warnings beyond pre-existing surface-deprecation lints unrelated to this round.

Carryover

  • Phase 4 cutover (disable LoginApiView / RegisterApiView etc.; freeze Session writes).
  • Phase 5 cleanup (delete every legacy auth class / serializer / template / helper enumerated in casdoor-migration.md).
  • 0.1.94 GitHub Sync open items.