From a06e869e33f2b808873e4c669cae6e8eecfe596b Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Thu, 30 Apr 2026 00:04:12 +0300 Subject: [PATCH 1/5] Frontend updates first pass --- core/api.py | 9 +- core/serializers.py | 48 +++ core/tests/test_api.py | 17 + .../app/admin/sources/__tests__/page.test.tsx | 59 +++- frontend/src/app/admin/sources/page.tsx | 314 +++++++++++++++--- .../[id]/intake/__tests__/route.test.ts | 85 +++++ .../src/app/api/projects/[id]/intake/route.ts | 59 ++++ .../__tests__/route.test.ts | 79 +++++ .../[id]/verify-bluesky-credentials/route.ts | 58 ++++ frontend/src/components/copy-button.tsx | 42 +++ frontend/src/lib/api.ts | 52 +++ frontend/src/lib/types.ts | 16 +- frontend/tsconfig.tsbuildinfo | 2 +- 13 files changed, 779 insertions(+), 61 deletions(-) create mode 100644 frontend/src/app/api/projects/[id]/intake/__tests__/route.test.ts create mode 100644 frontend/src/app/api/projects/[id]/intake/route.ts create mode 100644 frontend/src/app/api/projects/[id]/verify-bluesky-credentials/__tests__/route.test.ts create mode 100644 frontend/src/app/api/projects/[id]/verify-bluesky-credentials/route.ts create mode 100644 frontend/src/components/copy-button.tsx diff --git a/core/api.py b/core/api.py index 4f4ee912..1cf08cba 100644 --- a/core/api.py +++ b/core/api.py @@ -104,6 +104,13 @@ "group": 3, "topic_description": "Coverage of developer tools, model releases, and applied AI workflows.", "content_retention_days": 180, + "intake_token": "project-token-123", + "intake_enabled": True, + "has_bluesky_credentials": True, + "bluesky_handle": "aiweekly.bsky.social", + "bluesky_is_active": True, + "bluesky_last_verified_at": "2026-04-26T13:00:00Z", + "bluesky_last_error": "", "created_at": "2026-04-26T12:00:00Z", }, response_only=True, @@ -594,7 +601,7 @@ class ProjectViewSet(viewsets.ModelViewSet): """Manage projects accessible through the current user's group memberships.""" serializer_class = ProjectSerializer - queryset = Project.objects.select_related("group") + queryset = Project.objects.select_related("group", "bluesky_credentials") lookup_url_kwarg = "id" def get_queryset(self): diff --git a/core/serializers.py b/core/serializers.py index ef3fcafd..4e69252f 100644 --- a/core/serializers.py +++ b/core/serializers.py @@ -83,6 +83,12 @@ def __init__(self, *args, **kwargs): class ProjectSerializer(ProjectScopedSerializerMixin, serializers.ModelSerializer): """Serialize top-level project records.""" + has_bluesky_credentials = serializers.SerializerMethodField() + bluesky_handle = serializers.SerializerMethodField() + bluesky_is_active = serializers.SerializerMethodField() + bluesky_last_verified_at = serializers.SerializerMethodField() + bluesky_last_error = serializers.SerializerMethodField() + class Meta: model = Project fields = [ @@ -93,10 +99,52 @@ class Meta: "content_retention_days", "intake_token", "intake_enabled", + "has_bluesky_credentials", + "bluesky_handle", + "bluesky_is_active", + "bluesky_last_verified_at", + "bluesky_last_error", "created_at", ] read_only_fields = ["id", "created_at"] + def _get_bluesky_credentials(self, obj: Project): + """Return the project's stored Bluesky credentials, if configured.""" + + try: + return obj.bluesky_credentials + except Project.bluesky_credentials.RelatedObjectDoesNotExist: + return None + + def get_has_bluesky_credentials(self, obj: Project) -> bool: + """Return whether the project has stored Bluesky credentials.""" + + return self._get_bluesky_credentials(obj) is not None + + def get_bluesky_handle(self, obj: Project) -> str: + """Return the stored Bluesky handle, or an empty string.""" + + credentials = self._get_bluesky_credentials(obj) + return credentials.handle if credentials else "" + + def get_bluesky_is_active(self, obj: Project) -> bool: + """Return whether the stored Bluesky credentials are currently active.""" + + credentials = self._get_bluesky_credentials(obj) + return credentials.is_active if credentials else False + + def get_bluesky_last_verified_at(self, obj: Project): + """Return the last successful verification timestamp, if available.""" + + credentials = self._get_bluesky_credentials(obj) + return credentials.last_verified_at if credentials else None + + def get_bluesky_last_error(self, obj: Project) -> str: + """Return the latest Bluesky verification error, or an empty string.""" + + credentials = self._get_bluesky_credentials(obj) + return credentials.last_error if credentials else "" + class ProjectConfigSerializer( ProjectScopedSerializerMixin, serializers.ModelSerializer diff --git a/core/tests/test_api.py b/core/tests/test_api.py index 874a515a..50e03e6e 100644 --- a/core/tests/test_api.py +++ b/core/tests/test_api.py @@ -147,11 +147,28 @@ def test_project_list_requires_authentication(self): ) def test_project_list_is_scoped_to_request_user_groups(self): + BlueskyCredentials.objects.create( + project=self.owner_project, + handle="owner-project.bsky.social", + is_active=True, + last_error="", + ) + response = self.client.get(reverse("v1:project-list")) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.json()), 1) self.assertEqual(response.json()[0]["id"], self.owner_project.id) + self.assertEqual( + response.json()[0]["intake_token"], self.owner_project.intake_token + ) + self.assertFalse(response.json()[0]["intake_enabled"]) + self.assertTrue(response.json()[0]["has_bluesky_credentials"]) + self.assertEqual( + response.json()[0]["bluesky_handle"], "owner-project.bsky.social" + ) + self.assertTrue(response.json()[0]["bluesky_is_active"]) + self.assertEqual(response.json()[0]["bluesky_last_error"], "") def test_entity_list_is_scoped_to_request_user_project(self): response = self.client.get( diff --git a/frontend/src/app/admin/sources/__tests__/page.test.tsx b/frontend/src/app/admin/sources/__tests__/page.test.tsx index 192791b9..0732677a 100644 --- a/frontend/src/app/admin/sources/__tests__/page.test.tsx +++ b/frontend/src/app/admin/sources/__tests__/page.test.tsx @@ -72,6 +72,13 @@ function createProject(overrides: Partial = {}): Project { group: 10, topic_description: "AI news", content_retention_days: 30, + intake_token: "intake-token-123", + intake_enabled: false, + has_bluesky_credentials: false, + bluesky_handle: "", + bluesky_is_active: false, + bluesky_last_verified_at: null, + bluesky_last_error: "", created_at: "2026-04-01T00:00:00Z", ...overrides, } @@ -203,10 +210,42 @@ describe("SourcesPage", () => { expect( screen.getByText("No source configurations exist for this project yet."), ).toBeInTheDocument() + expect(screen.getByDisplayValue("intake-token-123")).toBeInTheDocument() + expect( + screen.getByRole("button", { name: "Verify credentials" }), + ).toBeDisabled() expect(getProjectSourceConfigsMock).toHaveBeenCalledWith(1) expect(getProjectIngestionRunsMock).toHaveBeenCalledWith(1) }) + it("renders intake controls and Bluesky verification state from the selected project", async () => { + const selectedProject = createProject({ + id: 3, + intake_enabled: true, + intake_token: "intake-token-xyz", + has_bluesky_credentials: true, + bluesky_handle: "project.bsky.social", + bluesky_is_active: true, + bluesky_last_verified_at: "2026-04-29T10:00:00Z", + }) + + getProjectsMock.mockResolvedValue([selectedProject]) + selectProjectMock.mockReturnValue(selectedProject) + + await renderSourcesPage({ project: "3" }) + + expect(screen.getByText("Project intake settings")).toBeInTheDocument() + expect(screen.getByDisplayValue("intake-token-xyz")).toBeInTheDocument() + expect( + screen.getByDisplayValue("intake+intake-token-xyz@inbox.example.com"), + ).toBeInTheDocument() + expect(screen.getByText("project.bsky.social")).toBeInTheDocument() + expect(screen.getByText("verified")).toBeInTheDocument() + expect( + screen.getByRole("button", { name: "Verify credentials" }), + ).toBeEnabled() + }) + it("renders source cards with badge tones and the latest run summary", async () => { const selectedProject = createProject({ id: 3 }) getProjectsMock.mockResolvedValue([selectedProject]) @@ -258,11 +297,21 @@ describe("SourcesPage", () => { expect(screen.getByText("Rate limited")).toBeInTheDocument() const badges = screen.getAllByTestId("status-badge") - expect(badges).toHaveLength(2) - expect(badges[0]).toHaveAttribute("data-tone", "positive") - expect(badges[0]).toHaveTextContent("active") - expect(badges[1]).toHaveAttribute("data-tone", "neutral") - expect(badges[1]).toHaveTextContent("disabled") + expect(badges).toHaveLength(4) + expect( + badges.some( + (badge) => + badge.getAttribute("data-tone") === "neutral" && + badge.textContent === "disabled", + ), + ).toBe(true) + expect( + badges.some( + (badge) => + badge.getAttribute("data-tone") === "positive" && + badge.textContent === "active", + ), + ).toBe(true) }) it("shows fallback latest-run text when a source has no ingestion history", async () => { diff --git a/frontend/src/app/admin/sources/page.tsx b/frontend/src/app/admin/sources/page.tsx index 69ad525e..0546a0a1 100644 --- a/frontend/src/app/admin/sources/page.tsx +++ b/frontend/src/app/admin/sources/page.tsx @@ -1,10 +1,12 @@ import { AppShell } from "@/components/app-shell" +import { CopyButton } from "@/components/copy-button" import { StatusBadge } from "@/components/status-badge" import { getProjectIngestionRuns, getProjects, getProjectSourceConfigs, } from "@/lib/api" +import type { Project } from "@/lib/types" import { formatDate, getErrorMessage, @@ -12,6 +14,49 @@ import { selectProject, } from "@/lib/view-helpers" +type BlueskyVerificationState = { + label: string + tone: "positive" | "warning" | "negative" | "neutral" +} + +/** + * Build the documented newsletter intake address pattern for one project token. + * + * The backend stores only the per-project token today, not the mail-provider domain. + * This helper renders the documented address pattern so editors can copy the project + * token and see how it is expected to be used with the inbound mailbox domain. + * + * @param intakeToken - The stable per-project intake token. + * @returns The documented intake address pattern. + */ +export function buildIntakeAddressTemplate(intakeToken: string) { + return `intake+${intakeToken || ""}@inbox.example.com` +} + +/** + * Derive the current Bluesky verification badge state for the selected project. + * + * @param project - Project record returned from the backend. + * @returns A badge label and semantic tone describing the stored credential state. + */ +export function deriveBlueskyVerificationState( + project: Project, +): BlueskyVerificationState { + if (!project.has_bluesky_credentials) { + return { label: "not configured", tone: "neutral" } + } + + if (project.bluesky_last_error) { + return { label: "verification failed", tone: "negative" } + } + + if (project.bluesky_last_verified_at) { + return { label: "verified", tone: "positive" } + } + + return { label: "needs verification", tone: "warning" } +} + type SourcesPageProps = { searchParams: Promise> } @@ -75,6 +120,13 @@ export default async function SourcesPage({ searchParams }: SourcesPageProps) { getProjectIngestionRuns(selectedProject.id), ]) const latestRunByPlugin = buildLatestRunByPlugin(ingestionRuns) + const blueskyVerificationState = deriveBlueskyVerificationState(selectedProject) + const intakeAddressTemplate = buildIntakeAddressTemplate( + selectedProject.intake_token ?? "", + ) + const sortedSourceConfigs = sourceConfigs + .slice() + .sort((left, right) => left.plugin_name.localeCompare(right.plugin_name)) const errorMessage = getErrorMessage(resolvedSearchParams) const successMessage = getSuccessMessage(resolvedSearchParams) @@ -82,7 +134,7 @@ export default async function SourcesPage({ searchParams }: SourcesPageProps) { return ( @@ -94,66 +146,222 @@ export default async function SourcesPage({ searchParams }: SourcesPageProps) { ) : null}
-
-

Add source

-
- - - -