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
+
+
+
+
+ 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 === 'loading' ? 'Uploading…' : 'Upload new state file'}
+
+
+ {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
+
+
+
+
+ Generate New Token
+
+ {newToken && (
+
+
New Token (copy this now, it won't be shown again):
+
+
+ navigator.clipboard.writeText(newToken)}>
+ Copy
+
+
+
+ )}
+
+
Your Tokens
+ {tokens.length === 0 ? (
+
No tokens generated yet
+ ) : (
+
+ {tokens.map((token, index) => (
+
+ •••••••••••{token.slice(-4)}
+ setTokens(tokens.filter((_, i) => i !== index))}
+ >
+ Revoke
+
+
+ ))}
+
+ )}
+
+
+
+ )
+}
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
+
+
+
+
+
+
+
+
+
+
+ User Settings
+
+
+
+
+
+ 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() {
-
-
+
+
Download Latest State
-
+ {unit.locked &&
Unlock
-
+ }
+ {!unit.locked &&
+
+ Lock
+ }
@@ -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 && (
-
-
- Restore
-
- )}
-
-
- Download
-
-
-
- ))}
-
+ {(!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 && (
+ handleRestoreStateVersion(version.timestamp, version.lockId)}>
+
+ Restore
+
+ )}
+
+
+
+ )
+ })}
+
+
+ )}
-
-
-
- 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.
-
-
- Force Push State
-
+
@@ -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 Unit
-
+
+
+
+
+ Delete Unit
+
+
+
+
+ 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' } }),
],
};
});