|
13 | 13 | keyType: CertificateType; |
14 | 14 | /** Certificate type for the CA certificate (optional) */ |
15 | 15 | caType?: CertificateType; |
16 | | - /** Section title */ |
17 | | - title?: string; |
18 | | - /** Optional description text */ |
19 | | - description?: string; |
| 16 | + /** Mode determines certificate requirements - mqtt (CA required) or https (cert+key required) */ |
| 17 | + mode?: 'mqtt' | 'https'; |
20 | 18 | } |
21 | 19 |
|
22 | 20 | let { |
23 | 21 | certType, |
24 | 22 | keyType, |
25 | 23 | caType, |
26 | | - title = 'Current Certificate', |
27 | | - description, |
| 24 | + mode = 'https', |
28 | 25 | }: Props = $props(); |
29 | 26 |
|
| 27 | + let certLabel = $derived(mode === 'mqtt' ? 'Client Certificate' : 'Certificate File'); |
| 28 | + let keyLabel = $derived(mode === 'mqtt' ? 'Client Private Key' : 'Private Key'); |
| 29 | + let caLabel = $derived(mode === 'mqtt' ? 'CA Certificate (Required)' : 'CA Certificate File (Optional)'); |
| 30 | +
|
30 | 31 | let selectedCertFile: File | null = $state(null); |
31 | 32 | let selectedKeyFile: File | null = $state(null); |
32 | 33 | let selectedCaFile: File | null = $state(null); |
|
85 | 86 |
|
86 | 87 | const VALID_CERT_EXTENSIONS = ['.pem', '.crt', '.cer']; |
87 | 88 | const VALID_KEY_EXTENSIONS = ['.pem', '.key']; |
88 | | - const VALID_CA_EXTENSIONS = ['.pem', '.crt', '.cer']; |
89 | 89 |
|
90 | 90 | function isValidCertFile(file: File): boolean { |
91 | 91 | const ext = '.' + file.name.split('.').pop()?.toLowerCase(); |
|
123 | 123 |
|
124 | 124 | function isValidCaFile(file: File): boolean { |
125 | 125 | const ext = '.' + file.name.split('.').pop()?.toLowerCase(); |
126 | | - return VALID_CA_EXTENSIONS.includes(ext); |
| 126 | + return VALID_CERT_EXTENSIONS.includes(ext); |
127 | 127 | } |
128 | 128 |
|
129 | 129 | function handleCaFileChange(event: Event) { |
|
145 | 145 | }); |
146 | 146 |
|
147 | 147 | async function handleUpload() { |
148 | | - if (!selectedCertFile || !selectedKeyFile) { |
149 | | - notifications.addError('Please select both certificate and private key files'); |
150 | | - return; |
| 148 | + if (mode === 'mqtt') { |
| 149 | + if (!selectedCaFile) { |
| 150 | + notifications.addError('Please select a CA certificate file for server verification'); |
| 151 | + return; |
| 152 | + } |
| 153 | + if (selectedCertFile && !selectedKeyFile) { |
| 154 | + notifications.addError('Please select a private key file to match the client certificate'); |
| 155 | + return; |
| 156 | + } |
| 157 | + if (selectedKeyFile && !selectedCertFile) { |
| 158 | + notifications.addError('Please select a client certificate file to match the private key'); |
| 159 | + return; |
| 160 | + } |
| 161 | + } else { |
| 162 | + if (!selectedCertFile || !selectedKeyFile) { |
| 163 | + notifications.addError('Please select both certificate and private key files'); |
| 164 | + return; |
| 165 | + } |
151 | 166 | } |
152 | 167 |
|
153 | 168 | isUploading = true; |
154 | | - uploadProgress.cert = 10; |
155 | | - uploadProgress.key = 10; |
| 169 | + uploadProgress.cert = selectedCertFile ? 10 : 0; |
| 170 | + uploadProgress.key = selectedKeyFile ? 10 : 0; |
| 171 | + uploadProgress.ca = selectedCaFile ? 10 : 0; |
156 | 172 |
|
157 | 173 | try { |
158 | | - const certContent = await selectedCertFile.text(); |
159 | | - uploadProgress.cert = 50; |
160 | | - uploadProgress.key = 50; |
161 | | -
|
162 | | - const keyContent = await selectedKeyFile.text(); |
163 | | -
|
164 | | - const certResult = await uploadCertificate(certType, certContent); |
165 | | - uploadProgress.cert = 90; |
166 | | - uploadProgress.key = 90; |
167 | | -
|
168 | | - const keyResult = await uploadCertificate(keyType, keyContent); |
169 | | -
|
| 174 | + let certResult = { success: true }; |
| 175 | + let keyResult = { success: true }; |
170 | 176 | let caResult = { success: true }; |
| 177 | +
|
171 | 178 | if (selectedCaFile && caType !== undefined) { |
172 | 179 | uploadProgress.ca = 50; |
173 | 180 | const caContent = await selectedCaFile.text(); |
174 | 181 | caResult = await uploadCertificate(caType, caContent); |
175 | 182 | uploadProgress.ca = 90; |
176 | 183 | } |
177 | 184 |
|
| 185 | + if (selectedCertFile) { |
| 186 | + uploadProgress.cert = 50; |
| 187 | + const certContent = await selectedCertFile.text(); |
| 188 | + certResult = await uploadCertificate(certType, certContent); |
| 189 | + uploadProgress.cert = 90; |
| 190 | + } |
| 191 | +
|
| 192 | + if (selectedKeyFile) { |
| 193 | + uploadProgress.key = 50; |
| 194 | + const keyContent = await selectedKeyFile.text(); |
| 195 | + keyResult = await uploadCertificate(keyType, keyContent); |
| 196 | + uploadProgress.key = 90; |
| 197 | + } |
| 198 | +
|
178 | 199 | if (certResult.success && keyResult.success && caResult.success) { |
179 | 200 | uploadProgress.cert = 100; |
180 | 201 | uploadProgress.key = 100; |
|
218 | 239 | return; |
219 | 240 | } |
220 | 241 |
|
221 | | - // Also delete CA cert if it exists |
222 | 242 | if (caType !== undefined) { |
223 | 243 | await deleteCertificate(caType); |
224 | 244 | } |
|
247 | 267 | <!-- Certificate Status Display --> |
248 | 268 | <div class="py-3 px-3 bg-base-100 rounded-lg"> |
249 | 269 | <div class="flex items-center justify-between mb-3"> |
250 | | - <h4 class="text-sm font-medium">{title}</h4> |
| 270 | + <h4 class="text-sm font-medium">Certificates</h4> |
251 | 271 | {#if hasCustomCert} |
252 | 272 | <button |
253 | 273 | type="button" |
|
265 | 285 | {/if} |
266 | 286 | </div> |
267 | 287 |
|
268 | | - {#if description} |
269 | | - <p class="text-xs text-base-content/60 mb-3">{description}</p> |
270 | | - {/if} |
271 | | - |
272 | 288 | {#if certInfo} |
273 | 289 | <div class="space-y-2 text-xs"> |
| 290 | + <div class="flex items-center gap-2"> |
| 291 | + <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-info" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> |
| 292 | + <path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /> |
| 293 | + </svg> |
| 294 | + <span class="font-medium text-info">{mode === "https" ? "Server" : "Client"} Certificate Present</span> |
| 295 | + </div> |
274 | 296 | {#if certInfo.issuer} |
275 | 297 | <div class="flex flex-col"> |
276 | 298 | <span class="text-base-content/60">Issuer</span> |
|
288 | 310 | {#if certInfo.serial} |
289 | 311 | <div class="flex flex-col"> |
290 | 312 | <span class="text-base-content/60">Serial number</span> |
291 | | - <span class="font-mono truncate" title={certInfo.serial}>{certInfo.serial}</span> |
| 313 | + <span class="font-mono break-words" title={certInfo.serial}>{certInfo.serial}</span> |
292 | 314 | </div> |
293 | 315 | {/if} |
294 | 316 |
|
|
358 | 380 | </div> |
359 | 381 | {#if caCertInfo.issuer} |
360 | 382 | <div class="flex flex-col"> |
361 | | - <span class="text-base-content/60">CA Issuer</span> |
| 383 | + <span class="text-base-content/60">Issuer</span> |
362 | 384 | <span class="font-mono truncate" title={caCertInfo.issuer}>{caCertInfo.issuer}</span> |
363 | 385 | </div> |
364 | 386 | {/if} |
365 | 387 | {#if caCertInfo.subject} |
366 | 388 | <div class="flex flex-col"> |
367 | | - <span class="text-base-content/60">CA Subject</span> |
| 389 | + <span class="text-base-content/60">Subject</span> |
368 | 390 | <span class="font-mono truncate" title={caCertInfo.subject}>{caCertInfo.subject}</span> |
369 | 391 | </div> |
370 | 392 | {/if} |
| 393 | + {#if caCertInfo.serial} |
| 394 | + <div class="flex flex-col"> |
| 395 | + <span class="text-base-content/60">Serial number</span> |
| 396 | + <span class="font-mono break-words" title={caCertInfo.serial}>{caCertInfo.serial}</span> |
| 397 | + </div> |
| 398 | + {/if} |
| 399 | + |
| 400 | + {#if caCertInfo.fingerprint} |
| 401 | + <div class="flex flex-col"> |
| 402 | + <span class="text-base-content/60">SHA256 Fingerprint</span> |
| 403 | + <span class="font-mono break-words" title={caCertInfo.fingerprint}>{caCertInfo.fingerprint}</span> |
| 404 | + </div> |
| 405 | + {/if} |
371 | 406 | {#if caCertInfo.expiration?.from && caCertInfo.expiration?.to} |
372 | 407 | <div class="flex flex-col"> |
373 | | - <span class="text-base-content/60">CA Valid Period</span> |
| 408 | + <span class="text-base-content/60">Valid Period</span> |
374 | 409 | <span class="font-mono"> |
375 | 410 | {formatDate(caCertInfo.expiration.from)} - {formatDate(caCertInfo.expiration.to)} |
376 | 411 | </span> |
|
420 | 455 | <!-- File Upload Section --> |
421 | 456 | <div class="space-y-3 py-2 px-3 bg-base-100 rounded-lg"> |
422 | 457 | <h4 class="text-sm font-medium">Upload Certificate</h4> |
423 | | - <p class="text-xs text-base-content/60">Upload PEM-encoded certificate, private key, and optional CA certificate (for mTLS).</p> |
424 | | - |
| 458 | + <!-- Help Text --> |
| 459 | + <div class="border-l-4 border-info bg-gradient-to-r from-info/10 to-transparent rounded-r-lg p-3 shadow-sm"> |
| 460 | + <div class="flex items-start gap-2"> |
| 461 | + <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-info mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> |
| 462 | + <path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> |
| 463 | + </svg> |
| 464 | + <div class="space-y-1.5"> |
| 465 | + <p class="text-xs font-semibold text-base-content">Certificate Requirements</p> |
| 466 | + <ul class="text-xs text-base-content/75 list-disc list-inside space-y-0.5"> |
| 467 | + <li>Must be in PEM format</li> |
| 468 | + <li>Supported keys: RSA (2048+ bits) and SECP256R1 (P-256)</li> |
| 469 | + {#if mode === 'mqtt'} |
| 470 | + <li><strong>CA certificate</strong> is required for verifying the MQTT broker's identity</li> |
| 471 | + <li><strong>Client certificate + key</strong> are only needed for mutual TLS (mTLS) authentication</li> |
| 472 | + {:else} |
| 473 | + <li><strong>Server certificate + private key</strong> are required for HTTPS operation</li> |
| 474 | + <li><strong>CA certificate</strong> is only needed if requiring client certificate validation (mTLS)</li> |
| 475 | + {/if} |
| 476 | + </ul> |
| 477 | + </div> |
| 478 | + </div> |
| 479 | + </div> |
425 | 480 | <div class="grid grid-cols-1 md:grid-cols-2 gap-3"> |
426 | 481 | <!-- Certificate File Input --> |
427 | 482 | <div class="form-control"> |
428 | | - <label class="label" for="https-cert-file"> |
429 | | - <span class="label-text text-xs">Certificate File (.pem, .crt, .cer)</span> |
| 483 | + <label class="label" for="cert-file-input"> |
| 484 | + <span class="label-text text-xs">{certLabel} (.pem, .crt, .cer){mode === 'mqtt' ? ' - Optional' : ''}</span> |
430 | 485 | </label> |
431 | 486 | <input |
432 | | - id="https-cert-file" |
| 487 | + id="cert-file-input" |
433 | 488 | type="file" |
434 | 489 | accept={VALID_CERT_EXTENSIONS.join(",")} |
435 | 490 | onchange={handleCertFileChange} |
436 | 491 | bind:this={certInputRef} |
437 | 492 | class="file-input file-input-sm file-input-bordered w-full text-xs" |
| 493 | + disabled={isUploading} |
438 | 494 | /> |
439 | 495 | {#if selectedCertFile} |
440 | 496 | <p class="text-xs text-success mt-1">Selected: {selectedCertFile.name}</p> |
|
446 | 502 |
|
447 | 503 | <!-- Private Key File Input --> |
448 | 504 | <div class="form-control"> |
449 | | - <label class="label" for="https-key-file"> |
450 | | - <span class="label-text text-xs">Private Key File (.pem, .key)</span> |
| 505 | + <label class="label" for="key-file-input"> |
| 506 | + <span class="label-text text-xs">{keyLabel} (.pem, .key){mode === 'mqtt' ? ' - Optional' : ''}</span> |
451 | 507 | </label> |
452 | 508 | <input |
453 | | - id="https-key-file" |
| 509 | + id="key-file-input" |
454 | 510 | type="file" |
455 | 511 | accept={VALID_KEY_EXTENSIONS.join(",")} |
456 | 512 | onchange={handleKeyFileChange} |
457 | 513 | bind:this={keyInputRef} |
458 | 514 | class="file-input file-input-sm file-input-bordered w-full text-xs" |
| 515 | + disabled={isUploading} |
459 | 516 | /> |
460 | 517 | {#if selectedKeyFile} |
461 | 518 | <p class="text-xs text-success mt-1">Selected: {selectedKeyFile.name}</p> |
|
466 | 523 | </div> |
467 | 524 | </div> |
468 | 525 |
|
469 | | - <!-- CA Certificate File Input --> |
| 526 | + <!-- CA Certificate File Input - Full width for both modes --> |
470 | 527 | <div class="form-control mt-3"> |
471 | | - <label class="label" for="https-ca-file"> |
472 | | - <span class="label-text text-xs">CA Certificate File (.pem, .crt, .cer) - Optional</span> |
| 528 | + <label class="label" for="ca-file-input"> |
| 529 | + <span class="label-text text-xs">{caLabel} (.pem, .crt, .cer)</span> |
473 | 530 | </label> |
474 | 531 | <input |
475 | | - id="https-ca-file" |
| 532 | + id="ca-file-input" |
476 | 533 | type="file" |
477 | | - accept={VALID_CA_EXTENSIONS.join(",")} |
| 534 | + accept={VALID_CERT_EXTENSIONS.join(",")} |
478 | 535 | onchange={handleCaFileChange} |
479 | 536 | bind:this={caInputRef} |
480 | 537 | class="file-input file-input-sm file-input-bordered w-full text-xs" |
| 538 | + disabled={isUploading} |
481 | 539 | /> |
482 | 540 | {#if selectedCaFile} |
483 | 541 | <p class="text-xs text-success mt-1">Selected: {selectedCaFile.name}</p> |
|
491 | 549 | <button |
492 | 550 | type="button" |
493 | 551 | class="btn btn-sm btn-primary" |
494 | | - disabled={!selectedCertFile || !selectedKeyFile || isUploading} |
| 552 | + disabled={isUploading || (mode === 'mqtt' ? !selectedCaFile : (!selectedCertFile || !selectedKeyFile))} |
495 | 553 | onclick={handleUpload} |
496 | 554 | > |
497 | 555 | {#if isUploading} |
|
505 | 563 | {/if} |
506 | 564 | </button> |
507 | 565 | </div> |
508 | | - |
509 | | - <!-- Help Text --> |
510 | | - <div class="text-xs text-base-content/60 py-2 px-3 bg-base-100 rounded-lg"> |
511 | | - <ul class="list-disc list-inside space-y-1"> |
512 | | - <li>Certificate must be in PEM format</li> |
513 | | - <li>Supported keys: RSA (2048+ bits) and SECP256R1</li> |
514 | | - <li>CA certificate is optional and only used for client certificate validation (mTLS)</li> |
515 | | - </ul> |
516 | | - </div> |
517 | 566 | </div> |
0 commit comments