Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 37 additions & 7 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,28 +68,58 @@ jobs:
cd backend
node dist/index.js > /tmp/backend.log 2>&1 &
echo "Waiting for backend /health..."
for i in $(seq 1 60); do
for _ in $(seq 1 60); do
if curl -fsS http://localhost:3001/health >/dev/null 2>&1; then echo "backend up"; exit 0; fi
sleep 2
done
echo "backend failed to start"; cat /tmp/backend.log; exit 1

- name: Create admin user
# Seed a FULLY-PROVISIONED admin: user + personal org + active owner
# membership. The authenticated shell sits behind WorkspaceProvisioningGate
# (frontend/src/components/WorkspaceProvisioningGate.tsx), which only renders
# the app layout once the user has a personal org. Real logins provision that
# org asynchronously (fire-and-forget self-heal on login); seeding it directly
# makes the gate open immediately, so the e2e is deterministic instead of
# racing a background job (and tolerant of Permit/Kafka being absent in CI).
- name: Create admin user (provisioned with personal org)
working-directory: backend
run: |
node -e '
const bcrypt = require("bcrypt");
const crypto = require("crypto");
const knex = require("knex")({
client: "pg",
connection: { host: process.env.DB_HOST, port: +process.env.DB_PORT,
database: process.env.DB_NAME, user: process.env.DB_USER, password: process.env.DB_PASSWORD },
});
(async () => {
const password_hash = await bcrypt.hash("admin123", 10);
await knex("users").insert({ email: "admin@fuzefront.dev", password_hash,
const [u] = await knex("users").insert({ email: "admin@fuzefront.dev", password_hash,
first_name: "Admin", last_name: "User", roles: JSON.stringify(["admin","user"]) })
.onConflict("email").merge({ password_hash });
console.log("admin user ready");
.onConflict("email").merge({ password_hash }).returning("id");
const userId = u.id || u;
console.log("admin user ready", userId);

// Mirror ensurePersonalOrg(): one active personal org owned by the user.
const existing = await knex("organizations")
.where({ owner_id: userId, type: "personal" }).first();
if (!existing) {
const orgId = crypto.randomUUID();
await knex.transaction(async trx => {
await trx("organizations").insert({ id: orgId, name: "Personal",
slug: "personal-" + userId, parent_id: null, owner_id: userId,
type: "personal", settings: JSON.stringify({}),
metadata: JSON.stringify({ personal: true }), is_active: true,
provisioning_state: "active" });
await trx("organization_memberships").insert({ id: crypto.randomUUID(),
user_id: userId, organization_id: orgId, role: "owner", status: "active",
joined_at: new Date(), permissions: JSON.stringify({}),
metadata: JSON.stringify({}) });
});
console.log("personal org provisioned", orgId);
} else {
console.log("personal org already present", existing.id);
}
await knex.destroy();
})().catch(e => { console.error(e); process.exit(1); });
'
Expand All @@ -101,7 +131,7 @@ jobs:
npm ci --include=dev # need vite/@playwright (devDeps) despite NODE_ENV=production
npm run build
npx vite preview --port 4173 --host 127.0.0.1 > /tmp/frontend.log 2>&1 &
for i in $(seq 1 30); do
for _ in $(seq 1 30); do
if curl -fsS http://localhost:4173 >/dev/null 2>&1; then echo "frontend up"; exit 0; fi
sleep 2
done
Expand All @@ -114,7 +144,7 @@ jobs:
npm install --include=dev # need vite (devDep) despite NODE_ENV=production
VITE_HUB_API_URL=http://localhost:3001 VITE_PUBLIC_URL=http://localhost:4174 npm run build
npx vite preview --port 4174 --host 127.0.0.1 > /tmp/clock.log 2>&1 &
for i in $(seq 1 30); do
for _ in $(seq 1 30); do
if curl -fsS http://localhost:4174/assets/remoteEntry.js >/dev/null 2>&1; then echo "clock-app up"; break; fi
sleep 2
done
Expand Down
43 changes: 34 additions & 9 deletions backend/src/routes/organizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,24 @@ import { reconcileOrganizationProvisioning } from '../services/organizationProvi

const router = express.Router()

// `settings`/`metadata` are jsonb columns. The `pg` driver already parses jsonb
// into JS objects on read, so calling JSON.parse() on them throws
// ("[object Object]" is not valid JSON) and 500s the route. Older code paths /
// other drivers (e.g. sqlite) may hand back a string instead, so accept both:
// pass objects through, parse strings, and fall back to {} on anything invalid.
function parseJsonColumn(value: unknown): Record<string, any> {
if (value == null) return {}
if (typeof value === 'object') return value as Record<string, any>
if (typeof value === 'string') {
try {
return JSON.parse(value)
} catch {
return {}
}
}
return {}
}

// Input validation helpers
function validateOrganizationInput(data: any) {
const errors: string[] = []
Expand Down Expand Up @@ -164,8 +182,8 @@ router.post('/', authenticateToken, async (req: any, res) => {
parent_id: newOrganization.parent_id,
owner_id: newOrganization.owner_id,
type: newOrganization.type,
settings: JSON.parse(newOrganization.settings || '{}'),
metadata: JSON.parse(newOrganization.metadata || '{}'),
settings: parseJsonColumn(newOrganization.settings),
metadata: parseJsonColumn(newOrganization.metadata),
is_active: newOrganization.is_active,
created_at: newOrganization.created_at,
updated_at: newOrganization.updated_at,
Expand Down Expand Up @@ -265,8 +283,15 @@ router.get('/', authenticateToken, async (req: any, res) => {
}
}

// `is_active` defaults to the boolean `true` (when no query param is sent),
// but arrives as a string when it IS sent. Comparing `true === 'true'`
// yields false, which previously filtered to is_active=false and hid every
// active org (including the user's personal org) — leaving the frontend
// WorkspaceProvisioningGate stuck on "Creating your workspace…". Coerce both
// shapes: treat boolean true and the string 'true' as active.
if (is_active !== undefined) {
query = query.where('organizations.is_active', is_active === 'true')
const wantActive = is_active === true || is_active === 'true'
query = query.where('organizations.is_active', wantActive)
}

if (search) {
Expand Down Expand Up @@ -298,8 +323,8 @@ router.get('/', authenticateToken, async (req: any, res) => {
parent_id: org.parent_id,
owner_id: org.owner_id,
type: org.type,
settings: JSON.parse(org.settings || '{}'),
metadata: JSON.parse(org.metadata || '{}'),
settings: parseJsonColumn(org.settings),
metadata: parseJsonColumn(org.metadata),
is_active: org.is_active,
created_at: org.created_at,
updated_at: org.updated_at,
Expand Down Expand Up @@ -374,8 +399,8 @@ router.get(
parent_id: organization.parent_id,
owner_id: organization.owner_id,
type: organization.type,
settings: JSON.parse(organization.settings || '{}'),
metadata: JSON.parse(organization.metadata || '{}'),
settings: parseJsonColumn(organization.settings),
metadata: parseJsonColumn(organization.metadata),
is_active: organization.is_active,
created_at: organization.created_at,
updated_at: organization.updated_at,
Expand Down Expand Up @@ -459,8 +484,8 @@ router.put(
parent_id: updatedOrganization.parent_id,
owner_id: updatedOrganization.owner_id,
type: updatedOrganization.type,
settings: JSON.parse(updatedOrganization.settings || '{}'),
metadata: JSON.parse(updatedOrganization.metadata || '{}'),
settings: parseJsonColumn(updatedOrganization.settings),
metadata: parseJsonColumn(updatedOrganization.metadata),
is_active: updatedOrganization.is_active,
created_at: updatedOrganization.created_at,
updated_at: updatedOrganization.updated_at,
Expand Down
Loading