Files
honeyDueAPI/admin/src/app/(dashboard)/devices/page.tsx
Trey t bf309f5ff9 Move admin dashboard to admin.myhoneydue.com subdomain
- Remove Next.js basePath "/admin" — admin now serves at root
- Update all internal links from /admin/xxx to /xxx
- Change Go proxy to host-based routing: admin subdomain requests
  proxy to Next.js, /admin/* redirects to main web app
- Update timeout middleware skipper for admin subdomain

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:35:31 -06:00

420 lines
15 KiB
TypeScript

'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import Link from 'next/link';
import { Smartphone, Trash2, Search, ChevronLeft, ChevronRight, Apple, Tablet } from 'lucide-react';
import { toast } from 'sonner';
import { devicesApi, type APNSDevice, type GCMDevice, type DeviceListParams } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card';
export default function DevicesPage() {
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<'apns' | 'gcm'>('apns');
const [params, setParams] = useState<DeviceListParams>({
page: 1,
per_page: 20,
});
const [search, setSearch] = useState('');
const [selectedRows, setSelectedRows] = useState<number[]>([]);
const { data: stats } = useQuery({
queryKey: ['device-stats'],
queryFn: () => devicesApi.getStats(),
});
const { data: apnsData, isLoading: apnsLoading } = useQuery({
queryKey: ['devices-apns', params],
queryFn: () => devicesApi.listAPNS(params),
enabled: activeTab === 'apns',
});
const { data: gcmData, isLoading: gcmLoading } = useQuery({
queryKey: ['devices-gcm', params],
queryFn: () => devicesApi.listGCM(params),
enabled: activeTab === 'gcm',
});
const updateAPNSMutation = useMutation({
mutationFn: ({ id, active }: { id: number; active: boolean }) =>
devicesApi.updateAPNS(id, { active }),
onSuccess: () => {
toast.success('Device updated');
queryClient.invalidateQueries({ queryKey: ['devices-apns'] });
queryClient.invalidateQueries({ queryKey: ['device-stats'] });
},
});
const updateGCMMutation = useMutation({
mutationFn: ({ id, active }: { id: number; active: boolean }) =>
devicesApi.updateGCM(id, { active }),
onSuccess: () => {
toast.success('Device updated');
queryClient.invalidateQueries({ queryKey: ['devices-gcm'] });
queryClient.invalidateQueries({ queryKey: ['device-stats'] });
},
});
const deleteAPNSMutation = useMutation({
mutationFn: (id: number) => devicesApi.deleteAPNS(id),
onSuccess: () => {
toast.success('Device deleted');
queryClient.invalidateQueries({ queryKey: ['devices-apns'] });
queryClient.invalidateQueries({ queryKey: ['device-stats'] });
},
});
const deleteGCMMutation = useMutation({
mutationFn: (id: number) => devicesApi.deleteGCM(id),
onSuccess: () => {
toast.success('Device deleted');
queryClient.invalidateQueries({ queryKey: ['devices-gcm'] });
queryClient.invalidateQueries({ queryKey: ['device-stats'] });
},
});
const bulkDeleteAPNSMutation = useMutation({
mutationFn: (ids: number[]) => devicesApi.bulkDeleteAPNS(ids),
onSuccess: () => {
toast.success('Devices deleted');
queryClient.invalidateQueries({ queryKey: ['devices-apns'] });
queryClient.invalidateQueries({ queryKey: ['device-stats'] });
setSelectedRows([]);
},
});
const bulkDeleteGCMMutation = useMutation({
mutationFn: (ids: number[]) => devicesApi.bulkDeleteGCM(ids),
onSuccess: () => {
toast.success('Devices deleted');
queryClient.invalidateQueries({ queryKey: ['devices-gcm'] });
queryClient.invalidateQueries({ queryKey: ['device-stats'] });
setSelectedRows([]);
},
});
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setParams({ ...params, search, page: 1 });
};
const data = activeTab === 'apns' ? apnsData : gcmData;
const isLoading = activeTab === 'apns' ? apnsLoading : gcmLoading;
const totalPages = data ? Math.ceil(data.total / (params.per_page || 20)) : 0;
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Push Notification Devices</h1>
<p className="text-muted-foreground">
Manage registered iOS and Android devices
</p>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-3 gap-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Apple className="h-4 w-4" />
iOS Devices
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats?.apns.total || 0}</div>
<p className="text-xs text-muted-foreground">
{stats?.apns.active || 0} active
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Tablet className="h-4 w-4" />
Android Devices
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats?.gcm.total || 0}</div>
<p className="text-xs text-muted-foreground">
{stats?.gcm.active || 0} active
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Smartphone className="h-4 w-4" />
Total Devices
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats?.total || 0}</div>
</CardContent>
</Card>
</div>
{/* Tabs */}
<div className="flex gap-2">
<Button
variant={activeTab === 'apns' ? 'default' : 'outline'}
onClick={() => { setActiveTab('apns'); setSelectedRows([]); setParams({ ...params, page: 1 }); }}
>
<Apple className="mr-2 h-4 w-4" />
iOS (APNS)
</Button>
<Button
variant={activeTab === 'gcm' ? 'default' : 'outline'}
onClick={() => { setActiveTab('gcm'); setSelectedRows([]); setParams({ ...params, page: 1 }); }}
>
<Tablet className="mr-2 h-4 w-4" />
Android (FCM)
</Button>
</div>
{/* Search and Bulk Actions */}
<div className="flex items-center justify-between gap-4">
<form onSubmit={handleSearch} className="flex gap-2 flex-1 max-w-md">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search by name, device ID, or user..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Button type="submit" variant="secondary">
Search
</Button>
</form>
{selectedRows.length > 0 && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">
<Trash2 className="mr-2 h-4 w-4" />
Delete Selected ({selectedRows.length})
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Selected Devices?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete {selectedRows.length} device(s).
Users will need to re-register for push notifications.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => activeTab === 'apns'
? bulkDeleteAPNSMutation.mutate(selectedRows)
: bulkDeleteGCMMutation.mutate(selectedRows)
}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
{/* Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">
<Checkbox
checked={data?.data && data.data.length > 0 && selectedRows.length === data.data.length}
onCheckedChange={(checked) => {
if (checked && data?.data) {
setSelectedRows(data.data.map((d) => d.id));
} else {
setSelectedRows([]);
}
}}
/>
</TableHead>
<TableHead>ID</TableHead>
<TableHead>Name</TableHead>
<TableHead>User</TableHead>
<TableHead>Device ID</TableHead>
<TableHead>Device Token</TableHead>
<TableHead>Active</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8">
Loading...
</TableCell>
</TableRow>
) : data?.data?.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8">
No devices found
</TableCell>
</TableRow>
) : (
data?.data?.map((device) => (
<TableRow key={device.id}>
<TableCell>
<Checkbox
checked={selectedRows.includes(device.id)}
onCheckedChange={(checked) => {
if (checked) {
setSelectedRows([...selectedRows, device.id]);
} else {
setSelectedRows(selectedRows.filter((id) => id !== device.id));
}
}}
/>
</TableCell>
<TableCell className="font-mono text-sm text-muted-foreground">{device.id}</TableCell>
<TableCell className="font-medium">{device.name || 'Unknown'}</TableCell>
<TableCell>
{device.user_id ? (
<Link
href={`/users/${device.user_id}`}
className="text-blue-600 hover:underline"
>
{device.username || `User #${device.user_id}`}
</Link>
) : (
<span className="text-muted-foreground">Anonymous</span>
)}
</TableCell>
<TableCell>
<code className="text-xs bg-muted px-2 py-1 rounded">
{device.device_id.substring(0, 12)}...
</code>
</TableCell>
<TableCell>
<code className="text-xs bg-muted px-2 py-1 rounded break-all max-w-[200px] block">
{device.registration_id}
</code>
</TableCell>
<TableCell>
<Switch
checked={device.active}
onCheckedChange={(checked) =>
activeTab === 'apns'
? updateAPNSMutation.mutate({ id: device.id, active: checked })
: updateGCMMutation.mutate({ id: device.id, active: checked })
}
/>
</TableCell>
<TableCell>
{new Date(device.date_created).toLocaleDateString()}
</TableCell>
<TableCell>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Device?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete this device registration.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() =>
activeTab === 'apns'
? deleteAPNSMutation.mutate(device.id)
: deleteGCMMutation.mutate(device.id)
}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{data && totalPages > 1 && (
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
Showing {((params.page || 1) - 1) * (params.per_page || 20) + 1} to{' '}
{Math.min((params.page || 1) * (params.per_page || 20), data.total)} of {data.total}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={(params.page || 1) <= 1}
onClick={() => setParams({ ...params, page: (params.page || 1) - 1 })}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={(params.page || 1) >= totalPages}
onClick={() => setParams({ ...params, page: (params.page || 1) + 1 })}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
);
}