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
5 changes: 5 additions & 0 deletions hub/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ TEST_DATABASE_URL=postgresql://replica:replica@127.0.0.1:5432/postgres
# GitHub proves identity by default. Select legacy-repository only for compatibility.
HUB_ADMISSION_POLICY=hub-managed

# Generate separate host-held values. Bootstrap can enroll the first administrator once.
# Recovery works only when no active administrator remains and never reopens bootstrap.
# HUB_BOOTSTRAP_SECRET=<openssl rand -hex 32>
# HUB_ADMIN_RECOVERY_SECRET=<openssl rand -hex 32>

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

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 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.
The API exposes its health check, GitHub authentication, sessions, and installation membership administration. The frontend provides GitHub sign-in, one-use administrator enrollment and recovery, and a minimal membership administration surface. Existing prototype components, features, and pages remain in Storybook, but they are not connected to the new product APIs.

Contributors can use PostgreSQL and Prisma locally or in the devcontainer. The database has no product models yet. GitHub sign-in works when its dedicated client credentials are configured; invitations, membership administration, product authorization, saved product data, live updates, background jobs, and deployment are not implemented.
Contributors can use PostgreSQL and Prisma locally or in the devcontainer. GitHub sign-in works when its dedicated client credentials are configured. Hub-owned admission, invitations, membership roles and lifecycle, administrator recovery, and session revocation are implemented. Prototype authorization, saved prototype data, live updates, background jobs, 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
112 changes: 111 additions & 1 deletion hub/app/javascript/application/authentication/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,27 @@ import { useEffect, useState } from "react"

import { SignInPage } from "@pages/sign-in-page"
import type { SignInPageUser } from "@pages/sign-in-page/types"
import { MembershipAdministrationPage } from "@pages/membership-administration-page"
import type {
InvitationRecord,
MembershipRecord,
} from "@pages/membership-administration-page/types"

interface SessionResponse {
authenticated: boolean
configured: boolean
csrfToken: string
user?: SignInPageUser
membership?: {
id: string
role: "member" | "administrator"
status: "active"
}
}

interface AdministrationResponse {
memberships: MembershipRecord[]
invitations: InvitationRecord[]
}

async function fetchSession(): Promise<SessionResponse> {
Expand All @@ -20,10 +35,18 @@ export function AuthenticationApplication() {
const [session, setSession] = useState<SessionResponse | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [administration, setAdministration] = useState<AdministrationResponse | null>(null)

useEffect(() => {
fetchSession()
.then(setSession)
.then(async (value) => {
setSession(value)
if (value.membership?.role === "administrator") {
const response = await fetch("/api/administration", { credentials: "same-origin" })
if (!response.ok) throw new Error("Administration request failed")
setAdministration((await response.json()) as AdministrationResponse)
}
})
.catch(() => setError("Hub could not load your sign-in status."))
.finally(() => setLoading(false))
}, [])
Expand All @@ -47,6 +70,29 @@ export function AuthenticationApplication() {
}
}

async function startEnrollment(purpose: "bootstrap" | "recovery", secret: string) {
if (!session) return
setLoading(true)
setError(null)
try {
const response = await fetch(`/api/auth/${purpose}`, {
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": session.csrfToken,
},
body: JSON.stringify({ secret }),
})
if (!response.ok) throw new Error("Enrollment is unavailable.")
const data = (await response.json()) as { authorizationUrl: string }
window.location.assign(data.authorizationUrl)
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : "Enrollment is unavailable.")
setLoading(false)
}
}

async function signOut() {
if (!session) return
setLoading(true)
Expand All @@ -59,13 +105,75 @@ export function AuthenticationApplication() {
})
if (!response.ok) throw new Error("Sign-out request failed")
setSession(await fetchSession())
setAdministration(null)
} catch {
setError("Hub could not sign you out.")
} finally {
setLoading(false)
}
}

async function updateAdministration(path: string, method: string, body?: unknown) {
if (!session) return
setLoading(true)
setError(null)
try {
const response = await fetch(path, {
method,
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": session.csrfToken,
},
body: body === undefined ? undefined : JSON.stringify(body),
})
if (!response.ok) {
const data = (await response.json()) as { error?: string }
throw new Error(data.error ?? "Administration request failed")
Comment on lines +131 to +132
Comment on lines +131 to +132
}
const current = await fetchSession()
setSession(current)
if (current.membership?.role === "administrator") {
const refreshed = await fetch("/api/administration", { credentials: "same-origin" })
if (!refreshed.ok) throw new Error("Administration request failed")
setAdministration((await refreshed.json()) as AdministrationResponse)
} else {
setAdministration(null)
}
} catch (requestError) {
setError(
requestError instanceof Error ? requestError.message : "Hub could not update membership.",
)
} finally {
setLoading(false)
}
}

if (session?.authenticated && session.membership?.role === "administrator" && administration) {
return (
<MembershipAdministrationPage
memberships={administration.memberships}
invitations={administration.invitations}
loading={loading}
error={error}
onInvite={(input) =>
void updateAdministration("/api/administration/invitations", "POST", input)
}
onChange={(membershipId, input) =>
void updateAdministration(
`/api/administration/memberships/${membershipId}`,
"PATCH",
input,
)
}
onRevokeInvitation={(invitationId) =>
void updateAdministration(`/api/administration/invitations/${invitationId}`, "DELETE")
}
onSignOut={() => void signOut()}
/>
)
}

return (
<SignInPage
configured={session?.configured ?? false}
Expand All @@ -74,6 +182,8 @@ export function AuthenticationApplication() {
error={error}
onSignIn={() => void signIn()}
onSignOut={() => void signOut()}
onBootstrap={(secret) => void startEnrollment("bootstrap", secret)}
onRecovery={(secret) => void startEnrollment("recovery", secret)}
/>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { ThemeProvider } from "@primer/react-brand"
import { fireEvent, render, screen, within } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest"

import { MembershipAdministrationPage } from "."

describe("MembershipAdministrationPage()", () => {
it("offers restoration for suspension but no lifecycle actions after removal", () => {
const onChange = vi.fn()
render(
<ThemeProvider>
<MembershipAdministrationPage
memberships={[
{
id: "suspended",
status: "suspended",
role: "member",
user: {
id: "suspended-user",
displayName: "Suspended member",
externalIdentities: [],
},
},
{
id: "removed",
status: "removed",
role: "administrator",
user: {
id: "removed-user",
displayName: "Removed administrator",
externalIdentities: [],
},
},
]}
invitations={[]}
loading={false}
error={null}
onInvite={vi.fn()}
onChange={onChange}
onRevokeInvitation={vi.fn()}
onSignOut={vi.fn()}
/>
</ThemeProvider>,
)

const suspended = screen.getByText("Suspended member").closest("li")!
fireEvent.click(within(suspended).getByRole("button", { name: "Restore" }))
expect(onChange).toHaveBeenCalledWith("suspended", { status: "active" })

const removed = screen.getByText("Removed administrator").closest("li")!
expect(within(removed).queryByRole("button")).not.toBeInTheDocument()
})
})
Loading
Loading