Seth commited on
Commit ·
1695b82
1
Parent(s): 51ee8a8
update
Browse files
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
|
| 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 |
-
|
|
|
|
| 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 |
-
:
|
|
|
|
|
|
|
| 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) =>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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) =>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
|
|
|
| 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={
|
| 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={
|
| 367 |
onClick={() => removeMember(m.user_id)}
|
| 368 |
title="Remove from workspace"
|
| 369 |
>
|
| 370 |
-
{
|
| 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" />
|