Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ SESSION_SECRET=
# SESSION_COOKIE_SECURE=false

# ── Cache Control ────────────────────────────────────────────
# These mirror LibreChat's cache env vars. ADMIN_PANEL_* variants
# take precedence, falling back to the shared LibreChat equivalents.

# Static asset caching (hashed files in /assets/)
Expand Down
29 changes: 17 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# LibreChat Admin Panel

A browser-based management interface for [LibreChat](https://github.com/danny-avila/LibreChat). It connects to the same database as the main application and provides a GUI for tasks that would otherwise require editing `librechat.yaml` directly.
A browser-based management interface for [LibreChat](https://github.com/danny-avila/LibreChat). It communicates with LibreChat exclusively through authenticated backend APIs and provides a GUI for tasks that would otherwise require editing `librechat.yaml` directly.

## Features

Expand Down Expand Up @@ -36,19 +36,24 @@ docker compose down # stop
> Use `http://host.docker.internal:3080` for `VITE_API_BASE_URL` to reach
> LibreChat running on the host.

The admin panel is a standalone API client and must not receive MongoDB credentials.
Atomic configuration writes, revision snapshots, history, and rollback are owned by
the LibreChat backend. Deploy the matching backend before, or in the same release
train as, this panel version.

#### Environment variables

| Variable | Required | Default | Description |
| ------------------------------- | ----------------------------------- | -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `PORT` | No | `3000` | Port the admin panel listens on |
| `SESSION_SECRET` | **Yes** (always required in Docker) | Dev fallback only when running `bun dev` locally; no default in the Docker image | Encryption key for sessions (min 32 chars) |
| `VITE_API_BASE_URL` | **Yes** (Docker) | `http://localhost:3080` (local dev only) | LibreChat API server URL; use `http://host.docker.internal:<port>` in Docker |
| `VITE_BASE_PATH` | No | `/` | URL subpath to serve the panel under (e.g., `/adminpanel`). Must match at build time and runtime |
| `API_SERVER_URL` | No | Falls back to `VITE_API_BASE_URL` | Server-side LibreChat API URL when the container reaches LibreChat differently than the browser |
| `ADMIN_SSO_ONLY` | No | `false` | Hide email/password form, SSO only |
| `ADMIN_SSO_ENABLED` | No | `true` | Set `false` to hide the SSO button (and auto-redirect) while keeping email/password login |
| `ADMIN_SESSION_IDLE_TIMEOUT_MS` | No | `1800000` (30 min) | Session idle timeout in ms |
| `SESSION_COOKIE_SECURE` | No | `true` in production, `false` otherwise | Set `false` only for plain-HTTP deployments so the browser keeps the admin session cookie |
| Variable | Required | Default | Description |
| ------------------------------- | ----------------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| `PORT` | No | `3000` | Port the admin panel listens on |
| `SESSION_SECRET` | **Yes** (always required in Docker) | Dev fallback only when running `bun dev` locally; no default in the Docker image | Encryption key for sessions (min 32 chars) |
| `VITE_API_BASE_URL` | **Yes** (Docker) | `http://localhost:3080` (local dev only) | LibreChat API server URL; use `http://host.docker.internal:<port>` in Docker |
| `VITE_BASE_PATH` | No | `/` | URL subpath to serve the panel under (e.g., `/adminpanel`). Must match at build time and runtime |
| `API_SERVER_URL` | No | Falls back to `VITE_API_BASE_URL` | Server-side LibreChat API URL when the container reaches LibreChat differently than the browser |
| `ADMIN_SSO_ONLY` | No | `false` | Hide email/password form, SSO only |
| `ADMIN_SSO_ENABLED` | No | `true` | Set `false` to hide the SSO button (and auto-redirect) while keeping email/password login |
| `ADMIN_SESSION_IDLE_TIMEOUT_MS` | No | `1800000` (30 min) | Session idle timeout in ms |
| `SESSION_COOKIE_SECURE` | No | `true` in production, `false` otherwise | Set `false` only for plain-HTTP deployments so the browser keeps the admin session cookie |

For OpenID SSO, the admin panel stores a short-lived PKCE verifier in the
`admin-session` cookie before redirecting to LibreChat. If the admin panel is
Expand Down
21 changes: 20 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,25 @@ services:
restart: unless-stopped
extra_hosts:
- 'host.docker.internal:host-gateway'
env_file: .env
environment:
- PORT=${PORT:-3000}
- SESSION_SECRET=${SESSION_SECRET}
- VITE_API_BASE_URL=${VITE_API_BASE_URL:-http://host.docker.internal:3080}
- API_SERVER_URL=${API_SERVER_URL:-}
- VITE_BASE_PATH=${VITE_BASE_PATH:-/}
- ADMIN_SSO_ONLY=${ADMIN_SSO_ONLY:-false}
- ADMIN_SSO_ENABLED=${ADMIN_SSO_ENABLED:-true}
- ADMIN_SESSION_IDLE_TIMEOUT_MS=${ADMIN_SESSION_IDLE_TIMEOUT_MS:-1800000}
- SESSION_COOKIE_SECURE=${SESSION_COOKIE_SECURE:-true}
- ADMIN_PANEL_METRICS_SECRET=${ADMIN_PANEL_METRICS_SECRET:-}
- ADMIN_PANEL_CSP_ENFORCE=${ADMIN_PANEL_CSP_ENFORCE:-false}
- STATIC_CACHE_MAX_AGE=${STATIC_CACHE_MAX_AGE:-}
- STATIC_CACHE_S_MAX_AGE=${STATIC_CACHE_S_MAX_AGE:-}
- ADMIN_PANEL_STATIC_CACHE_MAX_AGE=${ADMIN_PANEL_STATIC_CACHE_MAX_AGE:-}
- ADMIN_PANEL_STATIC_CACHE_S_MAX_AGE=${ADMIN_PANEL_STATIC_CACHE_S_MAX_AGE:-}
- INDEX_CACHE_CONTROL=${INDEX_CACHE_CONTROL:-}
- INDEX_PRAGMA=${INDEX_PRAGMA:-}
- INDEX_EXPIRES=${INDEX_EXPIRES:-}
- ADMIN_PANEL_INDEX_CACHE_CONTROL=${ADMIN_PANEL_INDEX_CACHE_CONTROL:-}
- ADMIN_PANEL_INDEX_PRAGMA=${ADMIN_PANEL_INDEX_PRAGMA:-}
- ADMIN_PANEL_INDEX_EXPIRES=${ADMIN_PANEL_INDEX_EXPIRES:-}
24 changes: 15 additions & 9 deletions server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,23 @@ if (env.NODE_ENV !== 'development') {
}

const ONE_DAY = 86400;
const rawMaxAge = Number(env.ADMIN_PANEL_STATIC_CACHE_MAX_AGE ?? env.STATIC_CACHE_MAX_AGE);
const rawSMaxAge = Number(env.ADMIN_PANEL_STATIC_CACHE_S_MAX_AGE ?? env.STATIC_CACHE_S_MAX_AGE);
const firstNonEmpty = (...values: Array<string | undefined>): string | undefined =>
values.find((value) => typeof value === 'string' && value.trim().length > 0);
const rawMaxAge = Number(
firstNonEmpty(env.ADMIN_PANEL_STATIC_CACHE_MAX_AGE, env.STATIC_CACHE_MAX_AGE),
);
const rawSMaxAge = Number(
firstNonEmpty(env.ADMIN_PANEL_STATIC_CACHE_S_MAX_AGE, env.STATIC_CACHE_S_MAX_AGE),
);
const maxAge = Number.isNaN(rawMaxAge) ? ONE_DAY * 2 : rawMaxAge;
const sMaxAge = Number.isNaN(rawSMaxAge) ? ONE_DAY : rawSMaxAge;

const NO_CACHE: Record<string, string> = {
'Cache-Control':
env.ADMIN_PANEL_INDEX_CACHE_CONTROL ??
env.INDEX_CACHE_CONTROL ??
firstNonEmpty(env.ADMIN_PANEL_INDEX_CACHE_CONTROL, env.INDEX_CACHE_CONTROL) ??
'no-cache, no-store, must-revalidate',
Pragma: env.ADMIN_PANEL_INDEX_PRAGMA ?? env.INDEX_PRAGMA ?? 'no-cache',
Expires: env.ADMIN_PANEL_INDEX_EXPIRES ?? env.INDEX_EXPIRES ?? '0',
Pragma: firstNonEmpty(env.ADMIN_PANEL_INDEX_PRAGMA, env.INDEX_PRAGMA) ?? 'no-cache',
Expires: firstNonEmpty(env.ADMIN_PANEL_INDEX_EXPIRES, env.INDEX_EXPIRES) ?? '0',
};

const LONG_CACHE: Record<string, string> = {
Expand Down Expand Up @@ -136,9 +141,10 @@ const server = Bun.serve({
...(BASE_PATH ? { [`${BASE_PATH}`]: () => Response.redirect(`${BASE_PATH}/`, 302) } : {}),
'/*': async (req) => {
const url = new URL(req.url);
const metricsPath = BASE_PATH && url.pathname.startsWith(BASE_PATH)
? url.pathname.slice(BASE_PATH.length) || '/'
: url.pathname;
const metricsPath =
BASE_PATH && url.pathname.startsWith(BASE_PATH)
? url.pathname.slice(BASE_PATH.length) || '/'
: url.pathname;
const res = await withHttpMetrics(req, metricsPath, () => handler.fetch(req));
const patched = new Response(res.body, res);
for (const [k, v] of Object.entries(NO_CACHE)) {
Expand Down
9 changes: 8 additions & 1 deletion src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { Icon, Dropdown } from '@clickhouse/click-ui';
import { useQueryClient } from '@tanstack/react-query';
import { Link, useRouter } from '@tanstack/react-router';
import type * as t from '@/types';
import { useStripAriaExpanded, useCapabilities, useLocalize } from '@/hooks';
Expand Down Expand Up @@ -43,6 +44,7 @@ function getUserInitials(user?: { name?: string; email?: string } | null): strin
export function Sidebar({ user, collapsed, onToggle }: t.SidebarProps) {
const localize = useLocalize();
const router = useRouter();
const queryClient = useQueryClient();
const { hasCapability } = useCapabilities();
const currentPath = router.state.location.pathname;
const [isLoggingOut, setIsLoggingOut] = useState(false);
Expand All @@ -62,6 +64,7 @@ export function Sidebar({ user, collapsed, onToggle }: t.SidebarProps) {
setIsLoggingOut(true);
try {
const result = await adminLogoutFn();
queryClient.clear();
if (!result.error && result.redirect) {
window.location.href = result.redirect;
return;
Expand All @@ -88,7 +91,11 @@ export function Sidebar({ user, collapsed, onToggle }: t.SidebarProps) {
>
<div className="flex h-14 shrink-0 items-center px-2">
<div className="flex items-center gap-2.5 overflow-hidden px-1.5">
<img src={libreChatLogo} alt={localize('com_a11y_logo_alt')} className="h-6 w-6 shrink-0" />
<img
src={libreChatLogo}
alt={localize('com_a11y_logo_alt')}
className="h-6 w-6 shrink-0"
/>
<span className="truncate text-sm font-semibold text-(--cui-color-text-default)">
{localize('com_auth_title')}
</span>
Expand Down
23 changes: 19 additions & 4 deletions src/components/access/AccessPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export function AccessPage({
onTabChange,
canReadRoles,
canReadGroups,
expectedTenantId,
}: t.AccessPageProps) {
const localize = useLocalize();
const [createGroupOpen, setCreateGroupOpen] = useState(false);
Expand All @@ -36,16 +37,30 @@ export function AccessPage({

<div className="flex min-h-0 flex-1 flex-col overflow-hidden pt-3">
{activeTab === 'groups' && canReadGroups && (
<GroupsTab onCreateGroup={() => setCreateGroupOpen(true)} />
<GroupsTab
expectedTenantId={expectedTenantId}
onCreateGroup={() => setCreateGroupOpen(true)}
/>
)}

{activeTab === 'roles' && canReadRoles && (
<RolesTab onCreateRole={() => setCreateRoleOpen(true)} />
<RolesTab
expectedTenantId={expectedTenantId}
onCreateRole={() => setCreateRoleOpen(true)}
/>
)}
</div>

<CreateGroupDialog open={createGroupOpen} onClose={() => setCreateGroupOpen(false)} />
<CreateRoleDialog open={createRoleOpen} onClose={() => setCreateRoleOpen(false)} />
<CreateGroupDialog
open={createGroupOpen}
expectedTenantId={expectedTenantId}
onClose={() => setCreateGroupOpen(false)}
/>
<CreateRoleDialog
open={createRoleOpen}
expectedTenantId={expectedTenantId}
onClose={() => setCreateRoleOpen(false)}
/>
</div>
);
}
23 changes: 15 additions & 8 deletions src/components/access/CreateGroupDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ import { Button, Dialog, Tabs } from '@clickhouse/click-ui';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { AdminUserSearchResult } from '@librechat/data-schemas';
import type * as t from '@/types';
import { addGroupMemberFn, createGroupFn, tenantQueryKeys } from '@/server';
import { SelectedMemberList, UserSearchInline } from '@/components/shared';
import { addGroupMemberFn, createGroupFn } from '@/server';
import { cn, notifySuccess, notifyError } from '@/utils';
import { useLocalize } from '@/hooks';

export function CreateGroupDialog({ open, onClose }: t.CreateGroupDialogProps) {
export function CreateGroupDialog({ open, expectedTenantId, onClose }: t.CreateGroupDialogProps) {
const localize = useLocalize();
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<t.CreateGroupTab>('details');
Expand All @@ -29,18 +29,24 @@ export function CreateGroupDialog({ open, onClose }: t.CreateGroupDialogProps) {
const mutation = useMutation({
mutationFn: async ({ name: submittedName }: { name: string }) => {
const { group } = await createGroupFn({
data: { name: submittedName, description },
data: { name: submittedName, description, expectedTenantId },
});
for (const user of selectedUsers) {
await addGroupMemberFn({ data: { groupId: group.id, userId: user.id } });
await addGroupMemberFn({
data: { groupId: group.id, userId: user.id, expectedTenantId },
});
}
return { name: submittedName };
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['groups'] });
queryClient.invalidateQueries({ queryKey: ['groupMembers'] });
queryClient.invalidateQueries({ queryKey: ['availableScopes'] });
queryClient.invalidateQueries({ queryKey: ['groupAssignments'] });
queryClient.invalidateQueries({ queryKey: tenantQueryKeys.groups(expectedTenantId) });
queryClient.invalidateQueries({ queryKey: tenantQueryKeys.groupMembers(expectedTenantId) });
queryClient.invalidateQueries({
queryKey: tenantQueryKeys.availableScopes(expectedTenantId),
});
queryClient.invalidateQueries({
queryKey: tenantQueryKeys.groupAssignments(expectedTenantId),
});
notifySuccess(localize('com_toast_group_created', { name: data.name }));
resetAndClose();
},
Expand Down Expand Up @@ -148,6 +154,7 @@ export function CreateGroupDialog({ open, onClose }: t.CreateGroupDialogProps) {
<UserSearchInline
existingIds={selectedUsers.map((u) => u.id)}
onAdd={addUser}
expectedTenantId={expectedTenantId}
listboxId="create-group-member-results"
disabled={mutation.isPending}
/>
Expand Down
27 changes: 18 additions & 9 deletions src/components/access/CreateRoleDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ import { Button, Dialog, Tabs } from '@clickhouse/click-ui';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { AdminUserSearchResult } from '@librechat/data-schemas';
import type * as t from '@/types';
import { addRoleMemberFn, createRoleFn, updateRolePermissionsFn } from '@/server';
import { addRoleMemberFn, createRoleFn, tenantQueryKeys, updateRolePermissionsFn } from '@/server';
import { SelectedMemberList, UserSearchInline } from '@/components/shared';
import { RolePermissionsPanel } from './RolePermissionsPanel';
import { cn, notifySuccess, notifyError } from '@/utils';
import { defaultPermissions } from '@/constants';
import { useLocalize } from '@/hooks';

export function CreateRoleDialog({ open, onClose }: t.CreateRoleDialogProps) {
export function CreateRoleDialog({ open, expectedTenantId, onClose }: t.CreateRoleDialogProps) {
const localize = useLocalize();
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<t.CreateRoleTab>('details');
Expand All @@ -32,18 +32,26 @@ export function CreateRoleDialog({ open, onClose }: t.CreateRoleDialogProps) {

const mutation = useMutation({
mutationFn: async ({ name: submittedName }: { name: string }) => {
const { role } = await createRoleFn({ data: { name: submittedName, description } });
await updateRolePermissionsFn({ data: { id: role.id, permissions } });
const { role } = await createRoleFn({
data: { name: submittedName, description, expectedTenantId },
});
await updateRolePermissionsFn({ data: { id: role.id, permissions, expectedTenantId } });
for (const user of selectedUsers) {
await addRoleMemberFn({ data: { roleId: role.id, userId: user.id } });
await addRoleMemberFn({
data: { roleId: role.id, userId: user.id, expectedTenantId },
});
}
return { name: submittedName };
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['roles'] });
queryClient.invalidateQueries({ queryKey: ['roleMembers'] });
queryClient.invalidateQueries({ queryKey: ['availableScopes'] });
queryClient.invalidateQueries({ queryKey: ['roleAssignments'] });
queryClient.invalidateQueries({ queryKey: tenantQueryKeys.roles(expectedTenantId) });
queryClient.invalidateQueries({ queryKey: tenantQueryKeys.roleMembers(expectedTenantId) });
queryClient.invalidateQueries({
queryKey: tenantQueryKeys.availableScopes(expectedTenantId),
});
queryClient.invalidateQueries({
queryKey: tenantQueryKeys.roleAssignments(expectedTenantId),
});
notifySuccess(localize('com_toast_role_created', { name: data.name }));
resetAndClose();
},
Expand Down Expand Up @@ -168,6 +176,7 @@ export function CreateRoleDialog({ open, onClose }: t.CreateRoleDialogProps) {
<UserSearchInline
existingIds={selectedUsers.map((u) => u.id)}
onAdd={addUser}
expectedTenantId={expectedTenantId}
listboxId="create-role-member-results"
disabled={mutation.isPending}
/>
Expand Down
Loading