token
This commit is contained in:
parent
bb9fc9a9cf
commit
9dbda69db1
5 changed files with 222 additions and 3 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
103
frontend/src/features/admin/AdminTokensPage.tsx
Normal file
103
frontend/src/features/admin/AdminTokensPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue