feat(api+client): linked-identities panel on the account screen (#216) #244

Merged
james merged 1 commit from 216-linked-identities-panel into main 2026-06-23 13:14:57 +00:00
Owner

Summary

Restore the "Sign-in methods" panel on the universal client's /account screen. The PWA had this; #185 deleted it. This is the universal-client rebuild against @carol/api-client per ADR-0027.

API

  • GET /api/account/identities — bundle:
    {
      "local": { "email": "…" } | null,
      "oauth": [{ "id": "…", "provider": "…", "email": "…" }],
      "available": [{ "id": "…", "label": "…", "kind": "oauth2"|"oidc" }]
    }
    
    available is the server's enabled OAuth/OIDC providers minus the providers the user is already linked to. That keeps the panel a single round-trip and avoids baking a build-time provider list into the client (which wouldn't match the configured server in self-hoster deployments).
  • DELETE /api/account/identities/oauth/{id} — enforces the same "last sign-in method" check the deleted PWA unlink-action.ts carried: refuse with 409 last_sign_in_method if removing this row would leave the user with zero ways to sign in. Cross-user attempts return 404 per the don't-leak-existence rule.
  • Both routes go through getAuthIdentity() so they accept session OR bearer (ADR-0027).
  • New zod DTO module apps/api/lib/dto/account-identities.ts; both routes registered in lib/api/openapi-routes.ts; last_sign_in_method listed in docs/api-conventions.md.

@carol/api-client

  • New hooks useAccountIdentities (query, key keys.account.identities) and useUnlinkAccountIdentity (mutation, invalidates on success) under packages/api-client/src/hooks/account-identities.ts.
  • Typed client regenerated against the updated spec; drift gate passes.

Client UI

New SignInMethodsCard sits between the header and the PATs card on /account:

  • Local identity: row shows "Email and password" with the user's email.
  • Linked OAuth identities: provider label + masked email + inline Unlink confirm (same shape as the PAT revoke). The Unlink affordance hides on the only remaining method; the server's 409 is the backstop.
  • Connect another: lists every available provider with a button that hits /api/auth/oauth/start?provider=…&next=/account. On web it's a same-origin navigation. On native it goes through expo-linking's openURL so the system browser carries the session cookie. A hint under the buttons documents the native handoff.
  • All copy through useTranslation("account"); new keys land in packages/i18n/messages/en.json. Spanish stays partial per ADR-0025.

Surprises / notable calls

  • Provider list source — server-driven, returned inline with the identities bundle. The ticket flagged this as the open question; bundling it kept the panel one round-trip and the env vars stay server-side where they belong.
  • Unlink-on-native UX — the universal-client design uses bearer tokens (ADR-0027), so the native client doesn't have a session cookie that /api/auth/oauth/start could detect. The start route's link branch hinges on getSession(req) returning a session; without that cookie the user lands in a signin flow instead. That's the same constraint the PWA's old panel had — linking is web-only today. The native button still opens the URL so a self-hoster who's web-signed-in already can complete the link in their browser, and the hint string under the buttons documents that handoff.

Test plan

  • pnpm install --frozen-lockfile
  • pnpm -F @carol/api typecheck
  • pnpm -F @carol/api lint
  • pnpm -F @carol/api test — 47 files / 567 tests, 11 new for the identities surface (happy path GET, GET after unlink, DELETE with multi-methods, DELETE with single method → 409, cross-user 404 on both routes, 404 on random id)
  • pnpm -F @carol/api openapi:check
  • pnpm -F @carol/api openapi:coverage — 56 registered (was 54)
  • pnpm -F @carol/api-client typecheck / lint / test / check
  • pnpm -F @carol/client typecheck / lint / test / export:web
  • Issue: #216
  • ADR: docs/adr/0015-oauth-account-linking.md
