build and ready for demo
login screen
This commit is contained in:
parent
0ae2dc3888
commit
6af2853faa
33 changed files with 2020 additions and 1221 deletions
BIN
ui/app/assets/background.png
Normal file
BIN
ui/app/assets/background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 254 KiB |
|
|
@ -6,6 +6,7 @@ import {
|
|||
type Content,
|
||||
type ContentUpdate,
|
||||
getContent,
|
||||
listDemoUsers,
|
||||
listBadges,
|
||||
listCategories,
|
||||
listContent,
|
||||
|
|
@ -14,7 +15,14 @@ import {
|
|||
import {useAuth} from './auth'
|
||||
|
||||
function Shell({children}: { children: React.ReactNode }) {
|
||||
const {authenticated, loading, canSignIn, signIn} = useAuth()
|
||||
const {authenticated, loading, canSignIn, signIn, username, sudo, signOut} = useAuth()
|
||||
const [sudoOpen, setSudoOpen] = useState(false)
|
||||
const [helpOpen, setHelpOpen] = useState(false)
|
||||
const [users, setUsers] = useState<{username: string; name: string}[]>([])
|
||||
const [user, setUser] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [sudoError, setSudoError] = useState('')
|
||||
useEffect(() => { void listDemoUsers().then(setUsers) }, [])
|
||||
const [dark, setDark] = useState(() => window.localStorage.getItem('urpi-theme') !== 'light')
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = dark ? 'dark' : 'light'
|
||||
|
|
@ -24,7 +32,12 @@ function Shell({children}: { children: React.ReactNode }) {
|
|||
<header className="header"><Link className="brand" to="/content"><span className="brand-prompt">~/</span> Urpi's
|
||||
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>}
|
||||
<span className="mode-badge">{authenticated ? `${username ?? 'Owner'} · sudo` : 'Anonymous · read-only'}</span>}
|
||||
<button className="theme-button" type="button" aria-label="Demo users" onClick={() => setHelpOpen(value => !value)}>?</button>
|
||||
{!loading && !authenticated && users.length > 0 &&
|
||||
<button className="theme-button" type="button" onClick={() => setSudoOpen(true)}>sudo</button>}
|
||||
{!loading && authenticated && users.length > 0 &&
|
||||
<button className="theme-button" type="button" onClick={signOut}>logout</button>}
|
||||
{!loading && !authenticated && canSignIn &&
|
||||
<button className="google-button session-button" type="button" onClick={signIn}><span
|
||||
aria-hidden="true">G</span> Sign in with Google</button>}
|
||||
|
|
@ -32,6 +45,34 @@ function Shell({children}: { children: React.ReactNode }) {
|
|||
onClick={() => setDark(value => !value)}>{dark ? '☀ Light' : '☾ Dark'}</button>
|
||||
</div>
|
||||
</header>
|
||||
{helpOpen && users.length > 0 && <aside className="card terminal-panel">
|
||||
<strong>sudo users</strong><p className="muted">Demo only: each password is the same as the username.</p>
|
||||
{users.map(item => <div key={item.username}><code>sudo {item.username}</code> · {item.name}</div>)}
|
||||
</aside>}
|
||||
{sudoOpen && <div className="terminal-backdrop" role="presentation" onMouseDown={event => {
|
||||
if (event.target === event.currentTarget) setSudoOpen(false)
|
||||
}}>
|
||||
<form className="terminal-window" role="dialog" aria-modal="true" aria-labelledby="sudo-title" onSubmit={event => {
|
||||
event.preventDefault()
|
||||
setSudoError('')
|
||||
void sudo(user, password).then(() => { setSudoOpen(false); setPassword('') }).catch(error => setSudoError(error instanceof Error ? error.message : 'sudo failed'))
|
||||
}}>
|
||||
<div className="terminal-titlebar">
|
||||
<span id="sudo-title">urpi@backlog: ~</span>
|
||||
<button type="button" className="terminal-close" aria-label="Close sudo terminal"
|
||||
onClick={() => setSudoOpen(false)}>×</button>
|
||||
</div>
|
||||
<div className="terminal-body">
|
||||
<p><span className="terminal-prompt">$</span> sudo login</p>
|
||||
<label><span className="terminal-prompt">$</span> sudo <input autoFocus value={user}
|
||||
onChange={event => setUser(event.target.value)} placeholder="user" aria-label="Username"/></label>
|
||||
<label><span className="terminal-prompt">$</span> password: <input type="password" value={password}
|
||||
onChange={event => setPassword(event.target.value)} aria-label="Password"/></label>
|
||||
{sudoError && <p className="terminal-error">{sudoError}</p>}
|
||||
<button className="terminal-submit" type="submit"><span className="terminal-prompt">$</span> Enter</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>}
|
||||
{children}
|
||||
</main>
|
||||
}
|
||||
|
|
@ -41,6 +82,10 @@ function ContentCard({item}: { item: Content }) {
|
|||
<span className="status">{item.status}</span>
|
||||
<h2><Link to={`/content/${item.id}`}>{item.title}</Link></h2>
|
||||
<p>{item.summary}</p>
|
||||
<p className="content-meta">
|
||||
by {item.author ?? 'unknown'}
|
||||
{item.publishedAt && <> · published {new Date(item.publishedAt).toLocaleDateString()}</>}
|
||||
</p>
|
||||
{item.badges && item.badges.length > 0 &&
|
||||
<div className="badge-list">{item.badges.map(badge => <span className="badge"
|
||||
key={badge}>{badge}</span>)}</div>}
|
||||
|
|
@ -166,7 +211,7 @@ function ContentList() {
|
|||
|
||||
function ContentDetail() {
|
||||
const {id = ''} = useParams()
|
||||
const {canEdit} = useAuth()
|
||||
const {canEdit, username} = useAuth()
|
||||
const [item, setItem] = useState<Content | null>(null)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draft, setDraft] = useState<ContentUpdate | null>(null)
|
||||
|
|
@ -270,9 +315,9 @@ function ContentDetail() {
|
|||
</div>
|
||||
</div> : <>
|
||||
<div className="detail-heading"><span className="status">{item.status}</span><h1>{item.title}</h1><p
|
||||
className="muted">{item.summary}</p>{item.badges && item.badges.length > 0 &&
|
||||
className="muted">{item.summary}</p><p className="muted">by {item.author ?? 'unknown'}{item.publishedAt ? ` · published ${new Date(item.publishedAt).toLocaleDateString()}` : ''}</p>{item.badges && item.badges.length > 0 &&
|
||||
<div className="badge-list">{item.badges.map(badge => <span className="badge"
|
||||
key={badge}>{badge}</span>)}</div>}{canEdit &&
|
||||
key={badge}>{badge}</span>)}</div>}{canEdit && username === item.author &&
|
||||
<button className="primary-button edit-button" onClick={() => setEditing(true)}>Edit</button>}</div>
|
||||
<div className="body-copy">{item.body}</div>
|
||||
</>}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export type User = {
|
|||
export type SessionResponse = {
|
||||
authenticated?: boolean
|
||||
canEdit?: boolean
|
||||
username?: string
|
||||
}
|
||||
|
||||
const baseUrl = (import.meta.env.VITE_API_BASE_URL ?? '').replace(/\/$/, '')
|
||||
|
|
@ -20,6 +21,24 @@ export const apiConfig = {
|
|||
signIn: import.meta.env.VITE_SSO_LOGIN_URL ?? '',
|
||||
}
|
||||
|
||||
export type DemoUser = { username: string; name: string }
|
||||
|
||||
export async function listDemoUsers(): Promise<DemoUser[]> {
|
||||
const response = await fetch(apiUrl('/api/auth/users'), {headers: {Accept: 'application/json'}})
|
||||
if (!response.ok) return []
|
||||
return ((await response.json()) as {items?: DemoUser[]}).items ?? []
|
||||
}
|
||||
|
||||
export async function sudoLogin(username: string, password: string): Promise<{token: string; user: DemoUser}> {
|
||||
const response = await fetch(apiUrl('/api/auth/sudo'), {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', Accept: 'application/json'},
|
||||
body: JSON.stringify({username, password}),
|
||||
})
|
||||
if (!response.ok) throw new Error('sudo: authentication failed')
|
||||
return (await response.json()) as {token: string; user: DemoUser}
|
||||
}
|
||||
|
||||
export function apiUrl(path: string) {
|
||||
if (/^https?:\/\//i.test(path)) return path
|
||||
return `${baseUrl}${path.startsWith('/') ? path : `/${path}`}`
|
||||
|
|
@ -39,7 +58,7 @@ export function signInWithGoogle(target = apiConfig.signIn): void {
|
|||
export async function requestSession(): Promise<SessionResponse | null> {
|
||||
const response = await fetch(apiUrl(apiConfig.session), {
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' },
|
||||
headers: { Accept: 'application/json', ...demoAuthHeaders() },
|
||||
})
|
||||
if (response.status === 404) return null
|
||||
if (!response.ok) throw new Error('Unable to check your session.')
|
||||
|
|
@ -48,6 +67,11 @@ export async function requestSession(): Promise<SessionResponse | null> {
|
|||
return value
|
||||
}
|
||||
|
||||
function demoAuthHeaders(): Record<string, string> {
|
||||
const token = window.sessionStorage.getItem('cms-demo-token')
|
||||
return token ? {Authorization: `Bearer ${token}`} : {}
|
||||
}
|
||||
|
||||
export type Content = {
|
||||
id: string
|
||||
title: string
|
||||
|
|
@ -58,6 +82,8 @@ export type Content = {
|
|||
badges?: string[]
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
author?: string
|
||||
publishedAt?: string
|
||||
}
|
||||
|
||||
export type ContentListResponse = {
|
||||
|
|
@ -71,15 +97,45 @@ export type ContentListResponse = {
|
|||
limit?: number
|
||||
}
|
||||
|
||||
function normalizeContent(value: Content & { ID?: string; Slug?: string; Title?: string; Summary?: string; Body?: string; Status?: string; Category?: string; Badges?: string[] }): Content {
|
||||
return { id: value.id ?? value.Slug ?? String(value.ID ?? ''), title: value.title ?? value.Title ?? '', summary: value.summary ?? value.Summary ?? '', body: value.body ?? value.Body ?? '', status: value.status ?? value.Status ?? '', category: value.category ?? value.Category, badges: value.badges ?? value.Badges ?? [], createdAt: value.createdAt, updatedAt: value.updatedAt }
|
||||
// Raw JSON as sent by the Go API: lowercase keys, with the slug doubling as the
|
||||
// identifier used by the detail and update routes.
|
||||
type RawContent = Omit<Partial<Content>, 'id'> & {
|
||||
id?: string | number
|
||||
ID?: string | number
|
||||
slug?: string
|
||||
Slug?: string
|
||||
Title?: string
|
||||
Summary?: string
|
||||
Body?: string
|
||||
Status?: string
|
||||
Category?: string
|
||||
Badges?: string[]
|
||||
Author?: string
|
||||
PublishedAt?: string
|
||||
}
|
||||
|
||||
function normalizeContent(value: RawContent): Content {
|
||||
const rawId = value.id ?? value.ID
|
||||
return {
|
||||
id: value.slug ?? value.Slug ?? (rawId !== undefined ? String(rawId) : ''),
|
||||
title: value.title ?? value.Title ?? '',
|
||||
summary: value.summary ?? value.Summary ?? '',
|
||||
body: value.body ?? value.Body ?? '',
|
||||
status: value.status ?? value.Status ?? '',
|
||||
category: value.category ?? value.Category,
|
||||
badges: value.badges ?? value.Badges ?? [],
|
||||
createdAt: value.createdAt,
|
||||
updatedAt: value.updatedAt,
|
||||
author: value.author ?? value.Author,
|
||||
publishedAt: value.publishedAt ?? value.PublishedAt,
|
||||
}
|
||||
}
|
||||
|
||||
async function contentRequest<T>(path = '', init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(apiUrl(`${apiConfig.content}${path}`), {
|
||||
credentials: 'include',
|
||||
...init,
|
||||
headers: { Accept: 'application/json', ...(init?.body ? { 'Content-Type': 'application/json' } : {}), ...init?.headers },
|
||||
headers: { Accept: 'application/json', ...demoAuthHeaders(), ...(init?.body ? { 'Content-Type': 'application/json' } : {}), ...init?.headers },
|
||||
})
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) throw new Error('Your SSO session is required to edit content.')
|
||||
|
|
@ -95,7 +151,7 @@ export async function listContent(cursor?: string | null, category = '', status
|
|||
if (status) params.set('status', status)
|
||||
badges.forEach(badge => params.append('badge', badge))
|
||||
const query = params.toString() ? `?${params.toString()}` : ''
|
||||
const response = await contentRequest<ContentListResponse | (Content & { ID?: string; Slug?: string; Title?: string; Summary?: string; Body?: string; Status?: string })[]>(query)
|
||||
const response = await contentRequest<ContentListResponse | RawContent[]>(query)
|
||||
if (Array.isArray(response)) return { items: response.map(normalizeContent), nextCursor: null }
|
||||
const items = (response.items ?? response.content ?? response.data ?? []).map(normalizeContent)
|
||||
const offset = response.offset ?? (cursor ? Number(cursor) : 0)
|
||||
|
|
@ -107,24 +163,24 @@ export type Category = { id: string; slug: string; name: string }
|
|||
export type Badge = { id: string; name: string }
|
||||
|
||||
export async function listCategories(): Promise<Category[]> {
|
||||
const response = await contentRequest<{ items?: Array<Category & { ID?: string; Slug?: string; Name?: string }> }>('/categories')
|
||||
return (response.items ?? []).map(item => ({ id: item.id ?? item.ID ?? '', slug: item.slug ?? item.Slug ?? '', name: item.name ?? item.Name ?? '' }))
|
||||
const response = await contentRequest<{ items?: Array<{ id?: string | number; slug?: string; Slug?: string; name?: string; Name?: string }> }>('/categories')
|
||||
return (response.items ?? []).map(item => ({ id: String(item.slug ?? item.slug ?? item.id ?? ''), slug: item.slug ?? item.Slug ?? '', name: item.name ?? item.Name ?? '' }))
|
||||
}
|
||||
|
||||
export async function listBadges(): Promise<Badge[]> {
|
||||
const response = await contentRequest<{ items?: Array<Badge & { ID?: string; Name?: string }> }>('/badges')
|
||||
return (response.items ?? []).map(item => ({ id: item.id ?? item.ID ?? '', name: item.name ?? item.Name ?? '' }))
|
||||
const response = await contentRequest<{ items?: Array<{ id?: string | number; name?: string; Name?: string }> }>('/badges')
|
||||
return (response.items ?? []).map(item => ({ id: String(item.id ?? item.name ?? ''), name: item.name ?? item.Name ?? '' }))
|
||||
}
|
||||
|
||||
export function getContent(id: string): Promise<Content> {
|
||||
return contentRequest<Content>(`/${encodeURIComponent(id)}`).then(value => normalizeContent(value as Content & { ID?: string; Slug?: string; Title?: string; Summary?: string; Body?: string; Status?: string }))
|
||||
return contentRequest<RawContent>(`/${encodeURIComponent(id)}`).then(normalizeContent)
|
||||
}
|
||||
|
||||
export type ContentUpdate = Pick<Content, 'title' | 'summary' | 'body' | 'status' | 'badges'>
|
||||
|
||||
export function updateContent(id: string, content: ContentUpdate): Promise<Content> {
|
||||
return contentRequest<Content>(`/${encodeURIComponent(id)}`, {
|
||||
return contentRequest<RawContent>(`/${encodeURIComponent(id)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(content),
|
||||
}).then(value => normalizeContent(value as Content & { ID?: string; Slug?: string; Title?: string; Summary?: string; Body?: string; Status?: string }))
|
||||
}).then(normalizeContent)
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { apiConfig, requestSession, signInWithGoogle } from './api'
|
||||
import { apiConfig, requestSession, signInWithGoogle, sudoLogin } from './api'
|
||||
|
||||
// There is no sign-in or sign-out: the upstream SSO proxy decides whether the
|
||||
// current request carries the owner's identity. Signing in just sends the
|
||||
|
|
@ -8,10 +8,13 @@ type AuthState = {
|
|||
loading: boolean
|
||||
authenticated: boolean
|
||||
canEdit: boolean
|
||||
username?: string
|
||||
error: string | null
|
||||
refresh: () => Promise<void>
|
||||
canSignIn: boolean
|
||||
signIn: () => void
|
||||
sudo: (username: string, password: string) => Promise<void>
|
||||
signOut: () => void
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthState | undefined>(undefined)
|
||||
|
|
@ -19,6 +22,7 @@ const AuthContext = createContext<AuthState | undefined>(undefined)
|
|||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [authenticated, setAuthenticated] = useState(false)
|
||||
const [canEdit, setCanEdit] = useState(false)
|
||||
const [username, setUsername] = useState<string | undefined>()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
|
|
@ -28,6 +32,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
const session = await requestSession()
|
||||
setAuthenticated(session?.authenticated === true)
|
||||
setCanEdit(session?.canEdit === true)
|
||||
setUsername(session?.username)
|
||||
} catch (err) {
|
||||
setAuthenticated(false)
|
||||
setCanEdit(false)
|
||||
|
|
@ -40,11 +45,24 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
useEffect(() => { void refresh() }, [refresh])
|
||||
|
||||
const signIn = useCallback(() => { signInWithGoogle() }, [])
|
||||
const sudo = useCallback(async (username: string, password: string) => {
|
||||
const result = await sudoLogin(username, password)
|
||||
window.sessionStorage.setItem('cms-demo-token', result.token)
|
||||
setAuthenticated(true)
|
||||
setCanEdit(true)
|
||||
setUsername(result.user.username)
|
||||
}, [])
|
||||
const signOut = useCallback(() => {
|
||||
window.sessionStorage.removeItem('cms-demo-token')
|
||||
setAuthenticated(false)
|
||||
setCanEdit(false)
|
||||
setUsername(undefined)
|
||||
}, [])
|
||||
const canSignIn = apiConfig.signIn !== ''
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ loading, authenticated, canEdit, error, refresh, canSignIn, signIn }),
|
||||
[loading, authenticated, canEdit, error, refresh, canSignIn, signIn],
|
||||
() => ({ loading, authenticated, canEdit, username, error, refresh, canSignIn, signIn, sudo, signOut }),
|
||||
[loading, authenticated, canEdit, username, error, refresh, canSignIn, signIn, sudo, signOut],
|
||||
)
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ p { line-height: 1.65; }
|
|||
.content-card h2 a { color: #f5e0e8; text-decoration: none; }
|
||||
.content-card h2 a:hover, .read-link:hover { color: #89b4fa; }
|
||||
.content-card p { color: #a6adc8; margin: 0 0 18px; }
|
||||
.content-card .content-meta { margin-top: -8px; color: #7f849c; font-size: .82rem; }
|
||||
.status { display: inline-block; color: #94e2d5; background: #193d3a; border-radius: 999px; padding: 4px 9px; font-size: .72rem; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; }
|
||||
.read-link, .back-link { color: #89b4fa; font-weight: 650; text-decoration: none; }
|
||||
.list-status { text-align: center; min-height: 54px; padding: 14px; }
|
||||
|
|
@ -82,6 +83,78 @@ p { line-height: 1.65; }
|
|||
.actions .secondary-button { margin: 0; background: #45475a; color: #cdd6f4; }
|
||||
.inline-button { margin-top: 22px; }
|
||||
.protected-card { text-align: center; }
|
||||
.terminal-backdrop {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: #000000b8;
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
.terminal-window {
|
||||
width: min(100%, 620px);
|
||||
overflow: hidden;
|
||||
border: 1px solid #4d4d4d;
|
||||
border-radius: 8px;
|
||||
background: #000;
|
||||
color: #fff;
|
||||
box-shadow: 0 24px 90px #000;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
.terminal-titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 9px 14px;
|
||||
color: #fff;
|
||||
background: #252525;
|
||||
border-bottom: 1px solid #454545;
|
||||
font-size: .82rem;
|
||||
}
|
||||
.terminal-close {
|
||||
border: 0;
|
||||
color: #fff;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 1.35rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.terminal-close:hover { color: #ff6b6b; }
|
||||
.terminal-body { display: grid; gap: 14px; padding: 24px; }
|
||||
.terminal-body p { margin: 0; line-height: 1.5; }
|
||||
.terminal-body label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.terminal-prompt { color: #fff; font-weight: 700; }
|
||||
.terminal-body input {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
padding: 0;
|
||||
color: #fff;
|
||||
background: #000;
|
||||
font: inherit;
|
||||
}
|
||||
.terminal-body input::placeholder { color: #777; }
|
||||
.terminal-submit {
|
||||
justify-self: start;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
color: #fff;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.terminal-submit:hover { color: #a6e3a1; }
|
||||
.terminal-error { color: #ff6b6b; }
|
||||
html[data-theme="light"] {
|
||||
color: #4c4f69;
|
||||
background: #eff1f5;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,12 @@ import react from '@vitejs/plugin-react'
|
|||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
// Build into the repository root; the Dockerfile and `make ui` copy the result
|
||||
// into the Go embed directory (cmd/server/web/dist).
|
||||
build: {
|
||||
outDir: '../dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue