Seth commited on
Commit ·
4cf3d77
1
Parent(s): 84ca4b1
update
Browse files- backend/app/database.py +29 -0
- backend/app/main.py +154 -8
- backend/app/models.py +4 -0
- frontend/src/components/campaigns/LinkedinCampaignsTab.jsx +8 -60
backend/app/database.py
CHANGED
|
@@ -224,6 +224,7 @@ class UnipileAccount(Base):
|
|
| 224 |
|
| 225 |
id = Column(Integer, primary_key=True, index=True)
|
| 226 |
tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=True, index=True)
|
|
|
|
| 227 |
label = Column(String, default="")
|
| 228 |
provider = Column(String, default="LINKEDIN")
|
| 229 |
unipile_account_id = Column(String, index=True)
|
|
@@ -239,6 +240,7 @@ class LinkedinCampaign(Base):
|
|
| 239 |
|
| 240 |
id = Column(Integer, primary_key=True, index=True)
|
| 241 |
tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=True, index=True)
|
|
|
|
| 242 |
name = Column(String, index=True)
|
| 243 |
status = Column(String, default="draft") # draft | generated | running | completed | failed
|
| 244 |
unipile_account_ref_id = Column(Integer, ForeignKey("unipile_accounts.id"), nullable=False, index=True)
|
|
@@ -251,6 +253,20 @@ class LinkedinCampaign(Base):
|
|
| 251 |
executed_at = Column(DateTime, nullable=True)
|
| 252 |
|
| 253 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
def run_migrations(connection_engine):
|
| 255 |
"""Add tenant_id to legacy SQLite tables and attach rows to the default tenant."""
|
| 256 |
from sqlalchemy import inspect, text
|
|
@@ -266,6 +282,7 @@ def run_migrations(connection_engine):
|
|
| 266 |
"smartlead_runs",
|
| 267 |
"unipile_accounts",
|
| 268 |
"linkedin_campaigns",
|
|
|
|
| 269 |
)
|
| 270 |
|
| 271 |
with connection_engine.begin() as conn:
|
|
@@ -371,6 +388,18 @@ def run_migrations(connection_engine):
|
|
| 371 |
)
|
| 372 |
)
|
| 373 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 374 |
|
| 375 |
# Create tables then migrate legacy SQLite schemas
|
| 376 |
Base.metadata.create_all(bind=engine)
|
|
|
|
| 224 |
|
| 225 |
id = Column(Integer, primary_key=True, index=True)
|
| 226 |
tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=True, index=True)
|
| 227 |
+
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
|
| 228 |
label = Column(String, default="")
|
| 229 |
provider = Column(String, default="LINKEDIN")
|
| 230 |
unipile_account_id = Column(String, index=True)
|
|
|
|
| 240 |
|
| 241 |
id = Column(Integer, primary_key=True, index=True)
|
| 242 |
tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=True, index=True)
|
| 243 |
+
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
|
| 244 |
name = Column(String, index=True)
|
| 245 |
status = Column(String, default="draft") # draft | generated | running | completed | failed
|
| 246 |
unipile_account_ref_id = Column(Integer, ForeignKey("unipile_accounts.id"), nullable=False, index=True)
|
|
|
|
| 253 |
executed_at = Column(DateTime, nullable=True)
|
| 254 |
|
| 255 |
|
| 256 |
+
class UnipileHostedAuthState(Base):
|
| 257 |
+
__tablename__ = "unipile_hosted_auth_states"
|
| 258 |
+
|
| 259 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 260 |
+
tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=True, index=True)
|
| 261 |
+
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
|
| 262 |
+
token = Column(String, unique=True, index=True)
|
| 263 |
+
provider = Column(String, default="LINKEDIN")
|
| 264 |
+
label = Column(String, default="")
|
| 265 |
+
expires_at = Column(DateTime, nullable=True)
|
| 266 |
+
used_at = Column(DateTime, nullable=True)
|
| 267 |
+
created_at = Column(DateTime, default=datetime.utcnow)
|
| 268 |
+
|
| 269 |
+
|
| 270 |
def run_migrations(connection_engine):
|
| 271 |
"""Add tenant_id to legacy SQLite tables and attach rows to the default tenant."""
|
| 272 |
from sqlalchemy import inspect, text
|
|
|
|
| 282 |
"smartlead_runs",
|
| 283 |
"unipile_accounts",
|
| 284 |
"linkedin_campaigns",
|
| 285 |
+
"unipile_hosted_auth_states",
|
| 286 |
)
|
| 287 |
|
| 288 |
with connection_engine.begin() as conn:
|
|
|
|
| 388 |
)
|
| 389 |
)
|
| 390 |
|
| 391 |
+
insp = inspect(connection_engine)
|
| 392 |
+
if insp.has_table("unipile_accounts"):
|
| 393 |
+
uacols = [c["name"] for c in insp.get_columns("unipile_accounts")]
|
| 394 |
+
if "user_id" not in uacols:
|
| 395 |
+
conn.execute(text("ALTER TABLE unipile_accounts ADD COLUMN user_id INTEGER"))
|
| 396 |
+
|
| 397 |
+
insp = inspect(connection_engine)
|
| 398 |
+
if insp.has_table("linkedin_campaigns"):
|
| 399 |
+
lccols = [c["name"] for c in insp.get_columns("linkedin_campaigns")]
|
| 400 |
+
if "user_id" not in lccols:
|
| 401 |
+
conn.execute(text("ALTER TABLE linkedin_campaigns ADD COLUMN user_id INTEGER"))
|
| 402 |
+
|
| 403 |
|
| 404 |
# Create tables then migrate legacy SQLite schemas
|
| 405 |
Base.metadata.create_all(bind=engine)
|
backend/app/main.py
CHANGED
|
@@ -36,6 +36,7 @@ from .database import (
|
|
| 36 |
CrmDeal,
|
| 37 |
UnipileAccount,
|
| 38 |
LinkedinCampaign,
|
|
|
|
| 39 |
)
|
| 40 |
from pydantic import ValidationError
|
| 41 |
|
|
@@ -54,6 +55,7 @@ from .models import (
|
|
| 54 |
ContactPatchRequest,
|
| 55 |
WonBillingPayload,
|
| 56 |
UnipileConnectRequest,
|
|
|
|
| 57 |
LinkedinCampaignCreateRequest,
|
| 58 |
LinkedinCampaignGenerateRequest,
|
| 59 |
LinkedinCampaignExecuteRequest,
|
|
@@ -103,6 +105,7 @@ UPLOAD_DIR = Path("/data/uploads")
|
|
| 103 |
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
| 104 |
UNIPILE_API_BASE = os.getenv("UNIPILE_API_BASE", "").rstrip("/")
|
| 105 |
UNIPILE_API_KEY = os.getenv("UNIPILE_API_KEY", "")
|
|
|
|
| 106 |
|
| 107 |
# ---- API ----
|
| 108 |
def _safe_str(value):
|
|
@@ -984,7 +987,11 @@ async def upload_csv(file: UploadFile = File(...), t: TenantContext = Depends(ge
|
|
| 984 |
async def list_unipile_linkedin_accounts(t: TenantContext = Depends(get_tenant_context)):
|
| 985 |
rows = (
|
| 986 |
t.db.query(UnipileAccount)
|
| 987 |
-
.filter(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 988 |
.order_by(UnipileAccount.created_at.desc(), UnipileAccount.id.desc())
|
| 989 |
.all()
|
| 990 |
)
|
|
@@ -1004,6 +1011,114 @@ async def list_unipile_linkedin_accounts(t: TenantContext = Depends(get_tenant_c
|
|
| 1004 |
}
|
| 1005 |
|
| 1006 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1007 |
@app.post("/api/unipile/linkedin/connect")
|
| 1008 |
async def connect_unipile_linkedin(
|
| 1009 |
body: UnipileConnectRequest,
|
|
@@ -1038,6 +1153,7 @@ async def connect_unipile_linkedin(
|
|
| 1038 |
|
| 1039 |
db_row = UnipileAccount(
|
| 1040 |
tenant_id=t.tenant_id,
|
|
|
|
| 1041 |
label=(body.label or "LinkedIn account").strip(),
|
| 1042 |
provider="LINKEDIN",
|
| 1043 |
unipile_account_id=account_id,
|
|
@@ -1066,7 +1182,12 @@ async def list_linkedin_campaigns(t: TenantContext = Depends(get_tenant_context)
|
|
| 1066 |
rows = (
|
| 1067 |
t.db.query(LinkedinCampaign, UnipileAccount)
|
| 1068 |
.join(UnipileAccount, UnipileAccount.id == LinkedinCampaign.unipile_account_ref_id)
|
| 1069 |
-
.filter(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1070 |
.order_by(LinkedinCampaign.created_at.desc(), LinkedinCampaign.id.desc())
|
| 1071 |
.all()
|
| 1072 |
)
|
|
@@ -1096,7 +1217,11 @@ async def create_linkedin_campaign(
|
|
| 1096 |
):
|
| 1097 |
account = (
|
| 1098 |
t.db.query(UnipileAccount)
|
| 1099 |
-
.filter(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1100 |
.first()
|
| 1101 |
)
|
| 1102 |
if not account:
|
|
@@ -1106,6 +1231,7 @@ async def create_linkedin_campaign(
|
|
| 1106 |
raise HTTPException(status_code=400, detail="Campaign name is required")
|
| 1107 |
row = LinkedinCampaign(
|
| 1108 |
tenant_id=t.tenant_id,
|
|
|
|
| 1109 |
name=name,
|
| 1110 |
status="draft",
|
| 1111 |
unipile_account_ref_id=body.unipile_account_ref_id,
|
|
@@ -1125,7 +1251,11 @@ async def upload_linkedin_campaign_csv(
|
|
| 1125 |
db = t.db
|
| 1126 |
campaign = (
|
| 1127 |
db.query(LinkedinCampaign)
|
| 1128 |
-
.filter(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1129 |
.first()
|
| 1130 |
)
|
| 1131 |
if not campaign:
|
|
@@ -1196,7 +1326,11 @@ async def generate_linkedin_campaign_sequences(
|
|
| 1196 |
db = t.db
|
| 1197 |
campaign = (
|
| 1198 |
db.query(LinkedinCampaign)
|
| 1199 |
-
.filter(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1200 |
.first()
|
| 1201 |
)
|
| 1202 |
if not campaign:
|
|
@@ -1257,7 +1391,11 @@ async def get_linkedin_campaign_sequences(campaign_id: int, t: TenantContext = D
|
|
| 1257 |
db = t.db
|
| 1258 |
campaign = (
|
| 1259 |
db.query(LinkedinCampaign)
|
| 1260 |
-
.filter(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1261 |
.first()
|
| 1262 |
)
|
| 1263 |
if not campaign:
|
|
@@ -1307,14 +1445,22 @@ async def execute_linkedin_campaign(
|
|
| 1307 |
db = t.db
|
| 1308 |
campaign = (
|
| 1309 |
db.query(LinkedinCampaign)
|
| 1310 |
-
.filter(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1311 |
.first()
|
| 1312 |
)
|
| 1313 |
if not campaign:
|
| 1314 |
raise HTTPException(status_code=404, detail="Campaign not found")
|
| 1315 |
account = (
|
| 1316 |
db.query(UnipileAccount)
|
| 1317 |
-
.filter(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1318 |
.first()
|
| 1319 |
)
|
| 1320 |
if not account:
|
|
|
|
| 36 |
CrmDeal,
|
| 37 |
UnipileAccount,
|
| 38 |
LinkedinCampaign,
|
| 39 |
+
UnipileHostedAuthState,
|
| 40 |
)
|
| 41 |
from pydantic import ValidationError
|
| 42 |
|
|
|
|
| 55 |
ContactPatchRequest,
|
| 56 |
WonBillingPayload,
|
| 57 |
UnipileConnectRequest,
|
| 58 |
+
UnipileHostedLinkRequest,
|
| 59 |
LinkedinCampaignCreateRequest,
|
| 60 |
LinkedinCampaignGenerateRequest,
|
| 61 |
LinkedinCampaignExecuteRequest,
|
|
|
|
| 105 |
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
| 106 |
UNIPILE_API_BASE = os.getenv("UNIPILE_API_BASE", "").rstrip("/")
|
| 107 |
UNIPILE_API_KEY = os.getenv("UNIPILE_API_KEY", "")
|
| 108 |
+
FRONTEND_ORIGIN = os.getenv("FRONTEND_ORIGIN", "").rstrip("/")
|
| 109 |
|
| 110 |
# ---- API ----
|
| 111 |
def _safe_str(value):
|
|
|
|
| 987 |
async def list_unipile_linkedin_accounts(t: TenantContext = Depends(get_tenant_context)):
|
| 988 |
rows = (
|
| 989 |
t.db.query(UnipileAccount)
|
| 990 |
+
.filter(
|
| 991 |
+
UnipileAccount.tenant_id == t.tenant_id,
|
| 992 |
+
UnipileAccount.user_id == t.user_id,
|
| 993 |
+
UnipileAccount.provider == "LINKEDIN",
|
| 994 |
+
)
|
| 995 |
.order_by(UnipileAccount.created_at.desc(), UnipileAccount.id.desc())
|
| 996 |
.all()
|
| 997 |
)
|
|
|
|
| 1011 |
}
|
| 1012 |
|
| 1013 |
|
| 1014 |
+
@app.post("/api/unipile/linkedin/hosted-link")
|
| 1015 |
+
async def create_unipile_linkedin_hosted_link(
|
| 1016 |
+
body: UnipileHostedLinkRequest,
|
| 1017 |
+
t: TenantContext = Depends(get_tenant_context),
|
| 1018 |
+
):
|
| 1019 |
+
if not FRONTEND_ORIGIN:
|
| 1020 |
+
raise HTTPException(
|
| 1021 |
+
status_code=400,
|
| 1022 |
+
detail="FRONTEND_ORIGIN is required to use UniPile Hosted Auth redirect flow.",
|
| 1023 |
+
)
|
| 1024 |
+
token = str(uuid.uuid4())
|
| 1025 |
+
expires_at = datetime.utcnow() + timedelta(minutes=20)
|
| 1026 |
+
state = UnipileHostedAuthState(
|
| 1027 |
+
tenant_id=t.tenant_id,
|
| 1028 |
+
user_id=t.user_id,
|
| 1029 |
+
token=token,
|
| 1030 |
+
provider="LINKEDIN",
|
| 1031 |
+
label=(body.label or "LinkedIn Account").strip(),
|
| 1032 |
+
expires_at=expires_at,
|
| 1033 |
+
)
|
| 1034 |
+
t.db.add(state)
|
| 1035 |
+
t.db.commit()
|
| 1036 |
+
|
| 1037 |
+
payload = {
|
| 1038 |
+
"type": "create",
|
| 1039 |
+
"providers": ["LINKEDIN"],
|
| 1040 |
+
"api_url": UNIPILE_API_BASE,
|
| 1041 |
+
"expiresOn": expires_at.isoformat() + "Z",
|
| 1042 |
+
"notify_url": f"{FRONTEND_ORIGIN}/api/unipile/linkedin/hosted-callback",
|
| 1043 |
+
"success_redirect_url": f"{FRONTEND_ORIGIN}/?tab=linkedin-connected",
|
| 1044 |
+
"failure_redirect_url": f"{FRONTEND_ORIGIN}/?tab=linkedin-connect-failed",
|
| 1045 |
+
"name": token,
|
| 1046 |
+
}
|
| 1047 |
+
response = _unipile_request("POST", "/api/v1/hosted/accounts/link", payload)
|
| 1048 |
+
hosted_url = _safe_str(response.get("url") if isinstance(response, dict) else "")
|
| 1049 |
+
if not hosted_url:
|
| 1050 |
+
raise HTTPException(status_code=400, detail="UniPile did not return hosted auth URL")
|
| 1051 |
+
return {"url": hosted_url}
|
| 1052 |
+
|
| 1053 |
+
|
| 1054 |
+
@app.post("/api/unipile/linkedin/hosted-callback")
|
| 1055 |
+
async def unipile_linkedin_hosted_callback(request: Request, db: Session = Depends(get_db)):
|
| 1056 |
+
"""
|
| 1057 |
+
UniPile Hosted Auth notify_url callback.
|
| 1058 |
+
No session is available on this route (server-to-server call), so we map by stored token in `name`.
|
| 1059 |
+
"""
|
| 1060 |
+
try:
|
| 1061 |
+
body = await request.json()
|
| 1062 |
+
except Exception:
|
| 1063 |
+
raise HTTPException(status_code=400, detail="Expected JSON body")
|
| 1064 |
+
|
| 1065 |
+
name_token = _safe_str((body or {}).get("name"))
|
| 1066 |
+
unipile_account_id = _safe_str((body or {}).get("account_id"))
|
| 1067 |
+
status = _safe_str((body or {}).get("status") or "connected")
|
| 1068 |
+
if not name_token:
|
| 1069 |
+
raise HTTPException(status_code=400, detail="Missing name token")
|
| 1070 |
+
if not unipile_account_id:
|
| 1071 |
+
return {"ok": True, "ignored": True, "reason": "missing_account_id"}
|
| 1072 |
+
|
| 1073 |
+
state = (
|
| 1074 |
+
db.query(UnipileHostedAuthState)
|
| 1075 |
+
.filter(
|
| 1076 |
+
UnipileHostedAuthState.token == name_token,
|
| 1077 |
+
UnipileHostedAuthState.provider == "LINKEDIN",
|
| 1078 |
+
)
|
| 1079 |
+
.first()
|
| 1080 |
+
)
|
| 1081 |
+
if not state:
|
| 1082 |
+
return {"ok": True, "ignored": True, "reason": "unknown_token"}
|
| 1083 |
+
if state.used_at is not None:
|
| 1084 |
+
return {"ok": True, "ignored": True, "reason": "token_already_used"}
|
| 1085 |
+
if state.expires_at and state.expires_at < datetime.utcnow():
|
| 1086 |
+
return {"ok": True, "ignored": True, "reason": "token_expired"}
|
| 1087 |
+
|
| 1088 |
+
existing = (
|
| 1089 |
+
db.query(UnipileAccount)
|
| 1090 |
+
.filter(
|
| 1091 |
+
UnipileAccount.tenant_id == state.tenant_id,
|
| 1092 |
+
UnipileAccount.user_id == state.user_id,
|
| 1093 |
+
UnipileAccount.provider == "LINKEDIN",
|
| 1094 |
+
UnipileAccount.unipile_account_id == unipile_account_id,
|
| 1095 |
+
)
|
| 1096 |
+
.first()
|
| 1097 |
+
)
|
| 1098 |
+
if existing:
|
| 1099 |
+
existing.label = state.label or existing.label
|
| 1100 |
+
existing.status = status or existing.status
|
| 1101 |
+
existing.auth_mode = "hosted"
|
| 1102 |
+
existing.metadata_json = body
|
| 1103 |
+
existing.updated_at = datetime.utcnow()
|
| 1104 |
+
else:
|
| 1105 |
+
db.add(
|
| 1106 |
+
UnipileAccount(
|
| 1107 |
+
tenant_id=state.tenant_id,
|
| 1108 |
+
user_id=state.user_id,
|
| 1109 |
+
label=state.label or "LinkedIn account",
|
| 1110 |
+
provider="LINKEDIN",
|
| 1111 |
+
unipile_account_id=unipile_account_id,
|
| 1112 |
+
status=status or "connected",
|
| 1113 |
+
auth_mode="hosted",
|
| 1114 |
+
metadata_json=body,
|
| 1115 |
+
)
|
| 1116 |
+
)
|
| 1117 |
+
state.used_at = datetime.utcnow()
|
| 1118 |
+
db.commit()
|
| 1119 |
+
return {"ok": True}
|
| 1120 |
+
|
| 1121 |
+
|
| 1122 |
@app.post("/api/unipile/linkedin/connect")
|
| 1123 |
async def connect_unipile_linkedin(
|
| 1124 |
body: UnipileConnectRequest,
|
|
|
|
| 1153 |
|
| 1154 |
db_row = UnipileAccount(
|
| 1155 |
tenant_id=t.tenant_id,
|
| 1156 |
+
user_id=t.user_id,
|
| 1157 |
label=(body.label or "LinkedIn account").strip(),
|
| 1158 |
provider="LINKEDIN",
|
| 1159 |
unipile_account_id=account_id,
|
|
|
|
| 1182 |
rows = (
|
| 1183 |
t.db.query(LinkedinCampaign, UnipileAccount)
|
| 1184 |
.join(UnipileAccount, UnipileAccount.id == LinkedinCampaign.unipile_account_ref_id)
|
| 1185 |
+
.filter(
|
| 1186 |
+
LinkedinCampaign.tenant_id == t.tenant_id,
|
| 1187 |
+
LinkedinCampaign.user_id == t.user_id,
|
| 1188 |
+
UnipileAccount.tenant_id == t.tenant_id,
|
| 1189 |
+
UnipileAccount.user_id == t.user_id,
|
| 1190 |
+
)
|
| 1191 |
.order_by(LinkedinCampaign.created_at.desc(), LinkedinCampaign.id.desc())
|
| 1192 |
.all()
|
| 1193 |
)
|
|
|
|
| 1217 |
):
|
| 1218 |
account = (
|
| 1219 |
t.db.query(UnipileAccount)
|
| 1220 |
+
.filter(
|
| 1221 |
+
UnipileAccount.tenant_id == t.tenant_id,
|
| 1222 |
+
UnipileAccount.user_id == t.user_id,
|
| 1223 |
+
UnipileAccount.id == body.unipile_account_ref_id,
|
| 1224 |
+
)
|
| 1225 |
.first()
|
| 1226 |
)
|
| 1227 |
if not account:
|
|
|
|
| 1231 |
raise HTTPException(status_code=400, detail="Campaign name is required")
|
| 1232 |
row = LinkedinCampaign(
|
| 1233 |
tenant_id=t.tenant_id,
|
| 1234 |
+
user_id=t.user_id,
|
| 1235 |
name=name,
|
| 1236 |
status="draft",
|
| 1237 |
unipile_account_ref_id=body.unipile_account_ref_id,
|
|
|
|
| 1251 |
db = t.db
|
| 1252 |
campaign = (
|
| 1253 |
db.query(LinkedinCampaign)
|
| 1254 |
+
.filter(
|
| 1255 |
+
LinkedinCampaign.tenant_id == t.tenant_id,
|
| 1256 |
+
LinkedinCampaign.user_id == t.user_id,
|
| 1257 |
+
LinkedinCampaign.id == campaign_id,
|
| 1258 |
+
)
|
| 1259 |
.first()
|
| 1260 |
)
|
| 1261 |
if not campaign:
|
|
|
|
| 1326 |
db = t.db
|
| 1327 |
campaign = (
|
| 1328 |
db.query(LinkedinCampaign)
|
| 1329 |
+
.filter(
|
| 1330 |
+
LinkedinCampaign.tenant_id == t.tenant_id,
|
| 1331 |
+
LinkedinCampaign.user_id == t.user_id,
|
| 1332 |
+
LinkedinCampaign.id == campaign_id,
|
| 1333 |
+
)
|
| 1334 |
.first()
|
| 1335 |
)
|
| 1336 |
if not campaign:
|
|
|
|
| 1391 |
db = t.db
|
| 1392 |
campaign = (
|
| 1393 |
db.query(LinkedinCampaign)
|
| 1394 |
+
.filter(
|
| 1395 |
+
LinkedinCampaign.tenant_id == t.tenant_id,
|
| 1396 |
+
LinkedinCampaign.user_id == t.user_id,
|
| 1397 |
+
LinkedinCampaign.id == campaign_id,
|
| 1398 |
+
)
|
| 1399 |
.first()
|
| 1400 |
)
|
| 1401 |
if not campaign:
|
|
|
|
| 1445 |
db = t.db
|
| 1446 |
campaign = (
|
| 1447 |
db.query(LinkedinCampaign)
|
| 1448 |
+
.filter(
|
| 1449 |
+
LinkedinCampaign.tenant_id == t.tenant_id,
|
| 1450 |
+
LinkedinCampaign.user_id == t.user_id,
|
| 1451 |
+
LinkedinCampaign.id == campaign_id,
|
| 1452 |
+
)
|
| 1453 |
.first()
|
| 1454 |
)
|
| 1455 |
if not campaign:
|
| 1456 |
raise HTTPException(status_code=404, detail="Campaign not found")
|
| 1457 |
account = (
|
| 1458 |
db.query(UnipileAccount)
|
| 1459 |
+
.filter(
|
| 1460 |
+
UnipileAccount.tenant_id == t.tenant_id,
|
| 1461 |
+
UnipileAccount.user_id == t.user_id,
|
| 1462 |
+
UnipileAccount.id == campaign.unipile_account_ref_id,
|
| 1463 |
+
)
|
| 1464 |
.first()
|
| 1465 |
)
|
| 1466 |
if not account:
|
backend/app/models.py
CHANGED
|
@@ -243,6 +243,10 @@ class UnipileConnectRequest(BaseModel):
|
|
| 243 |
ip: Optional[str] = None
|
| 244 |
|
| 245 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
class LinkedinCampaignCreateRequest(BaseModel):
|
| 247 |
name: str
|
| 248 |
unipile_account_ref_id: int
|
|
|
|
| 243 |
ip: Optional[str] = None
|
| 244 |
|
| 245 |
|
| 246 |
+
class UnipileHostedLinkRequest(BaseModel):
|
| 247 |
+
label: str = "LinkedIn Account"
|
| 248 |
+
|
| 249 |
+
|
| 250 |
class LinkedinCampaignCreateRequest(BaseModel):
|
| 251 |
name: str
|
| 252 |
unipile_account_ref_id: int
|
frontend/src/components/campaigns/LinkedinCampaignsTab.jsx
CHANGED
|
@@ -13,11 +13,6 @@ export default function LinkedinCampaignsTab() {
|
|
| 13 |
|
| 14 |
const [connectForm, setConnectForm] = useState({
|
| 15 |
label: 'LinkedIn Account',
|
| 16 |
-
auth_mode: 'cookie',
|
| 17 |
-
access_token: '',
|
| 18 |
-
user_agent: '',
|
| 19 |
-
username: '',
|
| 20 |
-
password: '',
|
| 21 |
});
|
| 22 |
const [createForm, setCreateForm] = useState({
|
| 23 |
name: '',
|
|
@@ -62,29 +57,15 @@ export default function LinkedinCampaignsTab() {
|
|
| 62 |
const connectAccount = async () => {
|
| 63 |
setBusy(true);
|
| 64 |
try {
|
| 65 |
-
const
|
| 66 |
-
connectForm.auth_mode === 'cookie'
|
| 67 |
-
? {
|
| 68 |
-
label: connectForm.label,
|
| 69 |
-
auth_mode: 'cookie',
|
| 70 |
-
access_token: connectForm.access_token,
|
| 71 |
-
user_agent: connectForm.user_agent,
|
| 72 |
-
}
|
| 73 |
-
: {
|
| 74 |
-
label: connectForm.label,
|
| 75 |
-
auth_mode: 'credentials',
|
| 76 |
-
username: connectForm.username,
|
| 77 |
-
password: connectForm.password,
|
| 78 |
-
};
|
| 79 |
-
const res = await apiFetch('/api/unipile/linkedin/connect', {
|
| 80 |
method: 'POST',
|
| 81 |
headers: { 'Content-Type': 'application/json' },
|
| 82 |
-
body: JSON.stringify(
|
| 83 |
});
|
| 84 |
const data = await res.json().catch(() => ({}));
|
| 85 |
-
if (!res.ok) throw new Error(data.detail || 'Could not
|
| 86 |
-
|
| 87 |
-
|
| 88 |
} catch (e) {
|
| 89 |
alert(e.message || 'Could not connect account');
|
| 90 |
} finally {
|
|
@@ -199,42 +180,9 @@ export default function LinkedinCampaignsTab() {
|
|
| 199 |
value={connectForm.label}
|
| 200 |
onChange={(e) => setConnectForm((s) => ({ ...s, label: e.target.value }))}
|
| 201 |
/>
|
| 202 |
-
<
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
onChange={(e) => setConnectForm((s) => ({ ...s, auth_mode: e.target.value }))}
|
| 206 |
-
>
|
| 207 |
-
<option value="cookie">Cookie (li_at)</option>
|
| 208 |
-
<option value="credentials">Username / Password</option>
|
| 209 |
-
</select>
|
| 210 |
-
{connectForm.auth_mode === 'cookie' ? (
|
| 211 |
-
<>
|
| 212 |
-
<Input
|
| 213 |
-
placeholder="li_at access token"
|
| 214 |
-
value={connectForm.access_token}
|
| 215 |
-
onChange={(e) => setConnectForm((s) => ({ ...s, access_token: e.target.value }))}
|
| 216 |
-
/>
|
| 217 |
-
<Input
|
| 218 |
-
placeholder="User-Agent (recommended)"
|
| 219 |
-
value={connectForm.user_agent}
|
| 220 |
-
onChange={(e) => setConnectForm((s) => ({ ...s, user_agent: e.target.value }))}
|
| 221 |
-
/>
|
| 222 |
-
</>
|
| 223 |
-
) : (
|
| 224 |
-
<>
|
| 225 |
-
<Input
|
| 226 |
-
placeholder="LinkedIn username/email"
|
| 227 |
-
value={connectForm.username}
|
| 228 |
-
onChange={(e) => setConnectForm((s) => ({ ...s, username: e.target.value }))}
|
| 229 |
-
/>
|
| 230 |
-
<Input
|
| 231 |
-
type="password"
|
| 232 |
-
placeholder="LinkedIn password"
|
| 233 |
-
value={connectForm.password}
|
| 234 |
-
onChange={(e) => setConnectForm((s) => ({ ...s, password: e.target.value }))}
|
| 235 |
-
/>
|
| 236 |
-
</>
|
| 237 |
-
)}
|
| 238 |
</div>
|
| 239 |
<div className="mt-4">
|
| 240 |
<Button onClick={connectAccount} disabled={busy}>
|
|
|
|
| 13 |
|
| 14 |
const [connectForm, setConnectForm] = useState({
|
| 15 |
label: 'LinkedIn Account',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
});
|
| 17 |
const [createForm, setCreateForm] = useState({
|
| 18 |
name: '',
|
|
|
|
| 57 |
const connectAccount = async () => {
|
| 58 |
setBusy(true);
|
| 59 |
try {
|
| 60 |
+
const res = await apiFetch('/api/unipile/linkedin/hosted-link', {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
method: 'POST',
|
| 62 |
headers: { 'Content-Type': 'application/json' },
|
| 63 |
+
body: JSON.stringify({ label: connectForm.label }),
|
| 64 |
});
|
| 65 |
const data = await res.json().catch(() => ({}));
|
| 66 |
+
if (!res.ok) throw new Error(data.detail || 'Could not start LinkedIn connect flow');
|
| 67 |
+
if (!data.url) throw new Error('Hosted auth URL missing');
|
| 68 |
+
window.location.href = data.url;
|
| 69 |
} catch (e) {
|
| 70 |
alert(e.message || 'Could not connect account');
|
| 71 |
} finally {
|
|
|
|
| 180 |
value={connectForm.label}
|
| 181 |
onChange={(e) => setConnectForm((s) => ({ ...s, label: e.target.value }))}
|
| 182 |
/>
|
| 183 |
+
<div className="rounded-md border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-600">
|
| 184 |
+
Redirect-based Hosted Auth (no plaintext credentials in this app).
|
| 185 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
</div>
|
| 187 |
<div className="mt-4">
|
| 188 |
<Button onClick={connectAccount} disabled={busy}>
|