This commit is contained in:
debu-2 2026-10-13 01:33:49 +05:30
parent bb9fc9a9cf
commit 9dbda69db1
5 changed files with 222 additions and 3 deletions

View file

@ -5,7 +5,7 @@ from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import Session, joinedload
from sqlalchemy.orm import Session, joinedload, selectinload
from .auth import ADMIN_COOKIE_NAME, create_admin_token, current_admin
from .config import settings
@ -23,7 +23,10 @@ from .models import (
TemplateConfig,
UrmiOtpProject,
UserAccount,
WhatsAppBusinessAccount,
WhatsAppMessage,
WhatsAppMetaConnection,
MetaConnection,
)
from .phone_number_validators import normalize_phone_number, validate_per_min_price, validate_price
from .routes_urmiotp import serialize_project
@ -578,6 +581,77 @@ def _whatsapp_client(db: Session) -> WhatsAppGraphClient:
)
def _token_account(connection: WhatsAppMetaConnection, account: WhatsAppBusinessAccount) -> dict:
return {
"id": str(account.id),
"name": account.name,
"status": account.status,
"source": "token" if connection.granted_scopes == "manual" else "signup",
"access_token": connection.access_token or "",
"waba_id": account.waba_id,
"phones": [
{
"id": str(phone.id),
"display_phone": phone.display_phone,
"phone_number_id": phone.phone_number_id,
"status": phone.status,
}
for phone in account.phones
],
"updated_at": account.updated_at,
}
@router.get("/connection-tokens")
def admin_connection_tokens(db: Session = Depends(get_db), _: str = Depends(current_admin)) -> dict:
"""Every customer's saved WhatsApp and Facebook tokens. Admin session only."""
users = db.scalars(select(UserAccount).order_by(UserAccount.email.asc())).all()
whatsapp_rows = db.scalars(
select(WhatsAppMetaConnection).options(
selectinload(WhatsAppMetaConnection.accounts).selectinload(WhatsAppBusinessAccount.phones)
)
).all()
facebook_rows = db.scalars(select(MetaConnection)).all()
whatsapp_by_user: dict = {}
for connection in whatsapp_rows:
saved = [_token_account(connection, account) for account in connection.accounts]
if not saved and connection.access_token:
saved.append({
"id": str(connection.id),
"name": connection.business_name or "WhatsApp",
"status": connection.status,
"source": "token" if connection.granted_scopes == "manual" else "signup",
"access_token": connection.access_token,
"waba_id": "",
"phones": [],
"updated_at": connection.updated_at,
})
if saved:
whatsapp_by_user.setdefault(connection.user_id, []).extend(saved)
facebook_by_user = {row.user_id: row for row in facebook_rows}
payload = []
for user in users:
facebook = facebook_by_user.get(user.id)
accounts = whatsapp_by_user.get(user.id, [])
facebook_token = facebook.access_token if facebook and facebook.status != "disconnected" else ""
if not accounts and not facebook_token:
continue
payload.append({
"id": str(user.id),
"name": user.display_name,
"email": user.email,
"whatsapp_accounts": accounts,
"facebook": None if facebook is None or not facebook_token else {
"status": facebook.status,
"user_name": facebook.meta_user_name,
"access_token": facebook_token,
"expires_at": facebook.token_expires_at,
"connected_at": facebook.connected_at,
},
})
return {"users": payload}
@router.get("/whatsapp/config")
def admin_whatsapp_config(db: Session = Depends(get_db), _: str = Depends(current_admin)) -> dict:
return {

View file

@ -10,6 +10,7 @@ import {
type AdminTemplateConfig,
type AdminUrmiOtpProject,
} from '../../lib/api'
import { AdminTokensPage } from './AdminTokensPage'
import { AdminWhatsAppPage } from './AdminWhatsAppPage'
const KYC_DOC_LABELS: Record<string, string> = {
@ -392,7 +393,7 @@ function AdminCompliance() {
export function AdminApp() {
const [authenticated, setAuthenticated] = useState<boolean | null>(null)
const [section, setSection] = useState<'compliance' | 'whatsapp'>('compliance')
const [section, setSection] = useState<'compliance' | 'whatsapp' | 'tokens'>('compliance')
useEffect(() => {
adminApi.me().then(() => setAuthenticated(true)).catch(() => setAuthenticated(false))
@ -426,6 +427,13 @@ export function AdminApp() {
>
WhatsApp Business
</button>
<button
type="button"
className={section === 'tokens' ? 'active' : ''}
onClick={() => setSection('tokens')}
>
Tokens
</button>
</nav>
<button className="secondary-button" onClick={logout}>Log out</button>
</div>
@ -433,7 +441,7 @@ export function AdminApp() {
</header>
<main>
{authenticated
? (section === 'compliance' ? <AdminCompliance /> : <AdminWhatsAppPage />)
? (section === 'compliance' ? <AdminCompliance /> : section === 'tokens' ? <AdminTokensPage /> : <AdminWhatsAppPage />)
: <AdminLogin onSuccess={() => setAuthenticated(true)} />}
</main>
</div>

View file

@ -0,0 +1,103 @@
import { Copy } from 'lucide-react'
import { useEffect, useState } from 'react'
import { adminApi, type AdminConnectionTokens } from '../../lib/api'
type UserRow = AdminConnectionTokens['users'][number]
type WhatsAppAccount = UserRow['whatsapp_accounts'][number]
function TokenValue({ label, value }: { label: string; value: string }) {
const [copied, setCopied] = useState(false)
const shown = value || 'Not saved'
async function copy() {
if (!value) return
await navigator.clipboard.writeText(value)
setCopied(true)
window.setTimeout(() => setCopied(false), 1500)
}
return (
<div className="admin-token-value">
<div className="admin-token-label">
<strong>{label}</strong>
{value && (
<button type="button" className="secondary-button" onClick={() => void copy()}>
<Copy size={14} /> {copied ? 'Copied' : 'Copy'}
</button>
)}
</div>
<code>{shown}</code>
</div>
)
}
function WhatsAppTokens({ account }: { account: WhatsAppAccount }) {
const phoneIds = account.phones.map((phone) => phone.phone_number_id).filter(Boolean)
return (
<article className="admin-token-account">
<div className="panel-heading">
<div>
<h3>{account.name}</h3>
<p>{account.source === 'token' ? 'Pasted token' : 'Facebook signup'} · {account.status}</p>
</div>
</div>
<TokenValue label="Access token" value={account.access_token} />
<TokenValue label="WhatsApp Business account ID" value={account.waba_id} />
<TokenValue label="Phone number ID" value={phoneIds.join('\n')} />
{account.phones.length > 0 && (
<p className="admin-token-phones">
{account.phones.map((phone) => phone.display_phone || phone.phone_number_id).join(' · ')}
</p>
)}
</article>
)
}
export function AdminTokensPage() {
const [users, setUsers] = useState<UserRow[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
adminApi.connectionTokens()
.then((payload) => setUsers(payload.users))
.catch((reason) => setError(reason instanceof Error ? reason.message : 'Could not load tokens'))
.finally(() => setLoading(false))
}, [])
if (loading) return <div className="auth-loading">Loading tokens…</div>
return (
<div className="admin-content">
<div className="account-heading">
<span className="eyebrow">Connections</span>
<h1>Saved tokens</h1>
<p>Each customer account, with the WhatsApp access token, account ID, and phone number ID, plus the Facebook token when one is saved.</p>
</div>
{error && <p className="auth-error">{error}</p>}
{!error && users.length === 0 && <section className="panel placeholder"><p>No tokens have been saved yet.</p></section>}
{users.map((user) => (
<section className="panel admin-kyc-card" key={user.id}>
<div className="panel-heading">
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
</div>
{user.whatsapp_accounts.map((account) => <WhatsAppTokens key={account.id} account={account} />)}
{user.facebook && (
<article className="admin-token-account">
<div className="panel-heading">
<div>
<h3>Facebook</h3>
<p>{user.facebook.user_name || 'Facebook user'} · {user.facebook.status}</p>
</div>
</div>
<TokenValue label="Access token" value={user.facebook.access_token} />
</article>
)}
</section>
))}
</div>
)
}

View file

@ -864,6 +864,14 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
.admin-login-page { min-height: calc(100vh - 72px); display: grid; place-items: center; }
.admin-login-card { width: min(420px, 100%); }
.admin-kyc-card { margin-bottom: 18px; }
.admin-token-account { margin-top: 14px; padding-top: 12px; border-top: 1px solid #e6ebf2; }
.admin-token-account h3 { margin: 0; color: #14233b; font-size: 16px; }
.admin-token-account .panel-heading p { margin: 4px 0 0; }
.admin-token-value { display: grid; gap: 6px; margin-top: 12px; }
.admin-token-label { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.admin-token-label strong { color: #35435a; font-size: 13px; }
.admin-token-value code { display: block; white-space: pre-wrap; word-break: break-all; padding: 10px 12px; border-radius: 8px; background: #f6f8fb; border: 1px solid #e5eaf1; color: #17253e; font-size: 12px; }
.admin-token-phones { margin: 8px 0 0; color: #6d7c90; font-size: 12px; }
.admin-actions { display: flex; gap: 8px; }
/* Message settings accordion */

View file

@ -948,6 +948,31 @@ export type AdminWhatsAppSendPayload = {
reaction_emoji?: string
}
export type AdminConnectionTokens = {
users: Array<{
id: string
name: string
email: string
whatsapp_accounts: Array<{
id: string
name: string
status: string
source: 'token' | 'signup'
access_token: string
waba_id: string
phones: Array<{ id: string; display_phone: string; phone_number_id: string; status: string }>
updated_at: string
}>
facebook: {
status: string
user_name: string
access_token: string
expires_at: string | null
connected_at: string | null
} | null
}>
}
export const adminApi = {
me: () => jsonRequest<{ username: string }>('/api/v1/admin/me'),
login: (username: string, password: string) =>
@ -1008,6 +1033,7 @@ export const adminApi = {
method: 'PATCH',
body: JSON.stringify(payload),
}),
connectionTokens: () => jsonRequest<AdminConnectionTokens>('/api/v1/admin/connection-tokens'),
whatsappConfig: () => jsonRequest<AdminWhatsAppConfig>('/api/v1/admin/whatsapp/config'),
updateWhatsAppConfig: (payload: {
phone_number_id?: string