## Summary Restore the "Sign-in methods" panel on the universal client's `/account` screen. The PWA had this; #185 deleted it. This is the universal-client rebuild against `@carol/api-client` per ADR-0027. ### API - `GET /api/account/identities` — bundle: ```json { "local": { "email": "…" } | null, "oauth": [{ "id": "…", "provider": "…", "email": "…" }], "available": [{ "id": "…", "label": "…", "kind": "oauth2"|"oidc" }] } ``` `available` is the server's enabled OAuth/OIDC providers minus the providers the user is already linked to. That keeps the panel a single round-trip and avoids baking a build-time provider list into the client (which wouldn't match the configured server in self-hoster deployments). - `DELETE /api/account/identities/oauth/{id}` — enforces the same "last sign-in method" check the deleted PWA `unlink-action.ts` carried: refuse with `409 last_sign_in_method` if removing this row would leave the user with zero ways to sign in. Cross-user attempts return 404 per the don't-leak-existence rule. - Both routes go through `getAuthIdentity()` so they accept session OR bearer (ADR-0027). - New zod DTO module `apps/api/lib/dto/account-identities.ts`; both routes registered in `lib/api/openapi-routes.ts`; `last_sign_in_method` listed in `docs/api-conventions.md`. ### `@carol/api-client` - New hooks `useAccountIdentities` (query, key `keys.account.identities`) and `useUnlinkAccountIdentity` (mutation, invalidates on success) under `packages/api-client/src/hooks/account-identities.ts`. - Typed client regenerated against the updated spec; drift gate passes. ### Client UI New `SignInMethodsCard` sits between the header and the PATs card on `/account`: - **Local identity**: row shows "Email and password" with the user's email. - **Linked OAuth identities**: provider label + masked email + inline Unlink confirm (same shape as the PAT revoke). The Unlink affordance hides on the only remaining method; the server's 409 is the backstop. - **Connect another**: lists every `available` provider with a button that hits `/api/auth/oauth/start?provider=…&next=/account`. On web it's a same-origin navigation. On native it goes through `expo-linking`'s `openURL` so the system browser carries the session cookie. A hint under the buttons documents the native handoff. - All copy through `useTranslation("account")`; new keys land in `packages/i18n/messages/en.json`. Spanish stays partial per ADR-0025. ### Surprises / notable calls - **Provider list source** — server-driven, returned inline with the identities bundle. The ticket flagged this as the open question; bundling it kept the panel one round-trip and the env vars stay server-side where they belong. - **Unlink-on-native UX** — the universal-client design uses bearer tokens (ADR-0027), so the native client doesn't have a session cookie that `/api/auth/oauth/start` could detect. The start route's `link` branch hinges on `getSession(req)` returning a session; without that cookie the user lands in a `signin` flow instead. That's the same constraint the PWA's old panel had — linking is web-only today. The native button still opens the URL so a self-hoster who's web-signed-in already can complete the link in their browser, and the hint string under the buttons documents that handoff. ## Test plan - [x] `pnpm install --frozen-lockfile` - [x] `pnpm -F @carol/api typecheck` - [x] `pnpm -F @carol/api lint` - [x] `pnpm -F @carol/api test` — 47 files / 567 tests, 11 new for the identities surface (happy path GET, GET after unlink, DELETE with multi-methods, DELETE with single method → 409, cross-user 404 on both routes, 404 on random id) - [x] `pnpm -F @carol/api openapi:check` - [x] `pnpm -F @carol/api openapi:coverage` — 56 registered (was 54) - [x] `pnpm -F @carol/api-client typecheck` / `lint` / `test` / `check` - [x] `pnpm -F @carol/client typecheck` / `lint` / `test` / `export:web` ## Links - Issue: #216 - ADR: docs/adr/0015-oauth-account-linking.md
feat(api+client): linked-identities panel on the account screen (#216)
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 6s
PR / OSV-Scanner (pull_request) Successful in 1m33s
PR / pnpm audit (pull_request) Successful in 1m45s
PR / OpenAPI (pull_request) Successful in 1m56s
PR / Static analysis (pull_request) Successful in 1m57s
PR / Lint (pull_request) Successful in 2m30s
PR / Typecheck (pull_request) Successful in 2m46s
PR / Client (web export smoke) (pull_request) Successful in 2m52s
PR / Package age policy (soft) (pull_request) Successful in 54s
Secrets / gitleaks (pull_request) Successful in 54s
PR / Test (postgres) (pull_request) Successful in 2m56s
PR / Build (pull_request) Successful in 3m7s
PR / Trivy (image) (pull_request) Failing after 1m56s
PR / Coverage (soft) (pull_request) Successful in 1m54s
PR / Test (sqlite) (pull_request) Successful in 3m46s
32d00576c0
Surface sign-in methods on the universal client's /account screen.
The PWA had this; #185 deleted it. This restores the feature against
the universal client per ADR-0027:

- GET /api/account/identities returns { local, oauth, available } for
  the authed user. `available` is the server's configured providers
  minus what's already linked, so the panel knows what to offer for
  "Connect another" without a build-time provider list.
- DELETE /api/account/identities/oauth/{id} enforces the
  "last sign-in method" check from the deleted PWA server action:
  409 last_sign_in_method when the deletion would leave the user
  unable to sign in; cross-user attempts return 404 per the
  don't-leak-existence rule.
- New @carol/api-client hooks useAccountIdentities +
  useUnlinkAccountIdentity; key namespace keys.account.identities.
- New SignInMethodsCard on /account renders the bundle, the unlink
  affordance (with inline confirm) hides on the only remaining
  method, and "Connect another" hits the existing OAuth /start
  route. On native we open via expo-linking's openURL so the
  session cookie flows through the system browser.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

Trivy (container image)

Threshold: high  ·  Total findings: 121  ·  At/above threshold: 1

critical high medium low
0 1 50 70
severity id package installed / range fix
high CVE-2026-12151 undici 6.25.0 6.27.0, 7.28.0, 8.5.0
<!-- scanner-comment: trivy --> ### Trivy (container image) **Threshold:** `high` &nbsp;·&nbsp; **Total findings:** 121 &nbsp;·&nbsp; **At/above threshold:** 1 | critical | high | medium | low | |---:|---:|---:|---:| | 0 | 1 | 50 | 70 | | severity | id | package | installed / range | fix | |---|---|---|---|---| | high | [CVE-2026-12151](https://avd.aquasec.com/nvd/cve-2026-12151) | undici | 6.25.0 | `6.27.0, 7.28.0, 8.5.0` |

📊 Test coverage

Patch coverage: no testable lines changed.

Overall (app/, lib/, db/, excluding UI per ADR-0019):

Metric Value Soft target
Lines 83.2% ≥ 50%
Branches 76.3% ≥ 75%
Functions 91.6% informational

Soft thresholds per ADR-0019. Coverage is informational and does not block merge.

<!-- coverage-comment --> ## 📊 Test coverage **Patch coverage:** no testable lines changed. **Overall** (`app/`, `lib/`, `db/`, excluding UI per ADR-0019): | Metric | Value | Soft target | |---|---|---| | Lines | 83.2% ✅ | ≥ 50% | | Branches | 76.3% ✅ | ≥ 75% | | Functions | 91.6% | informational | Soft thresholds per [ADR-0019](docs/adr/0019-coverage-soft-targets.md). Coverage is informational and does not block merge.
james merged commit d525373048 into main 2026-06-23 13:14:57 +00:00
james deleted branch 216-linked-identities-panel 2026-06-23 13:14:57 +00:00
Sign in to join this conversation.
No description provided.