Various QOL updates
This commit is contained in:
+15
-3
@@ -2123,16 +2123,28 @@ export interface DeviceNetworkMembership {
|
||||
device_id: string;
|
||||
portal_network_id: string;
|
||||
user_network_approval_id: string | null;
|
||||
state: MembershipState;
|
||||
active: boolean;
|
||||
status: ApprovalState;
|
||||
grant_type: ApprovalGrantType;
|
||||
granted_by_user_id: string | null;
|
||||
justification: string | null;
|
||||
join_seen: boolean;
|
||||
currently_authorized: boolean;
|
||||
approved_for_activation: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at: string | null;
|
||||
active_session: ActivationSession | null;
|
||||
}
|
||||
|
||||
export function deriveMembershipState(status: ApprovalState, active: boolean): MembershipState {
|
||||
if (active) return "active_authorized";
|
||||
if (status === "approved") return "approved_inactive";
|
||||
if (status === "pending") return "pending_manager_approval";
|
||||
if (status === "rejected") return "rejected";
|
||||
if (status === "revoked") return "revoked";
|
||||
if (status === "suspended") return "suspended";
|
||||
return "pending_manager_approval";
|
||||
}
|
||||
|
||||
export interface EnrichedMembership {
|
||||
id: string;
|
||||
user_id: string;
|
||||
|
||||
@@ -1363,6 +1363,34 @@ export default function UserManagementPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ── Suspend confirmation dialog ─────────────────────────────────────── */}
|
||||
<Dialog open={showSuspendConfirm} onOpenChange={setShowSuspendConfirm}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-red-600">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
Suspend account?
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<strong>{user?.full_name || user?.email}</strong> will be blocked from requesting SSH certificates. You can restore their access at any time.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowSuspendConfirm(false)} disabled={isSuspending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleSuspend}
|
||||
disabled={isSuspending}
|
||||
>
|
||||
{isSuspending && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
Suspend
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ── Admin add SSH key dialog ──────────────────────────────────────────── */}
|
||||
<Dialog
|
||||
open={showAddKey}
|
||||
|
||||
@@ -362,6 +362,147 @@ $ systemctl restart sshd`}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Deployment Guide */}
|
||||
<section className="py-16 lg:py-24 bg-muted/30">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-12">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-accent/10 text-accent text-sm font-medium mb-4">
|
||||
<Terminal className="h-4 w-4" />
|
||||
Deployment Guide
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-foreground mb-4">
|
||||
Deploy to Your Servers
|
||||
</h2>
|
||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
|
||||
One-time setup per server. The script below installs the CA key, configures
|
||||
principal-based access, and reloads SSH — all in a single idempotent run.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-accent text-accent-foreground flex items-center justify-center text-sm font-bold">1</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base font-semibold mb-1">Get your CA public key</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
In the Secuird dashboard, go to <strong>Certificate Authorities</strong> and
|
||||
copy the <strong>User CA</strong> public key from the detail card.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-accent text-accent-foreground flex items-center justify-center text-sm font-bold">2</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base font-semibold mb-1">Decide the Unix user and principal</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Each server has a local Unix user (e.g. <code className="font-mono text-xs">ubuntu</code>, <code className="font-mono text-xs">deploy</code>, <code className="font-mono text-xs">root</code>)
|
||||
that SSH sessions connect to. Choose which <strong>principal</strong> (from your Secuird configuration) should be
|
||||
allowed to log in as that user.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-accent text-accent-foreground flex items-center justify-center text-sm font-bold">3</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base font-semibold mb-1">Run the setup script</h3>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
SSH into the server and run the script below as <strong>root</strong>. Paste your
|
||||
CA public key, set the Unix user and principal, then execute.
|
||||
</p>
|
||||
<Card>
|
||||
<div className="bg-muted/50 px-4 py-2 border-b flex items-center gap-2">
|
||||
<div className="h-3 w-3 rounded-full bg-destructive/60" />
|
||||
<div className="h-3 w-3 rounded-full bg-warning/60" />
|
||||
<div className="h-3 w-3 rounded-full bg-success/60" />
|
||||
<span className="text-xs text-muted-foreground ml-2 font-mono">deploy.sh</span>
|
||||
</div>
|
||||
<CardContent className="p-0">
|
||||
<pre className="p-4 text-sm font-mono text-foreground overflow-x-auto">
|
||||
<code>
|
||||
{`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
CA_KEY='<Your CA public key>'
|
||||
UNIX_USER="ubuntu" # ← change to the server's unix user
|
||||
PRINCIPAL="<Your principal>" # ← change to the principal for this user
|
||||
|
||||
CA_FILE="/etc/ssh/trusted_user_ca"
|
||||
PRINCIPALS_DIR="/etc/ssh/auth_principals"
|
||||
SSHD_DROP_IN="/etc/ssh/sshd_config.d/99-ca-auth.conf"
|
||||
|
||||
if [[ "$(id -u)" -ne 0 ]]; then
|
||||
echo "error: must be run as root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
install -m 0644 -o root -g root /dev/null "\${CA_FILE}"
|
||||
echo "\${CA_KEY}" > "\${CA_FILE}"
|
||||
|
||||
install -d -m 0755 -o root -g root "\${PRINCIPALS_DIR}"
|
||||
install -m 0644 -o root -g root /dev/null "\${PRINCIPALS_DIR}/\${UNIX_USER}"
|
||||
echo "\${PRINCIPAL}" > "\${PRINCIPALS_DIR}/\${UNIX_USER}"
|
||||
|
||||
install -d -m 0755 -o root -g root "/etc/ssh/sshd_config.d"
|
||||
install -m 0600 -o root -g root /dev/null "\${SSHD_DROP_IN}"
|
||||
cat > "\${SSHD_DROP_IN}" <<EOF
|
||||
TrustedUserCAKeys \${CA_FILE}
|
||||
AuthorizedPrincipalsFile \${PRINCIPALS_DIR}/%u
|
||||
EOF
|
||||
|
||||
if sshd -t; then
|
||||
systemctl reload ssh 2>/dev/null || systemctl reload sshd
|
||||
echo "done — CA trust and principal '\${PRINCIPAL}' configured for '\${UNIX_USER}'"
|
||||
else
|
||||
echo "error: sshd configuration test failed — SSH was NOT reloaded" >&2
|
||||
exit 1
|
||||
fi`}
|
||||
</code>
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-accent text-accent-foreground flex items-center justify-center text-sm font-bold">4</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base font-semibold mb-1">Verify the configuration</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The script validates <code className="font-mono text-xs">sshd -t</code> before reloading — if you see
|
||||
<strong>"done"</strong> at the end, everything is working. To double-check, run:
|
||||
</p>
|
||||
<pre className="mt-2 p-3 bg-muted rounded text-xs font-mono text-foreground overflow-x-auto">
|
||||
<code>{`ssh -T user@your-server # should succeed without a password prompt`}</code>
|
||||
</pre>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Repeat on every server. Once the CA key is trusted, <strong>any</strong> user with a valid
|
||||
Secuird-signed certificate for the matching principal can connect — no more distributing
|
||||
individual SSH keys to each server.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Deep Dive */}
|
||||
<section className="py-16 lg:py-24 bg-muted/30">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
|
||||
@@ -108,6 +108,64 @@ function MembershipStateBadge({ state }: { state: MembershipState }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ApprovedBadge({ approved }: { approved: boolean }) {
|
||||
if (approved) {
|
||||
return (
|
||||
<Badge className="text-xs bg-green-500/10 text-green-700 border-green-300">
|
||||
<CheckCircle className="w-3 h-3 mr-1" />Approved
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="outline" className="text-xs text-muted-foreground">
|
||||
<XCircle className="w-3 h-3 mr-1" />Not Approved
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function ActiveBadge({ active }: { active: boolean }) {
|
||||
if (active) {
|
||||
return (
|
||||
<Badge className="text-xs bg-green-500/15 text-green-700 border-green-400">
|
||||
<Zap className="w-3 h-3 mr-1" />Active
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="outline" className="text-xs text-muted-foreground">
|
||||
<ZapOff className="w-3 h-3 mr-1" />Inactive
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionProgress({ session }: { session: ActivationSession }) {
|
||||
const now = Date.now();
|
||||
const expires = new Date(session.expires_at).getTime();
|
||||
const created = new Date(session.authenticated_at).getTime();
|
||||
const total = expires - created;
|
||||
const elapsed = now - created;
|
||||
const ratio = Math.min(Math.max(elapsed / total, 0), 1);
|
||||
const remaining = Math.max(expires - now, 0);
|
||||
const remainingMin = Math.floor(remaining / 60000);
|
||||
const barColor = ratio < 0.5 ? "bg-green-500" : ratio < 0.8 ? "bg-yellow-500" : "bg-red-500";
|
||||
|
||||
const remainingText = remainingMin >= 60
|
||||
? `${Math.floor(remainingMin / 60)}h ${remainingMin % 60}m remaining`
|
||||
: `${remainingMin}m remaining`;
|
||||
|
||||
return (
|
||||
<div className="space-y-1 w-full">
|
||||
<div className="h-1.5 w-full rounded-full bg-secondary overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full transition-all rounded-full", barColor)}
|
||||
style={{ width: `${ratio * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{remainingText}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ApprovalStateBadge({ state }: { state: ApprovalState }) {
|
||||
const config: Record<ApprovalState, { color: string; icon: React.ReactNode; label: string }> = {
|
||||
pending: { color: "bg-yellow-500/10 text-yellow-600 border-yellow-200", icon: <Clock className="w-3 h-3 mr-1" />, label: "Pending" },
|
||||
@@ -468,7 +526,7 @@ export default function DevicesPage() {
|
||||
<div className="divide-y">
|
||||
{filteredDevices.map((device) => {
|
||||
const activeCount = memberships.filter(
|
||||
(m) => m.device_id === device.id && m.currently_authorized
|
||||
(m) => m.device_id === device.id && m.active
|
||||
).length;
|
||||
const isExpanded = expandedDeviceId === device.id;
|
||||
const deviceMemberships = memberships.filter((m) => m.device_id === device.id);
|
||||
@@ -576,12 +634,13 @@ export default function DevicesPage() {
|
||||
return (
|
||||
<div key={m.id} className="p-3 border rounded-lg space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium">{network?.name || m.portal_network_id}</span>
|
||||
<MembershipStateBadge state={m.state} />
|
||||
<ApprovedBadge approved={m.status === "approved"} />
|
||||
<ActiveBadge active={m.active} />
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{m.approved_for_activation && !m.currently_authorized && (
|
||||
{m.status === "approved" && !m.active && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -593,7 +652,7 @@ export default function DevicesPage() {
|
||||
Activate
|
||||
</Button>
|
||||
)}
|
||||
{m.currently_authorized && (
|
||||
{m.active && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -608,9 +667,9 @@ export default function DevicesPage() {
|
||||
</div>
|
||||
</div>
|
||||
{session && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Clock className="w-3 h-3" />
|
||||
Session expires: {formatExpiry(session.expires_at)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||
<SessionProgress session={session} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
@@ -734,7 +793,7 @@ export default function DevicesPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{network.request_mode === "open" && !hasMembership && (
|
||||
{network.request_mode === "open" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -748,7 +807,7 @@ export default function DevicesPage() {
|
||||
<Plus className="w-3 h-3" /> Join
|
||||
</Button>
|
||||
)}
|
||||
{network.request_mode === "approval_required" && !hasMembership && (
|
||||
{network.request_mode === "approval_required" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -784,10 +843,11 @@ export default function DevicesPage() {
|
||||
{devices.find((d) => d.id === m.device_id)?.device_nickname ||
|
||||
devices.find((d) => d.id === m.device_id)?.node_id}
|
||||
</span>
|
||||
<MembershipStateBadge state={m.state} />
|
||||
<ApprovedBadge approved={m.status === "approved"} />
|
||||
<ActiveBadge active={m.active} />
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{m.approved_for_activation && !m.currently_authorized && (
|
||||
{m.status === "approved" && !m.active && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -799,7 +859,7 @@ export default function DevicesPage() {
|
||||
Activate
|
||||
</Button>
|
||||
)}
|
||||
{m.currently_authorized && (
|
||||
{m.active && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -887,7 +947,7 @@ export default function DevicesPage() {
|
||||
const dev = devices.find((d) => d.id === m.device_id);
|
||||
return (
|
||||
<Badge key={m.id} variant="outline" className="text-xs">
|
||||
{dev?.device_nickname || dev?.node_id}: <MembershipStateBadge state={m.state} />
|
||||
{dev?.device_nickname || dev?.node_id}: {m.active ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { Network, ArrowLeft, Loader2, AlertTriangle, Users, Zap, Ban, Shield, Monitor, CheckCircle, XCircle, Clock, ChevronDown, ChevronRight, Plus, Search } from "lucide-react";
|
||||
import { Network, ArrowLeft, Loader2, AlertTriangle, Users, Zap, ZapOff, Ban, Shield, Monitor, CheckCircle, Clock, ChevronDown, ChevronRight, Plus, Search, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -8,9 +8,20 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api, PortalNetwork, ApiError, NetworkEnvironment, NetworkRequestMode, DeviceNetworkMembership, MembershipState, UserNetworkApproval, OrgMember, Device } from "@/lib/api";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api, PortalNetwork, ApiError, NetworkEnvironment, NetworkRequestMode, DeviceNetworkMembership, UserNetworkApproval, OrgMember, Device, ActivationSession, ApprovalState } from "@/lib/api";
|
||||
import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
function cn(...classes: (string | boolean | undefined | null)[]) {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
@@ -55,21 +66,47 @@ function groupMembersByUser(memberships: DeviceNetworkMembership[]): Map<string,
|
||||
return grouped;
|
||||
}
|
||||
|
||||
function MembershipStateBadge({ state }: { state: MembershipState }) {
|
||||
const config: Record<MembershipState, { label: string; className: string }> = {
|
||||
pending_device_registration: { label: "Pending Device", className: "bg-gray-100 text-gray-600 border-gray-200" },
|
||||
pending_request: { label: "Pending Request", className: "bg-yellow-100 text-yellow-700 border-yellow-200" },
|
||||
pending_manager_approval: { label: "Pending Approval", className: "bg-orange-100 text-orange-700 border-orange-200" },
|
||||
approved_inactive: { label: "Approved", className: "bg-blue-100 text-blue-700 border-blue-200" },
|
||||
joined_deauthorized: { label: "Deauthorized", className: "bg-red-100 text-red-700 border-red-200" },
|
||||
active_authorized: { label: "Active", className: "bg-green-100 text-green-700 border-green-200" },
|
||||
activation_expired: { label: "Expired", className: "bg-gray-100 text-gray-500 border-gray-200" },
|
||||
suspended: { label: "Suspended", className: "bg-red-100 text-red-700 border-red-200" },
|
||||
revoked: { label: "Revoked", className: "bg-red-100 text-red-700 border-red-200" },
|
||||
rejected: { label: "Rejected", className: "bg-red-100 text-red-700 border-red-200" },
|
||||
};
|
||||
const { label, className } = config[state] || { label: state, className: "bg-gray-100 text-gray-600" };
|
||||
return <Badge variant="outline" className={cn("text-xs", className)}>{label}</Badge>;
|
||||
function ActiveBadge({ active }: { active: boolean }) {
|
||||
if (active) {
|
||||
return (
|
||||
<Badge className="text-xs bg-green-500/15 text-green-700 border-green-400">
|
||||
<Zap className="w-3 h-3 mr-1" />Active
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="outline" className="text-xs text-muted-foreground">
|
||||
<ZapOff className="w-3 h-3 mr-1" />Inactive
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionProgress({ session }: { session: ActivationSession }) {
|
||||
const now = Date.now();
|
||||
const expires = new Date(session.expires_at).getTime();
|
||||
const created = new Date(session.authenticated_at).getTime();
|
||||
const total = expires - created;
|
||||
const elapsed = now - created;
|
||||
const ratio = Math.min(Math.max(elapsed / total, 0), 1);
|
||||
const remaining = Math.max(expires - now, 0);
|
||||
const remainingMin = Math.floor(remaining / 60000);
|
||||
const barColor = ratio < 0.5 ? "bg-green-500" : ratio < 0.8 ? "bg-yellow-500" : "bg-red-500";
|
||||
|
||||
const remainingText = remainingMin >= 60
|
||||
? `${Math.floor(remainingMin / 60)}h ${remainingMin % 60}m remaining`
|
||||
: `${remainingMin}m remaining`;
|
||||
|
||||
return (
|
||||
<div className="space-y-1 w-full">
|
||||
<div className="h-1.5 w-full rounded-full bg-secondary overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full transition-all rounded-full", barColor)}
|
||||
style={{ width: `${ratio * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{remainingText}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ApprovalStateBadge({ state }: { state: string }) {
|
||||
@@ -103,6 +140,13 @@ export default function NetworkManagementPage() {
|
||||
const [expandedUsers, setExpandedUsers] = useState<Set<string>>(new Set());
|
||||
const [activatingMembership, setActivatingMembership] = useState<string | null>(null);
|
||||
const [deactivatingMembership, setDeactivatingMembership] = useState<string | null>(null);
|
||||
const [removingMembership, setRemovingMembership] = useState<string | null>(null);
|
||||
const [removingUserId, setRemovingUserId] = useState<string | null>(null);
|
||||
const [confirmRemoveDevice, setConfirmRemoveDevice] = useState<string | null>(null);
|
||||
const [confirmRemoveUser, setConfirmRemoveUser] = useState<string | null>(null);
|
||||
const [showActivateDialog, setShowActivateDialog] = useState<string | null>(null);
|
||||
const [activateLifetime, setActivateLifetime] = useState("480");
|
||||
const [activatingAll, setActivatingAll] = useState(false);
|
||||
|
||||
const [requests, setRequests] = useState<UserNetworkApproval[]>([]);
|
||||
const [isRequestsLoading, setIsRequestsLoading] = useState(false);
|
||||
@@ -254,12 +298,18 @@ export default function NetworkManagementPage() {
|
||||
};
|
||||
|
||||
const handleActivate = async (membershipId: string) => {
|
||||
if (!orgId) return;
|
||||
setShowActivateDialog(membershipId);
|
||||
};
|
||||
|
||||
const handleActivateConfirm = async (membershipId: string) => {
|
||||
if (!orgId) return;
|
||||
setActivatingMembership(membershipId);
|
||||
try {
|
||||
await api.zerotier.activateMembership(orgId, membershipId);
|
||||
toast({ title: "Membership activated" });
|
||||
// Refresh members
|
||||
const lifetime = parseInt(activateLifetime);
|
||||
await api.zerotier.activateMembership(orgId, membershipId, lifetime);
|
||||
toast({ title: "Membership activated", description: `Active for ${lifetime} minutes.` });
|
||||
setShowActivateDialog(null);
|
||||
const result = await api.zerotier.getNetworkMembers(orgId, networkId!);
|
||||
setMembers(result.memberships || []);
|
||||
} catch (err) {
|
||||
@@ -284,6 +334,57 @@ export default function NetworkManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivateAll = async () => {
|
||||
if (!orgId) return;
|
||||
setActivatingAll(true);
|
||||
try {
|
||||
const lifetime = parseInt(activateLifetime);
|
||||
const res = await api.zerotier.activateAllMemberships(orgId, lifetime);
|
||||
toast({ title: "All memberships activated", description: `${res.count} memberships activated for ${lifetime} minutes.` });
|
||||
const result = await api.zerotier.getNetworkMembers(orgId, networkId!);
|
||||
setMembers(result.memberships || []);
|
||||
} catch (err) {
|
||||
toast({ variant: "destructive", title: "Failed to activate all", description: err instanceof ApiError ? err.message : "Something went wrong." });
|
||||
} finally {
|
||||
setActivatingAll(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveDevice = async (membershipId: string) => {
|
||||
if (!orgId) return;
|
||||
setRemovingMembership(membershipId);
|
||||
try {
|
||||
await api.zerotier.adminDeleteMembership(orgId, membershipId);
|
||||
toast({ title: "Device removed from network" });
|
||||
const result = await api.zerotier.getNetworkMembers(orgId, networkId!);
|
||||
setMembers(result.memberships || []);
|
||||
} catch (err) {
|
||||
toast({ variant: "destructive", title: "Failed to remove device", description: err instanceof ApiError ? err.message : "Something went wrong." });
|
||||
} finally {
|
||||
setRemovingMembership(null);
|
||||
setConfirmRemoveDevice(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveUserDevices = async (userId: string) => {
|
||||
if (!orgId) return;
|
||||
setRemovingUserId(userId);
|
||||
try {
|
||||
const userMemberships = members.filter(m => m.user_id === userId);
|
||||
await Promise.all(
|
||||
userMemberships.map(m => api.zerotier.adminDeleteMembership(orgId, m.id))
|
||||
);
|
||||
toast({ title: "All devices removed", description: `Removed ${userMemberships.length} device(s) for this user.` });
|
||||
const result = await api.zerotier.getNetworkMembers(orgId, networkId!);
|
||||
setMembers(result.memberships || []);
|
||||
} catch (err) {
|
||||
toast({ variant: "destructive", title: "Failed to remove devices", description: err instanceof ApiError ? err.message : "Something went wrong." });
|
||||
} finally {
|
||||
setRemovingUserId(null);
|
||||
setConfirmRemoveUser(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApprove = async (approvalId: string) => {
|
||||
if (!orgId) return;
|
||||
setApprovingRequest(approvalId);
|
||||
@@ -498,85 +599,112 @@ export default function NetworkManagementPage() {
|
||||
<div className="space-y-3">
|
||||
{Array.from(groupMembersByUser(members).entries()).map(([userId, userMemberships]) => {
|
||||
const isExpanded = expandedUsers.has(userId);
|
||||
const activeCount = userMemberships.filter(m => m.currently_authorized).length;
|
||||
const activeCount = userMemberships.filter(m => m.active).length;
|
||||
const approvedCount = userMemberships.filter(m => m.status === "approved" && !m.active).length;
|
||||
return (
|
||||
<div key={userId} className="border rounded-lg overflow-hidden">
|
||||
{/* User header - clickable to expand/collapse */}
|
||||
<button
|
||||
className="w-full flex items-center gap-3 p-4 text-left hover:bg-accent/50 transition-colors"
|
||||
onClick={() => {
|
||||
setExpandedUsers(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(userId)) next.delete(userId);
|
||||
else next.add(userId);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{isExpanded ? <ChevronDown className="w-4 h-4 flex-shrink-0" /> : <ChevronRight className="w-4 h-4 flex-shrink-0" />}
|
||||
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<Users className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate font-mono text-sm">{userId}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{userMemberships.length} device{userMemberships.length !== 1 ? "s" : ""}
|
||||
{activeCount > 0 && <span className="text-green-600 ml-2">{activeCount} active</span>}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
className="flex-1 flex items-center gap-3 p-4 text-left hover:bg-accent/50 transition-colors"
|
||||
onClick={() => {
|
||||
setExpandedUsers(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(userId)) next.delete(userId);
|
||||
else next.add(userId);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{isExpanded ? <ChevronDown className="w-4 h-4 flex-shrink-0" /> : <ChevronRight className="w-4 h-4 flex-shrink-0" />}
|
||||
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<Users className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate font-mono text-sm">{userId}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{userMemberships.length} device{userMemberships.length !== 1 ? "s" : ""}
|
||||
{activeCount > 0 && <span className="text-green-600 ml-2">{activeCount} active</span>}
|
||||
{approvedCount > 0 && <span className="text-blue-600 ml-2">{approvedCount} ready</span>}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setConfirmRemoveUser(userId)}
|
||||
disabled={removingUserId === userId}
|
||||
className="mr-2 text-destructive hover:text-destructive flex-shrink-0"
|
||||
>
|
||||
{removingUserId === userId ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4" />
|
||||
)}
|
||||
<span className="ml-1">Remove All</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Device list - shown when expanded */}
|
||||
{isExpanded && (
|
||||
<div className="border-t divide-y bg-muted/20">
|
||||
{userMemberships.map((m) => (
|
||||
<div key={m.id} className="flex items-center gap-3 p-3 pl-11">
|
||||
<Monitor className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="text-sm font-medium truncate">{m.device_id}</p>
|
||||
<MembershipStateBadge state={m.state} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
{m.currently_authorized ? (
|
||||
<><CheckCircle className="w-3 h-3 text-green-500" /> Authorized</>
|
||||
) : (
|
||||
<><XCircle className="w-3 h-3 text-muted-foreground" /> Unauthorized</>
|
||||
)}
|
||||
</span>
|
||||
{m.active_session && m.active_session.is_active && (
|
||||
<span className="flex items-center gap-1 text-green-600">
|
||||
<Clock className="w-3 h-3" /> Session active (expires {new Date(m.active_session.expires_at).toLocaleTimeString()})
|
||||
</span>
|
||||
)}
|
||||
<span>Joined: {m.join_seen ? "Yes" : "No"}</span>
|
||||
<div key={m.id} className="flex items-start gap-3 p-3 pl-11">
|
||||
<Monitor className="w-4 h-4 text-muted-foreground flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<p className="text-sm font-medium truncate">{m.device_id}</p>
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<ActiveBadge active={m.active} />
|
||||
<Badge variant="outline" className="text-xs text-muted-foreground">
|
||||
Joined: {m.join_seen ? "Yes" : "No"}
|
||||
</Badge>
|
||||
</div>
|
||||
{m.active_session && m.active_session.is_active && (
|
||||
<SessionProgress session={m.active_session} />
|
||||
)}
|
||||
</div>
|
||||
{/* Activate/Deactivate button */}
|
||||
{m.currently_authorized ? (
|
||||
<div className="flex items-center gap-1 flex-shrink-0 pt-0.5">
|
||||
{m.active ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDeactivate(m.id)}
|
||||
disabled={deactivatingMembership === m.id}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
{deactivatingMembership === m.id && <Loader2 className="w-3 h-3 mr-1 animate-spin" />}
|
||||
Deactivate
|
||||
</Button>
|
||||
) : m.status === "approved" ? (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => handleActivate(m.id)}
|
||||
disabled={activatingMembership === m.id}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
{activatingMembership === m.id && <Loader2 className="w-3 h-3 mr-1 animate-spin" />}
|
||||
Activate
|
||||
</Button>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs text-muted-foreground">
|
||||
Not eligible
|
||||
</Badge>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeactivate(m.id)}
|
||||
disabled={deactivatingMembership === m.id}
|
||||
className="flex-shrink-0"
|
||||
onClick={() => setConfirmRemoveDevice(m.id)}
|
||||
disabled={removingMembership === m.id}
|
||||
className="text-destructive hover:text-destructive flex-shrink-0"
|
||||
>
|
||||
{deactivatingMembership === m.id && <Loader2 className="w-3 h-3 mr-1 animate-spin" />}
|
||||
Deactivate
|
||||
{removingMembership === m.id ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-3 h-3" />
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => handleActivate(m.id)}
|
||||
disabled={activatingMembership === m.id}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
{activatingMembership === m.id && <Loader2 className="w-3 h-3 mr-1 animate-spin" />}
|
||||
Activate
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -588,6 +716,40 @@ export default function NetworkManagementPage() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Activate All section */}
|
||||
{members.filter(m => m.status === "approved" && !m.active).length > 0 && (
|
||||
<Card className="mt-4">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-green-500" />
|
||||
<span className="text-sm font-medium">
|
||||
{members.filter(m => m.active).length} active,{" "}
|
||||
{members.filter(m => m.status === "approved" && !m.active).length} ready to activate
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground">Duration:</span>
|
||||
<Input
|
||||
type="number"
|
||||
value={activateLifetime}
|
||||
onChange={(e) => setActivateLifetime(e.target.value)}
|
||||
className="h-8 w-20 text-xs"
|
||||
placeholder="480"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">min</span>
|
||||
</div>
|
||||
<Button size="sm" onClick={handleActivateAll} disabled={activatingAll} className="gap-1">
|
||||
{activatingAll ? <Loader2 className="w-3 h-3 animate-spin" /> : <Zap className="w-3 h-3" />}
|
||||
Activate All
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="requests">
|
||||
@@ -805,6 +967,76 @@ export default function NetworkManagementPage() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ── Activation Lifetime Dialog ──────────────────────────────────── */}
|
||||
<Dialog open={!!showActivateDialog} onOpenChange={(open) => { if (!open) setShowActivateDialog(null); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Set Activation Duration</DialogTitle>
|
||||
<DialogDescription>How long should this membership be active?</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Duration (minutes)</Label>
|
||||
<Input type="number" value={activateLifetime} onChange={(e) => setActivateLifetime(e.target.value)} placeholder="480" />
|
||||
<p className="text-xs text-muted-foreground">e.g. 480 = 8 hours, 60 = 1 hour</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowActivateDialog(null)}>Cancel</Button>
|
||||
<Button onClick={() => { if (showActivateDialog) handleActivateConfirm(showActivateDialog); }} disabled={activatingMembership !== null}>
|
||||
{activatingMembership !== null && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
Activate
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ── Confirm Remove Single Device ─────────────────────────────────────── */}
|
||||
<AlertDialog open={!!confirmRemoveDevice} onOpenChange={() => setConfirmRemoveDevice(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remove device from network?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will remove this device's membership from the network. The user will need to re-join if they want access again.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={removingMembership !== null}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
disabled={removingMembership !== null}
|
||||
onClick={() => confirmRemoveDevice && handleRemoveDevice(confirmRemoveDevice)}
|
||||
>
|
||||
{removingMembership !== null && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
Remove
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* ── Confirm Remove All User Devices ────────────────────────────────── */}
|
||||
<AlertDialog open={!!confirmRemoveUser} onOpenChange={() => setConfirmRemoveUser(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remove all devices for this user?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will remove all memberships for user <span className="font-mono font-medium">{confirmRemoveUser}</span>. All of their devices will lose access to this network.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={removingUserId !== null}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
disabled={removingUserId !== null}
|
||||
onClick={() => confirmRemoveUser && handleRemoveUserDevices(confirmRemoveUser)}
|
||||
>
|
||||
{removingUserId !== null && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
Remove All
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,13 +47,43 @@ export function CADetailCard({ ca, onEdit, onRotate, onDelete }: CADetailCardPro
|
||||
const isSystem = !!ca.is_system;
|
||||
|
||||
// ── User CA: server trusts this public key so it accepts user certs ──────
|
||||
const userCaServerSnippet = `# On each SSH server — trust Secuird-issued user certificates:
|
||||
echo '${ca.public_key.trim()}' >> /etc/ssh/trusted_user_ca
|
||||
const userCaServerSnippet = `#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# /etc/ssh/sshd_config (add once, then reload sshd):
|
||||
TrustedUserCAKeys /etc/ssh/trusted_user_ca
|
||||
AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u
|
||||
# Create /etc/ssh/auth_principals/<unix-user> containing one principal per line.`;
|
||||
CA_KEY='${ca.public_key.trim()}'
|
||||
UNIX_USER="ubuntu" # ← change to the server's unix user
|
||||
PRINCIPAL="<Your principal>" # ← change to the principal for this user
|
||||
|
||||
CA_FILE="/etc/ssh/trusted_user_ca"
|
||||
PRINCIPALS_DIR="/etc/ssh/auth_principals"
|
||||
SSHD_DROP_IN="/etc/ssh/sshd_config.d/99-ca-auth.conf"
|
||||
|
||||
if [[ "\$(id -u)" -ne 0 ]]; then
|
||||
echo "error: must be run as root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
install -m 0644 -o root -g root /dev/null "\${CA_FILE}"
|
||||
echo "\${CA_KEY}" > "\${CA_FILE}"
|
||||
|
||||
install -d -m 0755 -o root -g root "\${PRINCIPALS_DIR}"
|
||||
install -m 0644 -o root -g root /dev/null "\${PRINCIPALS_DIR}/\${UNIX_USER}"
|
||||
echo "\${PRINCIPAL}" > "\${PRINCIPALS_DIR}/\${UNIX_USER}"
|
||||
|
||||
install -d -m 0755 -o root -g root "/etc/ssh/sshd_config.d"
|
||||
install -m 0600 -o root -g root /dev/null "\${SSHD_DROP_IN}"
|
||||
cat > "\${SSHD_DROP_IN}" <<EOF
|
||||
TrustedUserCAKeys \${CA_FILE}
|
||||
AuthorizedPrincipalsFile \${PRINCIPALS_DIR}/%u
|
||||
EOF
|
||||
|
||||
if sshd -t; then
|
||||
systemctl reload ssh 2>/dev/null || systemctl reload sshd
|
||||
echo "done — CA trust and principal '\${PRINCIPAL}' configured for '\${UNIX_USER}'"
|
||||
else
|
||||
echo "error: sshd configuration test failed — SSH was NOT reloaded" >&2
|
||||
exit 1
|
||||
fi`;
|
||||
|
||||
// ── Host CA: clients trust this public key so they can verify server certs ─
|
||||
const hostCaClientSnippet = `# On SSH clients — trust host certificates signed by this CA:
|
||||
|
||||
@@ -38,31 +38,23 @@ vi.mock("@/hooks/useCurrentOrganization", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
zerotier: {
|
||||
getNetwork: H.mockGetNetwork,
|
||||
getNetworkMembers: H.mockGetNetworkMembers,
|
||||
activateMembership: H.mockActivateMembership,
|
||||
deactivateMembership: H.mockDeactivateMembership,
|
||||
getNetworkPendingRequests: H.mockGetNetworkPendingRequests,
|
||||
approveRequest: H.mockApproveRequest,
|
||||
rejectRequest: H.mockRejectRequest,
|
||||
vi.mock("@/lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/api")>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
zerotier: {
|
||||
getNetwork: H.mockGetNetwork,
|
||||
getNetworkMembers: H.mockGetNetworkMembers,
|
||||
activateMembership: H.mockActivateMembership,
|
||||
deactivateMembership: H.mockDeactivateMembership,
|
||||
getNetworkPendingRequests: H.mockGetNetworkPendingRequests,
|
||||
approveRequest: H.mockApproveRequest,
|
||||
rejectRequest: H.mockRejectRequest,
|
||||
},
|
||||
},
|
||||
},
|
||||
ApiError: class ApiError extends Error {
|
||||
code: number;
|
||||
type: string;
|
||||
details: Record<string, unknown>;
|
||||
constructor(message: string, code: number, type: string, details: Record<string, unknown> = {}) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.code = code;
|
||||
this.type = type;
|
||||
this.details = details;
|
||||
}
|
||||
},
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/hooks/use-toast", () => ({
|
||||
useToast: () => ({
|
||||
@@ -669,10 +661,12 @@ describe("NetworkManagementPage", () => {
|
||||
device_id: "dev-laptop-1",
|
||||
portal_network_id: "net-abc",
|
||||
user_network_approval_id: null,
|
||||
state: "active_authorized",
|
||||
active: true,
|
||||
status: "approved",
|
||||
grant_type: "assigned",
|
||||
granted_by_user_id: null,
|
||||
justification: null,
|
||||
join_seen: true,
|
||||
currently_authorized: true,
|
||||
approved_for_activation: true,
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
updated_at: "2025-01-01T00:00:00Z",
|
||||
deleted_at: null,
|
||||
@@ -701,10 +695,12 @@ describe("NetworkManagementPage", () => {
|
||||
device_id: "dev-desktop-1",
|
||||
portal_network_id: "net-abc",
|
||||
user_network_approval_id: null,
|
||||
state: "joined_deauthorized",
|
||||
active: false,
|
||||
status: "approved",
|
||||
grant_type: "assigned",
|
||||
granted_by_user_id: null,
|
||||
justification: null,
|
||||
join_seen: false,
|
||||
currently_authorized: false,
|
||||
approved_for_activation: false,
|
||||
created_at: "2025-02-01T00:00:00Z",
|
||||
updated_at: "2025-02-01T00:00:00Z",
|
||||
deleted_at: null,
|
||||
@@ -718,10 +714,12 @@ describe("NetworkManagementPage", () => {
|
||||
device_id: "dev-phone-1",
|
||||
portal_network_id: "net-abc",
|
||||
user_network_approval_id: null,
|
||||
state: "approved_inactive",
|
||||
active: false,
|
||||
status: "approved",
|
||||
grant_type: "assigned",
|
||||
granted_by_user_id: null,
|
||||
justification: null,
|
||||
join_seen: true,
|
||||
currently_authorized: false,
|
||||
approved_for_activation: true,
|
||||
created_at: "2025-03-01T00:00:00Z",
|
||||
updated_at: "2025-03-01T00:00:00Z",
|
||||
deleted_at: null,
|
||||
@@ -735,10 +733,12 @@ describe("NetworkManagementPage", () => {
|
||||
device_id: "dev-server-1",
|
||||
portal_network_id: "net-abc",
|
||||
user_network_approval_id: null,
|
||||
state: "pending_request",
|
||||
active: false,
|
||||
status: "pending",
|
||||
grant_type: "requested",
|
||||
granted_by_user_id: null,
|
||||
justification: null,
|
||||
join_seen: false,
|
||||
currently_authorized: false,
|
||||
approved_for_activation: false,
|
||||
created_at: "2025-04-01T00:00:00Z",
|
||||
updated_at: "2025-04-01T00:00:00Z",
|
||||
deleted_at: null,
|
||||
@@ -966,7 +966,7 @@ describe("NetworkManagementPage", () => {
|
||||
|
||||
// ── Device Details ──────────────────────────────────────────────────────────
|
||||
|
||||
test("renders device state badge for active_authorized membership", async () => {
|
||||
test("renders Active badge for active_authorized membership", async () => {
|
||||
await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION]);
|
||||
|
||||
const userButton = screen.getByText("user-a").closest("button");
|
||||
@@ -975,102 +975,90 @@ describe("NetworkManagementPage", () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Active")).toBeDefined();
|
||||
});
|
||||
// active_authorized → green badge classes
|
||||
const badge = screen.getByText("Active");
|
||||
expect(badge.className).toContain("bg-green-100");
|
||||
expect(badge.className).toContain("text-green-700");
|
||||
});
|
||||
|
||||
test("renders device state badge for joined_deauthorized membership", async () => {
|
||||
test("renders Inactive badge for approved_inactive membership", async () => {
|
||||
await setupMembersTab([MEMBERSHIP_UNAUTHORIZED_NO_SESSION]);
|
||||
|
||||
const userButton = screen.getByText("user-a").closest("button");
|
||||
fireEvent.click(userButton!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Deauthorized")).toBeDefined();
|
||||
expect(screen.getByText("Inactive")).toBeDefined();
|
||||
});
|
||||
const badge = screen.getByText("Deauthorized");
|
||||
expect(badge.className).toContain("bg-red-100");
|
||||
expect(badge.className).toContain("text-red-700");
|
||||
});
|
||||
|
||||
test("renders device state badge for pending_request membership", async () => {
|
||||
test("renders Inactive badge for pending_request membership", async () => {
|
||||
await setupMembersTab([MEMBERSHIP_PENDING_REQUEST]);
|
||||
|
||||
const userButton = screen.getByText("user-c").closest("button");
|
||||
fireEvent.click(userButton!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Pending Request")).toBeDefined();
|
||||
expect(screen.getByText("Inactive")).toBeDefined();
|
||||
});
|
||||
const badge = screen.getByText("Pending Request");
|
||||
expect(badge.className).toContain("bg-yellow-100");
|
||||
expect(badge.className).toContain("text-yellow-700");
|
||||
});
|
||||
|
||||
test("renders device state badge for approved_inactive membership", async () => {
|
||||
test("renders Inactive badge for second user (approved_inactive)", async () => {
|
||||
await setupMembersTab([MEMBERSHIP_SECOND_USER]);
|
||||
|
||||
const userButton = screen.getByText("user-b").closest("button");
|
||||
fireEvent.click(userButton!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Approved")).toBeDefined();
|
||||
expect(screen.getByText("Inactive")).toBeDefined();
|
||||
});
|
||||
const badge = screen.getByText("Approved");
|
||||
expect(badge.className).toContain("bg-blue-100");
|
||||
expect(badge.className).toContain("text-blue-700");
|
||||
});
|
||||
|
||||
// ── Authorization Status ────────────────────────────────────────────────────
|
||||
|
||||
test("shows 'Authorized' with green check icon when currently_authorized is true", async () => {
|
||||
test("shows 'Active' badge when membership is active", async () => {
|
||||
await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION]);
|
||||
|
||||
const userButton = screen.getByText("user-a").closest("button");
|
||||
fireEvent.click(userButton!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Authorized")).toBeDefined();
|
||||
const actives = screen.getAllByText("Active");
|
||||
expect(actives.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
test("shows 'Unauthorized' with X icon when currently_authorized is false", async () => {
|
||||
test("shows 'Inactive' badge when membership is not active", async () => {
|
||||
await setupMembersTab([MEMBERSHIP_UNAUTHORIZED_NO_SESSION]);
|
||||
|
||||
const userButton = screen.getByText("user-a").closest("button");
|
||||
fireEvent.click(userButton!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Unauthorized")).toBeDefined();
|
||||
expect(screen.getByText("Inactive")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Active Session Info ─────────────────────────────────────────────────────
|
||||
|
||||
test("shows session info when active_session is present and is_active", async () => {
|
||||
test("shows session progress bar when active_session is present and is_active", async () => {
|
||||
await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION]);
|
||||
|
||||
const userButton = screen.getByText("user-a").closest("button");
|
||||
fireEvent.click(userButton!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Session active/)).toBeDefined();
|
||||
expect(screen.getByText(/remaining/)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("does NOT show session info when active_session is null", async () => {
|
||||
test("does NOT show session progress when active_session is null", async () => {
|
||||
await setupMembersTab([MEMBERSHIP_UNAUTHORIZED_NO_SESSION]);
|
||||
|
||||
const userButton = screen.getByText("user-a").closest("button");
|
||||
fireEvent.click(userButton!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Deauthorized")).toBeDefined();
|
||||
expect(screen.getByText("Inactive")).toBeDefined();
|
||||
});
|
||||
|
||||
expect(screen.queryByText(/Session active/)).toBeNull();
|
||||
expect(screen.queryByText(/remaining/)).toBeNull();
|
||||
});
|
||||
|
||||
// ── Join Seen ───────────────────────────────────────────────────────────────
|
||||
@@ -1099,7 +1087,7 @@ describe("NetworkManagementPage", () => {
|
||||
|
||||
// ── Activate / Deactivate Buttons ───────────────────────────────────────────
|
||||
|
||||
test("renders Deactivate button for currently_authorized memberships", async () => {
|
||||
test("renders Deactivate button for active memberships", async () => {
|
||||
await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION]);
|
||||
|
||||
const userButton = screen.getByText("user-a").closest("button");
|
||||
@@ -1111,7 +1099,7 @@ describe("NetworkManagementPage", () => {
|
||||
expect(screen.queryByRole("button", { name: "Activate" })).toBeNull();
|
||||
});
|
||||
|
||||
test("renders Activate button for non-currently_authorized memberships", async () => {
|
||||
test("renders Activate button for approved-but-inactive memberships", async () => {
|
||||
await setupMembersTab([MEMBERSHIP_UNAUTHORIZED_NO_SESSION]);
|
||||
|
||||
const userButton = screen.getByText("user-a").closest("button");
|
||||
@@ -1123,7 +1111,7 @@ describe("NetworkManagementPage", () => {
|
||||
expect(screen.queryByRole("button", { name: "Deactivate" })).toBeNull();
|
||||
});
|
||||
|
||||
test("clicking Activate calls api.zerotier.activateMembership with correct orgId and membershipId", async () => {
|
||||
test("clicking Activate opens dialog then calls api.zerotier.activateMembership with correct orgId, membershipId, and lifetime", async () => {
|
||||
H.mockActivateMembership.mockResolvedValue({});
|
||||
// Second call to getNetworkMembers (refresh after activate)
|
||||
H.mockGetNetworkMembers
|
||||
@@ -1138,8 +1126,15 @@ describe("NetworkManagementPage", () => {
|
||||
const activateBtn = await screen.findByRole("button", { name: "Activate" });
|
||||
fireEvent.click(activateBtn);
|
||||
|
||||
// Dialog opens; click the Activate button in the dialog
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Set Activation Duration")).toBeDefined();
|
||||
});
|
||||
const dialogBtns = screen.getAllByRole("button", { name: "Activate" });
|
||||
fireEvent.click(dialogBtns[dialogBtns.length - 1]);
|
||||
|
||||
expect(H.mockActivateMembership).toHaveBeenCalledTimes(1);
|
||||
expect(H.mockActivateMembership).toHaveBeenCalledWith("org-1", "mem-unauth-1");
|
||||
expect(H.mockActivateMembership).toHaveBeenCalledWith("org-1", "mem-unauth-1", 480);
|
||||
});
|
||||
|
||||
test("clicking Deactivate calls api.zerotier.deactivateMembership with correct orgId and membershipId", async () => {
|
||||
@@ -1175,6 +1170,13 @@ describe("NetworkManagementPage", () => {
|
||||
const activateBtn = await screen.findByRole("button", { name: "Activate" });
|
||||
fireEvent.click(activateBtn);
|
||||
|
||||
// Confirm in dialog
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Set Activation Duration")).toBeDefined();
|
||||
});
|
||||
const dialogBtns = screen.getAllByRole("button", { name: "Activate" });
|
||||
fireEvent.click(dialogBtns[dialogBtns.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(H.mockToast).toHaveBeenCalledWith(expect.objectContaining({ title: "Membership activated" }));
|
||||
});
|
||||
@@ -1211,6 +1213,13 @@ describe("NetworkManagementPage", () => {
|
||||
const activateBtn = await screen.findByRole("button", { name: "Activate" });
|
||||
fireEvent.click(activateBtn);
|
||||
|
||||
// Confirm in dialog
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Set Activation Duration")).toBeDefined();
|
||||
});
|
||||
const dialogBtns = screen.getAllByRole("button", { name: "Activate" });
|
||||
fireEvent.click(dialogBtns[dialogBtns.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(H.mockToast).toHaveBeenCalledWith(expect.objectContaining({
|
||||
variant: "destructive",
|
||||
@@ -1252,6 +1261,13 @@ describe("NetworkManagementPage", () => {
|
||||
const activateBtn = await screen.findByRole("button", { name: "Activate" });
|
||||
fireEvent.click(activateBtn);
|
||||
|
||||
// Confirm in dialog
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Set Activation Duration")).toBeDefined();
|
||||
});
|
||||
const dialogBtns = screen.getAllByRole("button", { name: "Activate" });
|
||||
fireEvent.click(dialogBtns[dialogBtns.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(H.mockToast).toHaveBeenCalledWith(expect.objectContaining({
|
||||
variant: "destructive",
|
||||
@@ -1265,8 +1281,8 @@ describe("NetworkManagementPage", () => {
|
||||
H.mockActivateMembership.mockResolvedValue({});
|
||||
const updatedMembership = {
|
||||
...MEMBERSHIP_UNAUTHORIZED_NO_SESSION,
|
||||
state: "active_authorized",
|
||||
currently_authorized: true,
|
||||
active: true,
|
||||
status: "approved",
|
||||
};
|
||||
H.mockGetNetworkMembers
|
||||
.mockResolvedValueOnce({ memberships: [MEMBERSHIP_UNAUTHORIZED_NO_SESSION], count: 1 })
|
||||
@@ -1280,6 +1296,13 @@ describe("NetworkManagementPage", () => {
|
||||
const activateBtn = await screen.findByRole("button", { name: "Activate" });
|
||||
fireEvent.click(activateBtn);
|
||||
|
||||
// Confirm in dialog
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Set Activation Duration")).toBeDefined();
|
||||
});
|
||||
const dialogBtns = screen.getAllByRole("button", { name: "Activate" });
|
||||
fireEvent.click(dialogBtns[dialogBtns.length - 1]);
|
||||
|
||||
// Verify getNetworkMembers was called twice: once on mount, once on refresh
|
||||
await waitFor(() => {
|
||||
expect(H.mockGetNetworkMembers).toHaveBeenCalledTimes(2);
|
||||
@@ -1292,8 +1315,8 @@ describe("NetworkManagementPage", () => {
|
||||
H.mockDeactivateMembership.mockResolvedValue({});
|
||||
const updatedMembership = {
|
||||
...MEMBERSHIP_AUTHORIZED_WITH_SESSION,
|
||||
state: "joined_deauthorized",
|
||||
currently_authorized: false,
|
||||
active: false,
|
||||
status: "approved",
|
||||
};
|
||||
H.mockGetNetworkMembers
|
||||
.mockResolvedValueOnce({ memberships: [MEMBERSHIP_AUTHORIZED_WITH_SESSION], count: 1 })
|
||||
@@ -1344,15 +1367,15 @@ describe("NetworkManagementPage", () => {
|
||||
test("shows active device count in user section header", async () => {
|
||||
await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION, MEMBERSHIP_UNAUTHORIZED_NO_SESSION]);
|
||||
|
||||
// 1 active (currently_authorized) + 1 inactive → "1 active"
|
||||
expect(screen.getByText(/1 active/)).toBeDefined();
|
||||
// 1 active + 1 inactive → "1 active"
|
||||
expect(screen.getByText(/^1 active$/)).toBeDefined();
|
||||
});
|
||||
|
||||
test("does not show active count when no devices are authorized", async () => {
|
||||
await setupMembersTab([MEMBERSHIP_UNAUTHORIZED_NO_SESSION]);
|
||||
|
||||
// 0 active devices → no "active" text
|
||||
expect(screen.queryByText(/active/)).toBeNull();
|
||||
// 0 active devices → no "active" count in user section
|
||||
expect(screen.queryByText(/^\d+ active$/)).toBeNull();
|
||||
});
|
||||
|
||||
// ── Adversarial: Unicode user_ids ───────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user