[PULSE-60] Frontend hardening, UX polish, and security #35

Merged
uz1mani merged 41 commits from staging into main 2026-02-22 21:43:06 +00:00
68 changed files with 1742 additions and 374 deletions
Showing only changes of commit c73c300620 - Show all commits

View File

@@ -8,26 +8,30 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
## [0.11.0-alpha] - 2026-02-22
### Changed
### Added
- **Smoother loading experience.** Pages now show a subtle preview of the layout while data loads instead of a blank screen or spinner. This applies everywhere — dashboards, settings, uptime, funnels, notifications, billing, and detail modals.
- **No more loading flicker.** Fast-loading pages no longer flash a loading state for a split second before showing content.
- **Clearer error messages.** When something goes wrong, the error message now tells you what failed (e.g. "Failed to load uptime monitors") instead of a generic "Failed to load data".
- **Faster favicon loading.** Site icons in the dashboard, referrers, and campaigns now use Next.js image optimization for better caching and lazy loading.
- **Better page titles.** Browser tabs now show which site and page you're on (e.g. "Uptime · example.com | Pulse") instead of the same generic title everywhere.
- **Link previews for public dashboards.** Sharing a public dashboard link on social media now shows a proper preview with the site name and description.
- **Faster login redirects.** If you're not signed in and try to open a dashboard or settings page, you're redirected to login immediately instead of seeing a blank page first. Already-signed-in users who visit the login page are sent straight to the dashboard.
- **Graceful error recovery.** If a page crashes, you now see a friendly error screen with a "Try again" button instead of a blank white page. Each section of the app has its own error message so you know exactly what went wrong.
- **Security headers.** All pages now include clickjacking protection, MIME-sniffing prevention, a strict referrer policy, and HSTS. Browser APIs like camera and microphone are explicitly disabled.
- **Better form experience.** Forms now auto-focus the first field when they open, text inputs enforce character limits with a visible counter when you're close, and the settings page warns you before navigating away with unsaved changes.
- **Accessibility improvements.** The notification bell, workspace switcher, and all dashboard tabs are now fully keyboard-navigable. Screen readers announce unread counts, active organizations, and tab changes correctly. Decorative icons are hidden from assistive technology.
- **Smooth organization switching.** Switching between organizations now shows a branded loading screen instead of a blank flash while the page reloads.
### Changed
- **Smoother loading experience.** Pages now show a subtle preview of the layout while data loads instead of a blank screen or spinner. This applies everywhere — dashboards, settings, uptime, funnels, notifications, billing, and detail modals.
- **Clearer error messages.** When something goes wrong, the error message now tells you what failed (e.g. "Failed to load uptime monitors") instead of a generic "Failed to load data".
- **Faster favicon loading.** Site icons in the dashboard, referrers, and campaigns now use Next.js image optimization for better caching and lazy loading.
- **Tighter name limits.** Site, funnel, and monitor names are now capped at 100 characters instead of 255 — long enough for any real name, short enough to not break the UI.
- **Stricter type safety.** Eliminated all `any` types and `@ts-ignore` suppressions across the codebase, so the TypeScript compiler catches more bugs at build time.
### Fixed
- **No more loading flicker.** Fast-loading pages no longer flash a loading state for a split second before showing content.
- **Organization context switch.** Switching away from a deleted organization now stores the session correctly instead of using an insecure fallback.
- **Removed debug logs.** Auth and organization-switching details no longer leak into the browser console in production.
- **Stricter type safety.** Eliminated all `any` types and `@ts-ignore` suppressions across the codebase, so the TypeScript compiler catches more bugs at build time.
- **Accessibility improvements.** The notification bell, workspace switcher, and all dashboard tabs are now fully keyboard-navigable. Screen readers announce unread counts, active organizations, and tab changes correctly. Decorative icons are hidden from assistive technology.
## [0.10.0-alpha] - 2026-02-21

View File

