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 ( -
-
-
-

Use an access token

-

Paste a user access token from Meta instead of signing in here. Urmi checks it, stores it for this workspace, and does not show it again.

-
-
- -
- - {saved && Token saved for this workspace.} -
-
- ) -} - 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 ( -
-

Use your own access token

-

Paste the token and WhatsApp Business account ID from Meta. Urmi stores them for this workspace and does not show the token again.

-
- - - - -
-
- ) -} - 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; }