HIGH fixes (7): - H1: VNCdirectory admin i18n — 30+ translation keys added - H2: handleSave try/catch with error toast - H3: Free/busy accountId scoping - H4: cancelEventBookings filter by eventId - H5: Resource picker static apiFetch import - H6: Sharing-store toast messages via lastMessage state - H7: roleLabel for all resource types MEDIUM fixes (11): - M1: identitySignatureMap cleanup on delete - M2: Now-line relative positioning - M3: Radial menu disabled item keyboard nav - M4: Radial menu stable event listener via refs - M5: cancelBooking error on missing booking - M6: PasswordRow isMasked state flag - M7: Extract shared rights into lib/sharing-rights.ts - M8: VNCtalk client server-side guard - M9: Collabora configManager instead of process.env - M10: CONFIG_ENV_MAP VNCdirectory fields - M11: SENSITIVE_CONFIG_KEYS field name unification LOW fixes (7): - L1-L3: Unused imports removed - L4: aria-labels on close, clear, search, spinner - L5-L7: Comments for intentional patterns, null guard
639 lines
21 KiB
TypeScript
639 lines
21 KiB
TypeScript
'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;
|
|
apiUrl: string;
|
|
apiKey: string;
|
|
samlEnabled: boolean;
|
|
samlIdpUrl: string;
|
|
samlSpCert: string;
|
|
samlIssuer: string;
|
|
ldapEnabled: boolean;
|
|
ldapUri: string;
|
|
ldapBindDn: string;
|
|
ldapBindPassword: string;
|
|
ldapSearchBase: string;
|
|
ldapType: 'openldap' | 'ms-ad';
|
|
tfaEnabled: boolean;
|
|
oidcEnabled: boolean;
|
|
oidcClientId: string;
|
|
oidcDiscoveryUrl: string;
|
|
sessionTtl: number;
|
|
federatedApps: Record<string, string>;
|
|
}
|
|
|
|
const BLANK_FORM: VncDirectoryFormData = {
|
|
enabled: false,
|
|
apiUrl: '',
|
|
apiKey: '',
|
|
samlEnabled: false,
|
|
samlIdpUrl: '',
|
|
samlSpCert: '',
|
|
samlIssuer: '',
|
|
ldapEnabled: false,
|
|
ldapUri: '',
|
|
ldapBindDn: '',
|
|
ldapBindPassword: '',
|
|
ldapSearchBase: '',
|
|
ldapType: 'openldap',
|
|
tfaEnabled: false,
|
|
oidcEnabled: false,
|
|
oidcClientId: '',
|
|
oidcDiscoveryUrl: '',
|
|
sessionTtl: 28800,
|
|
federatedApps: {},
|
|
};
|
|
|
|
export function VncDirectoryTab() {
|
|
const t = useTranslations('admin.vncdirectory');
|
|
const [config, setConfig] = useState<VncDirectoryFormData>({ ...BLANK_FORM });
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
|
const [dirty, setDirty] = useState(false);
|
|
|
|
useEffect(() => { fetchConfig(); }, []);
|
|
|
|
async function fetchConfig() {
|
|
setLoading(true);
|
|
try {
|
|
const res = await apiFetch('/api/admin/vncdirectory');
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setConfig(data);
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
function updateField<K extends keyof VncDirectoryFormData>(key: K, value: VncDirectoryFormData[K]) {
|
|
setConfig((prev) => ({ ...prev, [key]: value }));
|
|
setDirty(true);
|
|
setMessage(null);
|
|
}
|
|
|
|
function toggleBool(key: keyof VncDirectoryFormData) {
|
|
setConfig((prev) => ({ ...prev, [key]: !prev[key] }));
|
|
setDirty(true);
|
|
setMessage(null);
|
|
}
|
|
|
|
function setFederatedApp(name: string, url: string) {
|
|
setConfig((prev) => ({
|
|
...prev,
|
|
federatedApps: { ...prev.federatedApps, [name]: url },
|
|
}));
|
|
setDirty(true);
|
|
setMessage(null);
|
|
}
|
|
|
|
function removeFederatedApp(name: string) {
|
|
setConfig((prev) => {
|
|
const next = { ...prev.federatedApps };
|
|
delete next[name];
|
|
return { ...prev, federatedApps: next };
|
|
});
|
|
setDirty(true);
|
|
setMessage(null);
|
|
}
|
|
|
|
async function handleSave() {
|
|
setSaving(true);
|
|
setMessage(null);
|
|
|
|
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: 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);
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
|
{t('loading')}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const federatedAppsList = Object.entries(config.federatedApps);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
{t('description')}
|
|
</p>
|
|
</div>
|
|
{dirty && (
|
|
<button
|
|
onClick={handleSave}
|
|
disabled={saving}
|
|
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 ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
|
{t('save')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{message && (
|
|
<div
|
|
className={`text-sm rounded-md px-3 py-2 ${
|
|
message.type === 'success'
|
|
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300'
|
|
: 'bg-destructive/10 text-destructive'
|
|
}`}
|
|
>
|
|
{message.text}
|
|
</div>
|
|
)}
|
|
|
|
<Section title={t('enable_section')}>
|
|
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
|
<div className="min-w-0">
|
|
<span className="text-sm text-foreground">{t('enabled')}</span>
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
{t('enabled_description')}
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => toggleBool('enabled')}
|
|
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
|
config.enabled
|
|
? 'bg-primary'
|
|
: 'bg-muted-foreground/25 dark:bg-muted-foreground/50'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${
|
|
config.enabled ? 'translate-x-[18px]' : 'translate-x-[3px]'
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
</Section>
|
|
|
|
{config.enabled && (
|
|
<>
|
|
<Section title={t('connection')}>
|
|
<div className="divide-y divide-border">
|
|
<TextRow
|
|
label={t('url')}
|
|
value={config.apiUrl}
|
|
onChange={(v) => updateField('apiUrl', v)}
|
|
placeholder={t('url_placeholder')}
|
|
/>
|
|
<PasswordRow
|
|
label={t('api_key')}
|
|
value={config.apiKey}
|
|
onChange={(v) => updateField('apiKey', v)}
|
|
placeholder={t('api_key_placeholder')}
|
|
/>
|
|
</div>
|
|
</Section>
|
|
|
|
<Section title={t('saml')}>
|
|
<div className="divide-y divide-border">
|
|
<ToggleRow
|
|
label={t('saml_enabled')}
|
|
description={t('saml_enabled_description')}
|
|
value={config.samlEnabled}
|
|
onChange={() => toggleBool('samlEnabled')}
|
|
/>
|
|
{config.samlEnabled && (
|
|
<>
|
|
<TextRow
|
|
label={t('idp_url')}
|
|
value={config.samlIdpUrl}
|
|
onChange={(v) => updateField('samlIdpUrl', v)}
|
|
placeholder={t('idp_url_placeholder')}
|
|
/>
|
|
<TextRow
|
|
label={t('issuer')}
|
|
value={config.samlIssuer}
|
|
onChange={(v) => updateField('samlIssuer', v)}
|
|
placeholder={t('issuer_placeholder')}
|
|
/>
|
|
<div className="px-4 py-3 flex flex-col gap-2">
|
|
<label className="text-sm text-foreground">
|
|
{t('sp_cert')}
|
|
</label>
|
|
<textarea
|
|
value={config.samlSpCert}
|
|
onChange={(e) => updateField('samlSpCert', e.target.value)}
|
|
placeholder={t('sp_cert_placeholder')}
|
|
rows={4}
|
|
className="w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-sm font-mono text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-vertical"
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</Section>
|
|
|
|
<Section title={t('ldap')}>
|
|
<div className="divide-y divide-border">
|
|
<ToggleRow
|
|
label={t('ldap_enabled')}
|
|
description={t('ldap_enabled_description')}
|
|
value={config.ldapEnabled}
|
|
onChange={() => toggleBool('ldapEnabled')}
|
|
/>
|
|
{config.ldapEnabled && (
|
|
<>
|
|
<TextRow
|
|
label={t('ldap_uri')}
|
|
value={config.ldapUri}
|
|
onChange={(v) => updateField('ldapUri', v)}
|
|
placeholder={t('ldap_uri_placeholder')}
|
|
/>
|
|
<TextRow
|
|
label={t('bind_dn')}
|
|
value={config.ldapBindDn}
|
|
onChange={(v) => updateField('ldapBindDn', v)}
|
|
placeholder={t('bind_dn_placeholder')}
|
|
/>
|
|
<PasswordRow
|
|
label={t('bind_password')}
|
|
value={config.ldapBindPassword}
|
|
onChange={(v) => updateField('ldapBindPassword', v)}
|
|
placeholder={t('bind_password_placeholder')}
|
|
/>
|
|
<TextRow
|
|
label={t('search_base')}
|
|
value={config.ldapSearchBase}
|
|
onChange={(v) => updateField('ldapSearchBase', v)}
|
|
placeholder={t('search_base_placeholder')}
|
|
/>
|
|
<SelectRow
|
|
label={t('ldap_type')}
|
|
value={config.ldapType}
|
|
options={[
|
|
{ value: 'openldap', label: t('ldap_type_openldap') },
|
|
{ value: 'ms-ad', label: t('ldap_type_msad') },
|
|
]}
|
|
onChange={(v) => updateField('ldapType', v as 'openldap' | 'ms-ad')}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
</Section>
|
|
|
|
<Section title={t('auth_section')}>
|
|
<div className="divide-y divide-border">
|
|
<ToggleRow
|
|
label={t('require_2fa')}
|
|
description={t('require_2fa_description')}
|
|
value={config.tfaEnabled}
|
|
onChange={() => toggleBool('tfaEnabled')}
|
|
/>
|
|
<ToggleRow
|
|
label={t('oidc_section')}
|
|
description={t('oidc_section_description')}
|
|
value={config.oidcEnabled}
|
|
onChange={() => toggleBool('oidcEnabled')}
|
|
/>
|
|
{config.oidcEnabled && (
|
|
<>
|
|
<TextRow
|
|
label={t('oidc_client_id')}
|
|
value={config.oidcClientId}
|
|
onChange={(v) => updateField('oidcClientId', v)}
|
|
placeholder={t('oidc_client_id_placeholder')}
|
|
/>
|
|
<TextRow
|
|
label={t('oidc_discovery_url')}
|
|
value={config.oidcDiscoveryUrl}
|
|
onChange={(v) => updateField('oidcDiscoveryUrl', v)}
|
|
placeholder={t('oidc_discovery_url_placeholder')}
|
|
/>
|
|
</>
|
|
)}
|
|
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
|
<div className="min-w-0">
|
|
<span className="text-sm text-foreground">{t('session_ttl')}</span>
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
{t('session_ttl_description')}
|
|
</p>
|
|
</div>
|
|
<input
|
|
type="number"
|
|
min={0}
|
|
value={config.sessionTtl}
|
|
onChange={(e) => updateField('sessionTtl', Number(e.target.value))}
|
|
className="h-8 w-full sm:w-32 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Section>
|
|
|
|
<Section title={t('federated')}>
|
|
<div className="px-4 py-3">
|
|
<p className="text-xs text-muted-foreground mb-3">
|
|
{t('federated_description')}
|
|
</p>
|
|
<div className="space-y-2">
|
|
{federatedAppsList.map(([appName, url]) => (
|
|
<div
|
|
key={appName}
|
|
className="flex flex-col sm:flex-row items-start sm:items-center gap-2"
|
|
>
|
|
<input
|
|
type="text"
|
|
value={appName}
|
|
readOnly
|
|
className="h-8 w-full sm:w-36 rounded-md border border-input bg-muted/50 px-2.5 text-sm text-muted-foreground"
|
|
/>
|
|
<input
|
|
type="url"
|
|
value={url}
|
|
onChange={(e) => setFederatedApp(appName, e.target.value)}
|
|
placeholder={t('app_url_placeholder')}
|
|
className="h-8 w-full sm:flex-1 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
/>
|
|
<button
|
|
onClick={() => removeFederatedApp(appName)}
|
|
className="shrink-0 text-muted-foreground hover:text-destructive transition-colors"
|
|
title={t('remove_app', { name: appName })}
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
<AddFederatedApp
|
|
existingKeys={new Set(Object.keys(config.federatedApps))}
|
|
onAdd={(name, url) => setFederatedApp(name, url)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Section>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AddFederatedApp({
|
|
existingKeys,
|
|
onAdd,
|
|
}: {
|
|
existingKeys: Set<string>;
|
|
onAdd: (name: string, url: string) => void;
|
|
}) {
|
|
const t = useTranslations('admin.vncdirectory');
|
|
const [adding, setAdding] = useState(false);
|
|
const [name, setName] = useState('');
|
|
const [url, setUrl] = useState('');
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
if (!adding) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={() => setAdding(true)}
|
|
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-md border border-dashed border-input text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
|
>
|
|
<Plus className="w-3.5 h-3.5" />
|
|
{t('add_app')}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function handleAdd() {
|
|
const trimmed = name.trim();
|
|
if (!trimmed) {
|
|
setError(t('app_name_error'));
|
|
return;
|
|
}
|
|
if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
|
|
setError(t('app_name_format_error'));
|
|
return;
|
|
}
|
|
if (existingKeys.has(trimmed)) {
|
|
setError(t('app_exists_error'));
|
|
return;
|
|
}
|
|
if (!url.trim()) {
|
|
setError(t('app_url_error'));
|
|
return;
|
|
}
|
|
setError(null);
|
|
onAdd(trimmed, url.trim());
|
|
setName('');
|
|
setUrl('');
|
|
setAdding(false);
|
|
}
|
|
|
|
function handleCancel() {
|
|
setAdding(false);
|
|
setName('');
|
|
setUrl('');
|
|
setError(null);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-1.5">
|
|
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-2">
|
|
<input
|
|
type="text"
|
|
autoFocus
|
|
value={name}
|
|
onChange={(e) => { setName(e.target.value); setError(null); }}
|
|
onKeyDown={(e) => { if (e.key === 'Enter') handleAdd(); }}
|
|
placeholder={t('app_name_placeholder')}
|
|
className="h-8 w-full sm:w-36 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
/>
|
|
<input
|
|
type="url"
|
|
value={url}
|
|
onChange={(e) => { setUrl(e.target.value); setError(null); }}
|
|
onKeyDown={(e) => { if (e.key === 'Enter') handleAdd(); }}
|
|
placeholder={t('app_url_placeholder')}
|
|
className="h-8 w-full sm:flex-1 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
/>
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
<button
|
|
type="button"
|
|
onClick={handleAdd}
|
|
className="inline-flex items-center h-8 px-3 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
|
|
>
|
|
{t('add')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleCancel}
|
|
className="h-8 px-2.5 rounded-md text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
{t('cancel')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{error && <span className="text-xs text-destructive">{error}</span>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
|
return (
|
|
<div className="border border-border rounded-lg">
|
|
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
|
<h2 className="text-sm font-medium text-foreground">{title}</h2>
|
|
</div>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TextRow({
|
|
label,
|
|
value,
|
|
onChange,
|
|
placeholder,
|
|
}: {
|
|
label: string;
|
|
value: string;
|
|
onChange: (v: string) => void;
|
|
placeholder?: string;
|
|
}) {
|
|
return (
|
|
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
|
<span className="text-sm text-foreground">{label}</span>
|
|
<input
|
|
type="text"
|
|
value={value ?? ''}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
placeholder={placeholder}
|
|
className="h-8 w-full sm:w-72 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PasswordRow({
|
|
label,
|
|
value,
|
|
onChange,
|
|
placeholder,
|
|
}: {
|
|
label: string;
|
|
value: string;
|
|
onChange: (v: string) => void;
|
|
placeholder?: string;
|
|
}) {
|
|
const [isMasked, setIsMasked] = useState(value === '••••••');
|
|
|
|
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
|
if (isMasked) {
|
|
onChange(e.target.value);
|
|
setIsMasked(false);
|
|
} else {
|
|
onChange(e.target.value);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
|
<span className="text-sm text-foreground">{label}</span>
|
|
<div className="flex items-center gap-2 w-full sm:w-auto">
|
|
<input
|
|
type={isMasked ? 'text' : 'password'}
|
|
value={value ?? ''}
|
|
onChange={handleChange}
|
|
placeholder={placeholder || (isMasked ? 'Saved - type to replace' : undefined)}
|
|
className="h-8 w-full sm:w-72 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ToggleRow({
|
|
label,
|
|
description,
|
|
value,
|
|
onChange,
|
|
}: {
|
|
label: string;
|
|
description?: string;
|
|
value: boolean;
|
|
onChange: () => void;
|
|
}) {
|
|
return (
|
|
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
|
<div className="min-w-0">
|
|
<span className="text-sm text-foreground">{label}</span>
|
|
{description && (
|
|
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={onChange}
|
|
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
|
value ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${
|
|
value ? 'translate-x-[18px]' : 'translate-x-[3px]'
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SelectRow({
|
|
label,
|
|
value,
|
|
options,
|
|
onChange,
|
|
}: {
|
|
label: string;
|
|
value: string;
|
|
options: { value: string; label: string }[];
|
|
onChange: (v: string) => void;
|
|
}) {
|
|
return (
|
|
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
|
<span className="text-sm text-foreground">{label}</span>
|
|
<select
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
className="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
>
|
|
{options.map((opt) => (
|
|
<option key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
);
|
|
}
|