NetworkView: API routing fix, logout button, audit trail, port/notes editor tracking
- Fix frontend API base path (VITE_API_BASE env var, GraphPage hardcoded /api) - Add logout button to NetworkView sidebar (clears portal SSO) - Add AuditTrail component: inline change history on all entity pages - DB migration: add updated_at, last_edited_by to ports table - DB migration: add notes_last_edited_by, notes_updated_at to all entity tables - Backend: track actor on port create/update; notes editor on entity PUT - Frontend: extend types, MarkdownEditor shows last editor, port modal/list show last editor - Fix port CREATE TABLE definition to include new columns upfront - Add try/catch in handleSavePort to surface API errors in modal
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
31614
|
36827
|
||||||
31616
|
36829
|
||||||
31618
|
36831
|
||||||
31620
|
36833
|
||||||
31622
|
36835
|
||||||
|
|||||||
@@ -15,4 +15,4 @@ def login():
|
|||||||
@login_required
|
@login_required
|
||||||
def logout():
|
def logout():
|
||||||
logout_user()
|
logout_user()
|
||||||
return redirect(current_app.config.get('PORTAL_LOGIN_URL', 'http://localhost:8080/login'))
|
return redirect(current_app.config.get('PORTAL_LOGOUT_URL', 'http://localhost:8080/logout'))
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ class Config:
|
|||||||
|
|
||||||
# Portal SSO
|
# Portal SSO
|
||||||
PORTAL_LOGIN_URL = os.environ.get('PORTAL_LOGIN_URL', 'http://localhost:8080/login')
|
PORTAL_LOGIN_URL = os.environ.get('PORTAL_LOGIN_URL', 'http://localhost:8080/login')
|
||||||
|
PORTAL_LOGOUT_URL = os.environ.get('PORTAL_LOGOUT_URL', 'http://localhost:8080/logout')
|
||||||
|
|
||||||
# Pagination
|
# Pagination
|
||||||
ITEMS_PER_PAGE = int(os.environ.get('ITEMS_PER_PAGE', 25))
|
ITEMS_PER_PAGE = int(os.environ.get('ITEMS_PER_PAGE', 25))
|
||||||
|
|||||||
@@ -5,4 +5,4 @@ app = create_app(os.environ.get('FLASK_ENV', 'development'))
|
|||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
port = int(os.environ.get('PORT', 5003))
|
port = int(os.environ.get('PORT', 5003))
|
||||||
app.run(host='0.0.0.0', port=port)
|
app.run(host='127.0.0.1', port=port)
|
||||||
|
|||||||
@@ -68,7 +68,9 @@ db.exec(`
|
|||||||
port_type TEXT DEFAULT 'RJ45',
|
port_type TEXT DEFAULT 'RJ45',
|
||||||
connected_to_port_id TEXT REFERENCES ports(id) ON DELETE SET NULL,
|
connected_to_port_id TEXT REFERENCES ports(id) ON DELETE SET NULL,
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_edited_by TEXT
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -76,6 +78,20 @@ db.exec(`
|
|||||||
try { db.exec('ALTER TABLE components ADD COLUMN port_count INTEGER DEFAULT NULL'); } catch (_) {}
|
try { db.exec('ALTER TABLE components ADD COLUMN port_count INTEGER DEFAULT NULL'); } catch (_) {}
|
||||||
try { db.exec('ALTER TABLE components ADD COLUMN sfp_count INTEGER DEFAULT NULL'); } catch (_) {}
|
try { db.exec('ALTER TABLE components ADD COLUMN sfp_count INTEGER DEFAULT NULL'); } catch (_) {}
|
||||||
|
|
||||||
|
// Migrate: port editor tracking
|
||||||
|
try { db.exec('ALTER TABLE ports ADD COLUMN updated_at DATETIME DEFAULT CURRENT_TIMESTAMP'); } catch (_) {}
|
||||||
|
try { db.exec('ALTER TABLE ports ADD COLUMN last_edited_by TEXT'); } catch (_) {}
|
||||||
|
|
||||||
|
// Migrate: notes editor tracking per entity
|
||||||
|
try { db.exec('ALTER TABLE sites ADD COLUMN notes_last_edited_by TEXT'); } catch (_) {}
|
||||||
|
try { db.exec('ALTER TABLE sites ADD COLUMN notes_updated_at DATETIME'); } catch (_) {}
|
||||||
|
try { db.exec('ALTER TABLE rooms ADD COLUMN notes_last_edited_by TEXT'); } catch (_) {}
|
||||||
|
try { db.exec('ALTER TABLE rooms ADD COLUMN notes_updated_at DATETIME'); } catch (_) {}
|
||||||
|
try { db.exec('ALTER TABLE racks ADD COLUMN notes_last_edited_by TEXT'); } catch (_) {}
|
||||||
|
try { db.exec('ALTER TABLE racks ADD COLUMN notes_updated_at DATETIME'); } catch (_) {}
|
||||||
|
try { db.exec('ALTER TABLE components ADD COLUMN notes_last_edited_by TEXT'); } catch (_) {}
|
||||||
|
try { db.exec('ALTER TABLE components ADD COLUMN notes_updated_at DATETIME'); } catch (_) {}
|
||||||
|
|
||||||
// ── Users & Audit tables ─────────────────────────────────────────────────────
|
// ── Users & Audit tables ─────────────────────────────────────────────────────
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
|||||||
@@ -67,6 +67,6 @@ app.get('*', (_req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, '127.0.0.1', () => {
|
||||||
console.log(`NetworkView backend running on http://localhost:${PORT}`);
|
console.log(`NetworkView backend running on http://localhost:${PORT}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -126,12 +126,16 @@ router.put('/:id', (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const notesChanging = notes != null;
|
||||||
|
const actor = req.actor?.username || null;
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE components SET
|
UPDATE components SET
|
||||||
name = ?, type = ?, position = ?, height_units = ?,
|
name = ?, type = ?, position = ?, height_units = ?,
|
||||||
manufacturer = ?, model = ?, serial_number = ?, asset_tag = ?,
|
manufacturer = ?, model = ?, serial_number = ?, asset_tag = ?,
|
||||||
ip_address = ?, mac_address = ?, status = ?, notes = ?,
|
ip_address = ?, mac_address = ?, status = ?, notes = ?,
|
||||||
port_count = ?, sfp_count = ?,
|
port_count = ?, sfp_count = ?,
|
||||||
|
notes_last_edited_by = CASE WHEN ? = 1 THEN ? ELSE notes_last_edited_by END,
|
||||||
|
notes_updated_at = CASE WHEN ? = 1 THEN CURRENT_TIMESTAMP ELSE notes_updated_at END,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).run(
|
`).run(
|
||||||
@@ -149,6 +153,7 @@ router.put('/:id', (req, res) => {
|
|||||||
notes ?? component.notes,
|
notes ?? component.notes,
|
||||||
port_count !== undefined ? port_count : component.port_count,
|
port_count !== undefined ? port_count : component.port_count,
|
||||||
sfp_count !== undefined ? sfp_count : component.sfp_count,
|
sfp_count !== undefined ? sfp_count : component.sfp_count,
|
||||||
|
notesChanging ? 1 : 0, actor, notesChanging ? 1 : 0,
|
||||||
req.params.id
|
req.params.id
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -198,9 +203,9 @@ router.post('/:id/ports', (req, res) => {
|
|||||||
|
|
||||||
const id = uuidv4();
|
const id = uuidv4();
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO ports (id, component_id, port_number, label, port_type, connected_to_port_id, notes)
|
INSERT INTO ports (id, component_id, port_number, label, port_type, connected_to_port_id, notes, last_edited_by, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
`).run(id, req.params.id, port_number, label || null, port_type || 'RJ45', connected_to_port_id || null, notes || null);
|
`).run(id, req.params.id, port_number, label || null, port_type || 'RJ45', connected_to_port_id || null, notes || null, req.actor?.username || null);
|
||||||
|
|
||||||
// Mirror link on the other side
|
// Mirror link on the other side
|
||||||
if (connected_to_port_id) {
|
if (connected_to_port_id) {
|
||||||
@@ -225,12 +230,13 @@ router.put('/:componentId/ports/:portId', (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE ports SET label = ?, port_type = ?, connected_to_port_id = ?, notes = ? WHERE id = ?
|
UPDATE ports SET label = ?, port_type = ?, connected_to_port_id = ?, notes = ?, last_edited_by = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?
|
||||||
`).run(
|
`).run(
|
||||||
label ?? port.label,
|
label ?? port.label,
|
||||||
port_type ?? port.port_type,
|
port_type ?? port.port_type,
|
||||||
newLinkedId,
|
newLinkedId,
|
||||||
notes ?? port.notes,
|
notes ?? port.notes,
|
||||||
|
req.actor?.username || null,
|
||||||
req.params.portId
|
req.params.portId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -76,8 +76,12 @@ router.put('/:id', (req, res) => {
|
|||||||
if (total_units != null && total_units !== rack.total_units) changes.total_units = { from: rack.total_units, to: total_units };
|
if (total_units != null && total_units !== rack.total_units) changes.total_units = { from: rack.total_units, to: total_units };
|
||||||
if (manufacturer != null && manufacturer !== rack.manufacturer) changes.manufacturer = { from: rack.manufacturer, to: manufacturer };
|
if (manufacturer != null && manufacturer !== rack.manufacturer) changes.manufacturer = { from: rack.manufacturer, to: manufacturer };
|
||||||
if (model != null && model !== rack.model) changes.model = { from: rack.model, to: model };
|
if (model != null && model !== rack.model) changes.model = { from: rack.model, to: model };
|
||||||
|
const notesChanging = notes != null;
|
||||||
|
const actor = req.actor?.username || null;
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE racks SET name = ?, total_units = ?, manufacturer = ?, model = ?, notes = ?,
|
UPDATE racks SET name = ?, total_units = ?, manufacturer = ?, model = ?, notes = ?,
|
||||||
|
notes_last_edited_by = CASE WHEN ? = 1 THEN ? ELSE notes_last_edited_by END,
|
||||||
|
notes_updated_at = CASE WHEN ? = 1 THEN CURRENT_TIMESTAMP ELSE notes_updated_at END,
|
||||||
updated_at = CURRENT_TIMESTAMP WHERE id = ?
|
updated_at = CURRENT_TIMESTAMP WHERE id = ?
|
||||||
`).run(
|
`).run(
|
||||||
name ?? rack.name,
|
name ?? rack.name,
|
||||||
@@ -85,6 +89,7 @@ router.put('/:id', (req, res) => {
|
|||||||
manufacturer ?? rack.manufacturer,
|
manufacturer ?? rack.manufacturer,
|
||||||
model ?? rack.model,
|
model ?? rack.model,
|
||||||
notes ?? rack.notes,
|
notes ?? rack.notes,
|
||||||
|
notesChanging ? 1 : 0, actor, notesChanging ? 1 : 0,
|
||||||
req.params.id
|
req.params.id
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -63,9 +63,15 @@ router.put('/:id', (req, res) => {
|
|||||||
const { name, notes } = req.body;
|
const { name, notes } = req.body;
|
||||||
const changes = {};
|
const changes = {};
|
||||||
if (name != null && name !== room.name) changes.name = { from: room.name, to: name };
|
if (name != null && name !== room.name) changes.name = { from: room.name, to: name };
|
||||||
|
const notesChanging = notes != null;
|
||||||
|
const actor = req.actor?.username || null;
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE rooms SET name = ?, notes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?
|
UPDATE rooms SET name = ?, notes = ?,
|
||||||
`).run(name ?? room.name, notes ?? room.notes, req.params.id);
|
notes_last_edited_by = CASE WHEN ? = 1 THEN ? ELSE notes_last_edited_by END,
|
||||||
|
notes_updated_at = CASE WHEN ? = 1 THEN CURRENT_TIMESTAMP ELSE notes_updated_at END,
|
||||||
|
updated_at = CURRENT_TIMESTAMP WHERE id = ?
|
||||||
|
`).run(name ?? room.name, notes ?? room.notes,
|
||||||
|
notesChanging ? 1 : 0, actor, notesChanging ? 1 : 0, req.params.id);
|
||||||
|
|
||||||
logAudit(req, { action: 'update', entityType: 'room', entityId: room.id, entityName: name ?? room.name, changes });
|
logAudit(req, { action: 'update', entityType: 'room', entityId: room.id, entityName: name ?? room.name, changes });
|
||||||
res.json(db.prepare('SELECT * FROM rooms WHERE id = ?').get(req.params.id));
|
res.json(db.prepare('SELECT * FROM rooms WHERE id = ?').get(req.params.id));
|
||||||
|
|||||||
@@ -56,10 +56,16 @@ router.put('/:id', (req, res) => {
|
|||||||
const changes = {};
|
const changes = {};
|
||||||
if (name != null && name !== site.name) changes.name = { from: site.name, to: name };
|
if (name != null && name !== site.name) changes.name = { from: site.name, to: name };
|
||||||
if (location != null && location !== site.location) changes.location = { from: site.location, to: location };
|
if (location != null && location !== site.location) changes.location = { from: site.location, to: location };
|
||||||
|
const notesChanging = notes != null;
|
||||||
|
const actor = req.actor?.username || null;
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE sites SET name = ?, location = ?, notes = ?, updated_at = CURRENT_TIMESTAMP
|
UPDATE sites SET name = ?, location = ?, notes = ?,
|
||||||
|
notes_last_edited_by = CASE WHEN ? = 1 THEN ? ELSE notes_last_edited_by END,
|
||||||
|
notes_updated_at = CASE WHEN ? = 1 THEN CURRENT_TIMESTAMP ELSE notes_updated_at END,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`).run(name ?? site.name, location ?? site.location, notes ?? site.notes, req.params.id);
|
`).run(name ?? site.name, location ?? site.location, notes ?? site.notes,
|
||||||
|
notesChanging ? 1 : 0, actor, notesChanging ? 1 : 0, req.params.id);
|
||||||
|
|
||||||
logAudit(req, { action: 'update', entityType: 'site', entityId: site.id, entityName: name ?? site.name, changes });
|
logAudit(req, { action: 'update', entityType: 'site', entityId: site.id, entityName: name ?? site.name, changes });
|
||||||
res.json(db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id));
|
res.json(db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id));
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export const revokeApiKey = (id: string) =>
|
|||||||
|
|
||||||
// --- Audit ---
|
// --- Audit ---
|
||||||
export const getAuditLog = (params: {
|
export const getAuditLog = (params: {
|
||||||
entity_type?: string; action?: string; user_id?: string;
|
entity_type?: string; entity_id?: string; action?: string; user_id?: string;
|
||||||
limit?: number; offset?: number;
|
limit?: number; offset?: number;
|
||||||
}) => {
|
}) => {
|
||||||
const qs = new URLSearchParams(
|
const qs = new URLSearchParams(
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import * as api from '../api';
|
||||||
|
import type { AuditEntry } from '../types';
|
||||||
|
|
||||||
|
const ACTION_LABELS: Record<string, { label: string; color: string }> = {
|
||||||
|
create: { label: 'Created', color: 'var(--success)' },
|
||||||
|
update: { label: 'Updated', color: 'var(--accent)' },
|
||||||
|
delete: { label: 'Deleted', color: 'var(--danger)' },
|
||||||
|
delete_all: { label: 'Deleted all', color: 'var(--danger)' },
|
||||||
|
api_key_rotate: { label: 'API key rotated', color: 'var(--warning)' },
|
||||||
|
api_key_revoke: { label: 'API key revoked', color: 'var(--warning)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatTs(ts: string) {
|
||||||
|
const d = new Date(ts);
|
||||||
|
return d.toLocaleString(undefined, {
|
||||||
|
year: 'numeric', month: 'short', day: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChangeSummary({ raw }: { raw: string }) {
|
||||||
|
let parsed: Record<string, { from: unknown; to: unknown }> | null = null;
|
||||||
|
try { parsed = JSON.parse(raw); } catch { /* not JSON */ }
|
||||||
|
|
||||||
|
if (!parsed || typeof parsed !== 'object') {
|
||||||
|
return <span className="audit-trail-changes-raw">{raw}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = Object.entries(parsed);
|
||||||
|
if (entries.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="audit-trail-changes-list">
|
||||||
|
{entries.map(([field, diff]) => (
|
||||||
|
<li key={field}>
|
||||||
|
<span className="audit-trail-field">{field}</span>{' '}
|
||||||
|
<span className="audit-trail-from">"{String(diff.from ?? '')}"</span>
|
||||||
|
{' → '}
|
||||||
|
<span className="audit-trail-to">"{String(diff.to ?? '')}"</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
entityId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuditTrail({ entityId }: Props) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const LIMIT = 20;
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const result = await api.getAuditLog({ entity_id: entityId, limit: LIMIT, offset });
|
||||||
|
setEntries(result.entries);
|
||||||
|
setTotal(result.total);
|
||||||
|
setLoading(false);
|
||||||
|
}, [entityId, offset]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) load();
|
||||||
|
}, [open, load]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="content-section audit-trail-section">
|
||||||
|
<button
|
||||||
|
className="audit-trail-toggle"
|
||||||
|
onClick={() => setOpen(v => !v)}
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<span className="audit-trail-toggle-icon">{open ? '▾' : '▸'}</span>
|
||||||
|
<span>Change History</span>
|
||||||
|
{total > 0 && <span className="audit-trail-count">{total}</span>}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="audit-trail-body">
|
||||||
|
{loading && <div className="audit-trail-loading">Loading…</div>}
|
||||||
|
|
||||||
|
{!loading && entries.length === 0 && (
|
||||||
|
<div className="audit-trail-empty">No history recorded yet.</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && entries.length > 0 && (
|
||||||
|
<>
|
||||||
|
<ul className="audit-trail-list">
|
||||||
|
{entries.map((e, idx) => {
|
||||||
|
const meta = ACTION_LABELS[e.action] ?? { label: e.action, color: 'var(--text3)' };
|
||||||
|
const isFirst = idx === entries.length - 1 && offset === 0;
|
||||||
|
return (
|
||||||
|
<li key={e.id} className={`audit-trail-item${isFirst ? ' audit-trail-item-first' : ''}`}>
|
||||||
|
<span className="audit-trail-dot" style={{ background: meta.color }} />
|
||||||
|
<div className="audit-trail-content">
|
||||||
|
<div className="audit-trail-header">
|
||||||
|
<span className="audit-trail-action" style={{ color: meta.color }}>
|
||||||
|
{meta.label}
|
||||||
|
</span>
|
||||||
|
<span className="audit-trail-user">by {e.username ?? 'unknown'}</span>
|
||||||
|
<span className="audit-trail-time">{formatTs(e.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
{e.changes && (
|
||||||
|
<div className="audit-trail-changes">
|
||||||
|
<ChangeSummary raw={e.changes} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{(total > LIMIT) && (
|
||||||
|
<div className="audit-trail-pager">
|
||||||
|
<button
|
||||||
|
className="btn-ghost btn-sm"
|
||||||
|
disabled={offset === 0}
|
||||||
|
onClick={() => setOffset(o => Math.max(0, o - LIMIT))}
|
||||||
|
>
|
||||||
|
← Newer
|
||||||
|
</button>
|
||||||
|
<span className="audit-trail-pager-info">
|
||||||
|
{offset + 1}–{Math.min(offset + LIMIT, total)} of {total}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="btn-ghost btn-sm"
|
||||||
|
disabled={offset + LIMIT >= total}
|
||||||
|
onClick={() => setOffset(o => o + LIMIT)}
|
||||||
|
>
|
||||||
|
Older →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,11 +7,13 @@ interface Props {
|
|||||||
onChange: (val: string) => void;
|
onChange: (val: string) => void;
|
||||||
onSave?: (val: string) => void;
|
onSave?: (val: string) => void;
|
||||||
autoSaveDelay?: number;
|
autoSaveDelay?: number;
|
||||||
|
lastEditedBy?: string | null;
|
||||||
|
lastEditedAt?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type EditorMode = 'edit' | 'split' | 'preview';
|
type EditorMode = 'edit' | 'split' | 'preview';
|
||||||
|
|
||||||
export default function MarkdownEditor({ value, onChange, onSave, autoSaveDelay = 1500 }: Props) {
|
export default function MarkdownEditor({ value, onChange, onSave, autoSaveDelay = 1500, lastEditedBy, lastEditedAt }: Props) {
|
||||||
const [mode, setMode] = useState<EditorMode>('split');
|
const [mode, setMode] = useState<EditorMode>('split');
|
||||||
const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'unsaved'>('saved');
|
const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'unsaved'>('saved');
|
||||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
@@ -51,6 +53,14 @@ export default function MarkdownEditor({ value, onChange, onSave, autoSaveDelay
|
|||||||
{saveStatus === 'saving' ? '⟳ Saving…' : saveStatus === 'unsaved' ? '● Unsaved' : '✓ Saved'}
|
{saveStatus === 'saving' ? '⟳ Saving…' : saveStatus === 'unsaved' ? '● Unsaved' : '✓ Saved'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{lastEditedBy && (
|
||||||
|
<span className="md-last-edited">
|
||||||
|
✎ {lastEditedBy}
|
||||||
|
{lastEditedAt && (
|
||||||
|
<> · {new Date(lastEditedAt).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="md-help-hint">Markdown supported · GFM tables, checkboxes</span>
|
<span className="md-help-hint">Markdown supported · GFM tables, checkboxes</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -229,6 +229,13 @@ export default function Sidebar() {
|
|||||||
<Link to="/settings" className={`sidebar-footer-link ${isActive('/settings') ? 'active' : ''}`}>
|
<Link to="/settings" className={`sidebar-footer-link ${isActive('/settings') ? 'active' : ''}`}>
|
||||||
⚙ Settings
|
⚙ Settings
|
||||||
</Link>
|
</Link>
|
||||||
|
<button
|
||||||
|
className="sidebar-footer-link sidebar-logout-btn"
|
||||||
|
onClick={() => { window.location.href = (import.meta.env.VITE_PORTAL_LOGOUT_URL as string | undefined) ?? '/logout'; }}
|
||||||
|
title="Log out of NetworkView and the Enterprise Portal"
|
||||||
|
>
|
||||||
|
⏻ Logout
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -440,6 +440,7 @@ a:hover { text-decoration: underline; }
|
|||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.modal-title { font-size: 17px; font-weight: 600; }
|
.modal-title { font-size: 17px; font-weight: 600; }
|
||||||
|
.modal-last-edited { font-size: 11px; color: var(--accent); opacity: 0.8; margin-left: auto; margin-right: 12px; white-space: nowrap; }
|
||||||
.modal-close {
|
.modal-close {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -893,6 +894,7 @@ a:hover { text-decoration: underline; }
|
|||||||
.port-label { font-size: 13px; font-weight: 500; color: var(--text); }
|
.port-label { font-size: 13px; font-weight: 500; color: var(--text); }
|
||||||
.port-type { font-size: 11px; color: var(--text2); }
|
.port-type { font-size: 11px; color: var(--text2); }
|
||||||
.port-notes { font-size: 11px; color: var(--text3); font-style: italic; margin-top: 2px; }
|
.port-notes { font-size: 11px; color: var(--text3); font-style: italic; margin-top: 2px; }
|
||||||
|
.port-edited-by { font-size: 10px; color: var(--accent); opacity: 0.7; margin-top: 3px; }
|
||||||
|
|
||||||
.port-link-chain {
|
.port-link-chain {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -1005,6 +1007,7 @@ a:hover { text-decoration: underline; }
|
|||||||
.save-status-unsaved { color: var(--text2); }
|
.save-status-unsaved { color: var(--text2); }
|
||||||
|
|
||||||
.md-help-hint { font-size: 11px; color: var(--text3); }
|
.md-help-hint { font-size: 11px; color: var(--text3); }
|
||||||
|
.md-last-edited { font-size: 11px; color: var(--accent); opacity: 0.8; white-space: nowrap; }
|
||||||
|
|
||||||
.md-editor-body { display: flex; min-height: 280px; }
|
.md-editor-body { display: flex; min-height: 280px; }
|
||||||
.md-mode-edit .md-editor-body { }
|
.md-mode-edit .md-editor-body { }
|
||||||
@@ -1654,6 +1657,18 @@ a:hover { text-decoration: underline; }
|
|||||||
background: var(--surface2);
|
background: var(--surface2);
|
||||||
color: var(--text1);
|
color: var(--text1);
|
||||||
}
|
}
|
||||||
|
.sidebar-logout-btn {
|
||||||
|
width: 100%;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.sidebar-logout-btn:hover {
|
||||||
|
background: rgba(248, 81, 73, 0.12) !important;
|
||||||
|
color: var(--danger) !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Settings Page ──────────────────────────────────────────── */
|
/* ─── Settings Page ──────────────────────────────────────────── */
|
||||||
.page-settings {
|
.page-settings {
|
||||||
@@ -1867,3 +1882,103 @@ a:hover { text-decoration: underline; }
|
|||||||
width: 140px;
|
width: 140px;
|
||||||
}
|
}
|
||||||
.danger-input:focus { outline: 2px solid #f87171; }
|
.danger-input:focus { outline: 2px solid #f87171; }
|
||||||
|
|
||||||
|
/* ─── Audit Trail (inline entity history) ────────────────────── */
|
||||||
|
.audit-trail-section {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
.audit-trail-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text2);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 8px 0;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
.audit-trail-toggle:hover { color: var(--text); }
|
||||||
|
.audit-trail-toggle-icon { font-size: 10px; color: var(--text3); }
|
||||||
|
.audit-trail-count {
|
||||||
|
background: var(--surface2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 1px 7px;
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
.audit-trail-body {
|
||||||
|
margin-top: 4px;
|
||||||
|
border-left: 2px solid var(--border);
|
||||||
|
padding-left: 16px;
|
||||||
|
}
|
||||||
|
.audit-trail-loading,
|
||||||
|
.audit-trail-empty {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text3);
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
.audit-trail-list {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
.audit-trail-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid var(--border2);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.audit-trail-item:last-child { border-bottom: none; }
|
||||||
|
.audit-trail-item-first .audit-trail-action::after {
|
||||||
|
content: ' (initial)';
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
.audit-trail-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
.audit-trail-content { flex: 1; min-width: 0; }
|
||||||
|
.audit-trail-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.audit-trail-action { font-weight: 600; }
|
||||||
|
.audit-trail-user { color: var(--text2); }
|
||||||
|
.audit-trail-time { color: var(--text3); margin-left: auto; white-space: nowrap; }
|
||||||
|
.audit-trail-changes {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.audit-trail-changes-list {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
.audit-trail-changes-list li { color: var(--text3); }
|
||||||
|
.audit-trail-field { color: var(--text2); font-weight: 500; }
|
||||||
|
.audit-trail-from { color: var(--danger); opacity: 0.8; }
|
||||||
|
.audit-trail-to { color: var(--success); opacity: 0.9; }
|
||||||
|
.audit-trail-changes-raw { color: var(--text3); font-style: italic; }
|
||||||
|
.audit-trail-pager {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 0 0;
|
||||||
|
}
|
||||||
|
.audit-trail-pager-info { font-size: 11px; color: var(--text3); }
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import * as api from '../api';
|
|||||||
import type { Component, Port } from '../types';
|
import type { Component, Port } from '../types';
|
||||||
import { COMPONENT_META, COMPONENT_TYPES } from '../types';
|
import { COMPONENT_META, COMPONENT_TYPES } from '../types';
|
||||||
import MarkdownEditor from '../components/MarkdownEditor';
|
import MarkdownEditor from '../components/MarkdownEditor';
|
||||||
|
import AuditTrail from '../components/AuditTrail';
|
||||||
|
|
||||||
const STATUS_OPTIONS = ['active', 'maintenance', 'decommissioned'] as const;
|
const STATUS_OPTIONS = ['active', 'maintenance', 'decommissioned'] as const;
|
||||||
|
|
||||||
@@ -141,21 +142,26 @@ export default function ComponentPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (portModal?.editPort) {
|
try {
|
||||||
await api.updatePort(componentId, portModal.editPort.id, {
|
if (portModal?.editPort) {
|
||||||
label: portForm.label || undefined,
|
await api.updatePort(componentId, portModal.editPort.id, {
|
||||||
port_type: portForm.port_type,
|
label: portForm.label || undefined,
|
||||||
notes: portForm.notes || undefined,
|
port_type: portForm.port_type,
|
||||||
connected_to_port_id: connectedToPortId,
|
notes: portForm.notes || undefined,
|
||||||
});
|
connected_to_port_id: connectedToPortId,
|
||||||
} else {
|
});
|
||||||
await api.createPort(componentId, {
|
} else {
|
||||||
port_number: Number(portForm.port_number),
|
await api.createPort(componentId, {
|
||||||
label: portForm.label || undefined,
|
port_number: Number(portForm.port_number),
|
||||||
port_type: portForm.port_type,
|
label: portForm.label || undefined,
|
||||||
notes: portForm.notes || undefined,
|
port_type: portForm.port_type,
|
||||||
connected_to_port_id: connectedToPortId ?? undefined,
|
notes: portForm.notes || undefined,
|
||||||
});
|
connected_to_port_id: connectedToPortId ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setPortError(err instanceof Error ? err.message : 'Failed to save port');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
setPortModal(null);
|
setPortModal(null);
|
||||||
setSwLink(null);
|
setSwLink(null);
|
||||||
@@ -353,6 +359,14 @@ export default function ComponentPage() {
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{port.last_edited_by && (
|
||||||
|
<div className="port-edited-by">
|
||||||
|
✎ {port.last_edited_by}
|
||||||
|
{port.updated_at && (
|
||||||
|
<> · {new Date(port.updated_at).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button className="port-edit-btn" onClick={() => openPortModal(port)} title="Edit port">✎</button>
|
<button className="port-edit-btn" onClick={() => openPortModal(port)} title="Edit port">✎</button>
|
||||||
<button className="port-delete" onClick={() => handleDeletePort(port.id)} title="Delete port">✕</button>
|
<button className="port-delete" onClick={() => handleDeletePort(port.id)} title="Delete port">✕</button>
|
||||||
@@ -372,6 +386,14 @@ export default function ComponentPage() {
|
|||||||
? `Edit ${component.type === 'patch_panel' ? 'PP' : 'SW'} Port ${portModal.editPort.port_number}`
|
? `Edit ${component.type === 'patch_panel' ? 'PP' : 'SW'} Port ${portModal.editPort.port_number}`
|
||||||
: `Add ${component.type === 'patch_panel' ? 'PP Port' : 'SW Port'}`}
|
: `Add ${component.type === 'patch_panel' ? 'PP Port' : 'SW Port'}`}
|
||||||
</span>
|
</span>
|
||||||
|
{portModal.editPort?.last_edited_by && (
|
||||||
|
<span className="modal-last-edited">
|
||||||
|
✎ {portModal.editPort.last_edited_by}
|
||||||
|
{portModal.editPort.updated_at && (
|
||||||
|
<> · {new Date(portModal.editPort.updated_at).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<button className="modal-close" onClick={closePortModal}>✕</button>
|
<button className="modal-close" onClick={closePortModal}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleSavePort} style={{ padding: '20px', display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
<form onSubmit={handleSavePort} style={{ padding: '20px', display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||||
@@ -464,8 +486,16 @@ export default function ComponentPage() {
|
|||||||
{/* Notes */}
|
{/* Notes */}
|
||||||
<section className="content-section">
|
<section className="content-section">
|
||||||
<h2 className="section-title">Documentation</h2>
|
<h2 className="section-title">Documentation</h2>
|
||||||
<MarkdownEditor value={notes} onChange={setNotes} onSave={saveNotes} />
|
<MarkdownEditor
|
||||||
|
value={notes}
|
||||||
|
onChange={setNotes}
|
||||||
|
onSave={saveNotes}
|
||||||
|
lastEditedBy={component.notes_last_edited_by}
|
||||||
|
lastEditedAt={component.notes_updated_at}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<AuditTrail entityId={component.id} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { useEffect, useState, useRef, useCallback } from 'react';
|
|||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { COMPONENT_META } from '../types';
|
import { COMPONENT_META } from '../types';
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_BASE ?? '/api';
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface GNode {
|
interface GNode {
|
||||||
@@ -158,7 +160,7 @@ export default function GraphPage() {
|
|||||||
if (!level || !id) return;
|
if (!level || !id) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setErrMsg(null);
|
setErrMsg(null);
|
||||||
fetch(`/api/graph/${level}/${id}`)
|
fetch(`${API_BASE}/graph/${level}/${id}`)
|
||||||
.then(r => r.ok ? r.json() : r.json().then((b: { error: string }) => Promise.reject(b.error)))
|
.then(r => r.ok ? r.json() : r.json().then((b: { error: string }) => Promise.reject(b.error)))
|
||||||
.then((data: { nodes: Omit<GNode, 'x' | 'y' | 'vx' | 'vy' | 'tx' | 'ty' | 'pinned'>[]; edges: GEdge[] }) => {
|
.then((data: { nodes: Omit<GNode, 'x' | 'y' | 'vx' | 'vy' | 'tx' | 'ty' | 'pinned'>[]; edges: GEdge[] }) => {
|
||||||
const positions = computeTreeLayout(data.nodes, data.edges);
|
const positions = computeTreeLayout(data.nodes, data.edges);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import RackGraphicView from '../components/RackGraphicView';
|
|||||||
import MarkdownEditor from '../components/MarkdownEditor';
|
import MarkdownEditor from '../components/MarkdownEditor';
|
||||||
import { AddComponentModal, SimpleCreateModal } from '../components/AddItemModal';
|
import { AddComponentModal, SimpleCreateModal } from '../components/AddItemModal';
|
||||||
import type { ComponentFormData } from '../components/AddItemModal';
|
import type { ComponentFormData } from '../components/AddItemModal';
|
||||||
|
import AuditTrail from '../components/AuditTrail';
|
||||||
|
|
||||||
// ── Rack Layout Table ─────────────────────────────────────────────────────────
|
// ── Rack Layout Table ─────────────────────────────────────────────────────────
|
||||||
function RackLayoutView({ rack, components, onAddAtSlot }: {
|
function RackLayoutView({ rack, components, onAddAtSlot }: {
|
||||||
@@ -259,8 +260,16 @@ export default function RackPage() {
|
|||||||
|
|
||||||
<section className="content-section">
|
<section className="content-section">
|
||||||
<h2 className="section-title">Rack Notes</h2>
|
<h2 className="section-title">Rack Notes</h2>
|
||||||
<MarkdownEditor value={notes} onChange={setNotes} onSave={saveNotes} />
|
<MarkdownEditor
|
||||||
|
value={notes}
|
||||||
|
onChange={setNotes}
|
||||||
|
onSave={saveNotes}
|
||||||
|
lastEditedBy={rack.notes_last_edited_by}
|
||||||
|
lastEditedAt={rack.notes_updated_at}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<AuditTrail entityId={rack.id} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* RIGHT: Graphical front panel */}
|
{/* RIGHT: Graphical front panel */}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import * as api from '../api';
|
|||||||
import type { Room, Rack } from '../types';
|
import type { Room, Rack } from '../types';
|
||||||
import MarkdownEditor from '../components/MarkdownEditor';
|
import MarkdownEditor from '../components/MarkdownEditor';
|
||||||
import { SimpleCreateModal } from '../components/AddItemModal';
|
import { SimpleCreateModal } from '../components/AddItemModal';
|
||||||
|
import AuditTrail from '../components/AuditTrail';
|
||||||
|
|
||||||
export default function RoomPage() {
|
export default function RoomPage() {
|
||||||
const { roomId } = useParams<{ roomId: string }>();
|
const { roomId } = useParams<{ roomId: string }>();
|
||||||
@@ -132,9 +133,17 @@ export default function RoomPage() {
|
|||||||
|
|
||||||
<section className="content-section">
|
<section className="content-section">
|
||||||
<h2 className="section-title">Notes</h2>
|
<h2 className="section-title">Notes</h2>
|
||||||
<MarkdownEditor value={notes} onChange={setNotes} onSave={saveNotes} />
|
<MarkdownEditor
|
||||||
|
value={notes}
|
||||||
|
onChange={setNotes}
|
||||||
|
onSave={saveNotes}
|
||||||
|
lastEditedBy={room.notes_last_edited_by}
|
||||||
|
lastEditedAt={room.notes_updated_at}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<AuditTrail entityId={room.id} />
|
||||||
|
|
||||||
{showAddRack && (
|
{showAddRack && (
|
||||||
<SimpleCreateModal
|
<SimpleCreateModal
|
||||||
title="Add Rack"
|
title="Add Rack"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import * as api from '../api';
|
|||||||
import type { Site, Room } from '../types';
|
import type { Site, Room } from '../types';
|
||||||
import MarkdownEditor from '../components/MarkdownEditor';
|
import MarkdownEditor from '../components/MarkdownEditor';
|
||||||
import { SimpleCreateModal } from '../components/AddItemModal';
|
import { SimpleCreateModal } from '../components/AddItemModal';
|
||||||
|
import AuditTrail from '../components/AuditTrail';
|
||||||
|
|
||||||
export default function SitePage() {
|
export default function SitePage() {
|
||||||
const { siteId } = useParams<{ siteId: string }>();
|
const { siteId } = useParams<{ siteId: string }>();
|
||||||
@@ -124,9 +125,17 @@ export default function SitePage() {
|
|||||||
{/* Notes */}
|
{/* Notes */}
|
||||||
<section className="content-section">
|
<section className="content-section">
|
||||||
<h2 className="section-title">Notes</h2>
|
<h2 className="section-title">Notes</h2>
|
||||||
<MarkdownEditor value={notes} onChange={setNotes} onSave={saveNotes} />
|
<MarkdownEditor
|
||||||
|
value={notes}
|
||||||
|
onChange={setNotes}
|
||||||
|
onSave={saveNotes}
|
||||||
|
lastEditedBy={site.notes_last_edited_by}
|
||||||
|
lastEditedAt={site.notes_updated_at}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<AuditTrail entityId={site.id} />
|
||||||
|
|
||||||
{showAddRoom && (
|
{showAddRoom && (
|
||||||
<SimpleCreateModal
|
<SimpleCreateModal
|
||||||
title="Add Room"
|
title="Add Room"
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export interface Site {
|
|||||||
name: string;
|
name: string;
|
||||||
location?: string;
|
location?: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
|
notes_last_edited_by?: string | null;
|
||||||
|
notes_updated_at?: string | null;
|
||||||
room_count?: number;
|
room_count?: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
@@ -27,6 +29,8 @@ export interface Room {
|
|||||||
site_id: string;
|
site_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
|
notes_last_edited_by?: string | null;
|
||||||
|
notes_updated_at?: string | null;
|
||||||
rack_count?: number;
|
rack_count?: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
@@ -42,6 +46,8 @@ export interface Rack {
|
|||||||
manufacturer?: string;
|
manufacturer?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
|
notes_last_edited_by?: string | null;
|
||||||
|
notes_updated_at?: string | null;
|
||||||
component_count?: number;
|
component_count?: number;
|
||||||
components?: Component[];
|
components?: Component[];
|
||||||
room?: { id: string; name: string; site_id: string };
|
room?: { id: string; name: string; site_id: string };
|
||||||
@@ -65,6 +71,8 @@ export interface Component {
|
|||||||
mac_address?: string;
|
mac_address?: string;
|
||||||
status: ComponentStatus;
|
status: ComponentStatus;
|
||||||
notes: string;
|
notes: string;
|
||||||
|
notes_last_edited_by?: string | null;
|
||||||
|
notes_updated_at?: string | null;
|
||||||
port_count?: number | null;
|
port_count?: number | null;
|
||||||
sfp_count?: number | null;
|
sfp_count?: number | null;
|
||||||
ports?: Port[];
|
ports?: Port[];
|
||||||
@@ -83,6 +91,8 @@ export interface Port {
|
|||||||
port_type: string;
|
port_type: string;
|
||||||
connected_to_port_id?: string | null;
|
connected_to_port_id?: string | null;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
last_edited_by?: string | null;
|
||||||
|
updated_at?: string;
|
||||||
linked_port?: {
|
linked_port?: {
|
||||||
id: string;
|
id: string;
|
||||||
port_number: number;
|
port_number: number;
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ def main():
|
|||||||
# Run application
|
# Run application
|
||||||
try:
|
try:
|
||||||
app.run(
|
app.run(
|
||||||
host='0.0.0.0',
|
host='127.0.0.1',
|
||||||
port=int(os.environ.get('PORT', 80)),
|
port=int(os.environ.get('PORT', 80)),
|
||||||
debug=app.config.get('DEBUG', False)
|
debug=app.config.get('DEBUG', False)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -34,12 +34,12 @@ def login():
|
|||||||
@auth_bp.route('/logout')
|
@auth_bp.route('/logout')
|
||||||
@login_required
|
@login_required
|
||||||
def logout():
|
def logout():
|
||||||
"""User logout"""
|
"""Log out of DigiServer and redirect to portal logout to clear the SSO cookie."""
|
||||||
username = current_user.username
|
username = current_user.username
|
||||||
logout_user()
|
logout_user()
|
||||||
log_action('info', f'User {username} logged out')
|
log_action('info', f'User {username} logged out')
|
||||||
flash('You have been logged out.', 'info')
|
portal_logout = current_app.config.get('PORTAL_LOGOUT_URL', 'http://localhost:8080/logout')
|
||||||
return redirect(url_for('auth.login'))
|
return redirect(portal_logout)
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.route('/register', methods=['GET', 'POST'])
|
@auth_bp.route('/register', methods=['GET', 'POST'])
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ class Config:
|
|||||||
# URL of the portal login page — users are redirected here if they try to
|
# URL of the portal login page — users are redirected here if they try to
|
||||||
# access DigiServer directly without a portal session.
|
# access DigiServer directly without a portal session.
|
||||||
PORTAL_LOGIN_URL = os.getenv('PORTAL_LOGIN_URL', 'http://localhost:8080/login')
|
PORTAL_LOGIN_URL = os.getenv('PORTAL_LOGIN_URL', 'http://localhost:8080/login')
|
||||||
|
PORTAL_LOGOUT_URL = os.getenv('PORTAL_LOGOUT_URL', 'http://localhost:8080/logout')
|
||||||
|
|
||||||
# Set to True to disable DigiServer's own user registration and management UI.
|
# Set to True to disable DigiServer's own user registration and management UI.
|
||||||
# When True, all user accounts are managed exclusively through the portal.
|
# When True, all user accounts are managed exclusively through the portal.
|
||||||
|
|||||||
@@ -5,7 +5,15 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1>👥 User Management</h1>
|
<h1>👥 User Management</h1>
|
||||||
<button class="btn btn-primary" onclick="showCreateUserModal()">+ Create New User</button>
|
</div>
|
||||||
|
|
||||||
|
<div class="portal-notice">
|
||||||
|
<span class="portal-notice-icon">🔒</span>
|
||||||
|
<div>
|
||||||
|
<strong>User accounts are managed by the Enterprise Digital Platform portal.</strong><br>
|
||||||
|
To create users or change roles, visit <a href="/settings" target="_blank">Portal Settings → Users</a>.
|
||||||
|
This page shows only the users who currently have access to DigiServer.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Users Table -->
|
<!-- Users Table -->
|
||||||
@@ -19,7 +27,6 @@
|
|||||||
<th>Role</th>
|
<th>Role</th>
|
||||||
<th>Created At</th>
|
<th>Created At</th>
|
||||||
<th>Last Login</th>
|
<th>Last Login</th>
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -39,26 +46,11 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>{{ user.created_at | localtime if user.created_at else 'N/A' }}</td>
|
<td>{{ user.created_at | localtime if user.created_at else 'N/A' }}</td>
|
||||||
<td>{{ user.last_login | localtime if user.last_login else 'Never' }}</td>
|
<td>{{ user.last_login | localtime if user.last_login else 'Never' }}</td>
|
||||||
<td class="actions">
|
|
||||||
{% if user.id != current_user.id %}
|
|
||||||
<button class="btn btn-sm btn-warning" onclick="showEditUserModal({{ user.id }}, '{{ user.username }}', '{{ user.role }}')">
|
|
||||||
Edit Role
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-secondary" onclick="showResetPasswordModal({{ user.id }}, '{{ user.username }}')">
|
|
||||||
Reset Password
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-danger" onclick="confirmDeleteUser({{ user.id }}, '{{ user.username }}')">
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
{% else %}
|
|
||||||
<span class="text-muted">Current User</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="5" class="text-center">No users found</td>
|
<td colspan="4" class="text-center">No users found</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -94,93 +86,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Create User Modal -->
|
|
||||||
<div id="createUserModal" class="modal">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h2>Create New User</h2>
|
|
||||||
<span class="close" onclick="closeModal('createUserModal')">×</span>
|
|
||||||
</div>
|
|
||||||
<form method="POST" action="{{ url_for('admin.create_user') }}">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="username">Username *</label>
|
|
||||||
<input type="text" id="username" name="username" required minlength="3"
|
|
||||||
placeholder="Enter username" class="form-control">
|
|
||||||
<small>Minimum 3 characters</small>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="password">Password *</label>
|
|
||||||
<input type="password" id="password" name="password" required minlength="6"
|
|
||||||
placeholder="Enter password" class="form-control">
|
|
||||||
<small>Minimum 6 characters</small>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="role">Role *</label>
|
|
||||||
<select id="role" name="role" required class="form-control">
|
|
||||||
<option value="user">Normal User</option>
|
|
||||||
<option value="admin">Admin User</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" onclick="closeModal('createUserModal')">Cancel</button>
|
|
||||||
<button type="submit" class="btn btn-primary">Create User</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Edit Role Modal -->
|
|
||||||
<div id="editRoleModal" class="modal">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h2>Edit User Role</h2>
|
|
||||||
<span class="close" onclick="closeModal('editRoleModal')">×</span>
|
|
||||||
</div>
|
|
||||||
<form method="POST" id="editRoleForm">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Username</label>
|
|
||||||
<input type="text" id="edit_username" readonly class="form-control">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="edit_role">New Role *</label>
|
|
||||||
<select id="edit_role" name="role" required class="form-control">
|
|
||||||
<option value="user">Normal User</option>
|
|
||||||
<option value="admin">Admin User</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" onclick="closeModal('editRoleModal')">Cancel</button>
|
|
||||||
<button type="submit" class="btn btn-primary">Update Role</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Reset Password Modal -->
|
|
||||||
<div id="resetPasswordModal" class="modal">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h2>Reset User Password</h2>
|
|
||||||
<span class="close" onclick="closeModal('resetPasswordModal')">×</span>
|
|
||||||
</div>
|
|
||||||
<form method="POST" id="resetPasswordForm">
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Username</label>
|
|
||||||
<input type="text" id="reset_username" readonly class="form-control">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="new_password">New Password *</label>
|
|
||||||
<input type="password" id="new_password" name="password" required minlength="6"
|
|
||||||
placeholder="Enter new password" class="form-control">
|
|
||||||
<small>Minimum 6 characters</small>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" onclick="closeModal('resetPasswordModal')">Cancel</button>
|
|
||||||
<button type="submit" class="btn btn-primary">Reset Password</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.page-header {
|
.page-header {
|
||||||
@@ -190,22 +96,37 @@
|
|||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header .btn-primary {
|
.portal-notice {
|
||||||
background: #667eea;
|
display: flex;
|
||||||
color: white;
|
align-items: flex-start;
|
||||||
border: none;
|
gap: 12px;
|
||||||
|
background: #eff6ff;
|
||||||
|
border: 1px solid #93c5fd;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #1e40af;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header .btn-primary:hover {
|
.portal-notice a {
|
||||||
background: #5568d3;
|
color: #1d4ed8;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark-mode .page-header .btn-primary {
|
.portal-notice-icon {
|
||||||
background: #7c3aed;
|
font-size: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark-mode .page-header .btn-primary:hover {
|
body.dark-mode .portal-notice {
|
||||||
background: #6d28d9;
|
background: #1e293b;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
color: #93c5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .portal-notice a {
|
||||||
|
color: #60a5fa;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-responsive {
|
.table-responsive {
|
||||||
@@ -273,35 +194,6 @@ body.dark-mode .user-table tbody tr:hover {
|
|||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-sm {
|
|
||||||
padding: 6px 12px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-warning {
|
|
||||||
background: #ffc107;
|
|
||||||
color: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-warning:hover {
|
|
||||||
background: #e0a800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
background: #dc3545;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger:hover {
|
|
||||||
background: #c82333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-descriptions {
|
.role-descriptions {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
@@ -344,132 +236,6 @@ body.dark-mode .role-item li {
|
|||||||
color: #a0aec0;
|
color: #a0aec0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal {
|
|
||||||
display: none;
|
|
||||||
position: fixed;
|
|
||||||
z-index: 1000;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background-color: rgba(0,0,0,0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-content {
|
|
||||||
background-color: #fff;
|
|
||||||
margin: 5% auto;
|
|
||||||
padding: 0;
|
|
||||||
border-radius: 8px;
|
|
||||||
width: 90%;
|
|
||||||
max-width: 500px;
|
|
||||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode .modal-content {
|
|
||||||
background-color: #2d3748;
|
|
||||||
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 20px;
|
|
||||||
border-bottom: 1px solid #e0e0e0;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode .modal-header {
|
|
||||||
border-bottom: 1px solid #4a5568;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode .modal-header h2 {
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close {
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #aaa;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close:hover {
|
|
||||||
color: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal form {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #495057;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode .form-group label {
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control {
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px;
|
|
||||||
border: 1px solid #ced4da;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 14px;
|
|
||||||
background: white;
|
|
||||||
color: #2d3748;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #667eea;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode .form-control {
|
|
||||||
background: #1a202c;
|
|
||||||
border-color: #4a5568;
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode .form-control:focus {
|
|
||||||
border-color: #7c3aed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group small {
|
|
||||||
display: block;
|
|
||||||
margin-top: 5px;
|
|
||||||
color: #6c757d;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode .form-group small {
|
|
||||||
color: #a0aec0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 10px;
|
|
||||||
padding-top: 20px;
|
|
||||||
border-top: 1px solid #e0e0e0;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode .modal-footer {
|
|
||||||
border-top: 1px solid #4a5568;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-muted {
|
.text-muted {
|
||||||
color: #6c757d;
|
color: #6c757d;
|
||||||
}
|
}
|
||||||
@@ -479,46 +245,5 @@ body.dark-mode .modal-footer {
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
|
||||||
function showCreateUserModal() {
|
|
||||||
document.getElementById('createUserModal').style.display = 'block';
|
|
||||||
}
|
|
||||||
|
|
||||||
function showEditUserModal(userId, username, currentRole) {
|
|
||||||
document.getElementById('edit_username').value = username;
|
|
||||||
document.getElementById('edit_role').value = currentRole;
|
|
||||||
document.getElementById('editRoleForm').action = `/admin/user/${userId}/role`;
|
|
||||||
document.getElementById('editRoleModal').style.display = 'block';
|
|
||||||
}
|
|
||||||
|
|
||||||
function showResetPasswordModal(userId, username) {
|
|
||||||
document.getElementById('reset_username').value = username;
|
|
||||||
document.getElementById('resetPasswordForm').action = `/admin/user/${userId}/password`;
|
|
||||||
document.getElementById('resetPasswordModal').style.display = 'block';
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeModal(modalId) {
|
|
||||||
document.getElementById(modalId).style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmDeleteUser(userId, username) {
|
|
||||||
if (confirm(`Are you sure you want to delete user "${username}"? This action cannot be undone.`)) {
|
|
||||||
const form = document.createElement('form');
|
|
||||||
form.method = 'POST';
|
|
||||||
form.action = `/admin/user/${userId}/delete`;
|
|
||||||
document.body.appendChild(form);
|
|
||||||
form.submit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close modal when clicking outside
|
|
||||||
window.onclick = function(event) {
|
|
||||||
const modals = document.getElementsByClassName('modal');
|
|
||||||
for (let modal of modals) {
|
|
||||||
if (event.target === modal) {
|
|
||||||
modal.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+1
-1
@@ -5,4 +5,4 @@ app = create_app(os.environ.get('FLASK_ENV', 'production'))
|
|||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
port = int(os.environ.get('PORT', 5001))
|
port = int(os.environ.get('PORT', 5001))
|
||||||
app.run(host='0.0.0.0', port=port, debug=(os.environ.get('FLASK_ENV') == 'development'))
|
app.run(host='127.0.0.1', port=port, debug=(os.environ.get('FLASK_ENV') == 'development'))
|
||||||
|
|||||||
+3
-1
@@ -359,8 +359,9 @@ if module_enabled "digiserver"; then
|
|||||||
ADMIN_USERNAME=admin \
|
ADMIN_USERNAME=admin \
|
||||||
ADMIN_PASSWORD=admin123 \
|
ADMIN_PASSWORD=admin123 \
|
||||||
PORTAL_JWT_SECRET=change-this-jwt-secret-in-production \
|
PORTAL_JWT_SECRET=change-this-jwt-secret-in-production \
|
||||||
|
PORTAL_LOGOUT_URL="http://localhost:${NGINX_PORT}/logout" \
|
||||||
"$ROOT/digiserver-v2/.venv/bin/gunicorn" \
|
"$ROOT/digiserver-v2/.venv/bin/gunicorn" \
|
||||||
--bind "0.0.0.0:$DIGISERVER_PORT" \
|
--bind "127.0.0.1:$DIGISERVER_PORT" \
|
||||||
--workers 2 \
|
--workers 2 \
|
||||||
--timeout 120 \
|
--timeout 120 \
|
||||||
--chdir "$ROOT/digiserver-v2" \
|
--chdir "$ROOT/digiserver-v2" \
|
||||||
@@ -378,6 +379,7 @@ if module_enabled "itassets"; then
|
|||||||
SQLALCHEMY_DATABASE_URI="sqlite:///$ROOT/IT_asset_management/data/itassets.db" \
|
SQLALCHEMY_DATABASE_URI="sqlite:///$ROOT/IT_asset_management/data/itassets.db" \
|
||||||
PORTAL_JWT_SECRET=change-this-jwt-secret-in-production \
|
PORTAL_JWT_SECRET=change-this-jwt-secret-in-production \
|
||||||
PORTAL_LOGIN_URL="http://localhost:${NGINX_PORT}/login" \
|
PORTAL_LOGIN_URL="http://localhost:${NGINX_PORT}/login" \
|
||||||
|
PORTAL_LOGOUT_URL="http://localhost:${NGINX_PORT}/logout" \
|
||||||
FLASK_APP=run.py \
|
FLASK_APP=run.py \
|
||||||
"$ROOT/IT_asset_management/.venv/bin/python" "$ROOT/IT_asset_management/run.py"
|
"$ROOT/IT_asset_management/.venv/bin/python" "$ROOT/IT_asset_management/run.py"
|
||||||
else
|
else
|
||||||
|
|||||||
Reference in New Issue
Block a user