Phase 2 Task 35. Adds OAuth Google/Microsoft sign-in to the register flow, gated on the public SELF_SERVE_ENABLED flag, and hides the legacy invite-code field when self-serve is on. - New `useAppConfig` hook + `configApi`. One-shot module-cached fetch of `GET /api/v1/config/public`; falls back to `VITE_SELF_SERVE_ENABLED` env var (default false) if the endpoint is unreachable. - New `OAuthCallbackPage` mounted at `/auth/google/callback` and `/auth/microsoft/callback` (public, NOT inside ProtectedRoute). Posts the authorization code to the backend, persists tokens, hydrates the auth store via fetchUser, and redirects to `/welcome` (new) or `/` (returning). - `RegisterPage` now renders OAuth buttons + email/password divider when `self_serve_enabled` is true and only emits buttons for providers the backend reports as configured. Invite-code field hidden in that mode. Captures `?plan=pro` into `localStorage.rf-intended-plan` on mount. - `authApi` gains `googleCallback(code)` / `microsoftCallback(code)`. - `frontend/.env.example` + `frontend/Dockerfile` document and bake the three new VITE_* build-time variables (Lesson 60: Vite needs ARG+ENV). - Vitest coverage for the three required cases plus the plan-param capture. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
100 lines
2.4 KiB
TypeScript
100 lines
2.4 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { configApi, type PublicConfig } from '@/api/config'
|
|
|
|
/**
|
|
* Module-scope cache: the public config endpoint is fetched at most once
|
|
* per page load. Subsequent hook mounts return the cached value synchronously
|
|
* (after the initial state update).
|
|
*/
|
|
let cached: PublicConfig | null = null
|
|
let inFlight: Promise<PublicConfig> | null = null
|
|
const subscribers = new Set<(c: PublicConfig) => void>()
|
|
|
|
function envFallback(): PublicConfig {
|
|
// Falls back to build-time flag when the public config endpoint is
|
|
// unreachable. Defaults to the legacy invite-only behavior so that
|
|
// a backend hiccup never opens public signup.
|
|
const selfServe =
|
|
String(import.meta.env.VITE_SELF_SERVE_ENABLED ?? '').toLowerCase() === 'true'
|
|
return {
|
|
self_serve_enabled: selfServe,
|
|
oauth_providers: [],
|
|
}
|
|
}
|
|
|
|
async function loadConfig(): Promise<PublicConfig> {
|
|
if (cached) return cached
|
|
if (inFlight) return inFlight
|
|
inFlight = configApi
|
|
.getPublic()
|
|
.then((c) => {
|
|
cached = c
|
|
subscribers.forEach((cb) => cb(c))
|
|
return c
|
|
})
|
|
.catch(() => {
|
|
const fallback = envFallback()
|
|
cached = fallback
|
|
subscribers.forEach((cb) => cb(fallback))
|
|
return fallback
|
|
})
|
|
.finally(() => {
|
|
inFlight = null
|
|
})
|
|
return inFlight
|
|
}
|
|
|
|
/** Test-only: clear the module-scope cache between tests. */
|
|
export function __resetAppConfigCache() {
|
|
cached = null
|
|
inFlight = null
|
|
subscribers.clear()
|
|
}
|
|
|
|
/** Test-only: prime the module-scope cache so hook returns synchronously. */
|
|
export function __setAppConfigCache(c: PublicConfig) {
|
|
cached = c
|
|
}
|
|
|
|
export interface UseAppConfigResult {
|
|
self_serve_enabled: boolean
|
|
oauth_providers: string[]
|
|
isLoading: boolean
|
|
}
|
|
|
|
export function useAppConfig(): UseAppConfigResult {
|
|
const [config, setConfig] = useState<PublicConfig | null>(cached)
|
|
|
|
useEffect(() => {
|
|
if (cached) {
|
|
setConfig(cached)
|
|
return
|
|
}
|
|
let active = true
|
|
const handler = (c: PublicConfig) => {
|
|
if (active) setConfig(c)
|
|
}
|
|
subscribers.add(handler)
|
|
void loadConfig()
|
|
return () => {
|
|
active = false
|
|
subscribers.delete(handler)
|
|
}
|
|
}, [])
|
|
|
|
if (config) {
|
|
return {
|
|
self_serve_enabled: config.self_serve_enabled,
|
|
oauth_providers: config.oauth_providers,
|
|
isLoading: false,
|
|
}
|
|
}
|
|
return {
|
|
self_serve_enabled: false,
|
|
oauth_providers: [],
|
|
isLoading: true,
|
|
}
|
|
}
|
|
|
|
export default useAppConfig
|