changing layout

This commit is contained in:
Daniel Noulet 2026-09-19 16:51:24 +02:00
parent b1c6aa659b
commit 0ae2dc3888
9 changed files with 72 additions and 10 deletions

View file

@ -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=

View file

@ -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

View file

@ -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<span className="brand-cursor">_</span><small>linux · go · java · ideas</small></Link>
<div className="header-actions">{!loading &&
<span className="mode-badge">{authenticated ? 'Owner · SSO' : 'Anonymous · read-only'}</span>}
{!loading && !authenticated && canSignIn &&
<button className="google-button session-button" type="button" onClick={signIn}><span
aria-hidden="true">G</span> Sign in with Google</button>}
<button className="theme-button" type="button" aria-label={`Switch to ${dark ? 'light' : 'dark'} theme`}
onClick={() => setDark(value => !value)}>{dark ? '☀ Light' : '☾ Dark'}</button>
</div>

View file

@ -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<SessionResponse | null> {
const response = await fetch(apiUrl(apiConfig.session), {
credentials: 'include',

View file

@ -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<void>
canSignIn: boolean
signIn: () => void
}
const AuthContext = createContext<AuthState | undefined>(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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}

View file

@ -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; }

View file

@ -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 {

View file

@ -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.