Implement OIDC authentication with Pocket-ID (P2.18)

Add OIDC/OAuth2 authentication support to the login page with automatic
provider detection and email/password fallback.

Features:
- Auto-detect OIDC provider via PocketBase listAuthMethods() API
- Display "Sign In with Pocket-ID" button when OIDC is configured
- Use PocketBase authWithOAuth2() popup-based OAuth2 flow
- Fall back to email/password form when OIDC not available
- Loading states during authentication
- Error handling with user-friendly messages

The implementation checks for available auth methods on page load and
conditionally renders either the OIDC button or the email/password form.
This allows production deployments to use OIDC while development
environments can continue using email/password.

Tests: 24 tests (10 new OIDC tests added)
- OIDC button rendering when provider configured
- OIDC authentication flow with authWithOAuth2
- Loading and error states for OIDC
- Fallback to email/password when OIDC unavailable

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-11 09:12:29 +00:00
parent 267d45f98a
commit 00d902a396
3 changed files with 441 additions and 85 deletions

View File

@@ -4,7 +4,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
## Current State Summary ## Current State Summary
### Overall Status: 707 tests passing across 40 test files ### Overall Status: 717 tests passing across 40 test files
### Library Implementation ### Library Implementation
| File | Status | Gap Analysis | | File | Status | Gap Analysis |
@@ -28,7 +28,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
| Health Check Endpoint | specs/observability.md | P2.15 | **COMPLETE** | | Health Check Endpoint | specs/observability.md | P2.15 | **COMPLETE** |
| Prometheus Metrics | specs/observability.md | P2.16 | **COMPLETE** | | Prometheus Metrics | specs/observability.md | P2.16 | **COMPLETE** |
| Structured Logging (pino) | specs/observability.md | P2.17 | **COMPLETE** | | Structured Logging (pino) | specs/observability.md | P2.17 | **COMPLETE** |
| OIDC Authentication | specs/authentication.md | P2.18 | Medium | | OIDC Authentication | specs/authentication.md | P2.18 | **COMPLETE** |
| Token Expiration Warnings | specs/email.md | P3.9 | **COMPLETE** | | Token Expiration Warnings | specs/email.md | P3.9 | **COMPLETE** |
### API Routes (17 total) ### API Routes (17 total)
@@ -529,27 +529,28 @@ Full feature set for production use.
- **Why:** Required for log aggregators and production debugging (per specs/observability.md) - **Why:** Required for log aggregators and production debugging (per specs/observability.md)
- **Next Step:** Integrate logger into API routes (can be done incrementally) - **Next Step:** Integrate logger into API routes (can be done incrementally)
### P2.18: OIDC Authentication ### P2.18: OIDC Authentication ✅ COMPLETE
- [ ] Replace email/password login with OIDC (Pocket-ID) - [x] Replace email/password login with OIDC (Pocket-ID)
- **Current State:** Using email/password form, no OIDC code exists
- **Files:** - **Files:**
- `src/app/login/page.tsx` - Replace form with "Sign In with Pocket-ID" button - `src/app/login/page.tsx` - OIDC button with email/password fallback
- `src/lib/pocketbase.ts` - Add OIDC redirect and callback handling
- **Tests:** - **Tests:**
- Update `src/app/login/page.test.tsx` - Tests for OIDC redirect flow - `src/app/login/page.test.tsx` - 24 tests (10 new OIDC tests)
- **Features Implemented:**
- Auto-detection of OIDC provider via `listAuthMethods()` API
- "Sign In with Pocket-ID" button when OIDC provider is configured
- Email/password form fallback when OIDC is not available
- PocketBase `authWithOAuth2()` popup-based OAuth2 flow
- Loading states during authentication
- Error handling with user-friendly messages
- **Flow:** - **Flow:**
1. User clicks "Sign In with Pocket-ID" 1. Page checks for available auth methods on mount
2. Redirect to Pocket-ID authorization endpoint 2. If OIDC provider configured, shows "Sign In with Pocket-ID" button
3. User authenticates (MFA if configured) 3. User clicks button, PocketBase handles OAuth2 popup flow
4. Callback with authorization code 4. On success, user redirected to dashboard
5. PocketBase exchanges code for tokens 5. Falls back to email/password when OIDC not available
6. Redirect to dashboard - **Environment Variables (configured in PocketBase Admin):**
- **Environment Variables:** - Client ID, Client Secret, Issuer URL configured in PocketBase
- `POCKETBASE_OIDC_CLIENT_ID`
- `POCKETBASE_OIDC_CLIENT_SECRET`
- `POCKETBASE_OIDC_ISSUER_URL`
- **Why:** Required per specs/authentication.md for secure identity management - **Why:** Required per specs/authentication.md for secure identity management
- **Note:** Current email/password implementation works but OIDC is the production requirement
--- ---
@@ -797,7 +798,6 @@ P4.* UX Polish ────────> After core functionality complete
| Priority | Task | Effort | Notes | | Priority | Task | Effort | Notes |
|----------|------|--------|-------| |----------|------|--------|-------|
| Medium | P2.18 OIDC Auth | Large | Production auth requirement |
| Low | P3.7 Error Handling | Small | Polish | | Low | P3.7 Error Handling | Small | Polish |
| Low | P3.8 Loading States | Small | Polish | | Low | P3.8 Loading States | Small | Polish |
| Low | P4.* UX Polish | Various | After core complete | | Low | P4.* UX Polish | Various | After core complete |
@@ -810,7 +810,6 @@ P4.* UX Polish ────────> After core functionality complete
| P0.2 | P0.1 | P0.4, P1.1-P1.5, P2.2-P2.3, P2.7-P2.8 | | P0.2 | P0.1 | P0.4, P1.1-P1.5, P2.2-P2.3, P2.7-P2.8 |
| P0.3 | - | P1.4, P1.5 | | P0.3 | - | P1.4, P1.5 |
| P0.4 | P0.1, P0.2 | P1.7, P2.9, P2.10, P2.13 | | P0.4 | P0.1, P0.2 | P1.7, P2.9, P2.10, P2.13 |
| P2.18 | P1.6 | - |
| P3.9 | P2.4 | - | | P3.9 | P2.4 | - |
--- ---
@@ -860,7 +859,7 @@ P4.* UX Polish ────────> After core functionality complete
- [x] **GET /metrics** - Prometheus metrics endpoint with counters, gauges, histograms, 33 tests (18 lib + 15 route) (P2.16) - [x] **GET /metrics** - Prometheus metrics endpoint with counters, gauges, histograms, 33 tests (18 lib + 15 route) (P2.16)
### Pages (7 complete) ### Pages (7 complete)
- [x] **Login Page** - Email/password form with PocketBase auth, error handling, loading states, redirect, 14 tests (P1.6) - [x] **Login Page** - OIDC (Pocket-ID) with email/password fallback, error handling, loading states, redirect, 24 tests (P1.6, P2.18)
- [x] **Dashboard Page** - Complete daily interface with /api/today integration, DecisionCard, DataPanel, NutritionPanel, OverrideToggles, 23 tests (P1.7) - [x] **Dashboard Page** - Complete daily interface with /api/today integration, DecisionCard, DataPanel, NutritionPanel, OverrideToggles, 23 tests (P1.7)
- [x] **Settings Page** - Form for cycleLength, notificationTime, timezone with validation, loading states, error handling, 28 tests (P2.9) - [x] **Settings Page** - Form for cycleLength, notificationTime, timezone with validation, loading states, error handling, 28 tests (P2.9)
- [x] **Settings/Garmin Page** - Token input form, connection status, expiry warnings, disconnect functionality, 27 tests (P2.10) - [x] **Settings/Garmin Page** - Token input form, connection status, expiry warnings, disconnect functionality, 27 tests (P2.10)
@@ -909,7 +908,7 @@ P4.* UX Polish ────────> After core functionality complete
10. **Token Warnings:** Per spec, warnings are sent at exactly 14 days and 7 days before expiry (P3.9 COMPLETE) 10. **Token Warnings:** Per spec, warnings are sent at exactly 14 days and 7 days before expiry (P3.9 COMPLETE)
11. **Health Check Priority:** P2.15 (GET /api/health) should be implemented early - it's required for deployment monitoring and load balancer health probes 11. **Health Check Priority:** P2.15 (GET /api/health) should be implemented early - it's required for deployment monitoring and load balancer health probes
12. **Structured Logging:** P2.17 (pino logger) is COMPLETE - new code should use `import { logger } from "@/lib/logger"` for all logging 12. **Structured Logging:** P2.17 (pino logger) is COMPLETE - new code should use `import { logger } from "@/lib/logger"` for all logging
13. **OIDC vs Email/Password:** Current email/password login (P1.6) works for development. P2.18 upgrades to OIDC for production security per specs/authentication.md 13. **OIDC Authentication:** P2.18 COMPLETE - Login page auto-detects OIDC via `listAuthMethods()` and shows "Sign In with Pocket-ID" button when configured. Falls back to email/password when OIDC not available. Configure OIDC provider in PocketBase Admin under Settings → Auth providers → OpenID Connect
14. **E2E Tests:** Authorized skip per specs/testing.md - unit and integration tests are sufficient for MVP 14. **E2E Tests:** Authorized skip per specs/testing.md - unit and integration tests are sufficient for MVP
15. **Dark Mode:** Partial Tailwind support exists via dark: classes but may need prefers-color-scheme configuration in tailwind.config.js (see P4.3) 15. **Dark Mode:** Partial Tailwind support exists via dark: classes but may need prefers-color-scheme configuration in tailwind.config.js (see P4.3)
16. **Component Tests:** P3.11 COMPLETE - All 5 dashboard and calendar components now have comprehensive unit tests (82 tests total) 16. **Component Tests:** P3.11 COMPLETE - All 5 dashboard and calendar components now have comprehensive unit tests (82 tests total)

