mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-26 23:51:14 +02:00
Merge remote-tracking branch 'upstream/dev' into fix/models
This commit is contained in:
commit
a2d5467023
1952 changed files with 103406 additions and 35663 deletions
|
|
@ -2,19 +2,20 @@
|
|||
|
||||
import { useQuery as useZeroQuery } from "@rocicorp/zero/react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, CreditCard, RefreshCw } from "lucide-react";
|
||||
import { AlertTriangle, Info } from "lucide-react";
|
||||
import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { stripeApiService } from "@/lib/apis/stripe-api.service";
|
||||
import { AppError } from "@/lib/error";
|
||||
import { getWorkspaceIdNumber } from "@/lib/route-params";
|
||||
import { queries } from "@/zero/queries";
|
||||
|
||||
const microsToDollars = (micros: number | null | undefined): string => {
|
||||
|
|
@ -38,7 +39,7 @@ export function AutoReloadSettings() {
|
|||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const searchSpaceId = Number(params?.search_space_id);
|
||||
const workspaceId = getWorkspaceIdNumber(params) ?? 0;
|
||||
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [thresholdInput, setThresholdInput] = useState("");
|
||||
|
|
@ -68,7 +69,7 @@ export function AutoReloadSettings() {
|
|||
const setupResult = searchParams.get("auto_reload_setup");
|
||||
if (!setupResult) return;
|
||||
if (setupResult === "success") {
|
||||
toast.success("Card saved. You can now enable auto-reload.");
|
||||
toast.success("Card saved. You can now enable top-ups.");
|
||||
queryClient.invalidateQueries({ queryKey: ["auto-reload-settings"] });
|
||||
} else if (setupResult === "cancel") {
|
||||
toast.info("Card setup canceled.");
|
||||
|
|
@ -78,8 +79,7 @@ export function AutoReloadSettings() {
|
|||
}, [searchParams, router, pathname, queryClient]);
|
||||
|
||||
const setupMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
stripeApiService.createAutoReloadSetupSession({ search_space_id: searchSpaceId }),
|
||||
mutationFn: () => stripeApiService.createAutoReloadSetupSession({ workspace_id: workspaceId }),
|
||||
onSuccess: (response) => {
|
||||
window.location.assign(response.checkout_url);
|
||||
},
|
||||
|
|
@ -92,19 +92,19 @@ export function AutoReloadSettings() {
|
|||
mutationFn: stripeApiService.updateAutoReloadSettings,
|
||||
onSuccess: (updated) => {
|
||||
queryClient.setQueryData(["auto-reload-settings"], updated);
|
||||
toast.success(updated.enabled ? "Auto-reload is on." : "Auto-reload settings saved.");
|
||||
toast.success(updated.enabled ? "Top-ups are on." : "Top-up settings saved.");
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error instanceof AppError && error.message) {
|
||||
toast.error(error.message);
|
||||
return;
|
||||
}
|
||||
toast.error("Couldn't save auto-reload settings. Please try again.");
|
||||
toast.error("Couldn't save top-up settings. Please try again.");
|
||||
},
|
||||
});
|
||||
|
||||
// Render nothing while loading (avoids a spinner flash on pages where the
|
||||
// feature flag turns out to be off) and when auto-reload is disabled
|
||||
// feature flag turns out to be off) and when top-ups are disabled
|
||||
// server-side.
|
||||
if (isLoading || !settings || !settings.feature_enabled) {
|
||||
return null;
|
||||
|
|
@ -131,7 +131,7 @@ export function AutoReloadSettings() {
|
|||
return;
|
||||
}
|
||||
if (amountMicros == null || amountMicros < settings.min_amount_micros) {
|
||||
toast.error(`Reload amount must be at least $${minAmountDollars}.`);
|
||||
toast.error(`Top-up amount must be at least $${minAmountDollars}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -142,135 +142,172 @@ export function AutoReloadSettings() {
|
|||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<RefreshCw className="h-4 w-4 text-amber-500" />
|
||||
Auto-reload
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Automatically top up your credit balance when it drops below a threshold, using a saved
|
||||
card. Current balance:{" "}
|
||||
<span className="font-medium text-foreground">{formatUsd(balanceMicros)}</span>.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
const addCardButton = (
|
||||
<Button
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => setupMutation.mutate()}
|
||||
disabled={setupMutation.isPending}
|
||||
>
|
||||
{setupMutation.isPending ? (
|
||||
<>
|
||||
<Spinner size="xs" />
|
||||
Redirecting
|
||||
</>
|
||||
) : (
|
||||
"Add a card"
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (!hasCard) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{settings.failed_at && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>Last auto-reload failed</AlertTitle>
|
||||
<AlertTitle>Last top-up failed</AlertTitle>
|
||||
<AlertDescription>
|
||||
Your saved card was declined and auto-reload was turned off. Update your card and
|
||||
re-enable it below to keep topping up automatically.
|
||||
Your saved card was declined and top-ups were turned off. Update your card and
|
||||
re-enable top-ups below.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!hasCard ? (
|
||||
<div className="flex flex-col items-start gap-3 rounded-lg border bg-muted/20 p-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<CreditCard className="h-4 w-4 text-muted-foreground" />
|
||||
<span>Add a card to enable automatic top-ups.</span>
|
||||
<div className="space-y-6">
|
||||
<Alert className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<Info className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
Automatically top up your credit balance when it drops below a threshold, using a
|
||||
saved card. Current balance:{" "}
|
||||
<span className="font-medium text-foreground">{formatUsd(balanceMicros)}</span>.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setupMutation.mutate()} disabled={setupMutation.isPending}>
|
||||
{setupMutation.isPending ? (
|
||||
<>
|
||||
<Spinner size="xs" />
|
||||
Redirecting
|
||||
</>
|
||||
) : (
|
||||
"Add a card"
|
||||
)}
|
||||
</Button>
|
||||
{addCardButton}
|
||||
</Alert>
|
||||
<Separator />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{settings.failed_at && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>Last top-up failed</AlertTitle>
|
||||
<AlertDescription>
|
||||
Your saved card was declined and top-ups were turned off. Update your card and re-enable
|
||||
top-ups below.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<section className="space-y-5">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-base font-semibold tracking-tight">Automatic top-ups</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Current balance:{" "}
|
||||
<span className="font-medium text-foreground">{formatUsd(balanceMicros)}</span>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="auto-reload-toggle" className="text-sm font-medium">
|
||||
Enable auto-reload
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Charge your saved card when the balance gets low.
|
||||
</p>
|
||||
</div>
|
||||
<Switch id="auto-reload-toggle" checked={enabled} onCheckedChange={setEnabled} />
|
||||
</div>
|
||||
<Button
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => setupMutation.mutate()}
|
||||
disabled={setupMutation.isPending}
|
||||
>
|
||||
{setupMutation.isPending ? (
|
||||
<>
|
||||
<Spinner size="xs" />
|
||||
Redirecting
|
||||
</>
|
||||
) : (
|
||||
"Update card"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="auto-reload-threshold" className="text-xs">
|
||||
When balance falls below
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
|
||||
$
|
||||
</span>
|
||||
<Input
|
||||
id="auto-reload-threshold"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
inputMode="decimal"
|
||||
className="pl-6 tabular-nums"
|
||||
value={thresholdInput}
|
||||
onChange={(e) => setThresholdInput(e.target.value)}
|
||||
disabled={!enabled}
|
||||
placeholder="5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="auto-reload-amount" className="text-xs">
|
||||
Add this much credit
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
|
||||
$
|
||||
</span>
|
||||
<Input
|
||||
id="auto-reload-amount"
|
||||
type="number"
|
||||
min={minAmountDollars}
|
||||
step="1"
|
||||
inputMode="decimal"
|
||||
className="pl-6 tabular-nums"
|
||||
value={amountInput}
|
||||
onChange={(e) => setAmountInput(e.target.value)}
|
||||
disabled={!enabled}
|
||||
placeholder="10"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">Minimum ${minAmountDollars}.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="top-ups-toggle" className="text-sm font-medium">
|
||||
Enable top-ups
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Charge your saved card when the balance gets low.
|
||||
</p>
|
||||
</div>
|
||||
<Switch id="top-ups-toggle" checked={enabled} onCheckedChange={setEnabled} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => setupMutation.mutate()}
|
||||
disabled={setupMutation.isPending}
|
||||
>
|
||||
<CreditCard className="h-3.5 w-3.5" />
|
||||
Update card
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saveMutation.isPending}>
|
||||
{saveMutation.isPending ? (
|
||||
<>
|
||||
<Spinner size="xs" />
|
||||
Saving
|
||||
</>
|
||||
) : (
|
||||
"Save"
|
||||
)}
|
||||
</Button>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="top-ups-threshold" className="text-xs">
|
||||
When balance falls below
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
|
||||
$
|
||||
</span>
|
||||
<Input
|
||||
id="top-ups-threshold"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
inputMode="decimal"
|
||||
className="pl-6 tabular-nums"
|
||||
value={thresholdInput}
|
||||
onChange={(e) => setThresholdInput(e.target.value)}
|
||||
disabled={!enabled}
|
||||
placeholder="5"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="top-ups-amount" className="text-xs">
|
||||
Add this much credit
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
|
||||
$
|
||||
</span>
|
||||
<Input
|
||||
id="top-ups-amount"
|
||||
type="number"
|
||||
min={minAmountDollars}
|
||||
step="1"
|
||||
inputMode="decimal"
|
||||
className="pl-6 tabular-nums"
|
||||
value={amountInput}
|
||||
onChange={(e) => setAmountInput(e.target.value)}
|
||||
disabled={!enabled}
|
||||
placeholder="10"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">Minimum ${minAmountDollars}.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className="w-full sm:w-auto"
|
||||
onClick={handleSave}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<>
|
||||
<Spinner size="xs" />
|
||||
Saving
|
||||
</>
|
||||
) : (
|
||||
"Save"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { stripeApiService } from "@/lib/apis/stripe-api.service";
|
||||
import { AppError } from "@/lib/error";
|
||||
import { getWorkspaceIdNumber } from "@/lib/route-params";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { queries } from "@/zero/queries";
|
||||
|
||||
|
|
@ -37,7 +38,7 @@ const formatUsd = (micros: number) => {
|
|||
|
||||
export function BuyCreditsContent() {
|
||||
const params = useParams();
|
||||
const searchSpaceId = Number(params?.search_space_id);
|
||||
const workspaceId = getWorkspaceIdNumber(params) ?? 0;
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
// Raw text of the amount field so the user can clear it while typing;
|
||||
// committed back to a clamped integer on blur.
|
||||
|
|
@ -176,7 +177,7 @@ export function BuyCreditsContent() {
|
|||
<Button
|
||||
className="w-full"
|
||||
disabled={purchaseMutation.isPending}
|
||||
onClick={() => purchaseMutation.mutate({ quantity, search_space_id: searchSpaceId })}
|
||||
onClick={() => purchaseMutation.mutate({ quantity, workspace_id: workspaceId })}
|
||||
>
|
||||
{purchaseMutation.isPending ? (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||
import { Check, ExternalLink } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { USER_QUERY_KEY } from "@/atoms/user/user-query.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -15,11 +14,8 @@ import { Spinner } from "@/components/ui/spinner";
|
|||
import type { IncentiveTaskInfo } from "@/contracts/types/incentive-tasks.types";
|
||||
import { incentiveTasksApiService } from "@/lib/apis/incentive-tasks-api.service";
|
||||
import { stripeApiService } from "@/lib/apis/stripe-api.service";
|
||||
import {
|
||||
trackIncentivePageViewed,
|
||||
trackIncentiveTaskClicked,
|
||||
trackIncentiveTaskCompleted,
|
||||
} from "@/lib/posthog/events";
|
||||
import { trackIncentiveTaskClicked } from "@/lib/posthog/events";
|
||||
import { getWorkspaceIdParam } from "@/lib/route-params";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Compact dollar label for a task's reward (e.g. "+$0.03").
|
||||
|
|
@ -32,11 +28,9 @@ const formatRewardUsd = (micros: number) => {
|
|||
export function EarnCreditsContent() {
|
||||
const params = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
const searchSpaceId = params?.search_space_id ?? "";
|
||||
const workspaceId = getWorkspaceIdParam(params) ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
trackIncentivePageViewed();
|
||||
}, []);
|
||||
// incentive_page_viewed removed — redundant with $pageview.
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["incentive-tasks"],
|
||||
|
|
@ -50,13 +44,11 @@ export function EarnCreditsContent() {
|
|||
|
||||
const completeMutation = useMutation({
|
||||
mutationFn: incentiveTasksApiService.completeTask,
|
||||
onSuccess: (response, taskType) => {
|
||||
onSuccess: (response) => {
|
||||
if (response.success) {
|
||||
toast.success(response.message);
|
||||
const task = data?.tasks.find((t) => t.task_type === taskType);
|
||||
if (task) {
|
||||
trackIncentiveTaskCompleted(taskType, task.credit_micros_reward);
|
||||
}
|
||||
// incentive_task_completed is now emitted server-side
|
||||
// (incentive_tasks_routes.complete_task) where credit is granted.
|
||||
queryClient.invalidateQueries({ queryKey: ["incentive-tasks"] });
|
||||
queryClient.invalidateQueries({ queryKey: USER_QUERY_KEY });
|
||||
}
|
||||
|
|
@ -162,7 +154,7 @@ export function EarnCreditsContent() {
|
|||
<p className="text-sm text-muted-foreground">Need more?</p>
|
||||
{creditBuyingEnabled ? (
|
||||
<Button asChild variant="link" className="text-emerald-600 dark:text-emerald-400">
|
||||
<Link href={`/dashboard/${searchSpaceId}/buy-more`}>Buy credits at $1 per $1</Link>
|
||||
<Link href={`/dashboard/${workspaceId}/buy-more`}>Buy credits at $1 per $1</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -5,59 +5,52 @@ import { useAtomValue } from "jotai";
|
|||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
updateSearchSpaceApiAccessMutationAtom,
|
||||
updateSearchSpaceMutationAtom,
|
||||
} from "@/atoms/search-spaces/search-space-mutation.atoms";
|
||||
import { updateWorkspaceMutationAtom } from "@/atoms/workspaces/workspace-mutation.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||
import { workspacesApiService } from "@/lib/apis/workspaces-api.service";
|
||||
import { authenticatedFetch } from "@/lib/auth-fetch";
|
||||
import { buildBackendUrl } from "@/lib/env-config";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { Spinner } from "../ui/spinner";
|
||||
import { WorkspaceApiAccessControl } from "./workspace-api-access-control";
|
||||
|
||||
interface GeneralSettingsManagerProps {
|
||||
searchSpaceId: number;
|
||||
workspaceId: number;
|
||||
}
|
||||
|
||||
export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManagerProps) {
|
||||
const t = useTranslations("searchSpaceSettings");
|
||||
export function GeneralSettingsManager({ workspaceId }: GeneralSettingsManagerProps) {
|
||||
const t = useTranslations("workspaceSettings");
|
||||
const tCommon = useTranslations("common");
|
||||
const {
|
||||
data: searchSpace,
|
||||
data: workspace,
|
||||
isLoading: loading,
|
||||
isError,
|
||||
refetch: fetchSearchSpace,
|
||||
refetch: fetchWorkspace,
|
||||
} = useQuery({
|
||||
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId.toString()),
|
||||
queryFn: () => searchSpacesApiService.getSearchSpace({ id: searchSpaceId }),
|
||||
enabled: !!searchSpaceId,
|
||||
queryKey: cacheKeys.workspaces.detail(workspaceId.toString()),
|
||||
queryFn: () => workspacesApiService.getWorkspace({ id: workspaceId }),
|
||||
enabled: !!workspaceId,
|
||||
});
|
||||
|
||||
const { mutateAsync: updateSearchSpace } = useAtomValue(updateSearchSpaceMutationAtom);
|
||||
const { mutateAsync: updateSearchSpaceApiAccess } = useAtomValue(
|
||||
updateSearchSpaceApiAccessMutationAtom
|
||||
);
|
||||
const { mutateAsync: updateWorkspace } = useAtomValue(updateWorkspaceMutationAtom);
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savingApiAccess, setSavingApiAccess] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const hasSearchSpace = !!searchSpace;
|
||||
const searchSpaceName = searchSpace?.name;
|
||||
const searchSpaceDescription = searchSpace?.description;
|
||||
const hasWorkspace = !!workspace;
|
||||
const workspaceName = workspace?.name;
|
||||
const workspaceDescription = workspace?.description;
|
||||
|
||||
const handleExportKB = useCallback(async () => {
|
||||
if (isExporting) return;
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
buildBackendUrl(`/api/v1/search-spaces/${searchSpaceId}/export`),
|
||||
buildBackendUrl(`/api/v1/workspaces/${workspaceId}/export`),
|
||||
{ method: "GET" }
|
||||
);
|
||||
if (!response.ok) {
|
||||
|
|
@ -80,37 +73,37 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
|
|||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
}, [searchSpaceId, isExporting]);
|
||||
}, [workspaceId, isExporting]);
|
||||
|
||||
// Initialize state from fetched search space
|
||||
// Initialize state from fetched workspace
|
||||
useEffect(() => {
|
||||
if (hasSearchSpace) {
|
||||
setName(searchSpaceName || "");
|
||||
setDescription(searchSpaceDescription || "");
|
||||
if (hasWorkspace) {
|
||||
setName(workspaceName || "");
|
||||
setDescription(workspaceDescription || "");
|
||||
}
|
||||
}, [hasSearchSpace, searchSpaceName, searchSpaceDescription]);
|
||||
}, [hasWorkspace, workspaceName, workspaceDescription]);
|
||||
|
||||
// Derive hasChanges during render
|
||||
const hasChanges =
|
||||
!!searchSpace &&
|
||||
((searchSpace.name || "") !== name || (searchSpace.description || "") !== description);
|
||||
!!workspace &&
|
||||
((workspace.name || "") !== name || (workspace.description || "") !== description);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
|
||||
await updateSearchSpace({
|
||||
id: searchSpaceId,
|
||||
await updateWorkspace({
|
||||
id: workspaceId,
|
||||
data: {
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
await fetchSearchSpace();
|
||||
await fetchWorkspace();
|
||||
} catch (error: unknown) {
|
||||
console.error("Error saving search space details:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to save search space details");
|
||||
console.error("Error saving workspace details:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to save workspace details");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
|
@ -121,25 +114,6 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
|
|||
handleSave();
|
||||
};
|
||||
|
||||
const handleApiAccessToggle = useCallback(
|
||||
async (enabled: boolean) => {
|
||||
try {
|
||||
setSavingApiAccess(true);
|
||||
await updateSearchSpaceApiAccess({
|
||||
id: searchSpaceId,
|
||||
api_access_enabled: enabled,
|
||||
});
|
||||
await fetchSearchSpace();
|
||||
} catch (error) {
|
||||
console.error("Error updating API access:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to update API access");
|
||||
} finally {
|
||||
setSavingApiAccess(false);
|
||||
}
|
||||
},
|
||||
[fetchSearchSpace, searchSpaceId, updateSearchSpaceApiAccess]
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
|
|
@ -155,7 +129,7 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
|
|||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-8 text-center">
|
||||
<p className="text-sm text-destructive">Failed to load settings.</p>
|
||||
<Button variant="outline" size="sm" onClick={() => fetchSearchSpace()}>
|
||||
<Button variant="outline" size="sm" onClick={() => fetchWorkspace()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -167,9 +141,9 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
|
|||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="search-space-name">{t("general_name_label")}</Label>
|
||||
<Label htmlFor="workspace-name">{t("general_name_label")}</Label>
|
||||
<Input
|
||||
id="search-space-name"
|
||||
id="workspace-name"
|
||||
maxLength={100}
|
||||
placeholder={t("general_name_placeholder")}
|
||||
value={name}
|
||||
|
|
@ -179,12 +153,12 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="search-space-description">
|
||||
<Label htmlFor="workspace-description">
|
||||
{t("general_description_label")}{" "}
|
||||
<span className="text-muted-foreground font-normal">({tCommon("optional")})</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="search-space-description"
|
||||
id="workspace-description"
|
||||
placeholder={t("general_description_placeholder")}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
|
|
@ -206,26 +180,13 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
|
|||
</div>
|
||||
</form>
|
||||
|
||||
<div className="border-t pt-6 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="api-access-enabled">API key access</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Allow API keys to access this search space.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="api-access-enabled"
|
||||
checked={!!searchSpace?.api_access_enabled}
|
||||
disabled={savingApiAccess}
|
||||
onCheckedChange={handleApiAccessToggle}
|
||||
/>
|
||||
</div>
|
||||
<WorkspaceApiAccessControl workspaceId={workspaceId} className="border-t pt-6" />
|
||||
|
||||
<div className="border-t pt-6 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label>Export knowledge base</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Download all documents in this search space as a ZIP of markdown files.
|
||||
Download all documents in this workspace as a ZIP of markdown files.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ function renderAutoModeOption() {
|
|||
);
|
||||
}
|
||||
|
||||
export function ModelConnectionsSettings({ searchSpaceId }: { searchSpaceId: number }) {
|
||||
export function ModelConnectionsSettings({ workspaceId }: { workspaceId: number }) {
|
||||
const [{ data: globalConnections = [] }] = useAtom(globalModelConnectionsAtom);
|
||||
const [{ data: connections = [] }] = useAtom(modelConnectionsAtom);
|
||||
const [{ data: roles }] = useAtom(modelRolesAtom);
|
||||
|
|
@ -147,7 +147,7 @@ export function ModelConnectionsSettings({ searchSpaceId }: { searchSpaceId: num
|
|||
|
||||
<Separator />
|
||||
|
||||
<ModelProviderConnectionsPanel searchSpaceId={searchSpaceId} connections={connections} />
|
||||
<ModelProviderConnectionsPanel workspaceId={workspaceId} connections={connections} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ export function ConnectionCard({ connection }: { connection: ConnectionRead }) {
|
|||
<AlertDialogTitle>Delete this provider?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<span className="font-medium text-foreground">{providerLabel}</span> and all of
|
||||
its models will be removed from this search space. This cannot be undone.
|
||||
its models will be removed from this workspace. This cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ export function ConnectionSettingsDialog({
|
|||
base_url: data.base_url,
|
||||
api_key: apiKeyForTest,
|
||||
scope: "SEARCH_SPACE",
|
||||
search_space_id: connection.search_space_id,
|
||||
workspace_id: connection.workspace_id,
|
||||
extra: connection.extra ?? {},
|
||||
enabled: connection.enabled,
|
||||
models: [],
|
||||
|
|
|
|||
|
|
@ -9,9 +9,17 @@ function baseUrlHint(provider: string) {
|
|||
return "For local servers, use host.docker.internal instead of localhost.";
|
||||
}
|
||||
if (provider === "openai_compatible") {
|
||||
return "Enter the full endpoint URL.";
|
||||
return "Enter the full endpoint URL. This provider expects a /v1-compatible endpoint.";
|
||||
}
|
||||
if (provider === "openai" || provider === "anthropic" || provider === "openrouter") {
|
||||
if (provider === "openai_compatible_raw") {
|
||||
return "Enter the exact chat-completions API base URL. SurfSense will not append /v1.";
|
||||
}
|
||||
if (
|
||||
provider === "openai" ||
|
||||
provider === "anthropic" ||
|
||||
provider === "openrouter" ||
|
||||
provider === "requesty"
|
||||
) {
|
||||
return "Override only if you route through a proxy or gateway.";
|
||||
}
|
||||
return undefined;
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import {
|
|||
} from "./provider-metadata";
|
||||
|
||||
interface ModelProviderConnectionsPanelProps {
|
||||
searchSpaceId: number;
|
||||
workspaceId: number;
|
||||
connections: ConnectionRead[];
|
||||
className?: string;
|
||||
addProviderTitle?: string;
|
||||
|
|
@ -49,7 +49,7 @@ function toModelSelection(model: SelectableModel): ModelSelection {
|
|||
}
|
||||
|
||||
export function ModelProviderConnectionsPanel({
|
||||
searchSpaceId,
|
||||
workspaceId,
|
||||
connections,
|
||||
className,
|
||||
addProviderTitle = "Add Provider",
|
||||
|
|
@ -126,6 +126,11 @@ export function ModelProviderConnectionsPanel({
|
|||
// Each provider connect form builds its own credential payload; the backend
|
||||
// resolver (`to_litellm`) forwards `extra.litellm_params` straight to LiteLLM.
|
||||
function handleCreate(draft: ConnectionDraft) {
|
||||
if (!Number.isFinite(workspaceId) || workspaceId <= 0) {
|
||||
toast.error("Workspace is still loading. Please try again.");
|
||||
return;
|
||||
}
|
||||
|
||||
const models = connectionModelsForDraft(draft);
|
||||
const testModel = representativeTestModel(models);
|
||||
if (!testModel) {
|
||||
|
|
@ -138,7 +143,7 @@ export function ModelProviderConnectionsPanel({
|
|||
base_url: draft.base_url,
|
||||
api_key: draft.api_key,
|
||||
scope: "SEARCH_SPACE" as const,
|
||||
search_space_id: searchSpaceId,
|
||||
workspace_id: workspaceId,
|
||||
extra: draft.extra,
|
||||
enabled: true,
|
||||
models,
|
||||
|
|
@ -165,13 +170,18 @@ export function ModelProviderConnectionsPanel({
|
|||
setProvider(providerId);
|
||||
setIsAddProviderOpen(true);
|
||||
if (providerId === "vertex_ai") {
|
||||
if (!Number.isFinite(workspaceId) || workspaceId <= 0) {
|
||||
toast.error("Workspace is still loading. Please try again.");
|
||||
return;
|
||||
}
|
||||
|
||||
previewModels.mutate(
|
||||
{
|
||||
provider: providerId,
|
||||
base_url: null,
|
||||
api_key: null,
|
||||
scope: "SEARCH_SPACE",
|
||||
search_space_id: searchSpaceId,
|
||||
workspace_id: workspaceId,
|
||||
extra: {},
|
||||
enabled: true,
|
||||
models: [],
|
||||
|
|
@ -184,13 +194,18 @@ export function ModelProviderConnectionsPanel({
|
|||
}
|
||||
|
||||
function refreshConnectModels(draft: ConnectionDraft) {
|
||||
if (!Number.isFinite(workspaceId) || workspaceId <= 0) {
|
||||
toast.error("Workspace is still loading. Please try again.");
|
||||
return;
|
||||
}
|
||||
|
||||
previewModels.mutate(
|
||||
{
|
||||
provider,
|
||||
base_url: draft.base_url,
|
||||
api_key: draft.api_key,
|
||||
scope: "SEARCH_SPACE",
|
||||
search_space_id: searchSpaceId,
|
||||
workspace_id: workspaceId,
|
||||
extra: draft.extra,
|
||||
enabled: true,
|
||||
models: [],
|
||||
|
|
|
|||
|
|
@ -7,9 +7,11 @@ export const PROVIDER_ORDER = [
|
|||
"bedrock",
|
||||
"azure",
|
||||
"openrouter",
|
||||
"requesty",
|
||||
"ollama_chat",
|
||||
"lm_studio",
|
||||
"openai_compatible",
|
||||
"openai_compatible_raw",
|
||||
];
|
||||
|
||||
export const PROVIDER_DISPLAY: Record<
|
||||
|
|
@ -37,12 +39,23 @@ export const PROVIDER_DISPLAY: Record<
|
|||
subtitle: "OpenAI-compatible endpoint",
|
||||
iconKey: "custom",
|
||||
},
|
||||
openai_compatible_raw: {
|
||||
name: "OpenAI-Compatible Raw",
|
||||
subtitle: "Use the exact base URL, no /v1 is appended",
|
||||
iconKey: "custom",
|
||||
},
|
||||
openrouter: {
|
||||
name: "OpenRouter",
|
||||
subtitle: "OpenRouter",
|
||||
iconKey: "openrouter",
|
||||
defaultBaseUrl: "https://openrouter.ai/api/v1",
|
||||
},
|
||||
requesty: {
|
||||
name: "Requesty",
|
||||
subtitle: "Requesty",
|
||||
iconKey: "requesty",
|
||||
defaultBaseUrl: "https://router.requesty.ai/v1",
|
||||
},
|
||||
vertex_ai: { name: "Gemini", subtitle: "Google Cloud Vertex AI", iconKey: "vertex_ai" },
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -5,50 +5,50 @@ import { useAtomValue } from "jotai";
|
|||
import { AlertTriangle, Info } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { updateSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
|
||||
import { updateWorkspaceMutationAtom } from "@/atoms/workspaces/workspace-mutation.atoms";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||
import { workspacesApiService } from "@/lib/apis/workspaces-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { Spinner } from "../ui/spinner";
|
||||
|
||||
interface PromptConfigManagerProps {
|
||||
searchSpaceId: number;
|
||||
workspaceId: number;
|
||||
}
|
||||
|
||||
export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps) {
|
||||
const { data: searchSpace, isLoading: loading } = useQuery({
|
||||
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId.toString()),
|
||||
queryFn: () => searchSpacesApiService.getSearchSpace({ id: searchSpaceId }),
|
||||
enabled: !!searchSpaceId,
|
||||
export function PromptConfigManager({ workspaceId }: PromptConfigManagerProps) {
|
||||
const { data: workspace, isLoading: loading } = useQuery({
|
||||
queryKey: cacheKeys.workspaces.detail(workspaceId.toString()),
|
||||
queryFn: () => workspacesApiService.getWorkspace({ id: workspaceId }),
|
||||
enabled: !!workspaceId,
|
||||
});
|
||||
|
||||
const { mutateAsync: updateSearchSpace, isPending: isSaving } = useAtomValue(
|
||||
updateSearchSpaceMutationAtom
|
||||
const { mutateAsync: updateWorkspace, isPending: isSaving } = useAtomValue(
|
||||
updateWorkspaceMutationAtom
|
||||
);
|
||||
|
||||
const [customInstructions, setCustomInstructions] = useState("");
|
||||
const hasSearchSpace = !!searchSpace;
|
||||
const searchSpaceInstructions = searchSpace?.qna_custom_instructions;
|
||||
const hasWorkspace = !!workspace;
|
||||
const workspaceInstructions = workspace?.qna_custom_instructions;
|
||||
|
||||
// Initialize state from fetched search space
|
||||
// Initialize state from fetched workspace
|
||||
useEffect(() => {
|
||||
if (hasSearchSpace) {
|
||||
setCustomInstructions(searchSpaceInstructions || "");
|
||||
if (hasWorkspace) {
|
||||
setCustomInstructions(workspaceInstructions || "");
|
||||
}
|
||||
}, [hasSearchSpace, searchSpaceInstructions]);
|
||||
}, [hasWorkspace, workspaceInstructions]);
|
||||
|
||||
// Derive hasChanges during render
|
||||
const hasChanges =
|
||||
!!searchSpace && (searchSpace.qna_custom_instructions || "") !== customInstructions;
|
||||
!!workspace && (workspace.qna_custom_instructions || "") !== customInstructions;
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await updateSearchSpace({
|
||||
id: searchSpaceId,
|
||||
await updateWorkspace({
|
||||
id: workspaceId,
|
||||
data: { qna_custom_instructions: customInstructions.trim() || "" },
|
||||
});
|
||||
toast.success("System instructions saved successfully");
|
||||
|
|
@ -97,8 +97,8 @@ export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps)
|
|||
<Alert>
|
||||
<Info />
|
||||
<AlertDescription>
|
||||
System instructions apply to all AI interactions in this search space. They guide how the
|
||||
AI responds, its tone, focus areas, and behavior patterns.
|
||||
System instructions apply to all AI interactions in this workspace. They guide how the AI
|
||||
responds, its tone, focus areas, and behavior patterns.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
|
|
@ -111,7 +111,7 @@ export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps)
|
|||
</h3>
|
||||
<p className="text-xs md:text-sm text-muted-foreground">
|
||||
Provide specific guidelines for how you want the AI to respond. These instructions
|
||||
will be applied to all answers in this search space.
|
||||
will be applied to all answers in this workspace.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3 md:space-y-4">
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ const CATEGORY_CONFIG: Record<
|
|||
settings: {
|
||||
label: "Settings",
|
||||
icon: Settings,
|
||||
description: "Manage search space settings",
|
||||
description: "Manage workspace settings",
|
||||
order: 10,
|
||||
},
|
||||
public_sharing: {
|
||||
|
|
@ -172,7 +172,7 @@ const CATEGORY_CONFIG: Record<
|
|||
general: {
|
||||
label: "General",
|
||||
icon: SlidersHorizontal,
|
||||
description: "General search space permissions",
|
||||
description: "General workspace permissions",
|
||||
order: 12,
|
||||
},
|
||||
};
|
||||
|
|
@ -269,7 +269,7 @@ type PermissionWithDescription = PermissionInfo;
|
|||
|
||||
// ============ Roles Manager (for Settings page) ============
|
||||
|
||||
export function RolesManager({ searchSpaceId }: { searchSpaceId: number }) {
|
||||
export function RolesManager({ workspaceId }: { workspaceId: number }) {
|
||||
const { data: access = null } = useAtomValue(myAccessAtom);
|
||||
|
||||
const hasPermission = useCallback(
|
||||
|
|
@ -278,9 +278,9 @@ export function RolesManager({ searchSpaceId }: { searchSpaceId: number }) {
|
|||
);
|
||||
|
||||
const { data: roles = [], isLoading: rolesLoading } = useQuery({
|
||||
queryKey: cacheKeys.roles.all(searchSpaceId.toString()),
|
||||
queryFn: () => rolesApiService.getRoles({ search_space_id: searchSpaceId }),
|
||||
enabled: !!searchSpaceId,
|
||||
queryKey: cacheKeys.roles.all(workspaceId.toString()),
|
||||
queryFn: () => rolesApiService.getRoles({ workspace_id: workspaceId }),
|
||||
enabled: !!workspaceId,
|
||||
});
|
||||
|
||||
const { data: permissionsData } = useAtomValue(permissionsAtom);
|
||||
|
|
@ -311,36 +311,36 @@ export function RolesManager({ searchSpaceId }: { searchSpaceId: number }) {
|
|||
}
|
||||
): Promise<Role> => {
|
||||
const request: UpdateRoleRequest = {
|
||||
search_space_id: searchSpaceId,
|
||||
workspace_id: workspaceId,
|
||||
role_id: roleId,
|
||||
data: data,
|
||||
};
|
||||
return await updateRole(request);
|
||||
},
|
||||
[updateRole, searchSpaceId]
|
||||
[updateRole, workspaceId]
|
||||
);
|
||||
|
||||
const handleDeleteRole = useCallback(
|
||||
async (roleId: number): Promise<boolean> => {
|
||||
const request: DeleteRoleRequest = {
|
||||
search_space_id: searchSpaceId,
|
||||
workspace_id: workspaceId,
|
||||
role_id: roleId,
|
||||
};
|
||||
await deleteRole(request);
|
||||
return true;
|
||||
},
|
||||
[deleteRole, searchSpaceId]
|
||||
[deleteRole, workspaceId]
|
||||
);
|
||||
|
||||
const handleCreateRole = useCallback(
|
||||
async (roleData: CreateRoleRequest["data"]): Promise<Role> => {
|
||||
const request: CreateRoleRequest = {
|
||||
search_space_id: searchSpaceId,
|
||||
workspace_id: workspaceId,
|
||||
data: roleData,
|
||||
};
|
||||
return await createRole(request);
|
||||
},
|
||||
[createRole, searchSpaceId]
|
||||
[createRole, workspaceId]
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -884,7 +884,7 @@ function CreateRoleDialog({
|
|||
<DialogHeader className="px-5 pt-5 pb-4 shrink-0">
|
||||
<DialogTitle className="text-lg">Create Custom Role</DialogTitle>
|
||||
<DialogDescription className="text-sm text-muted-foreground">
|
||||
Define permissions for a new role in this search space
|
||||
Define permissions for a new role in this workspace
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,115 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { updateWorkspaceApiAccessMutationAtom } from "@/atoms/workspaces/workspace-mutation.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { workspacesApiService } from "@/lib/apis/workspaces-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface WorkspaceApiAccessControlProps {
|
||||
workspaceId: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function WorkspaceApiAccessControl({
|
||||
workspaceId,
|
||||
className,
|
||||
}: WorkspaceApiAccessControlProps) {
|
||||
const {
|
||||
data: workspace,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: cacheKeys.workspaces.detail(workspaceId.toString()),
|
||||
queryFn: () => workspacesApiService.getWorkspace({ id: workspaceId }),
|
||||
enabled: !!workspaceId,
|
||||
});
|
||||
|
||||
const { mutateAsync: updateWorkspaceApiAccess } = useAtomValue(
|
||||
updateWorkspaceApiAccessMutationAtom
|
||||
);
|
||||
const [savingApiAccess, setSavingApiAccess] = useState(false);
|
||||
|
||||
const handleApiAccessToggle = useCallback(
|
||||
async (enabled: boolean) => {
|
||||
try {
|
||||
setSavingApiAccess(true);
|
||||
await updateWorkspaceApiAccess({
|
||||
id: workspaceId,
|
||||
api_access_enabled: enabled,
|
||||
});
|
||||
await refetch();
|
||||
} catch (error) {
|
||||
console.error("Error updating API access:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to update API access");
|
||||
} finally {
|
||||
setSavingApiAccess(false);
|
||||
}
|
||||
},
|
||||
[refetch, workspaceId, updateWorkspaceApiAccess]
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-3 md:flex-row md:items-center md:justify-between",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3 w-56" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-11 rounded-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-3 md:flex-row md:items-center md:justify-between",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<Label>API key access</Label>
|
||||
<p className="text-xs text-destructive">Failed to load workspace API access.</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-3 md:flex-row md:items-center md:justify-between",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="api-access-enabled">API key access</Label>
|
||||
<p className="text-xs text-muted-foreground">Allow API keys to access this workspace.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="api-access-enabled"
|
||||
checked={!!workspace?.api_access_enabled}
|
||||
disabled={savingApiAccess}
|
||||
onCheckedChange={handleApiAccessToggle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue