Unified · Split
| @@ -9,6 +9,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; · 8 unmodified lines | ||
| 9 | 9 | import { Input } from "@/components/ui/input"; |
| 10 | 10 | import { useSession } from "@/lib/session-context"; |
| 11 | 11 | import { convexApi } from "@/lib/convex-api"; |
| 12 | +import type { Id } from "../../../../convex/_generated/dataModel"; | |
| 12 | 13 | |
| 13 | 14 | type Role = "owner" | "admin" | "member" | "billing_admin"; |
| 14 | 15 | |
| @@ -52,14 +53,14 @@ export function MembersView() { · 37 unmodified lines | ||
| 52 | 53 | const [inviteRole, setInviteRole] = useState<Role>("member"); |
| 53 | 54 | const [inviteState, setInviteState] = useState<"idle" | "sending" | "sent" | "failed">("idle"); |
| 54 | 55 | const [inviteMessage, setInviteMessage] = useState<string | null>(null); |
| 55 | − const [busyMemberAccountId, setBusyMemberAccountId] = useState<string | null>(null); | |
| 56 | − const [busyInviteId, setBusyInviteId] = useState<string | null>(null); | |
| 56 | + const [busyMemberAccountId, setBusyMemberAccountId] = useState<Id<"accounts"> | null>(null); | |
| 57 | + const [busyInviteId, setBusyInviteId] = useState<Id<"invites"> | null>(null); | |
| 57 | 58 | |
| 58 | 59 | const memberItems = members?.items ?? []; |
| 59 | 60 | const inviteItems = listInvites?.items ?? []; |
| 60 | 61 | const pendingInviteItems = inviteItems.filter((invite) => invite.status === "pending" || invite.status === "failed"); |
| 61 | 62 | const actorMembership = memberItems.find((member) => |
| 62 | − context?.accountId ? String(member.accountId) === context.accountId : false, | |
| 63 | + context?.accountId ? member.accountId === context.accountId : false, | |
| 63 | 64 | ); |
| 64 | 65 | const actorRole = actorMembership?.role ?? null; |
| 65 | 66 | const canManageMembers = actorRole === "owner" || actorRole === "admin"; |
| @@ -276,20 +277,20 @@ export function MembersView() { · 210 unmodified lines | ||
| 276 | 277 | <p className="text-xs text-muted-foreground">Role: {invite.role}</p> |
| 277 | 278 | <p className="text-xs text-muted-foreground">Status: {invite.status}</p> |
| 278 | 279 | </div> |
| 279 | − <Button | |
| 280 | − variant="outline" | |
| 281 | − size="sm" | |
| 282 | − className="h-8 text-xs" | |
| 283 | − disabled={!canManageMembers || busyInviteId === String(invite.id)} | |
| 284 | − onClick={async () => { | |
| 285 | − if (!typedOrganizationId) { | |
| 286 | − return; | |
| 287 | − } | |
| 280 | + <Button | |
| 281 | + variant="outline" | |
| 282 | + size="sm" | |
| 283 | + className="h-8 text-xs" | |
| 284 | + disabled={!canManageMembers || busyInviteId === invite.id} | |
| 285 | + onClick={async () => { | |
| 286 | + if (!typedOrganizationId) { | |
| 287 | + return; | |
| 288 | + } | |
| 288 | 289 | |
| 289 | − setBusyInviteId(String(invite.id)); | |
| 290 | − try { | |
| 291 | − await revokeInvite({ | |
| 292 | − organizationId: typedOrganizationId, | |
| 290 | + setBusyInviteId(invite.id); | |
| 291 | + try { | |
| 292 | + await revokeInvite({ | |
| 293 | + organizationId: typedOrganizationId, | |
| 293 | 294 | inviteId: invite.id, |
| 294 | 295 | sessionId: context?.sessionId ?? undefined, |
| 295 | 296 | }); |
| @@ -30,20 +30,20 @@ interface SessionState { · 29 unmodified lines | ||
| 30 | 30 | } | null; |
| 31 | 31 | mode: "guest" | "workos"; |
| 32 | 32 | organizations: Array<{ |
| 33 | − id: string; | |
| 33 | + id: Id<"organizations">; | |
| 34 | 34 | name: string; |
| 35 | 35 | slug: string; |
| 36 | 36 | status: string; |
| 37 | 37 | role: string; |
| 38 | 38 | }>; |
| 39 | 39 | workspaces: Array<{ |
| 40 | − id: string; | |
| 40 | + id: Id<"workspaces">; | |
| 41 | 41 | docId: Id<"workspaces"> | null; |
| 42 | 42 | name: string; |
| 43 | 43 | organizationId: Id<"organizations"> | null; |
| 44 | 44 | iconUrl?: string | null; |
| 45 | 45 | }>; |
| 46 | − switchWorkspace: (workspaceId: string) => void; | |
| 46 | + switchWorkspace: (workspaceId: Id<"workspaces">) => void; | |
| 47 | 47 | creatingWorkspace: boolean; |
| 48 | 48 | createWorkspace: (name: string, iconFile?: File | null) => Promise<void>; |
| 49 | 49 | isSignedInToWorkos: boolean; |
| @@ -77,16 +77,16 @@ const ACTIVE_WORKSPACE_BY_ACCOUNT_KEY = "executor_active_workspace_by_account"; · 27 unmodified lines | ||
| 77 | 77 | |
| 78 | 78 | function readWorkspaceByAccount() { |
| 79 | 79 | const raw = localStorage.getItem(ACTIVE_WORKSPACE_BY_ACCOUNT_KEY); |
| 80 | − if (!raw) return {} as Record<string, string>; | |
| 80 | + if (!raw) return {} as Record<string, Id<"workspaces">>; | |
| 81 | 81 | try { |
| 82 | − const parsed = JSON.parse(raw) as Record<string, string>; | |
| 82 | + const parsed = JSON.parse(raw) as Record<string, Id<"workspaces">>; | |
| 83 | 83 | return parsed ?? {}; |
| 84 | 84 | } catch { |
| 85 | 85 | return {}; |
| 86 | 86 | } |
| 87 | 87 | } |
| 88 | 88 | |
| 89 | −function writeWorkspaceByAccount(value: Record<string, string>) { | |
| 89 | +function writeWorkspaceByAccount(value: Record<string, Id<"workspaces">>) { | |
| 90 | 90 | localStorage.setItem(ACTIVE_WORKSPACE_BY_ACCOUNT_KEY, JSON.stringify(value)); |
| 91 | 91 | } |
| 92 | 92 | |
| @@ -98,11 +98,12 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 5 unmodified lines | ||
| 98 | 98 | } |
| 99 | 99 | return localStorage.getItem(SESSION_KEY); |
| 100 | 100 | }); |
| 101 | − const [activeWorkspaceId, setActiveWorkspaceId] = useState<string | null>(() => { | |
| 101 | + const [activeWorkspaceId, setActiveWorkspaceId] = useState<Id<"workspaces"> | null>(() => { | |
| 102 | 102 | if (typeof window === "undefined") { |
| 103 | 103 | return null; |
| 104 | 104 | } |
| 105 | − return localStorage.getItem(ACTIVE_WORKSPACE_KEY); | |
| 105 | + const stored = localStorage.getItem(ACTIVE_WORKSPACE_KEY); | |
| 106 | + return stored as Id<"workspaces"> | null; | |
| 106 | 107 | }); |
| 107 | 108 | const [runtimeError, setRuntimeError] = useState<string | null>(null); |
| 108 | 109 | |
| @@ -148,17 +149,17 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 39 unmodified lines | ||
| 148 | 149 | return activeWorkspaceId; |
| 149 | 150 | } |
| 150 | 151 | |
| 151 | − if (activeWorkspaceId && workspaces.some((workspace) => workspace.runtimeWorkspaceId === activeWorkspaceId)) { | |
| 152 | + if (activeWorkspaceId && workspaces.some((workspace) => workspace._id === activeWorkspaceId)) { | |
| 152 | 153 | return activeWorkspaceId; |
| 153 | 154 | } |
| 154 | 155 | |
| 155 | − const accountId = account?.provider === "workos" ? String(account._id) : null; | |
| 156 | + const accountId = account?.provider === "workos" ? account._id : null; | |
| 156 | 157 | const accountStoredWorkspace = accountId ? readWorkspaceByAccount()[accountId] : null; |
| 157 | − if (accountStoredWorkspace && workspaces.some((workspace) => workspace.runtimeWorkspaceId === accountStoredWorkspace)) { | |
| 158 | + if (accountStoredWorkspace && workspaces.some((workspace) => workspace._id === accountStoredWorkspace)) { | |
| 158 | 159 | return accountStoredWorkspace; |
| 159 | 160 | } |
| 160 | 161 | |
| 161 | − return workspaces[0]?.runtimeWorkspaceId ?? null; | |
| 162 | + return workspaces[0]?._id ?? null; | |
| 162 | 163 | }, [workspaces, activeWorkspaceId, account]); |
| 163 | 164 | |
| 164 | 165 | const bootstrapWorkosAccountQuery = useTanstackQuery({ |
| @@ -177,12 +178,12 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 12 unmodified lines | ||
| 177 | 178 | setRuntimeError(null); |
| 178 | 179 | }, []); |
| 179 | 180 | |
| 180 | − const switchWorkspace = useCallback((workspaceId: string) => { | |
| 181 | + const switchWorkspace = useCallback((workspaceId: Id<"workspaces">) => { | |
| 181 | 182 | setActiveWorkspaceId(workspaceId); |
| 182 | 183 | localStorage.setItem(ACTIVE_WORKSPACE_KEY, workspaceId); |
| 183 | 184 | |
| 184 | 185 | if (account?.provider === "workos") { |
| 185 | − const accountId = String(account._id); | |
| 186 | + const accountId = account._id; | |
| 186 | 187 | const byAccount = readWorkspaceByAccount(); |
| 187 | 188 | writeWorkspaceByAccount({ |
| 188 | 189 | ...byAccount, |
| @@ -227,8 +228,8 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 38 unmodified lines | ||
| 227 | 228 | sessionId: storedSessionId ?? undefined, |
| 228 | 229 | }); |
| 229 | 230 | |
| 230 | − if (created?.runtimeWorkspaceId) { | |
| 231 | − switchWorkspace(created.runtimeWorkspaceId); | |
| 231 | + if (created?._id) { | |
| 232 | + switchWorkspace(created._id); | |
| 232 | 233 | } |
| 233 | 234 | } catch (cause) { |
| 234 | 235 | const message = cause instanceof Error ? cause.message : "Failed to create workspace"; |
| @@ -250,7 +251,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 15 unmodified lines | ||
| 250 | 251 | } |
| 251 | 252 | |
| 252 | 253 | const activeWorkspace = |
| 253 | − workspaces.find((workspace) => workspace.runtimeWorkspaceId === resolvedActiveWorkspaceId) | |
| 254 | + workspaces.find((workspace) => workspace._id === resolvedActiveWorkspaceId) | |
| 254 | 255 | ?? workspaces[0] |
| 255 | 256 | ?? null; |
| 256 | 257 | if (!activeWorkspace) { |
| @@ -258,13 +259,12 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 1 unmodified line | ||
| 258 | 259 | } |
| 259 | 260 | |
| 260 | 261 | return { |
| 261 | − sessionId: `workos_${String(account._id)}`, | |
| 262 | − workspaceId: activeWorkspace.runtimeWorkspaceId, | |
| 263 | − actorId: String(activeWorkspace.userId), | |
| 262 | + sessionId: `workos_${account._id}`, | |
| 263 | + workspaceId: activeWorkspace._id, | |
| 264 | + actorId: activeWorkspace.userId, | |
| 264 | 265 | clientId: "web", |
| 265 | − accountId: String(account._id), | |
| 266 | − workspaceDocId: String(activeWorkspace._id), | |
| 267 | − userId: String(activeWorkspace.userId), | |
| 266 | + accountId: account._id, | |
| 267 | + userId: activeWorkspace.userId, | |
| 268 | 268 | createdAt: Date.now(), |
| 269 | 269 | lastSeenAt: Date.now(), |
| 270 | 270 | }; |
| @@ -301,7 +301,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 30 unmodified lines | ||
| 301 | 301 | const workspaceOptions = useMemo(() => { |
| 302 | 302 | if (mode === "workos" && workspaces) { |
| 303 | 303 | return workspaces.map((workspace): SessionState["workspaces"][number] => ({ |
| 304 | − id: workspace.runtimeWorkspaceId, | |
| 304 | + id: workspace._id, | |
| 305 | 305 | docId: workspace._id, |
| 306 | 306 | name: workspace.name, |
| 307 | 307 | organizationId: workspace.organizationId ?? null, |
| @@ -312,7 +312,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 4 unmodified lines | ||
| 312 | 312 | if (guestContext) { |
| 313 | 313 | return [ |
| 314 | 314 | { |
| 315 | − id: guestContext.workspaceId, | |
| 315 | + id: guestContext.workspaceId as Id<"workspaces">, | |
| 316 | 316 | docId: null, |
| 317 | 317 | name: "Guest Workspace", |
| 318 | 318 | organizationId: null, |
| @@ -9,6 +9,8 @@ import { ensureUniqueSlug } from "./lib/slug"; · 8 unmodified lines | ||
| 9 | 9 | type DbCtx = Pick<MutationCtx, "db">; |
| 10 | 10 | type RunQueryCtx = Pick<MutationCtx, "runQuery">; |
| 11 | 11 | type WorkosEventCtx = Pick<MutationCtx, "db" | "runQuery">; |
| 12 | +type OrganizationRole = "owner" | "admin" | "member" | "billing_admin"; | |
| 13 | +type OrganizationMemberStatus = "active" | "pending" | "removed"; | |
| 12 | 14 | |
| 13 | 15 | const workosEnabled = Boolean( |
| 14 | 16 | process.env.WORKOS_CLIENT_ID && process.env.WORKOS_API_KEY && process.env.WORKOS_WEBHOOK_SECRET, |
| @@ -81,8 +83,8 @@ async function upsertOrganizationMembership( · 66 unmodified lines | ||
| 81 | 83 | args: { |
| 82 | 84 | organizationId: Id<"organizations">; |
| 83 | 85 | accountId: Id<"accounts">; |
| 84 | − role: string; | |
| 85 | − status: string; | |
| 86 | + role: OrganizationRole; | |
| 87 | + status: OrganizationMemberStatus; | |
| 86 | 88 | billable: boolean; |
| 87 | 89 | invitedByAccountId?: Id<"accounts">; |
| 88 | 90 | now: number; |
| @@ -153,7 +155,7 @@ async function ensurePersonalWorkspace( · 64 unmodified lines | ||
| 153 | 155 | opts: { email: string; firstName?: string; workosUserId: string; now: number; workspaceName?: string }, |
| 154 | 156 | ) { |
| 155 | 157 | const memberships = await ctx.db |
| 156 | − .query("users") | |
| 158 | + .query("workspaceMembers") | |
| 157 | 159 | .withIndex("by_account", (q) => q.eq("accountId", accountId)) |
| 158 | 160 | .collect(); |
| 159 | 161 | |
| @@ -204,7 +206,7 @@ async function ensurePersonalWorkspace( · 44 unmodified lines | ||
| 204 | 206 | now: opts.now, |
| 205 | 207 | }); |
| 206 | 208 | |
| 207 | − const userId = await ctx.db.insert("users", { | |
| 209 | + const userId = await ctx.db.insert("workspaceMembers", { | |
| 208 | 210 | workspaceId, |
| 209 | 211 | accountId, |
| 210 | 212 | role: "owner", |
| @@ -305,7 +307,7 @@ const workosEventHandlers: Record<string, (ctx: WorkosEventCtx, event: any) => P · 94 unmodified lines | ||
| 305 | 307 | if (!account) return; |
| 306 | 308 | |
| 307 | 309 | const memberships = await ctx.db |
| 308 | − .query("users") | |
| 310 | + .query("workspaceMembers") | |
| 309 | 311 | .withIndex("by_account", (q) => q.eq("accountId", account._id)) |
| 310 | 312 | .collect(); |
| 311 | 313 | for (const membership of memberships) { |
| @@ -394,7 +396,7 @@ const workosEventHandlers: Record<string, (ctx: WorkosEventCtx, event: any) => P · 82 unmodified lines | ||
| 394 | 396 | if (!workspace) return; |
| 395 | 397 | |
| 396 | 398 | const members = await ctx.db |
| 397 | − .query("users") | |
| 399 | + .query("workspaceMembers") | |
| 398 | 400 | .withIndex("by_workspace", (q) => q.eq("workspaceId", workspace._id)) |
| 399 | 401 | .collect(); |
| 400 | 402 | for (const member of members) { |
| @@ -428,7 +430,7 @@ const workosEventHandlers: Record<string, (ctx: WorkosEventCtx, event: any) => P · 27 unmodified lines | ||
| 428 | 430 | const organizationId = workspace.organizationId; |
| 429 | 431 | |
| 430 | 432 | const existing = await ctx.db |
| 431 | − .query("users") | |
| 433 | + .query("workspaceMembers") | |
| 432 | 434 | .withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspace._id).eq("accountId", account._id)) |
| 433 | 435 | .unique(); |
| 434 | 436 | |
| @@ -463,7 +465,7 @@ const workosEventHandlers: Record<string, (ctx: WorkosEventCtx, event: any) => P · 28 unmodified lines | ||
| 463 | 465 | return; |
| 464 | 466 | } |
| 465 | 467 | |
| 466 | − await ctx.db.insert("users", { | |
| 468 | + await ctx.db.insert("workspaceMembers", { | |
| 467 | 469 | workspaceId: workspace._id, |
| 468 | 470 | accountId: account._id, |
| 469 | 471 | workosOrgMembershipId: event.data.id, |
| @@ -487,7 +489,7 @@ const workosEventHandlers: Record<string, (ctx: WorkosEventCtx, event: any) => P · 17 unmodified lines | ||
| 487 | 489 | }; |
| 488 | 490 | |
| 489 | 491 | let membership = await ctx.db |
| 490 | − .query("users") | |
| 492 | + .query("workspaceMembers") | |
| 491 | 493 | .withIndex("by_workos_membership_id", (q) => q.eq("workosOrgMembershipId", data.id)) |
| 492 | 494 | .unique(); |
| 493 | 495 | |
| @@ -506,7 +508,7 @@ const workosEventHandlers: Record<string, (ctx: WorkosEventCtx, event: any) => P · 12 unmodified lines | ||
| 506 | 508 | const workspaceId = workspace._id; |
| 507 | 509 | const accountId = account._id; |
| 508 | 510 | membership = await ctx.db |
| 509 | − .query("users") | |
| 511 | + .query("workspaceMembers") | |
| 510 | 512 | .withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspaceId).eq("accountId", accountId)) |
| 511 | 513 | .unique(); |
| 512 | 514 | if (!membership) return; |
| @@ -551,7 +553,7 @@ const workosEventHandlers: Record<string, (ctx: WorkosEventCtx, event: any) => P · 38 unmodified lines | ||
| 551 | 553 | "organization_membership.deleted": async (ctx, event) => { |
| 552 | 554 | const now = Date.now(); |
| 553 | 555 | const membership = await ctx.db |
| 554 | − .query("users") | |
| 556 | + .query("workspaceMembers") | |
| 555 | 557 | .withIndex("by_workos_membership_id", (q) => q.eq("workosOrgMembershipId", event.data.id)) |
| 556 | 558 | .unique(); |
| 557 | 559 | if (!membership) return; |
| @@ -723,7 +725,7 @@ export const createWorkspace = mutation({ · 165 unmodified lines | ||
| 723 | 725 | now, |
| 724 | 726 | }); |
| 725 | 727 | |
| 726 | − const userId = await ctx.db.insert("users", { | |
| 728 | + const userId = await ctx.db.insert("workspaceMembers", { | |
| 727 | 729 | workspaceId, |
| 728 | 730 | accountId: account._id, |
| 729 | 731 | role: "owner", |
| @@ -750,7 +752,6 @@ export const createWorkspace = mutation({ · 20 unmodified lines | ||
| 750 | 752 | userId: user._id, |
| 751 | 753 | role: user.role, |
| 752 | 754 | status: user.status, |
| 753 | − runtimeWorkspaceId: workspace._id, | |
| 754 | 755 | }; |
| 755 | 756 | }, |
| 756 | 757 | }); |
| @@ -804,7 +805,7 @@ export const getMyWorkspaces = query({ · 47 unmodified lines | ||
| 804 | 805 | if (!account) return []; |
| 805 | 806 | |
| 806 | 807 | const memberships = await ctx.db |
| 807 | − .query("users") | |
| 808 | + .query("workspaceMembers") | |
| 808 | 809 | .withIndex("by_account", (q) => q.eq("accountId", account._id)) |
| 809 | 810 | .collect(); |
| 810 | 811 | |
| @@ -823,7 +824,6 @@ export const getMyWorkspaces = query({ · 12 unmodified lines | ||
| 823 | 824 | userId: membership._id, |
| 824 | 825 | role: membership.role, |
| 825 | 826 | status: membership.status, |
| 826 | − runtimeWorkspaceId: workspace._id, | |
| 827 | 827 | }; |
| 828 | 828 | }), |
| 829 | 829 | ); |
| @@ -839,7 +839,7 @@ export const getMyAccountsWithWorkspaces = query({ · 9 unmodified lines | ||
| 839 | 839 | if (!account) return []; |
| 840 | 840 | |
| 841 | 841 | const memberships = await ctx.db |
| 842 | − .query("users") | |
| 842 | + .query("workspaceMembers") | |
| 843 | 843 | .withIndex("by_account", (q) => q.eq("accountId", account._id)) |
| 844 | 844 | .collect(); |
| 845 | 845 | |
| @@ -858,7 +858,6 @@ export const getMyAccountsWithWorkspaces = query({ · 12 unmodified lines | ||
| 858 | 858 | userId: membership._id, |
| 859 | 859 | role: membership.role, |
| 860 | 860 | status: membership.status, |
| 861 | − runtimeWorkspaceId: workspace._id, | |
| 862 | 861 | }; |
| 863 | 862 | }), |
| 864 | 863 | ); |
| @@ -5,6 +5,8 @@ import { mutation, query } from "./_generated/server"; · 4 unmodified lines | ||
| 5 | 5 | import { ensureUniqueSlug } from "./lib/slug"; |
| 6 | 6 | |
| 7 | 7 | const DEFAULT_TIMEOUT_MS = 15_000; |
| 8 | +type OrganizationRole = "owner" | "admin" | "member" | "billing_admin"; | |
| 9 | +type OrganizationMemberStatus = "active" | "pending" | "removed"; | |
| 8 | 10 | |
| 9 | 11 | function normalizeOptional(value?: string): string { |
| 10 | 12 | if (typeof value !== "string") { |
| @@ -45,8 +47,8 @@ async function upsertOrganizationMembership( · 34 unmodified lines | ||
| 45 | 47 | args: { |
| 46 | 48 | organizationId: Doc<"organizations">["_id"]; |
| 47 | 49 | accountId: Doc<"accounts">["_id"]; |
| 48 | − role: string; | |
| 49 | − status: string; | |
| 50 | + role: OrganizationRole; | |
| 51 | + status: OrganizationMemberStatus; | |
| 50 | 52 | billable: boolean; |
| 51 | 53 | now: number; |
| 52 | 54 | }, |
| @@ -168,7 +170,7 @@ async function ensureAnonymousIdentity( · 115 unmodified lines | ||
| 168 | 170 | ctx: MutationCtx, |
| 169 | 171 | params: { |
| 170 | 172 | sessionId: string; |
| 171 | − workspaceDocId?: Doc<"workspaces">["_id"]; | |
| 173 | + workspaceId?: Doc<"workspaces">["_id"]; | |
| 172 | 174 | actorId: string; |
| 173 | 175 | timestamp: number; |
| 174 | 176 | }, |
| @@ -199,7 +201,7 @@ async function ensureAnonymousIdentity( · 24 unmodified lines | ||
| 199 | 201 | await ctx.db.patch(account._id, { updatedAt: now, lastLoginAt: now }); |
| 200 | 202 | } |
| 201 | 203 | |
| 202 | − let workspace = params.workspaceDocId ? await ctx.db.get(params.workspaceDocId) : null; | |
| 204 | + let workspace = params.workspaceId ? await ctx.db.get(params.workspaceId) : null; | |
| 203 | 205 | |
| 204 | 206 | let organizationId: Doc<"organizations">["_id"]; |
| 205 | 207 | |
| @@ -241,12 +243,12 @@ async function ensureAnonymousIdentity( · 35 unmodified lines | ||
| 241 | 243 | }); |
| 242 | 244 | |
| 243 | 245 | let user = await ctx.db |
| 244 | − .query("users") | |
| 246 | + .query("workspaceMembers") | |
| 245 | 247 | .withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspace._id).eq("accountId", account._id)) |
| 246 | 248 | .unique(); |
| 247 | 249 | |
| 248 | 250 | if (!user) { |
| 249 | − const userId = await ctx.db.insert("users", { | |
| 251 | + const userId = await ctx.db.insert("workspaceMembers", { | |
| 250 | 252 | workspaceId: workspace._id, |
| 251 | 253 | accountId: account._id, |
| 252 | 254 | role: "owner", |
| @@ -264,7 +266,7 @@ async function ensureAnonymousIdentity( · 11 unmodified lines | ||
| 264 | 266 | |
| 265 | 267 | return { |
| 266 | 268 | accountId: account._id, |
| 267 | − workspaceDocId: workspace._id, | |
| 269 | + workspaceId: workspace._id, | |
| 268 | 270 | userId: user._id, |
| 269 | 271 | }; |
| 270 | 272 | } |
| @@ -276,7 +278,6 @@ function mapAnonymousContext(doc: Doc<"anonymousSessions">) { · 5 unmodified lines | ||
| 276 | 278 | actorId: doc.actorId, |
| 277 | 279 | clientId: doc.clientId, |
| 278 | 280 | accountId: doc.accountId, |
| 279 | − workspaceDocId: doc.workspaceDocId, | |
| 280 | 281 | userId: doc.userId, |
| 281 | 282 | createdAt: doc.createdAt, |
| 282 | 283 | lastSeenAt: doc.lastSeenAt, |
| @@ -708,15 +709,14 @@ export const bootstrapAnonymousSession = mutation({ · 425 unmodified lines | ||
| 708 | 709 | if (existing) { |
| 709 | 710 | const identity = await ensureAnonymousIdentity(ctx, { |
| 710 | 711 | sessionId, |
| 711 | − workspaceDocId: existing.workspaceDocId, | |
| 712 | + workspaceId: existing.workspaceId, | |
| 712 | 713 | actorId: existing.actorId, |
| 713 | 714 | timestamp: now, |
| 714 | 715 | }); |
| 715 | 716 | |
| 716 | 717 | await ctx.db.patch(existing._id, { |
| 717 | − workspaceId: identity.workspaceDocId, | |
| 718 | + workspaceId: identity.workspaceId, | |
| 718 | 719 | accountId: identity.accountId, |
| 719 | − workspaceDocId: identity.workspaceDocId, | |
| 720 | 720 | userId: identity.userId, |
| 721 | 721 | lastSeenAt: now, |
| 722 | 722 | }); |
| @@ -744,11 +744,10 @@ export const bootstrapAnonymousSession = mutation({ · 21 unmodified lines | ||
| 744 | 744 | |
| 745 | 745 | await ctx.db.insert("anonymousSessions", { |
| 746 | 746 | sessionId, |
| 747 | − workspaceId: identity.workspaceDocId, | |
| 747 | + workspaceId: identity.workspaceId, | |
| 748 | 748 | actorId, |
| 749 | 749 | clientId, |
| 750 | 750 | accountId: identity.accountId, |
| 751 | − workspaceDocId: identity.workspaceDocId, | |
| 752 | 751 | userId: identity.userId, |
| 753 | 752 | createdAt: now, |
| 754 | 753 | lastSeenAt: now, |
| @@ -7,6 +7,14 @@ import { authedMutation, organizationMutation, organizationQuery } from "./lib/f · 6 unmodified lines | ||
| 7 | 7 | |
| 8 | 8 | const workosEnabled = Boolean(process.env.WORKOS_CLIENT_ID && process.env.WORKOS_API_KEY); |
| 9 | 9 | const workosClient = process.env.WORKOS_API_KEY ? new WorkOS(process.env.WORKOS_API_KEY) : null; |
| 10 | +type OrganizationRole = "owner" | "admin" | "member" | "billing_admin"; | |
| 11 | + | |
| 12 | +const organizationRoleValidator = v.union( | |
| 13 | + v.literal("owner"), | |
| 14 | + v.literal("admin"), | |
| 15 | + v.literal("member"), | |
| 16 | + v.literal("billing_admin"), | |
| 17 | +); | |
| 10 | 18 | |
| 11 | 19 | async function sha256Hex(value: string): Promise<string> { |
| 12 | 20 | const bytes = new TextEncoder().encode(value); |
| @@ -65,7 +73,7 @@ async function revokeWorkosInvitation(invitationId: string): Promise<void> { · 52 unmodified lines | ||
| 65 | 73 | await workos.userManagement.revokeInvitation(invitationId); |
| 66 | 74 | } |
| 67 | 75 | |
| 68 | −function mapRoleToWorkosRoleSlug(role: string): string | undefined { | |
| 76 | +function mapRoleToWorkosRoleSlug(role: OrganizationRole): string | undefined { | |
| 69 | 77 | if (role === "admin" || role === "owner") { |
| 70 | 78 | return "admin"; |
| 71 | 79 | } |
| @@ -103,7 +111,7 @@ export const create = organizationMutation({ · 31 unmodified lines | ||
| 103 | 111 | requireAdmin: true, |
| 104 | 112 | args: { |
| 105 | 113 | email: v.string(), |
| 106 | − role: v.string(), | |
| 114 | + role: organizationRoleValidator, | |
| 107 | 115 | workspaceId: v.optional(v.id("workspaces")), |
| 108 | 116 | expiresInDays: v.optional(v.number()), |
| 109 | 117 | }, |
| @@ -123,17 +131,10 @@ export const create = organizationMutation({ · 13 unmodified lines | ||
| 123 | 131 | } |
| 124 | 132 | } |
| 125 | 133 | |
| 126 | − const inviterWorkosUserId = ctx.account.provider === "workos" | |
| 127 | − ? ctx.account.providerAccountId | |
| 128 | − : (await ctx.db | |
| 129 | − .query("accountIdentities") | |
| 130 | − .withIndex("by_account", (q) => q.eq("accountId", ctx.account._id)) | |
| 131 | − .filter((q) => q.eq(q.field("provider"), "workos")) | |
| 132 | − .first())?.providerUserId; | |
| 133 | − | |
| 134 | − if (!inviterWorkosUserId) { | |
| 134 | + if (ctx.account.provider !== "workos") { | |
| 135 | 135 | throw new Error("Inviter is not linked to WorkOS"); |
| 136 | 136 | } |
| 137 | + const inviterWorkosUserId = ctx.account.providerAccountId; | |
| 137 | 138 | |
| 138 | 139 | const token = `invite_${crypto.randomUUID()}`; |
| 139 | 140 | const tokenHash = await sha256Hex(token); |
| @@ -25,14 +25,6 @@ export async function resolveAccountForRequest( · 24 unmodified lines | ||
| 25 | 25 | if (fromAccounts) { |
| 26 | 26 | return fromAccounts; |
| 27 | 27 | } |
| 28 | − | |
| 29 | − const fromIdentity = await ctx.db | |
| 30 | − .query("accountIdentities") | |
| 31 | − .withIndex("by_provider_user", (q) => q.eq("provider", "workos").eq("providerUserId", identity.subject)) | |
| 32 | − .unique(); | |
| 33 | − if (fromIdentity?.accountId) { | |
| 34 | − return await ctx.db.get(fromIdentity.accountId); | |
| 35 | − } | |
| 36 | 28 | } |
| 37 | 29 | |
| 38 | 30 | if (!sessionId) { |
| @@ -3,6 +3,13 @@ import { internal } from "./_generated/api"; · 2 unmodified lines | ||
| 3 | 3 | import { getOrganizationMembership } from "./lib/identity"; |
| 4 | 4 | import { organizationMutation, organizationQuery } from "./lib/functionBuilders"; |
| 5 | 5 | |
| 6 | +const organizationRoleValidator = v.union( | |
| 7 | + v.literal("owner"), | |
| 8 | + v.literal("admin"), | |
| 9 | + v.literal("member"), | |
| 10 | + v.literal("billing_admin"), | |
| 11 | +); | |
| 12 | + | |
| 6 | 13 | export const list = organizationQuery({ |
| 7 | 14 | args: {}, |
| 8 | 15 | handler: async (ctx) => { |
| @@ -36,7 +43,7 @@ export const list = organizationQuery({ · 27 unmodified lines | ||
| 36 | 43 | export const updateRole = organizationMutation({ |
| 37 | 44 | args: { |
| 38 | 45 | accountId: v.id("accounts"), |
| 39 | − role: v.string(), | |
| 46 | + role: organizationRoleValidator, | |
| 40 | 47 | }, |
| 41 | 48 | requireAdmin: true, |
| 42 | 49 | handler: async (ctx, args) => { |
| @@ -11,7 +11,6 @@ type WorkspaceSummary = { · 10 unmodified lines | ||
| 11 | 11 | name: string; |
| 12 | 12 | slug: string; |
| 13 | 13 | iconUrl: string | null; |
| 14 | − runtimeWorkspaceId: string; | |
| 15 | 14 | }; |
| 16 | 15 | |
| 17 | 16 | async function ensureUniqueOrganizationSlug(ctx: Pick<MutationCtx, "db">, baseName: string): Promise<string> { |
| @@ -36,7 +35,6 @@ async function mapWorkspaceWithIcon( · 18 unmodified lines | ||
| 36 | 35 | name: workspace.name, |
| 37 | 36 | slug: workspace.slug, |
| 38 | 37 | iconUrl, |
| 39 | − runtimeWorkspaceId: workspace._id, | |
| 40 | 38 | }; |
| 41 | 39 | } |
| 42 | 40 | |
| @@ -152,8 +150,8 @@ export const getNavigationState = optionalAccountQuery({ · 109 unmodified lines | ||
| 152 | 150 | .query("anonymousSessions") |
| 153 | 151 | .withIndex("by_session_id", (q) => q.eq("sessionId", sessionId)) |
| 154 | 152 | .unique(); |
| 155 | − if (anonymousSession?.workspaceDocId) { | |
| 156 | − const workspace = await ctx.db.get(anonymousSession.workspaceDocId); | |
| 153 | + if (anonymousSession?.workspaceId) { | |
| 154 | + const workspace = await ctx.db.get(anonymousSession.workspaceId); | |
| 157 | 155 | if (workspace) { |
| 158 | 156 | workspaces.push(await mapWorkspaceWithIcon(ctx, workspace)); |
| 159 | 157 | } |
| @@ -162,7 +160,7 @@ export const getNavigationState = optionalAccountQuery({ · 2 unmodified lines | ||
| 162 | 160 | |
| 163 | 161 | return { |
| 164 | 162 | currentOrganizationId: workspaces[0]?.organizationId ?? null, |
| 165 | − currentWorkspaceId: workspaces[0]?.runtimeWorkspaceId ?? null, | |
| 163 | + currentWorkspaceId: workspaces[0]?.id ?? null, | |
| 166 | 164 | organizations, |
| 167 | 165 | workspaces, |
| 168 | 166 | }; |
| @@ -199,12 +197,12 @@ export const getNavigationState = optionalAccountQuery({ · 30 unmodified lines | ||
| 199 | 197 | } |
| 200 | 198 | |
| 201 | 199 | const uniqueWorkspaces = Array.from( |
| 202 | − new Map(workspaces.map((workspace) => [workspace.runtimeWorkspaceId, workspace])).values(), | |
| 200 | + new Map(workspaces.map((workspace) => [workspace.id, workspace])).values(), | |
| 203 | 201 | ); |
| 204 | 202 | |
| 205 | 203 | return { |
| 206 | 204 | currentOrganizationId: organizations[0]?.id ?? null, |
| 207 | − currentWorkspaceId: uniqueWorkspaces[0]?.runtimeWorkspaceId ?? null, | |
| 205 | + currentWorkspaceId: uniqueWorkspaces[0]?.id ?? null, | |
| 208 | 206 | organizations, |
| 209 | 207 | workspaces: uniqueWorkspaces, |
| 210 | 208 | }; |
| @@ -1,16 +1,32 @@ | ||
| 1 | 1 | import { defineSchema, defineTable } from "convex/server"; |
| 2 | 2 | import { v } from "convex/values"; |
| 3 | 3 | |
| 4 | +const accountProvider = v.union(v.literal("workos"), v.literal("anonymous")); | |
| 5 | +const accountStatus = v.union(v.literal("active"), v.literal("deleted")); | |
| 6 | +const organizationStatus = v.union(v.literal("active"), v.literal("deleted")); | |
| 7 | +const orgRole = v.union(v.literal("owner"), v.literal("admin"), v.literal("member"), v.literal("billing_admin")); | |
| 8 | +const orgMemberStatus = v.union(v.literal("active"), v.literal("pending"), v.literal("removed")); | |
| 9 | +const workspaceMemberRole = v.union(v.literal("owner"), v.literal("admin"), v.literal("member")); | |
| 10 | +const workspaceMemberStatus = v.union(v.literal("active"), v.literal("pending"), v.literal("removed")); | |
| 11 | +const inviteStatus = v.union( | |
| 12 | + v.literal("pending"), | |
| 13 | + v.literal("accepted"), | |
| 14 | + v.literal("expired"), | |
| 15 | + v.literal("revoked"), | |
| 16 | + v.literal("failed"), | |
| 17 | +); | |
| 18 | +const inviteProvider = v.literal("workos"); | |
| 19 | + | |
| 4 | 20 | export default defineSchema({ |
| 5 | 21 | accounts: defineTable({ |
| 6 | − provider: v.string(), | |
| 22 | + provider: accountProvider, | |
| 7 | 23 | providerAccountId: v.string(), |
| 8 | 24 | email: v.string(), |
| 9 | 25 | name: v.string(), |
| 10 | 26 | firstName: v.optional(v.string()), |
| 11 | 27 | lastName: v.optional(v.string()), |
| 12 | 28 | avatarUrl: v.optional(v.string()), |
| 13 | − status: v.string(), | |
| 29 | + status: accountStatus, | |
| 14 | 30 | createdAt: v.number(), |
| 15 | 31 | updatedAt: v.number(), |
| 16 | 32 | lastLoginAt: v.optional(v.number()), |
| @@ -39,7 +55,7 @@ export default defineSchema({ · 22 unmodified lines | ||
| 39 | 55 | workosOrgId: v.optional(v.string()), |
| 40 | 56 | slug: v.string(), |
| 41 | 57 | name: v.string(), |
| 42 | − status: v.string(), | |
| 58 | + status: organizationStatus, | |
| 43 | 59 | createdByAccountId: v.optional(v.id("accounts")), |
| 44 | 60 | createdAt: v.number(), |
| 45 | 61 | updatedAt: v.number(), |
| @@ -51,8 +67,8 @@ export default defineSchema({ · 5 unmodified lines | ||
| 51 | 67 | organizationMembers: defineTable({ |
| 52 | 68 | organizationId: v.id("organizations"), |
| 53 | 69 | accountId: v.id("accounts"), |
| 54 | − role: v.string(), | |
| 55 | − status: v.string(), | |
| 70 | + role: orgRole, | |
| 71 | + status: orgMemberStatus, | |
| 56 | 72 | billable: v.boolean(), |
| 57 | 73 | invitedByAccountId: v.optional(v.id("accounts")), |
| 58 | 74 | joinedAt: v.optional(v.number()), |
| @@ -68,37 +84,26 @@ export default defineSchema({ · 9 unmodified lines | ||
| 68 | 84 | workspaceMembers: defineTable({ |
| 69 | 85 | workspaceId: v.id("workspaces"), |
| 70 | 86 | accountId: v.id("accounts"), |
| 71 | − role: v.string(), | |
| 72 | − status: v.string(), | |
| 87 | + workosOrgMembershipId: v.optional(v.string()), | |
| 88 | + role: workspaceMemberRole, | |
| 89 | + status: workspaceMemberStatus, | |
| 73 | 90 | createdAt: v.number(), |
| 74 | 91 | updatedAt: v.number(), |
| 75 | 92 | }) |
| 76 | 93 | .index("by_workspace", ["workspaceId"]) |
| 77 | 94 | .index("by_workspace_account", ["workspaceId", "accountId"]) |
| 78 | 95 | .index("by_account", ["accountId"]) |
| 79 | − .index("by_workspace_status", ["workspaceId", "status"]), | |
| 80 | − | |
| 81 | − accountIdentities: defineTable({ | |
| 82 | − accountId: v.id("accounts"), | |
| 83 | − provider: v.string(), | |
| 84 | − providerUserId: v.string(), | |
| 85 | − providerEmail: v.optional(v.string()), | |
| 86 | − rawClaims: v.optional(v.any()), | |
| 87 | − lastLoginAt: v.optional(v.number()), | |
| 88 | − createdAt: v.number(), | |
| 89 | − updatedAt: v.number(), | |
| 90 | − }) | |
| 91 | − .index("by_account", ["accountId"]) | |
| 92 | − .index("by_provider_user", ["provider", "providerUserId"]), | |
| 96 | + .index("by_workspace_status", ["workspaceId", "status"]) | |
| 97 | + .index("by_workos_membership_id", ["workosOrgMembershipId"]), | |
| 93 | 98 | |
| 94 | 99 | invites: defineTable({ |
| 95 | 100 | organizationId: v.id("organizations"), |
| 96 | 101 | workspaceId: v.optional(v.id("workspaces")), |
| 97 | 102 | email: v.string(), |
| 98 | − role: v.string(), | |
| 99 | − status: v.string(), | |
| 103 | + role: orgRole, | |
| 104 | + status: inviteStatus, | |
| 100 | 105 | tokenHash: v.string(), |
| 101 | − provider: v.string(), | |
| 106 | + provider: inviteProvider, | |
| 102 | 107 | providerInviteId: v.optional(v.string()), |
| 103 | 108 | invitedByAccountId: v.id("accounts"), |
| 104 | 109 | expiresAt: v.number(), |
| @@ -146,20 +151,6 @@ export default defineSchema({ · 41 unmodified lines | ||
| 146 | 151 | updatedAt: v.number(), |
| 147 | 152 | }).index("by_org", ["organizationId"]), |
| 148 | 153 | |
| 149 | − users: defineTable({ | |
| 150 | − workspaceId: v.id("workspaces"), | |
| 151 | − accountId: v.id("accounts"), | |
| 152 | − workosOrgMembershipId: v.optional(v.string()), | |
| 153 | − role: v.string(), | |
| 154 | − status: v.string(), | |
| 155 | − createdAt: v.number(), | |
| 156 | − updatedAt: v.number(), | |
| 157 | − }) | |
| 158 | − .index("by_workspace_account", ["workspaceId", "accountId"]) | |
| 159 | − .index("by_account", ["accountId"]) | |
| 160 | − .index("by_workspace", ["workspaceId"]) | |
| 161 | − .index("by_workos_membership_id", ["workosOrgMembershipId"]), | |
| 162 | − | |
| 163 | 154 | tasks: defineTable({ |
| 164 | 155 | taskId: v.string(), |
| 165 | 156 | code: v.string(), |
| @@ -277,8 +268,7 @@ export default defineSchema({ · 111 unmodified lines | ||
| 277 | 268 | actorId: v.string(), |
| 278 | 269 | clientId: v.string(), |
| 279 | 270 | accountId: v.optional(v.id("accounts")), |
| 280 | − workspaceDocId: v.optional(v.id("workspaces")), | |
| 281 | − userId: v.optional(v.id("users")), | |
| 271 | + userId: v.optional(v.id("workspaceMembers")), | |
| 282 | 272 | createdAt: v.number(), |
| 283 | 273 | lastSeenAt: v.number(), |
| 284 | 274 | }) |
| @@ -11,7 +11,6 @@ type WorkspaceResult = { · 10 unmodified lines | ||
| 11 | 11 | name: string; |
| 12 | 12 | slug: string; |
| 13 | 13 | iconUrl: string | null; |
| 14 | − runtimeWorkspaceId: string; | |
| 15 | 14 | createdAt: number; |
| 16 | 15 | }; |
| 17 | 16 | |
| @@ -52,7 +51,6 @@ async function toWorkspaceResult( · 34 unmodified lines | ||
| 52 | 51 | name: workspace.name, |
| 53 | 52 | slug: workspace.slug, |
| 54 | 53 | iconUrl, |
| 55 | − runtimeWorkspaceId: workspace._id, | |
| 56 | 54 | createdAt: workspace.createdAt, |
| 57 | 55 | }; |
| 58 | 56 | } |
| @@ -118,7 +118,6 @@ export interface AnonymousContext { · 117 unmodified lines | ||
| 118 | 118 | actorId: string; |
| 119 | 119 | clientId: string; |
| 120 | 120 | accountId?: string; |
| 121 | − workspaceDocId?: string; | |
| 122 | 121 | userId?: string; |
| 123 | 122 | createdAt: number; |
| 124 | 123 | lastSeenAt: number; |