Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions backend/migrations/0003_backfill_resource_orgs.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
-- Migration 0003: Backfill org_id/team_id on existing resources
--
-- Assigns every existing project/workflow/tabular_review/chat/subfolder to its
-- owner's PERSONAL organization + default team. This must run before listing
-- is scoped by org (migration is paired with the resource-scoping backend
-- change) so existing data doesn't disappear from users' views.
--
-- Owner is identified by user_id (text) == auth.users.id::text, which is how
-- the app stores ownership today.
--
-- Idempotent: only fills rows where org_id is still null.

do $$
declare
r record;
begin
for r in
select o.id as org_id, t.id as team_id, o.created_by as uid
from public.organizations o
join public.teams t on t.org_id = o.id and t.is_default
where o.is_personal
loop
update public.projects
set org_id = r.org_id, team_id = coalesce(team_id, r.team_id)
where user_id = r.uid::text and org_id is null;
update public.workflows
set org_id = r.org_id, team_id = coalesce(team_id, r.team_id)
where user_id = r.uid::text and org_id is null;
update public.tabular_reviews
set org_id = r.org_id, team_id = coalesce(team_id, r.team_id)
where user_id = r.uid::text and org_id is null;
update public.chats
set org_id = r.org_id, team_id = coalesce(team_id, r.team_id)
where user_id = r.uid::text and org_id is null;
update public.project_subfolders
set org_id = r.org_id
where user_id = r.uid::text and org_id is null;
end loop;
end;
$$;
41 changes: 41 additions & 0 deletions backend/src/lib/tenancy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,47 @@ export async function getPermissions(
);
}

/**
* Resolve the "active organization" for a resource operation:
* - the X-Org-Id header if present AND the user is an active member, else
* - the user's personal org (auto-provisioned if missing).
* Also returns the org's default team id. Used by resource routes to stamp
* org_id/team_id on creates and to scope listings.
*/
export async function resolveActiveOrg(
req: Request,
userId: string,
email: string | null | undefined,
db: Db,
): Promise<{ orgId: string; teamId: string | null } | null> {
const headerOrg = req.header("x-org-id") ?? undefined;
let orgId: string | null = null;
if (headerOrg) {
const m = await getMembership(headerOrg, userId, db);
if (m) orgId = headerOrg;
}
if (!orgId) {
orgId = await ensurePersonalOrg(userId, email, db);
}
if (!orgId) return null;
const { data: team } = await db
.from("teams")
.select("id")
.eq("org_id", orgId)
.eq("is_default", true)
.maybeSingle();
return { orgId, teamId: (team as { id: string } | null)?.id ?? null };
}

/** Org ids the user can access (active membership). Convenience wrapper. */
export async function accessibleOrgIds(
userId: string,
db: Db,
): Promise<string[]> {
const m = await listMemberships(userId, db);
return m.map((x) => x.org_id);
}

/** Write an audit row (best-effort; never throws into the request path). */
export async function writeAudit(
orgId: string | null,
Expand Down
34 changes: 26 additions & 8 deletions backend/src/routes/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import { downloadFile, uploadFile, storageKey } from "../lib/storage";
import { docxToPdf, convertedPdfKey } from "../lib/convert";
import { checkProjectAccess } from "../lib/access";
import { resolveActiveOrg } from "../lib/tenancy";
import { singleFileUpload } from "../lib/upload";

export const projectsRouter = Router();
Expand All @@ -29,13 +30,20 @@ projectsRouter.get("/", requireAuth, async (req, res) => {
const userEmail = res.locals.userEmail as string;
const db = createServerSupabase();

const { data: ownProjects, error: ownError } = await db
.from("projects")
.select("*")
.eq("user_id", userId)
.order("created_at", { ascending: false });
if (ownError) return void res.status(500).json({ detail: ownError.message });
// Active-org view: every project in the org the caller is currently in
// (team-wide visibility). Falls back to the personal org when no X-Org-Id.
const active = await resolveActiveOrg(req, userId, userEmail, db);
const { data: orgProjects, error: orgError } = active
? await db
.from("projects")
.select("*")
.eq("org_id", active.orgId)
.order("created_at", { ascending: false })
: { data: [], error: null };
if (orgError) return void res.status(500).json({ detail: orgError.message });

// Transitional: also include legacy shares (shared_with my email) until the
// shared_with model is fully retired.
const { data: sharedProjects, error: sharedError } = userEmail
? await db
.from("projects")
Expand All @@ -47,9 +55,15 @@ projectsRouter.get("/", requireAuth, async (req, res) => {
if (sharedError)
return void res.status(500).json({ detail: sharedError.message });

const projects = [...(ownProjects ?? []), ...(sharedProjects ?? [])].sort(
// De-dupe (a shared project may also be in the active org).
const byId = new Map<string, Record<string, unknown>>();
for (const p of [...(orgProjects ?? []), ...(sharedProjects ?? [])]) {
byId.set((p as { id: string }).id, p as Record<string, unknown>);
}
const projects = [...byId.values()].sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
new Date(b.created_at as string).getTime() -
new Date(a.created_at as string).getTime(),
);

const result = await Promise.all(
Expand Down Expand Up @@ -110,13 +124,17 @@ projectsRouter.post("/", requireAuth, async (req, res) => {
}

const db = createServerSupabase();
// Stamp the active organization (X-Org-Id header, else personal org).
const active = await resolveActiveOrg(req, userId, userEmail, db);
const { data, error } = await db
.from("projects")
.insert({
user_id: userId,
name: name.trim(),
cm_number: cm_number ?? null,
shared_with: cleanedSharedWith,
org_id: active?.orgId ?? null,
team_id: active?.teamId ?? null,
})
.select("*")
.single();
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/app/(pages)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import { useAuth } from "@/contexts/AuthContext";
import { ChatHistoryProvider } from "@/app/contexts/ChatHistoryContext";
import { SidebarContext } from "@/app/contexts/SidebarContext";
import { OrgProvider } from "@/app/contexts/OrgContext";
import { AppSidebar } from "@/app/components/shared/AppSidebar";

export default function MikeLayout({
Expand Down Expand Up @@ -35,7 +36,7 @@
if (typeof window !== "undefined" && window.innerWidth >= 768) {
localStorage.setItem("sidebarOpen", isSidebarOpen.toString());
}
}, [isSidebarOpenDesktop]);

Check warning on line 39 in frontend/src/app/(pages)/layout.tsx

View workflow job for this annotation

GitHub Actions / ci-frontend

React Hook useEffect has a missing dependency: 'isSidebarOpen'. Either include it or remove the dependency array

useEffect(() => {
if (typeof window === "undefined") return;
Expand Down Expand Up @@ -75,6 +76,7 @@
if (!isAuthenticated) return null;

return (
<OrgProvider>
<ChatHistoryProvider>
<SidebarContext.Provider
value={{
Expand Down Expand Up @@ -110,5 +112,6 @@
</div>
</SidebarContext.Provider>
</ChatHistoryProvider>
</OrgProvider>
);
}
68 changes: 27 additions & 41 deletions frontend/src/app/(pages)/organization/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ import {
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useAuth } from "@/contexts/AuthContext";
import { useOrg } from "@/app/contexts/OrgContext";
import {
listOrganizations,
createOrganization,
listMembers,
changeMemberRole,
Expand All @@ -37,13 +37,11 @@ const ROLES: OrgRole[] = ["owner", "admin", "member"];

export default function OrganizationPage() {
const { user } = useAuth();
const [orgs, setOrgs] = useState<Organization[]>([]);
const [current, setCurrent] = useState<Organization | null>(null);
const { orgs, activeOrg: current, loading, setActiveOrg, refresh } = useOrg();
const [switcherOpen, setSwitcherOpen] = useState(false);
const [members, setMembers] = useState<OrgMember[]>([]);
const [teams, setTeams] = useState<Team[]>([]);
const [invites, setInvites] = useState<Invitation[]>([]);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);

Expand All @@ -55,12 +53,12 @@ export default function OrganizationPage() {

const canManage = current?.my_role === "owner" || current?.my_role === "admin";

const loadOrgDetail = useCallback(async (org: Organization) => {
const loadDetail = useCallback(async (orgId: string) => {
try {
const [m, t, inv] = await Promise.all([
listMembers(org.id).catch(() => []),
listTeams(org.id).catch(() => []),
listInvitations(org.id).catch(() => []),
listMembers(orgId).catch(() => []),
listTeams(orgId).catch(() => []),
listInvitations(orgId).catch(() => []),
]);
setMembers(m);
setTeams(t);
Expand All @@ -70,35 +68,15 @@ export default function OrganizationPage() {
}
}, []);

const loadOrgs = useCallback(async () => {
setLoading(true);
try {
const list = await listOrganizations();
setOrgs(list);
const cur = current
? list.find((o) => o.id === current.id) ?? list[0]
: list[0];
setCurrent(cur ?? null);
if (cur) await loadOrgDetail(cur);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load organizations");
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loadOrgDetail]);

useEffect(() => {
loadOrgs();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (current?.id) loadDetail(current.id);
}, [current?.id, loadDetail]);

async function switchTo(org: Organization) {
setCurrent(org);
function switchTo(org: Organization) {
setActiveOrg(org.id);
setSwitcherOpen(false);
setLastInviteUrl(null);
setError(null);
await loadOrgDetail(org);
}

async function handleCreateOrg() {
Expand All @@ -107,8 +85,9 @@ export default function OrganizationPage() {
try {
const org = await createOrganization(newOrg.trim());
setNewOrg("");
await loadOrgs();
await switchTo({ ...org, my_role: "owner" });
setSwitcherOpen(false);
await refresh();
setActiveOrg(org.id);
} catch (e) {
setError(e instanceof Error ? e.message : "Create failed");
} finally {
Expand All @@ -124,7 +103,7 @@ export default function OrganizationPage() {
const inv = await createInvitation(current.id, inviteEmail.trim(), inviteRole);
setInviteEmail("");
if (inv.accept_url) setLastInviteUrl(inv.accept_url);
await loadOrgDetail(current);
await loadDetail(current.id);
} catch (e) {
setError(e instanceof Error ? e.message : "Invite failed");
} finally {
Expand All @@ -136,7 +115,7 @@ export default function OrganizationPage() {
if (!current) return;
try {
await changeMemberRole(current.id, m.user_id, role);
await loadOrgDetail(current);
await loadDetail(current.id);
} catch (e) {
setError(e instanceof Error ? e.message : "Role change failed");
}
Expand All @@ -146,7 +125,7 @@ export default function OrganizationPage() {
if (!current) return;
try {
await removeMember(current.id, m.user_id);
await loadOrgDetail(current);
await loadDetail(current.id);
} catch (e) {
setError(e instanceof Error ? e.message : "Remove failed");
}
Expand All @@ -158,7 +137,7 @@ export default function OrganizationPage() {
try {
await createTeam(current.id, newTeam.trim());
setNewTeam("");
await loadOrgDetail(current);
await loadDetail(current.id);
} catch (e) {
setError(e instanceof Error ? e.message : "Create team failed");
} finally {
Expand Down Expand Up @@ -223,7 +202,8 @@ export default function OrganizationPage() {
)}
{current && (
<p className="mt-1 text-xs text-gray-500">
Your role: <span className="font-medium">{current.my_role}</span>
Your role: <span className="font-medium">{current.my_role}</span> · switching here
changes which org&apos;s projects you see across the app.
</p>
)}
</div>
Expand Down Expand Up @@ -344,7 +324,10 @@ export default function OrganizationPage() {
{inv.email} · <span className="capitalize text-gray-500">{inv.role}</span>
</span>
<button
onClick={() => current && revokeInvitation(current.id, inv.id).then(() => loadOrgDetail(current))}
onClick={() =>
current &&
revokeInvitation(current.id, inv.id).then(() => loadDetail(current.id))
}
className="text-gray-400 hover:text-red-600"
title="Revoke"
>
Expand Down Expand Up @@ -375,7 +358,10 @@ export default function OrganizationPage() {
</span>
{canManage && !t.is_default && (
<button
onClick={() => current && deleteTeam(current.id, t.id).then(() => loadOrgDetail(current))}
onClick={() =>
current &&
deleteTeam(current.id, t.id).then(() => loadDetail(current.id))
}
className="text-gray-400 hover:text-red-600"
title="Delete team"
>
Expand Down
Loading
Loading