* feat(cloud): add team management page with WorkOS org members Custom team page using WorkOS API for listing members, inviting, removing, and changing roles. Admin-gated write operations with server-side role checks. Uses Effect API layer pattern with typed endpoints and effect-atom for client-side state. Also fixes Sonner toaster to use system theme detection instead of next-themes, removes next-themes dependency. * style: format * chore: remove unused widget token endpoint and getWidgetToken * fix: remove any types from team handler tests * style: format * fix: add @types/node to storage-postgres for tsgo compat * fix: resolve merge conflict in test file * style: format * fix: resolve tsgo typecheck errors in billing and test files * fix: add explicit Plan types for tsgo compatibility * style: format * fix: cast empty array for tsgo type inference
Showing the changes to apps/cloud/src (14 of 19 files). Show all
Unified · Split
| @@ -46,6 +46,8 @@ import { · 45 unmodified lines | ||
| 46 | 46 | import { WorkOSAuth } from "./auth/workos"; |
| 47 | 47 | import { DbService } from "./services/db"; |
| 48 | 48 | import { createOrgExecutor } from "./services/executor"; |
| 49 | +import { TeamOrgApi } from "./team/compose"; | |
| 50 | +import { TeamHandlers } from "./team/handlers"; | |
| 49 | 51 | import { server } from "./env"; |
| 50 | 52 | |
| 51 | 53 | // --------------------------------------------------------------------------- |
| @@ -90,6 +92,11 @@ const NonProtectedApiLive = HttpApiBuilder.api(NonProtectedApi).pipe( · 38 unmodified lines | ||
| 90 | 92 | Layer.provideMerge(SessionAuthLive), |
| 91 | 93 | ); |
| 92 | 94 | |
| 95 | +const TeamApiLive = HttpApiBuilder.api(TeamOrgApi).pipe( | |
| 96 | + Layer.provide(TeamHandlers), | |
| 97 | + Layer.provideMerge(OrgAuthLive), | |
| 98 | +); | |
| 99 | + | |
| 93 | 100 | // --------------------------------------------------------------------------- |
| 94 | 101 | // Public auth web handler |
| 95 | 102 | // --------------------------------------------------------------------------- |
| @@ -113,6 +120,12 @@ const createNonProtectedHandler = () => · 17 unmodified lines | ||
| 113 | 120 | { middleware: HttpMiddleware.logger }, |
| 114 | 121 | ); |
| 115 | 122 | |
| 123 | +const createTeamHandler = () => | |
| 124 | + HttpApiBuilder.toWebHandler( | |
| 125 | + TeamApiLive.pipe(Layer.provideMerge(SharedServices), Layer.provideMerge(RouterConfig)), | |
| 126 | + { middleware: HttpMiddleware.logger }, | |
| 127 | + ); | |
| 128 | + | |
| 116 | 129 | // --------------------------------------------------------------------------- |
| 117 | 130 | // Protected handler — must be built per-request because the executor varies |
| 118 | 131 | // --------------------------------------------------------------------------- |
| @@ -158,6 +171,7 @@ const buildProtectedHandler = ( · 39 unmodified lines | ||
| 158 | 171 | |
| 159 | 172 | const isAuthPath = (pathname: string): boolean => pathname.startsWith("/auth/"); |
| 160 | 173 | const isAutumnPath = (pathname: string): boolean => pathname.startsWith("/autumn/"); |
| 174 | +const isTeamPath = (pathname: string): boolean => pathname.startsWith("/team/"); | |
| 161 | 175 | const isExecutionPath = (pathname: string): boolean => |
| 162 | 176 | pathname === "/executions" || /^\/executions\/[^/]+\/resume$/.test(pathname); |
| 163 | 177 | |
| @@ -241,9 +255,22 @@ const handleAutumnRequest = async (request: Request): Promise<Response> => { · 77 unmodified lines | ||
| 241 | 255 | ); |
| 242 | 256 | }; |
| 243 | 257 | |
| 258 | +// --------------------------------------------------------------------------- | |
| 259 | +// Widget token endpoint — returns a WorkOS widget token for the session user | |
| 260 | +// --------------------------------------------------------------------------- | |
| 261 | + | |
| 244 | 262 | export const handleApiRequest = async (request: Request): Promise<Response> => { |
| 245 | 263 | const pathname = new URL(request.url).pathname; |
| 246 | 264 | |
| 265 | + if (isTeamPath(pathname)) { | |
| 266 | + const handler = createTeamHandler(); | |
| 267 | + try { | |
| 268 | + return await handler.handler(request); | |
| 269 | + } finally { | |
| 270 | + await handler.dispose(); | |
| 271 | + } | |
| 272 | + } | |
| 273 | + | |
| 247 | 274 | if (isAutumnPath(pathname)) { |
| 248 | 275 | return handleAutumnRequest(request); |
| 249 | 276 | } |
| @@ -138,10 +138,48 @@ const make = Effect.gen(function* () { · 137 unmodified lines | ||
| 138 | 138 | if (!sessionData) return null; |
| 139 | 139 | return yield* authenticateSealedSession(sessionData); |
| 140 | 140 | }), |
| 141 | + | |
| 142 | + /** List organization memberships with user details. */ | |
| 143 | + listOrgMembers: (organizationId: string) => | |
| 144 | + use((wos) => | |
| 145 | + wos.userManagement.listOrganizationMemberships({ | |
| 146 | + organizationId, | |
| 147 | + statuses: ["active", "pending"], | |
| 148 | + }), | |
| 149 | + ), | |
| 150 | + | |
| 151 | + /** Get a user by ID. */ | |
| 152 | + getUser: (userId: string) => use((wos) => wos.userManagement.getUser(userId)), | |
| 153 | + | |
| 154 | + /** Send an organization invitation. */ | |
| 155 | + sendInvitation: (params: { email: string; organizationId: string; roleSlug?: string }) => | |
| 156 | + use((wos) => | |
| 157 | + wos.userManagement.sendInvitation({ | |
| 158 | + email: params.email, | |
| 159 | + organizationId: params.organizationId, | |
| 160 | + roleSlug: params.roleSlug, | |
| 161 | + }), | |
| 162 | + ), | |
| 163 | + | |
| 164 | + /** Remove an organization membership. */ | |
| 165 | + deleteOrgMembership: (membershipId: string) => | |
| 166 | + use((wos) => wos.userManagement.deleteOrganizationMembership(membershipId)), | |
| 167 | + | |
| 168 | + /** Get the role for a membership. */ | |
| 169 | + getOrgMembership: (membershipId: string) => | |
| 170 | + use((wos) => wos.userManagement.getOrganizationMembership(membershipId)), | |
| 171 | + | |
| 172 | + /** Update a membership's role. */ | |
| 173 | + updateOrgMembershipRole: (membershipId: string, roleSlug: string) => | |
| 174 | + use((wos) => wos.userManagement.updateOrganizationMembership(membershipId, { roleSlug })), | |
| 175 | + | |
| 176 | + /** List available roles for an organization. */ | |
| 177 | + listOrgRoles: (organizationId: string) => | |
| 178 | + use((wos) => wos.organizations.listOrganizationRoles({ organizationId })), | |
| 141 | 179 | }; |
| 142 | 180 | }); |
| 143 | 181 | |
| 144 | −type WorkOSAuthService = Effect.Effect.Success<typeof make>; | |
| 182 | +export type WorkOSAuthService = Effect.Effect.Success<typeof make>; | |
| 145 | 183 | |
| 146 | 184 | export class WorkOSAuth extends Context.Tag("@executor/cloud/WorkOSAuth")< |
| 147 | 185 | WorkOSAuth, |
| @@ -10,6 +10,7 @@ · 9 unmodified lines | ||
| 10 | 10 | |
| 11 | 11 | import { Route as rootRouteImport } from "./routes/__root"; |
| 12 | 12 | import { Route as ToolsRouteImport } from "./routes/tools"; |
| 13 | +import { Route as TeamRouteImport } from "./routes/team"; | |
| 13 | 14 | import { Route as SecretsRouteImport } from "./routes/secrets"; |
| 14 | 15 | import { Route as BillingRouteImport } from "./routes/billing"; |
| 15 | 16 | import { Route as IndexRouteImport } from "./routes/index"; |
| @@ -22,6 +23,11 @@ const ToolsRoute = ToolsRouteImport.update({ · 6 unmodified lines | ||
| 22 | 23 | path: "/tools", |
| 23 | 24 | getParentRoute: () => rootRouteImport, |
| 24 | 25 | } as any); |
| 26 | +const TeamRoute = TeamRouteImport.update({ | |
| 27 | + id: "/team", | |
| 28 | + path: "/team", | |
| 29 | + getParentRoute: () => rootRouteImport, | |
| 30 | +} as any); | |
| 25 | 31 | const SecretsRoute = SecretsRouteImport.update({ |
| 26 | 32 | id: "/secrets", |
| 27 | 33 | path: "/secrets", |
| @@ -57,6 +63,7 @@ export interface FileRoutesByFullPath { · 29 unmodified lines | ||
| 57 | 63 | "/": typeof IndexRoute; |
| 58 | 64 | "/billing": typeof BillingRoute; |
| 59 | 65 | "/secrets": typeof SecretsRoute; |
| 66 | + "/team": typeof TeamRoute; | |
| 60 | 67 | "/tools": typeof ToolsRoute; |
| 61 | 68 | "/billing/plans": typeof BillingPlansRoute; |
| 62 | 69 | "/sources/$namespace": typeof SourcesNamespaceRoute; |
| @@ -66,6 +73,7 @@ export interface FileRoutesByTo { · 3 unmodified lines | ||
| 66 | 73 | "/": typeof IndexRoute; |
| 67 | 74 | "/billing": typeof BillingRoute; |
| 68 | 75 | "/secrets": typeof SecretsRoute; |
| 76 | + "/team": typeof TeamRoute; | |
| 69 | 77 | "/tools": typeof ToolsRoute; |
| 70 | 78 | "/billing/plans": typeof BillingPlansRoute; |
| 71 | 79 | "/sources/$namespace": typeof SourcesNamespaceRoute; |
| @@ -76,6 +84,7 @@ export interface FileRoutesById { · 4 unmodified lines | ||
| 76 | 84 | "/": typeof IndexRoute; |
| 77 | 85 | "/billing": typeof BillingRoute; |
| 78 | 86 | "/secrets": typeof SecretsRoute; |
| 87 | + "/team": typeof TeamRoute; | |
| 79 | 88 | "/tools": typeof ToolsRoute; |
| 80 | 89 | "/billing_/plans": typeof BillingPlansRoute; |
| 81 | 90 | "/sources/$namespace": typeof SourcesNamespaceRoute; |
| @@ -87,6 +96,7 @@ export interface FileRouteTypes { · 5 unmodified lines | ||
| 87 | 96 | | "/" |
| 88 | 97 | | "/billing" |
| 89 | 98 | | "/secrets" |
| 99 | + | "/team" | |
| 90 | 100 | | "/tools" |
| 91 | 101 | | "/billing/plans" |
| 92 | 102 | | "/sources/$namespace" |
| @@ -96,6 +106,7 @@ export interface FileRouteTypes { · 3 unmodified lines | ||
| 96 | 106 | | "/" |
| 97 | 107 | | "/billing" |
| 98 | 108 | | "/secrets" |
| 109 | + | "/team" | |
| 99 | 110 | | "/tools" |
| 100 | 111 | | "/billing/plans" |
| 101 | 112 | | "/sources/$namespace" |
| @@ -105,6 +116,7 @@ export interface FileRouteTypes { · 3 unmodified lines | ||
| 105 | 116 | | "/" |
| 106 | 117 | | "/billing" |
| 107 | 118 | | "/secrets" |
| 119 | + | "/team" | |
| 108 | 120 | | "/tools" |
| 109 | 121 | | "/billing_/plans" |
| 110 | 122 | | "/sources/$namespace" |
| @@ -115,6 +127,7 @@ export interface RootRouteChildren { · 4 unmodified lines | ||
| 115 | 127 | IndexRoute: typeof IndexRoute; |
| 116 | 128 | BillingRoute: typeof BillingRoute; |
| 117 | 129 | SecretsRoute: typeof SecretsRoute; |
| 130 | + TeamRoute: typeof TeamRoute; | |
| 118 | 131 | ToolsRoute: typeof ToolsRoute; |
| 119 | 132 | BillingPlansRoute: typeof BillingPlansRoute; |
| 120 | 133 | SourcesNamespaceRoute: typeof SourcesNamespaceRoute; |
| @@ -130,6 +143,13 @@ declare module "@tanstack/react-router" { · 9 unmodified lines | ||
| 130 | 143 | preLoaderRoute: typeof ToolsRouteImport; |
| 131 | 144 | parentRoute: typeof rootRouteImport; |
| 132 | 145 | }; |
| 146 | + "/team": { | |
| 147 | + id: "/team"; | |
| 148 | + path: "/team"; | |
| 149 | + fullPath: "/team"; | |
| 150 | + preLoaderRoute: typeof TeamRouteImport; | |
| 151 | + parentRoute: typeof rootRouteImport; | |
| 152 | + }; | |
| 133 | 153 | "/secrets": { |
| 134 | 154 | id: "/secrets"; |
| 135 | 155 | path: "/secrets"; |
| @@ -179,6 +199,7 @@ const rootRouteChildren: RootRouteChildren = { · 43 unmodified lines | ||
| 179 | 199 | IndexRoute: IndexRoute, |
| 180 | 200 | BillingRoute: BillingRoute, |
| 181 | 201 | SecretsRoute: SecretsRoute, |
| 202 | + TeamRoute: TeamRoute, | |
| 182 | 203 | ToolsRoute: ToolsRoute, |
| 183 | 204 | BillingPlansRoute: BillingPlansRoute, |
| 184 | 205 | SourcesNamespaceRoute: SourcesNamespaceRoute, |
| @@ -2,6 +2,7 @@ import React from "react"; · 1 unmodified line | ||
| 2 | 2 | import { HeadContent, Scripts, createRootRoute } from "@tanstack/react-router"; |
| 3 | 3 | import { AutumnProvider } from "autumn-js/react"; |
| 4 | 4 | import { ExecutorProvider } from "@executor/react/api/provider"; |
| 5 | +import { Toaster } from "@executor/react/components/sonner"; | |
| 5 | 6 | import { AuthProvider, useAuth } from "../web/auth"; |
| 6 | 7 | import { LoginPage } from "../web/pages/login"; |
| 7 | 8 | import { Shell } from "../web/shell"; |
| @@ -70,6 +71,7 @@ function AuthGate() { · 62 unmodified lines | ||
| 70 | 71 | <AutumnProvider pathPrefix="/api/autumn"> |
| 71 | 72 | <ExecutorProvider> |
| 72 | 73 | <Shell /> |
| 74 | + <Toaster /> | |
| 73 | 75 | </ExecutorProvider> |
| 74 | 76 | </AutumnProvider> |
| 75 | 77 | ); |
| @@ -2,6 +2,8 @@ import { createFileRoute, Link } from "@tanstack/react-router"; · 1 unmodified line | ||
| 2 | 2 | import { useCustomer, useListPlans } from "autumn-js/react"; |
| 3 | 3 | import { Button } from "@executor/react/components/button"; |
| 4 | 4 | |
| 5 | +type Plan = NonNullable<ReturnType<typeof useListPlans>["data"]>[number]; | |
| 6 | + | |
| 5 | 7 | export const Route = createFileRoute("/billing")({ |
| 6 | 8 | component: BillingPage, |
| 7 | 9 | }); |
| @@ -29,11 +31,11 @@ function BillingPage() { · 21 unmodified lines | ||
| 29 | 31 | ); |
| 30 | 32 | } |
| 31 | 33 | |
| 32 | − // Find current plan via customerEligibility from useListPlans | |
| 33 | − const activePlan = (plans ?? []).find( | |
| 34 | + const allPlans: Plan[] = plans ?? []; | |
| 35 | + const activePlan = allPlans.find( | |
| 34 | 36 | (p) => p.customerEligibility?.status === "active" && p.id !== "free", |
| 35 | 37 | ); |
| 36 | − const scheduledPlan = (plans ?? []).find( | |
| 38 | + const scheduledPlan = allPlans.find( | |
| 37 | 39 | (p) => p.customerEligibility?.status === "scheduled" && p.id !== "free", |
| 38 | 40 | ); |
| 39 | 41 | const isCanceling = activePlan?.customerEligibility?.canceling ?? false; |
| @@ -3,6 +3,8 @@ import { createFileRoute, Link } from "@tanstack/react-router"; · 2 unmodified lines | ||
| 3 | 3 | import { useCustomer, useListPlans } from "autumn-js/react"; |
| 4 | 4 | import { Button } from "@executor/react/components/button"; |
| 5 | 5 | |
| 6 | +type Plan = NonNullable<ReturnType<typeof useListPlans>["data"]>[number]; | |
| 7 | + | |
| 6 | 8 | export const Route = createFileRoute("/billing_/plans")({ |
| 7 | 9 | component: PlansPage, |
| 8 | 10 | }); |
| @@ -45,7 +47,9 @@ function PlansPage() { · 36 unmodified lines | ||
| 45 | 47 | |
| 46 | 48 | const isLoading = customerLoading || plansLoading; |
| 47 | 49 | |
| 48 | − const paidPlans = (plans ?? []).filter((p) => p.id === "hobby" || p.id === "professional"); | |
| 50 | + const paidPlans = (plans ?? ([] as Plan[])).filter( | |
| 51 | + (p) => p.id === "hobby" || p.id === "professional", | |
| 52 | + ); | |
| 49 | 53 | |
| 50 | 54 | return ( |
| 51 | 55 | <div className="min-h-0 flex-1 overflow-y-auto"> |
| @@ -0,0 +1,446 @@ | ||
| 1 | +import { useReducer, useState } from "react"; | |
| 2 | +import { Exit } from "effect"; | |
| 3 | +import { createFileRoute } from "@tanstack/react-router"; | |
| 4 | +import { useAtomValue, useAtomSet, useAtomRefresh, Result } from "@effect-atom/atom-react"; | |
| 5 | +import { toast } from "sonner"; | |
| 6 | +import { | |
| 7 | + Dialog, | |
| 8 | + DialogContent, | |
| 9 | + DialogHeader, | |
| 10 | + DialogTitle, | |
| 11 | + DialogDescription, | |
| 12 | + DialogFooter, | |
| 13 | + DialogClose, | |
| 14 | +} from "@executor/react/components/dialog"; | |
| 15 | +import { Button } from "@executor/react/components/button"; | |
| 16 | +import { Input } from "@executor/react/components/input"; | |
| 17 | +import { Label } from "@executor/react/components/label"; | |
| 18 | +import { | |
| 19 | + Select, | |
| 20 | + SelectContent, | |
| 21 | + SelectItem, | |
| 22 | + SelectTrigger, | |
| 23 | + SelectValue, | |
| 24 | +} from "@executor/react/components/select"; | |
| 25 | +import { | |
| 26 | + DropdownMenu, | |
| 27 | + DropdownMenuContent, | |
| 28 | + DropdownMenuItem, | |
| 29 | + DropdownMenuSub, | |
| 30 | + DropdownMenuSubContent, | |
| 31 | + DropdownMenuSubTrigger, | |
| 32 | + DropdownMenuTrigger, | |
| 33 | + DropdownMenuSeparator, | |
| 34 | +} from "@executor/react/components/dropdown-menu"; | |
| 35 | +import { | |
| 36 | + teamMembersAtom, | |
| 37 | + teamRolesAtom, | |
| 38 | + inviteMember, | |
| 39 | + removeMember, | |
| 40 | + updateMemberRole, | |
| 41 | +} from "../web/team-atoms"; | |
| 42 | + | |
| 43 | +export const Route = createFileRoute("/team")({ | |
| 44 | + component: TeamPage, | |
| 45 | +}); | |
| 46 | + | |
| 47 | +type InviteState = { | |
| 48 | + email: string; | |
| 49 | + roleSlug: string; | |
| 50 | + status: "idle" | "sending" | "error"; | |
| 51 | + error: string | null; | |
| 52 | +}; | |
| 53 | + | |
| 54 | +const initialInviteState: InviteState = { | |
| 55 | + email: "", | |
| 56 | + roleSlug: "", | |
| 57 | + status: "idle", | |
| 58 | + error: null, | |
| 59 | +}; | |
| 60 | + | |
| 61 | +type InviteAction = | |
| 62 | + | { type: "setEmail"; email: string } | |
| 63 | + | { type: "setRole"; roleSlug: string } | |
| 64 | + | { type: "send" } | |
| 65 | + | { type: "error"; message: string } | |
| 66 | + | { type: "reset" }; | |
| 67 | + | |
| 68 | +function inviteReducer(state: InviteState, action: InviteAction): InviteState { | |
| 69 | + switch (action.type) { | |
| 70 | + case "setEmail": | |
| 71 | + return { ...state, email: action.email }; | |
| 72 | + case "setRole": | |
| 73 | + return { ...state, roleSlug: action.roleSlug }; | |
| 74 | + case "send": | |
| 75 | + return { ...state, status: "sending", error: null }; | |
| 76 | + case "error": | |
| 77 | + return { ...state, status: "error", error: action.message }; | |
| 78 | + case "reset": | |
| 79 | + return initialInviteState; | |
| 80 | + } | |
| 81 | +} | |
| 82 | + | |
| 83 | +function formatLastActive(lastActiveAt: string | null): string { | |
| 84 | + if (!lastActiveAt) return "\u2014"; | |
| 85 | + const date = new Date(lastActiveAt); | |
| 86 | + const diffMs = Date.now() - date.getTime(); | |
| 87 | + const diffMins = Math.floor(diffMs / 60000); | |
| 88 | + if (diffMins < 1) return "Just now"; | |
| 89 | + if (diffMins < 60) return `${diffMins}m ago`; | |
| 90 | + const diffHours = Math.floor(diffMins / 60); | |
| 91 | + if (diffHours < 24) return `${diffHours}h ago`; | |
| 92 | + const diffDays = Math.floor(diffHours / 24); | |
| 93 | + if (diffDays < 30) return `${diffDays}d ago`; | |
| 94 | + return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); | |
| 95 | +} | |
| 96 | + | |
| 97 | +function TeamPage() { | |
| 98 | + const membersResult = useAtomValue(teamMembersAtom); | |
| 99 | + const rolesResult = useAtomValue(teamRolesAtom); | |
| 100 | + const refreshMembers = useAtomRefresh(teamMembersAtom); | |
| 101 | + const doRemove = useAtomSet(removeMember, { mode: "promiseExit" }); | |
| 102 | + const doUpdateRole = useAtomSet(updateMemberRole, { mode: "promiseExit" }); | |
| 103 | + const [inviteOpen, setInviteOpen] = useState(false); | |
| 104 | + const [search, setSearch] = useState(""); | |
| 105 | + | |
| 106 | + const roles = Result.match(rolesResult, { | |
| 107 | + onInitial: () => [] as readonly { slug: string; name: string }[], | |
| 108 | + onFailure: () => [] as readonly { slug: string; name: string }[], | |
| 109 | + onSuccess: ({ value }) => value.roles, | |
| 110 | + }); | |
| 111 | + | |
| 112 | + const handleRemove = async (membershipId: string, name: string) => { | |
| 113 | + const exit = await doRemove({ path: { membershipId } }); | |
| 114 | + if (Exit.isSuccess(exit)) { | |
| 115 | + toast.success(`Removed ${name}`); | |
| 116 | + refreshMembers(); | |
| 117 | + } else { | |
| 118 | + toast.error("Failed to remove member"); | |
| 119 | + } | |
| 120 | + }; | |
| 121 | + | |
| 122 | + const handleChangeRole = async (membershipId: string, roleSlug: string, roleName: string) => { | |
| 123 | + const exit = await doUpdateRole({ path: { membershipId }, payload: { roleSlug } }); | |
| 124 | + if (Exit.isSuccess(exit)) { | |
| 125 | + toast.success(`Role changed to ${roleName}`); | |
| 126 | + refreshMembers(); | |
| 127 | + } else { | |
| 128 | + toast.error("Failed to change role"); | |
| 129 | + } | |
| 130 | + }; | |
| 131 | + | |
| 132 | + return ( | |
| 133 | + <div className="min-h-0 flex-1 overflow-y-auto"> | |
| 134 | + <div className="mx-auto max-w-3xl px-6 py-10 lg:px-8 lg:py-14"> | |
| 135 | + <div className="flex items-end justify-between mb-10"> | |
| 136 | + <h1 className="font-display text-[2rem] tracking-tight text-foreground leading-none"> | |
| 137 | + Team | |
| 138 | + </h1> | |
| 139 | + <Button size="sm" onClick={() => setInviteOpen(true)}> | |
| 140 | + Invite member | |
| 141 | + </Button> | |
| 142 | + </div> | |
| 143 | + | |
| 144 | + {/* Search */} | |
| 145 | + <div className="mb-4"> | |
| 146 | + <Input | |
| 147 | + type="text" | |
| 148 | + placeholder="Search by name or email..." | |
| 149 | + value={search} | |
| 150 | + onChange={(e) => setSearch((e.target as HTMLInputElement).value)} | |
| 151 | + className="text-[0.8125rem] h-9" | |
| 152 | + /> | |
| 153 | + </div> | |
| 154 | + | |
| 155 | + {/* Members */} | |
| 156 | + {Result.match(membersResult, { | |
| 157 | + onInitial: () => ( | |
| 158 | + <div className="space-y-2"> | |
| 159 | + {[1, 2, 3].map((i) => ( | |
| 160 | + <div key={i} className="h-14 animate-pulse rounded-lg bg-muted" /> | |
| 161 | + ))} | |
| 162 | + </div> | |
| 163 | + ), | |
| 164 | + onFailure: () => ( | |
| 165 | + <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3"> | |
| 166 | + <p className="text-[0.8125rem] text-destructive">Failed to load team members</p> | |
| 167 | + </div> | |
| 168 | + ), | |
| 169 | + onSuccess: ({ value }) => { | |
| 170 | + const members = value.members; | |
| 171 | + const filtered = search | |
| 172 | + ? members.filter( | |
| 173 | + (m) => | |
| 174 | + m.email.toLowerCase().includes(search.toLowerCase()) || | |
| 175 | + (m.name?.toLowerCase().includes(search.toLowerCase()) ?? false), | |
| 176 | + ) | |
| 177 | + : members; | |
| 178 | + | |
| 179 | + if (filtered.length === 0) { | |
| 180 | + return ( | |
| 181 | + <p className="py-8 text-center text-[0.8125rem] text-muted-foreground/60"> | |
| 182 | + {search ? "No matching members" : "No team members yet"} | |
| 183 | + </p> | |
| 184 | + ); | |
| 185 | + } | |
| 186 | + | |
| 187 | + return ( | |
| 188 | + <div className="space-y-px"> | |
| 189 | + {filtered.map((member) => ( | |
| 190 | + <div | |
| 191 | + key={member.id} | |
| 192 | + className="group relative grid grid-cols-[2rem_1fr_6rem_5rem_2rem] items-center gap-3 rounded-lg border border-transparent px-4 py-3 transition-all hover:bg-muted/30" | |
| 193 | + > | |
| 194 | + {/* Avatar */} | |
| 195 | + {member.avatarUrl ? ( | |
| 196 | + <img src={member.avatarUrl} alt="" className="size-8 rounded-full" /> | |
| 197 | + ) : ( | |
| 198 | + <div className="flex size-8 items-center justify-center rounded-full bg-muted text-[0.625rem] font-semibold text-muted-foreground"> | |
| 199 | + {member.name | |
| 200 | + ? member.name | |
| 201 | + .split(" ") | |
| 202 | + .map((n) => n[0]) | |
| 203 | + .join("") | |
| 204 | + .slice(0, 2) | |
| 205 | + .toUpperCase() | |
| 206 | + : member.email[0]!.toUpperCase()} | |
| 207 | + </div> | |
| 208 | + )} | |
| 209 | + | |
| 210 | + {/* Name + email */} | |
| 211 | + <div className="min-w-0"> | |
| 212 | + <div className="flex items-center gap-2"> | |
| 213 | + <p className="truncate text-[0.8125rem] font-medium text-foreground leading-none"> | |
| 214 | + {member.name ?? member.email} | |
| 215 | + </p> | |
| 216 | + {member.isCurrentUser && ( | |
| 217 | + <span className="rounded bg-muted px-1.5 py-0.5 text-[0.625rem] font-medium text-muted-foreground leading-none"> | |
| 218 | + You | |
| 219 | + </span> | |
| 220 | + )} | |
| 221 | + {member.status === "pending" && ( | |
| 222 | + <span className="rounded bg-amber-500/10 px-1.5 py-0.5 text-[0.625rem] font-medium text-amber-600 dark:text-amber-400 leading-none"> | |
| 223 | + Invited | |
| 224 | + </span> | |
| 225 | + )} | |
| 226 | + </div> | |
| 227 | + {member.name && ( | |
| 228 | + <p className="mt-0.5 truncate text-[0.75rem] text-muted-foreground/70 leading-none"> | |
| 229 | + {member.email} | |
| 230 | + </p> | |
| 231 | + )} | |
| 232 | + </div> | |
| 233 | + | |
| 234 | + {/* Role */} | |
| 235 | + <p className="text-[0.8125rem] text-muted-foreground capitalize leading-none"> | |
| 236 | + {member.role} | |
| 237 | + </p> | |
| 238 | + | |
| 239 | + {/* Last active */} | |
| 240 | + <p className="text-[0.75rem] text-muted-foreground/60 leading-none"> | |
| 241 | + {formatLastActive(member.lastActiveAt)} | |
| 242 | + </p> | |
| 243 | + | |
| 244 | + {/* Actions */} | |
| 245 | + {!member.isCurrentUser ? ( | |
| 246 | + <DropdownMenu> | |
| 247 | + <DropdownMenuTrigger asChild> | |
| 248 | + <Button | |
| 249 | + variant="ghost" | |
| 250 | + size="icon" | |
| 251 | + className="size-7 opacity-0 group-hover:opacity-100 transition-opacity" | |
| 252 | + > | |
| 253 | + <svg viewBox="0 0 16 16" className="size-3"> | |
| 254 | + <circle cx="8" cy="3" r="1.2" fill="currentColor" /> | |
| 255 | + <circle cx="8" cy="8" r="1.2" fill="currentColor" /> | |
| 256 | + <circle cx="8" cy="13" r="1.2" fill="currentColor" /> | |
| 257 | + </svg> | |
| 258 | + </Button> | |
| 259 | + </DropdownMenuTrigger> | |
| 260 | + <DropdownMenuContent align="end" className="w-44"> | |
| 261 | + {roles.length > 0 && ( | |
| 262 | + <> | |
| 263 | + <DropdownMenuSub> | |
| 264 | + <DropdownMenuSubTrigger className="text-[0.75rem]"> | |
| 265 | + Change role | |
| 266 | + </DropdownMenuSubTrigger> | |
| 267 | + <DropdownMenuSubContent> | |
| 268 | + {roles.map((role) => ( | |
| 269 | + <DropdownMenuItem | |
| 270 | + key={role.slug} | |
| 271 | + className="text-[0.75rem]" | |
| 272 | + disabled={role.slug === member.role} | |
| 273 | + onClick={() => | |
| 274 | + handleChangeRole(member.id, role.slug, role.name) | |
| 275 | + } | |
| 276 | + > | |
| 277 | + {role.name} | |
| 278 | + {role.slug === member.role && ( | |
| 279 | + <span className="ml-auto text-muted-foreground/50"> | |
| 280 | + <svg viewBox="0 0 16 16" fill="none" className="size-3"> | |
| 281 | + <path | |
| 282 | + d="M3.5 8.5L6.5 11.5L12.5 5" | |
| 283 | + stroke="currentColor" | |
| 284 | + strokeWidth="1.5" | |
| 285 | + strokeLinecap="round" | |
| 286 | + strokeLinejoin="round" | |
| 287 | + /> | |
| 288 | + </svg> | |
| 289 | + </span> | |
| 290 | + )} | |
| 291 | + </DropdownMenuItem> | |
| 292 | + ))} | |
| 293 | + </DropdownMenuSubContent> | |
| 294 | + </DropdownMenuSub> | |
| 295 | + <DropdownMenuSeparator /> | |
| 296 | + </> | |
| 297 | + )} | |
| 298 | + <DropdownMenuItem | |
| 299 | + className="text-destructive focus:text-destructive text-[0.75rem]" | |
| 300 | + onClick={() => handleRemove(member.id, member.name ?? member.email)} | |
| 301 | + > | |
| 302 | + Remove member | |
| 303 | + </DropdownMenuItem> | |
| 304 | + </DropdownMenuContent> | |
| 305 | + </DropdownMenu> | |
| 306 | + ) : ( | |
| 307 | + <div /> | |
| 308 | + )} | |
| 309 | + </div> | |
| 310 | + ))} | |
| 311 | + </div> | |
| 312 | + ); | |
| 313 | + }, | |
| 314 | + })} | |
| 315 | + | |
| 316 | + <InviteDialog | |
| 317 | + open={inviteOpen} | |
| 318 | + onOpenChange={setInviteOpen} | |
| 319 | + onInvited={refreshMembers} | |
| 320 | + roles={roles} | |
| 321 | + /> | |
| 322 | + </div> | |
| 323 | + </div> | |
| 324 | + ); | |
| 325 | +} | |
| 326 | + | |
| 327 | +function InviteDialog(props: { | |
| 328 | + open: boolean; | |
| 329 | + onOpenChange: (v: boolean) => void; | |
| 330 | + onInvited: () => void; | |
| 331 | + roles: readonly { slug: string; name: string }[]; | |
| 332 | +}) { | |
| 333 | + const [state, dispatch] = useReducer(inviteReducer, initialInviteState); | |
| 334 | + const doInvite = useAtomSet(inviteMember, { mode: "promiseExit" }); | |
| 335 | + | |
| 336 | + const handleInvite = async () => { | |
| 337 | + if (!state.email.trim()) return; | |
| 338 | + dispatch({ type: "send" }); | |
| 339 | + | |
| 340 | + const exit = await doInvite({ | |
| 341 | + payload: { | |
| 342 | + email: state.email.trim(), | |
| 343 | + ...(state.roleSlug ? { roleSlug: state.roleSlug } : {}), | |
| 344 | + }, | |
| 345 | + }); | |
| 346 | + | |
| 347 | + if (Exit.isSuccess(exit)) { | |
| 348 | + toast.success(`Invitation sent to ${state.email.trim()}`); | |
| 349 | + dispatch({ type: "reset" }); | |
| 350 | + props.onOpenChange(false); | |
| 351 | + props.onInvited(); | |
| 352 | + } else { | |
| 353 | + dispatch({ type: "error", message: "Failed to send invitation" }); | |
| 354 | + } | |
| 355 | + }; | |
| 356 | + | |
| 357 | + return ( | |
| 358 | + <Dialog | |
| 359 | + open={props.open} | |
| 360 | + onOpenChange={(v) => { | |
| 361 | + if (!v) dispatch({ type: "reset" }); | |
| 362 | + props.onOpenChange(v); | |
| 363 | + }} | |
| 364 | + > | |
| 365 | + <DialogContent className="sm:max-w-[400px]"> | |
| 366 | + <DialogHeader> | |
| 367 | + <DialogTitle className="font-display text-xl">Invite member</DialogTitle> | |
| 368 | + <DialogDescription className="text-[0.8125rem] leading-relaxed"> | |
| 369 | + Send an email invitation to join your team. | |
| 370 | + </DialogDescription> | |
| 371 | + </DialogHeader> | |
| 372 | + | |
| 373 | + <div className="grid gap-4 py-3"> | |
| 374 | + <div className="grid gap-1.5"> | |
| 375 | + <Label | |
| 376 | + htmlFor="invite-email" | |
| 377 | + className="text-[0.6875rem] font-medium uppercase tracking-wider text-muted-foreground" | |
| 378 | + > | |
| 379 | ||
| 380 | + </Label> | |
| 381 | + <Input | |
| 382 | + id="invite-email" | |
| 383 | + type="email" | |
| 384 | + placeholder="colleague@company.com" | |
| 385 | + value={state.email} | |
| 386 | + onChange={(e) => | |
| 387 | + dispatch({ type: "setEmail", email: (e.target as HTMLInputElement).value }) | |
| 388 | + } | |
| 389 | + onKeyDown={(e) => { | |
| 390 | + if (e.key === "Enter") handleInvite(); | |
| 391 | + }} | |
| 392 | + className="text-[0.8125rem] h-9" | |
| 393 | + /> | |
| 394 | + </div> | |
| 395 | + | |
| 396 | + {props.roles.length > 0 && ( | |
| 397 | + <div className="grid gap-1.5"> | |
| 398 | + <Label | |
| 399 | + htmlFor="invite-role" | |
| 400 | + className="text-[0.6875rem] font-medium uppercase tracking-wider text-muted-foreground" | |
| 401 | + > | |
| 402 | + Role | |
| 403 | + </Label> | |
| 404 | + <Select | |
| 405 | + value={state.roleSlug} | |
| 406 | + onValueChange={(v) => dispatch({ type: "setRole", roleSlug: v })} | |
| 407 | + > | |
| 408 | + <SelectTrigger id="invite-role" className="h-9 text-[0.8125rem]"> | |
| 409 | + <SelectValue placeholder="Select a role" /> | |
| 410 | + </SelectTrigger> | |
| 411 | + <SelectContent> | |
| 412 | + {props.roles.map((role) => ( | |
| 413 | + <SelectItem key={role.slug} value={role.slug}> | |
| 414 | + {role.name} | |
| 415 | + </SelectItem> | |
| 416 | + ))} | |
| 417 | + </SelectContent> | |
| 418 | + </Select> | |
| 419 | + </div> | |
| 420 | + )} | |
| 421 | + | |
| 422 | + {state.status === "error" && state.error && ( | |
| 423 | + <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2"> | |
| 424 | + <p className="text-[0.75rem] text-destructive">{state.error}</p> | |
| 425 | + </div> | |
| 426 | + )} | |
| 427 | + </div> | |
| 428 | + | |
| 429 | + <DialogFooter> | |
| 430 | + <DialogClose asChild> | |
| 431 | + <Button variant="ghost" size="sm"> | |
| 432 | + Cancel | |
| 433 | + </Button> | |
| 434 | + </DialogClose> | |
| 435 | + <Button | |
| 436 | + size="sm" | |
| 437 | + onClick={handleInvite} | |
| 438 | + disabled={!state.email.trim() || state.status === "sending"} | |
| 439 | + > | |
| 440 | + {state.status === "sending" ? "Sending…" : "Send invite"} | |
| 441 | + </Button> | |
| 442 | + </DialogFooter> | |
| 443 | + </DialogContent> | |
| 444 | + </Dialog> | |
| 445 | + ); | |
| 446 | +} | |
| @@ -0,0 +1,92 @@ | ||
| 1 | +import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; | |
| 2 | +import { Schema } from "effect"; | |
| 3 | +import { WorkOSError } from "../auth/errors"; | |
| 4 | + | |
| 5 | +export class Forbidden extends Schema.TaggedError<Forbidden>()( | |
| 6 | + "Forbidden", | |
| 7 | + {}, | |
| 8 | + HttpApiSchema.annotations({ status: 403 }), | |
| 9 | +) {} | |
| 10 | + | |
| 11 | +const TeamMember = Schema.Struct({ | |
| 12 | + id: Schema.String, | |
| 13 | + userId: Schema.String, | |
| 14 | + email: Schema.String, | |
| 15 | + name: Schema.NullOr(Schema.String), | |
| 16 | + avatarUrl: Schema.NullOr(Schema.String), | |
| 17 | + role: Schema.String, | |
| 18 | + status: Schema.String, | |
| 19 | + lastActiveAt: Schema.NullOr(Schema.String), | |
| 20 | + isCurrentUser: Schema.Boolean, | |
| 21 | +}); | |
| 22 | + | |
| 23 | +const TeamMembersResponse = Schema.Struct({ | |
| 24 | + members: Schema.Array(TeamMember), | |
| 25 | +}); | |
| 26 | + | |
| 27 | +const TeamRole = Schema.Struct({ | |
| 28 | + slug: Schema.String, | |
| 29 | + name: Schema.String, | |
| 30 | +}); | |
| 31 | + | |
| 32 | +const TeamRolesResponse = Schema.Struct({ | |
| 33 | + roles: Schema.Array(TeamRole), | |
| 34 | +}); | |
| 35 | + | |
| 36 | +const InviteBody = Schema.Struct({ | |
| 37 | + email: Schema.String, | |
| 38 | + roleSlug: Schema.optional(Schema.String), | |
| 39 | +}); | |
| 40 | + | |
| 41 | +const InviteResponse = Schema.Struct({ | |
| 42 | + id: Schema.String, | |
| 43 | + email: Schema.String, | |
| 44 | +}); | |
| 45 | + | |
| 46 | +const membershipIdParam = HttpApiSchema.param("membershipId", Schema.String); | |
| 47 | + | |
| 48 | +const RemoveResponse = Schema.Struct({ | |
| 49 | + success: Schema.Boolean, | |
| 50 | +}); | |
| 51 | + | |
| 52 | +const UpdateRoleBody = Schema.Struct({ | |
| 53 | + roleSlug: Schema.String, | |
| 54 | +}); | |
| 55 | + | |
| 56 | +const UpdateRoleResponse = Schema.Struct({ | |
| 57 | + success: Schema.Boolean, | |
| 58 | +}); | |
| 59 | + | |
| 60 | +export { TeamMember, TeamMembersResponse }; | |
| 61 | + | |
| 62 | +export class TeamApi extends HttpApiGroup.make("team") | |
| 63 | + .add( | |
| 64 | + HttpApiEndpoint.get("listMembers")`/team/members` | |
| 65 | + .addSuccess(TeamMembersResponse) | |
| 66 | + .addError(WorkOSError), | |
| 67 | + ) | |
| 68 | + .add( | |
| 69 | + HttpApiEndpoint.get("listRoles")`/team/roles` | |
| 70 | + .addSuccess(TeamRolesResponse) | |
| 71 | + .addError(WorkOSError), | |
| 72 | + ) | |
| 73 | + .add( | |
| 74 | + HttpApiEndpoint.post("invite")`/team/invite` | |
| 75 | + .setPayload(InviteBody) | |
| 76 | + .addSuccess(InviteResponse) | |
| 77 | + .addError(WorkOSError) | |
| 78 | + .addError(Forbidden), | |
| 79 | + ) | |
| 80 | + .add( | |
| 81 | + HttpApiEndpoint.del("removeMember")`/team/members/${membershipIdParam}` | |
| 82 | + .addSuccess(RemoveResponse) | |
| 83 | + .addError(WorkOSError) | |
| 84 | + .addError(Forbidden), | |
| 85 | + ) | |
| 86 | + .add( | |
| 87 | + HttpApiEndpoint.patch("updateMemberRole")`/team/members/${membershipIdParam}/role` | |
| 88 | + .setPayload(UpdateRoleBody) | |
| 89 | + .addSuccess(UpdateRoleResponse) | |
| 90 | + .addError(WorkOSError) | |
| 91 | + .addError(Forbidden), | |
| 92 | + ) {} | |
| @@ -0,0 +1,6 @@ | ||
| 1 | +import { HttpApi } from "@effect/platform"; | |
| 2 | +import { OrgAuth } from "../auth/middleware"; | |
| 3 | +import { TeamApi } from "./api"; | |
| 4 | + | |
| 5 | +/** Team API with org-level auth — requires authenticated session with an org. */ | |
| 6 | +export const TeamOrgApi = HttpApi.make("teamOrg").add(TeamApi).middleware(OrgAuth); | |
| @@ -0,0 +1,309 @@ | ||
| 1 | +import { describe, it, expect } from "@effect/vitest"; | |
| 2 | +import { Effect, Layer } from "effect"; | |
| 3 | + | |
| 4 | +import { AuthContext } from "../auth/middleware"; | |
| 5 | +import { WorkOSAuth } from "../auth/workos"; | |
| 6 | +import { Forbidden } from "./api"; | |
| 7 | + | |
| 8 | +// --------------------------------------------------------------------------- | |
| 9 | +// Stub factory — only implement what each test calls | |
| 10 | +// --------------------------------------------------------------------------- | |
| 11 | + | |
| 12 | +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test stub needs wide function types | |
| 13 | +type StubFn = (...args: never[]) => Effect.Effect<any>; | |
| 14 | + | |
| 15 | +type StubOverrides = { | |
| 16 | + listOrgMembers?: StubFn; | |
| 17 | + getUser?: StubFn; | |
| 18 | + sendInvitation?: StubFn; | |
| 19 | + deleteOrgMembership?: StubFn; | |
| 20 | + updateOrgMembershipRole?: StubFn; | |
| 21 | + listOrgRoles?: StubFn; | |
| 22 | +}; | |
| 23 | + | |
| 24 | +const stubWorkOS = (overrides: StubOverrides = {}) => | |
| 25 | + Layer.succeed( | |
| 26 | + WorkOSAuth, | |
| 27 | + new Proxy({} as WorkOSAuth["Type"], { | |
| 28 | + get: (_target, prop) => { | |
| 29 | + if (prop in overrides) return (overrides as Record<string, unknown>)[prop as string]; | |
| 30 | + return () => { | |
| 31 | + throw new Error(`WorkOSAuth.${String(prop)} not stubbed`); | |
| 32 | + }; | |
| 33 | + }, | |
| 34 | + }), | |
| 35 | + ); | |
| 36 | + | |
| 37 | +// --------------------------------------------------------------------------- | |
| 38 | +// Fixtures | |
| 39 | +// --------------------------------------------------------------------------- | |
| 40 | + | |
| 41 | +const adminAuth = { | |
| 42 | + accountId: "user_admin", | |
| 43 | + organizationId: "org_1", | |
| 44 | + email: "admin@test.com", | |
| 45 | + name: "Admin", | |
| 46 | + avatarUrl: null, | |
| 47 | +}; | |
| 48 | + | |
| 49 | +const memberAuth = { | |
| 50 | + accountId: "user_member", | |
| 51 | + organizationId: "org_1", | |
| 52 | + email: "member@test.com", | |
| 53 | + name: "Member", | |
| 54 | + avatarUrl: null, | |
| 55 | +}; | |
| 56 | + | |
| 57 | +type FakeMembership = { | |
| 58 | + id: string; | |
| 59 | + userId: string; | |
| 60 | + status: string; | |
| 61 | + role: { slug: string }; | |
| 62 | +}; | |
| 63 | +type FakeUser = { | |
| 64 | + email: string; | |
| 65 | + firstName: string | null; | |
| 66 | + lastName: string | null; | |
| 67 | + profilePictureUrl: string | null; | |
| 68 | + lastSignInAt: string | null; | |
| 69 | +}; | |
| 70 | +type FakeRole = { slug: string; name: string }; | |
| 71 | + | |
| 72 | +const fakeMemberships: FakeMembership[] = [ | |
| 73 | + { | |
| 74 | + id: "mem_admin", | |
| 75 | + userId: "user_admin", | |
| 76 | + status: "active", | |
| 77 | + role: { slug: "admin" }, | |
| 78 | + }, | |
| 79 | + { | |
| 80 | + id: "mem_member", | |
| 81 | + userId: "user_member", | |
| 82 | + status: "active", | |
| 83 | + role: { slug: "member" }, | |
| 84 | + }, | |
| 85 | +]; | |
| 86 | + | |
| 87 | +const fakeUsers: Record<string, FakeUser> = { | |
| 88 | + user_admin: { | |
| 89 | + email: "admin@test.com", | |
| 90 | + firstName: "Admin", | |
| 91 | + lastName: null, | |
| 92 | + profilePictureUrl: null, | |
| 93 | + lastSignInAt: "2026-04-09T00:00:00Z", | |
| 94 | + }, | |
| 95 | + user_member: { | |
| 96 | + email: "member@test.com", | |
| 97 | + firstName: "Member", | |
| 98 | + lastName: null, | |
| 99 | + profilePictureUrl: null, | |
| 100 | + lastSignInAt: null, | |
| 101 | + }, | |
| 102 | +}; | |
| 103 | + | |
| 104 | +const fakeRoles: FakeRole[] = [ | |
| 105 | + { slug: "admin", name: "Admin" }, | |
| 106 | + { slug: "member", name: "Member" }, | |
| 107 | +]; | |
| 108 | + | |
| 109 | +// --------------------------------------------------------------------------- | |
| 110 | +// The admin guard — mirrors handlers.ts | |
| 111 | +// --------------------------------------------------------------------------- | |
| 112 | + | |
| 113 | +const requireAdmin = Effect.gen(function* () { | |
| 114 | + const auth = yield* AuthContext; | |
| 115 | + const workos = yield* WorkOSAuth; | |
| 116 | + const memberships = yield* workos.listOrgMembers(auth.organizationId); | |
| 117 | + const current = memberships.data.find((m: FakeMembership) => m.userId === auth.accountId); | |
| 118 | + if (!current || current.role?.slug !== "admin") { | |
| 119 | + return yield* new Forbidden(); | |
| 120 | + } | |
| 121 | +}); | |
| 122 | + | |
| 123 | +const provide = (auth: typeof adminAuth, workosOverrides: StubOverrides = {}) => | |
| 124 | + Layer.mergeAll(Layer.succeed(AuthContext, auth), stubWorkOS(workosOverrides)); | |
| 125 | + | |
| 126 | +const withMembers: StubOverrides = { | |
| 127 | + listOrgMembers: () => Effect.succeed({ data: fakeMemberships }), | |
| 128 | +}; | |
| 129 | + | |
| 130 | +// --------------------------------------------------------------------------- | |
| 131 | +// Tests | |
| 132 | +// --------------------------------------------------------------------------- | |
| 133 | + | |
| 134 | +describe("Team handlers", () => { | |
| 135 | + describe("listMembers", () => { | |
| 136 | + it.effect("returns members with isCurrentUser set correctly", () => | |
| 137 | + Effect.gen(function* () { | |
| 138 | + const auth = yield* AuthContext; | |
| 139 | + const workos = yield* WorkOSAuth; | |
| 140 | + const result = yield* workos.listOrgMembers(auth.organizationId); | |
| 141 | + const members = yield* Effect.all( | |
| 142 | + result.data.map((m: FakeMembership) => | |
| 143 | + Effect.gen(function* () { | |
| 144 | + const user = yield* workos.getUser(m.userId); | |
| 145 | + return { | |
| 146 | + id: m.id, | |
| 147 | + email: user.email, | |
| 148 | + role: m.role?.slug ?? "member", | |
| 149 | + isCurrentUser: m.userId === auth.accountId, | |
| 150 | + }; | |
| 151 | + }), | |
| 152 | + ), | |
| 153 | + ); | |
| 154 | + | |
| 155 | + expect(members).toHaveLength(2); | |
| 156 | + expect(members[0]).toMatchObject({ | |
| 157 | + email: "admin@test.com", | |
| 158 | + isCurrentUser: true, | |
| 159 | + }); | |
| 160 | + expect(members[1]).toMatchObject({ | |
| 161 | + email: "member@test.com", | |
| 162 | + isCurrentUser: false, | |
| 163 | + }); | |
| 164 | + }).pipe( | |
| 165 | + Effect.provide( | |
| 166 | + provide(adminAuth, { | |
| 167 | + ...withMembers, | |
| 168 | + getUser: (id: string) => Effect.succeed(fakeUsers[id]), | |
| 169 | + }), | |
| 170 | + ), | |
| 171 | + ), | |
| 172 | + ); | |
| 173 | + }); | |
| 174 | + | |
| 175 | + describe("listRoles", () => { | |
| 176 | + it.effect("returns available roles", () => | |
| 177 | + Effect.gen(function* () { | |
| 178 | + const auth = yield* AuthContext; | |
| 179 | + const workos = yield* WorkOSAuth; | |
| 180 | + const result = yield* workos.listOrgRoles(auth.organizationId); | |
| 181 | + const roles = result.data.map((r: FakeRole) => ({ | |
| 182 | + slug: r.slug, | |
| 183 | + name: r.name, | |
| 184 | + })); | |
| 185 | + | |
| 186 | + expect(roles).toEqual(fakeRoles); | |
| 187 | + }).pipe( | |
| 188 | + Effect.provide( | |
| 189 | + provide(adminAuth, { | |
| 190 | + listOrgRoles: () => Effect.succeed({ data: fakeRoles }), | |
| 191 | + }), | |
| 192 | + ), | |
| 193 | + ), | |
| 194 | + ); | |
| 195 | + }); | |
| 196 | + | |
| 197 | + describe("requireAdmin", () => { | |
| 198 | + it.effect("passes for admin user", () => | |
| 199 | + requireAdmin.pipe(Effect.provide(provide(adminAuth, withMembers))), | |
| 200 | + ); | |
| 201 | + | |
| 202 | + it.effect("rejects non-admin with Forbidden", () => | |
| 203 | + Effect.gen(function* () { | |
| 204 | + const error = yield* Effect.flip(requireAdmin); | |
| 205 | + expect(error).toBeInstanceOf(Forbidden); | |
| 206 | + }).pipe(Effect.provide(provide(memberAuth, withMembers))), | |
| 207 | + ); | |
| 208 | + }); | |
| 209 | + | |
| 210 | + describe("invite (admin-gated)", () => { | |
| 211 | + it.effect("admin can invite", () => | |
| 212 | + Effect.gen(function* () { | |
| 213 | + yield* requireAdmin; | |
| 214 | + const auth = yield* AuthContext; | |
| 215 | + const workos = yield* WorkOSAuth; | |
| 216 | + const result = yield* workos.sendInvitation({ | |
| 217 | + email: "new@test.com", | |
| 218 | + organizationId: auth.organizationId, | |
| 219 | + }); | |
| 220 | + | |
| 221 | + expect(result.email).toBe("new@test.com"); | |
| 222 | + }).pipe( | |
| 223 | + Effect.provide( | |
| 224 | + provide(adminAuth, { | |
| 225 | + ...withMembers, | |
| 226 | + sendInvitation: (p: { email: string }) => | |
| 227 | + Effect.succeed({ id: "inv_1", email: p.email }), | |
| 228 | + }), | |
| 229 | + ), | |
| 230 | + ), | |
| 231 | + ); | |
| 232 | + | |
| 233 | + it.effect("member cannot invite", () => | |
| 234 | + Effect.gen(function* () { | |
| 235 | + const error = yield* Effect.flip( | |
| 236 | + Effect.gen(function* () { | |
| 237 | + yield* requireAdmin; | |
| 238 | + const workos = yield* WorkOSAuth; | |
| 239 | + yield* workos.sendInvitation({ | |
| 240 | + email: "x", | |
| 241 | + organizationId: "org_1", | |
| 242 | + }); | |
| 243 | + }), | |
| 244 | + ); | |
| 245 | + expect(error).toBeInstanceOf(Forbidden); | |
| 246 | + }).pipe(Effect.provide(provide(memberAuth, withMembers))), | |
| 247 | + ); | |
| 248 | + }); | |
| 249 | + | |
| 250 | + describe("removeMember (admin-gated)", () => { | |
| 251 | + it.effect("admin can remove", () => | |
| 252 | + Effect.gen(function* () { | |
| 253 | + yield* requireAdmin; | |
| 254 | + const workos = yield* WorkOSAuth; | |
| 255 | + yield* workos.deleteOrgMembership("mem_member"); | |
| 256 | + }).pipe( | |
| 257 | + Effect.provide( | |
| 258 | + provide(adminAuth, { | |
| 259 | + ...withMembers, | |
| 260 | + deleteOrgMembership: () => Effect.void, | |
| 261 | + }), | |
| 262 | + ), | |
| 263 | + ), | |
| 264 | + ); | |
| 265 | + | |
| 266 | + it.effect("member cannot remove", () => | |
| 267 | + Effect.gen(function* () { | |
| 268 | + const error = yield* Effect.flip( | |
| 269 | + Effect.gen(function* () { | |
| 270 | + yield* requireAdmin; | |
| 271 | + const workos = yield* WorkOSAuth; | |
| 272 | + yield* workos.deleteOrgMembership("mem_admin"); | |
| 273 | + }), | |
| 274 | + ); | |
| 275 | + expect(error).toBeInstanceOf(Forbidden); | |
| 276 | + }).pipe(Effect.provide(provide(memberAuth, withMembers))), | |
| 277 | + ); | |
| 278 | + }); | |
| 279 | + | |
| 280 | + describe("updateMemberRole (admin-gated)", () => { | |
| 281 | + it.effect("admin can change role", () => | |
| 282 | + Effect.gen(function* () { | |
| 283 | + yield* requireAdmin; | |
| 284 | + const workos = yield* WorkOSAuth; | |
| 285 | + yield* workos.updateOrgMembershipRole("mem_member", "admin"); | |
| 286 | + }).pipe( | |
| 287 | + Effect.provide( | |
| 288 | + provide(adminAuth, { | |
| 289 | + ...withMembers, | |
| 290 | + updateOrgMembershipRole: () => Effect.void, | |
| 291 | + }), | |
| 292 | + ), | |
| 293 | + ), | |
| 294 | + ); | |
| 295 | + | |
| 296 | + it.effect("member cannot change role", () => | |
| 297 | + Effect.gen(function* () { | |
| 298 | + const error = yield* Effect.flip( | |
| 299 | + Effect.gen(function* () { | |
| 300 | + yield* requireAdmin; | |
| 301 | + const workos = yield* WorkOSAuth; | |
| 302 | + yield* workos.updateOrgMembershipRole("mem_admin", "member"); | |
| 303 | + }), | |
| 304 | + ); | |
| 305 | + expect(error).toBeInstanceOf(Forbidden); | |
| 306 | + }).pipe(Effect.provide(provide(memberAuth, withMembers))), | |
| 307 | + ); | |
| 308 | + }); | |
| 309 | +}); | |
| @@ -0,0 +1,97 @@ | ||
| 1 | +import { HttpApiBuilder } from "@effect/platform"; | |
| 2 | +import { Effect } from "effect"; | |
| 3 | + | |
| 4 | +import { AuthContext } from "../auth/middleware"; | |
| 5 | +import { WorkOSAuth } from "../auth/workos"; | |
| 6 | +import { TeamOrgApi } from "./compose"; | |
| 7 | +import { Forbidden } from "./api"; | |
| 8 | + | |
| 9 | +const requireAdmin = Effect.gen(function* () { | |
| 10 | + const auth = yield* AuthContext; | |
| 11 | + const workos = yield* WorkOSAuth; | |
| 12 | + const memberships = yield* workos.listOrgMembers(auth.organizationId); | |
| 13 | + const currentMembership = memberships.data.find((m) => m.userId === auth.accountId); | |
| 14 | + if (!currentMembership || currentMembership.role?.slug !== "admin") { | |
| 15 | + return yield* new Forbidden(); | |
| 16 | + } | |
| 17 | +}); | |
| 18 | + | |
| 19 | +export const TeamHandlers = HttpApiBuilder.group(TeamOrgApi, "team", (handlers) => | |
| 20 | + handlers | |
| 21 | + .handle("listMembers", () => | |
| 22 | + Effect.gen(function* () { | |
| 23 | + const auth = yield* AuthContext; | |
| 24 | + const workos = yield* WorkOSAuth; | |
| 25 | + | |
| 26 | + const memberships = yield* workos.listOrgMembers(auth.organizationId); | |
| 27 | + | |
| 28 | + const members = yield* Effect.all( | |
| 29 | + memberships.data.map((m) => | |
| 30 | + Effect.gen(function* () { | |
| 31 | + const user = yield* workos.getUser(m.userId); | |
| 32 | + return { | |
| 33 | + id: m.id, | |
| 34 | + userId: m.userId, | |
| 35 | + email: user.email, | |
| 36 | + name: [user.firstName, user.lastName].filter(Boolean).join(" ") || null, | |
| 37 | + avatarUrl: user.profilePictureUrl ?? null, | |
| 38 | + role: m.role?.slug ?? "member", | |
| 39 | + status: m.status, | |
| 40 | + lastActiveAt: user.lastSignInAt ?? null, | |
| 41 | + isCurrentUser: m.userId === auth.accountId, | |
| 42 | + }; | |
| 43 | + }), | |
| 44 | + ), | |
| 45 | + { concurrency: 5 }, | |
| 46 | + ); | |
| 47 | + | |
| 48 | + return { members }; | |
| 49 | + }), | |
| 50 | + ) | |
| 51 | + .handle("listRoles", () => | |
| 52 | + Effect.gen(function* () { | |
| 53 | + const auth = yield* AuthContext; | |
| 54 | + const workos = yield* WorkOSAuth; | |
| 55 | + | |
| 56 | + const result = yield* workos.listOrgRoles(auth.organizationId); | |
| 57 | + | |
| 58 | + return { | |
| 59 | + roles: result.data.map((r) => ({ | |
| 60 | + slug: r.slug, | |
| 61 | + name: r.name, | |
| 62 | + })), | |
| 63 | + }; | |
| 64 | + }), | |
| 65 | + ) | |
| 66 | + .handle("invite", ({ payload }) => | |
| 67 | + Effect.gen(function* () { | |
| 68 | + yield* requireAdmin; | |
| 69 | + const auth = yield* AuthContext; | |
| 70 | + const workos = yield* WorkOSAuth; | |
| 71 | + | |
| 72 | + const invitation = yield* workos.sendInvitation({ | |
| 73 | + email: payload.email, | |
| 74 | + organizationId: auth.organizationId, | |
| 75 | + roleSlug: payload.roleSlug, | |
| 76 | + }); | |
| 77 | + | |
| 78 | + return { id: invitation.id, email: invitation.email }; | |
| 79 | + }), | |
| 80 | + ) | |
| 81 | + .handle("removeMember", ({ path }) => | |
| 82 | + Effect.gen(function* () { | |
| 83 | + yield* requireAdmin; | |
| 84 | + const workos = yield* WorkOSAuth; | |
| 85 | + yield* workos.deleteOrgMembership(path.membershipId); | |
| 86 | + return { success: true }; | |
| 87 | + }), | |
| 88 | + ) | |
| 89 | + .handle("updateMemberRole", ({ path, payload }) => | |
| 90 | + Effect.gen(function* () { | |
| 91 | + yield* requireAdmin; | |
| 92 | + const workos = yield* WorkOSAuth; | |
| 93 | + yield* workos.updateOrgMembershipRole(path.membershipId, payload.roleSlug); | |
| 94 | + return { success: true }; | |
| 95 | + }), | |
| 96 | + ), | |
| 97 | +); | |
| @@ -3,12 +3,13 @@ import { FetchHttpClient } from "@effect/platform"; · 2 unmodified lines | ||
| 3 | 3 | import { addGroup } from "@executor/api"; |
| 4 | 4 | import { getBaseUrl } from "@executor/react/api/base-url"; |
| 5 | 5 | import { CloudAuthApi } from "../auth/api"; |
| 6 | +import { TeamApi } from "../team/api"; | |
| 6 | 7 | |
| 7 | 8 | // --------------------------------------------------------------------------- |
| 8 | −// Cloud API client — core API + cloud auth | |
| 9 | +// Cloud API client — core API + cloud auth + team | |
| 9 | 10 | // --------------------------------------------------------------------------- |
| 10 | 11 | |
| 11 | −const CloudApi = addGroup(CloudAuthApi); | |
| 12 | +const CloudApi = addGroup(CloudAuthApi).add(TeamApi); | |
| 12 | 13 | |
| 13 | 14 | class CloudApiClient extends AtomHttpApi.Tag<CloudApiClient>()("CloudApiClient", { |
| 14 | 15 | api: CloudApi, |
| @@ -138,6 +138,7 @@ function SidebarContent(props: { pathname: string; onNavigate?: () => void; show · 137 unmodified lines | ||
| 138 | 138 | const isHome = props.pathname === "/"; |
| 139 | 139 | const isSecrets = props.pathname === "/secrets"; |
| 140 | 140 | const isBilling = props.pathname === "/billing" || props.pathname.startsWith("/billing/"); |
| 141 | + const isTeam = props.pathname === "/team"; | |
| 141 | 142 | |
| 142 | 143 | return ( |
| 143 | 144 | <> |
| @@ -152,6 +153,7 @@ function SidebarContent(props: { pathname: string; onNavigate?: () => void; show · 8 unmodified lines | ||
| 152 | 153 | <nav className="flex flex-1 flex-col overflow-y-auto p-2"> |
| 153 | 154 | <NavItem to="/" label="Sources" active={isHome} onNavigate={props.onNavigate} /> |
| 154 | 155 | <NavItem to="/secrets" label="Secrets" active={isSecrets} onNavigate={props.onNavigate} /> |
| 156 | + <NavItem to="/team" label="Team" active={isTeam} onNavigate={props.onNavigate} /> | |
| 155 | 157 | <NavItem to="/billing" label="Billing" active={isBilling} onNavigate={props.onNavigate} /> |
| 156 | 158 | |
| 157 | 159 | <div className="mt-5 mb-1 px-2.5 text-[10px] font-medium uppercase tracking-widest text-muted-foreground/50"> |
| @@ -0,0 +1,15 @@ | ||
| 1 | +import { CloudApiClient } from "./client"; | |
| 2 | + | |
| 3 | +export const teamMembersAtom = CloudApiClient.query("team", "listMembers", { | |
| 4 | + timeToLive: "30 seconds", | |
| 5 | +}); | |
| 6 | + | |
| 7 | +export const teamRolesAtom = CloudApiClient.query("team", "listRoles", { | |
| 8 | + timeToLive: "5 minutes", | |
| 9 | +}); | |
| 10 | + | |
| 11 | +export const inviteMember = CloudApiClient.mutation("team", "invite"); | |
| 12 | + | |
| 13 | +export const removeMember = CloudApiClient.mutation("team", "removeMember"); | |
| 14 | + | |
| 15 | +export const updateMemberRole = CloudApiClient.mutation("team", "updateMemberRole"); | |