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
3 changes: 3 additions & 0 deletions apps/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="%BASE_URL%favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#1a1a2e" />
<meta name="description" content="ObjectOS — Business Operating System Admin Console" />
<link rel="manifest" href="%BASE_URL%manifest.json" />
<title>ObjectOS Console</title>
</head>
<body class="antialiased">
Expand Down
17 changes: 17 additions & 0 deletions apps/web/public/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "ObjectOS Console",
"short_name": "ObjectOS",
"description": "Business Operating System — Admin Console",
"start_url": "/console/",
"scope": "/console/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#1a1a2e",
"icons": [
{
"src": "favicon.svg",
"sizes": "any",
"type": "image/svg+xml"
}
]
}
92 changes: 92 additions & 0 deletions apps/web/public/sw.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* ObjectOS Service Worker — Offline-first PWA support.
*
* Strategy:
* - Static assets → Cache-first (install-time precache)
* - API calls → Network-first with cache fallback
* - Navigation → Network-first, fall back to cached shell
*/

const CACHE_NAME = 'objectos-v1';
const STATIC_ASSETS = ['/console/', '/console/index.html'];

Comment on lines +10 to +12
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

STATIC_ASSETS and the navigation fallback are hard-coded to /console/*, but vite.config.ts sets base to / on Vercel. This will cause the SW to precache the wrong URLs and fail to serve the cached shell when deployed at the root. Derive these paths from self.registration.scope (or use relative URLs like ./ and ./index.html) so the SW works under both / and /console/.

Copilot uses AI. Check for mistakes.
// ── Install: pre-cache the app shell ────────────────────────────
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)),
);
self.skipWaiting();
});

// ── Activate: clean old caches ──────────────────────────────────
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))),
),
);
self.clients.claim();
});

// ── Fetch: strategy router ──────────────────────────────────────
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);

// Skip non-GET requests
if (request.method !== 'GET') return;

// API requests → network-first
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request));
return;
}

// Static assets & navigation → cache-first
event.respondWith(cacheFirst(request));
});

Comment on lines +45 to +48
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SW header comment describes navigation as network-first, but the fetch handler routes all non-API GETs through cacheFirst(). Either change navigation handling to be network-first when request.mode === 'navigate', or update the comment so it matches the implemented strategy (to avoid unintended staleness).

Suggested change
// Static assets & navigation → cache-first
event.respondWith(cacheFirst(request));
});
// Navigation → network-first with shell fallback
if (request.mode === 'navigate') {
event.respondWith(navigationNetworkFirst(request));
return;
}
// Static assets → cache-first
event.respondWith(cacheFirst(request));
});
async function navigationNetworkFirst(request) {
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
} catch {
const shell = await caches.match('/console/index.html');
if (shell) return shell;
return new Response('Offline', { status: 503, statusText: 'Service Unavailable' });
}
}

Copilot uses AI. Check for mistakes.
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
} catch {
// For navigation requests, fall back to cached shell
if (request.mode === 'navigate') {
const shell = await caches.match('/console/index.html');
if (shell) return shell;
}
return new Response('Offline', { status: 503, statusText: 'Service Unavailable' });
}
}

async function networkFirst(request) {
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
} catch {
const cached = await caches.match(request);
if (cached) return cached;
return new Response(JSON.stringify({ error: 'offline' }), {
status: 503,
headers: { 'Content-Type': 'application/json' },
});
}
}

// ── Message handler for sync operations ─────────────────────────
self.addEventListener('message', (event) => {
if (event.data?.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
94 changes: 50 additions & 44 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ProtectedRoute } from './components/auth/ProtectedRoute';
import { RequireOrgAdmin } from './components/auth/RequireOrgAdmin';
import { SettingsLayout } from './components/layouts/SettingsLayout';
import { AppLayout } from './components/layouts/AppLayout';
import { SkipLink } from './components/ui/skip-link';

// ── Public pages ──────────────────────────────────────────────
const HomePage = lazy(() => import('./pages/home'));
Expand Down Expand Up @@ -44,55 +45,60 @@ export function App() {
);

return (
<Suspense fallback={fallback}>
<Routes>
{/* Public routes */}
<Route path="/" element={<HomePage />} />
<Route path="/sign-in" element={<SignInPage />} />
<Route path="/sign-up" element={<SignUpPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/verify-2fa" element={<Verify2FAPage />} />
<>
<SkipLink />
<main id="main-content">
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The skip link targets #main-content, but <main id="main-content"> is not focusable. Many browsers will scroll but won’t move keyboard focus, which reduces the a11y benefit. Add tabIndex={-1} (and optionally role="main") to the target container so focus can be programmatically moved there.

Suggested change
<main id="main-content">
<main id="main-content" tabIndex={-1} role="main">

Copilot uses AI. Check for mistakes.
<Suspense fallback={fallback}>
<Routes>
{/* Public routes */}
<Route path="/" element={<HomePage />} />
<Route path="/sign-in" element={<SignInPage />} />
<Route path="/sign-up" element={<SignUpPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/verify-2fa" element={<Verify2FAPage />} />

{/* Protected routes */}
<Route element={<ProtectedRoute />}>
{/* Protected routes */}
<Route element={<ProtectedRoute />}>

{/* ── Create Org (accessible to any authenticated user) ── */}
<Route path="/settings/organization/create" element={<CreateOrganizationPage />} />
{/* ── Create Org (accessible to any authenticated user) ── */}
<Route path="/settings/organization/create" element={<CreateOrganizationPage />} />

{/* ── Admin Console (/settings/*) — owner / admin only ── */}
<Route element={<RequireOrgAdmin />}>
<Route element={<SettingsLayout />}>
<Route path="/settings" element={<SettingsOverviewPage />} />
<Route path="/settings/organization" element={<OrganizationSettingsPage />} />
<Route path="/settings/members" element={<MembersPage />} />
<Route path="/settings/teams" element={<TeamsPage />} />
<Route path="/settings/invitations" element={<InvitationsPage />} />
<Route path="/settings/permissions" element={<PermissionsPage />} />
<Route path="/settings/sso" element={<SSOSettingsPage />} />
<Route path="/settings/audit" element={<AuditPage />} />
<Route path="/settings/packages" element={<PackagesPage />} />
<Route path="/settings/jobs" element={<JobsPage />} />
<Route path="/settings/plugins" element={<PluginsPage />} />
<Route path="/settings/metrics" element={<MetricsPage />} />
<Route path="/settings/notifications" element={<NotificationsPage />} />
<Route path="/settings/account" element={<AccountSettingsPage />} />
<Route path="/settings/security" element={<SecuritySettingsPage />} />
</Route>
</Route>
{/* ── Admin Console (/settings/*) — owner / admin only ── */}
<Route element={<RequireOrgAdmin />}>
<Route element={<SettingsLayout />}>
<Route path="/settings" element={<SettingsOverviewPage />} />
<Route path="/settings/organization" element={<OrganizationSettingsPage />} />
<Route path="/settings/members" element={<MembersPage />} />
<Route path="/settings/teams" element={<TeamsPage />} />
<Route path="/settings/invitations" element={<InvitationsPage />} />
<Route path="/settings/permissions" element={<PermissionsPage />} />
<Route path="/settings/sso" element={<SSOSettingsPage />} />
<Route path="/settings/audit" element={<AuditPage />} />
<Route path="/settings/packages" element={<PackagesPage />} />
<Route path="/settings/jobs" element={<JobsPage />} />
<Route path="/settings/plugins" element={<PluginsPage />} />
<Route path="/settings/metrics" element={<MetricsPage />} />
<Route path="/settings/notifications" element={<NotificationsPage />} />
<Route path="/settings/account" element={<AccountSettingsPage />} />
<Route path="/settings/security" element={<SecuritySettingsPage />} />
</Route>
</Route>

{/* ── Business Apps (/apps/:appId/*) ── */}
<Route path="/apps/:appId" element={<AppLayout />}>
<Route index element={<BusinessAppPage />} />
<Route path=":objectName" element={<ObjectListPage />} />
<Route path=":objectName/:recordId" element={<ObjectRecordPage />} />
</Route>
{/* ── Business Apps (/apps/:appId/*) ── */}
<Route path="/apps/:appId" element={<AppLayout />}>
<Route index element={<BusinessAppPage />} />
<Route path=":objectName" element={<ObjectListPage />} />
<Route path=":objectName/:recordId" element={<ObjectRecordPage />} />
</Route>

</Route>
</Route>

{/* Fallback */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
{/* Fallback */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
</main>
</>
);
}
16 changes: 16 additions & 0 deletions apps/web/src/__tests__/components/phase6-ui.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* Tests for UI components (Phase 6).
*/
import { describe, it, expect } from 'vitest';
import { ThemeToggle } from '@/components/ui/theme-toggle';
import { SkipLink } from '@/components/ui/skip-link';

describe('Phase 6 UI component exports', () => {
it('exports ThemeToggle', () => {
expect(ThemeToggle).toBeTypeOf('function');
});

it('exports SkipLink', () => {
expect(SkipLink).toBeTypeOf('function');
});
});
15 changes: 15 additions & 0 deletions apps/web/src/__tests__/components/sync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* Tests for sync components.
*/
import { describe, it, expect } from 'vitest';
import { OfflineIndicator, ConflictResolutionDialog } from '@/components/sync';

describe('sync component exports', () => {
it('exports OfflineIndicator', () => {
expect(OfflineIndicator).toBeTypeOf('function');
});

it('exports ConflictResolutionDialog', () => {
expect(ConflictResolutionDialog).toBeTypeOf('function');
});
});
15 changes: 15 additions & 0 deletions apps/web/src/__tests__/hooks/use-i18n.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* Tests for use-i18n hook.
*/
import { describe, it, expect } from 'vitest';
import { I18nProvider, useI18n } from '@/hooks/use-i18n';

describe('use-i18n exports', () => {
it('exports I18nProvider as a function', () => {
expect(I18nProvider).toBeTypeOf('function');
});

it('exports useI18n as a function', () => {
expect(useI18n).toBeTypeOf('function');
});
});
22 changes: 22 additions & 0 deletions apps/web/src/__tests__/hooks/use-keyboard-shortcuts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Tests for use-keyboard-shortcuts hook.
*/
import { describe, it, expect } from 'vitest';
import { useKeyboardShortcuts, SHORTCUT_PRESETS } from '@/hooks/use-keyboard-shortcuts';

describe('useKeyboardShortcuts', () => {
it('exports useKeyboardShortcuts as a function', () => {
expect(useKeyboardShortcuts).toBeTypeOf('function');
});

it('exports SHORTCUT_PRESETS with expected keys', () => {
expect(SHORTCUT_PRESETS).toBeDefined();
expect(SHORTCUT_PRESETS.search).toEqual({ key: 'k', ctrl: true, description: 'Open search' });
expect(SHORTCUT_PRESETS.save).toEqual({ key: 's', ctrl: true, description: 'Save' });
expect(SHORTCUT_PRESETS.escape).toEqual({ key: 'Escape', description: 'Close / Cancel' });
expect(SHORTCUT_PRESETS.newRecord).toEqual({ key: 'n', ctrl: true, shift: true, description: 'New record' });
expect(SHORTCUT_PRESETS.goHome).toEqual({ key: 'h', ctrl: true, shift: true, description: 'Go home' });
expect(SHORTCUT_PRESETS.goSettings).toEqual({ key: ',', ctrl: true, description: 'Open settings' });
expect(SHORTCUT_PRESETS.help).toEqual({ key: '?', shift: true, description: 'Show keyboard shortcuts' });
});
});
11 changes: 11 additions & 0 deletions apps/web/src/__tests__/hooks/use-offline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* Tests for use-offline hook.
*/
import { describe, it, expect } from 'vitest';
import { useOfflineStatus } from '@/hooks/use-offline';

describe('useOfflineStatus', () => {
it('exports useOfflineStatus as a function', () => {
expect(useOfflineStatus).toBeTypeOf('function');
});
});
11 changes: 11 additions & 0 deletions apps/web/src/__tests__/hooks/use-sync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* Tests for use-sync hook.
*/
import { describe, it, expect } from 'vitest';
import { useSyncEngine } from '@/hooks/use-sync';

describe('useSyncEngine', () => {
it('exports useSyncEngine as a function', () => {
expect(useSyncEngine).toBeTypeOf('function');
});
});
16 changes: 16 additions & 0 deletions apps/web/src/__tests__/hooks/use-theme.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* Tests for use-theme hook.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { useTheme } from '@/hooks/use-theme';

describe('useTheme', () => {
beforeEach(() => {
localStorage.clear();
document.documentElement.classList.remove('dark');
});

it('exports useTheme as a function', () => {
expect(useTheme).toBeTypeOf('function');
});
});
Loading
Loading