Seth commited on
Commit
1695b82
·
1 Parent(s): 51ee8a8
backend/app/tenant_routes.py CHANGED
@@ -162,7 +162,7 @@ async def create_invitation(body: InviteBody, tc: TenantContext = Depends(requir
162
 
163
  @router.get("/members")
164
  def list_tenant_members(tc: TenantContext = Depends(require_tenant_admin)):
165
- """List users in the current workspace (admin only)."""
166
  db = tc.db
167
  rows = (
168
  db.query(TenantMembership, User)
@@ -171,19 +171,79 @@ def list_tenant_members(tc: TenantContext = Depends(require_tenant_admin)):
171
  .order_by(User.email)
172
  .all()
173
  )
174
- members = []
 
175
  for m, u in rows:
 
 
 
176
  members.append(
177
  {
178
  "user_id": u.id,
 
179
  "email": u.email or "",
180
  "name": u.name or "",
181
  "role": m.role,
 
182
  }
183
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  return {"members": members}
185
 
186
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  @router.delete("/members/{member_user_id}")
188
  def remove_tenant_member(
189
  member_user_id: int,
 
162
 
163
  @router.get("/members")
164
  def list_tenant_members(tc: TenantContext = Depends(require_tenant_admin)):
165
+ """List workspace members and pending invitations (admin only)."""
166
  db = tc.db
167
  rows = (
168
  db.query(TenantMembership, User)
 
171
  .order_by(User.email)
172
  .all()
173
  )
174
+ member_emails: set[str] = set()
175
+ members: list[dict] = []
176
  for m, u in rows:
177
+ em = (u.email or "").strip().lower()
178
+ if em:
179
+ member_emails.add(em)
180
  members.append(
181
  {
182
  "user_id": u.id,
183
+ "invitation_id": None,
184
  "email": u.email or "",
185
  "name": u.name or "",
186
  "role": m.role,
187
+ "status": "active",
188
  }
189
  )
190
+
191
+ now = datetime.utcnow()
192
+ pending = (
193
+ db.query(Invitation)
194
+ .filter(
195
+ Invitation.tenant_id == tc.tenant_id,
196
+ Invitation.accepted_at.is_(None),
197
+ Invitation.expires_at > now,
198
+ )
199
+ .order_by(Invitation.created_at.desc())
200
+ .all()
201
+ )
202
+ pending_latest: dict[str, Invitation] = {}
203
+ for inv in pending:
204
+ key = (inv.email or "").strip().lower()
205
+ if key and key not in pending_latest:
206
+ pending_latest[key] = inv
207
+
208
+ for key, inv in pending_latest.items():
209
+ if key in member_emails:
210
+ continue
211
+ members.append(
212
+ {
213
+ "user_id": None,
214
+ "invitation_id": inv.id,
215
+ "email": inv.email or "",
216
+ "name": "",
217
+ "role": inv.role or "member",
218
+ "status": "invited",
219
+ }
220
+ )
221
+
222
+ members.sort(key=lambda x: (x.get("email") or "").lower())
223
  return {"members": members}
224
 
225
 
226
+ @router.delete("/invitations/{invitation_id}")
227
+ def revoke_tenant_invitation(
228
+ invitation_id: int,
229
+ tc: TenantContext = Depends(require_tenant_admin),
230
+ ):
231
+ """Remove a pending invitation before it is accepted."""
232
+ db = tc.db
233
+ inv = (
234
+ db.query(Invitation)
235
+ .filter(Invitation.id == invitation_id, Invitation.tenant_id == tc.tenant_id)
236
+ .first()
237
+ )
238
+ if not inv:
239
+ raise HTTPException(status_code=404, detail="Invitation not found")
240
+ if inv.accepted_at is not None:
241
+ raise HTTPException(status_code=400, detail="Invitation already accepted")
242
+ db.delete(inv)
243
+ db.commit()
244
+ return {"ok": True}
245
+
246
+
247
  @router.delete("/members/{member_user_id}")
248
  def remove_tenant_member(
249
  member_user_id: int,
frontend/src/components/layout/AppShell.jsx CHANGED
@@ -129,11 +129,13 @@ export default function AppShell({ title, subtitle, rightContent, children }) {
129
  const Icon = item.icon;
130
  const active = pathMatches(location.pathname, item.href);
131
  const activeHighlight = active && !sidebarCollapsed;
 
132
  return (
133
  <Link
134
  to={item.href}
135
  key={item.href}
136
  title={sidebarCollapsed ? item.label : undefined}
 
137
  className={cn(
138
  'flex rounded-2xl py-3 transition-all',
139
  sidebarCollapsed
@@ -146,13 +148,15 @@ export default function AppShell({ title, subtitle, rightContent, children }) {
146
  >
147
  <div
148
  className={cn(
149
- 'flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border',
150
  activeHighlight
151
  ? 'border-violet-200 bg-white text-violet-600'
152
- : 'border-slate-200 bg-white text-slate-500'
 
 
153
  )}
154
  >
155
- <Icon className="h-5 w-5" />
156
  </div>
157
  {!sidebarCollapsed && (
158
  <span
 
129
  const Icon = item.icon;
130
  const active = pathMatches(location.pathname, item.href);
131
  const activeHighlight = active && !sidebarCollapsed;
132
+ const collapsedActive = active && sidebarCollapsed;
133
  return (
134
  <Link
135
  to={item.href}
136
  key={item.href}
137
  title={sidebarCollapsed ? item.label : undefined}
138
+ aria-current={active ? 'page' : undefined}
139
  className={cn(
140
  'flex rounded-2xl py-3 transition-all',
141
  sidebarCollapsed
 
148
  >
149
  <div
150
  className={cn(
151
+ 'flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border transition-colors',
152
  activeHighlight
153
  ? 'border-violet-200 bg-white text-violet-600'
154
+ : collapsedActive
155
+ ? 'border-violet-300 bg-violet-50/90 text-violet-800'
156
+ : 'border-slate-200 bg-white text-slate-500'
157
  )}
158
  >
159
+ <Icon className="h-5 w-5" strokeWidth={collapsedActive ? 2.25 : 2} />
160
  </div>
161
  {!sidebarCollapsed && (
162
  <span
frontend/src/pages/Deals.jsx CHANGED
@@ -977,7 +977,11 @@ export default function Deals() {
977
  }
978
  apiFetch('/api/tenants/members')
979
  .then((r) => (r.ok ? r.json() : null))
980
- .then((d) => setTenantMembers(d?.members || []))
 
 
 
 
981
  .catch(() => setTenantMembers([]));
982
  }, [me]);
983
 
 
977
  }
978
  apiFetch('/api/tenants/members')
979
  .then((r) => (r.ok ? r.json() : null))
980
+ .then((d) =>
981
+ setTenantMembers(
982
+ (d?.members || []).filter((x) => x.user_id != null)
983
+ )
984
+ )
985
  .catch(() => setTenantMembers([]));
986
  }, [me]);
987
 
frontend/src/pages/SalesDashboard.jsx CHANGED
@@ -374,7 +374,11 @@ export default function SalesDashboard() {
374
  }
375
  apiFetch('/api/tenants/members')
376
  .then((r) => (r.ok ? r.json() : null))
377
- .then((d) => setTenantMembers(d?.members || []))
 
 
 
 
378
  .catch(() => setTenantMembers([]));
379
  }, [me, isAdmin]);
380
 
 
374
  }
375
  apiFetch('/api/tenants/members')
376
  .then((r) => (r.ok ? r.json() : null))
377
+ .then((d) =>
378
+ setTenantMembers(
379
+ (d?.members || []).filter((x) => x.user_id != null)
380
+ )
381
+ )
382
  .catch(() => setTenantMembers([]));
383
  }, [me, isAdmin]);
384
 
frontend/src/pages/Settings.jsx CHANGED
@@ -39,7 +39,8 @@ export default function Settings() {
39
  const [inviteBusy, setInviteBusy] = useState(false);
40
  const [inviteResult, setInviteResult] = useState(null);
41
 
42
- const [removeBusyId, setRemoveBusyId] = useState(null);
 
43
 
44
  const loadMe = useCallback(async () => {
45
  setMeLoading(true);
@@ -143,6 +144,7 @@ export default function Settings() {
143
  });
144
  setInviteEmail('');
145
  loadMe();
 
146
  } catch (e) {
147
  setInviteResult({ error: String(e) });
148
  } finally {
@@ -152,7 +154,7 @@ export default function Settings() {
152
 
153
  const removeMember = async (userId) => {
154
  if (!window.confirm('Remove this user from the workspace?')) return;
155
- setRemoveBusyId(userId);
156
  try {
157
  const res = await apiFetch(`/api/tenants/members/${userId}`, { method: 'DELETE' });
158
  const data = await res.json().catch(() => ({}));
@@ -164,7 +166,26 @@ export default function Settings() {
164
  } catch (e) {
165
  alert(e.message || 'Could not remove member');
166
  } finally {
167
- setRemoveBusyId(null);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
  }
169
  };
170
 
@@ -314,7 +335,7 @@ export default function Settings() {
314
  ) : membersError ? (
315
  <p className="text-sm text-red-600">{membersError}</p>
316
  ) : members.length === 0 ? (
317
- <p className="text-sm text-slate-500">No members found.</p>
318
  ) : (
319
  <div className="overflow-x-auto rounded-xl border border-slate-100">
320
  <table className="w-full text-sm text-left">
@@ -323,15 +344,23 @@ export default function Settings() {
323
  <th className="px-4 py-3 font-medium text-slate-600">Email</th>
324
  <th className="px-4 py-3 font-medium text-slate-600">Name</th>
325
  <th className="px-4 py-3 font-medium text-slate-600">Role</th>
326
- <th className="px-4 py-3 font-medium text-slate-600 w-24" />
 
327
  </tr>
328
  </thead>
329
  <tbody>
330
  {members.map((m) => {
 
 
331
  const isSelf = m.user_id === me.user_id;
 
 
 
 
 
332
  return (
333
  <tr
334
- key={m.user_id}
335
  className="border-b border-slate-50 last:border-0"
336
  >
337
  <td className="px-4 py-3 text-slate-900">
@@ -352,22 +381,50 @@ export default function Settings() {
352
  {m.role}
353
  </span>
354
  </td>
 
 
 
 
 
 
 
 
 
 
 
 
355
  <td className="px-4 py-3 text-right">
356
  {isSelf ? (
357
  <span className="text-xs text-slate-400">
358
  You
359
  </span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
360
  ) : (
361
  <Button
362
  type="button"
363
  variant="ghost"
364
  size="sm"
365
  className="h-8 text-red-600 hover:text-red-700 hover:bg-red-50"
366
- disabled={removeBusyId === m.user_id}
367
  onClick={() => removeMember(m.user_id)}
368
  title="Remove from workspace"
369
  >
370
- {removeBusyId === m.user_id ? (
371
  <Loader2 className="h-4 w-4 animate-spin" />
372
  ) : (
373
  <Trash2 className="h-4 w-4" />
 
39
  const [inviteBusy, setInviteBusy] = useState(false);
40
  const [inviteResult, setInviteResult] = useState(null);
41
 
42
+ /** e.g. `member:12` or `invite:3` while a remove/revoke request is in flight */
43
+ const [removeBusyKey, setRemoveBusyKey] = useState(null);
44
 
45
  const loadMe = useCallback(async () => {
46
  setMeLoading(true);
 
144
  });
145
  setInviteEmail('');
146
  loadMe();
147
+ await loadMembers();
148
  } catch (e) {
149
  setInviteResult({ error: String(e) });
150
  } finally {
 
154
 
155
  const removeMember = async (userId) => {
156
  if (!window.confirm('Remove this user from the workspace?')) return;
157
+ setRemoveBusyKey(`member:${userId}`);
158
  try {
159
  const res = await apiFetch(`/api/tenants/members/${userId}`, { method: 'DELETE' });
160
  const data = await res.json().catch(() => ({}));
 
166
  } catch (e) {
167
  alert(e.message || 'Could not remove member');
168
  } finally {
169
+ setRemoveBusyKey(null);
170
+ }
171
+ };
172
+
173
+ const revokeInvitation = async (invitationId) => {
174
+ if (!window.confirm('Cancel this invitation? They will not be able to join with the current link.'))
175
+ return;
176
+ setRemoveBusyKey(`invite:${invitationId}`);
177
+ try {
178
+ const res = await apiFetch(`/api/tenants/invitations/${invitationId}`, { method: 'DELETE' });
179
+ const data = await res.json().catch(() => ({}));
180
+ if (!res.ok) {
181
+ alert(typeof data.detail === 'string' ? data.detail : 'Could not cancel invitation');
182
+ return;
183
+ }
184
+ await loadMembers();
185
+ } catch (e) {
186
+ alert(e.message || 'Could not cancel invitation');
187
+ } finally {
188
+ setRemoveBusyKey(null);
189
  }
190
  };
191
 
 
335
  ) : membersError ? (
336
  <p className="text-sm text-red-600">{membersError}</p>
337
  ) : members.length === 0 ? (
338
+ <p className="text-sm text-slate-500">No members or pending invitations.</p>
339
  ) : (
340
  <div className="overflow-x-auto rounded-xl border border-slate-100">
341
  <table className="w-full text-sm text-left">
 
344
  <th className="px-4 py-3 font-medium text-slate-600">Email</th>
345
  <th className="px-4 py-3 font-medium text-slate-600">Name</th>
346
  <th className="px-4 py-3 font-medium text-slate-600">Role</th>
347
+ <th className="px-4 py-3 font-medium text-slate-600">Status</th>
348
+ <th className="px-4 py-3 font-medium text-slate-600 w-28" />
349
  </tr>
350
  </thead>
351
  <tbody>
352
  {members.map((m) => {
353
+ const rowKey =
354
+ m.user_id != null ? `m-${m.user_id}` : `i-${m.invitation_id}`;
355
  const isSelf = m.user_id === me.user_id;
356
+ const isInvited = m.status === 'invited';
357
+ const busyMember = removeBusyKey === `member:${m.user_id}`;
358
+ const busyInvite =
359
+ m.invitation_id != null &&
360
+ removeBusyKey === `invite:${m.invitation_id}`;
361
  return (
362
  <tr
363
+ key={rowKey}
364
  className="border-b border-slate-50 last:border-0"
365
  >
366
  <td className="px-4 py-3 text-slate-900">
 
381
  {m.role}
382
  </span>
383
  </td>
384
+ <td className="px-4 py-3">
385
+ <span
386
+ className={cn(
387
+ 'inline-flex rounded-full px-2 py-0.5 text-xs font-medium',
388
+ isInvited
389
+ ? 'bg-amber-50 text-amber-900 ring-1 ring-amber-200/80'
390
+ : 'bg-emerald-50 text-emerald-900 ring-1 ring-emerald-200/80'
391
+ )}
392
+ >
393
+ {isInvited ? 'Invited' : 'Active'}
394
+ </span>
395
+ </td>
396
  <td className="px-4 py-3 text-right">
397
  {isSelf ? (
398
  <span className="text-xs text-slate-400">
399
  You
400
  </span>
401
+ ) : isInvited ? (
402
+ <Button
403
+ type="button"
404
+ variant="ghost"
405
+ size="sm"
406
+ className="h-8 text-red-600 hover:text-red-700 hover:bg-red-50"
407
+ disabled={busyInvite}
408
+ onClick={() => revokeInvitation(m.invitation_id)}
409
+ title="Cancel invitation"
410
+ >
411
+ {busyInvite ? (
412
+ <Loader2 className="h-4 w-4 animate-spin" />
413
+ ) : (
414
+ <Trash2 className="h-4 w-4" />
415
+ )}
416
+ </Button>
417
  ) : (
418
  <Button
419
  type="button"
420
  variant="ghost"
421
  size="sm"
422
  className="h-8 text-red-600 hover:text-red-700 hover:bg-red-50"
423
+ disabled={busyMember}
424
  onClick={() => removeMember(m.user_id)}
425
  title="Remove from workspace"
426
  >
427
+ {busyMember ? (
428
  <Loader2 className="h-4 w-4 animate-spin" />
429
  ) : (
430
  <Trash2 className="h-4 w-4" />