mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-12-16 10:05:48 -07:00
Added Basic User Management
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -22,5 +22,7 @@ __pycache__
|
|||||||
/Agent/Python_API_Endpoints/__pycache__/
|
/Agent/Python_API_Endpoints/__pycache__/
|
||||||
/Update_Staging/
|
/Update_Staging/
|
||||||
agent_settings.json
|
agent_settings.json
|
||||||
|
agent_settings_svc.json
|
||||||
|
agent_settings_user.json
|
||||||
users.json
|
users.json
|
||||||
database.db
|
database.db
|
||||||
@@ -517,15 +517,6 @@ switch ($choice) {
|
|||||||
$host.UI.RawUI.WindowTitle = "Borealis Server"
|
$host.UI.RawUI.WindowTitle = "Borealis Server"
|
||||||
Write-Host "Ensuring Server Dependencies Exist..." -ForegroundColor DarkCyan
|
Write-Host "Ensuring Server Dependencies Exist..." -ForegroundColor DarkCyan
|
||||||
|
|
||||||
Run-Step "First-Run: Generating users.json" {
|
|
||||||
$usersJsonPath = Join-Path $scriptDir "users.json"
|
|
||||||
if (-not (Test-Path $usersJsonPath)) {
|
|
||||||
$defaultUsers = @{ users = @(@{ username = "admin"; password = "e6c83b282aeb2e022844595721cc00bbda47cb24537c1779f9bb84f04039e1676e6ba8573e588da1052510e3aa0a32a9e55879ae22b0c2d62136fc0a3e85f8bb" }) } | ConvertTo-Json -Depth 4
|
|
||||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
|
||||||
[System.IO.File]::WriteAllText($usersJsonPath, $defaultUsers, $utf8NoBom)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Install_Shared_Dependencies
|
Install_Shared_Dependencies
|
||||||
Install_Server_Dependencies
|
Install_Server_Dependencies
|
||||||
|
|
||||||
|
|||||||
14
Data/Server/WebUI/src/Admin/Server_Info.jsx
Normal file
14
Data/Server/WebUI/src/Admin/Server_Info.jsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Paper, Box, Typography } from "@mui/material";
|
||||||
|
|
||||||
|
export default function ServerInfo() {
|
||||||
|
return (
|
||||||
|
<Paper sx={{ m: 2, p: 0, bgcolor: "#1e1e1e" }} elevation={2}>
|
||||||
|
<Box sx={{ p: 2 }}>
|
||||||
|
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 1 }}>Server Info</Typography>
|
||||||
|
<Typography sx={{ color: '#aaa' }}>Basic server information will appear here.</Typography>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
427
Data/Server/WebUI/src/Admin/User_Management.jsx
Normal file
427
Data/Server/WebUI/src/Admin/User_Management.jsx
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
import React, { useEffect, useMemo, useState, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
Paper,
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableSortLabel,
|
||||||
|
IconButton,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogContentText,
|
||||||
|
DialogActions,
|
||||||
|
TextField,
|
||||||
|
Select,
|
||||||
|
FormControl,
|
||||||
|
InputLabel
|
||||||
|
} from "@mui/material";
|
||||||
|
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||||
|
import { ConfirmDeleteDialog } from "../Dialogs.jsx";
|
||||||
|
|
||||||
|
function formatTs(tsSec) {
|
||||||
|
if (!tsSec) return "-";
|
||||||
|
const d = new Date((tsSec || 0) * 1000);
|
||||||
|
const date = d.toLocaleDateString("en-US", { month: "2-digit", day: "2-digit", year: "numeric" });
|
||||||
|
const time = d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
|
||||||
|
return `${date} @ ${time}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sha512(text) {
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
const data = enc.encode(text || "");
|
||||||
|
const buf = await crypto.subtle.digest("SHA-512", data);
|
||||||
|
const arr = Array.from(new Uint8Array(buf));
|
||||||
|
return arr.map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserManagement() {
|
||||||
|
const [rows, setRows] = useState([]); // {username, display_name, role, last_login}
|
||||||
|
const [orderBy, setOrderBy] = useState("username");
|
||||||
|
const [order, setOrder] = useState("asc");
|
||||||
|
const [menuAnchor, setMenuAnchor] = useState(null);
|
||||||
|
const [menuUser, setMenuUser] = useState(null);
|
||||||
|
const [resetOpen, setResetOpen] = useState(false);
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [createForm, setCreateForm] = useState({ username: "", display_name: "", password: "", role: "User" });
|
||||||
|
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||||
|
const [confirmChangeRoleOpen, setConfirmChangeRoleOpen] = useState(false);
|
||||||
|
const [changeRoleTarget, setChangeRoleTarget] = useState(null);
|
||||||
|
const [changeRoleNext, setChangeRoleNext] = useState(null);
|
||||||
|
const [warnOpen, setWarnOpen] = useState(false);
|
||||||
|
const [warnMessage, setWarnMessage] = useState("");
|
||||||
|
const [me, setMe] = useState(null);
|
||||||
|
|
||||||
|
const fetchUsers = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/users", { credentials: "include" });
|
||||||
|
const data = await res.json();
|
||||||
|
setRows(Array.isArray(data?.users) ? data.users : []);
|
||||||
|
} catch {
|
||||||
|
setRows([]);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUsers();
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/auth/me', { credentials: 'include' });
|
||||||
|
if (resp.ok) {
|
||||||
|
const who = await resp.json();
|
||||||
|
setMe(who);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
})();
|
||||||
|
}, [fetchUsers]);
|
||||||
|
|
||||||
|
const handleSort = (col) => {
|
||||||
|
if (orderBy === col) setOrder(order === "asc" ? "desc" : "asc");
|
||||||
|
else { setOrderBy(col); setOrder("asc"); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const sorted = useMemo(() => {
|
||||||
|
const dir = order === "asc" ? 1 : -1;
|
||||||
|
const arr = [...rows];
|
||||||
|
arr.sort((a, b) => {
|
||||||
|
if (orderBy === "last_login") return ((a.last_login || 0) - (b.last_login || 0)) * dir;
|
||||||
|
return String(a[orderBy] ?? "").toLowerCase().localeCompare(String(b[orderBy] ?? "").toLowerCase()) * dir;
|
||||||
|
});
|
||||||
|
return arr;
|
||||||
|
}, [rows, orderBy, order]);
|
||||||
|
|
||||||
|
const openMenu = (evt, user) => {
|
||||||
|
setMenuAnchor({ mouseX: evt.clientX, mouseY: evt.clientY, anchorEl: evt.currentTarget });
|
||||||
|
setMenuUser(user);
|
||||||
|
};
|
||||||
|
const closeMenu = () => { setMenuAnchor(null); setMenuUser(null); };
|
||||||
|
|
||||||
|
const confirmDelete = (user) => {
|
||||||
|
if (!user) return;
|
||||||
|
if (me && user.username && String(me.username).toLowerCase() === String(user.username).toLowerCase()) {
|
||||||
|
setWarnMessage("You cannot delete the user you are currently logged in as.");
|
||||||
|
setWarnOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDeleteTarget(user);
|
||||||
|
setConfirmDeleteOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const doDelete = async () => {
|
||||||
|
const user = deleteTarget;
|
||||||
|
setConfirmDeleteOpen(false);
|
||||||
|
if (!user) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/users/${encodeURIComponent(user.username)}`, { method: 'DELETE', credentials: 'include' });
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok) {
|
||||||
|
setWarnMessage(data?.error || "Failed to delete user");
|
||||||
|
setWarnOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fetchUsers();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setWarnMessage("Failed to delete user");
|
||||||
|
setWarnOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openChangeRole = (user) => {
|
||||||
|
if (!user) return;
|
||||||
|
const nextRole = (String(user.role || 'User').toLowerCase() === 'admin') ? 'User' : 'Admin';
|
||||||
|
setChangeRoleTarget(user);
|
||||||
|
setChangeRoleNext(nextRole);
|
||||||
|
setConfirmChangeRoleOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const doChangeRole = async () => {
|
||||||
|
const user = changeRoleTarget;
|
||||||
|
const nextRole = changeRoleNext;
|
||||||
|
setConfirmChangeRoleOpen(false);
|
||||||
|
if (!user || !nextRole) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/users/${encodeURIComponent(user.username)}/role`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ role: nextRole })
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok) {
|
||||||
|
setWarnMessage(data?.error || "Failed to change role");
|
||||||
|
setWarnOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fetchUsers();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setWarnMessage("Failed to change role");
|
||||||
|
setWarnOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const doResetPassword = async () => {
|
||||||
|
const user = menuUser;
|
||||||
|
if (!user) return;
|
||||||
|
const pw = newPassword || '';
|
||||||
|
if (!pw.trim()) return;
|
||||||
|
try {
|
||||||
|
const hash = await sha512(pw);
|
||||||
|
const resp = await fetch(`/api/users/${encodeURIComponent(user.username)}/reset_password`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ password_sha512: hash })
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok) {
|
||||||
|
alert(data?.error || "Failed to reset password");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setResetOpen(false);
|
||||||
|
setNewPassword("");
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert("Failed to reset password");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openReset = () => { setResetOpen(true); setNewPassword(""); closeMenu(); };
|
||||||
|
|
||||||
|
const openCreate = () => { setCreateOpen(true); setCreateForm({ username: "", display_name: "", password: "", role: "User" }); };
|
||||||
|
const doCreate = async () => {
|
||||||
|
const u = (createForm.username || '').trim();
|
||||||
|
const dn = (createForm.display_name || u).trim();
|
||||||
|
const pw = (createForm.password || '').trim();
|
||||||
|
const role = (createForm.role || 'User');
|
||||||
|
if (!u || !pw) return;
|
||||||
|
try {
|
||||||
|
const hash = await sha512(pw);
|
||||||
|
const resp = await fetch('/api/users', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ username: u, display_name: dn, password_sha512: hash, role })
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok) {
|
||||||
|
alert(data?.error || 'Failed to create user');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCreateOpen(false);
|
||||||
|
await fetchUsers();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert('Failed to create user');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Paper sx={{ m: 2, p: 0, bgcolor: "#1e1e1e" }} elevation={2}>
|
||||||
|
<Box sx={{ p: 2, pb: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0 }}>User Management</Typography>
|
||||||
|
<Typography sx={{ color: '#aaa', fontSize: '0.85rem', mt: 0.5 }}>
|
||||||
|
Create, Edit, and Remove Borealis Server Operators
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
onClick={openCreate}
|
||||||
|
sx={{ color: '#58a6ff', borderColor: '#58a6ff', textTransform: 'none' }}
|
||||||
|
>
|
||||||
|
Create User
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Table size="small" sx={{ minWidth: 700 }}>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
{[
|
||||||
|
{ id: 'display_name', label: 'Display Name' },
|
||||||
|
{ id: 'username', label: 'User Name' },
|
||||||
|
{ id: 'last_login', label: 'Last Login' },
|
||||||
|
{ id: 'role', label: 'User Role' },
|
||||||
|
{ id: 'actions', label: '' },
|
||||||
|
].map((col) => (
|
||||||
|
<TableCell key={col.id} sortDirection={['actions'].includes(col.id) ? false : (orderBy === col.id ? order : false)}>
|
||||||
|
{col.id !== 'actions' ? (
|
||||||
|
<TableSortLabel
|
||||||
|
active={orderBy === col.id}
|
||||||
|
direction={orderBy === col.id ? order : 'asc'}
|
||||||
|
onClick={() => handleSort(col.id)}
|
||||||
|
>
|
||||||
|
{col.label}
|
||||||
|
</TableSortLabel>
|
||||||
|
) : null}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{sorted.map((u) => (
|
||||||
|
<TableRow key={u.username} hover>
|
||||||
|
<TableCell>{u.display_name || u.username}</TableCell>
|
||||||
|
<TableCell>{u.username}</TableCell>
|
||||||
|
<TableCell>{formatTs(u.last_login)}</TableCell>
|
||||||
|
<TableCell>{u.role || 'User'}</TableCell>
|
||||||
|
<TableCell align="right" sx={{ width: 1 }}>
|
||||||
|
<IconButton size="small" onClick={(e) => openMenu(e, u)} sx={{ color: '#aaa' }}>
|
||||||
|
<MoreVertIcon fontSize="inherit" />
|
||||||
|
</IconButton>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<Menu
|
||||||
|
anchorEl={menuAnchor?.anchorEl}
|
||||||
|
open={Boolean(menuAnchor)}
|
||||||
|
onClose={closeMenu}
|
||||||
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||||
|
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||||
|
PaperProps={{ sx: { bgcolor: '#1e1e1e', color: '#fff' } }}
|
||||||
|
>
|
||||||
|
<MenuItem
|
||||||
|
disabled={me && menuUser && String(me.username).toLowerCase() === String(menuUser.username).toLowerCase()}
|
||||||
|
onClick={() => { const u = menuUser; closeMenu(); confirmDelete(u); }}
|
||||||
|
>
|
||||||
|
Delete User
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={() => { openReset(); }}>Reset Password</MenuItem>
|
||||||
|
<MenuItem onClick={() => { const u = menuUser; closeMenu(); openChangeRole(u); }}>Change Role</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
<Dialog open={resetOpen} onClose={() => setResetOpen(false)} PaperProps={{ sx: { bgcolor: '#121212', color: '#fff' } }}>
|
||||||
|
<DialogTitle>Reset Password</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText sx={{ color: '#ccc' }}>
|
||||||
|
Enter a new password for {menuUser?.username}.
|
||||||
|
</DialogContentText>
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
margin="dense"
|
||||||
|
fullWidth
|
||||||
|
label="New Password"
|
||||||
|
type="password"
|
||||||
|
variant="outlined"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
sx={{
|
||||||
|
"& .MuiOutlinedInput-root": {
|
||||||
|
backgroundColor: "#2a2a2a",
|
||||||
|
color: "#ccc",
|
||||||
|
"& fieldset": { borderColor: "#444" },
|
||||||
|
"&:hover fieldset": { borderColor: "#666" }
|
||||||
|
},
|
||||||
|
label: { color: "#aaa" },
|
||||||
|
mt: 1
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setResetOpen(false)} sx={{ color: '#58a6ff' }}>Cancel</Button>
|
||||||
|
<Button onClick={doResetPassword} sx={{ color: '#58a6ff' }}>OK</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} PaperProps={{ sx: { bgcolor: '#121212', color: '#fff' } }}>
|
||||||
|
<DialogTitle>Create User</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
margin="dense"
|
||||||
|
fullWidth
|
||||||
|
label="Username"
|
||||||
|
variant="outlined"
|
||||||
|
value={createForm.username}
|
||||||
|
onChange={(e) => setCreateForm((p) => ({ ...p, username: e.target.value }))}
|
||||||
|
sx={{
|
||||||
|
"& .MuiOutlinedInput-root": { backgroundColor: "#2a2a2a", color: "#ccc", "& fieldset": { borderColor: "#444" }, "&:hover fieldset": { borderColor: "#666" } },
|
||||||
|
label: { color: "#aaa" }, mt: 1
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
fullWidth
|
||||||
|
label="Display Name (optional)"
|
||||||
|
variant="outlined"
|
||||||
|
value={createForm.display_name}
|
||||||
|
onChange={(e) => setCreateForm((p) => ({ ...p, display_name: e.target.value }))}
|
||||||
|
sx={{
|
||||||
|
"& .MuiOutlinedInput-root": { backgroundColor: "#2a2a2a", color: "#ccc", "& fieldset": { borderColor: "#444" }, "&:hover fieldset": { borderColor: "#666" } },
|
||||||
|
label: { color: "#aaa" }, mt: 1
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
fullWidth
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
variant="outlined"
|
||||||
|
value={createForm.password}
|
||||||
|
onChange={(e) => setCreateForm((p) => ({ ...p, password: e.target.value }))}
|
||||||
|
sx={{
|
||||||
|
"& .MuiOutlinedInput-root": { backgroundColor: "#2a2a2a", color: "#ccc", "& fieldset": { borderColor: "#444" }, "&:hover fieldset": { borderColor: "#666" } },
|
||||||
|
label: { color: "#aaa" }, mt: 1
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FormControl fullWidth sx={{ mt: 2 }}>
|
||||||
|
<InputLabel sx={{ color: '#aaa' }}>Role</InputLabel>
|
||||||
|
<Select
|
||||||
|
native
|
||||||
|
value={createForm.role}
|
||||||
|
onChange={(e) => setCreateForm((p) => ({ ...p, role: e.target.value }))}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: '#2a2a2a',
|
||||||
|
color: '#ccc',
|
||||||
|
borderColor: '#444'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="User">User</option>
|
||||||
|
<option value="Admin">Admin</option>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setCreateOpen(false)} sx={{ color: '#58a6ff' }}>Cancel</Button>
|
||||||
|
<Button onClick={doCreate} sx={{ color: '#58a6ff' }}>Create</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Paper>
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open={confirmDeleteOpen}
|
||||||
|
message={`Are you sure you want to delete user '${deleteTarget?.username || ''}'?`}
|
||||||
|
onCancel={() => setConfirmDeleteOpen(false)}
|
||||||
|
onConfirm={doDelete}
|
||||||
|
/>
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open={confirmChangeRoleOpen}
|
||||||
|
message={changeRoleTarget ? `Change role for '${changeRoleTarget.username}' to ${changeRoleNext}?` : ''}
|
||||||
|
onCancel={() => setConfirmChangeRoleOpen(false)}
|
||||||
|
onConfirm={doChangeRole}
|
||||||
|
/>
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open={warnOpen}
|
||||||
|
message={warnMessage}
|
||||||
|
onCancel={() => setWarnOpen(false)}
|
||||||
|
onConfirm={() => setWarnOpen(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -35,6 +35,8 @@ import DeviceDetails from "./Devices/Device_Details";
|
|||||||
import WorkflowList from "./Workflows/Workflow_List";
|
import WorkflowList from "./Workflows/Workflow_List";
|
||||||
import ScriptEditor from "./Scripting/Script_Editor";
|
import ScriptEditor from "./Scripting/Script_Editor";
|
||||||
import ScheduledJobsList from "./Scheduling/Scheduled_Jobs_List";
|
import ScheduledJobsList from "./Scheduling/Scheduled_Jobs_List";
|
||||||
|
import UserManagement from "./Admin/User_Management.jsx";
|
||||||
|
import ServerInfo from "./Admin/Server_Info.jsx";
|
||||||
|
|
||||||
// Networking Imports
|
// Networking Imports
|
||||||
import { io } from "socket.io-client";
|
import { io } from "socket.io-client";
|
||||||
@@ -97,6 +99,7 @@ export default function App() {
|
|||||||
const [tabMenuTabId, setTabMenuTabId] = useState(null);
|
const [tabMenuTabId, setTabMenuTabId] = useState(null);
|
||||||
const fileInputRef = useRef(null);
|
const fileInputRef = useRef(null);
|
||||||
const [user, setUser] = useState(null);
|
const [user, setUser] = useState(null);
|
||||||
|
const [userRole, setUserRole] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const session = localStorage.getItem("borealis_session");
|
const session = localStorage.getItem("borealis_session");
|
||||||
@@ -105,6 +108,7 @@ export default function App() {
|
|||||||
const data = JSON.parse(session);
|
const data = JSON.parse(session);
|
||||||
if (Date.now() - data.timestamp < 3600 * 1000) {
|
if (Date.now() - data.timestamp < 3600 * 1000) {
|
||||||
setUser(data.username);
|
setUser(data.username);
|
||||||
|
setUserRole(data.role || null);
|
||||||
} else {
|
} else {
|
||||||
localStorage.removeItem("borealis_session");
|
localStorage.removeItem("borealis_session");
|
||||||
}
|
}
|
||||||
@@ -112,13 +116,28 @@ export default function App() {
|
|||||||
localStorage.removeItem("borealis_session");
|
localStorage.removeItem("borealis_session");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/auth/me', { credentials: 'include' });
|
||||||
|
if (resp.ok) {
|
||||||
|
const me = await resp.json();
|
||||||
|
setUser(me.username);
|
||||||
|
setUserRole(me.role || null);
|
||||||
|
localStorage.setItem(
|
||||||
|
"borealis_session",
|
||||||
|
JSON.stringify({ username: me.username, role: me.role, timestamp: Date.now() })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleLoginSuccess = (username) => {
|
const handleLoginSuccess = ({ username, role }) => {
|
||||||
setUser(username);
|
setUser(username);
|
||||||
|
setUserRole(role || null);
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"borealis_session",
|
"borealis_session",
|
||||||
JSON.stringify({ username, timestamp: Date.now() })
|
JSON.stringify({ username, role: role || null, timestamp: Date.now() })
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -391,6 +410,12 @@ export default function App() {
|
|||||||
case "scripts":
|
case "scripts":
|
||||||
return <ScriptEditor />;
|
return <ScriptEditor />;
|
||||||
|
|
||||||
|
case "admin_users":
|
||||||
|
return <UserManagement />;
|
||||||
|
|
||||||
|
case "server_info":
|
||||||
|
return <ServerInfo />;
|
||||||
|
|
||||||
case "workflow-editor":
|
case "workflow-editor":
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", flexGrow: 1, overflow: "hidden" }}>
|
<Box sx={{ display: "flex", flexDirection: "column", flexGrow: 1, overflow: "hidden" }}>
|
||||||
@@ -487,7 +512,7 @@ export default function App() {
|
|||||||
</Toolbar>
|
</Toolbar>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
<Box sx={{ display: "flex", flexGrow: 1, overflow: "hidden" }}>
|
<Box sx={{ display: "flex", flexGrow: 1, overflow: "hidden" }}>
|
||||||
<NavigationSidebar currentPage={currentPage} onNavigate={setCurrentPage} />
|
<NavigationSidebar currentPage={currentPage} onNavigate={setCurrentPage} isAdmin={(String(userRole||'').toLowerCase()==='admin')} />
|
||||||
<Box sx={{ flexGrow: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
<Box sx={{ flexGrow: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
||||||
{renderMainContent()}
|
{renderMainContent()}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,19 +1,11 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState } from "react";
|
||||||
import { Box, TextField, Button, Typography } from "@mui/material";
|
import { Box, TextField, Button, Typography } from "@mui/material";
|
||||||
|
|
||||||
export default function Login({ onLogin }) {
|
export default function Login({ onLogin }) {
|
||||||
const [users, setUsers] = useState([]);
|
|
||||||
const [username, setUsername] = useState("admin");
|
const [username, setUsername] = useState("admin");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch("/api/users")
|
|
||||||
.then((res) => res.json())
|
|
||||||
.then((data) => setUsers(data.users || []))
|
|
||||||
.catch(() => setUsers([]));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const sha512 = async (text) => {
|
const sha512 = async (text) => {
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const data = encoder.encode(text);
|
const data = encoder.encode(text);
|
||||||
@@ -24,16 +16,19 @@ export default function Login({ onLogin }) {
|
|||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const user = users.find((u) => u.username === username);
|
try {
|
||||||
if (!user) {
|
const hash = await sha512(password);
|
||||||
setError("Invalid username or password");
|
const resp = await fetch("/api/auth/login", {
|
||||||
return;
|
method: "POST",
|
||||||
}
|
headers: { "Content-Type": "application/json" },
|
||||||
const hash = await sha512(password);
|
credentials: "include",
|
||||||
if (hash.toLowerCase() === (user.password || "").toLowerCase()) {
|
body: JSON.stringify({ username, password_sha512: hash })
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok) throw new Error(data?.error || `HTTP ${resp.status}`);
|
||||||
setError("");
|
setError("");
|
||||||
onLogin(username);
|
onLogin({ username: data.username, role: data.role });
|
||||||
} else {
|
} catch (err) {
|
||||||
setError("Invalid username or password");
|
setError("Invalid username or password");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -100,4 +95,3 @@ export default function Login({ onLogin }) {
|
|||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,13 +21,15 @@ import {
|
|||||||
PeopleOutline as CommunityIcon
|
PeopleOutline as CommunityIcon
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
import { LocationCity as SitesIcon } from "@mui/icons-material";
|
import { LocationCity as SitesIcon } from "@mui/icons-material";
|
||||||
|
import { ManageAccounts as AdminUsersIcon, Dns as ServerInfoIcon } from "@mui/icons-material";
|
||||||
|
|
||||||
function NavigationSidebar({ currentPage, onNavigate }) {
|
function NavigationSidebar({ currentPage, onNavigate, isAdmin = false }) {
|
||||||
const [expandedNav, setExpandedNav] = useState({
|
const [expandedNav, setExpandedNav] = useState({
|
||||||
sites: true,
|
sites: true,
|
||||||
devices: true,
|
devices: true,
|
||||||
automation: true,
|
automation: true,
|
||||||
filters: true
|
filters: true,
|
||||||
|
admin: true
|
||||||
});
|
});
|
||||||
|
|
||||||
const NavItem = ({ icon, label, pageKey, indent = 0 }) => {
|
const NavItem = ({ icon, label, pageKey, indent = 0 }) => {
|
||||||
@@ -286,6 +288,53 @@ function NavigationSidebar({ currentPage, onNavigate }) {
|
|||||||
</Accordion>
|
</Accordion>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
|
{/* Admin */}
|
||||||
|
{(() => {
|
||||||
|
if (!isAdmin) return null;
|
||||||
|
const groupActive = currentPage === "admin_users" || currentPage === "server_info";
|
||||||
|
return (
|
||||||
|
<Accordion
|
||||||
|
expanded={expandedNav.admin}
|
||||||
|
onChange={(_, e) => setExpandedNav((s) => ({ ...s, admin: e }))}
|
||||||
|
square
|
||||||
|
disableGutters
|
||||||
|
sx={{ "&:before": { display: "none" }, margin: 0, border: 0 }}
|
||||||
|
>
|
||||||
|
<AccordionSummary
|
||||||
|
expandIcon={<ExpandMoreIcon />}
|
||||||
|
sx={{
|
||||||
|
position: "relative",
|
||||||
|
background: groupActive
|
||||||
|
? "linear-gradient(90deg, rgba(88,166,255,0.08) 0%, rgba(88,166,255,0.00) 100%)"
|
||||||
|
: "#2c2c2c",
|
||||||
|
minHeight: "36px",
|
||||||
|
"& .MuiAccordionSummary-content": { margin: 0 },
|
||||||
|
"&::before": {
|
||||||
|
content: '""',
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: groupActive ? 3 : 0,
|
||||||
|
bgcolor: "#58a6ff",
|
||||||
|
borderTopRightRadius: 2,
|
||||||
|
borderBottomRightRadius: 2,
|
||||||
|
transition: "width 160ms ease"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography sx={{ fontSize: "0.85rem", color: "#58a6ff" }}>
|
||||||
|
<b>Admin</b>
|
||||||
|
</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails sx={{ p: 0, bgcolor: "#232323" }}>
|
||||||
|
<NavItem icon={<AdminUsersIcon fontSize="small" />} label="User Management" pageKey="admin_users" />
|
||||||
|
<NavItem icon={<ServerInfoIcon fontSize="small" />} label="Server Info" pageKey="server_info" />
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import eventlet
|
|||||||
eventlet.monkey_patch()
|
eventlet.monkey_patch()
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from flask import Flask, request, jsonify, Response, send_from_directory, make_response
|
from flask import Flask, request, jsonify, Response, send_from_directory, make_response, session
|
||||||
from flask_socketio import SocketIO, emit, join_room
|
from flask_socketio import SocketIO, emit, join_room
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
|
|
||||||
@@ -30,8 +30,11 @@ app = Flask(
|
|||||||
static_url_path=''
|
static_url_path=''
|
||||||
)
|
)
|
||||||
|
|
||||||
# Enable CORS on All Routes
|
# Enable CORS on All Routes (allow credentials for dev UI)
|
||||||
CORS(app)
|
CORS(app, supports_credentials=True)
|
||||||
|
|
||||||
|
# Basic secret key for session cookies (can be overridden via env)
|
||||||
|
app.secret_key = os.environ.get('BOREALIS_SECRET', 'borealis-dev-secret')
|
||||||
|
|
||||||
socketio = SocketIO(
|
socketio = SocketIO(
|
||||||
app,
|
app,
|
||||||
@@ -65,18 +68,270 @@ def health():
|
|||||||
return jsonify({"status": "ok"})
|
return jsonify({"status": "ok"})
|
||||||
|
|
||||||
# ---------------------------------------------
|
# ---------------------------------------------
|
||||||
# User Authentication Endpoint
|
# Auth + Users (DB-backed)
|
||||||
# ---------------------------------------------
|
# ---------------------------------------------
|
||||||
@app.route("/api/users", methods=["GET"])
|
def _now_ts() -> int:
|
||||||
def get_users():
|
return int(time.time())
|
||||||
users_path = os.path.abspath(
|
|
||||||
os.path.join(os.path.dirname(__file__), "..", "..", "users.json")
|
|
||||||
)
|
def _sha512_hex(s: str) -> str:
|
||||||
|
import hashlib
|
||||||
|
return hashlib.sha512((s or '').encode('utf-8')).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _db_conn():
|
||||||
|
return sqlite3.connect(DB_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
def _user_row_to_dict(row):
|
||||||
|
# id, username, display_name, role, last_login, created_at, updated_at
|
||||||
|
return {
|
||||||
|
"id": row[0],
|
||||||
|
"username": row[1],
|
||||||
|
"display_name": row[2] or row[1],
|
||||||
|
"role": row[3] or "User",
|
||||||
|
"last_login": row[4] or 0,
|
||||||
|
"created_at": row[5] or 0,
|
||||||
|
"updated_at": row[6] or 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _current_user():
|
||||||
|
u = session.get('username')
|
||||||
|
role = session.get('role')
|
||||||
|
if not u:
|
||||||
|
return None
|
||||||
|
return {"username": u, "role": role or "User"}
|
||||||
|
|
||||||
|
|
||||||
|
def _require_login():
|
||||||
|
user = _current_user()
|
||||||
|
if not user:
|
||||||
|
return jsonify({"error": "unauthorized"}), 401
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _require_admin():
|
||||||
|
user = _current_user()
|
||||||
|
if not user:
|
||||||
|
return jsonify({"error": "unauthorized"}), 401
|
||||||
|
if (user.get('role') or '').lower() != 'admin':
|
||||||
|
return jsonify({"error": "forbidden"}), 403
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/auth/login", methods=["POST"])
|
||||||
|
def api_login():
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
username = (payload.get('username') or '').strip()
|
||||||
|
password = payload.get('password') # plain (optional)
|
||||||
|
password_sha512 = (payload.get('password_sha512') or '').strip().lower()
|
||||||
|
if not username or (not password and not password_sha512):
|
||||||
|
return jsonify({"error": "missing credentials"}), 400
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(users_path, "r", encoding="utf-8") as fh:
|
conn = _db_conn()
|
||||||
return jsonify(json.load(fh))
|
cur = conn.cursor()
|
||||||
except Exception:
|
cur.execute(
|
||||||
return jsonify({"users": []})
|
"SELECT id, username, display_name, password_sha512, role, last_login, created_at, updated_at FROM users WHERE LOWER(username)=LOWER(?)",
|
||||||
|
(username,)
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({"error": "invalid username or password"}), 401
|
||||||
|
stored_hash = (row[3] or '').lower()
|
||||||
|
check_hash = password_sha512 or _sha512_hex(password or '')
|
||||||
|
if stored_hash != (check_hash or '').lower():
|
||||||
|
conn.close()
|
||||||
|
return jsonify({"error": "invalid username or password"}), 401
|
||||||
|
role = row[4] or 'User'
|
||||||
|
# update last_login
|
||||||
|
now = _now_ts()
|
||||||
|
cur.execute("UPDATE users SET last_login=?, updated_at=? WHERE id=?", (now, now, row[0]))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
# set session cookie
|
||||||
|
session['username'] = row[1]
|
||||||
|
session['role'] = role
|
||||||
|
return jsonify({"status": "ok", "username": row[1], "role": role})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/auth/logout", methods=["POST"]) # simple logout
|
||||||
|
def api_logout():
|
||||||
|
session.clear()
|
||||||
|
return jsonify({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/auth/me", methods=["GET"]) # whoami
|
||||||
|
def api_me():
|
||||||
|
user = _current_user()
|
||||||
|
if not user:
|
||||||
|
return jsonify({"error": "unauthorized"}), 401
|
||||||
|
return jsonify(user)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/users", methods=["GET"])
|
||||||
|
def api_users_list():
|
||||||
|
chk = _require_admin()
|
||||||
|
if chk:
|
||||||
|
return chk
|
||||||
|
try:
|
||||||
|
conn = _db_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, username, display_name, role, last_login, created_at, updated_at FROM users ORDER BY LOWER(username) ASC"
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
conn.close()
|
||||||
|
users = [
|
||||||
|
{
|
||||||
|
"id": r[0],
|
||||||
|
"username": r[1],
|
||||||
|
"display_name": r[2] or r[1],
|
||||||
|
"role": r[3] or 'User',
|
||||||
|
"last_login": r[4] or 0,
|
||||||
|
"created_at": r[5] or 0,
|
||||||
|
"updated_at": r[6] or 0,
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
return jsonify({"users": users})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/users", methods=["POST"]) # create user
|
||||||
|
def api_users_create():
|
||||||
|
chk = _require_admin()
|
||||||
|
if chk:
|
||||||
|
return chk
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
username = (data.get('username') or '').strip()
|
||||||
|
display_name = (data.get('display_name') or username).strip()
|
||||||
|
role = (data.get('role') or 'User').strip().title()
|
||||||
|
password_sha512 = (data.get('password_sha512') or '').strip().lower()
|
||||||
|
if not username or not password_sha512:
|
||||||
|
return jsonify({"error": "username and password_sha512 are required"}), 400
|
||||||
|
if role not in ('User', 'Admin'):
|
||||||
|
return jsonify({"error": "invalid role"}), 400
|
||||||
|
now = _now_ts()
|
||||||
|
try:
|
||||||
|
conn = _db_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO users(username, display_name, password_sha512, role, created_at, updated_at) VALUES(?,?,?,?,?,?)",
|
||||||
|
(username, display_name or username, password_sha512, role, now, now)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return jsonify({"status": "ok"})
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
return jsonify({"error": "username already exists"}), 409
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/users/<username>", methods=["DELETE"]) # delete user
|
||||||
|
def api_users_delete(username):
|
||||||
|
chk = _require_admin()
|
||||||
|
if chk:
|
||||||
|
return chk
|
||||||
|
username = (username or '').strip()
|
||||||
|
if not username:
|
||||||
|
return jsonify({"error": "invalid username"}), 400
|
||||||
|
try:
|
||||||
|
conn = _db_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
# prevent deleting current user
|
||||||
|
me = _current_user()
|
||||||
|
if me and (me.get('username','').lower() == username.lower()):
|
||||||
|
conn.close()
|
||||||
|
return jsonify({"error": "You cannot delete the user you are currently logged in as."}), 400
|
||||||
|
# ensure at least one other user remains
|
||||||
|
cur.execute("SELECT COUNT(*) FROM users")
|
||||||
|
total = cur.fetchone()[0] or 0
|
||||||
|
if total <= 1:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({"error": "There is only one user currently configured, you cannot delete this user until you have created another."}), 400
|
||||||
|
cur.execute("DELETE FROM users WHERE LOWER(username)=LOWER(?)", (username,))
|
||||||
|
deleted = cur.rowcount
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
if deleted == 0:
|
||||||
|
return jsonify({"error": "user not found"}), 404
|
||||||
|
return jsonify({"status": "ok"})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/users/<username>/reset_password", methods=["POST"]) # reset password
|
||||||
|
def api_users_reset_password(username):
|
||||||
|
chk = _require_admin()
|
||||||
|
if chk:
|
||||||
|
return chk
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
password_sha512 = (data.get('password_sha512') or '').strip().lower()
|
||||||
|
if not password_sha512 or len(password_sha512) != 128:
|
||||||
|
return jsonify({"error": "invalid password hash"}), 400
|
||||||
|
try:
|
||||||
|
conn = _db_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
now = _now_ts()
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE users SET password_sha512=?, updated_at=? WHERE LOWER(username)=LOWER(?)",
|
||||||
|
(password_sha512, now, username)
|
||||||
|
)
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({"error": "user not found"}), 404
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return jsonify({"status": "ok"})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/users/<username>/role", methods=["POST"]) # change role
|
||||||
|
def api_users_change_role(username):
|
||||||
|
chk = _require_admin()
|
||||||
|
if chk:
|
||||||
|
return chk
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
role = (data.get('role') or '').strip().title()
|
||||||
|
if role not in ('User', 'Admin'):
|
||||||
|
return jsonify({"error": "invalid role"}), 400
|
||||||
|
try:
|
||||||
|
conn = _db_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
# Prevent removing last admin
|
||||||
|
if role == 'User':
|
||||||
|
cur.execute("SELECT COUNT(*) FROM users WHERE LOWER(role)='admin'")
|
||||||
|
admin_cnt = cur.fetchone()[0] or 0
|
||||||
|
cur.execute("SELECT LOWER(role) FROM users WHERE LOWER(username)=LOWER(?)", (username,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row and (row[0] or '').lower() == 'admin' and admin_cnt <= 1:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({"error": "cannot demote the last admin"}), 400
|
||||||
|
now = _now_ts()
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE users SET role=?, updated_at=? WHERE LOWER(username)=LOWER(?)",
|
||||||
|
(role, now, username)
|
||||||
|
)
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({"error": "user not found"}), 404
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
# If current user changed their own role, refresh session role
|
||||||
|
me = _current_user()
|
||||||
|
if me and me.get('username','').lower() == username.lower():
|
||||||
|
session['role'] = role
|
||||||
|
return jsonify({"status": "ok"})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
# ---------------------------------------------
|
# ---------------------------------------------
|
||||||
# Borealis Python API Endpoints
|
# Borealis Python API Endpoints
|
||||||
@@ -718,12 +973,52 @@ def init_db():
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Users table
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
display_name TEXT,
|
||||||
|
password_sha512 TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'Admin',
|
||||||
|
last_login INTEGER,
|
||||||
|
created_at INTEGER,
|
||||||
|
updated_at INTEGER
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
init_db()
|
init_db()
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_default_admin():
|
||||||
|
"""Ensure the default admin account exists (admin / Password)."""
|
||||||
|
try:
|
||||||
|
conn = _db_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("SELECT COUNT(*) FROM users WHERE LOWER(username)='admin'")
|
||||||
|
exists = (cur.fetchone()[0] or 0) > 0
|
||||||
|
if not exists:
|
||||||
|
now = _now_ts()
|
||||||
|
default_hash = "e6c83b282aeb2e022844595721cc00bbda47cb24537c1779f9bb84f04039e1676e6ba8573e588da1052510e3aa0a32a9e55879ae22b0c2d62136fc0a3e85f8bb"
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO users(username, display_name, password_sha512, role, created_at, updated_at) VALUES(?,?,?,?,?,?)",
|
||||||
|
("admin", "Administrator", default_hash, "Admin", now, now)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
# Non-fatal if this fails; /health etc still work
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
ensure_default_admin()
|
||||||
|
|
||||||
# ---------------------------------------------
|
# ---------------------------------------------
|
||||||
# Sites API
|
# Sites API
|
||||||
# ---------------------------------------------
|
# ---------------------------------------------
|
||||||
|
|||||||
@@ -3,7 +3,20 @@
|
|||||||
"max_task_workers": 8,
|
"max_task_workers": 8,
|
||||||
"config_file_watcher_interval": 2,
|
"config_file_watcher_interval": 2,
|
||||||
"agent_id": "lab-operator-01-agent-66ba3ad3",
|
"agent_id": "lab-operator-01-agent-66ba3ad3",
|
||||||
"regions": {},
|
"regions": {
|
||||||
|
"node-1757304427610": {
|
||||||
|
"x": 2864,
|
||||||
|
"y": 258,
|
||||||
|
"w": 497,
|
||||||
|
"h": 442
|
||||||
|
},
|
||||||
|
"node-1758330030680": {
|
||||||
|
"x": 3058,
|
||||||
|
"y": 1016,
|
||||||
|
"w": 565,
|
||||||
|
"h": 126
|
||||||
|
}
|
||||||
|
},
|
||||||
"agent_operating_system": "Windows 11 Pro 24H2 Build 26100.6584",
|
"agent_operating_system": "Windows 11 Pro 24H2 Build 26100.6584",
|
||||||
"created": "2025-09-02 23:57:00"
|
"created": "2025-09-02 23:57:00"
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user