[PULSE-60] Frontend hardening, UX polish, and security #35
18
CHANGELOG.md
18
CHANGELOG.md
@@ -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
|
## [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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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
|
### 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.
|
- **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.
|
- **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
|
## [0.10.0-alpha] - 2026-02-21
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,29 @@ import Link from 'next/link'
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { getUserOrganizations, switchContext } from '@/lib/api/organization'
|
import { getUserOrganizations, switchContext } from '@/lib/api/organization'
|
||||||
import { setSessionAction } from '@/app/actions/auth'
|
import { setSessionAction } from '@/app/actions/auth'
|
||||||
|
import { LoadingOverlay } from '@ciphera-net/ui'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
|
|
||||||
|
const ORG_SWITCH_KEY = 'pulse_switching_org'
|
||||||
|
|
||||||
export default function LayoutContent({ children }: { children: React.ReactNode }) {
|
export default function LayoutContent({ children }: { children: React.ReactNode }) {
|
||||||
const auth = useAuth()
|
const auth = useAuth()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const isOnline = useOnlineStatus()
|
const isOnline = useOnlineStatus()
|
||||||
const [orgs, setOrgs] = useState<any[]>([])
|
const [orgs, setOrgs] = useState<any[]>([])
|
||||||
|
const [isSwitchingOrg, setIsSwitchingOrg] = useState(() => {
|
||||||
|
|
|||||||
|
if (typeof window === 'undefined') return false
|
||||||
|
return sessionStorage.getItem(ORG_SWITCH_KEY) === 'true'
|
||||||
|
})
|
||||||
|
|
||||||
|
// * Clear the switching flag once the page has settled after reload
|
||||||
|
SSR/hydration mismatch from The lazy initializer reads from This trades a brief flash for a guaranteed clean hydration. In practice, the current approach may work due to Prompt To Fix With AI**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
|
// * Fetch organizations for the header organization switcher
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -32,6 +48,7 @@ export default function LayoutContent({ children }: { children: React.ReactNode
|
|||||||
try {
|
try {
|
||||||
const { access_token } = await switchContext(orgId)
|
const { access_token } = await switchContext(orgId)
|
||||||
await setSessionAction(access_token)
|
await setSessionAction(access_token)
|
||||||
|
sessionStorage.setItem(ORG_SWITCH_KEY, 'true')
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to switch organization', err)
|
console.error('Failed to switch organization', err)
|
||||||
@@ -47,6 +64,10 @@ export default function LayoutContent({ children }: { children: React.ReactNode
|
|||||||
const headerHeightRem = 6;
|
const headerHeightRem = 6;
|
||||||
const mainTopPaddingRem = barHeightRem + headerHeightRem;
|
const mainTopPaddingRem = barHeightRem + headerHeightRem;
|
||||||
|
|
||||||
|
if (isSwitchingOrg) {
|
||||||
|
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" portal={false} />
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{auth.user && <OfflineBanner isOnline={isOnline} />}
|
{auth.user && <OfflineBanner isOnline={isOnline} />}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export default function OrganizationSwitcher({ orgs, activeOrgId }: { orgs: Orga
|
|||||||
// * Note: switchContext only returns access_token, we keep existing refresh token
|
// * Note: switchContext only returns access_token, we keep existing refresh token
|
||||||
await setSessionAction(access_token)
|
await setSessionAction(access_token)
|
||||||
|
|
||||||
// Force reload to pick up new permissions
|
sessionStorage.setItem('pulse_switching_org', 'true')
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -419,10 +419,11 @@ export default function OrganizationSettings() {
|
|||||||
try {
|
try {
|
||||||
const { access_token } = await switchContext(null)
|
const { access_token } = await switchContext(null)
|
||||||
await setSessionAction(access_token)
|
await setSessionAction(access_token)
|
||||||
|
sessionStorage.setItem('pulse_switching_org', 'true')
|
||||||
window.location.href = '/'
|
window.location.href = '/'
|
||||||
} catch (switchErr) {
|
} catch (switchErr) {
|
||||||
console.error('Failed to switch to personal context after delete:', 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 = '/'
|
window.location.href = '/'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user
Remaining
anytype fororgsstateThroughout this PR,
anytypes have been systematically replaced with proper types, butorgsis stillany[]. This should use theOrganizationMembertype that's already imported from@/lib/api/organization:Prompt To Fix With AI