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
42 changes: 31 additions & 11 deletions apps/console/src/pages/auth/SetupPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ export function SetupPage() {
const {
user,
signUp,
organizations,
refreshOrganizations,
updateOrganization,
createOrganization,
Expand Down Expand Up @@ -83,37 +82,58 @@ export function SetupPage() {
}, [bootstrapped, user, navigate]);

useEffect(() => {
if (user) {
// Already-signed-in visitors bounce home — but NOT mid-submission: signUp
// flips `user` while handleSubmit is still renaming the bootstrap org, and
// navigating here killed that in-flight rename (the org silently kept the
// "Default Organization" name). handleSubmit owns the redirect on success.
if (user && !submitting) {
window.location.assign('/');
}
}, [user]);
}, [user, submitting]);

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
try {
await signUp(name, email, password);

// The Security plugin auto-creates a personal "<User>'s Workspace"
// on signup so multi-tenant RLS has something to hang on. Don't
// create a second org — rename the auto-created one to the user's
// chosen name (or create a fresh one if the plugin is disabled).
// The server bootstraps the owner's organization (plugin-auth's
// default-org bootstrap in single-org mode; org-scoping's in
// multi-org) — don't create a second org, RENAME the bootstrap one
// to the user's chosen name. Two subtleties:
// - the bootstrap runs off a permission-grant middleware and may
// land moments after signUp() resolves → poll briefly;
// - the `organizations` state from useAuth() is a STALE CLOSURE
// here (refresh just updated it, but this render can't see it) —
// use the list refreshOrganizations() returns. Reading the state
// was the bug that made this path silently fall through to
// createOrganization(), which single-org mode FORBIDs.
const trimmedName = orgName.trim();
if (trimmedName) {
try {
await refreshOrganizations();
const personal = organizations[0];
let personal: { id?: string } | undefined;
for (let attempt = 0; attempt < 4 && !personal?.id; attempt++) {
if (attempt > 0) await new Promise((r) => setTimeout(r, 500));
const orgs = await refreshOrganizations();
personal = orgs?.[0];
}
// CJK/emoji-only names slugify to '' — an empty slug fails the org
// update server-side and the whole rename used to be silently
// swallowed. The rename is about the DISPLAY name: keep the
// existing slug when there's nothing latin to derive, and mint a
// stable fallback only when creating from scratch.
const slug = slugify(trimmedName);
let activeOrgId: string | undefined;
if (personal?.id) {
await updateOrganization(personal.id, {
name: trimmedName,
slug: slugify(trimmedName),
...(slug ? { slug } : {}),
});
activeOrgId = personal.id;
} else {
const created = await createOrganization({
name: trimmedName,
slug: slugify(trimmedName),
slug: slug || `org-${Date.now().toString(36)}`,
});
activeOrgId = created?.id;
}
Expand Down
7 changes: 5 additions & 2 deletions packages/app-shell/src/chrome/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,11 @@ export function CommandPalette({ apps, activeApp, objects, onAppChange, dataSour
}, [open]);

const baseUrl = `/apps/${appName || appRouteSegment(activeApp)}`;
const { user } = useAuth();
const templateContext = useMemo(() => ({ currentUserId: user?.id ?? null }), [user?.id]);
const { user, activeOrganization } = useAuth();
const templateContext = useMemo(
() => ({ currentUserId: user?.id ?? null, currentOrgId: activeOrganization?.id ?? null }),
[user?.id, activeOrganization?.id],
);

const runCommand = useCallback((command: () => void) => {
setOpen(false);
Expand Down
13 changes: 11 additions & 2 deletions packages/app-shell/src/hooks/useConsoleActionRuntime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,16 +165,25 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
const row = action?.params && !Array.isArray(action.params)
? (action.params as Record<string, any>)._rowRecord
: undefined;
// Field-backed params resolve against the action's OWN object when the
// dispatch carries one (related-list row actions retarget a CHILD object
// — e.g. sys_member rows on an org record page); the page-level object
// is only the fallback. Without this, a child action's `field` lookup
// ran against the parent object, missed, and degraded to a bare text
// input (no select options, no field label).
const actionObject = typeof action?.objectName === 'string' && action.objectName
? action.objectName
: undefined;
const resolved = resolveActionParams(params as any, {
objectName: objectName || (objectDef as any)?.name || '',
objectName: actionObject || objectName || (objectDef as any)?.name || '',
objects: objects || [],
fieldLabel,
fieldOptionLabel,
row,
});
// Localize each param's label/placeholder/helpText via the
// `_actions.<action>.params.<param>.<attr>` convention.
const objForI18n = objectName || (objectDef as any)?.name;
const objForI18n = actionObject || objectName || (objectDef as any)?.name;
const localized = (resolved as any[]).map((p: any) => ({
...p,
label: actionParamText(objForI18n, action?.name, p.name, 'label', p.label) ?? p.label,
Expand Down
8 changes: 4 additions & 4 deletions packages/app-shell/src/layout/AppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ const getIcon = resolveIcon;

export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: string, onAppChange: (name: string) => void }) {
const { isMobile, setOpenMobile } = useSidebar();
const { user, signOut, isAuthEnabled } = useAuth();
const { user, signOut, isAuthEnabled, activeOrganization } = useAuth();
const isWorkspaceAdmin = useIsWorkspaceAdmin();
const navigate = useNavigate();
const location = useLocation();
Expand Down Expand Up @@ -389,7 +389,7 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri
location.pathname,
location.search,
basePath,
{ currentUserId: user?.id ?? null, contextValues },
{ currentUserId: user?.id ?? null, currentOrgId: activeOrganization?.id ?? null, contextValues },
);
const sel = active ? `?sel=${encodeURIComponent(`nav:${active.id}`)}` : '';
navigate(`/apps/${seg}/metadata/app/${activeAppName}${sel}`);
Expand Down Expand Up @@ -499,7 +499,7 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri
resolveObjectLabel={(objectName, fallback) => resolveNavObjectLabel({ name: objectName, label: fallback })}
resolveViewLabel={(objectName, viewName, fallback) => resolveNavViewLabel(objectName, viewName, fallback)}
t={t}
templateContext={{ currentUserId: user?.id ?? null, contextValues }}
templateContext={{ currentUserId: user?.id ?? null, currentOrgId: activeOrganization?.id ?? null, contextValues }}
/>

{/* Recent Items (elevated position for quick access) */}
Expand Down Expand Up @@ -686,7 +686,7 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri
return leaves.slice(0, 5).map((item: any) => {
const NavIcon = getIcon(item.icon);
const baseUrl = activeApp ? `/apps/${appRouteSegment(activeApp) ?? activeAppName}` : '';
const { href } = resolveHref(item, baseUrl, { currentUserId: user?.id ?? null });
const { href } = resolveHref(item, baseUrl, { currentUserId: user?.id ?? null, currentOrgId: activeOrganization?.id ?? null });
return (
<Link key={item.id} to={href} className="flex flex-col items-center gap-0.5 px-2 py-1.5 text-muted-foreground hover:text-foreground transition-colors min-w-[44px] min-h-[44px] justify-center">
<NavIcon className="h-5 w-5" />
Expand Down
4 changes: 2 additions & 2 deletions packages/app-shell/src/layout/UnifiedSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ export function UnifiedSidebar({ activeAppName }: UnifiedSidebarProps) {
const { t } = useObjectTranslation();
const { objectLabel: resolveNavObjectLabel, dashboardLabel: resolveNavDashboardLabel, navGroupLabel: resolveNavGroupLabel, viewLabel: resolveNavViewLabel } = useObjectLabel();
const { context, currentAppName } = useNavigationContext();
const { user } = useAuth();
const { user, activeOrganization } = useAuth();
const isWorkspaceAdmin = useIsWorkspaceAdmin();

// Swipe-from-left-edge gesture to open sidebar on mobile
Expand Down Expand Up @@ -454,7 +454,7 @@ export function UnifiedSidebar({ activeAppName }: UnifiedSidebarProps) {
) : undefined}
resolveViewLabel={(objectName, viewName, fallback) => resolveNavViewLabel(objectName, viewName, fallback)}
t={t}
templateContext={{ currentUserId: user?.id ?? null, contextValues }}
templateContext={{ currentUserId: user?.id ?? null, currentOrgId: activeOrganization?.id ?? null, contextValues }}
/>

{/* Recent Items */}
Expand Down
94 changes: 86 additions & 8 deletions packages/app-shell/src/views/RecordDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
const objectName = objectNameOverride ?? params.objectName;
const recordId = recordIdOverride ?? params.recordId;
const { showDebug } = useMetadataInspector();
const { user } = useAuth();
const { user, activeOrganization } = useAuth();
const navigate = useNavigate();
// objectui#2257 — the active detail tab is URL-addressable (`?tab=`), so it
// survives the page subtree remounting (refreshKey-style save refreshes;
Expand Down Expand Up @@ -362,15 +362,27 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri

const paramCollectionHandler = useCallback((params: ActionParamDef[], action?: any) => {
return new Promise<Record<string, any> | null>((resolve) => {
// Related-list row actions retarget a CHILD object (e.g. sys_member rows
// on an org record page) and stash the clicked row under
// `params._rowRecord` — resolve field-backed params against the
// action's own object and pre-fill `defaultFromRow` from the row, with
// the page object only as fallback (mirrors useConsoleActionRuntime).
const actionObject = typeof action?.objectName === 'string' && action.objectName
? action.objectName
: undefined;
const row = action?.params && !Array.isArray(action.params)
? (action.params as Record<string, any>)._rowRecord
: undefined;
const resolved = resolveActionParams(params as any, {
objectName: objectName || objectDef?.name || '',
objectName: actionObject || objectName || objectDef?.name || '',
objects: objects || [],
fieldLabel,
fieldOptionLabel,
row,
});
// Localize param label/placeholder/helpText (see ObjectView for the
// convention); falls back to the metadata literal.
const objForI18n = objectName || objectDef?.name;
const objForI18n = actionObject || objectName || objectDef?.name;
const localized = (resolved as any[]).map((p: any) => ({
...p,
label: actionParamText(objForI18n, action?.name, p.name, 'label', p.label) ?? p.label,
Expand Down Expand Up @@ -422,13 +434,82 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
}
}, [navigate]);

// API action handler — maps logical action targets to dataSource operations
// Authenticated fetch for direct backend calls (absolute `type:'api'`
// targets below + the flow trigger). Declared before apiHandler.
const authFetch = useMemo(() => createAuthenticatedFetch(), []);

// API action handler — absolute HTTP targets go to the backend verbatim
// (the canonical `type:'api'` semantics); logical targets map to
// dataSource operations.
const apiHandler = useCallback(async (action: ActionDef) => {
try {
const target = action.target || action.name;
const rowRecord = action.params && !Array.isArray(action.params)
? ((action.params as Record<string, any>)._rowRecord as Record<string, any> | undefined)
: undefined;
const params: Record<string, any> = { ...(action.params || {}) };
delete params._rowRecord;

// Absolute HTTP target — mirror of useConsoleActionRuntime.apiHandler's
// canonical branch (fetch + recordIdParam/bodyExtra/bodyShape +
// active-org injection for better-auth endpoints). The legacy
// dataSource.update mapping below MUST NOT see these: routing a
// better-auth row action (remove_member / update_member_role) through
// it either silently no-opped (no collected params) or bypassed
// better-auth with a raw table write on a managed identity table.
const targetStr = typeof target === 'string' ? target : '';
if (targetStr.startsWith('/') || /^https?:\/\//i.test(targetStr)) {
const baseUrl = import.meta.env.VITE_SERVER_URL || '';
// Interpolate `{field}` tokens in the target URL from the row record.
let resolvedTarget = targetStr;
if (rowRecord && /\{[a-z_][a-z0-9_]*\}/i.test(resolvedTarget)) {
resolvedTarget = resolvedTarget.replace(/\{([a-z_][a-z0-9_]*)\}/gi, (_, k) => {
const v = rowRecord[k];
return v == null ? '' : encodeURIComponent(String(v));
});
}
const url = resolvedTarget.startsWith('http') ? resolvedTarget : `${baseUrl}${resolvedTarget}`;

const wrap = action.bodyShape && typeof action.bodyShape === 'object' && (action.bodyShape as any).wrap
? (action.bodyShape as any).wrap
: undefined;
const body: Record<string, any> = wrap ? { [wrap]: params } : { ...params };

if (action.recordIdParam) {
const rowField = (action as any).recordIdField || 'id';
const rowValue = rowRecord?.[rowField] ?? (action as any).recordId
?? (!action.objectName || action.objectName === objectName ? (pageRecord as any)?.[rowField] : undefined);
if (rowValue != null) body[action.recordIdParam] = rowValue;
}

// better-auth org endpoints resolve the session's active org; pass it
// explicitly so row actions stay correct mid-org-switch.
if (/\/api\/v1\/auth\//.test(resolvedTarget) && !body.organizationId && activeOrganization?.id) {
body.organizationId = activeOrganization.id;
}
if (action.bodyExtra && typeof action.bodyExtra === 'object') {
Object.assign(body, action.bodyExtra);
}

const method = (action.method || 'POST').toUpperCase();
const init: any = {
method,
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
};
if (method !== 'GET' && method !== 'DELETE') init.body = JSON.stringify(body);
const res = await authFetch(url, init);
if (!res.ok) {
let errBody: any = null;
try { errBody = await res.json(); } catch { /* response body not JSON */ }
const detail = errBody?.error?.message || errBody?.error || errBody?.message || `HTTP ${res.status}`;
return { success: false, error: typeof detail === 'string' ? detail : `HTTP ${res.status}` };
}
const data = await res.json().catch(() => ({}));
if (action.refreshAfter === true) notifyRecordChanged();
return { success: true, data, reload: action.refreshAfter === true };
}

// Merge `bodyExtra` constant fields into the update payload. Per the
// ActionSchema contract these are "applied last; overrides user params",
// and the PageView/list executeAPI path already honors them. Without this
Expand Down Expand Up @@ -496,15 +577,12 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
} catch (error) {
return { success: false, error: (error as Error).message };
}
}, [dataSource, objectName, pureRecordId, pageRecord]);
}, [dataSource, objectName, pureRecordId, pageRecord, authFetch, activeOrganization]);

// Client-side modal transport: `type:'modal'` actions open here (Dialog /
// Sheet / Drawer by `placement`) and render arbitrary SchemaNode content.
const { modalHandler, modalElement } = useActionModal(dataSource);

// Authenticated fetch for direct backend calls (e.g. flow trigger).
const authFetch = useMemo(() => createAuthenticatedFetch(), []);

// Flow action handler — POST to /api/v1/automation/{name}/trigger.
// Triggered when an Action with `type: 'flow'` is invoked from a record-level
// location (record_header, record_more, …). The server-side automation
Expand Down
Loading
Loading