Seth commited on
Commit
4cf3d77
·
1 Parent(s): 84ca4b1
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(UnipileAccount.tenant_id == t.tenant_id, UnipileAccount.provider == "LINKEDIN")
 
 
 
 
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(LinkedinCampaign.tenant_id == t.tenant_id)
 
 
 
 
 
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(UnipileAccount.tenant_id == t.tenant_id, UnipileAccount.id == body.unipile_account_ref_id)
 
 
 
 
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(LinkedinCampaign.tenant_id == t.tenant_id, LinkedinCampaign.id == campaign_id)
 
 
 
 
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(LinkedinCampaign.tenant_id == t.tenant_id, LinkedinCampaign.id == campaign_id)
 
 
 
 
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(LinkedinCampaign.tenant_id == t.tenant_id, LinkedinCampaign.id == campaign_id)
 
 
 
 
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(LinkedinCampaign.tenant_id == t.tenant_id, LinkedinCampaign.id == campaign_id)
 
 
 
 
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(UnipileAccount.tenant_id == t.tenant_id, UnipileAccount.id == campaign.unipile_account_ref_id)
 
 
 
 
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 payload =
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(payload),
83
  });
84
  const data = await res.json().catch(() => ({}));
85
- if (!res.ok) throw new Error(data.detail || 'Could not connect LinkedIn account');
86
- await refreshAll();
87
- alert('LinkedIn account connected.');
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
- <select
203
- className="h-10 rounded-md border border-slate-200 px-3 text-sm"
204
- value={connectForm.auth_mode}
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}>