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
4 changes: 4 additions & 0 deletions packages/web/src/Layout.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Sidebar from './components/sidebar/Sidebar.jsx';
import { Toaster } from '@corates/ui';
import { ImpersonationBanner } from '@/components/admin/index.js';
import { isImpersonating } from '@/stores/adminStore.js';
import { useMembershipSync } from '@/primitives/useMembershipSync.js';

const SIDEBAR_MODE_KEY = 'corates-sidebar-mode';
const SIDEBAR_WIDTH_KEY = 'corates-sidebar-width';
Expand All @@ -13,6 +14,9 @@ const MIN_SIDEBAR_WIDTH = 200;
const MAX_SIDEBAR_WIDTH = 480;

export default function MainLayout(props) {
// Set up real-time membership sync
useMembershipSync();

// Desktop sidebar mode: 'expanded' or 'collapsed' (rail)
const [desktopSidebarMode, setDesktopSidebarMode] = createSignal('collapsed');
// Mobile sidebar open state (overlay behavior)
Expand Down
20 changes: 11 additions & 9 deletions packages/web/src/Routes.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ import { AdminDashboard } from '@/components/admin/index.js';
import StorageManagement from '@/components/admin/StorageManagement.jsx';
import { BASEPATH } from '@config/api.js';
import ProtectedGuard from '@/components/auth/ProtectedGuard.jsx';
import { OrgProjectsPage, ProjectView, CreateOrgPage } from '@/components/org/index.js';
import ProjectView from '@/components/project/ProjectView.jsx';
import { CreateOrgPage } from '@/components/org/index.js';

export default function AppRoutes() {
return (
Expand All @@ -34,31 +35,32 @@ export default function AppRoutes() {

{/* Main app routes */}
<Route path='/' component={Layout}>
{/* Dashboard redirects to org context */}
{/* Dashboard - public home for all users */}
<Route path='/' component={Dashboard} />
<Route path='/dashboard' component={Dashboard} />

{/* Protected routes - requires login */}
<Route path='/' component={ProtectedGuard}>
{/* Global user routes (outside org context) */}
{/* Global user routes */}
<Route path='/profile' component={ProfilePage} />
<Route path='/settings' component={SettingsPage} />
<Route path='/admin' component={AdminDashboard} />
<Route path='/admin/storage' component={StorageManagement} />
<Route path='/settings/billing' component={BillingPage} />

{/* Organization routes */}
{/* Organization creation */}
<Route path='/orgs/new' component={CreateOrgPage} />
<Route path='/orgs/:orgSlug' component={OrgProjectsPage} />
<Route path='/orgs/:orgSlug/projects/:projectId' component={ProjectView} />

{/* Org-scoped checklist routes */}
{/* Project-scoped routes */}
<Route path='/projects/:projectId' component={ProjectView} />

{/* Project-scoped checklist routes */}
<Route
path='/orgs/:orgSlug/projects/:projectId/studies/:studyId/checklists/:checklistId'
path='/projects/:projectId/studies/:studyId/checklists/:checklistId'
component={ChecklistYjsWrapper}
/>
<Route
path='/orgs/:orgSlug/projects/:projectId/studies/:studyId/reconcile/:checklist1Id/:checklist2Id'
path='/projects/:projectId/studies/:studyId/reconcile/:checklist1Id/:checklist2Id'
component={ReconciliationWrapper}
/>
</Route>
Expand Down
12 changes: 11 additions & 1 deletion packages/web/src/api/better-auth-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,12 @@ function createBetterAuthStore() {
// Combined signals that use cached data when offline
const isLoggedIn = () => {
if (isOnline()) {
// During auth loading (e.g., visibilitychange refetch), keep previous state stable
// to prevent UI thrash. Use cached user if available during loading.
if (authLoading()) {
const cached = cachedUser();
if (cached) return true;
}
return sessionIsLoggedIn();
}
// When offline, use cached data
Expand Down Expand Up @@ -208,8 +214,12 @@ function createBetterAuthStore() {
// Invalidate project list query if user is authenticated
const currentUser = user();
if (currentUser?.id) {
// Invalidate and refetch project list query to ensure it's current
// Invalidate and refetch project list queries to ensure they're current
try {
await queryClient.invalidateQueries({
queryKey: queryKeys.projects.all,
});
// Also invalidate legacy query key for backward compatibility
await queryClient.invalidateQueries({
queryKey: queryKeys.projects.list(currentUser.id),
});
Expand Down
16 changes: 8 additions & 8 deletions packages/web/src/components/Dashboard.jsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
import { Show } from 'solid-js';
import ChecklistsDashboard from '@/components/checklist/ChecklistsDashboard.jsx';
import { OrgRedirect } from '@/components/org/index.js';
import ProjectsPanel from '@/components/project/ProjectsPanel.jsx';
import LocalAppraisalsPanel from '@/components/checklist/LocalAppraisalsPanel.jsx';
import { useBetterAuth } from '@api/better-auth-store.js';

export default function Dashboard() {
const { isLoggedIn } = useBetterAuth();
const { isLoggedIn, authLoading } = useBetterAuth();

return (
<div class='p-6'>
<div class='mx-auto max-w-7xl space-y-8'>
{/* Logged-in users are redirected to their org context */}
<Show when={isLoggedIn()}>
<OrgRedirect />
{/* Projects section - only shown when logged in */}
<Show when={isLoggedIn() && !authLoading()}>
<ProjectsPanel />
</Show>

{/* Local checklists work offline and don't need org context */}
<ChecklistsDashboard isLoggedIn={isLoggedIn()} />
{/* Local Appraisals Section - always shown */}
<LocalAppraisalsPanel showHeader={true} showSignInPrompt={!isLoggedIn()} />
</div>
</div>
);
Expand Down
85 changes: 5 additions & 80 deletions packages/web/src/components/Navbar.jsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,18 @@
import { Show, For, createEffect, createSignal, onMount, onCleanup } from 'solid-js';
import { Show, createEffect, createSignal, onMount, onCleanup } from 'solid-js';
import { A, useNavigate } from '@solidjs/router';
import { useBetterAuth } from '@api/better-auth-store.js';
import { useOrgContext } from '@primitives/useOrgContext.js';
import { FiMenu, FiWifiOff, FiChevronDown, FiPlus, FiX } from 'solid-icons/fi';
import { BiRegularBuildings } from 'solid-icons/bi';
import { FiMenu, FiWifiOff, FiChevronDown, FiX } from 'solid-icons/fi';
import { LANDING_URL } from '@config/api.js';
import useOnlineStatus from '@primitives/useOnlineStatus.js';
import { Avatar } from '@corates/ui';

export default function Navbar(props) {
const { user, signout, authLoading, isLoggedIn } = useBetterAuth();
const { user, signout, authLoading } = useBetterAuth();
const navigate = useNavigate();
const isOnline = useOnlineStatus();

// Org context for workspace switcher
const { orgs, currentOrg, orgSlug, isLoading: orgsLoading } = useOrgContext();

const [showUserMenu, setShowUserMenu] = createSignal(false);
const [showOrgMenu, setShowOrgMenu] = createSignal(false);
let userMenuRef;
let orgMenuRef;

// Read from localStorage on render to avoid layout shift on refresh
const storedName = localStorage.getItem('userName');
Expand All @@ -40,9 +33,6 @@ export default function Navbar(props) {
if (userMenuRef && !userMenuRef.contains(event.target)) {
setShowUserMenu(false);
}
if (orgMenuRef && !orgMenuRef.contains(event.target)) {
setShowOrgMenu(false);
}
};

document.addEventListener('mousedown', handleClickOutside);
Expand All @@ -54,18 +44,13 @@ export default function Navbar(props) {
const handleSignOut = async () => {
try {
await signout();
// Use replace: true to avoid back button issues
navigate('/signin', { replace: true });
// Navigate to dashboard (public home) after sign out
navigate('/dashboard', { replace: true });
} catch (error) {
console.error('Sign out failed:', error);
}
};

const handleOrgSwitch = orgSlugValue => {
setShowOrgMenu(false);
navigate(`/orgs/${orgSlugValue}`);
};

return (
<nav class='sticky top-0 z-50 flex items-center justify-between bg-linear-to-r from-blue-700 to-blue-500 px-4 py-2 text-white shadow-lg'>
<div class='flex items-center space-x-3'>
Expand Down Expand Up @@ -96,66 +81,6 @@ export default function Navbar(props) {
</div>
CoRATES
</a>
{/* Workspace switcher - only show when logged in */}
<Show when={isLoggedIn() && !authLoading()}>
<div class='relative' ref={orgMenuRef}>
<button
onClick={() => setShowOrgMenu(!showOrgMenu())}
class='flex h-8 items-center gap-2 rounded-lg bg-white/10 px-3 text-sm font-medium transition hover:bg-white/20'
>
<BiRegularBuildings class='h-4 w-4' />
<span class='max-w-32 truncate'>{currentOrg()?.name || 'Select workspace'}</span>
<FiChevronDown
class={`h-3 w-3 transition-transform ${showOrgMenu() ? 'rotate-180' : ''}`}
/>
</button>

<Show when={showOrgMenu()}>
<div class='absolute left-0 z-50 mt-2 w-56 rounded-md border border-gray-200 bg-white py-1 text-gray-700 shadow-lg'>
<div class='border-b border-gray-200 px-3 py-2'>
<p class='text-xs font-medium text-gray-500 uppercase'>Workspaces</p>
</div>

<Show when={orgsLoading()}>
<div class='px-3 py-2 text-sm text-gray-400'>Loading...</div>
</Show>

<Show when={!orgsLoading() && orgs().length === 0}>
<div class='px-3 py-2 text-sm text-gray-500'>No workspaces</div>
</Show>

<For each={orgs()}>
{org => (
<button
onClick={() => handleOrgSwitch(org.slug)}
class={`flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-gray-100 ${
orgSlug() === org.slug ? 'bg-blue-50 text-blue-700' : ''
}`}
>
<BiRegularBuildings class='h-4 w-4 text-gray-400' />
<span class='truncate'>{org.name}</span>
<Show when={orgSlug() === org.slug}>
<span class='ml-auto text-xs text-blue-600'>Current</span>
</Show>
</button>
)}
</For>

<div class='mt-1 border-t border-gray-200 pt-1'>
<A
href='/orgs/new'
class='flex w-full items-center gap-2 px-3 py-2 text-sm text-blue-600 hover:bg-gray-100'
onClick={() => setShowOrgMenu(false)}
>
<FiPlus class='h-4 w-4' />
Create workspace
</A>
</div>
</div>
</Show>
</div>
</Show>

{/* Offline indicator */}
<Show when={!isOnline()}>
<div class='flex items-center gap-1 rounded-full bg-amber-500/90 px-2 py-1 text-xs text-white'>
Expand Down
26 changes: 11 additions & 15 deletions packages/web/src/components/checklist/ChecklistYjsWrapper.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createSignal, createEffect, createMemo, Show } from 'solid-js';
import { useParams, useNavigate, useLocation } from '@solidjs/router';
import ChecklistWithPdf from '@/components/checklist/ChecklistWithPdf.jsx';
import useProject from '@/primitives/useProject/index.js';
import { useOrgContext } from '@primitives/useOrgContext.js';
import { useProjectOrgId } from '@primitives/useProjectOrgId.js';
import projectStore from '@/stores/projectStore.js';
import projectActionsStore from '@/stores/projectActionsStore';
import { ACCESS_DENIED_ERRORS } from '@/constants/errors.js';
Expand All @@ -24,45 +24,45 @@ export default function ChecklistYjsWrapper() {
const { user } = useBetterAuth();
const confirmDialog = useConfirmDialog();

// Get org context for navigation and API calls
const { orgSlug, orgId } = useOrgContext();
// Get orgId from project data (for API calls)
const orgId = useProjectOrgId(params.projectId);

const [pdfData, setPdfData] = createSignal(null);
const [pdfFileName, setPdfFileName] = createSignal(null);
const [pdfLoading, setPdfLoading] = createSignal(false);
const [selectedPdfId, setSelectedPdfId] = createSignal(null);

// Use full hook for write operations
// orgId() is required for remote projects' WebSocket connection
const {
connect,
updateChecklistAnswer,
updateChecklist,
getChecklistData,
addPdfToStudy,
getQuestionNote,
} = useProject(orgId(), params.projectId);
} = useProject(params.projectId);

// Set active project for action store
createEffect(() => {
const pid = params.projectId;
const oid = orgId();
if (pid && oid) {
projectActionsStore._setActiveProject(pid, oid);
if (pid) {
if (oid) {
projectActionsStore._setActiveProject(pid, oid);
}
connect();
}
});

// Read data directly from store for faster reactivity
const connectionState = () => projectStore.getConnectionState(params.projectId);

// Watch for access-denied errors and redirect to org projects
// Watch for access-denied errors and redirect to projects
createEffect(() => {
const state = connectionState();
if (state.error && ACCESS_DENIED_ERRORS.includes(state.error)) {
showToast.error('Access Denied', state.error);
const slug = orgSlug();
navigate(slug ? `/orgs/${slug}` : '/dashboard', { replace: true });
navigate('/dashboard', { replace: true });
}
});

Expand Down Expand Up @@ -363,13 +363,9 @@ export default function ChecklistYjsWrapper() {
return tabFromUrl || 'overview';
};

// Build back path with org context
// Build back path
const getBackPath = () => {
const slug = orgSlug();
const tab = getBackTab();
if (slug) {
return `/orgs/${slug}/projects/${params.projectId}?tab=${tab}`;
}
return `/projects/${params.projectId}?tab=${tab}`;
};

Expand Down
Loading