Support client certificates (#319)

This commit is contained in:
Gregory Schier
2025-12-10 13:54:22 -08:00
committed by GitHub
parent ef1ba9b834
commit c4b559f34b
39 changed files with 1131 additions and 236 deletions

View File

@@ -1,15 +1,19 @@
import { useSearch } from '@tanstack/react-router';
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
import { type } from '@tauri-apps/plugin-os';
import { useLicense } from '@yaakapp-internal/license';
import { pluginsAtom, settingsAtom } from '@yaakapp-internal/models';
import classNames from 'classnames';
import { useAtomValue } from 'jotai';
import { useState } from 'react';
import { useKeyPressEvent } from 'react-use';
import { appInfo } from '../../lib/appInfo';
import { capitalize } from '../../lib/capitalize';
import { CountBadge } from '../core/CountBadge';
import { HStack } from '../core/Stacks';
import type { TabItem } from '../core/Tabs/Tabs';
import { TabContent, Tabs } from '../core/Tabs/Tabs';
import { TabContent, type TabItem, Tabs } from '../core/Tabs/Tabs';
import { HeaderSize } from '../HeaderSize';
import { SettingsCertificates } from './SettingsCertificates';
import { SettingsGeneral } from './SettingsGeneral';
import { SettingsInterface } from './SettingsInterface';
import { SettingsLicense } from './SettingsLicense';
@@ -25,14 +29,26 @@ const TAB_GENERAL = 'general';
const TAB_INTERFACE = 'interface';
const TAB_THEME = 'theme';
const TAB_PROXY = 'proxy';
const TAB_CERTIFICATES = 'certificates';
const TAB_PLUGINS = 'plugins';
const TAB_LICENSE = 'license';
const tabs = [TAB_GENERAL, TAB_THEME, TAB_INTERFACE, TAB_PROXY, TAB_PLUGINS, TAB_LICENSE] as const;
const tabs = [
TAB_GENERAL,
TAB_THEME,
TAB_INTERFACE,
TAB_CERTIFICATES,
TAB_PROXY,
TAB_PLUGINS,
TAB_LICENSE,
] as const;
export type SettingsTab = (typeof tabs)[number];
export default function Settings({ hide }: Props) {
const { tab: tabFromQuery } = useSearch({ from: '/workspaces/$workspaceId/settings' });
const [tab, setTab] = useState<string | undefined>(tabFromQuery);
const settings = useAtomValue(settingsAtom);
const plugins = useAtomValue(pluginsAtom);
const licenseCheck = useLicense();
// Close settings window on escape
// TODO: Could this be put in a better place? Eg. in Rust key listener when creating the window
@@ -79,6 +95,16 @@ export default function Settings({ hide }: Props) {
value,
label: capitalize(value),
hidden: !appInfo.featureLicense && value === TAB_LICENSE,
rightSlot:
value === TAB_CERTIFICATES ? (
<CountBadge count={settings.clientCertificates.length} />
) : value === TAB_PLUGINS ? (
<CountBadge count={plugins.length} />
) : value === TAB_PROXY && settings.proxy?.type === 'enabled' ? (
<CountBadge count />
) : value === TAB_LICENSE && licenseCheck.check.data?.status === 'personal_use' ? (
<CountBadge count color="notice" />
) : null,
}),
)}
>
@@ -97,6 +123,9 @@ export default function Settings({ hide }: Props) {
<TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-6 !py-4">
<SettingsProxy />
</TabContent>
<TabContent value={TAB_CERTIFICATES} className="overflow-y-auto h-full px-6 !py-4">
<SettingsCertificates />
</TabContent>
<TabContent value={TAB_LICENSE} className="overflow-y-auto h-full px-6 !py-4">
<SettingsLicense />
</TabContent>

View File

@@ -0,0 +1,247 @@
import type { ClientCertificate } from '@yaakapp-internal/models';
import { patchModel, settingsAtom } from '@yaakapp-internal/models';
import classNames from 'classnames';
import { useAtomValue } from 'jotai';
import { showConfirmDelete } from '../../lib/confirm';
import { Button } from '../core/Button';
import { Checkbox } from '../core/Checkbox';
import { DetailsBanner } from '../core/DetailsBanner';
import { Heading } from '../core/Heading';
import { IconButton } from '../core/IconButton';
import { InlineCode } from '../core/InlineCode';
import { PlainInput } from '../core/PlainInput';
import { Separator } from '../core/Separator';
import { HStack, VStack } from '../core/Stacks';
import { SelectFile } from '../SelectFile';
function createEmptyCertificate(): ClientCertificate {
return {
host: '',
port: null,
crtFile: null,
keyFile: null,
pfxFile: null,
passphrase: null,
enabled: true,
};
}
interface CertificateEditorProps {
certificate: ClientCertificate;
index: number;
onUpdate: (index: number, cert: ClientCertificate) => void;
onRemove: (index: number) => void;
}
function CertificateEditor({ certificate, index, onUpdate, onRemove }: CertificateEditorProps) {
const updateField = <K extends keyof ClientCertificate>(
field: K,
value: ClientCertificate[K],
) => {
onUpdate(index, { ...certificate, [field]: value });
};
const hasPfx = Boolean(certificate.pfxFile && certificate.pfxFile.length > 0);
const hasCrtKey = Boolean(
(certificate.crtFile && certificate.crtFile.length > 0) ||
(certificate.keyFile && certificate.keyFile.length > 0),
);
// Determine certificate type for display
const certType = hasPfx ? 'PFX' : hasCrtKey ? 'CERT' : null;
return (
<DetailsBanner
summary={
<HStack alignItems="center" justifyContent="between" space={2} className="w-full">
<HStack space={1.5}>
<Checkbox
className="ml-1"
checked={certificate.enabled ?? true}
title={certificate.enabled ? 'Disable certificate' : 'Enable certificate'}
hideLabel
onChange={(enabled) => updateField('enabled', enabled)}
/>
<InlineCode className={classNames(!certificate.host && 'border-danger')}>
{certificate.host || <>&nbsp;</>}
{certificate.port != null && `:${certificate.port}`}
</InlineCode>
{certType && <InlineCode>{certType}</InlineCode>}
</HStack>
<IconButton
icon="trash"
size="sm"
title="Remove certificate"
className="text-text-subtlest -mr-2"
onClick={() => onRemove(index)}
/>
</HStack>
}
>
<VStack space={3} className="mt-2">
<HStack space={2} alignItems="end">
<PlainInput
leftSlot={
<div className="bg-surface-highlight flex items-center text-editor font-mono px-2 text-text-subtle mr-1">
https://
</div>
}
validate={(value) => {
if (!value) return false;
if (!/^[a-zA-Z0-9_.-]+$/.test(value)) return false;
return true;
}}
label="Host"
placeholder="example.com"
size="sm"
required
defaultValue={certificate.host}
onChange={(host) => updateField('host', host)}
/>
<PlainInput
label="Port"
hideLabel
validate={(value) => {
if (!value) return true;
if (Number.isNaN(parseInt(value, 10))) return false;
return true;
}}
placeholder="443"
leftSlot={
<div className="bg-surface-highlight flex items-center text-editor font-mono px-2 text-text-subtle mr-1">
:
</div>
}
size="sm"
className="w-24"
defaultValue={certificate.port?.toString() ?? ''}
onChange={(port) => updateField('port', port ? parseInt(port, 10) : null)}
/>
</HStack>
<Separator className="my-3" />
<VStack space={2}>
<SelectFile
label="CRT File"
noun="Cert"
filePath={certificate.crtFile ?? null}
size="sm"
disabled={hasPfx}
onChange={({ filePath }) => updateField('crtFile', filePath)}
/>
<SelectFile
label="KEY File"
noun="Key"
filePath={certificate.keyFile ?? null}
size="sm"
disabled={hasPfx}
onChange={({ filePath }) => updateField('keyFile', filePath)}
/>
</VStack>
<Separator className="my-3" />
<SelectFile
label="PFX File"
noun="Key"
filePath={certificate.pfxFile ?? null}
size="sm"
disabled={hasCrtKey}
onChange={({ filePath }) => updateField('pfxFile', filePath)}
/>
<PlainInput
label="Passphrase"
size="sm"
type="password"
defaultValue={certificate.passphrase ?? ''}
onChange={(passphrase) => updateField('passphrase', passphrase || null)}
/>
</VStack>
</DetailsBanner>
);
}
export function SettingsCertificates() {
const settings = useAtomValue(settingsAtom);
const certificates = settings.clientCertificates ?? [];
const updateCertificates = async (newCertificates: ClientCertificate[]) => {
await patchModel(settings, { clientCertificates: newCertificates });
};
const handleAdd = async () => {
const newCert = createEmptyCertificate();
await updateCertificates([...certificates, newCert]);
};
const handleUpdate = async (index: number, cert: ClientCertificate) => {
const newCertificates = [...certificates];
newCertificates[index] = cert;
await updateCertificates(newCertificates);
};
const handleRemove = async (index: number) => {
const cert = certificates[index];
if (cert == null) return;
const host = cert.host || 'this certificate';
const port = cert.port != null ? `:${cert.port}` : '';
const confirmed = await showConfirmDelete({
id: 'confirm-remove-certificate',
title: 'Delete Certificate',
description: (
<>
Permanently delete certificate for{' '}
<InlineCode>
{host}
{port}
</InlineCode>
?
</>
),
});
if (!confirmed) return;
const newCertificates = certificates.filter((_, i) => i !== index);
await updateCertificates(newCertificates);
};
return (
<VStack space={3}>
<div className="mb-3">
<HStack justifyContent="between" alignItems="start">
<div>
<Heading>Client Certificates</Heading>
<p className="text-text-subtle">
Add and manage TLS certificates on a per domain basis
</p>
</div>
<Button variant="border" size="sm" color="secondary" onClick={handleAdd}>
Add Certificate
</Button>
</HStack>
</div>
{certificates.length > 0 && (
<VStack space={3}>
{certificates.map((cert, index) => (
<CertificateEditor
// biome-ignore lint/suspicious/noArrayIndexKey: Index is fine here
key={index}
certificate={cert}
index={index}
onUpdate={handleUpdate}
onRemove={handleRemove}
/>
))}
</VStack>
)}
</VStack>
);
}

View File

@@ -26,6 +26,10 @@ export function SettingsGeneral() {
return (
<VStack space={1.5} className="mb-4">
<div className="mb-4">
<Heading>General</Heading>
<p className="text-text-subtle">Configure general settings for update behavior and more.</p>
</div>
<CargoFeature feature="updater">
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-1">
<Select

View File

@@ -13,6 +13,7 @@ import { invokeCmd } from '../../lib/tauri';
import { CargoFeature } from '../CargoFeature';
import { Button } from '../core/Button';
import { Checkbox } from '../core/Checkbox';
import { Heading } from '../core/Heading';
import { Icon } from '../core/Icon';
import { Link } from '../core/Link';
import { Select } from '../core/Select';
@@ -42,6 +43,10 @@ export function SettingsInterface() {
return (
<VStack space={3} className="mb-4">
<div className="mb-3">
<Heading>Interface</Heading>
<p className="text-text-subtle">Tweak settings related to the user interface.</p>
</div>
<Select
name="switchWorkspaceBehavior"
label="Open workspace behavior"

View File

@@ -2,6 +2,7 @@ import { patchModel, settingsAtom } from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai';
import { Checkbox } from '../core/Checkbox';
import { Heading } from '../core/Heading';
import { InlineCode } from '../core/InlineCode';
import { PlainInput } from '../core/PlainInput';
import { Select } from '../core/Select';
@@ -13,6 +14,13 @@ export function SettingsProxy() {
return (
<VStack space={1.5} className="mb-4">
<div className="mb-3">
<Heading>Proxy</Heading>
<p className="text-text-subtle">
Configure a proxy server for HTTP requests. Useful for corporate firewalls, debugging
traffic, or routing through specific infrastructure.
</p>
</div>
<Select
name="proxy"
label="Proxy"

View File

@@ -5,9 +5,11 @@ import { activeWorkspaceAtom } from '../../hooks/useActiveWorkspace';
import { useResolvedAppearance } from '../../hooks/useResolvedAppearance';
import { useResolvedTheme } from '../../hooks/useResolvedTheme';
import type { ButtonProps } from '../core/Button';
import { Heading } from '../core/Heading';
import type { IconProps } from '../core/Icon';
import { Icon } from '../core/Icon';
import { IconButton } from '../core/IconButton';
import { Link } from '../core/Link';
import type { SelectProps } from '../core/Select';
import { Select } from '../core/Select';
import { HStack, VStack } from '../core/Stacks';
@@ -69,6 +71,15 @@ export function SettingsTheme() {
return (
<VStack space={3} className="mb-4">
<div className="mb-3">
<Heading>Theme</Heading>
<p className="text-text-subtle">
Make Yaak your own by selecting a theme, or{' '}
<Link href="https://feedback.yaak.app/help/articles/6911763-plugins-quick-start">
Create Your Own
</Link>
</p>
</div>
<Select
name="appearance"
label="Appearance"

View File

@@ -1,11 +1,13 @@
import type { Color } from '@yaakapp-internal/plugins';
import classNames from 'classnames';
interface Props {
count: number | true;
className?: string;
color?: Color;
}
export function CountBadge({ count, className }: Props) {
export function CountBadge({ count, className, color }: Props) {
if (count === 0) return null;
return (
<div
@@ -13,10 +15,21 @@ export function CountBadge({ count, className }: Props) {
className={classNames(
className,
'flex items-center',
'opacity-70 border border-border-subtle text-4xs rounded mb-0.5 px-1 ml-1 h-4 font-mono',
'opacity-70 border text-4xs rounded mb-0.5 px-1 ml-1 h-4 font-mono',
color == null && 'border-border-subtle',
color === 'primary' && 'text-primary',
color === 'secondary' && 'text-secondary',
color === 'success' && 'text-success',
color === 'notice' && 'text-notice',
color === 'warning' && 'text-warning',
color === 'danger' && 'text-danger',
)}
>
{count === true ? <div aria-hidden className="rounded-full h-1 w-1 bg-text-subtle" /> : count}
{count === true ? (
<div aria-hidden className="rounded-full h-1 w-1 bg-[currentColor]" />
) : (
count
)}
</div>
);
}

View File

@@ -24,7 +24,7 @@ export function DetailsBanner({ className, color, summary, children, ...extraPro
/>
{summary}
</summary>
<div className="mt-1.5">{children}</div>
<div className="mt-1.5 pb-2">{children}</div>
</details>
</Banner>
);

View File

@@ -162,6 +162,7 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
'x-theme-input',
'relative w-full rounded-md text',
'border',
'overflow-hidden',
focused ? 'border-border-focus' : 'border-border-subtle',
hasChanged && 'has-[:invalid]:border-danger', // For built-in HTML validation
size === 'md' && 'min-h-md',