Feat(Fix): SSH Keys-Expiry+Log; Department+Principal Link; CA Keys mgmt;
- Fix Login nav to /profile or /
This commit is contained in:
@@ -0,0 +1,376 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import {
|
||||
Search,
|
||||
User,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Key,
|
||||
Loader2,
|
||||
Plus,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { api, User as ApiUser, SSHKey, ApiError } from "@/lib/api";
|
||||
|
||||
function formatDate(d: string | null) {
|
||||
if (!d) return "—";
|
||||
return new Date(d).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const { toast } = useToast();
|
||||
|
||||
// User list
|
||||
const [users, setUsers] = useState<ApiUser[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pages, setPages] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
|
||||
// Debounce search
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedSearch(search), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [search]);
|
||||
|
||||
// User detail drawer
|
||||
const [selectedUser, setSelectedUser] = useState<ApiUser | null>(null);
|
||||
const [userSshKeys, setUserSshKeys] = useState<SSHKey[]>([]);
|
||||
const [isDrawerLoading, setIsDrawerLoading] = useState(false);
|
||||
|
||||
// Admin add SSH key dialog
|
||||
const [showAddKey, setShowAddKey] = useState(false);
|
||||
const [addKeyPublicKey, setAddKeyPublicKey] = useState("");
|
||||
const [addKeyDescription, setAddKeyDescription] = useState("");
|
||||
const [isAddingKey, setIsAddingKey] = useState(false);
|
||||
const [addKeyError, setAddKeyError] = useState<string | null>(null);
|
||||
|
||||
// ── Fetch users ─────────────────────────────────────────────────────────────
|
||||
const fetchUsers = useCallback(async (q: string, pg: number) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const params: Record<string, string> = { page: String(pg), per_page: "50" };
|
||||
if (q) params.q = q;
|
||||
const data = await api.admin.listUsers(params);
|
||||
setUsers(data.users);
|
||||
setTotal(data.count);
|
||||
setPages(data.pages);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.code === 403) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Access denied",
|
||||
description: "Admin or owner role required to view all users.",
|
||||
});
|
||||
} else {
|
||||
toast({ variant: "destructive", title: "Failed to load users" });
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
fetchUsers(debouncedSearch, 1);
|
||||
}, [debouncedSearch, fetchUsers]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers(debouncedSearch, page);
|
||||
}, [page]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ── Open user drawer ─────────────────────────────────────────────────────────
|
||||
const openUserDrawer = async (user: ApiUser) => {
|
||||
setSelectedUser(user);
|
||||
setUserSshKeys([]);
|
||||
setIsDrawerLoading(true);
|
||||
try {
|
||||
const data = await api.admin.getUser(user.id);
|
||||
setUserSshKeys(data.ssh_keys);
|
||||
} catch {
|
||||
// Non-fatal — drawer still shows basic user info
|
||||
} finally {
|
||||
setIsDrawerLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Admin add SSH key ────────────────────────────────────────────────────────
|
||||
const handleAddKey = async () => {
|
||||
if (!selectedUser) return;
|
||||
setAddKeyError(null);
|
||||
if (!addKeyPublicKey.trim()) {
|
||||
setAddKeyError("Public key is required");
|
||||
return;
|
||||
}
|
||||
setIsAddingKey(true);
|
||||
try {
|
||||
const key = await api.ssh.adminAddKey(selectedUser.id, addKeyPublicKey.trim(), addKeyDescription.trim() || undefined);
|
||||
setUserSshKeys((prev) => [...prev, key]);
|
||||
toast({ title: "SSH key added", description: `Key added for ${selectedUser.email}` });
|
||||
setShowAddKey(false);
|
||||
setAddKeyPublicKey("");
|
||||
setAddKeyDescription("");
|
||||
} catch (err) {
|
||||
setAddKeyError(err instanceof ApiError ? err.message : "Failed to add key");
|
||||
} finally {
|
||||
setIsAddingKey(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">User Management</h1>
|
||||
<p className="page-description">
|
||||
View and manage users across your organizations
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search bar */}
|
||||
<div className="relative mb-4">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
placeholder="Search by name or email…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<User className="w-4 h-4" />
|
||||
Users
|
||||
{!isLoading && <Badge variant="secondary" className="ml-1">{total}</Badge>}
|
||||
</CardTitle>
|
||||
<CardDescription>Click a user to view details and manage their SSH keys</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<User className="w-10 h-10 mx-auto mb-3 opacity-40" />
|
||||
<p className="text-sm">{debouncedSearch ? "No users match your search" : "No users found"}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{users.map((user) => (
|
||||
<button
|
||||
key={user.id}
|
||||
className="w-full flex items-center justify-between p-3 rounded-lg border hover:bg-accent/50 transition-colors text-left"
|
||||
onClick={() => openUserDrawer(user)}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<User className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{user.full_name || user.email}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{(user as ApiUser & { activated?: boolean }).activated === false && (
|
||||
<Badge variant="outline" className="text-xs text-amber-600 border-amber-300">
|
||||
Not activated
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{pages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Page {page} of {pages} · {total} total
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.min(pages, p + 1))}
|
||||
disabled={page === pages}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── User detail drawer ─────────────────────────────────────────────────── */}
|
||||
<Sheet open={!!selectedUser} onOpenChange={(open) => { if (!open) setSelectedUser(null); }}>
|
||||
<SheetContent className="w-full sm:max-w-lg overflow-y-auto">
|
||||
{selectedUser && (
|
||||
<>
|
||||
<SheetHeader className="mb-4">
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
<User className="w-5 h-5" />
|
||||
{selectedUser.full_name || selectedUser.email}
|
||||
</SheetTitle>
|
||||
<SheetDescription>{selectedUser.email}</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{/* Basic info */}
|
||||
<div className="space-y-3 mb-6">
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<span className="text-muted-foreground">Joined</span>
|
||||
<span>{formatDate(selectedUser.created_at)}</span>
|
||||
<span className="text-muted-foreground">Activated</span>
|
||||
<span className="flex items-center gap-1">
|
||||
{(selectedUser as ApiUser & { activated?: boolean }).activated === false ? (
|
||||
<><XCircle className="w-4 h-4 text-amber-500" /> No</>
|
||||
) : (
|
||||
<><CheckCircle className="w-4 h-4 text-green-500" /> Yes</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SSH Keys section */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
<Key className="w-4 h-4" />
|
||||
SSH Keys
|
||||
</h3>
|
||||
<Button size="sm" variant="outline" onClick={() => setShowAddKey(true)}>
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
Add key
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isDrawerLoading ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : userSshKeys.length === 0 ? (
|
||||
<div className="text-center py-6 text-muted-foreground text-sm">
|
||||
No SSH keys registered
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{userSshKeys.map((k) => (
|
||||
<div key={k.id} className="p-3 border rounded-lg text-sm">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium">{k.description || <em className="text-muted-foreground">No description</em>}</span>
|
||||
{k.verified ? (
|
||||
<Badge className="bg-green-500/10 text-green-600 border-0 text-xs">
|
||||
<CheckCircle className="w-3 h-3 mr-1" />Verified
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs text-amber-600 border-amber-300">
|
||||
Unverified
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground font-mono truncate">
|
||||
{k.fingerprint ?? k.public_key.slice(0, 64) + "…"}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Added {formatDate(k.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{/* ── Admin add SSH key dialog ───────────────────────────────────────────── */}
|
||||
<Dialog open={showAddKey} onOpenChange={(open) => { setShowAddKey(open); setAddKeyError(null); }}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add SSH Key for {selectedUser?.email}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add an SSH public key on behalf of this user (admin action).
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
{addKeyError && (
|
||||
<div className="p-3 rounded-md bg-destructive/10 text-destructive text-sm">{addKeyError}</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label>Public key</Label>
|
||||
<Textarea
|
||||
placeholder="ssh-ed25519 AAAA..."
|
||||
value={addKeyPublicKey}
|
||||
onChange={(e) => setAddKeyPublicKey(e.target.value)}
|
||||
className="font-mono text-xs min-h-[80px]"
|
||||
disabled={isAddingKey}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Description <span className="text-muted-foreground">(optional)</span></Label>
|
||||
<Input
|
||||
placeholder="Laptop key"
|
||||
value={addKeyDescription}
|
||||
onChange={(e) => setAddKeyDescription(e.target.value)}
|
||||
disabled={isAddingKey}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowAddKey(false)} disabled={isAddingKey}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleAddKey} disabled={isAddingKey || !addKeyPublicKey.trim()}>
|
||||
{isAddingKey && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
Add key
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user