mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-02 01:09:03 +02:00
Merge branch 'dev' into feat/ip-filtering
This commit is contained in:
@@ -17,10 +17,9 @@ import { useTranslations } from "next-intl";
|
||||
|
||||
type AccessTokenProps = {
|
||||
token: string;
|
||||
resourceId?: number;
|
||||
};
|
||||
|
||||
export default function AccessToken({ token, resourceId }: AccessTokenProps) {
|
||||
export default function AccessToken({ token }: AccessTokenProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isValid, setIsValid] = useState(false);
|
||||
|
||||
@@ -59,13 +58,13 @@ export default function AccessToken({ token, resourceId }: AccessTokenProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
async function checkSHA256() {
|
||||
async function check() {
|
||||
try {
|
||||
const res = await api.post<
|
||||
AxiosResponse<AuthWithAccessTokenResponse>
|
||||
>(`/auth/access-token`, {
|
||||
accessToken,
|
||||
accessTokenId
|
||||
accessTokenId: accessTokenId || undefined
|
||||
});
|
||||
|
||||
if (res.data.data.session) {
|
||||
@@ -82,35 +81,7 @@ export default function AccessToken({ token, resourceId }: AccessTokenProps) {
|
||||
}
|
||||
}
|
||||
|
||||
async function check() {
|
||||
try {
|
||||
const res = await api.post<
|
||||
AxiosResponse<AuthWithAccessTokenResponse>
|
||||
>(`/auth/resource/${resourceId}/access-token`, {
|
||||
accessToken,
|
||||
accessTokenId
|
||||
});
|
||||
|
||||
if (res.data.data.session) {
|
||||
setIsValid(true);
|
||||
window.location.href = appendRequestToken(
|
||||
res.data.data.redirectUrl!,
|
||||
res.data.data.session
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(t("accessTokenError"), e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!accessTokenId) {
|
||||
// no access token id so check the sha256
|
||||
checkSHA256();
|
||||
} else {
|
||||
check();
|
||||
}
|
||||
check();
|
||||
}, [token]);
|
||||
|
||||
function renderTitle() {
|
||||
|
||||
@@ -0,0 +1,604 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { type TagValue } from "@app/components/multi-select/multi-select-content";
|
||||
import { MultiSelectTagInput } from "@app/components/multi-select/multi-select-tag-input";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { aiProviderQueries } from "@app/lib/queries";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Plus, XIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
export type AiProviderAttachmentValue = {
|
||||
providerId: number;
|
||||
niceId: string;
|
||||
name: string;
|
||||
accessMode: "inherit" | "select";
|
||||
enabled: boolean;
|
||||
selectedModelIds: number[];
|
||||
};
|
||||
|
||||
export type AiProviderAttachmentsProps = {
|
||||
orgId: string;
|
||||
value: AiProviderAttachmentValue[];
|
||||
onChange: (value: AiProviderAttachmentValue[]) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function AiProviderAttachments({
|
||||
orgId,
|
||||
value,
|
||||
onChange,
|
||||
disabled
|
||||
}: AiProviderAttachmentsProps) {
|
||||
const t = useTranslations();
|
||||
const [editingProviderId, setEditingProviderId] = useState<number | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const { data: providers = [] } = useQuery(
|
||||
aiProviderQueries.orgProviders({ orgId })
|
||||
);
|
||||
|
||||
const attachedIds = useMemo(
|
||||
() => new Set(value.map((v) => v.providerId)),
|
||||
[value]
|
||||
);
|
||||
|
||||
const availableProviders = providers
|
||||
.filter((provider) => provider.enabled)
|
||||
.filter((provider) => !attachedIds.has(provider.providerId));
|
||||
|
||||
const editing = value.find((v) => v.providerId === editingProviderId);
|
||||
|
||||
function addProvider(providerId: number, niceId: string, name: string) {
|
||||
if (value.some((v) => v.providerId === providerId)) {
|
||||
return;
|
||||
}
|
||||
onChange([
|
||||
...value,
|
||||
{
|
||||
providerId,
|
||||
niceId,
|
||||
name,
|
||||
accessMode: "inherit",
|
||||
enabled: true,
|
||||
selectedModelIds: []
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
function removeProvider(providerId: number) {
|
||||
onChange(value.filter((v) => v.providerId !== providerId));
|
||||
}
|
||||
|
||||
function updateProvider(updated: AiProviderAttachmentValue) {
|
||||
onChange(
|
||||
value.map((v) =>
|
||||
v.providerId === updated.providerId ? updated : v
|
||||
)
|
||||
);
|
||||
setEditingProviderId(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{value.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("aiResourceProvidersNoneAttached")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{value.map((attachment) => (
|
||||
<AttachmentRow
|
||||
key={attachment.providerId}
|
||||
attachment={attachment}
|
||||
disabled={disabled}
|
||||
onEdit={() =>
|
||||
setEditingProviderId(attachment.providerId)
|
||||
}
|
||||
onRemove={() =>
|
||||
removeProvider(attachment.providerId)
|
||||
}
|
||||
onToggleEnabled={(enabled) => {
|
||||
onChange(
|
||||
value.map((v) =>
|
||||
v.providerId === attachment.providerId
|
||||
? { ...v, enabled }
|
||||
: v
|
||||
)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
disabled={disabled || availableProviders.length === 0}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
{t("aiResourceProvidersAdd")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
{availableProviders.map((provider) => (
|
||||
<DropdownMenuItem
|
||||
key={provider.providerId}
|
||||
onSelect={() =>
|
||||
addProvider(
|
||||
provider.providerId,
|
||||
provider.niceId,
|
||||
provider.name
|
||||
)
|
||||
}
|
||||
>
|
||||
{provider.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{editing && (
|
||||
<EditAttachmentCredenza
|
||||
orgId={orgId}
|
||||
attachment={editing}
|
||||
open={editingProviderId !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditingProviderId(null);
|
||||
}}
|
||||
onSave={updateProvider}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentRow({
|
||||
attachment,
|
||||
disabled,
|
||||
onEdit,
|
||||
onRemove,
|
||||
onToggleEnabled
|
||||
}: {
|
||||
attachment: AiProviderAttachmentValue;
|
||||
disabled?: boolean;
|
||||
onEdit: () => void;
|
||||
onRemove: () => void;
|
||||
onToggleEnabled: (enabled: boolean) => void;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const summary =
|
||||
attachment.accessMode === "inherit"
|
||||
? t("aiResourceProviderModeInherit")
|
||||
: t("aiResourceProviderModeSelectSummary", {
|
||||
count: attachment.selectedModelIds.length
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-md border border-input p-3 min-w-0",
|
||||
(disabled || !attachment.enabled) && "opacity-60",
|
||||
!disabled && "cursor-pointer hover:bg-muted/50"
|
||||
)}
|
||||
onClick={disabled ? undefined : onEdit}
|
||||
onKeyDown={
|
||||
disabled
|
||||
? undefined
|
||||
: (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onEdit();
|
||||
}
|
||||
}
|
||||
}
|
||||
role={disabled ? undefined : "button"}
|
||||
tabIndex={disabled ? undefined : 0}
|
||||
>
|
||||
<div className="flex flex-1 min-w-0 flex-col gap-0.5">
|
||||
<span className="text-sm font-medium truncate">
|
||||
{attachment.name}
|
||||
</span>
|
||||
<p className="truncate text-sm text-muted-foreground">
|
||||
{attachment.enabled
|
||||
? summary
|
||||
: t("aiResourceProviderDisabled")}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="text"
|
||||
size="sm"
|
||||
className="h-auto px-0"
|
||||
disabled={disabled}
|
||||
onClick={onEdit}
|
||||
>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="p-0.5 text-muted-foreground hover:text-foreground cursor-pointer disabled:opacity-50"
|
||||
disabled={disabled}
|
||||
aria-label={t("aiResourceProvidersRemove")}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
<Switch
|
||||
checked={attachment.enabled}
|
||||
disabled={disabled}
|
||||
aria-label={t("aiResourceProviderToggleEnabled")}
|
||||
onCheckedChange={onToggleEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type EditFormValues = {
|
||||
accessMode: "inherit" | "select";
|
||||
selectedModels: TagValue[];
|
||||
};
|
||||
|
||||
function EditAttachmentCredenza({
|
||||
orgId,
|
||||
attachment,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave
|
||||
}: {
|
||||
orgId: string;
|
||||
attachment: AiProviderAttachmentValue;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave: (value: AiProviderAttachmentValue) => void;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const [modelSearch, setModelSearch] = useState("");
|
||||
|
||||
const editSchema = useMemo(
|
||||
() =>
|
||||
z.object({
|
||||
accessMode: z.enum(["inherit", "select"]),
|
||||
selectedModels: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
text: z.string()
|
||||
})
|
||||
)
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const modelsQuery = useQuery({
|
||||
...aiProviderQueries.providerModels({
|
||||
providerId: attachment.providerId
|
||||
}),
|
||||
enabled: open
|
||||
});
|
||||
|
||||
const allowCatalog = useMemo(() => {
|
||||
const models = modelsQuery.data ?? [];
|
||||
return models.filter(
|
||||
(model) => model.enabled && (model.listType ?? "allow") === "allow"
|
||||
);
|
||||
}, [modelsQuery.data]);
|
||||
|
||||
const allowOptions: TagValue[] = useMemo(() => {
|
||||
const query = modelSearch.trim().toLowerCase();
|
||||
return allowCatalog
|
||||
.filter((model) => {
|
||||
if (!query) return true;
|
||||
return (
|
||||
model.modelKey.toLowerCase().includes(query) ||
|
||||
model.name.toLowerCase().includes(query)
|
||||
);
|
||||
})
|
||||
.map((model) => ({
|
||||
id: String(model.modelId),
|
||||
text: model.modelKey
|
||||
}));
|
||||
}, [allowCatalog, modelSearch]);
|
||||
|
||||
const form = useForm<EditFormValues>({
|
||||
resolver: zodResolver(editSchema),
|
||||
defaultValues: {
|
||||
accessMode: attachment.accessMode,
|
||||
selectedModels: []
|
||||
}
|
||||
});
|
||||
|
||||
const accessMode = form.watch("accessMode");
|
||||
const pendingSeedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
form.reset({
|
||||
accessMode: attachment.accessMode,
|
||||
selectedModels: attachment.selectedModelIds.map((modelId) => {
|
||||
const catalog = (modelsQuery.data ?? []).find(
|
||||
(model) => model.modelId === modelId
|
||||
);
|
||||
return {
|
||||
id: String(modelId),
|
||||
text: catalog?.modelKey ?? String(modelId)
|
||||
};
|
||||
})
|
||||
});
|
||||
setModelSearch("");
|
||||
pendingSeedRef.current = false;
|
||||
// Only re-init when opening or switching which attachment is edited.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, attachment.providerId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || allowCatalog.length === 0) return;
|
||||
|
||||
const current = form.getValues("selectedModels");
|
||||
const upgraded = current.map((model) => {
|
||||
const catalog = allowCatalog.find(
|
||||
(entry) => String(entry.modelId) === model.id
|
||||
);
|
||||
return catalog ? { id: model.id, text: catalog.modelKey } : model;
|
||||
});
|
||||
const changed = upgraded.some(
|
||||
(model, index) => model.text !== current[index]?.text
|
||||
);
|
||||
if (changed) {
|
||||
form.setValue("selectedModels", upgraded);
|
||||
}
|
||||
|
||||
if (pendingSeedRef.current) {
|
||||
form.setValue(
|
||||
"selectedModels",
|
||||
allowCatalog.map((model) => ({
|
||||
id: String(model.modelId),
|
||||
text: model.modelKey
|
||||
}))
|
||||
);
|
||||
pendingSeedRef.current = false;
|
||||
}
|
||||
}, [open, allowCatalog, form]);
|
||||
|
||||
function handleAccessModeChange(next: "inherit" | "select") {
|
||||
form.setValue("accessMode", next);
|
||||
if (next === "inherit") {
|
||||
form.setValue("selectedModels", []);
|
||||
pendingSeedRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (attachment.accessMode === "select") {
|
||||
form.setValue(
|
||||
"selectedModels",
|
||||
attachment.selectedModelIds.map((modelId) => {
|
||||
const catalog = allowCatalog.find(
|
||||
(model) => model.modelId === modelId
|
||||
);
|
||||
return {
|
||||
id: String(modelId),
|
||||
text: catalog?.modelKey ?? String(modelId)
|
||||
};
|
||||
})
|
||||
);
|
||||
pendingSeedRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (allowCatalog.length > 0) {
|
||||
form.setValue(
|
||||
"selectedModels",
|
||||
allowCatalog.map((model) => ({
|
||||
id: String(model.modelId),
|
||||
text: model.modelKey
|
||||
}))
|
||||
);
|
||||
pendingSeedRef.current = false;
|
||||
return;
|
||||
}
|
||||
form.setValue("selectedModels", []);
|
||||
pendingSeedRef.current = true;
|
||||
}
|
||||
|
||||
function onSubmit(values: EditFormValues) {
|
||||
onSave({
|
||||
providerId: attachment.providerId,
|
||||
niceId: attachment.niceId,
|
||||
name: attachment.name,
|
||||
accessMode: values.accessMode,
|
||||
enabled: attachment.enabled,
|
||||
selectedModelIds:
|
||||
values.accessMode === "select"
|
||||
? values.selectedModels.map((model) =>
|
||||
parseInt(model.id, 10)
|
||||
)
|
||||
: []
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Credenza open={open} onOpenChange={onOpenChange}>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{attachment.name}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("aiResourceProviderEditDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="ai-provider-attachment-edit-form"
|
||||
className="space-y-4"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="accessMode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("aiResourceProviderMode")}
|
||||
</FormLabel>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(value) =>
|
||||
handleAccessModeChange(
|
||||
value as
|
||||
| "inherit"
|
||||
| "select"
|
||||
)
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="inherit">
|
||||
{t(
|
||||
"aiResourceProviderModeInherit"
|
||||
)}
|
||||
</SelectItem>
|
||||
<SelectItem value="select">
|
||||
{t(
|
||||
"aiResourceProviderModeSelect"
|
||||
)}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
{field.value === "inherit"
|
||||
? t(
|
||||
"aiResourceProviderModeInheritHelp"
|
||||
)
|
||||
: t(
|
||||
"aiResourceProviderModeSelectHelp"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{accessMode === "select" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="selectedModels"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"aiResourceProviderAllowModels"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<MultiSelectTagInput
|
||||
buttonText={t(
|
||||
"aiResourceProviderAllowModelsSelect"
|
||||
)}
|
||||
emptyPlaceholder={t(
|
||||
"aiResourceProviderAllowModelsEmpty"
|
||||
)}
|
||||
searchPlaceholder={t(
|
||||
"aiResourceProviderAllowModelsSearch"
|
||||
)}
|
||||
searchQuery={modelSearch}
|
||||
options={allowOptions}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onSearch={setModelSearch}
|
||||
disabled={
|
||||
modelsQuery.isLoading
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"aiResourceProviderAllowModelsHelp"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="mr-auto px-0"
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
href={`/${orgId}/settings/ai-providers/${attachment.niceId}`}
|
||||
>
|
||||
{t("viewProviderSettings")}
|
||||
</Link>
|
||||
</Button>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="submit"
|
||||
form="ai-provider-attachment-edit-form"
|
||||
>
|
||||
{t("done")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "@app/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import {
|
||||
AI_PROVIDER_AUTH_TYPES,
|
||||
type AiProviderAuthType
|
||||
} from "@app/lib/aiProviderDefaults";
|
||||
import { CheckIcon, ChevronsUpDown } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
const authLabelMap = {
|
||||
bearer: "aiProviderAuthTypeBearer",
|
||||
"x-api-key": "aiProviderAuthTypeXApiKey",
|
||||
"x-goog-api-key": "aiProviderAuthTypeXGoogApiKey",
|
||||
hec: "aiProviderAuthTypeHec",
|
||||
"cf-aig-authorization": "aiProviderAuthTypeCfAigAuthorization",
|
||||
none: "aiProviderAuthTypeNone",
|
||||
passthrough: "aiProviderAuthTypePassthrough"
|
||||
} as const;
|
||||
|
||||
const authDescriptionMap = {
|
||||
bearer: "aiProviderAuthTypeBearerDescription",
|
||||
"x-api-key": "aiProviderAuthTypeXApiKeyDescription",
|
||||
"x-goog-api-key": "aiProviderAuthTypeXGoogApiKeyDescription",
|
||||
hec: "aiProviderAuthTypeHecDescription",
|
||||
"cf-aig-authorization": "aiProviderAuthTypeCfAigAuthorizationDescription",
|
||||
none: "aiProviderAuthTypeNoneDescription",
|
||||
passthrough: "aiProviderAuthTypePassthroughDescription"
|
||||
} as const;
|
||||
|
||||
type AiProviderAuthTypeSelectProps = {
|
||||
value: AiProviderAuthType;
|
||||
onChange: (value: AiProviderAuthType) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AiProviderAuthTypeSelect({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
className
|
||||
}: AiProviderAuthTypeSelectProps) {
|
||||
const t = useTranslations();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
AI_PROVIDER_AUTH_TYPES.map((authType) => ({
|
||||
authType,
|
||||
title: t(authLabelMap[authType]),
|
||||
description: t(authDescriptionMap[authType])
|
||||
})),
|
||||
[t]
|
||||
);
|
||||
|
||||
const selected = options.find((option) => option.authType === value);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"w-full justify-between",
|
||||
!selected && "text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-left">
|
||||
{selected?.title ?? t("noneSelected")}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder={t("aiProviderAuthTypeSearch")} />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{t("aiProviderAuthTypeNotFound")}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
key={option.authType}
|
||||
value={`${option.authType} ${option.title} ${option.description}`}
|
||||
onSelect={() => {
|
||||
onChange(option.authType);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4 shrink-0",
|
||||
option.authType === value
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="truncate">
|
||||
{option.title}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs leading-snug">
|
||||
{option.description}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { MultiSelectTagInput } from "@app/components/multi-select/multi-select-tag-input";
|
||||
import { AI_CAPABILITIES, type AiCapability } from "@app/lib/aiCapabilities";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
export type CapabilityOption = {
|
||||
id: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type AiProviderCapabilitiesSelectProps = {
|
||||
value: AiCapability[];
|
||||
onChange: (capabilities: AiCapability[]) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const CAPABILITY_LABEL_KEYS: Record<AiCapability, string> = {
|
||||
openai_chat: "aiCapabilityOpenaiChat",
|
||||
openai_responses: "aiCapabilityOpenaiResponses",
|
||||
anthropic_messages: "aiCapabilityAnthropicMessages",
|
||||
v1_models: "aiCapabilityV1Models",
|
||||
gemini_generate_content: "aiCapabilityGeminiGenerateContent",
|
||||
bedrock_model_invoke: "aiCapabilityBedrockModelInvoke",
|
||||
google_generate_content: "aiCapabilityGoogleGenerateContent",
|
||||
google_raw_predict: "aiCapabilityGoogleRawPredict",
|
||||
bedrock_converse: "aiCapabilityBedrockConverse"
|
||||
};
|
||||
|
||||
export function capabilityLabelKey(capability: AiCapability): string {
|
||||
return CAPABILITY_LABEL_KEYS[capability];
|
||||
}
|
||||
|
||||
export function AiProviderCapabilitiesSelect({
|
||||
value,
|
||||
onChange,
|
||||
disabled
|
||||
}: AiProviderCapabilitiesSelectProps) {
|
||||
const t = useTranslations();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const options: CapabilityOption[] = useMemo(
|
||||
() =>
|
||||
AI_CAPABILITIES.map((id) => ({
|
||||
id,
|
||||
text: t(CAPABILITY_LABEL_KEYS[id])
|
||||
})),
|
||||
[t]
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
if (!q) {
|
||||
return options;
|
||||
}
|
||||
return options.filter(
|
||||
(o) =>
|
||||
o.text.toLowerCase().includes(q) ||
|
||||
o.id.toLowerCase().includes(q)
|
||||
);
|
||||
}, [options, searchQuery]);
|
||||
|
||||
const selected: CapabilityOption[] = value.map((id) => ({
|
||||
id,
|
||||
text: t(CAPABILITY_LABEL_KEYS[id])
|
||||
}));
|
||||
|
||||
return (
|
||||
<MultiSelectTagInput
|
||||
buttonText={t("aiProviderCapabilitiesSelect")}
|
||||
emptyPlaceholder={t("aiProviderCapabilitiesEmpty")}
|
||||
searchPlaceholder={t("aiProviderCapabilitiesSearch")}
|
||||
searchQuery={searchQuery}
|
||||
options={filtered}
|
||||
value={selected}
|
||||
onChange={(next) =>
|
||||
onChange(
|
||||
next
|
||||
.map((item) => item.id)
|
||||
.filter((id): id is AiCapability =>
|
||||
(AI_CAPABILITIES as readonly string[]).includes(id)
|
||||
)
|
||||
)
|
||||
}
|
||||
onSearch={setSearchQuery}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,933 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "@app/components/ui/command";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { isModelKeyPattern } from "@server/lib/aiModelKeyMatch";
|
||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||
import {
|
||||
BudgetRowsFields,
|
||||
getBudgetRowsErrors,
|
||||
rowsFromBudgets,
|
||||
saveBudgetRows,
|
||||
type BudgetRow
|
||||
} from "@app/components/BudgetsEditor";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { aiBudgetQueries } from "@app/lib/queries";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import type { AxiosInstance } from "axios";
|
||||
import { Globe, Plus, XIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
export type ModelListType = "allow" | "block";
|
||||
export type ModelSource = "catalog" | "custom" | "pattern" | "all";
|
||||
|
||||
/** Matches every model key via the provider policy wildcard. */
|
||||
export const ALL_MODELS_KEY = "*";
|
||||
|
||||
const COLLAPSED_ROWS = 5;
|
||||
const GRID_COLUMNS = 2;
|
||||
|
||||
export type AiProviderModelListItem = {
|
||||
clientId: string;
|
||||
modelId?: number;
|
||||
modelKey: string;
|
||||
listType: ModelListType;
|
||||
hasBudget?: boolean;
|
||||
pendingBudgets?: BudgetRow[];
|
||||
};
|
||||
|
||||
export async function persistPendingModelBudgets({
|
||||
api,
|
||||
orgId,
|
||||
modelId,
|
||||
pendingBudgets
|
||||
}: {
|
||||
api: AxiosInstance;
|
||||
orgId: string;
|
||||
modelId: number;
|
||||
pendingBudgets?: BudgetRow[];
|
||||
}): Promise<void> {
|
||||
if (!pendingBudgets || pendingBudgets.length === 0) {
|
||||
return;
|
||||
}
|
||||
await saveBudgetRows({
|
||||
api,
|
||||
orgId,
|
||||
scope: { type: "model", id: modelId },
|
||||
existingBudgets: [],
|
||||
rows: pendingBudgets
|
||||
});
|
||||
}
|
||||
|
||||
export type AiProviderModelListEditorProps = {
|
||||
orgId: string;
|
||||
listType: ModelListType;
|
||||
items: AiProviderModelListItem[];
|
||||
catalogModels: string[];
|
||||
/** Keys already used on this list or the sibling list. */
|
||||
excludeKeys?: ReadonlySet<string>;
|
||||
onChange: (items: AiProviderModelListItem[]) => void;
|
||||
disabled?: boolean;
|
||||
emptyMessage: string;
|
||||
addPlaceholder: string;
|
||||
};
|
||||
|
||||
export function isAllModelsKey(modelKey: string): boolean {
|
||||
return modelKey.trim() === ALL_MODELS_KEY;
|
||||
}
|
||||
|
||||
export function resolveModelSource(
|
||||
modelKey: string,
|
||||
catalogModels: ReadonlySet<string> | readonly string[]
|
||||
): ModelSource {
|
||||
if (isAllModelsKey(modelKey)) {
|
||||
return "all";
|
||||
}
|
||||
if (isModelKeyPattern(modelKey)) {
|
||||
return "pattern";
|
||||
}
|
||||
const set =
|
||||
catalogModels instanceof Set ? catalogModels : new Set(catalogModels);
|
||||
return set.has(modelKey) ? "catalog" : "custom";
|
||||
}
|
||||
|
||||
function newClientId(): string {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `tmp-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
function parseBulkKeys(raw: string): string[] {
|
||||
const seen = new Set<string>();
|
||||
const keys: string[] = [];
|
||||
for (const part of raw.split(/[\n,]+/)) {
|
||||
const key = part.trim();
|
||||
if (!key || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
keys.push(key);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
export function AiProviderModelListEditor({
|
||||
orgId,
|
||||
listType,
|
||||
items,
|
||||
catalogModels,
|
||||
excludeKeys,
|
||||
onChange,
|
||||
disabled,
|
||||
emptyMessage,
|
||||
addPlaceholder
|
||||
}: AiProviderModelListEditorProps) {
|
||||
const t = useTranslations();
|
||||
const [editingClientId, setEditingClientId] = useState<string | null>(null);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [addQuery, setAddQuery] = useState("");
|
||||
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
|
||||
const [listExpanded, setListExpanded] = useState(false);
|
||||
const [clipHeight, setClipHeight] = useState<number | null>(null);
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
const collapsedLimit = GRID_COLUMNS * COLLAPSED_ROWS;
|
||||
const hasOverflow = items.length > collapsedLimit;
|
||||
const isCollapsed = hasOverflow && !listExpanded;
|
||||
|
||||
const catalogSet = useMemo(() => new Set(catalogModels), [catalogModels]);
|
||||
|
||||
const blockedKeys = useMemo(() => {
|
||||
const set = new Set<string>(excludeKeys ? [...excludeKeys] : []);
|
||||
for (const item of items) {
|
||||
set.add(item.modelKey);
|
||||
}
|
||||
return set;
|
||||
}, [excludeKeys, items]);
|
||||
|
||||
const availableCatalog = useMemo(() => {
|
||||
const q = addQuery.trim().toLowerCase();
|
||||
return catalogModels
|
||||
.filter((model) => !blockedKeys.has(model))
|
||||
.filter((model) => (q ? model.toLowerCase().includes(q) : true));
|
||||
}, [addQuery, blockedKeys, catalogModels]);
|
||||
|
||||
const trimmedQuery = addQuery.trim();
|
||||
const bulkKeys = useMemo(
|
||||
() =>
|
||||
parseBulkKeys(addQuery).filter(
|
||||
(key) => !blockedKeys.has(key) && !catalogSet.has(key)
|
||||
),
|
||||
[addQuery, blockedKeys, catalogSet]
|
||||
);
|
||||
const canAddCustom =
|
||||
bulkKeys.length === 1 &&
|
||||
!trimmedQuery.includes("\n") &&
|
||||
!trimmedQuery.includes(",") &&
|
||||
!isAllModelsKey(trimmedQuery) &&
|
||||
!catalogSet.has(trimmedQuery) &&
|
||||
!blockedKeys.has(trimmedQuery);
|
||||
const canAddBulkCustom = bulkKeys.length > 1;
|
||||
|
||||
const allModelsLabel = t("aiProviderModelsAllLabel");
|
||||
const showAllModelsOption =
|
||||
!blockedKeys.has(ALL_MODELS_KEY) &&
|
||||
(!trimmedQuery ||
|
||||
trimmedQuery === ALL_MODELS_KEY ||
|
||||
"all".includes(trimmedQuery.toLowerCase()) ||
|
||||
allModelsLabel.toLowerCase().includes(trimmedQuery.toLowerCase()));
|
||||
|
||||
const editing = items.find((item) => item.clientId === editingClientId);
|
||||
|
||||
function appendModels(modelKeys: string[]) {
|
||||
if (disabled) return;
|
||||
const nextBlocked = new Set(blockedKeys);
|
||||
const additions: AiProviderModelListItem[] = [];
|
||||
for (const raw of modelKeys) {
|
||||
const key = raw.trim();
|
||||
if (!key || nextBlocked.has(key)) continue;
|
||||
nextBlocked.add(key);
|
||||
additions.push({
|
||||
clientId: newClientId(),
|
||||
modelKey: key,
|
||||
listType,
|
||||
hasBudget: false
|
||||
});
|
||||
}
|
||||
if (additions.length === 0) return;
|
||||
onChange([...items, ...additions]);
|
||||
}
|
||||
|
||||
function addModel(modelKey: string, options?: { keepOpen?: boolean }) {
|
||||
appendModels([modelKey]);
|
||||
setAddQuery("");
|
||||
setSelectedKeys(new Set());
|
||||
if (!options?.keepOpen) {
|
||||
setAddOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
function addSelected() {
|
||||
appendModels([...selectedKeys]);
|
||||
setSelectedKeys(new Set());
|
||||
setAddQuery("");
|
||||
// Keep open so more can be selected after filter refresh
|
||||
}
|
||||
|
||||
function addBulkCustom() {
|
||||
appendModels(bulkKeys);
|
||||
setAddQuery("");
|
||||
setSelectedKeys(new Set());
|
||||
}
|
||||
|
||||
function toggleSelected(model: string) {
|
||||
setSelectedKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(model)) {
|
||||
next.delete(model);
|
||||
} else {
|
||||
next.add(model);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function selectAllVisible() {
|
||||
setSelectedKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const model of availableCatalog) {
|
||||
next.add(model);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function clearSelected() {
|
||||
setSelectedKeys(new Set());
|
||||
}
|
||||
|
||||
function removeModel(clientId: string) {
|
||||
onChange(items.filter((item) => item.clientId !== clientId));
|
||||
}
|
||||
|
||||
function updateModel(updated: AiProviderModelListItem) {
|
||||
onChange(
|
||||
items.map((item) =>
|
||||
item.clientId === updated.clientId ? updated : item
|
||||
)
|
||||
);
|
||||
setEditingClientId(null);
|
||||
}
|
||||
|
||||
// Drop selections that are no longer available (already added).
|
||||
useEffect(() => {
|
||||
setSelectedKeys((prev) => {
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
for (const key of prev) {
|
||||
if (blockedKeys.has(key)) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
next.add(key);
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [blockedKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasOverflow) {
|
||||
setListExpanded(false);
|
||||
}
|
||||
}, [hasOverflow]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isCollapsed || !gridRef.current) {
|
||||
setClipHeight(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const children = Array.from(gridRef.current.children) as HTMLElement[];
|
||||
const lastVisible = children[collapsedLimit - 1];
|
||||
if (!lastVisible) {
|
||||
setClipHeight(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const gridTop = gridRef.current.getBoundingClientRect().top;
|
||||
const cardBottom = lastVisible.getBoundingClientRect().bottom;
|
||||
// Peek slightly into the next row so the fade has content to soften.
|
||||
setClipHeight(cardBottom - gridTop + 12);
|
||||
}, [isCollapsed, collapsedLimit, items]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{emptyMessage}</p>
|
||||
) : (
|
||||
<div>
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={gridRef}
|
||||
className={cn(
|
||||
"grid grid-cols-2 gap-2",
|
||||
isCollapsed && "overflow-hidden"
|
||||
)}
|
||||
style={
|
||||
isCollapsed && clipHeight != null
|
||||
? { maxHeight: clipHeight }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<ModelCard
|
||||
key={item.clientId}
|
||||
item={item}
|
||||
source={resolveModelSource(
|
||||
item.modelKey,
|
||||
catalogSet
|
||||
)}
|
||||
disabled={disabled}
|
||||
onEdit={() =>
|
||||
setEditingClientId(item.clientId)
|
||||
}
|
||||
onRemove={() => removeModel(item.clientId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{isCollapsed ? (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-14 bg-gradient-to-t from-card from-25% via-card/80 to-transparent" />
|
||||
) : null}
|
||||
</div>
|
||||
{isCollapsed ? (
|
||||
<div className="relative z-10 flex justify-center pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="text"
|
||||
size="sm"
|
||||
className="bg-card px-2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setListExpanded(true)}
|
||||
>
|
||||
{t("aiProviderModelsViewMore", {
|
||||
count: items.length - collapsedLimit
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{hasOverflow && listExpanded ? (
|
||||
<div className="flex justify-center pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="text"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setListExpanded(false)}
|
||||
>
|
||||
{t("aiProviderModelsViewLess")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Popover
|
||||
open={addOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (disabled) return;
|
||||
setAddOpen(open);
|
||||
if (!open) {
|
||||
setAddQuery("");
|
||||
setSelectedKeys(new Set());
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
disabled={disabled}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
{t("aiProviderModelsAdd")}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
collisionPadding={8}
|
||||
className="flex w-[min(100vw-2rem,24rem)] max-h-[min(24rem,var(--radix-popover-content-available-height))] flex-col overflow-hidden p-0"
|
||||
>
|
||||
<Command
|
||||
shouldFilter={false}
|
||||
className="flex min-h-0 flex-1 flex-col overflow-hidden"
|
||||
>
|
||||
<CommandInput
|
||||
placeholder={addPlaceholder}
|
||||
value={addQuery}
|
||||
onValueChange={setAddQuery}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Enter") return;
|
||||
if (canAddBulkCustom) {
|
||||
e.preventDefault();
|
||||
addBulkCustom();
|
||||
return;
|
||||
}
|
||||
if (canAddCustom) {
|
||||
e.preventDefault();
|
||||
addModel(trimmedQuery, {
|
||||
keepOpen: true
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex shrink-0 items-center justify-between gap-2 border-b px-3 py-1.5">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("aiProviderModelsBulkHint")}
|
||||
</p>
|
||||
{availableCatalog.length > 0 ? (
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="text"
|
||||
size="sm"
|
||||
className="h-auto px-1 text-xs"
|
||||
onClick={selectAllVisible}
|
||||
>
|
||||
{t("aiProviderModelsSelectAll")}
|
||||
</Button>
|
||||
{selectedKeys.size > 0 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="text"
|
||||
size="sm"
|
||||
className="h-auto px-1 text-xs"
|
||||
onClick={clearSelected}
|
||||
>
|
||||
{t(
|
||||
"aiProviderModelsClearSelected"
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<CommandList className="max-h-none min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||
<CommandEmpty>
|
||||
{canAddBulkCustom
|
||||
? t("aiProviderModelsAddBulkHint", {
|
||||
count: bulkKeys.length
|
||||
})
|
||||
: canAddCustom
|
||||
? t("aiProviderModelsAddCustomHint")
|
||||
: t("aiProviderModelsCatalogEmpty")}
|
||||
</CommandEmpty>
|
||||
{showAllModelsOption ? (
|
||||
<CommandGroup className="overflow-visible">
|
||||
<CommandItem
|
||||
value={`all:${ALL_MODELS_KEY}`}
|
||||
onSelect={() =>
|
||||
addModel(ALL_MODELS_KEY, {
|
||||
keepOpen: true
|
||||
})
|
||||
}
|
||||
className="items-start gap-2 py-2"
|
||||
>
|
||||
<Globe className="mt-0.5 size-4 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">
|
||||
{listType === "allow"
|
||||
? t(
|
||||
"aiProviderModelsAddAllAllow"
|
||||
)
|
||||
: t(
|
||||
"aiProviderModelsAddAllBlock"
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"aiProviderModelsAddAllDescription"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
) : null}
|
||||
{canAddBulkCustom ? (
|
||||
<CommandGroup className="overflow-visible">
|
||||
<CommandItem
|
||||
value={`bulk:${bulkKeys.join(",")}`}
|
||||
onSelect={addBulkCustom}
|
||||
>
|
||||
<Plus className="mr-2 size-4" />
|
||||
{t("aiProviderModelsAddBulk", {
|
||||
count: bulkKeys.length
|
||||
})}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
) : null}
|
||||
{canAddCustom ? (
|
||||
<CommandGroup className="overflow-visible">
|
||||
<CommandItem
|
||||
value={`custom:${trimmedQuery}`}
|
||||
onSelect={() =>
|
||||
addModel(trimmedQuery, {
|
||||
keepOpen: true
|
||||
})
|
||||
}
|
||||
>
|
||||
<Plus className="mr-2 size-4" />
|
||||
{t("aiProviderModelsAddCustom", {
|
||||
key: trimmedQuery
|
||||
})}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
) : null}
|
||||
{availableCatalog.length > 0 ? (
|
||||
<CommandGroup
|
||||
heading={t(
|
||||
"aiProviderModelsCatalogHeading"
|
||||
)}
|
||||
className="overflow-visible"
|
||||
>
|
||||
{availableCatalog.map((model) => {
|
||||
const isSelected =
|
||||
selectedKeys.has(model);
|
||||
return (
|
||||
<CommandItem
|
||||
key={model}
|
||||
value={model}
|
||||
onSelect={() => {
|
||||
// Toggle selection for bulk;
|
||||
// double-purpose: shift-free multi-pick.
|
||||
toggleSelected(model);
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
className="pointer-events-none"
|
||||
tabIndex={-1}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs">
|
||||
{model}
|
||||
</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
) : null}
|
||||
</CommandList>
|
||||
{selectedKeys.size > 0 ? (
|
||||
<div className="flex shrink-0 items-center justify-between gap-2 border-t p-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("aiProviderModelsSelectedCount", {
|
||||
count: selectedKeys.size
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={addSelected}
|
||||
>
|
||||
{t("aiProviderModelsAddSelected")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{items.length > 0 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange([])}
|
||||
>
|
||||
{t("aiProviderModelsClearAll")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<EditModelCredenza
|
||||
orgId={orgId}
|
||||
item={editing}
|
||||
open={editingClientId !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditingClientId(null);
|
||||
}}
|
||||
existingKeys={blockedKeys}
|
||||
onSave={updateModel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelCard({
|
||||
item,
|
||||
source,
|
||||
disabled,
|
||||
onEdit,
|
||||
onRemove
|
||||
}: {
|
||||
item: AiProviderModelListItem;
|
||||
source: ModelSource;
|
||||
disabled?: boolean;
|
||||
onEdit: () => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 items-center gap-2 rounded-md border border-input px-2.5 py-2",
|
||||
disabled && "opacity-60",
|
||||
!disabled && "cursor-pointer hover:bg-muted/50"
|
||||
)}
|
||||
onClick={disabled ? undefined : onEdit}
|
||||
onKeyDown={
|
||||
disabled
|
||||
? undefined
|
||||
: (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onEdit();
|
||||
}
|
||||
}
|
||||
}
|
||||
role={disabled ? undefined : "button"}
|
||||
tabIndex={disabled ? undefined : 0}
|
||||
title={t("aiProviderModelsEditHint")}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
{source === "all" ? (
|
||||
<span className="block truncate text-xs font-medium">
|
||||
{t("aiProviderModelsAllLabel")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="block truncate font-mono text-xs font-medium">
|
||||
{item.modelKey}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 p-0.5 text-muted-foreground hover:text-foreground cursor-pointer disabled:opacity-50"
|
||||
disabled={disabled}
|
||||
aria-label={t("aiProviderModelsRemove")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type EditFormValues = {
|
||||
modelKey: string;
|
||||
};
|
||||
|
||||
function EditModelCredenza({
|
||||
orgId,
|
||||
item,
|
||||
open,
|
||||
onOpenChange,
|
||||
existingKeys,
|
||||
onSave
|
||||
}: {
|
||||
orgId: string;
|
||||
item: AiProviderModelListItem;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
existingKeys: ReadonlySet<string>;
|
||||
onSave: (item: AiProviderModelListItem) => void;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const editSchema = useMemo(
|
||||
() =>
|
||||
z.object({
|
||||
modelKey: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, t("aiProviderModelsKeyRequired"))
|
||||
.refine(
|
||||
(key) =>
|
||||
key === item.modelKey || !existingKeys.has(key),
|
||||
t("aiProviderModelsKeyDuplicate")
|
||||
)
|
||||
}),
|
||||
[existingKeys, item.modelKey, t]
|
||||
);
|
||||
|
||||
const form = useForm<EditFormValues>({
|
||||
resolver: zodResolver(editSchema),
|
||||
defaultValues: { modelKey: item.modelKey }
|
||||
});
|
||||
|
||||
const [pendingBudgetRows, setPendingBudgetRows] = useState<BudgetRow[]>([]);
|
||||
const [attemptedBudgetsSave, setAttemptedBudgetsSave] = useState(false);
|
||||
const [savingBudgets, setSavingBudgets] = useState(false);
|
||||
|
||||
const budgetScope =
|
||||
item.modelId !== undefined
|
||||
? { type: "model" as const, id: item.modelId }
|
||||
: null;
|
||||
|
||||
const budgetsQuery = useQuery({
|
||||
...aiBudgetQueries.scoped({
|
||||
scope: budgetScope ?? { type: "model", id: -1 }
|
||||
}),
|
||||
enabled: open && budgetScope !== null
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
form.reset({ modelKey: item.modelKey });
|
||||
setAttemptedBudgetsSave(false);
|
||||
if (item.modelId === undefined) {
|
||||
setPendingBudgetRows(item.pendingBudgets ?? []);
|
||||
} else {
|
||||
setPendingBudgetRows([]);
|
||||
}
|
||||
}, [
|
||||
form,
|
||||
item.clientId,
|
||||
item.modelId,
|
||||
item.modelKey,
|
||||
item.pendingBudgets,
|
||||
open
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !budgetsQuery.data) return;
|
||||
setPendingBudgetRows(rowsFromBudgets(budgetsQuery.data));
|
||||
}, [open, budgetsQuery.data]);
|
||||
|
||||
async function handleSubmit(values: EditFormValues) {
|
||||
const { conflictingKeys, invalidAmountKeys } =
|
||||
getBudgetRowsErrors(pendingBudgetRows);
|
||||
if (conflictingKeys.size > 0 || invalidAmountKeys.size > 0) {
|
||||
setAttemptedBudgetsSave(true);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("aiBudgetErrorSave"),
|
||||
description: conflictingKeys.size
|
||||
? t("aiBudgetConflictError")
|
||||
: t("aiBudgetInvalidAmountError")
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (budgetScope) {
|
||||
setSavingBudgets(true);
|
||||
try {
|
||||
const existingBudgets = await queryClient.fetchQuery(
|
||||
aiBudgetQueries.scoped({ scope: budgetScope })
|
||||
);
|
||||
await saveBudgetRows({
|
||||
api,
|
||||
orgId,
|
||||
scope: budgetScope,
|
||||
existingBudgets,
|
||||
rows: pendingBudgetRows
|
||||
});
|
||||
await queryClient.invalidateQueries(
|
||||
aiBudgetQueries.scoped({ scope: budgetScope })
|
||||
);
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("aiBudgetErrorSave"),
|
||||
description: formatAxiosError(e, t("aiBudgetErrorSave"))
|
||||
});
|
||||
setSavingBudgets(false);
|
||||
return;
|
||||
}
|
||||
setSavingBudgets(false);
|
||||
}
|
||||
|
||||
onSave({
|
||||
...item,
|
||||
modelKey: values.modelKey.trim(),
|
||||
pendingBudgets: pendingBudgetRows,
|
||||
hasBudget: pendingBudgetRows.length > 0
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Credenza open={open} onOpenChange={onOpenChange}>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{t("aiProviderModelsEditTitle")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("aiProviderModelsEditDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="ai-provider-model-edit-form"
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
>
|
||||
<CredenzaBody>
|
||||
<HorizontalTabs
|
||||
clientSide={true}
|
||||
defaultTab={0}
|
||||
items={[
|
||||
{ title: t("general"), href: "#" },
|
||||
{
|
||||
title: t("aiProviderModelsBudgetTab"),
|
||||
href: "#"
|
||||
}
|
||||
]}
|
||||
>
|
||||
<div className="space-y-4 mt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="modelKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"aiProviderModelsKeyLabel"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="font-mono"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4 mt-4">
|
||||
<BudgetRowsFields
|
||||
rows={pendingBudgetRows}
|
||||
onChange={setPendingBudgetRows}
|
||||
disabled={
|
||||
budgetScope !== null &&
|
||||
budgetsQuery.isLoading
|
||||
}
|
||||
attemptedSave={attemptedBudgetsSave}
|
||||
/>
|
||||
</div>
|
||||
</HorizontalTabs>
|
||||
</CredenzaBody>
|
||||
</form>
|
||||
</Form>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button type="button" variant="outline">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="submit"
|
||||
form="ai-provider-model-edit-form"
|
||||
loading={savingBudgets}
|
||||
disabled={savingBudgets}
|
||||
>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AiProviderModelListEditor,
|
||||
type AiProviderModelListItem
|
||||
} from "@app/components/AiProviderModelListEditor";
|
||||
import { Label } from "@app/components/ui/label";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export type AiProviderModelsListsProps = {
|
||||
orgId: string;
|
||||
allowItems: AiProviderModelListItem[];
|
||||
onAllowChange: (items: AiProviderModelListItem[]) => void;
|
||||
blockItems: AiProviderModelListItem[];
|
||||
onBlockChange: (items: AiProviderModelListItem[]) => void;
|
||||
catalogModels: string[];
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function AiProviderModelsLists({
|
||||
orgId,
|
||||
allowItems,
|
||||
onAllowChange,
|
||||
blockItems,
|
||||
onBlockChange,
|
||||
catalogModels,
|
||||
disabled
|
||||
}: AiProviderModelsListsProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
const allowExcludeKeys = useMemo(
|
||||
() => new Set(blockItems.map((item) => item.modelKey)),
|
||||
[blockItems]
|
||||
);
|
||||
const blockExcludeKeys = useMemo(
|
||||
() => new Set(allowItems.map((item) => item.modelKey)),
|
||||
[allowItems]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("aiProviderModelsAllow")}</Label>
|
||||
<AiProviderModelListEditor
|
||||
orgId={orgId}
|
||||
listType="allow"
|
||||
items={allowItems}
|
||||
onChange={onAllowChange}
|
||||
catalogModels={catalogModels}
|
||||
excludeKeys={allowExcludeKeys}
|
||||
disabled={disabled}
|
||||
emptyMessage={t("aiProviderModelsAllowEmpty")}
|
||||
addPlaceholder={t("aiProviderModelsAllowPlaceholder")}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("aiProviderModelsAllowDescription")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("aiProviderModelsBlock")}</Label>
|
||||
<AiProviderModelListEditor
|
||||
orgId={orgId}
|
||||
listType="block"
|
||||
items={blockItems}
|
||||
onChange={onBlockChange}
|
||||
catalogModels={catalogModels}
|
||||
excludeKeys={blockExcludeKeys}
|
||||
disabled={disabled}
|
||||
emptyMessage={t("aiProviderModelsBlockEmpty")}
|
||||
addPlaceholder={t("aiProviderModelsBlockPlaceholder")}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("aiProviderModelsBlockDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "@app/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { aiProviderTypeValues } from "@app/lib/aiProviderFormSchema";
|
||||
import type { AiProviderType } from "@app/lib/aiProviderDefaults";
|
||||
import { CheckIcon, ChevronsUpDown } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
export const aiProviderTypeLabelMap = {
|
||||
openai: "aiProviderTypeOpenai",
|
||||
anthropic: "aiProviderTypeAnthropic",
|
||||
googleGemini: "aiProviderTypeGoogleGemini",
|
||||
vertexAi: "aiProviderTypeVertexAi",
|
||||
bedrock: "aiProviderTypeBedrock",
|
||||
microsoftFoundry: "aiProviderTypeMicrosoftFoundry",
|
||||
openRouter: "aiProviderTypeOpenRouter",
|
||||
vercelAiGateway: "aiProviderTypeVercelAiGateway",
|
||||
custom: "aiProviderTypeCustom"
|
||||
} as const;
|
||||
|
||||
const typeDescriptionMap = {
|
||||
openai: "aiProviderTypeOpenaiDescription",
|
||||
anthropic: "aiProviderTypeAnthropicDescription",
|
||||
googleGemini: "aiProviderTypeGoogleGeminiDescription",
|
||||
vertexAi: "aiProviderTypeVertexAiDescription",
|
||||
bedrock: "aiProviderTypeBedrockDescription",
|
||||
microsoftFoundry: "aiProviderTypeMicrosoftFoundryDescription",
|
||||
openRouter: "aiProviderTypeOpenRouterDescription",
|
||||
vercelAiGateway: "aiProviderTypeVercelAiGatewayDescription",
|
||||
custom: "aiProviderTypeCustomDescription"
|
||||
} as const;
|
||||
|
||||
type AiProviderTypeSelectProps = {
|
||||
value: AiProviderType;
|
||||
onChange: (value: AiProviderType) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AiProviderTypeSelect({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
className
|
||||
}: AiProviderTypeSelectProps) {
|
||||
const t = useTranslations();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
aiProviderTypeValues.map((type) => ({
|
||||
type,
|
||||
title: t(aiProviderTypeLabelMap[type]),
|
||||
description: t(typeDescriptionMap[type])
|
||||
})),
|
||||
[t]
|
||||
);
|
||||
|
||||
const selected = options.find((option) => option.type === value);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"w-full justify-between",
|
||||
!selected && "text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-left">
|
||||
{selected?.title ?? t("noneSelected")}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder={t("aiProviderTypeSearch")} />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{t("aiProviderTypeNotFound")}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
key={option.type}
|
||||
value={`${option.type} ${option.title} ${option.description}`}
|
||||
onSelect={() => {
|
||||
onChange(option.type);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4 shrink-0",
|
||||
option.type === value
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="truncate">
|
||||
{option.title}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs leading-snug">
|
||||
{option.description}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import DismissableBanner from "./DismissableBanner";
|
||||
|
||||
export const AiProvidersBanner = () => {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DismissableBanner
|
||||
storageKey="ai-providers-banner-dismissed"
|
||||
version={1}
|
||||
title={t("aiProvidersBannerTitle")}
|
||||
titleIcon={<Sparkles className="w-5 h-5 text-primary" />}
|
||||
description={t("aiProvidersBannerDescription")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default AiProvidersBanner;
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { aiProviderQueries } from "@app/lib/queries";
|
||||
import { MultiSelectTagInput } from "@app/components/multi-select/multi-select-tag-input";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { useDebounce } from "use-debounce";
|
||||
|
||||
export type SelectedAiProvider = {
|
||||
id: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type AiProvidersSelectorProps = {
|
||||
orgId: string;
|
||||
selectedProviders?: SelectedAiProvider[];
|
||||
onSelectProviders: (providers: SelectedAiProvider[]) => void;
|
||||
disabled?: boolean;
|
||||
buttonText?: string;
|
||||
};
|
||||
|
||||
export function AiProvidersSelector({
|
||||
orgId,
|
||||
selectedProviders = [],
|
||||
onSelectProviders,
|
||||
disabled,
|
||||
buttonText
|
||||
}: AiProvidersSelectorProps) {
|
||||
const t = useTranslations();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [debouncedValue] = useDebounce(searchQuery, 150);
|
||||
|
||||
const { data: providers = [] } = useQuery(
|
||||
aiProviderQueries.orgProviders({
|
||||
orgId,
|
||||
query: debouncedValue || undefined
|
||||
})
|
||||
);
|
||||
|
||||
const options: SelectedAiProvider[] = providers
|
||||
.filter((provider) => provider.enabled)
|
||||
.map((provider) => ({
|
||||
id: String(provider.providerId),
|
||||
text: provider.name
|
||||
}));
|
||||
|
||||
return (
|
||||
<MultiSelectTagInput
|
||||
buttonText={buttonText ?? t("aiResourceProvidersSelect")}
|
||||
emptyPlaceholder={t("aiResourceProvidersEmpty")}
|
||||
searchPlaceholder={t("aiProvidersSearch")}
|
||||
searchQuery={searchQuery}
|
||||
options={options}
|
||||
value={selectedProviders}
|
||||
onChange={onSelectProviders}
|
||||
onSearch={setSearchQuery}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
"use client";
|
||||
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import {
|
||||
ControlledDataTable,
|
||||
type ExtendedColumnDef
|
||||
} from "@app/components/ui/controlled-data-table";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
import { ArrowRight, MoreHorizontal } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
|
||||
export type AiProviderRow = {
|
||||
providerId: number;
|
||||
niceId: string;
|
||||
name: string;
|
||||
type: string;
|
||||
routingMode: string;
|
||||
enabled: boolean;
|
||||
effectiveUpstreamUrl: string | null;
|
||||
apiKeyLastChars: string | null;
|
||||
};
|
||||
|
||||
type AiProvidersTableProps = {
|
||||
providers: AiProviderRow[];
|
||||
orgId: string;
|
||||
pagination: PaginationState;
|
||||
rowCount: number;
|
||||
};
|
||||
|
||||
export default function AiProvidersTable({
|
||||
providers,
|
||||
orgId,
|
||||
pagination,
|
||||
rowCount
|
||||
}: AiProvidersTableProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const {
|
||||
navigate: filter,
|
||||
isNavigating: isFiltering,
|
||||
searchParams
|
||||
} = useNavigationContext();
|
||||
|
||||
const [rows, setRows] = useState(providers);
|
||||
const [selected, setSelected] = useState<AiProviderRow | null>(null);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isRefreshing, startTransition] = useTransition();
|
||||
|
||||
useEffect(() => {
|
||||
setRows(providers);
|
||||
}, [providers]);
|
||||
|
||||
function refreshData() {
|
||||
startTransition(() => {
|
||||
try {
|
||||
router.refresh();
|
||||
} catch {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("refreshError"),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const handlePaginationChange = (newPage: PaginationState) => {
|
||||
searchParams.set("page", (newPage.pageIndex + 1).toString());
|
||||
searchParams.set("pageSize", newPage.pageSize.toString());
|
||||
filter({ searchParams });
|
||||
};
|
||||
|
||||
const handleSearchChange = useDebouncedCallback((query: string) => {
|
||||
searchParams.set("query", query);
|
||||
searchParams.delete("page");
|
||||
filter({ searchParams });
|
||||
}, 300);
|
||||
|
||||
function typeLabel(type: string) {
|
||||
const key = `aiProviderType${type.charAt(0).toUpperCase()}${type.slice(1)}`;
|
||||
const map: Record<string, string> = {
|
||||
openai: "aiProviderTypeOpenai",
|
||||
anthropic: "aiProviderTypeAnthropic",
|
||||
googleGemini: "aiProviderTypeGoogleGemini",
|
||||
vertexAi: "aiProviderTypeVertexAi",
|
||||
bedrock: "aiProviderTypeBedrock",
|
||||
microsoftFoundry: "aiProviderTypeMicrosoftFoundry",
|
||||
openRouter: "aiProviderTypeOpenRouter",
|
||||
vercelAiGateway: "aiProviderTypeVercelAiGateway",
|
||||
custom: "aiProviderTypeCustom"
|
||||
};
|
||||
return t(map[type] ?? key);
|
||||
}
|
||||
|
||||
function routingLabel(mode: string) {
|
||||
return mode === "target"
|
||||
? t("aiProviderRoutingModeTarget")
|
||||
: t("aiProviderRoutingModeUrl");
|
||||
}
|
||||
|
||||
async function toggleEnabled(row: AiProviderRow, enabled: boolean) {
|
||||
setRows((prev) =>
|
||||
prev.map((r) =>
|
||||
r.providerId === row.providerId ? { ...r, enabled } : r
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
await api.post(`/ai-provider/${row.providerId}`, { enabled });
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("aiProviderUpdated")
|
||||
});
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
setRows((prev) =>
|
||||
prev.map((r) =>
|
||||
r.providerId === row.providerId
|
||||
? { ...r, enabled: row.enabled }
|
||||
: r
|
||||
)
|
||||
);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("aiProviderErrorUpdate"),
|
||||
description: formatAxiosError(e, t("aiProviderErrorUpdate"))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function deleteProvider(row: AiProviderRow) {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await api.delete(`/ai-provider/${row.providerId}`);
|
||||
setRows((prev) =>
|
||||
prev.filter((r) => r.providerId !== row.providerId)
|
||||
);
|
||||
setIsDeleteModalOpen(false);
|
||||
setSelected(null);
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("aiProviderDeleted")
|
||||
});
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("aiProviderErrorDelete"),
|
||||
description: formatAxiosError(e, t("aiProviderErrorDelete"))
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const columns = useMemo<ExtendedColumnDef<AiProviderRow>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "name",
|
||||
enableHiding: false,
|
||||
header: () => <span className="p-3">{t("name")}</span>,
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
href={`/${orgId}/settings/ai-providers/${row.original.niceId}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{row.original.name}
|
||||
</Link>
|
||||
)
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: () => (
|
||||
<span className="p-3">{t("aiProviderType")}</span>
|
||||
),
|
||||
cell: ({ row }) => typeLabel(row.original.type)
|
||||
},
|
||||
{
|
||||
accessorKey: "routingMode",
|
||||
header: () => (
|
||||
<span className="p-3">{t("aiProviderRoutingMode")}</span>
|
||||
),
|
||||
cell: ({ row }) => routingLabel(row.original.routingMode)
|
||||
},
|
||||
{
|
||||
accessorKey: "effectiveUpstreamUrl",
|
||||
header: () => (
|
||||
<span className="p-3">{t("aiProviderUpstreamUrl")}</span>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span>{row.original.effectiveUpstreamUrl ?? "-"}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
accessorKey: "enabled",
|
||||
header: () => (
|
||||
<span className="p-3">{t("aiProviderEnabled")}</span>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Switch
|
||||
checked={row.original.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleEnabled(row.original, checked)
|
||||
}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
header: () => <span className="p-3" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">
|
||||
{t("openMenu")}
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={`/${orgId}/settings/ai-providers/${row.original.niceId}`}
|
||||
>
|
||||
{t("edit")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelected(row.original);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
{t("delete")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Link
|
||||
href={`/${orgId}/settings/ai-providers/${row.original.niceId}`}
|
||||
>
|
||||
<Button variant="outline">
|
||||
{t("edit")}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
],
|
||||
[orgId, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{selected && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
setOpen={(val) => {
|
||||
setIsDeleteModalOpen(val);
|
||||
if (!val) {
|
||||
setSelected(null);
|
||||
}
|
||||
}}
|
||||
dialog={
|
||||
<div className="space-y-2">
|
||||
<p>{t("aiProviderQuestionRemove")}</p>
|
||||
<p>{t("aiProviderMessageRemove")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("aiProviderDeleteConfirm")}
|
||||
onConfirm={async () => deleteProvider(selected)}
|
||||
string={selected.name}
|
||||
title={t("aiProviderDelete")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ControlledDataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
addButtonText={t("aiProvidersAdd")}
|
||||
onAdd={() =>
|
||||
router.push(`/${orgId}/settings/ai-providers/create`)
|
||||
}
|
||||
tableId="ai-providers-table"
|
||||
searchPlaceholder={t("aiProvidersSearch")}
|
||||
pagination={pagination}
|
||||
onPaginationChange={handlePaginationChange}
|
||||
searchQuery={searchParams.get("query")?.toString()}
|
||||
onSearch={handleSearchChange}
|
||||
onRefresh={refreshData}
|
||||
isRefreshing={isRefreshing || isFiltering}
|
||||
rowCount={rowCount}
|
||||
stickyRightColumn="actions"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bot,
|
||||
Code,
|
||||
MessagesSquare,
|
||||
Terminal,
|
||||
User as UserIcon,
|
||||
Wrench
|
||||
} from "lucide-react";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import type { NormalizedAiMessage } from "@server/lib/aiMessageNormalization";
|
||||
|
||||
type AiSessionChatViewProps = {
|
||||
normalizedRequest: string | null;
|
||||
normalizedResponse: string | null;
|
||||
requestBody: string | null;
|
||||
responseBody: string | null;
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
function parseMessages(json: string | null): NormalizedAiMessage[] | null {
|
||||
if (!json) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(json);
|
||||
return Array.isArray(parsed) ? (parsed as NormalizedAiMessage[]) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function prettyRaw(raw: string | null): string | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(raw), null, 2);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function MessageBubble({ message }: { message: NormalizedAiMessage }) {
|
||||
const isUser = message.role === "user";
|
||||
const isSystem = message.role === "system";
|
||||
const isTool = message.role === "tool";
|
||||
|
||||
if (isSystem) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 rounded-md border border-dashed bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
|
||||
<Terminal className="h-3.5 w-3.5 mt-0.5 flex-none" />
|
||||
<pre className="whitespace-pre-wrap break-words font-sans">
|
||||
{message.content}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-start gap-2 ${isUser ? "flex-row-reverse" : ""}`}
|
||||
>
|
||||
<div
|
||||
className={`flex h-7 w-7 flex-none items-center justify-center rounded-full ${
|
||||
isUser
|
||||
? "bg-primary text-primary-foreground"
|
||||
: isTool
|
||||
? "bg-amber-100 dark:bg-amber-900/40"
|
||||
: "bg-muted"
|
||||
}`}
|
||||
>
|
||||
{isUser ? (
|
||||
<UserIcon className="h-4 w-4" />
|
||||
) : isTool ? (
|
||||
<Wrench className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Bot className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`max-w-[80%] rounded-lg px-3 py-2 text-sm whitespace-pre-wrap break-words ${
|
||||
isUser
|
||||
? "bg-primary text-primary-foreground"
|
||||
: isTool
|
||||
? "bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-900 font-mono text-xs"
|
||||
: "bg-muted"
|
||||
}`}
|
||||
>
|
||||
{message.content || (
|
||||
<span className="italic opacity-60"> </span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RawFallbackBlock({
|
||||
label,
|
||||
raw,
|
||||
noDataLabel,
|
||||
unparsedLabel
|
||||
}: {
|
||||
label: string;
|
||||
raw: string | null;
|
||||
noDataLabel: string;
|
||||
unparsedLabel?: string;
|
||||
}) {
|
||||
const pretty = prettyRaw(raw);
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/30 p-3">
|
||||
<div className="mb-1 text-xs font-medium text-muted-foreground">
|
||||
{label}
|
||||
{pretty && unparsedLabel && (
|
||||
<span className="ml-2 font-normal italic opacity-70">
|
||||
{unparsedLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words text-xs text-muted-foreground">
|
||||
{pretty ?? noDataLabel}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AiSessionChatView({
|
||||
normalizedRequest,
|
||||
normalizedResponse,
|
||||
requestBody,
|
||||
responseBody,
|
||||
truncated
|
||||
}: AiSessionChatViewProps) {
|
||||
const t = useTranslations();
|
||||
const [rawMode, setRawMode] = useState(false);
|
||||
|
||||
const requestMessages = useMemo(
|
||||
() => parseMessages(normalizedRequest),
|
||||
[normalizedRequest]
|
||||
);
|
||||
const responseMessages = useMemo(
|
||||
() => parseMessages(normalizedResponse),
|
||||
[normalizedResponse]
|
||||
);
|
||||
|
||||
const hasRequestMessages = !!requestMessages && requestMessages.length > 0;
|
||||
const hasResponseMessages =
|
||||
!!responseMessages && responseMessages.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{truncated ? (
|
||||
<div className="flex items-center gap-2 text-xs text-amber-600 dark:text-amber-500">
|
||||
<AlertTriangle className="h-3.5 w-3.5 flex-none" />
|
||||
{t("aiSessionLogTruncated")}
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setRawMode((prev) => !prev)}
|
||||
>
|
||||
{rawMode ? (
|
||||
<MessagesSquare className="mr-2 h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Code className="mr-2 h-3.5 w-3.5" />
|
||||
)}
|
||||
{rawMode ? t("aiSessionViewChat") : t("aiSessionViewRaw")}
|
||||
</Button>
|
||||
</div>
|
||||
{rawMode ? (
|
||||
<div className="flex max-h-[32rem] flex-col gap-3 overflow-y-auto rounded-md border bg-background p-4">
|
||||
<RawFallbackBlock
|
||||
label={t("aiSessionRequest")}
|
||||
raw={normalizedRequest}
|
||||
noDataLabel={t("aiSessionNoData")}
|
||||
/>
|
||||
<RawFallbackBlock
|
||||
label={t("aiSessionResponse")}
|
||||
raw={normalizedResponse}
|
||||
noDataLabel={t("aiSessionNoData")}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex max-h-[32rem] flex-col gap-3 overflow-y-auto rounded-md border bg-background p-4">
|
||||
{hasRequestMessages ? (
|
||||
requestMessages!.map((message, i) => (
|
||||
<MessageBubble key={`req-${i}`} message={message} />
|
||||
))
|
||||
) : (
|
||||
<RawFallbackBlock
|
||||
label={t("aiSessionRequest")}
|
||||
raw={requestBody}
|
||||
noDataLabel={t("aiSessionNoData")}
|
||||
unparsedLabel={t("aiSessionCouldNotParse")}
|
||||
/>
|
||||
)}
|
||||
{hasResponseMessages ? (
|
||||
responseMessages!.map((message, i) => (
|
||||
<MessageBubble key={`res-${i}`} message={message} />
|
||||
))
|
||||
) : (
|
||||
<RawFallbackBlock
|
||||
label={t("aiSessionResponse")}
|
||||
raw={responseBody}
|
||||
noDataLabel={t("aiSessionNoData")}
|
||||
unparsedLabel={t("aiSessionCouldNotParse")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@app/lib/cn";
|
||||
import {
|
||||
aiUsageAnalyticsFiltersSchema,
|
||||
aiUsageAnalyticsQueries,
|
||||
type AiUsageAnalyticsFilters
|
||||
} from "@app/lib/queries";
|
||||
import { useIsFetching, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { CheckIcon, ChevronsUpDown, RefreshCw, XIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { DateRangePicker, type DateTimeValue } from "./DateTimePicker";
|
||||
import { Button } from "./ui/button";
|
||||
import { Card, CardHeader } from "./ui/card";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "./ui/command";
|
||||
import { Label } from "./ui/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import { Separator } from "./ui/separator";
|
||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||
import { HorizontalTabs, type TabItem } from "./HorizontalTabs";
|
||||
import { OverviewTab } from "./ai-usage-analytics/OverviewTab";
|
||||
import { ProvidersTab } from "./ai-usage-analytics/ProvidersTab";
|
||||
import { ResourcesTab } from "./ai-usage-analytics/ResourcesTab";
|
||||
import { RolesTab } from "./ai-usage-analytics/RolesTab";
|
||||
import { UsersTab } from "./ai-usage-analytics/UsersTab";
|
||||
import { VirtualApiKeysTab } from "./ai-usage-analytics/VirtualApiKeysTab";
|
||||
import { formatVirtualApiKeyPreview } from "@app/lib/virtualApiKeyFormat";
|
||||
|
||||
export type AiUsageAnalyticsDataProps = {
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
const AI_USAGE_ANALYTICS_QUERY_PREFIX = ["AI_USAGE_ANALYTICS"];
|
||||
|
||||
export function AiUsageAnalyticsData(props: AiUsageAnalyticsDataProps) {
|
||||
const t = useTranslations();
|
||||
const searchParams = useSearchParams();
|
||||
const path = usePathname();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const filters = aiUsageAnalyticsFiltersSchema.parse(
|
||||
Object.fromEntries(searchParams.entries())
|
||||
);
|
||||
|
||||
const isEmptySearchParams = Object.values(filters).every(
|
||||
(v) => v === undefined
|
||||
);
|
||||
|
||||
const dateRange = {
|
||||
startDate: filters.timeStart
|
||||
? new Date(filters.timeStart)
|
||||
: getSevenDaysAgo(),
|
||||
endDate: filters.timeEnd ? new Date(filters.timeEnd) : new Date()
|
||||
};
|
||||
|
||||
const { data: filterOptions } = useQuery(
|
||||
aiUsageAnalyticsQueries.filterOptions({
|
||||
orgId: props.orgId,
|
||||
filters: {
|
||||
timeStart: filters.timeStart,
|
||||
timeEnd: filters.timeEnd
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const isFetching =
|
||||
useIsFetching({
|
||||
queryKey: [...AI_USAGE_ANALYTICS_QUERY_PREFIX, props.orgId]
|
||||
}) > 0;
|
||||
|
||||
function setFilter(key: keyof AiUsageAnalyticsFilters, value?: string) {
|
||||
const newSearch = new URLSearchParams(searchParams);
|
||||
newSearch.delete(key);
|
||||
if (value !== undefined) {
|
||||
newSearch.set(key, value);
|
||||
}
|
||||
router.replace(`${path}?${newSearch.toString()}`);
|
||||
}
|
||||
|
||||
function handleTimeRangeUpdate(start: DateTimeValue, end: DateTimeValue) {
|
||||
const newSearch = new URLSearchParams(searchParams);
|
||||
const timeRegex =
|
||||
/^(?<hours>\d{1,2})\:(?<minutes>\d{1,2})(\:(?<seconds>\d{1,2}))?$/;
|
||||
|
||||
if (start.date) {
|
||||
const startDate = new Date(start.date);
|
||||
if (start.time) {
|
||||
const time = timeRegex.exec(start.time);
|
||||
const groups = time?.groups ?? {};
|
||||
startDate.setHours(Number(groups.hours));
|
||||
startDate.setMinutes(Number(groups.minutes));
|
||||
if (groups.seconds) {
|
||||
startDate.setSeconds(Number(groups.seconds));
|
||||
}
|
||||
}
|
||||
newSearch.set("timeStart", startDate.toISOString());
|
||||
}
|
||||
if (end.date) {
|
||||
const endDate = new Date(end.date);
|
||||
if (end.time) {
|
||||
const time = timeRegex.exec(end.time);
|
||||
const groups = time?.groups ?? {};
|
||||
endDate.setHours(Number(groups.hours));
|
||||
endDate.setMinutes(Number(groups.minutes));
|
||||
if (groups.seconds) {
|
||||
endDate.setSeconds(Number(groups.seconds));
|
||||
}
|
||||
}
|
||||
newSearch.set("timeEnd", endDate.toISOString());
|
||||
}
|
||||
router.replace(`${path}?${newSearch.toString()}`);
|
||||
}
|
||||
|
||||
function getDateTime(date: Date) {
|
||||
return `${date.getHours()}:${date.getMinutes()}`;
|
||||
}
|
||||
|
||||
const providerOptions = (filterOptions?.providers ?? []).map((p) => ({
|
||||
value: String(p.id),
|
||||
label: p.name ?? `Provider #${p.id}`
|
||||
}));
|
||||
const modelOptions = (filterOptions?.models ?? []).map((m) => ({
|
||||
value: m,
|
||||
label: m
|
||||
}));
|
||||
const resourceOptions = (filterOptions?.resources ?? []).map((r) => ({
|
||||
value: String(r.id),
|
||||
label: r.name ?? `Resource #${r.id}`
|
||||
}));
|
||||
const roleOptions = (filterOptions?.roles ?? []).map((r) => ({
|
||||
value: String(r.id),
|
||||
label: r.name ?? `Role #${r.id}`
|
||||
}));
|
||||
const userOptions = (filterOptions?.users ?? []).map((u) => ({
|
||||
value: u.id,
|
||||
label: u.email ?? u.id
|
||||
}));
|
||||
const virtualApiKeyOptions = (filterOptions?.virtualApiKeys ?? []).map(
|
||||
(k) => ({
|
||||
value: k.id,
|
||||
label: k.name ?? formatVirtualApiKeyPreview(k.id, k.lastChars)
|
||||
})
|
||||
);
|
||||
|
||||
const tabs: TabItem[] = [
|
||||
{ title: t("aiUsageTabOverview"), href: "#" },
|
||||
{ title: t("aiUsageTabProviders"), href: "#" },
|
||||
{ title: t("aiUsageTabResources"), href: "#" },
|
||||
{ title: t("aiUsageRolesTab"), href: "#" },
|
||||
{ title: t("aiUsageUsersTab"), href: "#" },
|
||||
{ title: t("aiUsageVirtualApiKeysTab"), href: "#" }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row flex-wrap items-end gap-x-3 gap-y-3 space-y-0">
|
||||
<DateRangePicker
|
||||
startValue={{
|
||||
date: dateRange.startDate,
|
||||
time: dateRange.startDate
|
||||
? getDateTime(dateRange.startDate)
|
||||
: undefined
|
||||
}}
|
||||
endValue={{
|
||||
date: dateRange.endDate,
|
||||
time: dateRange.endDate
|
||||
? getDateTime(dateRange.endDate)
|
||||
: undefined
|
||||
}}
|
||||
onRangeChange={handleTimeRangeUpdate}
|
||||
className="flex-wrap gap-2"
|
||||
/>
|
||||
|
||||
<Separator className="w-px h-6 self-end relative bottom-1.5 hidden lg:block" />
|
||||
|
||||
<FilterSelect
|
||||
id="providerId"
|
||||
label={t("aiUsageFilterProvider")}
|
||||
value={filters.providerId?.toString()}
|
||||
options={providerOptions}
|
||||
placeholder={t("aiUsageFilterAllProviders")}
|
||||
onValueChange={(v) => setFilter("providerId", v)}
|
||||
/>
|
||||
<FilterSelect
|
||||
id="model"
|
||||
label={t("aiUsageFilterModel")}
|
||||
value={filters.model}
|
||||
options={modelOptions}
|
||||
placeholder={t("aiUsageFilterAllModels")}
|
||||
onValueChange={(v) => setFilter("model", v)}
|
||||
/>
|
||||
<FilterSelect
|
||||
id="resourceId"
|
||||
label={t("aiUsageFilterResource")}
|
||||
value={filters.resourceId?.toString()}
|
||||
options={resourceOptions}
|
||||
placeholder={t("aiUsageFilterAllResources")}
|
||||
onValueChange={(v) => setFilter("resourceId", v)}
|
||||
/>
|
||||
<FilterSelect
|
||||
id="roleId"
|
||||
label={t("aiUsageFilterRole")}
|
||||
value={filters.roleId?.toString()}
|
||||
options={roleOptions}
|
||||
placeholder={t("aiUsageFilterAllRoles")}
|
||||
onValueChange={(v) => setFilter("roleId", v)}
|
||||
/>
|
||||
<FilterSelect
|
||||
id="userId"
|
||||
label={t("aiUsageFilterUser")}
|
||||
value={filters.userId}
|
||||
options={userOptions}
|
||||
placeholder={t("aiUsageFilterAllUsers")}
|
||||
onValueChange={(v) => setFilter("userId", v)}
|
||||
/>
|
||||
<FilterSelect
|
||||
id="virtualApiKeyId"
|
||||
label={t("aiUsageFilterVirtualApiKey")}
|
||||
value={filters.virtualApiKeyId}
|
||||
options={virtualApiKeyOptions}
|
||||
placeholder={t("aiUsageFilterAllVirtualApiKeys")}
|
||||
onValueChange={(v) => setFilter("virtualApiKeyId", v)}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
{!isEmptySearchParams && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => router.replace(path)}
|
||||
className="gap-2"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
{t("aiUsageResetFilters")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [
|
||||
...AI_USAGE_ANALYTICS_QUERY_PREFIX,
|
||||
props.orgId
|
||||
]
|
||||
})
|
||||
}
|
||||
disabled={isFetching}
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
"size-4",
|
||||
isFetching && "animate-spin"
|
||||
)}
|
||||
/>
|
||||
{t("aiUsageRefresh")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<HorizontalTabs items={tabs} clientSide>
|
||||
<OverviewTab orgId={props.orgId} filters={filters} />
|
||||
<ProvidersTab orgId={props.orgId} filters={filters} />
|
||||
<ResourcesTab orgId={props.orgId} filters={filters} />
|
||||
<RolesTab orgId={props.orgId} filters={filters} />
|
||||
<UsersTab orgId={props.orgId} filters={filters} />
|
||||
<VirtualApiKeysTab orgId={props.orgId} filters={filters} />
|
||||
</HorizontalTabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type FilterSelectProps = {
|
||||
id: string;
|
||||
label: string;
|
||||
value?: string;
|
||||
options: { value: string; label: string }[];
|
||||
placeholder: string;
|
||||
onValueChange: (value?: string) => void;
|
||||
};
|
||||
|
||||
function FilterSelect(props: FilterSelectProps) {
|
||||
const t = useTranslations();
|
||||
const [open, setOpen] = useState(false);
|
||||
const selected = props.options.find(
|
||||
(option) => option.value === props.value
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-2 w-44">
|
||||
<Label htmlFor={props.id}>{props.label}</Label>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id={props.id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"w-full justify-between font-normal",
|
||||
!selected && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-left">
|
||||
{selected?.label ?? props.placeholder}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[var(--radix-popover-trigger-width)] min-w-56 p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder={t("aiUsageFilterSearch")} />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{t("aiUsageFilterNotFound")}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value={props.placeholder}
|
||||
onSelect={() => {
|
||||
props.onValueChange(undefined);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4 shrink-0",
|
||||
!props.value
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{props.placeholder}
|
||||
</CommandItem>
|
||||
{props.options.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={`${option.value} ${option.label}`}
|
||||
onSelect={() => {
|
||||
props.onValueChange(
|
||||
option.value === props.value
|
||||
? undefined
|
||||
: option.value
|
||||
);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4 shrink-0",
|
||||
option.value === props.value
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">
|
||||
{option.label}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -399,7 +399,7 @@ function AuthPageSettings({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{build !== "oss" && (build === "enterprise" ||
|
||||
{(build === "enterprise" ||
|
||||
!isPaidUser(
|
||||
tierMatrix.loginPageDomain
|
||||
)) &&
|
||||
@@ -412,7 +412,6 @@ function AuthPageSettings({
|
||||
fullDomain={
|
||||
loginPage.fullDomain
|
||||
}
|
||||
autoFetch={true}
|
||||
showLabel={true}
|
||||
polling={true}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,562 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import {
|
||||
AI_BUDGET_PERIODS,
|
||||
AI_BUDGET_UNITS,
|
||||
getAiBudgetScopeBodyField,
|
||||
type AiBudgetPeriod,
|
||||
type AiBudgetScope,
|
||||
type AiBudgetUnit
|
||||
} from "@app/lib/aiBudgetScope";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { aiBudgetQueries } from "@app/lib/queries";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AiBudget } from "@server/db";
|
||||
import type { AxiosInstance } from "axios";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
export type BudgetRow = {
|
||||
key: string;
|
||||
budgetId?: number;
|
||||
amount: string;
|
||||
unit: AiBudgetUnit;
|
||||
period: AiBudgetPeriod;
|
||||
};
|
||||
|
||||
export function rowsFromBudgets(budgets: AiBudget[]): BudgetRow[] {
|
||||
return budgets.map((budget) => ({
|
||||
key: String(budget.budgetId),
|
||||
budgetId: budget.budgetId,
|
||||
amount: String(budget.amount),
|
||||
unit: budget.unit,
|
||||
period: budget.period
|
||||
}));
|
||||
}
|
||||
|
||||
function comboKey(unit: AiBudgetUnit, period: AiBudgetPeriod): string {
|
||||
return `${unit}:${period}`;
|
||||
}
|
||||
|
||||
function nextAvailableCombo(rows: BudgetRow[]): {
|
||||
unit: AiBudgetUnit;
|
||||
period: AiBudgetPeriod;
|
||||
} {
|
||||
const used = new Set(rows.map((row) => comboKey(row.unit, row.period)));
|
||||
for (const unit of AI_BUDGET_UNITS) {
|
||||
for (const period of AI_BUDGET_PERIODS) {
|
||||
if (!used.has(comboKey(unit, period))) {
|
||||
return { unit, period };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { unit: "usd", period: "monthly" };
|
||||
}
|
||||
|
||||
function newRowKey(): string {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `tmp-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
export function newBudgetRow(rows: BudgetRow[]): BudgetRow {
|
||||
const combo = nextAvailableCombo(rows);
|
||||
return {
|
||||
key: newRowKey(),
|
||||
amount: "",
|
||||
unit: combo.unit,
|
||||
period: combo.period
|
||||
};
|
||||
}
|
||||
|
||||
export function getBudgetRowsErrors(rows: BudgetRow[]): {
|
||||
conflictingKeys: Set<string>;
|
||||
invalidAmountKeys: Set<string>;
|
||||
} {
|
||||
const counts = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
const key = comboKey(row.unit, row.period);
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
}
|
||||
const conflictingKeys = new Set<string>();
|
||||
for (const row of rows) {
|
||||
const key = comboKey(row.unit, row.period);
|
||||
if ((counts.get(key) ?? 0) > 1) {
|
||||
conflictingKeys.add(row.key);
|
||||
}
|
||||
}
|
||||
|
||||
const invalidAmountKeys = new Set<string>();
|
||||
for (const row of rows) {
|
||||
const amount = Number(row.amount);
|
||||
if (!row.amount.trim() || !Number.isFinite(amount) || amount <= 0) {
|
||||
invalidAmountKeys.add(row.key);
|
||||
}
|
||||
}
|
||||
|
||||
return { conflictingKeys, invalidAmountKeys };
|
||||
}
|
||||
|
||||
type BudgetRowFieldProps = {
|
||||
row: BudgetRow;
|
||||
disabled: boolean;
|
||||
showInvalidAmount: boolean;
|
||||
showConflict: boolean;
|
||||
unitLabels: Record<AiBudgetUnit, string>;
|
||||
periodLabels: Record<AiBudgetPeriod, string>;
|
||||
amountPlaceholder: string;
|
||||
onUpdate: (patch: Partial<BudgetRow>) => void;
|
||||
};
|
||||
|
||||
function BudgetRowAmountInput({
|
||||
row,
|
||||
disabled,
|
||||
showInvalidAmount,
|
||||
amountPlaceholder,
|
||||
onUpdate,
|
||||
className
|
||||
}: Pick<
|
||||
BudgetRowFieldProps,
|
||||
"row" | "disabled" | "showInvalidAmount" | "amountPlaceholder" | "onUpdate"
|
||||
> & { className?: string }) {
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
placeholder={amountPlaceholder}
|
||||
value={row.amount}
|
||||
aria-invalid={showInvalidAmount}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onUpdate({ amount: e.target.value })}
|
||||
className={cn("w-full min-w-0", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BudgetRowUnitSelect({
|
||||
row,
|
||||
disabled,
|
||||
showConflict,
|
||||
unitLabels,
|
||||
onUpdate,
|
||||
className
|
||||
}: Pick<
|
||||
BudgetRowFieldProps,
|
||||
"row" | "disabled" | "showConflict" | "unitLabels" | "onUpdate"
|
||||
> & { className?: string }) {
|
||||
return (
|
||||
<Select
|
||||
value={row.unit}
|
||||
onValueChange={(value) => onUpdate({ unit: value as AiBudgetUnit })}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn("w-full min-w-0", className)}
|
||||
aria-invalid={showConflict}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AI_BUDGET_UNITS.map((unit) => (
|
||||
<SelectItem key={unit} value={unit}>
|
||||
{unitLabels[unit]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
function BudgetRowPeriodSelect({
|
||||
row,
|
||||
disabled,
|
||||
showConflict,
|
||||
periodLabels,
|
||||
onUpdate,
|
||||
className
|
||||
}: Pick<
|
||||
BudgetRowFieldProps,
|
||||
"row" | "disabled" | "showConflict" | "periodLabels" | "onUpdate"
|
||||
> & { className?: string }) {
|
||||
return (
|
||||
<Select
|
||||
value={row.period}
|
||||
onValueChange={(value) =>
|
||||
onUpdate({ period: value as AiBudgetPeriod })
|
||||
}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn("w-full min-w-0", className)}
|
||||
aria-invalid={showConflict}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AI_BUDGET_PERIODS.map((period) => (
|
||||
<SelectItem key={period} value={period}>
|
||||
{periodLabels[period]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export function BudgetRowsFields({
|
||||
rows,
|
||||
onChange,
|
||||
disabled = false,
|
||||
attemptedSave = false
|
||||
}: {
|
||||
rows: BudgetRow[];
|
||||
onChange: (rows: BudgetRow[]) => void;
|
||||
disabled?: boolean;
|
||||
attemptedSave?: boolean;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
|
||||
const { conflictingKeys, invalidAmountKeys } = useMemo(
|
||||
() => getBudgetRowsErrors(rows),
|
||||
[rows]
|
||||
);
|
||||
|
||||
function addRow() {
|
||||
onChange([...rows, newBudgetRow(rows)]);
|
||||
}
|
||||
|
||||
function removeRow(key: string) {
|
||||
onChange(rows.filter((row) => row.key !== key));
|
||||
}
|
||||
|
||||
function updateRow(key: string, patch: Partial<BudgetRow>) {
|
||||
onChange(
|
||||
rows.map((row) => (row.key === key ? { ...row, ...patch } : row))
|
||||
);
|
||||
}
|
||||
|
||||
const periodLabels: Record<AiBudgetPeriod, string> = {
|
||||
hourly: t("aiBudgetPeriodHourly"),
|
||||
daily: t("aiBudgetPeriodDaily"),
|
||||
weekly: t("aiBudgetPeriodWeekly"),
|
||||
monthly: t("aiBudgetPeriodMonthly"),
|
||||
yearly: t("aiBudgetPeriodYearly"),
|
||||
lifetime: t("aiBudgetPeriodLifetime")
|
||||
};
|
||||
|
||||
const unitLabels: Record<AiBudgetUnit, string> = {
|
||||
usd: t("aiBudgetUnitUsd"),
|
||||
tokens: t("aiBudgetUnitTokens")
|
||||
};
|
||||
|
||||
const amountPlaceholder = t("aiBudgetAmountPlaceholder");
|
||||
|
||||
const addRowButton = (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={addRow}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("aiBudgetAdd")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const errorMessage =
|
||||
conflictingKeys.size > 0 ||
|
||||
(attemptedSave && invalidAmountKeys.size > 0) ? (
|
||||
<p className="text-destructive text-sm">
|
||||
{conflictingKeys.size > 0
|
||||
? t("aiBudgetConflictError")
|
||||
: t("aiBudgetInvalidAmountError")}
|
||||
</p>
|
||||
) : null;
|
||||
|
||||
function rowFieldProps(row: BudgetRow): BudgetRowFieldProps {
|
||||
return {
|
||||
row,
|
||||
disabled,
|
||||
showInvalidAmount: attemptedSave && invalidAmountKeys.has(row.key),
|
||||
showConflict: conflictingKeys.has(row.key),
|
||||
unitLabels,
|
||||
periodLabels,
|
||||
amountPlaceholder,
|
||||
onUpdate: (patch) => updateRow(row.key, patch)
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{rows.length === 0 ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("aiBudgetEmpty")}
|
||||
</p>
|
||||
{addRowButton}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{rows.map((row) => {
|
||||
const fields = rowFieldProps(row);
|
||||
return (
|
||||
<div
|
||||
key={row.key}
|
||||
className="flex items-center gap-1 sm:gap-2"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-9 min-w-0 flex-1 overflow-hidden rounded-md border border-input",
|
||||
"focus-within:border-ring",
|
||||
(fields.showInvalidAmount ||
|
||||
fields.showConflict) &&
|
||||
"border-destructive"
|
||||
)}
|
||||
>
|
||||
<BudgetRowUnitSelect
|
||||
{...fields}
|
||||
className="h-full w-20 min-w-20 shrink-0 rounded-none border-0 px-2 shadow-none focus-visible:ring-0 sm:w-28 sm:min-w-28 max-sm:[&_svg]:hidden"
|
||||
/>
|
||||
<div
|
||||
className="w-px shrink-0 bg-border"
|
||||
aria-hidden
|
||||
/>
|
||||
<BudgetRowAmountInput
|
||||
{...fields}
|
||||
className="h-full min-w-0 flex-1 rounded-none border-0 text-sm shadow-none focus-visible:border-transparent focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
<BudgetRowPeriodSelect
|
||||
{...fields}
|
||||
className="h-9 w-24 min-w-24 shrink-0 sm:w-32 sm:min-w-32 max-sm:[&_svg]:hidden"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
disabled={disabled}
|
||||
onClick={() => removeRow(row.key)}
|
||||
aria-label={t("delete")}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{errorMessage}
|
||||
{rows.length > 0 && addRowButton}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function saveBudgetRows({
|
||||
api,
|
||||
orgId,
|
||||
scope,
|
||||
existingBudgets,
|
||||
rows
|
||||
}: {
|
||||
api: AxiosInstance;
|
||||
orgId: string;
|
||||
scope: AiBudgetScope;
|
||||
existingBudgets: AiBudget[];
|
||||
rows: Pick<BudgetRow, "budgetId" | "amount" | "unit" | "period">[];
|
||||
}): Promise<void> {
|
||||
const existingById = new Map(
|
||||
existingBudgets.map((budget) => [budget.budgetId, budget])
|
||||
);
|
||||
const currentBudgetIds = new Set(
|
||||
rows
|
||||
.filter((row) => row.budgetId !== undefined)
|
||||
.map((row) => row.budgetId as number)
|
||||
);
|
||||
const bodyField = getAiBudgetScopeBodyField(scope);
|
||||
|
||||
const toDelete = existingBudgets.filter(
|
||||
(budget) => !currentBudgetIds.has(budget.budgetId)
|
||||
);
|
||||
const toCreate = rows.filter((row) => row.budgetId === undefined);
|
||||
const toUpdate = rows.filter((row) => {
|
||||
if (row.budgetId === undefined) return false;
|
||||
const existingBudget = existingById.get(row.budgetId);
|
||||
if (!existingBudget) return false;
|
||||
return (
|
||||
existingBudget.amount !== Number(row.amount) ||
|
||||
existingBudget.unit !== row.unit ||
|
||||
existingBudget.period !== row.period
|
||||
);
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
...toDelete.map((budget) =>
|
||||
api.delete(`/ai-budget/${budget.budgetId}`)
|
||||
),
|
||||
...toCreate.map((row) =>
|
||||
api.put(`/org/${orgId}/ai-budget`, {
|
||||
[bodyField]: scope.id,
|
||||
amount: Number(row.amount),
|
||||
unit: row.unit,
|
||||
period: row.period
|
||||
})
|
||||
),
|
||||
...toUpdate.map((row) =>
|
||||
api.post(`/ai-budget/${row.budgetId}`, {
|
||||
amount: Number(row.amount),
|
||||
unit: row.unit,
|
||||
period: row.period
|
||||
})
|
||||
)
|
||||
]);
|
||||
}
|
||||
|
||||
export function BudgetsEditor({
|
||||
scope,
|
||||
orgId,
|
||||
title,
|
||||
description,
|
||||
hideCardHeader = false
|
||||
}: {
|
||||
scope: AiBudgetScope;
|
||||
orgId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
hideCardHeader?: boolean;
|
||||
}) {
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const queryClient = useQueryClient();
|
||||
const t = useTranslations();
|
||||
|
||||
const [rows, setRows] = useState<BudgetRow[]>([]);
|
||||
const [saveLoading, setSaveLoading] = useState(false);
|
||||
const [attemptedSave, setAttemptedSave] = useState(false);
|
||||
|
||||
const budgetsQuery = useQuery(aiBudgetQueries.scoped({ scope }));
|
||||
|
||||
useEffect(() => {
|
||||
if (!budgetsQuery.data) return;
|
||||
setRows(rowsFromBudgets(budgetsQuery.data));
|
||||
setAttemptedSave(false);
|
||||
}, [budgetsQuery.data]);
|
||||
|
||||
const { conflictingKeys, invalidAmountKeys } = useMemo(
|
||||
() => getBudgetRowsErrors(rows),
|
||||
[rows]
|
||||
);
|
||||
|
||||
const hasErrors = conflictingKeys.size > 0 || invalidAmountKeys.size > 0;
|
||||
|
||||
async function onSave() {
|
||||
setAttemptedSave(true);
|
||||
if (hasErrors) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("aiBudgetErrorSave"),
|
||||
description: conflictingKeys.size
|
||||
? t("aiBudgetConflictError")
|
||||
: t("aiBudgetInvalidAmountError")
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSaveLoading(true);
|
||||
try {
|
||||
await saveBudgetRows({
|
||||
api,
|
||||
orgId,
|
||||
scope,
|
||||
existingBudgets: budgetsQuery.data ?? [],
|
||||
rows
|
||||
});
|
||||
|
||||
await queryClient.invalidateQueries(
|
||||
aiBudgetQueries.scoped({ scope })
|
||||
);
|
||||
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("aiBudgetUpdated")
|
||||
});
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("aiBudgetErrorSave"),
|
||||
description: formatAxiosError(e, t("aiBudgetErrorSave"))
|
||||
});
|
||||
} finally {
|
||||
setSaveLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const body = (
|
||||
<>
|
||||
<SettingsSectionBody>
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="half">
|
||||
<BudgetRowsFields
|
||||
rows={rows}
|
||||
onChange={setRows}
|
||||
disabled={budgetsQuery.isLoading}
|
||||
attemptedSave={attemptedSave}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="button"
|
||||
loading={saveLoading}
|
||||
disabled={saveLoading || budgetsQuery.isLoading}
|
||||
onClick={onSave}
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</>
|
||||
);
|
||||
|
||||
if (hideCardHeader) {
|
||||
return <div className="space-y-4">{body}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>{title}</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{description}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
{body}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { FileBadge, RotateCw } from "lucide-react";
|
||||
import { useCertificate } from "@app/hooks/useCertificate";
|
||||
import type { GetCertificateResponse } from "@server/routers/certificates/types";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { durationToMs } from "@app/lib/durationToMs";
|
||||
|
||||
export type CertificateStatusContentProps = {
|
||||
cert: GetCertificateResponse | null;
|
||||
@@ -32,8 +33,7 @@ export function CertificateStatusContent({
|
||||
|
||||
const labelClass =
|
||||
"inline-flex shrink-0 items-center self-center text-sm font-medium leading-normal";
|
||||
const valueClass =
|
||||
"inline-flex items-center gap-2 text-sm leading-normal";
|
||||
const valueClass = "inline-flex items-center gap-2 text-sm leading-normal";
|
||||
|
||||
const handleRefresh = async () => {
|
||||
await refreshCert();
|
||||
@@ -187,7 +187,6 @@ type CertificateStatusProps = {
|
||||
orgId: string;
|
||||
domainId: string;
|
||||
fullDomain: string;
|
||||
autoFetch?: boolean;
|
||||
showLabel?: boolean;
|
||||
className?: string;
|
||||
onRefresh?: () => void;
|
||||
@@ -199,18 +198,16 @@ export default function CertificateStatus({
|
||||
orgId,
|
||||
domainId,
|
||||
fullDomain,
|
||||
autoFetch = true,
|
||||
showLabel = true,
|
||||
className = "",
|
||||
onRefresh,
|
||||
polling = false,
|
||||
pollingInterval = 5000
|
||||
pollingInterval = durationToMs(5, "seconds")
|
||||
}: CertificateStatusProps) {
|
||||
const hook = useCertificate({
|
||||
orgId,
|
||||
domainId,
|
||||
fullDomain,
|
||||
autoFetch,
|
||||
polling,
|
||||
pollingInterval
|
||||
});
|
||||
|
||||
@@ -24,19 +24,21 @@ export function ContactSalesBanner() {
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</Link>
|
||||
{" " + t("contactSalesOr") + " "}
|
||||
<Link
|
||||
href="https://pangolin.net/contact"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 font-medium text-black-600 underline"
|
||||
>
|
||||
{t("contactSalesContactUs")}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</Link>
|
||||
.
|
||||
<span className="whitespace-nowrap">
|
||||
<Link
|
||||
href="https://pangolin.net/contact"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 font-medium text-black-600 underline"
|
||||
>
|
||||
{t("contactSalesContactUs")}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,29 +8,40 @@ import { useTranslations } from "next-intl";
|
||||
type CopyTextBoxProps = {
|
||||
text?: string;
|
||||
displayText?: string;
|
||||
getCopyText?: () => Promise<string>;
|
||||
wrapText?: boolean;
|
||||
outline?: boolean;
|
||||
centered?: boolean;
|
||||
};
|
||||
|
||||
export default function CopyTextBox({
|
||||
text = "",
|
||||
displayText,
|
||||
getCopyText,
|
||||
wrapText = false,
|
||||
outline = true
|
||||
outline = true,
|
||||
centered = false
|
||||
}: CopyTextBoxProps) {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const [isCopying, setIsCopying] = useState(false);
|
||||
const textRef = useRef<HTMLPreElement>(null);
|
||||
const t = useTranslations();
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
if (textRef.current) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error(t("copyTextFailed"), err);
|
||||
}
|
||||
if (!textRef.current || isCopying) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCopying(true);
|
||||
try {
|
||||
const value = getCopyText ? await getCopyText() : text;
|
||||
await navigator.clipboard.writeText(value);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error(t("copyTextFailed"), err);
|
||||
} finally {
|
||||
setIsCopying(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -40,7 +51,9 @@ export default function CopyTextBox({
|
||||
>
|
||||
<pre
|
||||
ref={textRef}
|
||||
className={`p-4 pr-16 text-sm w-full ${
|
||||
className={`py-4 text-sm w-full ${
|
||||
centered ? "px-16 text-center" : "pl-4 pr-16"
|
||||
} ${
|
||||
wrapText
|
||||
? "whitespace-pre-wrap break-words"
|
||||
: "overflow-x-auto"
|
||||
@@ -54,6 +67,7 @@ export default function CopyTextBox({
|
||||
type="button"
|
||||
className="absolute top-0.5 right-0 z-10 bg-card"
|
||||
onClick={copyToClipboard}
|
||||
loading={isCopying}
|
||||
aria-label={t("copyTextClipboard")}
|
||||
>
|
||||
{isCopied ? (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { Check, Copy, Loader2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -7,6 +7,7 @@ import { useTranslations } from "next-intl";
|
||||
type CopyToClipboardProps = {
|
||||
text: string;
|
||||
displayText?: string;
|
||||
getCopyText?: () => Promise<string>;
|
||||
isLink?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
@@ -14,18 +15,31 @@ type CopyToClipboardProps = {
|
||||
const CopyToClipboard = ({
|
||||
text,
|
||||
displayText,
|
||||
getCopyText,
|
||||
isLink,
|
||||
className
|
||||
}: CopyToClipboardProps) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copying, setCopying] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
const handleCopy = async () => {
|
||||
if (copying) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
setCopying(true);
|
||||
try {
|
||||
const value = getCopyText ? await getCopyText() : text;
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
} catch {
|
||||
// Fetch errors are toasted by the caller; clipboard failures stay silent.
|
||||
} finally {
|
||||
setCopying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const displayValue = displayText ?? text;
|
||||
@@ -38,11 +52,14 @@ const CopyToClipboard = ({
|
||||
type="button"
|
||||
className="h-4 w-4 p-0 flex items-center justify-center cursor-pointer flex-shrink-0"
|
||||
onClick={handleCopy}
|
||||
disabled={copying}
|
||||
>
|
||||
{!copied ? (
|
||||
<Copy className="h-4 w-4" />
|
||||
) : (
|
||||
{copying ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : copied ? (
|
||||
<Check className="text-green-500 h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
<span className="sr-only">{t("copyText")}</span>
|
||||
</button>
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function CreateRoleForm({
|
||||
requireDeviceApproval: values.requireDeviceApproval,
|
||||
allowSsh: values.allowSsh
|
||||
};
|
||||
if (isPaidUser(tierMatrix.advancedPrivateResources)) {
|
||||
if (isPaidUser(tierMatrix.roleBasedSSHControls)) {
|
||||
payload.sshSudoMode = values.sshSudoMode;
|
||||
payload.sshCreateHomeDir = values.sshCreateHomeDir;
|
||||
payload.sshSudoCommands =
|
||||
@@ -80,13 +80,39 @@ export default function CreateRoleForm({
|
||||
});
|
||||
|
||||
if (res && res.status === 201) {
|
||||
const createdRole = res.data.data;
|
||||
|
||||
const pendingBudgets = (values.budgets ?? []).filter(
|
||||
(budget) => budget.amount.trim() !== ""
|
||||
);
|
||||
if (pendingBudgets.length > 0) {
|
||||
try {
|
||||
await Promise.all(
|
||||
pendingBudgets.map((budget) =>
|
||||
api.put(`/org/${org?.org.orgId}/ai-budget`, {
|
||||
roleId: createdRole.roleId,
|
||||
amount: Number(budget.amount),
|
||||
unit: budget.unit,
|
||||
period: budget.period
|
||||
})
|
||||
)
|
||||
);
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("aiBudgetErrorSave"),
|
||||
description: formatAxiosError(e, t("aiBudgetErrorSave"))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
toast({
|
||||
variant: "default",
|
||||
title: t("accessRoleCreated"),
|
||||
description: t("accessRoleCreatedDescription")
|
||||
});
|
||||
if (open) setOpen(false);
|
||||
afterCreate?.(res.data.data);
|
||||
afterCreate?.(createdRole);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,6 @@ import { ChevronsUpDown } from "lucide-react";
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import { GenerateAccessTokenResponse } from "@server/routers/accessToken";
|
||||
import { constructShareLink } from "@app/lib/shareLinks";
|
||||
import { ShareLinkRow } from "@app/components/ShareLinksTable";
|
||||
import { QRCodeCanvas, QRCodeSVG } from "qrcode.react";
|
||||
import {
|
||||
Collapsible,
|
||||
@@ -63,11 +62,26 @@ import AccessTokenSection from "@app/components/AccessTokenUsage";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toUnicode } from "punycode";
|
||||
import { ResourceSelector, type SelectedResource } from "./resource-selector";
|
||||
import { UserSelector, type SelectedUser } from "@app/components/user-selector";
|
||||
|
||||
type CreatedShareLink = {
|
||||
accessTokenId: string;
|
||||
resourceId: number;
|
||||
resourceName: string;
|
||||
resourceNiceId: string;
|
||||
title: string | null;
|
||||
createdAt: number;
|
||||
expiresAt: number | null;
|
||||
userId?: string | null;
|
||||
userName?: string | null;
|
||||
username?: string | null;
|
||||
userEmail?: string | null;
|
||||
};
|
||||
|
||||
type FormProps = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
onCreated?: (result: ShareLinkRow) => void;
|
||||
onCreated?: (result: CreatedShareLink) => void;
|
||||
};
|
||||
|
||||
export default function CreateShareLinkForm({
|
||||
@@ -85,6 +99,8 @@ export default function CreateShareLinkForm({
|
||||
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [neverExpire, setNeverExpire] = useState(false);
|
||||
const [persistSession, setPersistSession] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<SelectedUser | null>(null);
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const t = useTranslations();
|
||||
@@ -175,7 +191,9 @@ export default function CreateShareLinkForm({
|
||||
values.resourceName ||
|
||||
"Resource" + values.resourceId
|
||||
}),
|
||||
path: values.path
|
||||
path: values.path,
|
||||
persistSession,
|
||||
userId: selectedUser?.id
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
@@ -205,7 +223,11 @@ export default function CreateShareLinkForm({
|
||||
resourceNiceId: selectedResource ? selectedResource.niceId : "",
|
||||
title: token.title,
|
||||
createdAt: token.createdAt,
|
||||
expiresAt: token.expiresAt
|
||||
expiresAt: token.expiresAt,
|
||||
userId: token.userId,
|
||||
userName: selectedUser?.text ?? null,
|
||||
username: null,
|
||||
userEmail: null
|
||||
});
|
||||
}
|
||||
|
||||
@@ -220,6 +242,9 @@ export default function CreateShareLinkForm({
|
||||
setOpen(val);
|
||||
setLink(null);
|
||||
setLoading(false);
|
||||
setNeverExpire(false);
|
||||
setPersistSession(false);
|
||||
setSelectedUser(null);
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
@@ -344,6 +369,48 @@ export default function CreateShareLinkForm({
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel>
|
||||
{t(
|
||||
"shareAssociateUserOptional"
|
||||
)}
|
||||
</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full justify-between",
|
||||
!selectedUser &&
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{selectedUser?.text
|
||||
? selectedUser.text
|
||||
: t("userSelect")}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0 w-[var(--radix-popover-trigger-width)]">
|
||||
<UserSelector
|
||||
orgId={org.org.orgId}
|
||||
selectedUser={
|
||||
selectedUser
|
||||
}
|
||||
onSelectUser={
|
||||
setSelectedUser
|
||||
}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"shareAssociateUserDescription"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<FormLabel>
|
||||
@@ -437,6 +504,34 @@ export default function CreateShareLinkForm({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-2">
|
||||
<Checkbox
|
||||
id="persist-session"
|
||||
checked={persistSession}
|
||||
onCheckedChange={(val) =>
|
||||
setPersistSession(
|
||||
val as boolean
|
||||
)
|
||||
}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="persist-session"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{t(
|
||||
"sharePersistSession"
|
||||
)}
|
||||
</label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"sharePersistSessionDescription"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("shareExpireDescription")}
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,569 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import CopyTextBox from "@app/components/CopyTextBox";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { formatAxiosError, createApiClient } from "@app/lib/api";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { CaretSortIcon } from "@radix-ui/react-icons";
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { UserSelector, type SelectedUser } from "@app/components/user-selector";
|
||||
import type { CreateOrEditVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types";
|
||||
import { formatVirtualApiKeyCredential } from "@app/lib/virtualApiKeyFormat";
|
||||
import {
|
||||
MultiResourcesSelector,
|
||||
formatMultiResourcesSelectorLabel
|
||||
} from "@app/components/multi-resource-selector";
|
||||
import type { SelectedResource } from "@app/components/resource-selector";
|
||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||
import {
|
||||
BudgetRowsFields,
|
||||
getBudgetRowsErrors,
|
||||
type BudgetRow
|
||||
} from "@app/components/BudgetsEditor";
|
||||
import VirtualApiKeyEmailSection from "@app/components/VirtualApiKeyEmailSection";
|
||||
import type { Tag } from "@app/components/tags/tag-input";
|
||||
|
||||
export type CreatedVirtualApiKey = {
|
||||
virtualApiKeyId: string;
|
||||
orgId: string;
|
||||
kind: "manual" | "user";
|
||||
userId: string | null;
|
||||
name: string | null;
|
||||
description: string | null;
|
||||
lastChars: string;
|
||||
allResources: boolean;
|
||||
expiresAt: number | null;
|
||||
lastUsedAt: number | null;
|
||||
createdAt: number;
|
||||
createdByUserId: string | null;
|
||||
resourceIds: number[];
|
||||
userName?: string | null;
|
||||
username?: string | null;
|
||||
userEmail?: string | null;
|
||||
resourceNames: string;
|
||||
resources: { resourceId: number; name: string; niceId: string }[];
|
||||
};
|
||||
|
||||
type FormProps = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
onCreated?: (result: CreatedVirtualApiKey) => void;
|
||||
};
|
||||
|
||||
export default function CreateVirtualApiKeyForm({
|
||||
open,
|
||||
setOpen,
|
||||
onCreated
|
||||
}: FormProps) {
|
||||
const { org } = useOrgContext();
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const t = useTranslations();
|
||||
|
||||
const [credential, setCredential] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [allResources, setAllResources] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<SelectedUser | null>(null);
|
||||
const [selectedResources, setSelectedResources] = useState<
|
||||
SelectedResource[]
|
||||
>([]);
|
||||
const [pendingBudgetRows, setPendingBudgetRows] = useState<BudgetRow[]>([]);
|
||||
const [attemptedBudgetsSave, setAttemptedBudgetsSave] = useState(false);
|
||||
const [sendEmail, setSendEmail] = useState(false);
|
||||
const [sendToAttributedUser, setSendToAttributedUser] = useState(false);
|
||||
const [emailTags, setEmailTags] = useState<Tag[]>([]);
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional()
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: ""
|
||||
}
|
||||
});
|
||||
|
||||
function resetLocalState() {
|
||||
setCredential(null);
|
||||
setLoading(false);
|
||||
setAllResources(false);
|
||||
setSelectedUser(null);
|
||||
setSelectedResources([]);
|
||||
setPendingBudgetRows([]);
|
||||
setAttemptedBudgetsSave(false);
|
||||
setSendEmail(false);
|
||||
setSendToAttributedUser(false);
|
||||
setEmailTags([]);
|
||||
form.reset();
|
||||
}
|
||||
|
||||
function handleFormSubmit(values: z.infer<typeof formSchema>) {
|
||||
const { conflictingKeys, invalidAmountKeys } =
|
||||
getBudgetRowsErrors(pendingBudgetRows);
|
||||
if (conflictingKeys.size > 0 || invalidAmountKeys.size > 0) {
|
||||
setAttemptedBudgetsSave(true);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("aiBudgetErrorSave"),
|
||||
description: conflictingKeys.size
|
||||
? t("aiBudgetConflictError")
|
||||
: t("aiBudgetInvalidAmountError")
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
env.email.emailEnabled &&
|
||||
sendEmail &&
|
||||
!sendToAttributedUser &&
|
||||
emailTags.length === 0
|
||||
) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("virtualApiKeysEmailRecipientsRequired"),
|
||||
description: t("virtualApiKeysEmailRecipientsRequired")
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
return onSubmit(values);
|
||||
}
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
setLoading(true);
|
||||
|
||||
const res = await api
|
||||
.put<AxiosResponse<CreateOrEditVirtualApiKeyResponse>>(
|
||||
`/org/${org.org.orgId}/virtual-api-key`,
|
||||
{
|
||||
name: values.name,
|
||||
description: values.description || null,
|
||||
userId: selectedUser?.id ?? null,
|
||||
allResources,
|
||||
resourceIds: allResources
|
||||
? []
|
||||
: selectedResources.map((r) => r.resourceId),
|
||||
sendEmail: env.email.emailEnabled && sendEmail,
|
||||
sendToAttributedUser:
|
||||
env.email.emailEnabled &&
|
||||
sendEmail &&
|
||||
sendToAttributedUser,
|
||||
emails:
|
||||
env.email.emailEnabled && sendEmail
|
||||
? emailTags.map((tag) => tag.text)
|
||||
: []
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("virtualApiKeysErrorCreate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("virtualApiKeysErrorCreateDescription")
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
if (res?.data.data.virtualApiKey) {
|
||||
const key = res.data.data.virtualApiKey;
|
||||
if (key.secret) {
|
||||
setCredential(
|
||||
formatVirtualApiKeyCredential(
|
||||
key.virtualApiKeyId,
|
||||
key.secret
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const pendingBudgets = pendingBudgetRows.filter(
|
||||
(budget) => budget.amount.trim() !== ""
|
||||
);
|
||||
if (pendingBudgets.length > 0) {
|
||||
try {
|
||||
await Promise.all(
|
||||
pendingBudgets.map((budget) =>
|
||||
api.put(`/org/${org.org.orgId}/ai-budget`, {
|
||||
virtualApiKeyId: key.virtualApiKeyId,
|
||||
amount: Number(budget.amount),
|
||||
unit: budget.unit,
|
||||
period: budget.period
|
||||
})
|
||||
)
|
||||
);
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("aiBudgetErrorSave"),
|
||||
description: formatAxiosError(e, t("aiBudgetErrorSave"))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const resourceLookup = new Map(
|
||||
selectedResources.map((r) => [
|
||||
r.resourceId,
|
||||
{ name: r.name, niceId: r.niceId }
|
||||
])
|
||||
);
|
||||
const resourceNames = key.allResources
|
||||
? t("virtualApiKeysAllResources")
|
||||
: key.resourceIds
|
||||
.map((id) => resourceLookup.get(id)?.name)
|
||||
.filter(Boolean)
|
||||
.join(", ") || t("virtualApiKeysNoResources");
|
||||
|
||||
onCreated?.({
|
||||
virtualApiKeyId: key.virtualApiKeyId,
|
||||
orgId: key.orgId,
|
||||
kind: key.kind,
|
||||
userId: key.userId,
|
||||
name: key.name,
|
||||
description: key.description,
|
||||
lastChars: key.lastChars,
|
||||
allResources: key.allResources,
|
||||
expiresAt: key.expiresAt,
|
||||
lastUsedAt: key.lastUsedAt,
|
||||
createdAt: key.createdAt,
|
||||
createdByUserId: key.createdByUserId,
|
||||
resourceIds: key.resourceIds,
|
||||
userName: selectedUser?.text ?? null,
|
||||
username: null,
|
||||
userEmail: null,
|
||||
resourceNames,
|
||||
resources: key.resourceIds.map((id) => ({
|
||||
resourceId: id,
|
||||
name: resourceLookup.get(id)?.name ?? String(id),
|
||||
niceId: resourceLookup.get(id)?.niceId ?? ""
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Credenza
|
||||
open={open}
|
||||
onOpenChange={(val) => {
|
||||
setOpen(val);
|
||||
if (!val) {
|
||||
resetLocalState();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t("virtualApiKeysCreate")}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("virtualApiKeysCreateDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<div className="flex flex-col gap-y-4 px-1">
|
||||
{!credential && (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(
|
||||
handleFormSubmit
|
||||
)}
|
||||
className="space-y-4"
|
||||
id="virtual-api-key-form"
|
||||
>
|
||||
<HorizontalTabs
|
||||
clientSide={true}
|
||||
defaultTab={0}
|
||||
items={[
|
||||
{ title: t("general"), href: "#" },
|
||||
{
|
||||
title: t(
|
||||
"virtualApiKeysInferenceBudget"
|
||||
),
|
||||
href: "#"
|
||||
}
|
||||
]}
|
||||
>
|
||||
<div className="space-y-4 mt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"virtualApiKeysName"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"virtualApiKeysDescriptionOptional"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel>
|
||||
{t(
|
||||
"virtualApiKeysAssociateUserOptional"
|
||||
)}
|
||||
</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full justify-between",
|
||||
!selectedUser &&
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{selectedUser?.text
|
||||
? selectedUser.text
|
||||
: t(
|
||||
"userSelect"
|
||||
)}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0 w-[var(--radix-popover-trigger-width)]">
|
||||
<UserSelector
|
||||
orgId={
|
||||
org.org.orgId
|
||||
}
|
||||
selectedUser={
|
||||
selectedUser
|
||||
}
|
||||
onSelectUser={
|
||||
setSelectedUser
|
||||
}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"virtualApiKeysAssociateUserDescription"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start space-x-2">
|
||||
<Checkbox
|
||||
id="all-resources"
|
||||
checked={allResources}
|
||||
onCheckedChange={(
|
||||
val
|
||||
) => {
|
||||
setAllResources(
|
||||
val as boolean
|
||||
);
|
||||
if (val) {
|
||||
setSelectedResources(
|
||||
[]
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="all-resources"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{t(
|
||||
"virtualApiKeysAllResources"
|
||||
)}
|
||||
</label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"virtualApiKeysAllResourcesDescription"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!allResources && (
|
||||
<div className="space-y-2">
|
||||
<FormLabel>
|
||||
{t(
|
||||
"virtualApiKeysSelectResources"
|
||||
)}
|
||||
</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
asChild
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full justify-between",
|
||||
selectedResources.length ===
|
||||
0 &&
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-left">
|
||||
{formatMultiResourcesSelectorLabel(
|
||||
selectedResources,
|
||||
t,
|
||||
"virtualApiKeysSelectResourcesPlaceholder"
|
||||
)}
|
||||
</span>
|
||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||
<MultiResourcesSelector
|
||||
orgId={
|
||||
org.org
|
||||
.orgId
|
||||
}
|
||||
selectedResources={
|
||||
selectedResources
|
||||
}
|
||||
onSelectionChange={
|
||||
setSelectedResources
|
||||
}
|
||||
protocol="inference"
|
||||
showClear={
|
||||
selectedResources.length >
|
||||
0
|
||||
}
|
||||
onClear={() =>
|
||||
setSelectedResources(
|
||||
[]
|
||||
)
|
||||
}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"virtualApiKeysSelectResourcesDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<VirtualApiKeyEmailSection
|
||||
emailEnabled={
|
||||
env.email.emailEnabled
|
||||
}
|
||||
mode="create"
|
||||
sendEmail={sendEmail}
|
||||
onSendEmailChange={setSendEmail}
|
||||
sendToAttributedUser={
|
||||
sendToAttributedUser
|
||||
}
|
||||
onSendToAttributedUserChange={
|
||||
setSendToAttributedUser
|
||||
}
|
||||
hasAssociatedUser={
|
||||
!!selectedUser
|
||||
}
|
||||
emailTags={emailTags}
|
||||
onEmailTagsChange={setEmailTags}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 mt-4">
|
||||
<BudgetRowsFields
|
||||
rows={pendingBudgetRows}
|
||||
onChange={setPendingBudgetRows}
|
||||
attemptedSave={
|
||||
attemptedBudgetsSave
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</HorizontalTabs>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
{credential && (
|
||||
<div className="space-y-4">
|
||||
<p>{t("virtualApiKeysCopyKey")}</p>
|
||||
<CopyTextBox
|
||||
text={credential}
|
||||
wrapText={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={form.handleSubmit(handleFormSubmit)}
|
||||
loading={loading}
|
||||
disabled={credential !== null || loading}
|
||||
>
|
||||
{t("virtualApiKeysCreateButton")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "@app/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { CheckIcon, ChevronsUpDown } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export type DescribedSelectOption<TValue extends string> = {
|
||||
value: TValue;
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type DescribedSelectProps<TValue extends string> = {
|
||||
options: ReadonlyArray<DescribedSelectOption<TValue>>;
|
||||
value: TValue;
|
||||
onChange: (value: TValue) => void;
|
||||
searchPlaceholder: string;
|
||||
emptyMessage: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function DescribedSelect<TValue extends string>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
searchPlaceholder,
|
||||
emptyMessage,
|
||||
placeholder,
|
||||
disabled,
|
||||
className
|
||||
}: DescribedSelectProps<TValue>) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const selected = options.find((option) => option.value === value);
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", className)}>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"h-9 w-full justify-between font-normal",
|
||||
!selected && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-left">
|
||||
{selected?.title ?? placeholder}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder={searchPlaceholder} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{emptyMessage}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={`${option.value} ${option.title} ${option.description}`}
|
||||
onSelect={() => {
|
||||
onChange(option.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4 shrink-0",
|
||||
option.value === value
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="truncate">
|
||||
{option.title}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs leading-snug">
|
||||
{option.description}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+150
-128
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
@@ -38,20 +38,19 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { AxiosResponse } from "axios";
|
||||
import {
|
||||
AlertCircle,
|
||||
Building2,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
CheckIcon,
|
||||
ChevronsUpDown,
|
||||
ExternalLink,
|
||||
KeyRound,
|
||||
Zap
|
||||
Globe,
|
||||
KeyRound
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { usePaidStatus } from "@/hooks/usePaidStatus";
|
||||
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { toUnicode } from "punycode";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
|
||||
type AvailableOption = {
|
||||
@@ -164,8 +163,19 @@ export default function DomainPicker({
|
||||
const [selectedProvidedDomain, setSelectedProvidedDomain] =
|
||||
useState<AvailableOption | null>(null);
|
||||
|
||||
// Only run the initial base-domain selection once the domains have
|
||||
// loaded. This must not re-run on later `defaultDomainId`/`defaultSubdomain`
|
||||
// changes, because selecting a provided (namespace) domain calls
|
||||
// onDomainChange(null), which the parent form echoes back as
|
||||
// defaultDomainId/defaultSubdomain becoming undefined — re-running this
|
||||
// effect on that change would immediately snap the selector back to the
|
||||
// organization domain, making provided domains unselectable whenever one
|
||||
// was already set.
|
||||
const didSelectInitialDomainRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loadingDomains) {
|
||||
if (!loadingDomains && !didSelectInitialDomainRef.current) {
|
||||
didSelectInitialDomainRef.current = true;
|
||||
let domainOptionToSelect: DomainOption | null = null;
|
||||
if (organizationDomains.length > 0) {
|
||||
// Select the first organization domain or the one provided from props
|
||||
@@ -494,6 +504,30 @@ export default function DomainPicker({
|
||||
const hasMoreProvided =
|
||||
sortedAvailableOptions.length > providedDomainsShown;
|
||||
|
||||
const noDomainsAvailable =
|
||||
!loadingDomains &&
|
||||
organizationDomains.length === 0 &&
|
||||
(build === "oss" || hideFreeDomain || requiresPaywall);
|
||||
|
||||
if (noDomainsAvailable) {
|
||||
return (
|
||||
<Alert>
|
||||
<Globe className="h-4 w-4" />
|
||||
<AlertTitle>
|
||||
{t("domainPickerNoDomainsAvailableTitle")}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="space-y-3">
|
||||
<p>{t("domainPickerNoDomainsAvailableDescription")}</p>
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link href={`/${orgId}/settings/domains`}>
|
||||
{t("domainPickerNoDomainsAvailableAction")}
|
||||
</Link>
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
@@ -557,7 +591,6 @@ export default function DomainPicker({
|
||||
)}
|
||||
</p>
|
||||
<PaidFeaturesAlert
|
||||
showBookADemo={false}
|
||||
tiers={
|
||||
tierMatrix[
|
||||
TierFeature.WildcardSubdomain
|
||||
@@ -573,61 +606,72 @@ export default function DomainPicker({
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
{selectedBaseDomain ? (
|
||||
<div className="flex items-center gap-x-2 min-w-0 flex-1">
|
||||
{selectedBaseDomain.type ===
|
||||
"organization" ? null : (
|
||||
<Zap className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">
|
||||
{selectedBaseDomain.domain}
|
||||
</span>
|
||||
{selectedBaseDomain.verified &&
|
||||
selectedBaseDomain.domainType !==
|
||||
"wildcard" && (
|
||||
<CheckCircle2 className="h-3 w-3 text-green-500 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
t("domainPickerSelectBaseDomain")
|
||||
className={cn(
|
||||
"h-9 w-full justify-between font-normal",
|
||||
!selectedBaseDomain &&
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-left">
|
||||
{selectedBaseDomain
|
||||
? selectedBaseDomain.domain
|
||||
: t("domainPickerSelectBaseDomain")}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[400px] p-0" align="start">
|
||||
<Command className="rounded-lg">
|
||||
<PopoverContent
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={t("domainPickerSearchDomains")}
|
||||
className="border-0 focus:ring-0"
|
||||
/>
|
||||
<CommandEmpty className="py-6 text-center">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{t("domainPickerNoDomainsFound")}
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
|
||||
{organizationDomains.length > 0 && (
|
||||
<>
|
||||
</CommandEmpty>
|
||||
{organizationDomains.length > 0 && (
|
||||
<CommandGroup
|
||||
heading={t(
|
||||
"domainPickerOrganizationDomains"
|
||||
)}
|
||||
className="py-2"
|
||||
>
|
||||
<CommandList>
|
||||
{organizationDomains.map(
|
||||
(orgDomain) => (
|
||||
{organizationDomains.map(
|
||||
(orgDomain) => {
|
||||
const description =
|
||||
orgDomain.type ===
|
||||
"wildcard"
|
||||
? t(
|
||||
"domainPickerManual"
|
||||
)
|
||||
: `${orgDomain.type.toUpperCase()} · ${
|
||||
orgDomain.verified
|
||||
? t(
|
||||
"domainPickerVerified"
|
||||
)
|
||||
: t(
|
||||
"domainPickerUnverified"
|
||||
)
|
||||
}`;
|
||||
const optionId = `org-${orgDomain.domainId}`;
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={`org-${orgDomain.domainId}`}
|
||||
key={optionId}
|
||||
value={`${orgDomain.baseDomain} ${description}`}
|
||||
disabled={
|
||||
!orgDomain.verified
|
||||
}
|
||||
onSelect={() =>
|
||||
handleBaseDomainSelect(
|
||||
{
|
||||
id: `org-${orgDomain.domainId}`,
|
||||
id: optionId,
|
||||
domain: orgDomain.baseDomain,
|
||||
type: "organization",
|
||||
verified:
|
||||
@@ -639,80 +683,63 @@ export default function DomainPicker({
|
||||
}
|
||||
)
|
||||
}
|
||||
className="mx-2 rounded-md"
|
||||
disabled={
|
||||
!orgDomain.verified
|
||||
}
|
||||
>
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-muted mr-3">
|
||||
<Building2 className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<span className="font-medium truncate">
|
||||
{
|
||||
orgDomain.baseDomain
|
||||
}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{orgDomain.type ===
|
||||
"wildcard" ? (
|
||||
t(
|
||||
"domainPickerManual"
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{orgDomain.type.toUpperCase()}{" "}
|
||||
•{" "}
|
||||
{orgDomain.verified
|
||||
? t(
|
||||
"domainPickerVerified"
|
||||
)
|
||||
: t(
|
||||
"domainPickerUnverified"
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<Check
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"h-4 w-4 text-primary",
|
||||
"mr-2 h-4 w-4 shrink-0",
|
||||
selectedBaseDomain?.id ===
|
||||
`org-${orgDomain.domainId}`
|
||||
optionId
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="truncate">
|
||||
{
|
||||
orgDomain.baseDomain
|
||||
}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs leading-snug">
|
||||
{
|
||||
description
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
)
|
||||
)}
|
||||
</CommandList>
|
||||
</CommandGroup>
|
||||
{(build === "saas" ||
|
||||
build === "enterprise") &&
|
||||
!hideFreeDomain && (
|
||||
<CommandSeparator className="my-2" />
|
||||
);
|
||||
}
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{(build === "saas" || build === "enterprise") &&
|
||||
!hideFreeDomain && (
|
||||
<CommandGroup
|
||||
heading={
|
||||
build === "enterprise"
|
||||
? t(
|
||||
"domainPickerProvidedDomains"
|
||||
)
|
||||
: t(
|
||||
"domainPickerFreeDomains"
|
||||
)
|
||||
}
|
||||
className="py-2"
|
||||
>
|
||||
<CommandList>
|
||||
</CommandGroup>
|
||||
)}
|
||||
{organizationDomains.length > 0 &&
|
||||
(build === "saas" ||
|
||||
build === "enterprise") &&
|
||||
!hideFreeDomain && <CommandSeparator />}
|
||||
{(build === "saas" ||
|
||||
build === "enterprise") &&
|
||||
!hideFreeDomain && (
|
||||
<CommandGroup
|
||||
heading={
|
||||
build === "enterprise"
|
||||
? t(
|
||||
"domainPickerProvidedDomains"
|
||||
)
|
||||
: t(
|
||||
"domainPickerFreeDomains"
|
||||
)
|
||||
}
|
||||
>
|
||||
<CommandItem
|
||||
key="provided-search"
|
||||
value={`${
|
||||
build === "enterprise"
|
||||
? t(
|
||||
"domainPickerProvidedDomain"
|
||||
)
|
||||
: t(
|
||||
"domainPickerFreeProvidedDomain"
|
||||
)
|
||||
} ${t("domainPickerSearchForAvailableDomains")}`}
|
||||
disabled={requiresPaywall}
|
||||
onSelect={() =>
|
||||
handleBaseDomainSelect({
|
||||
id: "provided-search",
|
||||
@@ -728,14 +755,18 @@ export default function DomainPicker({
|
||||
type: "provided-search"
|
||||
})
|
||||
}
|
||||
className="mx-2 rounded-md"
|
||||
disabled={requiresPaywall}
|
||||
>
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 mr-3">
|
||||
<Zap className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<span className="font-medium truncate">
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4 shrink-0",
|
||||
selectedBaseDomain?.id ===
|
||||
"provided-search"
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="truncate">
|
||||
{build ===
|
||||
"enterprise"
|
||||
? t(
|
||||
@@ -745,25 +776,16 @@ export default function DomainPicker({
|
||||
"domainPickerFreeProvidedDomain"
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span className="text-muted-foreground text-xs leading-snug">
|
||||
{t(
|
||||
"domainPickerSearchForAvailableDomains"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<Check
|
||||
className={cn(
|
||||
"h-4 w-4 text-primary",
|
||||
selectedBaseDomain?.id ===
|
||||
"provided-search"
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
</CommandList>
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -15,6 +15,8 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { aiBudgetQueries } from "@app/lib/queries";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { Role } from "@server/db";
|
||||
import type { UpdateRoleBody, UpdateRoleResponse } from "@server/routers/role";
|
||||
import { AxiosResponse } from "axios";
|
||||
@@ -26,6 +28,7 @@ import {
|
||||
RoleForm,
|
||||
type RoleFormValues
|
||||
} from "./RoleForm";
|
||||
import { saveBudgetRows } from "./BudgetsEditor";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
type EditRoleFormProps = {
|
||||
@@ -44,6 +47,7 @@ export default function EditRoleForm({
|
||||
const t = useTranslations();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const queryClient = useQueryClient();
|
||||
const [loading, startTransition] = useTransition();
|
||||
|
||||
async function onSubmit(values: RoleFormValues) {
|
||||
@@ -55,7 +59,7 @@ export default function EditRoleForm({
|
||||
payload.name = values.name;
|
||||
payload.description = values.description || undefined;
|
||||
}
|
||||
if (isPaidUser(tierMatrix.advancedPrivateResources)) {
|
||||
if (isPaidUser(tierMatrix.roleBasedSSHControls)) {
|
||||
payload.sshSudoMode = values.sshSudoMode;
|
||||
payload.sshCreateHomeDir = values.sshCreateHomeDir;
|
||||
payload.sshSudoCommands =
|
||||
@@ -83,6 +87,31 @@ export default function EditRoleForm({
|
||||
});
|
||||
|
||||
if (res && res.status === 200) {
|
||||
if (values.budgets) {
|
||||
try {
|
||||
const scope = { type: "role" as const, id: role.roleId };
|
||||
const existingBudgets = await queryClient.fetchQuery(
|
||||
aiBudgetQueries.scoped({ scope })
|
||||
);
|
||||
await saveBudgetRows({
|
||||
api,
|
||||
orgId: role.orgId,
|
||||
scope,
|
||||
existingBudgets,
|
||||
rows: values.budgets
|
||||
});
|
||||
await queryClient.invalidateQueries(
|
||||
aiBudgetQueries.scoped({ scope })
|
||||
);
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("aiBudgetErrorSave"),
|
||||
description: formatAxiosError(e, t("aiBudgetErrorSave"))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
toast({
|
||||
variant: "default",
|
||||
title: t("accessRoleUpdated"),
|
||||
|
||||
@@ -0,0 +1,604 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Label } from "@app/components/ui/label";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { formatAxiosError, createApiClient } from "@app/lib/api";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { CaretSortIcon } from "@radix-ui/react-icons";
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { UserSelector, type SelectedUser } from "@app/components/user-selector";
|
||||
import type { CreateOrEditVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types";
|
||||
import { formatVirtualApiKeyCredential } from "@app/lib/virtualApiKeyFormat";
|
||||
import {
|
||||
MultiResourcesSelector,
|
||||
formatMultiResourcesSelectorLabel
|
||||
} from "@app/components/multi-resource-selector";
|
||||
import type { SelectedResource } from "@app/components/resource-selector";
|
||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||
import CopyTextBox from "@app/components/CopyTextBox";
|
||||
import type { CreatedVirtualApiKey } from "@app/components/CreateVirtualApiKeyForm";
|
||||
import type { GetVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types";
|
||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||
import {
|
||||
BudgetRowsFields,
|
||||
getBudgetRowsErrors,
|
||||
rowsFromBudgets,
|
||||
saveBudgetRows,
|
||||
type BudgetRow
|
||||
} from "@app/components/BudgetsEditor";
|
||||
import { aiBudgetQueries } from "@app/lib/queries";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import VirtualApiKeyEmailSection from "@app/components/VirtualApiKeyEmailSection";
|
||||
import type { Tag } from "@app/components/tags/tag-input";
|
||||
|
||||
type FormProps = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
virtualApiKey: CreatedVirtualApiKey | null;
|
||||
onUpdated?: (result: CreatedVirtualApiKey) => void;
|
||||
};
|
||||
|
||||
function resourcesFromRow(key: CreatedVirtualApiKey): SelectedResource[] {
|
||||
return key.resources.map((r) => ({
|
||||
resourceId: r.resourceId,
|
||||
name: r.name,
|
||||
niceId: r.niceId,
|
||||
fullDomain: null,
|
||||
ssl: false,
|
||||
wildcard: false
|
||||
}));
|
||||
}
|
||||
|
||||
function userFromRow(key: CreatedVirtualApiKey): SelectedUser | null {
|
||||
if (!key.userId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: key.userId,
|
||||
text: getUserDisplayName({
|
||||
email: key.userEmail,
|
||||
name: key.userName,
|
||||
username: key.username
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
export default function EditVirtualApiKeyForm({
|
||||
open,
|
||||
setOpen,
|
||||
virtualApiKey,
|
||||
onUpdated
|
||||
}: FormProps) {
|
||||
const { org } = useOrgContext();
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const t = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<SelectedUser | null>(null);
|
||||
const [selectedResources, setSelectedResources] = useState<
|
||||
SelectedResource[]
|
||||
>([]);
|
||||
const [credential, setCredential] = useState<string | null>(null);
|
||||
const [credentialLoading, setCredentialLoading] = useState(false);
|
||||
const [pendingBudgetRows, setPendingBudgetRows] = useState<BudgetRow[]>([]);
|
||||
const [attemptedBudgetsSave, setAttemptedBudgetsSave] = useState(false);
|
||||
const [sendEmail, setSendEmail] = useState(false);
|
||||
const [sendToAttributedUser, setSendToAttributedUser] = useState(false);
|
||||
const [emailTags, setEmailTags] = useState<Tag[]>([]);
|
||||
|
||||
const budgetScope = {
|
||||
type: "virtualApiKey" as const,
|
||||
id: virtualApiKey?.virtualApiKeyId ?? ""
|
||||
};
|
||||
const budgetsQuery = useQuery({
|
||||
...aiBudgetQueries.scoped({ scope: budgetScope }),
|
||||
enabled: open && !!virtualApiKey
|
||||
});
|
||||
|
||||
const formSchema = z
|
||||
.object({
|
||||
allResources: z.boolean()
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (!data.allResources && selectedResources.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("virtualApiKeysSelectResourcesRequired"),
|
||||
path: ["allResources"]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
allResources: false
|
||||
}
|
||||
});
|
||||
|
||||
const allResources = form.watch("allResources");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !virtualApiKey) {
|
||||
return;
|
||||
}
|
||||
setLoading(false);
|
||||
setSelectedUser(userFromRow(virtualApiKey));
|
||||
setSelectedResources(
|
||||
virtualApiKey.allResources ? [] : resourcesFromRow(virtualApiKey)
|
||||
);
|
||||
setSendEmail(false);
|
||||
setSendToAttributedUser(false);
|
||||
setEmailTags([]);
|
||||
form.reset({
|
||||
allResources: virtualApiKey.allResources
|
||||
});
|
||||
|
||||
let cancelled = false;
|
||||
setCredentialLoading(true);
|
||||
setCredential(null);
|
||||
|
||||
api.get<AxiosResponse<GetVirtualApiKeyResponse>>(
|
||||
`/virtual-api-key/${virtualApiKey.virtualApiKeyId}`
|
||||
)
|
||||
.then((res) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const secret = res.data.data.virtualApiKey.secret;
|
||||
if (secret) {
|
||||
setCredential(
|
||||
formatVirtualApiKeyCredential(
|
||||
virtualApiKey.virtualApiKeyId,
|
||||
secret
|
||||
)
|
||||
);
|
||||
} else {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("virtualApiKeysErrorFetchSecret"),
|
||||
description: t(
|
||||
"virtualApiKeysErrorFetchSecretDescription"
|
||||
)
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("virtualApiKeysErrorFetchSecret"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("virtualApiKeysErrorFetchSecretDescription")
|
||||
)
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setCredentialLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, virtualApiKey, form]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !budgetsQuery.data) {
|
||||
return;
|
||||
}
|
||||
setPendingBudgetRows(rowsFromBudgets(budgetsQuery.data));
|
||||
setAttemptedBudgetsSave(false);
|
||||
}, [open, budgetsQuery.data]);
|
||||
|
||||
function handleFormSubmit(values: z.infer<typeof formSchema>) {
|
||||
const { conflictingKeys, invalidAmountKeys } =
|
||||
getBudgetRowsErrors(pendingBudgetRows);
|
||||
if (conflictingKeys.size > 0 || invalidAmountKeys.size > 0) {
|
||||
setAttemptedBudgetsSave(true);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("aiBudgetErrorSave"),
|
||||
description: conflictingKeys.size
|
||||
? t("aiBudgetConflictError")
|
||||
: t("aiBudgetInvalidAmountError")
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
env.email.emailEnabled &&
|
||||
sendEmail &&
|
||||
!sendToAttributedUser &&
|
||||
emailTags.length === 0
|
||||
) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("virtualApiKeysEmailRecipientsRequired"),
|
||||
description: t("virtualApiKeysEmailRecipientsRequired")
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
return onSubmit(values);
|
||||
}
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
if (!virtualApiKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const res = await api
|
||||
.post<AxiosResponse<CreateOrEditVirtualApiKeyResponse>>(
|
||||
`/virtual-api-key/${virtualApiKey.virtualApiKeyId}`,
|
||||
{
|
||||
userId: selectedUser?.id ?? null,
|
||||
allResources: values.allResources,
|
||||
resourceIds: values.allResources
|
||||
? []
|
||||
: selectedResources.map((r) => r.resourceId),
|
||||
sendEmail: env.email.emailEnabled && sendEmail,
|
||||
sendToAttributedUser:
|
||||
env.email.emailEnabled &&
|
||||
sendEmail &&
|
||||
sendToAttributedUser,
|
||||
emails:
|
||||
env.email.emailEnabled && sendEmail
|
||||
? emailTags.map((tag) => tag.text)
|
||||
: []
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("virtualApiKeysErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("virtualApiKeysErrorUpdateDescription")
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
if (res?.data.data.virtualApiKey) {
|
||||
const key = res.data.data.virtualApiKey;
|
||||
|
||||
try {
|
||||
await saveBudgetRows({
|
||||
api,
|
||||
orgId: virtualApiKey.orgId,
|
||||
scope: budgetScope,
|
||||
existingBudgets: budgetsQuery.data ?? [],
|
||||
rows: pendingBudgetRows
|
||||
});
|
||||
await queryClient.invalidateQueries(
|
||||
aiBudgetQueries.scoped({ scope: budgetScope })
|
||||
);
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("aiBudgetErrorSave"),
|
||||
description: formatAxiosError(e, t("aiBudgetErrorSave"))
|
||||
});
|
||||
}
|
||||
|
||||
const resourceLookup = new Map(
|
||||
selectedResources.map((r) => [
|
||||
r.resourceId,
|
||||
{ name: r.name, niceId: r.niceId }
|
||||
])
|
||||
);
|
||||
const resourceNames = key.allResources
|
||||
? t("virtualApiKeysAllResources")
|
||||
: key.resourceIds
|
||||
.map((id) => resourceLookup.get(id)?.name)
|
||||
.filter(Boolean)
|
||||
.join(", ") || t("virtualApiKeysNoResources");
|
||||
|
||||
onUpdated?.({
|
||||
...virtualApiKey,
|
||||
userId: key.userId,
|
||||
allResources: key.allResources,
|
||||
resourceIds: key.resourceIds,
|
||||
userName: selectedUser?.text ?? null,
|
||||
username: null,
|
||||
userEmail: null,
|
||||
resourceNames,
|
||||
resources: key.resourceIds.map((id) => ({
|
||||
resourceId: id,
|
||||
name: resourceLookup.get(id)?.name ?? String(id),
|
||||
niceId: resourceLookup.get(id)?.niceId ?? ""
|
||||
}))
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t("virtualApiKeysUpdated"),
|
||||
description: t("virtualApiKeysUpdatedDescription")
|
||||
});
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Credenza
|
||||
open={open}
|
||||
onOpenChange={(val) => {
|
||||
setOpen(val);
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t("virtualApiKeysEdit")}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("virtualApiKeysEditDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<div className="flex flex-col gap-y-4 px-1">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleFormSubmit)}
|
||||
className="space-y-4"
|
||||
id="edit-virtual-api-key-form"
|
||||
>
|
||||
<HorizontalTabs
|
||||
clientSide={true}
|
||||
defaultTab={0}
|
||||
items={[
|
||||
{ title: t("general"), href: "#" },
|
||||
{
|
||||
title: t(
|
||||
"virtualApiKeysInferenceBudget"
|
||||
),
|
||||
href: "#"
|
||||
}
|
||||
]}
|
||||
>
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{t(
|
||||
"virtualApiKeysAssociateUserOptional"
|
||||
)}
|
||||
</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full justify-between",
|
||||
!selectedUser &&
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{selectedUser?.text
|
||||
? selectedUser.text
|
||||
: t("userSelect")}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0 w-[var(--radix-popover-trigger-width)]">
|
||||
<UserSelector
|
||||
orgId={org.org.orgId}
|
||||
selectedUser={
|
||||
selectedUser
|
||||
}
|
||||
onSelectUser={
|
||||
setSelectedUser
|
||||
}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"virtualApiKeysAssociateUserDescription"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="allResources"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-start space-x-2">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
id="edit-all-resources"
|
||||
checked={
|
||||
field.value
|
||||
}
|
||||
onCheckedChange={(
|
||||
val
|
||||
) => {
|
||||
field.onChange(
|
||||
val as boolean
|
||||
);
|
||||
if (
|
||||
val
|
||||
) {
|
||||
setSelectedResources(
|
||||
[]
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="edit-all-resources"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{t(
|
||||
"virtualApiKeysAllResources"
|
||||
)}
|
||||
</label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"virtualApiKeysAllResourcesDescription"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!allResources && (
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{t(
|
||||
"virtualApiKeysSelectResources"
|
||||
)}
|
||||
</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full justify-between",
|
||||
selectedResources.length ===
|
||||
0 &&
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-left">
|
||||
{formatMultiResourcesSelectorLabel(
|
||||
selectedResources,
|
||||
t,
|
||||
"virtualApiKeysSelectResourcesPlaceholder"
|
||||
)}
|
||||
</span>
|
||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||
<MultiResourcesSelector
|
||||
orgId={
|
||||
org.org
|
||||
.orgId
|
||||
}
|
||||
selectedResources={
|
||||
selectedResources
|
||||
}
|
||||
onSelectionChange={
|
||||
setSelectedResources
|
||||
}
|
||||
protocol="inference"
|
||||
showClear={
|
||||
selectedResources.length >
|
||||
0
|
||||
}
|
||||
onClear={() =>
|
||||
setSelectedResources(
|
||||
[]
|
||||
)
|
||||
}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"virtualApiKeysSelectResourcesRequired"
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<VirtualApiKeyEmailSection
|
||||
emailEnabled={
|
||||
env.email.emailEnabled
|
||||
}
|
||||
mode="edit"
|
||||
sendEmail={sendEmail}
|
||||
onSendEmailChange={setSendEmail}
|
||||
sendToAttributedUser={
|
||||
sendToAttributedUser
|
||||
}
|
||||
onSendToAttributedUserChange={
|
||||
setSendToAttributedUser
|
||||
}
|
||||
hasAssociatedUser={!!selectedUser}
|
||||
emailTags={emailTags}
|
||||
onEmailTagsChange={setEmailTags}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 mt-4">
|
||||
<BudgetRowsFields
|
||||
rows={pendingBudgetRows}
|
||||
onChange={setPendingBudgetRows}
|
||||
disabled={budgetsQuery.isLoading}
|
||||
attemptedSave={attemptedBudgetsSave}
|
||||
/>
|
||||
</div>
|
||||
</HorizontalTabs>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="submit"
|
||||
form="edit-virtual-api-key-form"
|
||||
loading={loading}
|
||||
disabled={loading || !virtualApiKey}
|
||||
>
|
||||
{t("virtualApiKeysSaveButton")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { Label } from "@app/components/ui/label";
|
||||
import {
|
||||
RolesSelector,
|
||||
type SelectedRole
|
||||
} from "@app/components/roles-selector";
|
||||
import {
|
||||
UsersSelector,
|
||||
type SelectedUser
|
||||
} from "@app/components/users-selector";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import type { EmailIdentityKeysResponse } from "@server/routers/virtualApiKey/types";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type EmailIdentityKeysFormProps = {
|
||||
orgId: string;
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export default function EmailIdentityKeysForm({
|
||||
orgId,
|
||||
open,
|
||||
setOpen
|
||||
}: EmailIdentityKeysFormProps) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const [sendToAll, setSendToAll] = useState(false);
|
||||
const [selectedUsers, setSelectedUsers] = useState<SelectedUser[]>([]);
|
||||
const [selectedRoles, setSelectedRoles] = useState<SelectedRole[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
function resetState() {
|
||||
setSendToAll(false);
|
||||
setSelectedUsers([]);
|
||||
setSelectedRoles([]);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (
|
||||
!sendToAll &&
|
||||
selectedUsers.length === 0 &&
|
||||
selectedRoles.length === 0
|
||||
) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("virtualApiKeysEmailIdentityRecipientsRequired"),
|
||||
description: t("virtualApiKeysEmailIdentityRecipientsRequired")
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.post<
|
||||
AxiosResponse<EmailIdentityKeysResponse>
|
||||
>(`/org/${orgId}/virtual-api-keys/email-identity-keys`, {
|
||||
sendToAll,
|
||||
userIds: sendToAll ? [] : selectedUsers.map((user) => user.id),
|
||||
roleIds: sendToAll
|
||||
? []
|
||||
: selectedRoles.map((role) => Number(role.id))
|
||||
});
|
||||
|
||||
const { sent, skipped } = res.data.data;
|
||||
toast({
|
||||
title: t("virtualApiKeysEmailIdentitySuccess"),
|
||||
description:
|
||||
skipped > 0
|
||||
? `${t("virtualApiKeysEmailIdentitySuccessDescription", { sent })} ${t("virtualApiKeysEmailIdentitySkipped", { skipped })}`
|
||||
: t("virtualApiKeysEmailIdentitySuccessDescription", {
|
||||
sent
|
||||
})
|
||||
});
|
||||
setOpen(false);
|
||||
resetState();
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("virtualApiKeysEmailIdentityError"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("virtualApiKeysEmailIdentityErrorDescription")
|
||||
)
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Credenza
|
||||
open={open}
|
||||
onOpenChange={(val) => {
|
||||
setOpen(val);
|
||||
if (!val) {
|
||||
resetState();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{t("virtualApiKeysEmailIdentity")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("virtualApiKeysEmailIdentityDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start space-x-2">
|
||||
<Checkbox
|
||||
id="email-identity-send-all"
|
||||
checked={sendToAll}
|
||||
onCheckedChange={(val) =>
|
||||
setSendToAll(val === true)
|
||||
}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor="email-identity-send-all"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{t("virtualApiKeysEmailIdentitySendAll")}
|
||||
</label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"virtualApiKeysEmailIdentitySendAllDescription"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{t("virtualApiKeysEmailIdentitySelectUsers")}
|
||||
</Label>
|
||||
<UsersSelector
|
||||
orgId={orgId}
|
||||
selectedUsers={selectedUsers}
|
||||
onSelectUsers={setSelectedUsers}
|
||||
disabled={sendToAll}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{t("virtualApiKeysEmailIdentitySelectRoles")}
|
||||
</Label>
|
||||
<RolesSelector
|
||||
orgId={orgId}
|
||||
selectedRoles={selectedRoles}
|
||||
onSelectRoles={setSelectedRoles}
|
||||
disabled={sendToAll}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("virtualApiKeysEmailIdentitySubmit")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import UptimeMiniBar from "@app/components/UptimeMiniBar";
|
||||
import { UptimeMiniBar } from "@app/components/UptimeMiniBar";
|
||||
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import HealthCheckCredenza, {
|
||||
@@ -51,6 +51,8 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
type StandaloneHealthChecksTableProps = {
|
||||
orgId: string;
|
||||
@@ -81,6 +83,8 @@ function formatTarget(row: HealthCheckRow): string {
|
||||
return `${scheme}://${host}${port}${path}`;
|
||||
}
|
||||
|
||||
const HEALTH_CHECK_STATUS_HISTORY_DAYS = 30;
|
||||
|
||||
export default function HealthChecksTable({
|
||||
orgId,
|
||||
healthChecks,
|
||||
@@ -157,6 +161,20 @@ export default function HealthChecksTable({
|
||||
|
||||
const rows = healthChecks;
|
||||
|
||||
const healthCheckIds = useMemo(
|
||||
() => rows.map((r) => r.targetHealthCheckId),
|
||||
[rows]
|
||||
);
|
||||
|
||||
const statusHistoryQuery = useQuery({
|
||||
...orgQueries.batchedHealthCheckStatusHistory({
|
||||
orgId,
|
||||
healthCheckIds,
|
||||
days: HEALTH_CHECK_STATUS_HISTORY_DAYS
|
||||
}),
|
||||
enabled: healthCheckIds.length > 0
|
||||
});
|
||||
|
||||
function refreshList() {
|
||||
startRefresh(() => {
|
||||
router.refresh();
|
||||
@@ -547,9 +565,13 @@ export default function HealthChecksTable({
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<UptimeMiniBar
|
||||
orgId={orgId}
|
||||
healthCheckId={row.original.targetHealthCheckId}
|
||||
days={30}
|
||||
isLoading={statusHistoryQuery.isLoading}
|
||||
data={
|
||||
statusHistoryQuery.data?.[
|
||||
row.original.targetHealthCheckId
|
||||
]
|
||||
}
|
||||
days={HEALTH_CHECK_STATUS_HISTORY_DAYS}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ export function HorizontalTabs({
|
||||
.replace("{userId}", params.userId as string)
|
||||
.replace("{clientId}", params.clientId as string)
|
||||
.replace("{apiKeyId}", params.apiKeyId as string)
|
||||
.replace("{providerId}", params.providerId as string)
|
||||
.replace("{remoteExitNodeId}", params.remoteExitNodeId as string);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionFooter
|
||||
} from "@app/components/Settings";
|
||||
import EmailIdentityKeysForm from "@app/components/EmailIdentityKeysForm";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { formatVirtualApiKeyCredential } from "@app/lib/virtualApiKeyFormat";
|
||||
import { ArrowRight, ExternalLink, Globe, KeyRound, Mail } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
const EXAMPLE_IDENTITY_KEY = formatVirtualApiKeyCredential(
|
||||
"k7m2n9qx",
|
||||
"a8f3c1e0b5d24791"
|
||||
);
|
||||
|
||||
type IdentityKeysSplashProps = {
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
export default function IdentityKeysSplash({ orgId }: IdentityKeysSplashProps) {
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
const [emailOpen, setEmailOpen] = useState(false);
|
||||
const emailEnabled = env.email.emailEnabled;
|
||||
|
||||
const dashboardUrl = env.app.dashboardUrl?.replace(/\/$/, "") ?? "";
|
||||
const keysPath = `/${orgId}/keys`;
|
||||
const keysUrl = dashboardUrl ? `${dashboardUrl}${keysPath}` : keysPath;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection>
|
||||
<SettingsSectionBody>
|
||||
<div className="flex flex-col items-center text-center py-6 md:py-10 px-2">
|
||||
<KeyRound className="h-8 w-8 text-primary" />
|
||||
<h2 className="mt-4 text-2xl font-semibold tracking-tight max-w-xl">
|
||||
{t("virtualApiKeysIdentitySplashTitle")}
|
||||
</h2>
|
||||
<p className="mt-3 text-sm text-muted-foreground max-w-lg">
|
||||
{t("virtualApiKeysIdentitySplashDescription")}
|
||||
</p>
|
||||
|
||||
<div className="mt-8 w-full max-w-lg text-left space-y-3">
|
||||
<p className="text-sm font-medium text-center">
|
||||
{t("virtualApiKeysIdentitySplashRetrieveTitle")}
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li className="flex items-start gap-2">
|
||||
<Globe className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
|
||||
<span>
|
||||
{t(
|
||||
"virtualApiKeysIdentitySplashRetrieveResource"
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<ExternalLink className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
|
||||
<span>
|
||||
{t.rich(
|
||||
"virtualApiKeysIdentitySplashRetrievePage",
|
||||
{
|
||||
url: () => (
|
||||
<Link
|
||||
href={keysPath}
|
||||
className="font-medium text-foreground underline underline-offset-4 break-all"
|
||||
>
|
||||
{keysUrl}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p className="mt-8 text-sm text-muted-foreground max-w-lg">
|
||||
{t("virtualApiKeysIdentitySplashManual")}
|
||||
</p>
|
||||
{!emailEnabled && (
|
||||
<p className="mt-3 text-sm text-muted-foreground max-w-lg">
|
||||
{t(
|
||||
"virtualApiKeysEmailSmtpRequiredDescription"
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSectionBody>
|
||||
<SettingsSectionFooter className="justify-center md:justify-center">
|
||||
<Button
|
||||
disabled={!emailEnabled}
|
||||
onClick={() => setEmailOpen(true)}
|
||||
>
|
||||
{t("virtualApiKeysEmailIdentity")}
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/${orgId}/settings/virtual-api-keys/keys`}>
|
||||
{t("virtualApiKeysIdentitySplashGoToVirtual")}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
<EmailIdentityKeysForm
|
||||
orgId={orgId}
|
||||
open={emailOpen}
|
||||
setOpen={setEmailOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { generateOidcUrlProxy } from "@app/actions/server";
|
||||
import IdpTypeIcon from "@app/components/IdpTypeIcon";
|
||||
import {
|
||||
generateOidcUrlProxy,
|
||||
type GenerateOidcUrlResponse
|
||||
} from "@app/actions/server";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
import { LAST_USED_IDP_COOKIE_NAME } from "@app/lib/consts";
|
||||
import { setClientCookie } from "@app/lib/setClientCookie";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
redirect as redirectTo,
|
||||
useParams,
|
||||
useRouter,
|
||||
useSearchParams
|
||||
} from "next/navigation";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
|
||||
export type LoginFormIDP = {
|
||||
idpId: number;
|
||||
name: string;
|
||||
variant?: string;
|
||||
lastUsed?: boolean;
|
||||
};
|
||||
|
||||
type IdpLoginButtonsProps = {
|
||||
idps: LoginFormIDP[];
|
||||
redirect?: string;
|
||||
orgId?: string;
|
||||
passOrgIdToOidcUrl?: boolean;
|
||||
};
|
||||
|
||||
export default function IdpLoginButtons({
|
||||
idps,
|
||||
redirect,
|
||||
orgId
|
||||
orgId,
|
||||
passOrgIdToOidcUrl = true
|
||||
}: IdpLoginButtonsProps) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const t = useTranslations();
|
||||
|
||||
const params = useSearchParams();
|
||||
@@ -52,23 +52,35 @@ export default function IdpLoginButtons({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [loading, startTransition] = useTransition();
|
||||
|
||||
async function loginWithIdp(idpId: number) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
setClientCookie(
|
||||
LAST_USED_IDP_COOKIE_NAME,
|
||||
JSON.stringify({
|
||||
orgId,
|
||||
idpId
|
||||
}),
|
||||
{
|
||||
sameSite: "Lax"
|
||||
}
|
||||
);
|
||||
|
||||
let redirectToUrl: string | undefined;
|
||||
try {
|
||||
console.log("generating", idpId, redirect || "/", orgId);
|
||||
const oidcOrgId = passOrgIdToOidcUrl ? orgId : undefined;
|
||||
console.log("generating", idpId, redirect || "/", oidcOrgId);
|
||||
const safeRedirect = cleanRedirect(redirect || "/");
|
||||
const response = await generateOidcUrlProxy(
|
||||
idpId,
|
||||
safeRedirect,
|
||||
orgId
|
||||
oidcOrgId
|
||||
);
|
||||
|
||||
if (response.error) {
|
||||
setError(response.message);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -84,7 +96,6 @@ export default function IdpLoginButtons({
|
||||
"An unexpected error occurred. Please try again."
|
||||
})
|
||||
);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
if (redirectToUrl) {
|
||||
@@ -106,41 +117,52 @@ export default function IdpLoginButtons({
|
||||
|
||||
<div className="space-y-4">
|
||||
{params.get("gotoapp") ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
goToApp();
|
||||
}}
|
||||
>
|
||||
{t("continueToApplication")}
|
||||
</Button>
|
||||
</>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
goToApp();
|
||||
}}
|
||||
>
|
||||
{t("continueToApplication")}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{idps.map((idp) => {
|
||||
const effectiveType =
|
||||
idp.variant || idp.name.toLowerCase();
|
||||
idps.map((idp) => {
|
||||
const effectiveType =
|
||||
idp.variant || idp.name.toLowerCase();
|
||||
|
||||
return (
|
||||
return (
|
||||
<div className="w-full relative" key={idp.idpId}>
|
||||
<Button
|
||||
key={idp.idpId}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full inline-flex items-center space-x-2"
|
||||
className="w-full inline-flex items-center space-x-2 after:absolute after:inset-0 after:z-10"
|
||||
onClick={() => {
|
||||
loginWithIdp(idp.idpId);
|
||||
startTransition(() =>
|
||||
loginWithIdp(idp.idpId)
|
||||
);
|
||||
}}
|
||||
disabled={loading}
|
||||
loading={loading}
|
||||
>
|
||||
<IdpTypeIcon type={effectiveType} size={16} />
|
||||
<IdpTypeIcon
|
||||
type={effectiveType}
|
||||
size={16}
|
||||
/>
|
||||
<span>{idp.name}</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
|
||||
{idp.lastUsed && (
|
||||
<div className="absolute inset-0">
|
||||
<span className="absolute top-0 right-0 text-xs bg-primary text-primary-foreground rounded-bl-sm rounded-tr-sm px-2 py-0.5">
|
||||
{t("idpLastUsed")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,8 @@ export default function IdpTypeIcon({
|
||||
}: Props) {
|
||||
const effectiveType = (variant || type || "").toLowerCase();
|
||||
|
||||
console.log(`[IdpTypeIcon]`, { effectiveType, variant, type });
|
||||
|
||||
let src: string | null = null;
|
||||
let defaultAlt = "";
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ export default function InviteStatusCard({
|
||||
| "user_does_not_exist"
|
||||
| "not_logged_in"
|
||||
| "user_limit_exceeded"
|
||||
| "oidc_not_allowed"
|
||||
>("rejected");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -69,6 +70,12 @@ export default function InviteStatusCard({
|
||||
function cardType() {
|
||||
if (error.includes("Invite is not for this user")) {
|
||||
return "wrong_user";
|
||||
} else if (
|
||||
error.includes(
|
||||
"Invites can only be accepted by internal users."
|
||||
)
|
||||
) {
|
||||
return "oidc_not_allowed";
|
||||
} else if (
|
||||
error.includes(
|
||||
"User does not exist. Please create an account first."
|
||||
@@ -93,14 +100,20 @@ export default function InviteStatusCard({
|
||||
setType(type);
|
||||
|
||||
if (!user && type === "user_does_not_exist") {
|
||||
const inviteRedirect = encodeURIComponent(
|
||||
`/invite?token=${tokenParam}`
|
||||
);
|
||||
const redirectUrl = email
|
||||
? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${email}`
|
||||
: `/auth/signup?redirect=/invite?token=${tokenParam}`;
|
||||
? `/auth/signup?redirect=${inviteRedirect}&email=${encodeURIComponent(email)}`
|
||||
: `/auth/signup?redirect=${inviteRedirect}`;
|
||||
router.push(redirectUrl);
|
||||
} else if (!user && type === "not_logged_in") {
|
||||
const inviteRedirect = encodeURIComponent(
|
||||
`/invite?token=${tokenParam}`
|
||||
);
|
||||
const redirectUrl = email
|
||||
? `/auth/login?redirect=/invite?token=${tokenParam}&user=${email}`
|
||||
: `/auth/login?redirect=/invite?token=${tokenParam}`;
|
||||
? `/auth/login?redirect=${inviteRedirect}&user=${encodeURIComponent(email)}`
|
||||
: `/auth/login?redirect=${inviteRedirect}`;
|
||||
router.push(redirectUrl);
|
||||
} else {
|
||||
setLoading(false);
|
||||
@@ -112,17 +125,23 @@ export default function InviteStatusCard({
|
||||
|
||||
async function goToLogin() {
|
||||
await api.post("/auth/logout", {});
|
||||
const inviteRedirect = encodeURIComponent(
|
||||
`/invite?token=${tokenParam}`
|
||||
);
|
||||
const redirectUrl = email
|
||||
? `/auth/login?redirect=/invite?token=${tokenParam}&user=${email}`
|
||||
: `/auth/login?redirect=/invite?token=${tokenParam}`;
|
||||
? `/auth/login?redirect=${inviteRedirect}&user=${encodeURIComponent(email)}`
|
||||
: `/auth/login?redirect=${inviteRedirect}`;
|
||||
router.push(redirectUrl);
|
||||
}
|
||||
|
||||
async function goToSignup() {
|
||||
await api.post("/auth/logout", {});
|
||||
const inviteRedirect = encodeURIComponent(
|
||||
`/invite?token=${tokenParam}`
|
||||
);
|
||||
const redirectUrl = email
|
||||
? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${email}`
|
||||
: `/auth/signup?redirect=/invite?token=${tokenParam}`;
|
||||
? `/auth/signup?redirect=${inviteRedirect}&email=${encodeURIComponent(email)}`
|
||||
: `/auth/signup?redirect=${inviteRedirect}`;
|
||||
router.push(redirectUrl);
|
||||
}
|
||||
|
||||
@@ -154,6 +173,14 @@ export default function InviteStatusCard({
|
||||
<p className="text-center">{t("inviteCreateUser")}</p>
|
||||
</div>
|
||||
);
|
||||
} else if (type === "oidc_not_allowed") {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-center mb-4">
|
||||
{t("inviteErrorOidcNotAllowed")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
} else if (type === "user_limit_exceeded") {
|
||||
return (
|
||||
<div>
|
||||
@@ -187,6 +214,10 @@ export default function InviteStatusCard({
|
||||
);
|
||||
} else if (type === "user_does_not_exist") {
|
||||
return <Button onClick={goToSignup}>{t("createAnAccount")}</Button>;
|
||||
} else if (type === "oidc_not_allowed") {
|
||||
return (
|
||||
<Button onClick={goToLogin}>{t("inviteLogInOtherUser")}</Button>
|
||||
);
|
||||
} else if (type === "user_limit_exceeded") {
|
||||
return (
|
||||
<Button
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React from "react";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import type {
|
||||
CommandBarNavSection,
|
||||
SidebarNavSection
|
||||
import {
|
||||
orgLangingNavItems,
|
||||
type CommandBarNavSection,
|
||||
type SidebarNavSection
|
||||
} from "@app/app/navigation";
|
||||
import type { SidebarNavItem } from "@app/components/SidebarNav";
|
||||
import { LayoutSidebar } from "@app/components/LayoutSidebar";
|
||||
import { LayoutHeader } from "@app/components/LayoutHeader";
|
||||
import { LayoutMobileMenu } from "@app/components/LayoutMobileMenu";
|
||||
@@ -46,6 +48,10 @@ export async function Layout({
|
||||
sidebarStateCookie === "collapsed" ||
|
||||
(sidebarStateCookie !== "expanded" && defaultSidebarCollapsed);
|
||||
|
||||
const launcherNavItems: SidebarNavItem[] = launcherMode
|
||||
? orgLangingNavItems
|
||||
: [];
|
||||
|
||||
return (
|
||||
<CommandPaletteProvider
|
||||
orgId={orgId}
|
||||
@@ -77,6 +83,7 @@ export async function Layout({
|
||||
orgId={orgId}
|
||||
orgs={orgs}
|
||||
navItems={navItems}
|
||||
launcherNavItems={launcherNavItems}
|
||||
showSidebar={showSidebar}
|
||||
showTopBar={showTopBar}
|
||||
launcherMode={launcherMode}
|
||||
@@ -92,6 +99,7 @@ export async function Layout({
|
||||
orgId={orgId}
|
||||
orgs={orgs}
|
||||
showViewAsAdmin={showViewAsAdmin}
|
||||
launcherNavItems={launcherNavItems}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import ProfileIcon from "@app/components/ProfileIcon";
|
||||
import ThemeSwitcher from "@app/components/ThemeSwitcher";
|
||||
import { useTheme } from "next-themes";
|
||||
@@ -13,6 +14,8 @@ import { LauncherOrgSelector } from "@app/components/resource-launcher/LauncherO
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { CommandPaletteTrigger } from "@app/components/command-palette/CommandPaletteTrigger";
|
||||
import type { SidebarNavItem } from "@app/components/SidebarNav";
|
||||
import { cn } from "@app/lib/cn";
|
||||
|
||||
type LayoutHeaderProps = {
|
||||
showTopBar: boolean;
|
||||
@@ -20,6 +23,7 @@ type LayoutHeaderProps = {
|
||||
orgId?: string;
|
||||
orgs?: ListUserOrgsResponse["orgs"];
|
||||
showViewAsAdmin?: boolean;
|
||||
launcherNavItems?: SidebarNavItem[];
|
||||
};
|
||||
|
||||
export function LayoutHeader({
|
||||
@@ -27,13 +31,15 @@ export function LayoutHeader({
|
||||
launcherMode = false,
|
||||
orgId,
|
||||
orgs,
|
||||
showViewAsAdmin = false
|
||||
showViewAsAdmin = false,
|
||||
launcherNavItems = []
|
||||
}: LayoutHeaderProps) {
|
||||
const { theme } = useTheme();
|
||||
const [path, setPath] = useState<string>("");
|
||||
const { env } = useEnvContext();
|
||||
const { isUnlocked } = useLicenseStatusContext();
|
||||
const t = useTranslations();
|
||||
const pathname = usePathname();
|
||||
|
||||
const logoWidth = isUnlocked()
|
||||
? env.branding.logo?.navbar?.width || 98
|
||||
@@ -85,6 +91,42 @@ export function LayoutHeader({
|
||||
orgId={orgId}
|
||||
orgs={orgs}
|
||||
/>
|
||||
{orgId
|
||||
? launcherNavItems
|
||||
.filter((item) => item.href)
|
||||
.map((item) => {
|
||||
const href =
|
||||
item.href!.replace(
|
||||
"{orgId}",
|
||||
orgId
|
||||
);
|
||||
const isActive =
|
||||
href === `/${orgId}`
|
||||
? pathname === href
|
||||
: pathname === href ||
|
||||
pathname?.startsWith(
|
||||
`${href}/`
|
||||
);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={href}
|
||||
variant="text"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"p-0",
|
||||
isActive &&
|
||||
"text-foreground font-medium underline-offset-4 underline"
|
||||
)}
|
||||
asChild
|
||||
>
|
||||
<Link href={href}>
|
||||
{t(item.title)}
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
{showViewAsAdmin && orgId ? (
|
||||
<Button
|
||||
variant="text"
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { SidebarNavSection } from "@app/app/navigation";
|
||||
import { CommandPaletteTrigger } from "@app/components/command-palette/CommandPaletteTrigger";
|
||||
import { OrgSelector } from "@app/components/OrgSelector";
|
||||
import ProfileIcon from "@app/components/ProfileIcon";
|
||||
import { SidebarNav } from "@app/components/SidebarNav";
|
||||
import { SidebarNav, type SidebarNavItem } from "@app/components/SidebarNav";
|
||||
import ThemeSwitcher from "@app/components/ThemeSwitcher";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
@@ -14,19 +14,18 @@ import {
|
||||
SheetTitle,
|
||||
SheetTrigger
|
||||
} from "@app/components/ui/sheet";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { Menu, Server, Settings, LayoutGrid } from "lucide-react";
|
||||
import { Menu, Settings } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
interface LayoutMobileMenuProps {
|
||||
orgId?: string;
|
||||
orgs?: ListUserOrgsResponse["orgs"];
|
||||
navItems: SidebarNavSection[];
|
||||
launcherNavItems?: SidebarNavItem[];
|
||||
showSidebar: boolean;
|
||||
showTopBar: boolean;
|
||||
launcherMode?: boolean;
|
||||
@@ -37,24 +36,15 @@ export function LayoutMobileMenu({
|
||||
orgId,
|
||||
orgs,
|
||||
navItems,
|
||||
launcherNavItems = [],
|
||||
showSidebar,
|
||||
showTopBar,
|
||||
launcherMode = false,
|
||||
showViewAsAdmin = false
|
||||
}: LayoutMobileMenuProps) {
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
const isAdminPage = pathname?.startsWith("/admin");
|
||||
const { user } = useUserContext();
|
||||
const t = useTranslations();
|
||||
const showMobileNav = showSidebar || launcherMode;
|
||||
const currentOrg = orgs?.find((org) => org.orgId === orgId);
|
||||
const isSettingsPage = Boolean(
|
||||
orgId && pathname?.includes(`/${orgId}/settings`)
|
||||
);
|
||||
const canViewResourceLauncher = Boolean(
|
||||
currentOrg?.isAdmin || currentOrg?.isOwner
|
||||
);
|
||||
|
||||
const mobileNavLinkClassName = cn(
|
||||
"flex items-center rounded transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-secondary/50 dark:hover:bg-secondary/20 rounded-md px-3 py-1.5"
|
||||
@@ -95,8 +85,55 @@ export function LayoutMobileMenu({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{showViewAsAdmin && orgId ? (
|
||||
<div className="px-3">
|
||||
<div className="px-3">
|
||||
{orgId
|
||||
? launcherNavItems
|
||||
.filter(
|
||||
(item) =>
|
||||
item.href
|
||||
)
|
||||
.map((item) => {
|
||||
const href =
|
||||
item.href!.replace(
|
||||
"{orgId}",
|
||||
orgId
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={href}
|
||||
className="mb-1"
|
||||
>
|
||||
<Link
|
||||
href={
|
||||
href
|
||||
}
|
||||
className={
|
||||
mobileNavLinkClassName
|
||||
}
|
||||
onClick={() =>
|
||||
setIsMobileMenuOpen(
|
||||
false
|
||||
)
|
||||
}
|
||||
>
|
||||
{item.icon ? (
|
||||
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground mr-3">
|
||||
{
|
||||
item.icon
|
||||
}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="flex-1">
|
||||
{t(
|
||||
item.title
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
{showViewAsAdmin && orgId ? (
|
||||
<div className="mb-1">
|
||||
<Link
|
||||
href={`/${orgId}/settings`}
|
||||
@@ -119,8 +156,8 @@ export function LayoutMobileMenu({
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -134,58 +171,6 @@ export function LayoutMobileMenu({
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto relative">
|
||||
<div className="px-3">
|
||||
{!isAdminPage &&
|
||||
isSettingsPage &&
|
||||
canViewResourceLauncher &&
|
||||
orgId && (
|
||||
<div className="mb-1">
|
||||
<Link
|
||||
href={`/${orgId}`}
|
||||
className={
|
||||
mobileNavLinkClassName
|
||||
}
|
||||
onClick={() =>
|
||||
setIsMobileMenuOpen(
|
||||
false
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground mr-3">
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
{t(
|
||||
"resourceSidebarLauncherTitle"
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{!isAdminPage &&
|
||||
user.serverAdmin && (
|
||||
<div className="mb-1">
|
||||
<Link
|
||||
href="/admin"
|
||||
className={
|
||||
mobileNavLinkClassName
|
||||
}
|
||||
onClick={() =>
|
||||
setIsMobileMenuOpen(
|
||||
false
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground mr-3">
|
||||
<Server className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
{t(
|
||||
"serverAdmin"
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<SidebarNav
|
||||
sections={navItems}
|
||||
onItemClick={() =>
|
||||
|
||||
@@ -18,13 +18,7 @@ import { approvalQueries } from "@app/lib/queries";
|
||||
import { build } from "@server/build";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import {
|
||||
ArrowRight,
|
||||
ExternalLink,
|
||||
LayoutGrid,
|
||||
PanelRightOpen,
|
||||
Server
|
||||
} from "lucide-react";
|
||||
import { ExternalLink, PanelRightOpen } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
@@ -136,13 +130,6 @@ export function LayoutSidebar({
|
||||
const showTrial =
|
||||
build === "saas" && Boolean(orgId) && subscriptionContext?.isTrial;
|
||||
|
||||
const isSettingsPage = Boolean(
|
||||
orgId && pathname?.includes(`/${orgId}/settings`)
|
||||
);
|
||||
const canViewResourceLauncher = Boolean(
|
||||
currentOrg?.isAdmin || currentOrg?.isOwner
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -165,107 +152,6 @@ export function LayoutSidebar({
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto relative">
|
||||
<div className="px-2 pt-3">
|
||||
{!isAdminPage &&
|
||||
isSettingsPage &&
|
||||
canViewResourceLauncher &&
|
||||
orgId && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
isSidebarCollapsed ? "mb-4" : "mb-1"
|
||||
)}
|
||||
>
|
||||
{isSidebarCollapsed ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={`/${orgId}`}
|
||||
className={cn(
|
||||
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 rounded-md px-2 py-2 justify-center"
|
||||
)}
|
||||
>
|
||||
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground">
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
</span>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
>
|
||||
<p>
|
||||
{t(
|
||||
"resourceSidebarLauncherTitle"
|
||||
)}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<Link
|
||||
href={`/${orgId}`}
|
||||
className={cn(
|
||||
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 rounded-md px-3 py-1.5"
|
||||
)}
|
||||
>
|
||||
<span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center text-muted-foreground">
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
{t("resourceSidebarLauncherTitle")}
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isAdminPage && user.serverAdmin && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
isSidebarCollapsed ? "mb-4" : "mb-1"
|
||||
)}
|
||||
>
|
||||
{isSidebarCollapsed ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href="/admin"
|
||||
className={cn(
|
||||
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 rounded-md px-2 py-2 justify-center"
|
||||
)}
|
||||
>
|
||||
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground">
|
||||
<Server className="h-4 w-4" />
|
||||
</span>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
>
|
||||
<p>{t("serverAdmin")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<Link
|
||||
href="/admin"
|
||||
className={cn(
|
||||
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 rounded-md px-3 py-1.5"
|
||||
)}
|
||||
>
|
||||
<span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center text-muted-foreground">
|
||||
<Server className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
{t("serverAdmin")}
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<SidebarNav
|
||||
sections={navItems}
|
||||
isCollapsed={isSidebarCollapsed}
|
||||
@@ -304,8 +190,9 @@ export function LayoutSidebar({
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"pt-1 flex flex-col shrink-0 gap-2 w-full border-t border-border",
|
||||
isSidebarCollapsed && "pb-2"
|
||||
"pt-1 flex flex-col shrink-0 gap-2 w-full",
|
||||
!isSidebarCollapsed && "border-t border-border",
|
||||
isSidebarCollapsed && "pb-4"
|
||||
)}
|
||||
>
|
||||
{canShowProductUpdates ? (
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import ActionBanner from "@app/components/ActionBanner";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { ArrowRight, ShieldAlert } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
|
||||
type LogRetentionWarningProps = {
|
||||
orgId: string;
|
||||
logTypeLabel: string;
|
||||
};
|
||||
|
||||
export function LogRetentionWarning({
|
||||
orgId,
|
||||
logTypeLabel
|
||||
}: LogRetentionWarningProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<ActionBanner
|
||||
variant="warning"
|
||||
title={t("logRetentionDisabledWarningTitle")}
|
||||
titleIcon={<ShieldAlert className="w-5 h-5" />}
|
||||
description={t("logRetentionDisabledWarningDescription", {
|
||||
logType: logTypeLabel
|
||||
})}
|
||||
actions={
|
||||
<Link href={`/${orgId}/settings/general/security`}>
|
||||
<Button variant="outline" className="gap-2">
|
||||
{t("logRetentionDisabledWarningButton")}
|
||||
<ArrowRight className="size-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default LogRetentionWarning;
|
||||
@@ -30,10 +30,7 @@ import Link from "next/link";
|
||||
import { GenerateOidcUrlResponse } from "@server/routers/idp";
|
||||
import { Separator } from "./ui/separator";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
generateOidcUrlProxy,
|
||||
loginProxy
|
||||
} from "@app/actions/server";
|
||||
import { generateOidcUrlProxy, loginProxy } from "@app/actions/server";
|
||||
import { redirect as redirectTo } from "next/navigation";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import IdpTypeIcon from "@app/components/IdpTypeIcon";
|
||||
@@ -41,11 +38,13 @@ import IdpTypeIcon from "@app/components/IdpTypeIcon";
|
||||
import { loadReoScript } from "reodotdev";
|
||||
import { build } from "@server/build";
|
||||
import MfaInputForm from "@app/components/MfaInputForm";
|
||||
import { useLocalStorage } from "@app/hooks/useLocalStorage";
|
||||
|
||||
export type LoginFormIDP = {
|
||||
idpId: number;
|
||||
name: string;
|
||||
variant?: string;
|
||||
lastUsed?: boolean;
|
||||
};
|
||||
|
||||
type LoginFormProps = {
|
||||
@@ -105,7 +104,6 @@ export default function LoginForm({
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.string().email({ message: t("emailInvalid") }),
|
||||
password: z.string().min(8, { message: t("passwordRequirementsChars") })
|
||||
@@ -130,11 +128,16 @@ export default function LoginForm({
|
||||
}
|
||||
});
|
||||
|
||||
const [lastUsedIdpId, setLastUsedIdpId] = useLocalStorage<string | null>(
|
||||
"login:last-used-idp",
|
||||
null
|
||||
);
|
||||
|
||||
async function onSubmit(values: any) {
|
||||
const { email, password } = form.getValues();
|
||||
const { code } = mfaForm.getValues();
|
||||
|
||||
setLastUsedIdpId(null);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
@@ -179,8 +182,7 @@ export default function LoginForm({
|
||||
if (data.useSecurityKey) {
|
||||
setError(
|
||||
t("securityKeyRequired", {
|
||||
defaultValue:
|
||||
"Please use your security key to sign in."
|
||||
defaultValue: "Please use your security key to sign in."
|
||||
})
|
||||
);
|
||||
return;
|
||||
@@ -242,6 +244,8 @@ export default function LoginForm({
|
||||
|
||||
async function loginWithIdp(idpId: number) {
|
||||
let redirectUrl: string | undefined;
|
||||
|
||||
setLastUsedIdpId(idpId.toString());
|
||||
try {
|
||||
const data = await generateOidcUrlProxy(
|
||||
idpId,
|
||||
@@ -356,7 +360,6 @@ export default function LoginForm({
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
|
||||
{!mfaRequested && (
|
||||
<>
|
||||
<SecurityKeyAuthButton
|
||||
@@ -385,25 +388,41 @@ export default function LoginForm({
|
||||
idp.variant || idp.name.toLowerCase();
|
||||
|
||||
return (
|
||||
<Button
|
||||
<div
|
||||
className="w-full relative"
|
||||
key={idp.idpId}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full inline-flex items-center space-x-2"
|
||||
onClick={() => {
|
||||
loginWithIdp(idp.idpId);
|
||||
}}
|
||||
>
|
||||
<IdpTypeIcon type={effectiveType} size={16} />
|
||||
<span>{idp.name}</span>
|
||||
</Button>
|
||||
<Button
|
||||
key={idp.idpId}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full inline-flex items-center space-x-2 after:absolute after:inset-0 after:z-10"
|
||||
onClick={() => {
|
||||
loginWithIdp(idp.idpId);
|
||||
}}
|
||||
>
|
||||
<IdpTypeIcon
|
||||
type={effectiveType}
|
||||
size={16}
|
||||
/>
|
||||
<span>{idp.name}</span>
|
||||
</Button>
|
||||
|
||||
{lastUsedIdpId ===
|
||||
idp.idpId.toString() && (
|
||||
<div className="absolute inset-0">
|
||||
<span className="absolute top-0 right-0 text-xs bg-primary text-primary-foreground rounded-bl-sm rounded-tr-sm px-2 py-0.5">
|
||||
{t("idpLastUsed")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -22,6 +22,8 @@ import Link from "next/link";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
import MfaInputForm from "@app/components/MfaInputForm";
|
||||
import { LAST_USED_IDP_COOKIE_NAME } from "@app/lib/consts";
|
||||
import { setClientCookie } from "@app/lib/setClientCookie";
|
||||
|
||||
type LoginPasswordFormProps = {
|
||||
identifier: string;
|
||||
@@ -82,6 +84,12 @@ export default function LoginPasswordForm({
|
||||
const { password } = values;
|
||||
const { code } = mfaForm.getValues();
|
||||
|
||||
// delete last used auth cookie by setting it in the past
|
||||
setClientCookie(LAST_USED_IDP_COOKIE_NAME, JSON.stringify(null), {
|
||||
sameSite: "Lax",
|
||||
days: -1
|
||||
});
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from "./ui/input-otp";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { REGEXP_ONLY_DIGITS } from "input-otp";
|
||||
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
|
||||
|
||||
const MFA_OTP_INPUT_ID = "mfa-otp-code";
|
||||
|
||||
@@ -82,9 +82,11 @@ export default function MfaInputForm({
|
||||
maxLength={6}
|
||||
{...field}
|
||||
autoComplete="one-time-code"
|
||||
inputMode="numeric"
|
||||
inputMode="text"
|
||||
autoFocus
|
||||
pattern={REGEXP_ONLY_DIGITS}
|
||||
pattern={
|
||||
REGEXP_ONLY_DIGITS_AND_CHARS
|
||||
}
|
||||
onChange={(value: string) => {
|
||||
field.onChange(value);
|
||||
if (value.length === 6) {
|
||||
|
||||
@@ -1,17 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||
import { IdpDataTable } from "@app/components/OrgIdpDataTable";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "@app/components/ui/command";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
@@ -22,37 +11,42 @@ import {
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { isIdpGlobalModeBannerVisible } from "@app/components/IdpGlobalModeBanner";
|
||||
import IdpTypeBadge from "@app/components/IdpTypeBadge";
|
||||
import IdpTypeIcon from "@app/components/IdpTypeIcon";
|
||||
import { IdpDataTable } from "@app/components/OrgIdpDataTable";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
ArrowRight,
|
||||
ArrowUpDown,
|
||||
MoreHorizontal
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import { useRouter } from "next/navigation";
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "@app/components/ui/command";
|
||||
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import IdpTypeBadge from "@app/components/IdpTypeBadge";
|
||||
import IdpTypeIcon from "@app/components/IdpTypeIcon";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import type { ListUserAdminOrgIdpsResponse } from "@server/routers/orgIdp/types";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { isIdpGlobalModeBannerVisible } from "@app/components/IdpGlobalModeBanner";
|
||||
import type { ListUserAdminOrgIdpsResponse } from "@server/routers/orgIdp/types";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useDebounce } from "use-debounce";
|
||||
|
||||
export type IdpRow = {
|
||||
idpId: number;
|
||||
@@ -483,15 +477,17 @@ export default function IdpTable({ idps, orgId }: Props) {
|
||||
{group.name}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{group.sources.map((src) => (
|
||||
<Badge
|
||||
key={src.orgId}
|
||||
variant="secondary"
|
||||
className="max-w-full truncate font-normal"
|
||||
>
|
||||
{src.orgName}
|
||||
</Badge>
|
||||
))}
|
||||
{group.sources.map(
|
||||
(src) => (
|
||||
<Badge
|
||||
key={src.orgId}
|
||||
variant="secondary"
|
||||
className="max-w-full truncate font-normal"
|
||||
>
|
||||
{src.orgName}
|
||||
</Badge>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
InfoSections,
|
||||
InfoSectionTitle
|
||||
} from "@app/components/InfoSection";
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type OrgInfoCardProps = {};
|
||||
@@ -26,7 +27,9 @@ export default function OrgInfoCard({}: OrgInfoCardProps) {
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("orgId")}</InfoSectionTitle>
|
||||
<InfoSectionContent>{org.org.orgId}</InfoSectionContent>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard text={org.org.orgId} />
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("subnet")}</InfoSectionTitle>
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "@app/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { build } from "@server/build";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { CheckIcon, Plus } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
|
||||
export type OrgPickerProps = {
|
||||
orgId?: string;
|
||||
orgs?: ListUserOrgsResponse["orgs"];
|
||||
contentClassName?: string;
|
||||
sideOffset?: number;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function OrgPicker({
|
||||
orgId,
|
||||
orgs,
|
||||
contentClassName,
|
||||
sideOffset = 0,
|
||||
children
|
||||
}: OrgPickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
const { user } = useUserContext();
|
||||
|
||||
let canCreateOrg = !env.flags.disableUserCreateOrg || user.serverAdmin;
|
||||
if (build === "saas" && user.type !== "internal") {
|
||||
canCreateOrg = false;
|
||||
}
|
||||
|
||||
const sortedOrgs = useMemo(() => {
|
||||
if (!orgs?.length) {
|
||||
return orgs ?? [];
|
||||
}
|
||||
return [...orgs].sort((a, b) => {
|
||||
const aPrimary = Boolean(a.isPrimaryOrg);
|
||||
const bPrimary = Boolean(b.isPrimaryOrg);
|
||||
if (aPrimary && !bPrimary) return -1;
|
||||
if (!aPrimary && bPrimary) return 1;
|
||||
return 0;
|
||||
});
|
||||
}, [orgs]);
|
||||
|
||||
function selectOrg(nextOrgId: string) {
|
||||
setOpen(false);
|
||||
const newPath = pathname.includes("/settings/")
|
||||
? pathname.replace(/^\/[^/]+/, `/${nextOrgId}`)
|
||||
: `/${nextOrgId}`;
|
||||
router.push(newPath);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>{children}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className={cn("p-0", contentClassName)}
|
||||
align="start"
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder={t("searchPlaceholder")} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("orgNotFound2")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{sortedOrgs.map((org) => (
|
||||
<CommandItem
|
||||
key={org.orgId}
|
||||
value={`${org.orgId} ${org.name}`}
|
||||
onSelect={() => selectOrg(org.orgId)}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4 shrink-0",
|
||||
orgId === org.orgId
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="truncate">
|
||||
{org.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs leading-snug">
|
||||
{org.orgId}
|
||||
{org.isPrimaryOrg
|
||||
? ` · ${t("primary")}`
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
{canCreateOrg && (
|
||||
<div className="border-t p-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start font-normal"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
router.push("/setup");
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-3" />
|
||||
{t("setupNewOrg")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -12,8 +12,8 @@ import { Shield, ArrowRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useState } from "react";
|
||||
import { logoutProxy } from "@app/actions/server";
|
||||
|
||||
type OrgPolicyRequiredProps = {
|
||||
orgId: string;
|
||||
@@ -40,21 +40,23 @@ export default function OrgPolicyRequired({
|
||||
}: OrgPolicyRequiredProps) {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const sessionExpired =
|
||||
policies?.maxSessionLength &&
|
||||
policies.maxSessionLength.compliant === false;
|
||||
|
||||
function reauthenticate() {
|
||||
api.post("/auth/logout")
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
const destination = redirectAfterAuth ?? `/${orgId}`;
|
||||
router.push(destination);
|
||||
router.refresh();
|
||||
});
|
||||
async function reauthenticate() {
|
||||
setLoading(true);
|
||||
try {
|
||||
await logoutProxy();
|
||||
} catch (error) {
|
||||
console.error("Error during logout:", error);
|
||||
} finally {
|
||||
const destination = redirectAfterAuth ?? `/${orgId}`;
|
||||
router.push(destination);
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionExpired) {
|
||||
@@ -76,6 +78,7 @@ export default function OrgPolicyRequired({
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={reauthenticate}
|
||||
loading={loading}
|
||||
>
|
||||
{t("reauthenticate")}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
|
||||
@@ -9,17 +9,15 @@ import {
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { useRef } from "react";
|
||||
import type { FieldValues, Path, UseFormReturn } from "react-hook-form";
|
||||
import { RolesSelector, type SelectedRole } from "./roles-selector";
|
||||
|
||||
type OrgRolesTagFieldProps<TFieldValues extends FieldValues> = {
|
||||
form: Pick<
|
||||
UseFormReturn<TFieldValues>,
|
||||
"control" | "getValues" | "setValue"
|
||||
"control" | "getValues" | "setValue" | "clearErrors"
|
||||
>;
|
||||
orgId: string;
|
||||
/** Field in the form that holds Tag[] (role tags). Default: `"roles"`. */
|
||||
@@ -42,46 +40,6 @@ export default function OrgRolesTagField<TFieldValues extends FieldValues>({
|
||||
disabled
|
||||
}: OrgRolesTagFieldProps<TFieldValues>) {
|
||||
const t = useTranslations();
|
||||
const isPopoverOpenRef = useRef(false);
|
||||
const lastValidRolesRef = useRef<SelectedRole[]>(
|
||||
(form.getValues(name) as SelectedRole[]) ?? []
|
||||
);
|
||||
|
||||
function validateRolesSelection() {
|
||||
const current = form.getValues(name) as SelectedRole[];
|
||||
|
||||
if (current.length === 0 && lastValidRolesRef.current.length > 0) {
|
||||
form.setValue(name, lastValidRolesRef.current as never, {
|
||||
shouldDirty: true
|
||||
});
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("accessRoleRequired"),
|
||||
description: t("accessRoleSelectPlease")
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (current.length > 0) {
|
||||
lastValidRolesRef.current = current;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function handlePopoverOpenChange(open: boolean) {
|
||||
isPopoverOpenRef.current = open;
|
||||
|
||||
if (open) {
|
||||
const current = form.getValues(name) as SelectedRole[];
|
||||
if (current.length > 0) {
|
||||
lastValidRolesRef.current = current;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
validateRolesSelection();
|
||||
}
|
||||
|
||||
function setRoleTags(nextValue: SelectedRole[]) {
|
||||
const prev = form.getValues(name) as SelectedRole[];
|
||||
@@ -99,15 +57,14 @@ export default function OrgRolesTagField<TFieldValues extends FieldValues>({
|
||||
form.setValue(name, [prev[prev.length - 1]] as never, {
|
||||
shouldDirty: true
|
||||
});
|
||||
form.clearErrors(name);
|
||||
return;
|
||||
}
|
||||
|
||||
form.setValue(name, next as never, { shouldDirty: true });
|
||||
|
||||
if (next.length > 0 && !isPopoverOpenRef.current) {
|
||||
lastValidRolesRef.current = next;
|
||||
} else if (!isPopoverOpenRef.current) {
|
||||
validateRolesSelection();
|
||||
if (next.length > 0) {
|
||||
form.clearErrors(name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,9 +74,6 @@ export default function OrgRolesTagField<TFieldValues extends FieldValues>({
|
||||
name={name}
|
||||
render={({ field }) => {
|
||||
const selectedRoles = (field.value ?? []) as SelectedRole[];
|
||||
if (!isPopoverOpenRef.current && selectedRoles.length > 0) {
|
||||
lastValidRolesRef.current = selectedRoles;
|
||||
}
|
||||
|
||||
return (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
@@ -129,7 +83,6 @@ export default function OrgRolesTagField<TFieldValues extends FieldValues>({
|
||||
orgId={orgId}
|
||||
selectedRoles={selectedRoles}
|
||||
onSelectRoles={setRoleTags}
|
||||
onPopoverOpenChange={handlePopoverOpenChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
+40
-163
@@ -1,199 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "@app/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { OrgPicker } from "@app/components/OrgPicker";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
} from "@app/components/ui/tooltip";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { Check, ChevronsUpDown, Plus, Building2, Users } from "lucide-react";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import { Building2, ChevronsUpDown } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { build } from "@server/build";
|
||||
|
||||
interface OrgSelectorProps {
|
||||
type OrgSelectorProps = {
|
||||
orgId?: string;
|
||||
orgs?: ListUserOrgsResponse["orgs"];
|
||||
isCollapsed?: boolean;
|
||||
}
|
||||
};
|
||||
|
||||
export function OrgSelector({
|
||||
orgId,
|
||||
orgs,
|
||||
isCollapsed = false
|
||||
}: OrgSelectorProps) {
|
||||
const { user } = useUserContext();
|
||||
const [open, setOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { env } = useEnvContext();
|
||||
const t = useTranslations();
|
||||
|
||||
const selectedOrg = orgs?.find((org) => org.orgId === orgId);
|
||||
|
||||
let canCreateOrg = !env.flags.disableUserCreateOrg || user.serverAdmin;
|
||||
if (build === "saas" && user.type !== "internal") {
|
||||
canCreateOrg = false;
|
||||
}
|
||||
|
||||
const sortedOrgs = useMemo(() => {
|
||||
if (!orgs?.length) return orgs ?? [];
|
||||
return [...orgs].sort((a, b) => {
|
||||
const aPrimary = Boolean(a.isPrimaryOrg);
|
||||
const bPrimary = Boolean(b.isPrimaryOrg);
|
||||
if (aPrimary && !bPrimary) return -1;
|
||||
if (!aPrimary && bPrimary) return 1;
|
||||
return 0;
|
||||
});
|
||||
}, [orgs]);
|
||||
|
||||
const orgSelectorContent = (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"cursor-pointer transition-colors",
|
||||
isCollapsed
|
||||
? "w-full h-16 flex items-center justify-center hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50"
|
||||
: "w-full px-5 py-4 hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50"
|
||||
)}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<Building2 className="h-4 w-4" />
|
||||
) : (
|
||||
<div className="flex items-center justify-between w-full min-w-0">
|
||||
<div className="flex items-center min-w-0 flex-1">
|
||||
<div className="flex flex-col items-start min-w-0 flex-1 gap-1">
|
||||
<span className="font-semibold">
|
||||
{t("org")}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground truncate w-full text-left">
|
||||
{selectedOrg?.name || t("noneSelected")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50 ml-2" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[320px] p-0 ml-4 flex flex-col relative overflow-visible"
|
||||
align="start"
|
||||
sideOffset={12}
|
||||
const picker = (
|
||||
<OrgPicker
|
||||
orgId={orgId}
|
||||
orgs={orgs}
|
||||
contentClassName={
|
||||
isCollapsed
|
||||
? "w-[320px]"
|
||||
: "w-[var(--radix-popover-trigger-width)]"
|
||||
}
|
||||
>
|
||||
<div
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"cursor-pointer transition-colors",
|
||||
isCollapsed
|
||||
? "w-full h-16 flex items-center justify-center hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50"
|
||||
: "w-full px-5 py-4 hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50"
|
||||
)}
|
||||
>
|
||||
<Command className="rounded-lg border-0 flex-1 min-h-0">
|
||||
<CommandInput
|
||||
placeholder={t("searchPlaceholder")}
|
||||
className="border-0 focus:ring-0 h-9 rounded-b-none"
|
||||
/>
|
||||
<CommandList className="max-h-[280px]">
|
||||
<CommandEmpty className="py-4 text-center">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{t("orgNotFound2")}
|
||||
{isCollapsed ? (
|
||||
<Building2 className="h-4 w-4" />
|
||||
) : (
|
||||
<div className="flex items-center justify-between w-full min-w-0">
|
||||
<div className="flex items-center min-w-0 flex-1">
|
||||
<div className="flex flex-col items-start min-w-0 flex-1 gap-1">
|
||||
<span className="font-semibold">
|
||||
{t("org")}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground truncate w-full text-left">
|
||||
{selectedOrg?.name || t("noneSelected")}
|
||||
</span>
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
<CommandGroup className="p-1" heading={t("orgs")}>
|
||||
{sortedOrgs.map((org) => (
|
||||
<CommandItem
|
||||
key={org.orgId}
|
||||
onSelect={() => {
|
||||
setOpen(false);
|
||||
const newPath = pathname.includes(
|
||||
"/settings/"
|
||||
)
|
||||
? pathname.replace(
|
||||
/^\/[^/]+/,
|
||||
`/${org.orgId}`
|
||||
)
|
||||
: `/${org.orgId}`;
|
||||
router.push(newPath);
|
||||
}}
|
||||
className="mx-1 rounded-md py-1.5 h-auto min-h-0"
|
||||
>
|
||||
<div className="flex items-center justify-center w-6 h-6 rounded-md bg-muted mr-2.5 flex-shrink-0">
|
||||
<Users className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 min-w-0 gap-0.5">
|
||||
<span className="font-medium truncate text-sm">
|
||||
{org.name}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-xs text-muted-foreground font-mono truncate">
|
||||
{org.orgId}
|
||||
</span>
|
||||
{org.isPrimaryOrg && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="shrink-0 text-[10px] px-1.5 py-0 font-medium ml-auto"
|
||||
>
|
||||
{t("primary")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Check
|
||||
className={cn(
|
||||
"h-4 w-4 text-primary flex-shrink-0",
|
||||
orgId === org.orgId
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
{canCreateOrg && (
|
||||
<div className="p-2 border-t border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start h-8 font-normal text-muted-foreground"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
router.push("/setup");
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 mr-2" />
|
||||
{t("setupNewOrg")}
|
||||
</Button>
|
||||
</div>
|
||||
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50 ml-2" />
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</OrgPicker>
|
||||
);
|
||||
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{orgSelectorContent}
|
||||
</TooltipTrigger>
|
||||
<TooltipTrigger asChild>{picker}</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
<div className="text-center">
|
||||
<p className="font-medium">
|
||||
@@ -209,5 +86,5 @@ export function OrgSelector({
|
||||
);
|
||||
}
|
||||
|
||||
return orgSelectorContent;
|
||||
return picker;
|
||||
}
|
||||
|
||||
@@ -45,11 +45,11 @@ const bannerClassName =
|
||||
"mb-6 border-black-500/30 bg-linear-to-br from-black-500/10 via-background to-background overflow-hidden";
|
||||
const bannerContentClassName = "py-3 px-4";
|
||||
const bannerRowClassName =
|
||||
"flex items-center gap-2.5 text-sm text-muted-foreground";
|
||||
"flex items-center gap-2.5 text-sm text-muted-foreground whitespace-nowrap";
|
||||
const bannerIconClassName = "size-4 shrink-0 text-black-500";
|
||||
const bannerTextClassName = "whitespace-nowrap shrink-0";
|
||||
const docsLinkClassName =
|
||||
"inline-flex items-center gap-1 font-medium text-black-600 underline";
|
||||
const PANGOLIN_CLOUD_SIGNUP_URL = "https://app.pangolin.net/auth/signup/";
|
||||
const ENTERPRISE_DOCS_URL =
|
||||
"https://docs.pangolin.net/self-host/enterprise-edition";
|
||||
const BOOK_A_DEMO_URL = "https://click.fossorial.io/ep922";
|
||||
@@ -64,34 +64,21 @@ function getTierLinkRenderer(billingHref: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function getPangolinCloudLinkRenderer() {
|
||||
return function pangolinCloudLinkRenderer(chunks: React.ReactNode) {
|
||||
return (
|
||||
<Link
|
||||
href={PANGOLIN_CLOUD_SIGNUP_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={docsLinkClassName}
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function getBookADemoLinkRenderer() {
|
||||
return function bookADemoLinkRenderer(chunks: React.ReactNode) {
|
||||
return (
|
||||
<Link
|
||||
href={BOOK_A_DEMO_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={docsLinkClassName}
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</Link>
|
||||
<span className="whitespace-nowrap">
|
||||
<Link
|
||||
href={BOOK_A_DEMO_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={docsLinkClassName}
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -114,10 +101,9 @@ function getDocsLinkRenderer(href: string) {
|
||||
|
||||
type Props = {
|
||||
tiers: Tier[];
|
||||
showBookADemo?: boolean;
|
||||
};
|
||||
|
||||
export function PaidFeaturesAlert({ tiers, showBookADemo = true }: Props) {
|
||||
export function PaidFeaturesAlert({ tiers }: Props) {
|
||||
const t = useTranslations();
|
||||
const params = useParams();
|
||||
const orgId = params?.orgId as string | undefined;
|
||||
@@ -133,11 +119,8 @@ export function PaidFeaturesAlert({ tiers, showBookADemo = true }: Props) {
|
||||
? `/${orgId}/settings/billing`
|
||||
: "https://pangolin.net/pricing";
|
||||
const tierLinkRenderer = getTierLinkRenderer(billingHref);
|
||||
const pangolinCloudLinkRenderer = getPangolinCloudLinkRenderer();
|
||||
const enterpriseDocsLinkRenderer = getDocsLinkRenderer(ENTERPRISE_DOCS_URL);
|
||||
const bookADemoLinkRenderer = showBookADemo
|
||||
? getBookADemoLinkRenderer()
|
||||
: () => null;
|
||||
const bookADemoLinkRenderer = getBookADemoLinkRenderer();
|
||||
|
||||
if (env.flags.disableEnterpriseFeatures) {
|
||||
return null;
|
||||
@@ -150,17 +133,12 @@ export function PaidFeaturesAlert({ tiers, showBookADemo = true }: Props) {
|
||||
<CardContent className={bannerContentClassName}>
|
||||
<div className={bannerRowClassName}>
|
||||
<KeyRound className={bannerIconClassName} />
|
||||
<span>
|
||||
<span className={bannerTextClassName}>
|
||||
{requiredTiersLabel
|
||||
? isActive
|
||||
? t.rich("upgradeToTierToUse", {
|
||||
tier: requiredTiersLabel,
|
||||
tierLink: tierLinkRenderer
|
||||
})
|
||||
: t.rich("upgradeToTierToUse", {
|
||||
tier: requiredTiersLabel,
|
||||
tierLink: tierLinkRenderer
|
||||
})
|
||||
? t.rich("upgradeToTierToUse", {
|
||||
tier: requiredTiersLabel,
|
||||
tierLink: tierLinkRenderer
|
||||
})
|
||||
: isActive
|
||||
? t("mustUpgradeToUse")
|
||||
: t("subscriptionRequiredToUse")}
|
||||
@@ -170,34 +148,16 @@ export function PaidFeaturesAlert({ tiers, showBookADemo = true }: Props) {
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{build === "enterprise" && !hasEnterpriseLicense ? (
|
||||
{(build === "enterprise" || build === "oss") &&
|
||||
!hasEnterpriseLicense ? (
|
||||
<Card className={bannerClassName}>
|
||||
<CardContent className={bannerContentClassName}>
|
||||
<div className={bannerRowClassName}>
|
||||
<KeyRound className={bannerIconClassName} />
|
||||
<span>
|
||||
{t.rich("licenseRequiredToUse", {
|
||||
enterpriseLicenseLink:
|
||||
enterpriseDocsLinkRenderer,
|
||||
pangolinCloudLink: pangolinCloudLinkRenderer,
|
||||
bookADemoLink: bookADemoLinkRenderer
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{build === "oss" && !hasEnterpriseLicense ? (
|
||||
<Card className={bannerClassName}>
|
||||
<CardContent className={bannerContentClassName}>
|
||||
<div className={bannerRowClassName}>
|
||||
<KeyRound className={bannerIconClassName} />
|
||||
<span>
|
||||
<span className={bannerTextClassName}>
|
||||
{t.rich("ossEnterpriseEditionRequired", {
|
||||
enterpriseEditionLink:
|
||||
enterpriseDocsLinkRenderer,
|
||||
pangolinCloudLink: pangolinCloudLinkRenderer,
|
||||
bookADemoLink: bookADemoLinkRenderer
|
||||
})}
|
||||
</span>
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import SiteResourcesOverview from "@app/components/SiteResourcesOverview";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
@@ -24,6 +34,7 @@ import {
|
||||
ArrowUp10Icon,
|
||||
ArrowUpRight,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronsUpDownIcon,
|
||||
MoreHorizontal,
|
||||
X
|
||||
@@ -65,8 +76,10 @@ export default function PendingSitesTable({
|
||||
const [isRefreshing, startTransition] = useTransition();
|
||||
const [approvingIds, setApprovingIds] = useState<Set<number>>(new Set());
|
||||
const [rejectingIds, setRejectingIds] = useState<Set<number>>(new Set());
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isRejectModalOpen, setIsRejectModalOpen] = useState(false);
|
||||
const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null);
|
||||
const [resourcesDialogSite, setResourcesDialogSite] =
|
||||
useState<SiteRow | null>(null);
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const t = useTranslations();
|
||||
@@ -111,7 +124,7 @@ export default function PendingSitesTable({
|
||||
async function approveSite(siteId: number) {
|
||||
setApprovingIds((prev) => new Set(prev).add(siteId));
|
||||
try {
|
||||
await api.post(`/site/${siteId}`, { status: "approved" });
|
||||
await api.post(`/site/${siteId}/approve`);
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("siteApproveSuccess"),
|
||||
@@ -136,20 +149,20 @@ export default function PendingSitesTable({
|
||||
async function rejectSite(siteId: number) {
|
||||
setRejectingIds((prev) => new Set(prev).add(siteId));
|
||||
try {
|
||||
await api.delete(`/site/${siteId}`);
|
||||
await api.post(`/site/${siteId}/reject`);
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("siteDeleted"),
|
||||
description: t("siteRejectSuccess"),
|
||||
variant: "default"
|
||||
});
|
||||
setIsDeleteModalOpen(false);
|
||||
setIsRejectModalOpen(false);
|
||||
setSelectedSite(null);
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("siteErrorDelete"),
|
||||
description: formatAxiosError(e, t("siteErrorDelete"))
|
||||
title: t("siteRejectError"),
|
||||
description: formatAxiosError(e, t("siteRejectError"))
|
||||
});
|
||||
} finally {
|
||||
setRejectingIds((prev) => {
|
||||
@@ -342,6 +355,29 @@ export default function PendingSitesTable({
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "resources",
|
||||
accessorKey: "resourceCount",
|
||||
friendlyName: t("resources"),
|
||||
header: () => <span className="p-3">{t("resources")}</span>,
|
||||
cell: ({ row }) => {
|
||||
const siteRow = row.original;
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setResourcesDialogSite(siteRow)}
|
||||
className="flex h-8 items-center gap-2 px-0 font-normal"
|
||||
>
|
||||
<span className="text-sm tabular-nums">
|
||||
{siteRow.resourceCount} {t("resources")}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "exitNode",
|
||||
friendlyName: t("exitNode"),
|
||||
@@ -445,7 +481,7 @@ export default function PendingSitesTable({
|
||||
disabled={isApproving || isRejecting}
|
||||
onClick={() => {
|
||||
setSelectedSite(siteRow);
|
||||
setIsDeleteModalOpen(true);
|
||||
setIsRejectModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<X className="mr-2 w-4 h-4" />
|
||||
@@ -491,25 +527,63 @@ export default function PendingSitesTable({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Credenza
|
||||
open={Boolean(resourcesDialogSite)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setResourcesDialogSite(null);
|
||||
}}
|
||||
>
|
||||
<CredenzaContent className="md:max-w-7xl">
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t("siteResourcesTab")}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("siteResourcesDialogDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
{resourcesDialogSite != null && (
|
||||
<SiteResourcesOverview
|
||||
orgIdOverride={orgId}
|
||||
siteId={resourcesDialogSite.id}
|
||||
initialPublicData={null}
|
||||
initialPrivateData={null}
|
||||
initialPublicForbidden={false}
|
||||
initialPrivateForbidden={false}
|
||||
showViewAllLinks={false}
|
||||
/>
|
||||
)}
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setResourcesDialogSite(null)}
|
||||
>
|
||||
{t("close")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
|
||||
{selectedSite && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
open={isRejectModalOpen}
|
||||
setOpen={(val) => {
|
||||
setIsDeleteModalOpen(val);
|
||||
setIsRejectModalOpen(val);
|
||||
if (!val) {
|
||||
setSelectedSite(null);
|
||||
}
|
||||
}}
|
||||
dialog={
|
||||
<div className="space-y-2">
|
||||
<p>{t("siteQuestionRemove")}</p>
|
||||
<p>{t("siteMessageRemove")}</p>
|
||||
<p>{t("siteQuestionReject")}</p>
|
||||
<p>{t("siteMessageReject")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("siteConfirmDelete")}
|
||||
buttonText={t("siteConfirmReject")}
|
||||
onConfirm={async () => rejectSite(selectedSite.id)}
|
||||
string={selectedSite.name}
|
||||
title={t("siteDelete")}
|
||||
title={t("siteReject")}
|
||||
/>
|
||||
)}
|
||||
<ControlledDataTable
|
||||
|
||||
@@ -55,6 +55,7 @@ function getActionsCategories(root: boolean) {
|
||||
[t("actionGetSite")]: "getSite",
|
||||
[t("actionListSites")]: "listSites",
|
||||
[t("actionUpdateSite")]: "updateSite",
|
||||
[t("actionUpdateSiteApprovals")]: "updateSiteApprovals",
|
||||
[t("actionListSiteRoles")]: "listSiteRoles"
|
||||
},
|
||||
|
||||
@@ -78,7 +79,10 @@ function getActionsCategories(root: boolean) {
|
||||
[t("actionGetSiteResource")]: "getSiteResource",
|
||||
[t("actionListSiteResources")]: "listSiteResources",
|
||||
[t("actionUpdateSiteResource")]: "updateSiteResource",
|
||||
[t("actionCreateResourceSessionToken")]: "createResourceSessionToken"
|
||||
[t("actionListResourceAiModels")]: "listResourceAiModels",
|
||||
[t("actionSetResourceAiModels")]: "setResourceAiModels",
|
||||
[t("actionCreateResourceSessionToken")]:
|
||||
"createResourceSessionToken"
|
||||
},
|
||||
|
||||
Target: {
|
||||
@@ -113,8 +117,11 @@ function getActionsCategories(root: boolean) {
|
||||
},
|
||||
|
||||
"Resource Policy": {
|
||||
[t("actionListResourcePolicies")]: "listResourcePolicies",
|
||||
[t("actionCreateResourcePolicy")]: "createResourcePolicy",
|
||||
[t("actionGetResourcePolicy")]: "getResourcePolicy",
|
||||
[t("actionUpdateResourcePolicy")]: "updateResourcePolicy",
|
||||
[t("actionDeleteResourcePolicy")]: "deleteResourcePolicy",
|
||||
[t("actionSetResourcePolicyUsers")]: "setResourcePolicyUsers",
|
||||
[t("actionSetResourcePolicyRoles")]: "setResourcePolicyRoles",
|
||||
[t("actionSetResourcePolicyPassword")]: "setResourcePolicyPassword",
|
||||
@@ -141,6 +148,45 @@ function getActionsCategories(root: boolean) {
|
||||
Logs: {
|
||||
[t("actionExportLogs")]: "exportLogs",
|
||||
[t("actionViewLogs")]: "viewLogs"
|
||||
},
|
||||
|
||||
"Site Provisioning Key": {
|
||||
[t("actionCreateSiteProvisioningKey")]: "createSiteProvisioningKey",
|
||||
[t("actionListSiteProvisioningKeys")]: "listSiteProvisioningKeys",
|
||||
[t("actionUpdateSiteProvisioningKey")]: "updateSiteProvisioningKey",
|
||||
[t("actionDeleteSiteProvisioningKey")]: "deleteSiteProvisioningKey"
|
||||
},
|
||||
|
||||
"AI Provider": {
|
||||
[t("actionCreateAiProvider")]: "createAiProvider",
|
||||
[t("actionDeleteAiProvider")]: "deleteAiProvider",
|
||||
[t("actionGetAiProvider")]: "getAiProvider",
|
||||
[t("actionListAiProviders")]: "listAiProviders",
|
||||
[t("actionUpdateAiProvider")]: "updateAiProvider"
|
||||
},
|
||||
|
||||
"AI Model": {
|
||||
[t("actionCreateAiModel")]: "createAiModel",
|
||||
[t("actionDeleteAiModel")]: "deleteAiModel",
|
||||
[t("actionGetAiModel")]: "getAiModel",
|
||||
[t("actionListAiModels")]: "listAiModels",
|
||||
[t("actionUpdateAiModel")]: "updateAiModel"
|
||||
},
|
||||
|
||||
"AI Budget": {
|
||||
[t("actionCreateAiBudget")]: "createAiBudget",
|
||||
[t("actionDeleteAiBudget")]: "deleteAiBudget",
|
||||
[t("actionGetAiBudget")]: "getAiBudget",
|
||||
[t("actionListAiBudgets")]: "listAiBudgets",
|
||||
[t("actionUpdateAiBudget")]: "updateAiBudget"
|
||||
},
|
||||
|
||||
"Virtual API Key": {
|
||||
[t("actionCreateVirtualApiKey")]: "createVirtualApiKey",
|
||||
[t("actionDeleteVirtualApiKey")]: "deleteVirtualApiKey",
|
||||
[t("actionGetVirtualApiKey")]: "getVirtualApiKey",
|
||||
[t("actionListVirtualApiKeys")]: "listVirtualApiKeys",
|
||||
[t("actionUpdateVirtualApiKey")]: "updateVirtualApiKey"
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { MachinesSelector } from "@app/components/machines-selector";
|
||||
import { RolesSelector } from "@app/components/roles-selector";
|
||||
import { UsersSelector } from "@app/components/users-selector";
|
||||
import { SettingsFormCell, SettingsFormGrid } from "@app/components/Settings";
|
||||
import type { Tag } from "@app/components/tags/tag-input";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import type { PrivateResourceClient } from "@app/lib/privateResourceForm";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { Control } from "react-hook-form";
|
||||
|
||||
type AccessFormValues = {
|
||||
roles?: Tag[];
|
||||
users?: Tag[];
|
||||
clients?: PrivateResourceClient[];
|
||||
};
|
||||
|
||||
type PrivateResourceAccessFieldsProps = {
|
||||
control: Control<AccessFormValues>;
|
||||
orgId: string;
|
||||
loading?: boolean;
|
||||
hasMachineClients?: boolean;
|
||||
};
|
||||
|
||||
export function PrivateResourceAccessFields({
|
||||
control,
|
||||
orgId,
|
||||
loading = false,
|
||||
hasMachineClients = false
|
||||
}: PrivateResourceAccessFieldsProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground">{t("loading")}</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={control}
|
||||
name="roles"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t("roles")}</FormLabel>
|
||||
<FormControl>
|
||||
<RolesSelector
|
||||
selectedRoles={field.value ?? []}
|
||||
orgId={orgId}
|
||||
restrictAdminRole
|
||||
onSelectRoles={(newRoles) => {
|
||||
field.onChange(newRoles);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={control}
|
||||
name="users"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t("users")}</FormLabel>
|
||||
<UsersSelector
|
||||
selectedUsers={field.value ?? []}
|
||||
orgId={orgId}
|
||||
onSelectUsers={(newUsers) => {
|
||||
field.onChange(newUsers);
|
||||
}}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
{hasMachineClients && (
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={control}
|
||||
name="clients"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>{t("machineClients")}</FormLabel>
|
||||
<MachinesSelector
|
||||
selectedMachines={field.value ?? []}
|
||||
orgId={orgId}
|
||||
onSelectMachines={(machines) => {
|
||||
field.onChange(machines);
|
||||
}}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
</SettingsFormGrid>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
"use client";
|
||||
|
||||
import { SettingsFormCell, SettingsFormGrid } from "@app/components/Settings";
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { Control, UseFormWatch } from "react-hook-form";
|
||||
|
||||
type PrivateResourceAliasFieldProps = {
|
||||
control: Control<any>;
|
||||
watch: UseFormWatch<any>;
|
||||
labelPrefix?: "create" | "edit";
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function PrivateResourceAliasField({
|
||||
control,
|
||||
watch,
|
||||
labelPrefix = "edit",
|
||||
disabled = false
|
||||
}: PrivateResourceAliasFieldProps) {
|
||||
const t = useTranslations();
|
||||
const aliasLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogAlias"
|
||||
: "editInternalResourceDialogAlias";
|
||||
const aliasDescriptionKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogAliasDescription"
|
||||
: "editInternalResourceDialogAliasDescription";
|
||||
|
||||
const aliasValue = watch("alias");
|
||||
const aliasEndsWithLocal =
|
||||
typeof aliasValue === "string" &&
|
||||
aliasValue.trim().toLowerCase().endsWith(".local");
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name="alias"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t(aliasLabelKey)}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="w-full"
|
||||
value={field.value ?? ""}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormControl>
|
||||
{aliasEndsWithLocal && (
|
||||
<p className="text-xs text-amber-700/80 mt-1">
|
||||
{t("internalResourceAliasLocalWarning")}
|
||||
</p>
|
||||
)}
|
||||
<FormMessage />
|
||||
<FormDescription>{t(aliasDescriptionKey)}</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type PrivateResourceHostDestinationFieldsProps = {
|
||||
control: Control<any>;
|
||||
watch: UseFormWatch<any>;
|
||||
labelPrefix?: "create" | "edit";
|
||||
hideAlias?: boolean;
|
||||
};
|
||||
|
||||
type PrivateResourceInferenceDestinationFieldsProps = {
|
||||
control: Control<any>;
|
||||
watch: UseFormWatch<any>;
|
||||
labelPrefix?: "create" | "edit";
|
||||
hideAlias?: boolean;
|
||||
};
|
||||
|
||||
export function PrivateResourceInferenceDestinationFields({
|
||||
control,
|
||||
watch,
|
||||
labelPrefix = "edit"
|
||||
}: PrivateResourceInferenceDestinationFieldsProps) {
|
||||
const t = useTranslations();
|
||||
const destinationLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogDestination"
|
||||
: "editInternalResourceDialogDestination";
|
||||
|
||||
return (
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceAliasField
|
||||
control={control}
|
||||
watch={watch}
|
||||
labelPrefix={labelPrefix}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
);
|
||||
}
|
||||
|
||||
export function PrivateResourceHostDestinationFields({
|
||||
control,
|
||||
watch,
|
||||
labelPrefix = "edit",
|
||||
hideAlias = false
|
||||
}: PrivateResourceHostDestinationFieldsProps) {
|
||||
const t = useTranslations();
|
||||
const destinationLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogDestination"
|
||||
: "editInternalResourceDialogDestination";
|
||||
|
||||
const destinationField = (
|
||||
<FormField
|
||||
control={control}
|
||||
name="destination"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t(destinationLabelKey)}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="w-full"
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value === ""
|
||||
? null
|
||||
: e.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
if (hideAlias) {
|
||||
return destinationField;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="half">{destinationField}</SettingsFormCell>
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceAliasField
|
||||
control={control}
|
||||
watch={watch}
|
||||
labelPrefix={labelPrefix}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</SettingsFormGrid>
|
||||
);
|
||||
}
|
||||
|
||||
export function PrivateResourceCidrDestinationField({
|
||||
control,
|
||||
labelPrefix = "edit"
|
||||
}: {
|
||||
control: Control<any>;
|
||||
labelPrefix?: "create" | "edit";
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const destinationLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogDestination"
|
||||
: "editInternalResourceDialogDestination";
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name="destination"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t(destinationLabelKey)}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="w-full"
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value === ""
|
||||
? null
|
||||
: e.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
"use client";
|
||||
|
||||
import DomainPicker from "@app/components/DomainPicker";
|
||||
import {
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSubsectionDescription,
|
||||
SettingsSubsectionHeader,
|
||||
SettingsSubsectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { Control, UseFormSetValue, UseFormWatch } from "react-hook-form";
|
||||
|
||||
type PrivateResourceHttpFieldsProps = {
|
||||
control: Control<any>;
|
||||
setValue: UseFormSetValue<any>;
|
||||
orgId: string;
|
||||
watch: UseFormWatch<any>;
|
||||
disabled?: boolean;
|
||||
siteResourceId?: number;
|
||||
labelPrefix?: "create" | "edit";
|
||||
hideDomainPicker?: boolean;
|
||||
hidePaidFeaturesAlert?: boolean;
|
||||
};
|
||||
|
||||
export function PrivateResourceHttpFields({
|
||||
control,
|
||||
setValue,
|
||||
orgId,
|
||||
watch,
|
||||
disabled = false,
|
||||
siteResourceId,
|
||||
labelPrefix = "edit",
|
||||
hideDomainPicker = false
|
||||
}: PrivateResourceHttpFieldsProps) {
|
||||
const t = useTranslations();
|
||||
const schemeLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogScheme"
|
||||
: "editInternalResourceDialogScheme";
|
||||
const destinationLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogDestination"
|
||||
: "editInternalResourceDialogDestination";
|
||||
const destinationPortLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogModePort"
|
||||
: "editInternalResourceDialogModePort";
|
||||
const httpConfigurationTitleKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogHttpConfiguration"
|
||||
: "editInternalResourceDialogHttpConfiguration";
|
||||
const httpConfigurationDescriptionKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogHttpConfigurationDescription"
|
||||
: "editInternalResourceDialogHttpConfigurationDescription";
|
||||
const enableSslLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogEnableSsl"
|
||||
: "editInternalResourceDialogEnableSsl";
|
||||
const enableSslDescriptionKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogEnableSslDescription"
|
||||
: "editInternalResourceDialogEnableSslDescription";
|
||||
|
||||
const httpConfigSubdomain = watch("httpConfigSubdomain");
|
||||
const httpConfigDomainId = watch("httpConfigDomainId");
|
||||
const httpConfigFullDomain = watch("httpConfigFullDomain");
|
||||
|
||||
return (
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="quarter">
|
||||
<FormField
|
||||
control={control}
|
||||
name="scheme"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t(schemeLabelKey)}</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value ?? "http"}
|
||||
disabled={disabled}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="http">http</SelectItem>
|
||||
<SelectItem value="https">https</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={control}
|
||||
name="destination"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t(destinationLabelKey)}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="w-full"
|
||||
value={field.value ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value === ""
|
||||
? null
|
||||
: e.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="quarter">
|
||||
<FormField
|
||||
control={control}
|
||||
name="destinationPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t(destinationPortLabelKey)}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="w-full"
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={field.value ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
if (raw === "") {
|
||||
field.onChange(null);
|
||||
return;
|
||||
}
|
||||
const n = Number(raw);
|
||||
field.onChange(
|
||||
Number.isFinite(n) ? n : null
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
{!hideDomainPicker && (
|
||||
<>
|
||||
<SettingsFormCell span="full">
|
||||
<SettingsSubsectionHeader>
|
||||
<SettingsSubsectionTitle>
|
||||
{t(httpConfigurationTitleKey)}
|
||||
</SettingsSubsectionTitle>
|
||||
<SettingsSubsectionDescription>
|
||||
{t(httpConfigurationDescriptionKey)}
|
||||
</SettingsSubsectionDescription>
|
||||
</SettingsSubsectionHeader>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="full">
|
||||
<div
|
||||
className={
|
||||
disabled
|
||||
? "pointer-events-none opacity-50"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<DomainPicker
|
||||
key={
|
||||
siteResourceId
|
||||
? `http-domain-${siteResourceId}`
|
||||
: "http-domain-create"
|
||||
}
|
||||
orgId={orgId}
|
||||
cols={2}
|
||||
hideFreeDomain
|
||||
defaultSubdomain={
|
||||
httpConfigSubdomain ?? undefined
|
||||
}
|
||||
defaultDomainId={
|
||||
httpConfigDomainId ?? undefined
|
||||
}
|
||||
defaultFullDomain={
|
||||
httpConfigFullDomain ?? undefined
|
||||
}
|
||||
onDomainChange={(res) => {
|
||||
if (res === null) {
|
||||
setValue("httpConfigSubdomain", null);
|
||||
setValue("httpConfigDomainId", null);
|
||||
setValue("httpConfigFullDomain", null);
|
||||
return;
|
||||
}
|
||||
setValue(
|
||||
"httpConfigSubdomain",
|
||||
res.subdomain ?? null
|
||||
);
|
||||
setValue(
|
||||
"httpConfigDomainId",
|
||||
res.domainId
|
||||
);
|
||||
setValue(
|
||||
"httpConfigFullDomain",
|
||||
res.fullDomain
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={control}
|
||||
name="ssl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="private-resource-ssl"
|
||||
label={t(enableSslLabelKey)}
|
||||
description={t(
|
||||
enableSslDescriptionKey
|
||||
)}
|
||||
checked={!!field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</>
|
||||
)}
|
||||
|
||||
{hideDomainPicker && (
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={control}
|
||||
name="ssl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="private-resource-ssl"
|
||||
label={t(enableSslLabelKey)}
|
||||
description={t(enableSslDescriptionKey)}
|
||||
checked={!!field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
</SettingsFormGrid>
|
||||
);
|
||||
}
|
||||
+20
-14
@@ -18,7 +18,6 @@ import {
|
||||
type LauncherAccessFields
|
||||
} from "@app/lib/launcherResourceAccess";
|
||||
import type { PrivateResourceMode } from "@app/lib/privateResourceForm";
|
||||
import { build } from "@server/build";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type SiteResourceInfoInput = {
|
||||
@@ -80,7 +79,7 @@ function AccessMethodContent({
|
||||
);
|
||||
}
|
||||
|
||||
export function SiteResourceInfoSections({
|
||||
export function PrivateResourceInfoSections({
|
||||
siteResource,
|
||||
access,
|
||||
variant,
|
||||
@@ -93,7 +92,8 @@ export function SiteResourceInfoSections({
|
||||
host: t("editInternalResourceDialogModeHost"),
|
||||
cidr: t("editInternalResourceDialogModeCidr"),
|
||||
http: t("editInternalResourceDialogModeHttp"),
|
||||
ssh: t("editInternalResourceDialogModeSsh")
|
||||
ssh: t("editInternalResourceDialogModeSsh"),
|
||||
inference: t("editInternalResourceDialogModeInference")
|
||||
};
|
||||
|
||||
const destination = formatSiteResourceDestinationDisplay({
|
||||
@@ -108,24 +108,31 @@ export function SiteResourceInfoSections({
|
||||
udpPortRangeString: siteResource.udpPortRangeString ?? "*"
|
||||
});
|
||||
const showAlias =
|
||||
siteResource.mode !== "cidr" && siteResource.mode !== "http";
|
||||
const showDestination = !(
|
||||
siteResource.mode === "ssh" && siteResource.authDaemonMode === "native"
|
||||
);
|
||||
siteResource.mode !== "cidr" &&
|
||||
siteResource.mode !== "http" &&
|
||||
siteResource.mode !== "inference";
|
||||
const showDestination =
|
||||
!(
|
||||
siteResource.mode === "ssh" &&
|
||||
siteResource.authDaemonMode === "native"
|
||||
) && siteResource.mode !== "inference";
|
||||
const showCertificate = !!(
|
||||
siteResource.mode === "http" &&
|
||||
(siteResource.mode === "http" || siteResource.mode === "inference") &&
|
||||
siteResource.ssl &&
|
||||
siteResource.domainId &&
|
||||
siteResource.fullDomain &&
|
||||
build != "oss"
|
||||
siteResource.fullDomain
|
||||
);
|
||||
const showPortRestrictions =
|
||||
isPanel &&
|
||||
siteResource.mode !== "http" &&
|
||||
siteResource.mode !== "inference";
|
||||
|
||||
const numSections =
|
||||
2 +
|
||||
(showDestination ? 1 : 0) +
|
||||
(showAlias ? 1 : 0) +
|
||||
(showCertificate ? 1 : 0) +
|
||||
(isPanel ? 1 : 0);
|
||||
(showPortRestrictions ? 1 : 0);
|
||||
|
||||
const sections = (
|
||||
<InfoSections cols={numSections} layout={isPanel ? "panel" : "default"}>
|
||||
@@ -182,7 +189,6 @@ export function SiteResourceInfoSections({
|
||||
orgId={siteResource.orgId}
|
||||
domainId={siteResource.domainId!}
|
||||
fullDomain={siteResource.fullDomain!}
|
||||
autoFetch={true}
|
||||
showLabel={false}
|
||||
polling={true}
|
||||
/>
|
||||
@@ -190,7 +196,7 @@ export function SiteResourceInfoSections({
|
||||
</InfoSection>
|
||||
) : null}
|
||||
|
||||
{isPanel ? (
|
||||
{showPortRestrictions ? (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("portRestrictions")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
@@ -258,7 +264,7 @@ export default function SiteResourceInfoBox({
|
||||
});
|
||||
|
||||
return (
|
||||
<SiteResourceInfoSections
|
||||
<PrivateResourceInfoSections
|
||||
siteResource={siteResource}
|
||||
access={access}
|
||||
variant={variant}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export function PrivateResourceMultiSiteRoutingHelp() {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{t("internalResourceFormMultiSiteRoutingHelp")}{" "}
|
||||
<a
|
||||
href="https://docs.pangolin.net/manage/resources/private/multi-site-routing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{t("internalResourceFormMultiSiteRoutingHelpLearnMore")}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSubsectionDescription,
|
||||
SettingsSubsectionHeader,
|
||||
SettingsSubsectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import {
|
||||
getPortModeFromString,
|
||||
getPortStringFromMode,
|
||||
type PortMode
|
||||
} from "@app/lib/privateResourceForm";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import type { Control, UseFormSetValue } from "react-hook-form";
|
||||
|
||||
type PrivateResourceNetworkAccessFieldsProps = {
|
||||
control: Control<any>;
|
||||
setValue: UseFormSetValue<any>;
|
||||
showPortRanges?: boolean;
|
||||
initialTcp?: string | null;
|
||||
initialUdp?: string | null;
|
||||
disabled?: boolean;
|
||||
icmpId?: string;
|
||||
embedInParentGrid?: boolean;
|
||||
};
|
||||
|
||||
export function PrivateResourceAllowIcmpField({
|
||||
control,
|
||||
id = "private-resource-allow-icmp",
|
||||
disabled = false
|
||||
}: {
|
||||
control: Control<any>;
|
||||
id?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name="disableIcmp"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id={id}
|
||||
label={t("privateResourceAllowIcmpPing")}
|
||||
checked={!field.value}
|
||||
onCheckedChange={(checked) =>
|
||||
field.onChange(!checked)
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PrivateResourceNetworkAccessHeader() {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<SettingsFormCell span="full">
|
||||
<SettingsSubsectionHeader>
|
||||
<SettingsSubsectionTitle>
|
||||
{t("privateResourceNetworkAccess")}
|
||||
</SettingsSubsectionTitle>
|
||||
<SettingsSubsectionDescription>
|
||||
{t("privateResourceNetworkAccessDescription")}
|
||||
</SettingsSubsectionDescription>
|
||||
</SettingsSubsectionHeader>
|
||||
</SettingsFormCell>
|
||||
);
|
||||
}
|
||||
|
||||
export function PrivateResourceNetworkAccessFields({
|
||||
control,
|
||||
setValue,
|
||||
showPortRanges = true,
|
||||
initialTcp,
|
||||
initialUdp,
|
||||
disabled = false,
|
||||
icmpId = "private-resource-allow-icmp",
|
||||
embedInParentGrid = false
|
||||
}: PrivateResourceNetworkAccessFieldsProps) {
|
||||
const t = useTranslations();
|
||||
const resolvedInitialTcp = initialTcp !== undefined ? initialTcp : "*";
|
||||
const resolvedInitialUdp = initialUdp !== undefined ? initialUdp : "*";
|
||||
const [tcpPortMode, setTcpPortMode] = useState<PortMode>(() =>
|
||||
getPortModeFromString(resolvedInitialTcp)
|
||||
);
|
||||
const [udpPortMode, setUdpPortMode] = useState<PortMode>(() =>
|
||||
getPortModeFromString(resolvedInitialUdp)
|
||||
);
|
||||
const [tcpCustomPorts, setTcpCustomPorts] = useState(() =>
|
||||
resolvedInitialTcp && resolvedInitialTcp !== "*"
|
||||
? resolvedInitialTcp
|
||||
: ""
|
||||
);
|
||||
const [udpCustomPorts, setUdpCustomPorts] = useState(() =>
|
||||
resolvedInitialUdp && resolvedInitialUdp !== "*"
|
||||
? resolvedInitialUdp
|
||||
: ""
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showPortRanges) return;
|
||||
|
||||
setValue(
|
||||
"tcpPortRangeString",
|
||||
getPortStringFromMode(tcpPortMode, tcpCustomPorts)
|
||||
);
|
||||
}, [showPortRanges, tcpPortMode, tcpCustomPorts, setValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showPortRanges) return;
|
||||
|
||||
setValue(
|
||||
"udpPortRangeString",
|
||||
getPortStringFromMode(udpPortMode, udpCustomPorts)
|
||||
);
|
||||
}, [showPortRanges, udpPortMode, udpCustomPorts, setValue]);
|
||||
|
||||
const content: ReactNode = (
|
||||
<>
|
||||
<PrivateResourceNetworkAccessHeader />
|
||||
|
||||
{showPortRanges ? (
|
||||
<>
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={control}
|
||||
name="tcpPortRangeString"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("editInternalResourceDialogTcp")}
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={tcpPortMode}
|
||||
onValueChange={(v: PortMode) =>
|
||||
setTcpPortMode(v)
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-[110px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t("allPorts")}
|
||||
</SelectItem>
|
||||
<SelectItem value="blocked">
|
||||
{t("blocked")}
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
{t("custom")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{tcpPortMode === "custom" ? (
|
||||
<FormControl>
|
||||
<Input
|
||||
className="flex-1"
|
||||
placeholder="80,443,8000-9000"
|
||||
value={tcpCustomPorts}
|
||||
onChange={(e) =>
|
||||
setTcpCustomPorts(
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
) : (
|
||||
<Input
|
||||
className="flex-1"
|
||||
disabled
|
||||
placeholder={
|
||||
tcpPortMode === "all"
|
||||
? t("allPortsAllowed")
|
||||
: t("allPortsBlocked")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<FormField
|
||||
control={control}
|
||||
name="udpPortRangeString"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("editInternalResourceDialogUdp")}
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={udpPortMode}
|
||||
onValueChange={(v: PortMode) =>
|
||||
setUdpPortMode(v)
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-[110px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t("allPorts")}
|
||||
</SelectItem>
|
||||
<SelectItem value="blocked">
|
||||
{t("blocked")}
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
{t("custom")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{udpPortMode === "custom" ? (
|
||||
<FormControl>
|
||||
<Input
|
||||
className="flex-1"
|
||||
placeholder="53,123,500-600"
|
||||
value={udpCustomPorts}
|
||||
onChange={(e) =>
|
||||
setUdpCustomPorts(
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
) : (
|
||||
<Input
|
||||
className="flex-1"
|
||||
disabled
|
||||
placeholder={
|
||||
udpPortMode === "all"
|
||||
? t("allPortsAllowed")
|
||||
: t("allPortsBlocked")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<PrivateResourceAllowIcmpField
|
||||
control={control}
|
||||
id={icmpId}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</>
|
||||
);
|
||||
|
||||
if (embedInParentGrid) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return <SettingsFormGrid>{content}</SettingsFormGrid>;
|
||||
}
|
||||
|
||||
export function PrivateResourcePortRanges(
|
||||
props: Omit<
|
||||
PrivateResourceNetworkAccessFieldsProps,
|
||||
"showPortRanges" | "embedInParentGrid"
|
||||
>
|
||||
) {
|
||||
return <PrivateResourceNetworkAccessFields showPortRanges {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
MultiSitesSelector,
|
||||
formatMultiSitesSelectorLabel
|
||||
} from "@app/components/multi-site-selector";
|
||||
import { SitesSelector } from "@app/components/site-selector";
|
||||
import type { Selectedsite } from "@app/components/site-selector";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { ChevronsUpDown } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { Control, FieldPath, FieldValues } from "react-hook-form";
|
||||
import { PrivateResourceMultiSiteRoutingHelp } from "@app/components/PrivateResourceMultiSiteRoutingHelp";
|
||||
|
||||
type PrivateResourceSitesFieldProps<T extends FieldValues> = {
|
||||
control: Control<T>;
|
||||
orgId: string;
|
||||
selectedSites: Selectedsite[];
|
||||
onSelectedSitesChange: (sites: Selectedsite[]) => void;
|
||||
siteIdsFieldName?: FieldPath<T>;
|
||||
singleSite?: boolean;
|
||||
};
|
||||
|
||||
export function PrivateResourceSitesField<T extends FieldValues>({
|
||||
control,
|
||||
orgId,
|
||||
selectedSites,
|
||||
onSelectedSitesChange,
|
||||
siteIdsFieldName = "siteIds" as FieldPath<T>,
|
||||
singleSite = false
|
||||
}: PrivateResourceSitesFieldProps<T>) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={siteIdsFieldName}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>{t("sites")}</FormLabel>
|
||||
{singleSite ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full justify-between",
|
||||
selectedSites.length === 0 &&
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-left">
|
||||
{selectedSites[0]?.name ??
|
||||
t("selectSite")}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0">
|
||||
<SitesSelector
|
||||
orgId={orgId}
|
||||
selectedSite={selectedSites[0] ?? null}
|
||||
filterTypes={["newt"]}
|
||||
onSelectSite={(site) => {
|
||||
onSelectedSitesChange([site]);
|
||||
field.onChange([site.siteId]);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full justify-between",
|
||||
selectedSites.length === 0 &&
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-left">
|
||||
{formatMultiSitesSelectorLabel(
|
||||
selectedSites,
|
||||
t
|
||||
)}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0">
|
||||
<MultiSitesSelector
|
||||
orgId={orgId}
|
||||
selectedSites={selectedSites}
|
||||
filterTypes={["newt"]}
|
||||
onSelectionChange={(sites) => {
|
||||
onSelectedSitesChange(sites);
|
||||
field.onChange(
|
||||
sites.map((s) => s.siteId)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
<FormMessage />
|
||||
{!singleSite && selectedSites.length > 1 ? (
|
||||
<PrivateResourceMultiSiteRoutingHelp />
|
||||
) : null}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSubsectionDescription,
|
||||
SettingsSubsectionHeader,
|
||||
SettingsSubsectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { SshServerSettingsFields } from "@app/components/SshServerSettingsFields";
|
||||
import { PrivateResourceAliasField } from "@app/components/PrivateResourceDestinationFields";
|
||||
import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
|
||||
import { inferSshPamMode } from "@app/lib/privateResourceForm";
|
||||
import { getSshUseMultiSiteTargetForm } from "@app/lib/privateResourceUtils";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import type { Control, UseFormSetValue, UseFormWatch } from "react-hook-form";
|
||||
import type { Selectedsite } from "@app/components/site-selector";
|
||||
|
||||
type PrivateResourceSshFieldsProps = {
|
||||
control: Control<any>;
|
||||
setValue: UseFormSetValue<any>;
|
||||
watch: UseFormWatch<any>;
|
||||
orgId?: string;
|
||||
disabled?: boolean;
|
||||
selectedSites: Selectedsite[];
|
||||
onSelectedSitesChange: (sites: Selectedsite[]) => void;
|
||||
labelPrefix?: "create" | "edit";
|
||||
showSshSettings?: boolean;
|
||||
layout?: "default" | "wizard";
|
||||
hideAlias?: boolean;
|
||||
embedInParentGrid?: boolean;
|
||||
isNativeSsh?: boolean;
|
||||
};
|
||||
|
||||
export function PrivateResourceSshFields({
|
||||
control,
|
||||
setValue,
|
||||
watch,
|
||||
orgId,
|
||||
disabled = false,
|
||||
selectedSites,
|
||||
onSelectedSitesChange,
|
||||
labelPrefix = "edit",
|
||||
showSshSettings = true,
|
||||
layout = "default",
|
||||
hideAlias = false,
|
||||
embedInParentGrid = false,
|
||||
isNativeSsh: isNativeSshProp
|
||||
}: PrivateResourceSshFieldsProps) {
|
||||
const t = useTranslations();
|
||||
const destinationLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogDestination"
|
||||
: "editInternalResourceDialogDestination";
|
||||
const destinationPortLabelKey =
|
||||
labelPrefix === "create"
|
||||
? "createInternalResourceDialogModePort"
|
||||
: "editInternalResourceDialogModePort";
|
||||
|
||||
const authDaemonMode = watch("authDaemonMode") ?? "site";
|
||||
const pamMode = inferSshPamMode(authDaemonMode, watch("pamMode"));
|
||||
const standardDaemonLocation =
|
||||
watch("standardDaemonLocation") ??
|
||||
(authDaemonMode === "remote" ? "remote" : "site");
|
||||
const formAuthDaemonPort = watch("authDaemonPort");
|
||||
const [authDaemonPortInput, setAuthDaemonPortInput] = useState(() =>
|
||||
formAuthDaemonPort != null ? String(formAuthDaemonPort) : "22123"
|
||||
);
|
||||
const isEditLayout = layout === "default";
|
||||
|
||||
const [sshServerMode, setSshServerMode] = useState<"standard" | "native">(
|
||||
() => (authDaemonMode === "native" ? "native" : "standard")
|
||||
);
|
||||
|
||||
const isNative =
|
||||
isNativeSshProp ??
|
||||
(isEditLayout
|
||||
? authDaemonMode === "native"
|
||||
: sshServerMode === "native");
|
||||
const useMultiSiteTargetForm = getSshUseMultiSiteTargetForm(
|
||||
isNative,
|
||||
authDaemonMode,
|
||||
pamMode
|
||||
);
|
||||
|
||||
function trimSitesToFirst() {
|
||||
if (selectedSites.length <= 1) return;
|
||||
|
||||
const first = selectedSites.slice(0, 1);
|
||||
onSelectedSitesChange(first);
|
||||
setValue(
|
||||
"siteIds",
|
||||
first.map((s: Selectedsite) => s.siteId),
|
||||
{ shouldValidate: true }
|
||||
);
|
||||
}
|
||||
|
||||
function handlePamModeChange(value: "passthrough" | "push") {
|
||||
if (disabled) return;
|
||||
|
||||
setValue("pamMode", value, { shouldValidate: true });
|
||||
|
||||
if (value === "passthrough") {
|
||||
setValue("authDaemonPort", null, { shouldValidate: true });
|
||||
setAuthDaemonPortInput("22123");
|
||||
return;
|
||||
}
|
||||
|
||||
if (standardDaemonLocation !== "remote" && selectedSites.length > 1) {
|
||||
trimSitesToFirst();
|
||||
}
|
||||
}
|
||||
|
||||
function handleDaemonLocationChange(value: "site" | "remote") {
|
||||
if (disabled) return;
|
||||
|
||||
setValue("standardDaemonLocation", value, { shouldValidate: true });
|
||||
setValue("authDaemonMode", value, { shouldValidate: true });
|
||||
|
||||
if (value === "site") {
|
||||
setValue("authDaemonPort", null, { shouldValidate: true });
|
||||
setAuthDaemonPortInput("22123");
|
||||
trimSitesToFirst();
|
||||
}
|
||||
}
|
||||
|
||||
function handleAuthDaemonPortChange(value: string) {
|
||||
if (disabled) return;
|
||||
|
||||
setAuthDaemonPortInput(value);
|
||||
const trimmed = value.trim();
|
||||
setValue("authDaemonPort", trimmed ? Number(trimmed) : null, {
|
||||
shouldValidate: true
|
||||
});
|
||||
}
|
||||
|
||||
function handleServerModeChange(mode: "standard" | "native") {
|
||||
if (disabled) return;
|
||||
|
||||
setSshServerMode(mode);
|
||||
if (mode === "native") {
|
||||
setValue("authDaemonMode", "native", { shouldValidate: true });
|
||||
setValue("authDaemonPort", null, { shouldValidate: true });
|
||||
setValue("destination", null, { shouldValidate: true });
|
||||
setValue("destinationPort", null, { shouldValidate: true });
|
||||
setAuthDaemonPortInput("22123");
|
||||
trimSitesToFirst();
|
||||
return;
|
||||
}
|
||||
|
||||
setValue("authDaemonMode", standardDaemonLocation, {
|
||||
shouldValidate: true
|
||||
});
|
||||
setValue("destinationPort", 22, { shouldValidate: true });
|
||||
}
|
||||
|
||||
const aliasField = hideAlias ? null : (
|
||||
<PrivateResourceAliasField
|
||||
control={control}
|
||||
watch={watch}
|
||||
labelPrefix={labelPrefix}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
const standardSshTargetRow =
|
||||
orgId && !isNative ? (
|
||||
<div className="grid grid-cols-3 gap-4 items-start">
|
||||
<PrivateResourceSitesField
|
||||
control={control}
|
||||
orgId={orgId}
|
||||
selectedSites={selectedSites}
|
||||
onSelectedSitesChange={onSelectedSitesChange}
|
||||
singleSite={!useMultiSiteTargetForm}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="destination"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t(destinationLabelKey)}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="w-full"
|
||||
value={field.value ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value === ""
|
||||
? null
|
||||
: e.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="destinationPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t(destinationPortLabelKey)}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="w-full"
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={field.value ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
if (raw === "") {
|
||||
field.onChange(null);
|
||||
return;
|
||||
}
|
||||
const n = Number(raw);
|
||||
field.onChange(
|
||||
Number.isFinite(n) ? n : null
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const sshSettingsFields = showSshSettings ? (
|
||||
<SshServerSettingsFields
|
||||
idPrefix={
|
||||
layout === "wizard"
|
||||
? "private-ssh-create"
|
||||
: "private-ssh-fields"
|
||||
}
|
||||
pamMode={pamMode}
|
||||
standardDaemonLocation={standardDaemonLocation}
|
||||
authDaemonPort={authDaemonPortInput}
|
||||
onPamModeChange={handlePamModeChange}
|
||||
onStandardDaemonLocationChange={handleDaemonLocationChange}
|
||||
onAuthDaemonPortChange={handleAuthDaemonPortChange}
|
||||
sshServerMode={sshServerMode}
|
||||
serverModeDisplay={layout === "wizard" ? "select" : "badge"}
|
||||
onServerModeChange={handleServerModeChange}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const destinationSection = (
|
||||
<>
|
||||
<SettingsFormCell span="full">
|
||||
<SettingsSubsectionHeader>
|
||||
<SettingsSubsectionTitle>
|
||||
{t("sshServerDestination")}
|
||||
</SettingsSubsectionTitle>
|
||||
<SettingsSubsectionDescription>
|
||||
{t("sshServerDestinationDescription")}
|
||||
</SettingsSubsectionDescription>
|
||||
</SettingsSubsectionHeader>
|
||||
</SettingsFormCell>
|
||||
|
||||
{isNative && orgId ? (
|
||||
<>
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceSitesField
|
||||
control={control}
|
||||
orgId={orgId}
|
||||
selectedSites={selectedSites}
|
||||
onSelectedSitesChange={onSelectedSitesChange}
|
||||
singleSite
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
<SettingsFormCell span="half">
|
||||
<PrivateResourceAliasField
|
||||
control={control}
|
||||
watch={watch}
|
||||
labelPrefix={labelPrefix}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{!isNative && orgId ? (
|
||||
<SettingsFormCell span="full">
|
||||
{standardSshTargetRow}
|
||||
</SettingsFormCell>
|
||||
) : null}
|
||||
|
||||
{!isNative && !hideAlias ? (
|
||||
<SettingsFormCell span="half">{aliasField}</SettingsFormCell>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
const content: ReactNode = (
|
||||
<>
|
||||
{sshSettingsFields}
|
||||
{destinationSection}
|
||||
</>
|
||||
);
|
||||
|
||||
if (embedInParentGrid) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return <SettingsFormGrid>{content}</SettingsFormGrid>;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
|
||||
@@ -36,7 +37,10 @@ import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHr
|
||||
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
||||
import { build } from "@server/build";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import type { GetBatchedCertificateResponse } from "@server/routers/certificates/types";
|
||||
import { UpdateSiteResourceResponse } from "@server/routers/siteResource";
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
import { AxiosResponse } from "axios";
|
||||
import {
|
||||
ArrowDown01Icon,
|
||||
ArrowRight,
|
||||
@@ -49,27 +53,37 @@ import {
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { startTransition, useMemo, useState, useTransition } from "react";
|
||||
import {
|
||||
startTransition,
|
||||
useMemo,
|
||||
useOptimistic,
|
||||
useRef,
|
||||
useState,
|
||||
useTransition,
|
||||
type ComponentRef
|
||||
} from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import z from "zod";
|
||||
import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
|
||||
import { LabelsTableCell } from "./LabelsTableCell";
|
||||
import { ControlledDataTable } from "./ui/controlled-data-table";
|
||||
import { SitesColumnFilterButton } from "./SitesColumnFilterButton";
|
||||
import { SiteResource } from "@server/db";
|
||||
|
||||
export type InternalResourceSiteRow = ResourceSiteRow;
|
||||
export type PrivateResourceSiteRow = ResourceSiteRow;
|
||||
|
||||
export type InternalResourceRow = {
|
||||
export type PrivateResourceRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
orgId: string;
|
||||
sites: InternalResourceSiteRow[];
|
||||
sites: PrivateResourceSiteRow[];
|
||||
siteNames: string[];
|
||||
siteAddresses: (string | null)[];
|
||||
siteIds: number[];
|
||||
siteNiceIds: string[];
|
||||
// mode: "host" | "cidr" | "port";
|
||||
mode: "host" | "cidr" | "http" | "ssh";
|
||||
mode: SiteResource["mode"];
|
||||
scheme: "http" | "https" | null;
|
||||
ssl: boolean;
|
||||
// protocol: string | null;
|
||||
@@ -96,7 +110,7 @@ export type InternalResourceRow = {
|
||||
}>;
|
||||
};
|
||||
|
||||
function formatDestinationDisplay(row: InternalResourceRow): string {
|
||||
function formatDestinationDisplay(row: PrivateResourceRow): string {
|
||||
return formatSiteResourceDestinationDisplay({
|
||||
mode: row.mode,
|
||||
destination: row.destination,
|
||||
@@ -114,18 +128,26 @@ function isSafeUrlForLink(href: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
const booleanSearchFilterSchema = z
|
||||
.enum(["true", "false"])
|
||||
.optional()
|
||||
.catch(undefined);
|
||||
|
||||
type ClientResourcesTableProps = {
|
||||
internalResources: InternalResourceRow[];
|
||||
internalResources: PrivateResourceRow[];
|
||||
orgId: string;
|
||||
pagination: PaginationState;
|
||||
rowCount: number;
|
||||
/** Certificates prefetched on the server, keyed by full domain. */
|
||||
initialCertificates?: GetBatchedCertificateResponse;
|
||||
};
|
||||
|
||||
export default function PrivateResourcesTable({
|
||||
internalResources,
|
||||
orgId,
|
||||
pagination,
|
||||
rowCount
|
||||
rowCount,
|
||||
initialCertificates
|
||||
}: ClientResourcesTableProps) {
|
||||
const router = useRouter();
|
||||
const {
|
||||
@@ -143,10 +165,10 @@ export default function PrivateResourcesTable({
|
||||
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
||||
|
||||
const [selectedInternalResource, setSelectedInternalResource] =
|
||||
useState<InternalResourceRow | null>(null);
|
||||
useState<PrivateResourceRow | null>(null);
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
const [editingResource, setEditingResource] =
|
||||
useState<InternalResourceRow | null>();
|
||||
useState<PrivateResourceRow | null>();
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
|
||||
const [isRefreshing, startRefreshTransition] = useTransition();
|
||||
@@ -174,6 +196,30 @@ export default function PrivateResourcesTable({
|
||||
});
|
||||
};
|
||||
|
||||
async function toggleInternalResourceEnabled(
|
||||
val: boolean,
|
||||
resourceId: number
|
||||
) {
|
||||
try {
|
||||
await api.post<AxiosResponse<UpdateSiteResourceResponse>>(
|
||||
`site-resource/${resourceId}`,
|
||||
{
|
||||
enabled: val
|
||||
}
|
||||
);
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("resourcesErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("resourcesErrorUpdateDescription")
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const deleteInternalResource = async (
|
||||
resourceId: number,
|
||||
siteId: number
|
||||
@@ -196,9 +242,9 @@ export default function PrivateResourcesTable({
|
||||
};
|
||||
|
||||
const internalColumns = useMemo<
|
||||
ExtendedColumnDef<InternalResourceRow>[]
|
||||
ExtendedColumnDef<PrivateResourceRow>[]
|
||||
>(() => {
|
||||
const cols: ExtendedColumnDef<InternalResourceRow>[] = [
|
||||
const cols: ExtendedColumnDef<PrivateResourceRow>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
enableHiding: false,
|
||||
@@ -286,7 +332,7 @@ export default function PrivateResourcesTable({
|
||||
},
|
||||
{
|
||||
accessorKey: "mode",
|
||||
friendlyName: t("editInternalResourceDialogMode"),
|
||||
friendlyName: t("type"),
|
||||
header: () => (
|
||||
<ColumnFilterButton
|
||||
options={[
|
||||
@@ -305,6 +351,12 @@ export default function PrivateResourcesTable({
|
||||
{
|
||||
value: "ssh",
|
||||
label: t("editInternalResourceDialogModeSsh")
|
||||
},
|
||||
{
|
||||
value: "inference",
|
||||
label: t(
|
||||
"editInternalResourceDialogModeInference"
|
||||
)
|
||||
}
|
||||
]}
|
||||
selectedValue={searchParams.get("mode") ?? undefined}
|
||||
@@ -313,21 +365,18 @@ export default function PrivateResourcesTable({
|
||||
}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
emptyMessage={t("emptySearchOptions")}
|
||||
label={t("editInternalResourceDialogMode")}
|
||||
label={t("type")}
|
||||
className="p-3"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
const modeLabels: Record<
|
||||
"host" | "cidr" | "port" | "http" | "ssh",
|
||||
string
|
||||
> = {
|
||||
const modeLabels: Record<SiteResource["mode"], string> = {
|
||||
host: t("editInternalResourceDialogModeHost"),
|
||||
cidr: t("editInternalResourceDialogModeCidr"),
|
||||
port: t("editInternalResourceDialogModePort"),
|
||||
http: t("editInternalResourceDialogModeHttp"),
|
||||
ssh: t("editInternalResourceDialogModeSsh")
|
||||
ssh: t("editInternalResourceDialogModeSsh"),
|
||||
inference: t("editInternalResourceDialogModeInference")
|
||||
};
|
||||
return <span>{modeLabels[resourceRow.mode]}</span>;
|
||||
}
|
||||
@@ -372,12 +421,14 @@ export default function PrivateResourcesTable({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (resourceRow.mode === "http") {
|
||||
if (
|
||||
resourceRow.mode === "http" ||
|
||||
resourceRow.mode === "inference"
|
||||
) {
|
||||
const domainId = resourceRow.domainId;
|
||||
const fullDomain = resourceRow.fullDomain;
|
||||
const url = `${resourceRow.ssl ? "https" : "http"}://${fullDomain}`;
|
||||
const did =
|
||||
build !== "oss" &&
|
||||
resourceRow.ssl &&
|
||||
domainId != null &&
|
||||
domainId !== "" &&
|
||||
@@ -391,6 +442,9 @@ export default function PrivateResourcesTable({
|
||||
orgId={resourceRow.orgId}
|
||||
domainId={domainId}
|
||||
fullDomain={fullDomain}
|
||||
initialCertValue={
|
||||
initialCertificates?.[fullDomain]
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<div className="">
|
||||
@@ -429,6 +483,36 @@ export default function PrivateResourcesTable({
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "enabled",
|
||||
friendlyName: t("enabled"),
|
||||
header: () => (
|
||||
<ColumnFilterButton
|
||||
options={[
|
||||
{ value: "true", label: t("enabled") },
|
||||
{ value: "false", label: t("disabled") }
|
||||
]}
|
||||
selectedValue={booleanSearchFilterSchema.parse(
|
||||
searchParams.get("enabled")
|
||||
)}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("enabled", value)
|
||||
}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
emptyMessage={t("emptySearchOptions")}
|
||||
label={t("enabled")}
|
||||
className="p-3"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<InternalResourceEnabledForm
|
||||
resource={row.original}
|
||||
onToggleInternalResourceEnabled={
|
||||
toggleInternalResourceEnabled
|
||||
}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "labels",
|
||||
accessorKey: "labels",
|
||||
@@ -443,7 +527,7 @@ export default function PrivateResourcesTable({
|
||||
className="p-3"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }: { row: { original: InternalResourceRow } }) => (
|
||||
cell: ({ row }: { row: { original: PrivateResourceRow } }) => (
|
||||
<ClientResourceLabelCell
|
||||
resource={row.original}
|
||||
orgId={orgId}
|
||||
@@ -514,7 +598,7 @@ export default function PrivateResourcesTable({
|
||||
];
|
||||
|
||||
return cols;
|
||||
}, [orgId, t, searchParams]);
|
||||
}, [orgId, t, searchParams, initialCertificates]);
|
||||
|
||||
function handleFilterChange(
|
||||
column: string,
|
||||
@@ -619,7 +703,7 @@ export default function PrivateResourcesTable({
|
||||
}
|
||||
|
||||
type ClientResourceLabelCellProps = {
|
||||
resource: InternalResourceRow;
|
||||
resource: PrivateResourceRow;
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
@@ -643,3 +727,39 @@ function ClientResourceLabelCell({
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type InternalResourceEnabledFormProps = {
|
||||
resource: PrivateResourceRow;
|
||||
onToggleInternalResourceEnabled: (
|
||||
val: boolean,
|
||||
resourceId: number
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
function InternalResourceEnabledForm({
|
||||
resource,
|
||||
onToggleInternalResourceEnabled
|
||||
}: InternalResourceEnabledFormProps) {
|
||||
const [optimisticEnabled, setOptimisticEnabled] = useOptimistic(
|
||||
resource.enabled
|
||||
);
|
||||
|
||||
const formRef = useRef<ComponentRef<"form">>(null);
|
||||
|
||||
async function submitAction(formData: FormData) {
|
||||
const newEnabled = !(formData.get("enabled") === "on");
|
||||
setOptimisticEnabled(newEnabled);
|
||||
await onToggleInternalResourceEnabled(newEnabled, resource.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={submitAction} ref={formRef}>
|
||||
<Switch
|
||||
checked={optimisticEnabled}
|
||||
disabled={optimisticEnabled !== resource.enabled}
|
||||
name="enabled"
|
||||
onCheckedChange={() => formRef.current?.requestSubmit()}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,8 +7,7 @@ import {
|
||||
ResourceSitesStatusCell,
|
||||
type ResourceSiteRow
|
||||
} from "@app/components/ResourceSitesStatusCell";
|
||||
import { Selectedsite, SitesSelector } from "@app/components/site-selector";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { Selectedsite } from "@app/components/site-selector";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||
import {
|
||||
@@ -18,23 +17,19 @@ import {
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
||||
import { build } from "@server/build";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { UpdateResourceResponse } from "@server/routers/resource";
|
||||
import type { GetBatchedCertificateResponse } from "@server/routers/certificates/types";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
import { AxiosResponse } from "axios";
|
||||
import {
|
||||
@@ -45,9 +40,7 @@ import {
|
||||
ChevronDown,
|
||||
ChevronsUpDownIcon,
|
||||
Clock,
|
||||
Funnel,
|
||||
MoreHorizontal,
|
||||
PlusIcon,
|
||||
ShieldCheck,
|
||||
ShieldOff,
|
||||
XCircle
|
||||
@@ -57,7 +50,6 @@ import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
startTransition,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useOptimistic,
|
||||
useRef,
|
||||
@@ -68,15 +60,11 @@ import {
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import z from "zod";
|
||||
import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||
import { ControlledDataTable } from "./ui/controlled-data-table";
|
||||
import UptimeMiniBar from "./UptimeMiniBar";
|
||||
import { type SelectedLabel } from "./labels-selector";
|
||||
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
|
||||
import { useLocalLabels } from "@app/hooks/useLocalLabels";
|
||||
import { LabelsTableCell } from "./LabelsTableCell";
|
||||
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
|
||||
import { refresh } from "next/cache";
|
||||
import { SitesColumnFilterButton } from "./SitesColumnFilterButton";
|
||||
import { ControlledDataTable } from "./ui/controlled-data-table";
|
||||
import { UptimeMiniBar } from "./UptimeMiniBar";
|
||||
|
||||
export type TargetHealth = {
|
||||
targetId: number;
|
||||
@@ -120,6 +108,8 @@ type ProxyResourcesTableProps = {
|
||||
pagination: PaginationState;
|
||||
rowCount: number;
|
||||
initialFilterSite?: Selectedsite | null;
|
||||
/** Certificates prefetched on the server, keyed by full domain. */
|
||||
initialCertificates?: GetBatchedCertificateResponse;
|
||||
};
|
||||
|
||||
const booleanSearchFilterSchema = z
|
||||
@@ -127,12 +117,14 @@ const booleanSearchFilterSchema = z
|
||||
.optional()
|
||||
.catch(undefined);
|
||||
|
||||
const RESOURCE_STATUS_HISTORY_DAYS = 30;
|
||||
|
||||
export default function PublicResourcesTable({
|
||||
resources,
|
||||
orgId,
|
||||
pagination,
|
||||
rowCount,
|
||||
initialFilterSite = null
|
||||
initialCertificates
|
||||
}: ProxyResourcesTableProps) {
|
||||
const router = useRouter();
|
||||
const {
|
||||
@@ -150,11 +142,24 @@ export default function PublicResourcesTable({
|
||||
const [selectedResource, setSelectedResource] =
|
||||
useState<ResourceRow | null>();
|
||||
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
|
||||
const [isRefreshing, startTransition] = useTransition();
|
||||
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
||||
|
||||
// Only http resources show an uptime bar, so don't ask for the others
|
||||
const statusHistoryResourceIds = useMemo(
|
||||
() => resources.filter((r) => r.mode === "http").map((r) => r.id),
|
||||
[resources]
|
||||
);
|
||||
|
||||
const statusHistoryQuery = useQuery({
|
||||
...orgQueries.batchedResourceStatusHistory({
|
||||
orgId,
|
||||
resourceIds: statusHistoryResourceIds,
|
||||
days: RESOURCE_STATUS_HISTORY_DAYS
|
||||
}),
|
||||
enabled: statusHistoryResourceIds.length > 0
|
||||
});
|
||||
|
||||
const refreshData = () => {
|
||||
startTransition(() => {
|
||||
try {
|
||||
@@ -275,7 +280,7 @@ export default function PublicResourcesTable({
|
||||
},
|
||||
{
|
||||
accessorKey: "protocol",
|
||||
friendlyName: t("protocol"),
|
||||
friendlyName: t("type"),
|
||||
enableHiding: true,
|
||||
header: () => (
|
||||
<ColumnFilterButton
|
||||
@@ -307,6 +312,12 @@ export default function PublicResourcesTable({
|
||||
{
|
||||
value: "vnc",
|
||||
label: t("vncTitle")
|
||||
},
|
||||
{
|
||||
value: "inference",
|
||||
label: t(
|
||||
"createInternalResourceDialogModeInference"
|
||||
)
|
||||
}
|
||||
]}
|
||||
selectedValue={
|
||||
@@ -317,7 +328,7 @@ export default function PublicResourcesTable({
|
||||
}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
emptyMessage={t("emptySearchOptions")}
|
||||
label={t("protocol")}
|
||||
label={t("type")}
|
||||
className="p-3"
|
||||
/>
|
||||
),
|
||||
@@ -329,7 +340,11 @@ export default function PublicResourcesTable({
|
||||
? resourceRow.ssl
|
||||
? "HTTPS"
|
||||
: "HTTP"
|
||||
: resourceRow.mode?.toUpperCase()}
|
||||
: resourceRow.mode === "inference"
|
||||
? t(
|
||||
"createInternalResourceDialogModeInference"
|
||||
)
|
||||
: resourceRow.mode?.toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -407,7 +422,11 @@ export default function PublicResourcesTable({
|
||||
return <span>-</span>;
|
||||
}
|
||||
return (
|
||||
<UptimeMiniBar resourceId={resourceRow.id} days={30} />
|
||||
<UptimeMiniBar
|
||||
isLoading={statusHistoryQuery.isLoading}
|
||||
data={statusHistoryQuery.data?.[resourceRow.id]}
|
||||
days={RESOURCE_STATUS_HISTORY_DAYS}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -419,7 +438,7 @@ export default function PublicResourcesTable({
|
||||
const resourceRow = row.original;
|
||||
|
||||
if (
|
||||
!["http", "ssh", "rdp", "vnc"].includes(
|
||||
!["http", "ssh", "rdp", "vnc", "inference"].includes(
|
||||
resourceRow.mode || ""
|
||||
)
|
||||
) {
|
||||
@@ -449,7 +468,6 @@ export default function PublicResourcesTable({
|
||||
const domainId = resourceRow.domainId;
|
||||
const certHostname = resourceRow.fullDomain;
|
||||
const showHttpsCertIndicator =
|
||||
build !== "oss" &&
|
||||
resourceRow.ssl &&
|
||||
certHostname != null &&
|
||||
certHostname !== "";
|
||||
@@ -461,6 +479,9 @@ export default function PublicResourcesTable({
|
||||
orgId={resourceRow.orgId}
|
||||
domainId={domainId}
|
||||
fullDomain={certHostname}
|
||||
initialCertValue={
|
||||
initialCertificates?.[certHostname]
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<div className="">
|
||||
@@ -625,7 +646,14 @@ export default function PublicResourcesTable({
|
||||
];
|
||||
|
||||
return cols;
|
||||
}, [orgId, t, searchParams]);
|
||||
}, [
|
||||
orgId,
|
||||
t,
|
||||
searchParams,
|
||||
statusHistoryQuery.data,
|
||||
statusHistoryQuery.isLoading,
|
||||
initialCertificates
|
||||
]);
|
||||
|
||||
function handleFilterChange(
|
||||
column: string,
|
||||
@@ -718,7 +746,6 @@ export default function PublicResourcesTable({
|
||||
enableColumnVisibility
|
||||
columnVisibility={{
|
||||
niceId: false,
|
||||
protocol: false,
|
||||
labels: true
|
||||
}}
|
||||
stickyLeftColumn="name"
|
||||
@@ -875,7 +902,9 @@ function ResourceEnabledForm({
|
||||
resource,
|
||||
onToggleResourceEnabled
|
||||
}: ResourceEnabledFormProps) {
|
||||
const enabled = ["http", "ssh", "rdp", "vnc"].includes(resource.mode || "")
|
||||
const enabled = ["http", "ssh", "rdp", "vnc", "inference"].includes(
|
||||
resource.mode || ""
|
||||
)
|
||||
? !!resource.domainId && resource.enabled
|
||||
: resource.enabled;
|
||||
const [optimisticEnabled, setOptimisticEnabled] = useOptimistic(enabled);
|
||||
@@ -893,7 +922,7 @@ function ResourceEnabledForm({
|
||||
<Switch
|
||||
checked={optimisticEnabled}
|
||||
disabled={
|
||||
(["http", "ssh", "rdp", "vnc"].includes(
|
||||
(["http", "ssh", "rdp", "vnc", "inference"].includes(
|
||||
resource.mode || ""
|
||||
) &&
|
||||
!resource.domainId) ||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
PopoverContent
|
||||
} from "@app/components/ui/popover";
|
||||
import { useCertificate } from "@app/hooks/useCertificate";
|
||||
import type { GetCertificateResponse } from "@server/routers/certificates/types";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { FileBadge } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -17,11 +18,13 @@ import {
|
||||
useState,
|
||||
type ReactNode
|
||||
} from "react";
|
||||
import { durationToMs } from "@app/lib/durationToMs";
|
||||
|
||||
type ResourceAccessCertIndicatorProps = {
|
||||
orgId: string;
|
||||
domainId: string;
|
||||
fullDomain: string;
|
||||
initialCertValue?: GetCertificateResponse | null;
|
||||
};
|
||||
|
||||
function getStatusColor(status: string) {
|
||||
@@ -43,7 +46,8 @@ function getStatusColor(status: string) {
|
||||
export function ResourceAccessCertIndicator({
|
||||
orgId,
|
||||
domainId,
|
||||
fullDomain
|
||||
fullDomain,
|
||||
initialCertValue
|
||||
}: ResourceAccessCertIndicatorProps) {
|
||||
const t = useTranslations();
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -53,16 +57,19 @@ export function ResourceAccessCertIndicator({
|
||||
orgId,
|
||||
domainId,
|
||||
fullDomain,
|
||||
autoFetch: true,
|
||||
initialCertValue,
|
||||
polling: open,
|
||||
pollingInterval: 5000
|
||||
pollingInterval: durationToMs(5, "seconds")
|
||||
});
|
||||
|
||||
const { cert, certLoading, certError, refreshing, fetchCert } = certificate;
|
||||
|
||||
// `polling` only schedules on predefined intervals (1 second),
|
||||
// so the first request would be a full interval away if open = true (which set polling = true).
|
||||
// So we fetch immediately so the popover opens with fresh data.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
void fetchCert(false);
|
||||
void fetchCert();
|
||||
}, [open, fetchCert]);
|
||||
|
||||
const clearCloseTimer = useCallback(() => {
|
||||
|
||||
@@ -30,15 +30,20 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
|
||||
const fullUrl = `${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`;
|
||||
|
||||
const isDomainResource = [
|
||||
"http",
|
||||
"ssh",
|
||||
"rdp",
|
||||
"vnc",
|
||||
"inference"
|
||||
].includes(resource.mode);
|
||||
const showCertificate = !!(
|
||||
["http", "ssh", "rdp", "vnc"].includes(resource.mode) &&
|
||||
isDomainResource &&
|
||||
resource.domainId &&
|
||||
resource.fullDomain &&
|
||||
build != "oss"
|
||||
);
|
||||
const showType = !!(
|
||||
["http", "ssh", "rdp", "vnc"].includes(resource.mode) && resource.mode
|
||||
resource.fullDomain
|
||||
);
|
||||
const showType = !!(isDomainResource && resource.mode);
|
||||
const showAuth = resource.mode !== "inference";
|
||||
const showHealth =
|
||||
!["ssh", "rdp", "vnc"].includes(resource.mode || "") &&
|
||||
!!resource.health &&
|
||||
@@ -47,7 +52,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
|
||||
const numSections = [
|
||||
true, // URL or Protocol
|
||||
true, // Authentication or Port
|
||||
showAuth || !isDomainResource, // Authentication or Port
|
||||
showType,
|
||||
showCertificate,
|
||||
showHealth,
|
||||
@@ -66,7 +71,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
</span>
|
||||
</InfoSectionContent>
|
||||
</InfoSection> */}
|
||||
{["http", "ssh", "rdp", "vnc"].includes(resource.mode) ? (
|
||||
{isDomainResource ? (
|
||||
<>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>URL</InfoSectionTitle>
|
||||
@@ -94,33 +99,39 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
? resource.ssl
|
||||
? "HTTPS"
|
||||
: "HTTP"
|
||||
: resource.mode?.toUpperCase()}
|
||||
: resource.mode === "inference"
|
||||
? t(
|
||||
"createInternalResourceDialogModeInference"
|
||||
)
|
||||
: resource.mode?.toUpperCase()}
|
||||
</span>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
)}
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("authentication")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{authInfo.password ||
|
||||
authInfo.pincode ||
|
||||
authInfo.sso ||
|
||||
authInfo.whitelist ||
|
||||
authInfo.headerAuth ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<ShieldCheck className="w-4 h-4 flex-shrink-0 text-green-500" />
|
||||
<span>{t("protected")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<ShieldOff className="w-4 h-4 flex-shrink-0 text-yellow-500" />
|
||||
<span>{t("notProtected")}</span>
|
||||
</div>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
{showAuth && (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("authentication")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{authInfo.password ||
|
||||
authInfo.pincode ||
|
||||
authInfo.sso ||
|
||||
authInfo.whitelist ||
|
||||
authInfo.headerAuth ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<ShieldCheck className="w-4 h-4 flex-shrink-0 text-green-500" />
|
||||
<span>{t("protected")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<ShieldOff className="w-4 h-4 flex-shrink-0 text-yellow-500" />
|
||||
<span>{t("notProtected")}</span>
|
||||
</div>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -138,7 +149,9 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
<InfoSectionTitle>{t("port")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard
|
||||
text={resource.proxyPort!.toString()}
|
||||
text={
|
||||
resource.proxyPort?.toString() ?? ""
|
||||
}
|
||||
isLink={false}
|
||||
/>
|
||||
</InfoSectionContent>
|
||||
@@ -180,7 +193,6 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
orgId={resource.orgId}
|
||||
domainId={resource.domainId!}
|
||||
fullDomain={resource.fullDomain!}
|
||||
autoFetch={true}
|
||||
showLabel={false}
|
||||
polling={true}
|
||||
/>
|
||||
|
||||
+259
-183
@@ -30,11 +30,20 @@ import {
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
|
||||
import { CheckboxWithLabel } from "./ui/checkbox";
|
||||
import {
|
||||
BudgetRowsFields,
|
||||
getBudgetRowsErrors,
|
||||
rowsFromBudgets,
|
||||
type BudgetRow
|
||||
} from "@app/components/BudgetsEditor";
|
||||
import { aiBudgetQueries } from "@app/lib/queries";
|
||||
import type { AiBudgetPeriod, AiBudgetUnit } from "@app/lib/aiBudgetScope";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import type { Role } from "@server/db";
|
||||
|
||||
@@ -82,6 +91,13 @@ function hasOnlyAbsoluteSudoCommands(value: string | undefined): boolean {
|
||||
});
|
||||
}
|
||||
|
||||
export type PendingRoleBudget = {
|
||||
budgetId?: number;
|
||||
amount: string;
|
||||
unit: AiBudgetUnit;
|
||||
period: AiBudgetPeriod;
|
||||
};
|
||||
|
||||
export type RoleFormValues = {
|
||||
name: string;
|
||||
description?: string;
|
||||
@@ -91,6 +107,7 @@ export type RoleFormValues = {
|
||||
sshSudoCommands?: string;
|
||||
sshCreateHomeDir?: boolean;
|
||||
sshUnixGroups?: string;
|
||||
budgets?: PendingRoleBudget[];
|
||||
};
|
||||
|
||||
type RoleFormProps = {
|
||||
@@ -166,7 +183,7 @@ export function RoleForm({
|
||||
description: "",
|
||||
requireDeviceApproval: false,
|
||||
allowSsh: false,
|
||||
sshSudoMode: "none",
|
||||
sshSudoMode: "full",
|
||||
sshSudoCommands: "",
|
||||
sshCreateHomeDir: true,
|
||||
sshUnixGroups: ""
|
||||
@@ -195,19 +212,28 @@ export function RoleForm({
|
||||
}
|
||||
}, [variant, role, form]);
|
||||
|
||||
const sshDisabled = !isPaidUser(tierMatrix.advancedPrivateResources);
|
||||
const sshDisabled = !isPaidUser(tierMatrix.roleBasedSSHControls);
|
||||
const sshSudoMode = form.watch("sshSudoMode");
|
||||
const isAdminRole = variant === "edit" && role?.isAdmin === true;
|
||||
const [pendingImport, setPendingImport] =
|
||||
useState<PendingTextImport | null>(null);
|
||||
const [dragOverField, setDragOverField] =
|
||||
useState<RoleTextImportField | null>(null);
|
||||
const [pendingBudgetRows, setPendingBudgetRows] = useState<BudgetRow[]>([]);
|
||||
const [attemptedBudgetsSave, setAttemptedBudgetsSave] = useState(false);
|
||||
|
||||
const budgetsQuery = useQuery({
|
||||
...aiBudgetQueries.scoped({
|
||||
scope: { type: "role", id: role?.roleId ?? -1 }
|
||||
}),
|
||||
enabled: variant === "edit" && !!role
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (sshDisabled) {
|
||||
form.setValue("allowSsh", false);
|
||||
}
|
||||
}, [sshDisabled, form]);
|
||||
if (variant !== "edit" || !budgetsQuery.data) return;
|
||||
setPendingBudgetRows(rowsFromBudgets(budgetsQuery.data));
|
||||
setAttemptedBudgetsSave(false);
|
||||
}, [variant, budgetsQuery.data]);
|
||||
|
||||
async function handleFileDrop(
|
||||
file: File,
|
||||
@@ -252,6 +278,34 @@ export function RoleForm({
|
||||
});
|
||||
}
|
||||
|
||||
function handleFormSubmit(values: z.infer<typeof formSchema>) {
|
||||
const { conflictingKeys, invalidAmountKeys } =
|
||||
getBudgetRowsErrors(pendingBudgetRows);
|
||||
if (conflictingKeys.size > 0 || invalidAmountKeys.size > 0) {
|
||||
setAttemptedBudgetsSave(true);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("aiBudgetErrorSave"),
|
||||
description: conflictingKeys.size
|
||||
? t("aiBudgetConflictError")
|
||||
: t("aiBudgetInvalidAmountError")
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
return onSubmit({
|
||||
...values,
|
||||
budgets: pendingBudgetRows.map(
|
||||
({ budgetId, amount, unit, period }) => ({
|
||||
budgetId,
|
||||
amount,
|
||||
unit,
|
||||
period
|
||||
})
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
function getTextImportDropHandlers(field: RoleTextImportField) {
|
||||
return {
|
||||
onDragOver: (event: React.DragEvent<HTMLTextAreaElement>) => {
|
||||
@@ -284,7 +338,7 @@ export function RoleForm({
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((values) => onSubmit(values))}
|
||||
onSubmit={form.handleSubmit(handleFormSubmit)}
|
||||
className="space-y-4"
|
||||
id={formId}
|
||||
>
|
||||
@@ -333,7 +387,11 @@ export function RoleForm({
|
||||
{ title: t("general"), href: "#" },
|
||||
...(env.flags.disableEnterpriseFeatures
|
||||
? []
|
||||
: [{ title: t("sshAccess"), href: "#" }])
|
||||
: [{ title: t("sshAccess"), href: "#" }]),
|
||||
{
|
||||
title: t("accessRoleInferenceBudget"),
|
||||
href: "#"
|
||||
}
|
||||
]}
|
||||
>
|
||||
{/* General tab */}
|
||||
@@ -423,115 +481,157 @@ export function RoleForm({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* SSH tab - hidden when enterprise features are disabled */}
|
||||
{!env.flags.disableEnterpriseFeatures && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.advancedPrivateResources}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="allowSsh"
|
||||
render={({ field }) => {
|
||||
const allowSshOptions: OptionSelectOption<
|
||||
"allow" | "disallow"
|
||||
>[] = [
|
||||
{
|
||||
value: "allow",
|
||||
label: t("roleAllowSshAllow")
|
||||
},
|
||||
{
|
||||
value: "disallow",
|
||||
label: t("roleAllowSshDisallow")
|
||||
}
|
||||
];
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("roleAllowSsh")}
|
||||
</FormLabel>
|
||||
<OptionSelect<
|
||||
"allow" | "disallow"
|
||||
>
|
||||
options={allowSshOptions}
|
||||
value={
|
||||
sshDisabled
|
||||
? "disallow"
|
||||
: field.value
|
||||
? "allow"
|
||||
: "disallow"
|
||||
}
|
||||
onChange={(v) => {
|
||||
if (sshDisabled) return;
|
||||
field.onChange(
|
||||
v === "allow"
|
||||
);
|
||||
}}
|
||||
cols={2}
|
||||
disabled={sshDisabled}
|
||||
/>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"roleAllowSshDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sshSudoMode"
|
||||
render={({ field }) => {
|
||||
const sudoOptions: OptionSelectOption<SshSudoMode>[] =
|
||||
[
|
||||
{
|
||||
value: "none",
|
||||
label: t("sshSudoModeNone")
|
||||
},
|
||||
{
|
||||
value: "full",
|
||||
label: t("sshSudoModeFull")
|
||||
},
|
||||
{
|
||||
value: "commands",
|
||||
label: t(
|
||||
"sshSudoModeCommands"
|
||||
)
|
||||
<div className="space-y-4 mt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="allowSsh"
|
||||
render={({ field }) => {
|
||||
const allowSshOptions: OptionSelectOption<
|
||||
"allow" | "disallow"
|
||||
>[] = [
|
||||
{
|
||||
value: "allow",
|
||||
label: t("roleAllowSshAllow")
|
||||
},
|
||||
{
|
||||
value: "disallow",
|
||||
label: t("roleAllowSshDisallow")
|
||||
}
|
||||
];
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("roleAllowSsh")}
|
||||
</FormLabel>
|
||||
<OptionSelect<"allow" | "disallow">
|
||||
options={allowSshOptions}
|
||||
value={
|
||||
field.value
|
||||
? "allow"
|
||||
: "disallow"
|
||||
}
|
||||
];
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("sshSudoMode")}
|
||||
</FormLabel>
|
||||
<OptionSelect<SshSudoMode>
|
||||
options={sudoOptions}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
cols={3}
|
||||
disabled={sshDisabled}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{sshSudoMode === "commands" && (
|
||||
onChange={(v) => {
|
||||
field.onChange(
|
||||
v === "allow"
|
||||
);
|
||||
}}
|
||||
cols={2}
|
||||
/>
|
||||
<FormDescription>
|
||||
{t("roleAllowSshDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{/* SSH tab - hidden when enterprise features are disabled */}
|
||||
{!env.flags.disableEnterpriseFeatures && (
|
||||
<>
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.roleBasedSSHControls}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sshSudoCommands"
|
||||
name="sshSudoMode"
|
||||
render={({ field }) => {
|
||||
const sudoOptions: OptionSelectOption<SshSudoMode>[] =
|
||||
[
|
||||
{
|
||||
value: "none",
|
||||
label: t(
|
||||
"sshSudoModeNone"
|
||||
)
|
||||
},
|
||||
{
|
||||
value: "full",
|
||||
label: t(
|
||||
"sshSudoModeFull"
|
||||
)
|
||||
},
|
||||
{
|
||||
value: "commands",
|
||||
label: t(
|
||||
"sshSudoModeCommands"
|
||||
)
|
||||
}
|
||||
];
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("sshSudoMode")}
|
||||
</FormLabel>
|
||||
<OptionSelect<SshSudoMode>
|
||||
options={sudoOptions}
|
||||
value={field.value}
|
||||
onChange={
|
||||
field.onChange
|
||||
}
|
||||
cols={3}
|
||||
disabled={sshDisabled}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{sshSudoMode === "commands" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sshSudoCommands"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("sshSudoCommands")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
{...getTextImportDropHandlers(
|
||||
"sshSudoCommands"
|
||||
)}
|
||||
placeholder={
|
||||
sshDisabled
|
||||
? undefined
|
||||
: t(
|
||||
"roleTextFieldPlaceholder"
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
sshDisabled
|
||||
}
|
||||
className={cn(
|
||||
"h-20 min-h-20",
|
||||
dragOverField ===
|
||||
"sshSudoCommands" &&
|
||||
"border-primary"
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"sshSudoCommandsDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sshUnixGroups"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("sshSudoCommands")}
|
||||
{t("sshUnixGroups")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
{...getTextImportDropHandlers(
|
||||
"sshSudoCommands"
|
||||
"sshUnixGroups"
|
||||
)}
|
||||
placeholder={
|
||||
sshDisabled
|
||||
@@ -544,97 +644,73 @@ export function RoleForm({
|
||||
className={cn(
|
||||
"h-20 min-h-20",
|
||||
dragOverField ===
|
||||
"sshSudoCommands" &&
|
||||
"sshUnixGroups" &&
|
||||
"border-primary"
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"sshSudoCommandsDescription"
|
||||
"sshUnixGroupsDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sshUnixGroups"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("sshUnixGroups")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
{...getTextImportDropHandlers(
|
||||
"sshUnixGroups"
|
||||
)}
|
||||
placeholder={
|
||||
sshDisabled
|
||||
? undefined
|
||||
: t(
|
||||
"roleTextFieldPlaceholder"
|
||||
)
|
||||
}
|
||||
disabled={sshDisabled}
|
||||
className={cn(
|
||||
"h-20 min-h-20",
|
||||
dragOverField ===
|
||||
"sshUnixGroups" &&
|
||||
"border-primary"
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("sshUnixGroupsDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sshCreateHomeDir"
|
||||
render={({ field }) => (
|
||||
<FormItem className="my-2">
|
||||
<FormControl>
|
||||
<CheckboxWithLabel
|
||||
{...field}
|
||||
value="on"
|
||||
checked={form.watch(
|
||||
"sshCreateHomeDir"
|
||||
)}
|
||||
onCheckedChange={(
|
||||
checked
|
||||
) => {
|
||||
if (
|
||||
checked !==
|
||||
"indeterminate"
|
||||
) {
|
||||
form.setValue(
|
||||
"sshCreateHomeDir",
|
||||
checked
|
||||
);
|
||||
}
|
||||
}}
|
||||
label={t(
|
||||
"sshCreateHomeDir"
|
||||
)}
|
||||
disabled={sshDisabled}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sshCreateHomeDir"
|
||||
render={({ field }) => (
|
||||
<FormItem className="my-2">
|
||||
<FormControl>
|
||||
<CheckboxWithLabel
|
||||
{...field}
|
||||
value="on"
|
||||
checked={form.watch(
|
||||
"sshCreateHomeDir"
|
||||
)}
|
||||
onCheckedChange={(
|
||||
checked
|
||||
) => {
|
||||
if (
|
||||
checked !==
|
||||
"indeterminate"
|
||||
) {
|
||||
form.setValue(
|
||||
"sshCreateHomeDir",
|
||||
checked
|
||||
);
|
||||
}
|
||||
}}
|
||||
label={t(
|
||||
"sshCreateHomeDir"
|
||||
)}
|
||||
disabled={sshDisabled}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-4 mt-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("accessRoleInferenceBudgetDescription")}
|
||||
</p>
|
||||
<BudgetRowsFields
|
||||
rows={pendingBudgetRows}
|
||||
onChange={setPendingBudgetRows}
|
||||
disabled={
|
||||
variant === "edit" && budgetsQuery.isLoading
|
||||
}
|
||||
attemptedSave={attemptedBudgetsSave}
|
||||
/>
|
||||
</div>
|
||||
</HorizontalTabs>
|
||||
)}
|
||||
</form>
|
||||
|
||||
@@ -35,6 +35,7 @@ import moment from "moment";
|
||||
import CreateShareLinkForm from "@app/components/CreateShareLinkForm";
|
||||
import { constructShareLink } from "@app/lib/shareLinks";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||
|
||||
export type ShareLinkRow = {
|
||||
accessTokenId: string;
|
||||
@@ -44,6 +45,10 @@ export type ShareLinkRow = {
|
||||
title: string | null;
|
||||
createdAt: number;
|
||||
expiresAt: number | null;
|
||||
userId?: string | null;
|
||||
userName?: string | null;
|
||||
username?: string | null;
|
||||
userEmail?: string | null;
|
||||
};
|
||||
|
||||
type ShareLinksTableProps = {
|
||||
@@ -155,6 +160,41 @@ export default function ShareLinksTable({
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "userId",
|
||||
friendlyName: t("user"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("user")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
if (!r.userId) {
|
||||
return <span>-</span>;
|
||||
}
|
||||
return (
|
||||
<Link href={`/${orgId}/settings/access/users/${r.userId}`}>
|
||||
<Button variant="outline" size="sm">
|
||||
{getUserDisplayName({
|
||||
email: r.userEmail,
|
||||
name: r.userName,
|
||||
username: r.username
|
||||
})}
|
||||
<ArrowUpRight className="ml-2 h-3 w-3" />
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
},
|
||||
// {
|
||||
// accessorKey: "domain",
|
||||
// header: "Link",
|
||||
|
||||
@@ -34,6 +34,7 @@ export type SidebarNavItem = {
|
||||
showEE?: boolean;
|
||||
isBeta?: boolean;
|
||||
items?: SidebarNavItem[];
|
||||
exact?: boolean;
|
||||
};
|
||||
|
||||
export type SidebarNavSection = {
|
||||
@@ -49,6 +50,17 @@ export interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
|
||||
notificationCounts?: Record<string, number | undefined>;
|
||||
}
|
||||
|
||||
function isPathActive(
|
||||
pathname: string,
|
||||
href: string,
|
||||
exact?: boolean
|
||||
): boolean {
|
||||
if (exact) {
|
||||
return pathname === href;
|
||||
}
|
||||
return pathname === href || pathname.startsWith(`${href}/`);
|
||||
}
|
||||
|
||||
type CollapsibleNavItemProps = {
|
||||
item: SidebarNavItem;
|
||||
level: number;
|
||||
@@ -285,7 +297,11 @@ function CollapsedNavItemWithPopover({
|
||||
childItem.href
|
||||
);
|
||||
const childIsActive = childHydratedHref
|
||||
? pathname.startsWith(childHydratedHref)
|
||||
? isPathActive(
|
||||
pathname,
|
||||
childHydratedHref,
|
||||
childItem.exact
|
||||
)
|
||||
: false;
|
||||
const childIsEE =
|
||||
build === "enterprise" &&
|
||||
@@ -392,7 +408,7 @@ export function SidebarNav({
|
||||
|
||||
function isItemOrChildActive(item: SidebarNavItem): boolean {
|
||||
const hydratedHref = hydrateHref(item.href);
|
||||
if (hydratedHref && pathname.startsWith(hydratedHref)) {
|
||||
if (hydratedHref && isPathActive(pathname, hydratedHref, item.exact)) {
|
||||
return true;
|
||||
}
|
||||
if (item.items) {
|
||||
@@ -408,7 +424,7 @@ export function SidebarNav({
|
||||
const hydratedHref = hydrateHref(item.href);
|
||||
const hasNestedItems = item.items && item.items.length > 0;
|
||||
const isActive = hydratedHref
|
||||
? pathname.startsWith(hydratedHref)
|
||||
? isPathActive(pathname, hydratedHref, item.exact)
|
||||
: false;
|
||||
const isChildActive = hasNestedItems
|
||||
? isItemOrChildActive(item)
|
||||
|
||||
@@ -70,7 +70,8 @@ function PrivateResourceMeta({ row }: { row: SiteResourceRow }) {
|
||||
host: t("editInternalResourceDialogModeHost"),
|
||||
cidr: t("editInternalResourceDialogModeCidr"),
|
||||
http: t("editInternalResourceDialogModeHttp"),
|
||||
ssh: t("editInternalResourceDialogModeSsh")
|
||||
ssh: t("editInternalResourceDialogModeSsh"),
|
||||
inference: t("editInternalResourceDialogModeInference")
|
||||
};
|
||||
const dest = formatSiteResourceDestinationDisplay({
|
||||
mode: row.mode,
|
||||
@@ -174,8 +175,8 @@ type OverviewRow = {
|
||||
type OverviewColumnProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
viewAllHref: string;
|
||||
viewAllLabel: string;
|
||||
viewAllHref?: string;
|
||||
viewAllLabel?: string;
|
||||
emptyLabel: string;
|
||||
isForbidden: boolean;
|
||||
isFetching: boolean;
|
||||
@@ -212,12 +213,14 @@ function OverviewColumn({
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={viewAllHref}
|
||||
className="shrink-0 text-muted-foreground text-sm hover:underline"
|
||||
>
|
||||
{viewAllLabel}
|
||||
</Link>
|
||||
{viewAllHref && viewAllLabel ? (
|
||||
<Link
|
||||
href={viewAllHref}
|
||||
className="shrink-0 text-muted-foreground text-sm hover:underline"
|
||||
>
|
||||
{viewAllLabel}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -319,6 +322,8 @@ type SiteResourcesOverviewProps = {
|
||||
initialPrivateForbidden: boolean;
|
||||
/** When not under `/[orgId]/...` routes, pass org id explicitly (e.g. credenza on sites list). */
|
||||
orgIdOverride?: string;
|
||||
/** When false, hides links to the org resources tables filtered by this site. */
|
||||
showViewAllLinks?: boolean;
|
||||
};
|
||||
|
||||
export default function SiteResourcesOverview({
|
||||
@@ -327,7 +332,8 @@ export default function SiteResourcesOverview({
|
||||
initialPrivateData,
|
||||
initialPublicForbidden,
|
||||
initialPrivateForbidden,
|
||||
orgIdOverride
|
||||
orgIdOverride,
|
||||
showViewAllLinks = true
|
||||
}: SiteResourcesOverviewProps) {
|
||||
const t = useTranslations();
|
||||
const params = useParams<{ orgId: string }>();
|
||||
@@ -467,8 +473,10 @@ export default function SiteResourcesOverview({
|
||||
key="public"
|
||||
title={t("siteResourcesSectionPublic")}
|
||||
description={t("siteResourcesSectionPublicDescription")}
|
||||
viewAllHref={publicViewAllHref}
|
||||
viewAllLabel={t("siteResourcesViewAllPublic")}
|
||||
viewAllHref={showViewAllLinks ? publicViewAllHref : undefined}
|
||||
viewAllLabel={
|
||||
showViewAllLinks ? t("siteResourcesViewAllPublic") : undefined
|
||||
}
|
||||
emptyLabel={t("siteResourcesEmptyPublic")}
|
||||
isForbidden={publicForbidden}
|
||||
isFetching={publicQuery.isFetching}
|
||||
@@ -484,8 +492,10 @@ export default function SiteResourcesOverview({
|
||||
key="private"
|
||||
title={t("siteResourcesSectionPrivate")}
|
||||
description={t("siteResourcesSectionPrivateDescription")}
|
||||
viewAllHref={privateViewAllHref}
|
||||
viewAllLabel={t("siteResourcesViewAllPrivate")}
|
||||
viewAllHref={showViewAllLinks ? privateViewAllHref : undefined}
|
||||
viewAllLabel={
|
||||
showViewAllLinks ? t("siteResourcesViewAllPrivate") : undefined
|
||||
}
|
||||
emptyLabel={t("siteResourcesEmptyPrivate")}
|
||||
isForbidden={privateForbidden}
|
||||
isFetching={privateQuery.isFetching}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import UptimeMiniBar from "@app/components/UptimeMiniBar";
|
||||
import { UptimeMiniBar } from "@app/components/UptimeMiniBar";
|
||||
|
||||
import {
|
||||
Credenza,
|
||||
@@ -52,12 +52,12 @@ import {
|
||||
} from "./ui/controlled-data-table";
|
||||
|
||||
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { durationToMs } from "@app/lib/durationToMs";
|
||||
import { orgQueries, productUpdatesQueries } from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import semver from "semver";
|
||||
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
|
||||
import { LabelsTableCell } from "./LabelsTableCell";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { productUpdatesQueries } from "@app/lib/queries";
|
||||
import semver from "semver";
|
||||
|
||||
export type SiteRow = {
|
||||
id: number;
|
||||
@@ -89,6 +89,8 @@ type SitesTableProps = {
|
||||
rowCount: number;
|
||||
};
|
||||
|
||||
const SITE_STATUS_HISTORY_DAYS = 30;
|
||||
|
||||
export default function SitesTable({
|
||||
sites,
|
||||
orgId,
|
||||
@@ -112,7 +114,16 @@ export default function SitesTable({
|
||||
const [isRefreshing, startTransition] = useTransition();
|
||||
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
||||
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const siteIds = useMemo(() => sites.map((s) => s.id), [sites]);
|
||||
|
||||
const statusHistoryQuery = useQuery({
|
||||
...orgQueries.batchedSiteStatusHistory({
|
||||
orgId,
|
||||
siteIds,
|
||||
days: SITE_STATUS_HISTORY_DAYS
|
||||
}),
|
||||
enabled: siteIds.length > 0
|
||||
});
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const t = useTranslations();
|
||||
@@ -296,7 +307,14 @@ export default function SitesTable({
|
||||
if (originalRow.type == "local") {
|
||||
return <span>-</span>;
|
||||
}
|
||||
return <UptimeMiniBar siteId={originalRow.id} days={30} />;
|
||||
const data = statusHistoryQuery.data?.[row.original.id];
|
||||
return (
|
||||
<UptimeMiniBar
|
||||
isLoading={statusHistoryQuery.isLoading}
|
||||
data={data}
|
||||
days={SITE_STATUS_HISTORY_DAYS}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -359,14 +377,11 @@ export default function SitesTable({
|
||||
cell: ({ row }) => {
|
||||
const originalRow = row.original;
|
||||
|
||||
let updateAvailable = Boolean(
|
||||
const updateAvailable = Boolean(
|
||||
latestNewtVersion &&
|
||||
originalRow.newtVersion &&
|
||||
semver.valid(originalRow.newtVersion) &&
|
||||
semver.lt(
|
||||
originalRow.newtVersion,
|
||||
latestNewtVersion
|
||||
)
|
||||
originalRow.newtVersion &&
|
||||
semver.valid(originalRow.newtVersion) &&
|
||||
semver.lt(originalRow.newtVersion, latestNewtVersion)
|
||||
);
|
||||
|
||||
if (originalRow.type === "newt") {
|
||||
@@ -623,7 +638,14 @@ export default function SitesTable({
|
||||
];
|
||||
|
||||
return cols;
|
||||
}, [orgId, t, searchParams, latestNewtVersion]);
|
||||
}, [
|
||||
orgId,
|
||||
t,
|
||||
searchParams,
|
||||
latestNewtVersion,
|
||||
statusHistoryQuery.data,
|
||||
statusHistoryQuery.isLoading
|
||||
]);
|
||||
|
||||
function toggleSort(column: string) {
|
||||
const newSearch = getNextSortOrder(column, searchParams);
|
||||
|
||||
@@ -27,6 +27,8 @@ import UserProfileCard from "@app/components/UserProfileCard";
|
||||
import SecurityKeyAuthButton from "@app/components/SecurityKeyAuthButton";
|
||||
import { Separator } from "@app/components/ui/separator";
|
||||
import OrgSignInLink from "@app/components/OrgSignInLink";
|
||||
import type { LoginFormIDP } from "./LoginForm";
|
||||
import IdpLoginButtons from "./IdpLoginButtons";
|
||||
|
||||
const identifierSchema = z.object({
|
||||
identifier: z.string().min(1, "Username or email is required")
|
||||
@@ -53,6 +55,8 @@ type SmartLoginFormProps = {
|
||||
forceLogin?: boolean;
|
||||
defaultUser?: string;
|
||||
orgSignIn?: OrgSignInConfig;
|
||||
lastUsedIdp?: (LoginFormIDP & { orgId?: string }) | null;
|
||||
inviteMode?: boolean;
|
||||
};
|
||||
|
||||
type ViewState =
|
||||
@@ -89,7 +93,9 @@ export default function SmartLoginForm({
|
||||
redirect,
|
||||
forceLogin,
|
||||
defaultUser,
|
||||
orgSignIn
|
||||
orgSignIn,
|
||||
lastUsedIdp,
|
||||
inviteMode = false
|
||||
}: SmartLoginFormProps) {
|
||||
const router = useRouter();
|
||||
const { env } = useEnvContext();
|
||||
@@ -132,6 +138,10 @@ export default function SmartLoginForm({
|
||||
return;
|
||||
}
|
||||
|
||||
const signupUrl = redirect
|
||||
? `/auth/signup?email=${encodeURIComponent(identifier)}&redirect=${encodeURIComponent(redirect)}&fromSmartLogin=true`
|
||||
: `/auth/signup?email=${encodeURIComponent(identifier)}&fromSmartLogin=true`;
|
||||
|
||||
if (!result.found || result.accounts.length === 0) {
|
||||
// No accounts found
|
||||
if (!isEmail || forceLogin) {
|
||||
@@ -143,13 +153,36 @@ export default function SmartLoginForm({
|
||||
return;
|
||||
}
|
||||
// Valid email but no accounts and not forceLogin - redirect to signup
|
||||
const signupUrl = redirect
|
||||
? `/auth/signup?email=${encodeURIComponent(identifier)}&redirect=${encodeURIComponent(redirect)}&fromSmartLogin=true`
|
||||
: `/auth/signup?email=${encodeURIComponent(identifier)}&fromSmartLogin=true`;
|
||||
router.push(signupUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// Invite accept only supports internal (password) accounts
|
||||
if (inviteMode) {
|
||||
const internalAccount = result.accounts.find(
|
||||
(acc) => acc.hasInternalAuth
|
||||
);
|
||||
if (internalAccount) {
|
||||
setViewState({
|
||||
type: "password",
|
||||
identifier,
|
||||
account: internalAccount
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEmail && !forceLogin) {
|
||||
router.push(signupUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
form.setError("identifier", {
|
||||
type: "manual",
|
||||
message: t("inviteLoginInternalOnly")
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine which view to show
|
||||
const account = result.accounts[0]; // Use first account for now
|
||||
|
||||
@@ -294,6 +327,16 @@ export default function SmartLoginForm({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lastUsedIdp && (
|
||||
<IdpLoginButtons
|
||||
idps={[lastUsedIdp]}
|
||||
orgId={lastUsedIdp.orgId}
|
||||
passOrgIdToOidcUrl={false}
|
||||
redirect={redirect}
|
||||
/>
|
||||
)}
|
||||
|
||||
<OrgSignInLink
|
||||
href={orgSignIn.href}
|
||||
linkText={orgSignIn.linkText}
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
} from "next/navigation";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
import { Separator } from "@app/components/ui/separator";
|
||||
import { setClientCookie } from "@app/lib/setClientCookie";
|
||||
import { LAST_USED_IDP_COOKIE_NAME } from "@app/lib/consts";
|
||||
|
||||
type SmartLoginOrgSelectorProps = {
|
||||
identifier: string;
|
||||
@@ -141,6 +143,17 @@ export default function SmartLoginOrgSelector({
|
||||
setPendingIdpId(idpId);
|
||||
setError(null);
|
||||
|
||||
setClientCookie(
|
||||
LAST_USED_IDP_COOKIE_NAME,
|
||||
JSON.stringify({
|
||||
orgId,
|
||||
idpId
|
||||
}),
|
||||
{
|
||||
sameSite: "Lax"
|
||||
}
|
||||
);
|
||||
|
||||
let redirectToUrl: string | undefined;
|
||||
try {
|
||||
const safeRedirect = cleanRedirect(redirect || "/");
|
||||
|
||||
@@ -134,7 +134,9 @@ export default function UptimeBar({
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const allNoData = data.days.every((d) => d.status === "no_data");
|
||||
const allNoData = data.days.every(
|
||||
(d) => d.status === "no_data" || d.status === "unknown"
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-3", className)}>
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger
|
||||
} from "@app/components/ui/tooltip";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import type { StatusHistoryResponse } from "@server/lib/statusHistory";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
@@ -46,21 +45,16 @@ type UptimeMiniBarProps = {
|
||||
days?: number;
|
||||
};
|
||||
|
||||
export default function UptimeMiniBar({
|
||||
export default function UptimeMiniBarWrapper({
|
||||
orgId,
|
||||
siteId,
|
||||
resourceId,
|
||||
healthCheckId,
|
||||
days = 30
|
||||
}: UptimeMiniBarProps) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const siteQuery = useQuery({
|
||||
...orgQueries.siteStatusHistory({ siteId: siteId ?? 0, days }),
|
||||
enabled: siteId != null,
|
||||
meta: { api },
|
||||
staleTime: 5 * 60 * 1000
|
||||
enabled: siteId != null
|
||||
});
|
||||
|
||||
const hcQuery = useQuery({
|
||||
@@ -69,16 +63,12 @@ export default function UptimeMiniBar({
|
||||
healthCheckId: healthCheckId ?? 0,
|
||||
days
|
||||
}),
|
||||
enabled: healthCheckId != null && siteId == null && resourceId == null,
|
||||
meta: { api },
|
||||
staleTime: 5 * 60 * 1000
|
||||
enabled: healthCheckId != null && siteId == null && resourceId == null
|
||||
});
|
||||
|
||||
const resourceQuery = useQuery({
|
||||
...orgQueries.resourceStatusHistory({ resourceId, days }),
|
||||
enabled: resourceId != null && siteId == null && healthCheckId == null,
|
||||
meta: { api },
|
||||
staleTime: 5 * 60 * 1000
|
||||
enabled: resourceId != null && siteId == null && healthCheckId == null
|
||||
});
|
||||
|
||||
const { data, isLoading } =
|
||||
@@ -88,6 +78,22 @@ export default function UptimeMiniBar({
|
||||
? resourceQuery
|
||||
: hcQuery;
|
||||
|
||||
return <UptimeMiniBar data={data} isLoading={isLoading} days={days} />;
|
||||
}
|
||||
|
||||
type UptimeMiniBarUIProps = {
|
||||
data?: StatusHistoryResponse;
|
||||
isLoading?: boolean;
|
||||
days?: number;
|
||||
};
|
||||
|
||||
export function UptimeMiniBar({
|
||||
data,
|
||||
isLoading,
|
||||
days = 30
|
||||
}: UptimeMiniBarUIProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -118,7 +124,9 @@ export default function UptimeMiniBar({
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const allNoData = data.days.every((d) => d.status === "no_data");
|
||||
const allNoData = data.days.every(
|
||||
(d) => d.status === "no_data" || d.status === "unknown"
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -138,7 +146,8 @@ export default function UptimeMiniBar({
|
||||
{formatDate(day.date)}
|
||||
</div>
|
||||
<div className="text-xs text-primary-foreground/80">
|
||||
{day.status === "no_data" || day.status === "unknown"
|
||||
{day.status === "no_data" ||
|
||||
day.status === "unknown"
|
||||
? t("uptimeNoData")
|
||||
: `${day.uptimePercent.toFixed(1)}% ${t("uptimeSuffix")}`}
|
||||
</div>
|
||||
@@ -159,4 +168,4 @@ export default function UptimeMiniBar({
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,8 @@ export type ClientRow = {
|
||||
username: string | null;
|
||||
hostname: string | null;
|
||||
} | null;
|
||||
firstSeen: number | null;
|
||||
lastSeen: number | null;
|
||||
};
|
||||
|
||||
type ClientTableProps = {
|
||||
@@ -112,7 +114,9 @@ export default function UserDevicesTable({
|
||||
|
||||
const defaultUserColumnVisibility = {
|
||||
subnet: false,
|
||||
niceId: false
|
||||
niceId: false,
|
||||
firstSeen: false,
|
||||
lastSeen: false
|
||||
};
|
||||
|
||||
const refreshData = () => {
|
||||
@@ -621,6 +625,68 @@ export default function UserDevicesTable({
|
||||
accessorKey: "subnet",
|
||||
friendlyName: t("address"),
|
||||
header: () => <span className="px-3">{t("address")}</span>
|
||||
},
|
||||
{
|
||||
accessorKey: "firstSeen",
|
||||
friendlyName: t("firstSeen"),
|
||||
header: () => {
|
||||
const firstSeenOrder = getSortDirection(
|
||||
"firstSeen",
|
||||
searchParams
|
||||
);
|
||||
|
||||
const Icon =
|
||||
firstSeenOrder === "asc"
|
||||
? ArrowDown01Icon
|
||||
: firstSeenOrder === "desc"
|
||||
? ArrowUp10Icon
|
||||
: ChevronsUpDownIcon;
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => toggleSort("firstSeen")}
|
||||
>
|
||||
{t("firstSeen")}
|
||||
<Icon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const firstSeen = row.original.firstSeen;
|
||||
if (!firstSeen) return "-";
|
||||
return new Date(firstSeen * 1000).toLocaleString();
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "lastSeen",
|
||||
friendlyName: t("lastSeen"),
|
||||
header: () => {
|
||||
const lastSeenOrder = getSortDirection(
|
||||
"lastSeen",
|
||||
searchParams
|
||||
);
|
||||
|
||||
const Icon =
|
||||
lastSeenOrder === "asc"
|
||||
? ArrowDown01Icon
|
||||
: lastSeenOrder === "desc"
|
||||
? ArrowUp10Icon
|
||||
: ChevronsUpDownIcon;
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => toggleSort("lastSeen")}
|
||||
>
|
||||
{t("lastSeen")}
|
||||
<Icon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const lastSeen = row.original.lastSeen;
|
||||
if (!lastSeen) return "-";
|
||||
return new Date(lastSeen * 1000).toLocaleString();
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import moment from "moment";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import CopyTextBox from "@app/components/CopyTextBox";
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import { AiClientConfigSection } from "@app/components/ai-client-config/AiClientConfigSection";
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle as SectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { useMyVirtualApiKeySecret } from "@app/hooks/useMyVirtualApiKeySecret";
|
||||
import type {
|
||||
ListMyVirtualApiKeysResponse,
|
||||
VirtualApiKeyWithResources
|
||||
} from "@server/routers/virtualApiKey/types";
|
||||
import { formatVirtualApiKeyPreview } from "@app/lib/virtualApiKeyFormat";
|
||||
|
||||
type UserVirtualApiKeysProps = {
|
||||
orgId: string;
|
||||
initialData: ListMyVirtualApiKeysResponse;
|
||||
/** The resource's niceId, when this page is scoped to a single resource. */
|
||||
resourceNiceId?: string;
|
||||
/** The resource's real access URL, when known; falls back to a placeholder otherwise. */
|
||||
endpoint?: string;
|
||||
};
|
||||
|
||||
function OwnedKeySecret({
|
||||
orgId,
|
||||
virtualApiKeyId,
|
||||
lastChars
|
||||
}: {
|
||||
orgId: string;
|
||||
virtualApiKeyId: string;
|
||||
lastChars: string;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const preview = formatVirtualApiKeyPreview(virtualApiKeyId, lastChars);
|
||||
const { credential, revealed, loading, getCopyText, revealSecret } =
|
||||
useMyVirtualApiKeySecret(orgId, virtualApiKeyId);
|
||||
const displayValue = revealed && credential ? credential : preview;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CopyToClipboard
|
||||
text={displayValue}
|
||||
displayText={displayValue}
|
||||
getCopyText={getCopyText}
|
||||
/>
|
||||
</div>
|
||||
{!revealed ? (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="shrink-0 px-0 h-auto"
|
||||
loading={loading}
|
||||
onClick={revealSecret}
|
||||
>
|
||||
{t("myVirtualApiKeysRevealSecret")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IdentityKeyCenterpiece({
|
||||
orgId,
|
||||
virtualApiKeyId,
|
||||
lastChars,
|
||||
resourceName
|
||||
}: {
|
||||
orgId: string;
|
||||
virtualApiKeyId: string;
|
||||
lastChars: string;
|
||||
resourceName?: string | null;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const preview = formatVirtualApiKeyPreview(virtualApiKeyId, lastChars);
|
||||
const { credential, revealed, loading, getCopyText, revealSecret } =
|
||||
useMyVirtualApiKeySecret(orgId, virtualApiKeyId);
|
||||
const displayValue = revealed && credential ? credential : preview;
|
||||
const headline = resourceName
|
||||
? t("myVirtualApiKeysIdentityResourceHeadline", { resourceName })
|
||||
: t("myVirtualApiKeysIdentityHeadline");
|
||||
const description = resourceName
|
||||
? t("myVirtualApiKeysIdentityResourceDescription", { resourceName })
|
||||
: t("myVirtualApiKeysIdentityDescription");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center text-center py-10 md:py-14 px-4">
|
||||
<h2 className="text-2xl font-semibold tracking-tight max-w-xl">
|
||||
{headline}
|
||||
</h2>
|
||||
<p className="mt-3 text-muted-foreground max-w-lg text-sm">
|
||||
{description}
|
||||
</p>
|
||||
<div className="mt-8 w-full max-w-2xl">
|
||||
<div className="[&_pre]:text-base [&_code]:font-mono [&_code]:tracking-wide">
|
||||
<CopyTextBox
|
||||
text={displayValue}
|
||||
getCopyText={getCopyText}
|
||||
wrapText={false}
|
||||
centered
|
||||
/>
|
||||
</div>
|
||||
{!revealed ? (
|
||||
<div className="mt-3 flex justify-center">
|
||||
<Button
|
||||
variant="link"
|
||||
className="px-0 h-auto"
|
||||
loading={loading}
|
||||
onClick={revealSecret}
|
||||
>
|
||||
{t("myVirtualApiKeysRevealSecret")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ManualKeyRow({
|
||||
orgId,
|
||||
keyRow
|
||||
}: {
|
||||
orgId: string;
|
||||
keyRow: VirtualApiKeyWithResources;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 border rounded-md p-4">
|
||||
<div className="space-y-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="font-medium truncate">
|
||||
{keyRow.name || t("myVirtualApiKeysUnnamed")}
|
||||
</p>
|
||||
</div>
|
||||
{keyRow.description ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{keyRow.description}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="pt-1">
|
||||
<OwnedKeySecret
|
||||
orgId={orgId}
|
||||
virtualApiKeyId={keyRow.virtualApiKeyId}
|
||||
lastChars={keyRow.lastChars}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("created")} {moment(keyRow.createdAt).format("lll")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UserVirtualApiKeys({
|
||||
orgId,
|
||||
initialData,
|
||||
resourceNiceId,
|
||||
endpoint
|
||||
}: UserVirtualApiKeysProps) {
|
||||
const t = useTranslations();
|
||||
const resourceName = initialData.resourceName;
|
||||
const { getCopyText: getKeyCopyText } = useMyVirtualApiKeySecret(
|
||||
orgId,
|
||||
initialData.userKey.virtualApiKeyId
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsContainer>
|
||||
<IdentityKeyCenterpiece
|
||||
orgId={orgId}
|
||||
virtualApiKeyId={initialData.userKey.virtualApiKeyId}
|
||||
lastChars={initialData.userKey.lastChars}
|
||||
resourceName={resourceName}
|
||||
/>
|
||||
|
||||
{initialData.manualKeys.length > 0 ? (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SectionTitle>
|
||||
{t("myVirtualApiKeysManualTitle")}
|
||||
</SectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{resourceName
|
||||
? t(
|
||||
"myVirtualApiKeysManualResourceDescription",
|
||||
{ resourceName }
|
||||
)
|
||||
: t("myVirtualApiKeysManualDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsFormGrid>
|
||||
{initialData.manualKeys.map((keyRow) => (
|
||||
<SettingsFormCell
|
||||
key={keyRow.virtualApiKeyId}
|
||||
span="half"
|
||||
>
|
||||
<ManualKeyRow
|
||||
orgId={orgId}
|
||||
keyRow={keyRow}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
))}
|
||||
</SettingsFormGrid>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
) : null}
|
||||
|
||||
<AiClientConfigSection
|
||||
layout="wide"
|
||||
endpoint={endpoint ?? t("aiClientConfigEndpointPlaceholder")}
|
||||
auth={{
|
||||
mode: "keyed",
|
||||
getKeyText: getKeyCopyText
|
||||
}}
|
||||
resourceNiceId={resourceNiceId}
|
||||
/>
|
||||
</SettingsContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { AxiosResponse } from "axios";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import CopyTextBox from "@app/components/CopyTextBox";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import type { GetVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types";
|
||||
import { formatVirtualApiKeyCredential } from "@app/lib/virtualApiKeyFormat";
|
||||
|
||||
type ViewVirtualApiKeySecretProps = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
virtualApiKeyId: string | null;
|
||||
name?: string | null;
|
||||
};
|
||||
|
||||
export default function ViewVirtualApiKeySecret({
|
||||
open,
|
||||
setOpen,
|
||||
virtualApiKeyId,
|
||||
name
|
||||
}: ViewVirtualApiKeySecretProps) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [credential, setCredential] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !virtualApiKeyId) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setCredential(null);
|
||||
|
||||
api.get<AxiosResponse<GetVirtualApiKeyResponse>>(
|
||||
`/virtual-api-key/${virtualApiKeyId}`
|
||||
)
|
||||
.then((res) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const key = res.data.data.virtualApiKey;
|
||||
if (key.secret) {
|
||||
setCredential(
|
||||
formatVirtualApiKeyCredential(
|
||||
key.virtualApiKeyId,
|
||||
key.secret
|
||||
)
|
||||
);
|
||||
} else {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("virtualApiKeysErrorFetchSecret"),
|
||||
description: t(
|
||||
"virtualApiKeysErrorFetchSecretDescription"
|
||||
)
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("virtualApiKeysErrorFetchSecret"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("virtualApiKeysErrorFetchSecretDescription")
|
||||
)
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, virtualApiKeyId]);
|
||||
|
||||
return (
|
||||
<Credenza
|
||||
open={open}
|
||||
onOpenChange={(val) => {
|
||||
setOpen(val);
|
||||
if (!val) {
|
||||
setCredential(null);
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{t("virtualApiKeysViewSecretTitle")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{name ? name : t("virtualApiKeysViewSecretDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<div className="space-y-4 px-1">
|
||||
{loading && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("loading")}
|
||||
</p>
|
||||
)}
|
||||
{!loading && credential && (
|
||||
<CopyTextBox text={credential} wrapText={false} />
|
||||
)}
|
||||
</div>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import { FormLabel } from "@app/components/ui/form";
|
||||
import { TagInput, type Tag } from "@app/components/tags/tag-input";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type VirtualApiKeyEmailSectionProps = {
|
||||
emailEnabled: boolean;
|
||||
mode: "create" | "edit";
|
||||
sendEmail: boolean;
|
||||
onSendEmailChange: (value: boolean) => void;
|
||||
sendToAttributedUser: boolean;
|
||||
onSendToAttributedUserChange: (value: boolean) => void;
|
||||
hasAssociatedUser: boolean;
|
||||
emailTags: Tag[];
|
||||
onEmailTagsChange: (tags: Tag[]) => void;
|
||||
};
|
||||
|
||||
export default function VirtualApiKeyEmailSection({
|
||||
emailEnabled,
|
||||
mode,
|
||||
sendEmail,
|
||||
onSendEmailChange,
|
||||
sendToAttributedUser,
|
||||
onSendToAttributedUserChange,
|
||||
hasAssociatedUser,
|
||||
emailTags,
|
||||
onEmailTagsChange
|
||||
}: VirtualApiKeyEmailSectionProps) {
|
||||
const t = useTranslations();
|
||||
const [activeEmailTagIndex, setActiveEmailTagIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasAssociatedUser && sendToAttributedUser) {
|
||||
onSendToAttributedUserChange(false);
|
||||
}
|
||||
}, [hasAssociatedUser, sendToAttributedUser, onSendToAttributedUserChange]);
|
||||
|
||||
const checkboxId =
|
||||
mode === "create"
|
||||
? "virtual-api-key-send-email"
|
||||
: "edit-virtual-api-key-send-email";
|
||||
const sendToUserId =
|
||||
mode === "create"
|
||||
? "virtual-api-key-send-to-user"
|
||||
: "edit-virtual-api-key-send-to-user";
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start space-x-2">
|
||||
<Checkbox
|
||||
id={checkboxId}
|
||||
checked={emailEnabled ? sendEmail : false}
|
||||
disabled={!emailEnabled}
|
||||
onCheckedChange={(val) => {
|
||||
if (emailEnabled) {
|
||||
onSendEmailChange(val === true);
|
||||
}
|
||||
}}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor={checkboxId}
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{t(
|
||||
mode === "create"
|
||||
? "virtualApiKeysEmailOnGenerate"
|
||||
: "virtualApiKeysEmailThisKey"
|
||||
)}
|
||||
</label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{emailEnabled
|
||||
? t(
|
||||
mode === "create"
|
||||
? "virtualApiKeysEmailOnGenerateDescription"
|
||||
: "virtualApiKeysEmailThisKeyDescription"
|
||||
)
|
||||
: t("virtualApiKeysEmailSmtpRequiredDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{emailEnabled && sendEmail && (
|
||||
<div className="space-y-4 pl-6">
|
||||
<div className="flex items-start space-x-2">
|
||||
<Checkbox
|
||||
id={sendToUserId}
|
||||
checked={sendToAttributedUser}
|
||||
disabled={!hasAssociatedUser}
|
||||
onCheckedChange={(val) =>
|
||||
onSendToAttributedUserChange(val === true)
|
||||
}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor={sendToUserId}
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{t("virtualApiKeysEmailSendToUser")}
|
||||
</label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{hasAssociatedUser
|
||||
? t(
|
||||
"virtualApiKeysEmailSendToUserDescription"
|
||||
)
|
||||
: t(
|
||||
"virtualApiKeysEmailSendToUserDisabled"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel>
|
||||
{t("virtualApiKeysEmailAdditional")}
|
||||
</FormLabel>
|
||||
<TagInput
|
||||
activeTagIndex={activeEmailTagIndex}
|
||||
setActiveTagIndex={setActiveEmailTagIndex}
|
||||
placeholder={t(
|
||||
"virtualApiKeysEmailAdditionalPlaceholder"
|
||||
)}
|
||||
size="sm"
|
||||
tags={emailTags}
|
||||
setTags={(newTags) => {
|
||||
const next =
|
||||
typeof newTags === "function"
|
||||
? newTags(emailTags)
|
||||
: newTags;
|
||||
onEmailTagsChange(next as Tag[]);
|
||||
}}
|
||||
allowDuplicates={false}
|
||||
sortTags
|
||||
validateTag={(tag) =>
|
||||
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(tag)
|
||||
}
|
||||
delimiterList={[",", "Enter"]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { DataTable } from "@app/components/ui/data-table";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type DataTableProps<TData, TValue> = {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
createVirtualApiKey?: () => void;
|
||||
onRefresh?: () => void;
|
||||
isRefreshing?: boolean;
|
||||
};
|
||||
|
||||
export function VirtualApiKeysDataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
createVirtualApiKey,
|
||||
onRefresh,
|
||||
isRefreshing
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
persistPageSize="virtualApiKeys-table"
|
||||
title={t("virtualApiKeys")}
|
||||
searchPlaceholder={t("virtualApiKeysSearch")}
|
||||
searchColumn="name"
|
||||
onAdd={createVirtualApiKey}
|
||||
onRefresh={onRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
addButtonText={t("virtualApiKeysCreate")}
|
||||
enableColumnVisibility={true}
|
||||
stickyLeftColumn="name"
|
||||
stickyRightColumn="actions"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
"use client";
|
||||
|
||||
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||
import { VirtualApiKeysDataTable } from "@app/components/VirtualApiKeysDataTable";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import {
|
||||
ArrowRight,
|
||||
ArrowUpDown,
|
||||
ArrowUpRight,
|
||||
Funnel,
|
||||
MoreHorizontal
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { formatAxiosError, createApiClient } from "@app/lib/api";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import moment from "moment";
|
||||
import CreateVirtualApiKeyForm, {
|
||||
type CreatedVirtualApiKey
|
||||
} from "@app/components/CreateVirtualApiKeyForm";
|
||||
import EditVirtualApiKeyForm from "@app/components/EditVirtualApiKeyForm";
|
||||
import ViewVirtualApiKeySecret from "@app/components/ViewVirtualApiKeySecret";
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||
import { UserSelector, type SelectedUser } from "@app/components/user-selector";
|
||||
import {
|
||||
ResourceSelector,
|
||||
type SelectedResource
|
||||
} from "@app/components/resource-selector";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
|
||||
import type { GetVirtualApiKeyResponse } from "@server/routers/virtualApiKey/types";
|
||||
import {
|
||||
formatVirtualApiKeyCredential,
|
||||
formatVirtualApiKeyPreview
|
||||
} from "@app/lib/virtualApiKeyFormat";
|
||||
import { AxiosResponse } from "axios";
|
||||
|
||||
export type VirtualApiKeyRow = CreatedVirtualApiKey;
|
||||
|
||||
type VirtualApiKeysTableProps = {
|
||||
virtualApiKeys: VirtualApiKeyRow[];
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
export default function VirtualApiKeysTable({
|
||||
virtualApiKeys,
|
||||
orgId
|
||||
}: VirtualApiKeysTableProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isViewSecretOpen, setIsViewSecretOpen] = useState(false);
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [selectedKey, setSelectedKey] = useState<VirtualApiKeyRow | null>(
|
||||
null
|
||||
);
|
||||
const [rows, setRows] = useState<VirtualApiKeyRow[]>(virtualApiKeys);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const [userFilterOpen, setUserFilterOpen] = useState(false);
|
||||
const [resourceFilterOpen, setResourceFilterOpen] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<SelectedUser | null>(null);
|
||||
const [selectedResource, setSelectedResource] =
|
||||
useState<SelectedResource | null>(null);
|
||||
const [unassignedOnly, setUnassignedOnly] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setRows(virtualApiKeys);
|
||||
}, [virtualApiKeys]);
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
return rows.filter((row) => {
|
||||
if (unassignedOnly && row.userId) {
|
||||
return false;
|
||||
}
|
||||
if (selectedUser && row.userId !== selectedUser.id) {
|
||||
return false;
|
||||
}
|
||||
if (selectedResource) {
|
||||
if (
|
||||
!row.allResources &&
|
||||
!row.resourceIds.includes(selectedResource.resourceId)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [rows, selectedUser, selectedResource, unassignedOnly]);
|
||||
|
||||
const refreshData = async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
router.refresh();
|
||||
} catch {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("refreshError"),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
async function deleteKey(id: string) {
|
||||
await api.delete(`/virtual-api-key/${id}`).catch((e) => {
|
||||
toast({
|
||||
title: t("virtualApiKeysErrorDelete"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("virtualApiKeysErrorDeleteMessage")
|
||||
)
|
||||
});
|
||||
throw e;
|
||||
});
|
||||
|
||||
setRows((prev) => prev.filter((r) => r.virtualApiKeyId !== id));
|
||||
|
||||
toast({
|
||||
title: t("virtualApiKeysDeleted"),
|
||||
description: t("virtualApiKeysDeletedDescription")
|
||||
});
|
||||
}
|
||||
|
||||
const clearUserFilter = () => {
|
||||
setSelectedUser(null);
|
||||
setUnassignedOnly(false);
|
||||
setUserFilterOpen(false);
|
||||
};
|
||||
|
||||
const clearResourceFilter = () => {
|
||||
setSelectedResource(null);
|
||||
setResourceFilterOpen(false);
|
||||
};
|
||||
|
||||
const columns: ExtendedColumnDef<VirtualApiKeyRow>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
enableHiding: false,
|
||||
friendlyName: t("virtualApiKeysName"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("virtualApiKeysName")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => row.original.name || "-"
|
||||
},
|
||||
{
|
||||
id: "resources",
|
||||
accessorFn: (row) => row.resourceNames,
|
||||
friendlyName: t("resource"),
|
||||
header: () => (
|
||||
<Popover
|
||||
open={resourceFilterOpen}
|
||||
onOpenChange={setResourceFilterOpen}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"justify-between text-sm h-8 px-2 w-full p-3",
|
||||
!selectedResource && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{t("resource")}
|
||||
<Funnel className="size-4 flex-none" />
|
||||
{selectedResource && (
|
||||
<Badge
|
||||
className="truncate max-w-[10rem]"
|
||||
variant="secondary"
|
||||
>
|
||||
{selectedResource.name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className={dataTableFilterPopoverContentClassName}
|
||||
align="start"
|
||||
>
|
||||
<ResourceSelector
|
||||
orgId={orgId}
|
||||
selectedResource={selectedResource}
|
||||
showClear={!!selectedResource}
|
||||
onClear={clearResourceFilter}
|
||||
protocol="inference"
|
||||
onSelectResource={(resource) => {
|
||||
setSelectedResource(resource);
|
||||
setResourceFilterOpen(false);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
if (r.allResources) {
|
||||
return t("virtualApiKeysAllResources");
|
||||
}
|
||||
if (r.resources.length === 0) {
|
||||
return <span>{t("virtualApiKeysNoResources")}</span>;
|
||||
}
|
||||
if (r.resources.length === 1) {
|
||||
const resource = r.resources[0];
|
||||
if (!resource.niceId) {
|
||||
return resource.name;
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
href={`/${orgId}/settings/resources/public/${resource.niceId}`}
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
{resource.name}
|
||||
<ArrowUpRight className="ml-2 h-3 w-3" />
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return r.resourceNames;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "userId",
|
||||
friendlyName: t("user"),
|
||||
header: () => (
|
||||
<Popover open={userFilterOpen} onOpenChange={setUserFilterOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"justify-between text-sm h-8 px-2 w-full p-3",
|
||||
!selectedUser &&
|
||||
!unassignedOnly &&
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{t("user")}
|
||||
<Funnel className="size-4 flex-none" />
|
||||
{(selectedUser || unassignedOnly) && (
|
||||
<Badge
|
||||
className="truncate max-w-[10rem]"
|
||||
variant="secondary"
|
||||
>
|
||||
{unassignedOnly
|
||||
? t(
|
||||
"virtualApiKeysFilterUnassigned"
|
||||
)
|
||||
: selectedUser?.text}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className={dataTableFilterPopoverContentClassName}
|
||||
align="start"
|
||||
>
|
||||
<UserSelector
|
||||
orgId={orgId}
|
||||
selectedUser={selectedUser}
|
||||
allowClear={false}
|
||||
showClear={!!selectedUser || unassignedOnly}
|
||||
onClear={clearUserFilter}
|
||||
unassignedOption={{
|
||||
label: t("virtualApiKeysFilterUnassigned"),
|
||||
selected: unassignedOnly,
|
||||
onSelect: () => {
|
||||
setSelectedUser(null);
|
||||
setUnassignedOnly(true);
|
||||
setUserFilterOpen(false);
|
||||
}
|
||||
}}
|
||||
onSelectUser={(user) => {
|
||||
setSelectedUser(user);
|
||||
setUnassignedOnly(false);
|
||||
setUserFilterOpen(false);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
if (!r.userId) {
|
||||
return <span>-</span>;
|
||||
}
|
||||
return (
|
||||
<Link href={`/${orgId}/settings/access/users/${r.userId}`}>
|
||||
<Button variant="outline" size="sm">
|
||||
{getUserDisplayName({
|
||||
email: r.userEmail,
|
||||
name: r.userName,
|
||||
username: r.username
|
||||
})}
|
||||
<ArrowUpRight className="ml-2 h-3 w-3" />
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "lastChars",
|
||||
friendlyName: t("virtualApiKeysSecret"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("virtualApiKeysSecret")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<VirtualApiKeySecretCell
|
||||
virtualApiKeyId={row.original.virtualApiKeyId}
|
||||
lastChars={row.original.lastChars}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
friendlyName: t("created"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("created")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => moment(row.original.createdAt).format("lll")
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
header: () => <span className="p-3"></span>,
|
||||
cell: ({ row }) => {
|
||||
const keyRow = row.original;
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">
|
||||
{t("openMenu")}
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedKey(keyRow);
|
||||
setIsViewSecretOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("virtualApiKeysViewSecret")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedKey(keyRow);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
{t("delete")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSelectedKey(keyRow);
|
||||
setIsEditModalOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedKey && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
setOpen={(val) => {
|
||||
setIsDeleteModalOpen(val);
|
||||
if (!val) setSelectedKey(null);
|
||||
}}
|
||||
dialog={
|
||||
<div className="space-y-2">
|
||||
<p>{t("virtualApiKeysQuestionRemove")}</p>
|
||||
<p>{t("virtualApiKeysMessageRemove")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("virtualApiKeysDeleteConfirm")}
|
||||
onConfirm={async () =>
|
||||
deleteKey(selectedKey.virtualApiKeyId)
|
||||
}
|
||||
string={selectedKey.name || selectedKey.virtualApiKeyId}
|
||||
title={t("virtualApiKeysDelete")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ViewVirtualApiKeySecret
|
||||
open={isViewSecretOpen}
|
||||
setOpen={(val) => {
|
||||
setIsViewSecretOpen(val);
|
||||
if (!val) setSelectedKey(null);
|
||||
}}
|
||||
virtualApiKeyId={selectedKey?.virtualApiKeyId ?? null}
|
||||
name={selectedKey?.name}
|
||||
/>
|
||||
|
||||
<CreateVirtualApiKeyForm
|
||||
open={isCreateModalOpen}
|
||||
setOpen={setIsCreateModalOpen}
|
||||
onCreated={(val) => {
|
||||
setRows([val, ...rows]);
|
||||
}}
|
||||
/>
|
||||
|
||||
<EditVirtualApiKeyForm
|
||||
open={isEditModalOpen}
|
||||
setOpen={(val) => {
|
||||
setIsEditModalOpen(val);
|
||||
if (!val) setSelectedKey(null);
|
||||
}}
|
||||
virtualApiKey={selectedKey}
|
||||
onUpdated={(val) => {
|
||||
setRows((prev) =>
|
||||
prev.map((row) =>
|
||||
row.virtualApiKeyId === val.virtualApiKeyId
|
||||
? val
|
||||
: row
|
||||
)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<VirtualApiKeysDataTable
|
||||
columns={columns}
|
||||
data={filteredRows}
|
||||
createVirtualApiKey={() => {
|
||||
setIsCreateModalOpen(true);
|
||||
}}
|
||||
onRefresh={refreshData}
|
||||
isRefreshing={isRefreshing}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function VirtualApiKeySecretCell({
|
||||
virtualApiKeyId,
|
||||
lastChars
|
||||
}: {
|
||||
virtualApiKeyId: string;
|
||||
lastChars: string;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const preview = formatVirtualApiKeyPreview(virtualApiKeyId, lastChars);
|
||||
const [credential, setCredential] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
api.get<AxiosResponse<GetVirtualApiKeyResponse>>(
|
||||
`/virtual-api-key/${virtualApiKeyId}`
|
||||
)
|
||||
.then((res) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const secret = res.data.data.virtualApiKey.secret;
|
||||
if (secret) {
|
||||
setCredential(
|
||||
formatVirtualApiKeyCredential(virtualApiKeyId, secret)
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("virtualApiKeysErrorFetchSecret"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("virtualApiKeysErrorFetchSecretDescription")
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [virtualApiKeyId]);
|
||||
|
||||
return (
|
||||
<CopyToClipboard text={credential ?? preview} displayText={preview} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
"use client";
|
||||
|
||||
import { AiConfigBlocks } from "@app/components/ai-client-config/AiConfigBlocks";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger
|
||||
} from "@app/components/ui/collapsible";
|
||||
import { OptionSelect } from "@app/components/OptionSelect";
|
||||
import type {
|
||||
AiClientAuthInput,
|
||||
AiClientId,
|
||||
AiClientPresetId
|
||||
} from "@app/lib/aiClientConfig";
|
||||
import { buildAiClientGuide } from "@app/lib/aiClientConfig";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { ChevronDown, Loader2 } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import Image from "next/image";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
type AiClientConfigCardProps = {
|
||||
clientId: AiClientId;
|
||||
name: string;
|
||||
endpoint: string;
|
||||
keyAuth: AiClientAuthInput;
|
||||
description: string;
|
||||
iconSrc: { light: string; dark: string };
|
||||
resourceNiceId?: string;
|
||||
};
|
||||
|
||||
type SetupMode = "cli" | "manual";
|
||||
|
||||
export function AiClientConfigCard({
|
||||
clientId,
|
||||
name,
|
||||
endpoint,
|
||||
keyAuth,
|
||||
description,
|
||||
iconSrc,
|
||||
resourceNiceId
|
||||
}: AiClientConfigCardProps) {
|
||||
const t = useTranslations();
|
||||
const { theme } = useTheme();
|
||||
const [resolvedIconSrc, setResolvedIconSrc] = useState(iconSrc.light);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [presetId, setPresetId] = useState<AiClientPresetId>("default");
|
||||
const [setupMode, setSetupMode] = useState<SetupMode>("cli");
|
||||
const [revealedKey, setRevealedKey] = useState<string | null>(null);
|
||||
const [revealing, setRevealing] = useState(false);
|
||||
const [revealError, setRevealError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let lightOrDark = theme;
|
||||
if (theme === "system" || !theme) {
|
||||
lightOrDark = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
setResolvedIconSrc(
|
||||
lightOrDark === "dark" ? iconSrc.dark : iconSrc.light
|
||||
);
|
||||
}, [theme, iconSrc]);
|
||||
|
||||
const reveal = () => {
|
||||
if (keyAuth.mode !== "keyed" || revealedKey !== null || revealing) {
|
||||
return;
|
||||
}
|
||||
setRevealing(true);
|
||||
setRevealError(false);
|
||||
keyAuth
|
||||
.getKeyText()
|
||||
.then(setRevealedKey)
|
||||
.catch(() => setRevealError(true))
|
||||
.finally(() => setRevealing(false));
|
||||
};
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
setOpen(next);
|
||||
if (next) {
|
||||
reveal();
|
||||
}
|
||||
};
|
||||
|
||||
const guide = useMemo(() => {
|
||||
if (keyAuth.mode === "keyless") {
|
||||
return buildAiClientGuide(
|
||||
clientId,
|
||||
endpoint,
|
||||
{ mode: "keyless" },
|
||||
resourceNiceId
|
||||
);
|
||||
}
|
||||
if (revealedKey === null) {
|
||||
return null;
|
||||
}
|
||||
return buildAiClientGuide(
|
||||
clientId,
|
||||
endpoint,
|
||||
{ mode: "keyed", key: revealedKey },
|
||||
resourceNiceId
|
||||
);
|
||||
}, [clientId, endpoint, keyAuth.mode, revealedKey, resourceNiceId]);
|
||||
|
||||
const preset =
|
||||
guide?.presets.find((p) => p.id === presetId) ?? guide?.presets[0];
|
||||
|
||||
const manualContent = guide ? (
|
||||
<div className="min-w-0 space-y-4">
|
||||
{guide.presets.length > 1 ? (
|
||||
<OptionSelect<AiClientPresetId>
|
||||
label={t("aiClientConfigPreset")}
|
||||
options={guide.presets.map((p) => ({
|
||||
value: p.id,
|
||||
label: p.label
|
||||
}))}
|
||||
value={preset?.id ?? presetId}
|
||||
onChange={setPresetId}
|
||||
cols={2}
|
||||
/>
|
||||
) : null}
|
||||
{preset ? (
|
||||
<AiConfigBlocks
|
||||
key={preset.id}
|
||||
blocks={preset.blocks}
|
||||
relation={preset.relation}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
className="min-w-0 rounded-md border bg-card"
|
||||
>
|
||||
<CollapsibleTrigger className="flex w-full items-center gap-3 px-4 py-3 text-left cursor-pointer">
|
||||
<Image
|
||||
src={resolvedIconSrc}
|
||||
alt={name}
|
||||
width={16}
|
||||
height={16}
|
||||
className="size-4 shrink-0 object-contain"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium truncate">{name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-4 shrink-0 text-muted-foreground transition-transform",
|
||||
open && "rotate-180"
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="min-w-0 border-t px-4 py-4">
|
||||
{!guide && revealing ? (
|
||||
<div className="flex items-center justify-center py-6 text-muted-foreground">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
</div>
|
||||
) : null}
|
||||
{!guide && revealError ? (
|
||||
<div className="flex flex-col items-center gap-2 py-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("aiClientConfigRevealError")}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={reveal}>
|
||||
{t("aiClientConfigRevealRetry")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{guide ? (
|
||||
guide.cli ? (
|
||||
<div className="min-w-0 space-y-4">
|
||||
<OptionSelect<SetupMode>
|
||||
label={t("aiClientConfigSetup")}
|
||||
options={[
|
||||
{
|
||||
value: "cli",
|
||||
label: t("aiClientConfigTabCli")
|
||||
},
|
||||
{
|
||||
value: "manual",
|
||||
label: t("aiClientConfigTabManual")
|
||||
}
|
||||
]}
|
||||
value={setupMode}
|
||||
onChange={setSetupMode}
|
||||
cols={2}
|
||||
/>
|
||||
{setupMode === "cli" ? (
|
||||
<AiConfigBlocks
|
||||
blocks={guide.cli}
|
||||
relation="options"
|
||||
/>
|
||||
) : (
|
||||
manualContent
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
manualContent
|
||||
)
|
||||
) : null}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { AiClientConfigCard } from "@app/components/ai-client-config/AiClientConfigCard";
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import {
|
||||
AI_CLIENT_IDS,
|
||||
AI_CLIENT_NAMES,
|
||||
type AiClientAuthInput
|
||||
} from "@app/lib/aiClientConfig";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type AiClientConfigSectionProps = {
|
||||
endpoint: string;
|
||||
auth: AiClientAuthInput;
|
||||
/**
|
||||
* "wide" lays the client cards out side by side once there's room
|
||||
* (e.g. the Keys page). "compact" always stacks them, which is what
|
||||
* fits the Resource Launcher's side panel. A card's own code blocks
|
||||
* always stack regardless of this setting.
|
||||
*/
|
||||
layout?: "wide" | "compact";
|
||||
className?: string;
|
||||
/**
|
||||
* The resource's niceId, when known, so the displayed CLI commands can
|
||||
* include `--resource <niceId>` and let `pangolin configure` skip its
|
||||
* own resource picker.
|
||||
*/
|
||||
resourceNiceId?: string;
|
||||
};
|
||||
|
||||
// Each logo file is named for its own color, not the theme it belongs on, so
|
||||
// the light-colored "-light" asset is what we show in dark mode and vice versa.
|
||||
const CLIENT_LOGOS = {
|
||||
claude: {
|
||||
light: "/third-party/claude-dark.svg",
|
||||
dark: "/third-party/claude-light.svg"
|
||||
},
|
||||
codex: {
|
||||
light: "/third-party/openai-dark.svg",
|
||||
dark: "/third-party/openai-light.svg"
|
||||
},
|
||||
opencode: {
|
||||
light: "/third-party/opencode-dark.svg",
|
||||
dark: "/third-party/opencode-light.svg"
|
||||
},
|
||||
gemini: {
|
||||
light: "/third-party/gemini-dark.svg",
|
||||
dark: "/third-party/gemini-light.svg"
|
||||
}
|
||||
} as const;
|
||||
|
||||
export function AiClientConfigSection({
|
||||
endpoint,
|
||||
auth,
|
||||
layout = "compact",
|
||||
className,
|
||||
resourceNiceId
|
||||
}: AiClientConfigSectionProps) {
|
||||
const t = useTranslations();
|
||||
const isWide = layout === "wide";
|
||||
|
||||
const descriptions: Record<string, string> = {
|
||||
claude: t("aiClientConfigDescriptionClaude"),
|
||||
codex: t("aiClientConfigDescriptionCodex"),
|
||||
opencode: t("aiClientConfigDescriptionOpencode"),
|
||||
gemini: t("aiClientConfigDescriptionGemini")
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("aiClientConfigTitle")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("aiClientConfigDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<div className={cn("@container min-w-0", className)}>
|
||||
<div
|
||||
className={cn(
|
||||
"grid min-w-0 items-start gap-3",
|
||||
isWide && "@3xl:grid-cols-2"
|
||||
)}
|
||||
>
|
||||
{AI_CLIENT_IDS.map((clientId) => (
|
||||
<AiClientConfigCard
|
||||
key={clientId}
|
||||
clientId={clientId}
|
||||
name={AI_CLIENT_NAMES[clientId]}
|
||||
endpoint={endpoint}
|
||||
keyAuth={auth}
|
||||
description={descriptions[clientId]}
|
||||
iconSrc={CLIENT_LOGOS[clientId]}
|
||||
resourceNiceId={resourceNiceId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { AiConfigCodeBlock } from "@app/components/ai-client-config/AiConfigCodeBlock";
|
||||
import {
|
||||
OptionSelect,
|
||||
type OptionSelectOption
|
||||
} from "@app/components/OptionSelect";
|
||||
import type { AiConfigBlock, AiConfigRelation } from "@app/lib/aiClientConfig";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
type AiConfigBlocksProps = {
|
||||
blocks: AiConfigBlock[];
|
||||
relation: AiConfigRelation;
|
||||
};
|
||||
|
||||
export function AiConfigBlocks({ blocks, relation }: AiConfigBlocksProps) {
|
||||
const t = useTranslations();
|
||||
const showPicker = relation === "options" && blocks.length > 1;
|
||||
const [selectedId, setSelectedId] = useState(blocks[0]?.id ?? "");
|
||||
|
||||
const methodOptions: OptionSelectOption<string>[] = useMemo(
|
||||
() =>
|
||||
blocks.map((block) => ({
|
||||
value: block.id,
|
||||
label: block.label
|
||||
})),
|
||||
[blocks]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!blocks.some((block) => block.id === selectedId)) {
|
||||
setSelectedId(blocks[0]?.id ?? "");
|
||||
}
|
||||
}, [blocks, selectedId]);
|
||||
|
||||
if (blocks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (showPicker) {
|
||||
const selected =
|
||||
blocks.find((block) => block.id === selectedId) ?? blocks[0];
|
||||
|
||||
return (
|
||||
<div className="min-w-0 space-y-4">
|
||||
<OptionSelect
|
||||
label={t("method")}
|
||||
options={methodOptions}
|
||||
value={selected.id}
|
||||
onChange={setSelectedId}
|
||||
cols={2}
|
||||
/>
|
||||
<AiConfigCodeBlock block={selected} hideLabel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const numberSteps = relation === "steps" && blocks.length > 1;
|
||||
|
||||
return (
|
||||
<div className="grid min-w-0 gap-4">
|
||||
{blocks.map((block, index) => (
|
||||
<AiConfigCodeBlock
|
||||
key={block.id}
|
||||
block={block}
|
||||
step={numberSteps ? index + 1 : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import CopyTextBox from "@app/components/CopyTextBox";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import {
|
||||
aiConfigBlockHasPlaceholders,
|
||||
type AiConfigBlock
|
||||
} from "@app/lib/aiClientConfig";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type AiConfigCodeBlockProps = {
|
||||
block: AiConfigBlock;
|
||||
step?: number;
|
||||
hideLabel?: boolean;
|
||||
};
|
||||
|
||||
export function AiConfigCodeBlock({
|
||||
block,
|
||||
step,
|
||||
hideLabel = false
|
||||
}: AiConfigCodeBlockProps) {
|
||||
const t = useTranslations();
|
||||
const label =
|
||||
step != null
|
||||
? `${t("aiClientConfigStep", { number: step })}: ${block.label}`
|
||||
: block.label;
|
||||
|
||||
return (
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
{!hideLabel ? (
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
{label}
|
||||
</p>
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0",
|
||||
block.kind !== "steps" && "[&_code]:font-mono"
|
||||
)}
|
||||
>
|
||||
{aiConfigBlockHasPlaceholders(block) ? (
|
||||
<Alert variant="neutral" className="mb-3">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{t("aiClientConfigPlaceholderWarning")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<CopyTextBox
|
||||
text={block.displayText}
|
||||
wrapText={block.kind === "steps"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { aiUsageAnalyticsQueries } from "@app/lib/queries";
|
||||
import type { AiUsageAnalyticsFilters } from "@app/lib/queries";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, CardContent, CardHeader } from "@app/components/ui/card";
|
||||
import {
|
||||
InfoSection,
|
||||
InfoSectionContent,
|
||||
InfoSections,
|
||||
InfoSectionTitle
|
||||
} from "@app/components/InfoSection";
|
||||
import { ToggleableTrendChart } from "./ToggleableTrendChart";
|
||||
import { TopEntitiesList, type TopEntity } from "./TopEntitiesList";
|
||||
import {
|
||||
SERIES_COLORS,
|
||||
buildSeriesFromData,
|
||||
compactNumberFormatter,
|
||||
formatCost
|
||||
} from "./shared";
|
||||
|
||||
type OverviewTabProps = {
|
||||
orgId: string;
|
||||
filters: AiUsageAnalyticsFilters;
|
||||
};
|
||||
|
||||
export function OverviewTab(props: OverviewTabProps) {
|
||||
const t = useTranslations();
|
||||
const { data, isLoading } = useQuery(
|
||||
aiUsageAnalyticsQueries.overview({
|
||||
orgId: props.orgId,
|
||||
filters: props.filters
|
||||
})
|
||||
);
|
||||
|
||||
const TOKEN_TYPE_LABELS: Record<string, string> = {
|
||||
promptTokens: t("aiUsageTokenTypePrompt"),
|
||||
cacheReadTokens: t("aiUsageTokenTypeCacheRead"),
|
||||
cacheWriteTokens: t("aiUsageTokenTypeCacheWrite"),
|
||||
completionTokens: t("aiUsageTokenTypeCompletion"),
|
||||
reasoningTokens: t("aiUsageTokenTypeReasoning")
|
||||
};
|
||||
|
||||
const requestsSeries = [
|
||||
{ key: "requests", label: t("aiUsageRequests"), color: SERIES_COLORS[0] }
|
||||
];
|
||||
const tokensSeries = Object.keys(TOKEN_TYPE_LABELS).map((key, i) => ({
|
||||
key,
|
||||
label: TOKEN_TYPE_LABELS[key],
|
||||
color: SERIES_COLORS[i % SERIES_COLORS.length]
|
||||
}));
|
||||
const costSeries = [
|
||||
{ key: "cost", label: t("aiUsageCost"), color: SERIES_COLORS[0] }
|
||||
];
|
||||
|
||||
const modelCostSeries = buildSeriesFromData(
|
||||
data?.modelCostPerDay ?? [],
|
||||
(key) => key,
|
||||
t("aiUsageOther")
|
||||
);
|
||||
const modelTokensSeries = buildSeriesFromData(
|
||||
data?.modelTokensPerDay ?? [],
|
||||
(key) => key,
|
||||
t("aiUsageOther")
|
||||
);
|
||||
|
||||
const topModels: TopEntity[] = (data?.topModels ?? []).map((m) => ({
|
||||
key: m.model,
|
||||
label: m.model,
|
||||
requests: m.requests,
|
||||
totalTokens: m.totalTokens,
|
||||
costUsd: m.costUsd
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<InfoSections cols={4}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("aiUsageTotalRequests")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{data
|
||||
? compactNumberFormatter.format(
|
||||
data.totalRequests
|
||||
)
|
||||
: "--"}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("aiUsageTotalTokens")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{data
|
||||
? compactNumberFormatter.format(
|
||||
data.totalTokens
|
||||
)
|
||||
: "--"}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("aiUsageTotalCost")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{data ? formatCost(data.totalCost) : "--"}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("aiUsageEstimated")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{data
|
||||
? `${Math.round(data.estimatedPercent)}%`
|
||||
: "--"}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</InfoSections>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<div className="grid lg:grid-cols-3 gap-5">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<ToggleableTrendChart
|
||||
title={t("aiUsageRequestVolume")}
|
||||
data={data?.requestsPerDay ?? []}
|
||||
series={requestsSeries}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<ToggleableTrendChart
|
||||
title={t("aiUsageTokenUsage")}
|
||||
data={data?.tokensPerDay ?? []}
|
||||
series={tokensSeries}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<ToggleableTrendChart
|
||||
title={t("aiUsageCost")}
|
||||
data={data?.costPerDay ?? []}
|
||||
series={costSeries}
|
||||
isLoading={isLoading}
|
||||
valueFormatter={(v) => formatCost(v)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-2 gap-5">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<ToggleableTrendChart
|
||||
title={t("aiUsageModelCost")}
|
||||
data={data?.modelCostPerDay ?? []}
|
||||
series={modelCostSeries}
|
||||
isLoading={isLoading}
|
||||
valueFormatter={(v) => formatCost(v)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<ToggleableTrendChart
|
||||
title={t("aiUsageModelTokens")}
|
||||
data={data?.modelTokensPerDay ?? []}
|
||||
series={modelTokensSeries}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">{t("aiUsageTopModels")}</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TopEntitiesList
|
||||
entities={topModels}
|
||||
isLoading={isLoading}
|
||||
nameColumnLabel={t("aiUsageFilterModel")}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { aiUsageAnalyticsQueries } from "@app/lib/queries";
|
||||
import type { AiUsageAnalyticsFilters } from "@app/lib/queries";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, CardContent, CardHeader } from "@app/components/ui/card";
|
||||
import { ToggleableTrendChart } from "./ToggleableTrendChart";
|
||||
import { TopEntitiesList, type TopEntity } from "./TopEntitiesList";
|
||||
import { buildSeriesFromData, formatCost } from "./shared";
|
||||
|
||||
type ProvidersTabProps = {
|
||||
orgId: string;
|
||||
filters: AiUsageAnalyticsFilters;
|
||||
};
|
||||
|
||||
export function ProvidersTab(props: ProvidersTabProps) {
|
||||
const t = useTranslations();
|
||||
const { data, isLoading } = useQuery(
|
||||
aiUsageAnalyticsQueries.providers({
|
||||
orgId: props.orgId,
|
||||
filters: props.filters
|
||||
})
|
||||
);
|
||||
|
||||
const nameByKey = new Map<string, string>();
|
||||
for (const p of data?.topProviders ?? []) {
|
||||
nameByKey.set(String(p.providerId), p.name ?? `Provider #${p.providerId}`);
|
||||
}
|
||||
const labelFor = (key: string) => nameByKey.get(key) ?? `Provider #${key}`;
|
||||
|
||||
const costSeries = buildSeriesFromData(
|
||||
data?.providerCostPerDay ?? [],
|
||||
labelFor,
|
||||
t("aiUsageOther")
|
||||
);
|
||||
const tokensSeries = buildSeriesFromData(
|
||||
data?.providerTokensPerDay ?? [],
|
||||
labelFor,
|
||||
t("aiUsageOther")
|
||||
);
|
||||
|
||||
const topProviders: TopEntity[] = (data?.topProviders ?? []).map((p) => ({
|
||||
key: String(p.providerId),
|
||||
label: p.name ?? `Provider #${p.providerId}`,
|
||||
requests: p.requests,
|
||||
totalTokens: p.totalTokens,
|
||||
costUsd: p.costUsd
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">{t("aiUsageTopProviders")}</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TopEntitiesList
|
||||
entities={topProviders}
|
||||
isLoading={isLoading}
|
||||
nameColumnLabel={t("aiUsageFilterProvider")}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid lg:grid-cols-2 gap-5">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<ToggleableTrendChart
|
||||
title={t("aiUsageProviderCost")}
|
||||
data={data?.providerCostPerDay ?? []}
|
||||
series={costSeries}
|
||||
isLoading={isLoading}
|
||||
valueFormatter={(v) => formatCost(v)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<ToggleableTrendChart
|
||||
title={t("aiUsageProviderTokenUsage")}
|
||||
data={data?.providerTokensPerDay ?? []}
|
||||
series={tokensSeries}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { aiUsageAnalyticsQueries } from "@app/lib/queries";
|
||||
import type { AiUsageAnalyticsFilters } from "@app/lib/queries";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, CardContent, CardHeader } from "@app/components/ui/card";
|
||||
import { ToggleableTrendChart } from "./ToggleableTrendChart";
|
||||
import { TopEntitiesList, type TopEntity } from "./TopEntitiesList";
|
||||
import { buildSeriesFromData, formatCost } from "./shared";
|
||||
|
||||
type ResourcesTabProps = {
|
||||
orgId: string;
|
||||
filters: AiUsageAnalyticsFilters;
|
||||
};
|
||||
|
||||
export function ResourcesTab(props: ResourcesTabProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
function resourceTypeLabel(type: "public" | "site" | null) {
|
||||
if (type === "public") return t("aiUsageResourceTypePublic");
|
||||
if (type === "site") return t("aiUsageResourceTypeSite");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { data, isLoading } = useQuery(
|
||||
aiUsageAnalyticsQueries.resources({
|
||||
orgId: props.orgId,
|
||||
filters: props.filters
|
||||
})
|
||||
);
|
||||
|
||||
const nameByKey = new Map<string, string>();
|
||||
for (const r of data?.topResources ?? []) {
|
||||
nameByKey.set(r.key, r.name ?? r.key);
|
||||
}
|
||||
const labelFor = (key: string) =>
|
||||
key === "none" ? t("aiUsageNoResource") : (nameByKey.get(key) ?? key);
|
||||
|
||||
const costSeries = buildSeriesFromData(
|
||||
data?.resourceCostPerDay ?? [],
|
||||
labelFor,
|
||||
t("aiUsageOther")
|
||||
);
|
||||
const tokensSeries = buildSeriesFromData(
|
||||
data?.resourceTokensPerDay ?? [],
|
||||
labelFor,
|
||||
t("aiUsageOther")
|
||||
);
|
||||
|
||||
const topResources: TopEntity[] = (data?.topResources ?? []).map((r) => ({
|
||||
key: r.key,
|
||||
label: r.name ?? (r.key === "none" ? t("aiUsageNoResource") : r.key),
|
||||
sublabel: resourceTypeLabel(r.type),
|
||||
requests: r.requests,
|
||||
totalTokens: r.totalTokens,
|
||||
costUsd: r.costUsd
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">{t("aiUsageTopResources")}</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TopEntitiesList
|
||||
entities={topResources}
|
||||
isLoading={isLoading}
|
||||
nameColumnLabel={t("aiUsageFilterResource")}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid lg:grid-cols-2 gap-5">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<ToggleableTrendChart
|
||||
title={t("aiUsageResourceCost")}
|
||||
data={data?.resourceCostPerDay ?? []}
|
||||
series={costSeries}
|
||||
isLoading={isLoading}
|
||||
valueFormatter={(v) => formatCost(v)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<ToggleableTrendChart
|
||||
title={t("aiUsageResourceTokenUsage")}
|
||||
data={data?.resourceTokensPerDay ?? []}
|
||||
series={tokensSeries}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { aiUsageAnalyticsQueries } from "@app/lib/queries";
|
||||
import type { AiUsageAnalyticsFilters } from "@app/lib/queries";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, CardContent, CardHeader } from "@app/components/ui/card";
|
||||
import { ToggleableTrendChart } from "./ToggleableTrendChart";
|
||||
import { TopEntitiesList, type TopEntity } from "./TopEntitiesList";
|
||||
import { buildSeriesFromData, formatCost } from "./shared";
|
||||
|
||||
type RolesTabProps = {
|
||||
orgId: string;
|
||||
filters: AiUsageAnalyticsFilters;
|
||||
};
|
||||
|
||||
export function RolesTab(props: RolesTabProps) {
|
||||
const t = useTranslations();
|
||||
const { data, isLoading } = useQuery(
|
||||
aiUsageAnalyticsQueries.usersRoles({
|
||||
orgId: props.orgId,
|
||||
filters: props.filters
|
||||
})
|
||||
);
|
||||
|
||||
const roleNameByKey = new Map<string, string>();
|
||||
for (const r of data?.topRoles ?? []) {
|
||||
roleNameByKey.set(String(r.roleId), r.name ?? `Role #${r.roleId}`);
|
||||
}
|
||||
const roleLabelFor = (key: string) =>
|
||||
roleNameByKey.get(key) ?? `Role #${key}`;
|
||||
|
||||
const roleCostSeries = buildSeriesFromData(
|
||||
data?.roleCostPerDay ?? [],
|
||||
roleLabelFor,
|
||||
t("aiUsageOther")
|
||||
);
|
||||
const roleTokensSeries = buildSeriesFromData(
|
||||
data?.roleTokensPerDay ?? [],
|
||||
roleLabelFor,
|
||||
t("aiUsageOther")
|
||||
);
|
||||
|
||||
const topRoles: TopEntity[] = (data?.topRoles ?? []).map((r) => ({
|
||||
key: String(r.roleId),
|
||||
label: r.name ?? `Role #${r.roleId}`,
|
||||
requests: r.requests,
|
||||
totalTokens: r.totalTokens,
|
||||
costUsd: r.costUsd
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">{t("aiUsageTopRoles")}</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TopEntitiesList
|
||||
entities={topRoles}
|
||||
isLoading={isLoading}
|
||||
nameColumnLabel={t("aiUsageFilterRole")}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="grid lg:grid-cols-2 gap-5">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<ToggleableTrendChart
|
||||
title={t("aiUsageRoleCost")}
|
||||
data={data?.roleCostPerDay ?? []}
|
||||
series={roleCostSeries}
|
||||
isLoading={isLoading}
|
||||
valueFormatter={(v) => formatCost(v)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<ToggleableTrendChart
|
||||
title={t("aiUsageRoleTokenUsage")}
|
||||
data={data?.roleTokensPerDay ?? []}
|
||||
series={roleTokensSeries}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
BarChart3,
|
||||
LineChart as LineChartIcon,
|
||||
LoaderIcon
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
XAxis,
|
||||
YAxis
|
||||
} from "recharts";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig
|
||||
} from "@app/components/ui/chart";
|
||||
|
||||
export type TrendSeries = {
|
||||
key: string;
|
||||
label: string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
export interface TrendChartRow {
|
||||
day: string;
|
||||
[seriesKey: string]: number | string;
|
||||
}
|
||||
|
||||
type ToggleableTrendChartProps = {
|
||||
title: string;
|
||||
data: TrendChartRow[];
|
||||
series: TrendSeries[];
|
||||
isLoading?: boolean;
|
||||
valueFormatter?: (value: number) => string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const compactFormatter = new Intl.NumberFormat(undefined, {
|
||||
maximumFractionDigits: 1,
|
||||
notation: "compact",
|
||||
compactDisplay: "short"
|
||||
});
|
||||
|
||||
export function ToggleableTrendChart(props: ToggleableTrendChartProps) {
|
||||
const t = useTranslations();
|
||||
const [chartType, setChartType] = useState<"bar" | "line">("bar");
|
||||
|
||||
const valueFormatter = props.valueFormatter ?? compactFormatter.format;
|
||||
|
||||
const chartConfig = props.series.reduce((acc, s) => {
|
||||
acc[s.key] = { label: s.label, color: s.color };
|
||||
return acc;
|
||||
}, {} as ChartConfig);
|
||||
|
||||
const hasData = props.data.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex min-w-0 flex-col gap-2",
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="font-semibold">{props.title}</h3>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={chartType === "bar" ? "secondary" : "ghost"}
|
||||
onClick={() => setChartType("bar")}
|
||||
className="gap-1.5 px-2"
|
||||
>
|
||||
<BarChart3 className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={chartType === "line" ? "secondary" : "ghost"}
|
||||
onClick={() => setChartType("line")}
|
||||
className="gap-1.5 px-2"
|
||||
>
|
||||
<LineChartIcon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!hasData ? (
|
||||
<div className="flex h-64 w-full items-center justify-center text-muted-foreground gap-2">
|
||||
{props.isLoading ? (
|
||||
<>
|
||||
<LoaderIcon className="size-4 animate-spin" />
|
||||
{t("aiUsageLoading")}
|
||||
</>
|
||||
) : (
|
||||
t("aiUsageNoData")
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto min-h-50 h-64 w-full min-w-0 overflow-hidden"
|
||||
>
|
||||
{chartType === "bar" ? (
|
||||
<BarChart accessibilityLayer data={props.data}>
|
||||
<ChartLegend
|
||||
content={
|
||||
<ChartLegendContent className="flex-wrap" />
|
||||
}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
indicator="dot"
|
||||
labelFormatter={(_value, payload) =>
|
||||
formatDay(
|
||||
payload?.[0]?.payload?.day
|
||||
)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<CartesianGrid vertical={false} />
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={valueFormatter}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
tickFormatter={formatDay}
|
||||
/>
|
||||
{props.series.map((s) => (
|
||||
<Bar
|
||||
key={s.key}
|
||||
dataKey={s.key}
|
||||
stackId="stack"
|
||||
fill={s.color}
|
||||
radius={2}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
) : (
|
||||
<LineChart accessibilityLayer data={props.data}>
|
||||
<ChartLegend
|
||||
content={
|
||||
<ChartLegendContent className="flex-wrap" />
|
||||
}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
indicator="line"
|
||||
labelFormatter={(_value, payload) =>
|
||||
formatDay(
|
||||
payload?.[0]?.payload?.day
|
||||
)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<CartesianGrid vertical={false} />
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={valueFormatter}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
tickFormatter={formatDay}
|
||||
/>
|
||||
{props.series.map((s) => (
|
||||
<Line
|
||||
key={s.key}
|
||||
dataKey={s.key}
|
||||
stroke={s.color}
|
||||
strokeWidth={2}
|
||||
fill="transparent"
|
||||
isAnimationActive={false}
|
||||
dot={false}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
)}
|
||||
</ChartContainer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDay(value: unknown) {
|
||||
if (typeof value !== "string") return "";
|
||||
const date = new Date(value);
|
||||
if (isNaN(date.getTime())) return value;
|
||||
return date.toLocaleDateString(undefined, { dateStyle: "medium" });
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { LoaderIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { compactNumberFormatter, formatCost } from "./shared";
|
||||
|
||||
export type TopEntity = {
|
||||
key: string;
|
||||
label: string;
|
||||
sublabel?: string | null;
|
||||
requests: number;
|
||||
totalTokens: number;
|
||||
costUsd: number | null;
|
||||
};
|
||||
|
||||
type TopEntitiesListProps = {
|
||||
entities: TopEntity[];
|
||||
isLoading: boolean;
|
||||
nameColumnLabel: string;
|
||||
emptyLabel?: string;
|
||||
};
|
||||
|
||||
export function TopEntitiesList(props: TopEntitiesListProps) {
|
||||
const t = useTranslations();
|
||||
const totalCost = props.entities.reduce(
|
||||
(sum, e) => sum + (e.costUsd ?? 0),
|
||||
0
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col gap-2">
|
||||
{props.entities.length > 0 && (
|
||||
<div className="grid grid-cols-12 text-sm text-muted-foreground font-semibold h-4">
|
||||
<div className="col-span-5">{props.nameColumnLabel}</div>
|
||||
<div className="col-span-2 text-end">
|
||||
{t("aiUsageRequests")}
|
||||
</div>
|
||||
<div className="col-span-2 text-end">
|
||||
{t("aiUsageTokens")}
|
||||
</div>
|
||||
<div className="col-span-2 text-end">
|
||||
{t("aiUsageCost")}
|
||||
</div>
|
||||
<div className="col-span-1 text-end">%</div>
|
||||
</div>
|
||||
)}
|
||||
<ol className="w-full overflow-auto gap-1 flex flex-col max-h-100">
|
||||
{props.entities.length === 0 && (
|
||||
<div className="flex items-center justify-center size-full text-muted-foreground gap-2 py-8">
|
||||
{props.isLoading ? (
|
||||
<>
|
||||
<LoaderIcon className="size-4 animate-spin" />
|
||||
{t("aiUsageLoading")}
|
||||
</>
|
||||
) : (
|
||||
(props.emptyLabel ?? t("aiUsageNoData"))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{props.entities.map((entity) => {
|
||||
const percent =
|
||||
totalCost > 0 ? (entity.costUsd ?? 0) / totalCost : 0;
|
||||
return (
|
||||
<li
|
||||
key={entity.key}
|
||||
className="w-full grid grid-cols-12 rounded-xs hover:bg-muted relative items-center text-sm py-1"
|
||||
>
|
||||
<div
|
||||
className="absolute bg-[#f36117]/40 top-0 bottom-0 left-0 rounded-xs"
|
||||
style={{ width: `${percent * 100}%` }}
|
||||
/>
|
||||
<div className="col-span-5 px-2 relative z-1 flex flex-col min-w-0">
|
||||
<span className="truncate">{entity.label}</span>
|
||||
{entity.sublabel && (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{entity.sublabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-2 text-end relative z-1">
|
||||
{compactNumberFormatter.format(entity.requests)}
|
||||
</div>
|
||||
<div className="col-span-2 text-end relative z-1">
|
||||
{compactNumberFormatter.format(
|
||||
entity.totalTokens
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-2 text-end relative z-1">
|
||||
{formatCost(entity.costUsd)}
|
||||
</div>
|
||||
<div className="col-span-1 text-end relative z-1">
|
||||
{Math.round(percent * 100)}%
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { aiUsageAnalyticsQueries } from "@app/lib/queries";
|
||||
import type { AiUsageAnalyticsFilters } from "@app/lib/queries";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, CardContent, CardHeader } from "@app/components/ui/card";
|
||||
import { ToggleableTrendChart } from "./ToggleableTrendChart";
|
||||
import { TopEntitiesList, type TopEntity } from "./TopEntitiesList";
|
||||
import { buildSeriesFromData, formatCost } from "./shared";
|
||||
|
||||
type UsersTabProps = {
|
||||
orgId: string;
|
||||
filters: AiUsageAnalyticsFilters;
|
||||
};
|
||||
|
||||
const UNKNOWN_USER_KEY = "unknown";
|
||||
|
||||
export function UsersTab(props: UsersTabProps) {
|
||||
const t = useTranslations();
|
||||
const { data, isLoading } = useQuery(
|
||||
aiUsageAnalyticsQueries.usersRoles({
|
||||
orgId: props.orgId,
|
||||
filters: props.filters
|
||||
})
|
||||
);
|
||||
|
||||
const userEmailByKey = new Map<string, string>();
|
||||
for (const u of data?.topUsers ?? []) {
|
||||
if (u.userId) {
|
||||
userEmailByKey.set(u.userId, u.email ?? u.userId);
|
||||
}
|
||||
}
|
||||
const userLabelFor = (key: string) =>
|
||||
key === UNKNOWN_USER_KEY
|
||||
? t("aiUsageUnknownUser")
|
||||
: (userEmailByKey.get(key) ?? key);
|
||||
|
||||
const userCostSeries = buildSeriesFromData(
|
||||
data?.userCostPerDay ?? [],
|
||||
userLabelFor,
|
||||
t("aiUsageOther")
|
||||
);
|
||||
const userTokensSeries = buildSeriesFromData(
|
||||
data?.userTokensPerDay ?? [],
|
||||
userLabelFor,
|
||||
t("aiUsageOther")
|
||||
);
|
||||
|
||||
const topUsers: TopEntity[] = (data?.topUsers ?? []).map((u) => ({
|
||||
key: u.userId ?? UNKNOWN_USER_KEY,
|
||||
label: u.email ?? u.userId ?? t("aiUsageUnknownUser"),
|
||||
requests: u.requests,
|
||||
totalTokens: u.totalTokens,
|
||||
costUsd: u.costUsd
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">{t("aiUsageTopUsers")}</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TopEntitiesList
|
||||
entities={topUsers}
|
||||
isLoading={isLoading}
|
||||
nameColumnLabel={t("aiUsageFilterUser")}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="grid lg:grid-cols-2 gap-5">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<ToggleableTrendChart
|
||||
title={t("aiUsageUserCost")}
|
||||
data={data?.userCostPerDay ?? []}
|
||||
series={userCostSeries}
|
||||
isLoading={isLoading}
|
||||
valueFormatter={(v) => formatCost(v)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<ToggleableTrendChart
|
||||
title={t("aiUsageUserTokenUsage")}
|
||||
data={data?.userTokensPerDay ?? []}
|
||||
series={userTokensSeries}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { aiUsageAnalyticsQueries } from "@app/lib/queries";
|
||||
import type { AiUsageAnalyticsFilters } from "@app/lib/queries";
|
||||
import { formatVirtualApiKeyPreview } from "@app/lib/virtualApiKeyFormat";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, CardContent, CardHeader } from "@app/components/ui/card";
|
||||
import { ToggleableTrendChart } from "./ToggleableTrendChart";
|
||||
import { TopEntitiesList, type TopEntity } from "./TopEntitiesList";
|
||||
import { buildSeriesFromData, formatCost } from "./shared";
|
||||
|
||||
type VirtualApiKeysTabProps = {
|
||||
orgId: string;
|
||||
filters: AiUsageAnalyticsFilters;
|
||||
};
|
||||
|
||||
const UNKNOWN_VIRTUAL_API_KEY_KEY = "unknown";
|
||||
|
||||
export function VirtualApiKeysTab(props: VirtualApiKeysTabProps) {
|
||||
const t = useTranslations();
|
||||
const { data, isLoading } = useQuery(
|
||||
aiUsageAnalyticsQueries.virtualApiKeys({
|
||||
orgId: props.orgId,
|
||||
filters: props.filters
|
||||
})
|
||||
);
|
||||
|
||||
const labelByKey = new Map<string, string>();
|
||||
for (const k of data?.topVirtualApiKeys ?? []) {
|
||||
if (k.virtualApiKeyId) {
|
||||
labelByKey.set(k.virtualApiKeyId, k.name ?? k.virtualApiKeyId);
|
||||
}
|
||||
}
|
||||
const virtualApiKeyLabelFor = (key: string) =>
|
||||
key === UNKNOWN_VIRTUAL_API_KEY_KEY
|
||||
? t("aiUsageUnknownVirtualApiKey")
|
||||
: (labelByKey.get(key) ?? key);
|
||||
|
||||
const virtualApiKeyCostSeries = buildSeriesFromData(
|
||||
data?.virtualApiKeyCostPerDay ?? [],
|
||||
virtualApiKeyLabelFor,
|
||||
t("aiUsageOther")
|
||||
);
|
||||
const virtualApiKeyTokensSeries = buildSeriesFromData(
|
||||
data?.virtualApiKeyTokensPerDay ?? [],
|
||||
virtualApiKeyLabelFor,
|
||||
t("aiUsageOther")
|
||||
);
|
||||
|
||||
const topVirtualApiKeys: TopEntity[] = (data?.topVirtualApiKeys ?? []).map(
|
||||
(k) => ({
|
||||
key: k.virtualApiKeyId ?? UNKNOWN_VIRTUAL_API_KEY_KEY,
|
||||
label: k.virtualApiKeyId
|
||||
? (k.name ?? t("aiUsageUnnamedVirtualApiKey"))
|
||||
: t("aiUsageUnknownVirtualApiKey"),
|
||||
sublabel:
|
||||
k.virtualApiKeyId && k.lastChars
|
||||
? formatVirtualApiKeyPreview(k.virtualApiKeyId, k.lastChars)
|
||||
: undefined,
|
||||
requests: k.requests,
|
||||
totalTokens: k.totalTokens,
|
||||
costUsd: k.costUsd
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="font-semibold">
|
||||
{t("aiUsageTopVirtualApiKeys")}
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TopEntitiesList
|
||||
entities={topVirtualApiKeys}
|
||||
isLoading={isLoading}
|
||||
nameColumnLabel={t("aiUsageFilterVirtualApiKey")}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="grid lg:grid-cols-2 gap-5">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<ToggleableTrendChart
|
||||
title={t("aiUsageVirtualApiKeyCost")}
|
||||
data={data?.virtualApiKeyCostPerDay ?? []}
|
||||
series={virtualApiKeyCostSeries}
|
||||
isLoading={isLoading}
|
||||
valueFormatter={(v) => formatCost(v)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<ToggleableTrendChart
|
||||
title={t("aiUsageVirtualApiKeyTokenUsage")}
|
||||
data={data?.virtualApiKeyTokensPerDay ?? []}
|
||||
series={virtualApiKeyTokensSeries}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { TrendSeries } from "./ToggleableTrendChart";
|
||||
|
||||
// Matches the theme's 5 categorical chart colors (--chart-1..--chart-5 in
|
||||
// src/app/globals.css) - the same ceiling RequestChart already respects.
|
||||
export const SERIES_COLORS = [
|
||||
"var(--chart-1)",
|
||||
"var(--chart-2)",
|
||||
"var(--chart-3)",
|
||||
"var(--chart-4)",
|
||||
"var(--chart-5)"
|
||||
];
|
||||
export const OTHER_COLOR = "var(--muted-foreground)";
|
||||
export const OTHER_KEY = "other";
|
||||
|
||||
// The server already collapsed each day's row down to the top-N dimension
|
||||
// keys (already ranked) plus an optional "other" bucket - so the full set of
|
||||
// series can be derived straight from the data's own keys, no separate
|
||||
// top-list needed. Assigns one categorical color per key, "other" last.
|
||||
export function buildSeriesFromData(
|
||||
data: Array<Record<string, number | string>>,
|
||||
labelFor: (key: string) => string,
|
||||
otherLabel = "Other"
|
||||
): TrendSeries[] {
|
||||
const keys = new Set<string>();
|
||||
for (const row of data) {
|
||||
for (const key of Object.keys(row)) {
|
||||
if (key !== "day" && key !== OTHER_KEY) {
|
||||
keys.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const series: TrendSeries[] = [...keys].map((key, i) => ({
|
||||
key,
|
||||
label: labelFor(key),
|
||||
color: SERIES_COLORS[i % SERIES_COLORS.length]
|
||||
}));
|
||||
|
||||
const hasOther = data.some((row) => OTHER_KEY in row);
|
||||
if (hasOther) {
|
||||
series.push({ key: OTHER_KEY, label: otherLabel, color: OTHER_COLOR });
|
||||
}
|
||||
|
||||
return series;
|
||||
}
|
||||
|
||||
export const currencyFormatter = new Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 2
|
||||
});
|
||||
|
||||
export const compactNumberFormatter = new Intl.NumberFormat(undefined, {
|
||||
maximumFractionDigits: 1,
|
||||
notation: "compact",
|
||||
compactDisplay: "short"
|
||||
});
|
||||
|
||||
export const exactNumberFormatter = new Intl.NumberFormat(undefined, {
|
||||
maximumFractionDigits: 0
|
||||
});
|
||||
|
||||
export function formatCost(value: number | null | undefined) {
|
||||
return currencyFormatter.format(value ?? 0);
|
||||
}
|
||||
@@ -45,7 +45,14 @@ import {
|
||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Bell, ChevronsUpDown, Globe, Plus, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Bell,
|
||||
ChevronRightIcon,
|
||||
ChevronsUpDown,
|
||||
Globe,
|
||||
Plus,
|
||||
Trash2
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { Control, UseFormReturn } from "react-hook-form";
|
||||
@@ -53,6 +60,7 @@ import { useFormContext, useWatch } from "react-hook-form";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import { RolesSelector } from "../roles-selector";
|
||||
import { UsersSelector } from "../users-selector";
|
||||
import { cn } from "@app/lib/cn";
|
||||
|
||||
export function AddActionPanel({
|
||||
onAdd
|
||||
@@ -95,6 +103,7 @@ export function AddActionPanel({
|
||||
const EXTERNAL_IDS = EXTERNAL_INTEGRATIONS.map((i) => i.id);
|
||||
|
||||
const [selected, setSelected] = useState<string | null>("notify");
|
||||
const [isPopoverOpen, setPopoverOpen] = useState(false);
|
||||
|
||||
const isPremiumSelected =
|
||||
selected !== null && EXTERNAL_IDS.includes(selected as any);
|
||||
@@ -131,27 +140,46 @@ export function AddActionPanel({
|
||||
if (!isBuiltInSelected) return;
|
||||
onAdd(selected as AlertRuleFormAction["type"]);
|
||||
setSelected(null);
|
||||
setPopoverOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<StrategySelect
|
||||
options={actionTypeOptions}
|
||||
value={selected}
|
||||
cols={2}
|
||||
onChange={(v) => setSelected(v)}
|
||||
/>
|
||||
{isPremiumSelected && <ContactSalesBanner />}
|
||||
{!isPremiumSelected && (
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!isBuiltInSelected}
|
||||
onClick={handleAdd}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t("alertingAddAction")}
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex flex-col gap-3 items-start">
|
||||
<h3 className="font-medium">{t("alertingAddActionHeading")}</h3>
|
||||
<Popover open={isPopoverOpen} onOpenChange={setPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button type="button" variant="outline">
|
||||
{t("alertingSelectActionType")}
|
||||
<ChevronRightIcon
|
||||
className={cn(
|
||||
"size-4 transition-transform duration-150",
|
||||
isPopoverOpen && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="shadow-md flex flex-col gap-3 w-150">
|
||||
<StrategySelect
|
||||
options={actionTypeOptions}
|
||||
value={selected}
|
||||
cols={2}
|
||||
onChange={(v) => setSelected(v)}
|
||||
/>
|
||||
|
||||
{isPremiumSelected ? (
|
||||
<ContactSalesBanner />
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!isBuiltInSelected}
|
||||
onClick={handleAdd}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t("alertingAddAction")}
|
||||
</Button>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
AlertRuleSourceFields,
|
||||
AlertRuleTriggerFields
|
||||
} from "@app/components/alert-rule-editor/AlertRuleFields";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { SettingsContainer } from "@app/components/Settings";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Card, CardContent } from "@app/components/ui/card";
|
||||
import {
|
||||
@@ -19,6 +21,7 @@ import {
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import {
|
||||
buildFormSchema,
|
||||
@@ -27,19 +30,15 @@ import {
|
||||
type AlertRuleFormValues
|
||||
} from "@app/lib/alertRuleForm";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import type { CreateAlertRuleResponse } from "@server/routers/alertRule/types";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ChevronLeft, Cog, Flag, Zap } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { useFieldArray, useForm, type Resolver } from "react-hook-form";
|
||||
import { Cog, Flag, Zap, ZapIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useActionState, useMemo, useTransition, type ReactNode } from "react";
|
||||
import { useFieldArray, useForm, type Resolver } from "react-hook-form";
|
||||
import { Badge } from "../ui/badge";
|
||||
|
||||
const FORM_ID = "alert-rule-form";
|
||||
@@ -115,7 +114,6 @@ export default function AlertRuleGraphEditor({
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const schema = useMemo(() => buildFormSchema(t), [t]);
|
||||
const form = useForm<AlertRuleFormValues>({
|
||||
resolver: zodResolver(schema) as Resolver<AlertRuleFormValues>,
|
||||
@@ -127,8 +125,22 @@ export default function AlertRuleGraphEditor({
|
||||
name: "actions"
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit(async (values) => {
|
||||
setIsSaving(true);
|
||||
const saveAlert = async () => {
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) {
|
||||
const values = form.getValues();
|
||||
if (values.actions.length === 0) {
|
||||
toast({
|
||||
variant: "warning",
|
||||
title: t("alertingNoActionsTitle"),
|
||||
description: t("alertingNoActionsSaveDescription")
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const values = form.getValues();
|
||||
|
||||
try {
|
||||
const payload = formValuesToApiPayload(values);
|
||||
if (isNew) {
|
||||
@@ -158,14 +170,48 @@ export default function AlertRuleGraphEditor({
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const testAlert = async () => {
|
||||
const isValid = await form.trigger("actions");
|
||||
const values = form.getValues();
|
||||
|
||||
if (!isValid) {
|
||||
if (values.actions.length === 0) {
|
||||
toast({
|
||||
variant: "warning",
|
||||
title: t("alertingNoActionsTitle"),
|
||||
description: t("alertingNoActionsTestDescription")
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = formValuesToApiPayload(values);
|
||||
await api.post(`/org/${orgId}/test-alert-rule`, payload);
|
||||
|
||||
toast({
|
||||
title: t("alertingTestAlertSent"),
|
||||
description: t("alertingTestAlertSentDescription")
|
||||
});
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const [, formAction, isSaving] = useActionState(saveAlert, null);
|
||||
const [isTestingAlert, startTransition] = useTransition();
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form id={FORM_ID} onSubmit={onSubmit}>
|
||||
<form id={FORM_ID} action={formAction}>
|
||||
<SettingsContainer>
|
||||
<PaidFeaturesAlert tiers={tierMatrix.alertingRules} />
|
||||
<div className="flex flex-col lg:flex-row gap-6 lg:gap-8 items-start">
|
||||
@@ -263,14 +309,29 @@ export default function AlertRuleGraphEditor({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isSaving}
|
||||
loading={isSaving}
|
||||
>
|
||||
{t("save")}
|
||||
</Button>
|
||||
<div className="flex flex-col items-center w-full gap-3">
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isSaving}
|
||||
loading={isSaving}
|
||||
>
|
||||
{t("save")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full gap-1.5"
|
||||
onClick={() =>
|
||||
startTransition(testAlert)
|
||||
}
|
||||
loading={isTestingAlert}
|
||||
>
|
||||
<ZapIcon className="size-3.5 flex-none" />
|
||||
{t("alertingTestRule")}
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -475,7 +475,7 @@ function CommandPaletteProviderInner({
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (
|
||||
event.key.toLowerCase() !== "k" ||
|
||||
event.key?.toLowerCase() !== "k" ||
|
||||
!(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
return;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
KeyRound,
|
||||
MonitorUp,
|
||||
Plus,
|
||||
Sparkles,
|
||||
SunMoon,
|
||||
UserPlus
|
||||
} from "lucide-react";
|
||||
@@ -75,6 +76,12 @@ export function useCommandPaletteActions(
|
||||
});
|
||||
}
|
||||
} else if (orgId) {
|
||||
actions.push({
|
||||
id: "my-api-keys",
|
||||
label: t("sidebarMyApiKeys"),
|
||||
icon: <KeyRound className="size-4" />,
|
||||
href: `/${orgId}/keys`
|
||||
});
|
||||
actions.push({
|
||||
id: "create-site",
|
||||
label: t("commandPaletteCreateSite"),
|
||||
@@ -111,6 +118,18 @@ export function useCommandPaletteActions(
|
||||
icon: <KeyRound className="size-4" />,
|
||||
href: `/${orgId}/settings/api-keys/create`
|
||||
});
|
||||
actions.push({
|
||||
id: "create-ai-provider",
|
||||
label: t("commandPaletteCreateAiProvider"),
|
||||
icon: <Sparkles className="size-4" />,
|
||||
href: `/${orgId}/settings/ai-providers/create`
|
||||
});
|
||||
actions.push({
|
||||
id: "create-virtual-api-key",
|
||||
label: t("commandPaletteCreateVirtualApiKey"),
|
||||
icon: <KeyRound className="size-4" />,
|
||||
href: `/${orgId}/settings/virtual-api-keys/keys`
|
||||
});
|
||||
|
||||
if (!env?.flags.disableEnterpriseFeatures) {
|
||||
actions.push({
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "./ui/command";
|
||||
import { Checkbox } from "./ui/checkbox";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useDebounce } from "use-debounce";
|
||||
import { type SelectedResource } from "./resource-selector";
|
||||
|
||||
export type MultiResourcesSelectorProps = {
|
||||
orgId: string;
|
||||
selectedResources: SelectedResource[];
|
||||
onSelectionChange: (resources: SelectedResource[]) => void;
|
||||
excludeWildcard?: boolean;
|
||||
onClear?: () => void;
|
||||
showClear?: boolean;
|
||||
protocol?: string;
|
||||
};
|
||||
|
||||
export function formatMultiResourcesSelectorLabel(
|
||||
selectedResources: SelectedResource[],
|
||||
t: (key: string, values?: { count: number }) => string,
|
||||
emptyLabelKey = "selectResources"
|
||||
): string {
|
||||
if (selectedResources.length === 0) {
|
||||
return t(emptyLabelKey);
|
||||
}
|
||||
if (selectedResources.length === 1) {
|
||||
return selectedResources[0]!.name;
|
||||
}
|
||||
return t("multiResourcesSelectorResourcesCount", {
|
||||
count: selectedResources.length
|
||||
});
|
||||
}
|
||||
|
||||
export function MultiResourcesSelector({
|
||||
orgId,
|
||||
selectedResources,
|
||||
onSelectionChange,
|
||||
excludeWildcard = false,
|
||||
onClear,
|
||||
showClear = false,
|
||||
protocol
|
||||
}: MultiResourcesSelectorProps) {
|
||||
const t = useTranslations();
|
||||
const [resourceSearchQuery, setResourceSearchQuery] = useState("");
|
||||
const [debouncedQuery] = useDebounce(resourceSearchQuery, 150);
|
||||
|
||||
const { data: resources = [] } = useQuery(
|
||||
orgQueries.proxyResources({
|
||||
orgId,
|
||||
query: debouncedQuery,
|
||||
perPage: 10,
|
||||
protocol
|
||||
})
|
||||
);
|
||||
|
||||
const resourcesShown = useMemo(() => {
|
||||
const base: SelectedResource[] = excludeWildcard
|
||||
? resources.filter((r) => !r.wildcard)
|
||||
: [...resources];
|
||||
if (
|
||||
debouncedQuery.trim().length === 0 &&
|
||||
selectedResources.length > 0
|
||||
) {
|
||||
const selectedNotInBase = selectedResources.filter(
|
||||
(sel) =>
|
||||
!base.some((r) => r.resourceId === sel.resourceId) &&
|
||||
!(excludeWildcard && sel.wildcard)
|
||||
);
|
||||
return [...selectedNotInBase, ...base];
|
||||
}
|
||||
return base;
|
||||
}, [debouncedQuery, resources, selectedResources, excludeWildcard]);
|
||||
|
||||
const selectedIds = useMemo(
|
||||
() => new Set(selectedResources.map((r) => r.resourceId)),
|
||||
[selectedResources]
|
||||
);
|
||||
|
||||
const toggleResource = (resource: SelectedResource) => {
|
||||
if (selectedIds.has(resource.resourceId)) {
|
||||
onSelectionChange(
|
||||
selectedResources.filter(
|
||||
(r) => r.resourceId !== resource.resourceId
|
||||
)
|
||||
);
|
||||
} else {
|
||||
onSelectionChange([...selectedResources, resource]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={t("resourceSearch")}
|
||||
value={resourceSearchQuery}
|
||||
onValueChange={(v) => setResourceSearchQuery(v)}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("resourcesNotFound")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{showClear && onClear && (
|
||||
<CommandItem
|
||||
onSelect={onClear}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
{t("accessFilterClear")}
|
||||
</CommandItem>
|
||||
)}
|
||||
{resourcesShown.map((resource) => (
|
||||
<CommandItem
|
||||
key={resource.resourceId}
|
||||
value={`${resource.resourceId}:${resource.name}`}
|
||||
onSelect={() => {
|
||||
toggleResource(resource);
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
className="pointer-events-none shrink-0"
|
||||
checked={selectedIds.has(resource.resourceId)}
|
||||
onCheckedChange={() => {}}
|
||||
aria-hidden
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{resource.name}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
LauncherViewConfig
|
||||
} from "@server/routers/launcher/types";
|
||||
import {
|
||||
LAUNCHER_AI_GATEWAY_GROUP_KEY,
|
||||
LAUNCHER_NO_SITE_GROUP_KEY,
|
||||
LAUNCHER_UNLABELED_GROUP_KEY
|
||||
} from "@server/routers/launcher/types";
|
||||
@@ -148,9 +149,11 @@ export function LauncherGroupSection({
|
||||
const groupTitle =
|
||||
group.groupKey === LAUNCHER_UNLABELED_GROUP_KEY
|
||||
? t("resourceLauncherUnlabeled")
|
||||
: group.groupKey === LAUNCHER_NO_SITE_GROUP_KEY
|
||||
? t("resourceLauncherNoSite")
|
||||
: group.name;
|
||||
: group.groupKey === LAUNCHER_AI_GATEWAY_GROUP_KEY
|
||||
? t("resourceLauncherAiGateway")
|
||||
: group.groupKey === LAUNCHER_NO_SITE_GROUP_KEY
|
||||
? t("resourceLauncherNoSite")
|
||||
: group.name;
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
import { CollapsibleTrigger } from "@app/components/ui/collapsible";
|
||||
import type { LauncherGroup } from "@server/routers/launcher/types";
|
||||
import {
|
||||
LAUNCHER_AI_GATEWAY_GROUP_KEY,
|
||||
LAUNCHER_NO_SITE_GROUP_KEY
|
||||
} from "@server/routers/launcher/types";
|
||||
import { ChevronDown, ChevronLeft } from "lucide-react";
|
||||
|
||||
type LauncherGroupTriggerProps = {
|
||||
@@ -21,6 +25,13 @@ function LauncherGroupStatusDot({ group }: { group: LauncherGroup }) {
|
||||
}
|
||||
|
||||
if (group.groupType === "site") {
|
||||
if (
|
||||
group.groupKey === LAUNCHER_AI_GATEWAY_GROUP_KEY ||
|
||||
group.groupKey === LAUNCHER_NO_SITE_GROUP_KEY
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
(group.siteType === "newt" || group.siteType === "wireguard") &&
|
||||
typeof group.siteOnline === "boolean"
|
||||
@@ -47,11 +58,11 @@ export function LauncherGroupTrigger({
|
||||
title,
|
||||
isOpen
|
||||
}: LauncherGroupTriggerProps) {
|
||||
const statusDot = <LauncherGroupStatusDot group={group} />;
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger className="flex w-full items-center gap-2.5 rounded-md bg-accent px-4 py-2.5 text-left transition-colors cursor-pointer">
|
||||
{group.groupType === "site" || group.groupType === "label" ? (
|
||||
<LauncherGroupStatusDot group={group} />
|
||||
) : null}
|
||||
{statusDot}
|
||||
<span className="flex min-w-0 items-center gap-2.5 text-sm font-semibold text-foreground">
|
||||
<span className="truncate">
|
||||
{title} ({group.itemCount})
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle,
|
||||
SettingsSubsectionDescription,
|
||||
SettingsSubsectionHeader,
|
||||
SettingsSubsectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useMyVirtualApiKeySecret } from "@app/hooks/useMyVirtualApiKeySecret";
|
||||
import { launcherQueries } from "@app/lib/queries";
|
||||
import type { VirtualApiKeyWithResources } from "@server/routers/virtualApiKey/types";
|
||||
import { formatVirtualApiKeyPreview } from "@app/lib/virtualApiKeyFormat";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type LauncherInferenceApiKeysSectionProps = {
|
||||
orgId: string;
|
||||
resourceGuid: string;
|
||||
};
|
||||
|
||||
function PanelKeySecret({
|
||||
orgId,
|
||||
virtualApiKeyId,
|
||||
lastChars
|
||||
}: {
|
||||
orgId: string;
|
||||
virtualApiKeyId: string;
|
||||
lastChars: string;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const preview = formatVirtualApiKeyPreview(virtualApiKeyId, lastChars);
|
||||
const { credential, revealed, loading, getCopyText, revealSecret } =
|
||||
useMyVirtualApiKeySecret(orgId, virtualApiKeyId);
|
||||
const displayValue = revealed && credential ? credential : preview;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CopyToClipboard
|
||||
text={displayValue}
|
||||
displayText={displayValue}
|
||||
getCopyText={getCopyText}
|
||||
/>
|
||||
</div>
|
||||
{!revealed ? (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="shrink-0 px-0 h-auto"
|
||||
loading={loading}
|
||||
onClick={revealSecret}
|
||||
>
|
||||
{t("myVirtualApiKeysRevealSecret")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ManualKeyRow({
|
||||
orgId,
|
||||
keyRow
|
||||
}: {
|
||||
orgId: string;
|
||||
keyRow: VirtualApiKeyWithResources;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<div className="space-y-1 min-w-0">
|
||||
<p className="font-medium truncate">
|
||||
{keyRow.name || t("myVirtualApiKeysUnnamed")}
|
||||
</p>
|
||||
{keyRow.description ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{keyRow.description}
|
||||
</p>
|
||||
) : null}
|
||||
<PanelKeySecret
|
||||
orgId={orgId}
|
||||
virtualApiKeyId={keyRow.virtualApiKeyId}
|
||||
lastChars={keyRow.lastChars}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LauncherInferenceApiKeysSection({
|
||||
orgId,
|
||||
resourceGuid
|
||||
}: LauncherInferenceApiKeysSectionProps) {
|
||||
const t = useTranslations();
|
||||
const { data, isPending, isError } = useQuery(
|
||||
launcherQueries.myVirtualApiKeys(orgId, resourceGuid)
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("resourceLauncherApiKeys")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("resourceLauncherApiKeysDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
{isPending ? (
|
||||
<div className="flex items-center justify-center py-6 text-muted-foreground">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
</div>
|
||||
) : null}
|
||||
{isError ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("resourceLauncherApiKeysError")}
|
||||
</p>
|
||||
) : null}
|
||||
{!isPending && !isError && data ? (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1 min-w-0">
|
||||
<p className="font-medium">
|
||||
{t("resourceLauncherApiKeysIdentity")}
|
||||
</p>
|
||||
<PanelKeySecret
|
||||
orgId={orgId}
|
||||
virtualApiKeyId={data.userKey.virtualApiKeyId}
|
||||
lastChars={data.userKey.lastChars}
|
||||
/>
|
||||
</div>
|
||||
{data.manualKeys.length > 0 ? (
|
||||
<div>
|
||||
<SettingsSubsectionHeader>
|
||||
<SettingsSubsectionTitle>
|
||||
{t("resourceLauncherApiKeysManual")}
|
||||
</SettingsSubsectionTitle>
|
||||
<SettingsSubsectionDescription>
|
||||
{t(
|
||||
"myVirtualApiKeysManualResourceDescription",
|
||||
{
|
||||
resourceName:
|
||||
data.resourceName ??
|
||||
t("resource")
|
||||
}
|
||||
)}
|
||||
</SettingsSubsectionDescription>
|
||||
</SettingsSubsectionHeader>
|
||||
<div className="space-y-3">
|
||||
{data.manualKeys.map((keyRow) => (
|
||||
<ManualKeyRow
|
||||
key={keyRow.virtualApiKeyId}
|
||||
orgId={orgId}
|
||||
keyRow={keyRow}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { launcherQueries } from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
|
||||
const COLLAPSED_ROWS = 5;
|
||||
const GRID_COLUMNS = 2;
|
||||
|
||||
type LauncherInferenceModelsSectionProps = {
|
||||
orgId: string;
|
||||
params:
|
||||
| {
|
||||
resourceType: "public";
|
||||
resourceId: number;
|
||||
}
|
||||
| {
|
||||
resourceType: "site";
|
||||
siteResourceId: number;
|
||||
};
|
||||
};
|
||||
|
||||
export function LauncherInferenceModelsSection({
|
||||
orgId,
|
||||
params
|
||||
}: LauncherInferenceModelsSectionProps) {
|
||||
const t = useTranslations();
|
||||
const { data, isPending, isError } = useQuery(
|
||||
launcherQueries.aiModels(orgId, params)
|
||||
);
|
||||
const models = data?.models ?? [];
|
||||
const [listExpanded, setListExpanded] = useState(false);
|
||||
const [clipHeight, setClipHeight] = useState<number | null>(null);
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const collapsedLimit = GRID_COLUMNS * COLLAPSED_ROWS;
|
||||
const hasOverflow = models.length > collapsedLimit;
|
||||
const isCollapsed = hasOverflow && !listExpanded;
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasOverflow) {
|
||||
setListExpanded(false);
|
||||
}
|
||||
}, [hasOverflow]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isCollapsed || !gridRef.current) {
|
||||
setClipHeight(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const children = Array.from(gridRef.current.children) as HTMLElement[];
|
||||
const lastVisible = children[collapsedLimit - 1];
|
||||
if (!lastVisible) {
|
||||
setClipHeight(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const gridTop = gridRef.current.getBoundingClientRect().top;
|
||||
const cardBottom = lastVisible.getBoundingClientRect().bottom;
|
||||
// Peek slightly into the next row so the fade has content to soften.
|
||||
setClipHeight(cardBottom - gridTop + 12);
|
||||
}, [isCollapsed, collapsedLimit, models]);
|
||||
|
||||
return (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("resourceLauncherAvailableModels")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("resourceLauncherAvailableModelsDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
{isPending ? (
|
||||
<div className="flex items-center justify-center py-6 text-muted-foreground">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
</div>
|
||||
) : null}
|
||||
{isError ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("resourceLauncherAvailableModelsError")}
|
||||
</p>
|
||||
) : null}
|
||||
{!isPending && !isError && models.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("resourceLauncherAvailableModelsEmpty")}
|
||||
</p>
|
||||
) : null}
|
||||
{!isPending && !isError && models.length > 0 ? (
|
||||
<div>
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={gridRef}
|
||||
className={cn(
|
||||
"grid grid-cols-2 gap-2",
|
||||
isCollapsed && "overflow-hidden"
|
||||
)}
|
||||
style={
|
||||
isCollapsed && clipHeight != null
|
||||
? { maxHeight: clipHeight }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{models.map((model) => (
|
||||
<div
|
||||
key={model.modelId}
|
||||
className="flex min-w-0 flex-col gap-0.5 rounded-md border border-input px-2.5 py-2"
|
||||
>
|
||||
<span className="block truncate font-mono text-xs font-medium">
|
||||
{model.modelKey}
|
||||
</span>
|
||||
{model.providerName ? (
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{model.providerName}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{isCollapsed ? (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-14 bg-gradient-to-t from-card from-25% via-card/80 to-transparent" />
|
||||
) : null}
|
||||
</div>
|
||||
{isCollapsed ? (
|
||||
<div className="relative z-10 flex justify-center pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="text"
|
||||
size="sm"
|
||||
className="bg-card px-2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setListExpanded(true)}
|
||||
>
|
||||
{t("aiProviderModelsViewMore", {
|
||||
count: models.length - collapsedLimit
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{hasOverflow && listExpanded ? (
|
||||
<div className="flex justify-center pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="text"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setListExpanded(false)}
|
||||
>
|
||||
{t("aiProviderModelsViewLess")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "@app/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { Check, ChevronDown, ChevronsUpDown } from "lucide-react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { OrgPicker } from "@app/components/OrgPicker";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type LauncherOrgSelectorProps = {
|
||||
orgId?: string;
|
||||
@@ -27,88 +12,21 @@ type LauncherOrgSelectorProps = {
|
||||
};
|
||||
|
||||
export function LauncherOrgSelector({ orgId, orgs }: LauncherOrgSelectorProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const t = useTranslations();
|
||||
|
||||
const selectedOrg = orgs?.find((org) => org.orgId === orgId);
|
||||
|
||||
const sortedOrgs = useMemo(() => {
|
||||
if (!orgs?.length) {
|
||||
return orgs ?? [];
|
||||
}
|
||||
return [...orgs].sort((a, b) => {
|
||||
const aPrimary = Boolean(a.isPrimaryOrg);
|
||||
const bPrimary = Boolean(b.isPrimaryOrg);
|
||||
if (aPrimary && !bPrimary) {
|
||||
return -1;
|
||||
}
|
||||
if (!aPrimary && bPrimary) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}, [orgs]);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
className="inline-flex items-center gap-1 p-0"
|
||||
variant="text"
|
||||
size="sm"
|
||||
>
|
||||
<span className="truncate max-w-[200px]">
|
||||
{selectedOrg?.name ?? t("noneSelected")}
|
||||
</span>
|
||||
<ChevronDown className="size-4 shrink-0" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[320px] p-0" align="start">
|
||||
<Command className="rounded-lg border-0">
|
||||
<CommandInput placeholder={t("searchPlaceholder")} />
|
||||
<CommandList className="max-h-[280px]">
|
||||
<CommandEmpty>{t("orgNotFound2")}</CommandEmpty>
|
||||
<CommandGroup heading={t("orgs")}>
|
||||
{sortedOrgs.map((org) => (
|
||||
<CommandItem
|
||||
key={org.orgId}
|
||||
onSelect={() => {
|
||||
setOpen(false);
|
||||
const newPath = pathname.includes(
|
||||
"/settings/"
|
||||
)
|
||||
? pathname.replace(
|
||||
/^\/[^/]+/,
|
||||
`/${org.orgId}`
|
||||
)
|
||||
: `/${org.orgId}`;
|
||||
router.push(newPath);
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<span className="font-medium truncate text-sm">
|
||||
{org.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground font-mono truncate">
|
||||
{org.orgId}
|
||||
</span>
|
||||
</div>
|
||||
<Check
|
||||
className={cn(
|
||||
"h-4 w-4 text-primary shrink-0",
|
||||
orgId === org.orgId
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<OrgPicker orgId={orgId} orgs={orgs} contentClassName="w-[320px]">
|
||||
<Button
|
||||
className="inline-flex items-center gap-1 p-0"
|
||||
variant="text"
|
||||
size="sm"
|
||||
>
|
||||
<span className="truncate max-w-[200px]">
|
||||
{selectedOrg?.name ?? t("noneSelected")}
|
||||
</span>
|
||||
<ChevronDown className="size-4 shrink-0" />
|
||||
</Button>
|
||||
</OrgPicker>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { AiClientConfigSection } from "@app/components/ai-client-config/AiClientConfigSection";
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import {
|
||||
InfoSection,
|
||||
@@ -7,7 +8,9 @@ import {
|
||||
InfoSections,
|
||||
InfoSectionTitle
|
||||
} from "@app/components/InfoSection";
|
||||
import { SiteResourceInfoSections } from "@app/components/SiteResourceInfoBox";
|
||||
import { PrivateResourceInfoSections } from "@app/components/PrivateResourceInfoBox";
|
||||
import { LauncherInferenceApiKeysSection } from "@app/components/resource-launcher/LauncherInferenceApiKeysSection";
|
||||
import { LauncherInferenceModelsSection } from "@app/components/resource-launcher/LauncherInferenceModelsSection";
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
@@ -25,6 +28,7 @@ import {
|
||||
} from "@app/components/SidePanel";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useMyVirtualApiKeySecret } from "@app/hooks/useMyVirtualApiKeySecret";
|
||||
import {
|
||||
derivePublicAuthState,
|
||||
formatPublicResourceType
|
||||
@@ -146,7 +150,8 @@ function HealthStatusDisplay({
|
||||
);
|
||||
}
|
||||
|
||||
const PUBLIC_AUTH_BROWSER_MODES = ["http", "ssh", "rdp", "vnc"];
|
||||
const PUBLIC_AUTH_METHODS_MODES = ["http", "ssh", "rdp", "vnc"];
|
||||
const PUBLIC_AUTH_BADGE_MODES = [...PUBLIC_AUTH_METHODS_MODES, "inference"];
|
||||
|
||||
function AuthMethodStatusDisplay({ enabled }: { enabled: boolean }) {
|
||||
const t = useTranslations();
|
||||
@@ -227,20 +232,33 @@ function PublicResourceAuthMethods({
|
||||
}
|
||||
|
||||
function PublicResourceDetails({
|
||||
orgId,
|
||||
launcherResource,
|
||||
resource,
|
||||
authInfo
|
||||
}: {
|
||||
orgId: string;
|
||||
launcherResource: LauncherResource;
|
||||
resource: GetResourceResponse;
|
||||
authInfo: GetResourceAuthInfoResponse;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const supportsAuth = PUBLIC_AUTH_BROWSER_MODES.includes(
|
||||
resource.mode || ""
|
||||
);
|
||||
const mode = resource.mode || "";
|
||||
const isInference = mode === "inference";
|
||||
const showAuthBadge = PUBLIC_AUTH_BADGE_MODES.includes(mode);
|
||||
const showAuthMethods = PUBLIC_AUTH_METHODS_MODES.includes(mode);
|
||||
const showHealth = !isInference;
|
||||
const authState = derivePublicAuthState(resource.mode, authInfo);
|
||||
const infoSectionCount = supportsAuth ? 4 : 3;
|
||||
const infoSectionCount = 2 + (showAuthBadge ? 1 : 0) + (showHealth ? 1 : 0);
|
||||
|
||||
const { data: aiKeysData } = useQuery({
|
||||
...launcherQueries.myVirtualApiKeys(orgId, resource.resourceGuid),
|
||||
enabled: isInference
|
||||
});
|
||||
const { getCopyText: getAiKeyCopyText } = useMyVirtualApiKeySecret(
|
||||
orgId,
|
||||
aiKeysData?.userKey.virtualApiKeyId ?? ""
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -275,7 +293,7 @@ function PublicResourceDetails({
|
||||
/>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
{supportsAuth ? (
|
||||
{showAuthBadge ? (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("authentication")}
|
||||
@@ -295,30 +313,64 @@ function PublicResourceDetails({
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
) : null}
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("health")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<HealthStatusDisplay health={resource.health} />
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
{showHealth ? (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("health")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<HealthStatusDisplay
|
||||
health={resource.health}
|
||||
/>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
) : null}
|
||||
</InfoSections>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
{supportsAuth ? (
|
||||
{showAuthMethods ? (
|
||||
<PublicResourceAuthMethods authInfo={authInfo} />
|
||||
) : null}
|
||||
{isInference ? (
|
||||
<>
|
||||
<LauncherInferenceModelsSection
|
||||
orgId={orgId}
|
||||
params={{
|
||||
resourceType: "public",
|
||||
resourceId: resource.resourceId
|
||||
}}
|
||||
/>
|
||||
<LauncherInferenceApiKeysSection
|
||||
orgId={orgId}
|
||||
resourceGuid={resource.resourceGuid}
|
||||
/>
|
||||
{aiKeysData ? (
|
||||
<AiClientConfigSection
|
||||
endpoint={launcherResource.accessUrl ?? ""}
|
||||
auth={{
|
||||
mode: "keyed",
|
||||
getKeyText: getAiKeyCopyText
|
||||
}}
|
||||
resourceNiceId={launcherResource.niceId}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PrivateResourceDetails({
|
||||
orgId,
|
||||
launcherResource,
|
||||
resource
|
||||
}: {
|
||||
orgId: string;
|
||||
launcherResource: LauncherResource;
|
||||
resource: GetSiteResourceResponse;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const isInference = resource.mode === "inference";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -353,7 +405,7 @@ function PrivateResourceDetails({
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SiteResourceInfoSections
|
||||
<PrivateResourceInfoSections
|
||||
siteResource={resource}
|
||||
access={{
|
||||
accessDisplay: launcherResource.accessDisplay,
|
||||
@@ -365,6 +417,22 @@ function PrivateResourceDetails({
|
||||
/>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
{isInference ? (
|
||||
<>
|
||||
<LauncherInferenceModelsSection
|
||||
orgId={orgId}
|
||||
params={{
|
||||
resourceType: "site",
|
||||
siteResourceId: resource.siteResourceId
|
||||
}}
|
||||
/>
|
||||
<AiClientConfigSection
|
||||
endpoint={launcherResource.accessUrl ?? ""}
|
||||
auth={{ mode: "keyless" }}
|
||||
resourceNiceId={launcherResource.niceId}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -405,6 +473,7 @@ function LauncherResourcePanelBody({
|
||||
if (detail.resourceType === "public") {
|
||||
return (
|
||||
<PublicResourceDetails
|
||||
orgId={orgId}
|
||||
launcherResource={resource}
|
||||
resource={detail.data}
|
||||
authInfo={detail.authInfo}
|
||||
@@ -414,6 +483,7 @@ function LauncherResourcePanelBody({
|
||||
|
||||
return (
|
||||
<PrivateResourceDetails
|
||||
orgId={orgId}
|
||||
launcherResource={resource}
|
||||
resource={detail.data}
|
||||
/>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user