Skip to content
Open
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
18 changes: 16 additions & 2 deletions hub/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,33 @@ LOG_LEVEL=info
ON_DEMAND_JOB_CONCURRENCY=1
SCHEDULED_JOB_CONCURRENCY=1

# Public browser origin. Production requires HTTPS; local HTTP must use loopback.
HUB_ORIGIN=http://127.0.0.1:5173

# Keep 0 unless the API is reachable only through this exact number of trusted proxies.
HUB_TRUSTED_PROXY_HOPS=0

# Local development only. The devcontainer supplies its own database hostname.
DATABASE_URL=postgresql://replica:replica@127.0.0.1:5432/replica

# Only database-enabled tests require this URL and CREATEDB permission.
TEST_DATABASE_URL=postgresql://replica:replica@127.0.0.1:5432/postgres

# Select the GitHub source credential provider. See docs/identity.md.
# GitHub user authorization is optional until both values are configured.
# These credentials establish user identity and are separate from source integration.
# HUB_GITHUB_AUTH_CLIENT_ID=
# HUB_GITHUB_AUTH_CLIENT_SECRET=

# GitHub proves identity by default. Select legacy-repository only for compatibility.
HUB_ADMISSION_POLICY=hub-managed

# Select the GitHub source-integration credential provider. See docs/identity.md.
HUB_GITHUB_CREDENTIAL_PROVIDER=database

# Generate separate values outside the database. See docs/identity.md.
# HUB_GITHUB_CREDENTIAL_KEY=<openssl rand -hex 32>

# The environment provider is read-only and requires every GitHub integration setting below.
# The environment provider is read-only and requires every source-integration setting below.
# GITHUB_APP_ID=
# GITHUB_INSTALLATION_ID=
# GITHUB_CLIENT_ID=
Expand Down
4 changes: 2 additions & 2 deletions hub/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

Hub is being rebuilt on `build/hub/new` using TypeScript, Express, React, Vite, and Vitest. This branch lets contributors start the new app locally. It is not ready to replace the existing Hub.

The API has one route: `GET /api/health`. The frontend displays the existing Replica logo and supports light and dark themes. Existing components, features, and pages are kept in Storybook, but they are not connected to the new API.
The API exposes its health check and GitHub authentication and session routes. The frontend provides the minimal GitHub sign-in and sign-out surface with light and dark themes. Existing components, features, and pages remain in Storybook, but they are not connected to product APIs.

Contributors can use PostgreSQL and Prisma locally or in the devcontainer. The [background-job foundation](docs/background-jobs.md) provides PostgreSQL-backed BullMQ queues and independent on-demand and scheduled workers, without product jobs yet. The database has no product models. Sign-in, saved product data, live updates, and deployment are not implemented.
Contributors can use PostgreSQL and Prisma locally or in the devcontainer. The [background-job foundation](docs/background-jobs.md) provides PostgreSQL-backed BullMQ queues and independent on-demand and scheduled workers, without product jobs yet. GitHub sign-in works when its dedicated client credentials are configured. The database has no product models; invitations, membership administration, product authorization, saved product data, live updates, and deployment are not implemented.

Use [Development](docs/development.md) to start the API and frontend. Review [Architecture](docs/architecture.md), [code architecture conventions](docs/backend-architecture.md), and [coding conventions](docs/coding-conventions.md) before adding behavior.

Expand Down
73 changes: 73 additions & 0 deletions hub/app/javascript/application/authentication/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest"

import { AuthenticationApplication } from "."

function sessionResponse(
authenticated: boolean,
csrfToken: string,
user?: { displayName: string; username: string; avatarUrl: null },
) {
return new Response(
JSON.stringify({
authenticated,
configured: true,
csrfToken,
user,
}),
{ headers: { "Content-Type": "application/json" } },
)
}

