diff --git a/Dockerfile b/Dockerfile index c5bfa163..6fb18bec 100644 --- a/Dockerfile +++ b/Dockerfile @@ -49,6 +49,13 @@ RUN apk upgrade --no-cache && \ COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +# next/dist/lib/metadata/** (get-metadata-route.js and its neighbours). A +# plain top-level require in router-utils/filesystem.js, yet Next's own +# output file tracing for `output: "standalone"` + `next build --webpack` +# drops the whole directory - the server crashes on its first line with +# "Cannot find module '../../../lib/metadata/get-metadata-route'" without +# this. Same tracing-gap class as the plugins copy below. +COPY --from=builder --chown=nextjs:nodejs /app/node_modules/next/dist/lib/metadata ./node_modules/next/dist/lib/metadata # Staged first-party plugin bundles. Read by path at runtime, so Next's output # file tracing does not carry them into .next/standalone - copy explicitly or # the image boots with the S/MIME policy toggle on and no plugin installed. diff --git a/app/(main)/admin/_tabs/ai-policy.tsx b/app/(main)/admin/_tabs/ai-policy.tsx index a0f21b4d..d0b07220 100644 --- a/app/(main)/admin/_tabs/ai-policy.tsx +++ b/app/(main)/admin/_tabs/ai-policy.tsx @@ -1,8 +1,8 @@ 'use client'; import { useEffect, useState } from 'react'; -import { Save, Loader2, X, ArrowRight } from 'lucide-react'; -import type { AiConsoleConfig, AiClass } from '@/lib/ai/types'; +import { Save, Loader2, X, ArrowRight, Plus, Trash2 } from 'lucide-react'; +import type { AiConsoleConfig, AiClass, PublicAiPreset } from '@/lib/ai/types'; import { DEFAULT_AI_CONSOLE_CONFIG } from '@/lib/ai/types'; import type { AiEntitlementState, MeteringEntry } from '@/lib/ai/entitlement'; import { apiFetch } from '@/lib/browser-navigation'; @@ -73,6 +73,85 @@ function AllowlistEditor({ ); } +function newPresetId(): string { + return `preset-${Math.random().toString(36).slice(2, 10)}`; +} + +/** + * The Paperclip-style env-var-key picker (decision 2026-08-07): an admin + * names a preset and an env var; the actual secret value is never entered + * here — it's whatever ops has set in the server's real environment. This is + * what lets a user in Settings pick a provider from a dropdown instead of + * pasting a key. + */ +function PublicPresetsEditor({ + presets, onChange, +}: { presets: PublicAiPreset[]; onChange: (next: PublicAiPreset[]) => void }) { + const [name, setName] = useState(''); + const [baseUrl, setBaseUrl] = useState('https://api.deepseek.com'); + const [model, setModel] = useState(''); + const [envVar, setEnvVar] = useState(''); + + const canAdd = name.trim() && baseUrl.trim() && model.trim() && envVar.trim(); + + function addPreset() { + if (!canAdd) return; + onChange([...presets, { id: newPresetId(), name: name.trim(), baseUrl: baseUrl.trim(), model: model.trim(), apiKeyEnvVar: envVar.trim() }]); + setName(''); + setBaseUrl('https://api.deepseek.com'); + setModel(''); + setEnvVar(''); + } + + return ( + <> + {presets.length > 0 && ( +
+ {presets.map((p) => ( +
+
+ {p.name} +

+ {p.model} · {p.baseUrl} · reads {p.apiKeyEnvVar} +

+
+ +
+ ))} +
+ )} +
+
+ setName(e.target.value)} placeholder="Name, e.g. DeepSeek (org)" + className="flex-1 min-w-[160px] h-8 rounded border border-input bg-background px-2.5 text-xs" /> + setModel(e.target.value)} placeholder="Model, e.g. deepseek-chat" + className="flex-1 min-w-[160px] h-8 rounded border border-input bg-background px-2.5 text-xs" /> +
+
+ setBaseUrl(e.target.value)} placeholder="API base URL" + className="flex-1 min-w-[200px] h-8 rounded border border-input bg-background px-2.5 text-xs" /> + setEnvVar(e.target.value)} placeholder="Env var, e.g. DEEPSEEK_API_KEY" + className="flex-1 min-w-[200px] h-8 rounded border border-input bg-background px-2.5 text-xs" /> + +
+

+ Only the env var name is stored here — provision the actual key as a real environment variable on + the server (k8s secret, .env, Electron packaging). This app never sees or stores the value. +

+
+ + ); +} + export function AiPolicyTab() { const setActiveTab = useAdminTabStore((s) => s.setActiveTab); const [config, setConfig] = useState({ ...DEFAULT_AI_CONSOLE_CONFIG }); @@ -247,6 +326,17 @@ export function AiPolicyTab() { /> +
+
+

Public — org-managed presets

+

+ Paperclip-style: publish a provider by name instead of making every user paste their own key. Users pick + one of these in Settings with no key field at all — the server resolves the named env var at request time. +

+
+ update({ publicPresets: v })} /> +
+

Entitlement & seats

diff --git a/app/(main)/admin/_tabs/vncdirectory.tsx b/app/(main)/admin/_tabs/vncdirectory.tsx index 911c167d..3cde5fd4 100644 --- a/app/(main)/admin/_tabs/vncdirectory.tsx +++ b/app/(main)/admin/_tabs/vncdirectory.tsx @@ -1,8 +1,10 @@ 'use client'; import { useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; import { Save, Loader2, Plus, X } from 'lucide-react'; import { apiFetch } from '@/lib/browser-navigation'; +import { toast } from '@/stores/toast-store'; interface VncDirectoryFormData { enabled: boolean; @@ -49,6 +51,7 @@ const BLANK_FORM: VncDirectoryFormData = { }; export function VncDirectoryTab() { + const t = useTranslations('admin.vncdirectory'); const [config, setConfig] = useState({ ...BLANK_FORM }); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -105,19 +108,25 @@ export function VncDirectoryTab() { setSaving(true); setMessage(null); - const res = await apiFetch('/api/admin/vncdirectory', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(config), - }); + try { + const res = await apiFetch('/api/admin/vncdirectory', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config), + }); - if (res.ok) { - setMessage({ type: 'success', text: 'VNCdirectory configuration saved.' }); - setDirty(false); - await fetchConfig(); - } else { - const data = await res.json(); - setMessage({ type: 'error', text: data.error || 'Failed to save' }); + if (res.ok) { + setMessage({ type: 'success', text: t('saved') }); + setDirty(false); + await fetchConfig(); + } else { + const data = await res.json(); + setMessage({ type: 'error', text: data.error || t('save_error') }); + } + } catch (err) { + const msg = err instanceof Error ? err.message : t('save_error'); + setMessage({ type: 'error', text: msg }); + toast.error(msg); } setSaving(false); } @@ -125,7 +134,7 @@ export function VncDirectoryTab() { if (loading) { return (
- Loading... + {t('loading')}
); } @@ -136,9 +145,9 @@ export function VncDirectoryTab() {
-

VNCdirectory

+

{t('title')}

- Centralized identity and directory integration (SAML, LDAP, 2FA) + {t('description')}

{dirty && ( @@ -148,7 +157,7 @@ export function VncDirectoryTab() { className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm" > {saving ? : } - Save configuration + {t('save')} )}
@@ -165,12 +174,12 @@ export function VncDirectoryTab() {
)} -
+
- Enabled + {t('enabled')}

- Turn on VNCdirectory integration for identity management, SSO, and directory services + {t('enabled_description')}