From 049f4b6f2c86049ddd1f1d6cb891ebb4097d1fc7 Mon Sep 17 00:00:00 2001 From: omaro Date: Mon, 23 Mar 2026 19:33:12 +0300 Subject: [PATCH 1/9] feat(dashboard): animate default tabs variant active indicator --- apps/dashboard/components/ui/tabs.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/dashboard/components/ui/tabs.tsx b/apps/dashboard/components/ui/tabs.tsx index 40295b33e..58f4f9cea 100644 --- a/apps/dashboard/components/ui/tabs.tsx +++ b/apps/dashboard/components/ui/tabs.tsx @@ -75,7 +75,7 @@ function TabsList({ useLayoutEffect(() => { if ( - (variant !== "underline" && variant !== "navigation") || + (!["navigation", "underline", "default"].includes(variant) ) || !listRef.current || !activeValue ) @@ -149,6 +149,7 @@ function TabsList({ // Default variant return ( +
+
+
); } @@ -248,10 +254,11 @@ function TabsTrigger({ return ( Date: Mon, 23 Mar 2026 19:33:41 +0300 Subject: [PATCH 2/9] fix(dashboard): polish outline button variant styling --- apps/dashboard/components/ui/button.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/dashboard/components/ui/button.tsx b/apps/dashboard/components/ui/button.tsx index 2333554ce..c60696af4 100644 --- a/apps/dashboard/components/ui/button.tsx +++ b/apps/dashboard/components/ui/button.tsx @@ -12,9 +12,9 @@ const buttonVariants = cva( default: 'bg-primary text-primary-foreground hover:bg-primary/90 disabled:bg-accent-disabled disabled:text-neutral-500', destructive: - 'bg-destructive text-white disabled:bg-accent-disabled disabled:text-neutral-500 hover:bg-destructive-brighter focus-visible:ring-destructive/20', + 'bg-destructive text-white disabled:bg-accent-disabled disabled:border-accent-disabled disabled:text-neutral-500 hover:bg-destructive-brighter focus-visible:ring-destructive/20', outline: - 'border text-accent-foreground bg-transparent hover:border-transparent disabled:bg-accent-disabled disabled:text-neutral-400 hover:bg-secondary hover:text-accent-foreground', + 'border text-accent-foreground disabled:border-accent-disabled bg-transparent disabled:bg-accent-disabled disabled:text-muted-foreground hover:bg-secondary hover:text-accent-foreground', secondary: 'bg-secondary text-accent-foreground hover:bg-secondary-brighter disabled:bg-accent-disabled disabled:text-neutral-500', ghost: From 9c07c4ac59feb5504b16eb1a9fab3c66a0025488 Mon Sep 17 00:00:00 2001 From: omaro Date: Mon, 23 Mar 2026 19:34:29 +0300 Subject: [PATCH 3/9] feat(dashboard): streamline api key permission setup --- .../organizations/api-key-create-dialog.tsx | 321 ++++++++---------- 1 file changed, 143 insertions(+), 178 deletions(-) diff --git a/apps/dashboard/components/organizations/api-key-create-dialog.tsx b/apps/dashboard/components/organizations/api-key-create-dialog.tsx index 13b10eff9..3c3e19a28 100644 --- a/apps/dashboard/components/organizations/api-key-create-dialog.tsx +++ b/apps/dashboard/components/organizations/api-key-create-dialog.tsx @@ -3,13 +3,12 @@ import type { ApiScope } from "@databuddy/api-keys/scopes"; import { zodResolver } from "@hookform/resolvers/zod"; import { + CaretUpDownIcon, CheckCircleIcon, CheckIcon, CopyIcon, - GlobeIcon, KeyIcon, PlusIcon, - TrashIcon, } from "@phosphor-icons/react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; @@ -19,15 +18,16 @@ import type { Website } from "@/hooks/use-websites"; import { orpc } from "@/lib/orpc"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandItem, + CommandList, +} from "../ui/command"; import { Input } from "../ui/input"; import { Label } from "../ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "../ui/select"; +import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import { Sheet, SheetBody, @@ -37,6 +37,7 @@ import { SheetHeader, SheetTitle, } from "../ui/sheet"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; import { type ApiKeyAccessEntry, SCOPE_OPTIONS } from "./api-key-types"; interface ApiKeyCreateDialogProps { @@ -57,6 +58,9 @@ const formSchema = z.object({ type FormData = z.infer; +const DEFAULT_SCOPES: ApiScope[] = ["read:data"]; +const ALL_SCOPES: ApiScope[] = SCOPE_OPTIONS.map((opt) => opt.value); + export function ApiKeyCreateDialog({ open, onOpenChangeAction, @@ -64,9 +68,9 @@ export function ApiKeyCreateDialog({ onSuccessAction, }: ApiKeyCreateDialogProps) { const queryClient = useQueryClient(); - const [globalScopes, setGlobalScopes] = useState(["read:data"]); + const [globalScopes, setGlobalScopes] = useState(ALL_SCOPES); const [websiteAccess, setWebsiteAccess] = useState([]); - const [websiteToAdd, setWebsiteToAdd] = useState(); + const [popoverOpen, setPopoverOpen] = useState(false); const [created, setCreated] = useState<{ id: string; secret: string; @@ -93,6 +97,22 @@ export function ApiKeyCreateDialog({ }, }); + const assignGlobalScopes = (value: string) => { + switch (value) { + case "all": + setGlobalScopes(ALL_SCOPES); + break; + case "restricted": + setGlobalScopes(DEFAULT_SCOPES); + break; + case "read-data": + setGlobalScopes(DEFAULT_SCOPES); + break; + default: + setGlobalScopes(DEFAULT_SCOPES); + } + }; + const handleClose = () => { if (created) { onSuccessAction(created); @@ -100,9 +120,8 @@ export function ApiKeyCreateDialog({ onOpenChangeAction(false); setTimeout(() => { form.reset(); - setGlobalScopes(["read:data"]); + setGlobalScopes(ALL_SCOPES); setWebsiteAccess([]); - setWebsiteToAdd(undefined); setCreated(null); setCopied(false); }, 200); @@ -122,49 +141,39 @@ export function ApiKeyCreateDialog({ ); }; - const addWebsite = () => { - if (!websiteToAdd) { - return; - } - if (websiteAccess.some((e) => e.resourceId === websiteToAdd)) { + const addWebsite = (resourceId: string) => { + if (websiteAccess.some((e) => e.resourceId === resourceId)) { return; } setWebsiteAccess((prev) => [ ...prev, - { resourceType: "website", resourceId: websiteToAdd, scopes: [] }, + { resourceType: "website", resourceId, scopes: globalScopes }, ]); - setWebsiteToAdd(undefined); }; const removeWebsite = (resourceId: string) => { setWebsiteAccess((prev) => prev.filter((e) => e.resourceId !== resourceId)); }; - const toggleWebsiteScope = (resourceId: string, scope: ApiScope) => { - setWebsiteAccess((prev) => - prev.map((entry) => { - if (entry.resourceId !== resourceId) { - return entry; - } - const scopes = entry.scopes.includes(scope) - ? entry.scopes.filter((s) => s !== scope) - : [...entry.scopes, scope]; - return { ...entry, scopes }; - }) - ); + const isWebsiteSelected = (resourceId: string) => { + return websiteAccess.some((e) => e.resourceId === resourceId); }; const onSubmit = form.handleSubmit((values) => { const resources: Record = {}; if (globalScopes.length > 0) { - resources.global = globalScopes; + if (websiteAccess.length === 0) { + resources.global = globalScopes; + } else { + resources.global = DEFAULT_SCOPES; + } } // Add website-specific scopes with proper prefix for (const entry of websiteAccess) { if (entry.resourceId && entry.scopes.length > 0) { - resources[`website:${entry.resourceId}`] = entry.scopes; + resources[`website:${entry.resourceId}`] = globalScopes; } } @@ -182,7 +191,7 @@ export function ApiKeyCreateDialog({
-
+
-
- - -
- + + {globalScopes.length} selected
@@ -283,44 +289,58 @@ export function ApiKeyCreateDialog({ These permissions apply to all websites. Read Data is included by default.

-
-
- {SCOPE_OPTIONS.map((scope) => { - const isSelected = globalScopes.includes(scope.value); - const isDefault = scope.value === "read:data"; - return ( -
- {scope.label} - {isDefault && ( - - default - - )} - - ); - })} -
-
+ + ); + })} +
+ + {/* Website-Specific Permissions */} @@ -333,112 +353,57 @@ export function ApiKeyCreateDialog({

- Limit this key to specific websites with custom permissions + Limit this key to specific websites

- {/* Add Website */} -
- - + + - - -
- - {/* Website Access List */} - {websiteAccess.length > 0 && ( -
- {websiteAccess.map((entry) => { - const website = websites.find( - (w) => w.id === entry.resourceId - ); - return ( -
-
- - {(website?.name || website?.domain) ?? - entry.resourceId} - - -
-
- {SCOPE_OPTIONS.slice(0, 6).map((scope) => { - const isSelected = entry.scopes.includes( - scope.value - ); - return ( - - ); - })} -
-
- ); - })} -
- )} + {website.name || website.domain} + + + ))} + + + + + )} From bff3ee9f6ec12d77652801b501e2d08456754196 Mon Sep 17 00:00:00 2001 From: omaro Date: Mon, 23 Mar 2026 19:46:04 +0300 Subject: [PATCH 4/9] fix(api-key-create-dialog): don't send resources.global when websites are selected for the api key --- .../components/organizations/api-key-create-dialog.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/apps/dashboard/components/organizations/api-key-create-dialog.tsx b/apps/dashboard/components/organizations/api-key-create-dialog.tsx index 3c3e19a28..b87a90ffb 100644 --- a/apps/dashboard/components/organizations/api-key-create-dialog.tsx +++ b/apps/dashboard/components/organizations/api-key-create-dialog.tsx @@ -162,12 +162,8 @@ export function ApiKeyCreateDialog({ const onSubmit = form.handleSubmit((values) => { const resources: Record = {}; - if (globalScopes.length > 0) { - if (websiteAccess.length === 0) { - resources.global = globalScopes; - } else { - resources.global = DEFAULT_SCOPES; - } + if (globalScopes.length > 0 && websiteAccess.length === 0) { + resources.global = globalScopes; } // Add website-specific scopes with proper prefix From 3434a2f9ce2dc3470d341af563d74718c4ca11a9 Mon Sep 17 00:00:00 2001 From: omaro Date: Mon, 23 Mar 2026 19:54:30 +0300 Subject: [PATCH 5/9] docs(api-keys): documented permission presets and outlined api key behavior --- apps/docs/content/docs/api-keys.mdx | 22 ++++++++++++++----- apps/docs/content/docs/api/authentication.mdx | 9 +++++--- apps/docs/content/docs/api/index.mdx | 2 ++ 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/apps/docs/content/docs/api-keys.mdx b/apps/docs/content/docs/api-keys.mdx index 220b810e9..20a300aa8 100644 --- a/apps/docs/content/docs/api-keys.mdx +++ b/apps/docs/content/docs/api-keys.mdx @@ -21,9 +21,14 @@ An API key authenticates server-to-server calls to Databuddy. It supports fine-g ### Create a key 1. Open [Organization Settings → API Keys](https://app.databuddy.cc/organizations/settings/api-keys) -2. Click “Create API Key” -3. Choose a name, optional organization, scopes, and resource access -4. Copy the secret immediately (it’s only shown once) +2. Click "Create API Key" +3. Enter a key name +4. Choose how broad the key should be: + - `All`: grants all available scopes across the organization + - `Restricted`: lets you customize the key's global scopes + - `Read data only`: quickly creates a read-only key +5. Optionally select specific websites to limit the key to those websites only +6. Copy the secret immediately (it's only shown once) We only display the `prefix` and first characters (`start`) later for identification. Never share the full secret. @@ -59,12 +64,16 @@ Grant only what you need. Prefer resource-scoped access where possible. ### Resource access +API keys are created from Organization Settings and default to organization-wide access. + Access can be scoped to: -- **global**: Applies to all websites in the organization -- **website**: Applies to a specific website only +- **global**: Applies the selected scopes to all websites in the organization +- **website**: Applies the selected scopes only to the specific websites you choose + +If you add website restrictions, the key no longer inherits global read access by default. -Example: a key with `read:data` scoped to a single website can read analytics only for that website, not others in the organization. +Example: a key with `read:data` limited to a single website can read analytics only for that website, not others in the organization. ### Errors @@ -115,6 +124,7 @@ Logs include IP addresses, user agents, and timestamps for security monitoring. - Treat API keys like passwords; don't commit them to source control - Use environment variables or secret managers - Share only the prefix and `start` snippet for identification +- Start with `Read data only` or `Restricted` when full global access is not needed - Use least-privilege scopes and resource scoping - Rotate keys periodically and revoke unused keys - Monitor audit logs for suspicious activity diff --git a/apps/docs/content/docs/api/authentication.mdx b/apps/docs/content/docs/api/authentication.mdx index 51c552a82..8ee8a98b7 100644 --- a/apps/docs/content/docs/api/authentication.mdx +++ b/apps/docs/content/docs/api/authentication.mdx @@ -30,7 +30,10 @@ Alternatively, use Bearer token format: 1. Go to **[Dashboard → Organization Settings → API Keys](https://app.databuddy.cc/organizations/settings/api-keys)** 2. Click **Create API Key** 3. Enter a descriptive name (e.g., "Production Server", "CI Pipeline") -4. Select the required scopes +4. Choose a preset: + - **All** for full organization-wide access + - **Restricted** to customize global scopes + - **Read data only** for a quick read-only key 5. Optionally restrict access to specific websites 6. Copy and securely store your key — it won't be shown again @@ -59,14 +62,14 @@ API keys can have two access levels: ### Global Access -Access all websites in your account or organization. Best for: +Access all websites in your account or organization with the selected global scopes. Best for: - Internal dashboards - Automated reporting - Organization-wide analytics ### Website-Specific Access -Access only specified websites. Best for: +Access only specified websites. Restricted keys do not inherit a default global `read:data` scope. Best for: - Third-party integrations - Client-specific keys - Least-privilege security diff --git a/apps/docs/content/docs/api/index.mdx b/apps/docs/content/docs/api/index.mdx index 2d0e56881..a6cd6c6c7 100644 --- a/apps/docs/content/docs/api/index.mdx +++ b/apps/docs/content/docs/api/index.mdx @@ -22,6 +22,8 @@ Access your analytics data programmatically with Databuddy's REST API. All endpo **1. Get your API key** from [Dashboard → Organization Settings → API Keys](https://app.databuddy.cc/organizations/settings/api-keys) +API keys start with organization-wide access by default, and you can switch to restricted scopes or limit a key to specific websites during creation. + **2. List your websites:** Date: Mon, 23 Mar 2026 20:12:48 +0300 Subject: [PATCH 6/9] fix(dashboard): decrease tabs z-index to z-1 --- apps/dashboard/components/ui/tabs.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dashboard/components/ui/tabs.tsx b/apps/dashboard/components/ui/tabs.tsx index 58f4f9cea..965b47d0d 100644 --- a/apps/dashboard/components/ui/tabs.tsx +++ b/apps/dashboard/components/ui/tabs.tsx @@ -254,7 +254,7 @@ function TabsTrigger({ return ( Date: Mon, 23 Mar 2026 20:14:39 +0300 Subject: [PATCH 7/9] fix(dashboard): various bug fixes in api key creat dialog --- .../components/organizations/api-key-create-dialog.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/dashboard/components/organizations/api-key-create-dialog.tsx b/apps/dashboard/components/organizations/api-key-create-dialog.tsx index b87a90ffb..69fe9155e 100644 --- a/apps/dashboard/components/organizations/api-key-create-dialog.tsx +++ b/apps/dashboard/components/organizations/api-key-create-dialog.tsx @@ -22,6 +22,7 @@ import { Command, CommandEmpty, CommandGroup, + CommandInput, CommandItem, CommandList, } from "../ui/command"; @@ -147,7 +148,7 @@ export function ApiKeyCreateDialog({ } setWebsiteAccess((prev) => [ ...prev, - { resourceType: "website", resourceId, scopes: globalScopes }, + { resourceType: "website", resourceId, scopes: [] }, ]); }; @@ -168,7 +169,7 @@ export function ApiKeyCreateDialog({ // Add website-specific scopes with proper prefix for (const entry of websiteAccess) { - if (entry.resourceId && entry.scopes.length > 0) { + if (entry.resourceId) { resources[`website:${entry.resourceId}`] = globalScopes; } } @@ -355,7 +356,7 @@ export function ApiKeyCreateDialog({

- These permissions apply to all websites. Read Data is included - by default. + These permissions apply to all websites unless you restrict to + specific ones below.

+ No websites found. - {(websites as Website[]).map((website) => ( Date: Mon, 23 Mar 2026 23:06:46 +0300 Subject: [PATCH 9/9] fix(dashboard): preserve restricted API key scopes when switching tabs --- .../organizations/api-key-create-dialog.tsx | 51 +++++++++++-------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/apps/dashboard/components/organizations/api-key-create-dialog.tsx b/apps/dashboard/components/organizations/api-key-create-dialog.tsx index 9ed50e170..3a2ed65ba 100644 --- a/apps/dashboard/components/organizations/api-key-create-dialog.tsx +++ b/apps/dashboard/components/organizations/api-key-create-dialog.tsx @@ -11,7 +11,7 @@ import { PlusIcon, } from "@phosphor-icons/react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; import type { Website } from "@/hooks/use-websites"; @@ -69,8 +69,13 @@ export function ApiKeyCreateDialog({ onSuccessAction, }: ApiKeyCreateDialogProps) { const queryClient = useQueryClient(); - const [globalScopes, setGlobalScopes] = useState(ALL_SCOPES); + // const [globalScopes, setGlobalScopes] = useState(ALL_SCOPES); const [websiteAccess, setWebsiteAccess] = useState([]); + + const [scopeMode, setScopeMode] = useState("all"); + const [restrictedScopes, setRestrictedScopes] = + useState(DEFAULT_SCOPES); + const [popoverOpen, setPopoverOpen] = useState(false); const [created, setCreated] = useState<{ id: string; @@ -98,21 +103,18 @@ export function ApiKeyCreateDialog({ }, }); - const assignGlobalScopes = (value: string) => { - switch (value) { + const globalScopes = useMemo(() => { + switch (scopeMode) { case "all": - setGlobalScopes(ALL_SCOPES); - break; + return ALL_SCOPES; case "restricted": - setGlobalScopes(DEFAULT_SCOPES); - break; + return restrictedScopes; case "read-data": - setGlobalScopes(DEFAULT_SCOPES); - break; + return DEFAULT_SCOPES; default: - setGlobalScopes(DEFAULT_SCOPES); + return DEFAULT_SCOPES; } - }; + }, [scopeMode, restrictedScopes]); const handleClose = () => { if (created) { @@ -121,7 +123,8 @@ export function ApiKeyCreateDialog({ onOpenChangeAction(false); setTimeout(() => { form.reset(); - setGlobalScopes(ALL_SCOPES); + setScopeMode("all"); + setRestrictedScopes(DEFAULT_SCOPES); setWebsiteAccess([]); setCreated(null); setCopied(false); @@ -136,12 +139,6 @@ export function ApiKeyCreateDialog({ } }; - const toggleGlobalScope = (scope: ApiScope) => { - setGlobalScopes((prev) => - prev.includes(scope) ? prev.filter((s) => s !== scope) : [...prev, scope] - ); - }; - const addWebsite = (resourceId: string) => { if (websiteAccess.some((e) => e.resourceId === resourceId)) { return; @@ -149,6 +146,7 @@ export function ApiKeyCreateDialog({ setWebsiteAccess((prev) => [ ...prev, { resourceType: "website", resourceId, scopes: [] }, + // don't snapshot globalScopes here, as they are computed on submit to ensure they reflect the latest selection ]); }; @@ -170,6 +168,7 @@ export function ApiKeyCreateDialog({ // Add website-specific scopes with proper prefix for (const entry of websiteAccess) { if (entry.resourceId) { + // snapshot globalScopes on submit to ensure it reflects latest selection resources[`website:${entry.resourceId}`] = globalScopes; } } @@ -288,7 +287,8 @@ export function ApiKeyCreateDialog({

@@ -302,13 +302,20 @@ export function ApiKeyCreateDialog({ >
{SCOPE_OPTIONS.map((scope) => { - const isSelected = globalScopes.includes(scope.value); + const isSelected = restrictedScopes.includes(scope.value); const isDefault = DEFAULT_SCOPES.includes(scope.value); return (