Compare commits

...

8 Commits

Author SHA1 Message Date
pablodanswer
3acee8e400 various billing updates 2025-02-12 14:25:06 -08:00
pablodanswer
dcf7af227f misct billing_fixes
g
2025-02-11 18:49:28 -08:00
pablodanswer
1ad7565703 k 2025-02-11 15:59:16 -08:00
Weves
400b319bc1 Small tweaks 2025-02-11 15:57:10 -08:00
pablodanswer
968749bff7 fix "featured" logic 2025-02-11 15:57:10 -08:00
pablodanswer
1e0e2a8ec6 k 2025-02-11 15:57:09 -08:00
pablodanswer
d1ea706b20 quick nit 2025-02-11 15:57:09 -08:00
pablodanswer
88f6ab820f update assistant logic 2025-02-11 15:57:09 -08:00
31 changed files with 904 additions and 463 deletions

View File

@@ -0,0 +1,32 @@
"""set built in to default
Revision ID: 2cdeff6d8c93
Revises: f5437cc136c5
Create Date: 2025-02-11 14:57:51.308775
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = "2cdeff6d8c93"
down_revision = "f5437cc136c5"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Prior to this migration / point in the codebase history,
# built in personas were implicitly treated as default personas (with no option to change this)
# This migration makes that explicit
op.execute(
"""
UPDATE persona
SET is_default_persona = TRUE
WHERE builtin_persona = TRUE
"""
)
def downgrade() -> None:
pass

View File

@@ -9,7 +9,6 @@ from sqlalchemy.orm import Session
from ee.onyx.auth.users import current_cloud_superuser
from ee.onyx.auth.users import generate_anonymous_user_jwt_token
from ee.onyx.configs.app_configs import ANONYMOUS_USER_COOKIE_NAME
from ee.onyx.configs.app_configs import STRIPE_SECRET_KEY
from ee.onyx.server.tenants.access import control_plane_dep
from ee.onyx.server.tenants.anonymous_user_path import get_anonymous_user_path
from ee.onyx.server.tenants.anonymous_user_path import (
@@ -18,6 +17,7 @@ from ee.onyx.server.tenants.anonymous_user_path import (
from ee.onyx.server.tenants.anonymous_user_path import modify_anonymous_user_path
from ee.onyx.server.tenants.anonymous_user_path import validate_anonymous_user_path
from ee.onyx.server.tenants.billing import fetch_billing_information
from ee.onyx.server.tenants.billing import fetch_stripe_checkout_session
from ee.onyx.server.tenants.billing import fetch_tenant_stripe_information
from ee.onyx.server.tenants.models import AnonymousUserPath
from ee.onyx.server.tenants.models import BillingInformation
@@ -33,6 +33,7 @@ from onyx.auth.users import current_admin_user
from onyx.auth.users import get_redis_strategy
from onyx.auth.users import optional_user
from onyx.auth.users import User
from onyx.configs.app_configs import STRIPE_SECRET_KEY
from onyx.configs.app_configs import WEB_DOMAIN
from onyx.configs.constants import FASTAPI_USERS_AUTH_COOKIE_NAME
from onyx.db.auth import get_user_count
@@ -149,7 +150,7 @@ def gate_product(
CURRENT_TENANT_ID_CONTEXTVAR.reset(token)
@router.get("/billing-information", response_model=BillingInformation)
@router.get("/billing-information")
async def billing_information(
_: User = Depends(current_admin_user),
) -> BillingInformation:
@@ -169,9 +170,11 @@ async def create_customer_portal_session(_: User = Depends(current_admin_user))
if not stripe_customer_id:
raise HTTPException(status_code=400, detail="Stripe customer ID not found")
logger.info(stripe_customer_id)
print("CREATING CUSTOMER PORTAL SESSION for ", stripe_customer_id)
portal_session = stripe.billing_portal.Session.create(
customer=stripe_customer_id,
return_url=f"{WEB_DOMAIN}/admin/cloud-settings",
return_url=f"{WEB_DOMAIN}/admin/billing",
)
logger.info(portal_session)
return {"url": portal_session.url}
@@ -180,6 +183,18 @@ async def create_customer_portal_session(_: User = Depends(current_admin_user))
raise HTTPException(status_code=500, detail=str(e))
@router.post("/create-subscription-session")
async def create_resubscription_session(_: User = Depends(current_admin_user)) -> dict:
try:
tenant_id = CURRENT_TENANT_ID_CONTEXTVAR.get()
session_id = fetch_stripe_checkout_session(tenant_id)
return {"sessionId": session_id}
except Exception as e:
logger.exception("Failed to create resubscription session")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/impersonate")
async def impersonate_user(
impersonate_request: ImpersonateRequest,

View File

@@ -14,6 +14,20 @@ stripe.api_key = STRIPE_SECRET_KEY
logger = setup_logger()
def fetch_stripe_checkout_session(tenant_id: str) -> str:
token = generate_data_plane_token()
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
url = f"{CONTROL_PLANE_API_BASE_URL}/create-checkout-session"
params = {"tenant_id": tenant_id}
response = requests.post(url, headers=headers, params=params)
response.raise_for_status()
print(response.json())
return response.json()["sessionId"]
def fetch_tenant_stripe_information(tenant_id: str) -> dict:
token = generate_data_plane_token()
headers = {

View File

@@ -1,6 +1,7 @@
from datetime import datetime
from pydantic import BaseModel
from onyx.configs.constants import NotificationType
from onyx.server.settings.models import GatingType
@@ -16,14 +17,19 @@ class CreateTenantRequest(BaseModel):
class ProductGatingRequest(BaseModel):
tenant_id: str
product_gating: GatingType
notification: NotificationType | None = None
class BillingInformation(BaseModel):
stripe_subscription_id: str
status: str
current_period_start: datetime
current_period_end: datetime
number_of_seats: int
cancel_at_period_end: bool
canceled_at: datetime | None
trial_start: datetime | None
trial_end: datetime | None
seats: int
subscription_status: str
billing_start: str
billing_end: str
payment_method_enabled: bool

View File

@@ -204,6 +204,14 @@ def create_update_persona(
if not all_prompt_ids:
raise ValueError("No prompt IDs provided")
# Default persona validation
if create_persona_request.is_default_persona:
if not create_persona_request.is_public:
raise ValueError("Cannot make a default persona non public")
if user and user.role != UserRole.ADMIN:
raise ValueError("Only admins can make a default persona")
persona = upsert_persona(
persona_id=persona_id,
user=user,
@@ -510,6 +518,7 @@ def upsert_persona(
existing_persona.is_visible = is_visible
existing_persona.search_start_date = search_start_date
existing_persona.labels = labels or []
existing_persona.is_default_persona = is_default_persona
# Do not delete any associations manually added unless
# a new updated list is provided
if document_sets is not None:
@@ -590,6 +599,23 @@ def delete_old_default_personas(
db_session.commit()
def update_persona_is_default(
persona_id: int,
is_default: bool,
db_session: Session,
user: User | None = None,
) -> None:
persona = fetch_persona_by_id_for_user(
db_session=db_session, persona_id=persona_id, user=user, get_editable=True
)
if not persona.is_public:
persona.is_public = True
persona.is_default_persona = is_default
db_session.commit()
def update_persona_visibility(
persona_id: int,
is_visible: bool,

View File

@@ -32,6 +32,7 @@ from onyx.db.persona import get_personas_for_user
from onyx.db.persona import mark_persona_as_deleted
from onyx.db.persona import mark_persona_as_not_deleted
from onyx.db.persona import update_all_personas_display_priority
from onyx.db.persona import update_persona_is_default
from onyx.db.persona import update_persona_label
from onyx.db.persona import update_persona_public_status
from onyx.db.persona import update_persona_shared_users
@@ -56,7 +57,6 @@ from onyx.tools.utils import is_image_generation_available
from onyx.utils.logger import setup_logger
from onyx.utils.telemetry import create_milestone_and_report
logger = setup_logger()
@@ -72,6 +72,10 @@ class IsPublicRequest(BaseModel):
is_public: bool
class IsDefaultRequest(BaseModel):
is_default_persona: bool
@admin_router.patch("/{persona_id}/visible")
def patch_persona_visibility(
persona_id: int,
@@ -106,6 +110,25 @@ def patch_user_presona_public_status(
raise HTTPException(status_code=403, detail=str(e))
@admin_router.patch("/{persona_id}/default")
def patch_persona_default_status(
persona_id: int,
is_default_request: IsDefaultRequest,
user: User | None = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> None:
try:
update_persona_is_default(
persona_id=persona_id,
is_default=is_default_request.is_default_persona,
db_session=db_session,
user=user,
)
except ValueError as e:
logger.exception("Failed to update persona default status")
raise HTTPException(status_code=403, detail=str(e))
@admin_router.put("/display-priority")
def patch_persona_display_priority(
display_priority_request: DisplayPriorityRequest,

View File

@@ -23,12 +23,12 @@ _Note:_ if you are having problems accessing the ^, try setting the `WEB_DOMAIN`
`http://127.0.0.1:3000` and accessing it there.
## Testing
This testing process will reset your application into a clean state.
This testing process will reset your application into a clean state.
Don't run these tests if you don't want to do this!
Bring up the entire application.
1. Reset the instance
```cd backend
@@ -59,4 +59,4 @@ may use this for local troubleshooting and testing.
```
cd web
npx chromatic --playwright --project-token={your token here}
```
```

View File

@@ -3,7 +3,13 @@
import React from "react";
import { Option } from "@/components/Dropdown";
import { generateRandomIconShape } from "@/lib/assistantIconUtils";
import { CCPairBasicInfo, DocumentSet, User, UserGroup } from "@/lib/types";
import {
CCPairBasicInfo,
DocumentSet,
User,
UserGroup,
UserRole,
} from "@/lib/types";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import { ArrayHelpers, FieldArray, Form, Formik, FormikProps } from "formik";
@@ -33,9 +39,8 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { FiInfo } from "react-icons/fi";
import * as Yup from "yup";
import CollapsibleSection from "./CollapsibleSection";
import { SuccessfulPersonaUpdateRedirectType } from "./enums";
@@ -71,11 +76,11 @@ import {
Option as DropdownOption,
} from "@/components/Dropdown";
import { SourceChip } from "@/app/chat/input/ChatInputBar";
import { TagIcon, UserIcon, XIcon } from "lucide-react";
import { TagIcon, UserIcon, XIcon, InfoIcon } from "lucide-react";
import { LLMSelector } from "@/components/llm/LLMSelector";
import useSWR from "swr";
import { errorHandlingFetcher } from "@/lib/fetcher";
import { DeleteEntityModal } from "@/components/modals/DeleteEntityModal";
import { ConfirmEntityModal } from "@/components/modals/ConfirmEntityModal";
import Title from "@/components/ui/title";
import { SEARCH_TOOL_ID } from "@/app/chat/tools/constants";
@@ -127,6 +132,8 @@ export function AssistantEditor({
}) {
const { refreshAssistants, isImageGenerationAvailable } = useAssistants();
const router = useRouter();
const searchParams = useSearchParams();
const isAdminPage = searchParams.get("admin") === "true";
const { popup, setPopup } = usePopup();
const { labels, refreshLabels, createLabel, updateLabel, deleteLabel } =
@@ -216,6 +223,8 @@ export function AssistantEditor({
enabledToolsMap[tool.id] = personaCurrentToolIds.includes(tool.id);
});
const [showVisibilityWarning, setShowVisibilityWarning] = useState(false);
const initialValues = {
name: existingPersona?.name ?? "",
description: existingPersona?.description ?? "",
@@ -252,6 +261,7 @@ export function AssistantEditor({
(u) => u.id !== existingPersona.owner?.id
) ?? [],
selectedGroups: existingPersona?.groups ?? [],
is_default_persona: existingPersona?.is_default_persona ?? false,
};
interface AssistantPrompt {
@@ -308,24 +318,12 @@ export function AssistantEditor({
const [isRequestSuccessful, setIsRequestSuccessful] = useState(false);
const { data: userGroups } = useUserGroups();
// const { data: allUsers } = useUsers({ includeApiKeys: false }) as {
// data: MinimalUserSnapshot[] | undefined;
// };
const { data: users } = useSWR<MinimalUserSnapshot[]>(
"/api/users",
errorHandlingFetcher
);
const mapUsersToMinimalSnapshot = (users: any): MinimalUserSnapshot[] => {
if (!users || !Array.isArray(users.users)) return [];
return users.users.map((user: any) => ({
id: user.id,
name: user.name,
email: user.email,
}));
};
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
if (!labels) {
@@ -346,9 +344,7 @@ export function AssistantEditor({
if (response.ok) {
await refreshAssistants();
router.push(
redirectType === SuccessfulPersonaUpdateRedirectType.ADMIN
? `/admin/assistants?u=${Date.now()}`
: `/chat`
isAdminPage ? `/admin/assistants?u=${Date.now()}` : `/chat`
);
} else {
setPopup({
@@ -374,8 +370,9 @@ export function AssistantEditor({
<BackButton />
</div>
)}
{labelToDelete && (
<DeleteEntityModal
<ConfirmEntityModal
entityType="label"
entityName={labelToDelete.name}
onClose={() => setLabelToDelete(null)}
@@ -398,7 +395,7 @@ export function AssistantEditor({
/>
)}
{deleteModalOpen && existingPersona && (
<DeleteEntityModal
<ConfirmEntityModal
entityType="Persona"
entityName={existingPersona.name}
onClose={closeDeleteModal}
@@ -439,6 +436,7 @@ export function AssistantEditor({
label_ids: Yup.array().of(Yup.number()),
selectedUsers: Yup.array().of(Yup.object()),
selectedGroups: Yup.array().of(Yup.number()),
is_default_persona: Yup.boolean().required(),
})
.test(
"system-prompt-or-task-prompt",
@@ -459,6 +457,19 @@ export function AssistantEditor({
"Must provide either Instructions or Reminders (Advanced)",
});
}
)
.test(
"default-persona-public",
"Default persona must be public",
function (values) {
if (values.is_default_persona && !values.is_public) {
return this.createError({
path: "is_public",
message: "Default persona must be public",
});
}
return true;
}
)}
onSubmit={async (values, formikHelpers) => {
if (
@@ -499,7 +510,6 @@ export function AssistantEditor({
const submissionData: PersonaUpsertParameters = {
...values,
existing_prompt_id: existingPrompt?.id ?? null,
is_default_persona: admin!,
starter_messages: starterMessages,
groups: groups,
users: values.is_public
@@ -563,8 +573,9 @@ export function AssistantEditor({
}
await refreshAssistants();
router.push(
redirectType === SuccessfulPersonaUpdateRedirectType.ADMIN
isAdminPage
? `/admin/assistants?u=${Date.now()}`
: `/chat?assistantId=${assistantId}`
);
@@ -1005,6 +1016,22 @@ export function AssistantEditor({
{showAdvancedOptions && (
<>
<div className="max-w-4xl w-full">
{user?.role == UserRole.ADMIN && (
<BooleanFormField
onChange={(checked) => {
if (checked) {
setFieldValue("is_public", true);
setFieldValue("is_default_persona", true);
}
}}
name="is_default_persona"
label="Featured Assistant"
subtext="If set, this assistant will be pinned for all new users and appear in the Featured list in the assistant explorer. This also makes the assistant public."
/>
)}
<Separator />
<div className="flex gap-x-2 items-center ">
<div className="block font-medium text-sm">Access</div>
</div>
@@ -1014,22 +1041,60 @@ export function AssistantEditor({
<div className="min-h-[100px]">
<div className="flex items-center mb-2">
<SwitchField
name="is_public"
size="md"
onCheckedChange={(checked) => {
setFieldValue("is_public", checked);
if (checked) {
setFieldValue("selectedUsers", []);
setFieldValue("selectedGroups", []);
}
}}
/>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<div>
<SwitchField
name="is_public"
size="md"
onCheckedChange={(checked) => {
if (values.is_default_persona && !checked) {
setShowVisibilityWarning(true);
} else {
setFieldValue("is_public", checked);
if (!checked) {
// Even though this code path should not be possible,
// we set the default persona to false to be safe
setFieldValue(
"is_default_persona",
false
);
}
if (checked) {
setFieldValue("selectedUsers", []);
setFieldValue("selectedGroups", []);
}
}
}}
disabled={values.is_default_persona}
/>
</div>
</TooltipTrigger>
{values.is_default_persona && (
<TooltipContent side="top" align="center">
Default persona must be public. Set
&quot;Default Persona&quot; to false to change
visibility.
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
<span className="text-sm ml-2">
{values.is_public ? "Public" : "Private"}
</span>
</div>
{showVisibilityWarning && (
<div className="flex items-center text-warning mt-2">
<InfoIcon size={16} className="mr-2" />
<span className="text-sm">
Default persona must be public. Visibility has been
automatically set to public.
</span>
</div>
)}
{values.is_public ? (
<p className="text-sm text-text-dark">
Anyone from your organization can view and use this

View File

@@ -11,13 +11,14 @@ import { DraggableTable } from "@/components/table/DraggableTable";
import {
deletePersona,
personaComparator,
togglePersonaDefault,
togglePersonaVisibility,
} from "./lib";
import { FiEdit2 } from "react-icons/fi";
import { TrashIcon } from "@/components/icons/icons";
import { useUser } from "@/components/user/UserProvider";
import { useAssistants } from "@/components/context/AssistantsContext";
import { DeleteEntityModal } from "@/components/modals/DeleteEntityModal";
import { ConfirmEntityModal } from "@/components/modals/ConfirmEntityModal";
function PersonaTypeDisplay({ persona }: { persona: Persona }) {
if (persona.builtin_persona) {
@@ -56,6 +57,9 @@ export function PersonasTable() {
const [finalPersonas, setFinalPersonas] = useState<Persona[]>([]);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [personaToDelete, setPersonaToDelete] = useState<Persona | null>(null);
const [defaultModalOpen, setDefaultModalOpen] = useState(false);
const [personaToToggleDefault, setPersonaToToggleDefault] =
useState<Persona | null>(null);
useEffect(() => {
const editable = editablePersonas.sort(personaComparator);
@@ -126,11 +130,39 @@ export function PersonasTable() {
}
};
const openDefaultModal = (persona: Persona) => {
setPersonaToToggleDefault(persona);
setDefaultModalOpen(true);
};
const closeDefaultModal = () => {
setDefaultModalOpen(false);
setPersonaToToggleDefault(null);
};
const handleToggleDefault = async () => {
if (personaToToggleDefault) {
const response = await togglePersonaDefault(
personaToToggleDefault.id,
personaToToggleDefault.is_default_persona
);
if (response.ok) {
await refreshAssistants();
closeDefaultModal();
} else {
setPopup({
type: "error",
message: `Failed to update persona - ${await response.text()}`,
});
}
}
};
return (
<div>
{popup}
{deleteModalOpen && personaToDelete && (
<DeleteEntityModal
<ConfirmEntityModal
entityType="Persona"
entityName={personaToDelete.name}
onClose={closeDeleteModal}
@@ -138,8 +170,35 @@ export function PersonasTable() {
/>
)}
{defaultModalOpen && personaToToggleDefault && (
<ConfirmEntityModal
variant="action"
entityType="Assistant"
entityName={personaToToggleDefault.name}
onClose={closeDefaultModal}
onSubmit={handleToggleDefault}
actionButtonText={
personaToToggleDefault.is_default_persona
? "Remove Featured"
: "Set as Featured"
}
additionalDetails={
personaToToggleDefault.is_default_persona
? `Removing "${personaToToggleDefault.name}" as a featured assistant will not affect its visibility or accessibility.`
: `Setting "${personaToToggleDefault.name}" as a featured assistant will make it public and visible to all users. This action cannot be undone.`
}
/>
)}
<DraggableTable
headers={["Name", "Description", "Type", "Is Visible", "Delete"]}
headers={[
"Name",
"Description",
"Type",
"Featured Assistant",
"Is Visible",
"Delete",
]}
isAdmin={isAdmin}
rows={finalPersonas.map((persona) => {
const isEditable = editablePersonas.includes(persona);
@@ -152,7 +211,9 @@ export function PersonasTable() {
className="mr-1 my-auto cursor-pointer"
onClick={() =>
router.push(
`/admin/assistants/${persona.id}?u=${Date.now()}`
`/assistants/edit/${
persona.id
}?u=${Date.now()}&admin=true`
)
}
/>
@@ -168,6 +229,30 @@ export function PersonasTable() {
{persona.description}
</p>,
<PersonaTypeDisplay key={persona.id} persona={persona} />,
<div
key="is_default_persona"
onClick={() => {
if (isEditable) {
openDefaultModal(persona);
}
}}
className={`px-1 py-0.5 rounded flex ${
isEditable
? "hover:bg-accent-background-hovered cursor-pointer"
: ""
} select-none w-fit`}
>
<div className="my-auto flex-none w-22">
{!persona.is_default_persona ? (
<div className="text-error">Not Featured</div>
) : (
"Featured"
)}
</div>
<div className="ml-1 my-auto">
<CustomCheckbox checked={persona.is_default_persona} />
</div>
</div>,
<div
key="is_visible"
onClick={async () => {

View File

@@ -1,36 +0,0 @@
"use client";
import { Button } from "@/components/ui/button";
import { deletePersona } from "../lib";
import { useRouter } from "next/navigation";
import { SuccessfulPersonaUpdateRedirectType } from "../enums";
export function DeletePersonaButton({
personaId,
redirectType,
}: {
personaId: number;
redirectType: SuccessfulPersonaUpdateRedirectType;
}) {
const router = useRouter();
return (
<Button
variant="destructive"
onClick={async () => {
const response = await deletePersona(personaId);
if (response.ok) {
router.push(
redirectType === SuccessfulPersonaUpdateRedirectType.ADMIN
? `/admin/assistants?u=${Date.now()}`
: `/chat`
);
} else {
alert(`Failed to delete persona - ${await response.text()}`);
}
}}
>
Delete
</Button>
);
}

View File

@@ -1,43 +0,0 @@
import { ErrorCallout } from "@/components/ErrorCallout";
import { AssistantEditor } from "../AssistantEditor";
import { BackButton } from "@/components/BackButton";
import { DeletePersonaButton } from "./DeletePersonaButton";
import { fetchAssistantEditorInfoSS } from "@/lib/assistants/fetchPersonaEditorInfoSS";
import { SuccessfulPersonaUpdateRedirectType } from "../enums";
import { RobotIcon } from "@/components/icons/icons";
import { AdminPageTitle } from "@/components/admin/Title";
import CardSection from "@/components/admin/CardSection";
import Title from "@/components/ui/title";
export default async function Page(props: { params: Promise<{ id: string }> }) {
const params = await props.params;
const [values, error] = await fetchAssistantEditorInfoSS(params.id);
let body;
if (!values) {
body = (
<ErrorCallout errorTitle="Something went wrong :(" errorMsg={error} />
);
} else {
body = (
<>
<CardSection className="!border-none !bg-transparent !ring-none">
<AssistantEditor
{...values}
admin
defaultPublic={true}
redirectType={SuccessfulPersonaUpdateRedirectType.ADMIN}
/>
</CardSection>
</>
);
}
return (
<div className="w-full">
<AdminPageTitle title="Edit Assistant" icon={<RobotIcon size={32} />} />
{body}
</div>
);
}

View File

@@ -261,6 +261,22 @@ export function personaComparator(a: Persona, b: Persona) {
return closerToZeroNegativesFirstComparator(a.id, b.id);
}
export const togglePersonaDefault = async (
personaId: number,
isDefault: boolean
) => {
const response = await fetch(`/api/admin/persona/${personaId}/default`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
is_default_persona: !isDefault,
}),
});
return response;
};
export const togglePersonaVisibility = async (
personaId: number,
isVisible: boolean

View File

@@ -1,25 +0,0 @@
import { AssistantEditor } from "../AssistantEditor";
import { ErrorCallout } from "@/components/ErrorCallout";
import { fetchAssistantEditorInfoSS } from "@/lib/assistants/fetchPersonaEditorInfoSS";
import { SuccessfulPersonaUpdateRedirectType } from "../enums";
export default async function Page() {
const [values, error] = await fetchAssistantEditorInfoSS();
if (!values) {
return (
<ErrorCallout errorTitle="Something went wrong :(" errorMsg={error} />
);
} else {
return (
<div className="w-full">
<AssistantEditor
{...values}
admin
defaultPublic={true}
redirectType={SuccessfulPersonaUpdateRedirectType.ADMIN}
/>
</div>
);
}
}

View File

@@ -29,7 +29,7 @@ export default async function Page() {
<Separator />
<Title>Create an Assistant</Title>
<CreateButton href="/admin/assistants/new" text="New Assistant" />
<CreateButton href="/assistants/new?admin=true" text="New Assistant" />
<Separator />

View File

@@ -100,7 +100,7 @@ export function SlackChannelConfigsTable({
slackChannelConfig.persona
) ? (
<Link
href={`/admin/assistants/${slackChannelConfig.persona.id}`}
href={`/assistants/${slackChannelConfig.persona.id}`}
className="text-primary hover:underline"
>
{slackChannelConfig.persona.name}

View File

@@ -19,7 +19,7 @@ import { Dispatch, SetStateAction, useEffect, useState } from "react";
import { CustomEmbeddingModelForm } from "@/components/embedding/CustomEmbeddingModelForm";
import { deleteSearchSettings } from "./utils";
import { usePopup } from "@/components/admin/connectors/Popup";
import { DeleteEntityModal } from "@/components/modals/DeleteEntityModal";
import { ConfirmEntityModal } from "@/components/modals/ConfirmEntityModal";
import { AdvancedSearchConfiguration } from "../interfaces";
import CardSection from "@/components/admin/CardSection";
@@ -456,7 +456,7 @@ export function CloudModelCard({
>
{popup}
{showDeleteModel && (
<DeleteEntityModal
<ConfirmEntityModal
entityName={model.model_name}
entityType="embedding model configuration"
onSubmit={() => deleteModel()}

View File

@@ -1,4 +1,4 @@
import React, { useContext, useState, useRef, useLayoutEffect } from "react";
import React, { useState, useRef, useLayoutEffect } from "react";
import { useRouter } from "next/navigation";
import {
FiMoreHorizontal,
@@ -8,7 +8,7 @@ import {
FiLock,
FiUnlock,
} from "react-icons/fi";
import { FaHashtag } from "react-icons/fa";
import {
Popover,
PopoverTrigger,
@@ -26,14 +26,12 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { PinnedIcon } from "@/components/icons/icons";
import {
deletePersona,
togglePersonaPublicStatus,
} from "@/app/admin/assistants/lib";
import { deletePersona } from "@/app/admin/assistants/lib";
import { PencilIcon } from "lucide-react";
import { SettingsContext } from "@/components/settings/SettingsProvider";
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
import { truncateString } from "@/lib/utils";
import { usePopup } from "@/components/admin/connectors/Popup";
import { Button } from "@/components/ui/button";
export const AssistantBadge = ({
text,
@@ -63,6 +61,7 @@ const AssistantCard: React.FC<{
const { user, toggleAssistantPinnedStatus } = useUser();
const router = useRouter();
const { refreshAssistants, pinnedAssistants } = useAssistants();
const { popup, setPopup } = usePopup();
const isOwnedByUser = checkUserOwnsAssistant(user, persona);
@@ -72,7 +71,34 @@ const AssistantCard: React.FC<{
const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
const handleDelete = () => setActivePopover("delete");
const [isDeleteConfirmation, setIsDeleteConfirmation] = useState(false);
const handleDelete = () => {
setIsDeleteConfirmation(true);
};
const confirmDelete = async () => {
const response = await deletePersona(persona.id);
if (response.ok) {
await refreshAssistants();
setActivePopover(null);
setIsDeleteConfirmation(false);
setPopup({
message: `${persona.name} has been successfully deleted.`,
type: "success",
});
} else {
setPopup({
message: `Failed to delete assistant - ${await response.text()}`,
type: "error",
});
}
};
const cancelDelete = () => {
setIsDeleteConfirmation(false);
};
const handleEdit = () => {
router.push(`/assistants/edit/${persona.id}`);
setActivePopover(null);
@@ -100,6 +126,7 @@ const AssistantCard: React.FC<{
return (
<div className="w-full text-text-800 p-2 overflow-visible pb-4 pt-3 bg-transparent dark:bg-neutral-800/80 rounded shadow-[0px_0px_4px_0px_rgba(0,0,0,0.25)] flex flex-col">
{popup}
<div className="w-full flex">
<div className="ml-2 flex-none mr-2 mt-1 w-10 h-10">
<AssistantIcon assistant={persona} size="large" />
@@ -148,7 +175,7 @@ const AssistantCard: React.FC<{
</div>
{isOwnedByUser && (
<div className="flex ml-2 relative items-center gap-x-2">
<Popover modal>
<Popover>
<PopoverTrigger>
<button
type="button"
@@ -157,55 +184,84 @@ const AssistantCard: React.FC<{
<FiMoreHorizontal size={16} />
</button>
</PopoverTrigger>
<PopoverContent className={`w-32 z-[10000] p-2`}>
<div className="flex flex-col text-sm space-y-1">
<button
onClick={isOwnedByUser ? handleEdit : undefined}
className={`w-full flex items-center text-left px-2 py-1 rounded ${
isOwnedByUser
? "hover:bg-neutral-200 dark:hover:bg-neutral-700"
: "opacity-50 cursor-not-allowed"
}`}
disabled={!isOwnedByUser}
>
<FiEdit size={12} className="inline mr-2" />
Edit
</button>
{isPaidEnterpriseFeaturesEnabled && isOwnedByUser && (
<PopoverContent
className={`${
isDeleteConfirmation ? "w-64" : "w-32"
} z-[10000] p-2`}
>
{!isDeleteConfirmation ? (
<div className="flex flex-col text-sm space-y-1">
<button
onClick={
onClick={isOwnedByUser ? handleEdit : undefined}
className={`w-full flex items-center text-left px-2 py-1 rounded ${
isOwnedByUser
? () => {
router.push(
`/assistants/stats/${persona.id}`
);
closePopover();
}
: undefined
}
className={`w-full text-left items-center px-2 py-1 rounded ${
isOwnedByUser
? "hover:bg-neutral-200 dark:hover:bg-neutral-800"
? "hover:bg-neutral-200 dark:hover:bg-neutral-700"
: "opacity-50 cursor-not-allowed"
}`}
disabled={!isOwnedByUser}
>
<FiBarChart size={12} className="inline mr-2" />
Stats
<FiEdit size={12} className="inline mr-2" />
Edit
</button>
)}
<button
onClick={isOwnedByUser ? handleDelete : undefined}
className={`w-full text-left items-center px-2 py-1 rounded ${
isOwnedByUser
? "hover:bg-neutral-200 dark:hover:bg-neutral- text-red-600 dark:text-red-400"
: "opacity-50 cursor-not-allowed text-red-300 dark:text-red-500"
}`}
disabled={!isOwnedByUser}
>
<FiTrash size={12} className="inline mr-2" />
Delete
</button>
</div>
{isPaidEnterpriseFeaturesEnabled && isOwnedByUser && (
<button
onClick={
isOwnedByUser
? () => {
router.push(
`/assistants/stats/${persona.id}`
);
closePopover();
}
: undefined
}
className={`w-full text-left items-center px-2 py-1 rounded ${
isOwnedByUser
? "hover:bg-neutral-200 dark:hover:bg-neutral-800"
: "opacity-50 cursor-not-allowed"
}`}
>
<FiBarChart size={12} className="inline mr-2" />
Stats
</button>
)}
<button
onClick={isOwnedByUser ? handleDelete : undefined}
className={`w-full text-left items-center px-2 py-1 rounded ${
isOwnedByUser
? "hover:bg-neutral-200 dark:hover:bg-neutral- text-red-600 dark:text-red-400"
: "opacity-50 cursor-not-allowed text-red-300 dark:text-red-500"
}`}
disabled={!isOwnedByUser}
>
<FiTrash size={12} className="inline mr-2" />
Delete
</button>
</div>
) : (
<div className="w-full">
<p className="text-sm mb-3">
Are you sure you want to delete assistant{" "}
<b>{persona.name}</b>?
</p>
<div className="flex justify-center gap-2">
<Button
variant="secondary"
size="sm"
onClick={cancelDelete}
>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
onClick={confirmDelete}
>
Delete
</Button>
</div>
</div>
)}
</PopoverContent>
</Popover>
</div>

View File

@@ -5,9 +5,8 @@ import { useRouter } from "next/navigation";
import AssistantCard from "./AssistantCard";
import { useAssistants } from "@/components/context/AssistantsContext";
import { useUser } from "@/components/user/UserProvider";
import { FilterIcon } from "lucide-react";
import { FilterIcon, XIcon } from "lucide-react";
import { checkUserOwnsAssistant } from "@/lib/assistants/checkOwnership";
import { Dialog, DialogContent } from "@/components/ui/dialog";
export const AssistantBadgeSelector = ({
text,
@@ -108,16 +107,20 @@ export function AssistantModal({
const featuredAssistants = [
...memoizedCurrentlyVisibleAssistants.filter(
(assistant) => assistant.builtin_persona || assistant.is_default_persona
(assistant) => assistant.is_default_persona
),
];
const allAssistants = memoizedCurrentlyVisibleAssistants.filter(
(assistant) => !assistant.builtin_persona && !assistant.is_default_persona
(assistant) => !assistant.is_default_persona
);
return (
<div className="fixed inset-0 bg-neutral-950/80 bg-opacity-50 flex items-center justify-center z-50">
<div
onClick={hideModal}
className="fixed inset-0 bg-neutral-950/80 bg-opacity-50 flex items-center justify-center z-50"
>
<div
onClick={(e) => e.stopPropagation()}
className="p-0 max-w-4xl overflow-hidden max-h-[80vh] w-[95%] bg-background rounded-md shadow-2xl transform transition-all duration-300 ease-in-out relative w-11/12 max-w-4xl pt-10 pb-10 px-10 overflow-hidden flex flex-col"
style={{
position: "fixed",
@@ -127,6 +130,15 @@ export function AssistantModal({
margin: 0,
}}
>
<div className="absolute top-2 right-2">
<button
onClick={hideModal}
className="cursor-pointer text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-300 transition-colors duration-200 p-2"
aria-label="Close modal"
>
<XIcon className="w-5 h-5" />
</button>
</div>
<div className="flex overflow-hidden flex-col h-full">
<div className="flex overflow-hidden flex-col h-full">
<div className="flex flex-col sticky top-0 z-10">

View File

@@ -97,7 +97,6 @@ import {
} from "@/components/resizable/constants";
import FixedLogo from "../../components/logo/FixedLogo";
import { DeleteEntityModal } from "../../components/modals/DeleteEntityModal";
import { MinimalMarkdown } from "@/components/chat/MinimalMarkdown";
import ExceptionTraceModal from "@/components/modals/ExceptionTraceModal";
@@ -130,6 +129,7 @@ import {
useSidebarShortcut,
} from "@/lib/browserUtilities";
import { Button } from "@/components/ui/button";
import { ConfirmEntityModal } from "@/components/modals/ConfirmEntityModal";
const TEMP_USER_MESSAGE_ID = -1;
const TEMP_ASSISTANT_MESSAGE_ID = -2;
@@ -2122,7 +2122,7 @@ export function ChatPage({
<ChatPopup />
{showDeleteAllModal && (
<DeleteEntityModal
<ConfirmEntityModal
entityType="All Chats"
entityName="all your chat sessions"
onClose={() => setShowDeleteAllModal(false)}
@@ -2277,8 +2277,6 @@ export function ChatPage({
bg-opacity-80
duration-300
ease-in-out
${
!untoggled && (showHistorySidebar || sidebarVisible)
? "opacity-100 w-[250px] translate-x-0"
@@ -2287,6 +2285,7 @@ export function ChatPage({
>
<div className="w-full relative">
<HistorySidebar
liveAssistant={liveAssistant}
setShowAssistantsModal={setShowAssistantsModal}
explicitlyUntoggle={explicitlyUntoggle}
reset={() => setMessage("")}
@@ -2294,7 +2293,6 @@ export function ChatPage({
ref={innerSidebarElementRef}
toggleSidebar={toggleSidebar}
toggled={sidebarVisible}
currentAssistantId={liveAssistant?.id}
existingChats={chatSessions}
currentChatSession={selectedChatSession}
folders={folders}

View File

@@ -50,10 +50,12 @@ import {
} from "@dnd-kit/sortable";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { CircleX } from "lucide-react";
import { CirclePlus, CircleX, PinIcon } from "lucide-react";
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
import { turborepoTraceAccess } from "next/dist/build/turborepo-access-trace";
interface HistorySidebarProps {
liveAssistant?: Persona | null;
page: pageType;
existingChats?: ChatSession[];
currentChatSession?: ChatSession | null | undefined;
@@ -66,22 +68,23 @@ interface HistorySidebarProps {
showDeleteModal?: (chatSession: ChatSession) => void;
explicitlyUntoggle: () => void;
showDeleteAllModal?: () => void;
currentAssistantId?: number | null;
setShowAssistantsModal: (show: boolean) => void;
}
interface SortableAssistantProps {
assistant: Persona;
currentAssistantId: number | null | undefined;
active: boolean;
onClick: () => void;
onUnpin: (e: React.MouseEvent) => void;
onPinAction: (e: React.MouseEvent) => void;
pinned?: boolean;
}
const SortableAssistant: React.FC<SortableAssistantProps> = ({
assistant,
currentAssistantId,
active,
onClick,
onUnpin,
onPinAction,
pinned = true,
}) => {
const {
attributes,
@@ -126,7 +129,9 @@ const SortableAssistant: React.FC<SortableAssistantProps> = ({
>
<DragHandle
size={16}
className="w-3 ml-[2px] mr-[2px] group-hover:visible invisible flex-none cursor-grab"
className={`w-3 ml-[2px] mr-[2px] group-hover:visible invisible flex-none cursor-grab ${
!pinned ? "opacity-0" : ""
}`}
/>
<div
data-testid={`assistant-[${assistant.id}]`}
@@ -137,9 +142,7 @@ const SortableAssistant: React.FC<SortableAssistantProps> = ({
}
}}
className={`cursor-pointer w-full group hover:bg-background-chat-hover ${
currentAssistantId === assistant.id
? "bg-background-chat-hover/60"
: ""
active ? "bg-accent-background-selected" : ""
} relative flex items-center gap-x-2 py-1 px-2 rounded-md`}
>
<AssistantIcon assistant={assistant} size={16} className="flex-none" />
@@ -164,15 +167,36 @@ const SortableAssistant: React.FC<SortableAssistantProps> = ({
>
{assistant.name}
</span>
<button
onClick={(e) => {
e.stopPropagation();
onUnpin(e);
}}
className="group-hover:block hidden absolute right-2"
>
<CircleX size={16} className="text-text-history-sidebar-button" />
</button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(e) => {
e.stopPropagation();
onPinAction(e);
}}
className="group-hover:block hidden absolute right-2"
>
{pinned ? (
<CircleX
size={16}
className="text-text-history-sidebar-button"
/>
) : (
<PinIcon
size={16}
className="text-text-history-sidebar-button"
/>
)}
</button>
</TooltipTrigger>
<TooltipContent>
{pinned
? "Unpin this assistant from the sidebar"
: "Pin this assistant to the sidebar"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
);
@@ -181,6 +205,7 @@ const SortableAssistant: React.FC<SortableAssistantProps> = ({
export const HistorySidebar = forwardRef<HTMLDivElement, HistorySidebarProps>(
(
{
liveAssistant,
reset = () => null,
setShowAssistantsModal = () => null,
toggled,
@@ -194,7 +219,6 @@ export const HistorySidebar = forwardRef<HTMLDivElement, HistorySidebarProps>(
showShareModal,
showDeleteModal,
showDeleteAllModal,
currentAssistantId,
},
ref: ForwardedRef<HTMLDivElement>
) => {
@@ -353,13 +377,13 @@ export const HistorySidebar = forwardRef<HTMLDivElement, HistorySidebarProps>(
<SortableAssistant
key={assistant.id === 0 ? "assistant-0" : assistant.id}
assistant={assistant}
currentAssistantId={currentAssistantId}
active={assistant.id === liveAssistant?.id}
onClick={() => {
router.push(
buildChatUrl(searchParams, null, assistant.id)
);
}}
onUnpin={async (e: React.MouseEvent) => {
onPinAction={async (e: React.MouseEvent) => {
e.stopPropagation();
await toggleAssistantPinnedStatus(
pinnedAssistants.map((a) => a.id),
@@ -373,6 +397,31 @@ export const HistorySidebar = forwardRef<HTMLDivElement, HistorySidebarProps>(
</div>
</SortableContext>
</DndContext>
{!pinnedAssistants.some((a) => a.id === liveAssistant?.id) &&
liveAssistant && (
<div className="w-full mt-1 pr-4">
<SortableAssistant
pinned={false}
assistant={liveAssistant}
active={liveAssistant.id === liveAssistant?.id}
onClick={() => {
router.push(
buildChatUrl(searchParams, null, liveAssistant.id)
);
}}
onPinAction={async (e: React.MouseEvent) => {
e.stopPropagation();
await toggleAssistantPinnedStatus(
[...pinnedAssistants.map((a) => a.id)],
liveAssistant.id,
true
);
await refreshAssistants();
}}
/>
</div>
)}
<div className="w-full px-4">
<button
onClick={() => setShowAssistantsModal(true)}

View File

@@ -1,18 +1,24 @@
"use client";
import { CreditCard, ArrowFatUp } from "@phosphor-icons/react";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { loadStripe } from "@stripe/stripe-js";
import { usePopup } from "@/components/admin/connectors/Popup";
import { SettingsIcon } from "@/components/icons/icons";
import {
updateSubscriptionQuantity,
fetchCustomerPortal,
statusToDisplay,
useBillingInformation,
} from "./utils";
import { useEffect } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { CircleIcon } from "lucide-react";
export default function BillingInformationPage() {
const router = useRouter();
@@ -24,9 +30,6 @@ export default function BillingInformationPage() {
isLoading,
} = useBillingInformation();
if (error) {
console.error("Failed to fetch billing information:", error);
}
useEffect(() => {
const url = new URL(window.location.href);
if (url.searchParams.has("session_id")) {
@@ -35,11 +38,8 @@ export default function BillingInformationPage() {
"Congratulations! Your subscription has been updated successfully.",
type: "success",
});
// Remove the session_id from the URL
url.searchParams.delete("session_id");
window.history.replaceState({}, "", url.toString());
// You might want to refresh the billing information here
// by calling an API endpoint to get the latest data
}
}, [setPopup]);
@@ -47,6 +47,22 @@ export default function BillingInformationPage() {
return <div>Loading...</div>;
}
if (error) {
console.error("Failed to fetch billing information:", error);
return (
<div>Error loading billing information. Please try again later.</div>
);
}
if (!billingInformation) {
return <div>No billing information available.</div>;
}
const isTrialing = billingInformation.status === "trialing";
const isCancelled = billingInformation.cancel_at_period_end;
const isExpired =
new Date(billingInformation.current_period_end) < new Date();
const handleManageSubscription = async () => {
try {
const response = await fetchCustomerPortal();
@@ -75,138 +91,122 @@ export default function BillingInformationPage() {
});
}
};
if (!billingInformation) {
return <div>Loading...</div>;
}
return (
<div className="space-y-8">
<div className="bg-background-50 rounded-lg p-8 border border-background-200">
{popup}
<h2 className="text-2xl font-bold mb-6 text-text-800 flex items-center">
{/* <CreditCard className="mr-4 text-text-600" size={24} /> */}
Subscription Details
</h2>
<div className="space-y-4">
<div className="bg-white p-5 rounded-lg shadow-sm transition-all duration-300 hover:shadow-md">
<div className="flex justify-between items-center">
<div>
<p className="text-lg font-medium text-text-700">Seats</p>
<p className="text-sm text-text-500">
Number of licensed users
</p>
</div>
<p className="text-xl font-semibold text-text-900">
{billingInformation.seats}
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="text-2xl font-bold flex items-center">
<CreditCard className="mr-4 text-text-600" size={24} />
Subscription Details
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<InfoItem
title="Subscription Status"
value={statusToDisplay(billingInformation.status)}
/>
<InfoItem
title="Seats"
value={billingInformation.seats.toString()}
/>
<InfoItem
title="Billing Start"
value={new Date(
billingInformation.current_period_start
).toLocaleDateString()}
/>
<InfoItem
title="Billing End"
value={new Date(
billingInformation.current_period_end
).toLocaleDateString()}
/>
</div>
<div className="bg-white p-5 rounded-lg shadow-sm transition-all duration-300 hover:shadow-md">
<div className="flex justify-between items-center">
<div>
<p className="text-lg font-medium text-text-700">
Subscription Status
</p>
<p className="text-sm text-text-500">
Current state of your subscription
</p>
</div>
<p className="text-xl font-semibold text-text-900">
{statusToDisplay(billingInformation.subscription_status)}
</p>
</div>
</div>
<div className="bg-white p-5 rounded-lg shadow-sm transition-all duration-300 hover:shadow-md">
<div className="flex justify-between items-center">
<div>
<p className="text-lg font-medium text-text-700">
Billing Start
</p>
<p className="text-sm text-text-500">
Start date of current billing cycle
</p>
</div>
<p className="text-xl font-semibold text-text-900">
{isCancelled && (
<Alert>
<AlertTitle>Subscription Cancelled</AlertTitle>
<AlertDescription>
Your subscription will end on{" "}
{new Date(
billingInformation.billing_start
billingInformation.current_period_end
).toLocaleDateString()}
</p>
</div>
</div>
. You can resubscribe to continue using the service after this
date.
</AlertDescription>
</Alert>
)}
<div className="bg-white p-5 rounded-lg shadow-sm transition-all duration-300 hover:shadow-md">
<div className="flex justify-between items-center">
<div>
<p className="text-lg font-medium text-text-700">Billing End</p>
<p className="text-sm text-text-500">
End date of current billing cycle
</p>
</div>
<p className="text-xl font-semibold text-text-900">
{new Date(billingInformation.billing_end).toLocaleDateString()}
</p>
</div>
</div>
</div>
{isTrialing && (
<Alert>
<CircleIcon className="h-4 w-4" />
<AlertTitle>Trial Period</AlertTitle>
<AlertDescription>
Your trial ends on{" "}
{billingInformation.trial_end
? new Date(billingInformation.trial_end).toLocaleDateString()
: "N/A"}
.
{!billingInformation.payment_method_enabled &&
" Add a payment method to continue using the service after the trial."}
</AlertDescription>
</Alert>
)}
{!billingInformation.payment_method_enabled && (
<div className="mt-4 p-4 bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700">
<p className="font-bold">Notice:</p>
<p>
You&apos;ll need to add a payment method before your trial ends to
continue using the service.
</p>
</div>
)}
{!billingInformation.payment_method_enabled && (
<Alert variant="destructive">
<AlertTitle>Payment Method Required</AlertTitle>
<AlertDescription>
You need to add a payment method before your trial ends to
continue using the service.
</AlertDescription>
</Alert>
)}
{billingInformation.subscription_status === "trialing" ? (
<div className="bg-white p-5 rounded-lg shadow-sm transition-all duration-300 hover:shadow-md mt-8">
<p className="text-lg font-medium text-text-700">
No cap on users during trial
</p>
</div>
) : (
<div className="flex items-center space-x-4 mt-8">
<div className="flex items-center space-x-4">
<p className="text-lg font-medium text-text-700">
Current Seats:
</p>
<p className="text-xl font-semibold text-text-900">
{billingInformation.seats}
</p>
</div>
<p className="text-sm text-text-500">
Seats automatically update based on adding, removing, or inviting
users.
</p>
</div>
)}
</div>
{isExpired && (
<Alert variant="destructive">
<AlertTitle>Subscription Expired</AlertTitle>
<AlertDescription>
Your subscription has expired. Please resubscribe to continue
using the service.
</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
<div className="bg-white p-5 rounded-lg shadow-sm transition-all duration-300 hover:shadow-md">
<div className="flex justify-between items-center mb-4">
<div>
<p className="text-lg font-medium text-text-700">
Manage Subscription
</p>
<p className="text-sm text-text-500">
View your plan, update payment, or change subscription
</p>
</div>
<SettingsIcon className="text-text-600" size={20} />
</div>
<button
onClick={handleManageSubscription}
className="bg-background-600 text-white px-4 py-2 rounded-md hover:bg-background-700 transition duration-300 ease-in-out focus:outline-none focus:ring-2 focus:ring-text-500 focus:ring-opacity-50 font-medium shadow-sm text-sm flex items-center justify-center"
>
<ArrowFatUp className="mr-2" size={16} />
Manage Subscription
</button>
</div>
<Card>
<CardHeader>
<CardTitle className="text-lg font-medium">
Manage Subscription
</CardTitle>
<CardDescription>
View your plan, update payment, or change subscription
</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={handleManageSubscription} className="w-full">
<ArrowFatUp className="mr-2" size={16} />
Manage Subscription
</Button>
</CardContent>
</Card>
</div>
);
}
interface InfoItemProps {
title: string;
value: string;
}
function InfoItem({ title, value }: InfoItemProps) {
return (
<div className="bg-background-50 p-4 rounded-lg">
<p className="text-sm font-medium text-text-500">{title}</p>
<p className="text-lg font-semibold text-text-900">{value}</p>
</div>
);
}

View File

@@ -3,10 +3,16 @@ import BillingInformationPage from "./BillingInformationPage";
import { MdOutlineCreditCard } from "react-icons/md";
export interface BillingInformation {
stripe_subscription_id: string;
status: string;
current_period_start: Date;
current_period_end: Date;
number_of_seats: number;
cancel_at_period_end: boolean;
canceled_at: Date | null;
trial_start: Date | null;
trial_end: Date | null;
seats: number;
subscription_status: string;
billing_start: Date;
billing_end: Date;
payment_method_enabled: boolean;
}

View File

@@ -70,6 +70,7 @@
--accent-foreground: 0 0% 9%;
--accent-background: #f0eee8;
--accent-background-hovered: #e5e3dd;
--accent-background-selected: #eae8e2;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--ring: 0 0% 3.9%;
@@ -247,6 +248,7 @@
--accent-background: #333333;
--accent-background-hovered: #2f2f2f;
--accent-background-selected: #222222;
--text-darker: #f0f0f0;

View File

@@ -28,6 +28,7 @@ import { WebVitals } from "./web-vitals";
import { ThemeProvider } from "next-themes";
import CloudError from "@/components/errorPages/CloudErrorPage";
import Error from "@/components/errorPages/ErrorPage";
import AccessRestrictedPage from "@/components/errorPages/AccessRestrictedPage";
const inter = Inter({
subsets: ["latin"],
@@ -130,40 +131,16 @@ export default async function RootLayout({
</html>
);
if (productGating === GatingType.FULL) {
return getPageContent(<AccessRestrictedPage />);
}
if (!combinedSettings) {
return getPageContent(
NEXT_PUBLIC_CLOUD_ENABLED ? <CloudError /> : <Error />
);
}
if (productGating === GatingType.FULL) {
return getPageContent(
<div className="flex flex-col items-center justify-center min-h-screen">
<div className="mb-2 flex items-center max-w-[175px]">
<LogoType />
</div>
<CardSection className="w-full max-w-md">
<h1 className="text-2xl font-bold mb-4 text-error">
Access Restricted
</h1>
<p className="text-text-500 mb-4">
We regret to inform you that your access to Onyx has been
temporarily suspended due to a lapse in your subscription.
</p>
<p className="text-text-500 mb-4">
To reinstate your access and continue benefiting from Onyx&apos;s
powerful features, please update your payment information.
</p>
<p className="text-text-500">
If you&apos;re an admin, you can resolve this by visiting the
billing section. For other users, please reach out to your
administrator to address this matter.
</p>
</CardSection>
</div>
);
}
const { assistants, hasAnyConnectors, hasImageCompatibleModel } =
assistantsData;

View File

@@ -4,7 +4,7 @@ import userMutationFetcher from "@/lib/admin/users/userMutationFetcher";
import useSWRMutation from "swr/mutation";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import { DeleteEntityModal } from "@/components/modals/DeleteEntityModal";
import { ConfirmEntityModal } from "@/components/modals/ConfirmEntityModal";
const DeleteUserButton = ({
user,
@@ -38,7 +38,7 @@ const DeleteUserButton = ({
return (
<>
{showDeleteModal && (
<DeleteEntityModal
<ConfirmEntityModal
entityType="user"
entityName={user.email}
onClose={() => setShowDeleteModal(false)}

View File

@@ -4,7 +4,7 @@ import userMutationFetcher from "@/lib/admin/users/userMutationFetcher";
import useSWRMutation from "swr/mutation";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import { DeleteEntityModal } from "@/components/modals/DeleteEntityModal";
import { ConfirmEntityModal } from "@/components/modals/ConfirmEntityModal";
import { useRouter } from "next/navigation";
export const LeaveOrganizationButton = ({
@@ -46,8 +46,9 @@ export const LeaveOrganizationButton = ({
return (
<>
{showLeaveModal && (
<DeleteEntityModal
deleteButtonText="Leave"
<ConfirmEntityModal
variant="action"
actionButtonText="Leave"
entityType="organization"
entityName="your organization"
onClose={() => setShowLeaveModal(false)}

View File

@@ -60,7 +60,7 @@ export const AssistantsProvider: React.FC<{
.map((id) => assistants.find((assistant) => assistant.id === id))
.filter((assistant): assistant is Persona => assistant !== undefined);
} else {
return assistants.filter((a) => a.builtin_persona);
return assistants.filter((a) => a.is_default_persona);
}
});
@@ -71,7 +71,7 @@ export const AssistantsProvider: React.FC<{
.map((id) => assistants.find((assistant) => assistant.id === id))
.filter((assistant): assistant is Persona => assistant !== undefined);
} else {
return assistants.filter((a) => a.builtin_persona);
return assistants.filter((a) => a.is_default_persona);
}
});
}, [user?.preferences?.pinned_assistants, assistants]);

View File

@@ -0,0 +1,145 @@
"use client";
import { FiLock } from "react-icons/fi";
import ErrorPageLayout from "./ErrorPageLayout";
import { fetchCustomerPortal } from "@/app/ee/admin/billing/utils";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { logout } from "@/lib/user";
import { loadStripe } from "@stripe/stripe-js";
const fetchResubscriptionSession = async () => {
const response = await fetch("/api/tenants/create-subscription-session", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error("Failed to create resubscription session");
}
return response.json();
};
export default function AccessRestricted() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const handleManageSubscription = async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetchCustomerPortal();
if (!response.ok) {
const errorData = await response.json();
throw new Error(
`Failed to create customer portal session: ${
errorData.message || response.statusText
}`
);
}
const { url } = await response.json();
alert(url);
if (!url) {
throw new Error("No portal URL returned from the server");
}
router.push(url);
} catch (error) {
console.error("Error creating customer portal session:", error);
setError("Error opening customer portal. Please try again later.");
} finally {
setIsLoading(false);
}
};
const handleResubscribe = async () => {
setIsLoading(true);
setError(null);
try {
const { sessionId } = await fetchResubscriptionSession();
const stripe = await loadStripe(
"pk_test_51NwZq2HlhTYqRZibiKRTTabTc54crtxg05tZnj4uiau8Gb6rnQq9ueVrbi6IMY7FDzM0P36mQs3Ovi9jDVsyUj4S00KUpkQLrt"
);
if (stripe) {
await stripe.redirectToCheckout({ sessionId });
} else {
throw new Error("Stripe failed to load");
}
} catch (error) {
console.error("Error creating resubscription session:", error);
setError("Error opening resubscription page. Please try again later.");
} finally {
setIsLoading(false);
}
};
return (
<ErrorPageLayout>
<h1 className="text-2xl font-semibold flex items-center gap-2 mb-4 text-gray-800 dark:text-gray-200">
<p>Access Restricted</p>
<FiLock className="text-error inline-block" />
</h1>
<div className="space-y-4 text-gray-600 dark:text-gray-300">
<p>
We regret to inform you that your access to Onyx has been temporarily
suspended due to a lapse in your subscription.
</p>
<p>
To reinstate your access and continue benefiting from Onyx's powerful
features, please update your payment information.
</p>
<p>
If you're an admin, you can manage your subscription by clicking the
button below. For other users, please reach out to your administrator
to address this matter.
</p>
<div className="flex flex-col space-y-4 sm:flex-row sm:space-y-0 sm:space-x-4">
<Button
onClick={handleResubscribe}
disabled={isLoading}
className="w-full sm:w-auto"
>
{isLoading ? "Loading..." : "Resubscribe"}
</Button>
<Button
variant="outline"
onClick={handleManageSubscription}
disabled={isLoading}
className="w-full sm:w-auto"
>
Manage Existing Subscription
</Button>
<Button
variant="outline"
onClick={async () => {
await logout();
window.location.reload();
}}
className="w-full sm:w-auto"
>
Log out
</Button>
</div>
{error && <p className="text-error">{error}</p>}
<p>
Need help? Join our{" "}
<a
className="text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
href="https://join.slack.com/t/danswer/shared_invite/zt-1w76msxmd-HJHLe3KNFIAIzk_0dSOKaQ"
target="_blank"
rel="noopener noreferrer"
>
Slack community
</a>{" "}
for support.
</p>
</div>
</ErrorPageLayout>
);
}

View File

@@ -0,0 +1,67 @@
import { Modal } from "../Modal";
import { Button } from "../ui/button";
export const ConfirmEntityModal = ({
onClose,
onSubmit,
entityType,
entityName,
additionalDetails,
actionButtonText,
includeCancelButton = true,
variant = "delete",
}: {
entityType: string;
entityName: string;
onClose: () => void;
onSubmit: () => void;
additionalDetails?: string;
actionButtonText?: string;
includeCancelButton?: boolean;
variant?: "delete" | "action";
}) => {
const isDeleteVariant = variant === "delete";
const defaultButtonText = isDeleteVariant ? "Delete" : "Confirm";
const buttonText = actionButtonText || defaultButtonText;
const getActionText = () => {
if (isDeleteVariant) {
return "delete";
}
switch (entityType) {
case "Default Persona":
return "change the default status of";
default:
return "modify";
}
};
return (
<Modal width="rounded max-w-sm w-full" onOutsideClick={onClose}>
<>
<div className="flex mb-4">
<h2 className="my-auto text-2xl font-bold">
{buttonText} {entityType}
</h2>
</div>
<p className="mb-4">
Are you sure you want to {getActionText()} <b>{entityName}</b>?
</p>
{additionalDetails && <p className="mb-4">{additionalDetails}</p>}
<div className="flex justify-end gap-2">
{includeCancelButton && (
<Button onClick={onClose} variant="outline">
Cancel
</Button>
)}
<Button
onClick={onSubmit}
variant={isDeleteVariant ? "destructive" : "default"}
>
{buttonText}
</Button>
</div>
</>
</Modal>
);
};

View File

@@ -1,51 +0,0 @@
import { FiTrash, FiX } from "react-icons/fi";
import { BasicClickable } from "@/components/BasicClickable";
import { Modal } from "../Modal";
import { Button } from "../ui/button";
export const DeleteEntityModal = ({
onClose,
onSubmit,
entityType,
entityName,
additionalDetails,
deleteButtonText,
includeCancelButton = true,
}: {
entityType: string;
entityName: string;
onClose: () => void;
onSubmit: () => void;
additionalDetails?: string;
deleteButtonText?: string;
includeCancelButton?: boolean;
}) => {
return (
<Modal width="rounded max-w-sm w-full" onOutsideClick={onClose}>
<>
<div className="flex mb-4">
<h2 className="my-auto text-2xl font-bold">
{deleteButtonText || `Delete`} {entityType}
</h2>
</div>
<p className="mb-4">
Are you sure you want to {deleteButtonText || "delete"}{" "}
<b>{entityName}</b>?
</p>
{additionalDetails && <p className="mb-4">{additionalDetails}</p>}
<div className="flex items-end justify-end">
<div className="flex gap-x-2">
{includeCancelButton && (
<Button variant="outline" onClick={onClose}>
<div className="flex mx-2">Cancel</div>
</Button>
)}
<Button size="sm" variant="destructive" onClick={onSubmit}>
<div className="flex mx-2">{deleteButtonText || "Delete"}</div>
</Button>
</div>
</div>
</>
</Modal>
);
};

View File

@@ -108,6 +108,7 @@ module.exports = {
"input-option-hover": "var(--input-option-hover)",
"accent-background": "var(--accent-background)",
"accent-background-hovered": "var(--accent-background-hovered)",
"accent-background-selected": "var(--accent-background-selected)",
"background-dark": "var(--off-white)",
"background-100": "var(--neutral-100-border-light)",
"background-125": "var(--neutral-125)",