feat: add Accounts page with QR code display for WhatsApp re-authentication
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,100 @@
|
|||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { AccountCard } from './AccountCard';
|
||||||
|
|
||||||
|
const activeAccount = {
|
||||||
|
id: 'acc_1',
|
||||||
|
jid: '111@s.whatsapp.net',
|
||||||
|
displayName: 'My Account',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
platform: 'whatsapp',
|
||||||
|
};
|
||||||
|
|
||||||
|
const disconnectedAccount = {
|
||||||
|
id: 'acc_2',
|
||||||
|
jid: '222@s.whatsapp.net',
|
||||||
|
displayName: null,
|
||||||
|
status: 'DISCONNECTED',
|
||||||
|
platform: 'whatsapp',
|
||||||
|
};
|
||||||
|
|
||||||
|
let fetchSpy: jest.SpyInstance;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchSpy = jest.spyOn(global, 'fetch');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AccountCard', () => {
|
||||||
|
it('shows displayName and Connected badge when ACTIVE', () => {
|
||||||
|
render(<AccountCard account={activeAccount} />);
|
||||||
|
expect(screen.getByText('My Account')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Connected')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to jid when displayName is null', () => {
|
||||||
|
render(<AccountCard account={disconnectedAccount} />);
|
||||||
|
expect(screen.getByText('222@s.whatsapp.net')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Awaiting scan badge when DISCONNECTED', () => {
|
||||||
|
fetchSpy.mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ status: 'DISCONNECTED', qrDataUrl: null }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
render(<AccountCard account={disconnectedAccount} />);
|
||||||
|
expect(screen.getByText('Awaiting scan')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fetch QR when account is ACTIVE', () => {
|
||||||
|
render(<AccountCard account={activeAccount} />);
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches QR from /api/accounts/:id/qr when DISCONNECTED', async () => {
|
||||||
|
fetchSpy.mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ status: 'DISCONNECTED', qrDataUrl: null }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
render(<AccountCard account={disconnectedAccount} />);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(fetchSpy).toHaveBeenCalledWith('/api/accounts/acc_2/qr'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows QR image when qrDataUrl is returned', async () => {
|
||||||
|
fetchSpy.mockResolvedValue(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ status: 'DISCONNECTED', qrDataUrl: 'data:image/png;base64,abc123' }),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
render(<AccountCard account={disconnectedAccount} />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('img', { name: /qr code/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByRole('img', { name: /qr code/i })).toHaveAttribute(
|
||||||
|
'src',
|
||||||
|
'data:image/png;base64,abc123',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows waiting message when DISCONNECTED but no QR yet', async () => {
|
||||||
|
fetchSpy.mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ status: 'DISCONNECTED', qrDataUrl: null }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
render(<AccountCard account={disconnectedAccount} />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/waiting for qr/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
'use client';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface Account {
|
||||||
|
id: string;
|
||||||
|
jid: string;
|
||||||
|
displayName: string | null;
|
||||||
|
status: string;
|
||||||
|
platform: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccountCard({ account }: { account: Account }) {
|
||||||
|
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||||
|
const isDisconnected = account.status === 'DISCONNECTED';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDisconnected) {
|
||||||
|
setQrDataUrl(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchQr() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/accounts/${account.id}/qr`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
setQrDataUrl(data.qrDataUrl ?? null);
|
||||||
|
} catch {
|
||||||
|
// ignore fetch errors (e.g. network issues)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchQr();
|
||||||
|
const interval = setInterval(fetchQr, 5000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [account.id, isDisconnected]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border rounded-lg p-4 bg-white">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{account.displayName ?? account.jid}</p>
|
||||||
|
{account.displayName && (
|
||||||
|
<p className="text-xs text-gray-500">{account.jid}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`text-xs px-2 py-1 rounded-full font-medium ${
|
||||||
|
account.status === 'ACTIVE'
|
||||||
|
? 'bg-green-100 text-green-700'
|
||||||
|
: 'bg-yellow-100 text-yellow-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{account.status === 'ACTIVE' ? 'Connected' : 'Awaiting scan'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isDisconnected && qrDataUrl && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<p className="text-sm text-gray-600 mb-2">
|
||||||
|
Open WhatsApp → Linked Devices → Link a Device → scan below
|
||||||
|
</p>
|
||||||
|
<img src={qrDataUrl} alt="WhatsApp QR Code" className="w-48 h-48" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isDisconnected && !qrDataUrl && (
|
||||||
|
<p className="text-sm text-gray-500 mt-2">Waiting for QR code from worker...</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { AccountCard } from './AccountCard';
|
||||||
|
|
||||||
|
interface Account {
|
||||||
|
id: string;
|
||||||
|
jid: string;
|
||||||
|
displayName: string | null;
|
||||||
|
status: string;
|
||||||
|
platform: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AccountsPage() {
|
||||||
|
const apiUrl = process.env.API_URL ?? 'http://localhost:3001';
|
||||||
|
let accounts: Account[] = [];
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiUrl}/accounts`, { cache: 'no-store' });
|
||||||
|
if (res.ok) accounts = await res.json();
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<h1 className="text-xl font-semibold mb-6">Accounts</h1>
|
||||||
|
{accounts.length === 0 ? (
|
||||||
|
<p className="text-gray-500">No accounts found.</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{accounts.map((a) => (
|
||||||
|
<AccountCard key={a.id} account={a} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,6 +19,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
<Link href="/groups" className="rounded px-3 py-2 text-sm hover:bg-gray-100">
|
<Link href="/groups" className="rounded px-3 py-2 text-sm hover:bg-gray-100">
|
||||||
Groups & Routes
|
Groups & Routes
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/accounts" className="rounded px-3 py-2 text-sm hover:bg-gray-100">
|
||||||
|
Accounts
|
||||||
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
<main className="flex-1 overflow-auto p-6">{children}</main>
|
<main className="flex-1 overflow-auto p-6">{children}</main>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user