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