diff --git a/docs/superpowers/plans/2026-04-26-auth-harmonization.md b/docs/superpowers/plans/2026-04-26-auth-harmonization.md
new file mode 100644
index 00000000..84f86093
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-26-auth-harmonization.md
@@ -0,0 +1,1167 @@
+# Auth Harmonization (Login Routing) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Replace today's `prompt=none → /login?local` trap with a capability-driven login UX so a fresh SaaS-provisioned tenant server lands users on Logto's hosted sign-in page, while preserving `?local` as an explicit admin-recovery escape hatch.
+
+**Architecture:** New unauthenticated `GET /api/v1/auth/capabilities` endpoint reports `{oidc:{enabled, providerName, primary}, localAccounts:{enabled, adminRecoveryOnly}}`. SPA renders LoginPage deterministically based on the response. Silent SSO (`prompt=none`) is removed; the IdP itself decides whether to silent-redirect when a session exists. `OidcCallback` no longer falls back to the local form on `login_required`.
+
+**Tech Stack:** Spring Boot 3.4 (Java 17, JUnit 5, AssertJ, Spring Boot Test + Testcontainers via `AbstractPostgresIT`, `@MockBean`), React 18 / TypeScript / TanStack Query / openapi-typescript / Vitest + React Testing Library, react-router v6.
+
+**Spec:** `docs/superpowers/specs/2026-04-26-auth-harmonization-design.md`
+
+**Deferred / out of scope:** MFA enrollment + enforcement, password reset for local accounts — tracked in [issue #154](https://gitea.siegeln.net/cameleer/cameleer-server/issues/154).
+
+---
+
+## File Structure
+
+| File | Purpose | Status |
+|---|---|---|
+| `cameleer-server-app/src/main/java/com/cameleer/server/app/security/OidcProviderNameDeriver.java` | Pure utility — issuer URI → display label (Logto / Keycloak / Auth0 / Okta / Single Sign-On) | New |
+| `cameleer-server-app/src/main/java/com/cameleer/server/app/dto/AuthCapabilitiesResponse.java` | DTO record `{oidc:{enabled, providerName, primary}, localAccounts:{enabled, adminRecoveryOnly}}` | New |
+| `cameleer-server-app/src/main/java/com/cameleer/server/app/security/AuthCapabilitiesController.java` | `GET /api/v1/auth/capabilities` (permit-all) | New |
+| `cameleer-server-app/src/test/java/com/cameleer/server/app/security/OidcProviderNameDeriverTest.java` | JUnit, no Spring — pattern-match coverage | New |
+| `cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AuthCapabilitiesControllerIT.java` | IT — extends `AbstractPostgresIT`, `@MockBean OidcConfigRepository`, drives via `TestRestTemplate` | New |
+| `ui/src/api/queries/auth.ts` | `useAuthCapabilities()` hook — `staleTime: Infinity` | New |
+| `ui/src/api/queries/auth.test.tsx` | Vitest — hook returns the capabilities body | New |
+| `ui/src/auth/LoginPage.tsx` | Rewrite render switch on capabilities; drop `prompt=none`; lazy `/auth/oidc/config` on click; admin-recovery banner | Modify |
+| `ui/src/auth/LoginPage.test.tsx` | Vitest + RTL — four render states + authorize URL has no `prompt=none` | New |
+| `ui/src/auth/LoginPage.module.css` | Add `.adminRecoveryBanner` + secondary `?local` link styles | Modify |
+| `ui/src/auth/OidcCallback.tsx` | Delete `login_required`/`interaction_required` → `?local` redirect block; replace with retry render | Modify |
+| `ui/src/api/openapi.json`, `ui/src/api/schema.d.ts` | Regenerated via `npm run generate-api:live` after the controller is up | Regenerated |
+| `.claude/rules/app-classes.md` | New "Auth (flat)" subsection documenting the controller | Modify |
+| `CLAUDE.md` | One-paragraph note under "Security" on the capability-gated login model | Modify |
+
+---
+
+## Task 1: `OidcProviderNameDeriver` utility (TDD)
+
+**Files:**
+- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/OidcProviderNameDeriver.java`
+- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/security/OidcProviderNameDeriverTest.java`
+
+- [ ] **Step 1: Write the failing test**
+
+```java
+package com.cameleer.server.app.security;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Unit tests for {@link OidcProviderNameDeriver}. No Spring context. */
+class OidcProviderNameDeriverTest {
+
+ @Test
+ void logtoIssuer_returnsLogto() {
+ assertThat(OidcProviderNameDeriver.deriveName("https://auth.logto.example/")).isEqualTo("Logto");
+ assertThat(OidcProviderNameDeriver.deriveName("https://logto.cameleer.local")).isEqualTo("Logto");
+ }
+
+ @Test
+ void keycloakIssuer_returnsKeycloak() {
+ assertThat(OidcProviderNameDeriver.deriveName("https://keycloak.example/realms/cameleer")).isEqualTo("Keycloak");
+ }
+
+ @Test
+ void auth0Issuer_returnsAuth0() {
+ assertThat(OidcProviderNameDeriver.deriveName("https://example.auth0.com/")).isEqualTo("Auth0");
+ }
+
+ @Test
+ void oktaIssuer_returnsOkta() {
+ assertThat(OidcProviderNameDeriver.deriveName("https://dev-123.okta.com/")).isEqualTo("Okta");
+ assertThat(OidcProviderNameDeriver.deriveName("https://login.oktapreview.com/")).isEqualTo("Okta");
+ }
+
+ @Test
+ void unknownIssuer_returnsGenericLabel() {
+ assertThat(OidcProviderNameDeriver.deriveName("https://idp.example.com/")).isEqualTo("Single Sign-On");
+ }
+
+ @Test
+ void blankOrNullIssuer_returnsGenericLabel() {
+ assertThat(OidcProviderNameDeriver.deriveName("")).isEqualTo("Single Sign-On");
+ assertThat(OidcProviderNameDeriver.deriveName(null)).isEqualTo("Single Sign-On");
+ assertThat(OidcProviderNameDeriver.deriveName(" ")).isEqualTo("Single Sign-On");
+ }
+
+ @Test
+ void malformedUri_returnsGenericLabel() {
+ assertThat(OidcProviderNameDeriver.deriveName("not a url")).isEqualTo("Single Sign-On");
+ }
+
+ @Test
+ void caseInsensitiveMatching() {
+ assertThat(OidcProviderNameDeriver.deriveName("https://AUTH.LOGTO.EXAMPLE/")).isEqualTo("Logto");
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `mvn -pl cameleer-server-app test -Dtest=OidcProviderNameDeriverTest -DfailIfNoTests=false`
+
+Expected: COMPILATION FAILURE — `OidcProviderNameDeriver` symbol not found.
+
+- [ ] **Step 3: Write minimal implementation**
+
+```java
+package com.cameleer.server.app.security;
+
+import java.net.URI;
+
+/**
+ * Pure utility — derives a display label for an OIDC provider from its issuer URI.
+ * Used by {@link AuthCapabilitiesController} so the SPA can render
+ * "Sign in with {providerName}" on the login page.
+ *
+ *
Pattern-match only — never network-discover. If the issuer doesn't match a
+ * known vendor pattern, we return the generic "Single Sign-On" label rather than
+ * leaking hostnames into the UI.
+ */
+public final class OidcProviderNameDeriver {
+
+ private static final String GENERIC = "Single Sign-On";
+
+ private OidcProviderNameDeriver() {}
+
+ public static String deriveName(String issuerUri) {
+ if (issuerUri == null || issuerUri.isBlank()) {
+ return GENERIC;
+ }
+ String host;
+ try {
+ URI uri = URI.create(issuerUri.trim());
+ host = uri.getHost();
+ } catch (IllegalArgumentException e) {
+ return GENERIC;
+ }
+ if (host == null || host.isBlank()) {
+ return GENERIC;
+ }
+ String h = host.toLowerCase();
+ if (h.contains("logto")) return "Logto";
+ if (h.contains("keycloak")) return "Keycloak";
+ if (h.endsWith("auth0.com")) return "Auth0";
+ if (h.endsWith("okta.com") || h.endsWith("oktapreview.com")) return "Okta";
+ return GENERIC;
+ }
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `mvn -pl cameleer-server-app test -Dtest=OidcProviderNameDeriverTest`
+
+Expected: 7 tests passing.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add cameleer-server-app/src/main/java/com/cameleer/server/app/security/OidcProviderNameDeriver.java \
+ cameleer-server-app/src/test/java/com/cameleer/server/app/security/OidcProviderNameDeriverTest.java
+git commit -m "feat(auth): OidcProviderNameDeriver — issuer URI → display label"
+```
+
+---
+
+## Task 2: `AuthCapabilitiesResponse` DTO
+
+**Files:**
+- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/dto/AuthCapabilitiesResponse.java`
+
+DTO has no behaviour, so no isolated unit test — it's exercised by the controller IT in Task 3.
+
+- [ ] **Step 1: Create the DTO**
+
+```java
+package com.cameleer.server.app.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+@Schema(description = "Authentication capabilities reported to the SPA so it can render the login page deterministically")
+public record AuthCapabilitiesResponse(
+ @Schema(description = "OIDC interactive login capability") Oidc oidc,
+ @Schema(description = "Local username/password account capability") LocalAccounts localAccounts
+) {
+
+ @Schema(description = "OIDC interactive login")
+ public record Oidc(
+ @Schema(description = "Whether OIDC is configured AND enabled") boolean enabled,
+ @Schema(description = "Best-effort display label, e.g. \"Logto\", \"Keycloak\", \"Single Sign-On\"") String providerName,
+ @Schema(description = "When true, OIDC is the canonical entry point and the SPA hides the local form unless ?local is set") boolean primary
+ ) {}
+
+ @Schema(description = "Local username/password accounts")
+ public record LocalAccounts(
+ @Schema(description = "Whether the local form is reachable at all") boolean enabled,
+ @Schema(description = "When true, the SPA gates the local form behind ?local with an admin-recovery banner") boolean adminRecoveryOnly
+ ) {}
+}
+```
+
+- [ ] **Step 2: Verify it compiles**
+
+Run: `mvn -pl cameleer-server-app compile -q`
+
+Expected: BUILD SUCCESS.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add cameleer-server-app/src/main/java/com/cameleer/server/app/dto/AuthCapabilitiesResponse.java
+git commit -m "feat(auth): AuthCapabilitiesResponse DTO"
+```
+
+---
+
+## Task 3: `AuthCapabilitiesController` (TDD)
+
+**Files:**
+- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/AuthCapabilitiesController.java`
+- Test: `cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AuthCapabilitiesControllerIT.java`
+
+- [ ] **Step 1: Write the failing IT**
+
+```java
+package com.cameleer.server.app.controller;
+
+import com.cameleer.server.app.AbstractPostgresIT;
+import com.cameleer.server.app.dto.AuthCapabilitiesResponse;
+import com.cameleer.server.core.security.OidcConfig;
+import com.cameleer.server.core.security.OidcConfigRepository;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+
+import java.util.List;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.when;
+
+/**
+ * Integration tests for {@link com.cameleer.server.app.security.AuthCapabilitiesController}.
+ * Mocks {@link OidcConfigRepository} so each test controls the OIDC state it observes.
+ */
+class AuthCapabilitiesControllerIT extends AbstractPostgresIT {
+
+ @Autowired private TestRestTemplate restTemplate;
+ @MockBean private OidcConfigRepository oidcConfigRepository;
+
+ @BeforeEach
+ void resetMock() {
+ when(oidcConfigRepository.find()).thenReturn(Optional.empty());
+ }
+
+ @Test
+ void noOidcConfig_returnsLocalOnlyCaps() {
+ when(oidcConfigRepository.find()).thenReturn(Optional.empty());
+
+ var resp = restTemplate.getForEntity("/api/v1/auth/capabilities", AuthCapabilitiesResponse.class);
+
+ assertThat(resp.getStatusCode().value()).isEqualTo(200);
+ assertThat(resp.getBody()).isNotNull();
+ assertThat(resp.getBody().oidc().enabled()).isFalse();
+ assertThat(resp.getBody().oidc().providerName()).isEqualTo("");
+ assertThat(resp.getBody().oidc().primary()).isFalse();
+ assertThat(resp.getBody().localAccounts().enabled()).isTrue();
+ assertThat(resp.getBody().localAccounts().adminRecoveryOnly()).isFalse();
+ }
+
+ @Test
+ void oidcDisabledRow_behavesLikeAbsent() {
+ OidcConfig disabled = new OidcConfig(false, "https://auth.logto.example/", "client-id", "secret",
+ "roles", List.of("VIEWER"), true, "name", "sub", "", List.of());
+ when(oidcConfigRepository.find()).thenReturn(Optional.of(disabled));
+
+ var resp = restTemplate.getForEntity("/api/v1/auth/capabilities", AuthCapabilitiesResponse.class);
+
+ assertThat(resp.getStatusCode().value()).isEqualTo(200);
+ assertThat(resp.getBody().oidc().enabled()).isFalse();
+ assertThat(resp.getBody().oidc().primary()).isFalse();
+ assertThat(resp.getBody().localAccounts().adminRecoveryOnly()).isFalse();
+ }
+
+ @Test
+ void oidcEnabledLogto_returnsOidcPrimaryWithProviderName() {
+ OidcConfig enabled = new OidcConfig(true, "https://auth.logto.example/", "client-id", "secret",
+ "roles", List.of("VIEWER"), true, "name", "sub", "", List.of());
+ when(oidcConfigRepository.find()).thenReturn(Optional.of(enabled));
+
+ var resp = restTemplate.getForEntity("/api/v1/auth/capabilities", AuthCapabilitiesResponse.class);
+
+ assertThat(resp.getStatusCode().value()).isEqualTo(200);
+ assertThat(resp.getBody().oidc().enabled()).isTrue();
+ assertThat(resp.getBody().oidc().providerName()).isEqualTo("Logto");
+ assertThat(resp.getBody().oidc().primary()).isTrue();
+ assertThat(resp.getBody().localAccounts().enabled()).isTrue();
+ assertThat(resp.getBody().localAccounts().adminRecoveryOnly()).isTrue();
+ }
+
+ @Test
+ void oidcEnabledUnknownProvider_returnsGenericProviderName() {
+ OidcConfig enabled = new OidcConfig(true, "https://idp.example.com/", "client-id", "secret",
+ "roles", List.of("VIEWER"), true, "name", "sub", "", List.of());
+ when(oidcConfigRepository.find()).thenReturn(Optional.of(enabled));
+
+ var resp = restTemplate.getForEntity("/api/v1/auth/capabilities", AuthCapabilitiesResponse.class);
+
+ assertThat(resp.getStatusCode().value()).isEqualTo(200);
+ assertThat(resp.getBody().oidc().providerName()).isEqualTo("Single Sign-On");
+ assertThat(resp.getBody().oidc().primary()).isTrue();
+ }
+
+ @Test
+ void endpointIsUnauthenticated() {
+ // No Authorization header — must still return 200, not 401.
+ var resp = restTemplate.getForEntity("/api/v1/auth/capabilities", String.class);
+ assertThat(resp.getStatusCode().value()).isEqualTo(200);
+ }
+}
+```
+
+- [ ] **Step 2: Run the IT to verify it fails**
+
+Run: `mvn -pl cameleer-server-app verify -Dit.test=AuthCapabilitiesControllerIT -DfailIfNoTests=false`
+
+Expected: COMPILATION FAILURE — `AuthCapabilitiesController` not found.
+
+- [ ] **Step 3: Implement the controller**
+
+```java
+package com.cameleer.server.app.security;
+
+import com.cameleer.server.app.dto.AuthCapabilitiesResponse;
+import com.cameleer.server.core.security.OidcConfig;
+import com.cameleer.server.core.security.OidcConfigRepository;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Optional;
+
+/**
+ * Reports auth capabilities so the SPA renders the login page deterministically
+ * instead of inferring from {@code GET /api/v1/auth/oidc/config} 200/404.
+ *
+ *
Unauthenticated by design — the SPA calls this before any sign-in attempt.
+ * Inherits permit-all from the {@code /api/v1/auth/**} matcher in
+ * {@link SecurityConfig}.
+ *
+ *
Future deferred work (issue #154) extends this same payload with MFA
+ * enrollment URL and password-reset URL fields.
+ */
+@RestController
+@RequestMapping("/api/v1/auth")
+@Tag(name = "Authentication", description = "Login and token refresh endpoints")
+public class AuthCapabilitiesController {
+
+ private final OidcConfigRepository oidcConfigRepository;
+
+ public AuthCapabilitiesController(OidcConfigRepository oidcConfigRepository) {
+ this.oidcConfigRepository = oidcConfigRepository;
+ }
+
+ @GetMapping("/capabilities")
+ @Operation(summary = "Auth capabilities for the SPA login page")
+ @ApiResponse(responseCode = "200", description = "Capabilities resolved")
+ public ResponseEntity getCapabilities() {
+ Optional config = oidcConfigRepository.find();
+ boolean oidcEnabled = config.isPresent() && config.get().enabled();
+ String providerName = oidcEnabled
+ ? OidcProviderNameDeriver.deriveName(config.get().issuerUri())
+ : "";
+
+ var oidc = new AuthCapabilitiesResponse.Oidc(oidcEnabled, providerName, oidcEnabled);
+ var local = new AuthCapabilitiesResponse.LocalAccounts(true, oidcEnabled);
+ return ResponseEntity.ok(new AuthCapabilitiesResponse(oidc, local));
+ }
+}
+```
+
+- [ ] **Step 4: Run the IT to verify it passes**
+
+Run: `mvn -pl cameleer-server-app verify -Dit.test=AuthCapabilitiesControllerIT`
+
+Expected: 5 tests passing.
+
+- [ ] **Step 5: Run the broader test suite to confirm nothing else regressed**
+
+Run: `mvn -pl cameleer-server-app verify -DskipITs=false`
+
+Expected: BUILD SUCCESS, no regressions.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add cameleer-server-app/src/main/java/com/cameleer/server/app/security/AuthCapabilitiesController.java \
+ cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AuthCapabilitiesControllerIT.java
+git commit -m "feat(auth): AuthCapabilitiesController — GET /api/v1/auth/capabilities"
+```
+
+---
+
+## Task 4: Regenerate OpenAPI types for the SPA
+
+**Files:**
+- Modify (regenerated): `ui/src/api/openapi.json`, `ui/src/api/schema.d.ts`
+
+Per `CLAUDE.md`: every controller change requires regenerating the SPA types.
+
+- [ ] **Step 1: Start the backend**
+
+In one terminal:
+
+```bash
+mvn -pl cameleer-server-app spring-boot:run
+```
+
+Wait for the line `Started CameleerServerApplication in N.NNN seconds`. The server listens on `:8081`.
+
+- [ ] **Step 2: Regenerate types**
+
+In another terminal:
+
+```bash
+cd ui && npm run generate-api:live
+```
+
+Expected: `openapi.json` updated, `schema.d.ts` regenerated. The new path `/api/v1/auth/capabilities` should appear in `openapi.json`.
+
+- [ ] **Step 3: Verify the type is exposed**
+
+```bash
+grep -c "/api/v1/auth/capabilities" ui/src/api/openapi.json
+```
+
+Expected: at least `1`.
+
+- [ ] **Step 4: Stop the backend**
+
+Ctrl-C in the backend terminal.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add ui/src/api/openapi.json ui/src/api/schema.d.ts
+git commit -m "chore(api): regenerate OpenAPI types for /auth/capabilities"
+```
+
+---
+
+## Task 5: `useAuthCapabilities()` hook (TDD)
+
+**Files:**
+- Create: `ui/src/api/queries/auth.ts`
+- Test: `ui/src/api/queries/auth.test.tsx`
+
+- [ ] **Step 1: Write the failing test**
+
+```tsx
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { renderHook, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import type { ReactNode } from 'react';
+
+vi.mock('../client', () => ({ api: { GET: vi.fn() } }));
+
+import { api as apiClient } from '../client';
+import { useAuthCapabilities } from './auth';
+
+function wrapper({ children }: { children: ReactNode }) {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return {children};
+}
+
+describe('useAuthCapabilities', () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ it('returns the capabilities body on success', async () => {
+ (apiClient.GET as any).mockResolvedValue({
+ data: {
+ oidc: { enabled: true, providerName: 'Logto', primary: true },
+ localAccounts: { enabled: true, adminRecoveryOnly: true },
+ },
+ error: null,
+ });
+
+ const { result } = renderHook(() => useAuthCapabilities(), { wrapper });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(result.current.data?.oidc.enabled).toBe(true);
+ expect(result.current.data?.oidc.providerName).toBe('Logto');
+ expect(result.current.data?.localAccounts.adminRecoveryOnly).toBe(true);
+ });
+
+ it('exposes error state when the request fails', async () => {
+ (apiClient.GET as any).mockResolvedValue({
+ data: undefined,
+ error: { message: 'boom' },
+ });
+
+ const { result } = renderHook(() => useAuthCapabilities(), { wrapper });
+
+ await waitFor(() => expect(result.current.isError).toBe(true));
+ });
+});
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `cd ui && npx vitest run src/api/queries/auth.test.tsx`
+
+Expected: FAIL — `useAuthCapabilities` cannot be imported (file doesn't exist).
+
+- [ ] **Step 3: Implement the hook**
+
+```ts
+import { useQuery } from '@tanstack/react-query';
+import { api } from '../client';
+import type { components } from '../schema';
+
+export type AuthCapabilities = components['schemas']['AuthCapabilitiesResponse'];
+
+export function useAuthCapabilities() {
+ return useQuery({
+ queryKey: ['auth', 'capabilities'],
+ queryFn: async () => {
+ const { data, error } = await api.GET('/auth/capabilities');
+ if (error || !data) throw new Error('Failed to load auth capabilities');
+ return data as AuthCapabilities;
+ },
+ staleTime: Infinity,
+ retry: false,
+ });
+}
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `cd ui && npx vitest run src/api/queries/auth.test.tsx`
+
+Expected: 2 tests passing.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add ui/src/api/queries/auth.ts ui/src/api/queries/auth.test.tsx
+git commit -m "feat(ui): useAuthCapabilities hook"
+```
+
+---
+
+## Task 6: Rewrite `LoginPage.tsx` (TDD)
+
+**Files:**
+- Modify: `ui/src/auth/LoginPage.tsx`
+- Modify: `ui/src/auth/LoginPage.module.css`
+- Test: `ui/src/auth/LoginPage.test.tsx`
+
+The four render states from the spec §4:
+
+| Caps state | `?local` | Result |
+|---|---|---|
+| `oidc.primary=true, adminRecoveryOnly=true` | absent | SSO button only, small "Admin recovery →" link |
+| `oidc.primary=true, adminRecoveryOnly=true` | present | local form + amber banner + "Back to SSO" link |
+| `oidc.enabled=false` | either | local form only |
+| caps load failed | either | local form + degraded banner |
+
+Crucial change: SSO button click builds the authorize URL **without** `prompt=none` and lazily fetches `/auth/oidc/config` only on click.
+
+- [ ] **Step 1: Write the failing test**
+
+```tsx
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { MemoryRouter } from 'react-router';
+import type { ReactNode } from 'react';
+
+vi.mock('../api/client', () => ({ api: { GET: vi.fn() } }));
+vi.mock('./auth-store', () => ({
+ useAuthStore: Object.assign(
+ () => ({ isAuthenticated: false, login: vi.fn(), loading: false, error: null }),
+ { setState: vi.fn() }
+ ),
+}));
+
+import { api as apiClient } from '../api/client';
+import { LoginPage } from './LoginPage';
+
+function wrapper(initialEntries: string[]) {
+ return ({ children }: { children: ReactNode }) => {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return (
+
+ {children}
+
+ );
+ };
+}
+
+function mockCaps(body: any) {
+ (apiClient.GET as any).mockImplementation((path: string) => {
+ if (path === '/auth/capabilities') return Promise.resolve({ data: body, error: null });
+ if (path === '/auth/oidc/config') return Promise.resolve({
+ data: {
+ clientId: 'spa-client',
+ authorizationEndpoint: 'https://auth.logto.example/oidc/auth',
+ resource: 'https://api.cameleer.local',
+ additionalScopes: [],
+ },
+ error: null,
+ });
+ return Promise.resolve({ data: undefined, error: { message: 'unexpected' } });
+ });
+}
+
+describe('LoginPage', () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ it('SSO primary, no ?local: renders SSO button only and admin-recovery link, no local form', async () => {
+ mockCaps({
+ oidc: { enabled: true, providerName: 'Logto', primary: true },
+ localAccounts: { enabled: true, adminRecoveryOnly: true },
+ });
+
+ render(, { wrapper: wrapper(['/login']) });
+
+ expect(await screen.findByRole('button', { name: /sign in with logto/i })).toBeInTheDocument();
+ expect(screen.queryByLabelText(/username/i)).toBeNull();
+ expect(screen.queryByLabelText(/password/i)).toBeNull();
+ expect(screen.getByRole('link', { name: /admin recovery/i })).toBeInTheDocument();
+ });
+
+ it('SSO primary, ?local present: renders local form with amber recovery banner and back-to-SSO link', async () => {
+ mockCaps({
+ oidc: { enabled: true, providerName: 'Logto', primary: true },
+ localAccounts: { enabled: true, adminRecoveryOnly: true },
+ });
+
+ render(, { wrapper: wrapper(['/login?local']) });
+
+ expect(await screen.findByLabelText(/username/i)).toBeInTheDocument();
+ expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
+ expect(screen.getByText(/admin recovery/i)).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: /back to sso/i })).toBeInTheDocument();
+ });
+
+ it('OIDC disabled: renders local form only, no SSO button', async () => {
+ mockCaps({
+ oidc: { enabled: false, providerName: '', primary: false },
+ localAccounts: { enabled: true, adminRecoveryOnly: false },
+ });
+
+ render(, { wrapper: wrapper(['/login']) });
+
+ expect(await screen.findByLabelText(/username/i)).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /sign in with/i })).toBeNull();
+ expect(screen.queryByText(/admin recovery/i)).toBeNull();
+ });
+
+ it('capabilities request fails: renders degraded local form with warning banner', async () => {
+ (apiClient.GET as any).mockImplementation((path: string) => {
+ if (path === '/auth/capabilities') return Promise.resolve({ data: undefined, error: { message: 'fail' } });
+ return Promise.resolve({ data: undefined, error: { message: 'unexpected' } });
+ });
+
+ render(, { wrapper: wrapper(['/login']) });
+
+ expect(await screen.findByLabelText(/username/i)).toBeInTheDocument();
+ expect(screen.getByText(/sign-in options couldn't load/i)).toBeInTheDocument();
+ });
+
+ it('SSO button click: navigates to authorize URL WITHOUT prompt=none', async () => {
+ mockCaps({
+ oidc: { enabled: true, providerName: 'Logto', primary: true },
+ localAccounts: { enabled: true, adminRecoveryOnly: true },
+ });
+
+ const originalLocation = window.location;
+ const hrefSetter = vi.fn();
+ Object.defineProperty(window, 'location', {
+ configurable: true,
+ value: { ...originalLocation, get href() { return ''; }, set href(v: string) { hrefSetter(v); } },
+ });
+
+ try {
+ render(, { wrapper: wrapper(['/login']) });
+ const btn = await screen.findByRole('button', { name: /sign in with logto/i });
+ fireEvent.click(btn);
+
+ await waitFor(() => expect(hrefSetter).toHaveBeenCalled());
+ const url: string = hrefSetter.mock.calls[0][0];
+ expect(url).toMatch(/^https:\/\/auth\.logto\.example\/oidc\/auth\?/);
+ expect(url).not.toMatch(/prompt=none/);
+ expect(url).toMatch(/response_type=code/);
+ expect(url).toMatch(/client_id=spa-client/);
+ expect(url).toMatch(/scope=/);
+ } finally {
+ Object.defineProperty(window, 'location', { configurable: true, value: originalLocation });
+ }
+ });
+});
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `cd ui && npx vitest run src/auth/LoginPage.test.tsx`
+
+Expected: FAIL — current `LoginPage.tsx` still uses the old `/auth/oidc/config`-only flow with `prompt=none`. The "Sign in with Logto" button name and admin-recovery link don't exist yet.
+
+- [ ] **Step 3: Implement the new LoginPage**
+
+```tsx
+import { type FormEvent, useMemo, useState } from 'react';
+import { Link, Navigate, useSearchParams } from 'react-router';
+import { useAuthStore } from './auth-store';
+import { api } from '../api/client';
+import { config } from '../config';
+import { useAuthCapabilities } from '../api/queries/auth';
+import { Card, Input, Button, Alert, FormField } from '@cameleer/design-system';
+import brandLogo from '@cameleer/design-system/assets/cameleer-logo.svg';
+import styles from './LoginPage.module.css';
+
+const PLATFORM_SCOPES = ['urn:logto:scope:organizations', 'urn:logto:scope:organization_roles'];
+
+const SUBTITLES = [
+ "Prove you're not a mirage",
+ "Only authorized cameleers beyond this dune",
+ "Halt, traveler — state your business",
+ "The caravan doesn't move without credentials",
+ "No hitchhikers on this caravan",
+ "This oasis requires a password",
+ "Camels remember faces. We use passwords.",
+ "You shall not pass... without logging in",
+ "The desert is vast. Your session has expired.",
+ "Another day, another dune to authenticate",
+ "Papers, please. The caravan master is watching.",
+ "Trust, but verify — ancient cameleer proverb",
+ "Even the Silk Road had checkpoints",
+ "Your camel is parked outside. Now identify yourself.",
+ "One does not simply walk into the dashboard",
+ "The sands shift, but your password shouldn't",
+ "Unauthorized access? In this economy?",
+ "Welcome back, weary traveler",
+ "The dashboard awaits on the other side of this dune",
+ "Keep calm and authenticate",
+ "Who goes there? Friend or rogue exchange?",
+ "Access denied looks the same in every desert",
+ "May your routes be green and your tokens valid",
+ "Forgot your password? That's between you and the dunes.",
+ "No ticket, no caravan",
+];
+
+export function LoginPage() {
+ const { isAuthenticated, login, loading, error } = useAuthStore();
+ const [searchParams] = useSearchParams();
+ const forceLocal = searchParams.has('local');
+ const subtitle = useMemo(() => SUBTITLES[Math.floor(Math.random() * SUBTITLES.length)], []);
+ const [username, setUsername] = useState('');
+ const [password, setPassword] = useState('');
+ const [oidcLoading, setOidcLoading] = useState(false);
+
+ const { data: caps, isError: capsFailed, isLoading: capsLoading } = useAuthCapabilities();
+
+ if (isAuthenticated) return ;
+ if (capsLoading) return null;
+
+ const oidcEnabled = caps?.oidc.enabled === true;
+ const adminRecoveryOnly = caps?.localAccounts.adminRecoveryOnly === true;
+ const providerName = caps?.oidc.providerName || 'Single Sign-On';
+
+ // Render decision
+ const showSsoButton = oidcEnabled;
+ const showLocalForm = !oidcEnabled || forceLocal || !adminRecoveryOnly || capsFailed;
+ const showAdminRecoveryBanner = oidcEnabled && adminRecoveryOnly && forceLocal;
+ const showSsoPrimary = oidcEnabled && adminRecoveryOnly && !forceLocal;
+
+ const handleSubmit = (e: FormEvent) => {
+ e.preventDefault();
+ login(username, password);
+ };
+
+ const handleOidcLogin = async () => {
+ setOidcLoading(true);
+ const { data } = await api.GET('/auth/oidc/config');
+ if (!data?.authorizationEndpoint || !data?.clientId) {
+ setOidcLoading(false);
+ useAuthStore.setState({ error: 'OIDC configuration unavailable. Try the local form via /login?local.' });
+ return;
+ }
+ if (data.endSessionEndpoint) {
+ localStorage.setItem('cameleer-oidc-end-session', data.endSessionEndpoint);
+ }
+ const redirectUri = `${window.location.origin}${config.basePath}oidc/callback`;
+ const scopes = ['openid', 'email', 'profile', ...PLATFORM_SCOPES, ...(data.additionalScopes || [])];
+ const params = new URLSearchParams({
+ response_type: 'code',
+ client_id: data.clientId,
+ redirect_uri: redirectUri,
+ scope: scopes.join(' '),
+ });
+ if (data.resource) params.set('resource', data.resource);
+ // Note: NO prompt=none. Per RFC 9700 §4.4, that's silent re-auth only;
+ // for first-time login it returns login_required, which traps users.
+ window.location.href = `${data.authorizationEndpoint}?${params}`;
+ };
+
+ return (
+
+
+
+
+
+ cameleer
+
+
{subtitle}
+
+ {capsFailed && (
+
+ Sign-in options couldn't load. Refresh or use the form below.
+
+ )}
+
+ {showAdminRecoveryBanner && (
+
+
+ Admin recovery login. Use SSO for normal sign-in.
+
+ ← Back to SSO
+