describe("AuthenticationApplication()", () => {
it("shows a generic callback error and removes its query marker", async () => {
window.history.replaceState({}, "", "/?authentication=failed")
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(sessionResponse(false, "csrf-token")))

render(<AuthenticationApplication />)

expect(await screen.findByRole("alert")).toHaveTextContent(
"GitHub could not authorize this Hub session.",
)
expect(window.location.search).toBe("")
})

it("refreshes the CSRF token before starting sign-in", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(sessionResponse(false, "initial-token"))
.mockResolvedValueOnce(sessionResponse(false, "fresh-token"))
.mockResolvedValueOnce(new Response(null, { status: 403 }))
vi.stubGlobal("fetch", fetchMock)
render(<AuthenticationApplication />)
fireEvent.click(await screen.findByRole("button", { name: "Sign in with GitHub" }))

await screen.findByRole("alert")
expect(fetchMock).toHaveBeenNthCalledWith(3, "/api/auth/github", {
method: "POST",
credentials: "same-origin",
headers: { "X-CSRF-Token": "fresh-token" },
})
})

it("reconciles an already-ended session without sending sign-out", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
sessionResponse(true, "initial-token", {
displayName: "Mona Lisa",
username: "mona",
avatarUrl: null,
}),
)
.mockResolvedValueOnce(sessionResponse(false, "fresh-token"))
vi.stubGlobal("fetch", fetchMock)
render(<AuthenticationApplication />)
fireEvent.click(await screen.findByRole("button", { name: "Sign out" }))

await waitFor(() =>
expect(screen.getByRole("button", { name: "Sign in with GitHub" })).toBeEnabled(),
)
expect(fetchMock).toHaveBeenCalledTimes(2)
})
})
93 changes: 93 additions & 0 deletions hub/app/javascript/application/authentication/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { useEffect, useState } from "react"

import { SignInPage } from "@pages/sign-in-page"
import type { SignInPageUser } from "@pages/sign-in-page/types"

interface SessionResponse {
authenticated: boolean
configured: boolean
csrfToken: string
user?: SignInPageUser
}

async function fetchSession(): Promise<SessionResponse> {
const response = await fetch("/api/session", { credentials: "same-origin" })
if (!response.ok) throw new Error("Session request failed")
return (await response.json()) as SessionResponse
}

export function AuthenticationApplication() {
const [session, setSession] = useState<SessionResponse | null>(null)
const [error, setError] = useState<string | null>(() =>
new URLSearchParams(window.location.search).get("authentication") === "failed"
? "GitHub could not authorize this Hub session."
: null,
)
const [loading, setLoading] = useState(true)

useEffect(() => {
const url = new URL(window.location.href)
if (url.searchParams.has("authentication")) {
url.searchParams.delete("authentication")
window.history.replaceState(window.history.state, "", url)
}
fetchSession()
.then(setSession)
.catch(() => setError("Hub could not load your sign-in status."))
.finally(() => setLoading(false))
}, [])

async function signIn() {
if (!session) return
setLoading(true)
setError(null)
try {
const current = await fetchSession()
setSession(current)
const response = await fetch("/api/auth/github", {
method: "POST",
credentials: "same-origin",
headers: { "X-CSRF-Token": current.csrfToken },
})
if (!response.ok) throw new Error("Sign-in request failed")
const data = (await response.json()) as { authorizationUrl: string }
window.location.assign(data.authorizationUrl)
} catch {
setError("Hub could not start GitHub sign-in.")
setLoading(false)
}
}

async function signOut() {
if (!session) return
setLoading(true)
setError(null)
try {
const current = await fetchSession()
setSession(current)
if (!current.authenticated) return
const response = await fetch("/api/session", {
method: "DELETE",
credentials: "same-origin",
headers: { "X-CSRF-Token": current.csrfToken },
})
if (!response.ok) throw new Error("Sign-out request failed")
setSession(await fetchSession())
} catch {
setError("Hub could not sign you out.")
} finally {
setLoading(false)
}
}

return (
<SignInPage
configured={session?.configured ?? false}
loading={loading}
user={session?.authenticated ? (session.user ?? null) : null}
error={error}
onSignIn={() => void signIn()}
onSignOut={() => void signOut()}
/>
)
}
4 changes: 2 additions & 2 deletions hub/app/javascript/entrypoints/application.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import "@/tokens/base.css"
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"

import { ReplicaLogo } from "@components/replica-logo"
import { AuthenticationApplication } from "@/application/authentication"
import { HubThemeProvider } from "@hooks/use-hub-theme"

const root = document.getElementById("root")
Expand All @@ -11,7 +11,7 @@ if (!root) throw new Error("The Hub document is missing its React root.")
createRoot(root).render(
<StrictMode>
<HubThemeProvider>
<ReplicaLogo />
<AuthenticationApplication />
</HubThemeProvider>
</StrictMode>,
)
57 changes: 57 additions & 0 deletions hub/app/javascript/pages/sign-in-page/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { fireEvent, render, screen } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest"

import { SignInPage } from "."

describe("SignInPage()", () => {
it("starts GitHub sign-in without presenting repository access as authentication", () => {
const onSignIn = vi.fn()
render(
<SignInPage
configured
loading={false}
user={null}
error={null}
onSignIn={onSignIn}
onSignOut={() => undefined}
/>,
)

fireEvent.click(screen.getByRole("button", { name: "Sign in with GitHub" }))

expect(onSignIn).toHaveBeenCalledOnce()
expect(screen.getByText(/Repository access is authorized separately/)).toBeVisible()
})

it("shows the provider-neutral session identity and sign-out control", () => {
render(
<SignInPage
configured
loading={false}
user={{ displayName: "Mona Lisa", username: "mona", avatarUrl: null }}
error={null}
onSignIn={() => undefined}
onSignOut={() => undefined}
/>,
)

expect(screen.getByText(/Signed in as/)).toHaveTextContent("Mona Lisa (@mona)")
expect(screen.getByRole("button", { name: "Sign out" })).toBeEnabled()
})

it("disables sign-in when GitHub user authorization is not configured", () => {
render(
<SignInPage
configured={false}
loading={false}
user={null}
error={null}
onSignIn={() => undefined}
onSignOut={() => undefined}
/>,
)

expect(screen.getByRole("button", { name: "Sign in with GitHub" })).toBeDisabled()
expect(screen.getByText(/has not been configured/)).toBeVisible()
})
})
60 changes: 60 additions & 0 deletions hub/app/javascript/pages/sign-in-page/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { Button, Heading, Stack, Text } from "@primer/react-brand"

import { ReplicaLogo } from "@components/replica-logo"

import type { SignInPageProps } from "./types"
import "./styles.css"

export function SignInPage({
configured,
loading,
user,
error,
onSignIn,
onSignOut,
}: SignInPageProps) {
return (
<main className="sign-in-page">
<Stack direction="vertical" gap="normal" alignItems="center">
<ReplicaLogo />
<Heading as="h1" size="3">
Replica Hub
</Heading>
{user ? (
<>
<Text as="p">
Signed in as <strong>{user.displayName ?? user.username}</strong>
{user.username ? ` (@${user.username})` : ""}.
</Text>
<Button type="button" variant="subtle" disabled={loading} onClick={onSignOut}>
Sign out
</Button>
</>
) : (
<>
<Text as="p">
Sign in with GitHub to create a Hub session. Repository access is authorized
separately.
</Text>
<Button
type="button"
variant="primary"
disabled={loading || !configured}
onClick={onSignIn}
>
Sign in with GitHub
</Button>
{!configured && (
<Text as="p">GitHub sign-in has not been configured for this Hub.</Text>
)}
</>
)}
{error && (
<Text as="p" className="sign-in-page__error" role="alert">
{error}
</Text>
)}
</Stack>
</main>
)
}
37 changes: 37 additions & 0 deletions hub/app/javascript/pages/sign-in-page/stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { Meta, StoryObj } from "@storybook/react-vite"

import { SignInPage } from "."

const meta = {
title: "Hub/Pages/Identity/Sign in",
component: SignInPage,
parameters: { layout: "fullscreen" },
args: {
configured: true,
loading: false,
user: null,
error: null,
onSignIn: () => undefined,
onSignOut: () => undefined,
},
} satisfies Meta<typeof SignInPage>

export default meta
type Story = StoryObj<typeof meta>

export const SignedOut: Story = {}
export const SignedIn: Story = {
args: {
user: {
displayName: "Mona Lisa",
username: "mona",
avatarUrl: null,
},
},
}
export const Unconfigured: Story = {
args: { configured: false },
}
export const AuthorizationFailed: Story = {
args: { error: "GitHub could not authorize this Hub session." },
}
Loading
Loading