mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2026-02-05 18:10:31 -07:00
Added Device Conflict Resolution
This commit is contained in:
@@ -7,6 +7,11 @@ import {
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
FormControl,
|
||||
IconButton,
|
||||
InputLabel,
|
||||
@@ -76,6 +81,7 @@ function DeviceApprovals() {
|
||||
const [feedback, setFeedback] = useState(null);
|
||||
const [guidInputs, setGuidInputs] = useState({});
|
||||
const [actioningId, setActioningId] = useState(null);
|
||||
const [conflictPrompt, setConflictPrompt] = useState(null);
|
||||
|
||||
const loadApprovals = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -126,25 +132,49 @@ function DeviceApprovals() {
|
||||
setGuidInputs((prev) => ({ ...prev, [id]: value }));
|
||||
}, []);
|
||||
|
||||
const handleApprove = useCallback(
|
||||
async (record) => {
|
||||
const submitApproval = useCallback(
|
||||
async (record, overrides = {}) => {
|
||||
if (!record?.id) return;
|
||||
setActioningId(record.id);
|
||||
setFeedback(null);
|
||||
setError("");
|
||||
try {
|
||||
const guid = (guidInputs[record.id] || "").trim();
|
||||
const manualGuid = (guidInputs[record.id] || "").trim();
|
||||
const payload = {};
|
||||
const overrideGuidRaw = overrides.guid;
|
||||
let overrideGuid = "";
|
||||
if (typeof overrideGuidRaw === "string") {
|
||||
overrideGuid = overrideGuidRaw.trim();
|
||||
} else if (overrideGuidRaw != null) {
|
||||
overrideGuid = String(overrideGuidRaw).trim();
|
||||
}
|
||||
if (overrideGuid) {
|
||||
payload.guid = overrideGuid;
|
||||
} else if (manualGuid) {
|
||||
payload.guid = manualGuid;
|
||||
}
|
||||
const resolutionRaw = overrides.conflictResolution || overrides.resolution;
|
||||
if (typeof resolutionRaw === "string" && resolutionRaw.trim()) {
|
||||
payload.conflict_resolution = resolutionRaw.trim().toLowerCase();
|
||||
}
|
||||
const resp = await fetch(`/api/admin/device-approvals/${encodeURIComponent(record.id)}/approve`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(guid ? { guid } : {}),
|
||||
body: JSON.stringify(Object.keys(payload).length ? payload : {}),
|
||||
});
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Approval failed (${resp.status})`);
|
||||
}
|
||||
setFeedback({ type: "success", message: "Enrollment approved" });
|
||||
const appliedResolution = (body.conflict_resolution || payload.conflict_resolution || "").toLowerCase();
|
||||
let successMessage = "Enrollment approved";
|
||||
if (appliedResolution === "overwrite") {
|
||||
successMessage = "Enrollment approved; existing device overwritten";
|
||||
} else if (appliedResolution === "coexist") {
|
||||
successMessage = "Enrollment approved; devices will co-exist";
|
||||
}
|
||||
setFeedback({ type: "success", message: successMessage });
|
||||
await loadApprovals();
|
||||
} catch (err) {
|
||||
setFeedback({ type: "error", message: err.message || "Unable to approve request" });
|
||||
@@ -155,6 +185,73 @@ function DeviceApprovals() {
|
||||
[guidInputs, loadApprovals]
|
||||
);
|
||||
|
||||
const startApprove = useCallback(
|
||||
(record) => {
|
||||
if (!record?.id) return;
|
||||
const status = normalizeStatus(record.status);
|
||||
if (status !== "pending") return;
|
||||
const manualGuid = (guidInputs[record.id] || "").trim();
|
||||
const conflict = record.hostname_conflict;
|
||||
if (conflict && !manualGuid) {
|
||||
const fallbackAlternate =
|
||||
record.alternate_hostname ||
|
||||
(record.hostname_claimed ? `${record.hostname_claimed}-1` : "");
|
||||
setConflictPrompt({
|
||||
record,
|
||||
conflict,
|
||||
alternate: fallbackAlternate || "",
|
||||
});
|
||||
return;
|
||||
}
|
||||
submitApproval(record);
|
||||
},
|
||||
[guidInputs, submitApproval]
|
||||
);
|
||||
|
||||
const handleConflictCancel = useCallback(() => {
|
||||
setConflictPrompt(null);
|
||||
}, []);
|
||||
|
||||
const handleConflictOverwrite = useCallback(() => {
|
||||
if (!conflictPrompt?.record) {
|
||||
setConflictPrompt(null);
|
||||
return;
|
||||
}
|
||||
const { record, conflict } = conflictPrompt;
|
||||
setConflictPrompt(null);
|
||||
const conflictGuid = conflict?.guid != null ? String(conflict.guid).trim() : "";
|
||||
submitApproval(record, {
|
||||
guid: conflictGuid,
|
||||
conflictResolution: "overwrite",
|
||||
});
|
||||
}, [conflictPrompt, submitApproval]);
|
||||
|
||||
const handleConflictCoexist = useCallback(() => {
|
||||
if (!conflictPrompt?.record) {
|
||||
setConflictPrompt(null);
|
||||
return;
|
||||
}
|
||||
const { record } = conflictPrompt;
|
||||
setConflictPrompt(null);
|
||||
submitApproval(record, {
|
||||
conflictResolution: "coexist",
|
||||
});
|
||||
}, [conflictPrompt, submitApproval]);
|
||||
|
||||
const conflictRecord = conflictPrompt?.record;
|
||||
const conflictInfo = conflictPrompt?.conflict;
|
||||
const conflictHostname = conflictRecord?.hostname_claimed || conflictRecord?.hostname || "";
|
||||
const conflictSiteName = conflictInfo?.site_name || "";
|
||||
const conflictSiteDescriptor = conflictInfo
|
||||
? conflictSiteName
|
||||
? `under site ${conflictSiteName}`
|
||||
: "under site (not assigned)"
|
||||
: "under site (not assigned)";
|
||||
const conflictAlternate =
|
||||
conflictPrompt?.alternate ||
|
||||
(conflictHostname ? `${conflictHostname}-1` : "hostname-1");
|
||||
const conflictGuidDisplay = conflictInfo?.guid || "";
|
||||
|
||||
const handleDeny = useCallback(
|
||||
async (record) => {
|
||||
if (!record?.id) return;
|
||||
@@ -303,7 +400,7 @@ function DeviceApprovals() {
|
||||
<span>
|
||||
<IconButton
|
||||
color="success"
|
||||
onClick={() => handleApprove(record)}
|
||||
onClick={() => startApprove(record)}
|
||||
disabled={actioningId === record.id}
|
||||
>
|
||||
{actioningId === record.id ? (
|
||||
@@ -341,6 +438,48 @@ function DeviceApprovals() {
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
<Dialog
|
||||
open={Boolean(conflictPrompt)}
|
||||
onClose={handleConflictCancel}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Hostname Conflict</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Stack spacing={2}>
|
||||
<DialogContentText>
|
||||
{conflictHostname
|
||||
? `Device ${conflictHostname} already exists in the database ${conflictSiteDescriptor}.`
|
||||
: `A device with this hostname already exists in the database ${conflictSiteDescriptor}.`}
|
||||
</DialogContentText>
|
||||
<DialogContentText>
|
||||
Do you want this device to overwrite the existing device, or allow both to co-exist?
|
||||
</DialogContentText>
|
||||
<DialogContentText>
|
||||
{`Device will be renamed ${conflictAlternate} if you choose to allow both to co-exist.`}
|
||||
</DialogContentText>
|
||||
{conflictGuidDisplay ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Existing device GUID: {conflictGuidDisplay}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleConflictCancel}>Cancel</Button>
|
||||
<Button onClick={handleConflictCoexist} color="info" variant="outlined">
|
||||
Allow Both
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConflictOverwrite}
|
||||
color="primary"
|
||||
variant="contained"
|
||||
disabled={!conflictGuidDisplay}
|
||||
>
|
||||
Overwrite Existing
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,371 +0,0 @@
|
||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/Server/WebUI/src/Admin/Enrollment_Codes.jsx
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
FormControl,
|
||||
IconButton,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
ContentCopy as CopyIcon,
|
||||
DeleteOutline as DeleteIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Key as KeyIcon,
|
||||
} from "@mui/icons-material";
|
||||
|
||||
const TTL_PRESETS = [
|
||||
{ value: 1, label: "1 hour" },
|
||||
{ value: 3, label: "3 hours" },
|
||||
{ value: 6, label: "6 hours" },
|
||||
{ value: 12, label: "12 hours" },
|
||||
{ value: 24, label: "24 hours" },
|
||||
];
|
||||
|
||||
const statusColor = {
|
||||
active: "success",
|
||||
used: "default",
|
||||
expired: "warning",
|
||||
};
|
||||
|
||||
const maskCode = (code) => {
|
||||
if (!code) return "—";
|
||||
const parts = code.split("-");
|
||||
if (parts.length <= 1) {
|
||||
const prefix = code.slice(0, 4);
|
||||
return `${prefix}${"•".repeat(Math.max(0, code.length - prefix.length))}`;
|
||||
}
|
||||
return parts
|
||||
.map((part, idx) => (idx === 0 || idx === parts.length - 1 ? part : "•".repeat(part.length)))
|
||||
.join("-");
|
||||
};
|
||||
|
||||
const formatDateTime = (value) => {
|
||||
if (!value) return "—";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
const determineStatus = (record) => {
|
||||
if (!record) return "expired";
|
||||
const maxUses = Number.isFinite(record?.max_uses) ? record.max_uses : 1;
|
||||
const useCount = Number.isFinite(record?.use_count) ? record.use_count : 0;
|
||||
if (useCount >= Math.max(1, maxUses || 1)) return "used";
|
||||
if (!record.expires_at) return "expired";
|
||||
const expires = new Date(record.expires_at);
|
||||
if (Number.isNaN(expires.getTime())) return "expired";
|
||||
return expires.getTime() > Date.now() ? "active" : "expired";
|
||||
};
|
||||
|
||||
function EnrollmentCodes() {
|
||||
const [codes, setCodes] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [feedback, setFeedback] = useState(null);
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [ttlHours, setTtlHours] = useState(6);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [maxUses, setMaxUses] = useState(2);
|
||||
|
||||
const filteredCodes = useMemo(() => {
|
||||
if (statusFilter === "all") return codes;
|
||||
return codes.filter((code) => determineStatus(code) === statusFilter);
|
||||
}, [codes, statusFilter]);
|
||||
|
||||
const fetchCodes = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const query = statusFilter === "all" ? "" : `?status=${encodeURIComponent(statusFilter)}`;
|
||||
const resp = await fetch(`/api/admin/enrollment-codes${query}`, {
|
||||
credentials: "include",
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Request failed (${resp.status})`);
|
||||
}
|
||||
const data = await resp.json();
|
||||
setCodes(Array.isArray(data.codes) ? data.codes : []);
|
||||
} catch (err) {
|
||||
setError(err.message || "Unable to load enrollment codes");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [statusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCodes();
|
||||
}, [fetchCodes]);
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
setGenerating(true);
|
||||
setError("");
|
||||
try {
|
||||
const resp = await fetch("/api/admin/enrollment-codes", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ttl_hours: ttlHours, max_uses: maxUses }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Request failed (${resp.status})`);
|
||||
}
|
||||
const created = await resp.json();
|
||||
setFeedback({ type: "success", message: `Installer code ${created.code} created` });
|
||||
await fetchCodes();
|
||||
} catch (err) {
|
||||
setFeedback({ type: "error", message: err.message || "Failed to create code" });
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
}, [fetchCodes, ttlHours, maxUses]);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (id) => {
|
||||
if (!id) return;
|
||||
const confirmDelete = window.confirm("Delete this unused installer code?");
|
||||
if (!confirmDelete) return;
|
||||
setError("");
|
||||
try {
|
||||
const resp = await fetch(`/api/admin/enrollment-codes/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Request failed (${resp.status})`);
|
||||
}
|
||||
setFeedback({ type: "success", message: "Installer code deleted" });
|
||||
await fetchCodes();
|
||||
} catch (err) {
|
||||
setFeedback({ type: "error", message: err.message || "Failed to delete code" });
|
||||
}
|
||||
},
|
||||
[fetchCodes]
|
||||
);
|
||||
|
||||
const handleCopy = useCallback((code) => {
|
||||
if (!code) return;
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(code);
|
||||
setFeedback({ type: "success", message: "Code copied to clipboard" });
|
||||
} else {
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = code;
|
||||
textArea.style.position = "fixed";
|
||||
textArea.style.opacity = "0";
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textArea);
|
||||
setFeedback({ type: "success", message: "Code copied to clipboard" });
|
||||
}
|
||||
} catch (err) {
|
||||
setFeedback({ type: "error", message: err.message || "Unable to copy code" });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const renderStatusChip = (record) => {
|
||||
const status = determineStatus(record);
|
||||
return <Chip size="small" label={status} color={statusColor[status] || "default"} variant="outlined" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, display: "flex", flexDirection: "column", gap: 3 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={2}>
|
||||
<KeyIcon color="primary" />
|
||||
<Typography variant="h5">Enrollment Installer Codes</Typography>
|
||||
</Stack>
|
||||
|
||||
<Paper sx={{ p: 2, display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ xs: "stretch", sm: "center" }}>
|
||||
<FormControl size="small" sx={{ minWidth: 160 }}>
|
||||
<InputLabel id="status-filter-label">Filter</InputLabel>
|
||||
<Select
|
||||
labelId="status-filter-label"
|
||||
label="Filter"
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value)}
|
||||
>
|
||||
<MenuItem value="all">All</MenuItem>
|
||||
<MenuItem value="active">Active</MenuItem>
|
||||
<MenuItem value="used">Used</MenuItem>
|
||||
<MenuItem value="expired">Expired</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||
<InputLabel id="ttl-select-label">Duration</InputLabel>
|
||||
<Select
|
||||
labelId="ttl-select-label"
|
||||
label="Duration"
|
||||
value={ttlHours}
|
||||
onChange={(event) => setTtlHours(Number(event.target.value))}
|
||||
>
|
||||
{TTL_PRESETS.map((preset) => (
|
||||
<MenuItem key={preset.value} value={preset.value}>
|
||||
{preset.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl size="small" sx={{ minWidth: 160 }}>
|
||||
<InputLabel id="uses-select-label">Allowed Uses</InputLabel>
|
||||
<Select
|
||||
labelId="uses-select-label"
|
||||
label="Allowed Uses"
|
||||
value={maxUses}
|
||||
onChange={(event) => setMaxUses(Number(event.target.value))}
|
||||
>
|
||||
{[1, 2, 3, 5].map((uses) => (
|
||||
<MenuItem key={uses} value={uses}>
|
||||
{uses === 1 ? "Single use" : `${uses} uses`}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleGenerate}
|
||||
disabled={generating}
|
||||
startIcon={generating ? <CircularProgress size={16} color="inherit" /> : null}
|
||||
>
|
||||
{generating ? "Generating…" : "Generate Code"}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={fetchCodes}
|
||||
disabled={loading}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{feedback ? (
|
||||
<Alert
|
||||
severity={feedback.type}
|
||||
onClose={() => setFeedback(null)}
|
||||
variant="outlined"
|
||||
>
|
||||
{feedback.message}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<Alert severity="error" variant="outlined">
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<TableContainer component={Paper} variant="outlined" sx={{ maxHeight: 420 }}>
|
||||
<Table size="small" stickyHeader>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Installer Code</TableCell>
|
||||
<TableCell>Expires At</TableCell>
|
||||
<TableCell>Created By</TableCell>
|
||||
<TableCell>Usage</TableCell>
|
||||
<TableCell>Last Used</TableCell>
|
||||
<TableCell>Consumed At</TableCell>
|
||||
<TableCell>Used By GUID</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} align="center">
|
||||
<Stack direction="row" spacing={1} alignItems="center" justifyContent="center">
|
||||
<CircularProgress size={20} />
|
||||
<Typography variant="body2">Loading installer codes…</Typography>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredCodes.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} align="center">
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No installer codes match this filter.
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredCodes.map((record) => {
|
||||
const status = determineStatus(record);
|
||||
const maxAllowed = Math.max(1, Number.isFinite(record?.max_uses) ? record.max_uses : 1);
|
||||
const usageCount = Math.max(0, Number.isFinite(record?.use_count) ? record.use_count : 0);
|
||||
const disableDelete = usageCount !== 0;
|
||||
return (
|
||||
<TableRow hover key={record.id}>
|
||||
<TableCell>{renderStatusChip(record)}</TableCell>
|
||||
<TableCell sx={{ fontFamily: "monospace" }}>{maskCode(record.code)}</TableCell>
|
||||
<TableCell>{formatDateTime(record.expires_at)}</TableCell>
|
||||
<TableCell>{record.created_by_user_id || "—"}</TableCell>
|
||||
<TableCell sx={{ fontFamily: "monospace" }}>{`${usageCount} / ${maxAllowed}`}</TableCell>
|
||||
<TableCell>{formatDateTime(record.last_used_at)}</TableCell>
|
||||
<TableCell>{formatDateTime(record.used_at)}</TableCell>
|
||||
<TableCell sx={{ fontFamily: "monospace" }}>
|
||||
{record.used_by_guid || "—"}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="Copy code">
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleCopy(record.code)}
|
||||
disabled={!record.code}
|
||||
>
|
||||
<CopyIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title={disableDelete ? "Only unused codes can be deleted" : "Delete code"}>
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleDelete(record.id)}
|
||||
disabled={disableDelete}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(EnrollmentCodes);
|
||||
Reference in New Issue
Block a user