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
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,45 @@ All notable changes to Checkgate are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/).

## [0.1.19] - 2026-07-05

### Changed

- **Dashboard layout overhaul** — the sidebar can now be collapsed to an icon-only rail (persisted
across sessions via `localStorage`), reclaiming space on smaller screens. Every list/table page
(Dashboard, Feature Flags, Environments, Compare, Change Requests, Projects, Users, Audit Log,
Impressions, SDK Health, Scheduled, Webhooks) is now genuinely full width instead of capped at an
arbitrary max-width with dead space on either side.
- **Flag Editor and Settings restructured into two columns** — behavior/configuration (flag type,
rollout, targeting rules; SDK snippets, personal access tokens) in a wider main column, with
identity/metadata (key, tags, ownership, prerequisites; auth info, API endpoint, account) in a
narrower sidebar. Previously everything was stacked in a single column, requiring far more
scrolling and leaving most of the screen empty on anything wider than a laptop.
- **Setup and Login pages** now fill the browser window edge to edge (previously framed/centered
with growing dead space in the middle on wide monitors, then over-corrected to a narrow centered
card — this settles on genuinely full-width panels with wider inner content blocks). The Setup
welcome screen's headline now wraps to two lines instead of three, and gained an "Already set up?
Sign in" link for anyone who lands there after setup is already complete.
- `ProjectSettings` now supports deep-linking to a specific tab via `?tab=keys` (etc.), so other
pages can link straight to e.g. SDK Keys instead of dropping the user on the default tab.

### Fixed

- **Setup/login redirect loop** — `/api/auth/me` 401s for anyone without a valid session cookie,
which the frontend was also using to determine "has setup been completed?" A fresh browser, a
different device, or a session that expired after a server restart would incorrectly conclude
setup had never been done and permanently redirect to `/setup` instead of `/login`, with no way
back. Fixed by checking the public `/api/auth/workspace` endpoint (now also returns
`is_setup_complete`) instead, and by fixing a related race where the redirect decision fired on a
stale guess before that check even resolved.
- **Dead "SDK Keys" link on the Settings page** — pointed to `/settings` (itself) instead of the
actual key-management page. Now links to `/projects/{id}?tab=keys` and lands directly on the SDK
Keys tab.
- **React Native SDK packaging** — `sdks/react-native/android/.gradle/` (a local Gradle build cache,
accidentally committed) was being swept into the published npm tarball via the `files` field
covering the whole `android/` directory. Untracked and gitignored; harmless to existing consumers,
just junk that shouldn't have shipped.

## [0.1.18] - 2026-07-04

