* 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 (15 of 19 files). Show all
Unified · Split
| @@ -46,7 +46,8 @@ · 45 unmodified lines | |||
| 46 | "jose": "^5.6.3", | 46 | "jose": "^5.6.3", |
| 47 | "postgres": "^3.4.9", | 47 | "postgres": "^3.4.9", |
| 48 | "react": "catalog:", | 48 | "react": "catalog:", |
| 49 | "react-dom": "catalog:" | 49 | "react-dom": "catalog:", |
| 50 | "sonner": "^2.0.7" | ||
| 50 | }, | 51 | }, |
| 51 | "devDependencies": { | 52 | "devDependencies": { |
| 52 | "@cloudflare/vitest-pool-workers": "^0.14.3", | 53 | "@cloudflare/vitest-pool-workers": "^0.14.3", |
| @@ -46,6 +46,8 @@ import { · 45 unmodified lines | |||
| 46 | import { WorkOSAuth } from "./auth/workos"; | 46 | import { WorkOSAuth } from "./auth/workos"; |
| 47 | import { DbService } from "./services/db"; | 47 | import { DbService } from "./services/db"; |
| 48 | import { createOrgExecutor } from "./services/executor"; | 48 | import { createOrgExecutor } from "./services/executor"; |
| 49 | import { TeamOrgApi } from "./team/compose"; | ||
| 50 | import { TeamHandlers } from "./team/handlers"; | ||
| 49 | import { server } from "./env"; | 51 | import { server } from "./env"; |
| 50 | 52 | ||
| 51 | // --------------------------------------------------------------------------- | 53 | // --------------------------------------------------------------------------- |
| @@ -90,6 +92,11 @@ const NonProtectedApiLive = HttpApiBuilder.api(NonProtectedApi).pipe( · 38 unmodified lines | |||
| 90 | Layer.provideMerge(SessionAuthLive), | 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 | // Public auth web handler | 101 | // Public auth web handler |
| 95 | // --------------------------------------------------------------------------- | 102 | // --------------------------------------------------------------------------- |
| @@ -113,6 +120,12 @@ const createNonProtectedHandler = () => · 17 unmodified lines | |||
| 113 | { middleware: HttpMiddleware.logger }, | 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 | // Protected handler — must be built per-request because the executor varies | 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 | const isAuthPath = (pathname: string): boolean => pathname.startsWith("/auth/"); | 172 | const isAuthPath = (pathname: string): boolean => pathname.startsWith("/auth/"); |
| 160 | const isAutumnPath = (pathname: string): boolean => pathname.startsWith("/autumn/"); | 173 | const isAutumnPath = (pathname: string): boolean => pathname.startsWith("/autumn/"); |
| 174 | const isTeamPath = (pathname: string): boolean => pathname.startsWith("/team/"); | ||
| 161 | const isExecutionPath = (pathname: string): boolean => | 175 | const isExecutionPath = (pathname: string): boolean => |
| 162 | pathname === "/executions" || /^\/executions\/[^/]+\/resume$/.test(pathname); | 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 | export const handleApiRequest = async (request: Request): Promise<Response> => { | 262 | export const handleApiRequest = async (request: Request): Promise<Response> => { |
| 245 | const pathname = new URL(request.url).pathname; | 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 | if (isAutumnPath(pathname)) { | 274 | if (isAutumnPath(pathname)) { |
| 248 | return handleAutumnRequest(request); | 275 | return handleAutumnRequest(request); |
| 249 | } | 276 | } |
| @@ -138,10 +138,48 @@ const make = Effect.gen(function* () { · 137 unmodified lines | |||
| 138 | if (!sessionData) return null; | 138 | if (!sessionData) return null; |
| 139 | return yield* authenticateSealedSession(sessionData); | 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 | export class WorkOSAuth extends Context.Tag("@executor/cloud/WorkOSAuth")< | 184 | export class WorkOSAuth extends Context.Tag("@executor/cloud/WorkOSAuth")< |
| 147 | WorkOSAuth, | 185 | WorkOSAuth, |
| @@ -10,6 +10,7 @@ · 9 unmodified lines | |||
| 10 | 10 | ||
| 11 | import { Route as rootRouteImport } from "./routes/__root"; | 11 | import { Route as rootRouteImport } from "./routes/__root"; |
| 12 | import { Route as ToolsRouteImport } from "./routes/tools"; | 12 | import { Route as ToolsRouteImport } from "./routes/tools"; |
| 13 | import { Route as TeamRouteImport } from "./routes/team"; | ||
| 13 | import { Route as SecretsRouteImport } from "./routes/secrets"; | 14 | import { Route as SecretsRouteImport } from "./routes/secrets"; |
| 14 | import { Route as BillingRouteImport } from "./routes/billing"; | 15 | import { Route as BillingRouteImport } from "./routes/billing"; |
| 15 | import { Route as IndexRouteImport } from "./routes/index"; | 16 | import { Route as IndexRouteImport } from "./routes/index"; |
| @@ -22,6 +23,11 @@ const ToolsRoute = ToolsRouteImport.update({ · 6 unmodified lines | |||
| 22 | path: "/tools", | 23 | path: "/tools", |
| 23 | getParentRoute: () => rootRouteImport, | 24 | getParentRoute: () => rootRouteImport, |
| 24 | } as any); | 25 | } as any); |
| 26 | const TeamRoute = TeamRouteImport.update({ | ||
| 27 | id: "/team", | ||
| 28 | path: "/team", | ||
| 29 | getParentRoute: () => rootRouteImport, | ||
| 30 | } as any); | ||
| 25 | const SecretsRoute = SecretsRouteImport.update({ | 31 | const SecretsRoute = SecretsRouteImport.update({ |
| 26 | id: "/secrets", | 32 | id: "/secrets", |
| 27 | path: "/secrets", | 33 | path: "/secrets", |
| @@ -57,6 +63,7 @@ export interface FileRoutesByFullPath { · 29 unmodified lines | |||
| 57 | "/": typeof IndexRoute; | 63 | "/": typeof IndexRoute; |
| 58 | "/billing": typeof BillingRoute; | 64 | "/billing": typeof BillingRoute; |
| 59 | "/secrets": typeof SecretsRoute; | 65 | "/secrets": typeof SecretsRoute; |
| 66 | "/team": typeof TeamRoute; | ||
| 60 | "/tools": typeof ToolsRoute; | 67 | "/tools": typeof ToolsRoute; |
| 61 | "/billing/plans": typeof BillingPlansRoute; | 68 | "/billing/plans": typeof BillingPlansRoute; |
| 62 | "/sources/$namespace": typeof SourcesNamespaceRoute; | 69 | "/sources/$namespace": typeof SourcesNamespaceRoute; |
| @@ -66,6 +73,7 @@ export interface FileRoutesByTo { · 3 unmodified lines | |||
| 66 | "/": typeof IndexRoute; | 73 | "/": typeof IndexRoute; |
| 67 | "/billing": typeof BillingRoute; | 74 | "/billing": typeof BillingRoute; |
| 68 | "/secrets": typeof SecretsRoute; | 75 | "/secrets": typeof SecretsRoute; |
| 76 | "/team": typeof TeamRoute; | ||
| 69 | "/tools": typeof ToolsRoute; | 77 | "/tools": typeof ToolsRoute; |
| 70 | "/billing/plans": typeof BillingPlansRoute; | 78 | "/billing/plans": typeof BillingPlansRoute; |
| 71 | "/sources/$namespace": typeof SourcesNamespaceRoute; | 79 | "/sources/$namespace": typeof SourcesNamespaceRoute; |
| @@ -76,6 +84,7 @@ export interface FileRoutesById { · 4 unmodified lines | |||
| 76 | "/": typeof IndexRoute; | 84 | "/": typeof IndexRoute; |
| 77 | "/billing": typeof BillingRoute; | 85 | "/billing": typeof BillingRoute; |
| 78 | "/secrets": typeof SecretsRoute; | 86 | "/secrets": typeof SecretsRoute; |
| 87 | "/team": typeof TeamRoute; | ||
| 79 | "/tools": typeof ToolsRoute; | 88 | "/tools": typeof ToolsRoute; |
| 80 | "/billing_/plans": typeof BillingPlansRoute; | 89 | "/billing_/plans": typeof BillingPlansRoute; |
| 81 | "/sources/$namespace": typeof SourcesNamespaceRoute; | 90 | "/sources/$namespace": typeof SourcesNamespaceRoute; |
| @@ -87,6 +96,7 @@ export interface FileRouteTypes { · 5 unmodified lines | |||
| 87 | | "/" | 96 | | "/" |
| 88 | | "/billing" | 97 | | "/billing" |
| 89 | | "/secrets" | 98 | | "/secrets" |
| 99 | | "/team" | ||
| 90 | | "/tools" | 100 | | "/tools" |
| 91 | | "/billing/plans" | 101 | | "/billing/plans" |
| 92 | | "/sources/$namespace" | 102 | | "/sources/$namespace" |
| @@ -96,6 +106,7 @@ export interface FileRouteTypes { · 3 unmodified lines | |||
| 96 | | "/" | 106 | | "/" |
| 97 | | "/billing" | 107 | | "/billing" |
| 98 | | "/secrets" | 108 | | "/secrets" |
| 109 | | "/team" | ||
| 99 | | "/tools" | 110 | | "/tools" |
| 100 | | "/billing/plans" | 111 | | "/billing/plans" |
| 101 | | "/sources/$namespace" | 112 | | "/sources/$namespace" |
| @@ -105,6 +116,7 @@ export interface FileRouteTypes { · 3 unmodified lines | |||
| 105 | | "/" | 116 | | "/" |
| 106 | | "/billing" | 117 | | "/billing" |
| 107 | | "/secrets" | 118 | | "/secrets" |
| 119 | | "/team" | ||
| 108 | | "/tools" | 120 | | "/tools" |
| 109 | | "/billing_/plans" | 121 | | "/billing_/plans" |
| 110 | | "/sources/$namespace" | 122 | | "/sources/$namespace" |
| @@ -115,6 +127,7 @@ export interface RootRouteChildren { · 4 unmodified lines | |||
| 115 | IndexRoute: typeof IndexRoute; | 127 | IndexRoute: typeof IndexRoute; |
| 116 | BillingRoute: typeof BillingRoute; | 128 | BillingRoute: typeof BillingRoute; |
| 117 | SecretsRoute: typeof SecretsRoute; | 129 | SecretsRoute: typeof SecretsRoute; |
| 130 | TeamRoute: typeof TeamRoute; | ||
| 118 | ToolsRoute: typeof ToolsRoute; | 131 | ToolsRoute: typeof ToolsRoute; |
| 119 | BillingPlansRoute: typeof BillingPlansRoute; | 132 | BillingPlansRoute: typeof BillingPlansRoute; |
| 120 | SourcesNamespaceRoute: typeof SourcesNamespaceRoute; | 133 | SourcesNamespaceRoute: typeof SourcesNamespaceRoute; |
| @@ -130,6 +143,13 @@ declare module "@tanstack/react-router" { · 9 unmodified lines | |||
| 130 | preLoaderRoute: typeof ToolsRouteImport; | 143 | preLoaderRoute: typeof ToolsRouteImport; |
| 131 | parentRoute: typeof rootRouteImport; | 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 | "/secrets": { | 153 | "/secrets": { |
| 134 | id: "/secrets"; | 154 | id: "/secrets"; |
| 135 | path: "/secrets"; | 155 | path: "/secrets"; |
| @@ -179,6 +199,7 @@ const rootRouteChildren: RootRouteChildren = { · 43 unmodified lines | |||
| 179 | IndexRoute: IndexRoute, | 199 | IndexRoute: IndexRoute, |
| 180 | BillingRoute: BillingRoute, | 200 | BillingRoute: BillingRoute, |
| 181 | SecretsRoute: SecretsRoute, | 201 | SecretsRoute: SecretsRoute, |
| 202 | TeamRoute: TeamRoute, | ||
| 182 | ToolsRoute: ToolsRoute, | 203 | ToolsRoute: ToolsRoute, |
| 183 | BillingPlansRoute: BillingPlansRoute, | 204 | BillingPlansRoute: BillingPlansRoute, |
| 184 | SourcesNamespaceRoute: SourcesNamespaceRoute, | 205 | SourcesNamespaceRoute: SourcesNamespaceRoute, |
| @@ -2,6 +2,7 @@ import React from "react"; · 1 unmodified line | |||
| 2 | import { HeadContent, Scripts, createRootRoute } from "@tanstack/react-router"; | 2 | import { HeadContent, Scripts, createRootRoute } from "@tanstack/react-router"; |
| 3 | import { AutumnProvider } from "autumn-js/react"; | 3 | import { AutumnProvider } from "autumn-js/react"; |
| 4 | import { ExecutorProvider } from "@executor/react/api/provider"; | 4 | import { ExecutorProvider } from "@executor/react/api/provider"; |
| 5 | import { Toaster } from "@executor/react/components/sonner"; | ||
| 5 | import { AuthProvider, useAuth } from "../web/auth"; | 6 | import { AuthProvider, useAuth } from "../web/auth"; |
| 6 | import { LoginPage } from "../web/pages/login"; | 7 | import { LoginPage } from "../web/pages/login"; |
| 7 | import { Shell } from "../web/shell"; | 8 | import { Shell } from "../web/shell"; |
| @@ -70,6 +71,7 @@ function AuthGate() { · 62 unmodified lines | |||
| 70 | <AutumnProvider pathPrefix="/api/autumn"> | 71 | <AutumnProvider pathPrefix="/api/autumn"> |
| 71 | <ExecutorProvider> | 72 | <ExecutorProvider> |
| 72 | <Shell /> | 73 | <Shell /> |
| 74 | <Toaster /> | ||
| 73 | </ExecutorProvider> | 75 | </ExecutorProvider> |
| 74 | </AutumnProvider> | 76 | </AutumnProvider> |
| 75 | ); | 77 | ); |
| @@ -2,6 +2,8 @@ import { createFileRoute, Link } from "@tanstack/react-router"; · 1 unmodified line | |||
| 2 | import { useCustomer, useListPlans } from "autumn-js/react"; | 2 | import { useCustomer, useListPlans } from "autumn-js/react"; |
| 3 | import { Button } from "@executor/react/components/button"; | 3 | import { Button } from "@executor/react/components/button"; |
| 4 | 4 | ||
| 5 | type Plan = NonNullable<ReturnType<typeof useListPlans>["data"]>[number]; | ||
| 6 | |||
| 5 | export const Route = createFileRoute("/billing")({ | 7 | export const Route = createFileRoute("/billing")({ |
| 6 | component: BillingPage, | 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 | 34 | const allPlans: Plan[] = plans ?? []; |
| 33 | const activePlan = (plans ?? []).find( | 35 | const activePlan = allPlans.find( |
| 34 | (p) => p.customerEligibility?.status === "active" && p.id !== "free", | 36 | (p) => p.customerEligibility?.status === "active" && p.id !== "free", |
| 35 | ); | 37 | ); |
| 36 | const scheduledPlan = (plans ?? []).find( | 38 | const scheduledPlan = allPlans.find( |
| 37 | (p) => p.customerEligibility?.status === "scheduled" && p.id !== "free", | 39 | (p) => p.customerEligibility?.status === "scheduled" && p.id !== "free", |
| 38 | ); | 40 | ); |
| 39 | const isCanceling = activePlan?.customerEligibility?.canceling ?? false; | 41 | const isCanceling = activePlan?.customerEligibility?.canceling ?? false; |
| @@ -3,6 +3,8 @@ import { createFileRoute, Link } from "@tanstack/react-router"; · 2 unmodified lines | |||
| 3 | import { useCustomer, useListPlans } from "autumn-js/react"; | 3 | import { useCustomer, useListPlans } from "autumn-js/react"; |
| 4 | import { Button } from "@executor/react/components/button"; | 4 | import { Button } from "@executor/react/components/button"; |
| 5 | 5 | ||
| 6 | type Plan = NonNullable<ReturnType<typeof useListPlans>["data"]>[number]; | ||
| 7 | |||
| 6 | export const Route = createFileRoute("/billing_/plans")({ | 8 | export const Route = createFileRoute("/billing_/plans")({ |
| 7 | component: PlansPage, | 9 | component: PlansPage, |
| 8 | }); | 10 | }); |
| @@ -45,7 +47,9 @@ function PlansPage() { · 36 unmodified lines | |||
| 45 | 47 | ||
| 46 | const isLoading = customerLoading || plansLoading; | 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 | return ( | 54 | return ( |
| 51 | <div className="min-h-0 flex-1 overflow-y-auto"> | 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 | import { addGroup } from "@executor/api"; | 3 | import { addGroup } from "@executor/api"; |
| 4 | import { getBaseUrl } from "@executor/react/api/base-url"; | 4 | import { getBaseUrl } from "@executor/react/api/base-url"; |
| 5 | import { CloudAuthApi } from "../auth/api"; | 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 | class CloudApiClient extends AtomHttpApi.Tag<CloudApiClient>()("CloudApiClient", { | 14 | class CloudApiClient extends AtomHttpApi.Tag<CloudApiClient>()("CloudApiClient", { |
| 14 | api: CloudApi, | 15 | api: CloudApi, |
| @@ -138,6 +138,7 @@ function SidebarContent(props: { pathname: string; onNavigate?: () => void; show · 137 unmodified lines | |||
| 138 | const isHome = props.pathname === "/"; | 138 | const isHome = props.pathname === "/"; |
| 139 | const isSecrets = props.pathname === "/secrets"; | 139 | const isSecrets = props.pathname === "/secrets"; |
| 140 | const isBilling = props.pathname === "/billing" || props.pathname.startsWith("/billing/"); | 140 | const isBilling = props.pathname === "/billing" || props.pathname.startsWith("/billing/"); |
| 141 | const isTeam = props.pathname === "/team"; | ||
| 141 | 142 | ||
| 142 | return ( | 143 | return ( |
| 143 | <> | 144 | <> |
| @@ -152,6 +153,7 @@ function SidebarContent(props: { pathname: string; onNavigate?: () => void; show · 8 unmodified lines | |||
| 152 | <nav className="flex flex-1 flex-col overflow-y-auto p-2"> | 153 | <nav className="flex flex-1 flex-col overflow-y-auto p-2"> |
| 153 | <NavItem to="/" label="Sources" active={isHome} onNavigate={props.onNavigate} /> | 154 | <NavItem to="/" label="Sources" active={isHome} onNavigate={props.onNavigate} /> |
| 154 | <NavItem to="/secrets" label="Secrets" active={isSecrets} onNavigate={props.onNavigate} /> | 155 | <NavItem to="/secrets" label="Secrets" active={isSecrets} onNavigate={props.onNavigate} /> |
| 156 | <NavItem to="/team" label="Team" active={isTeam} onNavigate={props.onNavigate} /> | ||
| 155 | <NavItem to="/billing" label="Billing" active={isBilling} onNavigate={props.onNavigate} /> | 157 | <NavItem to="/billing" label="Billing" active={isBilling} onNavigate={props.onNavigate} /> |
| 156 | 158 | ||
| 157 | <div className="mt-5 mb-1 px-2.5 text-[10px] font-medium uppercase tracking-widest text-muted-foreground/50"> | 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"); | ||