@@ -852,6 +984,7 @@ export default async function SourcesPage({ searchParams }: SourcesPageProps) {
RSS
Reddit
Bluesky
+
LinkedIn
Mastodon
@@ -869,7 +1002,7 @@ export default async function SourcesPage({ searchParams }: SourcesPageProps) {
Bluesky configs accept either an actor handle or a feed URI. Mastodon
- configs accept an instance URL plus one of hashtag , account_acct , or list_id . RSS and Reddit continue to use the existing backend JSON shapes.
+ configs accept an instance URL plus one of hashtag , account_acct , or list_id . LinkedIn configs accept organization_urn , person_urn , or newsletter_urn . RSS and Reddit continue to use the existing backend JSON shapes.
Active
diff --git a/frontend/src/app/api/auth/[...nextauth]/route.test.ts b/frontend/src/app/api/auth/[...nextauth]/route.test.ts
index 28e4c885..7b9274c9 100644
--- a/frontend/src/app/api/auth/[...nextauth]/route.test.ts
+++ b/frontend/src/app/api/auth/[...nextauth]/route.test.ts
@@ -15,4 +15,4 @@ describe("/api/auth/[...nextauth] route exports", () => {
expect(GET).toBe(authHandlerMock)
expect(POST).toBe(authHandlerMock)
})
-})
\ No newline at end of file
+})
diff --git a/frontend/src/app/api/projects/[id]/invitations/[invitationId]/revoke/route.test.ts b/frontend/src/app/api/projects/[id]/invitations/[invitationId]/revoke/route.test.ts
index a173be66..7ae8e6aa 100644
--- a/frontend/src/app/api/projects/[id]/invitations/[invitationId]/revoke/route.test.ts
+++ b/frontend/src/app/api/projects/[id]/invitations/[invitationId]/revoke/route.test.ts
@@ -84,4 +84,4 @@ describe("POST /api/projects/[id]/invitations/[invitationId]/revoke", () => {
"http://localhost/projects/4/members?project=4&error=Revoke+invitation+failed",
)
})
-})
\ No newline at end of file
+})
diff --git a/frontend/src/app/api/projects/[id]/linkedin-oauth/start/route.test.ts b/frontend/src/app/api/projects/[id]/linkedin-oauth/start/route.test.ts
new file mode 100644
index 00000000..e84ef0e6
--- /dev/null
+++ b/frontend/src/app/api/projects/[id]/linkedin-oauth/start/route.test.ts
@@ -0,0 +1,79 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+
+import { startProjectLinkedInOAuth } from "@/lib/api"
+
+import { POST } from "./route"
+
+vi.mock("@/lib/api", () => ({
+ startProjectLinkedInOAuth: vi.fn(),
+}))
+
+function buildRequest(formData: FormData) {
+ return new Request("http://localhost/api/projects/4/linkedin-oauth/start", {
+ method: "POST",
+ body: formData,
+ })
+}
+
+async function getLocation(response: Response) {
+ return response.headers.get("location")
+}
+
+describe("POST /api/projects/[id]/linkedin-oauth/start", () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it("redirects to the LinkedIn authorization URL", async () => {
+ vi.mocked(startProjectLinkedInOAuth).mockResolvedValue({
+ authorize_url: "https://www.linkedin.com/oauth/v2/authorization?state=signed-state",
+ })
+
+ const formData = new FormData()
+ formData.set("redirectTo", "/admin/sources?project=4")
+
+ const response = await POST(buildRequest(formData), {
+ params: Promise.resolve({ id: "4" }),
+ })
+
+ expect(startProjectLinkedInOAuth).toHaveBeenCalledWith(
+ 4,
+ "/admin/sources?project=4",
+ )
+ expect(response.status).toBe(307)
+ await expect(getLocation(response)).resolves.toBe(
+ "https://www.linkedin.com/oauth/v2/authorization?state=signed-state",
+ )
+ })
+
+ it("redirects back with the thrown error message when authorization start fails", async () => {
+ vi.mocked(startProjectLinkedInOAuth).mockRejectedValue(
+ new Error("LinkedIn authorization failed"),
+ )
+
+ const formData = new FormData()
+ formData.set("redirectTo", "/admin/sources?project=4")
+
+ const response = await POST(buildRequest(formData), {
+ params: Promise.resolve({ id: "4" }),
+ })
+
+ expect(response.status).toBe(307)
+ await expect(getLocation(response)).resolves.toBe(
+ "http://localhost/admin/sources?project=4&error=LinkedIn+authorization+failed",
+ )
+ })
+
+ it("redirects back with a fallback error when a non-Error value is thrown", async () => {
+ vi.mocked(startProjectLinkedInOAuth).mockRejectedValue("boom")
+
+ const response = await POST(buildRequest(new FormData()), {
+ params: Promise.resolve({ id: "4" }),
+ })
+
+ expect(response.status).toBe(307)
+ await expect(getLocation(response)).resolves.toBe(
+ "http://localhost/admin/sources?error=Unable+to+start+LinkedIn+authorization.",
+ )
+ })
+})
diff --git a/frontend/src/app/api/projects/[id]/linkedin-oauth/start/route.ts b/frontend/src/app/api/projects/[id]/linkedin-oauth/start/route.ts
new file mode 100644
index 00000000..2090f1f6
--- /dev/null
+++ b/frontend/src/app/api/projects/[id]/linkedin-oauth/start/route.ts
@@ -0,0 +1,43 @@
+import { NextResponse } from "next/server"
+
+import { startProjectLinkedInOAuth } from "@/lib/api"
+
+function buildRedirectUrl(
+ request: Request,
+ redirectTo: string,
+ params: Record,
+) {
+ const url = new URL(redirectTo || "/admin/sources", request.url)
+ for (const [key, value] of Object.entries(params)) {
+ url.searchParams.set(key, value)
+ }
+ return url
+}
+
+/**
+ * Handle LinkedIn OAuth start requests for one project.
+ *
+ * @param request - Incoming form submission request.
+ * @param context - Route params containing the project id.
+ * @returns A redirect response to LinkedIn or back to the sources UI on failure.
+ */
+export async function POST(
+ request: Request,
+ context: { params: Promise<{ id: string }> },
+) {
+ const { id } = await context.params
+ const formData = await request.formData()
+ const redirectTo = String(formData.get("redirectTo") || "/admin/sources")
+
+ try {
+ const projectId = Number.parseInt(id, 10)
+ const result = await startProjectLinkedInOAuth(projectId, redirectTo)
+ return NextResponse.redirect(result.authorize_url)
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : "Unable to start LinkedIn authorization."
+ return NextResponse.redirect(
+ buildRedirectUrl(request, redirectTo, { error: message }),
+ )
+ }
+}
diff --git a/frontend/src/app/api/projects/[id]/verify-linkedin-credentials/route.test.ts b/frontend/src/app/api/projects/[id]/verify-linkedin-credentials/route.test.ts
new file mode 100644
index 00000000..e8544e72
--- /dev/null
+++ b/frontend/src/app/api/projects/[id]/verify-linkedin-credentials/route.test.ts
@@ -0,0 +1,80 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+
+import { verifyProjectLinkedInCredentials } from "@/lib/api"
+
+import { POST } from "./route"
+
+vi.mock("@/lib/api", () => ({
+ verifyProjectLinkedInCredentials: vi.fn(),
+}))
+
+function buildRequest(formData: FormData) {
+ return new Request("http://localhost/api/projects/4/verify-linkedin-credentials", {
+ method: "POST",
+ body: formData,
+ })
+}
+
+async function getLocation(response: Response) {
+ return response.headers.get("location")
+}
+
+describe("POST /api/projects/[id]/verify-linkedin-credentials", () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it("verifies LinkedIn credentials and redirects with a success message", async () => {
+ vi.mocked(verifyProjectLinkedInCredentials).mockResolvedValue({
+ status: "verified",
+ member_urn: "urn:li:person:abc123",
+ expires_at: "2026-04-29T10:00:00Z",
+ last_verified_at: "2026-04-29T10:00:00Z",
+ last_error: "",
+ })
+
+ const formData = new FormData()
+ formData.set("redirectTo", "/admin/sources?project=4")
+
+ const response = await POST(buildRequest(formData), {
+ params: Promise.resolve({ id: "4" }),
+ })
+
+ expect(verifyProjectLinkedInCredentials).toHaveBeenCalledWith(4)
+ expect(response.status).toBe(307)
+ await expect(getLocation(response)).resolves.toBe(
+ "http://localhost/admin/sources?project=4&message=Verified+LinkedIn+member+urn%3Ali%3Aperson%3Aabc123.",
+ )
+ })
+
+ it("redirects with the thrown error message when verification fails", async () => {
+ vi.mocked(verifyProjectLinkedInCredentials).mockRejectedValue(
+ new Error("Verification failed"),
+ )
+
+ const formData = new FormData()
+ formData.set("redirectTo", "/admin/sources?project=4")
+
+ const response = await POST(buildRequest(formData), {
+ params: Promise.resolve({ id: "4" }),
+ })
+
+ expect(response.status).toBe(307)
+ await expect(getLocation(response)).resolves.toBe(
+ "http://localhost/admin/sources?project=4&error=Verification+failed",
+ )
+ })
+
+ it("redirects with a fallback error when a non-Error value is thrown", async () => {
+ vi.mocked(verifyProjectLinkedInCredentials).mockRejectedValue("boom")
+
+ const response = await POST(buildRequest(new FormData()), {
+ params: Promise.resolve({ id: "4" }),
+ })
+
+ expect(response.status).toBe(307)
+ await expect(getLocation(response)).resolves.toBe(
+ "http://localhost/admin/sources?error=Unable+to+verify+LinkedIn+credentials.",
+ )
+ })
+})
diff --git a/frontend/src/app/api/projects/[id]/verify-linkedin-credentials/route.ts b/frontend/src/app/api/projects/[id]/verify-linkedin-credentials/route.ts
new file mode 100644
index 00000000..a3503e2c
--- /dev/null
+++ b/frontend/src/app/api/projects/[id]/verify-linkedin-credentials/route.ts
@@ -0,0 +1,50 @@
+import { NextResponse } from "next/server"
+
+import { verifyProjectLinkedInCredentials } from "@/lib/api"
+
+function buildRedirectUrl(
+ request: Request,
+ redirectTo: string,
+ params: Record,
+) {
+ const url = new URL(redirectTo || "/admin/sources", request.url)
+ for (const [key, value] of Object.entries(params)) {
+ url.searchParams.set(key, value)
+ }
+ return url
+}
+
+/**
+ * Handle LinkedIn credential verification requests for one project.
+ *
+ * @param request - Incoming form submission request.
+ * @param context - Route params containing the project id.
+ * @returns A redirect response pointing back to the source settings UI.
+ */
+export async function POST(
+ request: Request,
+ context: { params: Promise<{ id: string }> },
+) {
+ const { id } = await context.params
+ const formData = await request.formData()
+ const redirectTo = String(formData.get("redirectTo") || "/admin/sources")
+
+ try {
+ const projectId = Number.parseInt(id, 10)
+ const result = await verifyProjectLinkedInCredentials(projectId)
+ const message = result.member_urn
+ ? `Verified LinkedIn member ${result.member_urn}.`
+ : "LinkedIn credentials verified."
+ return NextResponse.redirect(
+ buildRedirectUrl(request, redirectTo, { message }),
+ )
+ } catch (error) {
+ const message =
+ error instanceof Error
+ ? error.message
+ : "Unable to verify LinkedIn credentials."
+ return NextResponse.redirect(
+ buildRedirectUrl(request, redirectTo, { error: message }),
+ )
+ }
+}
diff --git a/frontend/src/app/content/[id]/_components/SkillActionBar/index.ts b/frontend/src/app/content/[id]/_components/SkillActionBar/index.ts
index 9201da7c..9f60c281 100644
--- a/frontend/src/app/content/[id]/_components/SkillActionBar/index.ts
+++ b/frontend/src/app/content/[id]/_components/SkillActionBar/index.ts
@@ -1 +1 @@
-export { SkillActionBar } from "./SkillActionBar"
\ No newline at end of file
+export { SkillActionBar } from "./SkillActionBar"
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index 745e7c57..84146c6e 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -140,4 +140,4 @@
html {
@apply font-sans;
}
-}
\ No newline at end of file
+}
diff --git a/frontend/src/app/ideas/_components/OriginalContentIdeaCard/index.stories.tsx b/frontend/src/app/ideas/_components/OriginalContentIdeaCard/index.stories.tsx
index 6bac4311..40228d14 100644
--- a/frontend/src/app/ideas/_components/OriginalContentIdeaCard/index.stories.tsx
+++ b/frontend/src/app/ideas/_components/OriginalContentIdeaCard/index.stories.tsx
@@ -55,4 +55,4 @@ export const DismissedWithoutSupportingContent: Story = {
supporting_contents: [],
}),
},
-}
\ No newline at end of file
+}
diff --git a/frontend/src/app/login/_components/LoginForm/index.ts b/frontend/src/app/login/_components/LoginForm/index.ts
index 23d4c51c..fd121a83 100644
--- a/frontend/src/app/login/_components/LoginForm/index.ts
+++ b/frontend/src/app/login/_components/LoginForm/index.ts
@@ -1 +1 @@
-export { default } from "./LoginForm"
\ No newline at end of file
+export { default } from "./LoginForm"
diff --git a/frontend/src/app/login/_components/SocialAuthButtons/index.ts b/frontend/src/app/login/_components/SocialAuthButtons/index.ts
index c9a2bcca..54ed560b 100644
--- a/frontend/src/app/login/_components/SocialAuthButtons/index.ts
+++ b/frontend/src/app/login/_components/SocialAuthButtons/index.ts
@@ -1 +1 @@
-export { default } from "./SocialAuthButtons"
\ No newline at end of file
+export { default } from "./SocialAuthButtons"
diff --git a/frontend/src/app/profile/_components/AvatarDropzone/index.ts b/frontend/src/app/profile/_components/AvatarDropzone/index.ts
index c8c9ae8e..f46931e5 100644
--- a/frontend/src/app/profile/_components/AvatarDropzone/index.ts
+++ b/frontend/src/app/profile/_components/AvatarDropzone/index.ts
@@ -1 +1 @@
-export { AvatarDropzone } from "./AvatarDropzone"
\ No newline at end of file
+export { AvatarDropzone } from "./AvatarDropzone"
diff --git a/frontend/src/app/profile/_components/AvatarPreview/index.ts b/frontend/src/app/profile/_components/AvatarPreview/index.ts
index 093590e7..cf74b27c 100644
--- a/frontend/src/app/profile/_components/AvatarPreview/index.ts
+++ b/frontend/src/app/profile/_components/AvatarPreview/index.ts
@@ -1 +1 @@
-export { AvatarPreview } from "./AvatarPreview"
\ No newline at end of file
+export { AvatarPreview } from "./AvatarPreview"
diff --git a/frontend/src/app/profile/_components/ProfileForm/index.ts b/frontend/src/app/profile/_components/ProfileForm/index.ts
index 3e8f7c4e..f61ab00a 100644
--- a/frontend/src/app/profile/_components/ProfileForm/index.ts
+++ b/frontend/src/app/profile/_components/ProfileForm/index.ts
@@ -1 +1 @@
-export { ProfileForm } from "./ProfileForm"
\ No newline at end of file
+export { ProfileForm } from "./ProfileForm"
diff --git a/frontend/src/app/profile/_components/ProfileSettingsPanel/index.ts b/frontend/src/app/profile/_components/ProfileSettingsPanel/index.ts
index fa2622b3..65cab14e 100644
--- a/frontend/src/app/profile/_components/ProfileSettingsPanel/index.ts
+++ b/frontend/src/app/profile/_components/ProfileSettingsPanel/index.ts
@@ -1 +1 @@
-export { ProfileSettingsPanel } from "./ProfileSettingsPanel"
\ No newline at end of file
+export { ProfileSettingsPanel } from "./ProfileSettingsPanel"
diff --git a/frontend/src/app/themes/_components/ThemeSuggestionCard/index.stories.tsx b/frontend/src/app/themes/_components/ThemeSuggestionCard/index.stories.tsx
index 87804d22..d4aedde0 100644
--- a/frontend/src/app/themes/_components/ThemeSuggestionCard/index.stories.tsx
+++ b/frontend/src/app/themes/_components/ThemeSuggestionCard/index.stories.tsx
@@ -65,4 +65,4 @@ export const Dismissed: Story = {
decided_by_username: "editor-1",
}),
},
-}
\ No newline at end of file
+}
diff --git a/frontend/src/app/trends/_components/TopicClusterCard/index.stories.tsx b/frontend/src/app/trends/_components/TopicClusterCard/index.stories.tsx
index 77f45dd9..24939378 100644
--- a/frontend/src/app/trends/_components/TopicClusterCard/index.stories.tsx
+++ b/frontend/src/app/trends/_components/TopicClusterCard/index.stories.tsx
@@ -40,4 +40,4 @@ export const LowerVelocityWithoutEntity: Story = {
member_count: 2,
}),
},
-}
\ No newline at end of file
+}
diff --git a/frontend/src/app/trends/_components/TopicClusterCard/index.tsx b/frontend/src/app/trends/_components/TopicClusterCard/index.tsx
index ab4df891..5ab8cbd7 100644
--- a/frontend/src/app/trends/_components/TopicClusterCard/index.tsx
+++ b/frontend/src/app/trends/_components/TopicClusterCard/index.tsx
@@ -53,4 +53,4 @@ export function TopicClusterCard({
)
-}
\ No newline at end of file
+}
diff --git a/frontend/src/components/elements/CopyButton/index.test.tsx b/frontend/src/components/elements/CopyButton/index.test.tsx
index 1ee011ce..394aefd2 100644
--- a/frontend/src/components/elements/CopyButton/index.test.tsx
+++ b/frontend/src/components/elements/CopyButton/index.test.tsx
@@ -48,4 +48,4 @@ describe("CopyButton", () => {
screen.getByRole("button", { name: "Copy invite link" }),
).toBeInTheDocument()
})
-})
\ No newline at end of file
+})
diff --git a/frontend/src/components/elements/StatusBadge/index.stories.tsx b/frontend/src/components/elements/StatusBadge/index.stories.tsx
index 7c1d007f..e16553a9 100644
--- a/frontend/src/components/elements/StatusBadge/index.stories.tsx
+++ b/frontend/src/components/elements/StatusBadge/index.stories.tsx
@@ -59,4 +59,4 @@ export const AllTones: Story = {
Idle
),
-}
\ No newline at end of file
+}
diff --git a/frontend/src/components/elements/ThemeToggle/index.stories.tsx b/frontend/src/components/elements/ThemeToggle/index.stories.tsx
index febae0fe..c5820ebb 100644
--- a/frontend/src/components/elements/ThemeToggle/index.stories.tsx
+++ b/frontend/src/components/elements/ThemeToggle/index.stories.tsx
@@ -27,4 +27,4 @@ export const InToolbarRow: Story = {