access fix
This commit is contained in:
parent
73ce8c5f95
commit
bb9fc9a9cf
8 changed files with 309 additions and 192 deletions
|
|
@ -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]:
|
async def _load_waba(token: str, waba_id: str) -> dict[str, Any]:
|
||||||
return await _graph(
|
return await _graph_fields(token, waba_id, ["id,name,owner_business_info", "id,name"])
|
||||||
"GET",
|
|
||||||
waba_id,
|
|
||||||
token=token,
|
|
||||||
params={"fields": "id,name,owner_business_info"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _load_phones(token: str, waba_id: str) -> list[dict[str, Any]]:
|
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
|
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(
|
async def connect_with_token(
|
||||||
db: Session,
|
db: Session,
|
||||||
user: UserAccount,
|
user: UserAccount,
|
||||||
|
|
@ -627,11 +764,7 @@ async def connect_with_token(
|
||||||
waba_id: str,
|
waba_id: str,
|
||||||
phone_number_id: str | None = None,
|
phone_number_id: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Store a customer-supplied WhatsApp token and provision that account.
|
"""Store a customer-supplied WhatsApp token. Does not use Embedded Signup."""
|
||||||
|
|
||||||
This is the non-embedded alternative to Embedded Signup. The token is
|
|
||||||
kept on the connection row and is not returned to the browser.
|
|
||||||
"""
|
|
||||||
token = _clean_token(access_token)
|
token = _clean_token(access_token)
|
||||||
account_id = "".join(str(waba_id or "").split())
|
account_id = "".join(str(waba_id or "").split())
|
||||||
phone_id = "".join(str(phone_number_id or "").split()) or None
|
phone_id = "".join(str(phone_number_id or "").split()) or None
|
||||||
|
|
@ -664,21 +797,21 @@ async def connect_with_token(
|
||||||
connection_id=connection.id,
|
connection_id=connection.id,
|
||||||
waba_id=account_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.commit()
|
||||||
db.refresh(account)
|
db.refresh(account)
|
||||||
if account.status == "connected":
|
if account.status == "connected":
|
||||||
overall = "connected"
|
overall = "connected"
|
||||||
message = "WhatsApp Business is connected."
|
message = "This WhatsApp account is saved for the workspace."
|
||||||
elif account.status == "incomplete":
|
elif account.status == "incomplete":
|
||||||
overall = "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":
|
elif account.status == "expired":
|
||||||
overall = "expired"
|
overall = "expired"
|
||||||
message = account.last_error or "Meta did not accept this access token."
|
message = account.last_error or "Meta did not accept this access token."
|
||||||
else:
|
else:
|
||||||
overall = "failed"
|
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)]}
|
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.commit()
|
||||||
db.refresh(account)
|
db.refresh(account)
|
||||||
return serialize_account(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.commit()
|
||||||
db.refresh(account)
|
db.refresh(account)
|
||||||
return serialize_account(account)
|
return serialize_account(account)
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,10 @@ import { TestingCenterPage } from './features/testing/TestingCenterPage'
|
||||||
import { LogsPage } from './features/logs/LogsPage'
|
import { LogsPage } from './features/logs/LogsPage'
|
||||||
import { AnalyticsPage } from './features/analytics/AnalyticsPage'
|
import { AnalyticsPage } from './features/analytics/AnalyticsPage'
|
||||||
import { WhatsAppSetupPage } from './features/whatsapp/WhatsAppSetupPage'
|
import { WhatsAppSetupPage } from './features/whatsapp/WhatsAppSetupPage'
|
||||||
|
import { WhatsAppTokenPage } from './features/whatsapp/WhatsAppTokenPage'
|
||||||
import { MetaOverviewPage } from './features/meta/OverviewPage'
|
import { MetaOverviewPage } from './features/meta/OverviewPage'
|
||||||
import { MetaConnectionsPage } from './features/meta/ConnectionsPage'
|
import { MetaConnectionsPage } from './features/meta/ConnectionsPage'
|
||||||
|
import { MetaTokenPage } from './features/meta/TokenPage'
|
||||||
import { MetaPagesPage } from './features/meta/PagesPage'
|
import { MetaPagesPage } from './features/meta/PagesPage'
|
||||||
import { MetaAdAccountsPage } from './features/meta/AdAccountsPage'
|
import { MetaAdAccountsPage } from './features/meta/AdAccountsPage'
|
||||||
import { MetaCampaignsPage } from './features/meta/CampaignsPage'
|
import { MetaCampaignsPage } from './features/meta/CampaignsPage'
|
||||||
|
|
@ -72,8 +74,10 @@ export default function App() {
|
||||||
<Route path="templates" element={<TemplatesPage />} />
|
<Route path="templates" element={<TemplatesPage />} />
|
||||||
<Route path="messaging" element={<ComposeMessage />} />
|
<Route path="messaging" element={<ComposeMessage />} />
|
||||||
<Route path="whatsapp" element={<WhatsAppSetupPage />} />
|
<Route path="whatsapp" element={<WhatsAppSetupPage />} />
|
||||||
|
<Route path="whatsapp/token" element={<WhatsAppTokenPage />} />
|
||||||
<Route path="meta" element={<MetaOverviewPage />} />
|
<Route path="meta" element={<MetaOverviewPage />} />
|
||||||
<Route path="meta/connections" element={<MetaConnectionsPage />} />
|
<Route path="meta/connections" element={<MetaConnectionsPage />} />
|
||||||
|
<Route path="meta/token" element={<MetaTokenPage />} />
|
||||||
<Route path="meta/pages" element={<MetaPagesPage />} />
|
<Route path="meta/pages" element={<MetaPagesPage />} />
|
||||||
<Route path="meta/pages/:pageId" element={<MetaPagesPage />} />
|
<Route path="meta/pages/:pageId" element={<MetaPagesPage />} />
|
||||||
<Route path="meta/ad-accounts" element={<MetaAdAccountsPage />} />
|
<Route path="meta/ad-accounts" element={<MetaAdAccountsPage />} />
|
||||||
|
|
|
||||||
|
|
@ -30,13 +30,15 @@ const groups = [
|
||||||
]},
|
]},
|
||||||
{ label: 'Messages', links: [
|
{ label: 'Messages', links: [
|
||||||
{ label: 'Send a message', path: '/console/messaging', icon: MessageSquareText },
|
{ 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: 'Campaigns', path: '/console/campaigns', icon: Zap },
|
||||||
{ label: 'Templates', path: '/console/templates', icon: BookOpen },
|
{ label: 'Templates', path: '/console/templates', icon: BookOpen },
|
||||||
]},
|
]},
|
||||||
{ label: 'Advertising', links: [
|
{ label: 'Advertising', links: [
|
||||||
{ label: 'Overview', path: '/console/meta', icon: LayoutDashboard, end: true },
|
{ label: 'Overview', path: '/console/meta', icon: LayoutDashboard, end: true },
|
||||||
{ label: 'Facebook connection', path: '/console/meta/connections', icon: Link2 },
|
{ 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: 'Facebook Pages', path: '/console/meta/pages', icon: Globe },
|
||||||
{ label: 'Ad accounts', path: '/console/meta/ad-accounts', icon: CircleDollarSign },
|
{ label: 'Ad accounts', path: '/console/meta/ad-accounts', icon: CircleDollarSign },
|
||||||
{ label: 'Ad campaigns', path: '/console/meta/campaigns', icon: Megaphone },
|
{ label: 'Ad campaigns', path: '/console/meta/campaigns', icon: Megaphone },
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, type FormEvent } from 'react'
|
import { useState } from 'react'
|
||||||
import { Copy } from 'lucide-react'
|
import { Copy } from 'lucide-react'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import { metaApi, type MetaConnection, MetaRequestError } from './api'
|
import { metaApi, type MetaConnection, MetaRequestError } from './api'
|
||||||
|
|
@ -35,22 +35,13 @@ export function MetaConnectionsPage() {
|
||||||
return (
|
return (
|
||||||
<MetaScreen
|
<MetaScreen
|
||||||
title="Facebook connection"
|
title="Facebook connection"
|
||||||
subtitle="Sign in, or paste an access token. Urmi keeps the connection private and does not show the token here."
|
subtitle="Sign in once for this workspace. Urmi keeps the connection private and does not show it here."
|
||||||
actions={<button className="primary-button" type="button" onClick={connect} disabled={busy}>{connection?.connected ? 'Reconnect' : 'Connect Facebook'}</button>}
|
actions={<button className="primary-button" type="button" onClick={connect} disabled={busy}>{connection?.connected ? 'Reconnect' : 'Connect Facebook'}</button>}
|
||||||
>
|
>
|
||||||
{params.get('connected') && <p className="meta-banner">Facebook is connected for this workspace.</p>}
|
{params.get('connected') && <p className="meta-banner">Facebook is connected for this workspace.</p>}
|
||||||
{params.get('error') && <p className="meta-banner is-error">{params.get('error')}</p>}
|
{params.get('error') && <p className="meta-banner is-error">{params.get('error')}</p>}
|
||||||
{error && <MetaState error={error} />}
|
{error && <MetaState error={error} />}
|
||||||
{connection?.oauth_redirect_uri && <MetaOAuthSetup redirectUri={connection.oauth_redirect_uri} />}
|
{connection?.oauth_redirect_uri && <MetaOAuthSetup redirectUri={connection.oauth_redirect_uri} />}
|
||||||
<MetaTokenSetup
|
|
||||||
onSaved={() => {
|
|
||||||
status.reload()
|
|
||||||
businesses.reload()
|
|
||||||
pages.reload()
|
|
||||||
accounts.reload()
|
|
||||||
}}
|
|
||||||
onError={setError}
|
|
||||||
/>
|
|
||||||
{status.loading || !connection ? <MetaLoading /> : (
|
{status.loading || !connection ? <MetaLoading /> : (
|
||||||
<div className="meta-split">
|
<div className="meta-split">
|
||||||
<article className="panel">
|
<article className="panel">
|
||||||
|
|
@ -63,7 +54,7 @@ export function MetaConnectionsPage() {
|
||||||
<p><span>Selected Page</span><strong>{connection.selected_page_name || 'Not selected'}</strong></p>
|
<p><span>Selected Page</span><strong>{connection.selected_page_name || 'Not selected'}</strong></p>
|
||||||
<p><span>Selected ad account</span><strong>{connection.selected_ad_account_name || 'Not selected'}</strong></p>
|
<p><span>Selected ad account</span><strong>{connection.selected_ad_account_name || 'Not selected'}</strong></p>
|
||||||
</div>
|
</div>
|
||||||
) : <MetaEmpty title="Facebook is not connected" body="Sign in with Facebook, or save an access token above. Urmi only lists the Pages and ad accounts that connection allows." />}
|
) : <MetaEmpty title="Facebook is not connected" body="Sign in with Facebook. Urmi only lists the Pages and ad accounts that login allows." />}
|
||||||
</article>
|
</article>
|
||||||
<article className="panel">
|
<article className="panel">
|
||||||
<div className="panel-heading"><h2>Granted permissions</h2></div>
|
<div className="panel-heading"><h2>Granted permissions</h2></div>
|
||||||
|
|
@ -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 (
|
|
||||||
<form className="panel meta-token-setup" onSubmit={submit}>
|
|
||||||
<div className="panel-heading">
|
|
||||||
<div>
|
|
||||||
<h2>Use an access token</h2>
|
|
||||||
<p>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.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<label className="explorer-input">
|
|
||||||
<strong>Access token</strong>
|
|
||||||
<span>It needs permission to see your Pages, business, or ad accounts.</span>
|
|
||||||
<input type="password" value={token} onChange={(event) => setToken(event.target.value)} autoComplete="off" required />
|
|
||||||
</label>
|
|
||||||
<div className="meta-token-actions">
|
|
||||||
<button className="primary-button" type="submit" disabled={saving || token.trim().length < 20}>
|
|
||||||
{saving ? 'Checking token…' : 'Save token'}
|
|
||||||
</button>
|
|
||||||
{saved && <span>Token saved for this workspace.</span>}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MetaOAuthSetup({ redirectUri }: { redirectUri: string }) {
|
function MetaOAuthSetup({ redirectUri }: { redirectUri: string }) {
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
|
|
||||||
52
frontend/src/features/meta/TokenPage.tsx
Normal file
52
frontend/src/features/meta/TokenPage.tsx
Normal file
|
|
@ -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<MetaRequestError | null>(null)
|
||||||
|
const [connection, setConnection] = useState<MetaConnection | null>(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 (
|
||||||
|
<MetaScreen
|
||||||
|
title="Facebook token"
|
||||||
|
subtitle="Paste a user access token for Pages and ads. This stays separate from Facebook sign-in."
|
||||||
|
>
|
||||||
|
{error && <MetaState error={error} />}
|
||||||
|
<form className="panel meta-token-setup" onSubmit={submit}>
|
||||||
|
<label className="explorer-input">
|
||||||
|
<strong>Access token</strong>
|
||||||
|
<span>It needs permission to see your Pages, business, or ad accounts. Urmi stores it and does not show it again.</span>
|
||||||
|
<input type="password" value={token} onChange={(event) => setToken(event.target.value)} autoComplete="off" required />
|
||||||
|
</label>
|
||||||
|
<div className="meta-token-actions">
|
||||||
|
<button className="primary-button" type="submit" disabled={saving || token.trim().length < 20}>
|
||||||
|
{saving ? 'Checking token…' : 'Save token'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{connection && (
|
||||||
|
<article className="panel">
|
||||||
|
<div className="panel-heading"><h2>{connection.user_name || 'Facebook user'}</h2><StatusPill value={connection.status} /></div>
|
||||||
|
<p className="meta-muted">This token is saved for the workspace. Pages and ad accounts now use it.</p>
|
||||||
|
</article>
|
||||||
|
)}
|
||||||
|
</MetaScreen>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Check, ChevronLeft, Copy, ExternalLink, Loader2, MessageCircle, MoreHorizontal, Plus, RefreshCw } from 'lucide-react'
|
import { Check, ChevronLeft, Copy, ExternalLink, Loader2, MessageCircle, MoreHorizontal, Plus, RefreshCw } from 'lucide-react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
import { CustomerWhatsAppInbox } from './CustomerWhatsAppInbox'
|
import { CustomerWhatsAppInbox } from './CustomerWhatsAppInbox'
|
||||||
import { useEffect, useRef, useState, type FormEvent } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
whatsappApi,
|
whatsappApi,
|
||||||
type WhatsAppAccount,
|
type WhatsAppAccount,
|
||||||
|
|
@ -138,10 +139,6 @@ export function WhatsAppSetupPage() {
|
||||||
const [developer, setDeveloper] = useState<WhatsAppDeveloperConfig | null>(null)
|
const [developer, setDeveloper] = useState<WhatsAppDeveloperConfig | null>(null)
|
||||||
const [logs, setLogs] = useState<WhatsAppAccountLogs | null>(null)
|
const [logs, setLogs] = useState<WhatsAppAccountLogs | null>(null)
|
||||||
const [copied, setCopied] = useState(false)
|
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<Record<string, string>>({})
|
const [pins, setPins] = useState<Record<string, string>>({})
|
||||||
const sessionRef = useRef<SignupSession | null>(null)
|
const sessionRef = useRef<SignupSession | null>(null)
|
||||||
const codeRef = useRef<string | null>(null)
|
const codeRef = useRef<string | null>(null)
|
||||||
|
|
@ -261,41 +258,6 @@ export function WhatsAppSetupPage() {
|
||||||
reportRef.current = reportSession
|
reportRef.current = reportSession
|
||||||
finishRef.current = finishIfReady
|
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() {
|
async function finishIfReady() {
|
||||||
const session = sessionRef.current
|
const session = sessionRef.current
|
||||||
const code = codeRef.current
|
const code = codeRef.current
|
||||||
|
|
@ -437,26 +399,9 @@ export function WhatsAppSetupPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{accounts.length > 0 && phase !== 'provisioning' && (
|
{accounts.length > 0 && phase !== 'provisioning' && (
|
||||||
<div className="wa-biz-heading-actions">
|
<button className="primary-button" type="button" onClick={() => void startEmbeddedSignup()} disabled={busy}>
|
||||||
<button
|
<Plus size={16} /> Connect account
|
||||||
className="secondary-button"
|
</button>
|
||||||
type="button"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => {
|
|
||||||
if (phase === 'list' && showToken) {
|
|
||||||
setShowToken(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setShowToken(true)
|
|
||||||
if (phase === 'result') setPhase('list')
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{phase === 'list' && showToken ? 'Hide token setup' : 'Use a token'}
|
|
||||||
</button>
|
|
||||||
<button className="primary-button" type="button" onClick={() => void startEmbeddedSignup()} disabled={busy}>
|
|
||||||
<Plus size={16} /> Connect account
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -480,10 +425,7 @@ export function WhatsAppSetupPage() {
|
||||||
{busy ? <Loader2 size={16} className="wa-spin" /> : null}
|
{busy ? <Loader2 size={16} className="wa-spin" /> : null}
|
||||||
Continue with Facebook
|
Continue with Facebook
|
||||||
</button>
|
</button>
|
||||||
<button className="text-button wa-token-toggle" type="button" onClick={() => setShowToken((open) => !open)}>
|
<Link className="text-button wa-token-toggle" to="/console/whatsapp/token">Have an access token? Connect it separately</Link>
|
||||||
{showToken ? 'Hide token setup' : 'Use an access token instead'}
|
|
||||||
</button>
|
|
||||||
{showToken && <TokenConnectForm embedded busy={busy} tokenValue={tokenValue} wabaId={wabaId} phoneNumberId={phoneNumberId} onToken={setTokenValue} onWaba={setWabaId} onPhone={setPhoneNumberId} onSubmit={submitToken} />}
|
|
||||||
<small>
|
<small>
|
||||||
{config?.ready
|
{config?.ready
|
||||||
? 'Setup usually takes a few minutes. Facebook decides which businesses, accounts, and numbers you can use.'
|
? 'Setup usually takes a few minutes. Facebook decides which businesses, accounts, and numbers you can use.'
|
||||||
|
|
@ -546,10 +488,6 @@ export function WhatsAppSetupPage() {
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{phase === 'list' && showToken && (
|
|
||||||
<TokenConnectForm busy={busy} tokenValue={tokenValue} wabaId={wabaId} phoneNumberId={phoneNumberId} onToken={setTokenValue} onWaba={setWabaId} onPhone={setPhoneNumberId} onSubmit={submitToken} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{phase === 'list' && (
|
{phase === 'list' && (
|
||||||
<div className="wa-biz-list">
|
<div className="wa-biz-list">
|
||||||
{accounts.map((account) => (
|
{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 (
|
|
||||||
<form className={embedded ? 'wa-token-embed' : 'wa-biz-card wa-token-card'} onSubmit={onSubmit}>
|
|
||||||
<h2>Use your own access token</h2>
|
|
||||||
<p>Paste the token and WhatsApp Business account ID from Meta. Urmi stores them for this workspace and does not show the token again.</p>
|
|
||||||
<div className="wa-token-form">
|
|
||||||
<label className="explorer-input">
|
|
||||||
<strong>Access token</strong>
|
|
||||||
<span>A token that can manage this WhatsApp account and send messages.</span>
|
|
||||||
<input type="password" value={tokenValue} onChange={(event) => onToken(event.target.value)} autoComplete="off" required />
|
|
||||||
</label>
|
|
||||||
<label className="explorer-input">
|
|
||||||
<strong>WhatsApp Business account ID</strong>
|
|
||||||
<span>The account ID Meta shows for this business.</span>
|
|
||||||
<input value={wabaId} onChange={(event) => onWaba(event.target.value)} inputMode="numeric" required />
|
|
||||||
</label>
|
|
||||||
<label className="explorer-input">
|
|
||||||
<strong>Phone number ID</strong>
|
|
||||||
<span>Optional. Add it if you want a specific number connected with this account.</span>
|
|
||||||
<input value={phoneNumberId} onChange={(event) => onPhone(event.target.value)} inputMode="numeric" />
|
|
||||||
</label>
|
|
||||||
<button className="primary-button" type="submit" disabled={busy}>
|
|
||||||
{busy ? <Loader2 size={16} className="wa-spin" /> : null}
|
|
||||||
Save and connect
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusDot({ status, label }: { status: string; label?: string }) {
|
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'
|
const tone = status === 'connected' || status === 'active' ? 'ok' : status === 'unavailable' || status === 'failed' || status === 'expired' || status === 'disconnected' ? 'bad' : 'wait'
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
86
frontend/src/features/whatsapp/WhatsAppTokenPage.tsx
Normal file
86
frontend/src/features/whatsapp/WhatsAppTokenPage.tsx
Normal file
|
|
@ -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<WhatsAppSignupResult | null>(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 (
|
||||||
|
<div className="wa-biz wa-token-page">
|
||||||
|
<div className="account-heading">
|
||||||
|
<span className="eyebrow">Messages</span>
|
||||||
|
<h1>WhatsApp token</h1>
|
||||||
|
<p>Paste a token from Meta. This is separate from Facebook signup and does not ask Meta for signup-only fields.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="auth-error">{error}</p>}
|
||||||
|
|
||||||
|
<form className="wa-biz-card" onSubmit={submit}>
|
||||||
|
<div className="wa-token-form">
|
||||||
|
<label className="explorer-input">
|
||||||
|
<strong>Access token</strong>
|
||||||
|
<span>From WhatsApp → API Setup, or a system user token that can send on this account.</span>
|
||||||
|
<input type="password" value={token} onChange={(event) => setToken(event.target.value)} autoComplete="off" required />
|
||||||
|
</label>
|
||||||
|
<label className="explorer-input">
|
||||||
|
<strong>WhatsApp Business account ID</strong>
|
||||||
|
<span>The numeric ID on the same API Setup page.</span>
|
||||||
|
<input value={wabaId} onChange={(event) => setWabaId(event.target.value)} inputMode="numeric" required />
|
||||||
|
</label>
|
||||||
|
<label className="explorer-input">
|
||||||
|
<strong>Phone number ID</strong>
|
||||||
|
<span>Optional. Use it when the account has more than one number.</span>
|
||||||
|
<input value={phoneNumberId} onChange={(event) => setPhoneNumberId(event.target.value)} inputMode="numeric" />
|
||||||
|
</label>
|
||||||
|
<button className="primary-button" type="submit" disabled={busy}>
|
||||||
|
{busy ? <Loader2 size={16} className="wa-spin" /> : null}
|
||||||
|
Save token
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{result && !error && (
|
||||||
|
<section className="wa-biz-card wa-token-result">
|
||||||
|
<h2>{result.status === 'connected' ? 'Token saved' : 'Token saved, one step left'}</h2>
|
||||||
|
<p>{result.message}</p>
|
||||||
|
{account && (
|
||||||
|
<dl className="wa-biz-facts">
|
||||||
|
<div><dt>Account</dt><dd>{account.name}</dd></div>
|
||||||
|
<div><dt>Phone</dt><dd>{account.primary_phone?.display_phone || account.primary_phone?.phone_number_id || 'Not added yet'}</dd></div>
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
<Link className="primary-button" to="/console/whatsapp">Open WhatsApp</Link>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1839,6 +1839,9 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
|
||||||
.wa-token-embed h2 { margin-top: 0; }
|
.wa-token-embed h2 { margin-top: 0; }
|
||||||
.wa-token-form { display: grid; gap: 12px; margin-top: 16px; }
|
.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-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 h1 { margin: 6px 0; }
|
||||||
.wa-biz-heading p { max-width: 640px; }
|
.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; }
|
.wa-biz-notice { margin: 0 0 16px; padding: 12px 14px; border: 1px solid #d7e6ff; border-radius: 12px; background: #f4f8ff; color: #1d4f91; font-size: 13px; }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue