Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ services:
build:
context: ./frontend
ports:
- "8080:80"
- "80:80"
depends_on:
backend:
condition: service_healthy
Expand Down
25 changes: 22 additions & 3 deletions frontend/src/app/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,19 @@ import { Navigate, Outlet, createBrowserRouter } from 'react-router-dom'
import { AppShell } from '../components/layout/app-shell'
import { useAuth } from '../features/auth/auth-store'
import { AdminPromptsPage } from '../pages/admin/admin-prompts-page'
import { LandingPage } from '../pages/landing/landing-page'
import { LoginPage } from '../pages/login/login-page'
import { NotFoundPage } from '../pages/not-found/not-found-page'
import { PromptsPage } from '../pages/prompts/prompts-page'
import { RecoveryPage } from '../pages/recovery/recovery-page'
import { RegisterPage } from '../pages/register/register-page'

function ProtectedLayout() {
const { isAuthenticated } = useAuth()
const { isAuthenticated, isReady } = useAuth()
if (!isReady) {
return <div className="centered-page"><p className="muted">Restoring session...</p></div>
}

if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
Expand All @@ -21,7 +28,11 @@ function ProtectedLayout() {
}

function PublicLoginPage() {
const { isAuthenticated } = useAuth()
const { isAuthenticated, isReady } = useAuth()
if (!isReady) {
return <div className="centered-page"><p className="muted">Loading...</p></div>
}

if (isAuthenticated) {
return <Navigate to="/app/prompts" replace />
}
Expand All @@ -38,10 +49,18 @@ function RoleGuard() {
}

export const router = createBrowserRouter([
{
path: '/',
element: <LandingPage />,
},
{
path: '/login',
element: <PublicLoginPage />,
},
{
path: '/register',
element: <RegisterPage />,
},
{
path: '/recovery',
element: <RecoveryPage />,
Expand Down Expand Up @@ -72,6 +91,6 @@ export const router = createBrowserRouter([
},
{
path: '*',
element: <Navigate to="/login" replace />,
element: <NotFoundPage />,
},
])
18 changes: 10 additions & 8 deletions frontend/src/components/layout/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { NavLink } from 'react-router-dom'
import { useAuth } from '../../features/auth/auth-store'
import { applyTheme, getInitialTheme, type ThemeMode } from '../../lib/utils/theme'
import { Button } from '../ui/button'
import { Badge } from '../ui/badge'

type AppShellProps = {
children: ReactNode
Expand All @@ -24,20 +25,21 @@ export function AppShell({ children }: AppShellProps) {

return (
<div className="page">
<header className="topbar">
<strong>Prompt Manager</strong>
<header className="topbar app-topbar">
<div>
<strong>Prompt Catalog</strong>
<p className="muted">Personal library for prompts that work</p>
</div>
<div className="topbar-actions">
<NavLink className="nav-link" to="/app/prompts">
Prompts
Catalog
</NavLink>
{session.role === 'admin' || session.role === 'god' ? (
<NavLink className="nav-link" to="/app/admin/prompts">
Admin Monitor
Admin
</NavLink>
) : null}
<span className="muted">
{session.username} · {session.role}
</span>
<Badge tone="accent">{session.username} · {session.role}</Badge>
<Button variant="ghost" onClick={toggleTheme}>
{theme === 'light' ? 'Dark' : 'Light'}
</Button>
Expand All @@ -46,7 +48,7 @@ export function AppShell({ children }: AppShellProps) {
</Button>
</div>
</header>
<main>{children}</main>
<main className="app-main">{children}</main>
</div>
)
}
8 changes: 7 additions & 1 deletion frontend/src/components/prompts/prompt-list.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { PromptRecord } from '../../features/prompts/prompts-types'
import { Button } from '../ui/button'
import { EmptyState } from '../ui/empty-state'

type PromptListProps = {
prompts: PromptRecord[]
Expand All @@ -9,7 +10,12 @@ type PromptListProps = {

export function PromptList({ prompts, onEdit, onDelete }: PromptListProps) {
if (!prompts.length) {
return <p className="muted">No prompts yet.</p>
return (
<EmptyState
title="No prompts yet"
description="Save your first proven prompt to start building your reusable catalog."
/>
)
}

return (
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/components/ui/badge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { HTMLAttributes } from 'react'

type BadgeProps = HTMLAttributes<HTMLSpanElement> & {
tone?: 'default' | 'accent' | 'danger'
}

export function Badge({ tone = 'default', className = '', ...props }: BadgeProps) {
return <span className={`badge badge-${tone} ${className}`.trim()} {...props} />
}
48 changes: 48 additions & 0 deletions frontend/src/components/ui/confirm-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Button } from './button'

type ConfirmDialogProps = {
open: boolean
title: string
description: string
confirmLabel?: string
cancelLabel?: string
busy?: boolean
onConfirm: () => void
onCancel: () => void
}

export function ConfirmDialog({
open,
title,
description,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
busy = false,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
if (!open) return null

return (
<div className="dialog-backdrop" role="presentation" onMouseDown={onCancel}>
<div
className="dialog-panel"
role="dialog"
aria-modal="true"
aria-labelledby="confirm-dialog-title"
onMouseDown={(event) => event.stopPropagation()}
>
<h2 id="confirm-dialog-title">{title}</h2>
<p className="muted">{description}</p>
<div className="dialog-actions">
<Button variant="ghost" onClick={onCancel} disabled={busy}>
{cancelLabel}
</Button>
<Button variant="danger" onClick={onConfirm} disabled={busy}>
{busy ? 'Working...' : confirmLabel}
</Button>
</div>
</div>
</div>
)
}
17 changes: 17 additions & 0 deletions frontend/src/components/ui/empty-state.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { ReactNode } from 'react'

type EmptyStateProps = {
title: string
description: string
action?: ReactNode
}

export function EmptyState({ title, description, action }: EmptyStateProps) {
return (
<div className="empty-state">
<h3>{title}</h3>
<p className="muted">{description}</p>
{action ? <div>{action}</div> : null}
</div>
)
}
21 changes: 21 additions & 0 deletions frontend/src/components/ui/page-header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { ReactNode } from 'react'

type PageHeaderProps = {
eyebrow?: string
title: string
description?: string
actions?: ReactNode
}

export function PageHeader({ eyebrow, title, description, actions }: PageHeaderProps) {
return (
<div className="page-header">
<div>
{eyebrow ? <p className="eyebrow">{eyebrow}</p> : null}
<h1>{title}</h1>
{description ? <p className="muted">{description}</p> : null}
</div>
{actions ? <div className="page-header-actions">{actions}</div> : null}
</div>
)
}
9 changes: 9 additions & 0 deletions frontend/src/features/auth/auth-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import { apiRequest } from '../../lib/http/api-client'
import type {
LoginInput,
LoginResponse,
RegisterInput,
RecoveryGenerateInput,
RecoveryGenerateResponse,
RecoveryRedeemInput,
RecoveryRedeemResponse,
SignupResponse,
UserRecord,
UserRole,
} from './auth-types'
Expand Down Expand Up @@ -35,6 +37,13 @@ export async function loginAndResolveUserId(
}
}

export function signup(input: RegisterInput): Promise<SignupResponse> {
return apiRequest<SignupResponse>('/auth/signup', {
method: 'POST',
body: JSON.stringify(input),
})
}

export function getCurrentUser(token: string): Promise<UserRecord> {
return apiRequest<UserRecord>('/auth/profile', {
method: 'GET',
Expand Down
41 changes: 39 additions & 2 deletions frontend/src/features/auth/auth-store.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react'
import { loginAndResolveUserId } from './auth-service'
import { getCurrentUser, loginAndResolveUserId } from './auth-service'
import type { UserRole } from './auth-types'

type SessionState = {
Expand All @@ -20,11 +21,13 @@ type SessionState = {
type AuthContextValue = {
session: SessionState
isAuthenticated: boolean
isReady: boolean
login: (username: string, password: string) => Promise<void>
logout: () => void
}

const AuthContext = createContext<AuthContextValue | null>(null)
const SESSION_TOKEN_KEY = 'prompt-catalog-token'

type AuthProviderProps = {
children: ReactNode
Expand All @@ -37,9 +40,41 @@ export function AuthProvider({ children }: AuthProviderProps) {
userId: null,
role: null,
})
const [isReady, setIsReady] = useState(false)

useEffect(() => {
const token = sessionStorage.getItem(SESSION_TOKEN_KEY)
if (!token) {
setIsReady(true)
return
}

let cancelled = false
getCurrentUser(token)
.then((user) => {
if (cancelled) return
setSession({
token,
username: user.username,
userId: user.id,
role: user.role,
})
})
.catch(() => {
sessionStorage.removeItem(SESSION_TOKEN_KEY)
})
.finally(() => {
if (!cancelled) setIsReady(true)
})

return () => {
cancelled = true
}
}, [])

const login = useCallback(async (username: string, password: string) => {
const resolved = await loginAndResolveUserId({ username, password })
sessionStorage.setItem(SESSION_TOKEN_KEY, resolved.token)
setSession({
token: resolved.token,
username: resolved.username,
Expand All @@ -49,17 +84,19 @@ export function AuthProvider({ children }: AuthProviderProps) {
}, [])

const logout = useCallback(() => {
sessionStorage.removeItem(SESSION_TOKEN_KEY)
setSession({ token: null, username: null, userId: null, role: null })
}, [])

const value = useMemo<AuthContextValue>(
() => ({
session,
isAuthenticated: Boolean(session.token),
isReady,
login,
logout,
}),
[login, logout, session],
[isReady, login, logout, session],
)

return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/features/auth/auth-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ export type LoginResponse = {
token_type: string
}

export type RegisterInput = {
username: string
password: string
name: string
last_name: string
email: string
role: 'user'
}

export type UserRole = 'user' | 'admin' | 'god'

export type UserRecord = {
Expand All @@ -19,6 +28,8 @@ export type UserRecord = {
role: UserRole
}

export type SignupResponse = UserRecord

export type RecoveryGenerateInput = {
username: string
}
Expand Down
Loading
Loading