diff --git a/backend/controllers/internal_users.go b/backend/controllers/internal_users.go index c3d538a97..250bc6709 100644 --- a/backend/controllers/internal_users.go +++ b/backend/controllers/internal_users.go @@ -91,6 +91,13 @@ func (d DiggerController) CreateUserInternal(c *gin.Context) { return } + existingUser, err := models.DB.GetUserByEmail(userEmail) + if existingUser != nil && err == nil { + slog.Error("User email already exists", "email", userEmail) + c.JSON(http.StatusConflict, gin.H{"error": "User email already exists"}) + return + } + // for now using email for username since we want to deprecate that field username := userEmail user, err := models.DB.CreateUser(userEmail, extUserSource, extUserId, org.ID, username) diff --git a/backend/models/storage.go b/backend/models/storage.go index c5e8b5a34..cc2e70ddb 100644 --- a/backend/models/storage.go +++ b/backend/models/storage.go @@ -1394,6 +1394,17 @@ func (db *Database) GetOrganisation(tenantId any) (*Organisation, error) { return org, nil } +func (d *Database) GetUserByEmail(email string) (*User, error) { + user := User{} + err := d.GormDB.Where("email = ?", email).First(&user).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + } + return &user, err +} + func (db *Database) CreateUser(email string, externalSource string, externalId string, orgId uint, username string) (*User, error) { user := &User{ Email: email, diff --git a/taco/internal/tfe/well_known.go b/taco/internal/tfe/well_known.go index d73e87772..e56d73643 100644 --- a/taco/internal/tfe/well_known.go +++ b/taco/internal/tfe/well_known.go @@ -1,9 +1,10 @@ package tfe import ( + "os" + "github.com/diggerhq/digger/opentaco/internal/domain/tfe" "github.com/labstack/echo/v4" - "os" ) const ( @@ -68,9 +69,19 @@ func (h *TfeHandler) AuthTokenExchange(c echo.Context) error { // Helper function to get base URL func getBaseURL(c echo.Context) string { scheme := c.Scheme() - if fwd := c.Request().Header.Get("X-Forwarded-Proto"); fwd != "" { - scheme = fwd + allowForwardedFor := os.Getenv("OPENTACO_ALLOW_X_FORWARDED_FOR") + if allowForwardedFor == "true" { + if fwd := c.Request().Header.Get("X-Forwarded-Proto"); fwd != "" { + scheme = fwd + } } + host := c.Request().Host + if allowForwardedFor == "true" { + if fwdHost := c.Request().Header.Get("X-Forwarded-Host"); fwdHost != "" { + host = fwdHost + } + } + return scheme + "://" + host } diff --git a/taco/internal/unit/handler.go b/taco/internal/unit/handler.go index 5862b3f49..bd31895e8 100644 --- a/taco/internal/unit/handler.go +++ b/taco/internal/unit/handler.go @@ -300,8 +300,10 @@ func (h *Handler) UploadUnit(c echo.Context) error { } return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to upload unit"}) } + // TODO: This graph update function does not currently work correctly, + // commenting out for now until this functionality is fixed // Best-effort dependency graph update - go deps.UpdateGraphOnWrite(c.Request().Context(), h.store, id, data) + //go deps.UpdateGraphOnWrite(c.Request().Context(), h.store, id, data) analytics.SendEssential("taco_unit_push_completed") return c.JSON(http.StatusOK, map[string]string{"message": "Unit uploaded successfully"}) } diff --git a/ui/.gitignore b/ui/.gitignore index db2a54410..b6eedaa9a 100644 --- a/ui/.gitignore +++ b/ui/.gitignore @@ -1,3 +1,4 @@ +.tanstack/ .netlify/ dist/ node_modules/ diff --git a/ui/src/api/orchestrator_orgs.ts b/ui/src/api/orchestrator_orgs.ts index acddc00d4..6236d95da 100644 --- a/ui/src/api/orchestrator_orgs.ts +++ b/ui/src/api/orchestrator_orgs.ts @@ -46,7 +46,6 @@ export async function getOrgSettings( if (!response.ok) { const text = await response.text() - console.log(text) throw new Error('Failed to get organization settings') } diff --git a/ui/src/api/orchestrator_users.ts b/ui/src/api/orchestrator_users.ts index e1b07a718..5aab379b4 100644 --- a/ui/src/api/orchestrator_users.ts +++ b/ui/src/api/orchestrator_users.ts @@ -15,6 +15,11 @@ export async function syncUserToBackend(userId: string, userEmail: string, orgId }) }) + if (response.status === 409) { + console.log("User already exists in orchestrator") + return response.json(); + } + if (!response.ok) { throw new Error(`Failed to sync user: ${response.statusText}`); } diff --git a/ui/src/api/statesman_orgs.ts b/ui/src/api/statesman_orgs.ts index 62dd9bbf1..cb0b38440 100644 --- a/ui/src/api/statesman_orgs.ts +++ b/ui/src/api/statesman_orgs.ts @@ -1,26 +1,27 @@ -export async function syncOrgToStatesman(orgId: string, orgName: string, userId: string, adminEmail: string) { +export async function syncOrgToStatesman(orgId: string, orgName: string, displayName: string, userId: string, adminEmail: string) { const response = await fetch(`${process.env.STATESMAN_BACKEND_URL}/internal/api/orgs`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${process.env.STATESMAN_BACKEND_WEBHOOK_SECRET}`, - 'X-Org-ID': orgId, + 'X-Org-ID': "", 'X-User-ID': userId, 'X-Email': adminEmail, }, body: JSON.stringify({ - "org_id": orgId, + "external_org_id": orgId, "name": orgName, + "display_name": displayName, "created_by": adminEmail, }) }) - console.log(orgId) - console.log(orgName) - console.log(userId) - console.log(adminEmail) + if (response.status === 409) { + console.log("User already exists in statesman") + return response.json(); + } if (!response.ok) { throw new Error(`Failed to sync organization to statesman: ${response.statusText}`); diff --git a/ui/src/api/statesman_serverFunctions.ts b/ui/src/api/statesman_serverFunctions.ts index d8e0aff68..da945215b 100644 --- a/ui/src/api/statesman_serverFunctions.ts +++ b/ui/src/api/statesman_serverFunctions.ts @@ -1,5 +1,5 @@ import { createServerFn } from "@tanstack/react-start" -import { createUnit, getUnit, listUnits } from "./statesman_units" +import { createUnit, getUnit, listUnits, getUnitVersions, unlockUnit, lockUnit, getUnitStatus, deleteUnit, downloadLatestState, forcePushState, restoreUnitStateVersion } from "./statesman_units" export const listUnitsFn = createServerFn({method: 'GET'}) .inputValidator((data : {userId: string, organisationId: string, email: string}) => data) @@ -15,9 +15,64 @@ export const getUnitFn = createServerFn({method: 'GET'}) return unit }) -export const createUnitFn = createServerFn({method: 'POST'}) +export const getUnitVersionsFn = createServerFn({method: 'GET'}) + .inputValidator((data : {userId: string, organisationId: string, email: string, unitId: string}) => data) + .handler(async ({ data }) => { + const unitVersions : any = await getUnitVersions(data.organisationId, data.userId, data.email, data.unitId) + return unitVersions +}) + +export const lockUnitFn = createServerFn({method: 'POST'}) .inputValidator((data : {userId: string, organisationId: string, email: string, unitId: string}) => data) .handler(async ({ data }) => { - const unit : any = await createUnit(data.organisationId, data.userId, data.email, data.unitId) + const unit : any = await lockUnit(data.organisationId, data.userId, data.email, data.unitId) return unit +}) + +export const unlockUnitFn = createServerFn({method: 'POST'}) + .inputValidator((data : {userId: string, organisationId: string, email: string, unitId: string}) => data) + .handler(async ({ data }) => { + const unit : any = await unlockUnit(data.organisationId, data.userId, data.email, data.unitId) + return unit +}) + +export const downloadLatestStateFn = createServerFn({method: 'GET'}) + .inputValidator((data : {userId: string, organisationId: string, email: string, unitId: string}) => data) + .handler(async ({ data }) => { + const state : any = await downloadLatestState(data.organisationId, data.userId, data.email, data.unitId) + return state +}) + +export const forcePushStateFn = createServerFn({method: 'POST'}) + .inputValidator((data : {userId: string, organisationId: string, email: string, unitId: string, state: string}) => data) + .handler(async ({ data }) => { + const state : any = await forcePushState(data.organisationId, data.userId, data.email, data.unitId, data.state) + return state +}) + +export const restoreUnitStateVersionFn = createServerFn({method: 'POST'}) + .inputValidator((data : {userId: string, organisationId: string, email: string, unitId: string, timestamp: string, lockId: string}) => data) + .handler(async ({ data }) => { + const state : any = await restoreUnitStateVersion(data.organisationId, data.userId, data.email, data.unitId, data.timestamp, data.lockId) + return state +}) + +export const getUnitStatusFn = createServerFn({method: 'GET'}) + .inputValidator((data : {userId: string, organisationId: string, email: string, unitId: string}) => data) + .handler(async ({ data }) => { + const unitStatus : any = await getUnitStatus(data.organisationId, data.userId, data.email, data.unitId) + return unitStatus +}) + +export const createUnitFn = createServerFn({method: 'POST'}) + .inputValidator((data : {userId: string, organisationId: string, email: string, name: string}) => data) + .handler(async ({ data }) => { + const unit : any = await createUnit(data.organisationId, data.userId, data.email, data.name) + return unit +}) + +export const deleteUnitFn = createServerFn({method: 'POST'}) + .inputValidator((data : {userId: string, organisationId: string, email: string, unitId: string}) => data) + .handler(async ({ data }) => { + await deleteUnit(data.organisationId, data.userId, data.email, data.unitId) }) \ No newline at end of file diff --git a/ui/src/api/statesman_units.ts b/ui/src/api/statesman_units.ts index b849e0e4a..7d97c7ff0 100644 --- a/ui/src/api/statesman_units.ts +++ b/ui/src/api/statesman_units.ts @@ -13,7 +13,6 @@ export async function listUnits(orgId: string, userId: string, email: string) { if (!response.ok) { throw new Error(`Failed to list units: ${response.statusText}`); } - return response.json(); } @@ -28,9 +27,135 @@ export async function getUnit(orgId: string, userId: string, email: string, unit 'X-Email': email, }, }); + if (!response.ok) { + throw new Error(`Failed to get unit: ${response.statusText}`); + } + return response.json(); +} + +export async function getUnitVersions(orgId: string, userId: string, email: string, unitId: string) { + const response = await fetch(`${process.env.STATESMAN_BACKEND_URL}/internal/api/units/${unitId}/versions`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${process.env.STATESMAN_BACKEND_WEBHOOK_SECRET}`, + 'X-Org-ID': orgId, + 'X-User-ID': userId, + 'X-Email': email, + }, + }); + if (!response.ok) { + throw new Error(`Failed to get unit: ${response.statusText}`); + } + return response.json(); +} + + +export async function lockUnit(orgId: string, userId: string, email: string, unitId: string) { + const response = await fetch(`${process.env.STATESMAN_BACKEND_URL}/internal/api/units/${unitId}/lock`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${process.env.STATESMAN_BACKEND_WEBHOOK_SECRET}`, + 'X-Org-ID': orgId, + 'X-User-ID': userId, + 'X-Email': email, + }, + }); + if (!response.ok) { + throw new Error(`Failed to lock unit: ${response.statusText}`); + } + return response.json(); +} + +export async function unlockUnit(orgId: string, userId: string, email: string, unitId: string) { + const response = await fetch(`${process.env.STATESMAN_BACKEND_URL}/internal/api/units/${unitId}/unlock`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${process.env.STATESMAN_BACKEND_WEBHOOK_SECRET}`, + 'X-Org-ID': orgId, + 'X-User-ID': userId, + 'X-Email': email, + }, + }); + if (!response.ok) { + throw new Error(`Failed to unlock unit: ${response.statusText}`); + } + return response.json(); } -export async function createUnit(orgId: string, userId: string, email: string, unitId: string) { +export async function forcePushState(orgId: string, userId: string, email: string, unitId: string, state: string) { + const response = await fetch(`${process.env.STATESMAN_BACKEND_URL}/internal/api/units/${unitId}/upload`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${process.env.STATESMAN_BACKEND_WEBHOOK_SECRET}`, + 'X-Org-ID': orgId, + 'X-User-ID': userId, + 'X-Email': email, + }, + body: state, + }); + if (!response.ok) { + throw new Error(`Failed to force push state: ${response.statusText}`); + } + return response.json(); +} + +export async function downloadLatestState(orgId: string, userId: string, email: string, unitId: string) { + const response = await fetch(`${process.env.STATESMAN_BACKEND_URL}/internal/api/units/${unitId}/download`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${process.env.STATESMAN_BACKEND_WEBHOOK_SECRET}`, + 'X-Org-ID': orgId, + 'X-User-ID': userId, + 'X-Email': email, + }, + }); + return response.json() +} + +export async function restoreUnitStateVersion(orgId: string, userId: string, email: string, unitId: string, timestamp: string, lockId: string) { + const response = await fetch(`${process.env.STATESMAN_BACKEND_URL}/internal/api/units/${unitId}/restore`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${process.env.STATESMAN_BACKEND_WEBHOOK_SECRET}`, + 'X-Org-ID': orgId, + 'X-User-ID': userId, + 'X-Email': email, + }, + body: JSON.stringify({ + timestamp: timestamp, + lock_id: lockId, + }), + }); + if (!response.ok) { + throw new Error(`Failed to restore unit state version: ${response.statusText}`); + } + return response.json(); +} + +export async function getUnitStatus(orgId: string, userId: string, email: string, unitId: string) { + const response = await fetch(`${process.env.STATESMAN_BACKEND_URL}/internal/api/units/${unitId}/status`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${process.env.STATESMAN_BACKEND_WEBHOOK_SECRET}`, + 'X-Org-ID': orgId, + 'X-User-ID': userId, + 'X-Email': email, + }, + }); + if (!response.ok) { + throw new Error(`Failed to get unit status: ${response.statusText}`); + } + return response.json(); +} + +export async function createUnit(orgId: string, userId: string, email: string, name: string) { const response = await fetch(`${process.env.STATESMAN_BACKEND_URL}/internal/api/units`, { method: 'POST', headers: { @@ -41,7 +166,7 @@ export async function createUnit(orgId: string, userId: string, email: string, u 'X-Email': email, }, body: JSON.stringify({ - id: unitId, + name: name, }), }); console.log(response) @@ -50,4 +175,21 @@ export async function createUnit(orgId: string, userId: string, email: string, u } return response.json(); +} + +export async function deleteUnit(orgId: string, userId: string, email: string, unitId: string) { + const response = await fetch(`${process.env.STATESMAN_BACKEND_URL}/internal/api/units/${unitId}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${process.env.STATESMAN_BACKEND_WEBHOOK_SECRET}`, + 'X-Org-ID': orgId, + 'X-User-ID': userId, + 'X-Email': email, + }, + }); + if (!response.ok) { + throw new Error(`Failed to delete unit: ${response.statusText}`); + } + } \ No newline at end of file diff --git a/ui/src/api/statesman_users.ts b/ui/src/api/statesman_users.ts index 08b92e5f0..c021c9553 100644 --- a/ui/src/api/statesman_users.ts +++ b/ui/src/api/statesman_users.ts @@ -16,7 +16,13 @@ export async function syncUserToStatesman(userId: string, userEmail: string, org }) }) + if (response.status === 409) { + console.log("User already exists in statesman") + return response.json(); + } + if (!response.ok) { + console.log(response.text()) throw new Error(`Failed to sync user: ${response.statusText}`); } diff --git a/ui/src/authkit/serverFunctions.ts b/ui/src/authkit/serverFunctions.ts index 087bcabb7..c14bc97b5 100644 --- a/ui/src/authkit/serverFunctions.ts +++ b/ui/src/authkit/serverFunctions.ts @@ -6,6 +6,8 @@ import { getWorkOS } from './ssr/workos'; import type { GetAuthURLOptions, NoUserInfo, UserInfo } from './ssr/interfaces'; import { Organization } from '@workos-inc/node'; import { WidgetScope } from 'node_modules/@workos-inc/node/lib/widgets/interfaces/get-token'; +import { syncOrgToBackend } from '@/api/orchestrator_orgs'; +import { syncOrgToStatesman } from '@/api/statesman_orgs'; export const getAuthorizationUrl = createServerFn({ method: 'GET' }) .inputValidator((options?: GetAuthURLOptions) => options) @@ -29,8 +31,8 @@ export const getOrganisationDetails = createServerFn({method: 'GET'}) export const createOrganization = createServerFn({method: 'POST'}) - .inputValidator((data: {name: string, userId: string}) => data) - .handler(async ({data: {name, userId}}) : Promise => { + .inputValidator((data: {name: string, userId: string, email: string}) => data) + .handler(async ({data: {name, userId, email}}) : Promise => { try { const organization = await getWorkOS().organizations.createOrganization({ name: name }); @@ -40,6 +42,14 @@ export const createOrganization = createServerFn({method: 'POST'}) roleSlug: "admin", }); + try { + await syncOrgToBackend(organization.id, organization.name, email); + await syncOrgToStatesman(organization.id, organization.name, organization.name, userId, email); + } catch (error) { + console.error('Error syncing organization to backend:', error); + throw error; + } + return organization; } catch (error) { console.error('Error creating organization:', error); diff --git a/ui/src/authkit/ssr/workos_api.ts b/ui/src/authkit/ssr/workos_api.ts index 47b39f1f5..9cd172dc1 100644 --- a/ui/src/authkit/ssr/workos_api.ts +++ b/ui/src/authkit/ssr/workos_api.ts @@ -43,6 +43,18 @@ export async function getOrganisationDetails(orgId: string) { } } +export async function getOranizationsForUser(userId: string) { + try { + const memberships = await getWorkOS().userManagement.listOrganizationMemberships({ + userId: userId, + }); + return memberships.data; + } catch (error) { + console.error('Error fetching user organizations:', error); + throw error; + } +} + export async function listUserOrganizationInvitations(email: string) { try { const invitations = await getWorkOS().userManagement.listInvitations({ diff --git a/ui/src/components/CreateOrganisationButtonWOS.tsx b/ui/src/components/CreateOrganisationButtonWOS.tsx index 958c49f29..7c5fb4dbc 100644 --- a/ui/src/components/CreateOrganisationButtonWOS.tsx +++ b/ui/src/components/CreateOrganisationButtonWOS.tsx @@ -7,7 +7,7 @@ import { useState } from "react"; import { useToast } from "@/hooks/use-toast"; -export default function CreateOrganizationBtn({ userId }: { userId: string }) { +export default function CreateOrganizationBtn({ userId, email }: { userId: string, email: string }) { const [name, setName] = useState(""); const [open, setOpen] = useState(false); const { toast } = useToast(); @@ -15,7 +15,7 @@ export default function CreateOrganizationBtn({ userId }: { userId: string }) { e.preventDefault(); try { - const organization = await createOrganization({ data: { name: name, userId: userId } }); + const organization = await createOrganization({ data: { name: name, userId: userId, email: email } }); toast({ title: "Organization created", description: "The page will now reload to refresh organisations list. To use this new organization, select it from the list.", diff --git a/ui/src/components/UnitStateForceUploadDialog.tsx b/ui/src/components/UnitStateForceUploadDialog.tsx new file mode 100644 index 000000000..08a1fb937 --- /dev/null +++ b/ui/src/components/UnitStateForceUploadDialog.tsx @@ -0,0 +1,109 @@ +import React, { useState } from 'react' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog' +import { Upload } from 'lucide-react' +import { forcePushStateFn } from '@/api/statesman_serverFunctions' +import { toast } from '@/hooks/use-toast' + +export default function UnitStateForceUploadDialog({ userId, organisationId, userEmail, unitId, isDisabled }: { userId: string, organisationId: string, userEmail: string, unitId: string, isDisabled: boolean }) { + const [open, setOpen] = useState(false) + const [fileContent, setFileContent] = useState(null) + const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle') + + const handleOpenChange = (nextOpen: boolean) => { + setOpen(nextOpen) + if (!nextOpen) { + setFileContent(null) + setStatus('idle') + } + } + + const handleFileChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + const text = await file.text() + setFileContent(text) + } + + const handleUpload = async () => { + if (!fileContent) { + alert('Please select a file first.') + return + } + try { + setStatus('loading') + await forcePushStateFn({ + data: { + userId: userId, + organisationId: organisationId, + email: userEmail, + unitId: unitId, + state: fileContent, + }, + }) + toast({ + title: 'State uploaded', + description: `State for unit ${unitId} was uploaded successfully.`, + duration: 4000, + variant: 'default', + }) + setStatus('success') + setOpen(false) + } catch (err) { + console.error(err) + setStatus('error') + toast({ + title: 'Upload failed', + description: 'Failed to upload state. Please try again.', + duration: 5000, + variant: 'destructive', + }) + } + } + return ( + + + + + + + Force push state + + This will overwrite the remote state with your selected file. Only use this if you are absolutely sure your local state is correct. + + +
+ +
+ +
+ {status === 'success' && ( +

Upload successful!

+ )} + {status === 'error' && ( +

Upload failed.

+ )} +
+
+
+ ) +} + + diff --git a/ui/src/components/WorkosSettings.tsx b/ui/src/components/WorkosSettings.tsx index 1cd98906d..e71b96094 100644 --- a/ui/src/components/WorkosSettings.tsx +++ b/ui/src/components/WorkosSettings.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; -import { createFileRoute } from '@tanstack/react-router'; +import { createFileRoute, useRouter } from '@tanstack/react-router'; import { getWidgetsAuthToken } from '@/authkit/serverFunctions'; - +import { useToast } from '@/hooks/use-toast'; import { OrganizationSwitcher, UserProfile, @@ -14,6 +14,7 @@ import '@workos-inc/widgets/styles.css'; import '@radix-ui/themes/styles.css'; import CreateOrganizationBtn from './CreateOrganisationButtonWOS'; + type LoaderData = { organisationId: string; role: 'admin' | 'member' | string; @@ -26,14 +27,46 @@ type LoaderData = { type WorkosSettingsProps = { userId: string; + email: string; organisationId: string; role: 'admin' | 'member' | string; }; -export function WorkosSettings({ userId, organisationId, role }: WorkosSettingsProps) { +export function WorkosSettings({ userId, email, organisationId, role }: WorkosSettingsProps) { + const router = useRouter() + const { toast } = useToast() const [authToken, setAuthToken] = React.useState(null); const [error, setError] = React.useState(null); const [loading, setLoading] = React.useState(true); + const handleSwitchToOrganization = async (organizationId: string) => { + + try { + const res = await fetch('/api/auth/workos/switch-org', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ organizationId, pathname: '/dashboard/units' }), + }) + const data = await res.json() + if (!data?.redirectUrl) return + const url: string = data.redirectUrl + const isInternal = url.startsWith('/') + if (isInternal) { + await router.navigate({ to: url }) + router.invalidate() + } else { + console.log('Cannot redirect to external URL'); + throw new Error('Cannot redirect to external URL'); + } + } catch (e) { + toast({ + title: 'Failed to switch organization', + description: e?.message ?? 'Failed to switch organization', + variant: 'destructive', + }) + console.error('Failed to switch organization', e) + } + + } React.useEffect(() => { (async () => { @@ -59,13 +92,11 @@ export function WorkosSettings({ userId, organisationId, role }: WorkosSettingsP { - // Call your own server action if needed - }} + switchToOrganization={({ organizationId }) => handleSwitchToOrganization(organizationId)} />
{/* Add your org creation UI here */} - +
diff --git a/ui/src/lib/env.server.ts b/ui/src/lib/env.server.ts new file mode 100644 index 000000000..999df0827 --- /dev/null +++ b/ui/src/lib/env.server.ts @@ -0,0 +1,20 @@ +// Centralized server-only environment access. +// This module is evaluated once per server process and cached by Node's module system. + +import { createServerFn } from "@tanstack/react-start" + + +export type Env = { + PUBLIC_URL: string + PUBLIC_HOSTNAME: string + STATESMAN_BACKEND_URL: string +} + +export const getPublicServerConfig = createServerFn({ method: 'GET' }) + .handler(async ({}) => { + return { + PUBLIC_URL: process.env.PUBLIC_URL ?? '', + PUBLIC_HOSTNAME: process.env.PUBLIC_URL?.replace('https://', '').replace('http://', '') ?? '', + STATESMAN_BACKEND_URL: process.env.STATESMAN_BACKEND_URL ?? '', + } as Env +}) \ No newline at end of file diff --git a/ui/src/lib/io.ts b/ui/src/lib/io.ts new file mode 100644 index 000000000..cb4ba3cec --- /dev/null +++ b/ui/src/lib/io.ts @@ -0,0 +1,24 @@ +export async function downloadJson(data: Blob, filename: string) { + try { + // Convert to blob + const blob = new Blob([JSON.stringify(data, null, 2)], { + type: 'application/json', + }); + + // Create a temporary object URL + const url = URL.createObjectURL(blob); + + // Create a hidden element to trigger the download + const a = document.createElement('a'); + a.href = url; + a.download = filename; // filename for the user + document.body.appendChild(a); + a.click(); + + // Clean up + a.remove(); + URL.revokeObjectURL(url); + } catch (err) { + console.error(err); + } +} \ No newline at end of file diff --git a/ui/src/routeTree.gen.ts b/ui/src/routeTree.gen.ts index 58d428ed1..8c3ef3a07 100644 --- a/ui/src/routeTree.gen.ts +++ b/ui/src/routeTree.gen.ts @@ -12,10 +12,12 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as LogoutRouteImport } from './routes/logout' import { Route as AuthenticatedRouteImport } from './routes/_authenticated' import { Route as IndexRouteImport } from './routes/index' +import { Route as TfeSplatRouteImport } from './routes/tfe/$' import { Route as OrchestratorJob_artefactsRouteImport } from './routes/_orchestrator/job_artefacts' import { Route as AuthenticatedDashboardRouteImport } from './routes/_authenticated/_dashboard' import { Route as OrchestratorGithubWebhookRouteImport } from './routes/orchestrator/github/webhook' import { Route as OrchestratorGithubCallbackRouteImport } from './routes/orchestrator/github/callback' +import { Route as AppSettingsTokensRouteImport } from './routes/app/settings.tokens' import { Route as ApiAuthCallbackRouteImport } from './routes/api/auth/callback' import { Route as ApiAuthWorkosWebhooksRouteImport } from './routes/api/auth/workos/webhooks' import { Route as ApiAuthWorkosSwitchOrgRouteImport } from './routes/api/auth/workos/switch-org' @@ -32,9 +34,11 @@ import { Route as AuthenticatedDashboardDashboardUnitsIndexRouteImport } from '. import { Route as AuthenticatedDashboardDashboardReposIndexRouteImport } from './routes/_authenticated/_dashboard/dashboard/repos.index' import { Route as AuthenticatedDashboardDashboardProjectsIndexRouteImport } from './routes/_authenticated/_dashboard/dashboard/projects.index' import { Route as AuthenticatedDashboardDashboardUnitsUnitIdRouteImport } from './routes/_authenticated/_dashboard/dashboard/units.$unitId' +import { Route as AuthenticatedDashboardDashboardSettingsUserRouteImport } from './routes/_authenticated/_dashboard/dashboard/settings.user' +import { Route as AuthenticatedDashboardDashboardSettingsTokensRouteImport } from './routes/_authenticated/_dashboard/dashboard/settings.tokens' import { Route as AuthenticatedDashboardDashboardReposConnectRouteImport } from './routes/_authenticated/_dashboard/dashboard/repos.connect' import { Route as AuthenticatedDashboardDashboardReposRepoIdRouteImport } from './routes/_authenticated/_dashboard/dashboard/repos.$repoId' -import { Route as AuthenticatedDashboardDashboardProjectsProjectIdRouteImport } from './routes/_authenticated/_dashboard/dashboard/projects.$projectId' +import { Route as AuthenticatedDashboardDashboardProjectsProjectidRouteImport } from './routes/_authenticated/_dashboard/dashboard/projects.$projectid' import { Route as AuthenticatedDashboardDashboardConnectionsConnectionIdRouteImport } from './routes/_authenticated/_dashboard/dashboard/connections.$connectionId' import { Route as OrchestratorReposNamespaceProjectsProjectNamePlan_policyRouteImport } from './routes/_orchestrator/repos/$namespace/projects/$projectName/plan_policy' import { Route as OrchestratorReposNamespaceProjectsProjectNameAccess_policyRouteImport } from './routes/_orchestrator/repos/$namespace/projects/$projectName/access_policy' @@ -54,6 +58,11 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const TfeSplatRoute = TfeSplatRouteImport.update({ + id: '/tfe/$', + path: '/tfe/$', + getParentRoute: () => rootRouteImport, +} as any) const OrchestratorJob_artefactsRoute = OrchestratorJob_artefactsRouteImport.update({ id: '/_orchestrator/job_artefacts', @@ -76,6 +85,11 @@ const OrchestratorGithubCallbackRoute = path: '/orchestrator/github/callback', getParentRoute: () => rootRouteImport, } as any) +const AppSettingsTokensRoute = AppSettingsTokensRouteImport.update({ + id: '/app/settings/tokens', + path: '/app/settings/tokens', + getParentRoute: () => rootRouteImport, +} as any) const ApiAuthCallbackRoute = ApiAuthCallbackRouteImport.update({ id: '/api/auth/callback', path: '/api/auth/callback', @@ -169,6 +183,18 @@ const AuthenticatedDashboardDashboardUnitsUnitIdRoute = path: '/dashboard/units/$unitId', getParentRoute: () => AuthenticatedDashboardRoute, } as any) +const AuthenticatedDashboardDashboardSettingsUserRoute = + AuthenticatedDashboardDashboardSettingsUserRouteImport.update({ + id: '/user', + path: '/user', + getParentRoute: () => AuthenticatedDashboardDashboardSettingsRoute, + } as any) +const AuthenticatedDashboardDashboardSettingsTokensRoute = + AuthenticatedDashboardDashboardSettingsTokensRouteImport.update({ + id: '/tokens', + path: '/tokens', + getParentRoute: () => AuthenticatedDashboardDashboardSettingsRoute, + } as any) const AuthenticatedDashboardDashboardReposConnectRoute = AuthenticatedDashboardDashboardReposConnectRouteImport.update({ id: '/connect', @@ -181,10 +207,10 @@ const AuthenticatedDashboardDashboardReposRepoIdRoute = path: '/$repoId', getParentRoute: () => AuthenticatedDashboardDashboardReposRoute, } as any) -const AuthenticatedDashboardDashboardProjectsProjectIdRoute = - AuthenticatedDashboardDashboardProjectsProjectIdRouteImport.update({ - id: '/$projectId', - path: '/$projectId', +const AuthenticatedDashboardDashboardProjectsProjectidRoute = + AuthenticatedDashboardDashboardProjectsProjectidRouteImport.update({ + id: '/$projectid', + path: '/$projectid', getParentRoute: () => AuthenticatedDashboardDashboardProjectsRoute, } as any) const AuthenticatedDashboardDashboardConnectionsConnectionIdRoute = @@ -218,7 +244,9 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/logout': typeof LogoutRoute '/job_artefacts': typeof OrchestratorJob_artefactsRoute + '/tfe/$': typeof TfeSplatRoute '/api/auth/callback': typeof ApiAuthCallbackRoute + '/app/settings/tokens': typeof AppSettingsTokensRoute '/orchestrator/github/callback': typeof OrchestratorGithubCallbackRoute '/orchestrator/github/webhook': typeof OrchestratorGithubWebhookRoute '/dashboard/connections': typeof AuthenticatedDashboardDashboardConnectionsRouteWithChildren @@ -226,16 +254,18 @@ export interface FileRoutesByFullPath { '/dashboard/onboarding': typeof AuthenticatedDashboardDashboardOnboardingRoute '/dashboard/projects': typeof AuthenticatedDashboardDashboardProjectsRouteWithChildren '/dashboard/repos': typeof AuthenticatedDashboardDashboardReposRouteWithChildren - '/dashboard/settings': typeof AuthenticatedDashboardDashboardSettingsRoute + '/dashboard/settings': typeof AuthenticatedDashboardDashboardSettingsRouteWithChildren '/orgs/$orgId/access_policy': typeof OrchestratorOrgsOrgIdAccess_policyRoute '/orgs/$orgId/plan_policy': typeof OrchestratorOrgsOrgIdPlan_policyRoute '/repos/$namespace/report-projects': typeof OrchestratorReposNamespaceReportProjectsRoute '/api/auth/workos/switch-org': typeof ApiAuthWorkosSwitchOrgRoute '/api/auth/workos/webhooks': typeof ApiAuthWorkosWebhooksRoute '/dashboard/connections/$connectionId': typeof AuthenticatedDashboardDashboardConnectionsConnectionIdRoute - '/dashboard/projects/$projectId': typeof AuthenticatedDashboardDashboardProjectsProjectIdRoute + '/dashboard/projects/$projectid': typeof AuthenticatedDashboardDashboardProjectsProjectidRoute '/dashboard/repos/$repoId': typeof AuthenticatedDashboardDashboardReposRepoIdRoute '/dashboard/repos/connect': typeof AuthenticatedDashboardDashboardReposConnectRoute + '/dashboard/settings/tokens': typeof AuthenticatedDashboardDashboardSettingsTokensRoute + '/dashboard/settings/user': typeof AuthenticatedDashboardDashboardSettingsUserRoute '/dashboard/units/$unitId': typeof AuthenticatedDashboardDashboardUnitsUnitIdRoute '/dashboard/projects/': typeof AuthenticatedDashboardDashboardProjectsIndexRoute '/dashboard/repos/': typeof AuthenticatedDashboardDashboardReposIndexRoute @@ -248,22 +278,26 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '/logout': typeof LogoutRoute '/job_artefacts': typeof OrchestratorJob_artefactsRoute + '/tfe/$': typeof TfeSplatRoute '/api/auth/callback': typeof ApiAuthCallbackRoute + '/app/settings/tokens': typeof AppSettingsTokensRoute '/orchestrator/github/callback': typeof OrchestratorGithubCallbackRoute '/orchestrator/github/webhook': typeof OrchestratorGithubWebhookRoute '/dashboard/connections': typeof AuthenticatedDashboardDashboardConnectionsRouteWithChildren '/dashboard/drift': typeof AuthenticatedDashboardDashboardDriftRoute '/dashboard/onboarding': typeof AuthenticatedDashboardDashboardOnboardingRoute - '/dashboard/settings': typeof AuthenticatedDashboardDashboardSettingsRoute + '/dashboard/settings': typeof AuthenticatedDashboardDashboardSettingsRouteWithChildren '/orgs/$orgId/access_policy': typeof OrchestratorOrgsOrgIdAccess_policyRoute '/orgs/$orgId/plan_policy': typeof OrchestratorOrgsOrgIdPlan_policyRoute '/repos/$namespace/report-projects': typeof OrchestratorReposNamespaceReportProjectsRoute '/api/auth/workos/switch-org': typeof ApiAuthWorkosSwitchOrgRoute '/api/auth/workos/webhooks': typeof ApiAuthWorkosWebhooksRoute '/dashboard/connections/$connectionId': typeof AuthenticatedDashboardDashboardConnectionsConnectionIdRoute - '/dashboard/projects/$projectId': typeof AuthenticatedDashboardDashboardProjectsProjectIdRoute + '/dashboard/projects/$projectid': typeof AuthenticatedDashboardDashboardProjectsProjectidRoute '/dashboard/repos/$repoId': typeof AuthenticatedDashboardDashboardReposRepoIdRoute '/dashboard/repos/connect': typeof AuthenticatedDashboardDashboardReposConnectRoute + '/dashboard/settings/tokens': typeof AuthenticatedDashboardDashboardSettingsTokensRoute + '/dashboard/settings/user': typeof AuthenticatedDashboardDashboardSettingsUserRoute '/dashboard/units/$unitId': typeof AuthenticatedDashboardDashboardUnitsUnitIdRoute '/dashboard/projects': typeof AuthenticatedDashboardDashboardProjectsIndexRoute '/dashboard/repos': typeof AuthenticatedDashboardDashboardReposIndexRoute @@ -279,7 +313,9 @@ export interface FileRoutesById { '/logout': typeof LogoutRoute '/_authenticated/_dashboard': typeof AuthenticatedDashboardRouteWithChildren '/_orchestrator/job_artefacts': typeof OrchestratorJob_artefactsRoute + '/tfe/$': typeof TfeSplatRoute '/api/auth/callback': typeof ApiAuthCallbackRoute + '/app/settings/tokens': typeof AppSettingsTokensRoute '/orchestrator/github/callback': typeof OrchestratorGithubCallbackRoute '/orchestrator/github/webhook': typeof OrchestratorGithubWebhookRoute '/_authenticated/_dashboard/dashboard/connections': typeof AuthenticatedDashboardDashboardConnectionsRouteWithChildren @@ -287,16 +323,18 @@ export interface FileRoutesById { '/_authenticated/_dashboard/dashboard/onboarding': typeof AuthenticatedDashboardDashboardOnboardingRoute '/_authenticated/_dashboard/dashboard/projects': typeof AuthenticatedDashboardDashboardProjectsRouteWithChildren '/_authenticated/_dashboard/dashboard/repos': typeof AuthenticatedDashboardDashboardReposRouteWithChildren - '/_authenticated/_dashboard/dashboard/settings': typeof AuthenticatedDashboardDashboardSettingsRoute + '/_authenticated/_dashboard/dashboard/settings': typeof AuthenticatedDashboardDashboardSettingsRouteWithChildren '/_orchestrator/orgs/$orgId/access_policy': typeof OrchestratorOrgsOrgIdAccess_policyRoute '/_orchestrator/orgs/$orgId/plan_policy': typeof OrchestratorOrgsOrgIdPlan_policyRoute '/_orchestrator/repos/$namespace/report-projects': typeof OrchestratorReposNamespaceReportProjectsRoute '/api/auth/workos/switch-org': typeof ApiAuthWorkosSwitchOrgRoute '/api/auth/workos/webhooks': typeof ApiAuthWorkosWebhooksRoute '/_authenticated/_dashboard/dashboard/connections/$connectionId': typeof AuthenticatedDashboardDashboardConnectionsConnectionIdRoute - '/_authenticated/_dashboard/dashboard/projects/$projectId': typeof AuthenticatedDashboardDashboardProjectsProjectIdRoute + '/_authenticated/_dashboard/dashboard/projects/$projectid': typeof AuthenticatedDashboardDashboardProjectsProjectidRoute '/_authenticated/_dashboard/dashboard/repos/$repoId': typeof AuthenticatedDashboardDashboardReposRepoIdRoute '/_authenticated/_dashboard/dashboard/repos/connect': typeof AuthenticatedDashboardDashboardReposConnectRoute + '/_authenticated/_dashboard/dashboard/settings/tokens': typeof AuthenticatedDashboardDashboardSettingsTokensRoute + '/_authenticated/_dashboard/dashboard/settings/user': typeof AuthenticatedDashboardDashboardSettingsUserRoute '/_authenticated/_dashboard/dashboard/units/$unitId': typeof AuthenticatedDashboardDashboardUnitsUnitIdRoute '/_authenticated/_dashboard/dashboard/projects/': typeof AuthenticatedDashboardDashboardProjectsIndexRoute '/_authenticated/_dashboard/dashboard/repos/': typeof AuthenticatedDashboardDashboardReposIndexRoute @@ -311,7 +349,9 @@ export interface FileRouteTypes { | '/' | '/logout' | '/job_artefacts' + | '/tfe/$' | '/api/auth/callback' + | '/app/settings/tokens' | '/orchestrator/github/callback' | '/orchestrator/github/webhook' | '/dashboard/connections' @@ -326,9 +366,11 @@ export interface FileRouteTypes { | '/api/auth/workos/switch-org' | '/api/auth/workos/webhooks' | '/dashboard/connections/$connectionId' - | '/dashboard/projects/$projectId' + | '/dashboard/projects/$projectid' | '/dashboard/repos/$repoId' | '/dashboard/repos/connect' + | '/dashboard/settings/tokens' + | '/dashboard/settings/user' | '/dashboard/units/$unitId' | '/dashboard/projects/' | '/dashboard/repos/' @@ -341,7 +383,9 @@ export interface FileRouteTypes { | '/' | '/logout' | '/job_artefacts' + | '/tfe/$' | '/api/auth/callback' + | '/app/settings/tokens' | '/orchestrator/github/callback' | '/orchestrator/github/webhook' | '/dashboard/connections' @@ -354,9 +398,11 @@ export interface FileRouteTypes { | '/api/auth/workos/switch-org' | '/api/auth/workos/webhooks' | '/dashboard/connections/$connectionId' - | '/dashboard/projects/$projectId' + | '/dashboard/projects/$projectid' | '/dashboard/repos/$repoId' | '/dashboard/repos/connect' + | '/dashboard/settings/tokens' + | '/dashboard/settings/user' | '/dashboard/units/$unitId' | '/dashboard/projects' | '/dashboard/repos' @@ -371,7 +417,9 @@ export interface FileRouteTypes { | '/logout' | '/_authenticated/_dashboard' | '/_orchestrator/job_artefacts' + | '/tfe/$' | '/api/auth/callback' + | '/app/settings/tokens' | '/orchestrator/github/callback' | '/orchestrator/github/webhook' | '/_authenticated/_dashboard/dashboard/connections' @@ -386,9 +434,11 @@ export interface FileRouteTypes { | '/api/auth/workos/switch-org' | '/api/auth/workos/webhooks' | '/_authenticated/_dashboard/dashboard/connections/$connectionId' - | '/_authenticated/_dashboard/dashboard/projects/$projectId' + | '/_authenticated/_dashboard/dashboard/projects/$projectid' | '/_authenticated/_dashboard/dashboard/repos/$repoId' | '/_authenticated/_dashboard/dashboard/repos/connect' + | '/_authenticated/_dashboard/dashboard/settings/tokens' + | '/_authenticated/_dashboard/dashboard/settings/user' | '/_authenticated/_dashboard/dashboard/units/$unitId' | '/_authenticated/_dashboard/dashboard/projects/' | '/_authenticated/_dashboard/dashboard/repos/' @@ -403,7 +453,9 @@ export interface RootRouteChildren { AuthenticatedRoute: typeof AuthenticatedRouteWithChildren LogoutRoute: typeof LogoutRoute OrchestratorJob_artefactsRoute: typeof OrchestratorJob_artefactsRoute + TfeSplatRoute: typeof TfeSplatRoute ApiAuthCallbackRoute: typeof ApiAuthCallbackRoute + AppSettingsTokensRoute: typeof AppSettingsTokensRoute OrchestratorGithubCallbackRoute: typeof OrchestratorGithubCallbackRoute OrchestratorGithubWebhookRoute: typeof OrchestratorGithubWebhookRoute OrchestratorOrgsOrgIdAccess_policyRoute: typeof OrchestratorOrgsOrgIdAccess_policyRoute @@ -439,6 +491,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/tfe/$': { + id: '/tfe/$' + path: '/tfe/$' + fullPath: '/tfe/$' + preLoaderRoute: typeof TfeSplatRouteImport + parentRoute: typeof rootRouteImport + } '/_orchestrator/job_artefacts': { id: '/_orchestrator/job_artefacts' path: '/job_artefacts' @@ -467,6 +526,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof OrchestratorGithubCallbackRouteImport parentRoute: typeof rootRouteImport } + '/app/settings/tokens': { + id: '/app/settings/tokens' + path: '/app/settings/tokens' + fullPath: '/app/settings/tokens' + preLoaderRoute: typeof AppSettingsTokensRouteImport + parentRoute: typeof rootRouteImport + } '/api/auth/callback': { id: '/api/auth/callback' path: '/api/auth/callback' @@ -579,6 +645,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedDashboardDashboardUnitsUnitIdRouteImport parentRoute: typeof AuthenticatedDashboardRoute } + '/_authenticated/_dashboard/dashboard/settings/user': { + id: '/_authenticated/_dashboard/dashboard/settings/user' + path: '/user' + fullPath: '/dashboard/settings/user' + preLoaderRoute: typeof AuthenticatedDashboardDashboardSettingsUserRouteImport + parentRoute: typeof AuthenticatedDashboardDashboardSettingsRoute + } + '/_authenticated/_dashboard/dashboard/settings/tokens': { + id: '/_authenticated/_dashboard/dashboard/settings/tokens' + path: '/tokens' + fullPath: '/dashboard/settings/tokens' + preLoaderRoute: typeof AuthenticatedDashboardDashboardSettingsTokensRouteImport + parentRoute: typeof AuthenticatedDashboardDashboardSettingsRoute + } '/_authenticated/_dashboard/dashboard/repos/connect': { id: '/_authenticated/_dashboard/dashboard/repos/connect' path: '/connect' @@ -593,11 +673,11 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedDashboardDashboardReposRepoIdRouteImport parentRoute: typeof AuthenticatedDashboardDashboardReposRoute } - '/_authenticated/_dashboard/dashboard/projects/$projectId': { - id: '/_authenticated/_dashboard/dashboard/projects/$projectId' - path: '/$projectId' - fullPath: '/dashboard/projects/$projectId' - preLoaderRoute: typeof AuthenticatedDashboardDashboardProjectsProjectIdRouteImport + '/_authenticated/_dashboard/dashboard/projects/$projectid': { + id: '/_authenticated/_dashboard/dashboard/projects/$projectid' + path: '/$projectid' + fullPath: '/dashboard/projects/$projectid' + preLoaderRoute: typeof AuthenticatedDashboardDashboardProjectsProjectidRouteImport parentRoute: typeof AuthenticatedDashboardDashboardProjectsRoute } '/_authenticated/_dashboard/dashboard/connections/$connectionId': { @@ -647,14 +727,14 @@ const AuthenticatedDashboardDashboardConnectionsRouteWithChildren = ) interface AuthenticatedDashboardDashboardProjectsRouteChildren { - AuthenticatedDashboardDashboardProjectsProjectIdRoute: typeof AuthenticatedDashboardDashboardProjectsProjectIdRoute + AuthenticatedDashboardDashboardProjectsProjectidRoute: typeof AuthenticatedDashboardDashboardProjectsProjectidRoute AuthenticatedDashboardDashboardProjectsIndexRoute: typeof AuthenticatedDashboardDashboardProjectsIndexRoute } const AuthenticatedDashboardDashboardProjectsRouteChildren: AuthenticatedDashboardDashboardProjectsRouteChildren = { - AuthenticatedDashboardDashboardProjectsProjectIdRoute: - AuthenticatedDashboardDashboardProjectsProjectIdRoute, + AuthenticatedDashboardDashboardProjectsProjectidRoute: + AuthenticatedDashboardDashboardProjectsProjectidRoute, AuthenticatedDashboardDashboardProjectsIndexRoute: AuthenticatedDashboardDashboardProjectsIndexRoute, } @@ -685,13 +765,31 @@ const AuthenticatedDashboardDashboardReposRouteWithChildren = AuthenticatedDashboardDashboardReposRouteChildren, ) +interface AuthenticatedDashboardDashboardSettingsRouteChildren { + AuthenticatedDashboardDashboardSettingsTokensRoute: typeof AuthenticatedDashboardDashboardSettingsTokensRoute + AuthenticatedDashboardDashboardSettingsUserRoute: typeof AuthenticatedDashboardDashboardSettingsUserRoute +} + +const AuthenticatedDashboardDashboardSettingsRouteChildren: AuthenticatedDashboardDashboardSettingsRouteChildren = + { + AuthenticatedDashboardDashboardSettingsTokensRoute: + AuthenticatedDashboardDashboardSettingsTokensRoute, + AuthenticatedDashboardDashboardSettingsUserRoute: + AuthenticatedDashboardDashboardSettingsUserRoute, + } + +const AuthenticatedDashboardDashboardSettingsRouteWithChildren = + AuthenticatedDashboardDashboardSettingsRoute._addFileChildren( + AuthenticatedDashboardDashboardSettingsRouteChildren, + ) + interface AuthenticatedDashboardRouteChildren { AuthenticatedDashboardDashboardConnectionsRoute: typeof AuthenticatedDashboardDashboardConnectionsRouteWithChildren AuthenticatedDashboardDashboardDriftRoute: typeof AuthenticatedDashboardDashboardDriftRoute AuthenticatedDashboardDashboardOnboardingRoute: typeof AuthenticatedDashboardDashboardOnboardingRoute AuthenticatedDashboardDashboardProjectsRoute: typeof AuthenticatedDashboardDashboardProjectsRouteWithChildren AuthenticatedDashboardDashboardReposRoute: typeof AuthenticatedDashboardDashboardReposRouteWithChildren - AuthenticatedDashboardDashboardSettingsRoute: typeof AuthenticatedDashboardDashboardSettingsRoute + AuthenticatedDashboardDashboardSettingsRoute: typeof AuthenticatedDashboardDashboardSettingsRouteWithChildren AuthenticatedDashboardDashboardUnitsUnitIdRoute: typeof AuthenticatedDashboardDashboardUnitsUnitIdRoute AuthenticatedDashboardDashboardUnitsIndexRoute: typeof AuthenticatedDashboardDashboardUnitsIndexRoute } @@ -709,7 +807,7 @@ const AuthenticatedDashboardRouteChildren: AuthenticatedDashboardRouteChildren = AuthenticatedDashboardDashboardReposRoute: AuthenticatedDashboardDashboardReposRouteWithChildren, AuthenticatedDashboardDashboardSettingsRoute: - AuthenticatedDashboardDashboardSettingsRoute, + AuthenticatedDashboardDashboardSettingsRouteWithChildren, AuthenticatedDashboardDashboardUnitsUnitIdRoute: AuthenticatedDashboardDashboardUnitsUnitIdRoute, AuthenticatedDashboardDashboardUnitsIndexRoute: @@ -738,7 +836,9 @@ const rootRouteChildren: RootRouteChildren = { AuthenticatedRoute: AuthenticatedRouteWithChildren, LogoutRoute: LogoutRoute, OrchestratorJob_artefactsRoute: OrchestratorJob_artefactsRoute, + TfeSplatRoute: TfeSplatRoute, ApiAuthCallbackRoute: ApiAuthCallbackRoute, + AppSettingsTokensRoute: AppSettingsTokensRoute, OrchestratorGithubCallbackRoute: OrchestratorGithubCallbackRoute, OrchestratorGithubWebhookRoute: OrchestratorGithubWebhookRoute, OrchestratorOrgsOrgIdAccess_policyRoute: diff --git a/ui/src/router.tsx b/ui/src/router.tsx index 4a6b5e688..e55d049a0 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -1,11 +1,19 @@ import { createRouter } from '@tanstack/react-router'; import { routeTree } from './routeTree.gen'; +import { terraformRoute } from '@/routes/manual/terraformWellknown'; + + +const existingChildren = (routeTree as any).children ?? [] // internal but fine + +const mixedTree = routeTree.addChildren([ + ...existingChildren, // keep all file-based routes + terraformRoute, // add your manual route +]) export function getRouter() { - const router = createRouter({ - routeTree, + + return createRouter({ + routeTree: mixedTree, scrollRestoration: true, }); - - return router; } diff --git a/ui/src/routes/__root.tsx b/ui/src/routes/__root.tsx index 4eb49f208..b775c8576 100644 --- a/ui/src/routes/__root.tsx +++ b/ui/src/routes/__root.tsx @@ -11,13 +11,16 @@ import { Sidebar, SidebarMenuButton, SidebarGroupContent, SidebarGroupLabel, Sid import { GitBranch, Folders, Waves, Settings, CreditCard, LogOut } from 'lucide-react'; import globalCssUrl from '@/styles/global.css?url' import { Toaster } from '@/components/ui/toaster'; +import { getPublicServerConfig } from '@/lib/env.server'; + export const Route = createRootRoute({ beforeLoad: async () => { const { auth, organisationId } = await getAuth(); const organisationDetails = organisationId ? await getOrganisationDetails({data: {organizationId: organisationId}}) : null; - return { user: auth.user, organisationId, role: auth.role, organisationName: organisationDetails?.name }; + const publicServerConfig : Env = await getPublicServerConfig() + return { user: auth.user, organisationId, role: auth.role, organisationName: organisationDetails?.name, publicServerConfig }; }, head: () => ({ meta: [ diff --git a/ui/src/routes/_authenticated/_dashboard.tsx b/ui/src/routes/_authenticated/_dashboard.tsx index e94223356..192d11630 100644 --- a/ui/src/routes/_authenticated/_dashboard.tsx +++ b/ui/src/routes/_authenticated/_dashboard.tsx @@ -79,7 +79,7 @@ function DashboardComponent() { - + Settings diff --git a/ui/src/routes/_authenticated/_dashboard/dashboard/projects.$projectid.tsx b/ui/src/routes/_authenticated/_dashboard/dashboard/projects.$projectid.tsx index 86ed44761..6f01328c2 100644 --- a/ui/src/routes/_authenticated/_dashboard/dashboard/projects.$projectid.tsx +++ b/ui/src/routes/_authenticated/_dashboard/dashboard/projects.$projectid.tsx @@ -34,7 +34,7 @@ const getDriftIcon = (status: string) => { } export const Route = createFileRoute( - '/_authenticated/_dashboard/dashboard/projects/$projectId', + '/_authenticated/_dashboard/dashboard/projects/$projectid', )({ component: RouteComponent, loader: async ({ context, params: {projectId} }) => { diff --git a/ui/src/routes/_authenticated/_dashboard/dashboard/settings.tokens.tsx b/ui/src/routes/_authenticated/_dashboard/dashboard/settings.tokens.tsx new file mode 100644 index 000000000..742d192a1 --- /dev/null +++ b/ui/src/routes/_authenticated/_dashboard/dashboard/settings.tokens.tsx @@ -0,0 +1,71 @@ +import { createFileRoute } from '@tanstack/react-router' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { useState } from 'react' + +export const Route = createFileRoute( + '/_authenticated/_dashboard/dashboard/settings/tokens', +)({ + component: RouteComponent, +}) + +function RouteComponent() { + const [tokens, setTokens] = useState([]) + const [newToken, setNewToken] = useState('') + + const generateToken = () => { + // This is a placeholder - implement actual token generation logic + const token = `digger_${Math.random().toString(36).substring(2)}` + setTokens([...tokens, token]) + setNewToken(token) + } + + return ( + + + API Tokens + + Generate and manage your API tokens for accessing Digger programmatically + + + +
+ +
+ {newToken && ( +
+

New Token (copy this now, it won't be shown again):

+
+ + +
+
+ )} +
+

Your Tokens

+ {tokens.length === 0 ? ( +

No tokens generated yet

+ ) : ( +
+ {tokens.map((token, index) => ( +
+ •••••••••••{token.slice(-4)} + +
+ ))} +
+ )} +
+
+
+ ) +} diff --git a/ui/src/routes/_authenticated/_dashboard/dashboard/settings.tsx b/ui/src/routes/_authenticated/_dashboard/dashboard/settings.tsx index 7eaba84c3..4bc6081cd 100644 --- a/ui/src/routes/_authenticated/_dashboard/dashboard/settings.tsx +++ b/ui/src/routes/_authenticated/_dashboard/dashboard/settings.tsx @@ -1,6 +1,5 @@ -import {WorkosSettings} from '@/components/WorkosSettings' -import { createFileRoute } from '@tanstack/react-router' - +import { createFileRoute, Link, Outlet, redirect, useLocation } from '@tanstack/react-router' +import { cn } from '@/lib/utils' export const Route = createFileRoute( '/_authenticated/_dashboard/dashboard/settings', @@ -9,13 +8,87 @@ export const Route = createFileRoute( loader: async ({ context }) => { const { user, organisationId, role } = context return { user, organisationId, role } + }, + beforeLoad: ({ location, search }) => { + if (location.pathname === '/dashboard/settings') { + throw redirect({ + to: '.', + search + }) + } + return {} } }) function RouteComponent() { - const { user, role, organisationId } = Route.useLoaderData() + const data = Route.useLoaderData() + const location = useLocation() + const isTokensPage = location.pathname.includes('tokens') return ( - +
+
+

Settings

+

+ Manage your account settings and API tokens +

+
+ +
+ +
+ +
+ +
+
) -} +} \ No newline at end of file diff --git a/ui/src/routes/_authenticated/_dashboard/dashboard/settings.user.tsx b/ui/src/routes/_authenticated/_dashboard/dashboard/settings.user.tsx new file mode 100644 index 000000000..081784359 --- /dev/null +++ b/ui/src/routes/_authenticated/_dashboard/dashboard/settings.user.tsx @@ -0,0 +1,25 @@ +import { WorkosSettings } from '@/components/WorkosSettings' +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute( + '/_authenticated/_dashboard/dashboard/settings/user', +)({ + component: RouteComponent, + loader: async ({ context }) => { + const { user, organisationId, role } = context + return { user, organisationId, role } + } +}) + +function RouteComponent() { + const { user, role, organisationId } = Route.useLoaderData() + + return ( + + ) +} \ No newline at end of file diff --git a/ui/src/routes/_authenticated/_dashboard/dashboard/units.$unitId.tsx b/ui/src/routes/_authenticated/_dashboard/dashboard/units.$unitId.tsx index 7d1cb5e1b..ee533d6a0 100644 --- a/ui/src/routes/_authenticated/_dashboard/dashboard/units.$unitId.tsx +++ b/ui/src/routes/_authenticated/_dashboard/dashboard/units.$unitId.tsx @@ -1,4 +1,4 @@ -import { createFileRoute, Link, useParams } from '@tanstack/react-router' +import { createFileRoute, Link, useParams, useRouter } from '@tanstack/react-router' import { Button } from '@/components/ui/button' import { DropdownMenu, @@ -24,6 +24,25 @@ import { import { Badge } from "@/components/ui/badge" import { ArrowLeft, Lock, Unlock, MoreVertical, History, Trash2, Download, Upload, RefreshCcw, Copy, Check, ArrowUpRight } from 'lucide-react' import { useState } from 'react' +import { toast } from '@/hooks/use-toast' +import { getUnitFn, getUnitVersionsFn, lockUnitFn, unlockUnitFn, getUnitStatusFn, deleteUnitFn, downloadLatestStateFn, restoreUnitStateVersionFn } from '@/api/statesman_serverFunctions' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { getPublicServerConfig } from '@/lib/env.server' +import type { Env } from '@/lib/env.server' +import { downloadJson } from '@/lib/io' + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog" +import UnitStateForceUploadDialog from '@/components/UnitStateForceUploadDialog' function CopyButton({ content }: { content: string }) { const [copied, setCopied] = useState(false) @@ -50,31 +69,33 @@ function CopyButton({ content }: { content: string }) { ) } + export const Route = createFileRoute( '/_authenticated/_dashboard/dashboard/units/$unitId', )({ component: RouteComponent, + loader: async ({ context, params: {unitId} }) => { + const { user, organisationId } = context; + const unitData = await getUnitFn({data: {organisationId: organisationId || '', userId: user?.id || '', email: user?.email || '', unitId: unitId}}) + const unitVersionsData = await getUnitVersionsFn({data: {organisationId: organisationId || '', userId: user?.id || '', email: user?.email || '', unitId: unitId}}) + const unitStatusData = await getUnitStatusFn({data: {organisationId: organisationId || '', userId: user?.id || '', email: user?.email || '', unitId: unitId}}) + + const publicServerConfig = context.publicServerConfig + const publicHostname = publicServerConfig.PUBLIC_HOSTNAME || '' + + + return { + unitData: unitData, + unitStatus: unitStatusData, + unitVersions: unitVersionsData.versions, + user, + organisationId, + publicHostname, + + } + } }) -// Mock data - replace with actual data fetching -const mockUnit = { - id: "prod-vpc-network", - size: 2457600, - updatedAt: new Date("2025-10-16T09:30:00"), - locked: true, - lockedBy: "john.doe@company.com", - status: "up-to-date", // Can be "up-to-date" or "needs re-apply" - version: "v12", - versions: [ - { version: "v12", timestamp: new Date("2025-10-16T09:30:00"), author: "john.doe@company.com", isLatest: true }, - { version: "v11", timestamp: new Date("2025-10-15T16:45:00"), author: "jane.smith@company.com", isLatest: false }, - { version: "v10", timestamp: new Date("2025-10-14T14:20:00"), author: "john.doe@company.com", isLatest: false }, - ], - dependencies: [ - { name: "shared-networking", status: "up-to-date" }, - { name: "security-groups", status: "needs re-apply" }, - ] -} function formatBytes(bytes: number) { if (bytes === 0) return '0 Bytes' @@ -84,19 +105,166 @@ function formatBytes(bytes: number) { return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] } -function formatDate(date: Date) { - return date.toLocaleDateString('en-US', { +function formatDate(input: Date | string | number) { + const date = input instanceof Date ? input : new Date(input) + if (Number.isNaN(date.getTime())) return '—' + return date.toLocaleString(undefined, { year: 'numeric', month: 'short', - day: 'numeric', + day: '2-digit', hour: '2-digit', - minute: '2-digit' + minute: '2-digit', + second: '2-digit' }) } function RouteComponent() { - const { unitId } = useParams({ from: '/_authenticated/_dashboard/dashboard/units/$unitId' }) - const unit = mockUnit // Replace with actual data fetching + const data = Route.useLoaderData() + const { unitData, unitVersions, unitStatus, organisationId, publicHostname, user } = data + const unit = unitData + const router = useRouter() + + const handleUnlock = async () => { + try { + await unlockUnitFn({ + data: { + userId: user?.id || '', + organisationId: organisationId || '', + email: user?.email || '', + unitId: unit.id, + }, + }) + toast({ + title: 'Unit unlocked', + description: `Unit ${unit.name} was unlocked successfully.`, + duration: 1000, + variant: "default" + }) + router.invalidate() + } catch (error) { + toast({ + title: 'Failed to unlock unit', + description: `Failed to unlock unit ${unit.name}.`, + duration: 5000, + variant: "destructive" + }) + console.error('Failed to unlock unit', error) + } + } + + const handleLock = async () => { + try { + await lockUnitFn({ + data: { + userId: user?.id || '', + organisationId: organisationId || '', + email: user?.email || '', + unitId: unit.id, + }, + }) + toast({ + title: 'Unit locked', + description: `Unit ${unit.name} was locked successfully.`, + duration: 1000, + variant: "default" + }) + router.invalidate() + } catch (error) { + toast({ + title: 'Failed to lock unit', + description: `Failed to lock unit ${unit.name}.`, + duration: 5000, + variant: "destructive" + }) + console.error('Failed to lock unit', error) + } + } + + + const handleDelete = async () => { + try { + await deleteUnitFn({ + data: { + userId: user?.id || '', + organisationId: organisationId || '', + email: user?.email || '', + unitId: unit.id, + }, + }) + + toast({ + title: 'Unit deleted', + description: `Unit ${unit.name} was deleted successfully.`, + duration: 1000, + variant: "default" + }) + router.invalidate() + } catch (error) { + console.error('Failed to delete unit', error) + toast({ + title: 'Failed to delete unit', + description: `Failed to delete unit ${unit.name}.`, + duration: 5000, + variant: "destructive" + }) + return + } + setTimeout(() => router.navigate({ to: '/dashboard/units' }), 500) + } + + const handleDownloadLatestState = async () => { + try { + const state : any = await downloadLatestStateFn({ + data: { + userId: user?.id || '', + organisationId: organisationId || '', + email: user?.email || '', + unitId: unit.id, + }, + }) + downloadJson(state, `${unit.name}-latest-state.json`) + } catch (error) { + console.error('Failed to download latest state', error) + toast({ + title: 'Failed to download latest state', + description: `Failed to download latest state for unit ${unit.name}.`, + duration: 5000, + variant: "destructive" + }) + return + } + } + + const handleRestoreStateVersion = async (timestamp: string, lockId: string) => { + try { + await restoreUnitStateVersionFn({ + data: { + userId: user?.id || '', + organisationId: organisationId || '', + email: user?.email || '', + unitId: unit.id, + timestamp: timestamp, + lockId: lockId, + }, + }) + toast({ + title: 'State version restored', + description: `State version ${timestamp} was restored successfully.`, + duration: 1000, + variant: "default" + }) + router.invalidate() + } catch (error) { + console.error('Failed to restore state version', error) + toast({ + title: 'Failed to restore state version', + description: `Failed to restore state version ${timestamp}.`, + duration: 5000, + variant: "destructive" + }) + return + } + } return (
@@ -108,13 +276,13 @@ function RouteComponent() {
- - {unit.status === "up-to-date" ? ( + + {unitStatus.status === "green" ? ( ) : ( )} - {unit.status === "up-to-date" ? "Up-to-date" : "Needs re-apply"} + {unitStatus.status === "green" ? "Up-to-date" : "Needs re-apply"} {unit.locked ? : } @@ -124,14 +292,18 @@ function RouteComponent() {
- - + } + {!unit.locked && }
@@ -139,9 +311,12 @@ function RouteComponent() {
- {unit.id} + {unit.name} + + ID: {unit.id} + - Version {unit.version} • Last updated {formatDate(unit.updatedAt)} • {formatBytes(unit.size)} + Version {unit.version} • Last updated {formatDate(unit.updated)} • {formatBytes(unit.size)} @@ -150,7 +325,6 @@ function RouteComponent() { Setup State versions - Dependencies Settings @@ -169,10 +343,10 @@ function RouteComponent() {
 {`terraform {
   cloud {
-    hostname = "mo-opentaco-test.ngrok.app"
-    organization = "opentaco"    
+    hostname = "${publicHostname}"
+    organization = "${organisationId}"    
     workspaces {
-      name = "momo"
+      name = "${unit.id}"
     }
   }
 }`}
@@ -180,10 +354,10 @@ function RouteComponent() {
                     
                     
-
terraform login mo-opentaco-test.ngrok.app
- +
terraform login {publicHostname}
+
@@ -254,73 +428,51 @@ function RouteComponent() { Previous versions of this unit -
- {unit.versions.map((version) => ( -
-
-
- {version.version} - {version.isLatest && ( - - Latest - - )} -
-
- {formatDate(version.timestamp)} by {version.author} -
-
-
- {!version.isLatest && ( - - )} - -
-
- ))} -
+ {(!unitVersions || unitVersions.length === 0) ? ( +
+ No versions yet. A version will appear after the first state is uploaded. +
+ ) : ( + + + + Hash + Size + Date + Actions + + + + {unitVersions.map((version: any) => { + const shortHash = String(version.hash).slice(0, 8) + return ( + + + {shortHash} + + {formatBytes(Number(version.size) || 0)} + {formatDate(version.timestamp)} + +
+ {!version.isLatest && ( + + )} +
+
+
+ ) + })} +
+
+ )}
- - - - Dependencies - Units this unit depends on - - -
- {unit.dependencies.map((dep) => ( -
-
- - {dep.name} - - -
- Status: {dep.status} -
-
- - {dep.status} - -
- ))} -
-
-
-
+ @@ -336,10 +488,7 @@ function RouteComponent() { This will overwrite the remote state with your local state, ignoring any locks or version history. Only use this if you are absolutely sure your local state is correct.

- +
@@ -348,10 +497,27 @@ function RouteComponent() { This will permanently delete this unit and all of its version history. This action cannot be undone. Make sure to back up any important state before proceeding.

- + + + + + + + Delete this unit? + + This action cannot be undone. This will permanently delete the unit + and all of its version history. + + + + Cancel + Delete + + +
diff --git a/ui/src/routes/_authenticated/_dashboard/dashboard/units.index.tsx b/ui/src/routes/_authenticated/_dashboard/dashboard/units.index.tsx index 15226519e..2a924fd70 100644 --- a/ui/src/routes/_authenticated/_dashboard/dashboard/units.index.tsx +++ b/ui/src/routes/_authenticated/_dashboard/dashboard/units.index.tsx @@ -66,15 +66,13 @@ function CreateUnitModal({ onUnitCreated }: { onUnitCreated: () => void }) { setIsLoading(true) setError(null) - const unitId = `org-${organisationId}/${unitName}` - try { await createUnitFn({ data: { userId: user?.id!, organisationId, email: user?.email || '', - unitId: unitId, + name: unitName, } }) setOpen(false) @@ -182,7 +180,7 @@ function RouteComponent() { {unit.locked ? : } - {unit.id} + {unit.name} {formatBytes(unit.size)} {formatDate(unit.updatedAt || new Date())} diff --git a/ui/src/routes/api/auth/workos/switch-org.tsx b/ui/src/routes/api/auth/workos/switch-org.tsx index 3a5d4a908..78967f8e6 100644 --- a/ui/src/routes/api/auth/workos/switch-org.tsx +++ b/ui/src/routes/api/auth/workos/switch-org.tsx @@ -1,151 +1,61 @@ -// src/routes/api/auth/workos/switch-org.ts -import { NoUserInfo, UserInfo } from '@/authkit/ssr/interfaces' -import { getSessionFromCookie, saveSession } from '@/authkit/ssr/session' import { createFileRoute } from '@tanstack/react-router' -import { getSession } from '@tanstack/react-start/server' -import { getWorkOS } from '@/authkit/ssr/workos'; - import { decodeJwt } from 'jose' -import { AccessToken } from '@workos-inc/node' - -// WorkOS Node SDK must stay server-only. -import { createRequire } from 'node:module' -import { getConfig } from '@/authkit/ssr/config'; -const require = createRequire(import.meta.url) -const { WorkOS, } = require('@workos-inc/node') -const workos = new WorkOS(process.env.WORKOS_API_KEY!) - -async function refreshSession(options: { organizationId?: string; ensureSignedIn: true }): Promise; -async function refreshSession(options?: { - organizationId?: string; - ensureSignedIn?: boolean; -}): Promise; -async function refreshSession({ - organizationId: nextOrganizationId, - ensureSignedIn = false, -}: { - organizationId?: string; - ensureSignedIn?: boolean; -} = {}): Promise { - const session = await getSessionFromCookie(); - if (!session) { - if (ensureSignedIn) { - // await redirectToSignIn(); - } - return { user: null }; - } - - const WORKOS_CLIENT_ID = getConfig('clientId'); - const WORKOS_REDIRECT_URI = getConfig('redirectUri'); - - const { org_id: organizationIdFromAccessToken } = decodeJwt(session.accessToken); - - let refreshResult; - - try { - refreshResult = getWorkOS().userManagement.authenticateWithRefreshToken({ - clientId: WORKOS_CLIENT_ID, - refreshToken: session.refreshToken, - organizationId: nextOrganizationId ?? organizationIdFromAccessToken, - }); - } catch (error) { - throw new Error(`Failed to refresh session: ${error instanceof Error ? error.message : String(error)}`, { - cause: error, - }); - } - - const headersList = new Headers(); - const url = headersList.get('x-url'); - - await saveSession(refreshResult); - - const { accessToken, user, impersonator } = refreshResult; - - const { - sid: sessionId, - org_id: organizationId, - role, - roles, - permissions, - entitlements, - } = decodeJwt(accessToken); - - return { - sessionId, - user, - organizationId, - role, - permissions, - entitlements, - impersonator, - accessToken, - }; -} - +import { getWorkOS } from '@/authkit/ssr/workos' +import { getSessionFromCookie, saveSession } from '@/authkit/ssr/session' +import type { AccessToken } from '@workos-inc/node' export const Route = createFileRoute('/api/auth/workos/switch-org')({ server: { handlers: { POST: async ({ request }) => { try { - const { organizationId, pathname } = await request.json() as { - organizationId?: string - pathname?: string - } + const { organizationId, pathname } = await request.json() if (!organizationId) { - return jsonError(400, 'Missing organizationId') + return new Response(JSON.stringify({ error: 'Missing organizationId' }), { status: 400 }) } - // 1) Refresh/attach session for the target org + // Refresh/attach session for the target org + const session = await getSessionFromCookie() + if (!session) { + return new Response(JSON.stringify({ error: 'Not authenticated' }), { status: 401 }) + } + + const { org_id: currentOrgId } = decodeJwt(session.accessToken) + try { - await refreshSession({ organizationId, ensureSignedIn: true }) - } catch (err: any) { - // 2) Handle AuthKit redirect hints (AuthN/SSO/MFA) - const authkitRedirect = err?.rawData?.authkit_redirect_url - if (authkitRedirect) { - return redirectResponse(authkitRedirect) - } + const refreshResult = await getWorkOS().userManagement.authenticateWithRefreshToken({ + clientId: process.env.WORKOS_CLIENT_ID!, + refreshToken: session.refreshToken, + organizationId: organizationId ?? currentOrgId, + }) - // 3) Handle SSO required / MFA enrollment by initiating authorization + await saveSession(refreshResult) + } catch (err: any) { const code = err?.error if (code === 'sso_required' || code === 'mfa_enrollment') { - const url = workos.userManagement.getAuthorizationUrl({ + const url = getWorkOS().userManagement.getAuthorizationUrl({ organizationId, clientId: process.env.WORKOS_CLIENT_ID!, provider: 'authkit', redirectUri: process.env.WORKOS_REDIRECT_URI!, }) - return redirectResponse(url) + return new Response(JSON.stringify({ redirectUrl: url }), { status: 200 }) } - - // Unknown error — bubble as 500 throw err } - // 4) Redirect back to the requested page after session switch const to = pathname || '/' - // (No next/cache in TanStack — rely on loader invalidation on the client if needed) - return redirectResponse(to) + return new Response(JSON.stringify({ redirectUrl: to }), { status: 200 }) } catch (err: any) { - console.error('switch-org error:', err) - return jsonError(500, err?.message ?? 'Internal error') + console.error('switch-org route error:', err) + return new Response(JSON.stringify({ error: err?.message ?? 'Internal error' }), { status: 500 }) } + + }, }, }, }) -function redirectResponse(location: string) { - return new Response(null, { - status: 302, - headers: { Location: location }, - }) -} -function jsonError(status: number, message: string) { - return new Response(JSON.stringify({ error: message }), { - status, - headers: { 'Content-Type': 'application/json' }, - }) -} diff --git a/ui/src/routes/api/auth/workos/webhooks.tsx b/ui/src/routes/api/auth/workos/webhooks.tsx index f45afce4b..b386755ec 100644 --- a/ui/src/routes/api/auth/workos/webhooks.tsx +++ b/ui/src/routes/api/auth/workos/webhooks.tsx @@ -2,7 +2,7 @@ import { createFileRoute } from '@tanstack/react-router'; import { WorkOS , Event as WorkOsEvent } from '@workos-inc/node'; import { syncOrgToBackend } from '@/api/orchestrator_orgs'; import { syncUserToBackend } from '@/api/orchestrator_users'; -import { createOrgForUser, listUserOrganizationInvitations, getOrganisationDetails } from '@/authkit/ssr/workos_api'; +import { createOrgForUser, listUserOrganizationInvitations, getOrganisationDetails, getOranizationsForUser } from '@/authkit/ssr/workos_api'; import { syncOrgToStatesman } from '@/api/statesman_orgs'; import { syncUserToStatesman } from '@/api/statesman_users'; @@ -25,15 +25,31 @@ export const Route = createFileRoute('/api/auth/workos/webhooks')({ if (event.event === "user.created") { console.log("Creating personal organization for the user", event.data.email) - const orgDetails = await createOrgForUser(event.data.id, "Personal"); - let orgName = orgDetails.name; - let orgId = orgDetails.id; - console.log(`User ${event.data.email} is not invited to an organization, creating a new one`); + + const uuid = crypto.randomUUID(); + const personalOrgDisplayName = "Personal" + const personalOrgName = `${personalOrgDisplayName}_${uuid}`; + const userOrganizations = await getOranizationsForUser(event.data.id); + const personalOrg = userOrganizations.filter(membership => + membership.organizationName === personalOrgDisplayName + ); + const personalOrgExists = personalOrg.length > 0; + + let orgName, orgId; + if (personalOrgExists) { + orgName = personalOrg[0].organizationName; + orgId = personalOrg[0].organizationId; + } else { + console.log(`User ${event.data.email} is not invited to an organization, creating a new one`); + const orgDetails = await createOrgForUser(event.data.id, personalOrgDisplayName); + orgName = orgDetails.name; + orgId = orgDetails.id; + } try { console.log("Syncing organization to backend orgName", orgName, "orgId", orgId); await syncOrgToBackend(orgId, orgName, null); - await syncOrgToStatesman(orgId, orgName, event.data.id, event.data.email); + await syncOrgToStatesman(orgId, personalOrgName, personalOrgDisplayName, event.data.id, event.data.email); } catch (error) { console.error(`Error syncing organization to backend:`, error); throw error; @@ -62,18 +78,18 @@ export const Route = createFileRoute('/api/auth/workos/webhooks')({ let orgName = orgDetails.name; for (const invitation of userInvitations) { try { - await syncUserToBackend(invitation.userId!, invitation.userEmail!, invitation.organizationId!); - await syncUserToStatesman(invitation.userId!, invitation.userEmail!, invitation.organizationId!); + await syncOrgToBackend(invitation.organizationId!, orgName, null); + await syncOrgToStatesman(invitation.organizationId!, orgName, personalOrgDisplayName, invitation.userId!, invitation.userEmail!); } catch (error) { - console.error(`Error syncing user to backend:`, error); - throw error; - } + console.error(`Error syncing user to backend:`, error); + throw error; + } } - } + } } - - return new Response('Webhook received', { status: 200 }); + return new Response('Webhook received', { status: 200 }); }, }, }, -}); + }); + diff --git a/ui/src/routes/app/settings.tokens.tsx b/ui/src/routes/app/settings.tokens.tsx new file mode 100644 index 000000000..233072984 --- /dev/null +++ b/ui/src/routes/app/settings.tokens.tsx @@ -0,0 +1,11 @@ +import { createFileRoute, redirect } from '@tanstack/react-router' + +export const Route = createFileRoute('/app/settings/tokens')({ + server: { + handlers: { + GET: async ({ request }) => { + return new redirect({ to: '/dashboard/settings/tokens' }) + } + } + } +}) diff --git a/ui/src/routes/manual/terraformWellKnown.tsx b/ui/src/routes/manual/terraformWellKnown.tsx new file mode 100644 index 000000000..db9db672c --- /dev/null +++ b/ui/src/routes/manual/terraformWellKnown.tsx @@ -0,0 +1,23 @@ +import { createFileRoute, createRoute } from '@tanstack/react-router' +import { Route as rootRoute } from '@/routes/__root' + +export const terraformRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/.well-known/terraform.json', + // component: () =>
{JSON.stringify({ backend: 's3' })}
, + server: { + handlers: { + GET: async ({ request }) => { + const payload = { + "modules.v1":"/v1/modules/", + "motd.v1":"/tfe/api/v2/motd", + "state.v2":"/tfe/api/v2/", + "tfe.v2":"/tfe/api/v2/", + "tfe.v2.1":"/tfe/api/v2/", + "tfe.v2.2":"/tfe/api/v2/" + } + return new Response(JSON.stringify(payload), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + } + } + }) \ No newline at end of file diff --git a/ui/src/routes/tfe/$.tsx b/ui/src/routes/tfe/$.tsx new file mode 100644 index 000000000..c541554fe --- /dev/null +++ b/ui/src/routes/tfe/$.tsx @@ -0,0 +1,49 @@ +import { createFileRoute } from '@tanstack/react-router' + +async function handler({ request }) { + const url = new URL(request.url); + + // important: we need to set these to allow the statesman backend to return the correct URL to opentofu or terraform clients + const outgoingHeaders = new Headers(request.headers); + const originalHost = outgoingHeaders.get('host') ?? ''; + console.log('originalHost', originalHost); + if (originalHost) outgoingHeaders.set('x-forwarded-host', originalHost); + outgoingHeaders.set('x-forwarded-proto', url.protocol.replace(':', '')); + if (url.port) outgoingHeaders.set('x-forwarded-port', url.port); + // Let fetch manage these, and drop hop-by-hop headers + ['host','content-length','connection','keep-alive','proxy-connection','transfer-encoding','upgrade','te','trailer','accept-encoding'] + .forEach(h => outgoingHeaders.delete(h)); + + + const response = await fetch(`${process.env.STATESMAN_BACKEND_URL}${url.pathname}${url.search}`, { + method: request.method, + headers: request.headers, + body: request.method !== 'GET' ? await request.blob() : undefined + }); + + // important, remove all encoding headers since the fetch already decompresses the gzip + // the removal of headeres avoids gzip errors in the client + const headers = new Headers(response.headers); + headers.delete('Content-Encoding'); + headers.delete('content-length'); + headers.delete('transfer-encoding'); + headers.delete('connection'); + + return new Response(response.body, { headers }); +} + +export const Route = createFileRoute('/tfe/$')({ + server: { + handlers: { + GET: handler, + POST: handler, + PUT: handler, + DELETE: handler, + PATCH: handler, + HEAD: handler, + OPTIONS: handler, + LOCK: handler, + UNLOCK: handler + } + } +}) \ No newline at end of file diff --git a/ui/vite.config.ts b/ui/vite.config.ts index bf3f0e1ab..442039bac 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -26,10 +26,9 @@ export default defineConfig(({ mode }) => { tsConfigPaths({ projects: ['./tsconfig.json'], }), - netlify(), - // cloudflare({ viteEnvironment: { name: 'ssr' } }), tanstackStart(), viteReact(), + // cloudflare({ viteEnvironment: { name: 'ssr' } }), ], }; });