Prepare v0.1.0 release dependencies

This commit is contained in:
2026-06-24 20:02:49 +02:00
parent ce30b4d054
commit 9c2b84a64a
27 changed files with 900 additions and 48 deletions

View File

@@ -0,0 +1,24 @@
import type { ReactNode } from "react";
import PolicySourcePath, { type PolicySourcePathItem } from "./PolicySourcePath";
export type EffectivePolicyBlockProps = {
title: string;
sourcePath?: PolicySourcePathItem[];
children: ReactNode;
className?: string;
gridClassName?: string;
};
export function EffectivePolicyValue({ label, value }: { label: string; value: ReactNode }) {
return <div><span>{label}</span><strong>{value}</strong></div>;
}
export default function EffectivePolicyBlock({ title, sourcePath, children, className = "", gridClassName = "" }: EffectivePolicyBlockProps) {
return (
<section className={`policy-effective-block ${className}`.trim()}>
<h3>{title}</h3>
{sourcePath && <PolicySourcePath items={sourcePath} />}
<div className={gridClassName || "policy-effective-grid"}>{children}</div>
</section>
);
}

View File

@@ -0,0 +1,64 @@
import { useId, useState, type InputHTMLAttributes } from "react";
import { Eye, EyeOff } from "lucide-react";
export type PasswordFieldProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange"> & {
value: string;
onValueChange: (value: string) => void;
saved?: boolean;
savedPlaceholder?: string;
revealLabel?: string;
hideLabel?: string;
inputClassName?: string;
};
export default function PasswordField({
value,
onValueChange,
saved = false,
savedPlaceholder = "••••••••",
revealLabel = "Show password",
hideLabel = "Hide password",
placeholder,
disabled = false,
className = "",
inputClassName = "",
id,
...inputProps
}: PasswordFieldProps) {
const generatedId = useId();
const inputId = id ?? generatedId;
const [visible, setVisible] = useState(false);
const hasTypedPassword = value.length > 0;
const showSavedPlaceholder = saved && !hasTypedPassword;
const canReveal = hasTypedPassword && !disabled;
const inputType = visible && canReveal ? "text" : "password";
return (
<div className={`password-field ${canReveal ? "has-toggle" : ""} ${showSavedPlaceholder ? "is-saved-empty" : ""} ${className}`.trim()}>
<input
{...inputProps}
id={inputId}
className={inputClassName}
type={inputType}
value={value}
disabled={disabled}
placeholder={showSavedPlaceholder ? savedPlaceholder : placeholder}
onChange={(event) => {
onValueChange(event.target.value);
if (!event.target.value) setVisible(false);
}}
/>
{canReveal && (
<button
type="button"
className="password-field-toggle"
aria-label={visible ? hideLabel : revealLabel}
title={visible ? hideLabel : revealLabel}
onClick={() => setVisible((current) => !current)}
>
{visible ? <EyeOff size={17} aria-hidden="true" /> : <Eye size={17} aria-hidden="true" />}
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,11 @@
import type { ReactNode } from "react";
export type PolicyLockedHintProps = {
children: ReactNode;
className?: string;
};
export default function PolicyLockedHint({ children, className = "" }: PolicyLockedHintProps) {
if (!children) return null;
return <p className={`muted small-note policy-locked-hint ${className}`.trim()}>{children}</p>;
}

View File

@@ -0,0 +1,66 @@
export type PolicySourcePathItem = string | {
scope_type?: string;
scope_id?: string | null;
label: string;
applied_fields?: string[];
appliedFields?: string[];
};
export type PolicySourcePathProps = {
items: PolicySourcePathItem[];
label?: string;
className?: string;
};
type NormalizedPolicySourcePathItem = {
label: string;
appliedFields: string[];
};
export default function PolicySourcePath({ items, label = "Source path", className = "" }: PolicySourcePathProps) {
const cleanItems = items.map(normalizeSourceItem).filter((item): item is NormalizedPolicySourcePathItem => Boolean(item?.label));
if (cleanItems.length === 0) return null;
return (
<div className={`policy-source-path ${className}`.trim()} aria-label={label}>
<span className="policy-source-path-label">{label}</span>
<ol>
{cleanItems.map((item, index) => (
<li key={`${item.label}-${index}`}>
<span>{item.label}</span>
{item.appliedFields.length > 0 && <small>{sourceFieldSummary(item.appliedFields)}</small>}
</li>
))}
</ol>
</div>
);
}
function normalizeSourceItem(item: PolicySourcePathItem): NormalizedPolicySourcePathItem | null {
if (typeof item === "string") {
const label = item.trim();
return label ? { label, appliedFields: [] } : null;
}
const label = item.label?.trim();
if (!label) return null;
const appliedFields = [...(item.applied_fields ?? item.appliedFields ?? [])].map((field) => field.trim()).filter(Boolean);
return { label, appliedFields };
}
function sourceFieldSummary(fields: string[]): string {
const unique = Array.from(new Set(fields));
if (unique.length === 0) return "";
if (unique.length === 1 && unique[0] === "defaults") return "defaults";
if (unique.length <= 3) return unique.map(sourceFieldLabel).join(", ");
return `${unique.slice(0, 2).map(sourceFieldLabel).join(", ")} +${unique.length - 2}`;
}
function sourceFieldLabel(field: string): string {
if (field === "defaults") return "defaults";
const clean = field
.replace(/^whitelist[.]/, "allow ")
.replace(/^blacklist[.]/, "block ")
.replace(/[_.]/g, " ")
.trim();
return clean.charAt(0).toUpperCase() + clean.slice(1);
}

View File

@@ -20,7 +20,7 @@ const TRIGGER_GAP = 10;
const tooltipBaseStyle: CSSProperties = {
position: "fixed",
zIndex: 10000,
zIndex: 20000,
width: "max-content",
maxWidth: "min(320px, calc(100vw - 48px))",
border: "1px solid var(--line-dark)",

View File

@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
import Button from "../Button";
import DismissibleAlert from "../DismissibleAlert";
import FormField from "../FormField";
import PasswordField from "../PasswordField";
import ToggleSwitch from "../ToggleSwitch";
export type MailServerSecurity = "plain" | "tls" | "starttls" | string;
@@ -50,10 +51,14 @@ export type MailServerSettingsPanelProps = {
onImapEnabledChange?: (enabled: boolean) => void;
smtpDisabled?: boolean;
smtpCredentialDisabled?: boolean;
smtpPasswordSaved?: boolean;
smtpSavedPasswordPlaceholder?: string;
smtpActionDisabled?: boolean;
imapToggleDisabled?: boolean;
imapServerDisabled?: boolean;
imapCredentialDisabled?: boolean;
imapPasswordSaved?: boolean;
imapSavedPasswordPlaceholder?: string;
imapActionDisabled?: boolean;
smtpTitle?: ReactNode;
imapTitle?: ReactNode;
@@ -98,10 +103,14 @@ export default function MailServerSettingsPanel({
onImapEnabledChange,
smtpDisabled = false,
smtpCredentialDisabled = smtpDisabled,
smtpPasswordSaved = false,
smtpSavedPasswordPlaceholder = "••••••••",
smtpActionDisabled = smtpDisabled,
imapToggleDisabled = false,
imapServerDisabled = false,
imapCredentialDisabled = imapServerDisabled,
imapPasswordSaved = false,
imapSavedPasswordPlaceholder = "••••••••",
imapActionDisabled = imapServerDisabled,
smtpTitle = "SMTP login",
imapTitle = "IMAP sent-folder append",
@@ -149,7 +158,16 @@ export default function MailServerSettingsPanel({
<FormField label="Host"><input value={stringValue(smtp.host)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ host: event.target.value })} /></FormField>
<FormField label="Port"><input type="number" min={1} max={65535} value={stringValue(smtp.port)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ port: event.target.value })} /></FormField>
<FormField label="Username"><input value={stringValue(smtp.username)} disabled={smtpCredentialFieldsDisabled} onChange={(event) => onSmtpChange({ username: event.target.value })} /></FormField>
<FormField label="Password"><input type="password" value={stringValue(smtp.password)} disabled={smtpCredentialFieldsDisabled} onChange={(event) => onSmtpChange({ password: event.target.value })} /></FormField>
<FormField label="Password">
<PasswordField
value={stringValue(smtp.password)}
disabled={smtpCredentialFieldsDisabled}
saved={smtpPasswordSaved}
savedPlaceholder={smtpSavedPasswordPlaceholder}
autoComplete="new-password"
onValueChange={(password) => onSmtpChange({ password })}
/>
</FormField>
<FormField label="Security"><SecuritySelect value={stringValue(smtp.security, "starttls")} disabled={smtpFieldsDisabled} onChange={(security) => onSmtpChange({ security })} /></FormField>
<FormField label="Timeout seconds"><input type="number" min={1} value={stringValue(smtp.timeout_seconds)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ timeout_seconds: event.target.value })} /></FormField>
</div>
@@ -174,7 +192,16 @@ export default function MailServerSettingsPanel({
<FormField label="Host"><input value={stringValue(imap.host)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ host: event.target.value })} /></FormField>
<FormField label="Port"><input type="number" min={1} max={65535} value={stringValue(imap.port)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ port: event.target.value })} /></FormField>
<FormField label="Username"><input value={stringValue(imap.username)} disabled={imapCredentialFieldsDisabled} onChange={(event) => onImapChange({ username: event.target.value })} /></FormField>
<FormField label="Password"><input type="password" value={stringValue(imap.password)} disabled={imapCredentialFieldsDisabled} onChange={(event) => onImapChange({ password: event.target.value })} /></FormField>
<FormField label="Password">
<PasswordField
value={stringValue(imap.password)}
disabled={imapCredentialFieldsDisabled}
saved={imapPasswordSaved}
savedPlaceholder={imapSavedPasswordPlaceholder}
autoComplete="new-password"
onValueChange={(password) => onImapChange({ password })}
/>
</FormField>
<FormField label="Security"><SecuritySelect value={stringValue(imap.security, "tls")} disabled={imapFieldsDisabled} onChange={(security) => onImapChange({ security })} /></FormField>
<FormField label="Detected/saved sent folder"><input value={stringValue(imap.sent_folder, "auto")} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ sent_folder: event.target.value })} /></FormField>
<FormField label="Timeout seconds"><input type="number" min={1} value={stringValue(imap.timeout_seconds)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ timeout_seconds: event.target.value })} /></FormField>