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 fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy import select
|
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 .auth import ADMIN_COOKIE_NAME, create_admin_token, current_admin
|
||||||
from .config import settings
|
from .config import settings
|
||||||
|
|
@ -23,7 +23,10 @@ from .models import (
|
||||||
TemplateConfig,
|
TemplateConfig,
|
||||||
UrmiOtpProject,
|
UrmiOtpProject,
|
||||||
UserAccount,
|
UserAccount,
|
||||||
|
WhatsAppBusinessAccount,
|
||||||
WhatsAppMessage,
|
WhatsAppMessage,
|
||||||
|
WhatsAppMetaConnection,
|
||||||
|
MetaConnection,
|
||||||
)
|
)
|
||||||
from .phone_number_validators import normalize_phone_number, validate_per_min_price, validate_price
|
from .phone_number_validators import normalize_phone_number, validate_per_min_price, validate_price
|
||||||
from .routes_urmiotp import serialize_project
|
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")
|
@router.get("/whatsapp/config")
|
||||||
def admin_whatsapp_config(db: Session = Depends(get_db), _: str = Depends(current_admin)) -> dict:
|
def admin_whatsapp_config(db: Session = Depends(get_db), _: str = Depends(current_admin)) -> dict:
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
type AdminTemplateConfig,
|
type AdminTemplateConfig,
|
||||||
type AdminUrmiOtpProject,
|
type AdminUrmiOtpProject,
|
||||||
} from '../../lib/api'
|
} from '../../lib/api'
|
||||||
|
import { AdminTokensPage } from './AdminTokensPage'
|
||||||
import { AdminWhatsAppPage } from './AdminWhatsAppPage'
|
import { AdminWhatsAppPage } from './AdminWhatsAppPage'
|
||||||
|
|
||||||
const KYC_DOC_LABELS: Record<string, string> = {
|
const KYC_DOC_LABELS: Record<string, string> = {
|
||||||
|
|
@ -392,7 +393,7 @@ function AdminCompliance() {
|
||||||
|
|
||||||
export function AdminApp() {
|
export function AdminApp() {
|
||||||
const [authenticated, setAuthenticated] = useState<boolean | null>(null)
|
const [authenticated, setAuthenticated] = useState<boolean | null>(null)
|
||||||
const [section, setSection] = useState<'compliance' | 'whatsapp'>('compliance')
|
const [section, setSection] = useState<'compliance' | 'whatsapp' | 'tokens'>('compliance')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
adminApi.me().then(() => setAuthenticated(true)).catch(() => setAuthenticated(false))
|
adminApi.me().then(() => setAuthenticated(true)).catch(() => setAuthenticated(false))
|
||||||
|
|
@ -426,6 +427,13 @@ export function AdminApp() {
|
||||||
>
|
>
|
||||||
WhatsApp Business
|
WhatsApp Business
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={section === 'tokens' ? 'active' : ''}
|
||||||
|
onClick={() => setSection('tokens')}
|
||||||
|
>
|
||||||
|
Tokens
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
<button className="secondary-button" onClick={logout}>Log out</button>
|
<button className="secondary-button" onClick={logout}>Log out</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -433,7 +441,7 @@ export function AdminApp() {
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
{authenticated
|
{authenticated
|
||||||
? (section === 'compliance' ? <AdminCompliance /> : <AdminWhatsAppPage />)
|
? (section === 'compliance' ? <AdminCompliance /> : section === 'tokens' ? <AdminTokensPage /> : <AdminWhatsAppPage />)
|
||||||
: <AdminLogin onSuccess={() => setAuthenticated(true)} />}
|
: <AdminLogin onSuccess={() => setAuthenticated(true)} />}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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-page { min-height: calc(100vh - 72px); display: grid; place-items: center; }
|
||||||
.admin-login-card { width: min(420px, 100%); }
|
.admin-login-card { width: min(420px, 100%); }
|
||||||
.admin-kyc-card { margin-bottom: 18px; }
|
.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; }
|
.admin-actions { display: flex; gap: 8px; }
|
||||||
|
|
||||||
/* Message settings accordion */
|
/* Message settings accordion */
|
||||||
|
|
|
||||||
|
|
@ -948,6 +948,31 @@ export type AdminWhatsAppSendPayload = {
|
||||||
reaction_emoji?: string
|
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 = {
|
export const adminApi = {
|
||||||
me: () => jsonRequest<{ username: string }>('/api/v1/admin/me'),
|
me: () => jsonRequest<{ username: string }>('/api/v1/admin/me'),
|
||||||
login: (username: string, password: string) =>
|
login: (username: string, password: string) =>
|
||||||
|
|
@ -1008,6 +1033,7 @@ export const adminApi = {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
}),
|
}),
|
||||||
|
connectionTokens: () => jsonRequest<AdminConnectionTokens>('/api/v1/admin/connection-tokens'),
|
||||||
whatsappConfig: () => jsonRequest<AdminWhatsAppConfig>('/api/v1/admin/whatsapp/config'),
|
whatsappConfig: () => jsonRequest<AdminWhatsAppConfig>('/api/v1/admin/whatsapp/config'),
|
||||||
updateWhatsAppConfig: (payload: {
|
updateWhatsAppConfig: (payload: {
|
||||||
phone_number_id?: string
|
phone_number_id?: string
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue