Prepare v0.1.0 release dependencies
This commit is contained in:
49
webui/package.release.json
Normal file
49
webui/package.release.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"module": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"./app": {
|
||||
"types": "./src/app.ts",
|
||||
"import": "./src/app.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1 --port 5173",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview --host 127.0.0.1 --port 4173"
|
||||
},
|
||||
"dependencies": {
|
||||
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.0",
|
||||
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.0",
|
||||
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"lucide-react": "^0.555.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"lucide-react": "^0.555.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1"
|
||||
},
|
||||
"allowScripts": {
|
||||
"esbuild@0.25.12": true
|
||||
}
|
||||
}
|
||||
@@ -147,12 +147,21 @@ export type PrivacyRetentionPolicyPatch = Partial<Omit<PrivacyRetentionPolicy, "
|
||||
};
|
||||
export type PrivacyRetentionPolicyScope = "system" | "tenant" | "user" | "group" | "campaign";
|
||||
|
||||
export type PolicySourceStep = {
|
||||
scope_type: string;
|
||||
scope_id?: string | null;
|
||||
label: string;
|
||||
applied_fields?: string[];
|
||||
};
|
||||
|
||||
export type PrivacyRetentionPolicyScopeResponse = {
|
||||
scope_type: PrivacyRetentionPolicyScope;
|
||||
scope_id?: string | null;
|
||||
policy: PrivacyRetentionPolicyPatch;
|
||||
effective_policy: PrivacyRetentionPolicy;
|
||||
parent_policy?: PrivacyRetentionPolicy | null;
|
||||
effective_policy_sources?: PolicySourceStep[];
|
||||
parent_policy_sources?: PolicySourceStep[];
|
||||
};
|
||||
|
||||
export type SystemSettingsItem = {
|
||||
|
||||
@@ -3,6 +3,17 @@ import type { ApiSettings } from "../types";
|
||||
const STORAGE_KEY = "multimailer.apiSettings";
|
||||
const SESSION_STORAGE_KEY = "multimailer.session";
|
||||
const CSRF_COOKIE_NAME = import.meta.env.VITE_CSRF_COOKIE_NAME ?? "msm_csrf";
|
||||
const RECENT_SAFE_REQUEST_TTL_MS = 750;
|
||||
const MAX_RECENT_SAFE_REQUESTS = 100;
|
||||
|
||||
type RecentSafeRequest = {
|
||||
value: unknown;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
const inFlightSafeRequests = new Map<string, Promise<unknown>>();
|
||||
const recentSafeRequests = new Map<string, RecentSafeRequest>();
|
||||
let safeRequestGeneration = 0;
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
@@ -91,6 +102,63 @@ function isUnsafeMethod(method?: string): boolean {
|
||||
return !["GET", "HEAD", "OPTIONS", "TRACE"].includes(normalized);
|
||||
}
|
||||
|
||||
function canReuseSafeRequest(method: string, init?: RequestInit): boolean {
|
||||
return (method === "GET" || method === "HEAD")
|
||||
&& !init?.body
|
||||
&& !init?.signal
|
||||
&& init?.cache !== "no-store"
|
||||
&& init?.cache !== "reload";
|
||||
}
|
||||
|
||||
function requestHeadersKey(headers: Headers): string {
|
||||
return [...headers.entries()]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, value]) => `${key}:${value}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function safeRequestKey(url: string, method: string, headers: Headers, init?: RequestInit): string {
|
||||
return [
|
||||
method,
|
||||
url,
|
||||
init?.credentials ?? "include",
|
||||
init?.mode ?? "",
|
||||
init?.redirect ?? "",
|
||||
requestHeadersKey(headers),
|
||||
].join("\n\n");
|
||||
}
|
||||
|
||||
function pruneRecentSafeRequests(now = Date.now()): void {
|
||||
for (const [key, entry] of recentSafeRequests) {
|
||||
if (entry.expiresAt <= now) {
|
||||
recentSafeRequests.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
while (recentSafeRequests.size > MAX_RECENT_SAFE_REQUESTS) {
|
||||
const oldestKey = recentSafeRequests.keys().next().value;
|
||||
if (!oldestKey) break;
|
||||
recentSafeRequests.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
function recentSafeResponse(key: string): unknown | undefined {
|
||||
const now = Date.now();
|
||||
const recent = recentSafeRequests.get(key);
|
||||
if (!recent) return undefined;
|
||||
if (recent.expiresAt <= now) {
|
||||
recentSafeRequests.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
return recent.value;
|
||||
}
|
||||
|
||||
function rememberSafeResponse(key: string, value: unknown): void {
|
||||
const now = Date.now();
|
||||
recentSafeRequests.set(key, { value, expiresAt: now + RECENT_SAFE_REQUEST_TTL_MS });
|
||||
pruneRecentSafeRequests(now);
|
||||
}
|
||||
|
||||
export function authHeaders(settings: ApiSettings): Headers {
|
||||
const headers = new Headers();
|
||||
if (settings.accessToken) {
|
||||
@@ -103,6 +171,7 @@ export function authHeaders(settings: ApiSettings): Headers {
|
||||
|
||||
export async function apiFetch<T>(settings: ApiSettings, path: string, init?: RequestInit): Promise<T> {
|
||||
const headers = new Headers(init?.headers || {});
|
||||
const method = (init?.method || "GET").toUpperCase();
|
||||
|
||||
if (!(init?.body instanceof FormData) && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
@@ -113,27 +182,69 @@ export async function apiFetch<T>(settings: ApiSettings, path: string, init?: Re
|
||||
}
|
||||
|
||||
const csrf = csrfToken();
|
||||
if (csrf && isUnsafeMethod(init?.method) && !headers.has("X-CSRF-Token")) {
|
||||
if (csrf && isUnsafeMethod(method) && !headers.has("X-CSRF-Token")) {
|
||||
headers.set("X-CSRF-Token", csrf);
|
||||
}
|
||||
|
||||
const response = await fetch(apiUrl(settings, path), { ...init, headers, credentials: init?.credentials ?? "include" });
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new ApiError(response.status, response.statusText, text);
|
||||
if (isUnsafeMethod(method)) {
|
||||
safeRequestGeneration += 1;
|
||||
inFlightSafeRequests.clear();
|
||||
recentSafeRequests.clear();
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
const url = apiUrl(settings, path);
|
||||
const fetchInit = { ...init, headers, credentials: init?.credentials ?? "include" };
|
||||
|
||||
async function runFetch(): Promise<T> {
|
||||
const response = await fetch(url, fetchInit);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new ApiError(response.status, response.statusText, text);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
if (!contentType.includes("application/json")) {
|
||||
return (await response.text()) as T;
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
if (!contentType.includes("application/json")) {
|
||||
return (await response.text()) as T;
|
||||
if (!canReuseSafeRequest(method, init)) {
|
||||
return runFetch();
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
const cacheKey = safeRequestKey(url, method, headers, init);
|
||||
const requestGeneration = safeRequestGeneration;
|
||||
const recent = recentSafeResponse(cacheKey);
|
||||
if (recent !== undefined) {
|
||||
return recent as T;
|
||||
}
|
||||
|
||||
const existing = inFlightSafeRequests.get(cacheKey);
|
||||
if (existing) {
|
||||
return existing as Promise<T>;
|
||||
}
|
||||
|
||||
const request = runFetch()
|
||||
.then((value) => {
|
||||
if (requestGeneration === safeRequestGeneration) {
|
||||
rememberSafeResponse(cacheKey, value);
|
||||
}
|
||||
return value;
|
||||
})
|
||||
.finally(() => {
|
||||
if (inFlightSafeRequests.get(cacheKey) === request) {
|
||||
inFlightSafeRequests.delete(cacheKey);
|
||||
}
|
||||
});
|
||||
inFlightSafeRequests.set(cacheKey, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
|
||||
|
||||
24
webui/src/components/EffectivePolicyBlock.tsx
Normal file
24
webui/src/components/EffectivePolicyBlock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
webui/src/components/PasswordField.tsx
Normal file
64
webui/src/components/PasswordField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
webui/src/components/PolicyLockedHint.tsx
Normal file
11
webui/src/components/PolicyLockedHint.tsx
Normal 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>;
|
||||
}
|
||||
66
webui/src/components/PolicySourcePath.tsx
Normal file
66
webui/src/components/PolicySourcePath.tsx
Normal 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);
|
||||
}
|
||||
@@ -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)",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -6,6 +6,7 @@ import ConfirmDialog from "../../components/ConfirmDialog";
|
||||
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
||||
import Dialog from "../../components/Dialog";
|
||||
import FormField from "../../components/FormField";
|
||||
import PasswordField from "../../components/PasswordField";
|
||||
import StatusBadge from "../../components/StatusBadge";
|
||||
import {
|
||||
createSystemAccount,
|
||||
@@ -192,7 +193,16 @@ export default function SystemUsersPanel({
|
||||
<div className="admin-form-grid two-columns">
|
||||
<FormField label="Email"><input value={draft.email} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></FormField>
|
||||
<FormField label="Display name"><input value={draft.displayName} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, displayName: event.target.value })} /></FormField>
|
||||
{editing === "new" && <FormField label="Initial password"><input type="password" value={draft.password} placeholder="Leave empty to generate" onChange={(event) => setDraft({ ...draft, password: event.target.value })} /></FormField>}
|
||||
{editing === "new" && (
|
||||
<FormField label="Initial password">
|
||||
<PasswordField
|
||||
value={draft.password}
|
||||
placeholder="Leave empty to generate"
|
||||
autoComplete="new-password"
|
||||
onValueChange={(password) => setDraft({ ...draft, password })}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
<FormField label="Account status"><select value={draft.isActive ? "active" : "inactive"} disabled={Boolean(editing && editing !== "new" && (!canSuspend || editing.memberships.some((membership) => membership.is_last_active_owner)))} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">Active</option><option value="inactive">Inactive</option></select></FormField>
|
||||
</div>
|
||||
<div className="admin-assignment-grid">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings, AuthInfo } from "../../types";
|
||||
import { createTenant, fetchTenantOwnerCandidates, fetchTenants, updateTenant, type TenantAdminItem, type TenantOwnerCandidate } from "../../api/admin";
|
||||
import { createTenant, fetchSystemSettings, fetchTenantOwnerCandidates, fetchTenants, updateTenant, type SystemSettingsItem, type TenantAdminItem, type TenantOwnerCandidate } from "../../api/admin";
|
||||
import Button from "../../components/Button";
|
||||
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
||||
import Dialog from "../../components/Dialog";
|
||||
@@ -65,6 +65,7 @@ export default function TenantsPanel({
|
||||
onAuthRefresh: () => Promise<void>;
|
||||
}) {
|
||||
const [tenants, setTenants] = useState<TenantAdminItem[]>([]);
|
||||
const [systemSettings, setSystemSettings] = useState<SystemSettingsItem | null>(null);
|
||||
const [ownerCandidates, setOwnerCandidates] = useState<TenantOwnerCandidate[]>([]);
|
||||
const [editing, setEditing] = useState<TenantAdminItem | "new" | null>(null);
|
||||
const [viewing, setViewing] = useState<TenantAdminItem | null>(null);
|
||||
@@ -79,12 +80,14 @@ export default function TenantsPanel({
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [nextTenants, nextOwnerCandidates] = await Promise.all([
|
||||
const [nextTenants, nextOwnerCandidates, nextSystemSettings] = await Promise.all([
|
||||
fetchTenants(settings),
|
||||
canCreate ? fetchTenantOwnerCandidates(settings) : Promise.resolve([])
|
||||
canCreate ? fetchTenantOwnerCandidates(settings) : Promise.resolve([]),
|
||||
fetchSystemSettings(settings).catch(() => null)
|
||||
]);
|
||||
setTenants(nextTenants);
|
||||
setOwnerCandidates(nextOwnerCandidates);
|
||||
setSystemSettings(nextSystemSettings);
|
||||
} catch (err) {
|
||||
setError(adminErrorMessage(err));
|
||||
} finally {
|
||||
@@ -180,6 +183,14 @@ export default function TenantsPanel({
|
||||
}
|
||||
|
||||
const activeTenantId = (auth.active_tenant ?? auth.tenant).id;
|
||||
const systemAllowsCustomGroups = systemSettings?.allow_tenant_custom_groups !== false;
|
||||
const systemAllowsCustomRoles = systemSettings?.allow_tenant_custom_roles !== false;
|
||||
const systemAllowsApiKeys = systemSettings?.allow_tenant_api_keys !== false;
|
||||
const systemDeniedGovernance = [
|
||||
systemAllowsCustomGroups ? "" : "custom groups",
|
||||
systemAllowsCustomRoles ? "" : "custom roles",
|
||||
systemAllowsApiKeys ? "" : "API keys"
|
||||
].filter(Boolean).join(", ");
|
||||
const columns = useMemo<DataGridColumn<TenantAdminItem>[]>(() => [
|
||||
{ id: "name", header: "Tenant", width: "minmax(210px, 1fr)", minWidth: 190, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.name} ${row.slug}`, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug}</div></div> },
|
||||
{ id: "users", header: "Users", width: 100, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.users ?? 0, render: (row) => `${row.counts.active_users ?? 0}/${row.counts.users ?? 0}` },
|
||||
@@ -219,11 +230,12 @@ export default function TenantsPanel({
|
||||
</div>
|
||||
<h3>System governance overrides</h3>
|
||||
<div className="admin-form-grid two-columns">
|
||||
<GovernanceSelect disabled={editing !== "new" && !canUpdate} label="Custom tenant groups" value={draft.customGroups} onChange={(customGroups) => setDraft({ ...draft, customGroups })} />
|
||||
<GovernanceSelect disabled={editing !== "new" && !canUpdate} label="Custom tenant roles" value={draft.customRoles} onChange={(customRoles) => setDraft({ ...draft, customRoles })} />
|
||||
<GovernanceSelect disabled={editing !== "new" && !canUpdate} label="Tenant API keys" value={draft.apiKeys} onChange={(apiKeys) => setDraft({ ...draft, apiKeys })} />
|
||||
<GovernanceSelect disabled={editing !== "new" && !canUpdate} allowDisabled={!systemAllowsCustomGroups} label="Custom tenant groups" value={draft.customGroups} onChange={(customGroups) => setDraft({ ...draft, customGroups })} />
|
||||
<GovernanceSelect disabled={editing !== "new" && !canUpdate} allowDisabled={!systemAllowsCustomRoles} label="Custom tenant roles" value={draft.customRoles} onChange={(customRoles) => setDraft({ ...draft, customRoles })} />
|
||||
<GovernanceSelect disabled={editing !== "new" && !canUpdate} allowDisabled={!systemAllowsApiKeys} label="Tenant API keys" value={draft.apiKeys} onChange={(apiKeys) => setDraft({ ...draft, apiKeys })} />
|
||||
</div>
|
||||
<p className="muted small-note">Inherit follows the current system setting. Explicit deny narrows access; explicit allow is valid only while the system setting allows it.</p>
|
||||
{systemDeniedGovernance && <p className="muted small-note">Explicit allow is unavailable for {systemDeniedGovernance} because the current system setting denies it.</p>}
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(viewing)} title="Tenant details" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>Close</Button>}>
|
||||
@@ -243,6 +255,6 @@ export default function TenantsPanel({
|
||||
);
|
||||
}
|
||||
|
||||
function GovernanceSelect({ label, value, onChange, disabled = false }: { label: string; value: OverrideValue; onChange: (value: OverrideValue) => void; disabled?: boolean }) {
|
||||
return <FormField label={label}><select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as OverrideValue)}><option value="inherit">Inherit system setting</option><option value="allow">Allow when system allows</option><option value="deny">Explicitly deny</option></select></FormField>;
|
||||
function GovernanceSelect({ label, value, onChange, disabled = false, allowDisabled = false }: { label: string; value: OverrideValue; onChange: (value: OverrideValue) => void; disabled?: boolean; allowDisabled?: boolean }) {
|
||||
return <FormField label={label}><select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as OverrideValue)}><option value="inherit">Inherit system setting</option><option value="allow" disabled={allowDisabled}>Allow when system allows</option><option value="deny">Explicitly deny</option></select></FormField>;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import Button from "../../components/Button";
|
||||
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
||||
import Dialog from "../../components/Dialog";
|
||||
import FormField from "../../components/FormField";
|
||||
import PasswordField from "../../components/PasswordField";
|
||||
import StatusBadge from "../../components/StatusBadge";
|
||||
import ConfirmDialog from "../../components/ConfirmDialog";
|
||||
import AdminSelectionList from "./components/AdminSelectionList";
|
||||
@@ -151,7 +152,16 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
|
||||
<div className="admin-form-grid two-columns">
|
||||
<FormField label="Email"><input value={draft.email} disabled={editing !== "new"} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></FormField>
|
||||
<FormField label="Display name"><input value={draft.displayName} disabled={editing !== "new" && !canUpdate} onChange={(event) => setDraft({ ...draft, displayName: event.target.value })} /></FormField>
|
||||
{editing === "new" && <FormField label="Initial password"><input type="password" value={draft.password} placeholder="Leave empty to generate" onChange={(event) => setDraft({ ...draft, password: event.target.value })} /></FormField>}
|
||||
{editing === "new" && (
|
||||
<FormField label="Initial password">
|
||||
<PasswordField
|
||||
value={draft.password}
|
||||
placeholder="Leave empty to generate"
|
||||
autoComplete="new-password"
|
||||
onValueChange={(password) => setDraft({ ...draft, password })}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
<FormField label="Membership status"><select value={draft.isActive ? "active" : "inactive"} disabled={Boolean(editing && editing !== "new" && (!canSuspend || editing.is_last_active_owner))} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">Active</option><option value="inactive">Inactive</option></select></FormField>
|
||||
</div>
|
||||
{editing === "new" && <label className="admin-inline-check"><input type="checkbox" checked={draft.passwordResetRequired} onChange={(event) => setDraft({ ...draft, passwordResetRequired: event.target.checked })} /> Require password change when account settings are implemented</label>}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ApiSettings, LoginResponse } from "../../types";
|
||||
import { login } from "../../api/auth";
|
||||
import Button from "../../components/Button";
|
||||
import FormField from "../../components/FormField";
|
||||
import PasswordField from "../../components/PasswordField";
|
||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
||||
|
||||
export default function LoginModal({
|
||||
@@ -47,7 +48,7 @@ export default function LoginModal({
|
||||
<input type="email" value={email} autoComplete="username" onChange={(e) => setEmail(e.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="Password">
|
||||
<input type="password" value={password} autoComplete="current-password" onChange={(e) => setPassword(e.target.value)} />
|
||||
<PasswordField value={password} autoComplete="current-password" onValueChange={setPassword} />
|
||||
</FormField>
|
||||
</div>
|
||||
<footer className="modal-footer">
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
import Button from "../../components/Button";
|
||||
import Card from "../../components/Card";
|
||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
||||
import EffectivePolicyBlock, { EffectivePolicyValue } from "../../components/EffectivePolicyBlock";
|
||||
import type { PolicySourcePathItem } from "../../components/PolicySourcePath";
|
||||
import FormField from "../../components/FormField";
|
||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||
|
||||
@@ -158,6 +160,7 @@ export function RetentionPolicyEditor({
|
||||
const [policy, setPolicy] = useState<PrivacyRetentionPolicyPatch>({});
|
||||
const [effectivePolicy, setEffectivePolicy] = useState<PrivacyRetentionPolicy | null>(null);
|
||||
const [parentPolicy, setParentPolicy] = useState<PrivacyRetentionPolicy | null>(null);
|
||||
const [effectivePolicySources, setEffectivePolicySources] = useState<PolicySourcePathItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
@@ -181,6 +184,7 @@ export function RetentionPolicyEditor({
|
||||
setPolicy({});
|
||||
setEffectivePolicy(null);
|
||||
setParentPolicy(null);
|
||||
setEffectivePolicySources([]);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
@@ -189,10 +193,12 @@ export function RetentionPolicyEditor({
|
||||
setPolicy(isSystem ? normalizeFullPolicy(response.policy) : normalizePatch(response.policy));
|
||||
setEffectivePolicy(normalizeFullPolicy(response.effective_policy));
|
||||
setParentPolicy(response.parent_policy ? normalizeFullPolicy(response.parent_policy) : null);
|
||||
setEffectivePolicySources(response.effective_policy_sources ?? []);
|
||||
} catch (err) {
|
||||
setPolicy({});
|
||||
setEffectivePolicy(null);
|
||||
setParentPolicy(null);
|
||||
setEffectivePolicySources([]);
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -210,6 +216,7 @@ export function RetentionPolicyEditor({
|
||||
setPolicy(isSystem ? normalizeFullPolicy(response.policy) : normalizePatch(response.policy));
|
||||
setEffectivePolicy(normalizeFullPolicy(response.effective_policy));
|
||||
setParentPolicy(response.parent_policy ? normalizeFullPolicy(response.parent_policy) : null);
|
||||
setEffectivePolicySources(response.effective_policy_sources ?? []);
|
||||
setSuccess("Retention policy saved.");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
@@ -316,19 +323,21 @@ export function RetentionPolicyEditor({
|
||||
</section>
|
||||
|
||||
{effectivePolicy && (
|
||||
<section className="retention-policy-section retention-policy-effective">
|
||||
<h3>Effective policy</h3>
|
||||
<div className="retention-policy-effective-grid">
|
||||
<PolicyValue label="Raw JSON" value={effectivePolicy.store_raw_campaign_json ? "Stored" : "Disabled"} />
|
||||
<PolicyValue label="Raw JSON days" value={daysLabel(effectivePolicy.raw_campaign_json_retention_days)} />
|
||||
<PolicyValue label="Generated EML days" value={daysLabel(effectivePolicy.generated_eml_retention_days)} />
|
||||
<PolicyValue label="Report detail days" value={daysLabel(effectivePolicy.stored_report_detail_retention_days)} />
|
||||
<PolicyValue label="Mock mailbox days" value={daysLabel(effectivePolicy.mock_mailbox_retention_days)} />
|
||||
<PolicyValue label="Audit detail days" value={daysLabel(effectivePolicy.audit_detail_retention_days)} />
|
||||
<PolicyValue label="Audit detail" value={auditDetailLabel(effectivePolicy.audit_detail_level)} />
|
||||
{showAllowColumn && <PolicyValue label="Lower limits" value={allowLowerSummary(effectivePolicy.allow_lower_level_limits)} />}
|
||||
</div>
|
||||
</section>
|
||||
<EffectivePolicyBlock
|
||||
title="Effective policy"
|
||||
sourcePath={effectivePolicySources.length > 0 ? effectivePolicySources : retentionPolicySourcePath(scopeType)}
|
||||
className="retention-policy-section retention-policy-effective"
|
||||
gridClassName="retention-policy-effective-grid"
|
||||
>
|
||||
<EffectivePolicyValue label="Raw JSON" value={effectivePolicy.store_raw_campaign_json ? "Stored" : "Disabled"} />
|
||||
<EffectivePolicyValue label="Raw JSON days" value={daysLabel(effectivePolicy.raw_campaign_json_retention_days)} />
|
||||
<EffectivePolicyValue label="Generated EML days" value={daysLabel(effectivePolicy.generated_eml_retention_days)} />
|
||||
<EffectivePolicyValue label="Report detail days" value={daysLabel(effectivePolicy.stored_report_detail_retention_days)} />
|
||||
<EffectivePolicyValue label="Mock mailbox days" value={daysLabel(effectivePolicy.mock_mailbox_retention_days)} />
|
||||
<EffectivePolicyValue label="Audit detail days" value={daysLabel(effectivePolicy.audit_detail_retention_days)} />
|
||||
<EffectivePolicyValue label="Audit detail" value={auditDetailLabel(effectivePolicy.audit_detail_level)} />
|
||||
{showAllowColumn && <EffectivePolicyValue label="Lower limits" value={allowLowerSummary(effectivePolicy.allow_lower_level_limits)} />}
|
||||
</EffectivePolicyBlock>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
@@ -399,10 +408,6 @@ function AuditDetailSelect({ value, includeInherit, disabled, onChange }: { valu
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyValue({ label, value }: { label: string; value: string }) {
|
||||
return <div><span>{label}</span><strong>{value}</strong></div>;
|
||||
}
|
||||
|
||||
function rawJsonValue(value: boolean | undefined): RawJsonValue {
|
||||
if (value === undefined) return "inherit";
|
||||
return value ? "keep" : "disable";
|
||||
@@ -440,6 +445,14 @@ function localPolicyCount(policy: PrivacyRetentionPolicyPatch): string {
|
||||
return count === 0 ? "Fully inherited" : `${count} local override${count === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
function retentionPolicySourcePath(scopeType: PrivacyRetentionPolicyScope): string[] {
|
||||
if (scopeType === "system") return ["System"];
|
||||
if (scopeType === "tenant") return ["System", "Tenant"];
|
||||
if (scopeType === "user") return ["System", "Tenant", "User"];
|
||||
if (scopeType === "group") return ["System", "Tenant", "Group"];
|
||||
return ["System", "Tenant", "Owner policy", "Campaign"];
|
||||
}
|
||||
|
||||
function normalizeAllowLimits(value: PrivacyRetentionLimitPermissionPatch | PrivacyRetentionLimitPermissions | null | undefined): PrivacyRetentionLimitPermissions {
|
||||
return { ...defaultAllowLowerLevelLimits, ...(value ?? {}) };
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useSearchParams } from "react-router-dom";
|
||||
import type { ApiSettings, AuthInfo } from "../../types";
|
||||
import Card from "../../components/Card";
|
||||
import FormField from "../../components/FormField";
|
||||
import PasswordField from "../../components/PasswordField";
|
||||
import Button from "../../components/Button";
|
||||
import PageTitle from "../../components/PageTitle";
|
||||
import ToggleSwitch from "../../components/ToggleSwitch";
|
||||
@@ -240,7 +241,11 @@ export default function SettingsPage({
|
||||
<input value={settings.apiBaseUrl} onChange={(e) => onSettingsChange({ ...settings, apiBaseUrl: e.target.value })} placeholder="https://example.org or empty" />
|
||||
</FormField>
|
||||
<FormField label="Automation API key" help="Used only when there is no browser session token. Browser login remains the preferred interactive mode.">
|
||||
<input type="password" value={settings.apiKey} onChange={(e) => onSettingsChange({ ...settings, apiKey: e.target.value })} />
|
||||
<PasswordField
|
||||
value={settings.apiKey}
|
||||
autoComplete="off"
|
||||
onValueChange={(apiKey) => onSettingsChange({ ...settings, apiKey })}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="button-row compact-actions">
|
||||
<Button variant="primary" onClick={testConnection} disabled={testing}>{testing ? "Testing…" : "Test connection"}</Button>
|
||||
|
||||
@@ -17,6 +17,8 @@ export { default as Card } from "./components/Card";
|
||||
export { default as ConfirmDialog } from "./components/ConfirmDialog";
|
||||
export { default as Dialog } from "./components/Dialog";
|
||||
export { default as DismissibleAlert } from "./components/DismissibleAlert";
|
||||
export { default as EffectivePolicyBlock, EffectivePolicyValue } from "./components/EffectivePolicyBlock";
|
||||
export type { EffectivePolicyBlockProps } from "./components/EffectivePolicyBlock";
|
||||
export { default as FormField } from "./components/FormField";
|
||||
export { default as LoadingFrame } from "./components/LoadingFrame";
|
||||
export { default as LoadingIndicator } from "./components/LoadingIndicator";
|
||||
@@ -26,6 +28,12 @@ export { default as MetricCard } from "./components/MetricCard";
|
||||
export { default as MessageDisplayPanel } from "./components/MessageDisplayPanel";
|
||||
export type { MessageDisplayAttachment, MessageDisplayField } from "./components/MessageDisplayPanel";
|
||||
export { default as PageTitle } from "./components/PageTitle";
|
||||
export { default as PasswordField } from "./components/PasswordField";
|
||||
export type { PasswordFieldProps } from "./components/PasswordField";
|
||||
export { default as PolicySourcePath } from "./components/PolicySourcePath";
|
||||
export type { PolicySourcePathItem, PolicySourcePathProps } from "./components/PolicySourcePath";
|
||||
export { default as PolicyLockedHint } from "./components/PolicyLockedHint";
|
||||
export type { PolicyLockedHintProps } from "./components/PolicyLockedHint";
|
||||
export { default as StatusBadge } from "./components/StatusBadge";
|
||||
export { default as Stepper } from "./components/Stepper";
|
||||
export { default as ToggleSwitch } from "./components/ToggleSwitch";
|
||||
@@ -39,6 +47,8 @@ export type { DataGridColumn, DataGridListOption, DataGridPagination, DataGridQu
|
||||
|
||||
export { default as LoginModal } from "./features/auth/LoginModal";
|
||||
export { default as PublicLandingPage } from "./features/auth/PublicLandingPage";
|
||||
export { RetentionPolicyEditor, RetentionPolicyScopeManager } from "./features/privacy/RetentionPolicyManagement";
|
||||
export type { RetentionPolicyTargetOption } from "./features/privacy/RetentionPolicyManagement";
|
||||
|
||||
export { default as AppShell } from "./layout/AppShell";
|
||||
export { default as BreadcrumbBar } from "./layout/BreadcrumbBar";
|
||||
|
||||
@@ -142,6 +142,96 @@
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.policy-source-path {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 10px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.policy-source-path-label {
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.policy-source-path ol {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.policy-source-path li {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-strong);
|
||||
font-weight: 700;
|
||||
}
|
||||
.policy-source-path li small {
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.policy-source-path li small::before {
|
||||
content: "(";
|
||||
}
|
||||
.policy-source-path li small::after {
|
||||
content: ")";
|
||||
}
|
||||
.policy-source-path li + li::before {
|
||||
content: ">";
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.password-field {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
.password-field.has-toggle input {
|
||||
padding-right: 42px;
|
||||
}
|
||||
.password-field.is-saved-empty input::placeholder {
|
||||
color: var(--muted);
|
||||
opacity: 1;
|
||||
}
|
||||
.password-field-toggle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 7px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.password-field-toggle:hover {
|
||||
background: rgba(0, 0, 0, .06);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.password-field-toggle:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--blue) 55%, #fff);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* Mail-style address editor: textarea surface + pills + add dialog. */
|
||||
.email-address-editor {
|
||||
position: relative;
|
||||
|
||||
Reference in New Issue
Block a user