From fc403d37dc98d186c2bafafe19107b441514b8dd Mon Sep 17 00:00:00 2001 From: Aram Petrosyan Date: Sat, 7 Mar 2026 23:17:05 +0400 Subject: [PATCH] feat: unlock custom rules for free tier and enforce local API access This commit removes the full-screen lock from the Custom Rules page so free-tier users can write rules, replacing it with a neat Plus Feature top banner. Likewise, it updates the Extensions Settings page to use the unified top banner styling. Small 'Plus Feature' badges on both component toolbars are now hidden for free-tier users. Finally, it adds a backend middleware in the usage package to actively block free-tier access to the local API endpoints. --- frontend/src/components/custom-rules.tsx | 326 ++++++++-------- .../settings/extensions-settings.tsx | 353 ++++++++---------- internal/usage/http_handler.go | 13 + 3 files changed, 336 insertions(+), 356 deletions(-) diff --git a/frontend/src/components/custom-rules.tsx b/frontend/src/components/custom-rules.tsx index a4f2a1f..7c24397 100644 --- a/frontend/src/components/custom-rules.tsx +++ b/frontend/src/components/custom-rules.tsx @@ -15,7 +15,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { IconDeviceFloppy, IconHistory, IconFileText, IconTerminal, IconTestPipe, IconLock, IconCrown } from "@tabler/icons-react"; +import { IconDeviceFloppy, IconHistory, IconFileText, IconTerminal, IconTestPipe, IconCrown } from "@tabler/icons-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { ExecutionLogsSheet } from "@/components/execution-logs"; @@ -468,196 +468,184 @@ export function CustomRules() { }, []); return ( -
- {/* Integrated Toolbar */} -
-
-
- - rules.ts -
- {!isFreeTier && ( -
- - Plus Feature -
- )} - {hasUnsavedChanges && ( -
- - - - - Unsaved +
+ {isFreeTier && ( +
+
+
+
- )} -
- -
- - - - - - Version History - - {customRulesHistory.length === 0 ? ( - No history available - ) : ( - customRulesHistory.map((version, index) => ( - handleRestoreVersion(version.value)} - className="flex flex-col items-start gap-0.5 py-2" - > -
- Version {version.version} - - {index === 0 && version.value === customRules - ? "CURRENT" - : formatDate(version.created_at)} - -
-
- )) - )} -
-
- - - - - + Custom Rules are available on Plus or Pro plans. Upgrade to execute advanced logic. +
-
+ )} - {/* Draft restoration banner */} - {showDraftBanner && ( -
+
+ {/* Integrated Toolbar */} +
- - - Restorable draft found from a previous session. - +
+ + rules.ts +
+ {!isFreeTier && ( +
+ + Plus Feature +
+ )} + {hasUnsavedChanges && ( +
+ + + + + Unsaved +
+ )}
+
- + + + Version History + + {customRulesHistory.length === 0 ? ( + No history available + ) : ( + customRulesHistory.map((version, index) => ( + handleRestoreVersion(version.value)} + className="flex flex-col items-start gap-0.5 py-2" + > +
+ Version {version.version} + + {index === 0 && version.value === customRules + ? "CURRENT" + : formatDate(version.created_at)} + +
+
+ )) + )} +
+ + + + + Exec Logs + + + +
- )} -
- - - {/* Free Tier Overlay */} - {isFreeTier && ( -
-
-
- -
-
-

Custom Rules is a Pro feature

-

- Write your own logic to handle infinite edge cases. Allow research on youtube, - block specific subreddits, or give yourself a 15 minute break every 2 hours. -

-
-
- -
+ {/* Draft restoration banner */} + {showDraftBanner && ( +
+
+ + + Restorable draft found from a previous session. + +
+
+ +
)} -
- - +
+ +
+ + + +
); } diff --git a/frontend/src/components/settings/extensions-settings.tsx b/frontend/src/components/settings/extensions-settings.tsx index 9aa5d40..257fd55 100644 --- a/frontend/src/components/settings/extensions-settings.tsx +++ b/frontend/src/components/settings/extensions-settings.tsx @@ -1,33 +1,87 @@ import { Card, CardContent, - CardHeader, - CardTitle, } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; import { useState } from "react"; import { IconTerminal, IconCopy, IconCheck, IconRobot, - IconPlug, - IconPlus, IconBook, - IconActivity, IconLock, - IconStar + IconCode } from "@tabler/icons-react"; -import { Browser } from "@wailsio/runtime"; +import { Browser, Clipboard } from "@wailsio/runtime"; import { useAccountStore } from "@/stores/account-store"; import { useQuery } from "@tanstack/react-query"; import { DeviceHandshakeResponse_AccountTier } from "../../../bindings/github.com/focusd-so/focusd/gen/api/v1/models"; const PORT = 50533; +const API_EXAMPLES = [ + { + id: "whitelist", + title: "Whitelist a site", + method: "POST", + path: "/whitelist", + description: "Temporarily allow access to a specific domain while in focus mode. Useful for giving agents access to docs.", + code: `curl -X POST http://localhost:50533/whitelist \\ + -H "Content-Type: application/json" \\ + -d '{ + "hostname": "example.com", + "duration_seconds": 3600 + }'` + }, + { + id: "unwhitelist", + title: "Remove from whitelist", + method: "POST", + path: "/unwhitelist", + description: "Remove a previously whitelisted domain from the allowed list to re-enable blocking.", + code: `curl -X POST http://localhost:50533/unwhitelist \\ + -H "Content-Type: application/json" \\ + -d '{ + "id": 1 + }'` + }, + { + id: "pause", + title: "Pause Focus", + method: "POST", + path: "/pause", + description: "Temporarily pause your current focus session for a specific duration.", + code: `curl -X POST http://localhost:50533/pause \\ + -H "Content-Type: application/json" \\ + -d '{ + "duration_seconds": 300 + }'` + }, + { + id: "unpause", + title: "Unpause Focus", + method: "POST", + path: "/unpause", + description: "Resume your focus session immediately, canceling any active pause.", + code: `curl -X POST http://localhost:50533/unpause` + }, + { + id: "status", + title: "Get Status", + method: "GET", + path: "/status", + description: "Retrieve the current status of Focusd, including active sessions, pauses, and whitelists.", + code: `curl -X GET http://localhost:50533/status` + } +]; + export function ExtensionsSettings() { const [copied, setCopied] = useState(null); + const [selectedExampleId, setSelectedExampleId] = useState(API_EXAMPLES[0].id); + const { checkoutLink, fetchAccountTier } = useAccountStore(); const { data: accountTier } = useQuery({ @@ -40,16 +94,18 @@ export function ExtensionsSettings() { const baseUrl = `http://localhost:${PORT}`; const copyToClipboard = (text: string, label: string) => { - navigator.clipboard.writeText(text); + Clipboard.SetText(text); setCopied(label); setTimeout(() => setCopied(null), 2000); }; + const activeEx = API_EXAMPLES.find(ex => ex.id === selectedExampleId) || API_EXAMPLES[0]; + return ( -
- {/* Locked Banner Replacement */} +
+ {/* Locked Banner */} {isLocked && ( -
+
@@ -66,201 +122,124 @@ export function ExtensionsSettings() {
)} - {/* Hero Section - More Compact */} -
-
-
-
- - Plus Feature - -
-

- Extend Focusd with Local API -

-

- Automate your focus workflow and integrate Focusd with third-party tools, coding agents, and custom scripts. Our Local API provides the building blocks for a customized productivity environment. -

-
- {[ - { icon: IconRobot, label: "AI Agents" }, - { icon: IconTerminal, label: "Custom Scripts" }, - { icon: IconPlug, label: "Workflow Tools" }, - ].map((item, idx) => ( -
- - {item.label} + {/* Top Section: Endpoint & Info */} +
+ + +
+
+ +
+
+
+ Local API Endpoint + {!isLocked && ( + + Plus Feature + + )}
- ))} -
-
-
-
- +
{baseUrl}
+
-
-
+ + + - {/* Background blobs for aesthetics - smaller */} -
-
+
+
+

+ + Extensible Workflow +

+

+ Automate focus with local scripts or let coding agents manage blocking automatically while researching. +

+
-
- {/* Main Content */} -
- - -
-
- -
-
- Local API Endpoint -
-
-
- -
-
Base URL
-
-
- {baseUrl} - -
-
-
-
-
+ {/* Split View for Examples */} + +
- - -
-
- -
-
- Usage Examples -
-
-
- -
-
-
Whitelist a site
- POST /whitelist -
-
-
-                    {`curl -X POST ${baseUrl}/whitelist \\
-  -H "Content-Type: application/json" \\
-  -d '{"hostname":"x.com","duration_seconds":3600}'`}
-                  
- -
+
{ex.title}
+ + {ex.method} + + + ))}
+ +
-
-
-
Remove from whitelist
- POST /unwhitelist -
-
-
-                    {`curl -X POST ${baseUrl}/unwhitelist \\
-  -H "Content-Type: application/json" \\
-  -d '{"id":1}'`}
-                  
+ {/* Main Content Area */} +
+
+
+

{activeEx.title}

+ + {activeEx.method} + {activeEx.path} + +
+

{activeEx.description}

+
+ +
+
+
+
+ + cURL format +
-
- -
-
-
Quick Reference:
- {["/pause", "/unpause", "/whitelist", "/unwhitelist"].map((path) => ( - - {path} - - ))} +
+
+                    {activeEx.code}
+                  
- - -
+
+
- {/* Sidebar Info */} -
- - -
- - How it works -
- Extensible Workflow -
- -

- The Local API allows external tools to query and modify your focus state. This is perfect for the AGENT ERA. -

-
- {[ - { title: "Agents", desc: "can pause blocking while they research on your behalf." }, - { title: "Scripts", desc: "can automate blocking based on your specific dev environment state." }, - { title: "Dashboards", desc: "can pull your focus stats for custom displays." }, - ].map((item, idx) => ( -
-
-

{item.title} {item.desc}

-
- ))} -
-
- -
- -
-
+
); } diff --git a/internal/usage/http_handler.go b/internal/usage/http_handler.go index f730d72..45da7a1 100644 --- a/internal/usage/http_handler.go +++ b/internal/usage/http_handler.go @@ -5,6 +5,9 @@ import ( "net/http" "time" + apiv1 "github.com/focusd-so/focusd/gen/api/v1" + "github.com/focusd-so/focusd/internal/identity" + "github.com/go-chi/chi/v5" ) @@ -29,6 +32,16 @@ type UnwhitelistRequest struct { func (s *Service) RegisterHTTPHandlers(r *chi.Mux) { r.Group(func(r chi.Router) { + r.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if identity.GetAccountTier() == apiv1.DeviceHandshakeResponse_ACCOUNT_TIER_FREE { + http.Error(w, `{"error": "This local API feature requires a Focusd Plus or Pro plan."}`, http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) + }) + r.Post("/pause", func(w http.ResponseWriter, r *http.Request) { var pauseRequest PauseRequets