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
5 changes: 0 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,11 +108,6 @@ message naming which limit was hit — the app is not broken, it is full. Limits
(`DEMO_MAX_*`), enforced in the service layer, and scoped per owner so no single visitor can consume
everyone else's allowance.

**Not yet built (by design, not oversight):** Facebook sign-in and avatar uploads. The Facebook strategy is
written and dormant — it needs only credentials — but Facebook's HTTPS redirect requirement makes it poor
value for a demo, so only Google is wired up. See the phase table in
`docs/superpowers/specs/2026-07-16-express-react-rebuild-design.md` §13 for what's next.

Both integrations are optional infrastructure. With no `CLOUDINARY_*` variables the API still boots, the
upload endpoint reports 503, and every post falls back to its generated cover. With no `GOOGLE_*`
variables there is simply no Google button — `GET /api/v1/auth/providers` tells the client which providers
Expand Down
2 changes: 1 addition & 1 deletion apps/client/src/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { DEBUG } from '../lib/constants.js'
export type User = { id: string; username: string; email: string }

/** Which federated sign-in options this deployment can actually offer. */
export type AuthProviders = { google: boolean; facebook: boolean }
export type AuthProviders = { google: boolean }

export const authApi = {
signup: (input: z.infer<typeof SignupSchema>) => {
Expand Down
21 changes: 18 additions & 3 deletions apps/client/src/api/users.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,29 @@
import { request } from './client.js'
import type { z } from 'zod'
import { UpdateUserSchema } from '@blog/zod-shared'
import { DeleteUserSchema, UpdateUserSchema } from '@blog/zod-shared'

export type UserProfile = { id: string; username: string; bio?: string; avatar?: string }
/**
* `email`/`hasPassword`/`oauthProvider` are only ever present when the
* requester is viewing their own account — see userService.getPublicProfile's
* viewerId gate. Anyone else's profile arrives without them.
*/
export type UserProfile = {
id: string
username: string
bio?: string
image?: string
createdAt: string
email?: string
hasPassword?: boolean
oauthProvider?: 'google' | null
}

export const usersApi = {
get: (id: string) => request<UserProfile>(`/api/v1/users/${id}`),

update: (id: string, input: z.infer<typeof UpdateUserSchema>) =>
request<UserProfile>(`/api/v1/users/${id}`, { method: 'PATCH', body: JSON.stringify(input) }),

remove: (id: string) => request<void>(`/api/v1/users/${id}`, { method: 'DELETE' }),
remove: (id: string, confirmation: z.infer<typeof DeleteUserSchema>) =>
request<void>(`/api/v1/users/${id}`, { method: 'DELETE', body: JSON.stringify(confirmation) }),
}
4 changes: 3 additions & 1 deletion apps/client/src/components/layouts/PageShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ export function PageShell({ children }: { children: React.ReactNode }) {
New post
</NavLink>
<span aria-hidden="true" className="h-4 w-px bg-[var(--border)]" />
<span className="text-[var(--ink-faint)]">{me.username}</span>
<Link to="/account" className="text-[var(--ink-faint)] hover:text-[var(--foreground)]">
{me.username}
</Link>
{/* There is no /logout route — logging out is a POST, not a
page. Navigating there rendered a dead end. */}
<button
Expand Down
28 changes: 14 additions & 14 deletions apps/client/src/components/patterns/AutoForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const schema = z.object({
})

function addTag(tag: string) {
const input = screen.getByLabelText('tags')
const input = screen.getByLabelText('Tags')
fireEvent.change(input, { target: { value: tag } })
fireEvent.keyDown(input, { key: 'Enter' })
}
Expand All @@ -24,15 +24,15 @@ describe('AutoForm', () => {

it('renders one labeled field per schema key', () => {
render(<AutoForm schema={schema} onSubmit={vi.fn()} />)
expect(screen.getByLabelText('title')).toBeInTheDocument()
expect(screen.getByLabelText('draft')).toBeInTheDocument()
expect(screen.getByLabelText('tags')).toBeInTheDocument()
expect(screen.getByLabelText('Title')).toBeInTheDocument()
expect(screen.getByLabelText('Draft')).toBeInTheDocument()
expect(screen.getByLabelText('Tags')).toBeInTheDocument()
})

it('derives the control from the schema type, not the field name', () => {
render(<AutoForm schema={schema} onSubmit={vi.fn()} />)
expect(screen.getByLabelText('draft')).toHaveAttribute('type', 'checkbox')
expect(screen.getByLabelText('tags')).toHaveAttribute('type', 'text')
expect(screen.getByLabelText('Draft')).toHaveAttribute('type', 'checkbox')
expect(screen.getByLabelText('Tags')).toHaveAttribute('type', 'text')
})

// `.partial()` wraps every field in ZodOptional *outside* the ZodDefault that
Expand All @@ -41,19 +41,19 @@ describe('AutoForm', () => {
it('still derives field kinds through a .partial() schema', () => {
const onSubmit = vi.fn()
render(<AutoForm schema={schema.partial()} onSubmit={onSubmit} />)
expect(screen.getByLabelText('draft')).toHaveAttribute('type', 'checkbox')
expect(screen.getByLabelText('Draft')).toHaveAttribute('type', 'checkbox')

addTag('express')
addTag('testing')
fireEvent.change(screen.getByLabelText('title'), { target: { value: 'A valid title' } })
fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'A valid title' } })
fireEvent.click(screen.getByText('Save'))
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ tags: ['express', 'testing'] }))
})

it('shows the schema error message and does not call onSubmit for invalid input', () => {
const onSubmit = vi.fn()
render(<AutoForm schema={schema} onSubmit={onSubmit} />)
fireEvent.change(screen.getByLabelText('title'), { target: { value: 'ab' } })
fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'ab' } })
fireEvent.click(screen.getByText('Save'))
expect(screen.getByText('Title must be at least 3 characters')).toBeInTheDocument()
expect(onSubmit).not.toHaveBeenCalled()
Expand All @@ -62,7 +62,7 @@ describe('AutoForm', () => {
it('submits the tags added one at a time as a string array', () => {
const onSubmit = vi.fn()
render(<AutoForm schema={schema} onSubmit={onSubmit} />)
fireEvent.change(screen.getByLabelText('title'), { target: { value: 'A valid title' } })
fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'A valid title' } })
addTag('express')
addTag('testing')
expect(screen.getByText('express')).toBeInTheDocument()
Expand All @@ -75,7 +75,7 @@ describe('AutoForm', () => {
it('drops a tag removed with its × button', () => {
const onSubmit = vi.fn()
render(<AutoForm schema={schema} onSubmit={onSubmit} />)
fireEvent.change(screen.getByLabelText('title'), { target: { value: 'A valid title' } })
fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'A valid title' } })
addTag('keep')
addTag('drop')
fireEvent.click(screen.getByLabelText('Remove tag drop'))
Expand All @@ -92,11 +92,11 @@ describe('AutoForm', () => {
submitLabel="Save changes"
/>,
)
expect(screen.getByLabelText('title')).toHaveValue('Seeded')
expect(screen.getByLabelText('draft')).toBeChecked()
expect(screen.getByLabelText('Title')).toHaveValue('Seeded')
expect(screen.getByLabelText('Draft')).toBeChecked()
expect(screen.getByText('a')).toBeInTheDocument()
expect(screen.getByText('b')).toBeInTheDocument()
expect(screen.getByLabelText('tags')).toHaveValue('')
expect(screen.getByLabelText('Tags')).toHaveValue('')
expect(screen.getByText('Save changes')).toBeInTheDocument()
})
})
18 changes: 16 additions & 2 deletions apps/client/src/components/patterns/AutoForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,18 @@ import { ZodFirstPartyTypeKind, type z, type ZodObject, type ZodRawShape, type Z
import { Button } from '../ui/button.js'
import { Input } from '../ui/input.js'
import { Label } from '../ui/label.js'
import { PasswordInput } from '../ui/password-input.js'
import { Textarea } from '../ui/textarea.js'
import { ImageUpload } from './ImageUpload.js'
import { TagsInput } from './TagsInput.js'

type FieldKind = 'checkbox' | 'textarea' | 'tags' | 'image' | 'text'
type FieldKind = 'checkbox' | 'textarea' | 'tags' | 'image' | 'password' | 'text'

/** "currentPassword" -> "Current Password"; "coverImage" -> "Cover Image". */
function humanize(key: string): string {
const spaced = key.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
return spaced.charAt(0).toUpperCase() + spaced.slice(1)
}

/**
* `ZodTypeDef` is the public (near-empty) shape of `_def`; the discriminant and
Expand Down Expand Up @@ -53,6 +60,7 @@ function fieldKind(key: string, schema: ZodTypeAny): FieldKind {
// Keyed by name, not by type: a public ID is a plain string to zod, and a raw
// text box for one would be unusable — nobody types a Cloudinary ID by hand.
if (key === 'coverImage' || key === 'avatar') return 'image'
if (key.toLowerCase().includes('password')) return 'password'
return 'text'
}

Expand Down Expand Up @@ -129,7 +137,7 @@ export function AutoForm<S extends ZodObject<ZodRawShape>>({
return (
<div key={key} className="flex flex-col gap-1">
{/* ImageUpload renders its own label and controls. */}
{kind !== 'image' && <Label htmlFor={key}>{key}</Label>}
{kind !== 'image' && <Label htmlFor={key}>{humanize(key)}</Label>}
{kind === 'image' ? (
<ImageUpload
value={typeof values[key] === 'string' ? (values[key] as string) : null}
Expand Down Expand Up @@ -157,6 +165,12 @@ export function AutoForm<S extends ZodObject<ZodRawShape>>({
value={String(values[key] ?? '')}
onChange={(e) => setValues((v) => ({ ...v, [key]: e.target.value }))}
/>
) : kind === 'password' ? (
<PasswordInput
id={key}
value={String(values[key] ?? '')}
onChange={(e) => setValues((v) => ({ ...v, [key]: e.target.value }))}
/>
) : (
<Input
id={key}
Expand Down
11 changes: 10 additions & 1 deletion apps/client/src/components/patterns/ChatRoom.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,19 @@
// apps/client/src/components/patterns/CommentForm.test.tsx does.
import '@testing-library/jest-dom/vitest'
import type { ChatMessage } from '@blog/zod-shared'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { cleanup, fireEvent, render as rtlRender, screen, type RenderOptions } from '@testing-library/react'
import type { ReactElement } from 'react'
import { MemoryRouter } from 'react-router'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ChatStatus, ChatUser } from '../../hooks/use-chat.js'

// Every ChatRoom render needs a Router context now that author names link to
// /users/:id — wrapped once here rather than passing { wrapper: MemoryRouter }
// at every call site below.
function render(ui: ReactElement, options?: RenderOptions) {
return rtlRender(ui, { wrapper: MemoryRouter, ...options })
}

// Mocked so each test can drive ChatRoom through a specific hook state
// directly, instead of exercising the real socket.
vi.mock('../../hooks/use-chat.js', () => ({ useChat: vi.fn() }))
Expand Down
6 changes: 5 additions & 1 deletion apps/client/src/components/patterns/ChatRoom.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { Link } from 'react-router'
import { Button } from '../ui/button.js'
import { Input } from '../ui/input.js'
import { useChat } from '../../hooks/use-chat.js'
Expand Down Expand Up @@ -46,7 +47,10 @@ export function ChatRoom() {
<li key={m.id} className="text-sm">
{m.author.username ? (
<>
<span className="font-semibold">{m.author.username}</span> {m.body}
<Link to={`/users/${m.author.id}`} className="font-semibold hover:underline">
{m.author.username}
</Link>{' '}
{m.body}
</>
) : (
m.body
Expand Down
5 changes: 4 additions & 1 deletion apps/client/src/components/patterns/CommentThread.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import '@testing-library/jest-dom/vitest'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { buildCommentTree, CommentThread } from './CommentThread.js'
import { queryKeys } from '../../lib/query-client.js'
Expand Down Expand Up @@ -67,7 +68,9 @@ function renderThread(comments: Comment[], me: AuthUser | null) {
client.setQueryData(queryKeys.me, me)
return render(
<QueryClientProvider client={client}>
<CommentThread slug="my-post" comments={buildCommentTree(comments)} />
<MemoryRouter>
<CommentThread slug="my-post" comments={buildCommentTree(comments)} />
</MemoryRouter>
</QueryClientProvider>,
)
}
Expand Down
6 changes: 5 additions & 1 deletion apps/client/src/components/patterns/CommentThread.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { Link } from 'react-router'
import { toast } from 'sonner'
import { ApiError } from '../../api/client.js'
import { useMe } from '../../hooks/use-auth.js'
Expand Down Expand Up @@ -54,7 +55,10 @@ function CommentItem({ slug, node, depth }: { slug: string; node: CommentNode; d
<li className="flex flex-col gap-2">
<article className="flex flex-col gap-1 border border-[var(--border)] p-3">
<header className="text-xs text-[var(--muted-foreground)]">
{node.author.username} · {new Date(node.createdAt).toLocaleDateString()}
<Link to={`/users/${node.author.id}`} className="hover:text-[var(--foreground)]">
{node.author.username}
</Link>{' '}
· {new Date(node.createdAt).toLocaleDateString()}
</header>

{editing ? (
Expand Down
21 changes: 8 additions & 13 deletions apps/client/src/components/patterns/OAuthButtons.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest'
import { queryKeys } from '../../lib/query-client.js'
import { OAuthButtons } from './OAuthButtons.js'

function renderWith(providers: { google: boolean; facebook: boolean } | undefined) {
function renderWith(providers: { google: boolean } | undefined) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
})
Expand All @@ -20,35 +20,30 @@ function renderWith(providers: { google: boolean; facebook: boolean } | undefine
describe('OAuthButtons', () => {
afterEach(() => cleanup())

// A deployment with no OAuth apps registered must show no button at all,
// A deployment with no OAuth app registered must show no button at all,
// rather than one that leads to a 503.
it('renders nothing when both providers are disabled', () => {
const { container } = renderWith({ google: false, facebook: false })
it('renders nothing when the provider is disabled', () => {
const { container } = renderWith({ google: false })
expect(container).toBeEmptyDOMElement()
})

it('renders nothing before the providers are known', () => {
it('renders nothing before the provider is known', () => {
const { container } = renderWith(undefined)
expect(container).toBeEmptyDOMElement()
})

it('renders only the enabled provider', () => {
renderWith({ google: true, facebook: false })
it('renders the Google button when enabled', () => {
renderWith({ google: true })
expect(screen.getByRole('link', { name: /google/i })).toBeInTheDocument()
expect(screen.queryByRole('link', { name: /facebook/i })).not.toBeInTheDocument()
})

// A top-level navigation, not a fetch: the provider has to render its own
// consent screen and set its own cookies.
it('links straight at the API route rather than fetching it', () => {
renderWith({ google: true, facebook: true })
renderWith({ google: true })
expect(screen.getByRole('link', { name: /google/i })).toHaveAttribute(
'href',
'/api/v1/auth/google',
)
expect(screen.getByRole('link', { name: /facebook/i })).toHaveAttribute(
'href',
'/api/v1/auth/facebook',
)
})
})
32 changes: 12 additions & 20 deletions apps/client/src/components/patterns/OAuthButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ import { authApi } from '../../api/auth.js'
import { queryKeys } from '../../lib/query-client.js'

/**
* Federated sign-in options, rendered only for providers this deployment has
* credentials for — a missing credential pair means no button, rather than a
* button that leads to a 503.
* Federated sign-in, rendered only when this deployment has Google
* credentials configured — a missing credential pair means no button, rather
* than a button that leads to a 503.
*
* These are plain links, not fetch calls: OAuth needs a top-level navigation so
* the provider can show its own consent screen and set its own cookies.
* A plain link, not a fetch call: OAuth needs a top-level navigation so the
* provider can show its own consent screen and set its own cookies.
*/
export function OAuthButtons({ verb = 'Sign in' }: { verb?: string }) {
const { data: providers } = useQuery({
Expand All @@ -18,12 +18,7 @@ export function OAuthButtons({ verb = 'Sign in' }: { verb?: string }) {
staleTime: Infinity,
})

const enabled = [
{ id: 'google', label: 'Google', on: providers?.google },
{ id: 'facebook', label: 'Facebook', on: providers?.facebook },
].filter((p) => p.on)

if (enabled.length === 0) return null
if (!providers?.google) return null

return (
<div className="flex flex-col gap-3">
Expand All @@ -35,15 +30,12 @@ export function OAuthButtons({ verb = 'Sign in' }: { verb?: string }) {
<span className="h-px flex-1 bg-[var(--border)]" />
</div>

{enabled.map((provider) => (
<a
key={provider.id}
href={`/api/v1/auth/${provider.id}`}
className="flex h-10 items-center justify-center gap-2 border border-[var(--border)] text-sm font-medium hover:bg-[var(--muted)]"
>
{verb} with {provider.label}
</a>
))}
<a
href="/api/v1/auth/google"
className="flex h-10 items-center justify-center gap-2 border border-[var(--border)] text-sm font-medium hover:bg-[var(--muted)]"
>
{verb} with Google
</a>
</div>
)
}
4 changes: 3 additions & 1 deletion apps/client/src/components/patterns/PostCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ export function PostCard({ post, featured = false }: { post: Post; featured?: bo
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-xs tracking-[0.09em] text-[var(--ink-faint)] uppercase tabular-nums">
<time dateTime={post.createdAt}>{formatDate(post.createdAt)}</time>
<span aria-hidden="true">·</span>
<span>{post.author.username}</span>
<Link to={`/users/${post.author.id}`} className="hover:text-[var(--foreground)]">
{post.author.username}
</Link>
<span aria-hidden="true">·</span>
<span>
{post.likeCount} {post.likeCount === 1 ? 'like' : 'likes'}
Expand Down
Loading
Loading