diff --git a/PROGRESS.md b/PROGRESS.md index 3b0d435..df6ca3b 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -66,6 +66,7 @@ 15. **Real Login Authentication:** Fixed a security bug where the login screen accepted any password. Bound the input to component state and implemented proper 401 handling with inline error messaging. 16. **Network Hardening:** Configured the frontend to use a relative `/api` path, allowing the FastAPI backend to be completely shielded behind an Nginx SSL reverse proxy on `127.0.0.1`. No internal ports are now exposed to the public internet. 17. **API Authentication Enforcement:** Fixed a security vulnerability where API endpoints were publicly accessible without a token. Implemented the `get_current_user` dependency in `backend/auth.py` and applied it to all sensitive routes. Accessing `/tasks`, `/users`, etc. now strictly requires a valid JWT Bearer token. +18. **Persistent Workspace Settings:** Added a `Workspace` database model and API endpoints (`GET /workspace`, `PATCH /workspace`) to track global settings. Added an "Update workspace" button to the Settings UI, allowing Admins to persist changes to the Workspace Name and Timezone across the entire dashboard. ### Phase 3: Advanced Features - **Real-time Notifications:** Explore WebSockets for task assignments. diff --git a/api.js b/api.js index b0ba2e5..6cd37d1 100644 --- a/api.js +++ b/api.js @@ -66,6 +66,19 @@ class ApiService { return this.request('/users'); } + async getWorkspace() { + return this.request('/workspace'); + } + + async updateWorkspace(updates) { + const data = await this.request('/workspace', { + method: 'PATCH', + body: JSON.stringify(updates), + }); + this.notify(); + return data; + } + async createUser(userData) { const data = await this.request('/users', { method: 'POST', diff --git a/app.jsx b/app.jsx index e06a579..c29554d 100644 --- a/app.jsx +++ b/app.jsx @@ -10,7 +10,7 @@ const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ const ACCENTS = ['#2A6FDB', '#1F8A5B', '#D97757', '#7A5AF8']; function useApiData(authed) { - const [data, setData] = React.useState({ tasks: [], users: [], audit: [] }); + const [data, setData] = React.useState({ tasks: [], users: [], audit: [], workspace: null }); const [loading, setLoading] = React.useState(true); React.useEffect(() => { @@ -19,13 +19,14 @@ function useApiData(authed) { let mounted = true; const load = async () => { try { - const [tasks, users, audit] = await Promise.all([ + const [tasks, users, audit, workspace] = await Promise.all([ api.getTasks(), api.getUsers(), - api.getAudit() + api.getAudit(), + api.getWorkspace() ]); if (mounted) { - setData({ tasks, users, audit }); + setData({ tasks, users, audit, workspace }); setLoading(false); } } catch (e) { @@ -55,7 +56,7 @@ function App() { return 'rod'; }); - const { tasks, users: dbUsers, audit, loading } = useApiData(authed); + const { tasks, users: dbUsers, audit, workspace, loading } = useApiData(authed); const [tab, setTab] = React.useState('overview'); React.useEffect(() => { @@ -76,7 +77,7 @@ function App() { ]); if (!authed) { - return { + return { await api.login(id, pwd); setMeId(id); setAuthed(true); @@ -211,6 +212,7 @@ function App() { onAdd={() => setAdding(meId)} onLogs={() => setShowLogs(true)} onProfile={() => setShowSettings(true)} + workspace={workspace} /> @@ -308,6 +310,15 @@ function App() { await api.changePassword(meId, oldPwd, newPwd); await api.addAudit({ actor: meId, action: 'password_changed', summary: 'Updated password', target: meId }); }} + workspace={workspace} + onUpdateWorkspace={async (edits) => { + try { + await api.updateWorkspace(edits); + await api.addAudit({ actor: meId, action: 'workspace_updated', summary: 'Updated workspace settings', target: null }); + } catch(e) { + alert("Failed to update workspace: " + e.message); + } + }} /> )} diff --git a/backend/main.py b/backend/main.py index e4d847f..8a59718 100644 --- a/backend/main.py +++ b/backend/main.py @@ -147,6 +147,34 @@ def delete_task(task_id: str, db: Session = Depends(get_db), current_user: model db.delete(db_task) db.commit() return {"message": "Task deleted"} +@app.get("/workspace", response_model=schemas.Workspace) +def read_workspace(db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): + ws = db.query(models.Workspace).first() + if not ws: + ws = models.Workspace(id="default", name="murchison-auto", timezone="Pacific/Auckland") + db.add(ws) + db.commit() + db.refresh(ws) + return ws + +@app.patch("/workspace", response_model=schemas.Workspace) +def update_workspace(ws_update: schemas.WorkspaceUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): + if current_user.account_type != "admin": + raise HTTPException(status_code=403, detail="Not enough permissions") + + ws = db.query(models.Workspace).first() + if not ws: + ws = models.Workspace(id="default", name="murchison-auto", timezone="Pacific/Auckland") + db.add(ws) + + update_data = ws_update.dict(exclude_unset=True) + for key, value in update_data.items(): + setattr(ws, key, value) + + db.commit() + db.refresh(ws) + return ws + @app.get("/audit", response_model=List[schemas.AuditLog]) def read_audit(db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): return db.query(models.AuditLog).order_by(models.AuditLog.at.desc()).all() diff --git a/backend/models.py b/backend/models.py index 589fa35..257036d 100644 --- a/backend/models.py +++ b/backend/models.py @@ -82,3 +82,10 @@ class Session(Base): device = Column(String, nullable=False) location = Column(String) last_active = Column(DateTime(timezone=True), server_default=func.now()) + +class Workspace(Base): + __tablename__ = "workspace" + + id = Column(String, primary_key=True) + name = Column(String, nullable=False) + timezone = Column(String, nullable=False, default="Pacific/Auckland") diff --git a/backend/schemas.py b/backend/schemas.py index 09708a3..1b79309 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -112,6 +112,19 @@ class Session(SessionBase): class Config: from_attributes = True +class WorkspaceBase(BaseModel): + name: str + timezone: str + +class WorkspaceUpdate(BaseModel): + name: Optional[str] = None + timezone: Optional[str] = None + +class Workspace(WorkspaceBase): + id: str + class Config: + from_attributes = True + class Token(BaseModel): access_token: str token_type: str diff --git a/dashy.db b/dashy.db index d665771..60a3dcc 100644 Binary files a/dashy.db and b/dashy.db differ diff --git a/screens.jsx b/screens.jsx index 8b9117d..2cb0684 100644 --- a/screens.jsx +++ b/screens.jsx @@ -1,6 +1,6 @@ // Screens for Dashy -function LoginScreen({ onLogin, dbUsers = [] }) { +function LoginScreen({ onLogin, dbUsers = [], workspace }) { const [pickedId, setPickedId] = React.useState('rod'); const [password, setPassword] = React.useState(''); const [error, setError] = React.useState(''); @@ -28,7 +28,7 @@ function LoginScreen({ onLogin, dbUsers = [] }) { Dashy

