From bb9fc9a9cf8d07a42feec9dff4ad7d9f37827721 Mon Sep 17 00:00:00 2001
From: debu-2
Date: Tue, 13 Oct 2026 01:18:08 +0530
Subject: [PATCH] access fix
---
backend/app/whatsapp_onboarding.py | 168 ++++++++++++++++--
frontend/src/App.tsx | 4 +
frontend/src/components/AppShell.tsx | 4 +-
.../src/features/meta/ConnectionsPage.tsx | 60 +------
frontend/src/features/meta/TokenPage.tsx | 52 ++++++
.../features/whatsapp/WhatsAppSetupPage.tsx | 124 +------------
.../features/whatsapp/WhatsAppTokenPage.tsx | 86 +++++++++
frontend/src/index.css | 3 +
8 files changed, 309 insertions(+), 192 deletions(-)
create mode 100644 frontend/src/features/meta/TokenPage.tsx
create mode 100644 frontend/src/features/whatsapp/WhatsAppTokenPage.tsx
diff --git a/backend/app/whatsapp_onboarding.py b/backend/app/whatsapp_onboarding.py
index bd79498..ec756a3 100644
--- a/backend/app/whatsapp_onboarding.py
+++ b/backend/app/whatsapp_onboarding.py
@@ -257,12 +257,7 @@ def list_accounts(db: Session, user: UserAccount) -> list[dict[str, Any]]:
async def _load_waba(token: str, waba_id: str) -> dict[str, Any]:
- return await _graph(
- "GET",
- waba_id,
- token=token,
- params={"fields": "id,name,owner_business_info"},
- )
+ return await _graph_fields(token, waba_id, ["id,name,owner_business_info", "id,name"])
async def _load_phones(token: str, waba_id: str) -> list[dict[str, Any]]:
@@ -620,6 +615,148 @@ def _clean_token(value: str) -> str:
return token
+def _manual_connection(connection: WhatsAppMetaConnection | None) -> bool:
+ return bool(connection and connection.granted_scopes == "manual")
+
+
+def _field_rejected(exc: HTTPException) -> bool:
+ detail = str(exc.detail).lower()
+ return "nonexisting field" in detail or "(#100)" in detail
+
+
+async def _graph_fields(token: str, path: str, field_sets: list[str], extra: dict[str, Any] | None = None) -> dict[str, Any]:
+ """Try the smallest field set that this token's Graph version accepts."""
+ last: HTTPException | None = None
+ for fields in field_sets:
+ params = dict(extra or {})
+ params["fields"] = fields
+ try:
+ return await _graph("GET", path, token=token, params=params)
+ except HTTPException as exc:
+ last = exc
+ if _field_rejected(exc):
+ continue
+ raise
+ if last is not None:
+ raise last
+ return {}
+
+
+async def _phones_for_token(token: str, waba_id: str, phone_id: str | None) -> list[dict[str, Any]]:
+ """Read numbers with the token the customer supplied. Never registers a number."""
+ rows: list[dict[str, Any]] = []
+ try:
+ data = await _graph_fields(
+ token,
+ f"{waba_id}/phone_numbers",
+ ["id,display_phone_number,verified_name", "id"],
+ {"limit": "50"},
+ )
+ raw = data.get("data")
+ rows = [row for row in raw if isinstance(row, dict)] if isinstance(raw, list) else []
+ except HTTPException:
+ logger.info("Token connection could not list numbers for WhatsApp account %s", waba_id[-4:])
+ for row in rows:
+ if not row.get("status"):
+ row["status"] = "CONNECTED"
+ seen = {str(row.get("id")) for row in rows if row.get("id")}
+ if phone_id and phone_id not in seen:
+ detail: dict[str, Any] = {"id": phone_id, "status": "CONNECTED"}
+ try:
+ node = await _graph_fields(token, phone_id, ["id,display_phone_number,verified_name", "id"])
+ if isinstance(node, dict):
+ detail.update({key: value for key, value in node.items() if value})
+ detail["status"] = "CONNECTED"
+ except HTTPException:
+ logger.info("Token connection stored phone %s without extra fields", phone_id[-4:])
+ rows.append(detail)
+ return rows
+
+
+async def _save_token_account(
+ db: Session,
+ user: UserAccount,
+ connection: WhatsAppMetaConnection,
+ token: str,
+ waba_id: str,
+ phone_id: str | None,
+) -> WhatsAppBusinessAccount:
+ """Store a pasted WhatsApp token without Embedded Signup fields or phone registration."""
+ claimed = _claim_waba(db, user, waba_id)
+ if claimed is None and db.scalar(select(WhatsAppBusinessAccount.id).where(WhatsAppBusinessAccount.waba_id == waba_id)):
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail="This WhatsApp Business Account is already connected to another Urmi workspace",
+ )
+ try:
+ waba = await _graph_fields(token, waba_id, ["id,name"])
+ except HTTPException as exc:
+ account = claimed or WhatsAppBusinessAccount(
+ user_id=user.id,
+ connection_id=connection.id,
+ waba_id=waba_id,
+ name="WhatsApp Business",
+ )
+ if claimed is None:
+ db.add(account)
+ db.flush()
+ detail = str(exc.detail)[:500]
+ expired = "expired" in detail.lower() or "validating access token" in detail.lower()
+ account.status = "expired" if expired else "failed"
+ account.last_error = detail
+ account.messaging_status = "unavailable"
+ account.management_status = "unavailable"
+ connection.status = "failed"
+ connection.last_error = account.last_error
+ _audit(db, user, "token_rejected", account.last_error or "", connection_id=connection.id, waba_id=waba_id)
+ return account
+
+ name = str(waba.get("name") or "WhatsApp Business")
+ connection.business_name = name
+ connection.status = "connected"
+ connection.last_error = None
+ connection.access_token = token
+ connection.granted_scopes = "manual"
+
+ account = claimed or WhatsAppBusinessAccount(
+ user_id=user.id,
+ connection_id=connection.id,
+ waba_id=waba_id,
+ )
+ if claimed is None:
+ db.add(account)
+ db.flush()
+ previous_connection_id = account.connection_id if claimed is not None else None
+ account.user_id = user.id
+ account.connection_id = connection.id
+ if previous_connection_id and previous_connection_id != connection.id:
+ previous = db.get(WhatsAppMetaConnection, previous_connection_id)
+ if previous is not None and previous.user_id == user.id:
+ previous.access_token = None
+ account.name = name
+ account.last_error = None
+ account.management_status = "active"
+
+ phones = await _phones_for_token(token, waba_id, phone_id)
+ if phones:
+ _apply_phones(db, user, account, phones, phone_id)
+ subscribed = await _subscribe(token, waba_id)
+ template_count, templates_ok = await _template_count(token, waba_id)
+ account.template_count = template_count
+ account.templates_status = "active" if templates_ok else "unavailable"
+ account.webhooks_status = "active" if subscribed else "unavailable"
+ if account.phones:
+ account.status = "connected"
+ account.messaging_status = "active"
+ _audit(db, user, "token_connected", "WhatsApp token saved for this account", connection_id=connection.id, waba_id=waba_id)
+ else:
+ account.status = "incomplete"
+ account.messaging_status = "unavailable"
+ account.last_error = "The token is saved. Add the phone number ID from Meta so this account has a number to use."
+ _audit(db, user, "token_needs_phone", account.last_error, connection_id=connection.id, waba_id=waba_id)
+ return account
+
+
async def connect_with_token(
db: Session,
user: UserAccount,
@@ -627,11 +764,7 @@ async def connect_with_token(
waba_id: str,
phone_number_id: str | None = None,
) -> dict[str, Any]:
- """Store a customer-supplied WhatsApp token and provision that account.
-
- This is the non-embedded alternative to Embedded Signup. The token is
- kept on the connection row and is not returned to the browser.
- """
+ """Store a customer-supplied WhatsApp token. Does not use Embedded Signup."""
token = _clean_token(access_token)
account_id = "".join(str(waba_id or "").split())
phone_id = "".join(str(phone_number_id or "").split()) or None
@@ -664,21 +797,21 @@ async def connect_with_token(
connection_id=connection.id,
waba_id=account_id,
)
- account = await _provision_waba(db, user, connection, token, account_id, phone_id)
+ account = await _save_token_account(db, user, connection, token, account_id, phone_id)
db.commit()
db.refresh(account)
if account.status == "connected":
overall = "connected"
- message = "WhatsApp Business is connected."
+ message = "This WhatsApp account is saved for the workspace."
elif account.status == "incomplete":
overall = "incomplete"
- message = account.last_error or "The account was saved, but the phone number still needs verification."
+ message = account.last_error or "The token is saved. Add the phone number ID to finish."
elif account.status == "expired":
overall = "expired"
message = account.last_error or "Meta did not accept this access token."
else:
overall = "failed"
- message = account.last_error or "Urmi could not finish WhatsApp setup."
+ message = account.last_error or "Meta did not accept this WhatsApp token."
return {"status": overall, "message": message, "accounts": [serialize_account(account)]}
@@ -694,7 +827,10 @@ async def refresh_account(db: Session, user: UserAccount, account_id: Any) -> di
db.commit()
db.refresh(account)
return serialize_account(account)
- await _provision_waba(db, user, connection, token, account.waba_id, None)
+ if _manual_connection(connection):
+ await _save_token_account(db, user, connection, token, account.waba_id, None)
+ else:
+ await _provision_waba(db, user, connection, token, account.waba_id, None)
db.commit()
db.refresh(account)
return serialize_account(account)
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index cf3a9bd..19b528e 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -19,8 +19,10 @@ import { TestingCenterPage } from './features/testing/TestingCenterPage'
import { LogsPage } from './features/logs/LogsPage'
import { AnalyticsPage } from './features/analytics/AnalyticsPage'
import { WhatsAppSetupPage } from './features/whatsapp/WhatsAppSetupPage'
+import { WhatsAppTokenPage } from './features/whatsapp/WhatsAppTokenPage'
import { MetaOverviewPage } from './features/meta/OverviewPage'
import { MetaConnectionsPage } from './features/meta/ConnectionsPage'
+import { MetaTokenPage } from './features/meta/TokenPage'
import { MetaPagesPage } from './features/meta/PagesPage'
import { MetaAdAccountsPage } from './features/meta/AdAccountsPage'
import { MetaCampaignsPage } from './features/meta/CampaignsPage'
@@ -72,8 +74,10 @@ export default function App() {
} />
} />
} />
+ } />
} />
} />
+ } />
} />
} />
} />
diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx
index c258d22..d3fcd15 100644
--- a/frontend/src/components/AppShell.tsx
+++ b/frontend/src/components/AppShell.tsx
@@ -30,13 +30,15 @@ const groups = [
]},
{ label: 'Messages', links: [
{ label: 'Send a message', path: '/console/messaging', icon: MessageSquareText },
- { label: 'WhatsApp', path: '/console/whatsapp', icon: MessageCircle },
+ { label: 'WhatsApp', path: '/console/whatsapp', icon: MessageCircle, end: true },
+ { label: 'WhatsApp token', path: '/console/whatsapp/token', icon: KeyRound },
{ label: 'Campaigns', path: '/console/campaigns', icon: Zap },
{ label: 'Templates', path: '/console/templates', icon: BookOpen },
]},
{ label: 'Advertising', links: [
{ label: 'Overview', path: '/console/meta', icon: LayoutDashboard, end: true },
{ label: 'Facebook connection', path: '/console/meta/connections', icon: Link2 },
+ { label: 'Facebook token', path: '/console/meta/token', icon: KeyRound },
{ label: 'Facebook Pages', path: '/console/meta/pages', icon: Globe },
{ label: 'Ad accounts', path: '/console/meta/ad-accounts', icon: CircleDollarSign },
{ label: 'Ad campaigns', path: '/console/meta/campaigns', icon: Megaphone },
diff --git a/frontend/src/features/meta/ConnectionsPage.tsx b/frontend/src/features/meta/ConnectionsPage.tsx
index 4a7ce16..ea5a243 100644
--- a/frontend/src/features/meta/ConnectionsPage.tsx
+++ b/frontend/src/features/meta/ConnectionsPage.tsx
@@ -1,4 +1,4 @@
-import { useState, type FormEvent } from 'react'
+import { useState } from 'react'
import { Copy } from 'lucide-react'
import { useSearchParams } from 'react-router-dom'
import { metaApi, type MetaConnection, MetaRequestError } from './api'
@@ -35,22 +35,13 @@ export function MetaConnectionsPage() {
return (
{connection?.connected ? 'Reconnect' : 'Connect Facebook'}}
>
{params.get('connected') && Facebook is connected for this workspace.
}
{params.get('error') && {params.get('error')}
}
{error && }
{connection?.oauth_redirect_uri && }
- {
- status.reload()
- businesses.reload()
- pages.reload()
- accounts.reload()
- }}
- onError={setError}
- />
{status.loading || !connection ? : (
@@ -63,7 +54,7 @@ export function MetaConnectionsPage() {
Selected Page{connection.selected_page_name || 'Not selected'}
Selected ad account{connection.selected_ad_account_name || 'Not selected'}
- ) : }
+ ) : }
Granted permissions
@@ -105,51 +96,6 @@ export function MetaConnectionsPage() {
)
}
-function MetaTokenSetup({ onSaved, onError }: { onSaved: () => void; onError: (error: MetaRequestError | null) => void }) {
- const [token, setToken] = useState('')
- const [saving, setSaving] = useState(false)
- const [saved, setSaved] = useState(false)
-
- async function submit(event: FormEvent) {
- event.preventDefault()
- setSaving(true)
- setSaved(false)
- onError(null)
- try {
- await metaApi.saveToken(token.trim())
- setToken('')
- setSaved(true)
- onSaved()
- } catch (err) {
- onError(err instanceof MetaRequestError ? err : new MetaRequestError('This token could not be saved'))
- } finally {
- setSaving(false)
- }
- }
-
- return (
-
- )
-}
-
function MetaOAuthSetup({ redirectUri }: { redirectUri: string }) {
const [copied, setCopied] = useState(false)
diff --git a/frontend/src/features/meta/TokenPage.tsx b/frontend/src/features/meta/TokenPage.tsx
new file mode 100644
index 0000000..ccf71ee
--- /dev/null
+++ b/frontend/src/features/meta/TokenPage.tsx
@@ -0,0 +1,52 @@
+import { useState, type FormEvent } from 'react'
+import { metaApi, MetaRequestError, type MetaConnection } from './api'
+import { MetaScreen, MetaState, StatusPill } from './ui'
+
+export function MetaTokenPage() {
+ const [token, setToken] = useState('')
+ const [saving, setSaving] = useState(false)
+ const [error, setError] = useState(null)
+ const [connection, setConnection] = useState(null)
+
+ async function submit(event: FormEvent) {
+ event.preventDefault()
+ setSaving(true)
+ setError(null)
+ try {
+ setConnection(await metaApi.saveToken(token.trim()))
+ setToken('')
+ } catch (err) {
+ setConnection(null)
+ setError(err instanceof MetaRequestError ? err : new MetaRequestError('This token could not be saved'))
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+ {error && }
+
+ {connection && (
+
+ {connection.user_name || 'Facebook user'}
+ This token is saved for the workspace. Pages and ad accounts now use it.
+
+ )}
+
+ )
+}
diff --git a/frontend/src/features/whatsapp/WhatsAppSetupPage.tsx b/frontend/src/features/whatsapp/WhatsAppSetupPage.tsx
index 8c3e27e..a593076 100644
--- a/frontend/src/features/whatsapp/WhatsAppSetupPage.tsx
+++ b/frontend/src/features/whatsapp/WhatsAppSetupPage.tsx
@@ -1,6 +1,7 @@
import { Check, ChevronLeft, Copy, ExternalLink, Loader2, MessageCircle, MoreHorizontal, Plus, RefreshCw } from 'lucide-react'
+import { Link } from 'react-router-dom'
import { CustomerWhatsAppInbox } from './CustomerWhatsAppInbox'
-import { useEffect, useRef, useState, type FormEvent } from 'react'
+import { useEffect, useRef, useState } from 'react'
import {
whatsappApi,
type WhatsAppAccount,
@@ -138,10 +139,6 @@ export function WhatsAppSetupPage() {
const [developer, setDeveloper] = useState(null)
const [logs, setLogs] = useState(null)
const [copied, setCopied] = useState(false)
- const [showToken, setShowToken] = useState(false)
- const [tokenValue, setTokenValue] = useState('')
- const [wabaId, setWabaId] = useState('')
- const [phoneNumberId, setPhoneNumberId] = useState('')
const [pins, setPins] = useState>({})
const sessionRef = useRef(null)
const codeRef = useRef(null)
@@ -261,41 +258,6 @@ export function WhatsAppSetupPage() {
reportRef.current = reportSession
finishRef.current = finishIfReady
- function mergeAccounts(incoming: WhatsAppAccount[]) {
- if (!incoming.length) return
- setAccounts((current) => {
- const byId = new Map(incoming.map((account) => [account.id, account]))
- const merged = current.map((account) => byId.get(account.id) || account)
- for (const account of incoming) {
- if (!merged.some((item) => item.id === account.id)) merged.unshift(account)
- }
- return merged
- })
- }
-
- async function submitToken(event: FormEvent) {
- event.preventDefault()
- setBusy(true)
- setError('')
- setNotice('')
- try {
- const payload = await whatsappApi.connectToken({
- access_token: tokenValue.trim(),
- waba_id: wabaId.trim(),
- phone_number_id: phoneNumberId.trim() || null,
- })
- setResult(payload)
- mergeAccounts(payload.accounts)
- setTokenValue('')
- setShowToken(false)
- setPhase('result')
- } catch (reason) {
- setError(reason instanceof Error ? reason.message : 'WhatsApp could not be connected')
- } finally {
- setBusy(false)
- }
- }
-
async function finishIfReady() {
const session = sessionRef.current
const code = codeRef.current
@@ -437,26 +399,9 @@ export function WhatsAppSetupPage() {
{accounts.length > 0 && phase !== 'provisioning' && (
-
-
-
-
+
)}
@@ -480,10 +425,7 @@ export function WhatsAppSetupPage() {
{busy ? : null}
Continue with Facebook
-
- {showToken && }
+ Have an access token? Connect it separately
{config?.ready
? 'Setup usually takes a few minutes. Facebook decides which businesses, accounts, and numbers you can use.'
@@ -546,10 +488,6 @@ export function WhatsAppSetupPage() {
)}
- {phase === 'list' && showToken && (
-
- )}
-
{phase === 'list' && (
{accounts.map((account) => (
@@ -819,56 +757,6 @@ export function WhatsAppSetupPage() {
)
}
-function TokenConnectForm({
- embedded = false,
- busy,
- tokenValue,
- wabaId,
- phoneNumberId,
- onToken,
- onWaba,
- onPhone,
- onSubmit,
-}: {
- embedded?: boolean
- busy: boolean
- tokenValue: string
- wabaId: string
- phoneNumberId: string
- onToken: (value: string) => void
- onWaba: (value: string) => void
- onPhone: (value: string) => void
- onSubmit: (event: FormEvent) => void
-}) {
- return (
-
- )
-}
-
function StatusDot({ status, label }: { status: string; label?: string }) {
const tone = status === 'connected' || status === 'active' ? 'ok' : status === 'unavailable' || status === 'failed' || status === 'expired' || status === 'disconnected' ? 'bad' : 'wait'
return (
diff --git a/frontend/src/features/whatsapp/WhatsAppTokenPage.tsx b/frontend/src/features/whatsapp/WhatsAppTokenPage.tsx
new file mode 100644
index 0000000..c46dff7
--- /dev/null
+++ b/frontend/src/features/whatsapp/WhatsAppTokenPage.tsx
@@ -0,0 +1,86 @@
+import { Loader2 } from 'lucide-react'
+import { useState, type FormEvent } from 'react'
+import { Link } from 'react-router-dom'
+import { whatsappApi, type WhatsAppSignupResult } from '../../lib/api'
+
+export function WhatsAppTokenPage() {
+ const [token, setToken] = useState('')
+ const [wabaId, setWabaId] = useState('')
+ const [phoneNumberId, setPhoneNumberId] = useState('')
+ const [busy, setBusy] = useState(false)
+ const [error, setError] = useState('')
+ const [result, setResult] = useState
(null)
+
+ async function submit(event: FormEvent) {
+ event.preventDefault()
+ setBusy(true)
+ setError('')
+ setResult(null)
+ try {
+ const payload = await whatsappApi.connectToken({
+ access_token: token.trim(),
+ waba_id: wabaId.trim(),
+ phone_number_id: phoneNumberId.trim() || null,
+ })
+ setResult(payload)
+ setToken('')
+ if (payload.status === 'failed' || payload.status === 'expired') setError(payload.message)
+ } catch (reason) {
+ setError(reason instanceof Error ? reason.message : 'This token could not be saved')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ const account = result?.accounts[0]
+
+ return (
+
+
+
Messages
+
WhatsApp token
+
Paste a token from Meta. This is separate from Facebook signup and does not ask Meta for signup-only fields.
+
+
+ {error &&
{error}
}
+
+
+
+ {result && !error && (
+
+ {result.status === 'connected' ? 'Token saved' : 'Token saved, one step left'}
+ {result.message}
+ {account && (
+
+ - Account
- {account.name}
+ - Phone
- {account.primary_phone?.display_phone || account.primary_phone?.phone_number_id || 'Not added yet'}
+
+ )}
+ Open WhatsApp
+
+ )}
+
+ )
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index e27d0e5..a51c14b 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -1839,6 +1839,9 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.wa-token-embed h2 { margin-top: 0; }
.wa-token-form { display: grid; gap: 12px; margin-top: 16px; }
.wa-token-form .primary-button { justify-self: start; display: inline-flex; align-items: center; gap: 8px; }
+.wa-token-page { max-width: 720px; }
+.wa-token-result { margin-top: 16px; }
+.wa-token-result .primary-button { margin-top: 8px; text-decoration: none; display: inline-flex; }
.wa-biz-heading h1 { margin: 6px 0; }
.wa-biz-heading p { max-width: 640px; }
.wa-biz-notice { margin: 0 0 16px; padding: 12px 14px; border: 1px solid #d7e6ff; border-radius: 12px; background: #f4f8ff; color: #1d4f91; font-size: 13px; }