Created update button for workspace settings -- these include TIMEZONE and WORKSPACE NAME
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
+33
-8
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user