feat: add network management page and inline accordion device details
This commit is contained in:
@@ -19,8 +19,12 @@ import {
|
||||
KeyRound,
|
||||
Link2,
|
||||
Unlink,
|
||||
Award,
|
||||
ExternalLink,
|
||||
Lock,
|
||||
FileKey,
|
||||
} from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -56,7 +60,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { api, User as ApiUser, SSHKey, ApiError, AdminMfaMethod, AdminLinkedAccount } from "@/lib/api";
|
||||
import { api, User as ApiUser, SSHKey, ApiError, AdminMfaMethod, AdminLinkedAccount, AdminUserSshCertificate } from "@/lib/api";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
function formatDate(d: string | null) {
|
||||
@@ -99,6 +103,7 @@ function RoleBadge({ role }: { role: string }) {
|
||||
export default function AdminUsersPage() {
|
||||
const { toast } = useToast();
|
||||
const { user: currentUser } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// User list
|
||||
const [users, setUsers] = useState<ApiUser[]>([]);
|
||||
@@ -162,6 +167,11 @@ export default function AdminUsersPage() {
|
||||
const [passwordResetError, setPasswordResetError] = useState<string | null>(null);
|
||||
const [isResettingPassword, setIsResettingPassword] = useState(false);
|
||||
|
||||
// SSH Certificates summary
|
||||
const [userSshCerts, setUserSshCerts] = useState<AdminUserSshCertificate[]>([]);
|
||||
const [sshCertsCount, setSshCertsCount] = useState(0);
|
||||
const [isSshCertsLoading, setIsSshCertsLoading] = useState(false);
|
||||
|
||||
// ── Fetch users ─────────────────────────────────────────────────────────────
|
||||
const fetchUsers = useCallback(async (q: string, pg: number) => {
|
||||
setIsLoading(true);
|
||||
@@ -203,12 +213,16 @@ export default function AdminUsersPage() {
|
||||
setUserMfaMethods([]);
|
||||
setUserLinkedAccounts([]);
|
||||
setTotalAuthMethods(0);
|
||||
setUserSshCerts([]);
|
||||
setSshCertsCount(0);
|
||||
setIsSshCertsLoading(true);
|
||||
setIsDrawerLoading(true);
|
||||
try {
|
||||
const [userData, mfaData, linkedData] = await Promise.allSettled([
|
||||
const [userData, mfaData, linkedData, certsData] = await Promise.allSettled([
|
||||
api.admin.getUser(user.id),
|
||||
api.admin.getUserMfa(user.id),
|
||||
api.admin.getUserLinkedAccounts(user.id),
|
||||
api.admin.getUserSshCertificates(user.id, { per_page: 5 }),
|
||||
]);
|
||||
if (userData.status === "fulfilled") setUserSshKeys(userData.value.ssh_keys);
|
||||
if (mfaData.status === "fulfilled") setUserMfaMethods(mfaData.value.mfa_methods);
|
||||
@@ -216,10 +230,18 @@ export default function AdminUsersPage() {
|
||||
setUserLinkedAccounts(linkedData.value.linked_accounts);
|
||||
setTotalAuthMethods(linkedData.value.total_auth_methods);
|
||||
}
|
||||
if (certsData.status === "fulfilled") {
|
||||
setUserSshCerts(certsData.value.certificates);
|
||||
setSshCertsCount(certsData.value.count);
|
||||
} else {
|
||||
setUserSshCerts([]);
|
||||
setSshCertsCount(0);
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal
|
||||
} finally {
|
||||
setIsDrawerLoading(false);
|
||||
setIsSshCertsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -929,6 +951,70 @@ export default function AdminUsersPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SSH Certificates summary */}
|
||||
{selectedUser.id !== currentUser?.id && (
|
||||
<div className="mt-6 p-4 border rounded-lg space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
<Award className="w-4 h-4" />
|
||||
SSH Certificates
|
||||
</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
setSelectedUser(null);
|
||||
navigate(`/org/members/${selectedUser.id}`);
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="w-3 h-3 mr-1" />
|
||||
Full details
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isDrawerLoading || isSshCertsLoading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : sshCertsCount === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No SSH certificates issued.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* Total count badge */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<FileKey className="w-3 h-3 mr-1" />
|
||||
{sshCertsCount} certificate{sshCertsCount !== 1 ? "s" : ""}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Recent certificates (up to 5) */}
|
||||
<div className="space-y-1.5">
|
||||
{userSshCerts.slice(0, 5).map((cert) => (
|
||||
<div key={cert.id} className="flex items-center justify-between p-2 border rounded text-xs">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="font-mono truncate">{cert.key_id}</span>
|
||||
{cert.revoked ? (
|
||||
<Badge variant="destructive" className="text-[10px] px-1 py-0">Revoked</Badge>
|
||||
) : !cert.is_valid ? (
|
||||
<Badge variant="outline" className="text-[10px] px-1 py-0 text-muted-foreground">Expired</Badge>
|
||||
) : (
|
||||
<Badge className="bg-green-500/10 text-green-600 border-0 text-[10px] px-1 py-0">Active</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground flex-shrink-0 ml-2">
|
||||
{cert.principals.slice(0, 2).join(", ")}
|
||||
{cert.principals.length > 2 && ` +${cert.principals.length - 2}`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Danger zone — Hard delete */}
|
||||
{selectedUser.id !== currentUser?.id && (
|
||||
<div className="mt-6 p-4 border border-destructive/30 rounded-lg space-y-3">
|
||||
|
||||
Reference in New Issue
Block a user