Merge branch 'dev' into feat/ip-filtering

This commit is contained in:
Fred KISSIE
2026-08-21 23:24:09 +02:00
622 changed files with 64427 additions and 8040 deletions
+4 -33
View File
@@ -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() {
+604
View File
@@ -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>
);
}
+139
View File
@@ -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>
);
}
+80
View File
@@ -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>
);
}
+141
View File
@@ -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>
);
}
+21
View File
@@ -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;
+61
View File
@@ -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}
/>
);
}
+318
View File
@@ -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"
/>
</>
);
}
+217
View File
@@ -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">&nbsp;</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>
);
}
+384
View File
@@ -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>
);
}
+1 -2
View File
@@ -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}
/>
+562
View File
@@ -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>
);
}
+3 -6
View File
@@ -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
});
+13 -11
View File
@@ -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>
);
}
}
+24 -10
View File
@@ -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 ? (
+27 -10
View File
@@ -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>
+28 -2
View File
@@ -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);
}
}
+99 -4
View File
@@ -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>
+569
View File
@@ -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>
);
}
+115
View File
@@ -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
View File
@@ -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>
+30 -1
View File
@@ -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"),
+604
View File
@@ -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>
);
}
+192
View File
@@ -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>
);
}
+26 -4
View File
@@ -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}
/>
);
}
+1
View File
@@ -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);
}
+117
View File
@@ -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}
/>
</>
);
}
+62 -40
View File
@@ -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>
+2
View File
@@ -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 = "";
+39 -8
View File
@@ -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
+11 -3
View File
@@ -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}
/>
)}
+43 -1
View File
@@ -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"
+55 -70
View File
@@ -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={() =>
+4 -117
View File
@@ -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 ? (
+40
View File
@@ -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;
+38 -19
View File
@@ -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>
);
+8
View File
@@ -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);
+5 -3
View File
@@ -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) {
+39 -43
View File
@@ -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>
+4 -1
View File
@@ -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>
+137
View File
@@ -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>
);
}
+15 -12
View File
@@ -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" />
+4 -51
View File
@@ -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
View File
@@ -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;
}
+24 -64
View File
@@ -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>
+88 -14
View File
@@ -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
+47 -1
View File
@@ -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>
);
}
@@ -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>
)}
/>
);
}
+324
View File
@@ -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>;
}
+145 -25
View File
@@ -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>
);
}
+61 -32
View File
@@ -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) ||
+11 -4
View File
@@ -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(() => {
+45 -33
View File
@@ -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
View File
@@ -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>
+40
View File
@@ -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",
+19 -3
View File
@@ -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)
+24 -14
View File
@@ -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}
+37 -15
View File
@@ -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);
+47 -4
View File
@@ -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}
+13
View File
@@ -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 || "/");
+3 -1
View File
@@ -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)}>
+29 -20
View File
@@ -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>
);
}
}
+67 -1
View File
@@ -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();
}
}
];
+236
View File
@@ -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>
</>
);
}
+140
View File
@@ -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"
/>
);
}
+556
View File
@@ -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({
+142
View File
@@ -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