changing layout
This commit is contained in:
parent
b1c6aa659b
commit
0ae2dc3888
9 changed files with 72 additions and 10 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
2
ui/app/src/vite-env.d.ts
vendored
2
ui/app/src/vite-env.d.ts
vendored
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue