diff --git a/README.md b/README.md index d22178a..4bcc605 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ Configure the header with `CMS_SSO_HEADER` (default `X-SSO-Token`). Set `CMS_SSO Because the backend trusts the forwarded header, it must only be reachable through the proxy. Bind it to the loopback interface or a private network, and make sure the proxy strips any client-supplied copy of that header. +Anonymous visitors get a **Sign in with Google** button in the header. It redirects the browser to `VITE_SSO_LOGIN_URL` (frontend setting), where the provider authenticates the owner and returns to the page; the CMS itself never performs an OAuth exchange and holds no client secret. Leave the setting empty to hide the button when the proxy gates the whole app automatically. + ## Content Content supports one reusable category per document plus any number of badges. The anonymous `GET /api/content` endpoint accepts `category=engineering` and repeated `badge=go&badge=docker` filters; selected badges are combined with AND semantics. `GET /api/content/categories` returns the category dropdown values. The frontend applies these filters immediately when the category changes or when a badge is entered. diff --git a/ui/app/.env.example b/ui/app/.env.example index c9cdf36..1956b7d 100644 --- a/ui/app/.env.example +++ b/ui/app/.env.example @@ -1,8 +1,16 @@ # Leave empty in development; Vite proxies API requests to localhost:8080. # Set this to the public backend URL when the frontend is hosted separately. # -# There is no sign-in endpoint: the upstream SSO proxy authenticates the owner -# and the backend trusts the header it forwards. +# The CMS has no sign-in endpoint of its own: the upstream SSO proxy +# authenticates the owner and the backend trusts the header it forwards. VITE_API_BASE_URL= VITE_SESSION_ENDPOINT=/api/auth/session VITE_CONTENT_ENDPOINT=/api/content + +# Google/IdP sign-in URL for the header button. The button redirects here, the +# provider authenticates you, and you come back with the identity header set. +# Leave empty to hide the button (for example when the proxy gates the app +# automatically instead). Examples: +# oauth2-proxy: /oauth2/start +# hosted IdP: https://idp.example.com/login +VITE_SSO_LOGIN_URL= diff --git a/ui/app/README.md b/ui/app/README.md index 8332d58..111294f 100644 --- a/ui/app/README.md +++ b/ui/app/README.md @@ -3,10 +3,16 @@ This Vite/React app lives in `ui/app` and is intentionally independent of the Go backend. Run `npm install`, then `npm run dev` or `npm run build`. -There is no sign-in screen. Content is public and read-only for anonymous visitors; the Edit -button appears only when the backend reports `canEdit: true`, which happens when the upstream SSO -proxy asserted the owner's identity on the request. `/api/auth/session` returns -`{ "authenticated": bool, "canEdit": bool }` and never creates a session. +There is no sign-in screen and no session is ever created. Content is public and read-only for +anonymous visitors; the Edit button appears only when the backend reports `canEdit: true`, which +happens when the upstream SSO proxy asserted the owner's identity on the request. +`/api/auth/session` returns `{ "authenticated": bool, "canEdit": bool }`. + +Anonymous visitors also see a **Sign in with Google** button in the header. It redirects the +browser to `VITE_SSO_LOGIN_URL` (with the current page passed as `rd`), where the identity +provider authenticates you and sends you back with the identity header set. Leave +`VITE_SSO_LOGIN_URL` empty to hide the button — useful when the proxy gates the whole app +automatically instead. The app never performs an OAuth exchange, so it holds no client secret. The API defaults to same-origin `/api/auth/session` and `/api/content`. Set `VITE_API_BASE_URL` for a separate backend, or override the endpoint paths with `VITE_SESSION_ENDPOINT` and diff --git a/ui/app/src/App.tsx b/ui/app/src/App.tsx index ebfbca6..8d88400 100644 --- a/ui/app/src/App.tsx +++ b/ui/app/src/App.tsx @@ -14,7 +14,7 @@ import { import {useAuth} from './auth' function Shell({children}: { children: React.ReactNode }) { - const {authenticated, loading} = useAuth() + const {authenticated, loading, canSignIn, signIn} = useAuth() const [dark, setDark] = useState(() => window.localStorage.getItem('urpi-theme') !== 'light') useEffect(() => { document.documentElement.dataset.theme = dark ? 'dark' : 'light' @@ -25,6 +25,9 @@ function Shell({children}: { children: React.ReactNode }) { backlog_linux · go · java · ideas
{!loading && {authenticated ? 'Owner · SSO' : 'Anonymous · read-only'}} + {!loading && !authenticated && canSignIn && + }
diff --git a/ui/app/src/api.ts b/ui/app/src/api.ts index 3d977d0..594bb53 100644 --- a/ui/app/src/api.ts +++ b/ui/app/src/api.ts @@ -17,6 +17,7 @@ const baseUrl = (import.meta.env.VITE_API_BASE_URL ?? '').replace(/\/$/, '') export const apiConfig = { session: import.meta.env.VITE_SESSION_ENDPOINT ?? '/api/auth/session', content: import.meta.env.VITE_CONTENT_ENDPOINT ?? '/api/content', + signIn: import.meta.env.VITE_SSO_LOGIN_URL ?? '', } export function apiUrl(path: string) { @@ -24,6 +25,17 @@ export function apiUrl(path: string) { return `${baseUrl}${path.startsWith('/') ? path : `/${path}`}` } +// Send the browser to the configured Google/IdP sign-in URL. The identity +// provider authenticates the owner and sends them back here; the app never +// performs the OAuth exchange itself, so no client secret is needed. The +// current page is passed as `rd` for proxies (oauth2-proxy) that honour it and +// ignored by the ones that do not. +export function signInWithGoogle(target = apiConfig.signIn): void { + if (!target) return + const returnTo = apiUrl(`${window.location.pathname}${window.location.search}`) + window.location.assign(`${target}${target.includes('?') ? '&' : '?'}rd=${encodeURIComponent(returnTo)}`) +} + export async function requestSession(): Promise { const response = await fetch(apiUrl(apiConfig.session), { credentials: 'include', diff --git a/ui/app/src/auth.tsx b/ui/app/src/auth.tsx index ab3920a..804b5d8 100644 --- a/ui/app/src/auth.tsx +++ b/ui/app/src/auth.tsx @@ -1,14 +1,17 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react' -import { requestSession } from './api' +import { apiConfig, requestSession, signInWithGoogle } from './api' // There is no sign-in or sign-out: the upstream SSO proxy decides whether the -// current request carries the owner's identity. +// current request carries the owner's identity. Signing in just sends the +// browser to the configured Google/IdP URL. type AuthState = { loading: boolean authenticated: boolean canEdit: boolean error: string | null refresh: () => Promise + canSignIn: boolean + signIn: () => void } const AuthContext = createContext(undefined) @@ -36,7 +39,13 @@ export function AuthProvider({ children }: { children: ReactNode }) { useEffect(() => { void refresh() }, [refresh]) - const value = useMemo(() => ({ loading, authenticated, canEdit, error, refresh }), [loading, authenticated, canEdit, error, refresh]) + const signIn = useCallback(() => { signInWithGoogle() }, []) + const canSignIn = apiConfig.signIn !== '' + + const value = useMemo( + () => ({ loading, authenticated, canEdit, error, refresh, canSignIn, signIn }), + [loading, authenticated, canEdit, error, refresh, canSignIn, signIn], + ) return {children} } diff --git a/ui/app/src/styles.css b/ui/app/src/styles.css index 69b6b78..dcbe9a6 100644 --- a/ui/app/src/styles.css +++ b/ui/app/src/styles.css @@ -26,6 +26,9 @@ button, select { cursor: pointer; } .theme-button { border: 1px solid #45475a; border-radius: 8px; background: #313244; color: #bac2de; padding: 8px 12px; } .theme-button:hover { border-color: #cba6f7; color: #f5e0e8; } .mode-badge { border: 1px solid #45475a; border-radius: 999px; padding: 6px 11px; color: #a6adc8; background: #242438; font-size: .7rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; } +.google-button { display: inline-flex; align-items: center; gap: 8px; border: 1px solid #585b70; border-radius: 8px; background: #313244; color: #cdd6f4; cursor: pointer; padding: 8px 12px; font-weight: 650; } +.google-button:hover { background: #45475a; border-color: #89b4fa; } +.google-button span { color: #89b4fa; font-weight: 800; } .card { margin: 10vh auto 0; max-width: 500px; padding: 42px; background: #242438; border: 1px solid #45475a; border-radius: 16px; box-shadow: 0 18px 50px #11111b66; } h1 { margin: 10px 0 12px; color: #f5e0e8; font-size: clamp(1.7rem, 4vw, 2.4rem); letter-spacing: -.04em; } p { line-height: 1.65; } @@ -94,6 +97,8 @@ html[data-theme="light"] .theme-button { border-color: #ccd0da; background: #e6e html[data-theme="light"] .text-button:hover, html[data-theme="light"] .theme-button:hover { border-color: #8839ef; color: #4c4f69; } html[data-theme="light"] .mode-badge { border-color: #ccd0da; background: #e6e9ef; color: #6c6f85; } +html[data-theme="light"] .google-button { border-color: #ccd0da; background: #e6e9ef; color: #5c5f77; } +html[data-theme="light"] .google-button:hover { border-color: #1e66f5; background: #dce0e8; } html[data-theme="light"] .card, html[data-theme="light"] .filters, html[data-theme="light"] .content-card { background: #ffffff; border-color: #ccd0da; box-shadow: 0 12px 34px #4c4f6914; } diff --git a/ui/app/src/vite-env.d.ts b/ui/app/src/vite-env.d.ts index e0274b8..97d0a22 100644 --- a/ui/app/src/vite-env.d.ts +++ b/ui/app/src/vite-env.d.ts @@ -4,6 +4,8 @@ interface ImportMetaEnv { readonly VITE_API_BASE_URL?: string readonly VITE_SESSION_ENDPOINT?: string readonly VITE_CONTENT_ENDPOINT?: string + /** Google/IdP sign-in URL the header button redirects to; empty hides the button. */ + readonly VITE_SSO_LOGIN_URL?: string } interface ImportMeta { diff --git a/ui/changelog.md b/ui/changelog.md index 3a29785..942d7eb 100644 --- a/ui/changelog.md +++ b/ui/changelog.md @@ -83,3 +83,18 @@ application. Remove sign-in entirely, along with the admin database and the setu SSO/anonymous mode badge in the header, and an Edit button gated on `canEdit`. - Added backend tests for anonymous read-only access and proxy-authenticated editing. - Revalidated with `gofmt`, `go build ./...`, `go test ./...`, `npm run typecheck`, and `npm run build`. + +## 2026-09-19 — Google sign-in button + +### Prompt + +Add a button that redirects to a Google single sign-on. + +### Tasks completed + +- Added a **Sign in with Google** button to the header, shown only while the visitor is anonymous. +- It redirects to `VITE_SSO_LOGIN_URL` with the current page as `rd`, so oauth2-proxy-style + endpoints return the owner to where they were; an empty value hides the button entirely. +- Kept the SSO model intact: the button only navigates to the identity provider, the CMS still + holds no client secret and creates no session — the proxy asserts the identity header. +- Documented the new setting in `ui/app/.env.example`, `ui/app/README.md`, and the root README.