feat: use FileInput DS component for file uploads, fix certs volume perms
All checks were successful
CI / build (push) Successful in 1m24s
CI / docker (push) Successful in 1m12s

- Replace inline FileField and native <input type="file"> with
  FileInput from @cameleer/design-system (drag-and-drop, icons, clear)
- Update CertificatesPage and SsoPage to use FileInput + FormField
- Fix /certs volume permissions (chmod 775) so cameleer user can write

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-11 08:04:47 +02:00
parent 4fdf171912
commit 875b07fb3a
5 changed files with 39 additions and 71 deletions

View File

@@ -51,6 +51,8 @@ services:
{"subject":"$$SUBJECT","fingerprint":"$$FINGERPRINT","selfSigned":$$SELF_SIGNED,"hasCa":$$HAS_CA,"notBefore":"$$NOT_BEFORE","notAfter":"$$NOT_AFTER"} {"subject":"$$SUBJECT","fingerprint":"$$FINGERPRINT","selfSigned":$$SELF_SIGNED,"hasCa":$$HAS_CA,"notBefore":"$$NOT_BEFORE","notAfter":"$$NOT_AFTER"}
METAEOF METAEOF
mkdir -p /certs/staged /certs/prev mkdir -p /certs/staged /certs/prev
chmod 775 /certs /certs/staged /certs/prev
chmod 660 /certs/*.pem 2>/dev/null || true
environment: environment:
PUBLIC_HOST: ${PUBLIC_HOST:-localhost} PUBLIC_HOST: ${PUBLIC_HOST:-localhost}
CERT_FILE: ${CERT_FILE:-} CERT_FILE: ${CERT_FILE:-}

8
ui/package-lock.json generated
View File

@@ -9,7 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@cameleer/design-system": "^0.1.42", "@cameleer/design-system": "^0.0.0-snapshot.20260411.fd08d7a",
"@logto/react": "^4.0.13", "@logto/react": "^4.0.13",
"@tanstack/react-query": "^5.90.0", "@tanstack/react-query": "^5.90.0",
"lucide-react": "^1.7.0", "lucide-react": "^1.7.0",
@@ -309,9 +309,9 @@
} }
}, },
"node_modules/@cameleer/design-system": { "node_modules/@cameleer/design-system": {
"version": "0.1.42", "version": "0.0.0-snapshot.20260411.fd08d7a",
"resolved": "https://gitea.siegeln.net/api/packages/cameleer/npm/%40cameleer%2Fdesign-system/-/0.1.42/design-system-0.1.42.tgz", "resolved": "https://gitea.siegeln.net/api/packages/cameleer/npm/%40cameleer%2Fdesign-system/-/0.0.0-snapshot.20260411.fd08d7a/design-system-0.0.0-snapshot.20260411.fd08d7a.tgz",
"integrity": "sha512-Cyy+2HsbBPLKRZaSGFxMUvIwI+g8ocdjcojFTGgtq5vrpE/8IYJLgxdtM9+eDoF2Zewk7MrBzfpNqjEYlQO3ng==", "integrity": "sha512-ehTXpF6U7bZpvqwj/aaZxdD75sDB8zpn1QVQMDzVUmQGH6RMf9O261DiV1agsOSqPNUB9UPVcdzevMo8ooWTFg==",
"dependencies": { "dependencies": {
"lucide-react": "^1.7.0", "lucide-react": "^1.7.0",
"react": "^19.0.0", "react": "^19.0.0",

View File

@@ -10,7 +10,7 @@
"postinstall": "node -e \"const fs=require('fs'),p='node_modules/@cameleer/design-system/assets/';if(fs.existsSync('public')){fs.copyFileSync(p+'cameleer3-logo.svg','public/favicon.svg')}\"" "postinstall": "node -e \"const fs=require('fs'),p='node_modules/@cameleer/design-system/assets/';if(fs.existsSync('public')){fs.copyFileSync(p+'cameleer3-logo.svg','public/favicon.svg')}\""
}, },
"dependencies": { "dependencies": {
"@cameleer/design-system": "^0.1.42", "@cameleer/design-system": "^0.0.0-snapshot.20260411.fd08d7a",
"@logto/react": "^4.0.13", "@logto/react": "^4.0.13",
"@tanstack/react-query": "^5.90.0", "@tanstack/react-query": "^5.90.0",
"lucide-react": "^1.7.0", "lucide-react": "^1.7.0",

View File

@@ -1,9 +1,9 @@
import { useState, useRef } from 'react'; import { useState, useRef } from 'react';
import { import {
Alert, AlertDialog, Badge, Button, Card, DataTable, Alert, AlertDialog, Badge, Button, Card, DataTable,
EmptyState, FormField, Input, Spinner, useToast, EmptyState, FileInput, FormField, Input, Spinner, useToast,
} from '@cameleer/design-system'; } from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system'; import type { Column, FileInputHandle } from '@cameleer/design-system';
import { Shield, Plus, Trash2, FlaskConical, Upload, ShieldCheck } from 'lucide-react'; import { Shield, Plus, Trash2, FlaskConical, Upload, ShieldCheck } from 'lucide-react';
import { import {
useSsoConnectors, useCreateSsoConnector, useDeleteSsoConnector, useTestSsoConnector, useSsoConnectors, useCreateSsoConnector, useDeleteSsoConnector, useTestSsoConnector,
@@ -331,12 +331,12 @@ function CaCertificatesSection() {
const deleteMutation = useDeleteCaCert(); const deleteMutation = useDeleteCaCert();
const { toast } = useToast(); const { toast } = useToast();
const certInputRef = useRef<HTMLInputElement>(null); const certRef = useRef<FileInputHandle>(null);
const [label, setLabel] = useState(''); const [label, setLabel] = useState('');
const [deleteTarget, setDeleteTarget] = useState<CaCertResponse | null>(null); const [deleteTarget, setDeleteTarget] = useState<CaCertResponse | null>(null);
async function handleUpload() { async function handleUpload() {
const file = certInputRef.current?.files?.[0]; const file = certRef.current?.file;
if (!file) { if (!file) {
toast({ title: 'Select a CA certificate file', variant: 'error' }); toast({ title: 'Select a CA certificate file', variant: 'error' });
return; return;
@@ -348,7 +348,7 @@ function CaCertificatesSection() {
try { try {
await stageMutation.mutateAsync(formData); await stageMutation.mutateAsync(formData);
toast({ title: 'CA certificate staged', variant: 'success' }); toast({ title: 'CA certificate staged', variant: 'success' });
if (certInputRef.current) certInputRef.current.value = ''; certRef.current?.clear();
setLabel(''); setLabel('');
} catch (err) { } catch (err) {
toast({ title: 'Upload failed', description: String(err), variant: 'error' }); toast({ title: 'Upload failed', description: String(err), variant: 'error' });
@@ -409,21 +409,9 @@ function CaCertificatesSection() {
onChange={(e) => setLabel(e.target.value)} onChange={(e) => setLabel(e.target.value)}
/> />
</div> </div>
<div> <FormField label="CA Certificate (PEM) *">
<label style={{ fontSize: 13, color: 'var(--text-secondary)', display: 'block', marginBottom: 4 }}> <FileInput ref={certRef} accept=".pem,.crt,.cer" icon={<ShieldCheck size={16} />} />
CA Certificate (PEM) * </FormField>
</label>
<input
ref={certInputRef}
type="file"
accept=".pem,.crt,.cer"
style={{
fontSize: 13, color: 'var(--text-primary)',
background: 'var(--bg-inset)', border: '1px solid var(--border)',
borderRadius: 6, padding: '6px 8px', width: '100%',
}}
/>
</div>
<Button variant="primary" onClick={handleUpload} loading={stageMutation.isPending}> <Button variant="primary" onClick={handleUpload} loading={stageMutation.isPending}>
<Upload size={14} style={{ marginRight: 6 }} /> <Upload size={14} style={{ marginRight: 6 }} />
Stage Certificate Stage Certificate

View File

@@ -4,11 +4,14 @@ import {
Badge, Badge,
Button, Button,
Card, Card,
FileInput,
FormField,
Input, Input,
Spinner, Spinner,
useToast, useToast,
} from '@cameleer/design-system'; } from '@cameleer/design-system';
import { Upload, ShieldCheck, RotateCcw, Trash2 } from 'lucide-react'; import type { FileInputHandle } from '@cameleer/design-system';
import { Upload, ShieldCheck, RotateCcw, Trash2, FileKey, KeyRound, ShieldPlus } from 'lucide-react';
import { import {
useVendorCertificates, useVendorCertificates,
useStageCertificate, useStageCertificate,
@@ -101,34 +104,6 @@ function CertCard({
); );
} }
function FileField({ label, inputRef, accept }: {
label: string;
inputRef: React.RefObject<HTMLInputElement | null>;
accept: string;
}) {
return (
<div>
<label style={{ fontSize: 13, color: 'var(--text-secondary)', display: 'block', marginBottom: 4 }}>
{label}
</label>
<input
ref={inputRef}
type="file"
accept={accept}
style={{
fontSize: 13,
color: 'var(--text-primary)',
background: 'var(--bg-inset)',
border: '1px solid var(--border)',
borderRadius: 6,
padding: '6px 8px',
width: '100%',
}}
/>
</div>
);
}
export function CertificatesPage() { export function CertificatesPage() {
const { toast } = useToast(); const { toast } = useToast();
const { data, isLoading, isError } = useVendorCertificates(); const { data, isLoading, isError } = useVendorCertificates();
@@ -137,9 +112,9 @@ export function CertificatesPage() {
const restoreMutation = useRestoreCertificate(); const restoreMutation = useRestoreCertificate();
const discardMutation = useDiscardStaged(); const discardMutation = useDiscardStaged();
const certInputRef = useRef<HTMLInputElement>(null); const certRef = useRef<FileInputHandle>(null);
const keyInputRef = useRef<HTMLInputElement>(null); const keyRef = useRef<FileInputHandle>(null);
const caInputRef = useRef<HTMLInputElement>(null); const caRef = useRef<FileInputHandle>(null);
const [keyPassword, setKeyPassword] = useState(''); const [keyPassword, setKeyPassword] = useState('');
if (isLoading) { if (isLoading) {
@@ -161,9 +136,9 @@ export function CertificatesPage() {
} }
async function handleUpload() { async function handleUpload() {
const certFile = certInputRef.current?.files?.[0]; const certFile = certRef.current?.file;
const keyFile = keyInputRef.current?.files?.[0]; const keyFile = keyRef.current?.file;
const caFile = caInputRef.current?.files?.[0]; const caFile = caRef.current?.file;
if (!certFile || !keyFile) { if (!certFile || !keyFile) {
toast({ title: 'Certificate and key files are required', variant: 'error' }); toast({ title: 'Certificate and key files are required', variant: 'error' });
@@ -180,9 +155,9 @@ export function CertificatesPage() {
const result = await stageMutation.mutateAsync(formData); const result = await stageMutation.mutateAsync(formData);
if (result.valid) { if (result.valid) {
toast({ title: 'Certificate staged successfully', variant: 'success' }); toast({ title: 'Certificate staged successfully', variant: 'success' });
if (certInputRef.current) certInputRef.current.value = ''; certRef.current?.clear();
if (keyInputRef.current) keyInputRef.current.value = ''; keyRef.current?.clear();
if (caInputRef.current) caInputRef.current.value = ''; caRef.current?.clear();
setKeyPassword(''); setKeyPassword('');
} else { } else {
toast({ title: 'Validation failed', description: result.errors.join(', '), variant: 'error' }); toast({ title: 'Validation failed', description: result.errors.join(', '), variant: 'error' });
@@ -287,20 +262,23 @@ export function CertificatesPage() {
{/* Upload card */} {/* Upload card */}
<Card title="Upload Certificate"> <Card title="Upload Certificate">
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<FileField label="Certificate (PEM) *" inputRef={certInputRef} accept=".pem,.crt,.cer" /> <FormField label="Certificate (PEM) *">
<FileField label="Private Key (PEM) *" inputRef={keyInputRef} accept=".pem,.key" /> <FileInput ref={certRef} accept=".pem,.crt,.cer" icon={<FileKey size={16} />} />
<div> </FormField>
<label style={{ fontSize: 13, color: 'var(--text-secondary)', display: 'block', marginBottom: 4 }}> <FormField label="Private Key (PEM) *">
Key Password (if encrypted) <FileInput ref={keyRef} accept=".pem,.key" icon={<KeyRound size={16} />} />
</label> </FormField>
<FormField label="Key Password (if encrypted)">
<Input <Input
type="password" type="password"
placeholder="Leave empty if key is not encrypted" placeholder="Leave empty if key is not encrypted"
value={keyPassword} value={keyPassword}
onChange={(e) => setKeyPassword(e.target.value)} onChange={(e) => setKeyPassword(e.target.value)}
/> />
</div> </FormField>
<FileField label="CA Bundle (PEM, optional)" inputRef={caInputRef} accept=".pem,.crt,.cer" /> <FormField label="CA Bundle (PEM, optional)">
<FileInput ref={caRef} accept=".pem,.crt,.cer" icon={<ShieldPlus size={16} />} />
</FormField>
<Button <Button
variant="primary" variant="primary"
onClick={handleUpload} onClick={handleUpload}