From 122b72020ed3e8472d77883a0eac475dabcb3766 Mon Sep 17 00:00:00 2001 From: Alexander Belanger Date: Thu, 19 Mar 2026 10:11:35 -0400 Subject: [PATCH] feat: control plane phase 2 --- api/v1/server/authn/middleware.go | 53 +- api/v1/server/authz/middleware.go | 95 ++- api/v1/server/headers/middleware.go | 3 + api/v1/server/run/run.go | 16 + .../v1/cloud/logging/log-search/types.ts | 5 +- .../data-table/data-table-column-header.tsx | 6 +- .../time-picker/time-picker-input.tsx | 3 +- .../app/src/components/v1/nav/top-nav.tsx | 17 +- .../app/src/components/v1/shared/duration.tsx | 3 +- frontend/app/src/components/v1/ui/badge.tsx | 3 +- frontend/app/src/components/v1/ui/button.tsx | 3 +- frontend/app/src/components/v1/ui/card.tsx | 3 +- .../app/src/components/v1/ui/textarea.tsx | 3 +- frontend/app/src/hooks/use-breadcrumbs.ts | 3 +- .../src/{pages/auth => }/hooks/use-cloud.ts | 20 +- frontend/app/src/hooks/use-control-plane.ts | 39 + frontend/app/src/hooks/use-organizations.ts | 45 +- frontend/app/src/hooks/use-pending-invites.ts | 22 +- frontend/app/src/hooks/use-tenant.tsx | 39 +- frontend/app/src/lib/api/api.ts | 131 +++ .../app/src/lib/api/control-plane-status.ts | 37 + frontend/app/src/lib/api/exchange-token.ts | 188 +++++ .../lib/api/generated/cloud/data-contracts.ts | 4 - .../lib/api/generated/control-plane/Api.ts | 743 ++++++++++++++++++ .../generated/control-plane/data-contracts.ts | 270 +++++++ .../generated/control-plane/http-client.ts | 186 +++++ .../app/src/lib/api/organization-wrapper.ts | 132 ++++ frontend/app/src/lib/api/queries.ts | 66 +- frontend/app/src/lib/api/tenant-wrapper.ts | 81 ++ frontend/app/src/lib/api/user-wrapper.ts | 61 ++ frontend/app/src/pages/auth/login/index.tsx | 6 +- .../app/src/pages/auth/register/index.tsx | 6 +- frontend/app/src/pages/authenticated.tsx | 117 +-- .../error/components/tenant-forbidden.tsx | 5 +- frontend/app/src/pages/error/index.tsx | 9 +- .../main/info/components/version-info.tsx | 2 +- frontend/app/src/pages/main/v1/index.tsx | 23 +- .../managed-workers/$managed-worker/index.tsx | 2 +- .../main/v1/tenant-settings/github/index.tsx | 2 +- .../members/components/members-columns.tsx | 8 +- .../main/v1/tenant-settings/members/index.tsx | 25 +- .../tenant-settings/resource-limits/index.tsx | 2 +- .../pages/main/v1/workers/$worker/index.tsx | 2 +- .../main/v1/workflow-runs-v1/$run/index.tsx | 2 +- .../step-run-detail/step-run-detail.tsx | 2 +- .../hooks/use-is-task-run-skipped.ts | 2 +- .../hooks/use-workflow-details.tsx | 2 +- .../pages/main/v1/workflow-runs-v1/index.tsx | 7 + .../main/v1/workflows/$workflow/index.tsx | 4 +- .../components/tenant-create-form.tsx | 16 +- .../pages/onboarding/create-tenant/index.tsx | 49 +- .../src/pages/onboarding/invites/index.tsx | 66 +- .../pages/onboarding/verify-email/index.tsx | 2 +- .../components/invite-member-modal.tsx | 5 +- .../organizations/$organization/index.tsx | 11 +- frontend/app/src/providers/app-context.tsx | 62 +- frontend/app/src/router.tsx | 170 +++- pkg/auth/exchangetoken/token.go | 101 +++ pkg/config/loader/loader.go | 34 + pkg/config/server/server.go | 28 + pkg/encryption/local.go | 17 +- 61 files changed, 2786 insertions(+), 283 deletions(-) rename frontend/app/src/{pages/auth => }/hooks/use-cloud.ts (80%) create mode 100644 frontend/app/src/hooks/use-control-plane.ts create mode 100644 frontend/app/src/lib/api/control-plane-status.ts create mode 100644 frontend/app/src/lib/api/exchange-token.ts create mode 100644 frontend/app/src/lib/api/generated/control-plane/Api.ts create mode 100644 frontend/app/src/lib/api/generated/control-plane/data-contracts.ts create mode 100644 frontend/app/src/lib/api/generated/control-plane/http-client.ts create mode 100644 frontend/app/src/lib/api/organization-wrapper.ts create mode 100644 frontend/app/src/lib/api/tenant-wrapper.ts create mode 100644 frontend/app/src/lib/api/user-wrapper.ts create mode 100644 pkg/auth/exchangetoken/token.go diff --git a/api/v1/server/authn/middleware.go b/api/v1/server/authn/middleware.go index 611a64b9f2..bb9f992c28 100644 --- a/api/v1/server/authn/middleware.go +++ b/api/v1/server/authn/middleware.go @@ -208,7 +208,7 @@ func (a *AuthN) handleBearerAuth(c echo.Context) error { return fmt.Errorf("tenant not found in context") } - token, err := getBearerTokenFromRequest(c.Request()) + token, isExchangeToken, err := getBearerTokenFromRequest(c.Request()) if err != nil { a.l.Debug().Err(err).Msg("error getting bearer token from request") @@ -216,6 +216,46 @@ func (a *AuthN) handleBearerAuth(c echo.Context) error { return forbidden } + if isExchangeToken { + if a.config.Auth.ExchangeTokenClient == nil { + a.l.Error().Msgf("exchange token client is not configured") + + return forbidden + } + + tenantId, userId, validationErr := a.config.Auth.ExchangeTokenClient.ValidateExchangeToken(c.Request().Context(), token) + + if validationErr != nil { + a.l.Debug().Err(validationErr).Msg("error validating exchange token") + + return forbidden + } + + if tenantId != queriedTenant.ID { + a.l.Debug().Msgf("tenant id in token does not match tenant id in context") + + return forbidden + } + + user, getUserErr := a.config.V1.User().GetUserByID(c.Request().Context(), userId) + + if getUserErr != nil { + a.l.Debug().Err(getUserErr).Msg("error getting user by id from exchange token") + + if errors.Is(getUserErr, pgx.ErrNoRows) { + return forbidden + } + + return fmt.Errorf("error getting user by id from exchange token: %w", getUserErr) + } + + // important: user is validated later in the authz step + c.Set("user", user) + c.Set("is_exchange_token", true) + + return nil + } + // Validate the token. tenantId, _, err := a.config.Auth.JWTManager.ValidateTenantToken(c.Request().Context(), token) @@ -246,15 +286,20 @@ func (a *AuthN) handleCustomAuth(c echo.Context) error { var errInvalidAuthHeader = fmt.Errorf("invalid authorization header in request") -func getBearerTokenFromRequest(r *http.Request) (string, error) { +func getBearerTokenFromRequest(r *http.Request) (token string, isExchangeToken bool, err error) { reqToken := r.Header.Get("Authorization") splitToken := strings.Split(reqToken, "Bearer") if len(splitToken) != 2 { - return "", errInvalidAuthHeader + return "", false, errInvalidAuthHeader } reqToken = strings.TrimSpace(splitToken[1]) - return reqToken, nil + // if there's also an X-Exchange-Token header, then this is an exchange token request + if r.Header.Get("X-Exchange-Token") != "" { + return reqToken, true, nil + } + + return reqToken, false, nil } diff --git a/api/v1/server/authz/middleware.go b/api/v1/server/authz/middleware.go index 3bdc158a8f..ad87e37187 100644 --- a/api/v1/server/authz/middleware.go +++ b/api/v1/server/authz/middleware.go @@ -59,47 +59,13 @@ func (a *AuthZ) authorize(c echo.Context, r *middleware.RouteInfo) error { } func (a *AuthZ) handleCookieAuth(c echo.Context, r *middleware.RouteInfo) error { - unauthorized := echo.NewHTTPError(http.StatusUnauthorized, "Not authorized to view this resource") - if err := a.ensureVerifiedEmail(c, r); err != nil { a.l.Debug().Err(err).Msgf("error ensuring verified email") return echo.NewHTTPError(http.StatusUnauthorized, "Please verify your email before continuing") } - // if tenant is set in the context, verify that the user is a member of the tenant - if tenant, ok := c.Get("tenant").(*sqlcv1.Tenant); ok { - user, ok := c.Get("user").(*sqlcv1.User) - - if !ok { - a.l.Debug().Msgf("user not found in context") - - return unauthorized - } - - // check if the user is a member of the tenant - tenantMember, err := a.config.V1.Tenant().GetTenantMemberByUserID(c.Request().Context(), tenant.ID, user.ID) - - if err != nil { - a.l.Debug().Err(err).Msgf("error getting tenant member") - - return unauthorized - } - - if tenantMember == nil { - a.l.Debug().Msgf("user is not a member of the tenant") - - return unauthorized - } - - // set the tenant member in the context - c.Set("tenant-member", tenantMember) - - // authorize tenant operations - if err := a.authorizeTenantOperations(tenant, tenantMember, r); err != nil { - a.l.Debug().Err(err).Msgf("error authorizing tenant operations") - - return unauthorized - } + if err := a.validateUserTenantPermissions(c, r); err != nil { + return err } if a.config.Auth.CustomAuthenticator != nil { @@ -119,6 +85,19 @@ var restrictedWithBearerToken = []string{ // At the moment, there's no further bearer auth because bearer tokens are admin-scoped // and we check that the bearer token has access to the tenant in the authn step. func (a *AuthZ) handleBearerAuth(c echo.Context, r *middleware.RouteInfo) error { + // check for is_exchange_token set in the context, in which case we need to validate the user set in the context + if isExchangeToken, ok := c.Get("is_exchange_token").(bool); ok && isExchangeToken { + if err := a.ensureVerifiedEmail(c, r); err != nil { + a.l.Debug().Err(err).Msgf("error ensuring verified email for exchange token user") + return echo.NewHTTPError(http.StatusUnauthorized, "Please verify your email before continuing") + } + + if err := a.validateUserTenantPermissions(c, r); err != nil { + a.l.Debug().Err(err).Msgf("error validating user tenant permissions for exchange token user") + return echo.NewHTTPError(http.StatusUnauthorized, "Not authorized to view this resource") + } + } + if operationIn(r.OperationID, restrictedWithBearerToken) { return echo.NewHTTPError(http.StatusUnauthorized, "Not authorized to perform this operation") } @@ -143,7 +122,7 @@ func (a *AuthZ) ensureVerifiedEmail(c echo.Context, r *middleware.RouteInfo) err user, ok := c.Get("user").(*sqlcv1.User) if !ok { - return nil + return fmt.Errorf("user not found in context") } if operationIn(r.OperationID, permittedWithUnverifiedEmail) { @@ -157,6 +136,48 @@ func (a *AuthZ) ensureVerifiedEmail(c echo.Context, r *middleware.RouteInfo) err return nil } +func (a *AuthZ) validateUserTenantPermissions(c echo.Context, r *middleware.RouteInfo) error { + unauthorized := echo.NewHTTPError(http.StatusUnauthorized, "Not authorized to view this resource") + + // if tenant is set in the context, verify that the user is a member of the tenant + if tenant, ok := c.Get("tenant").(*sqlcv1.Tenant); ok { + user, ok := c.Get("user").(*sqlcv1.User) + + if !ok { + a.l.Debug().Msgf("user not found in context") + + return unauthorized + } + + // check if the user is a member of the tenant + tenantMember, err := a.config.V1.Tenant().GetTenantMemberByUserID(c.Request().Context(), tenant.ID, user.ID) + + if err != nil { + a.l.Debug().Err(err).Msgf("error getting tenant member") + + return unauthorized + } + + if tenantMember == nil { + a.l.Debug().Msgf("user is not a member of the tenant") + + return unauthorized + } + + // set the tenant member in the context + c.Set("tenant-member", tenantMember) + + // authorize tenant operations + if err := a.authorizeTenantOperations(tenant, tenantMember, r); err != nil { + a.l.Debug().Err(err).Msgf("error authorizing tenant operations") + + return unauthorized + } + } + + return nil +} + var adminAndOwnerOnly = []string{ "TenantInviteList", "TenantInviteCreate", diff --git a/api/v1/server/headers/middleware.go b/api/v1/server/headers/middleware.go index d9278ff6d2..95ad5a855a 100644 --- a/api/v1/server/headers/middleware.go +++ b/api/v1/server/headers/middleware.go @@ -9,6 +9,9 @@ import ( func Middleware() middleware.MiddlewareFunc { return func(r *middleware.RouteInfo) echo.HandlerFunc { return func(c echo.Context) error { + // TODO-CONTROL-PLANE: this needs to be set based on an env var, and should case on the origin I think... + c.Response().Header().Set("Access-Control-Allow-Origin", "*") + // ensure the Strict-Transport-Security header is set for all // endpoints, as it will help ensure protection against TLS protocol downgrade // attacks and cookie hijacking. The header also ensures that browsers only serve diff --git a/api/v1/server/run/run.go b/api/v1/server/run/run.go index 80665748f9..52a50e5f1a 100644 --- a/api/v1/server/run/run.go +++ b/api/v1/server/run/run.go @@ -209,6 +209,21 @@ func (t *APIServer) getCoreEchoService() (*echo.Echo, error) { g := e.Group("") + // TODO-CONTROL-PLANE: add casing here, temporary allow all OPTIONS requests + g.Use(func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + if c.Request().Method == http.MethodOptions { + c.Response().Header().Set("Access-Control-Allow-Origin", "*") + c.Response().Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS") + c.Response().Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, x-exchange-token") + + return c.NoContent(http.StatusOK) + } + + return next(c) + } + }) + if _, err := t.registerSpec(g, oaspec); err != nil { return nil, err } @@ -653,6 +668,7 @@ func (t *APIServer) registerSpec(g *echo.Group, spec *openapi3.T) (*populator.Po return nil, err } mw.Use(headers.Middleware()) + mw.Use(populatorMW.Middleware) mw.Use(authnMW.Middleware) mw.Use(authzMW.Middleware) diff --git a/frontend/app/src/components/v1/cloud/logging/log-search/types.ts b/frontend/app/src/components/v1/cloud/logging/log-search/types.ts index 5de49a2ec6..f7d2b9d7a2 100644 --- a/frontend/app/src/components/v1/cloud/logging/log-search/types.ts +++ b/frontend/app/src/components/v1/cloud/logging/log-search/types.ts @@ -19,8 +19,9 @@ export interface ParsedLogQuery { errors: string[]; } -export interface AutocompleteSuggestion - extends SearchSuggestion<'key' | 'value'> { +export interface AutocompleteSuggestion extends SearchSuggestion< + 'key' | 'value' +> { type: 'key' | 'value'; label: string; value: string; diff --git a/frontend/app/src/components/v1/molecules/data-table/data-table-column-header.tsx b/frontend/app/src/components/v1/molecules/data-table/data-table-column-header.tsx index 50782deb16..9b3a987f55 100644 --- a/frontend/app/src/components/v1/molecules/data-table/data-table-column-header.tsx +++ b/frontend/app/src/components/v1/molecules/data-table/data-table-column-header.tsx @@ -15,8 +15,10 @@ import { } from '@radix-ui/react-icons'; import { Column } from '@tanstack/react-table'; -interface DataTableColumnHeaderProps - extends React.HTMLAttributes { +interface DataTableColumnHeaderProps< + TData, + TValue, +> extends React.HTMLAttributes { column: Column; title: string; } diff --git a/frontend/app/src/components/v1/molecules/time-picker/time-picker-input.tsx b/frontend/app/src/components/v1/molecules/time-picker/time-picker-input.tsx index d102a8d542..bb0bde1326 100644 --- a/frontend/app/src/components/v1/molecules/time-picker/time-picker-input.tsx +++ b/frontend/app/src/components/v1/molecules/time-picker/time-picker-input.tsx @@ -9,8 +9,7 @@ import { Input } from '@/components/v1/ui/input'; import { cn } from '@/lib/utils'; import React from 'react'; -interface TimePickerInputProps - extends React.InputHTMLAttributes { +interface TimePickerInputProps extends React.InputHTMLAttributes { picker: TimePickerType; date: Date | undefined; setDate: (date: Date | undefined) => void; diff --git a/frontend/app/src/components/v1/nav/top-nav.tsx b/frontend/app/src/components/v1/nav/top-nav.tsx index 5730b93b5b..9b4beba427 100644 --- a/frontend/app/src/components/v1/nav/top-nav.tsx +++ b/frontend/app/src/components/v1/nav/top-nav.tsx @@ -30,12 +30,13 @@ import { import { Separator } from '@/components/v1/ui/separator'; import { useBreadcrumbs } from '@/hooks/use-breadcrumbs'; import { usePendingInvites } from '@/hooks/use-pending-invites'; +import { useOrganizations } from '@/hooks/use-organizations'; import { useTenantDetails } from '@/hooks/use-tenant'; import { useTenantHomeRoute } from '@/hooks/use-tenant-home-route'; -import api, { TenantMember, User } from '@/lib/api'; +import { TenantMember, User } from '@/lib/api'; +import { useUserApi } from '@/lib/api/user-wrapper'; import { useApiError } from '@/lib/hooks'; import { cn } from '@/lib/utils'; -import useCloud from '@/pages/auth/hooks/use-cloud'; import { appRoutes } from '@/router'; import { useMutation } from '@tanstack/react-query'; import { @@ -64,11 +65,12 @@ function AccountDropdown({ user }: { user?: User }) { // Check for pending invites to show the Invites menu item const { pendingInvitesQuery } = usePendingInvites(); + const userApi = useUserApi(); const logoutMutation = useMutation({ mutationKey: ['user:update:logout'], mutationFn: async () => { - await api.userUpdateLogout(); + await userApi.userUpdateLogout(); }, onSuccess: () => { navigate({ to: appRoutes.authLoginRoute.to }); @@ -275,10 +277,13 @@ export default function TopNav({ user, tenantMemberships }: TopNavProps) { setStoredCollapsed(!storedCollapsed); }; - const { isCloudEnabled } = useCloud(); + const { isCloudEnabled, isControlPlaneEnabled, hasOrganizations } = + useOrganizations(); const { tenant } = useTenantDetails(); const showTenantSwitcher = !!user && tenantMemberships?.length > 0 && !!tenant; + const showOrganizationSelector = + isCloudEnabled || isControlPlaneEnabled || hasOrganizations; return (
@@ -305,7 +310,7 @@ export default function TopNav({ user, tenantMemberships }: TopNavProps) {
{showTenantSwitcher && - (isCloudEnabled ? ( + (showOrganizationSelector ? ( ) : ( @@ -398,7 +403,7 @@ export default function TopNav({ user, tenantMemberships }: TopNavProps) { } /> {showTenantSwitcher && - (isCloudEnabled ? ( + (showOrganizationSelector ? ( ) : ( diff --git a/frontend/app/src/components/v1/shared/duration.tsx b/frontend/app/src/components/v1/shared/duration.tsx index 53f1f898b4..66fbafae07 100644 --- a/frontend/app/src/components/v1/shared/duration.tsx +++ b/frontend/app/src/components/v1/shared/duration.tsx @@ -76,7 +76,8 @@ const durationVariants = cva('text-sm', { }); interface DurationProps - extends React.HTMLAttributes, + extends + React.HTMLAttributes, VariantProps { start?: string | Date | null; end?: string | Date | null; diff --git a/frontend/app/src/components/v1/ui/badge.tsx b/frontend/app/src/components/v1/ui/badge.tsx index c16748b92b..310cad44d6 100644 --- a/frontend/app/src/components/v1/ui/badge.tsx +++ b/frontend/app/src/components/v1/ui/badge.tsx @@ -33,7 +33,8 @@ const badgeVariants = cva( }, ); export interface BadgeProps - extends React.HTMLAttributes, + extends + React.HTMLAttributes, VariantProps {} function Badge({ className, variant, ...props }: BadgeProps) { diff --git a/frontend/app/src/components/v1/ui/button.tsx b/frontend/app/src/components/v1/ui/button.tsx index 4e03fcbf62..0111f60cdb 100644 --- a/frontend/app/src/components/v1/ui/button.tsx +++ b/frontend/app/src/components/v1/ui/button.tsx @@ -43,7 +43,8 @@ const buttonVariants = cva( ); export interface ButtonProps - extends React.ButtonHTMLAttributes, + extends + React.ButtonHTMLAttributes, VariantProps { asChild?: boolean; hoverText?: string; diff --git a/frontend/app/src/components/v1/ui/card.tsx b/frontend/app/src/components/v1/ui/card.tsx index 373cb743e0..090fd03bb1 100644 --- a/frontend/app/src/components/v1/ui/card.tsx +++ b/frontend/app/src/components/v1/ui/card.tsx @@ -16,7 +16,8 @@ const cardVariants = cva('rounded-lg border', { }); export interface CardProps - extends React.HTMLAttributes, + extends + React.HTMLAttributes, VariantProps {} const Card = React.forwardRef( diff --git a/frontend/app/src/components/v1/ui/textarea.tsx b/frontend/app/src/components/v1/ui/textarea.tsx index 73b60c8e53..f6a9e1b835 100644 --- a/frontend/app/src/components/v1/ui/textarea.tsx +++ b/frontend/app/src/components/v1/ui/textarea.tsx @@ -1,8 +1,7 @@ import { cn } from '@/lib/utils'; import * as React from 'react'; -interface TextareaProps - extends React.TextareaHTMLAttributes {} +interface TextareaProps extends React.TextareaHTMLAttributes {} const Textarea = React.forwardRef( ({ className, ...props }, ref) => { diff --git a/frontend/app/src/hooks/use-breadcrumbs.ts b/frontend/app/src/hooks/use-breadcrumbs.ts index faa2bb56d2..af2d870229 100644 --- a/frontend/app/src/hooks/use-breadcrumbs.ts +++ b/frontend/app/src/hooks/use-breadcrumbs.ts @@ -1,5 +1,6 @@ import { useTenantHomeRoute } from '@/hooks/use-tenant-home-route'; import { generateBreadcrumbs, BreadcrumbItem } from '@/lib/breadcrumbs'; +import { appRoutes } from '@/router'; import { useLocation, useParams, useRouterState } from '@tanstack/react-router'; export function useBreadcrumbs(): BreadcrumbItem[] { @@ -28,7 +29,7 @@ export function useBreadcrumbs(): BreadcrumbItem[] { // When on a 404 page under /tenants/:tenant/*, the matches will only include // the tenant route itself, not any child routes const isOnTenantRoute = routerState.matches.some( - (match) => match.routeId === '/tenants/$tenant', + (match) => match.routeId === appRoutes.tenantRoute.id, ); const pathSegments = location.pathname.split('/').filter(Boolean); diff --git a/frontend/app/src/pages/auth/hooks/use-cloud.ts b/frontend/app/src/hooks/use-cloud.ts similarity index 80% rename from frontend/app/src/pages/auth/hooks/use-cloud.ts rename to frontend/app/src/hooks/use-cloud.ts index f7762c1b39..8b322a1139 100644 --- a/frontend/app/src/pages/auth/hooks/use-cloud.ts +++ b/frontend/app/src/hooks/use-cloud.ts @@ -5,12 +5,20 @@ import { useQuery } from '@tanstack/react-query'; import { AxiosError } from 'axios'; import { useCallback, useMemo } from 'react'; +type ApiErrorsLike = { errors?: unknown }; + +function isCloudMetadata(value: unknown): value is APICloudMetadata | ApiErrorsLike { + return !!value && typeof value === 'object'; +} + export default function useCloud(tenantId?: string) { const { handleApiError } = useApiError({}); - const checkIsCloudEnabled = useCallback((cloudMeta: APICloudMetadata) => { - // @ts-expect-error errors is returned when this is oss - return !!cloudMeta && !cloudMeta?.errors; + const checkIsCloudEnabled = useCallback((cloudMeta: unknown) => { + if (!isCloudMetadata(cloudMeta)) { + return false; + } + return !('errors' in cloudMeta) || !cloudMeta.errors; }, []); const cloudMetaQuery = useQuery({ @@ -52,9 +60,11 @@ export default function useCloud(tenantId?: string) { retry: false, enabled: isCloudEnabled && !!tenantId, queryFn: async () => { + if (!tenantId) { + return null; + } try { - // tenantId is guaranteed by `enabled` - return await cloudApi.featureFlagsList(tenantId as string); + return await cloudApi.featureFlagsList(tenantId); } catch (e) { return null; } diff --git a/frontend/app/src/hooks/use-control-plane.ts b/frontend/app/src/hooks/use-control-plane.ts new file mode 100644 index 0000000000..cf8bfbaffc --- /dev/null +++ b/frontend/app/src/hooks/use-control-plane.ts @@ -0,0 +1,39 @@ +import { controlPlaneApi } from '@/lib/api/api'; +import { + inferControlPlaneEnabled, + writeStoredControlPlaneEnabled, +} from '@/lib/api/control-plane-status'; +import { useQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; + +export default function useControlPlane() { + const controlPlaneMetaQuery = useQuery({ + queryKey: ['control-plane-metadata:get'], + retry: false, + queryFn: async () => { + try { + const meta = await controlPlaneApi.metadataGet(); + const enabled = inferControlPlaneEnabled(meta.data); + writeStoredControlPlaneEnabled(enabled); + if (enabled) { + console.log('🪓 Control plane active'); + } + return meta; + } catch (e) { + console.error('Failed to get control plane metadata', e); + return null; + } + }, + staleTime: 1000 * 60, + }); + + const isControlPlaneEnabled = useMemo(() => { + return inferControlPlaneEnabled(controlPlaneMetaQuery.data?.data); + }, [controlPlaneMetaQuery.data?.data]); + + return { + isControlPlaneEnabled, + isControlPlaneLoading: controlPlaneMetaQuery.isLoading, + controlPlaneMeta: controlPlaneMetaQuery.data?.data, + }; +} diff --git a/frontend/app/src/hooks/use-organizations.ts b/frontend/app/src/hooks/use-organizations.ts index 970dcecb56..546f558aeb 100644 --- a/frontend/app/src/hooks/use-organizations.ts +++ b/frontend/app/src/hooks/use-organizations.ts @@ -1,4 +1,6 @@ -import { cloudApi } from '@/lib/api/api'; +import useCloud from '@/hooks/use-cloud'; +import useControlPlane from '@/hooks/use-control-plane'; +import { useOrganizationApi } from '@/lib/api/organization-wrapper'; import { CreateManagementTokenResponse, ManagementTokenDuration, @@ -17,17 +19,27 @@ import { useMemo, useCallback } from 'react'; * Gets organization data from context, but keeps all mutation logic here. */ export function useOrganizations() { - const { organizations: organizationData, isCloudEnabled } = useAppContext(); + const { + organizations: organizationData, + isCloudEnabled, + isControlPlaneEnabled, + } = useAppContext(); const { handleApiError } = useApiError({}); + const orgApi = useOrganizationApi(); + const { isCloudLoading } = useCloud(); + const { isControlPlaneLoading } = useControlPlane(); // Re-query for mutations (will revalidate the context) const organizationListQuery = useQuery({ queryKey: ['organization:list'], queryFn: async () => { - const result = await cloudApi.organizationList(); + const result = await orgApi.organizationList(); return result.data; }, - enabled: isCloudEnabled, + enabled: + (isCloudEnabled || isControlPlaneEnabled) && + !isCloudLoading && + !isControlPlaneLoading, }); const organizations = useMemo( @@ -68,13 +80,13 @@ export function useOrganizations() { ); const hasOrganizations = useMemo(() => { - return (organizationListQuery.data?.rows?.length || 0) > 0; - }, [organizationListQuery.data?.rows]); + return organizations.length > 0; + }, [organizations]); const acceptOrgInviteMutation = useMutation({ mutationKey: ['organization-invite:accept'], mutationFn: async (data: { inviteId: string }) => { - await cloudApi.organizationInviteAccept({ + await orgApi.organizationInviteAccept({ id: data.inviteId, }); }, @@ -84,7 +96,7 @@ export function useOrganizations() { const rejectOrgInviteMutation = useMutation({ mutationKey: ['organization-invite:reject'], mutationFn: async (data: { inviteId: string }) => { - await cloudApi.organizationInviteReject({ + await orgApi.organizationInviteReject({ id: data.inviteId, }); }, @@ -97,7 +109,7 @@ export function useOrganizations() { name: string; slug: string; }) => { - const result = await cloudApi.organizationCreateTenant( + const result = await orgApi.organizationCreateTenant( data.organizationId, { name: data.name, @@ -111,7 +123,7 @@ export function useOrganizations() { const cancelInviteMutation = useMutation({ mutationFn: async (data: { inviteId: string }) => { - await cloudApi.organizationInviteDelete(data.inviteId); + await orgApi.organizationInviteDelete(data.inviteId); }, onError: handleApiError, }); @@ -150,7 +162,7 @@ export function useOrganizations() { if (data.duration != null) { body.duration = data.duration; } - const result = await cloudApi.managementTokenCreate( + const result = await orgApi.managementTokenCreate( data.organizationId, body, ); @@ -161,7 +173,7 @@ export function useOrganizations() { const deleteMemberMutation = useMutation({ mutationFn: async (data: { memberId: string; email: string }) => { - await cloudApi.organizationMemberDelete(data.memberId, { + await orgApi.organizationMemberDelete(data.memberId, { emails: [data.email], }); }, @@ -170,21 +182,21 @@ export function useOrganizations() { const deleteTokenMutation = useMutation({ mutationFn: async (data: { tokenId: string }) => { - await cloudApi.managementTokenDelete(data.tokenId); + await orgApi.managementTokenDelete(data.tokenId); }, onError: handleApiError, }); const deleteTenantMutation = useMutation({ mutationFn: async (data: { tenantId: string }) => { - await cloudApi.organizationTenantDelete(data.tenantId); + await orgApi.organizationTenantDelete(data.tenantId); }, onError: handleApiError, }); const updateOrganizationMutation = useMutation({ mutationFn: async (data: { organizationId: string; name: string }) => { - const result = await cloudApi.organizationUpdate(data.organizationId, { + const result = await orgApi.organizationUpdate(data.organizationId, { name: data.name, }); return result.data; @@ -194,7 +206,7 @@ export function useOrganizations() { const createOrganizationMutation = useMutation({ mutationFn: async (data: { name: string }) => { - const result = await cloudApi.organizationCreate({ + const result = await orgApi.organizationCreate({ name: data.name, }); return result.data; @@ -333,6 +345,7 @@ export function useOrganizations() { organizations, organizationData, // From context isCloudEnabled, + isControlPlaneEnabled, getOrganizationForTenant, getOrganizationIdForTenant, isTenantArchivedInOrg, diff --git a/frontend/app/src/hooks/use-pending-invites.ts b/frontend/app/src/hooks/use-pending-invites.ts index bcf2f1b207..c9356e4899 100644 --- a/frontend/app/src/hooks/use-pending-invites.ts +++ b/frontend/app/src/hooks/use-pending-invites.ts @@ -1,18 +1,24 @@ -import api from '@/lib/api'; -import { cloudApi } from '@/lib/api/api'; -import useCloud from '@/pages/auth/hooks/use-cloud'; +import { useOrganizationApi } from '@/lib/api/organization-wrapper'; +import { useTenantApi } from '@/lib/api/tenant-wrapper'; +import useCloud from '@/hooks/use-cloud'; +import useControlPlane from '@/hooks/use-control-plane'; import { useQuery } from '@tanstack/react-query'; export const usePendingInvites = () => { const { isCloudEnabled, isCloudLoading } = useCloud(); + const { isControlPlaneEnabled, isControlPlaneLoading } = useControlPlane(); + const orgApi = useOrganizationApi(); + const tenantApi = useTenantApi(); + const hasOrgInvites = isCloudEnabled || isControlPlaneEnabled; const query = useQuery({ - queryKey: ['pending-invites', isCloudEnabled], + queryKey: ['pending-invites', isCloudEnabled, isControlPlaneEnabled], + enabled: !isCloudLoading && !isControlPlaneLoading, queryFn: async () => { const [tenantInvites, orgInvites] = await Promise.allSettled([ - api.userListTenantInvites(), - isCloudEnabled - ? cloudApi.userListOrganizationInvites() + tenantApi.userListTenantInvites(), + hasOrgInvites + ? orgApi.userListOrganizationInvites() : Promise.resolve({ data: { rows: [] } }), ]); @@ -32,6 +38,6 @@ export const usePendingInvites = () => { return { pendingInvitesQuery: query, - isLoading: isCloudLoading || query.isLoading, + isLoading: isCloudLoading || isControlPlaneLoading || query.isLoading, }; }; diff --git a/frontend/app/src/hooks/use-tenant.tsx b/frontend/app/src/hooks/use-tenant.tsx index 530fbbd8de..a9226ac5bf 100644 --- a/frontend/app/src/hooks/use-tenant.tsx +++ b/frontend/app/src/hooks/use-tenant.tsx @@ -1,12 +1,14 @@ +import useCloud from '@/hooks/use-cloud'; import api, { UpdateTenantRequest, Tenant, CreateTenantRequest, queries, } from '@/lib/api'; +import { controlPlaneApi } from '@/lib/api/api'; +import { exchangeTokenQueryOptions } from '@/lib/api/exchange-token'; import { BillingContext, lastTenantAtom } from '@/lib/atoms'; import { Evaluate } from '@/lib/can/shared/permission.base'; -import useCloud from '@/pages/auth/hooks/use-cloud'; import { useAppContext } from '@/providers/app-context'; import { appRoutes } from '@/router'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; @@ -19,11 +21,11 @@ type Plan = 'free' | 'starter' | 'growth'; /** * Hook to get current tenant ID from route params * - * @deprecated Prefer using route params directly via `useParams({ from: appRoutes.tenantRoute.to })` + * @deprecated Prefer using route params directly via `useParams({ from: appRoutes.tenantRoute.id })` * This hook is maintained for backward compatibility during migration. */ export function useCurrentTenantId() { - const params = useParams({ from: appRoutes.tenantRoute.to }); + const params = useParams({ from: appRoutes.tenantRoute.id }); const tenantId = params.tenant; return { tenantId }; @@ -54,6 +56,21 @@ export function useTenantDetails() { setLastTenant(tenant); queryClient.clear(); + // When CP is active, warm up the exchange token for the incoming tenant + // immediately after clearing the cache. This runs the queryFn (which + // checks localStorage first) so that the first tenant-specific API + // request can resolve the token from memory instead of waiting for a + // CP round-trip. + if (appContext.isControlPlaneEnabled) { + queryClient.prefetchQuery( + exchangeTokenQueryOptions(tenant.metadata.id, () => + controlPlaneApi + .exchangeTokenCreate(tenant.metadata.id) + .then((r) => r.data), + ), + ); + } + const isOnTenantRoute = Boolean( matchRoute({ to: appRoutes.tenantRoute.to, @@ -79,15 +96,29 @@ export function useTenantDetails() { params: { tenant: tenant.metadata.id }, }); }, - [matchRoute, navigate, setLastTenant, queryClient, tenantParamInPath], + [ + matchRoute, + navigate, + setLastTenant, + queryClient, + tenantParamInPath, + appContext.isControlPlaneEnabled, + ], ); // Tenant and membership now come from AppContext // No need to compute them here anymore + // TODO-CONTROL-PLANE: move these mutations to the tenant wrapper const createTenantMutation = useMutation({ mutationKey: ['tenant:create'], mutationFn: async ({ name }: { name: string }): Promise => { + if (appContext.isControlPlaneEnabled) { + throw new Error( + 'Tenant creation requires organization-scoped API in control-plane mode', + ); + } + const tenantData: CreateTenantRequest = { name, slug: name.toLowerCase().replace(/\s+/g, '-'), diff --git a/frontend/app/src/lib/api/api.ts b/frontend/app/src/lib/api/api.ts index a265cd32c7..324f11817f 100644 --- a/frontend/app/src/lib/api/api.ts +++ b/frontend/app/src/lib/api/api.ts @@ -1,5 +1,13 @@ +import { + inferControlPlaneEnabled, + readStoredControlPlaneEnabled, + writeStoredControlPlaneEnabled, +} from './control-plane-status'; +import { exchangeTokenQueryOptions } from './exchange-token'; import { Api } from './generated/Api'; import { Api as CloudApi } from './generated/cloud/Api'; +import { Api as ControlPlaneApi } from './generated/control-plane/Api'; +import queryClient from '@/query-client'; import qs from 'qs'; const api = new Api({ @@ -11,3 +19,126 @@ export default api; export const cloudApi = new CloudApi({ paramsSerializer: (params) => qs.stringify(params, { arrayFormat: 'repeat' }), }); + +export const controlPlaneApi = new ControlPlaneApi({ + paramsSerializer: (params) => qs.stringify(params, { arrayFormat: 'repeat' }), +}); + +const LAST_TENANT_STORAGE_KEY = 'lastTenant'; + +type StoredTenantLike = { + metadata?: { + id?: string; + }; +}; + +function readStoredTenantId(): string | null { + try { + const raw = localStorage.getItem(LAST_TENANT_STORAGE_KEY); + if (!raw) { + return null; + } + const lastTenant: StoredTenantLike = JSON.parse(raw); + return lastTenant.metadata?.id ?? null; + } catch { + return null; + } +} + +/** + * Resolves whether control plane is enabled, preferring localStorage when + * available. If unknown in localStorage, wait for metadata query resolution. + */ +async function resolveControlPlaneEnabled(): Promise { + const stored = readStoredControlPlaneEnabled(); + if (stored !== null) { + return stored; + } + + const cpMeta = await queryClient.fetchQuery({ + queryKey: ['control-plane-metadata:get'], + queryFn: async () => { + try { + return await controlPlaneApi.metadataGet(); + } catch { + return null; + } + }, + staleTime: 1000 * 60, + }); + + if (cpMeta === null) { + return false; + } + + const enabled = inferControlPlaneEnabled(cpMeta.data); + writeStoredControlPlaneEnabled(enabled); + return enabled; +} + +/** + * Exchange-token request interceptor. + * + * When control plane is active, tenant-scoped OSS API requests are + * transparently authenticated with an exchange token: + * + * - Tenant is resolved from persisted app state (`lastTenant`) instead of URL + * parsing, so endpoints that omit tenant in path still work. + * - Control-plane status is resolved from localStorage; if unknown, this + * interceptor waits for metadata to resolve before deciding. + * - A valid exchange token is obtained via `queryClient.fetchQuery`, which + * deduplicates concurrent fetches for the same tenant, caches the result + * in React Query memory (staleTime = token lifetime − 1 min), and reads + * from localStorage on startup to avoid an unnecessary round-trip. + * - The request's baseURL is rewritten to the tenant-specific API URL that + * is embedded in the exchange token claims. + * - `Authorization: Bearer ` and `X-Exchange-Token: true` headers + * are added so the OSS backend can identify and validate the token. + * + * If the exchange token cannot be obtained (e.g. network error), the request + * proceeds unchanged and will likely fail with a 401, which React Query will + * surface to the UI normally. + */ +api.instance.interceptors.request.use(async (config) => { + const cpEnabled = await resolveControlPlaneEnabled(); + const tenantId = readStoredTenantId(); + + console.log('[exchange-token interceptor]', { + url: config.url, + method: config.method, + cpEnabled, + tenantId: tenantId ?? '(none)', + }); + + if (!cpEnabled) return config; + if (!tenantId) return config; + + try { + // TODO-CONTROL-PLANE: it doesn't seem like this is using the cached token? + const exchangeToken = await queryClient.fetchQuery( + exchangeTokenQueryOptions(tenantId, () => + controlPlaneApi.exchangeTokenCreate(tenantId).then((r) => r.data), + ), + ); + + console.log('[exchange-token interceptor] applying token', { + url: config.url, + rawApiUrl: exchangeToken.apiUrl, + apiUrl: exchangeToken.apiUrl, + expiresAt: exchangeToken.expiresAt, + }); + + config.baseURL = exchangeToken.apiUrl; + config.withCredentials = false; + config.headers.set('Authorization', `Bearer ${exchangeToken.token}`); + config.headers.set('X-Exchange-Token', 'true'); + } catch (err) { + console.error('[exchange-token interceptor] failed to get token', { + url: config.url, + tenantId, + err, + }); + } + + return config; +}); diff --git a/frontend/app/src/lib/api/control-plane-status.ts b/frontend/app/src/lib/api/control-plane-status.ts new file mode 100644 index 0000000000..74aa02f40c --- /dev/null +++ b/frontend/app/src/lib/api/control-plane-status.ts @@ -0,0 +1,37 @@ +const CONTROL_PLANE_ENABLED_STORAGE_KEY = 'hatchet_control_plane_enabled'; + +type MetaLikeWithErrors = { + errors?: unknown; +}; + +function hasErrorsField(value: unknown): value is MetaLikeWithErrors { + return !!value && typeof value === 'object' && 'errors' in value; +} + +export function inferControlPlaneEnabled(meta: unknown): boolean { + if (!meta || typeof meta !== 'object') { + return false; + } + + return !hasErrorsField(meta) || !meta.errors; +} + +export function readStoredControlPlaneEnabled(): boolean | null { + try { + const raw = localStorage.getItem(CONTROL_PLANE_ENABLED_STORAGE_KEY); + if (raw === null) { + return null; + } + return raw === 'true'; + } catch { + return null; + } +} + +export function writeStoredControlPlaneEnabled(enabled: boolean): void { + try { + localStorage.setItem(CONTROL_PLANE_ENABLED_STORAGE_KEY, String(enabled)); + } catch { + // Ignore storage failures. + } +} diff --git a/frontend/app/src/lib/api/exchange-token.ts b/frontend/app/src/lib/api/exchange-token.ts new file mode 100644 index 0000000000..82ecb25012 --- /dev/null +++ b/frontend/app/src/lib/api/exchange-token.ts @@ -0,0 +1,188 @@ +import queryClient from '@/query-client'; + +export interface TenantTokenData { + token: string; + apiUrl: string; + expiresAt: string; +} + +const STORAGE_PREFIX = 'hatchet_xt_'; +// Refresh the token 60 seconds before it actually expires so there's +// no window where we use an about-to-expire token. +const EXPIRY_BUFFER_MS = 60_000; + +// ── localStorage helpers ────────────────────────────────────────────────────── + +function storageKey(tenantId: string): string { + return `${STORAGE_PREFIX}${tenantId}`; +} + +function readStored(tenantId: string): TenantTokenData | null { + try { + const raw = localStorage.getItem(storageKey(tenantId)); + if (!raw) return null; + const stored: TenantTokenData = JSON.parse(raw); + const expiry = new Date(stored.expiresAt).getTime(); + if (Date.now() >= expiry - EXPIRY_BUFFER_MS) { + // Evict expired/nearly-expired tokens eagerly so the next read + // triggers a fresh fetch instead of returning a stale entry. + localStorage.removeItem(storageKey(tenantId)); + return null; + } + return stored; + } catch { + // Ignore parse errors and unavailable storage (private browsing, etc.) + return null; + } +} + +function writeStored(tenantId: string, token: TenantTokenData): void { + try { + localStorage.setItem(storageKey(tenantId), JSON.stringify(token)); + } catch { + // Ignore write failures (private browsing, quota exceeded, etc.) + } +} + +export function clearStoredExchangeToken(tenantId: string): void { + try { + localStorage.removeItem(storageKey(tenantId)); + } catch { + // Ignore + } +} + +// ── React Query integration ─────────────────────────────────────────────────── + +export function exchangeTokenQueryKey(tenantId: string) { + return ['exchange-token', tenantId] as const; +} + +/** + * Returns React Query options for fetching/caching an exchange token. + * + * `fetchFn` is called only when no valid token exists in localStorage or the + * React Query cache; callers supply it so that this module stays free of + * circular imports (it doesn't need to import `controlPlaneApi` from api.ts). + * + * Deduplication is handled by React Query — concurrent calls to + * `queryClient.fetchQuery(exchangeTokenQueryOptions(...))` for the same tenant + * share a single in-flight request. + */ +export function exchangeTokenQueryOptions( + tenantId: string, + fetchFn: () => Promise, +) { + // TODO-CONTROL-PLANE: this can be removed, we can assume that the apiUrl contains https + const normalizeApiUrl = (apiUrl: string): string => + /^https?:\/\//i.test(apiUrl) ? apiUrl : `http://${apiUrl}`; + + // TODO-CONTROL-PLANE: let's use zod here for typing and unmarshaling? + const getTokenDataFromQueryState = ( + query: unknown, + ): TenantTokenData | undefined => { + if (!query || typeof query !== 'object') { + return undefined; + } + + const state = Reflect.get(query, 'state'); + if (!state || typeof state !== 'object') { + return undefined; + } + + const data = Reflect.get(state, 'data'); + if (!data || typeof data !== 'object') { + return undefined; + } + + const token = Reflect.get(data, 'token'); + const apiUrl = Reflect.get(data, 'apiUrl'); + const expiresAt = Reflect.get(data, 'expiresAt'); + + if ( + typeof token !== 'string' || + typeof apiUrl !== 'string' || + typeof expiresAt !== 'string' + ) { + return undefined; + } + + return { + token, + apiUrl, + expiresAt, + }; + }; + + return { + queryKey: exchangeTokenQueryKey(tenantId), + queryFn: async (): Promise => { + console.log('[exchange-token queryFn] start', { tenantId }); + + // Prefer the localStorage copy (survives page refreshes) over a + // network round-trip when the stored token is still valid. + const stored = readStored(tenantId); + if (stored) { + console.log('[exchange-token queryFn] returning localStorage hit', { + tenantId, + apiUrl: stored.apiUrl, + expiresAt: stored.expiresAt, + }); + return stored; + } + + console.log( + '[exchange-token queryFn] no valid cached token — fetching from CP', + { tenantId }, + ); + try { + let token = await fetchFn(); + // Normalise the API URL before storing — the backend may omit the + // protocol (e.g. "localhost:8888"). We normalise here so that every + // cached copy (memory + localStorage) already has a valid absolute URL. + token = { ...token, apiUrl: normalizeApiUrl(token.apiUrl) }; + console.log('[exchange-token queryFn] fetched token', { + tenantId, + apiUrl: token.apiUrl, + expiresAt: token.expiresAt, + }); + writeStored(tenantId, token); + return token; + } catch (err) { + console.error('[exchange-token queryFn] fetch failed', { + tenantId, + err, + }); + throw err; + } + }, + // Dynamically compute staleTime from the token's own expiry so React Query + // will re-run the queryFn (and refresh the token) just before it expires. + // TODO-CONTROL-PLANE: why unknown type here? + staleTime: (query: unknown) => { + const data = getTokenDataFromQueryState(query); + if (!data) return 0; + const expiry = new Date(data.expiresAt).getTime(); + return Math.max(0, expiry - Date.now() - EXPIRY_BUFFER_MS); + }, + gcTime: 60 * 60 * 1000, // keep in memory for up to 1 hour + retry: 1, + }; +} + +/** + * Fetches an exchange token for `tenantId`, using `fetchFn` to acquire a new + * one from the control plane when the cached copy is absent or expired. + * + * Wraps `queryClient.fetchQuery` so that: + * - concurrent calls for the same tenant are automatically deduplicated + * - the result is cached in React Query memory with a staleTime derived from + * the token's `expiresAt` field + * - localStorage is checked first so the token survives page refreshes + */ +export function fetchExchangeToken( + tenantId: string, + fetchFn: () => Promise, +): Promise { + return queryClient.fetchQuery(exchangeTokenQueryOptions(tenantId, fetchFn)); +} diff --git a/frontend/app/src/lib/api/generated/cloud/data-contracts.ts b/frontend/app/src/lib/api/generated/cloud/data-contracts.ts index 34b66d3d57..4b394a27bf 100644 --- a/frontend/app/src/lib/api/generated/cloud/data-contracts.ts +++ b/frontend/app/src/lib/api/generated/cloud/data-contracts.ts @@ -39,7 +39,6 @@ export enum TemplateOptions { } export enum AutoscalingTargetKind { - PORTER = "PORTER", FLY = "FLY", } @@ -146,7 +145,6 @@ export interface ManagedWorker { metadata: APIResourceMeta; name: string; buildConfig?: ManagedWorkerBuildConfig; - isIac: boolean; directSecrets: ManagedWorkerSecret[]; globalSecrets: ManagedWorkerSecret[]; runtimeConfigs?: ManagedWorkerRuntimeConfig[]; @@ -256,7 +254,6 @@ export interface CreateManagedWorkerRequest { name: string; buildConfig: CreateManagedWorkerBuildConfigRequest; secrets?: CreateManagedWorkerSecretRequest; - isIac: boolean; runtimeConfig?: CreateManagedWorkerRuntimeConfigRequest; } @@ -264,7 +261,6 @@ export interface UpdateManagedWorkerRequest { name?: string; buildConfig?: CreateManagedWorkerBuildConfigRequest; secrets?: UpdateManagedWorkerSecretRequest; - isIac?: boolean; runtimeConfig?: CreateManagedWorkerRuntimeConfigRequest; } diff --git a/frontend/app/src/lib/api/generated/control-plane/Api.ts b/frontend/app/src/lib/api/generated/control-plane/Api.ts new file mode 100644 index 0000000000..3477238888 --- /dev/null +++ b/frontend/app/src/lib/api/generated/control-plane/Api.ts @@ -0,0 +1,743 @@ +/* eslint-disable */ +/* tslint:disable */ +// @ts-nocheck +/* + * --------------------------------------------------------------- + * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## + * ## ## + * ## AUTHOR: acacode ## + * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## + * --------------------------------------------------------------- + */ + +import { + AcceptOrganizationInviteRequest, + APIControlPlaneMetadata, + APIError, + APIErrors, + CreateManagementTokenRequest, + CreateManagementTokenResponse, + CreateNewTenantForOrganizationRequest, + CreateOrganizationInviteRequest, + CreateOrganizationRequest, + CreateTenantInviteRequest, + ManagementTokenList, + Organization, + OrganizationForUserList, + OrganizationInviteList, + OrganizationTenant, + RejectOrganizationInviteRequest, + RejectTenantInviteRequest, + RemoveOrganizationMembersRequest, + TenantInvite, + TenantInviteList, + TenantMember, + TenantMemberList, + TenantToken, + UpdateOrganizationRequest, + UpdateTenantInviteRequest, + UpdateTenantMemberRequest, + User, + UserChangePasswordRequest, + UserLoginRequest, + UserRegisterRequest, + UserTenantMembershipsList, +} from "./data-contracts"; +import { ContentType, HttpClient, RequestParams } from "./http-client"; + +export class Api< + SecurityDataType = unknown, +> extends HttpClient { + /** + * @description Gets metadata for the Hatchet instance + * + * @tags Metadata + * @name MetadataGet + * @summary Get metadata + * @request GET:/api/v1/control-plane/metadata + */ + metadataGet = (params: RequestParams = {}) => + this.request({ + path: `/api/v1/control-plane/metadata`, + method: "GET", + format: "json", + ...params, + }); + /** + * @description Logs in a cloud user. + * + * @tags User + * @name CloudUserUpdateLogin + * @summary Login user + * @request POST:/api/v1/control-plane/users/login + */ + cloudUserUpdateLogin = (data: UserLoginRequest, params: RequestParams = {}) => + this.request({ + path: `/api/v1/control-plane/users/login`, + method: "POST", + body: data, + type: ContentType.Json, + format: "json", + ...params, + }); + /** + * @description Logs out a cloud user. + * + * @tags User + * @name CloudUserUpdateLogout + * @summary Logout user + * @request POST:/api/v1/control-plane/users/logout + * @secure + */ + cloudUserUpdateLogout = (params: RequestParams = {}) => + this.request({ + path: `/api/v1/control-plane/users/logout`, + method: "POST", + secure: true, + format: "json", + ...params, + }); + /** + * @description Update a cloud user password. + * + * @tags User + * @name CloudUserUpdatePassword + * @summary Change user password + * @request POST:/api/v1/control-plane/users/password + * @secure + */ + cloudUserUpdatePassword = ( + data: UserChangePasswordRequest, + params: RequestParams = {}, + ) => + this.request({ + path: `/api/v1/control-plane/users/password`, + method: "POST", + body: data, + secure: true, + type: ContentType.Json, + format: "json", + ...params, + }); + /** + * @description Registers a cloud user. + * + * @tags User + * @name CloudUserCreate + * @summary Register user + * @request POST:/api/v1/control-plane/users/register + */ + cloudUserCreate = (data: UserRegisterRequest, params: RequestParams = {}) => + this.request({ + path: `/api/v1/control-plane/users/register`, + method: "POST", + body: data, + type: ContentType.Json, + format: "json", + ...params, + }); + /** + * @description Gets the current cloud user + * + * @tags User + * @name CloudUserGetCurrent + * @summary Get current cloud user + * @request GET:/api/v1/control-plane/users/current + * @secure + */ + cloudUserGetCurrent = (params: RequestParams = {}) => + this.request({ + path: `/api/v1/control-plane/users/current`, + method: "GET", + secure: true, + format: "json", + ...params, + }); + /** + * @description Starts the OAuth flow + * + * @tags User + * @name CloudUserUpdateGoogleOauthStart + * @summary Start OAuth flow + * @request GET:/api/v1/control-plane/users/google/start + */ + cloudUserUpdateGoogleOauthStart = (params: RequestParams = {}) => + this.request({ + path: `/api/v1/control-plane/users/google/start`, + method: "GET", + ...params, + }); + /** + * @description Completes the OAuth flow + * + * @tags User + * @name CloudUserUpdateGoogleOauthCallback + * @summary Complete OAuth flow + * @request GET:/api/v1/control-plane/users/google/callback + */ + cloudUserUpdateGoogleOauthCallback = (params: RequestParams = {}) => + this.request({ + path: `/api/v1/control-plane/users/google/callback`, + method: "GET", + ...params, + }); + /** + * @description Starts the OAuth flow + * + * @tags User + * @name CloudUserUpdateGithubOauthStart + * @summary Start OAuth flow + * @request GET:/api/v1/control-plane/users/github/start + */ + cloudUserUpdateGithubOauthStart = (params: RequestParams = {}) => + this.request({ + path: `/api/v1/control-plane/users/github/start`, + method: "GET", + ...params, + }); + /** + * @description Completes the OAuth flow + * + * @tags User + * @name CloudUserUpdateGithubOauthCallback + * @summary Complete OAuth flow + * @request GET:/api/v1/control-plane/users/github/callback + */ + cloudUserUpdateGithubOauthCallback = (params: RequestParams = {}) => + this.request({ + path: `/api/v1/control-plane/users/github/callback`, + method: "GET", + ...params, + }); + /** + * @description List all organizations the authenticated user is a member of + * + * @name OrganizationList + * @summary List Organizations + * @request GET:/api/v1/control-plane/organizations + * @secure + */ + organizationList = (params: RequestParams = {}) => + this.request({ + path: `/api/v1/control-plane/organizations`, + method: "GET", + secure: true, + format: "json", + ...params, + }); + /** + * @description Create a new organization + * + * @name OrganizationCreate + * @summary Create Organization + * @request POST:/api/v1/control-plane/organizations + * @secure + */ + organizationCreate = ( + data: CreateOrganizationRequest, + params: RequestParams = {}, + ) => + this.request({ + path: `/api/v1/control-plane/organizations`, + method: "POST", + body: data, + secure: true, + type: ContentType.Json, + format: "json", + ...params, + }); + /** + * @description Get organization details + * + * @tags Management + * @name OrganizationGet + * @summary Get Organization + * @request GET:/api/v1/control-plane/organizations/{organization} + * @secure + */ + organizationGet = (organization: string, params: RequestParams = {}) => + this.request({ + path: `/api/v1/control-plane/organizations/${organization}`, + method: "GET", + secure: true, + format: "json", + ...params, + }); + /** + * @description Update an organization + * + * @name OrganizationUpdate + * @summary Update Organization + * @request PATCH:/api/v1/control-plane/organizations/{organization} + * @secure + */ + organizationUpdate = ( + organization: string, + data: UpdateOrganizationRequest, + params: RequestParams = {}, + ) => + this.request({ + path: `/api/v1/control-plane/organizations/${organization}`, + method: "PATCH", + body: data, + secure: true, + type: ContentType.Json, + format: "json", + ...params, + }); + /** + * @description Create a new tenant in the organization + * + * @tags Management + * @name OrganizationCreateTenant + * @summary Create Tenant in Organization + * @request POST:/api/v1/control-plane/organizations/{organization}/tenants + * @secure + */ + organizationCreateTenant = ( + organization: string, + data: CreateNewTenantForOrganizationRequest, + params: RequestParams = {}, + ) => + this.request({ + path: `/api/v1/control-plane/organizations/${organization}/tenants`, + method: "POST", + body: data, + secure: true, + type: ContentType.Json, + format: "json", + ...params, + }); + /** + * @description Delete (archive) a tenant in the organization + * + * @tags Management + * @name OrganizationTenantDelete + * @summary Delete Tenant in Organization + * @request DELETE:/api/v1/control-plane/organization-tenants/{organization-tenant} + * @secure + */ + organizationTenantDelete = ( + organizationTenant: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/api/v1/control-plane/organization-tenants/${organizationTenant}`, + method: "DELETE", + secure: true, + format: "json", + ...params, + }); + /** + * @description Remove a member from an organization + * + * @tags Management + * @name OrganizationMemberDelete + * @summary Remove Member from Organization + * @request DELETE:/api/v1/control-plane/organization-members/{organization-member} + * @secure + */ + organizationMemberDelete = ( + organizationMember: string, + data: RemoveOrganizationMembersRequest, + params: RequestParams = {}, + ) => + this.request({ + path: `/api/v1/control-plane/organization-members/${organizationMember}`, + method: "DELETE", + body: data, + secure: true, + type: ContentType.Json, + ...params, + }); + /** + * @description Create a new management token for an organization + * + * @tags Management + * @name ManagementTokenCreate + * @summary Create Management Token for Organization + * @request POST:/api/v1/control-plane/organizations/{organization}/management-tokens + * @secure + */ + managementTokenCreate = ( + organization: string, + data: CreateManagementTokenRequest, + params: RequestParams = {}, + ) => + this.request({ + path: `/api/v1/control-plane/organizations/${organization}/management-tokens`, + method: "POST", + body: data, + secure: true, + type: ContentType.Json, + format: "json", + ...params, + }); + /** + * @description Get management tokens for an organization + * + * @name ManagementTokenList + * @summary Get Management Tokens for Organization + * @request GET:/api/v1/control-plane/organizations/{organization}/management-tokens + * @secure + */ + managementTokenList = (organization: string, params: RequestParams = {}) => + this.request({ + path: `/api/v1/control-plane/organizations/${organization}/management-tokens`, + method: "GET", + secure: true, + format: "json", + ...params, + }); + /** + * @description Delete a management token for an organization + * + * @name ManagementTokenDelete + * @summary Delete Management Token for Organization + * @request DELETE:/api/v1/control-plane/management-tokens/{management-token} + * @secure + */ + managementTokenDelete = ( + managementToken: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/api/v1/control-plane/management-tokens/${managementToken}`, + method: "DELETE", + secure: true, + ...params, + }); + /** + * @description List all organization invites for the authenticated user + * + * @name UserListOrganizationInvites + * @summary List Organization Invites for User + * @request GET:/api/v1/control-plane/invites + * @secure + */ + userListOrganizationInvites = (params: RequestParams = {}) => + this.request({ + path: `/api/v1/control-plane/invites`, + method: "GET", + secure: true, + format: "json", + ...params, + }); + /** + * @description Accept an organization invite + * + * @name OrganizationInviteAccept + * @summary Accept Organization Invite for User + * @request POST:/api/v1/control-plane/invites/accept + * @secure + */ + organizationInviteAccept = ( + data: AcceptOrganizationInviteRequest, + params: RequestParams = {}, + ) => + this.request({ + path: `/api/v1/control-plane/invites/accept`, + method: "POST", + body: data, + secure: true, + type: ContentType.Json, + ...params, + }); + /** + * @description Reject an organization invite + * + * @name OrganizationInviteReject + * @summary Reject Organization Invite for User + * @request POST:/api/v1/control-plane/invites/reject + * @secure + */ + organizationInviteReject = ( + data: RejectOrganizationInviteRequest, + params: RequestParams = {}, + ) => + this.request({ + path: `/api/v1/control-plane/invites/reject`, + method: "POST", + body: data, + secure: true, + type: ContentType.Json, + ...params, + }); + /** + * @description List all organization invites for an organization + * + * @tags Management + * @name OrganizationInviteList + * @summary List Organization Invites for Organization + * @request GET:/api/v1/control-plane/organizations/{organization}/invites + * @secure + */ + organizationInviteList = (organization: string, params: RequestParams = {}) => + this.request({ + path: `/api/v1/control-plane/organizations/${organization}/invites`, + method: "GET", + secure: true, + format: "json", + ...params, + }); + /** + * @description Create a new organization invite + * + * @tags Management + * @name OrganizationInviteCreate + * @summary Create Organization Invite for Organization + * @request POST:/api/v1/control-plane/organizations/{organization}/invites + * @secure + */ + organizationInviteCreate = ( + organization: string, + data: CreateOrganizationInviteRequest, + params: RequestParams = {}, + ) => + this.request({ + path: `/api/v1/control-plane/organizations/${organization}/invites`, + method: "POST", + body: data, + secure: true, + type: ContentType.Json, + ...params, + }); + /** + * @description Delete an organization invite + * + * @tags Management + * @name OrganizationInviteDelete + * @summary Delete Organization Invite for Organization + * @request DELETE:/api/v1/control-plane/organization-invites/{organization-invite} + * @secure + */ + organizationInviteDelete = ( + organizationInvite: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/api/v1/control-plane/organization-invites/${organizationInvite}`, + method: "DELETE", + secure: true, + ...params, + }); + /** + * @description Lists all tenant memberships for the current user + * + * @tags User + * @name TenantMembershipsList + * @summary List tenant memberships + * @request GET:/api/v1/control-plane/users/memberships + * @secure + */ + tenantMembershipsList = (params: RequestParams = {}) => + this.request({ + path: `/api/v1/control-plane/users/memberships`, + method: "GET", + secure: true, + format: "json", + ...params, + }); + /** + * @description Lists all pending tenant invites for the current user + * + * @tags Tenant + * @name UserListTenantInvites + * @summary List tenant invites + * @request GET:/api/v1/control-plane/users/tenant-invites + * @secure + */ + userListTenantInvites = (params: RequestParams = {}) => + this.request({ + path: `/api/v1/control-plane/users/tenant-invites`, + method: "GET", + secure: true, + format: "json", + ...params, + }); + /** + * @description Rejects a tenant invite + * + * @tags Tenant + * @name TenantInviteReject + * @summary Reject tenant invite + * @request POST:/api/v1/control-plane/users/tenant-invites/reject + * @secure + */ + tenantInviteReject = ( + data: RejectTenantInviteRequest, + params: RequestParams = {}, + ) => + this.request({ + path: `/api/v1/control-plane/users/tenant-invites/reject`, + method: "POST", + body: data, + secure: true, + type: ContentType.Json, + ...params, + }); + /** + * @description Generate a signed exchange token for the tenant, embedding the tenant's API URL in the claims + * + * @tags Tenant + * @name ExchangeTokenCreate + * @summary Generate Tenant Token + * @request POST:/api/v1/control-plane/tenants/{organization-tenant}/token + * @secure + */ + exchangeTokenCreate = ( + organizationTenant: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/api/v1/control-plane/tenants/${organizationTenant}/token`, + method: "POST", + secure: true, + format: "json", + ...params, + }); + /** + * @description List all members of a tenant + * + * @tags Tenant + * @name TenantMemberList + * @summary List Tenant Members + * @request GET:/api/v1/control-plane/tenants/{tenant}/members + * @secure + */ + tenantMemberList = (tenant: string, params: RequestParams = {}) => + this.request({ + path: `/api/v1/control-plane/tenants/${tenant}/members`, + method: "GET", + secure: true, + format: "json", + ...params, + }); + /** + * @description Update a tenant member's role + * + * @tags Tenant + * @name TenantMemberUpdate + * @summary Update Tenant Member + * @request PATCH:/api/v1/control-plane/tenants/{tenant}/members/{tenant-member} + * @secure + */ + tenantMemberUpdate = ( + tenant: string, + tenantMember: string, + data: UpdateTenantMemberRequest, + params: RequestParams = {}, + ) => + this.request({ + path: `/api/v1/control-plane/tenants/${tenant}/members/${tenantMember}`, + method: "PATCH", + body: data, + secure: true, + type: ContentType.Json, + format: "json", + ...params, + }); + /** + * @description Delete a tenant member + * + * @tags Tenant + * @name TenantMemberDelete + * @summary Delete Tenant Member + * @request DELETE:/api/v1/control-plane/tenants/{tenant}/members/{tenant-member} + * @secure + */ + tenantMemberDelete = ( + tenant: string, + tenantMember: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/api/v1/control-plane/tenants/${tenant}/members/${tenantMember}`, + method: "DELETE", + secure: true, + ...params, + }); + /** + * @description List all pending invites for a tenant + * + * @tags Tenant + * @name TenantInviteList + * @summary List Tenant Invites + * @request GET:/api/v1/control-plane/tenants/{tenant}/invites + * @secure + */ + tenantInviteList = (tenant: string, params: RequestParams = {}) => + this.request({ + path: `/api/v1/control-plane/tenants/${tenant}/invites`, + method: "GET", + secure: true, + format: "json", + ...params, + }); + /** + * @description Create a new tenant invite + * + * @tags Tenant + * @name TenantInviteCreate + * @summary Create Tenant Invite + * @request POST:/api/v1/control-plane/tenants/{tenant}/invites + * @secure + */ + tenantInviteCreate = ( + tenant: string, + data: CreateTenantInviteRequest, + params: RequestParams = {}, + ) => + this.request({ + path: `/api/v1/control-plane/tenants/${tenant}/invites`, + method: "POST", + body: data, + secure: true, + type: ContentType.Json, + ...params, + }); + /** + * @description Update a tenant invite's role + * + * @tags Tenant + * @name TenantInviteUpdate + * @summary Update Tenant Invite + * @request PATCH:/api/v1/control-plane/tenants/{tenant}/invites/{tenant-invite} + * @secure + */ + tenantInviteUpdate = ( + tenant: string, + tenantInvite: string, + data: UpdateTenantInviteRequest, + params: RequestParams = {}, + ) => + this.request({ + path: `/api/v1/control-plane/tenants/${tenant}/invites/${tenantInvite}`, + method: "PATCH", + body: data, + secure: true, + type: ContentType.Json, + format: "json", + ...params, + }); + /** + * @description Delete a tenant invite + * + * @tags Tenant + * @name TenantInviteDelete + * @summary Delete Tenant Invite + * @request DELETE:/api/v1/control-plane/tenants/{tenant}/invites/{tenant-invite} + * @secure + */ + tenantInviteDelete = ( + tenant: string, + tenantInvite: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/api/v1/control-plane/tenants/${tenant}/invites/${tenantInvite}`, + method: "DELETE", + secure: true, + ...params, + }); +} diff --git a/frontend/app/src/lib/api/generated/control-plane/data-contracts.ts b/frontend/app/src/lib/api/generated/control-plane/data-contracts.ts new file mode 100644 index 0000000000..215f4c8220 --- /dev/null +++ b/frontend/app/src/lib/api/generated/control-plane/data-contracts.ts @@ -0,0 +1,270 @@ +/* eslint-disable */ +/* tslint:disable */ +// @ts-nocheck +/* + * --------------------------------------------------------------- + * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## + * ## ## + * ## AUTHOR: acacode ## + * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## + * --------------------------------------------------------------- + */ + +export enum OrganizationInviteStatus { + PENDING = "PENDING", + ACCEPTED = "ACCEPTED", + REJECTED = "REJECTED", + EXPIRED = "EXPIRED", +} + +export enum ManagementTokenDuration { + Value30D = "30D", + Value60D = "60D", + Value90D = "90D", +} + +export enum TenantStatusType { + ACTIVE = "ACTIVE", + ARCHIVED = "ARCHIVED", +} + +export enum OrganizationMemberRoleType { + OWNER = "OWNER", +} + +export interface APIControlPlaneMetadata { + /** + * the inactivity timeout to log out for user sessions in milliseconds + * @example 3600000 + */ + inactivityLogoutMs?: number; +} + +export type { APIErrors } from '@/lib/api/generated/cloud/data-contracts'; + +export type { APIError } from '@/lib/api/generated/cloud/data-contracts'; + +export type { PaginationResponse } from '@/lib/api/generated/cloud/data-contracts'; + +export type { APIResourceMeta } from '@/lib/api/generated/cloud/data-contracts'; + +export type { User } from '@/lib/api/generated/cloud/data-contracts'; + +export type { UserLoginRequest } from '@/lib/api/generated/cloud/data-contracts'; + +export type { UserChangePasswordRequest } from '@/lib/api/generated/cloud/data-contracts'; + +export type { UserRegisterRequest } from '@/lib/api/generated/cloud/data-contracts'; + +export interface Organization { + metadata: APIResourceMeta; + /** Name of the organization */ + name: string; + tenants?: OrganizationTenant[]; + members?: OrganizationMember[]; +} + +export interface OrganizationForUser { + metadata: APIResourceMeta; + /** Name of the organization */ + name: string; + tenants: OrganizationTenant[]; + /** Whether the user is the owner of the organization */ + isOwner: boolean; +} + +export interface OrganizationForUserList { + rows: OrganizationForUser[]; + pagination: PaginationResponse; +} + +export interface CreateOrganizationRequest { + /** + * Name of the organization + * @minLength 1 + * @maxLength 256 + */ + name: string; +} + +export interface UpdateOrganizationRequest { + /** + * Name of the organization + * @minLength 1 + * @maxLength 256 + */ + name: string; +} + +export interface OrganizationMember { + metadata: APIResourceMeta; + /** Type/role of the member in the organization */ + role: OrganizationMemberRoleType; + /** + * Email of the user + * @format email + */ + email: string; +} + +export interface OrganizationMemberList { + rows: OrganizationMember[]; + pagination: PaginationResponse; +} + +export interface RemoveOrganizationMembersRequest { + /** + * Array of user emails to remove from the organization + * @minItems 1 + */ + emails: string[]; +} + +export interface OrganizationTenant { + /** + * ID of the tenant + * @format uuid + */ + id: string; + /** Status of the tenant */ + status: TenantStatusType; + /** + * The timestamp at which the tenant was archived + * @format date-time + */ + archivedAt?: string; +} + +export interface OrganizationTenantList { + rows: OrganizationTenant[]; +} + +export interface CreateNewTenantForOrganizationRequest { + /** The name of the tenant. */ + name: string; + /** The slug of the tenant. */ + slug: string; +} + +export interface CreateManagementTokenRequest { + /** The name of the management token. */ + name: string; + /** @default "30D" */ + duration?: ManagementTokenDuration; +} + +export interface CreateManagementTokenResponse { + /** The token of the management token. */ + token: string; +} + +export interface ManagementToken { + /** + * The ID of the management token. + * @format uuid + */ + id: string; + /** The name of the management token. */ + name: string; + /** + * The timestamp at which the management token expires + * @format date-time + */ + expiresAt?: string; +} + +export interface ManagementTokenList { + rows: ManagementToken[]; +} + +export interface OrganizationInvite { + metadata: APIResourceMeta; + /** + * The ID of the organization + * @format uuid + */ + organizationId: string; + /** + * The email of the inviter + * @format email + */ + inviterEmail: string; + /** + * The email of the invitee + * @format email + */ + inviteeEmail: string; + /** + * The timestamp at which the invite expires + * @format date-time + */ + expires: string; + /** The status of the invite */ + status: OrganizationInviteStatus; + /** The role of the invitee */ + role: OrganizationMemberRoleType; +} + +export interface OrganizationInviteList { + rows: OrganizationInvite[]; +} + +export interface CreateOrganizationInviteRequest { + /** + * The email of the invitee + * @format email + */ + inviteeEmail: string; + /** The role of the invitee */ + role: OrganizationMemberRoleType; +} + +export interface AcceptOrganizationInviteRequest { + /** + * The ID of the organization invite + * @format uuid + */ + id: string; +} + +export interface RejectOrganizationInviteRequest { + /** + * The ID of the organization invite + * @format uuid + */ + id: string; +} + +export type { TenantMemberRole } from '@/lib/api/generated/cloud/data-contracts'; + +export type { UserTenantPublic } from '@/lib/api/generated/cloud/data-contracts'; + +export type { TenantMember } from '@/lib/api/generated/cloud/data-contracts'; + +export type { TenantMemberList } from '@/lib/api/generated/cloud/data-contracts'; + +export type { UpdateTenantMemberRequest } from '@/lib/api/generated/cloud/data-contracts'; + +export type { TenantInvite } from '@/lib/api/generated/cloud/data-contracts'; + +export type { TenantInviteList } from '@/lib/api/generated/cloud/data-contracts'; + +export type { CreateTenantInviteRequest } from '@/lib/api/generated/cloud/data-contracts'; + +export type { UpdateTenantInviteRequest } from '@/lib/api/generated/cloud/data-contracts'; + +export type { RejectInviteRequest as RejectTenantInviteRequest } from '@/lib/api/generated/cloud/data-contracts'; + +export type { UserTenantMembershipsList } from '@/lib/api/generated/cloud/data-contracts'; + +export interface TenantToken { + /** The signed exchange token for the tenant */ + token: string; + /** The API URL embedded in the token claims */ + apiUrl: string; + /** + * Timestamp at which the token expires + * @format date-time + */ + expiresAt: string; +} diff --git a/frontend/app/src/lib/api/generated/control-plane/http-client.ts b/frontend/app/src/lib/api/generated/control-plane/http-client.ts new file mode 100644 index 0000000000..a587498bd2 --- /dev/null +++ b/frontend/app/src/lib/api/generated/control-plane/http-client.ts @@ -0,0 +1,186 @@ +/* eslint-disable */ +/* tslint:disable */ +// @ts-nocheck +/* + * --------------------------------------------------------------- + * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## + * ## ## + * ## AUTHOR: acacode ## + * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## + * --------------------------------------------------------------- + */ + +import type { + AxiosInstance, + AxiosRequestConfig, + AxiosResponse, + HeadersDefaults, + ResponseType, +} from "axios"; +import axios from "axios"; + +export type QueryParamsType = Record; + +export interface FullRequestParams + extends Omit { + /** set parameter to `true` for call `securityWorker` for this request */ + secure?: boolean; + /** request path */ + path: string; + /** content type of request body */ + type?: ContentType; + /** query params */ + query?: QueryParamsType; + /** format of response (i.e. response.json() -> format: "json") */ + format?: ResponseType; + /** request body */ + body?: unknown; +} + +export type RequestParams = Omit< + FullRequestParams, + "body" | "method" | "query" | "path" +>; + +export interface ApiConfig + extends Omit { + securityWorker?: ( + securityData: SecurityDataType | null, + ) => Promise | AxiosRequestConfig | void; + secure?: boolean; + format?: ResponseType; +} + +export enum ContentType { + Json = "application/json", + JsonApi = "application/vnd.api+json", + FormData = "multipart/form-data", + UrlEncoded = "application/x-www-form-urlencoded", + Text = "text/plain", +} + +export class HttpClient { + public instance: AxiosInstance; + private securityData: SecurityDataType | null = null; + private securityWorker?: ApiConfig["securityWorker"]; + private secure?: boolean; + private format?: ResponseType; + + constructor({ + securityWorker, + secure, + format, + ...axiosConfig + }: ApiConfig = {}) { + this.instance = axios.create({ + ...axiosConfig, + baseURL: axiosConfig.baseURL || "", + }); + this.secure = secure; + this.format = format; + this.securityWorker = securityWorker; + } + + public setSecurityData = (data: SecurityDataType | null) => { + this.securityData = data; + }; + + protected mergeRequestParams( + params1: AxiosRequestConfig, + params2?: AxiosRequestConfig, + ): AxiosRequestConfig { + const method = params1.method || (params2 && params2.method); + + return { + ...this.instance.defaults, + ...params1, + ...(params2 || {}), + headers: { + ...((method && + this.instance.defaults.headers[ + method.toLowerCase() as keyof HeadersDefaults + ]) || + {}), + ...(params1.headers || {}), + ...((params2 && params2.headers) || {}), + }, + }; + } + + protected stringifyFormItem(formItem: unknown) { + if (typeof formItem === "object" && formItem !== null) { + return JSON.stringify(formItem); + } else { + return `${formItem}`; + } + } + + protected createFormData(input: Record): FormData { + if (input instanceof FormData) { + return input; + } + return Object.keys(input || {}).reduce((formData, key) => { + const property = input[key]; + const propertyContent: any[] = + property instanceof Array ? property : [property]; + + for (const formItem of propertyContent) { + const isFileType = formItem instanceof Blob || formItem instanceof File; + formData.append( + key, + isFileType ? formItem : this.stringifyFormItem(formItem), + ); + } + + return formData; + }, new FormData()); + } + + public request = async ({ + secure, + path, + type, + query, + format, + body, + ...params + }: FullRequestParams): Promise> => { + const secureParams = + ((typeof secure === "boolean" ? secure : this.secure) && + this.securityWorker && + (await this.securityWorker(this.securityData))) || + {}; + const requestParams = this.mergeRequestParams(params, secureParams); + const responseFormat = format || this.format || undefined; + + if ( + type === ContentType.FormData && + body && + body !== null && + typeof body === "object" + ) { + body = this.createFormData(body as Record); + } + + if ( + type === ContentType.Text && + body && + body !== null && + typeof body !== "string" + ) { + body = JSON.stringify(body); + } + + return this.instance.request({ + ...requestParams, + headers: { + ...(requestParams.headers || {}), + ...(type ? { "Content-Type": type } : {}), + }, + params: query, + responseType: responseFormat, + data: body, + url: path, + }); + }; +} diff --git a/frontend/app/src/lib/api/organization-wrapper.ts b/frontend/app/src/lib/api/organization-wrapper.ts new file mode 100644 index 0000000000..2a96cba510 --- /dev/null +++ b/frontend/app/src/lib/api/organization-wrapper.ts @@ -0,0 +1,132 @@ +import useControlPlane from '@/hooks/use-control-plane'; +import { cloudApi, controlPlaneApi } from '@/lib/api/api'; +import { useMemo } from 'react'; + +type OrganizationCreateRequest = Parameters< + typeof cloudApi.organizationCreate +>[0]; +type OrganizationUpdateRequest = Parameters< + typeof cloudApi.organizationUpdate +>[1]; +type OrganizationCreateTenantRequest = Parameters< + typeof cloudApi.organizationCreateTenant +>[1]; +type OrganizationMemberDeleteRequest = Parameters< + typeof cloudApi.organizationMemberDelete +>[1]; +type ManagementTokenCreateRequest = Parameters< + typeof cloudApi.managementTokenCreate +>[1]; +type OrganizationInviteAcceptRequest = Parameters< + typeof cloudApi.organizationInviteAccept +>[0]; +type OrganizationInviteRejectRequest = Parameters< + typeof cloudApi.organizationInviteReject +>[0]; +type OrganizationInviteCreateRequest = Parameters< + typeof cloudApi.organizationInviteCreate +>[1]; + +export function useOrganizationApi() { + const { isControlPlaneEnabled } = useControlPlane(); + + return useMemo( + () => ({ + organizationList: () => + isControlPlaneEnabled + ? controlPlaneApi.organizationList() + : cloudApi.organizationList(), + + organizationCreate: (data: OrganizationCreateRequest) => + isControlPlaneEnabled + ? controlPlaneApi.organizationCreate(data) + : cloudApi.organizationCreate(data), + + organizationGet: (organization: string) => + isControlPlaneEnabled + ? controlPlaneApi.organizationGet(organization) + : cloudApi.organizationGet(organization), + + organizationUpdate: ( + organization: string, + data: OrganizationUpdateRequest, + ) => + isControlPlaneEnabled + ? controlPlaneApi.organizationUpdate(organization, data) + : cloudApi.organizationUpdate(organization, data), + + organizationCreateTenant: ( + organization: string, + data: OrganizationCreateTenantRequest, + ) => + isControlPlaneEnabled + ? controlPlaneApi.organizationCreateTenant(organization, data) + : cloudApi.organizationCreateTenant(organization, data), + + organizationTenantDelete: (organizationTenant: string) => + isControlPlaneEnabled + ? controlPlaneApi.organizationTenantDelete(organizationTenant) + : cloudApi.organizationTenantDelete(organizationTenant), + + organizationMemberDelete: ( + organizationMember: string, + data: OrganizationMemberDeleteRequest, + ) => + isControlPlaneEnabled + ? controlPlaneApi.organizationMemberDelete(organizationMember, data) + : cloudApi.organizationMemberDelete(organizationMember, data), + + managementTokenCreate: ( + organization: string, + data: ManagementTokenCreateRequest, + ) => + isControlPlaneEnabled + ? controlPlaneApi.managementTokenCreate(organization, data) + : cloudApi.managementTokenCreate(organization, data), + + managementTokenList: (organization: string) => + isControlPlaneEnabled + ? controlPlaneApi.managementTokenList(organization) + : cloudApi.managementTokenList(organization), + + managementTokenDelete: (managementToken: string) => + isControlPlaneEnabled + ? controlPlaneApi.managementTokenDelete(managementToken) + : cloudApi.managementTokenDelete(managementToken), + + userListOrganizationInvites: () => + isControlPlaneEnabled + ? controlPlaneApi.userListOrganizationInvites() + : cloudApi.userListOrganizationInvites(), + + organizationInviteAccept: (data: OrganizationInviteAcceptRequest) => + isControlPlaneEnabled + ? controlPlaneApi.organizationInviteAccept(data) + : cloudApi.organizationInviteAccept(data), + + organizationInviteReject: (data: OrganizationInviteRejectRequest) => + isControlPlaneEnabled + ? controlPlaneApi.organizationInviteReject(data) + : cloudApi.organizationInviteReject(data), + + organizationInviteList: (organization: string) => + isControlPlaneEnabled + ? controlPlaneApi.organizationInviteList(organization) + : cloudApi.organizationInviteList(organization), + + organizationInviteCreate: ( + organization: string, + data: OrganizationInviteCreateRequest, + ) => + isControlPlaneEnabled + ? controlPlaneApi.organizationInviteCreate(organization, data) + : cloudApi.organizationInviteCreate(organization, data), + + organizationInviteDelete: (organizationInvite: string) => + isControlPlaneEnabled + ? controlPlaneApi.organizationInviteDelete(organizationInvite) + : cloudApi.organizationInviteDelete(organizationInvite), + }), + [isControlPlaneEnabled], + ); +} diff --git a/frontend/app/src/lib/api/queries.ts b/frontend/app/src/lib/api/queries.ts index 6fd1d6abf6..5af2f8baa3 100644 --- a/frontend/app/src/lib/api/queries.ts +++ b/frontend/app/src/lib/api/queries.ts @@ -1,6 +1,8 @@ import { WebhookWorkerCreateRequest } from '.'; -import api, { cloudApi } from './api'; +import api, { cloudApi, controlPlaneApi } from './api'; +import { inferControlPlaneEnabled } from './control-plane-status'; import { TemplateOptions } from './generated/cloud/data-contracts'; +import queryClient from '@/query-client'; import { createQueryKeyStore } from '@lukemorales/query-key-factory'; import invariant from 'tiny-invariant'; @@ -26,6 +28,21 @@ type V2TaskGetPointMetricsQuery = Parameters< type GetTaskMetricsQuery = Parameters[1]; type ListWebhooksQuery = Parameters[1]; +async function isCpEnabled(): Promise { + const cpMeta = await queryClient.fetchQuery({ + queryKey: ['control-plane-metadata:get'], + queryFn: async () => { + try { + return await controlPlaneApi.metadataGet(); + } catch { + return null; + } + }, + staleTime: 1000 * 60, + }); + return inferControlPlaneEnabled(cpMeta?.data); +} + export const queries = createQueryKeyStore({ cloud: { billing: (tenant: string) => ({ @@ -129,15 +146,42 @@ export const queries = createQueryKeyStore({ user: { current: { queryKey: ['user:get'], - queryFn: async () => (await api.userGetCurrent()).data, + queryFn: async () => { + const cpMeta = await queryClient.fetchQuery({ + queryKey: ['control-plane-metadata:get'], + queryFn: async () => { + try { + return await controlPlaneApi.metadataGet(); + } catch { + return null; + } + }, + staleTime: 1000 * 60, + }); + const isControlPlaneEnabled = inferControlPlaneEnabled(cpMeta?.data); + if (isControlPlaneEnabled) { + return (await controlPlaneApi.cloudUserGetCurrent()).data; + } + return (await api.userGetCurrent()).data; + }, }, listTenantMemberships: { queryKey: ['tenant-memberships:list'], - queryFn: async () => (await api.tenantMembershipsList()).data, + queryFn: async () => { + if (await isCpEnabled()) { + return (await controlPlaneApi.tenantMembershipsList()).data; + } + return (await api.tenantMembershipsList()).data; + }, }, listInvites: { queryKey: ['user:list:tenant-invites'], - queryFn: async () => (await api.userListTenantInvites()).data, + queryFn: async () => { + if (await isCpEnabled()) { + return (await controlPlaneApi.userListTenantInvites()).data; + } + return (await api.userListTenantInvites()).data; + }, }, }, alertingSettings: { @@ -155,7 +199,12 @@ export const queries = createQueryKeyStore({ members: { list: (tenant: string) => ({ queryKey: ['tenant-member:list', tenant], - queryFn: async () => (await api.tenantMemberList(tenant)).data, + queryFn: async () => { + if (await isCpEnabled()) { + return (await controlPlaneApi.tenantMemberList(tenant)).data; + } + return (await api.tenantMemberList(tenant)).data; + }, }), }, tokens: { @@ -185,7 +234,12 @@ export const queries = createQueryKeyStore({ invites: { list: (tenant: string) => ({ queryKey: ['tenant-invite:list', tenant], - queryFn: async () => (await api.tenantInviteList(tenant)).data, + queryFn: async () => { + if (await isCpEnabled()) { + return (await controlPlaneApi.tenantInviteList(tenant)).data; + } + return (await api.tenantInviteList(tenant)).data; + }, }), }, workflows: { diff --git a/frontend/app/src/lib/api/tenant-wrapper.ts b/frontend/app/src/lib/api/tenant-wrapper.ts new file mode 100644 index 0000000000..4b23084066 --- /dev/null +++ b/frontend/app/src/lib/api/tenant-wrapper.ts @@ -0,0 +1,81 @@ +import useControlPlane from '@/hooks/use-control-plane'; +import api, { controlPlaneApi } from '@/lib/api/api'; +import { useMemo } from 'react'; + +type TenantInviteRejectRequest = Parameters[0]; +type TenantInviteAcceptRequest = Parameters[0]; +type TenantMemberUpdateRequest = Parameters[2]; +type TenantInviteCreateRequest = Parameters[1]; +type TenantInviteUpdateRequest = Parameters[2]; + +export function useTenantApi() { + const { isControlPlaneEnabled } = useControlPlane(); + + return useMemo( + () => ({ + tenantMembershipsList: () => + isControlPlaneEnabled + ? controlPlaneApi.tenantMembershipsList() + : api.tenantMembershipsList(), + + userListTenantInvites: () => + isControlPlaneEnabled + ? controlPlaneApi.userListTenantInvites() + : api.userListTenantInvites(), + + tenantInviteReject: (data: TenantInviteRejectRequest) => + isControlPlaneEnabled + ? controlPlaneApi.tenantInviteReject(data) + : api.tenantInviteReject(data), + + // tenantInviteAccept has no control-plane equivalent — always uses main API + // TODO-CONTROL-PLANE: need to implement this... + tenantInviteAccept: (data: TenantInviteAcceptRequest) => + api.tenantInviteAccept(data), + + tenantMemberList: (tenant: string) => + isControlPlaneEnabled + ? controlPlaneApi.tenantMemberList(tenant) + : api.tenantMemberList(tenant), + + tenantMemberUpdate: ( + tenant: string, + tenantMember: string, + data: TenantMemberUpdateRequest, + ) => + isControlPlaneEnabled + ? controlPlaneApi.tenantMemberUpdate(tenant, tenantMember, data) + : api.tenantMemberUpdate(tenant, tenantMember, data), + + tenantMemberDelete: (tenant: string, tenantMember: string) => + isControlPlaneEnabled + ? controlPlaneApi.tenantMemberDelete(tenant, tenantMember) + : api.tenantMemberDelete(tenant, tenantMember), + + tenantInviteList: (tenant: string) => + isControlPlaneEnabled + ? controlPlaneApi.tenantInviteList(tenant) + : api.tenantInviteList(tenant), + + tenantInviteCreate: (tenant: string, data: TenantInviteCreateRequest) => + isControlPlaneEnabled + ? controlPlaneApi.tenantInviteCreate(tenant, data) + : api.tenantInviteCreate(tenant, data), + + tenantInviteUpdate: ( + tenant: string, + tenantInvite: string, + data: TenantInviteUpdateRequest, + ) => + isControlPlaneEnabled + ? controlPlaneApi.tenantInviteUpdate(tenant, tenantInvite, data) + : api.tenantInviteUpdate(tenant, tenantInvite, data), + + tenantInviteDelete: (tenant: string, tenantInvite: string) => + isControlPlaneEnabled + ? controlPlaneApi.tenantInviteDelete(tenant, tenantInvite) + : api.tenantInviteDelete(tenant, tenantInvite), + }), + [isControlPlaneEnabled], + ); +} diff --git a/frontend/app/src/lib/api/user-wrapper.ts b/frontend/app/src/lib/api/user-wrapper.ts new file mode 100644 index 0000000000..61b92f2e8d --- /dev/null +++ b/frontend/app/src/lib/api/user-wrapper.ts @@ -0,0 +1,61 @@ +import useControlPlane from '@/hooks/use-control-plane'; +import api, { controlPlaneApi } from '@/lib/api/api'; +import { useMemo } from 'react'; + +type UserUpdateLoginRequest = Parameters[0]; +type UserCreateRequest = Parameters[0]; +type UserUpdatePasswordRequest = Parameters[0]; + +export function useUserApi() { + const { isControlPlaneEnabled } = useControlPlane(); + + return useMemo( + () => ({ + userGetCurrent: () => + isControlPlaneEnabled + ? controlPlaneApi.cloudUserGetCurrent() + : api.userGetCurrent(), + + userUpdateLogin: (data: UserUpdateLoginRequest) => + isControlPlaneEnabled + ? controlPlaneApi.cloudUserUpdateLogin(data) + : api.userUpdateLogin(data), + + userCreate: (data: UserCreateRequest) => + isControlPlaneEnabled + ? controlPlaneApi.cloudUserCreate(data) + : api.userCreate(data), + + userUpdateLogout: () => + isControlPlaneEnabled + ? controlPlaneApi.cloudUserUpdateLogout() + : api.userUpdateLogout(), + + userUpdatePassword: (data: UserUpdatePasswordRequest) => + isControlPlaneEnabled + ? controlPlaneApi.cloudUserUpdatePassword(data) + : api.userUpdatePassword(data), + + userUpdateGoogleOauthStart: () => + isControlPlaneEnabled + ? controlPlaneApi.cloudUserUpdateGoogleOauthStart() + : api.userUpdateGoogleOauthStart(), + + userUpdateGoogleOauthCallback: () => + isControlPlaneEnabled + ? controlPlaneApi.cloudUserUpdateGoogleOauthCallback() + : api.userUpdateGoogleOauthCallback(), + + userUpdateGithubOauthStart: () => + isControlPlaneEnabled + ? controlPlaneApi.cloudUserUpdateGithubOauthStart() + : api.userUpdateGithubOauthStart(), + + userUpdateGithubOauthCallback: () => + isControlPlaneEnabled + ? controlPlaneApi.cloudUserUpdateGithubOauthCallback() + : api.userUpdateGithubOauthCallback(), + }), + [isControlPlaneEnabled], + ); +} diff --git a/frontend/app/src/pages/auth/login/index.tsx b/frontend/app/src/pages/auth/login/index.tsx index f298a53473..d93123851f 100644 --- a/frontend/app/src/pages/auth/login/index.tsx +++ b/frontend/app/src/pages/auth/login/index.tsx @@ -1,6 +1,7 @@ import { AuthPage } from '../components/auth-page'; import { UserLoginForm } from './components/user-login-form'; -import api, { UserLoginRequest } from '@/lib/api'; +import { UserLoginRequest } from '@/lib/api'; +import { useUserApi } from '@/lib/api/user-wrapper'; import { useApiError } from '@/lib/hooks'; import { appRoutes } from '@/router'; import { useMutation } from '@tanstack/react-query'; @@ -32,11 +33,12 @@ function BasicLogin() { const [errors, setErrors] = useState([]); const [fieldErrors, setFieldErrors] = useState>({}); const { handleApiError } = useApiError({ setFieldErrors, setErrors }); + const userApi = useUserApi(); const loginMutation = useMutation({ mutationKey: ['user:update:login'], mutationFn: async (data: UserLoginRequest) => { - await api.userUpdateLogin(data); + await userApi.userUpdateLogin(data); }, onSuccess: () => { navigate({ to: appRoutes.authenticatedRoute.to }); diff --git a/frontend/app/src/pages/auth/register/index.tsx b/frontend/app/src/pages/auth/register/index.tsx index 61e7cbf9a9..526b349875 100644 --- a/frontend/app/src/pages/auth/register/index.tsx +++ b/frontend/app/src/pages/auth/register/index.tsx @@ -4,7 +4,8 @@ import { POSTHOG_DISTINCT_ID_LOCAL_STORAGE_KEY, POSTHOG_SESSION_ID_LOCAL_STORAGE_KEY, } from '@/hooks/use-analytics'; -import api, { UserRegisterRequest } from '@/lib/api'; +import { UserRegisterRequest } from '@/lib/api'; +import { useUserApi } from '@/lib/api/user-wrapper'; import { useApiError } from '@/lib/hooks'; import { appRoutes } from '@/router'; import { useMutation } from '@tanstack/react-query'; @@ -58,11 +59,12 @@ function BasicRegister() { setFieldErrors: setFieldErrors, setErrors: setErrors, }); + const userApi = useUserApi(); const createMutation = useMutation({ mutationKey: ['user:create'], mutationFn: async (data: UserRegisterRequest) => { - await api.userCreate(data); + await userApi.userCreate(data); }, onSuccess: () => { navigate({ to: appRoutes.authenticatedRoute.to }); diff --git a/frontend/app/src/pages/authenticated.tsx b/frontend/app/src/pages/authenticated.tsx index 6a4837b5ae..339ba7968e 100644 --- a/frontend/app/src/pages/authenticated.tsx +++ b/frontend/app/src/pages/authenticated.tsx @@ -4,9 +4,9 @@ import TopNav from '@/components/v1/nav/top-nav.tsx'; import { useCurrentUser } from '@/hooks/use-current-user.ts'; import { usePendingInvites } from '@/hooks/use-pending-invites'; import { useTenantDetails } from '@/hooks/use-tenant'; -import api, { queries, User } from '@/lib/api'; +import { queries, User } from '@/lib/api'; import { cloudApi } from '@/lib/api/api'; -import { lastTenantAtom } from '@/lib/atoms'; +import { useUserApi } from '@/lib/api/user-wrapper'; import { useContextFromParent } from '@/lib/outlet'; import { OutletWithContext } from '@/lib/router-helpers'; import { useInactivityDetection } from '@/pages/auth/hooks/use-inactivity-detection'; @@ -19,7 +19,6 @@ import { useNavigate, } from '@tanstack/react-router'; import { AxiosError } from 'axios'; -import { useAtom } from 'jotai'; import { lazy, Suspense, useEffect } from 'react'; const DevtoolsFooter = import.meta.env.DEV @@ -33,7 +32,7 @@ function AuthenticatedInner() { error: userError, isLoading: isUserLoading, } = useCurrentUser(); - const [lastTenant, setLastTenant] = useAtom(lastTenantAtom); + const userApi = useUserApi(); const { data: cloudMetadata } = useQuery({ queryKey: ['metadata'], @@ -45,7 +44,6 @@ function AuthenticatedInner() { const navigate = useNavigate(); const location = useLocation(); - const pathname = location.pathname; const matchRoute = useMatchRoute(); const isAuthPage = Boolean(matchRoute({ to: appRoutes.authLoginRoute.to })) || @@ -73,7 +71,7 @@ function AuthenticatedInner() { const logoutMutation = useMutation({ mutationKey: ['user:update:logout'], mutationFn: async () => { - await api.userUpdateLogout(); + await userApi.userUpdateLogout(); }, onSuccess: () => { navigate({ to: appRoutes.authLoginRoute.to }); @@ -100,21 +98,54 @@ function AuthenticatedInner() { memberships: listMembershipsQuery.data?.rows, }); + useEffect(() => { + console.log('[Authenticated] render state', { + path: window.location.pathname, + isUserLoading, + hasCurrentUser: Boolean(currentUser), + userErrorStatus: (userError as AxiosError | null | undefined)?.status, + membershipsCount: listMembershipsQuery.data?.rows?.length, + pendingInvites: pendingInvitesQuery.data, + isPendingInvitesLoading, + isAuthPage, + isTenantPage, + isOrganizationsPage, + isOnboardingPage, + tenantId: tenant?.metadata.id, + }); + }, [ + isUserLoading, + currentUser, + userError, + listMembershipsQuery.data?.rows?.length, + pendingInvitesQuery.data, + isPendingInvitesLoading, + isAuthPage, + isTenantPage, + isOrganizationsPage, + isOnboardingPage, + tenant?.metadata.id, + ]); + useEffect(() => { const userQueryError = userError as AxiosError | null | undefined; + const isRootPath = location.pathname === '/'; // Skip all redirects for organization pages if (isOrganizationsPage) { + console.log('[Authenticated] skip redirects on organizations page'); return; } // If we definitively have no user, always go to login. if (!isUserLoading && !currentUser && !isAuthPage) { + console.log('[Authenticated] redirect -> login (no current user)'); navigate({ to: appRoutes.authLoginRoute.to, replace: true }); return; } if (userQueryError?.status === 401 || userQueryError?.status === 403) { + console.log('[Authenticated] redirect -> login (401/403 user query)'); navigate({ to: appRoutes.authLoginRoute.to, replace: true }); return; } @@ -124,6 +155,9 @@ function AuthenticatedInner() { !currentUser.emailVerified && !isOnboardingVerifyEmailPage ) { + console.log( + '[Authenticated] redirect -> onboarding verify (email not verified)', + ); navigate({ to: appRoutes.onboardingVerifyRoute.to, replace: true }); return; } @@ -133,6 +167,9 @@ function AuthenticatedInner() { pendingInvitesQuery.data > 0 && !isOnboardingInvitesPage ) { + console.log( + '[Authenticated] redirect -> onboarding invites (pending invites)', + ); navigate({ to: appRoutes.onboardingInvitesRoute.to, replace: true }); return; } @@ -142,59 +179,33 @@ function AuthenticatedInner() { listMembershipsQuery.data?.rows?.length === 0 && !isOnboardingPage ) { + console.log( + '[Authenticated] redirect -> onboarding create tenant (no memberships)', + ); navigate({ to: appRoutes.onboardingCreateTenantRoute.to, replace: true }); return; } - // If user has memberships and we're at the bare root, go to their first tenant - if ( - pathname === '/' && - listMembershipsQuery.data?.rows && - listMembershipsQuery.data.rows.length > 0 - ) { - const memberships = listMembershipsQuery.data.rows; - const lastTenantId = lastTenant?.metadata.id; - - const lastTenantInMemberships = lastTenantId - ? memberships.find((m) => m.tenant?.metadata.id === lastTenantId) - ?.tenant - : undefined; - - // If the cached tenant isn't in the current user's memberships (e.g. user switched), - // clear it so we don't keep trying to use a stale tenant. - if (lastTenantId && !lastTenantInMemberships) { - setLastTenant(undefined); - } + if (isRootPath && !isUserLoading && currentUser) { + const firstMembershipTenantId = + listMembershipsQuery.data?.rows?.[0]?.tenant?.metadata.id; - const targetTenant = lastTenantInMemberships ?? memberships[0].tenant; - - if (targetTenant) { - // Check if tenant has workflows to decide where to redirect - api - .workflowList(targetTenant.metadata.id, { limit: 1 }) - .then((response) => { - const hasWorkflows = - response.data.rows && response.data.rows.length > 0; - - navigate({ - to: hasWorkflows - ? appRoutes.tenantRunsRoute.to - : appRoutes.tenantOverviewRoute.to, - params: { tenant: targetTenant.metadata.id }, - replace: true, - }); - }) - .catch(() => { - // On error, default to runs page - navigate({ - to: appRoutes.tenantRunsRoute.to, - params: { tenant: targetTenant.metadata.id }, - replace: true, - }); - }); + if (firstMembershipTenantId) { + console.log('[Authenticated] redirect -> tenant runs (root path)', { + tenant: firstMembershipTenantId, + }); + navigate({ + to: appRoutes.tenantRunsRoute.to, + params: { tenant: firstMembershipTenantId }, + replace: true, + }); + return; } } + + console.log('[Authenticated] no redirect'); }, [ + location.pathname, tenant?.metadata.id, currentUser, pendingInvitesQuery.data, @@ -204,18 +215,16 @@ function AuthenticatedInner() { userError, isUserLoading, navigate, - lastTenant, - pathname, isOrganizationsPage, isOnboardingVerifyEmailPage, isOnboardingInvitesPage, isOnboardingPage, isAuthPage, - setLastTenant, ]); useEffect(() => { if (userError && !isAuthPage) { + console.log('[Authenticated] fallback redirect -> login (userError)'); navigate({ to: appRoutes.authLoginRoute.to, replace: true }); } }, [isAuthPage, navigate, userError]); diff --git a/frontend/app/src/pages/error/components/tenant-forbidden.tsx b/frontend/app/src/pages/error/components/tenant-forbidden.tsx index 97878b26c4..fe386f09ec 100644 --- a/frontend/app/src/pages/error/components/tenant-forbidden.tsx +++ b/frontend/app/src/pages/error/components/tenant-forbidden.tsx @@ -2,7 +2,7 @@ import { ErrorPageLayout } from './layout'; import { Badge } from '@/components/v1/ui/badge'; import { Button } from '@/components/v1/ui/button'; import { useCurrentUser } from '@/hooks/use-current-user'; -import api from '@/lib/api'; +import { useUserApi } from '@/lib/api/user-wrapper'; import { getOptionalStringParam } from '@/lib/router-helpers'; import { appRoutes } from '@/router'; import { useMutation } from '@tanstack/react-query'; @@ -15,11 +15,12 @@ export function TenantForbidden() { const tenant = getOptionalStringParam(params, 'tenant'); const { currentUser } = useCurrentUser(); + const userApi = useUserApi(); const logoutMutation = useMutation({ mutationKey: ['user:update:logout'], mutationFn: async () => { - await api.userUpdateLogout(); + await userApi.userUpdateLogout(); }, onSuccess: () => { navigate({ to: appRoutes.authLoginRoute.to, replace: true }); diff --git a/frontend/app/src/pages/error/index.tsx b/frontend/app/src/pages/error/index.tsx index e9d6c1e902..9bc743f33d 100644 --- a/frontend/app/src/pages/error/index.tsx +++ b/frontend/app/src/pages/error/index.tsx @@ -18,7 +18,14 @@ export default function ErrorBoundary({ error }: ErrorComponentProps) { const status = getErrorStatus(error); const statusText = getErrorStatusText(error); - console.error(error); + console.error('[ErrorBoundary] caught error', { + type: error?.constructor?.name, + status, + statusText, + isResponse: error instanceof Response, + isAxios: typeof error === 'object' && error !== null && 'isAxiosError' in error, + error, + }); if ( error instanceof TypeError && diff --git a/frontend/app/src/pages/main/info/components/version-info.tsx b/frontend/app/src/pages/main/info/components/version-info.tsx index 4df5869121..9d66cd907c 100644 --- a/frontend/app/src/pages/main/info/components/version-info.tsx +++ b/frontend/app/src/pages/main/info/components/version-info.tsx @@ -1,6 +1,6 @@ import { Spinner } from '@/components/v1/ui/loading'; import { queries } from '@/lib/api'; -import useCloud from '@/pages/auth/hooks/use-cloud'; +import useCloud from '@/hooks/use-cloud'; import { useQuery } from '@tanstack/react-query'; import React from 'react'; diff --git a/frontend/app/src/pages/main/v1/index.tsx b/frontend/app/src/pages/main/v1/index.tsx index bd92fd9c79..583faa6d24 100644 --- a/frontend/app/src/pages/main/v1/index.tsx +++ b/frontend/app/src/pages/main/v1/index.tsx @@ -9,8 +9,8 @@ import { useContextFromParent, } from '@/lib/outlet'; import { OutletWithContext, useOutletContext } from '@/lib/router-helpers'; -import useCloud from '@/pages/auth/hooks/use-cloud'; -import { useMemo } from 'react'; +import useCloud from '@/hooks/use-cloud'; +import { useEffect, useMemo } from 'react'; function Main() { const ctx = useOutletContext(); @@ -33,6 +33,25 @@ function Main() { memberships, }); + useEffect(() => { + console.log('[MainV1] render', { + path: window.location.pathname, + tenantId, + hasUser: Boolean(user), + membershipsCount: memberships?.length, + canBill: cloud?.canBill, + managedWorkerEnabled, + navSectionCount: navSections.length, + }); + }, [ + tenantId, + user, + memberships?.length, + cloud?.canBill, + managedWorkerEnabled, + navSections.length, + ]); + return ( } diff --git a/frontend/app/src/pages/main/v1/managed-workers/$managed-worker/index.tsx b/frontend/app/src/pages/main/v1/managed-workers/$managed-worker/index.tsx index f26447d7bd..14e8ebf76a 100644 --- a/frontend/app/src/pages/main/v1/managed-workers/$managed-worker/index.tsx +++ b/frontend/app/src/pages/main/v1/managed-workers/$managed-worker/index.tsx @@ -36,7 +36,7 @@ export default function ExpandedWorkflow() { const { tenantId } = useCurrentTenantId(); const { refetchInterval } = useRefetchInterval(); - const params = useParams({ from: appRoutes.tenantManagedWorkerRoute.to }); + const params = useParams({ from: appRoutes.tenantManagedWorkerRoute.id }); const managedWorkerQuery = useQuery({ ...queries.cloud.getManagedWorker(params.managedWorker), diff --git a/frontend/app/src/pages/main/v1/tenant-settings/github/index.tsx b/frontend/app/src/pages/main/v1/tenant-settings/github/index.tsx index e572c95ca2..5d218ace2b 100644 --- a/frontend/app/src/pages/main/v1/tenant-settings/github/index.tsx +++ b/frontend/app/src/pages/main/v1/tenant-settings/github/index.tsx @@ -12,7 +12,7 @@ import { queries } from '@/lib/api'; import { cloudApi } from '@/lib/api/api'; import { GithubAppInstallation } from '@/lib/api/generated/cloud/data-contracts'; import { useApiError } from '@/lib/hooks'; -import useCloud from '@/pages/auth/hooks/use-cloud'; +import useCloud from '@/hooks/use-cloud'; import { useMutation, useQuery } from '@tanstack/react-query'; import { useState, useMemo } from 'react'; import invariant from 'tiny-invariant'; diff --git a/frontend/app/src/pages/main/v1/tenant-settings/members/components/members-columns.tsx b/frontend/app/src/pages/main/v1/tenant-settings/members/components/members-columns.tsx index 6cfedd58bf..56223eb939 100644 --- a/frontend/app/src/pages/main/v1/tenant-settings/members/components/members-columns.tsx +++ b/frontend/app/src/pages/main/v1/tenant-settings/members/components/members-columns.tsx @@ -1,12 +1,13 @@ import { ConfirmDialog } from '@/components/v1/molecules/confirm-dialog'; import { TableRowActions } from '@/components/v1/molecules/data-table/data-table-row-actions'; import { useCurrentTenantId } from '@/hooks/use-tenant'; -import api, { TenantMember, queries } from '@/lib/api'; +import { TenantMember, queries } from '@/lib/api'; +import { useTenantApi } from '@/lib/api/tenant-wrapper'; import { useApiError } from '@/lib/hooks'; import { UserContextType } from '@/lib/outlet'; import { useOutletContext } from '@/lib/router-helpers'; import useApiMeta from '@/pages/auth/hooks/use-api-meta'; -import useCloud from '@/pages/auth/hooks/use-cloud'; +import useCloud from '@/hooks/use-cloud'; import queryClient from '@/query-client'; import { useMutation } from '@tanstack/react-query'; import { useState } from 'react'; @@ -27,11 +28,12 @@ export function MemberActions({ const { tenantId } = useCurrentTenantId(); const { meta } = useApiMeta(); const { isCloudEnabled } = useCloud(); + const tenantApi = useTenantApi(); const deleteMemberMutation = useMutation({ mutationKey: ['tenant-member:delete', tenantId], mutationFn: async (data: { memberId: string }) => { - await api.tenantMemberDelete(tenantId, data.memberId); + await tenantApi.tenantMemberDelete(tenantId, data.memberId); }, onSuccess: () => { queryClient.invalidateQueries({ diff --git a/frontend/app/src/pages/main/v1/tenant-settings/members/index.tsx b/frontend/app/src/pages/main/v1/tenant-settings/members/index.tsx index 34b9dd743a..4ab5cfead7 100644 --- a/frontend/app/src/pages/main/v1/tenant-settings/members/index.tsx +++ b/frontend/app/src/pages/main/v1/tenant-settings/members/index.tsx @@ -13,7 +13,7 @@ import { Separator } from '@/components/v1/ui/separator'; import { useCurrentUser } from '@/hooks/use-current-user'; import { useOrganizations } from '@/hooks/use-organizations'; import { useCurrentTenantId } from '@/hooks/use-tenant'; -import api, { +import { CreateTenantInviteRequest, TenantInvite, TenantMember, @@ -22,6 +22,8 @@ import api, { UserChangePasswordRequest, queries, } from '@/lib/api'; +import { useTenantApi } from '@/lib/api/tenant-wrapper'; +import { useUserApi } from '@/lib/api/user-wrapper'; import { useApiError } from '@/lib/hooks'; import { capitalize } from '@/lib/utils'; import useApiMeta from '@/pages/auth/hooks/use-api-meta'; @@ -72,7 +74,7 @@ function MembersList() { // Check if current user is admin const currentUserMember = useMemo(() => { return listMembersQuery.data?.rows?.find( - (member) => member.user.email === currentUser?.email, + (member: TenantMember) => member.user.email === currentUser?.email, ); }, [listMembersQuery.data?.rows, currentUser?.email]); @@ -85,7 +87,7 @@ function MembersList() { } return ( listMembersQuery.data?.rows?.filter( - (member) => member.role === 'OWNER', + (member: TenantMember) => member.role === 'OWNER', ) || [] ); }, [listMembersQuery.data?.rows, isCloudEnabled]); @@ -97,7 +99,7 @@ function MembersList() { } return ( listMembersQuery.data?.rows?.filter( - (member) => member.role !== 'OWNER', + (member: TenantMember) => member.role !== 'OWNER', ) || [] ); }, [listMembersQuery.data?.rows, isCloudEnabled]); @@ -241,6 +243,7 @@ function UpdateMember({ setFieldErrors: setFieldErrors, }); const navigate = useNavigate(); + const tenantApi = useTenantApi(); // Check if this is a cloud tenant and if we're trying to modify an OWNER const isOwnerRole = member.role === 'OWNER'; @@ -266,7 +269,7 @@ function UpdateMember({ 'OWNER role management must be done through Organization Settings', ); } - await api.tenantMemberUpdate(tenantId, member.metadata.id, data); + await tenantApi.tenantMemberUpdate(tenantId, member.metadata.id, data); }, onSuccess: onSuccess, onError: handleApiError, @@ -421,13 +424,14 @@ function CreateInvite({ const { handleApiError } = useApiError({ setFieldErrors: setFieldErrors, }); + const tenantApi = useTenantApi(); const organizationId = getOrganizationIdForTenant(tenantId); const createMutation = useMutation({ mutationKey: ['tenant-invite:create', tenantId], mutationFn: async (data: CreateTenantInviteRequest) => { - await api.tenantInviteCreate(tenantId, data); + await tenantApi.tenantInviteCreate(tenantId, data); }, onSuccess: onSuccess, onError: handleApiError, @@ -464,11 +468,12 @@ function UpdateInvite({ const { handleApiError } = useApiError({ setFieldErrors: setFieldErrors, }); + const tenantApi = useTenantApi(); const updateMutation = useMutation({ mutationKey: ['tenant-invite:update', tenantId, tenantInvite], mutationFn: async (data: UpdateTenantInviteRequest) => { - await api.tenantInviteUpdate(tenantId, tenantInvite.metadata.id, data); + await tenantApi.tenantInviteUpdate(tenantId, tenantInvite.metadata.id, data); }, onSuccess: onSuccess, onError: handleApiError, @@ -497,11 +502,12 @@ function DeleteInvite({ }) { const { tenantId } = useCurrentTenantId(); const { handleApiError } = useApiError({}); + const tenantApi = useTenantApi(); const deleteMutation = useMutation({ mutationKey: ['tenant-invite:delete', tenantId, tenantInvite], mutationFn: async () => { - await api.tenantInviteDelete(tenantId, tenantInvite.metadata.id); + await tenantApi.tenantInviteDelete(tenantId, tenantInvite.metadata.id); }, onSuccess: onSuccess, onError: handleApiError, @@ -533,11 +539,12 @@ function ChangePassword({ const { handleApiError } = useApiError({ setFieldErrors: setFieldErrors, }); + const userApi = useUserApi(); const updatePasswordMutation = useMutation({ mutationKey: ['user:update', tenantId], mutationFn: async (data: UserChangePasswordRequest) => { - const res = await api.userUpdatePassword(data); + const res = await userApi.userUpdatePassword(data); return res.data; }, onMutate: () => { diff --git a/frontend/app/src/pages/main/v1/tenant-settings/resource-limits/index.tsx b/frontend/app/src/pages/main/v1/tenant-settings/resource-limits/index.tsx index cc4006b6dd..cd42c07882 100644 --- a/frontend/app/src/pages/main/v1/tenant-settings/resource-limits/index.tsx +++ b/frontend/app/src/pages/main/v1/tenant-settings/resource-limits/index.tsx @@ -11,7 +11,7 @@ import { Spinner } from '@/components/v1/ui/loading'; import { Separator } from '@/components/v1/ui/separator'; import { useCurrentTenantId } from '@/hooks/use-tenant'; import { queries, TenantMemberRole, TenantResourceLimit } from '@/lib/api'; -import useCloud from '@/pages/auth/hooks/use-cloud'; +import useCloud from '@/hooks/use-cloud'; import { useAppContext } from '@/providers/app-context'; import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'; import { useQuery } from '@tanstack/react-query'; diff --git a/frontend/app/src/pages/main/v1/workers/$worker/index.tsx b/frontend/app/src/pages/main/v1/workers/$worker/index.tsx index 5608d28e46..c088d02b87 100644 --- a/frontend/app/src/pages/main/v1/workers/$worker/index.tsx +++ b/frontend/app/src/pages/main/v1/workers/$worker/index.tsx @@ -110,7 +110,7 @@ export default function WorkerDetail() { const { refetchInterval } = useRefetchInterval(); const [showAllActions, setShowAllActions] = useState(false); - const params = useParams({ from: appRoutes.tenantWorkerRoute.to }); + const params = useParams({ from: appRoutes.tenantWorkerRoute.id }); const workerQuery = useQuery({ ...queries.workers.get(params.worker), diff --git a/frontend/app/src/pages/main/v1/workflow-runs-v1/$run/index.tsx b/frontend/app/src/pages/main/v1/workflow-runs-v1/$run/index.tsx index 10b78a643c..cd5f5a259e 100644 --- a/frontend/app/src/pages/main/v1/workflow-runs-v1/$run/index.tsx +++ b/frontend/app/src/pages/main/v1/workflow-runs-v1/$run/index.tsx @@ -122,7 +122,7 @@ async function fetchDAGRun(id: string) { } export default function Run() { - const params = useParams({ from: appRoutes.tenantRunRoute.to }); + const params = useParams({ from: appRoutes.tenantRunRoute.id }); const { run } = params; const taskRunQuery = useQuery({ diff --git a/frontend/app/src/pages/main/v1/workflow-runs-v1/$run/v2components/step-run-detail/step-run-detail.tsx b/frontend/app/src/pages/main/v1/workflow-runs-v1/$run/v2components/step-run-detail/step-run-detail.tsx index b3f566abc0..ae94630def 100644 --- a/frontend/app/src/pages/main/v1/workflow-runs-v1/$run/v2components/step-run-detail/step-run-detail.tsx +++ b/frontend/app/src/pages/main/v1/workflow-runs-v1/$run/v2components/step-run-detail/step-run-detail.tsx @@ -59,7 +59,7 @@ const TaskRunPermalinkOrBacklink = ({ taskRun: V1TaskSummary; showViewTaskRunButton: boolean; }) => { - const { tenant } = useParams({ from: appRoutes.tenantRoute.to }); + const { tenant } = useParams({ from: appRoutes.tenantRoute.id }); if (showViewTaskRunButton) { return ( diff --git a/frontend/app/src/pages/main/v1/workflow-runs-v1/hooks/use-is-task-run-skipped.ts b/frontend/app/src/pages/main/v1/workflow-runs-v1/hooks/use-is-task-run-skipped.ts index ce1e08fb1c..0fab8d146e 100644 --- a/frontend/app/src/pages/main/v1/workflow-runs-v1/hooks/use-is-task-run-skipped.ts +++ b/frontend/app/src/pages/main/v1/workflow-runs-v1/hooks/use-is-task-run-skipped.ts @@ -12,7 +12,7 @@ export const useIsTaskRunSkipped = ({ taskRunId, limit = 50, }: UseIsTaskRunSkippedProps) => { - const { tenant } = useParams({ from: appRoutes.tenantRoute.to }); + const { tenant } = useParams({ from: appRoutes.tenantRoute.id }); const eventsQuery = useQuery({ ...queries.v1TaskEvents.list(tenant, { limit, offset: 0 }, taskRunId), }); diff --git a/frontend/app/src/pages/main/v1/workflow-runs-v1/hooks/use-workflow-details.tsx b/frontend/app/src/pages/main/v1/workflow-runs-v1/hooks/use-workflow-details.tsx index 42d1a86cfe..d17efd63f0 100644 --- a/frontend/app/src/pages/main/v1/workflow-runs-v1/hooks/use-workflow-details.tsx +++ b/frontend/app/src/pages/main/v1/workflow-runs-v1/hooks/use-workflow-details.tsx @@ -18,7 +18,7 @@ export function isTerminalState(status: V1TaskStatus | undefined) { } export const useWorkflowDetails = () => { - const params = useParams({ from: appRoutes.tenantRunRoute.to }); + const params = useParams({ from: appRoutes.tenantRunRoute.id }); const { data, isLoading, isError, error } = useQuery({ retry: defaultQueryRetry, diff --git a/frontend/app/src/pages/main/v1/workflow-runs-v1/index.tsx b/frontend/app/src/pages/main/v1/workflow-runs-v1/index.tsx index 111144fd5e..35b3377d75 100644 --- a/frontend/app/src/pages/main/v1/workflow-runs-v1/index.tsx +++ b/frontend/app/src/pages/main/v1/workflow-runs-v1/index.tsx @@ -1,7 +1,14 @@ import { RunsTable } from './components/runs-table'; import { RunsProvider } from './hooks/runs-provider'; +import { useEffect } from 'react'; export default function Tasks() { + useEffect(() => { + console.log('[WorkflowRunsV1] mount', { + path: window.location.pathname, + }); + }, []); + return (
diff --git a/frontend/app/src/pages/main/v1/workflows/$workflow/index.tsx b/frontend/app/src/pages/main/v1/workflows/$workflow/index.tsx index be4effa55a..3186785844 100644 --- a/frontend/app/src/pages/main/v1/workflows/$workflow/index.tsx +++ b/frontend/app/src/pages/main/v1/workflows/$workflow/index.tsx @@ -45,7 +45,7 @@ export default function ExpandedWorkflow() { const [deleteWorkflow, setDeleteWorkflow] = useState(false); const { refetchInterval } = useRefetchInterval(); - const params = useParams({ from: appRoutes.tenantWorkflowRoute.to }); + const params = useParams({ from: appRoutes.tenantWorkflowRoute.id }); const workflowQuery = useQuery({ ...queries.workflows.get(params.workflow), @@ -296,7 +296,7 @@ export default function ExpandedWorkflow() { } function RecentRunsList() { - const params = useParams({ from: appRoutes.tenantWorkflowRoute.to }); + const params = useParams({ from: appRoutes.tenantWorkflowRoute.id }); return ( { +interface TenantCreateFormProps extends OnboardingStepProps<{ + name: string; + environment: string; + referralSource?: string; +}> { organizationList?: OrganizationForUserList; selectedOrganizationId?: string | null; onOrganizationChange?: (organizationId: string) => void; @@ -65,6 +64,7 @@ export function TenantCreateForm({ const navigate = useNavigate(); const { handleCreateOrganization, createOrganizationLoading } = useOrganizations(); + const userApi = useUserApi(); const [showCreateOrgModal, setShowCreateOrgModal] = useState(false); const [orgName, setOrgName] = useState(''); @@ -72,7 +72,7 @@ export function TenantCreateForm({ const logoutMutation = useMutation({ mutationKey: ['user:update:logout'], mutationFn: async () => { - await api.userUpdateLogout(); + await userApi.userUpdateLogout(); }, onSuccess: () => { navigate({ to: appRoutes.authLoginRoute.to, replace: true }); diff --git a/frontend/app/src/pages/onboarding/create-tenant/index.tsx b/frontend/app/src/pages/onboarding/create-tenant/index.tsx index 6d0cd98fe6..d3099f9be0 100644 --- a/frontend/app/src/pages/onboarding/create-tenant/index.tsx +++ b/frontend/app/src/pages/onboarding/create-tenant/index.tsx @@ -1,5 +1,4 @@ import { HeroPanel } from '../../auth/components/hero-panel'; -import useCloud from '../../auth/hooks/use-cloud'; import { TenantCreateForm } from './components/tenant-create-form'; import { OnboardingFormData } from './types'; import { Button } from '@/components/v1/ui/button'; @@ -16,9 +15,10 @@ import api, { CreateTenantRequest, queries, Tenant, + TenantMember, TenantEnvironment, } from '@/lib/api'; -import { cloudApi } from '@/lib/api/api'; +import { useOrganizationApi } from '@/lib/api/organization-wrapper'; import { OrganizationTenant } from '@/lib/api/generated/cloud/data-contracts'; import { useApiError } from '@/lib/hooks'; import { useSearchParams } from '@/lib/router-helpers'; @@ -35,14 +35,16 @@ import { useMemo, useState, useEffect } from 'react'; function CreateTenantInner() { const [searchParams] = useSearchParams(); - const { organizationData, isCloudEnabled } = useOrganizations(); - const { cloud } = useCloud(); + const { organizationData, isCloudEnabled, isControlPlaneEnabled } = + useOrganizations(); + const orgApi = useOrganizationApi(); const [showHelp, setShowHelp] = useState(false); const { capture } = useAnalytics(); const { pendingInvitesQuery, isLoading: isPendingInvitesLoading } = usePendingInvites(); const organizationId = searchParams.get('organizationId'); + const isOrganizationBacked = isCloudEnabled || isControlPlaneEnabled; // Track page view useEffect(() => { @@ -106,16 +108,22 @@ function CreateTenantInner() { const existingTenantNames = useMemo(() => { return (listMembershipsQuery.data?.rows ?? []) - .map((m) => m.tenant?.name) - .filter((n): n is string => Boolean(n && n.trim().length > 0)); + .map((m: TenantMember) => m.tenant?.name) + .filter((n: string | undefined): n is string => + Boolean(n && n.trim().length > 0), + ); }, [listMembershipsQuery.data?.rows]); const createMutation = useMutation({ mutationKey: ['user:update:login'], mutationFn: async (data: CreateTenantRequest) => { - // Use cloud API if cloud is enabled and organization is selected - if (cloud && selectedOrganizationId) { - const result = await cloudApi.organizationCreateTenant( + // In cloud/control-plane mode, tenant creation is organization-scoped. + if (isOrganizationBacked) { + if (!selectedOrganizationId) { + throw new Error('Organization is required to create a tenant'); + } + + const result = await orgApi.organizationCreateTenant( selectedOrganizationId, { name: data.name, @@ -123,13 +131,13 @@ function CreateTenantInner() { }, ); - return { type: 'cloud', data: result.data }; - } else { - // Use regular API for self-hosted - const tenant = await api.tenantCreate(data); - - return { type: 'regular', data: tenant.data }; + return { type: 'organization', data: result.data }; } + + // OSS/self-hosted path without organization-scoped create. + const tenant = await api.tenantCreate(data); + + return { type: 'regular', data: tenant.data }; }, onSuccess: async (result) => { await listMembershipsQuery.refetch(); @@ -137,11 +145,11 @@ function CreateTenantInner() { // Track tenant creation capture('onboarding_tenant_created', { tenant_type: result.type, - is_cloud: result.type === 'cloud', + is_cloud: result.type === 'organization', }); setTimeout(() => { - if (result.type === 'cloud') { + if (result.type === 'organization') { const tenant = result.data as OrganizationTenant; navigate({ to: appRoutes.tenantOverviewRoute.to, @@ -169,7 +177,7 @@ function CreateTenantInner() { errors.name = 'Name must be between 1 and 32 characters'; } - if (isCloudEnabled && !selectedOrganizationId) { + if (isOrganizationBacked && !selectedOrganizationId) { errors.organizationId = 'Please select an organization'; } @@ -205,7 +213,8 @@ function CreateTenantInner() { const slug = generateSlug(tenantData.name); // Build onboarding data object - const onboardingData: Record = {}; + const onboardingData: NonNullable = + {}; if (tenantData.referralSource && tenantData.referralSource.trim() !== '') { onboardingData.referral_source = tenantData.referralSource; } @@ -311,7 +320,7 @@ function CreateTenantInner() { organizationList={organizationData} selectedOrganizationId={selectedOrganizationId} onOrganizationChange={setSelectedOrganizationId} - isCloudEnabled={isCloudEnabled} + isCloudEnabled={isOrganizationBacked} existingTenantNames={existingTenantNames} />
diff --git a/frontend/app/src/pages/onboarding/invites/index.tsx b/frontend/app/src/pages/onboarding/invites/index.tsx index 25ef6f4e65..f10fe66bb4 100644 --- a/frontend/app/src/pages/onboarding/invites/index.tsx +++ b/frontend/app/src/pages/onboarding/invites/index.tsx @@ -2,8 +2,11 @@ import { Button } from '@/components/v1/ui/button'; import { useAnalytics } from '@/hooks/use-analytics'; import { useOrganizations } from '@/hooks/use-organizations'; import { useTenantDetails } from '@/hooks/use-tenant'; -import api, { queries } from '@/lib/api'; -import { cloudApi } from '@/lib/api/api'; +import api, { TenantInvite, TenantMember, queries } from '@/lib/api'; +import { cloudApi, controlPlaneApi } from '@/lib/api/api'; +import { inferControlPlaneEnabled } from '@/lib/api/control-plane-status'; +import type { OrganizationInvite } from '@/lib/api/generated/cloud/data-contracts'; +import { useTenantApi } from '@/lib/api/tenant-wrapper'; import { useApiError } from '@/lib/hooks'; import { appRoutes } from '@/router'; import { useMutation, useQueryClient } from '@tanstack/react-query'; @@ -12,23 +15,34 @@ import { useEffect } from 'react'; // eslint-disable-next-line @typescript-eslint/no-unused-vars export async function loader(_args: { request: Request }) { - // Avoid calling cloud-only endpoints (like /management/invites) unless cloud is enabled. - // In OSS environments, cloud endpoints can return a 403 and create noisy console logs. + // Detect which backend is active. Control plane takes priority over cloud. + let isControlPlaneEnabled = false; let isCloudEnabled = false; try { - const meta = await cloudApi.metadataGet(); - // In OSS, the API returns an `errors` field instead of cloud metadata. - // @ts-expect-error `errors` may be present in OSS mode - isCloudEnabled = !!meta?.data && !meta?.data?.errors; + const cpMeta = await controlPlaneApi.metadataGet(); + isControlPlaneEnabled = inferControlPlaneEnabled(cpMeta?.data); } catch { - isCloudEnabled = false; + isControlPlaneEnabled = false; } + if (!isControlPlaneEnabled) { + try { + const meta = await cloudApi.metadataGet(); + isCloudEnabled = inferControlPlaneEnabled(meta?.data); + } catch { + isCloudEnabled = false; + } + } + + const hasOrgInvites = isControlPlaneEnabled || isCloudEnabled; + const [tenantInvitesRes, orgInvitesRes] = await Promise.allSettled([ - api.userListTenantInvites(), - isCloudEnabled - ? cloudApi + isControlPlaneEnabled + ? controlPlaneApi.userListTenantInvites() + : api.userListTenantInvites(), + hasOrgInvites + ? (isControlPlaneEnabled ? controlPlaneApi : cloudApi) .userListOrganizationInvites() .catch(() => ({ data: { rows: [] } })) : Promise.resolve({ data: { rows: [] } }), @@ -36,11 +50,11 @@ export async function loader(_args: { request: Request }) { const tenantInvites = tenantInvitesRes.status === 'fulfilled' - ? tenantInvitesRes.value.data.rows || [] + ? ((tenantInvitesRes.value.data.rows || []) as TenantInvite[]) : []; const orgInvites = orgInvitesRes.status === 'fulfilled' - ? orgInvitesRes.value.data.rows || [] + ? ((orgInvitesRes.value.data.rows || []) as OrganizationInvite[]) : []; if (tenantInvites.length === 0 && orgInvites.length === 0) { @@ -61,9 +75,10 @@ export default function Invites() { const { acceptOrgInviteMutation, rejectOrgInviteMutation } = useOrganizations(); const { capture } = useAnalytics(); + const tenantApi = useTenantApi(); const { tenantInvites, orgInvites } = useLoaderData({ - from: appRoutes.onboardingInvitesRoute.to, + from: appRoutes.onboardingInvitesRoute.id, }) as Awaited>; // Track invites page view @@ -97,21 +112,20 @@ export default function Invites() { ); const membership = memberships.rows?.find( - (m) => m.tenant?.metadata.id === tenantId, + (m: TenantMember) => m.tenant?.metadata.id === tenantId, ); if (membership?.tenant) { setTenant(membership.tenant); - capture('onboarding_tenant_invite_accepted', { - tenant_id: tenantId, - }); - navigate({ - to: appRoutes.tenantOverviewRoute.to, - params: { tenant: tenantId }, - }); - } else { - throw new Error('Tenant not found after accepting invite'); } + + capture('onboarding_tenant_invite_accepted', { + tenant_id: tenantId, + }); + navigate({ + to: appRoutes.tenantOverviewRoute.to, + params: { tenant: tenantId }, + }); }, onError: handleApiError, }); @@ -119,7 +133,7 @@ export default function Invites() { const rejectMutation = useMutation({ mutationKey: ['tenant-invite:reject'], mutationFn: async (data: { invite: string }) => { - await api.tenantInviteReject(data); + await tenantApi.tenantInviteReject(data); return data.invite; }, onSuccess: async (inviteId: string) => { diff --git a/frontend/app/src/pages/onboarding/verify-email/index.tsx b/frontend/app/src/pages/onboarding/verify-email/index.tsx index 764a9c0551..c6f8379410 100644 --- a/frontend/app/src/pages/onboarding/verify-email/index.tsx +++ b/frontend/app/src/pages/onboarding/verify-email/index.tsx @@ -34,7 +34,7 @@ export async function loader({ request }: { request: Request }) { function VerifyEmailInner() { const res = useLoaderData({ - from: appRoutes.onboardingVerifyRoute.to, + from: appRoutes.onboardingVerifyRoute.id, }) as Awaited>; const { capture } = useAnalytics(); diff --git a/frontend/app/src/pages/organizations/$organization/components/invite-member-modal.tsx b/frontend/app/src/pages/organizations/$organization/components/invite-member-modal.tsx index e759dfdd6f..1298856bd5 100644 --- a/frontend/app/src/pages/organizations/$organization/components/invite-member-modal.tsx +++ b/frontend/app/src/pages/organizations/$organization/components/invite-member-modal.tsx @@ -8,7 +8,7 @@ import { } from '@/components/v1/ui/dialog'; import { Input } from '@/components/v1/ui/input'; import { Label } from '@/components/v1/ui/label'; -import { cloudApi } from '@/lib/api/api'; +import { useOrganizationApi } from '@/lib/api/organization-wrapper'; import { OrganizationMemberRoleType } from '@/lib/api/generated/cloud/data-contracts'; import { useApiError } from '@/lib/hooks'; import { UserPlusIcon } from '@heroicons/react/24/outline'; @@ -42,6 +42,7 @@ export function InviteMemberModal({ const { handleApiError } = useApiError({ setFieldErrors: setFieldErrors, }); + const orgApi = useOrganizationApi(); const { register, @@ -57,7 +58,7 @@ export function InviteMemberModal({ const inviteMemberMutation = useMutation({ mutationFn: async (data: { email: string }) => { - const result = await cloudApi.organizationInviteCreate(organizationId, { + const result = await orgApi.organizationInviteCreate(organizationId, { inviteeEmail: data.email, role: OrganizationMemberRoleType.OWNER, }); diff --git a/frontend/app/src/pages/organizations/$organization/index.tsx b/frontend/app/src/pages/organizations/$organization/index.tsx index bdf7df29b2..214543b85c 100644 --- a/frontend/app/src/pages/organizations/$organization/index.tsx +++ b/frontend/app/src/pages/organizations/$organization/index.tsx @@ -39,7 +39,7 @@ import { import { useCurrentUser } from '@/hooks/use-current-user'; import { useOrganizations } from '@/hooks/use-organizations'; import api from '@/lib/api'; -import { cloudApi } from '@/lib/api/api'; +import { useOrganizationApi } from '@/lib/api/organization-wrapper'; import { OrganizationMember, ManagementToken, @@ -70,12 +70,13 @@ import { useState } from 'react'; export default function OrganizationPage() { const { organization: orgId } = useParams({ - from: appRoutes.organizationsRoute.to, + from: appRoutes.organizationsRoute.id, }); const navigate = useNavigate(); const queryClient = useQueryClient(); const { handleUpdateOrganization, updateOrganizationLoading } = useOrganizations(); + const orgApi = useOrganizationApi(); const [showInviteMemberModal, setShowInviteMemberModal] = useState(false); const [memberToDelete, setMemberToDelete] = useState(null); @@ -151,7 +152,7 @@ export default function OrganizationPage() { if (!orgId) { throw new Error('Organization ID is required'); } - const result = await cloudApi.organizationGet(orgId); + const result = await orgApi.organizationGet(orgId); return result.data; }, enabled: !!orgId, @@ -186,7 +187,7 @@ export default function OrganizationPage() { if (!orgId) { throw new Error('Organization ID is required'); } - const result = await cloudApi.managementTokenList(orgId); + const result = await orgApi.managementTokenList(orgId); return result.data; }, enabled: !!orgId, @@ -199,7 +200,7 @@ export default function OrganizationPage() { if (!orgId) { throw new Error('Organization ID is required'); } - const result = await cloudApi.organizationInviteList(orgId); + const result = await orgApi.organizationInviteList(orgId); return result.data; }, enabled: !!orgId, diff --git a/frontend/app/src/providers/app-context.tsx b/frontend/app/src/providers/app-context.tsx index 9e8e028a29..e353c8d254 100644 --- a/frontend/app/src/providers/app-context.tsx +++ b/frontend/app/src/providers/app-context.tsx @@ -1,8 +1,9 @@ -import { queries, Tenant, User } from '@/lib/api'; -import { cloudApi } from '@/lib/api/api'; +import { queries, Tenant, TenantMember, User } from '@/lib/api'; +import { useOrganizationApi } from '@/lib/api/organization-wrapper'; import type { OrganizationForUserList } from '@/lib/api/generated/cloud/data-contracts'; import { lastTenantAtom } from '@/lib/atoms'; -import useCloud from '@/pages/auth/hooks/use-cloud'; +import useCloud from '@/hooks/use-cloud'; +import useControlPlane from '@/hooks/use-control-plane'; import { useQuery } from '@tanstack/react-query'; import { useParams } from '@tanstack/react-router'; import { useAtom } from 'jotai'; @@ -43,6 +44,7 @@ interface AppContextValue { organizations: OrganizationForUserList | undefined; isOrganizationsLoading: boolean; isCloudEnabled: boolean; + isControlPlaneEnabled: boolean; // Helper to get organization for current tenant getCurrentOrganization: () => @@ -57,7 +59,9 @@ interface AppContextProviderProps { } export function AppContextProvider({ children }: AppContextProviderProps) { - const { isCloudEnabled } = useCloud(); + const { isCloudEnabled, isCloudLoading } = useCloud(); + const { isControlPlaneEnabled, isControlPlaneLoading } = useControlPlane(); + const orgApi = useOrganizationApi(); // Get tenant ID from route params (following TanStack Router best practices) // This replaces the old useCurrentTenantId pattern @@ -65,7 +69,7 @@ export function AppContextProvider({ children }: AppContextProviderProps) { const [lastTenant, setLastTenant] = useAtom(lastTenantAtom); const tenantId = params.tenant || lastTenant?.metadata.id; - // Fetch current user + // Fetch current user (routes to control plane when enabled via queries.user.current) const currentUserQuery = useQuery({ ...queries.user.current, retry: false, @@ -76,14 +80,17 @@ export function AppContextProvider({ children }: AppContextProviderProps) { ...queries.user.listTenantMemberships, }); - // Fetch organizations (cloud only) + // Fetch organizations (cloud or control plane) const organizationsQuery = useQuery({ queryKey: ['organization:list'], queryFn: async () => { - const result = await cloudApi.organizationList(); + const result = await orgApi.organizationList(); return result.data; }, - enabled: isCloudEnabled, + enabled: + (isCloudEnabled || isControlPlaneEnabled) && + !isCloudLoading && + !isControlPlaneLoading, }); // Compute current membership and tenant @@ -93,7 +100,7 @@ export function AppContextProvider({ children }: AppContextProviderProps) { } return membershipsQuery.data.rows.find( - (m) => m.tenant?.metadata.id === tenantId, + (m: TenantMember) => m.tenant?.metadata.id === tenantId, ); }, [tenantId, membershipsQuery.data?.rows]); @@ -138,6 +145,7 @@ export function AppContextProvider({ children }: AppContextProviderProps) { organizations: organizationsQuery.data, isOrganizationsLoading: organizationsQuery.isLoading, isCloudEnabled, + isControlPlaneEnabled, // Helpers getCurrentOrganization, @@ -154,10 +162,46 @@ export function AppContextProvider({ children }: AppContextProviderProps) { organizationsQuery.data, organizationsQuery.isLoading, isCloudEnabled, + isControlPlaneEnabled, getCurrentOrganization, ], ); + useEffect(() => { + console.log('[AppContext] state', { + path: window.location.pathname, + tenantId, + tenantFromMembership: tenant?.metadata.id, + membershipRole: membership?.role, + lastTenantId: lastTenant?.metadata.id, + isUserLoading: currentUserQuery.isLoading, + hasUser: Boolean(currentUserQuery.data), + membershipsCount: membershipsQuery.data?.rows?.length, + isMembershipsLoading: membershipsQuery.isLoading, + isCloudEnabled, + isCloudLoading, + isControlPlaneEnabled, + isControlPlaneLoading, + isOrganizationsLoading: organizationsQuery.isLoading, + organizationsCount: organizationsQuery.data?.rows?.length, + }); + }, [ + tenantId, + tenant?.metadata.id, + membership?.role, + lastTenant?.metadata.id, + currentUserQuery.isLoading, + currentUserQuery.data, + membershipsQuery.data?.rows?.length, + membershipsQuery.isLoading, + isCloudEnabled, + isCloudLoading, + isControlPlaneEnabled, + isControlPlaneLoading, + organizationsQuery.isLoading, + organizationsQuery.data?.rows?.length, + ]); + return {children}; } diff --git a/frontend/app/src/router.tsx b/frontend/app/src/router.tsx index f802495b0a..a2320c6f85 100644 --- a/frontend/app/src/router.tsx +++ b/frontend/app/src/router.tsx @@ -2,6 +2,16 @@ import { NotFound } from './pages/error/components/not-found'; import ErrorBoundary from './pages/error/index.tsx'; import Root from './pages/root.tsx'; import api, { queries } from '@/lib/api'; +import { controlPlaneApi } from '@/lib/api/api'; +import { + inferControlPlaneEnabled, + readStoredControlPlaneEnabled, + writeStoredControlPlaneEnabled, +} from '@/lib/api/control-plane-status'; +import { + exchangeTokenQueryOptions, + type TenantTokenData, +} from '@/lib/api/exchange-token'; import queryClient from '@/query-client'; import { RouterProvider, @@ -15,6 +25,11 @@ import { Outlet } from '@tanstack/react-router'; import { FC } from 'react'; import { validate } from 'uuid'; +function LoggedNotFound({ scope }: { scope: string }) { + console.log('[router][not-found]', { scope, path: window.location.pathname }); + return ; +} + const rootRoute = createRootRoute({ component: Root, errorComponent: (props) => ( @@ -24,7 +39,7 @@ const rootRoute = createRootRoute({ ), notFoundComponent: () => ( - + ), }); @@ -91,16 +106,22 @@ const organizationsRoute = createRoute({ const authenticatedRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/', + id: 'authenticated', component: lazyRouteComponent( () => import('./pages/authenticated'), 'default', ), - notFoundComponent: () => , + notFoundComponent: () => , +}); + +const authenticatedIndexRoute = createRoute({ + getParentRoute: () => authenticatedRoute, + path: '/', + component: () => null, }); const onboardingCreateTenantRoute = createRoute({ - getParentRoute: () => rootRoute, + getParentRoute: () => authenticatedRoute, path: 'onboarding/create-tenant', component: lazyRouteComponent( () => import('./pages/onboarding/create-tenant'), @@ -136,33 +157,138 @@ const tenantRoute = createRoute({ getParentRoute: () => authenticatedRoute, path: 'tenants/$tenant', loader: async ({ params }) => { - // Ensure the tenant in the URL is one the user actually has access to. - // If not, throw a 403 so the global error boundary can show a friendly message. - const memberships = await queryClient.fetchQuery({ - ...queries.user.listTenantMemberships, - retry: false, + console.log('[router][tenantRoute.loader] start', { + tenant: params.tenant, }); + let memberships: unknown = null; + + try { + memberships = await queryClient.fetchQuery({ + ...queries.user.listTenantMemberships, + retry: false, + }); + } catch { + memberships = null; + } + + const rows = Array.isArray((memberships as { rows?: unknown[] } | null)?.rows) + ? ((memberships as { rows?: { tenant?: { metadata?: { id?: string } } }[] }).rows ?? []) + : []; - const hasAccess = Boolean( - memberships?.rows?.some((m) => m.tenant?.metadata.id === params.tenant), + const hasAccessViaMembership = rows.some( + (membership) => membership.tenant?.metadata?.id === params.tenant, ); + console.log('[router][tenantRoute.loader] memberships', { + tenant: params.tenant, + rowCount: rows.length, + hasAccessViaMembership, + }); - if (!hasAccess) { - throw new Response('Forbidden', { status: 403, statusText: 'Forbidden' }); + const resolveControlPlaneEnabled = async () => { + const stored = readStoredControlPlaneEnabled(); + if (stored !== null) { + return stored; + } + + const cpMeta = await queryClient.fetchQuery({ + queryKey: ['control-plane-metadata:get'], + queryFn: async () => { + try { + return await controlPlaneApi.metadataGet(); + } catch { + return null; + } + }, + staleTime: 1000 * 60, + }); + + const enabled = inferControlPlaneEnabled(cpMeta?.data); + writeStoredControlPlaneEnabled(enabled); + return enabled; + }; + + const isControlPlaneEnabled = await resolveControlPlaneEnabled(); + + if (!hasAccessViaMembership) { + console.log('[router][tenantRoute.loader] cp fallback', { + tenant: params.tenant, + isControlPlaneEnabled, + }); + if (!isControlPlaneEnabled) { + throw new Response('Forbidden', { status: 403, statusText: 'Forbidden' }); + } + + try { + await queryClient.fetchQuery( + exchangeTokenQueryOptions(params.tenant, () => + controlPlaneApi + .exchangeTokenCreate(params.tenant) + .then((result) => result.data as TenantTokenData), + ), + ); + console.log('[router][tenantRoute.loader] exchange token warmed', { + tenant: params.tenant, + }); + } catch { + console.log('[router][tenantRoute.loader] exchange token failed', { + tenant: params.tenant, + }); + throw new Response('Forbidden', { status: 403, statusText: 'Forbidden' }); + } + + return null; } - // Optionally warm the tenant details cache, since most tenant pages expect it. - // If this fails for any reason, let the error boundary handle it. - await queryClient.fetchQuery({ - queryKey: ['tenant:get', params.tenant], - queryFn: async () => (await api.tenantGet(params.tenant)).data, - retry: false, - }); + if (isControlPlaneEnabled) { + // In CP mode, avoid direct tenant:get warm against the OSS endpoint. + // Warm exchange token instead so subsequent tenant-scoped requests are authenticated. + try { + await queryClient.fetchQuery( + exchangeTokenQueryOptions(params.tenant, () => + controlPlaneApi + .exchangeTokenCreate(params.tenant) + .then((result) => result.data as TenantTokenData), + ), + ); + console.log('[router][tenantRoute.loader] exchange token warmed', { + tenant: params.tenant, + }); + } catch { + console.log('[router][tenantRoute.loader] exchange token failed', { + tenant: params.tenant, + }); + } + + console.log('[router][tenantRoute.loader] done', { + tenant: params.tenant, + }); + return null; + } + + // Non-CP mode: warm tenant details cache non-fatally. + try { + await queryClient.fetchQuery({ + queryKey: ['tenant:get', params.tenant], + queryFn: async () => (await api.tenantGet(params.tenant)).data, + retry: false, + }); + console.log('[router][tenantRoute.loader] tenant:get warmed', { + tenant: params.tenant, + }); + } catch { + // Non-fatal + console.log('[router][tenantRoute.loader] tenant:get failed (non-fatal)', { + tenant: params.tenant, + }); + } + console.log('[router][tenantRoute.loader] done', { + tenant: params.tenant, + }); return null; }, component: lazyRouteComponent(() => import('./pages/main/v1'), 'default'), - notFoundComponent: () => , + notFoundComponent: () => , }); const tenantIndexRedirectRoute = createRoute({ @@ -559,6 +685,7 @@ const routeTree = rootRoute.addChildren([ onboardingVerifyRoute, organizationsRoute, authenticatedRoute.addChildren([ + authenticatedIndexRoute, onboardingCreateTenantRoute, onboardingInvitesRoute, tenantRoute.addChildren([tenantIndexRedirectRoute, ...tenantRoutes]), @@ -588,6 +715,7 @@ export const appRoutes = { onboardingVerifyRoute, organizationsRoute, authenticatedRoute, + authenticatedIndexRoute, onboardingCreateTenantRoute, onboardingInvitesRoute, tenantRoute, diff --git a/pkg/auth/exchangetoken/token.go b/pkg/auth/exchangetoken/token.go new file mode 100644 index 0000000000..3eff4b4ca6 --- /dev/null +++ b/pkg/auth/exchangetoken/token.go @@ -0,0 +1,101 @@ +package exchangetoken + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/tink-crypto/tink-go/jwt" + "github.com/tink-crypto/tink-go/keyset" +) + +type ExchangeTokenClient interface { + ValidateExchangeToken(ctx context.Context, token string) (tenantId, userId uuid.UUID, err error) +} + +type ExchangeTokenOpts struct { + Issuer string + Audience string +} + +type exchangeTokenClientImpl struct { + opts *ExchangeTokenOpts + verifier jwt.Verifier +} + +func NewExchangeTokenClient(publicJWTHandle *keyset.Handle, opts *ExchangeTokenOpts) (ExchangeTokenClient, error) { + verifier, err := jwt.NewVerifier(publicJWTHandle) + + if err != nil { + return nil, fmt.Errorf("failed to create JWT Verifier: %v", err) + } + + return &exchangeTokenClientImpl{ + opts: opts, + verifier: verifier, + }, nil +} + +type Token struct { + ExpiresAt time.Time + Token string + TokenId uuid.UUID +} + +func (j *exchangeTokenClientImpl) ValidateExchangeToken(ctx context.Context, token string) (tenantId, userId uuid.UUID, err error) { + // Verify the signed token. + audience := j.opts.Audience + + validator, err := jwt.NewValidator(&jwt.ValidatorOpts{ + ExpectedAudience: &audience, + ExpectedIssuer: &j.opts.Issuer, + FixedNow: time.Now(), + ExpectIssuedInThePast: true, + }) + + if err != nil { + return uuid.Nil, uuid.Nil, fmt.Errorf("failed to create JWT Validator: %v", err) + } + + verifiedJwt, err := j.verifier.VerifyAndDecode(token, validator) + + if err != nil { + return uuid.Nil, uuid.Nil, fmt.Errorf("failed to verify and decode JWT: %v", err) + } + + if hasTenantId := verifiedJwt.HasStringClaim("tenant_id"); !hasTenantId { + return uuid.Nil, uuid.Nil, fmt.Errorf("token does not have tenant_id claim") + } + + tenantIdStr, err := verifiedJwt.StringClaim("tenant_id") + + if err != nil { + return uuid.Nil, uuid.Nil, fmt.Errorf("failed to read tenant_id claim: %v", err) + } + + tenantId, err = uuid.Parse(tenantIdStr) + + if err != nil { + return uuid.Nil, uuid.Nil, fmt.Errorf("failed to parse tenant_id claim: %v", err) + } + + // ensure the subject of the token matches the tenantId + if hasSubject := verifiedJwt.HasSubject(); !hasSubject { + return uuid.Nil, uuid.Nil, fmt.Errorf("token does not have subject claim") + } + + subject, err := verifiedJwt.Subject() + + if err != nil { + return uuid.Nil, uuid.Nil, fmt.Errorf("failed to read subject claim: %v", err) + } + + userId, err = uuid.Parse(subject) + + if err != nil { + return uuid.Nil, uuid.Nil, fmt.Errorf("failed to parse subject claim: %v", err) + } + + return tenantId, userId, nil +} diff --git a/pkg/config/loader/loader.go b/pkg/config/loader/loader.go index 622e82b55e..b506feb623 100644 --- a/pkg/config/loader/loader.go +++ b/pkg/config/loader/loader.go @@ -25,6 +25,7 @@ import ( "github.com/hatchet-dev/hatchet/pkg/analytics" "github.com/hatchet-dev/hatchet/pkg/analytics/posthog" "github.com/hatchet-dev/hatchet/pkg/auth/cookie" + "github.com/hatchet-dev/hatchet/pkg/auth/exchangetoken" "github.com/hatchet-dev/hatchet/pkg/auth/oauth" "github.com/hatchet-dev/hatchet/pkg/auth/token" "github.com/hatchet-dev/hatchet/pkg/config/client" @@ -616,6 +617,39 @@ func createControllerLayer(dc *database.Layer, cf *server.ServerConfigFile, vers return nil, nil, fmt.Errorf("could not create JWT manager: %w", err) } + if cf.Auth.ControlPlaneExchangeTokenConfig.Enabled { + if cf.Auth.ControlPlaneExchangeTokenConfig.JWTPublicKeyset == "" && cf.Auth.ControlPlaneExchangeTokenConfig.JWTPublicKeysetFile == "" { + return nil, nil, fmt.Errorf("control plane exchange token is required when exchange token config is enabled") + } + + publicJwt := cf.Auth.ControlPlaneExchangeTokenConfig.JWTPublicKeyset + + if cf.Auth.ControlPlaneExchangeTokenConfig.JWTPublicKeysetFile != "" { + keysetBytes, keyErr := os.ReadFile(cf.Auth.ControlPlaneExchangeTokenConfig.JWTPublicKeysetFile) + + if keyErr != nil { + return nil, nil, fmt.Errorf("could not read control plane exchange token JWT public keyset file: %w", keyErr) + } + + publicJwt = string(keysetBytes) + } + + publicJWTHandle, handleErr := encryption.InsecureHandleFromBytes([]byte(publicJwt)) + + if handleErr != nil { + return nil, nil, fmt.Errorf("could not create keyset handle from control plane exchange token JWT public keyset: %w", handleErr) + } + + auth.ExchangeTokenClient, err = exchangetoken.NewExchangeTokenClient(publicJWTHandle, &exchangetoken.ExchangeTokenOpts{ + Issuer: cf.Auth.ControlPlaneExchangeTokenConfig.Issuer, + Audience: cf.Auth.ControlPlaneExchangeTokenConfig.Audience, + }) + + if err != nil { + return nil, nil, fmt.Errorf("could not create exchange token client: %w", err) + } + } + var emailSvc email.EmailService = &email.NoOpService{} switch strings.ToLower(cf.Email.Kind) { diff --git a/pkg/config/server/server.go b/pkg/config/server/server.go index 6a4293069f..2fa2c204dd 100644 --- a/pkg/config/server/server.go +++ b/pkg/config/server/server.go @@ -16,6 +16,7 @@ import ( "github.com/hatchet-dev/hatchet/internal/services/ingestor" "github.com/hatchet-dev/hatchet/pkg/analytics" "github.com/hatchet-dev/hatchet/pkg/auth/cookie" + "github.com/hatchet-dev/hatchet/pkg/auth/exchangetoken" "github.com/hatchet-dev/hatchet/pkg/auth/token" client "github.com/hatchet-dev/hatchet/pkg/client/v1" "github.com/hatchet-dev/hatchet/pkg/config/database" @@ -424,6 +425,24 @@ type ConfigFileAuth struct { Google ConfigFileAuthGoogle `mapstructure:"google" json:"google,omitempty"` Github ConfigFileAuthGithub `mapstructure:"github" json:"github,omitempty"` + + ControlPlaneExchangeTokenConfig ConfigFileAuthControlPlaneExchangeToken `mapstructure:"controlPlaneExchangeToken" json:"controlPlaneExchangeToken,omitempty"` +} + +type ConfigFileAuthControlPlaneExchangeToken struct { + // important: we only need the public keyset to validate the exchange token; Hatchet instances do not generate the private + // keyset + JWTPublicKeyset string `mapstructure:"jwtPublicKeyset" json:"jwtPublicKeyset,omitempty"` + JWTPublicKeysetFile string `mapstructure:"jwtPublicKeysetFile" json:"jwtPublicKeysetFile,omitempty"` + + // Issuer is the expected issuer for the exchange token. This should be set to the URL of the control plane instance. + Issuer string `mapstructure:"issuer" json:"issuer,omitempty"` + + // Audience is the expected audience for the exchange token. This should be set to the identifier of the API server in the control plane instance. + Audience string `mapstructure:"audience" json:"audience,omitempty"` + + // Enabled controls whether the control plane exchange token authentication method is enabled for this Hatchet instance. + Enabled bool `mapstructure:"enabled" json:"enabled,omitempty" default:"false"` } type ConfigFileTenantAlerting struct { @@ -551,6 +570,8 @@ type AuthConfig struct { JWTManager token.JWTManager + ExchangeTokenClient exchangetoken.ExchangeTokenClient + CustomAuthenticator CustomAuthenticator } @@ -924,4 +945,11 @@ func BindAllEnv(v *viper.Viper) { // OLAP status update options _ = v.BindEnv("statusUpdates.dagBatchSizeLimit", "SERVER_OLAP_STATUS_UPDATE_DAG_BATCH_SIZE_LIMIT") _ = v.BindEnv("statusUpdates.taskBatchSizeLimit", "SERVER_OLAP_STATUS_UPDATE_TASK_BATCH_SIZE_LIMIT") + + // exchange token options + _ = v.BindEnv("auth.controlPlaneExchangeToken.enabled", "SERVER_AUTH_CONTROL_PLANE_EXCHANGE_TOKEN_ENABLED") + _ = v.BindEnv("auth.controlPlaneExchangeToken.jwtPublicKeyset", "SERVER_AUTH_CONTROL_PLANE_EXCHANGE_TOKEN_JWT_PUBLIC_KEYSET") + _ = v.BindEnv("auth.controlPlaneExchangeToken.jwtPublicKeysetFile", "SERVER_AUTH_CONTROL_PLANE_EXCHANGE_TOKEN_JWT_PUBLIC_KEYSET_FILE") + _ = v.BindEnv("auth.controlPlaneExchangeToken.issuer", "SERVER_AUTH_CONTROL_PLANE_EXCHANGE_TOKEN_ISSUER") + _ = v.BindEnv("auth.controlPlaneExchangeToken.audience", "SERVER_AUTH_CONTROL_PLANE_EXCHANGE_TOKEN_AUDIENCE") } diff --git a/pkg/encryption/local.go b/pkg/encryption/local.go index 1b8a3cd8b6..133d7d6600 100644 --- a/pkg/encryption/local.go +++ b/pkg/encryption/local.go @@ -22,7 +22,7 @@ type localEncryptionService struct { // base64-encoded JSON format. This can be generated by calling hatchet-admin keyset create-local. func NewLocalEncryption(masterKey []byte, privateEc256 []byte, publicEc256 []byte) (*localEncryptionService, error) { // get the master keyset handle - aes256GcmHandle, err := insecureHandleFromBytes(masterKey) + aes256GcmHandle, err := InsecureHandleFromBytes(masterKey) if err != nil { return nil, err @@ -122,6 +122,19 @@ func generateJWTKeysets(masterKey tink.AEAD) (privateEc256 []byte, publicEc256 [ return } + // TODO-CONTROL-PLANE: need to generate the public keyset for use in the API server + // here's some code for that. + // + // print the public keyset for debugging purposes + // pubBytes, err := insecureBytesFromHandle(publicHandle) + + // if err != nil { + // err = fmt.Errorf("failed to get bytes from public handle: %w", err) + // return + // } + + // fmt.Println("PUBLIC KEYSET (base64-encoded JSON):", string(pubBytes)) + publicEc256, err = bytesFromHandle(publicHandle, masterKey) if err != nil { @@ -185,7 +198,7 @@ func handleFromBytes(keysetBytes []byte, masterKey tink.AEAD) (*keyset.Handle, e return handle, nil } -func insecureHandleFromBytes(keysetBytes []byte) (*keyset.Handle, error) { +func InsecureHandleFromBytes(keysetBytes []byte) (*keyset.Handle, error) { // base64-decode bytes keysetJsonBytes := make([]byte, base64.RawStdEncoding.DecodedLen(len(keysetBytes))) _, err := base64.RawStdEncoding.Decode(keysetJsonBytes, keysetBytes)