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.
|
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
|
||||||
|
|
||||||
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.
|
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.
|
# Leave empty in development; Vite proxies API requests to localhost:8080.
|
||||||
# Set this to the public backend URL when the frontend is hosted separately.
|
# 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
|
# The CMS has no sign-in endpoint of its own: the upstream SSO proxy
|
||||||
# and the backend trusts the header it forwards.
|
# authenticates the owner and the backend trusts the header it forwards.
|
||||||
VITE_API_BASE_URL=
|
VITE_API_BASE_URL=
|
||||||
VITE_SESSION_ENDPOINT=/api/auth/session
|
VITE_SESSION_ENDPOINT=/api/auth/session
|
||||||
VITE_CONTENT_ENDPOINT=/api/content
|
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
|
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`.
|
`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
|
There is no sign-in screen and no session is ever created. Content is public and read-only for
|
||||||
button appears only when the backend reports `canEdit: true`, which happens when the upstream SSO
|
anonymous visitors; the Edit button appears only when the backend reports `canEdit: true`, which
|
||||||
proxy asserted the owner's identity on the request. `/api/auth/session` returns
|
happens when the upstream SSO proxy asserted the owner's identity on the request.
|
||||||
`{ "authenticated": bool, "canEdit": bool }` and never creates a session.
|
`/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`
|
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
|
for a separate backend, or override the endpoint paths with `VITE_SESSION_ENDPOINT` and
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import {
|
||||||
import {useAuth} from './auth'
|
import {useAuth} from './auth'
|
||||||
|
|
||||||
function Shell({children}: { children: React.ReactNode }) {
|
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')
|
const [dark, setDark] = useState(() => window.localStorage.getItem('urpi-theme') !== 'light')
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.dataset.theme = dark ? 'dark' : 'light'
|
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>
|
backlog<span className="brand-cursor">_</span><small>linux · go · java · ideas</small></Link>
|
||||||
<div className="header-actions">{!loading &&
|
<div className="header-actions">{!loading &&
|
||||||
<span className="mode-badge">{authenticated ? 'Owner · SSO' : 'Anonymous · read-only'}</span>}
|
<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`}
|
<button className="theme-button" type="button" aria-label={`Switch to ${dark ? 'light' : 'dark'} theme`}
|
||||||
onClick={() => setDark(value => !value)}>{dark ? '☀ Light' : '☾ Dark'}</button>
|
onClick={() => setDark(value => !value)}>{dark ? '☀ Light' : '☾ Dark'}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ const baseUrl = (import.meta.env.VITE_API_BASE_URL ?? '').replace(/\/$/, '')
|
||||||
export const apiConfig = {
|
export const apiConfig = {
|
||||||
session: import.meta.env.VITE_SESSION_ENDPOINT ?? '/api/auth/session',
|
session: import.meta.env.VITE_SESSION_ENDPOINT ?? '/api/auth/session',
|
||||||
content: import.meta.env.VITE_CONTENT_ENDPOINT ?? '/api/content',
|
content: import.meta.env.VITE_CONTENT_ENDPOINT ?? '/api/content',
|
||||||
|
signIn: import.meta.env.VITE_SSO_LOGIN_URL ?? '',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function apiUrl(path: string) {
|
export function apiUrl(path: string) {
|
||||||
|
|
@ -24,6 +25,17 @@ export function apiUrl(path: string) {
|
||||||
return `${baseUrl}${path.startsWith('/') ? path : `/${path}`}`
|
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> {
|
export async function requestSession(): Promise<SessionResponse | null> {
|
||||||
const response = await fetch(apiUrl(apiConfig.session), {
|
const response = await fetch(apiUrl(apiConfig.session), {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
|
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
|
// 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 = {
|
type AuthState = {
|
||||||
loading: boolean
|
loading: boolean
|
||||||
authenticated: boolean
|
authenticated: boolean
|
||||||
canEdit: boolean
|
canEdit: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
refresh: () => Promise<void>
|
refresh: () => Promise<void>
|
||||||
|
canSignIn: boolean
|
||||||
|
signIn: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthState | undefined>(undefined)
|
const AuthContext = createContext<AuthState | undefined>(undefined)
|
||||||
|
|
@ -36,7 +39,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
useEffect(() => { void refresh() }, [refresh])
|
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>
|
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 { border: 1px solid #45475a; border-radius: 8px; background: #313244; color: #bac2de; padding: 8px 12px; }
|
||||||
.theme-button:hover { border-color: #cba6f7; color: #f5e0e8; }
|
.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; }
|
.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; }
|
.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; }
|
h1 { margin: 10px 0 12px; color: #f5e0e8; font-size: clamp(1.7rem, 4vw, 2.4rem); letter-spacing: -.04em; }
|
||||||
p { line-height: 1.65; }
|
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"] .text-button:hover,
|
||||||
html[data-theme="light"] .theme-button:hover { border-color: #8839ef; color: #4c4f69; }
|
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"] .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"] .card,
|
||||||
html[data-theme="light"] .filters,
|
html[data-theme="light"] .filters,
|
||||||
html[data-theme="light"] .content-card { background: #ffffff; border-color: #ccd0da; box-shadow: 0 12px 34px #4c4f6914; }
|
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_API_BASE_URL?: string
|
||||||
readonly VITE_SESSION_ENDPOINT?: string
|
readonly VITE_SESSION_ENDPOINT?: string
|
||||||
readonly VITE_CONTENT_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 {
|
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`.
|
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.
|
- 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`.
|
- 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