- 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>
420 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|