diff --git a/apps/dashboard/components/organizations/api-key-create-dialog.tsx b/apps/dashboard/components/organizations/api-key-create-dialog.tsx index 13b10eff9..3a2ed65ba 100644 --- a/apps/dashboard/components/organizations/api-key-create-dialog.tsx +++ b/apps/dashboard/components/organizations/api-key-create-dialog.tsx @@ -3,31 +3,32 @@ 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"; +import { useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; 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, + CommandInput, + 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 +38,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 +59,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 +69,14 @@ 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 [scopeMode, setScopeMode] = useState("all"); + const [restrictedScopes, setRestrictedScopes] = + useState(DEFAULT_SCOPES); + + const [popoverOpen, setPopoverOpen] = useState(false); const [created, setCreated] = useState<{ id: string; secret: string; @@ -93,6 +103,19 @@ export function ApiKeyCreateDialog({ }, }); + const globalScopes = useMemo(() => { + switch (scopeMode) { + case "all": + return ALL_SCOPES; + case "restricted": + return restrictedScopes; + case "read-data": + return DEFAULT_SCOPES; + default: + return DEFAULT_SCOPES; + } + }, [scopeMode, restrictedScopes]); + const handleClose = () => { if (created) { onSuccessAction(created); @@ -100,9 +123,9 @@ export function ApiKeyCreateDialog({ onOpenChangeAction(false); setTimeout(() => { form.reset(); - setGlobalScopes(["read:data"]); + setScopeMode("all"); + setRestrictedScopes(DEFAULT_SCOPES); setWebsiteAccess([]); - setWebsiteToAdd(undefined); setCreated(null); setCopied(false); }, 200); @@ -116,55 +139,37 @@ export function ApiKeyCreateDialog({ } }; - const toggleGlobalScope = (scope: ApiScope) => { - setGlobalScopes((prev) => - prev.includes(scope) ? prev.filter((s) => s !== scope) : [...prev, scope] - ); - }; - - 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: [] }, + // don't snapshot globalScopes here, as they are computed on submit to ensure they reflect the latest selection ]); - 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) { + if (globalScopes.length > 0 && websiteAccess.length === 0) { resources.global = globalScopes; } // 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; + if (entry.resourceId) { + // snapshot globalScopes on submit to ensure it reflects latest selection + resources[`website:${entry.resourceId}`] = globalScopes; } } @@ -182,7 +187,7 @@ export function ApiKeyCreateDialog({
-
+
-
- - -
- + + {globalScopes.length} selected

- 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.

-
-
- {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 +357,58 @@ 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} + + + ))} + + + + + )} 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: diff --git a/apps/dashboard/components/ui/tabs.tsx b/apps/dashboard/components/ui/tabs.tsx index 40295b33e..697ca9bce 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,12 @@ function TabsTrigger({ return (