- Shipped
- September 5, 2026 at 4:14 PM UTC
- Author
- Kamo
- Commit
- e09f341
The personal calendars panel asked for GET **************** which was never mapped -- so it failed on every open -- and for Google and Microsoft it had no way to authorize anything at all. This is the service half of making it work. The member travels in the OAuth state, and that is the only thing that separates the two flows. The app registration, the scopes and the registered redirect URI are all org-scoped and byte-identical for a personal connect and an organization-wide one, and the callback is sessionless -- so without a member id in `state` a person authorizing their own Google account would land the grant on the ORGANIZATION's row and repoint every colleague's sync at one person's calendar. Absent still means the organization, which is what every state written before this looks like. /oauth/url, /oauth/status and DELETE /oauth take ?member=true, and the member comes from the SESSION rather than the query -- a client naming a member id would be choosing whose calendar to connect. saveMemberOAuth upserts by (member, provider) rather than inserting like saveMemberIntegration does. Insert is right for a typed-in DAV server, since a person may keep two, and wrong for a grant: there is one Google account connected at a time, and re-consenting has to replace it rather than leave a second row for the sync jobs to both pick up. GET /member/{memberId} now exists and refuses any id but the caller's. Dropping the id and reading the session instead would have been simpler and wrong: asking for somebody else's calendars would then be answered with your own. There is no right that opens it -- a private Google grant is not an organization asset, so an administrator has no read on it either.