Created update button for workspace settings -- these include TIMEZONE and WORKSPACE NAME

This commit is contained in:
NPS Agent
2026-05-12 09:42:03 +09:30
parent 62d431818a
commit 60a1cf1b67
8 changed files with 112 additions and 14 deletions
+1
View File
@@ -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.
+13
View File
@@ -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',
+17 -6
View File
@@ -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 <LoginScreen dbUsers={dbUsers} onLogin={async (id, pwd) => {
return <LoginScreen dbUsers={dbUsers} workspace={workspace} onLogin={async (id, pwd) => {
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}
/>
<HeadsUp items={headsUp} onDismiss={dismissHU} onOpenTask={openTaskFromAnywhere} />
@@ -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);
}
}}
/>
)}
+28
View File
@@ -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()
+7
View File
@@ -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")
+13
View File
@@ -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
BIN
View File
Binary file not shown.
+33 -8
View File
@@ -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 = [] }) {
<span className="login__wordmark">Dashy</span>
</div>
<h1 className="login__title">Pick up where you left off.</h1>
<p className="login__sub">Sign in to your team workspace · <span className="mono">murchison-auto</span></p>
<p className="login__sub">Sign in to your team workspace · <span className="mono">{workspace ? workspace.name : 'loading…'}</span></p>
<div className="login__users">
{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 (
<header className="topbar">
<div className="topbar__left">
<span className="topbar__brand"><BrandMark /><span>Dashy</span></span>
<span className="topbar__divider" />
<span className="topbar__workspace">murchison-auto</span>
<span className="topbar__workspace">{workspace ? workspace.name : 'loading…'}</span>
</div>
<nav className="tabs" role="tablist">
@@ -682,7 +682,7 @@ function FilterChip({ on, onClick, children }) {
);
}
function SettingsScreen({ user, dbUsers, isAdmin, onClose, onSave, onLogout, onSwitchUser, onCreateUser, onDeleteUser, onUpdateUserRole, onChangePassword }) {
function SettingsScreen({ user, dbUsers, isAdmin, onClose, onSave, onLogout, onSwitchUser, onCreateUser, onDeleteUser, onUpdateUserRole, onChangePassword, workspace, onUpdateWorkspace }) {
const [name, setName] = React.useState(user.name);
const [role, setRole] = React.useState(user.role);
const [photo, setPhoto] = React.useState(user.photo || null);
@@ -887,6 +887,8 @@ function SettingsScreen({ user, dbUsers, isAdmin, onClose, onSave, onLogout, onS
onCreateUser={onCreateUser}
onDeleteUser={onDeleteUser}
onUpdateUserRole={onUpdateUserRole}
workspace={workspace}
onUpdateWorkspace={onUpdateWorkspace}
/>
)}
</div>
@@ -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 (
<>
<h3 className="settings__h">Switch user</h3>
@@ -1019,6 +1037,13 @@ function WorkspaceTab({ user, isAdmin, dbUsers = [], onSwitchUser, onCreateUser,
<input className="field__input" value={wsTz} onChange={e => setWsTz(e.target.value)} disabled={!isAdmin} />
</label>
</div>
{isAdmin && (
<div className="settings__save-row" style={{ marginTop: '1rem' }}>
{wsSaved && <span className="settings__saved mono"><Icon.Check /> Saved</span>}
<button className="btn btn--ghost" onClick={() => { setWsName(workspace.name); setWsTz(workspace.timezone); }} disabled={!wsDirty}>Discard</button>
<button className="btn btn--primary" onClick={handleUpdateWorkspace} disabled={!wsDirty}>Update workspace</button>
</div>
)}
</>
);
}