Pick up where you left off.

-

Sign in to your team workspace · murchison-auto

+

Sign in to your team workspace · {workspace ? workspace.name : 'loading…'}

{dbUsers.map(u => ( @@ -106,13 +106,13 @@ function BrandMark({ size = 22 }) { ); } -function TopBar({ me, dbUsers = [], isAdmin, tab, setTab, onAdd, onLogs, onLogout, onProfile }) { +function TopBar({ me, dbUsers = [], isAdmin, tab, setTab, onAdd, onLogs, onLogout, onProfile, workspace }) { return (
Dashy - murchison-auto + {workspace ? workspace.name : 'loading…'}
@@ -907,13 +909,21 @@ function ToggleRow({ label, defaultOn = false }) { ); } -function WorkspaceTab({ user, isAdmin, dbUsers = [], onSwitchUser, onCreateUser, onDeleteUser, onUpdateUserRole }) { +function WorkspaceTab({ user, isAdmin, dbUsers = [], onSwitchUser, onCreateUser, onDeleteUser, onUpdateUserRole, workspace, onUpdateWorkspace }) { const [adding, setAdding] = React.useState(false); const [newName, setNewName] = React.useState(''); const [newRole, setNewRole] = React.useState(''); const [newType, setNewType] = React.useState('standard'); - const [wsName, setWsName] = React.useState('murchison-auto'); - const [wsTz, setWsTz] = React.useState('Pacific/Auckland'); + const [wsName, setWsName] = React.useState(workspace ? workspace.name : ''); + const [wsTz, setWsTz] = React.useState(workspace ? workspace.timezone : ''); + const [wsSaved, setWsSaved] = React.useState(false); + + React.useEffect(() => { + if (workspace) { + setWsName(workspace.name); + setWsTz(workspace.timezone); + } + }, [workspace]); const submit = () => { if (!newName.trim()) return; @@ -921,6 +931,14 @@ function WorkspaceTab({ user, isAdmin, dbUsers = [], onSwitchUser, onCreateUser, setNewName(''); setNewRole(''); setNewType('standard'); setAdding(false); }; + const handleUpdateWorkspace = async () => { + await onUpdateWorkspace({ name: wsName, timezone: wsTz }); + setWsSaved(true); + setTimeout(() => setWsSaved(false), 2000); + }; + + const wsDirty = workspace && (wsName !== workspace.name || wsTz !== workspace.timezone); + return ( <>

Switch user

@@ -1019,6 +1037,13 @@ function WorkspaceTab({ user, isAdmin, dbUsers = [], onSwitchUser, onCreateUser, setWsTz(e.target.value)} disabled={!isAdmin} /> + {isAdmin && ( +
+ {wsSaved && Saved} + + +
+ )} ); }