View File

@@ -13,10 +13,14 @@ vi.mock("next/navigation", () => ({
// Mock PocketBase // Mock PocketBase
const mockAuthWithPassword = vi.fn(); const mockAuthWithPassword = vi.fn();
const mockAuthWithOAuth2 = vi.fn();
const mockListAuthMethods = vi.fn();
vi.mock("@/lib/pocketbase", () => ({ vi.mock("@/lib/pocketbase", () => ({
pb: { pb: {
collection: () => ({ collection: () => ({
authWithPassword: mockAuthWithPassword, authWithPassword: mockAuthWithPassword,
authWithOAuth2: mockAuthWithOAuth2,
listAuthMethods: mockListAuthMethods,
}), }),
}, },
})); }));
@@ -26,22 +30,33 @@ import LoginPage from "./page";
describe("LoginPage", () => { describe("LoginPage", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
// Default: no OIDC configured, show email/password form
mockListAuthMethods.mockResolvedValue({
oauth2: {
enabled: false,
providers: [],
},
});
}); });
describe("rendering", () => { describe("rendering", () => {
it("renders the login form with email and password inputs", () => { it("renders the login form with email and password inputs", async () => {
render(<LoginPage />); render(<LoginPage />);
expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); await waitFor(() => {
expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
});
}); });
it("renders a sign in button", () => { it("renders a sign in button", async () => {
render(<LoginPage />); render(<LoginPage />);
expect( await waitFor(() => {
screen.getByRole("button", { name: /sign in/i }), expect(
).toBeInTheDocument(); screen.getByRole("button", { name: /sign in/i }),
).toBeInTheDocument();
});
}); });
it("renders the PhaseFlow branding", () => { it("renders the PhaseFlow branding", () => {
@@ -50,18 +65,22 @@ describe("LoginPage", () => {
expect(screen.getByText(/phaseflow/i)).toBeInTheDocument(); expect(screen.getByText(/phaseflow/i)).toBeInTheDocument();
}); });
it("has email input with type email", () => { it("has email input with type email", async () => {
render(<LoginPage />); render(<LoginPage />);
const emailInput = screen.getByLabelText(/email/i); await waitFor(() => {
expect(emailInput).toHaveAttribute("type", "email"); const emailInput = screen.getByLabelText(/email/i);
expect(emailInput).toHaveAttribute("type", "email");
});
}); });
it("has password input with type password", () => { it("has password input with type password", async () => {
render(<LoginPage />); render(<LoginPage />);
const passwordInput = screen.getByLabelText(/password/i); await waitFor(() => {
expect(passwordInput).toHaveAttribute("type", "password"); const passwordInput = screen.getByLabelText(/password/i);
expect(passwordInput).toHaveAttribute("type", "password");
});
}); });
}); });
@@ -70,6 +89,11 @@ describe("LoginPage", () => {
mockAuthWithPassword.mockResolvedValueOnce({ token: "test-token" }); mockAuthWithPassword.mockResolvedValueOnce({ token: "test-token" });
render(<LoginPage />); render(<LoginPage />);
// Wait for auth check to complete
await waitFor(() => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
const emailInput = screen.getByLabelText(/email/i); const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i); const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole("button", { name: /sign in/i }); const submitButton = screen.getByRole("button", { name: /sign in/i });
@@ -90,6 +114,11 @@ describe("LoginPage", () => {
mockAuthWithPassword.mockResolvedValueOnce({ token: "test-token" }); mockAuthWithPassword.mockResolvedValueOnce({ token: "test-token" });
render(<LoginPage />); render(<LoginPage />);
// Wait for auth check to complete
await waitFor(() => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
const emailInput = screen.getByLabelText(/email/i); const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i); const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole("button", { name: /sign in/i }); const submitButton = screen.getByRole("button", { name: /sign in/i });
@@ -113,6 +142,11 @@ describe("LoginPage", () => {
render(<LoginPage />); render(<LoginPage />);
// Wait for auth check to complete
await waitFor(() => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
const emailInput = screen.getByLabelText(/email/i); const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i); const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole("button", { name: /sign in/i }); const submitButton = screen.getByRole("button", { name: /sign in/i });
@@ -141,6 +175,11 @@ describe("LoginPage", () => {
render(<LoginPage />); render(<LoginPage />);
// Wait for auth check to complete
await waitFor(() => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
const emailInput = screen.getByLabelText(/email/i); const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i); const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole("button", { name: /sign in/i }); const submitButton = screen.getByRole("button", { name: /sign in/i });
@@ -166,6 +205,11 @@ describe("LoginPage", () => {
); );
render(<LoginPage />); render(<LoginPage />);
// Wait for auth check to complete
await waitFor(() => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
const emailInput = screen.getByLabelText(/email/i); const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i); const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole("button", { name: /sign in/i }); const submitButton = screen.getByRole("button", { name: /sign in/i });
@@ -186,6 +230,11 @@ describe("LoginPage", () => {
); );
render(<LoginPage />); render(<LoginPage />);
// Wait for auth check to complete
await waitFor(() => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
const emailInput = screen.getByLabelText(/email/i); const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i); const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole("button", { name: /sign in/i }); const submitButton = screen.getByRole("button", { name: /sign in/i });
@@ -210,6 +259,11 @@ describe("LoginPage", () => {
); );
render(<LoginPage />); render(<LoginPage />);
// Wait for auth check to complete
await waitFor(() => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
const emailInput = screen.getByLabelText(/email/i); const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i); const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole("button", { name: /sign in/i }); const submitButton = screen.getByRole("button", { name: /sign in/i });
@@ -235,6 +289,11 @@ describe("LoginPage", () => {
it("does not submit with empty email", async () => { it("does not submit with empty email", async () => {
render(<LoginPage />); render(<LoginPage />);
// Wait for auth check to complete
await waitFor(() => {
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
});
const passwordInput = screen.getByLabelText(/password/i); const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole("button", { name: /sign in/i }); const submitButton = screen.getByRole("button", { name: /sign in/i });
@@ -248,6 +307,11 @@ describe("LoginPage", () => {
it("does not submit with empty password", async () => { it("does not submit with empty password", async () => {
render(<LoginPage />); render(<LoginPage />);
// Wait for auth check to complete
await waitFor(() => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
const emailInput = screen.getByLabelText(/email/i); const emailInput = screen.getByLabelText(/email/i);
const submitButton = screen.getByRole("button", { name: /sign in/i }); const submitButton = screen.getByRole("button", { name: /sign in/i });
@@ -258,4 +322,223 @@ describe("LoginPage", () => {
expect(mockAuthWithPassword).not.toHaveBeenCalled(); expect(mockAuthWithPassword).not.toHaveBeenCalled();
}); });
}); });
describe("OIDC authentication", () => {
beforeEach(() => {
// Configure OIDC provider available
mockListAuthMethods.mockResolvedValue({
oauth2: {
enabled: true,
providers: [
{
name: "oidc",
displayName: "Pocket-ID",
state: "test-state",
codeVerifier: "test-verifier",
codeChallenge: "test-challenge",
codeChallengeMethod: "S256",
authURL: "https://id.example.com/auth",
},
],
},
});
});
it("shows OIDC button when provider is configured", async () => {
render(<LoginPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /sign in with pocket-id/i }),
).toBeInTheDocument();
});
});
it("hides email/password form when OIDC is available", async () => {
render(<LoginPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /sign in with pocket-id/i }),
).toBeInTheDocument();
});
// Email/password form should not be visible
expect(screen.queryByLabelText(/email/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/password/i)).not.toBeInTheDocument();
});
it("calls authWithOAuth2 when OIDC button is clicked", async () => {
mockAuthWithOAuth2.mockResolvedValueOnce({ token: "test-token" });
render(<LoginPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /sign in with pocket-id/i }),
).toBeInTheDocument();
});
const oidcButton = screen.getByRole("button", {
name: /sign in with pocket-id/i,
});
fireEvent.click(oidcButton);
await waitFor(() => {
expect(mockAuthWithOAuth2).toHaveBeenCalledWith({ provider: "oidc" });
});
});
it("redirects to dashboard on successful OIDC login", async () => {
mockAuthWithOAuth2.mockResolvedValueOnce({
token: "test-token",
record: { id: "user-123" },
});
render(<LoginPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /sign in with pocket-id/i }),
).toBeInTheDocument();
});
const oidcButton = screen.getByRole("button", {
name: /sign in with pocket-id/i,
});
fireEvent.click(oidcButton);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith("/");
});
});
it("shows loading state during OIDC authentication", async () => {
let resolveAuth: (value: unknown) => void = () => {};
const authPromise = new Promise((resolve) => {
resolveAuth = resolve;
});
mockAuthWithOAuth2.mockReturnValue(authPromise);
render(<LoginPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /sign in with pocket-id/i }),
).toBeInTheDocument();
});
const oidcButton = screen.getByRole("button", {
name: /sign in with pocket-id/i,
});
fireEvent.click(oidcButton);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /signing in/i }),
).toBeInTheDocument();
expect(screen.getByRole("button")).toBeDisabled();
});
resolveAuth({ token: "test-token" });
});
it("shows error message on OIDC failure", async () => {
mockAuthWithOAuth2.mockRejectedValueOnce(
new Error("Authentication cancelled"),
);
render(<LoginPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /sign in with pocket-id/i }),
).toBeInTheDocument();
});
const oidcButton = screen.getByRole("button", {
name: /sign in with pocket-id/i,
});
fireEvent.click(oidcButton);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(
screen.getByText(/authentication cancelled/i),
).toBeInTheDocument();
});
});
it("re-enables OIDC button after error", async () => {
mockAuthWithOAuth2.mockRejectedValueOnce(
new Error("Authentication cancelled"),
);
render(<LoginPage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /sign in with pocket-id/i }),
).toBeInTheDocument();
});
const oidcButton = screen.getByRole("button", {
name: /sign in with pocket-id/i,
});
fireEvent.click(oidcButton);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
});
// Button should be re-enabled
expect(
screen.getByRole("button", { name: /sign in with pocket-id/i }),
).not.toBeDisabled();
});
});
describe("fallback to email/password", () => {
it("shows email/password form when OIDC is not configured", async () => {
mockListAuthMethods.mockResolvedValue({
oauth2: {
enabled: false,
providers: [],
},
});
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
});
});
it("shows email/password form when listAuthMethods fails", async () => {
mockListAuthMethods.mockRejectedValue(new Error("Network error"));
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
});
});
it("does not show OIDC button when no providers configured", async () => {
mockListAuthMethods.mockResolvedValue({
oauth2: {
enabled: false,
providers: [],
},
});
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
});
expect(
screen.queryByRole("button", { name: /sign in with pocket-id/i }),
).not.toBeInTheDocument();
});
});
}); });

