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
22 changes: 22 additions & 0 deletions app/(app)/projects/[id]/leads/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { LeadActions } from "@/components/leads/lead-actions";
import { CampaignPanel, type CampaignSummary } from "@/components/leads/campaign-panel";
import { SenderAddress } from "@/components/leads/sender-address";
import { MailboxConnect, type ConnectedMailbox } from "@/components/leads/mailbox-connect";
import { SeedLogins } from "@/components/leads/seed-logins";
import { listSeedCredentials, type StoredSeedCredential } from "@/lib/outreach/seedCredentials";
import { RefreshLeads } from "@/components/leads/refresh-leads";
import { loadAddressSettings } from "@/lib/outreach/postalAddress";

Expand Down Expand Up @@ -119,6 +121,20 @@ export default async function LeadsPage({
.eq("id", projectId)
.maybeSingle();
const orgId = (projectRow?.organization_id as string | null) ?? null;
let seedCredentials: StoredSeedCredential[] = [];
if (orgId) seedCredentials = await listSeedCredentials(orgId);

// Hosts any campaign in this project is parked on, waiting for a sign-in.
const waitingHosts = [
...new Set(
campaigns.flatMap((c) =>
Array.isArray((c as { auth_required_hosts?: string[] }).auth_required_hosts)
? ((c as { auth_required_hosts?: string[] }).auth_required_hosts as string[])
: [],
),
),
];

let mailbox: ConnectedMailbox | null = null;
if (orgId) {
const { data: senderRow } = await supabase
Expand Down Expand Up @@ -164,6 +180,12 @@ export default async function LeadsPage({

<CampaignPanel projectId={projectId} campaigns={campaigns} canSendLive={canSendLive} />

<SeedLogins
projectId={projectId}
waitingHosts={waitingHosts}
credentials={seedCredentials}
/>

<section className="card p-4">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h2 className="text-lg font-semibold">Pipeline</h2>
Expand Down
118 changes: 118 additions & 0 deletions app/actions/seedCredentials.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"use server";

import { revalidatePath } from "next/cache";
import { serviceClient } from "@/lib/supabase/service";
import { requireProjectAccess } from "@/lib/lx/currentSite";
import { encryptSecret } from "@/lib/sp/vault";
import { normalizeHost } from "@/lib/outreach/cold";
import { seedHost } from "@/lib/outreach/seedCredentials";

type Ok<T = Record<string, never>> = { ok: true } & T;
type Err = { ok: false; error: string };

async function requireOrg(
projectId: string,
): Promise<{ ok: true; userId: string; organizationId: string } | Err> {
if (!projectId) return { ok: false, error: "Missing project." };
const access = await requireProjectAccess(projectId);
if (!access.ok) return { ok: false, error: access.error };
if (access.isViewer) return { ok: false, error: "Viewers can't manage seed logins." };

const { data: project } = await serviceClient()
.from("projects")
.select("organization_id")
.eq("id", projectId)
.maybeSingle();
const organizationId = (project?.organization_id as string | null) ?? null;
if (!organizationId) {
return { ok: false, error: "This project isn't in an organization, so there's nowhere to store a login." };
}
return { ok: true, userId: access.userId, organizationId };
}

/**
* Store a login for a gated seed directory.
*
* Unlike the mailbox, the credential is not verified at save time: doing so
* would mean driving a browser through someone else's login form inside a
* server action, and a directory that is slow or briefly down would look like
* a bad password. It is verified on the next tick instead, and the result is
* recorded on the row so the UI can show whether it actually worked.
*/
export async function saveSeedCredentialAction(input: {
projectId: string;
host: string;
username: string;
password: string;
loginUrl?: string;
}): Promise<Ok<{ note: string }> | Err> {
const access = await requireOrg(input.projectId);
if (!access.ok) return access;

// Accept a full URL or a bare host — the value usually comes from a seed
// URL the user pasted somewhere else.
const host = input.host.includes("://")
? seedHost(input.host)
: normalizeHost(input.host.trim());
if (!host || !host.includes(".")) return { ok: false, error: "That doesn't look like a site." };
if (!input.username.trim()) return { ok: false, error: "A username or email is required." };
if (!input.password) return { ok: false, error: "A password is required." };

const { error } = await serviceClient()
.from("outreach_seed_credentials")
.upsert(
{
organization_id: access.organizationId,
created_by: access.userId,
host,
username: input.username.trim(),
enc_password: encryptSecret(input.password),
login_url: input.loginUrl?.trim() || null,
// A new password invalidates whatever the last attempt concluded.
verified_at: null,
last_error: null,
},
{ onConflict: "organization_id,host" },
);
if (error) return { ok: false, error: error.message };

// Any campaign parked on this host can stop waiting.
const { data: campaigns } = await serviceClient()
.from("outreach_campaigns")
.select("id, auth_required_hosts")
.eq("project_id", input.projectId);
for (const c of (campaigns as { id: string; auth_required_hosts: string[] | null }[] | null) ?? []) {
const waiting = Array.isArray(c.auth_required_hosts) ? c.auth_required_hosts : [];
const remaining = waiting.filter((u) => seedHost(u) !== host);
if (remaining.length !== waiting.length) {
await serviceClient()
.from("outreach_campaigns")
.update({ auth_required_hosts: remaining })
.eq("id", c.id);
}
}

revalidatePath(`/projects/${input.projectId}/leads`);
return {
ok: true,
note: `Saved a login for ${host}. The next campaign tick will try it and report whether it worked.`,
};
}

export async function deleteSeedCredentialAction(input: {
projectId: string;
host: string;
}): Promise<{ ok: true } | Err> {
const access = await requireOrg(input.projectId);
if (!access.ok) return access;

const { error } = await serviceClient()
.from("outreach_seed_credentials")
.delete()
.eq("organization_id", access.organizationId)
.eq("host", normalizeHost(input.host));
if (error) return { ok: false, error: error.message };

revalidatePath(`/projects/${input.projectId}/leads`);
return { ok: true };
}
206 changes: 206 additions & 0 deletions components/leads/seed-logins.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
"use client";

import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import {
deleteSeedCredentialAction,
saveSeedCredentialAction,
} from "@/app/actions/seedCredentials";
import type { StoredSeedCredential } from "@/lib/outreach/seedCredentials";

/**
* Seed directories that need a sign-in.
*
* The panel leads with what is actually blocked. A campaign parked on a gated
* directory isn't failing, it is waiting on something only the user can
* supply, so the hosts it is waiting for are named and each one carries the
* form that unblocks it.
*/
export function SeedLogins({
projectId,
waitingHosts,
credentials,
}: {
projectId: string;
/** Seed URLs campaigns are parked on, with no stored credential. */
waitingHosts: string[];
credentials: StoredSeedCredential[];
}) {
const router = useRouter();
const [pending, start] = useTransition();
const [open, setOpen] = useState(false);
const [host, setHost] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [note, setNote] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);

const save = (targetHost?: string) =>
start(async () => {
setNote(null);
setError(null);
const res = await saveSeedCredentialAction({
projectId,
host: targetHost ?? host,
username,
password,
});
if (!res.ok) {
setError(res.error);
return;
}
setNote(res.note);
setHost("");
setUsername("");
setPassword("");
setOpen(false);
router.refresh();
});

const remove = (h: string) =>
start(async () => {
setError(null);
const res = await deleteSeedCredentialAction({ projectId, host: h });
if (!res.ok) setError(res.error);
else router.refresh();
});

const hasAnything = waitingHosts.length > 0 || credentials.length > 0 || open;
if (!hasAnything) {
return (
<section className="card p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h2 className="text-lg font-semibold">Seed logins</h2>
<p className="text-sm text-[var(--color-muted)]">
Some directories only show their listings to a signed-in visitor. Store a login and
campaigns can seed from those too.
</p>
</div>
<button onClick={() => setOpen(true)} className="btn text-sm">
Add a login
</button>
</div>
</section>
);
}

return (
<section className="card p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-lg font-semibold">Seed logins</h2>
{!open && (
<button onClick={() => setOpen(true)} className="btn text-sm">
Add a login
</button>
)}
</div>

{waitingHosts.length > 0 && (
<div className="mt-3 rounded border border-[var(--color-warn,#facc15)] p-3">
<p className="text-sm">
<strong>Waiting for sign-in.</strong> A campaign is parked on{" "}
{waitingHosts.length === 1 ? "a directory that requires" : "directories that require"} a
login:
</p>
<ul className="mt-2 space-y-2">
{waitingHosts.map((h) => (
<li key={h} className="text-sm">
<code className="break-all text-xs">{h}</code>
<button
onClick={() => {
setHost(h);
setOpen(true);
}}
className="ml-2 text-xs underline"
>
Add a login for this
</button>
</li>
))}
</ul>
</div>
)}

{open && (
<div className="mt-3 grid gap-3 border-t border-[var(--color-border)] pt-3 sm:grid-cols-2">
<label className="text-sm sm:col-span-2">
Site
<input
className="input mt-1 w-full"
value={host}
onChange={(e) => setHost(e.target.value)}
placeholder="directory.example.com — or paste the seed URL"
/>
</label>
<label className="text-sm">
Username or email
<input
className="input mt-1 w-full"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="off"
/>
</label>
<label className="text-sm">
Password
<input
type="password"
className="input mt-1 w-full"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
/>
</label>
<p className="text-xs text-[var(--color-muted)] sm:col-span-2">
Encrypted with AES-256-GCM before storing; the key lives in the application
environment, not the database. Sign-in is attempted on the next campaign tick and the
result is shown here — a site that asks for a verification code can&apos;t be signed
into from a server, and will say so.
</p>
<div className="sm:col-span-2">
<button
onClick={() => save()}
disabled={pending || !host.trim() || !username.trim() || !password}
className="btn btn-primary"
>
{pending ? "Saving…" : "Save login"}
</button>
<button onClick={() => setOpen(false)} className="btn ml-2 text-sm">
Cancel
</button>
</div>
</div>
)}

{credentials.length > 0 && (
<ul className="mt-4 divide-y divide-[var(--color-border)]">
{credentials.map((c) => (
<li key={c.id} className="flex flex-wrap items-start justify-between gap-2 py-3">
<div className="min-w-0">
<p className="font-medium">{c.host}</p>
<p className="text-xs text-[var(--color-muted)]">
{c.username}
{c.verifiedAt ? ` · signed in ${c.verifiedAt.slice(0, 10)}` : " · not tried yet"}
</p>
{c.lastError && (
<p className="mt-1 text-xs text-[var(--color-danger,#f87171)]">{c.lastError}</p>
)}
</div>
<button
onClick={() => remove(c.host)}
disabled={pending}
className="shrink-0 rounded border border-[var(--color-border)] px-2 py-1 text-xs"
>
Remove
</button>
</li>
))}
</ul>
)}

{note && <p className="mt-3 text-sm">{note}</p>}
{error && <p className="mt-3 text-sm text-[var(--color-danger,#f87171)]">{error}</p>}
</section>
);
}
Loading
Loading