Files
DynamoDNS/client/src/pages/api-tokens.tsx
T
alphaeusmote fac4492050 Update application name to DynamoDNS
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 9111ef36-26c8-4085-84ca-a35dc1fec1b5
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/7083d608-d6d3-4a6a-9a27-6286c5109627/7a5b96f8-9042-44cf-bea7-814540e9a60d.jpg
2025-04-10 02:09:58 +00:00

492 lines
18 KiB
TypeScript

import { useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { MainLayout } from "@/components/layouts/main-layout";
import { ApiToken, InsertApiToken, systemRoles } from "@shared/schema";
import { useAuth } from "@/hooks/use-auth";
import { useOrganization } from "@/context/organization-context";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription,
} from "@/components/ui/form";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Loader2, Key, MoreVertical, Calendar, Copy, Info } from "lucide-react";
import { formatDistanceToNow, format } from "date-fns";
// Token form schema
const tokenFormSchema = z.object({
name: z.string().min(1, "Token name is required"),
permissions: z.array(z.string()).min(1, "At least one permission is required"),
expiresAt: z.string().optional(),
});
export default function ApiTokensPage() {
const { toast } = useToast();
const { user } = useAuth();
const { currentOrganization } = useOrganization();
const [isAddTokenDialogOpen, setIsAddTokenDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [selectedToken, setSelectedToken] = useState<ApiToken | null>(null);
const [newToken, setNewToken] = useState<string | null>(null);
const [showTokenDialog, setShowTokenDialog] = useState(false);
// Fetch API tokens
const { data: tokens = [], isLoading } = useQuery<ApiToken[]>({
queryKey: ["/api/api-tokens", currentOrganization?.id],
enabled: !!currentOrganization?.id,
});
// Form for adding a token
const form = useForm<z.infer<typeof tokenFormSchema>>({
resolver: zodResolver(tokenFormSchema),
defaultValues: {
name: "",
permissions: ["readonly"],
expiresAt: undefined,
},
});
// Add token mutation
const addTokenMutation = useMutation({
mutationFn: async (data: z.infer<typeof tokenFormSchema>) => {
const tokenData: Partial<InsertApiToken> = {
name: data.name,
organizationId: currentOrganization?.id || 0,
permissions: data.permissions,
isActive: true,
};
if (data.expiresAt) {
tokenData.expiresAt = new Date(data.expiresAt);
}
const res = await apiRequest("POST", "/api/api-tokens", tokenData);
return await res.json();
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ["/api/api-tokens", currentOrganization?.id] });
setIsAddTokenDialogOpen(false);
setNewToken(data.token);
setShowTokenDialog(true);
form.reset();
toast({
title: "Token created",
description: "The API token has been successfully created.",
});
},
onError: (error) => {
toast({
title: "Failed to create token",
description: error.message,
variant: "destructive",
});
},
});
// Delete token mutation
const deleteTokenMutation = useMutation({
mutationFn: async (tokenId: number) => {
await apiRequest("DELETE", `/api/api-tokens/${tokenId}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/api-tokens", currentOrganization?.id] });
setIsDeleteDialogOpen(false);
setSelectedToken(null);
toast({
title: "Token deleted",
description: "The API token has been successfully deleted.",
});
},
onError: (error) => {
toast({
title: "Failed to delete token",
description: error.message,
variant: "destructive",
});
},
});
// Form submission handler
const onSubmit = (data: z.infer<typeof tokenFormSchema>) => {
addTokenMutation.mutate(data);
};
// Handle token deletion
const handleDeleteToken = (token: ApiToken) => {
setSelectedToken(token);
setIsDeleteDialogOpen(true);
};
// Copy token to clipboard
const copyTokenToClipboard = () => {
if (newToken) {
navigator.clipboard.writeText(newToken);
toast({
title: "Token copied",
description: "The API token has been copied to your clipboard.",
});
}
};
return (
<MainLayout
title="API Tokens"
description="Manage API tokens for programmatic access to DynamoDNS."
>
<div className="mb-6 flex justify-between items-center">
<div className="space-y-1">
<h2 className="text-xl font-semibold">Your API Tokens</h2>
<p className="text-sm text-muted-foreground">
Create and manage API tokens for secure access to the DynamoDNS API.
</p>
</div>
<Button onClick={() => setIsAddTokenDialogOpen(true)}>
<Key className="mr-2 h-4 w-4" />
Generate Token
</Button>
</div>
{/* Tokens Table */}
<Card>
<CardContent className="p-0">
{isLoading ? (
<div className="flex justify-center items-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : tokens.length === 0 ? (
<div className="text-center py-8">
<Key className="mx-auto h-12 w-12 text-muted-foreground" />
<h3 className="mt-4 text-lg font-medium">No API tokens found</h3>
<p className="mt-2 text-sm text-muted-foreground">
Create your first API token to enable programmatic access.
</p>
<div className="mt-6">
<Button onClick={() => setIsAddTokenDialogOpen(true)}>
Generate Token
</Button>
</div>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Token</TableHead>
<TableHead>Permissions</TableHead>
<TableHead>Expires</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tokens.map((token) => (
<TableRow key={token.id}>
<TableCell className="font-medium">{token.name}</TableCell>
<TableCell className="font-mono text-xs">
{token.token.substring(0, 8)}...
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{token.permissions.map((permission) => (
<Badge key={permission} variant="outline" className="text-xs">
{permission}
</Badge>
))}
</div>
</TableCell>
<TableCell>
{token.expiresAt ? (
<div className="flex items-center">
<Calendar className="mr-1 h-3 w-3 text-muted-foreground" />
<span
className="text-sm"
title={format(new Date(token.expiresAt), 'PPpp')}
>
{formatDistanceToNow(new Date(token.expiresAt), { addSuffix: true })}
</span>
</div>
) : (
<span className="text-sm text-muted-foreground">Never</span>
)}
</TableCell>
<TableCell>
{token.isActive ? (
<Badge variant="outline" className="bg-success/10 text-success border-success/20">
Active
</Badge>
) : (
<Badge variant="outline" className="bg-muted/10 text-muted-foreground">
Inactive
</Badge>
)}
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => handleDeleteToken(token)}
className="text-destructive focus:text-destructive"
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Add Token Dialog */}
<Dialog open={isAddTokenDialogOpen} onOpenChange={setIsAddTokenDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create API Token</DialogTitle>
<DialogDescription>
Generate a new API token for programmatic access to the DynamoDNS API.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Token Name</FormLabel>
<FormControl>
<Input placeholder="e.g., Production Server, Development, CI/CD" {...field} />
</FormControl>
<FormDescription>
A descriptive name to identify this token
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="permissions"
render={() => (
<FormItem>
<div className="mb-4">
<FormLabel className="text-base">Permissions</FormLabel>
<FormDescription>
Select the permissions for this token
</FormDescription>
</div>
{systemRoles.map((role) => (
<FormField
key={role}
control={form.control}
name="permissions"
render={({ field }) => {
return (
<FormItem
key={role}
className="flex flex-row items-start space-x-3 space-y-0"
>
<FormControl>
<Checkbox
checked={field.value?.includes(role)}
onCheckedChange={(checked) => {
return checked
? field.onChange([...field.value, role])
: field.onChange(
field.value?.filter(
(value) => value !== role
)
)
}}
/>
</FormControl>
<FormLabel className="text-sm font-normal">
{role === "admin" && "Administrator - Full access to all resources"}
{role === "manager" && "Manager - Can manage domains and records"}
{role === "user" && "User - Can manage records only"}
{role === "readonly" && "Read-only - Can only view resources"}
</FormLabel>
</FormItem>
)
}}
/>
))}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="expiresAt"
render={({ field }) => (
<FormItem>
<FormLabel>Expiration (Optional)</FormLabel>
<FormControl>
<Input
type="datetime-local"
{...field}
value={field.value || ""}
/>
</FormControl>
<FormDescription>
Leave blank for a non-expiring token
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="submit"
disabled={addTokenMutation.isPending}
>
{addTokenMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Generating...
</>
) : (
"Generate Token"
)}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
{/* New Token Display Dialog */}
<Dialog open={showTokenDialog} onOpenChange={setShowTokenDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Your New API Token</DialogTitle>
<DialogDescription>
Please copy your API token now. For security reasons, it won't be displayed again.
</DialogDescription>
</DialogHeader>
<div className="bg-muted p-4 rounded-md relative">
<div className="font-mono text-sm break-all">{newToken}</div>
<Button
variant="ghost"
size="icon"
className="absolute top-2 right-2"
onClick={copyTokenToClipboard}
>
<Copy className="h-4 w-4" />
</Button>
</div>
<div className="flex items-start mt-2 p-4 bg-amber-50 dark:bg-amber-950/30 text-amber-800 dark:text-amber-200 rounded-md">
<Info className="h-5 w-5 mr-2 flex-shrink-0 mt-0.5" />
<div className="text-sm">
<strong>Security note:</strong> Store this token securely. It provides access to your account based on the permissions you selected.
</div>
</div>
<DialogFooter>
<Button onClick={() => setShowTokenDialog(false)}>
I've Copied My Token
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Revoke API Token</AlertDialogTitle>
<AlertDialogDescription>
This will permanently revoke the API token <strong>{selectedToken?.name}</strong>.
Any applications using this token will no longer be able to access the API.
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => selectedToken && deleteTokenMutation.mutate(selectedToken.id)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={deleteTokenMutation.isPending}
>
{deleteTokenMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Revoking...
</>
) : (
"Revoke Token"
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</MainLayout>
);
}