View File

@@ -1,18 +1,65 @@
// ABOUTME: Login page for user authentication. // ABOUTME: Login page for user authentication.
// ABOUTME: Provides email/password login form using PocketBase auth. // ABOUTME: Provides OIDC (Pocket-ID) login with email/password fallback.
"use client"; "use client";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { type FormEvent, useState } from "react"; import { type FormEvent, useEffect, useState } from "react";
import { pb } from "@/lib/pocketbase"; import { pb } from "@/lib/pocketbase";
interface AuthProvider {
name: string;
displayName: string;
state: string;
codeVerifier: string;
codeChallenge: string;
codeChallengeMethod: string;
authURL: string;
}
export default function LoginPage() { export default function LoginPage() {
const router = useRouter(); const router = useRouter();
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isCheckingAuth, setIsCheckingAuth] = useState(true);
const [oidcProvider, setOidcProvider] = useState<AuthProvider | null>(null);
// Check available auth methods on mount
useEffect(() => {
const checkAuthMethods = async () => {
try {
const authMethods = await pb.collection("users").listAuthMethods();
const oidc = authMethods.oauth2?.providers?.find(
(p: AuthProvider) => p.name === "oidc",
);
if (oidc) {
setOidcProvider(oidc);
}
} catch {
// If listAuthMethods fails, fall back to email/password
} finally {
setIsCheckingAuth(false);
}
};
checkAuthMethods();
}, []);
const handleOidcLogin = async () => {
setIsLoading(true);
setError(null);
try {
await pb.collection("users").authWithOAuth2({ provider: "oidc" });
router.push("/");
} catch (err) {
const message =
err instanceof Error ? err.message : "Authentication failed";
setError(message);
setIsLoading(false);
}
};
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => { const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
@@ -47,65 +94,92 @@ export default function LoginPage() {
} }
}; };
// Show loading state while checking auth methods
if (isCheckingAuth) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="w-full max-w-md space-y-8 p-8">
<h1 className="text-2xl font-bold text-center">PhaseFlow</h1>
<div className="text-center text-gray-500">Loading...</div>
</div>
</div>
);
}
return ( return (
<div className="flex min-h-screen items-center justify-center"> <div className="flex min-h-screen items-center justify-center">
<div className="w-full max-w-md space-y-8 p-8"> <div className="w-full max-w-md space-y-8 p-8">
<h1 className="text-2xl font-bold text-center">PhaseFlow</h1> <h1 className="text-2xl font-bold text-center">PhaseFlow</h1>
<form onSubmit={handleSubmit} className="space-y-6"> {error && (
{error && ( <div
<div role="alert"
role="alert" className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded"
className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded" >
> {error}
{error}
</div>
)}
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700"
>
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => handleInputChange(setEmail, e.target.value)}
disabled={isLoading}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
required
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700"
>
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => handleInputChange(setPassword, e.target.value)}
disabled={isLoading}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
required
/>
</div> </div>
)}
{oidcProvider ? (
// OIDC login button
<button <button
type="submit" type="button"
onClick={handleOidcLogin}
disabled={isLoading} disabled={isLoading}
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:bg-blue-400 disabled:cursor-not-allowed" className="w-full rounded-md bg-blue-600 px-4 py-2 text-white font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:bg-blue-400 disabled:cursor-not-allowed"
> >
{isLoading ? "Signing in..." : "Sign in"} {isLoading
? "Signing in..."
: `Sign in with ${oidcProvider.displayName}`}
</button> </button>
</form> ) : (
// Email/password form fallback
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700"
>
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => handleInputChange(setEmail, e.target.value)}
disabled={isLoading}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
required
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700"
>
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => handleInputChange(setPassword, e.target.value)}
disabled={isLoading}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
required
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:bg-blue-400 disabled:cursor-not-allowed"
>
{isLoading ? "Signing in..." : "Sign in"}
</button>
</form>
)}
</div> </div>
</div> </div>
); );