Skip to content

Commit db16a94

Browse files
committed
UI
1 parent a0ee8ec commit db16a94

16 files changed

Lines changed: 453 additions & 169 deletions

apps/backend/app/api/routes/workspaces/api.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,24 @@ async def list_workspaces(
5959
org_id: str | None = Query(default=None),
6060
) -> list[WorkspaceOut]:
6161
workspaces = await find_user_workspaces(uid, org_id=org_id)
62+
# Auto-heal: any org the user belongs to but has no workspaces in (e.g.
63+
# accepted an org-only invite under an older build) gets a Personal
64+
# workspace seeded so the org-switcher has something to land on.
65+
from app.api.routes.workspaces.repo import (
66+
find_user_orgs,
67+
upsert_personal_workspace,
68+
upsert_ws_membership,
69+
)
70+
orgs_with_ws = {w["org_id"] for w in workspaces}
71+
user_orgs = await find_user_orgs(uid)
72+
missing = [o for o in user_orgs if o["_id"] not in orgs_with_ws]
73+
if org_id is not None:
74+
missing = [o for o in missing if o["_id"] == org_id]
75+
if missing:
76+
for org in missing:
77+
ws_id = await upsert_personal_workspace(org["_id"], uid)
78+
await upsert_ws_membership(ws_id, org["_id"], uid, "admin")
79+
workspaces = await find_user_workspaces(uid, org_id=org_id)
6280
return [_ws_to_out(w) for w in workspaces]
6381

6482

apps/backend/app/api/routes/workspaces/invitations_service.py

Lines changed: 32 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
from app.api.routes.auth.users_repo import get_user_doc
55
from app.api.routes.workspaces import invitations_repo, repo
66
from app.api.routes.workspaces.schema import InvitationOut, InviteMemberRequest
7-
from app.core.email import send_invitation_email
87
from app.utils.utils import create_timestamp, new_id
98

109
INVITATION_TTL_SECONDS = 14 * 24 * 3600
@@ -64,33 +63,35 @@ async def invite_to_org(
6463
email = body.email.lower().strip()
6564
if body.role not in ("owner", "admin", "member", "viewer"):
6665
raise HTTPException(400, "Invalid org role")
67-
now = create_timestamp()
66+
# Optional bundled workspace grant on accept.
67+
ws_id: str | None = body.workspace_id
68+
ws_role: str | None = body.workspace_role
69+
if ws_id:
70+
if ws_role not in ("admin", "developer", "viewer"):
71+
raise HTTPException(400, "Invalid workspace role")
72+
ws = await repo.find_workspace(ws_id)
73+
if not ws or ws.get("org_id") != org_id:
74+
raise HTTPException(400, "Workspace does not belong to this org")
6875
invited_uid = await _find_uid_by_email(email)
76+
if not invited_uid:
77+
raise HTTPException(404, "No user found with that email. Ask them to sign up first.")
78+
now = create_timestamp()
6979
doc = {
7080
"_id": new_id(),
7181
"org_id": org_id,
72-
"workspace_id": None,
82+
"workspace_id": ws_id,
7383
"invited_email": email,
7484
"invited_uid": invited_uid,
7585
"invited_role_org": body.role,
76-
"invited_role_ws": None,
86+
"invited_role_ws": ws_role,
7787
"token": _new_token(),
7888
"status": "pending",
7989
"invited_by": uid,
8090
"created_at": now,
8191
"expires_at": now + INVITATION_TTL_SECONDS * 1000,
8292
}
8393
await invitations_repo.create_invitation(doc)
84-
85-
inviter = await get_user_doc(uid) or {}
86-
org = await repo.find_org(org_id) or {}
87-
await send_invitation_email(
88-
to=email,
89-
token=doc["token"],
90-
inviter_name=inviter.get("displayName") or inviter.get("email") or "A teammate",
91-
org_name=org.get("name", "an org"),
92-
workspace_name=None,
93-
)
94+
# In-app notification only — the invited user sees this via the bell.
9495
return _doc_to_out(doc)
9596

9697

@@ -101,8 +102,10 @@ async def invite_to_workspace(
101102
email = body.email.lower().strip()
102103
if body.role not in ("admin", "developer", "viewer"):
103104
raise HTTPException(400, "Invalid ws role")
104-
now = create_timestamp()
105105
invited_uid = await _find_uid_by_email(email)
106+
if not invited_uid:
107+
raise HTTPException(404, "No user found with that email. Ask them to sign up first.")
108+
now = create_timestamp()
106109
doc = {
107110
"_id": new_id(),
108111
"org_id": ws["org_id"],
@@ -119,16 +122,7 @@ async def invite_to_workspace(
119122
"expires_at": now + INVITATION_TTL_SECONDS * 1000,
120123
}
121124
await invitations_repo.create_invitation(doc)
122-
123-
inviter = await get_user_doc(uid) or {}
124-
org = await repo.find_org(ws["org_id"]) or {}
125-
await send_invitation_email(
126-
to=email,
127-
token=doc["token"],
128-
inviter_name=inviter.get("displayName") or inviter.get("email") or "A teammate",
129-
org_name=org.get("name", "an org"),
130-
workspace_name=ws["name"],
131-
)
125+
# In-app notification only — the invited user sees this via PendingInvitationsBadge.
132126
return _doc_to_out(doc)
133127

134128

@@ -166,6 +160,11 @@ async def accept_invitation(uid: str, token: str) -> dict:
166160
await repo.upsert_ws_membership(
167161
inv["workspace_id"], inv["org_id"], uid, inv["invited_role_ws"],
168162
)
163+
else:
164+
# Org-only invite: seed a Personal workspace in this org so the user has
165+
# somewhere to land when they switch to it. Mirrors signup behavior.
166+
ws_id = await repo.upsert_personal_workspace(inv["org_id"], uid)
167+
await repo.upsert_ws_membership(ws_id, inv["org_id"], uid, "admin")
169168
await invitations_repo.update_invitation_status(
170169
inv["_id"], "accepted",
171170
accepted_uid=uid, accepted_at=create_timestamp(),
@@ -177,10 +176,15 @@ async def revoke_invitation(uid: str, token: str) -> None:
177176
inv = await invitations_repo.find_invitation_by_token(token)
178177
if not inv:
179178
raise HTTPException(404, "Invitation not found")
179+
# Allowed: original inviter, org owner/admin, OR the invitee themself (decline).
180+
user = await get_user_doc(uid)
181+
is_invitee = bool(
182+
(inv.get("invited_uid") and inv["invited_uid"] == uid)
183+
or (user and (user.get("email") or "").lower() == inv["invited_email"])
184+
)
180185
org_mem = await repo.find_org_membership(inv["org_id"], uid)
181-
if inv["invited_by"] != uid and not (
182-
org_mem and org_mem["org_role"] in ("owner", "admin")
183-
):
186+
is_admin = bool(org_mem and org_mem["org_role"] in ("owner", "admin"))
187+
if not (inv["invited_by"] == uid or is_admin or is_invitee):
184188
raise HTTPException(403, "Cannot revoke this invitation")
185189
if inv["status"] != "pending":
186190
return

apps/backend/app/api/routes/workspaces/middleware.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,21 @@ async def get_workspace_ctx(
7373
)
7474

7575

76+
def assert_writer(ctx: WorkspaceContext) -> None:
77+
"""Raise 403 if the active workspace role can't mutate. Viewers are read-only.
78+
Call at the top of every create/update/delete service path."""
79+
if ctx.ws_role == "viewer":
80+
raise HTTPException(403, "Read-only role in this workspace.")
81+
82+
83+
async def get_workspace_write_ctx(
84+
ctx: Annotated[WorkspaceContext, Depends(get_workspace_ctx)],
85+
) -> WorkspaceContext:
86+
"""FastAPI Depends for routes that mutate workspace data."""
87+
assert_writer(ctx)
88+
return ctx
89+
90+
7691
def apply_workspace_filter(
7792
ctx: WorkspaceContext, base_filter: dict[str, Any]
7893
) -> dict[str, Any]:

apps/backend/app/api/routes/workspaces/schema.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ class MemberOut(BaseModel):
7777
class InviteMemberRequest(BaseModel):
7878
email: str = Field(min_length=1)
7979
role: str
80+
# Org-level invite only — optionally also grant a workspace membership on accept.
81+
workspace_id: str | None = None
82+
workspace_role: str | None = None
8083

8184

8285
class ChangeRoleRequest(BaseModel):

apps/web/src/components/api-key-vault/add-api-key-dialog.tsx

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ import { auth } from "@/database/firebase"
1616
import { createApiKeyEntry, updateApiKeyEntry } from "@/lib/api-key-vault-api"
1717
import { cn } from "@/lib/utils"
1818
import { toast } from "sonner"
19-
import { useCipherKey } from "@/lib/use-cipher-key"
19+
import { useCipherKey, cipherKeyErrorMessage } from "@/lib/use-cipher-key"
20+
import { useActiveToolPermissions } from "@/lib/workspace-rbac"
2021

2122
type FormState = {
2223
name: string
@@ -257,10 +258,19 @@ export function AddApiKeyDialog({ children }: { children?: React.ReactNode }) {
257258
const { addEntry } = useApiKeyVaultStore()
258259
const [open, setOpen] = useState(false)
259260
const isMobile = useIsMobile()
261+
const { canWrite } = useActiveToolPermissions()
262+
263+
// Viewers in shared workspaces — hide the Add button entirely. Personal
264+
// workspace + admin/developer still see it (canWrite is true for both).
265+
if (!canWrite) return null
260266

261267
const handleSubmit = async (data: FormState) => {
262-
if (!encryptionKey || !auth.currentUser) {
263-
toast.error("Vault is locked")
268+
if (!auth.currentUser) {
269+
toast.error("Sign in to continue")
270+
return
271+
}
272+
if (!encryptionKey) {
273+
toast.error(cipherKeyErrorMessage())
264274
return
265275
}
266276
try {
@@ -329,12 +339,19 @@ export function EditApiKeyDialog({
329339
const encryptionKey = useCipherKey()
330340
const { updateEntry } = useApiKeyVaultStore()
331341
const isMobile = useIsMobile()
342+
const { canWrite } = useActiveToolPermissions()
332343

333344
if (!entry) return null
345+
// Viewers can open the row but the edit dialog itself is no-op for them.
346+
if (!canWrite) return null
334347

335348
const handleSubmit = async (data: FormState) => {
336-
if (!encryptionKey || !auth.currentUser) {
337-
toast.error("Vault is locked")
349+
if (!auth.currentUser) {
350+
toast.error("Sign in to continue")
351+
return
352+
}
353+
if (!encryptionKey) {
354+
toast.error(cipherKeyErrorMessage())
338355
return
339356
}
340357
try {

apps/web/src/components/environment-manager/add-environment-set-dialog.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
type EnvVariableRow,
1515
} from "@/store/environment-manager-store"
1616
import { useCipherKey } from "@/lib/use-cipher-key"
17+
import { useActiveToolPermissions } from "@/lib/workspace-rbac"
1718
import { Badge } from "@/components/ui/badge"
1819
import { EnvPasteCollapsible } from "@/components/environment-manager/env-paste-collapsible"
1920
import { encryptData } from "@/lib/encryption"
@@ -35,6 +36,8 @@ export function AddEnvironmentSetDialog({ children }: { children?: React.ReactNo
3536
const { addSet } = useEnvironmentManagerStore()
3637
const [open, setOpen] = useState(false)
3738
const [loading, setLoading] = useState(false)
39+
const { canWrite } = useActiveToolPermissions()
40+
if (!canWrite) return null
3841
const [showValues, setShowValues] = useState(false)
3942
const [project, setProject] = useState("")
4043
const [environment, setEnvironment] = useState("")

apps/web/src/components/invite-member-dialog.tsx

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client"
22

3-
import { useState } from "react"
3+
import { useMemo, useState } from "react"
44
import { Loader2 } from "lucide-react"
55
import {
66
Dialog,
@@ -20,6 +20,7 @@ import {
2020
} from "@/components/ui/select"
2121
import { toast } from "sonner"
2222
import { inviteToOrg, inviteToWorkspace } from "@/lib/invitations-api"
23+
import { useWorkspaceStore } from "@/store/workspace-store"
2324

2425
const ORG_ROLES = [
2526
{ value: "admin", label: "Admin" },
@@ -33,6 +34,8 @@ const WS_ROLES = [
3334
{ value: "viewer", label: "Viewer" },
3435
]
3536

37+
const NO_WORKSPACE = "__none__"
38+
3639
export function InviteMemberDialog({
3740
scope,
3841
scopeId,
@@ -47,13 +50,26 @@ export function InviteMemberDialog({
4750
const roles = scope === "org" ? ORG_ROLES : WS_ROLES
4851
const defaultRole = roles[1].value
4952

53+
const allWorkspaces = useWorkspaceStore((s) => s.workspaces)
54+
const orgWorkspaces = useMemo(
55+
() =>
56+
scope === "org"
57+
? allWorkspaces.filter((w) => w.org_id === scopeId && !w.is_personal)
58+
: [],
59+
[allWorkspaces, scope, scopeId],
60+
)
61+
5062
const [email, setEmail] = useState("")
5163
const [role, setRole] = useState(defaultRole)
64+
const [workspaceId, setWorkspaceId] = useState<string>(NO_WORKSPACE)
65+
const [workspaceRole, setWorkspaceRole] = useState<string>("developer")
5266
const [loading, setLoading] = useState(false)
5367

5468
function reset() {
5569
setEmail("")
5670
setRole(defaultRole)
71+
setWorkspaceId(NO_WORKSPACE)
72+
setWorkspaceRole("developer")
5773
setLoading(false)
5874
}
5975

@@ -73,11 +89,15 @@ export function InviteMemberDialog({
7389
setLoading(true)
7490
try {
7591
if (scope === "org") {
76-
await inviteToOrg(scopeId, trimmedEmail, role)
92+
const extras =
93+
workspaceId !== NO_WORKSPACE
94+
? { workspaceId, workspaceRole }
95+
: undefined
96+
await inviteToOrg(scopeId, trimmedEmail, role, extras)
7797
} else {
7898
await inviteToWorkspace(scopeId, trimmedEmail, role)
7999
}
80-
toast.success(`Invitation sent to ${trimmedEmail}`)
100+
toast.success(`${trimmedEmail} will see an in-app invitation to approve.`)
81101
reset()
82102
onOpenChange(false)
83103
} catch (err) {
@@ -123,6 +143,53 @@ export function InviteMemberDialog({
123143
</SelectContent>
124144
</Select>
125145
</div>
146+
147+
{scope === "org" && (
148+
<div className="space-y-2">
149+
<Label htmlFor="invite-ws">Add to workspace (optional)</Label>
150+
<Select
151+
value={workspaceId}
152+
onValueChange={setWorkspaceId}
153+
disabled={loading || orgWorkspaces.length === 0}
154+
>
155+
<SelectTrigger id="invite-ws">
156+
<SelectValue placeholder="Org membership only" />
157+
</SelectTrigger>
158+
<SelectContent>
159+
<SelectItem value={NO_WORKSPACE}>Org membership only</SelectItem>
160+
{orgWorkspaces.map((w) => (
161+
<SelectItem key={w.id} value={w.id}>
162+
{w.name}
163+
</SelectItem>
164+
))}
165+
</SelectContent>
166+
</Select>
167+
{workspaceId !== NO_WORKSPACE && (
168+
<div className="pt-1">
169+
<Label htmlFor="invite-ws-role" className="text-xs text-muted-foreground">
170+
Workspace role
171+
</Label>
172+
<Select
173+
value={workspaceRole}
174+
onValueChange={setWorkspaceRole}
175+
disabled={loading}
176+
>
177+
<SelectTrigger id="invite-ws-role" className="mt-1">
178+
<SelectValue />
179+
</SelectTrigger>
180+
<SelectContent>
181+
{WS_ROLES.map((r) => (
182+
<SelectItem key={r.value} value={r.value}>
183+
{r.label}
184+
</SelectItem>
185+
))}
186+
</SelectContent>
187+
</Select>
188+
</div>
189+
)}
190+
</div>
191+
)}
192+
126193
<div className="flex justify-end gap-2 pt-1">
127194
<Button
128195
type="button"

0 commit comments

Comments
 (0)