feat: add network management page and inline accordion device details

This commit is contained in:
Ubuntu
2026-05-07 19:59:21 +00:00
parent 9a5e023ec3
commit 16fb2b4e41
10 changed files with 4301 additions and 357 deletions
+88 -2
View File
@@ -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">