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.
|
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.
|
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.
|
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
|
### Phase 3: Advanced Features
|
||||||
- **Real-time Notifications:** Explore WebSockets for task assignments.
|
- **Real-time Notifications:** Explore WebSockets for task assignments.
|
||||||
|
|||||||
@@ -66,6 +66,19 @@ class ApiService {
|
|||||||
return this.request('/users');
|
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) {
|
async createUser(userData) {
|
||||||
const data = await this.request('/users', {
|
const data = await this.request('/users', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
|
|||||||
const ACCENTS = ['#2A6FDB', '#1F8A5B', '#D97757', '#7A5AF8'];
|
const ACCENTS = ['#2A6FDB', '#1F8A5B', '#D97757', '#7A5AF8'];
|
||||||
|
|
||||||
function useApiData(authed) {
|
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);
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -19,13 +19,14 @@ function useApiData(authed) {
|
|||||||
let mounted = true;
|
let mounted = true;
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const [tasks, users, audit] = await Promise.all([
|
const [tasks, users, audit, workspace] = await Promise.all([
|
||||||
api.getTasks(),
|
api.getTasks(),
|
||||||
api.getUsers(),
|
api.getUsers(),
|
||||||
api.getAudit()
|
api.getAudit(),
|
||||||
|
api.getWorkspace()
|
||||||
]);
|
]);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setData({ tasks, users, audit });
|
setData({ tasks, users, audit, workspace });
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -55,7 +56,7 @@ function App() {
|
|||||||
return 'rod';
|
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');
|
const [tab, setTab] = React.useState('overview');
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -76,7 +77,7 @@ function App() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (!authed) {
|
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);
|
await api.login(id, pwd);
|
||||||
setMeId(id);
|
setMeId(id);
|
||||||
setAuthed(true);
|
setAuthed(true);
|
||||||
@@ -211,6 +212,7 @@ function App() {
|
|||||||
onAdd={() => setAdding(meId)}
|
onAdd={() => setAdding(meId)}
|
||||||
onLogs={() => setShowLogs(true)}
|
onLogs={() => setShowLogs(true)}
|
||||||
onProfile={() => setShowSettings(true)}
|
onProfile={() => setShowSettings(true)}
|
||||||
|
workspace={workspace}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<HeadsUp items={headsUp} onDismiss={dismissHU} onOpenTask={openTaskFromAnywhere} />
|
<HeadsUp items={headsUp} onDismiss={dismissHU} onOpenTask={openTaskFromAnywhere} />
|
||||||
@@ -308,6 +310,15 @@ function App() {
|
|||||||
await api.changePassword(meId, oldPwd, newPwd);
|
await api.changePassword(meId, oldPwd, newPwd);
|
||||||
await api.addAudit({ actor: meId, action: 'password_changed', summary: 'Updated password', target: meId });
|
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.delete(db_task)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"message": "Task deleted"}
|
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])
|
@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)):
|
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()
|
return db.query(models.AuditLog).order_by(models.AuditLog.at.desc()).all()
|
||||||
|
|||||||
@@ -82,3 +82,10 @@ class Session(Base):
|
|||||||
device = Column(String, nullable=False)
|
device = Column(String, nullable=False)
|
||||||
location = Column(String)
|
location = Column(String)
|
||||||
last_active = Column(DateTime(timezone=True), server_default=func.now())
|
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:
|
class Config:
|
||||||
from_attributes = True
|
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):
|
class Token(BaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
token_type: str
|
token_type: str
|
||||||
|
|||||||
+33
-8
@@ -1,6 +1,6 @@
|
|||||||
// Screens for Dashy
|
// Screens for Dashy
|
||||||
|
|
||||||
function LoginScreen({ onLogin, dbUsers = [] }) {
|
function LoginScreen({ onLogin, dbUsers = [], workspace }) {
|
||||||
const [pickedId, setPickedId] = React.useState('rod');
|
const [pickedId, setPickedId] = React.useState('rod');
|
||||||
const [password, setPassword] = React.useState('');
|
const [password, setPassword] = React.useState('');
|
||||||
const [error, setError] = React.useState('');
|
const [error, setError] = React.useState('');
|
||||||
@@ -28,7 +28,7 @@ function LoginScreen({ onLogin, dbUsers = [] }) {
|
|||||||
<span className="login__wordmark">Dashy</span>
|
<span className="login__wordmark">Dashy</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="login__title">Pick up where you left off.</h1>
|
<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">
|
<div className="login__users">
|
||||||
{dbUsers.map(u => (
|
{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 (
|
return (
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<div className="topbar__left">
|
<div className="topbar__left">
|
||||||
<span className="topbar__brand"><BrandMark /><span>Dashy</span></span>
|
<span className="topbar__brand"><BrandMark /><span>Dashy</span></span>
|
||||||
<span className="topbar__divider" />
|
<span className="topbar__divider" />
|
||||||
<span className="topbar__workspace">murchison-auto</span>
|
<span className="topbar__workspace">{workspace ? workspace.name : 'loading…'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="tabs" role="tablist">
|
<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 [name, setName] = React.useState(user.name);
|
||||||
const [role, setRole] = React.useState(user.role);
|
const [role, setRole] = React.useState(user.role);
|
||||||
const [photo, setPhoto] = React.useState(user.photo || null);
|
const [photo, setPhoto] = React.useState(user.photo || null);
|
||||||
@@ -887,6 +887,8 @@ function SettingsScreen({ user, dbUsers, isAdmin, onClose, onSave, onLogout, onS
|
|||||||
onCreateUser={onCreateUser}
|
onCreateUser={onCreateUser}
|
||||||
onDeleteUser={onDeleteUser}
|
onDeleteUser={onDeleteUser}
|
||||||
onUpdateUserRole={onUpdateUserRole}
|
onUpdateUserRole={onUpdateUserRole}
|
||||||
|
workspace={workspace}
|
||||||
|
onUpdateWorkspace={onUpdateWorkspace}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 [adding, setAdding] = React.useState(false);
|
||||||
const [newName, setNewName] = React.useState('');
|
const [newName, setNewName] = React.useState('');
|
||||||
const [newRole, setNewRole] = React.useState('');
|
const [newRole, setNewRole] = React.useState('');
|
||||||
const [newType, setNewType] = React.useState('standard');
|
const [newType, setNewType] = React.useState('standard');
|
||||||
const [wsName, setWsName] = React.useState('murchison-auto');
|
const [wsName, setWsName] = React.useState(workspace ? workspace.name : '');
|
||||||
const [wsTz, setWsTz] = React.useState('Pacific/Auckland');
|
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 = () => {
|
const submit = () => {
|
||||||
if (!newName.trim()) return;
|
if (!newName.trim()) return;
|
||||||
@@ -921,6 +931,14 @@ function WorkspaceTab({ user, isAdmin, dbUsers = [], onSwitchUser, onCreateUser,
|
|||||||
setNewName(''); setNewRole(''); setNewType('standard'); setAdding(false);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<h3 className="settings__h">Switch user</h3>
|
<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} />
|
<input className="field__input" value={wsTz} onChange={e => setWsTz(e.target.value)} disabled={!isAdmin} />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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