### Added
Expand Down
12 changes: 6 additions & 6 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@ members = [
resolver = "2"

[workspace.package]
version = "0.1.18"
version = "0.1.19"
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ It is proudly built in Rust and ships with native SDKs for Node.js (NAPI), brows

![Environments](assets/screenshots/07-environments.png)

**Collapsible sidebar** — reclaim screen space on data-dense pages; the whole dashboard is full-width by default.

![Collapsed sidebar](assets/screenshots/08-sidebar-collapsed.png)

---

## Documentation
Expand Down
Binary file modified assets/screenshots/01-dashboard.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/screenshots/02-feature-flags.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/screenshots/03-flag-editor.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/screenshots/04-change-requests.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/screenshots/05-environment-diff.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/screenshots/06-settings-tokens.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/screenshots/07-environments.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/screenshots/08-sidebar-collapsed.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion dashboard/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "checkgate-dashboard",
"version": "0.1.18",
"version": "0.1.19",
"private": true,
"type": "module",
"scripts": {
Expand Down
26 changes: 23 additions & 3 deletions dashboard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,26 +23,46 @@ import ProjectSettings from './pages/ProjectSettings'
import Webhooks from './pages/Webhooks'

function RequireAuth({ children }: { readonly children: React.ReactNode }) {
const { session, sessionLoading, isSetupComplete } = useAuth()
const { session, sessionLoading, isSetupComplete, setupLoading } = useAuth()
// Must not act on isSetupComplete until the authoritative check resolves —
// its pre-check value is only a localStorage guess, and routing to /setup
// on a wrong guess is a one-way trip (nothing there re-checks and bounces
// back once the real value arrives).
if (setupLoading) return null
if (!isSetupComplete) return <Navigate to="/setup" replace />
if (sessionLoading) return null
if (!session) return <Navigate to="/login" replace />
return <>{children}</>
}

function PublicOnly({ children }: { readonly children: React.ReactNode }) {
const { session, sessionLoading, isSetupComplete } = useAuth()
const { session, sessionLoading, isSetupComplete, setupLoading } = useAuth()
if (setupLoading) return null
if (!isSetupComplete) return <Navigate to="/setup" replace />
if (sessionLoading) return null
if (session) return <Navigate to="/" replace />
return <>{children}</>
}

function SetupOnly({ children }: { readonly children: React.ReactNode }) {
const { isSetupComplete, setupLoading } = useAuth()
if (setupLoading) return null
if (isSetupComplete) return <Navigate to="/login" replace />
return <>{children}</>
}

export default function App() {
return (
<Routes>
{/* Auth routes (no sidebar) */}
<Route path="/setup" element={<Setup />} />
<Route
path="/setup"
element={
<SetupOnly>
<Setup />
</SetupOnly>
}
/>
<Route
path="/login"
element={
Expand Down
92 changes: 66 additions & 26 deletions dashboard/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {
Wifi,
CalendarClock,
GitPullRequest,
PanelLeftClose,
PanelLeftOpen,
} from 'lucide-react'
import { useState, useRef, useEffect } from 'react'
import { useAuth } from '../context/AuthContext'
Expand Down Expand Up @@ -168,47 +170,66 @@ function EnvSwitcher() {
// Sidebar
// ---------------------------------------------------------------------------

const KEY_SIDEBAR_COLLAPSED = 'lg_sidebar_collapsed'

export default function Sidebar() {
const { session, logout } = useAuth()
const navigate = useNavigate()
const isAdmin = session?.user.role === 'admin'
const NAV = NAV_ALL.filter(item => !item.adminOnly || isAdmin)
const [collapsed, setCollapsed] = useState(() => localStorage.getItem(KEY_SIDEBAR_COLLAPSED) === 'true')

function toggleCollapsed() {
setCollapsed(c => {
localStorage.setItem(KEY_SIDEBAR_COLLAPSED, String(!c))
return !c
})
}

function handleLogout() {
logout()
navigate('/login')
}

return (
<aside className="w-64 shrink-0 flex flex-col h-screen bg-white border-r border-gray-100 shadow-premium">
<aside className={`${collapsed ? 'w-[76px]' : 'w-64'} shrink-0 flex flex-col h-screen bg-white border-r border-gray-100 shadow-premium transition-[width] duration-200`}>
{/* Logo + workspace name */}
<div className="flex items-center gap-3 px-6 h-16 mb-2">
<div className="bg-white p-1 rounded-xl shadow-sm border border-gray-50 overflow-hidden">
<div className={`flex items-center h-16 mb-2 ${collapsed ? 'justify-center px-2' : 'gap-3 px-6'}`}>
<div className="bg-white p-1 rounded-xl shadow-sm border border-gray-50 overflow-hidden shrink-0">
<img src="/checkgate_logo.png" alt="" className="h-8 w-8 object-contain" />
</div>
<div className="min-w-0">
<span className="text-gray-900 font-display font-bold text-xl tracking-tight">Checkgate</span>
{session?.workspaceName && (
<p className="text-gray-400 text-[10px] font-medium truncate leading-none mt-0.5">{session.workspaceName}</p>
)}
</div>
{!collapsed && (
<div className="min-w-0">
<span className="text-gray-900 font-display font-bold text-xl tracking-tight">Checkgate</span>
{session?.workspaceName && (
<p className="text-gray-400 text-[10px] font-medium truncate leading-none mt-0.5">{session.workspaceName}</p>
)}
</div>
)}
</div>

{/* Project switcher */}
<ProjectSwitcher />
{!collapsed && (
<>
{/* Project switcher */}
<ProjectSwitcher />

{/* Environment switcher */}
<EnvSwitcher />
{/* Environment switcher */}
<EnvSwitcher />
</>
)}

{/* Nav */}
<nav className="flex-1 px-4 py-2 space-y-1 overflow-y-auto">
<nav className={`flex-1 py-2 space-y-1 overflow-y-auto ${collapsed ? 'px-3' : 'px-4'}`}>
{NAV.map(({ to, icon: Icon, label, end }) => (
<NavLink
key={to}
to={to}
end={end}
title={collapsed ? label : undefined}
className={({ isActive }) =>
`group flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 ${
`group flex items-center rounded-xl text-sm font-medium transition-all duration-200 ${
collapsed ? 'justify-center px-0 py-2.5' : 'gap-3 px-3 py-2.5'
} ${
isActive
? 'bg-emerald-50 text-emerald-700 shadow-sm shadow-emerald-100/50'
: 'text-gray-500 hover:text-gray-900 hover:bg-gray-50'
Expand All @@ -220,32 +241,51 @@ export default function Sidebar() {
<div className={`p-1 rounded-lg transition-colors ${isActive ? 'bg-white shadow-sm' : 'group-hover:bg-white/50'}`}>
<Icon className={`w-4 h-4 shrink-0 ${isActive ? 'text-emerald-600' : 'text-gray-400 group-hover:text-gray-600'}`} />
</div>
<span className="flex-1">{label}</span>
{!collapsed && <span className="flex-1">{label}</span>}
</>
)}
</NavLink>
))}
</nav>

{/* Collapse toggle */}
<div className={`px-4 pb-2 ${collapsed ? 'flex justify-center px-3' : ''}`}>
<button
onClick={toggleCollapsed}
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
className={`flex items-center gap-3 py-2 rounded-xl text-xs font-semibold text-gray-400 hover:text-gray-700 hover:bg-gray-50 transition-all duration-200 ${
collapsed ? 'justify-center w-10' : 'w-full px-3'
}`}
>
{collapsed ? <PanelLeftOpen className="w-4 h-4 shrink-0" /> : <PanelLeftClose className="w-4 h-4 shrink-0" />}
{!collapsed && <span>Collapse</span>}
</button>
</div>

{/* User section */}
<div className="px-4 py-4 border-t border-gray-100 bg-gray-50/50">
<div className="flex items-center gap-3 px-2 py-2 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-emerald-500 to-emerald-600 shadow-md shadow-emerald-200 flex items-center justify-center shrink-0">
<div className={`py-4 border-t border-gray-100 bg-gray-50/50 ${collapsed ? 'px-3' : 'px-4'}`}>
<div className={`flex items-center mb-2 ${collapsed ? 'justify-center' : 'gap-3 px-2 py-2'}`}>
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-emerald-500 to-emerald-600 shadow-md shadow-emerald-200 flex items-center justify-center shrink-0" title={collapsed ? session?.user.name : undefined}>
<span className="text-white text-sm font-bold">
{session?.user.name.charAt(0).toUpperCase() ?? '?'}
</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-gray-900 text-xs font-bold truncate">{session?.user.name}</p>
<p className="text-gray-500 text-[10px] tracking-wider uppercase font-semibold">{session?.user.role}</p>
</div>
{!collapsed && (
<div className="flex-1 min-w-0">
<p className="text-gray-900 text-xs font-bold truncate">{session?.user.name}</p>
<p className="text-gray-500 text-[10px] tracking-wider uppercase font-semibold">{session?.user.role}</p>
</div>
)}
</div>
<button
onClick={handleLogout}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm text-gray-500 hover:text-rose-600 hover:bg-rose-50 transition-all duration-200 group"
title={collapsed ? 'Sign out' : undefined}
className={`flex items-center rounded-xl text-sm text-gray-500 hover:text-rose-600 hover:bg-rose-50 transition-all duration-200 group ${
collapsed ? 'justify-center w-10 h-10 mx-auto' : 'w-full gap-3 px-3 py-2.5'
}`}
>
<LogOut className="w-4 h-4 transition-transform group-hover:translate-x-0.5" />
<span className="font-semibold">Sign out</span>
<LogOut className="w-4 h-4 transition-transform group-hover:translate-x-0.5 shrink-0" />
{!collapsed && <span className="font-semibold">Sign out</span>}
</button>
</div>
</aside>
Expand Down
29 changes: 29 additions & 0 deletions dashboard/src/context/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ interface AuthContextValue {
session: Session | null
sessionLoading: boolean
isSetupComplete: boolean
setupLoading: boolean
login: (email: string, password: string) => Promise<LoginResult>
logout: () => Promise<void>
completeSetup: (
Expand Down Expand Up @@ -77,9 +78,36 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [isSetupComplete, setIsSetupComplete] = useState(
() => localStorage.getItem(KEY_SETUP) === 'true',
)
// True until the authoritative /api/auth/workspace check resolves. Routing
// must not act on `isSetupComplete` while this is true — its initial value
// above is only a localStorage guess (wrong for a fresh browser, a
// different device, or a session that expired after a server restart), and
// routing to /setup on a wrong guess is a one-way trip: nothing on the
// /setup route itself re-checks and bounces back once the real value
// arrives.
const [setupLoading, setSetupLoading] = useState(true)

// On mount: restore session and authoritative setup state from the server.
//
// Setup status is fetched from the *public* /api/auth/workspace endpoint,
// not just inferred from /api/auth/me — the latter 401s for anyone without
// a valid session cookie (a fresh browser, a different device, a session
// that expired after a server restart), which previously meant those users
// got permanently bounced to /setup instead of /login even though setup
// had already been completed by someone else.
useEffect(() => {
fetch('/api/auth/workspace', { credentials: 'same-origin' })
.then(res => (res.ok ? res.json() as Promise<{ workspace_name: string; is_setup_complete: boolean }> : null))
.then(body => {
if (body) {
setIsSetupComplete(body.is_setup_complete)
if (body.is_setup_complete) localStorage.setItem(KEY_SETUP, 'true')
else localStorage.removeItem(KEY_SETUP)
}
})
.catch(() => {/* network error — fall back to the localStorage-seeded initial state */})
.finally(() => setSetupLoading(false))

fetch('/api/auth/me', { credentials: 'same-origin' })
.then(res => {
if (res.ok) return res.json() as Promise<AuthResponse>
Expand Down Expand Up @@ -182,6 +210,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
session,
sessionLoading,
isSetupComplete,
setupLoading,
login,
logout,
completeSetup,
Expand Down
2 changes: 1 addition & 1 deletion dashboard/src/pages/ChangeRequests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export default function ChangeRequests() {
}

return (
<div className="w-full max-w-3xl space-y-5">
<div className="w-full space-y-5">
<div className="premium-card shadow-premium-lg border-none">
<div className="flex items-start gap-5 px-6 py-5 border-b border-gray-50 bg-white">
<div className="w-10 h-10 rounded-xl bg-emerald-50 flex items-center justify-center shrink-0">
Expand Down
2 changes: 1 addition & 1 deletion dashboard/src/pages/EnvironmentDiff.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ export default function EnvironmentDiff() {
const diff = envAId && envBId && envAId !== envBId ? computeDiff(flagsA, flagsB) : null

return (
<div className="w-full max-w-3xl space-y-5">
<div className="w-full space-y-5">
<div className="premium-card shadow-premium-lg border-none">
<div className="flex items-start gap-5 px-6 py-5 border-b border-gray-50 bg-white">
<div className="w-10 h-10 rounded-xl bg-emerald-50 flex items-center justify-center shrink-0">
Expand Down
Loading
Loading