@@ -10,13 +10,29 @@ import Link from 'next/link'
import { useEffect, useState } from 'react'
import { getUserOrganizations, switchContext } from '@/lib/api/organization'
import { setSessionAction } from '@/app/actions/auth'
import { LoadingOverlay } from '@ciphera-net/ui'
import { useRouter } from 'next/navigation'
const ORG_SWITCH_KEY = 'pulse_switching_org'
export default function LayoutContent({ children }: { children: React.ReactNode }) {
const auth = useAuth()
const router = useRouter()
const isOnline = useOnlineStatus()
const [orgs, setOrgs] = useState<any[]>([])
const [isSwitchingOrg, setIsSwitchingOrg] = useState(() => {
greptile-apps[bot] commented 2026-02-22 21:47:15 +00:00 (Migrated from github.com)
Review

Remaining any type for orgs state

Throughout this PR, any types have been systematically replaced with proper types, but orgs is still any[]. This should use the OrganizationMember type that's already imported from @/lib/api/organization:

  const [orgs, setOrgs] = useState<OrganizationMember[]>([])
Prompt To Fix With AI
This is a comment left during a code review.
Path: app/layout-content.tsx
Line: 23

Comment:
**Remaining `any` type for `orgs` state**

Throughout this PR, `any` types have been systematically replaced with proper types, but `orgs` is still `any[]`. This should use the `OrganizationMember` type that's already imported from `@/lib/api/organization`:

```suggestion
  const [orgs, setOrgs] = useState<OrganizationMember[]>([])
```

How can I resolve this? If you propose a fix, please make it concise.
**Remaining `any` type for `orgs` state** Throughout this PR, `any` types have been systematically replaced with proper types, but `orgs` is still `any[]`. This should use the `OrganizationMember` type that's already imported from `@/lib/api/organization`: ```suggestion const [orgs, setOrgs] = useState<OrganizationMember[]>([]) ``` <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: app/layout-content.tsx Line: 23 Comment: **Remaining `any` type for `orgs` state** Throughout this PR, `any` types have been systematically replaced with proper types, but `orgs` is still `any[]`. This should use the `OrganizationMember` type that's already imported from `@/lib/api/organization`: ```suggestion const [orgs, setOrgs] = useState<OrganizationMember[]>([]) ``` How can I resolve this? If you propose a fix, please make it concise. ````` </details>
if (typeof window === 'undefined') return false
return sessionStorage.getItem(ORG_SWITCH_KEY) === 'true'
})
// * Clear the switching flag once the page has settled after reload
greptile-apps[bot] commented 2026-02-22 21:47:12 +00:00 (Migrated from github.com)
Review

SSR/hydration mismatch from sessionStorage in initial state

The lazy initializer reads from sessionStorage during the first render, but on the server typeof window === 'undefined' returns false. Since this is a 'use client' component, the server renders with false while the client may initialize with true, causing a React hydration mismatch warning. A safer pattern is to initialize as false and sync in a useEffect:

const [isSwitchingOrg, setIsSwitchingOrg] = useState(false)

useEffect(() => {
  if (sessionStorage.getItem(ORG_SWITCH_KEY) === 'true') {
    setIsSwitchingOrg(true)
  }
}, [])

This trades a brief flash for a guaranteed clean hydration. In practice, the current approach may work due to 'use client' only running on the client in many setups, but it's not guaranteed by the Next.js contract.

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/layout-content.tsx
Line: 24-27

Comment:
**SSR/hydration mismatch from `sessionStorage` in initial state**

The lazy initializer reads from `sessionStorage` during the first render, but on the server `typeof window === 'undefined'` returns `false`. Since this is a `'use client'` component, the server renders with `false` while the client may initialize with `true`, causing a React hydration mismatch warning. A safer pattern is to initialize as `false` and sync in a `useEffect`:

```
const [isSwitchingOrg, setIsSwitchingOrg] = useState(false)

useEffect(() => {
  if (sessionStorage.getItem(ORG_SWITCH_KEY) === 'true') {
    setIsSwitchingOrg(true)
  }
}, [])
```

This trades a brief flash for a guaranteed clean hydration. In practice, the current approach may work due to `'use client'` only running on the client in many setups, but it's not guaranteed by the Next.js contract.

How can I resolve this? If you propose a fix, please make it concise.
**SSR/hydration mismatch from `sessionStorage` in initial state** The lazy initializer reads from `sessionStorage` during the first render, but on the server `typeof window === 'undefined'` returns `false`. Since this is a `'use client'` component, the server renders with `false` while the client may initialize with `true`, causing a React hydration mismatch warning. A safer pattern is to initialize as `false` and sync in a `useEffect`: ``` const [isSwitchingOrg, setIsSwitchingOrg] = useState(false) useEffect(() => { if (sessionStorage.getItem(ORG_SWITCH_KEY) === 'true') { setIsSwitchingOrg(true) } }, []) ``` This trades a brief flash for a guaranteed clean hydration. In practice, the current approach may work due to `'use client'` only running on the client in many setups, but it's not guaranteed by the Next.js contract. <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: app/layout-content.tsx Line: 24-27 Comment: **SSR/hydration mismatch from `sessionStorage` in initial state** The lazy initializer reads from `sessionStorage` during the first render, but on the server `typeof window === 'undefined'` returns `false`. Since this is a `'use client'` component, the server renders with `false` while the client may initialize with `true`, causing a React hydration mismatch warning. A safer pattern is to initialize as `false` and sync in a `useEffect`: ``` const [isSwitchingOrg, setIsSwitchingOrg] = useState(false) useEffect(() => { if (sessionStorage.getItem(ORG_SWITCH_KEY) === 'true') { setIsSwitchingOrg(true) } }, []) ``` This trades a brief flash for a guaranteed clean hydration. In practice, the current approach may work due to `'use client'` only running on the client in many setups, but it's not guaranteed by the Next.js contract. How can I resolve this? If you propose a fix, please make it concise. ````` </details>
useEffect(() => {
if (isSwitchingOrg) {
sessionStorage.removeItem(ORG_SWITCH_KEY)
const timer = setTimeout(() => setIsSwitchingOrg(false), 600)
return () => clearTimeout(timer)
}
}, [isSwitchingOrg])
// * Fetch organizations for the header organization switcher
useEffect(() => {
@@ -32,6 +48,7 @@ export default function LayoutContent({ children }: { children: React.ReactNode
try {
const { access_token } = await switchContext(orgId)
await setSessionAction(access_token)
sessionStorage.setItem(ORG_SWITCH_KEY, 'true')
window.location.reload()
} catch (err) {
console.error('Failed to switch organization', err)
@@ -47,6 +64,10 @@ export default function LayoutContent({ children }: { children: React.ReactNode
const headerHeightRem = 6;
const mainTopPaddingRem = barHeightRem + headerHeightRem;
if (isSwitchingOrg) {
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" portal={false} />
}
return (
<>
{auth.user && <OfflineBanner isOnline={isOnline} />}

View File

@@ -33,7 +33,7 @@ export default function OrganizationSwitcher({ orgs, activeOrgId }: { orgs: Orga
// * Note: switchContext only returns access_token, we keep existing refresh token
await setSessionAction(access_token)
// Force reload to pick up new permissions
sessionStorage.setItem('pulse_switching_org', 'true')
window.location.reload()
} catch (err) {

View File

@@ -419,10 +419,11 @@ export default function OrganizationSettings() {
try {
const { access_token } = await switchContext(null)
await setSessionAction(access_token)
sessionStorage.setItem('pulse_switching_org', 'true')
window.location.href = '/'
} catch (switchErr) {
console.error('Failed to switch to personal context after delete:', switchErr)
// Fallback: reload and let backend handle invalid token if any
sessionStorage.setItem('pulse_switching_org', 'true')
window.location.href = '/'
}