import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { router } from '../router';
import { Router } from '@vaadin/router';
import { activityTracker } from '../services/activity-tracker';
import { getFeatures } from '../api';
import '../views/public/landing-view.ts';
import './static-view-wrapper.ts';
import '../views/public/login-view.ts';
import '../views/public/register-view.ts';
import '../views/public/forgot-password-view.ts';
import '../views/public/reset-password-view.ts';
import '../views/public/verify-email-view.ts';
import '../views/public/request-demo-view.ts';
import '../views/public/delete-account-view.ts';
import '../views/public/whatis-mcp-view.ts';
import '../views/public/pricing-view.ts';
import '../views/public/welcome-view.ts';
import '../views/public/static-view.ts';
import '../views/authed/console-shell.ts';
import '../views/authed/dashboard-view.ts';
import '../views/authed/trackers-view.ts';
import '../views/authed/tools-view.ts';
import '../views/authed/issues-view.ts';
import '../views/authed/issues-compliance-view.ts';
import '../views/authed/issues-dependencies-view.ts';
import '../views/authed/issues/duplicates-view.ts';
import '../views/authed/issues/assignments-view.ts';
import '../views/authed/api-usage-view.ts';
import '../views/authed/settings-view.ts';
import '../views/authed/settings/api-keys-view.ts';
import '../views/authed/settings/ai-models-view.ts';
import '../views/authed/settings/profile-view.ts';
import '../views/authed/settings/security-view.ts';
import '../views/authed/settings/appearance-view.ts';
import '../views/authed/settings/account-view.ts';
import '../views/authed/settings/user-management-view.ts';
import '../views/authed/settings/team-management-view.ts';
import '../views/authed/settings/invitation-management-view.ts';
import '../views/authed/notification-preferences-view.ts';
import '../components/settings-tabs.ts';
import '../views/authed/flows-view.ts';
import '../views/authed/flow-view.ts';
import '../views/authed/flow-executions-view.ts';
import '../views/authed/flow-execution-view.ts';
import '../views/authed/approval-view.ts';
import '../views/authed/approvals-view.ts';
import '../views/authed/policies-view.ts';
import '../views/authed/audit-view.ts';
import './app-header.ts';
import './app-footer.ts';
import { unifiedWebSocketManager } from '../services/unified-websocket-manager';
@customElement('lit-app')
export class LitApp extends LitElement {
private hasNavigated = false;
static styles = css`
:host {
display: flex;
flex-direction: column;
height: 100vh;
}
main {
flex: 1;
overflow-y: auto;
}
`;
firstUpdated() {
this.setupEventListeners();
// Defer WebSocket connection until after initial render
// This ensures the landing page loads quickly without waiting for WebSocket
requestAnimationFrame(() => {
this.connectWebSocket();
});
const outlet = this.renderRoot.querySelector('main');
const ssrRoute = this.getAttribute('data-ssr-route');
// Normalize path: remove .html suffix for comparison
const currentPath = window.location.pathname.replace(/\.html$/, '');
// Check if SSR content matches current route
const ssrContent = this.querySelector('landing-view, static-view-wrapper');
if (ssrContent && ssrRoute === currentPath) {
// SSR content matches current route - move it to outlet for router to use
outlet?.appendChild(ssrContent);
} else if (ssrContent) {
// SSR content doesn't match current route - remove it
// This happens when index.html is served for non-root routes
ssrContent.remove();
}
// Always initialize router
router.setOutlet(outlet);
// Note: Page view tracking is handled in main.ts to avoid duplication
// and ensure all navigation methods are tracked
router.setRoutes([
{
path: '/',
action: (context, commands) => {
// Check if landing-view already exists in the outlet (from SSR moved in firstUpdated)
const routerOutlet = this.renderRoot.querySelector('main');
const existingLandingView =
routerOutlet?.querySelector('landing-view');
if (existingLandingView) {
// Reuse existing SSR landing-view - it will load its own content
return existingLandingView;
}
// No existing landing-view, create a new one
return commands.component('landing-view');
},
},
{ path: '/login', component: 'login-view' },
{
path: '/register',
action: async (context, commands) => {
// Check if registration is enabled
try {
const features = await getFeatures();
if (features.features['registration'] === false) {
// Registration disabled, redirect to login
return commands.redirect('/login');
}
} catch (error) {
// If we can't check, allow registration (fail open)
}
return commands.component('register-view');
},
},
{ path: '/forgot-password', component: 'forgot-password-view' },
{ path: '/reset-password', component: 'reset-password-view' },
{ path: '/verify-email', component: 'verify-email-view' },
{ path: '/request-demo', component: 'request-demo-view' },
{ path: '/delete-account', component: 'delete-account-view' },
{
path: '/about',
action: (context, commands) => {
// Check if we have SSR content for this EXACT route on first load
const outlet = this.renderRoot.querySelector('main');
const existingWrapper = outlet?.querySelector('static-view-wrapper');
const ssrRoute = this.getAttribute('data-ssr-route');
if (existingWrapper && ssrRoute === '/about' && !this.hasNavigated) {
// Reuse SSR content on first load only
this.hasNavigated = true;
return existingWrapper;
}
// Load markdown dynamically
const view = commands.component('static-view') as any;
view.src = '/content/about.md';
return view;
},
},
{
path: '/whatis-mcp',
action: (context, commands) => {
// Check if we have SSR content for this EXACT route on first load
const outlet = this.renderRoot.querySelector('main');
const existingWrapper = outlet?.querySelector('static-view-wrapper');
const ssrRoute = this.getAttribute('data-ssr-route');
if (
existingWrapper &&
ssrRoute === '/whatis-mcp' &&
!this.hasNavigated
) {
// Reuse SSR content on first load only
this.hasNavigated = true;
return existingWrapper;
}
// Load markdown dynamically
const view = commands.component('static-view') as any;
view.src = '/content/whatis-mcp.md';
return view;
},
},
{
path: '/docs',
action: (context, commands) => {
const view = commands.component('static-view') as any;
view.src = '/content/docs.md';
return view;
},
},
{
path: '/terms',
action: (context, commands) => {
// Check if we have SSR content for this EXACT route on first load
const outlet = this.renderRoot.querySelector('main');
const existingWrapper = outlet?.querySelector('static-view-wrapper');
const ssrRoute = this.getAttribute('data-ssr-route');
if (existingWrapper && ssrRoute === '/terms' && !this.hasNavigated) {
// Reuse SSR content on first load only
this.hasNavigated = true;
return existingWrapper;
}
// Load markdown dynamically
const view = commands.component('static-view') as any;
view.src = '/content/terms.md';
return view;
},
},
{
path: '/privacy',
action: (context, commands) => {
// Check if we have SSR content for this EXACT route on first load
const outlet = this.renderRoot.querySelector('main');
const existingWrapper = outlet?.querySelector('static-view-wrapper');
const ssrRoute = this.getAttribute('data-ssr-route');
if (
existingWrapper &&
ssrRoute === '/privacy' &&
!this.hasNavigated
) {
// Reuse SSR content on first load only
this.hasNavigated = true;
return existingWrapper;
}
// Load markdown dynamically
const view = commands.component('static-view') as any;
view.src = '/content/privacy.md';
return view;
},
},
{
path: '/pricing',
action: (context, commands) => {
// Check if we have SSR content for this EXACT route on first load
const outlet = this.renderRoot.querySelector('main');
const existingWrapper = outlet?.querySelector('static-view-wrapper');
const ssrRoute = this.getAttribute('data-ssr-route');
if (
existingWrapper &&
ssrRoute === '/pricing' &&
!this.hasNavigated
) {
// Reuse SSR content on first load only
this.hasNavigated = true;
return existingWrapper;
}
// Load pricing view dynamically
return commands.component('public-pricing-view');
},
},
{ path: '/welcome', component: 'welcome-view' },
{
path: '/console',
component: 'console-shell',
action: () => {
// Handle OAuth callback tokens from URL fragment only.
// Fragment-based delivery prevents tokens from appearing in browser
// history, server access logs, and Referrer headers.
const params = new URLSearchParams(
window.location.hash.startsWith('#')
? window.location.hash.substring(1)
: ''
);
const accessToken = params.get('access_token');
const refreshToken = params.get('refresh_token');
if (accessToken) {
localStorage.setItem('accessToken', accessToken);
if (refreshToken) {
localStorage.setItem('refreshToken', refreshToken);
}
// Store onboarding hints for the dashboard
if (params.get('new_user')) {
sessionStorage.setItem('oauth_new_user', '1');
}
if (params.get('setup_tracker')) {
sessionStorage.setItem(
'oauth_setup_tracker',
params.get('setup_tracker')!
);
}
// Clean tokens from URL fragment
const cleanUrl = new URL(window.location.href);
cleanUrl.hash = '';
window.history.replaceState({}, '', cleanUrl.pathname);
// Notify components of auth change
window.dispatchEvent(
new CustomEvent('auth-change', {
bubbles: true,
composed: true,
})
);
// Redirect to Stripe checkout if billing is pending for new OAuth users
if (params.get('checkout_pending')) {
fetch('/api/v1/billing/create-checkout-session', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
plan_id: 'teams',
interval: 'month',
}),
})
.then((res) => res.json())
.then((data) => {
if (data.url && data.action === 'redirect') {
window.location.href = data.url;
}
})
.catch((err) => {
console.error('Failed to create checkout session:', err);
});
} else if (params.get('setup_tracker') === 'github') {
// No billing — go straight to GitHub App installation
this._autoStartGitHubAppInstall(accessToken);
}
}
// After returning from Stripe, check if GitHub tracker setup is still pending
if (
!accessToken &&
!window.location.pathname.includes('/trackers') &&
sessionStorage.getItem('oauth_setup_tracker') === 'github'
) {
const token = localStorage.getItem('accessToken');
if (token) {
this._autoStartGitHubAppInstall(token);
}
}
},
children: [
{ path: '', component: 'dashboard-view' },
{ path: 'trackers', component: 'trackers-view' },
{ path: 'tools', component: 'tools-view' },
{
path: 'issues',
children: [
{ path: '', component: 'issues-view' },
{ path: 'compliance', component: 'issues-compliance-view' },
{ path: 'dependencies', component: 'issues-dependencies-view' },
{ path: 'duplicates', component: 'duplicates-view' },
{ path: 'assignments', component: 'assignments-view' },
],
},
{
path: 'flows',
children: [
{ path: '', component: 'flows-view' },
{ path: 'new', component: 'flow-view' },
{ path: 'executions', component: 'flow-executions-view' },
{
path: 'executions/:executionId',
component: 'flow-execution-view',
},
{ path: ':flowId', component: 'flow-view' },
],
},
{ path: '/api-usage', component: 'api-usage-view' },
{ path: 'settings', redirect: '/console/settings/profile' },
{ path: 'settings/profile', component: 'profile-view' },
{ path: 'settings/security', component: 'security-view' },
{ path: 'settings/api-keys', component: 'api-keys-view' },
{ path: 'settings/ai-models', component: 'ai-models-view' },
{ path: 'settings/appearance', component: 'appearance-view' },
{ path: 'settings/account', component: 'account-view' },
{ path: 'settings/users', component: 'user-management-view' },
{ path: 'settings/teams', component: 'team-management-view' },
{
path: 'settings/invitations',
component: 'invitation-management-view',
},
{
path: 'settings/notification-preferences',
component: 'notification-preferences-view',
},
{ path: 'pricing', component: 'pricing-view' },
{ path: 'approvals', component: 'approvals-view' },
{ path: 'approval/:requestId', component: 'approval-view' },
{
path: 'governance',
action: (_context, commands) => {
// Governance is now integrated into the Tools page
return commands.redirect('/console/tools');
},
},
{ path: 'audit', component: 'audit-view' },
],
},
]);
}
/**
* Auto-redirect to GitHub App installation page for new OAuth users.
* Called after OAuth sign-in (or after Stripe checkout returns).
*/
private _autoStartGitHubAppInstall(token: string) {
// Clear the flag to prevent redirect loops
sessionStorage.removeItem('oauth_setup_tracker');
sessionStorage.removeItem('oauth_new_user');
fetch('/api/v1/auth/github/authorize', {
headers: { Authorization: `Bearer ${token}` },
})
.then((res) => {
if (!res.ok) throw new Error('GitHub App not configured');
return res.json();
})
.then((data) => {
if (data.authorization_url) {
sessionStorage.setItem('github_oauth_state', data.state);
window.location.href = data.authorization_url;
}
})
.catch((err) => {
console.error('Failed to start GitHub App install:', err);
// Fall back — user can add tracker manually from the trackers page
});
}
render() {
return html` <main></main> `;
}
connectWebSocket() {
// Connect unified WebSocket manager when app initializes
// This establishes a single persistent connection that all views can subscribe to
unifiedWebSocketManager.connect().catch((error) => {
console.error('Failed to connect unified WebSocket:', error);
});
}
setupEventListeners() {
// Event listeners can be added here as needed
}
}