intermittent commit

This commit is contained in:
2026-07-14 13:22:10 +02:00
parent 8aa1943581
commit e6f7c45f0a
76 changed files with 6039 additions and 2188 deletions

View File

@@ -9,6 +9,7 @@
"version": "0.1.8",
"dependencies": {
"@govoplan/access-webui": "file:../../govoplan-access/webui",
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
"@govoplan/admin-webui": "file:../../govoplan-admin/webui",
"@govoplan/audit-webui": "file:../../govoplan-audit/webui",
"@govoplan/calendar-webui": "file:../../govoplan-calendar/webui",
@@ -18,6 +19,7 @@
"@govoplan/files-webui": "file:../../govoplan-files/webui",
"@govoplan/idm-webui": "file:../../govoplan-idm/webui",
"@govoplan/mail-webui": "file:../../govoplan-mail/webui",
"@govoplan/notifications-webui": "file:../../govoplan-notifications/webui",
"@govoplan/ops-webui": "file:../../govoplan-ops/webui",
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
"@govoplan/policy-webui": "file:../../govoplan-policy/webui",
@@ -61,6 +63,22 @@
}
}
},
"../../govoplan-addresses/webui": {
"name": "@govoplan/addresses-webui",
"version": "0.1.8",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"../../govoplan-admin/webui": {
"name": "@govoplan/admin-webui",
"version": "0.1.8",
@@ -229,6 +247,25 @@
}
}
},
"../../govoplan-notifications/webui": {
"name": "@govoplan/notifications-webui",
"version": "0.1.8",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"typescript": "^5.7.2",
"vite": "^6.0.6"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"../../govoplan-ops/webui": {
"name": "@govoplan/ops-webui",
"version": "0.1.8",
@@ -1030,6 +1067,10 @@
"resolved": "../../govoplan-access/webui",
"link": true
},
"node_modules/@govoplan/addresses-webui": {
"resolved": "../../govoplan-addresses/webui",
"link": true
},
"node_modules/@govoplan/admin-webui": {
"resolved": "../../govoplan-admin/webui",
"link": true
@@ -1066,6 +1107,10 @@
"resolved": "../../govoplan-mail/webui",
"link": true
},
"node_modules/@govoplan/notifications-webui": {
"resolved": "../../govoplan-notifications/webui",
"link": true
},
"node_modules/@govoplan/ops-webui": {
"resolved": "../../govoplan-ops/webui",
"link": true

View File

@@ -28,6 +28,7 @@
"dependencies": {
"@govoplan/access-webui": "file:../../govoplan-access/webui",
"@govoplan/admin-webui": "file:../../govoplan-admin/webui",
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
"@govoplan/audit-webui": "file:../../govoplan-audit/webui",
"@govoplan/calendar-webui": "file:../../govoplan-calendar/webui",
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
@@ -36,6 +37,7 @@
"@govoplan/files-webui": "file:../../govoplan-files/webui",
"@govoplan/idm-webui": "file:../../govoplan-idm/webui",
"@govoplan/mail-webui": "file:../../govoplan-mail/webui",
"@govoplan/notifications-webui": "file:../../govoplan-notifications/webui",
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
"@govoplan/ops-webui": "file:../../govoplan-ops/webui",
"@govoplan/policy-webui": "file:../../govoplan-policy/webui",

View File

@@ -24,6 +24,7 @@
"dependencies": {
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#v0.1.8",
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.8",
"@govoplan/addresses-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-addresses.git#v0.1.8",
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-audit.git#v0.1.8",
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#v0.1.8",
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.8",
@@ -31,6 +32,7 @@
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.8",
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-idm.git#v0.1.8",
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.8",
"@govoplan/notifications-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-notifications.git#v0.1.8",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.8",
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-organizations.git#v0.1.8",
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-ops.git#v0.1.8",

View File

@@ -3,6 +3,7 @@ import { spawnSync } from "node:child_process";
const packageByModule = {
access: "@govoplan/access-webui",
admin: "@govoplan/admin-webui",
addresses: "@govoplan/addresses-webui",
audit: "@govoplan/audit-webui",
campaigns: "@govoplan/campaign-webui",
dashboard: "@govoplan/dashboard-webui",
@@ -20,6 +21,7 @@ const cases = [
{ name: "core-only", modules: [] },
{ name: "access-only", modules: ["access"] },
{ name: "admin-only", modules: ["admin"] },
{ name: "addresses-only", modules: ["addresses"] },
{ name: "access-with-admin", modules: ["access", "admin"] },
{ name: "admin-with-policy-and-audit", modules: ["access", "admin", "policy", "audit"] },
{ name: "dashboard-only", modules: ["dashboard"] },
@@ -33,7 +35,7 @@ const cases = [
{ name: "scheduling-only", modules: ["scheduling"] },
{ name: "scheduling-with-calendar", modules: ["scheduling", "calendar"] },
{ name: "docs-and-ops", modules: ["access", "docs", "ops"] },
{ name: "full-product", modules: ["access", "admin", "policy", "audit", "dashboard", "organizations", "idm", "campaigns", "files", "mail", "docs", "ops", "scheduling"] }
{ name: "full-product", modules: ["access", "admin", "addresses", "policy", "audit", "dashboard", "organizations", "idm", "campaigns", "files", "mail", "docs", "ops", "scheduling"] }
];
const npmExec = process.env.npm_execpath;

View File

@@ -1,9 +1,9 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import { fetchMe, updateProfile } from "./api/auth";
import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth";
import { fetchPlatformModules, fetchPlatformStatus } from "./api/platform";
import { AUTH_REQUIRED_EVENT, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
import type { ApiSettings, AuthInfo, AuthTenant, AuthTenantMembership, AuthUser, LoginResponse, PlatformModuleInfo, PlatformWebModule, UserUiPreferences } from "./types";
import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, LoginResponse, PlatformModuleInfo, PlatformWebModule, UserUiPreferences } from "./types";
import AppShell from "./layout/AppShell";
import PublicLandingPage from "./features/auth/PublicLandingPage";
import LoginModal from "./features/auth/LoginModal";
@@ -47,9 +47,9 @@ export default function App() {
saveApiSettings(next);
}
function updateAuth(next: AuthInfo | null, accessToken?: string) {
function updateAuth(next: AuthUpdate | null, accessToken?: string) {
const nextSettings = accessToken !== undefined ? { ...settings, accessToken } : settings;
setAuth(next ? normalizeAuthInfo(next) : null);
setAuth((current) => next ? normalizeAuthInfo(mergeAuthPayload(current, next)) : null);
if (accessToken !== undefined) {
setSettings(nextSettings);
saveApiSettings(nextSettings);
@@ -117,21 +117,26 @@ export default function App() {
useEffect(() => {
let cancelled = false;
setCheckingSession(true);
fetchMe(settings).
then((me) => {if (!cancelled) setAuth(normalizeAuthInfo(me));}).
catch(() => {
if (!cancelled) {
const cleared = { ...settings, accessToken: "" };
setSettings(cleared);
saveApiSettings(cleared);
setAuth(null);
setPlatformModules(null);
setRemoteWebModules([]);
async function bootstrapAuth() {
try {
const shellAuth = await fetchShellAuth(settings);
if (!cancelled) setAuth(normalizeAuthInfo(shellAuth));
} catch {
if (!cancelled) {
const cleared = { ...settings, accessToken: "" };
setSettings(cleared);
saveApiSettings(cleared);
setAuth(null);
setPlatformModules(null);
setRemoteWebModules([]);
}
} finally {
if (!cancelled) setCheckingSession(false);
}
}).
finally(() => {
if (!cancelled) setCheckingSession(false);
});
}
void bootstrapAuth();
return () => {cancelled = true;};
}, [settings.apiBaseUrl, settings.apiKey]);
@@ -180,11 +185,25 @@ export default function App() {
root.classList.toggle("ui-hide-help-hints", !preferences.show_inline_help_hints);
root.classList.toggle("ui-reduce-motion", preferences.reduce_motion);
root.classList.toggle("ui-no-sticky-section-sidebars", !preferences.sticky_section_sidebars);
if (preferences.theme === "system") {
delete root.dataset.theme;
} else {
root.dataset.theme = preferences.theme;
const systemDarkQuery = window.matchMedia?.("(prefers-color-scheme: dark)") ?? null;
const applyTheme = () => {
const resolvedTheme = preferences.theme === "system" ?
systemDarkQuery?.matches ? "dark" : "light" :
preferences.theme;
root.dataset.theme = resolvedTheme;
root.dataset.themePreference = preferences.theme;
};
applyTheme();
if (preferences.theme !== "system" || !systemDarkQuery) {
return undefined;
}
systemDarkQuery.addEventListener("change", applyTheme);
return () => {
systemDarkQuery.removeEventListener("change", applyTheme);
};
}, [
auth?.user.ui_preferences?.compact_tables,
auth?.user.ui_preferences?.show_inline_help_hints,
@@ -208,8 +227,11 @@ export default function App() {
useEffect(() => {
if (!auth) return;
const currentAuth = auth;
let cancelled = false;
let inFlight = false;
let lastRefreshAt = 0;
let lastShellRefreshAt = Date.now();
async function refreshVisibleSession() {
if (document.visibilityState === "hidden" || inFlight) return;
@@ -219,7 +241,17 @@ export default function App() {
inFlight = true;
lastRefreshAt = now;
try {
setAuth(normalizeAuthInfo(await fetchMe(settings)));
const sessionInfo = await fetchSession(settings);
if (cancelled) return;
const shellRefreshDue = now - lastShellRefreshAt >= 60_000;
if (!sessionMatchesAuth(sessionInfo, currentAuth) || shellRefreshDue) {
const shellAuth = await fetchShellAuth(settings);
if (cancelled) return;
lastShellRefreshAt = Date.now();
setAuth((current) => current && sessionMatchesAuth(sessionInfo, current)
? normalizeAuthInfo(mergeAuthPayload(current, shellAuth))
: normalizeAuthInfo(shellAuth));
}
} catch {
@@ -230,6 +262,7 @@ export default function App() {
window.addEventListener("focus", refreshVisibleSession);
document.addEventListener("visibilitychange", refreshVisibleSession);
return () => {
cancelled = true;
window.removeEventListener("focus", refreshVisibleSession);
document.removeEventListener("visibilitychange", refreshVisibleSession);
};
@@ -327,18 +360,40 @@ export default function App() {
}
type AuthPayload = Partial<AuthInfo> & {
principal?: AuthInfo["principal"];
user?: Partial<AuthUser> | null;
tenant?: AuthTenant | null;
active_tenant?: AuthTenant | null;
tenants?: AuthTenantMembership[] | null;
};
type AuthPayload = AuthUpdate;
function mergeAuthPayload(current: AuthInfo | null, next: AuthPayload): AuthPayload {
if (!current) return next;
const nextActiveTenant = next.active_tenant ?? next.tenant ?? null;
const currentActiveTenant = current.active_tenant ?? current.tenant;
const tenantChanged = Boolean(nextActiveTenant && nextActiveTenant.id !== currentActiveTenant.id);
return {
...current,
...next,
user: next.user ? { ...current.user, ...next.user } : current.user,
tenant: nextActiveTenant ?? current.tenant,
active_tenant: nextActiveTenant ?? currentActiveTenant,
tenants: next.tenants ?? current.tenants,
scopes: next.scopes ?? current.scopes,
roles: next.roles ?? (next.roles_loaded === false || tenantChanged ? [] : current.roles),
groups: next.groups ?? (next.groups_loaded === false || tenantChanged ? [] : current.groups),
principal: next.principal === undefined ? current.principal : next.principal,
available_languages: next.available_languages ?? (tenantChanged ? undefined : current.available_languages),
enabled_language_codes: next.enabled_language_codes ?? (tenantChanged ? undefined : current.enabled_language_codes),
default_language: next.default_language ?? (tenantChanged ? undefined : current.default_language),
profile_loaded: next.profile_loaded ?? (tenantChanged ? false : current.profile_loaded),
roles_loaded: next.roles_loaded ?? (tenantChanged ? false : current.roles_loaded),
groups_loaded: next.groups_loaded ?? (tenantChanged ? false : current.groups_loaded)
};
}
function normalizeAuthInfo(response: AuthPayload): AuthInfo {
const principal = response.principal ?? null;
const activeTenant = response.active_tenant ?? response.tenant ?? response.tenants?.[0] ?? null;
const user = normalizeAuthUser(response.user, principal);
const profileLoaded = response.profile_loaded ?? hasFullProfilePayload(response);
const rolesLoaded = response.roles_loaded ?? response.roles !== undefined;
const groupsLoaded = response.groups_loaded ?? response.groups !== undefined;
if (!activeTenant) {
throw new Error("Authentication response did not include an active tenant.");
@@ -356,12 +411,25 @@ function normalizeAuthInfo(response: AuthPayload): AuthInfo {
roles: response.roles ?? [],
groups: response.groups ?? [],
principal,
available_languages: response.available_languages ?? [],
enabled_language_codes: response.enabled_language_codes ?? activeTenant.enabled_language_codes ?? [],
default_language: response.default_language ?? user.preferred_language ?? activeTenant.default_locale ?? "en"
available_languages: response.available_languages,
enabled_language_codes: profileLoaded ? response.enabled_language_codes ?? activeTenant.enabled_language_codes ?? [] : undefined,
default_language: profileLoaded ? response.default_language ?? user.preferred_language ?? activeTenant.default_locale ?? "en" : undefined,
profile_loaded: profileLoaded,
roles_loaded: rolesLoaded,
groups_loaded: groupsLoaded
};
}
function hasFullProfilePayload(response: AuthPayload): boolean {
return Boolean(
response.default_language ||
response.available_languages ||
response.enabled_language_codes ||
response.roles ||
response.groups
);
}
function normalizeAuthUser(user: Partial<AuthUser> | null | undefined, principal: AuthInfo["principal"]): AuthUser | null {
if (user?.id && user.account_id) {
return {
@@ -396,6 +464,17 @@ function normalizeAuthUser(user: Partial<AuthUser> | null | undefined, principal
};
}
function sessionMatchesAuth(sessionInfo: AuthSessionInfo, auth: AuthInfo): boolean {
const activeTenant = auth.active_tenant ?? auth.tenant;
if (sessionInfo.user.id !== auth.user.id) return false;
if (sessionInfo.user.account_id !== auth.user.account_id) return false;
if ((sessionInfo.active_tenant ?? sessionInfo.tenant).id !== activeTenant.id) return false;
if (auth.principal?.auth_method && sessionInfo.auth_method !== auth.principal.auth_method) return false;
if (auth.principal?.session_id && sessionInfo.session_id && auth.principal.session_id !== sessionInfo.session_id) return false;
if (auth.principal?.api_key_id && sessionInfo.api_key_id && auth.principal.api_key_id !== sessionInfo.api_key_id) return false;
return true;
}
function normalizeUiPreferences(value: Partial<UserUiPreferences> | null | undefined): UserUiPreferences {
const theme = value?.theme === "light" || value?.theme === "dark" || value?.theme === "system" ? value.theme : "system";
return {

View File

@@ -0,0 +1,56 @@
import type { ApiSettings } from "../types";
import { apiFetch, apiGetList } from "./client";
export type PermissionItem = {
scope: string;
label: string;
description: string;
category: string;
level: "tenant" | "system";
system_template_id?: string | null;
system_required?: boolean;
};
export type AdminOverview = {
active_tenant_id: string;
active_tenant_name: string;
tenant_count?: number | null;
system_account_count?: number | null;
system_group_template_count?: number | null;
system_role_template_count?: number | null;
user_count: number;
active_user_count: number;
group_count: number;
role_count: number;
active_api_key_count: number;
capabilities: string[];
};
export type TenantAdminItem = {
id: string;
slug: string;
name: string;
description?: string | null;
default_locale: string;
settings: Record<string, unknown>;
allow_custom_groups?: boolean | null;
allow_custom_roles?: boolean | null;
allow_api_keys?: boolean | null;
effective_governance: Record<string, boolean>;
is_active: boolean;
counts: Record<string, number>;
created_at: string;
updated_at: string;
};
export function fetchAdminOverview(settings: ApiSettings): Promise<AdminOverview> {
return apiFetch(settings, "/api/v1/admin/overview");
}
export async function fetchPermissionCatalog(settings: ApiSettings): Promise<PermissionItem[]> {
return apiGetList<PermissionItem, "permissions">(settings, "/api/v1/admin/permissions", "permissions");
}
export async function fetchTenants(settings: ApiSettings): Promise<TenantAdminItem[]> {
return apiGetList<TenantAdminItem, "tenants">(settings, "/api/v1/admin/tenants", "tenants");
}

View File

@@ -1,4 +1,4 @@
import type { ApiSettings, AuthInfo, LoginResponse, UserUiPreferences } from "../types";
import type { ApiSettings, AuthGroupsInfo, AuthInfo, AuthProfileInfo, AuthRolesInfo, AuthSessionInfo, AuthShellInfo, LoginResponse, UserUiPreferences } from "../types";
import { apiFetch } from "./client";
export async function login(
@@ -15,8 +15,28 @@ export async function fetchMe(settings: ApiSettings): Promise<AuthInfo> {
return apiFetch<AuthInfo>(settings, "/api/v1/auth/me");
}
export async function switchTenant(settings: ApiSettings, tenantId: string): Promise<AuthInfo> {
return apiFetch<AuthInfo>(settings, "/api/v1/auth/switch-tenant", {
export async function fetchSession(settings: ApiSettings): Promise<AuthSessionInfo> {
return apiFetch<AuthSessionInfo>(settings, "/api/v1/auth/session", { cache: "no-store" });
}
export async function fetchShellAuth(settings: ApiSettings): Promise<AuthShellInfo> {
return apiFetch<AuthShellInfo>(settings, "/api/v1/auth/shell", { cache: "no-store" });
}
export async function fetchAuthProfile(settings: ApiSettings): Promise<AuthProfileInfo> {
return apiFetch<AuthProfileInfo>(settings, "/api/v1/auth/profile", { cache: "no-store" });
}
export async function fetchAuthRoles(settings: ApiSettings): Promise<AuthRolesInfo> {
return apiFetch<AuthRolesInfo>(settings, "/api/v1/auth/roles", { cache: "no-store" });
}
export async function fetchAuthGroups(settings: ApiSettings): Promise<AuthGroupsInfo> {
return apiFetch<AuthGroupsInfo>(settings, "/api/v1/auth/groups", { cache: "no-store" });
}
export async function switchTenant(settings: ApiSettings, tenantId: string): Promise<AuthShellInfo> {
return apiFetch<AuthShellInfo>(settings, "/api/v1/auth/switch-tenant", {
method: "POST",
body: JSON.stringify({ tenant_id: tenantId })
});
@@ -35,8 +55,8 @@ export async function updateProfile(
enabled_language_codes?: string[] | null;
ui_preferences?: Partial<UserUiPreferences> | null;
}
): Promise<AuthInfo> {
return apiFetch<AuthInfo>(settings, "/api/v1/auth/profile", {
): Promise<AuthProfileInfo> {
return apiFetch<AuthProfileInfo>(settings, "/api/v1/auth/profile", {
method: "PATCH",
body: JSON.stringify(payload)
});

View File

@@ -1,9 +1,9 @@
import type { ApiSettings } from "../types";
const STORAGE_KEY = "multimailer.apiSettings";
const LEGACY_STORAGE_KEYS = ["i18n:govoplan-core.multimailer_apisettings.1d1601d4"];
const SESSION_STORAGE_KEY = "multimailer.session";
const CSRF_COOKIE_NAME = import.meta.env.VITE_CSRF_COOKIE_NAME ?? "msm_csrf";
const STORAGE_KEY = "govoplan.apiSettings";
const LEGACY_STORAGE_KEYS: string[] = [];
const SESSION_STORAGE_KEY = "govoplan.session";
const CSRF_COOKIE_NAME = import.meta.env.VITE_CSRF_COOKIE_NAME ?? "govoplan_csrf";
const RECENT_SAFE_REQUEST_TTL_MS = 750;
const MAX_RECENT_SAFE_REQUESTS = 100;
const MAX_CONDITIONAL_SAFE_REQUESTS = 200;
@@ -77,6 +77,56 @@ export function apiUrl(settings: ApiSettings, path: string): string {
return baseUrl ? `${baseUrl}${normalizedPath}` : normalizedPath;
}
export type ApiQueryValue = string | number | boolean | null | undefined;
export type ApiQueryParams = Record<string, ApiQueryValue | readonly ApiQueryValue[]>;
function queryValue(value: ApiQueryValue): string | null {
if (value === null || value === undefined || value === "") return null;
return String(value);
}
export function apiQuery(params: ApiQueryParams = {}): string {
const search = new URLSearchParams();
for (const [key, rawValue] of Object.entries(params)) {
const values = Array.isArray(rawValue) ? rawValue : [rawValue];
for (const value of values) {
const normalized = queryValue(value);
if (normalized !== null) search.append(key, normalized);
}
}
const query = search.toString();
return query ? `?${query}` : "";
}
export function apiPath(path: string, params: ApiQueryParams = {}): string {
const query = apiQuery(params);
if (!query) return path;
return path.includes("?") ? `${path}&${query.slice(1)}` : `${path}${query}`;
}
export async function apiGetList<TItem, K extends string>(
settings: ApiSettings,
path: string,
key: K,
params: ApiQueryParams = {}
): Promise<TItem[]> {
const response = await apiFetch<Record<K, TItem[] | null | undefined>>(settings, apiPath(path, params));
return response[key] ?? [];
}
export function apiPost<TResponse>(settings: ApiSettings, path: string, init: Omit<RequestInit, "method"> = {}): Promise<TResponse> {
return apiFetch<TResponse>(settings, path, { ...init, method: "POST" });
}
export function apiPostJson<TResponse, TPayload = unknown>(
settings: ApiSettings,
path: string,
payload: TPayload,
init: Omit<RequestInit, "method" | "body"> = {}
): Promise<TResponse> {
return apiPost<TResponse>(settings, path, { ...init, body: JSON.stringify(payload) });
}
export function loadApiSettings(): ApiSettings {
const storedBaseUrl = loadStoredSetting("baseUrl");
const storedApiKey = sessionStorage.getItem(`${SESSION_STORAGE_KEY}.apiKey`);

View File

@@ -0,0 +1,143 @@
import type {
MailImapTransportSettings,
MailProfilePatternKey,
MailProfilePolicy,
MailProfileScope,
MailSecurity,
MailServerProfile,
MailServerProfileCredentials,
MailTransportCredentials,
MailTransportSettings
} from "../types";
export type {
MailCredentialPolicy,
MailProfilePatternKey,
MailProfilePolicy,
MailProfileScope,
MailSecurity,
MailServerProfile
} from "../types";
export type MailSmtpTestPayload = MailTransportSettings;
export type MailImapTestPayload = MailImapTransportSettings;
export type MailTransportCredentialsPayload = MailTransportCredentials;
export type MailServerProfileCredentialsPayload = MailServerProfileCredentials;
export type MailServerProfileListResponse = { profiles: MailServerProfile[] };
export type MailProfilePatternRules = Partial<Record<MailProfilePatternKey, string[]>>;
export type MailConnectionTestResponse = {
ok: boolean;
protocol: "smtp" | "imap";
host?: string | null;
port?: number | null;
security?: MailSecurity | string | null;
message: string;
details?: Record<string, unknown>;
};
export type MailImapFolderResponse = {
name: string;
flags?: string[];
message_count?: number | null;
unseen_count?: number | null;
};
export type MailImapFolderListResponse = {
ok: boolean;
protocol: "imap";
host?: string | null;
port?: number | null;
security?: MailSecurity | string | null;
message: string;
folders: MailImapFolderResponse[];
detected_sent_folder?: string | null;
from_cache?: boolean;
refreshing?: boolean;
indexed_at?: string | null;
details?: Record<string, unknown>;
};
export const mailProfilePatternKeys = [
"smtp_hosts",
"imap_hosts",
"envelope_senders",
"from_headers",
"recipient_domains"
] as const satisfies readonly MailProfilePatternKey[];
export const mailProfilePolicyLimitKeys = [
"allowed_profile_ids",
"allow_user_profiles",
"allow_group_profiles",
"smtp_credentials.inherit",
"imap_credentials.inherit",
"whitelist.smtp_hosts",
"whitelist.imap_hosts",
"whitelist.envelope_senders",
"whitelist.from_headers",
"whitelist.recipient_domains",
"blacklist.smtp_hosts",
"blacklist.imap_hosts",
"blacklist.envelope_senders",
"blacklist.from_headers",
"blacklist.recipient_domains"
] as const;
export type MailProfilePolicyLimitKey = typeof mailProfilePolicyLimitKeys[number];
export type MailProfilePolicyLimitPermissions = Partial<Record<MailProfilePolicyLimitKey, boolean>>;
export type MailPolicySourceStep = {
scope_type: string;
scope_id?: string | null;
label: string;
applied_fields?: string[];
policy?: MailProfilePolicy | null;
};
export type MailProfilePolicyResponse = {
scope_type: MailProfileScope;
scope_id?: string | null;
policy: MailProfilePolicy;
effective_policy?: MailProfilePolicy | null;
parent_policy?: MailProfilePolicy | null;
effective_policy_sources?: MailPolicySourceStep[];
parent_policy_sources?: MailPolicySourceStep[];
};
export type MailServerProfilePayload = {
name: string;
slug?: string | null;
description?: string | null;
is_active?: boolean;
scope_type?: MailProfileScope;
scope_id?: string | null;
smtp: MailSmtpTestPayload;
imap?: MailImapTestPayload | null;
credentials?: MailServerProfileCredentialsPayload | null;
};
export type MockMailboxMessage = {
id: string;
kind: "smtp" | "imap_append" | string;
created_at: string;
envelope_from?: string | null;
envelope_recipients?: string[];
subject?: string | null;
from_header?: string | null;
to_header?: string | null;
cc_header?: string | null;
bcc_header?: string | null;
message_id?: string | null;
size_bytes?: number;
body_preview?: string | null;
attachment_count?: number;
folder?: string | null;
raw_eml?: string | null;
headers?: Record<string, string>;
attachments?: Array<{ filename?: string | null; content_type?: string | null; size_bytes?: number }>;
};
export type MockMailboxMessageResponse = {
message: MockMailboxMessage;
};

View File

@@ -1,5 +1,5 @@
import type { ApiSettings } from "../types";
import { apiFetch } from "./client";
import { apiFetch, apiPath } from "./client";
export type PrivacyRetentionPolicyFieldKey =
| "store_raw_campaign_json"
@@ -78,13 +78,6 @@ export type RetentionRunResponse = {
};
};
function retentionPolicyQuery(scopeId?: string | null): string {
const params = new URLSearchParams();
if (scopeId) params.set("scope_id", scopeId);
const suffix = params.toString();
return suffix ? `?${suffix}` : "";
}
export function getPrivacyRetentionPolicy(
settings: ApiSettings,
scope: PrivacyRetentionPolicyScope,
@@ -92,7 +85,7 @@ export function getPrivacyRetentionPolicy(
): Promise<PrivacyRetentionPolicyScopeResponse> {
return apiFetch(
settings,
`/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${retentionPolicyQuery(scopeId)}`
apiPath(`/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}`, { scope_id: scopeId })
);
}
@@ -103,7 +96,7 @@ export function explainPrivacyRetentionPolicy(
): Promise<PrivacyRetentionPolicyExplainResponse> {
return apiFetch(
settings,
`/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}/explain${retentionPolicyQuery(scopeId)}`
apiPath(`/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}/explain`, { scope_id: scopeId })
);
}
@@ -116,7 +109,7 @@ export function updatePrivacyRetentionPolicy(
): Promise<PrivacyRetentionPolicyScopeResponse> {
return apiFetch(
settings,
`/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${retentionPolicyQuery(scopeId)}`,
apiPath(`/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}`, { scope_id: scopeId }),
{ method: "PUT", body: JSON.stringify({ policy, change_request_id: changeRequestId ?? null }) }
);
}

View File

@@ -0,0 +1,103 @@
import type { ReactNode } from "react";
import FormField from "./FormField";
import PasswordField from "./PasswordField";
export type CredentialValues = {
username?: string | null;
password?: string | null;
};
export type CredentialFieldsProps = {
values: CredentialValues;
onChange: (patch: Partial<CredentialValues>) => void;
disabled?: boolean;
usernameDisabled?: boolean;
passwordDisabled?: boolean;
savedPassword?: boolean;
savedPasswordPlaceholder?: string;
heading?: ReactNode;
headingClassName?: string;
usernameLabel?: ReactNode;
passwordLabel?: ReactNode;
usernamePlaceholder?: string;
passwordPlaceholder?: string;
usernameAutoComplete?: string;
passwordAutoComplete?: string;
showUsername?: boolean;
showPassword?: boolean;
};
export type CredentialPanelProps = CredentialFieldsProps & {
className?: string;
gridClassName?: string;
children?: ReactNode;
};
export function CredentialFields({
values,
onChange,
disabled = false,
usernameDisabled = disabled,
passwordDisabled = disabled,
savedPassword = false,
savedPasswordPlaceholder = "••••••••",
heading,
headingClassName = "credential-panel-heading",
usernameLabel = "i18n:govoplan-core.username.84c29015",
passwordLabel = "i18n:govoplan-core.password.8be3c943",
usernamePlaceholder,
passwordPlaceholder,
usernameAutoComplete = "username",
passwordAutoComplete = "new-password",
showUsername = true,
showPassword = true
}: CredentialFieldsProps) {
return (
<>
{heading && <div className={headingClassName}>{heading}</div>}
{showUsername &&
<FormField label={usernameLabel}>
<input
value={stringValue(values.username)}
disabled={usernameDisabled}
autoComplete={usernameAutoComplete}
placeholder={usernamePlaceholder}
onChange={(event) => onChange({ username: event.target.value })} />
</FormField>
}
{showPassword &&
<FormField label={passwordLabel}>
<PasswordField
value={stringValue(values.password)}
disabled={passwordDisabled}
saved={savedPassword}
savedPlaceholder={savedPasswordPlaceholder}
placeholder={passwordPlaceholder}
autoComplete={passwordAutoComplete}
onValueChange={(password) => onChange({ password })} />
</FormField>
}
</>);
}
export default function CredentialPanel({
className = "",
gridClassName = "",
children,
...fieldProps
}: CredentialPanelProps) {
return (
<div className={`credential-panel ${className}`.trim()}>
<div className={`credential-panel-grid ${gridClassName}`.trim()}>
<CredentialFields {...fieldProps} />
</div>
{children && <div className="credential-panel-extra">{children}</div>}
</div>);
}
function stringValue(value: string | number | null | undefined): string {
if (value === null || value === undefined) return "";
return String(value);
}

View File

@@ -0,0 +1,21 @@
import type { ReactNode } from "react";
import HoverTooltip from "./HoverTooltip";
export type DisabledActionTooltipProps = {
reason?: ReactNode;
children: ReactNode;
className?: string;
};
export default function DisabledActionTooltip({ reason, children, className = "" }: DisabledActionTooltipProps) {
if (!reason) return <>{children}</>;
return (
<HoverTooltip
content={reason}
tone="danger"
className={`disabled-action-tooltip ${className}`.trim()}
triggerTabIndex={0}>
{children}
</HoverTooltip>
);
}

View File

@@ -0,0 +1,228 @@
import type { CSSProperties, ReactNode } from "react";
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { usePlatformLanguage } from "../i18n/LanguageContext";
export type HoverTooltipTone = "default" | "danger";
type TooltipPosition = {
top: number;
left: number;
arrowLeft: number;
placement: "top" | "bottom";
};
export type HoverTooltipProps = {
content: ReactNode;
children: ReactNode;
className?: string;
tone?: HoverTooltipTone;
ariaLabel?: string;
triggerTabIndex?: number;
openDelayMs?: number;
};
const VIEWPORT_MARGIN = 12;
const TRIGGER_GAP = 10;
const BASE_TOOLTIP_STYLE: CSSProperties = {
position: "fixed",
zIndex: 20000,
width: "max-content",
maxWidth: "min(320px, calc(100vw - 48px))",
borderRadius: 7,
boxShadow: "var(--shadow-popover)",
fontSize: 12,
lineHeight: 1.4,
padding: "9px 10px",
whiteSpace: "normal",
pointerEvents: "none"
};
function clamp(value: number, min: number, max: number) {
if (max < min) return min;
return Math.min(Math.max(value, min), max);
}
function tooltipStyleForTone(tone: HoverTooltipTone): CSSProperties {
if (tone === "danger") {
return {
border: "1px solid var(--border-danger-soft)",
background: "var(--danger-muted-bg)",
color: "var(--danger-text-tooltip)",
fontWeight: 700
};
}
return {
border: "1px solid var(--line-dark)",
background: "var(--surface)",
color: "var(--text)",
fontWeight: 500
};
}
function arrowStyleForTone(tone: HoverTooltipTone): Pick<CSSProperties, "borderColor" | "background"> {
if (tone === "danger") {
return {
borderColor: "var(--border-danger-soft)",
background: "var(--danger-muted-bg)"
};
}
return {
borderColor: "var(--line-dark)",
background: "var(--surface)"
};
}
export default function HoverTooltip({
content,
children,
className = "",
tone = "default",
ariaLabel,
triggerTabIndex,
openDelayMs = 350
}: HoverTooltipProps) {
const { translateText } = usePlatformLanguage();
const tooltipId = useId();
const triggerRef = useRef<HTMLSpanElement | null>(null);
const tooltipRef = useRef<HTMLDivElement | null>(null);
const openTimerRef = useRef<number | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState<TooltipPosition | null>(null);
const clearOpenTimer = useCallback(() => {
if (openTimerRef.current !== null) {
window.clearTimeout(openTimerRef.current);
openTimerRef.current = null;
}
}, []);
const openWithDelay = useCallback(() => {
if (!content) return;
clearOpenTimer();
openTimerRef.current = window.setTimeout(() => {
openTimerRef.current = null;
setIsOpen(true);
}, openDelayMs);
}, [clearOpenTimer, content, openDelayMs]);
const close = useCallback(() => {
clearOpenTimer();
setIsOpen(false);
}, [clearOpenTimer]);
const updatePosition = useCallback(() => {
const trigger = triggerRef.current;
const tooltip = tooltipRef.current;
if (!trigger || !tooltip) return;
const triggerRect = trigger.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
const triggerCenterX = triggerRect.left + triggerRect.width / 2;
const preferredLeft = triggerCenterX - tooltipRect.width / 2;
const left = clamp(preferredLeft, VIEWPORT_MARGIN, window.innerWidth - tooltipRect.width - VIEWPORT_MARGIN);
const topCandidate = triggerRect.top - tooltipRect.height - TRIGGER_GAP;
const hasRoomAbove = topCandidate >= VIEWPORT_MARGIN;
const bottomCandidate = triggerRect.bottom + TRIGGER_GAP;
const top = hasRoomAbove
? topCandidate
: clamp(bottomCandidate, VIEWPORT_MARGIN, window.innerHeight - tooltipRect.height - VIEWPORT_MARGIN);
setPosition({
top,
left,
arrowLeft: clamp(triggerCenterX - left, 12, tooltipRect.width - 12),
placement: hasRoomAbove ? "top" : "bottom"
});
}, []);
useLayoutEffect(() => {
if (!isOpen) {
setPosition(null);
return;
}
updatePosition();
const frame = window.requestAnimationFrame(updatePosition);
return () => window.cancelAnimationFrame(frame);
}, [isOpen, updatePosition]);
useEffect(() => {
if (!isOpen) return undefined;
const handleScrollOrResize = () => updatePosition();
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") close();
};
window.addEventListener("scroll", handleScrollOrResize, true);
window.addEventListener("resize", handleScrollOrResize);
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("scroll", handleScrollOrResize, true);
window.removeEventListener("resize", handleScrollOrResize);
window.removeEventListener("keydown", handleKeyDown);
};
}, [close, isOpen, updatePosition]);
useEffect(() => clearOpenTimer, [clearOpenTimer]);
const translatedAriaLabel = ariaLabel ? translateText(ariaLabel) : undefined;
const tooltipStyle: CSSProperties = {
...BASE_TOOLTIP_STYLE,
...tooltipStyleForTone(tone),
top: position?.top ?? -9999,
left: position?.left ?? -9999,
opacity: position ? 1 : 0
};
const arrowColors = arrowStyleForTone(tone);
const arrowStyle: CSSProperties = position?.placement === "bottom"
? {
position: "absolute",
left: position.arrowLeft,
top: 0,
width: 9,
height: 9,
borderLeft: "1px solid",
borderTop: "1px solid",
...arrowColors,
transform: "translate(-50%, -5px) rotate(45deg)"
}
: {
position: "absolute",
left: position?.arrowLeft ?? 16,
top: "100%",
width: 9,
height: 9,
borderRight: "1px solid",
borderBottom: "1px solid",
...arrowColors,
transform: "translate(-50%, -5px) rotate(45deg)"
};
return (
<>
<span
ref={triggerRef}
className={className}
tabIndex={triggerTabIndex}
aria-label={translatedAriaLabel}
aria-describedby={isOpen ? tooltipId : undefined}
onMouseEnter={openWithDelay}
onMouseLeave={close}
onFocus={openWithDelay}
onBlur={close}>
{children}
</span>
{isOpen && typeof document !== "undefined" && createPortal(
<div ref={tooltipRef} id={tooltipId} role="tooltip" style={tooltipStyle}>
{typeof content === "string" ? translateText(content) : content}
<span aria-hidden="true" style={arrowStyle} />
</div>,
document.body
)}
</>
);
}

View File

@@ -1,4 +1,5 @@
import { i18nMessage } from "../../i18n/LanguageContext";import { useEffect, useId, useMemo, useRef, useState } from "react";
import { i18nMessage, usePlatformLanguage } from "../../i18n/LanguageContext";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import type { CSSProperties, KeyboardEvent } from "react";
import { createPortal } from "react-dom";
import Button from "../Button";
@@ -15,6 +16,7 @@ type EmailAddressInputProps = {
value: MailboxAddress[];
onChange?: (value: MailboxAddress[]) => void;
onAddressAdded?: (address: MailboxAddress) => void;
onSuggestionQueryChange?: (query: string) => void;
suggestions?: MailboxAddress[];
allowMultiple?: boolean;
clearOnAdd?: boolean;
@@ -31,6 +33,7 @@ export default function EmailAddressInput({
value,
onChange,
onAddressAdded,
onSuggestionQueryChange,
suggestions = [],
allowMultiple = true,
clearOnAdd = false,
@@ -42,6 +45,7 @@ export default function EmailAddressInput({
compact = false,
showAddButton
}: EmailAddressInputProps) {
const { translateText } = usePlatformLanguage();
const inputId = useId();
const normalizedValue = useMemo(() => dedupeAddresses(value), [value]);
const normalizedSuggestions = useMemo(() => dedupeAddresses(suggestions), [suggestions]);
@@ -51,9 +55,18 @@ export default function EmailAddressInput({
const [dialogEmail, setDialogEmail] = useState("");
const [error, setError] = useState("");
const [popoverStyle, setPopoverStyle] = useState<CSSProperties>({});
const lastSuggestionQueryRef = useRef<string | null>(null);
const addButtonRef = useRef<HTMLButtonElement | null>(null);
const canUseAddButton = showAddButton ?? allowMultiple;
useEffect(() => {
const query = entryText.trim();
if (query === "" && lastSuggestionQueryRef.current === null) return;
if (query === lastSuggestionQueryRef.current) return;
lastSuggestionQueryRef.current = query;
onSuggestionQueryChange?.(query);
}, [entryText, onSuggestionQueryChange]);
const filteredSuggestions = useMemo(() => {
const query = entryText.trim().toLowerCase();
if (!query) return normalizedSuggestions.slice(0, 6);
@@ -168,18 +181,18 @@ export default function EmailAddressInput({
if (event.key === "Escape") setDialogOpen(false);
}}>
<h4 id={`${inputId}-dialog-title`}>i18n:govoplan-core.add_address.a71075c4</h4>
<h4 id={`${inputId}-dialog-title`}>{translateText("i18n:govoplan-core.add_address.a71075c4")}</h4>
<label>
<span>i18n:govoplan-core.name.709a2322</span>
<input value={dialogName} onChange={(event) => setDialogName(event.target.value)} placeholder={namePlaceholder} autoFocus />
<span>{translateText("i18n:govoplan-core.name.709a2322")}</span>
<input value={dialogName} onChange={(event) => setDialogName(event.target.value)} placeholder={translateText(namePlaceholder)} autoFocus />
</label>
<label>
<span>i18n:govoplan-core.email_address.c94d3175</span>
<span>{translateText("i18n:govoplan-core.email_address.c94d3175")}</span>
<input value={dialogEmail} onChange={(event) => setDialogEmail(event.target.value)} placeholder={emailPlaceholder} inputMode="email" />
</label>
<div className="button-row compact-actions">
<Button type="button" onClick={() => setDialogOpen(false)}>i18n:govoplan-core.cancel.77dfd213</Button>
<Button type="button" variant="primary" onClick={commitDialogAddress}>{addLabel}</Button>
<Button type="button" onClick={() => setDialogOpen(false)}>{translateText("i18n:govoplan-core.cancel.77dfd213")}</Button>
<Button type="button" variant="primary" onClick={commitDialogAddress}>{translateText(addLabel)}</Button>
</div>
</div>,
document.body
@@ -189,11 +202,11 @@ export default function EmailAddressInput({
<div className={`email-address-input ${compact ? "compact" : ""} ${disabled ? "disabled" : ""} ${canUseAddButton ? "has-add-button" : ""}`}>
<div className={`email-address-editor ${error ? "has-error" : ""}`}>
<div className="email-chip-list" aria-live="polite">
{normalizedValue.length === 0 && !entryText && <span className="email-chip-empty">{emptyText}</span>}
{normalizedValue.length === 0 && !entryText && <span className="email-chip-empty">{translateText(emptyText)}</span>}
{normalizedValue.map((address) => {
const valid = isValidEmailAddress(address.email);
return (
<span className={`email-chip ${valid ? "" : "invalid"}`} key={address.email} title={valid ? address.email : "i18n:govoplan-core.invalid_email_address.9e4ee6d7"}>
<span className={`email-chip ${valid ? "" : "invalid"}`} key={address.email} title={valid ? address.email : translateText("i18n:govoplan-core.invalid_email_address.9e4ee6d7")}>
<span className="email-chip-main">{addressDisplayName(address)}</span>
{address.name && <span className="email-chip-address">{address.email}</span>}
{!disabled &&
@@ -217,11 +230,11 @@ export default function EmailAddressInput({
setError("");
}}
onKeyDown={handleTextKeyDown}
placeholder={i18nMessage("i18n:govoplan-core.value_value.27085f09", { value0: namePlaceholder, value1: emailPlaceholder })}
aria-label="i18n:govoplan-core.type_a_name_and_email_address_then_press_enter.7c8d43f0" />
placeholder={translateText(i18nMessage("i18n:govoplan-core.value_value.27085f09", { value0: translateText(namePlaceholder), value1: emailPlaceholder }))}
aria-label={translateText("i18n:govoplan-core.type_a_name_and_email_address_then_press_enter.7c8d43f0")} />
{canUseAddButton &&
<button ref={addButtonRef} type="button" className="email-address-plus" aria-label="i18n:govoplan-core.open_address_form.f8ee560f" title="i18n:govoplan-core.add_address_with_form.13b3b3e7" onClick={() => setDialogOpen((open) => !open)}>
<button ref={addButtonRef} type="button" className="email-address-plus" aria-label={translateText("i18n:govoplan-core.open_address_form.f8ee560f")} title={translateText("i18n:govoplan-core.add_address_with_form.13b3b3e7")} onClick={() => setDialogOpen((open) => !open)}>
+
</button>
}
@@ -230,7 +243,7 @@ export default function EmailAddressInput({
</div>
{!disabled && filteredSuggestions.length > 0 && entryText.trim() &&
<div className="email-address-suggestions" role="listbox" aria-label="i18n:govoplan-core.address_suggestions.45ba4a20">
<div className="email-address-suggestions" role="listbox" aria-label={translateText("i18n:govoplan-core.address_suggestions.45ba4a20")}>
{filteredSuggestions.map((item) =>
<button type="button" key={item.email} onClick={() => applySuggestion(item)} role="option">
<span>{addressDisplayName(item)}</span>
@@ -239,7 +252,7 @@ export default function EmailAddressInput({
)}
</div>
}
{error && <p className="form-help danger-text">{error}</p>}
{error && <p className="form-help danger-text">{translateText(error)}</p>}
{addressDialog}
</div>);

View File

@@ -1,188 +1,20 @@
import type { CSSProperties, ReactNode } from "react";
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { usePlatformLanguage } from "../../i18n/LanguageContext";
import type { ReactNode } from "react";
import HoverTooltip from "../HoverTooltip";
type InlineHelpProps = {
children: ReactNode;
className?: string;
};
type TooltipPosition = {
top: number;
left: number;
arrowLeft: number;
placement: "top" | "bottom";
};
const OPEN_DELAY_MS = 350;
const VIEWPORT_MARGIN = 12;
const TRIGGER_GAP = 10;
const tooltipBaseStyle: CSSProperties = {
position: "fixed",
zIndex: 20000,
width: "max-content",
maxWidth: "min(320px, calc(100vw - 48px))",
border: "1px solid var(--line-dark)",
borderRadius: 7,
background: "var(--surface)",
boxShadow: "var(--shadow-popover)",
color: "var(--text)",
fontSize: 12,
fontWeight: 500,
lineHeight: 1.4,
padding: "9px 10px",
whiteSpace: "normal",
pointerEvents: "none"
};
function clamp(value: number, min: number, max: number) {
if (max < min) return min;
return Math.min(Math.max(value, min), max);
}
export default function InlineHelp({ children, className = "" }: InlineHelpProps) {
const { translateText } = usePlatformLanguage();
const tooltipId = useId();
const triggerRef = useRef<HTMLSpanElement | null>(null);
const tooltipRef = useRef<HTMLDivElement | null>(null);
const openTimerRef = useRef<number | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState<TooltipPosition | null>(null);
const clearOpenTimer = useCallback(() => {
if (openTimerRef.current !== null) {
window.clearTimeout(openTimerRef.current);
openTimerRef.current = null;
}
}, []);
const openWithDelay = useCallback(() => {
clearOpenTimer();
openTimerRef.current = window.setTimeout(() => {
openTimerRef.current = null;
setIsOpen(true);
}, OPEN_DELAY_MS);
}, [clearOpenTimer]);
const close = useCallback(() => {
clearOpenTimer();
setIsOpen(false);
}, [clearOpenTimer]);
const updatePosition = useCallback(() => {
const trigger = triggerRef.current;
const tooltip = tooltipRef.current;
if (!trigger || !tooltip) return;
const triggerRect = trigger.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
const triggerCenterX = triggerRect.left + triggerRect.width / 2;
const preferredLeft = triggerCenterX - tooltipRect.width / 2;
const left = clamp(preferredLeft, VIEWPORT_MARGIN, window.innerWidth - tooltipRect.width - VIEWPORT_MARGIN);
const topCandidate = triggerRect.top - tooltipRect.height - TRIGGER_GAP;
const hasRoomAbove = topCandidate >= VIEWPORT_MARGIN;
const bottomCandidate = triggerRect.bottom + TRIGGER_GAP;
const top = hasRoomAbove ?
topCandidate :
clamp(bottomCandidate, VIEWPORT_MARGIN, window.innerHeight - tooltipRect.height - VIEWPORT_MARGIN);
setPosition({
top,
left,
arrowLeft: clamp(triggerCenterX - left, 12, tooltipRect.width - 12),
placement: hasRoomAbove ? "top" : "bottom"
});
}, []);
useLayoutEffect(() => {
if (!isOpen) {
setPosition(null);
return;
}
updatePosition();
const frame = window.requestAnimationFrame(updatePosition);
return () => window.cancelAnimationFrame(frame);
}, [isOpen, updatePosition]);
useEffect(() => {
if (!isOpen) return undefined;
const handleScrollOrResize = () => updatePosition();
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") close();
};
window.addEventListener("scroll", handleScrollOrResize, true);
window.addEventListener("resize", handleScrollOrResize);
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("scroll", handleScrollOrResize, true);
window.removeEventListener("resize", handleScrollOrResize);
window.removeEventListener("keydown", handleKeyDown);
};
}, [close, isOpen, updatePosition]);
useEffect(() => clearOpenTimer, [clearOpenTimer]);
if (!children) return null;
const tooltipStyle: CSSProperties = {
...tooltipBaseStyle,
top: position?.top ?? -9999,
left: position?.left ?? -9999,
opacity: position ? 1 : 0
};
const arrowStyle: CSSProperties = position?.placement === "bottom" ?
{
position: "absolute",
left: position.arrowLeft,
top: 0,
width: 9,
height: 9,
borderLeft: "1px solid var(--line-dark)",
borderTop: "1px solid var(--line-dark)",
background: "var(--surface)",
transform: "translate(-50%, -5px) rotate(45deg)"
} :
{
position: "absolute",
left: position?.arrowLeft ?? 16,
top: "100%",
width: 9,
height: 9,
borderRight: "1px solid var(--line-dark)",
borderBottom: "1px solid var(--line-dark)",
background: "var(--surface)",
transform: "translate(-50%, -5px) rotate(45deg)"
};
return (
<>
<span
ref={triggerRef}
className={`inline-help ${className}`.trim()}
tabIndex={-1}
aria-label={translateText("i18n:govoplan-core.show_field_help.e3dfe98f")}
aria-describedby={isOpen ? tooltipId : undefined}
onMouseEnter={openWithDelay}
onMouseLeave={close}
onFocus={openWithDelay}
onBlur={close}>
<span className="inline-help-mark" aria-hidden="true">?</span>
</span>
{isOpen && createPortal(
<div ref={tooltipRef} id={tooltipId} role="tooltip" style={tooltipStyle}>
{typeof children === "string" ? translateText(children) : children}
<span aria-hidden="true" style={arrowStyle} />
</div>,
document.body
)}
</>);
<HoverTooltip
content={children}
className={`inline-help ${className}`.trim()}
ariaLabel="i18n:govoplan-core.show_field_help.e3dfe98f"
triggerTabIndex={-1}>
<span className="inline-help-mark" aria-hidden="true">?</span>
</HoverTooltip>
);
}

View File

@@ -1,8 +1,8 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import Button from "../Button";
import { CredentialFields } from "../CredentialPanel";
import DismissibleAlert from "../DismissibleAlert";
import FormField from "../FormField";
import PasswordField from "../PasswordField";
import SegmentedControl from "../SegmentedControl";
import ToggleSwitch from "../ToggleSwitch";
@@ -48,6 +48,9 @@ export type MailServerFolderLookupResult = {
details?: Record<string, unknown> | null;
};
export type MailServerSettingsSection = "smtp" | "imap" | "advanced";
export type MailServerSettingsMode = "all" | "server" | "credentials";
export type MailServerSettingsPanelProps = {
smtp: MailServerSmtpSettings;
imap: MailServerImapSettings;
@@ -97,12 +100,13 @@ export type MailServerSettingsPanelProps = {
className?: string;
floatingResults?: boolean;
initialSection?: MailServerSettingsSection;
visibleSections?: readonly MailServerSettingsSection[];
mode?: MailServerSettingsMode;
};
export const mailServerSecurityOptions = ["plain", "tls", "starttls"] as const;
export type MailServerSecurityOption = typeof mailServerSecurityOptions[number];
const securityOptions = mailServerSecurityOptions;
type MailServerSettingsSection = "smtp" | "imap" | "advanced";
export function defaultSmtpPort(security: MailServerSecurity | null | undefined): number {
if (security === "tls") return 465;
@@ -234,7 +238,9 @@ export default function MailServerSettingsPanel({
disabled = false,
className = "",
floatingResults = false,
initialSection = "smtp"
initialSection = "smtp",
visibleSections,
mode = "all"
}: MailServerSettingsPanelProps) {
const smtpFieldsDisabled = disabled || smtpDisabled;
const smtpCredentialFieldsDisabled = disabled || smtpCredentialDisabled;
@@ -254,11 +260,29 @@ export default function MailServerSettingsPanel({
const appendTargetHelp = append ?
"i18n:govoplan-core.folder_for_sent_message_copies_leave_as_auto_unl.a62586e9" :
"i18n:govoplan-core.folder_used_when_this_imap_account_is_used_for_s.08503f5e";
const [activeSection, setActiveSection] = useState<MailServerSettingsSection>(initialSection);
const sections: {id: MailServerSettingsSection;label: string;}[] = [
const allSections: {id: MailServerSettingsSection;label: string;}[] = [
{ id: "smtp", label: "i18n:govoplan-core.smtp.efff9cca" },
{ id: "imap", label: "i18n:govoplan-core.imap.271f9ef2" },
{ id: "advanced", label: "i18n:govoplan-core.advanced.4d064726" }];
const visibleSectionSet = new Set(visibleSections ?? allSections.map((section) => section.id));
const sections = allSections.filter((section) => visibleSectionSet.has(section.id));
const fallbackSection = sections[0]?.id ?? "smtp";
const resolvedInitialSection = sections.some((section) => section.id === initialSection) ? initialSection : fallbackSection;
const sectionKey = sections.map((section) => section.id).join("|");
const [activeSection, setActiveSection] = useState<MailServerSettingsSection>(resolvedInitialSection);
const showSectionSwitcher = sections.length > 1;
const showServerFields = mode !== "credentials";
const showCredentialFields = mode !== "server";
useEffect(() => {
if (!sections.some((section) => section.id === activeSection)) {
setActiveSection(resolvedInitialSection);
return;
}
if (initialSection !== activeSection && sections.some((section) => section.id === initialSection)) {
setActiveSection(initialSection);
}
}, [activeSection, initialSection, resolvedInitialSection, sectionKey]);
function patchSmtpSecurity(security: MailServerSecurity) {
@@ -283,6 +307,7 @@ export default function MailServerSettingsPanel({
return (
<div className={`mail-server-settings-panel ${className}`.trim()}>
{showSectionSwitcher &&
<SegmentedControl
className="mail-server-segmented-control"
size="equal"
@@ -291,27 +316,30 @@ export default function MailServerSettingsPanel({
onChange={setActiveSection}
options={sections}
/>
}
<div className="mail-server-settings-view">
{activeSection === "smtp" &&
<section className="mail-server-subsection" role="tabpanel" aria-label="i18n:govoplan-core.smtp_settings.f103e570">
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
{showServerFields &&
<>
<div className="mail-server-field-heading">i18n:govoplan-core.server.cb0cb170</div>
<FormField label="i18n:govoplan-core.host.3960ec4c"><input value={stringValue(smtp.host)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ host: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-core.port.fe035157"><input type="number" min={1} max={65535} value={smtpPort} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ port: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-core.security.f25ce1b8"><SecuritySelect value={smtpSecurity} disabled={smtpFieldsDisabled} onChange={patchSmtpSecurity} /></FormField>
<div className="mail-server-field-heading">i18n:govoplan-core.credentials.dd097a22</div>
<FormField label="i18n:govoplan-core.username.84c29015"><input value={stringValue(smtpCredentialValues.username)} disabled={smtpCredentialFieldsDisabled} onChange={(event) => patchSmtpCredentials({ username: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-core.password.8be3c943">
<PasswordField
value={stringValue(smtpCredentialValues.password)}
</>
}
{showCredentialFields &&
<CredentialFields
values={smtpCredentialValues}
onChange={patchSmtpCredentials}
disabled={smtpCredentialFieldsDisabled}
saved={smtpPasswordSaved}
savedPlaceholder={smtpSavedPasswordPlaceholder}
autoComplete="new-password"
onValueChange={(password) => patchSmtpCredentials({ password })} />
</FormField>
savedPassword={smtpPasswordSaved}
savedPasswordPlaceholder={smtpSavedPasswordPlaceholder}
heading="i18n:govoplan-core.credentials.dd097a22"
headingClassName="mail-server-field-heading" />
}
</div>
{onTestSmtp &&
<div className="button-row compact-actions mail-server-actions">
@@ -325,22 +353,24 @@ export default function MailServerSettingsPanel({
{activeSection === "imap" &&
<section className="mail-server-subsection" role="tabpanel" aria-label="i18n:govoplan-core.imap_settings.ab8d8247">
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
{showServerFields &&
<>
<div className="mail-server-field-heading">i18n:govoplan-core.server.cb0cb170</div>
<FormField label="i18n:govoplan-core.host.3960ec4c"><input value={stringValue(imap.host)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ host: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-core.port.fe035157"><input type="number" min={1} max={65535} value={imapPort} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ port: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-core.security.f25ce1b8"><SecuritySelect value={imapSecurity} disabled={imapFieldsDisabled} onChange={patchImapSecurity} /></FormField>
<div className="mail-server-field-heading">i18n:govoplan-core.credentials.dd097a22</div>
<FormField label="i18n:govoplan-core.username.84c29015"><input value={stringValue(imapCredentialValues.username)} disabled={imapCredentialFieldsDisabled} onChange={(event) => patchImapCredentials({ username: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-core.password.8be3c943">
<PasswordField
value={stringValue(imapCredentialValues.password)}
</>
}
{showCredentialFields &&
<CredentialFields
values={imapCredentialValues}
onChange={patchImapCredentials}
disabled={imapCredentialFieldsDisabled}
saved={imapPasswordSaved}
savedPlaceholder={imapSavedPasswordPlaceholder}
autoComplete="new-password"
onValueChange={(password) => patchImapCredentials({ password })} />
</FormField>
savedPassword={imapPasswordSaved}
savedPasswordPlaceholder={imapSavedPasswordPlaceholder}
heading="i18n:govoplan-core.credentials.dd097a22"
headingClassName="mail-server-field-heading" />
}
</div>
{onTestImap &&
<div className="button-row compact-actions mail-server-actions">

View File

@@ -150,7 +150,7 @@ type ColumnResizeState = {
behavior: DataGridResizeBehavior;
};
const STORAGE_PREFIX = "multimailer.datagrid.";
const STORAGE_PREFIX = "govoplan.datagrid.";
const FILTER_POPOVER_WIDTH = 320;
const FILTER_POPOVER_MARGIN = 12;
const MIN_HEADER_LABEL_WIDTH = 72;

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import type { ApiSettings, AuthInfo, FilesConnectorsUiCapability, MailProfilesUiCapability, UserUiPreferences, UserUiTheme } from "../../types";
import type { ApiSettings, AuthInfo, AuthUpdate, FilesConnectorsUiCapability, MailProfilesUiCapability, SettingsSectionContribution, SettingsSectionsUiCapability, UserUiPreferences, UserUiTheme } from "../../types";
import Card from "../../components/Card";
import FormField from "../../components/FormField";
import PasswordField from "../../components/PasswordField";
@@ -8,15 +8,16 @@ import Button from "../../components/Button";
import PageTitle from "../../components/PageTitle";
import ToggleSwitch from "../../components/ToggleSwitch";
import { apiFetch } from "../../api/client";
import { updateProfile } from "../../api/auth";
import { fetchAuthProfile, fetchAuthRoles, updateProfile } from "../../api/auth";
import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav";
import DismissibleAlert from "../../components/DismissibleAlert";
import SegmentedControl from "../../components/SegmentedControl";
import { useUnsavedChanges, useUnsavedDraftGuard } from "../../components/UnsavedChangesGuard";
import { usePlatformUiCapability } from "../../platform/ModuleContext";
import { usePlatformUiCapabilities, usePlatformUiCapability } from "../../platform/ModuleContext";
import { hasAnyScope, hasScope } from "../../utils/permissions";
import { usePlatformLanguage } from "../../i18n/LanguageContext";
type SettingsSection = "profile" | "mail-profiles" | "file-connectors" | "interface" | "workspace" | "local-connection" | "notifications";
type SettingsSection = "profile" | "mail-profiles" | "file-connectors" | "interface" | "workspace" | "local-connection" | string;
const DEFAULT_UI_PREFERENCES: UserUiPreferences = {
compact_tables: false,
@@ -32,8 +33,8 @@ const UI_THEME_OPTIONS: Array<{ value: UserUiTheme; label: string }> = [
{ value: "dark", label: "i18n:govoplan-core.dark_theme.164a90d9" }
];
function settingsGroups(canUseMailProfiles: boolean, canUseFileConnectors: boolean): ModuleSubnavGroup<SettingsSection>[] {
return [
function settingsGroups(canUseMailProfiles: boolean, canUseFileConnectors: boolean, contributedSections: SettingsSectionContribution[]): ModuleSubnavGroup<SettingsSection>[] {
const groups: ModuleSubnavGroup<SettingsSection>[] = [
{
title: "i18n:govoplan-core.account.f967543b",
items: [
@@ -47,11 +48,29 @@ function settingsGroups(canUseMailProfiles: boolean, canUseFileConnectors: boole
items: [
{ id: "interface", label: "i18n:govoplan-core.interface.7b4db7ef" },
{ id: "workspace", label: "i18n:govoplan-core.workspace.4ca0a75c" },
{ id: "local-connection", label: "i18n:govoplan-core.local_connection.42fba65a" },
{ id: "notifications", label: "i18n:govoplan-core.notifications.753a22b2" }]
{ id: "local-connection", label: "i18n:govoplan-core.local_connection.42fba65a" }]
}];
for (const section of contributedSections) {
const groupId = section.group || "ui";
const title = section.groupTitle || (groupId === "account" ? "i18n:govoplan-core.account.f967543b" : groupId === "ui" ? "i18n:govoplan-core.ui_settings.9e9cc5ea" : groupId);
let group = groups.find((item) => item.title === title);
if (!group) {
group = { title, items: [] };
groups.push(group);
}
group.items.push({ id: section.id, label: section.label });
}
for (const group of groups) {
group.items.sort((left, right) => {
const leftId = "id" in left ? left.id : "";
const rightId = "id" in right ? right.id : "";
return (sectionOrder(leftId, contributedSections) - sectionOrder(rightId, contributedSections)) || left.label.localeCompare(right.label);
});
}
return groups;
}
export default function SettingsPage({
@@ -64,19 +83,26 @@ export default function SettingsPage({
}: {settings: ApiSettings;auth: AuthInfo;onSettingsChange: (settings: ApiSettings) => void;onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void;}) {
}: {settings: ApiSettings;auth: AuthInfo;onSettingsChange: (settings: ApiSettings) => void;onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;}) {
const [searchParams, setSearchParams] = useSearchParams();
const { requestNavigation } = useUnsavedChanges();
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors");
const settingsSectionCapabilities = usePlatformUiCapabilities<SettingsSectionsUiCapability>("settings.sections");
const { language, languageLabel, selectableLanguages, availableLanguages, enabledLanguages, setLanguage } = usePlatformLanguage();
const MailProfileScopeManager = mailProfilesUi?.MailProfileScopeManager ?? null;
const FileConnectorScopeManager = fileConnectorsUi?.FileConnectorScopeManager ?? null;
const canUseMailProfiles = Boolean(MailProfileScopeManager) && hasAnyScope(auth, ["mail_servers:read", "mail_servers:write", "mail_servers:manage_credentials", "admin:policies:read", "admin:policies:write"]);
const canUseFileConnectors = Boolean(FileConnectorScopeManager) && hasAnyScope(auth, ["files:file:read", "files:file:admin", "admin:settings:read", "admin:settings:write"]);
const settingsSubnav = useMemo(() => settingsGroups(canUseMailProfiles, canUseFileConnectors), [canUseFileConnectors, canUseMailProfiles]);
const requestedSection = searchParams.get("section") as SettingsSection | null;
const active: SettingsSection = settingsSectionAvailable(settingsSubnav, requestedSection) ? requestedSection : "interface";
const contributedSections = useMemo(
() => settingsSectionCapabilities.flatMap((capability) => capability.sections ?? []).filter((section) => canUseSettingsContribution(auth, section)),
[auth, settingsSectionCapabilities]
);
const settingsSubnav = useMemo(() => settingsGroups(canUseMailProfiles, canUseFileConnectors, contributedSections), [canUseFileConnectors, canUseMailProfiles, contributedSections]);
const availableSectionIds = useMemo(() => new Set(settingsSubnav.flatMap((group) => group.items.flatMap((item) => "id" in item ? [item.id] : []))), [settingsSubnav]);
const requestedSection = searchParams.get("section");
const active: SettingsSection = settingsSectionAvailable(availableSectionIds, requestedSection) ? requestedSection : "interface";
const activeContributedSection = contributedSections.find((section) => section.id === active) ?? null;
const currentUiPreferences = normalizeUiPreferences(auth.user.ui_preferences);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState("");
@@ -118,12 +144,30 @@ export default function SettingsPage({
});
useEffect(() => {
if (requestedSection && !settingsSectionAvailable(settingsSubnav, requestedSection)) {
if (requestedSection && !settingsSectionAvailable(availableSectionIds, requestedSection)) {
const next = new URLSearchParams(searchParams);
next.set("section", "interface");
setSearchParams(next, { replace: true });
}
}, [requestedSection, searchParams, setSearchParams, settingsSubnav]);
}, [availableSectionIds, requestedSection, searchParams, setSearchParams]);
useEffect(() => {
if (auth.profile_loaded) return;
let cancelled = false;
fetchAuthProfile(settings)
.then((next) => {if (!cancelled) onAuthChange(next);})
.catch(() => undefined);
return () => {cancelled = true;};
}, [auth.profile_loaded, auth.user.id, auth.active_tenant?.id, auth.tenant.id, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
useEffect(() => {
if (auth.roles_loaded) return;
let cancelled = false;
fetchAuthRoles(settings)
.then((next) => {if (!cancelled) onAuthChange(next);})
.catch(() => undefined);
return () => {cancelled = true;};
}, [auth.roles_loaded, auth.user.id, auth.active_tenant?.id, auth.tenant.id, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
useEffect(() => {
setProfileName(auth.user.display_name || "");
@@ -329,12 +373,16 @@ export default function SettingsPage({
</select>
</FormField>
<FormField label="i18n:govoplan-core.theme.a797e309">
<select value={theme} onChange={(event) => setTheme(event.target.value as UserUiTheme)}>
{UI_THEME_OPTIONS.map((item) =>
<option key={item.value} value={item.value}>{item.label}</option>
)}
</select>
<SegmentedControl
options={UI_THEME_OPTIONS.map((item) => ({ id: item.value, label: item.label }))}
value={theme}
onChange={setTheme}
role="group"
size="equal"
width="fill"
ariaLabel="i18n:govoplan-core.theme.a797e309" />
</FormField>
<ThemePreview theme={theme} />
<dl className="detail-list compact-detail-list">
<div><dt>i18n:govoplan-core.theme.a797e309</dt><dd>{themeLabel(theme)}</dd></div>
<div><dt>i18n:govoplan-core.accent_color.e49578ed</dt><dd>i18n:govoplan-core.default_brand_accent.606ae693</dd></div>
@@ -413,24 +461,15 @@ export default function SettingsPage({
</div>
}
{active === "notifications" &&
<div className="dashboard-grid settings-dashboard-grid">
<Card title="i18n:govoplan-core.notification_preferences.0ead6c12">
<p className="muted">i18n:govoplan-core.prepared_for_later_personal_notification_prefere.3fe73f86</p>
<div className="placeholder-stack">
<span>i18n:govoplan-core.in_app_completion_notices.b68f2f4c</span>
<span>i18n:govoplan-core.email_summary_preferences.b6c1dfb1</span>
<span>i18n:govoplan-core.failure_and_warning_alerts.939d21c2</span>
</div>
</Card>
<Card title="i18n:govoplan-core.quiet_ui_mode.1b0bd558">
<div className="placeholder-stack">
<span>i18n:govoplan-core.mute_non_critical_banners.27b23a4a</span>
<span>i18n:govoplan-core.batch_repetitive_notices.cc893559</span>
<span>i18n:govoplan-core.keep_validation_and_send_warnings_visible.9d6e0cf4</span>
</div>
</Card>
</div>
{activeContributedSection &&
activeContributedSection.render({
settings,
auth,
onAuthChange,
activeSection: active,
availableSections: availableSectionIds,
selectSection
})
}
</div>
</section>
@@ -439,8 +478,30 @@ export default function SettingsPage({
}
function settingsSectionAvailable(groups: ModuleSubnavGroup<SettingsSection>[], section: SettingsSection | null | undefined): section is SettingsSection {
return Boolean(section && groups.some((group) => group.items.some((item) => "id" in item && item.id === section)));
function settingsSectionAvailable(sections: ReadonlySet<string>, section: string | null | undefined): section is SettingsSection {
return Boolean(section && sections.has(section));
}
function canUseSettingsContribution(auth: AuthInfo, section: SettingsSectionContribution): boolean {
if (section.allOf?.some((scope) => !hasScope(auth, scope))) {
return false;
}
if (section.anyOf && !hasAnyScope(auth, section.anyOf)) {
return false;
}
return true;
}
function sectionOrder(sectionId: string, contributedSections: SettingsSectionContribution[]): number {
const builtInOrder: Record<string, number> = {
profile: 10,
"mail-profiles": 20,
"file-connectors": 30,
interface: 10,
workspace: 20,
"local-connection": 30
};
return builtInOrder[sectionId] ?? contributedSections.find((section) => section.id === sectionId)?.order ?? 100;
}
function normalizeUiPreferences(value: Partial<UserUiPreferences> | null | undefined): UserUiPreferences {
@@ -457,3 +518,24 @@ function normalizeUiPreferences(value: Partial<UserUiPreferences> | null | undef
function themeLabel(value: UserUiTheme): string {
return UI_THEME_OPTIONS.find((item) => item.value === value)?.label ?? UI_THEME_OPTIONS[0].label;
}
function ThemePreview({ theme }: {theme: UserUiTheme;}) {
const variants: UserUiTheme[] = theme === "system" ? ["light", "dark"] : [theme];
return (
<div className="theme-preview-list" aria-label="i18n:govoplan-core.theme.a797e309">
{variants.map((variant) =>
<div key={variant} className="theme-preview" data-preview-theme={variant}>
<div className="theme-preview-header">
<span>{themeLabel(variant)}</span>
<i />
</div>
<div className="theme-preview-body">
<strong>GovOPlaN</strong>
<span />
<span />
</div>
</div>
)}
</div>);
}

View File

@@ -3,6 +3,26 @@ export * from "./types";
export * from "./api/client";
export * from "./api/auth";
export * from "./api/platform";
export * from "./api/adminCommon";
export { mailProfilePatternKeys, mailProfilePolicyLimitKeys } from "./api/mailContracts";
export type {
MailConnectionTestResponse,
MailImapFolderListResponse,
MailImapFolderResponse,
MailImapTestPayload,
MailPolicySourceStep,
MailProfilePatternRules,
MailProfilePolicyLimitKey,
MailProfilePolicyLimitPermissions,
MailProfilePolicyResponse,
MailServerProfileCredentialsPayload,
MailServerProfileListResponse,
MailServerProfilePayload,
MailSmtpTestPayload,
MailTransportCredentialsPayload,
MockMailboxMessage,
MockMailboxMessageResponse
} from "./api/mailContracts";
export * from "./api/privacyRetention";
export * from "./platform/modules";
export * from "./platform/ModuleContext";
@@ -32,8 +52,12 @@ export { default as ConfirmDialog } from "./components/ConfirmDialog";
export { default as ConnectionTree } from "./components/ConnectionTree";
export type { ConnectionTreeColumn, ConnectionTreeProps } from "./components/ConnectionTree";
export { default as ColorPickerField } from "./components/ColorPickerField";
export { default as CredentialPanel, CredentialFields } from "./components/CredentialPanel";
export type { CredentialFieldsProps, CredentialPanelProps, CredentialValues } from "./components/CredentialPanel";
export { default as DateField, TimeField, DateTimeField } from "./components/DateTimeField";
export { default as Dialog } from "./components/Dialog";
export { default as DisabledActionTooltip } from "./components/DisabledActionTooltip";
export type { DisabledActionTooltipProps } from "./components/DisabledActionTooltip";
export { default as DismissibleAlert } from "./components/DismissibleAlert";
export { default as EffectivePolicyBlock, EffectivePolicyValue } from "./components/EffectivePolicyBlock";
export type { EffectivePolicyBlockProps } from "./components/EffectivePolicyBlock";
@@ -44,6 +68,8 @@ export { default as GuidedConfigDialog } from "./components/GuidedConfigDialog";
export type { GuidedConfigDialogProps } from "./components/GuidedConfigDialog";
export { default as GuidedReviewList } from "./components/GuidedReviewList";
export type { GuidedReviewItem } from "./components/GuidedReviewList";
export { default as HoverTooltip } from "./components/HoverTooltip";
export type { HoverTooltipProps, HoverTooltipTone } from "./components/HoverTooltip";
export { default as LoadingFrame } from "./components/LoadingFrame";
export { default as LoadingIndicator } from "./components/LoadingIndicator";
export { default as ExplorerTree } from "./components/ExplorerTree";
@@ -71,7 +97,7 @@ export type { UnsavedDraftGuardOptions } from "./components/UnsavedChangesGuard"
export type { UnsavedChangesRegistration, UnsavedNavigationAction } from "./components/UnsavedChangesGuard";
export { default as EmailAddressInput } from "./components/email/EmailAddressInput";
export { default as MailServerSettingsPanel, MailServerActionResult, MailServerFolderLookupResultView, defaultImapPort, defaultSmtpPort, hasMailImapSettings, mailImapSettingsPayload, mailNumberOrDefault, mailNumberOrNull, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, mailTransportCredentialsPayloadFromRecords, normalizeMailServerSecurity } from "./components/mail/MailServerSettingsPanel";
export type { MailServerConnectionTestResult, MailServerCredentialSettings, MailServerFolderLookupResult, MailServerImapSettings, MailServerSecurity, MailServerSecurityOption, MailServerSettingsPanelProps, MailServerSmtpSettings } from "./components/mail/MailServerSettingsPanel";
export type { MailServerConnectionTestResult, MailServerCredentialSettings, MailServerFolderLookupResult, MailServerImapSettings, MailServerSecurity, MailServerSecurityOption, MailServerSettingsMode, MailServerSettingsPanelProps, MailServerSettingsSection, MailServerSmtpSettings } from "./components/mail/MailServerSettingsPanel";
export { default as FieldLabel } from "./components/help/FieldLabel";
export { default as InlineHelp } from "./components/help/InlineHelp";
export { default as DataGrid, DataGridEmptyAction, DataGridRowActions } from "./components/table/DataGrid";

View File

@@ -1,5 +1,5 @@
import { useLocation } from "react-router-dom";
import type { ApiSettings, AuthInfo, PlatformNavItem } from "../types";
import type { ApiSettings, AuthInfo, AuthUpdate, PlatformNavItem } from "../types";
import IconRail from "./IconRail";
import Titlebar from "./Titlebar";
import BreadcrumbBar from "./BreadcrumbBar";
@@ -9,7 +9,7 @@ type Props = {
settings: ApiSettings;
auth: AuthInfo | null;
onSettingsChange: (settings: ApiSettings) => void;
onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void;
onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;
publicMode?: boolean;
navItems?: PlatformNavItem[];
maintenanceMode?: { enabled: boolean; message?: string | null };

View File

@@ -10,6 +10,8 @@ import type { PlatformWebModule } from "../types";
import { helpContextForPathname, helpQueryForContext, type HelpContext } from "../utils/helpContext";
import { usePlatformLanguage } from "../i18n/LanguageContext";
const EXTERNAL_DOCS_BASE_URL = "https://govoplan.add-ideas.de";
export default function HelpMenu() {
const [open, setOpen] = useState(false);
const [aboutOpen, setAboutOpen] = useState(false);
@@ -50,8 +52,11 @@ export default function HelpMenu() {
}
function openDocs(type: "user" | "admin") {
if (!docsAvailable) return;
setOpen(false);
if (!docsAvailable) {
window.open(externalDocsUrl(type, helpContext), "_blank", "noopener,noreferrer");
return;
}
const params = new URLSearchParams({ type });
params.set("context", helpContext.id);
navigate(`/docs?${params.toString()}`);
@@ -79,10 +84,10 @@ export default function HelpMenu() {
<HelpCircle size={16} /> {translateText("i18n:govoplan-core.help.c47ae153")} <small>i18n:govoplan-core.f1.88bfad9c</small>
</button>
<hr />
<button className="dropdown-item" onClick={() => openDocs("user")} disabled={!docsAvailable} title={docsAvailable ? translateText("i18n:govoplan-core.open_user_documentation.084af515") : translateText("i18n:govoplan-core.docs_module_is_not_available.08eb0cd5")}>
<button className="dropdown-item" onClick={() => openDocs("user")} title={docsAvailable ? translateText("i18n:govoplan-core.open_user_documentation.084af515") : "Open hosted user documentation"}>
<BookOpen size={16} /> {translateText("i18n:govoplan-core.user_docs.1e38e8d3")}
</button>
<button className="dropdown-item" onClick={() => openDocs("admin")} disabled={!docsAvailable} title={docsAvailable ? translateText("i18n:govoplan-core.open_admin_documentation.6adbdae3") : translateText("i18n:govoplan-core.docs_module_is_not_available.08eb0cd5")}>
<button className="dropdown-item" onClick={() => openDocs("admin")} title={docsAvailable ? translateText("i18n:govoplan-core.open_admin_documentation.6adbdae3") : "Open hosted admin documentation"}>
<BookOpen size={16} /> {translateText("i18n:govoplan-core.admin_docs.bf504a56")}
</button>
<hr />
@@ -96,6 +101,11 @@ export default function HelpMenu() {
}
function externalDocsUrl(type: "user" | "admin", context: HelpContext): string {
const params = new URLSearchParams({ type, context: context.id });
return `${EXTERNAL_DOCS_BASE_URL}/?${params.toString()}`;
}
function ContextHelpModal({ context, onClose }: {context: HelpContext;onClose: () => void;}) {
const { translateText } = usePlatformLanguage();
return (

View File

@@ -1,4 +1,4 @@
import { Settings } from "lucide-react";
import { PanelLeftClose, PanelLeftOpen, Settings } from "lucide-react";
import { NavLink, useLocation } from "react-router-dom";
import { useEffect, useMemo, useState, type MouseEvent } from "react";
import type { AuthInfo, PlatformNavItem } from "../types";
@@ -7,6 +7,7 @@ import { usePlatformLanguage } from "../i18n/LanguageContext";
import { useGuardedNavigate } from "../components/UnsavedChangesGuard";
const MODULE_NAV_STORAGE_KEY = "govoplan.lastModuleNav";
const RAIL_EXPANDED_STORAGE_KEY = "govoplan.iconRailExpanded";
function visibleNavItems(auth: AuthInfo | null | undefined, navItems: PlatformNavItem[]): PlatformNavItem[] {
return [...navItems].
@@ -22,14 +23,11 @@ export default function IconRail({
compact = false,
auth = null,
navItems = []
}: {compact?: boolean;auth?: AuthInfo | null;navItems?: PlatformNavItem[];}) {
const location = useLocation();
const items = visibleNavItems(auth, navItems);
const [rememberedTargets, setRememberedTargets] = useState<Record<string, string>>(() => loadRememberedTargets());
const [expanded, setExpanded] = useState(() => loadRailExpanded());
const topLevelItems = useMemo(() => items.map((item) => item.to), [items]);
const { translateText } = usePlatformLanguage();
const navigate = useGuardedNavigate();
@@ -46,15 +44,27 @@ export default function IconRail({
});
}, [location.hash, location.pathname, location.search, topLevelItems]);
function toggleExpanded() {
setExpanded((current) => {
const next = !current;
saveRailExpanded(next);
return next;
});
}
function handleNavClick(event: MouseEvent<HTMLAnchorElement>, target: string) {
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return;
event.preventDefault();
navigate(target);
}
const railExpanded = !compact && expanded;
return (
<aside className={`icon-rail ${compact ? "compact" : ""}`}>
<div className="brand-mark" title="i18n:govoplan-core.govoplan.a84c0a85">i18n:govoplan-core.g.a36a6718</div>
<aside className={`icon-rail ${compact ? "compact" : ""} ${railExpanded ? "expanded" : ""}`}>
<div className="icon-rail-header">
<div className="brand-mark" title="i18n:govoplan-core.govoplan.a84c0a85">i18n:govoplan-core.g.a36a6718</div>
</div>
{!compact &&
<>
@@ -62,9 +72,11 @@ export default function IconRail({
{items.map(({ to, label, icon: Icon }) => {
const target = rememberedTargets[to] ?? to;
const active = modulePathActive(location.pathname, to);
const renderedLabel = translateText(label);
return (
<NavLink key={to} to={target} className={`icon-nav-item ${active ? "active" : ""}`} title={translateText(label)} onClick={(event) => handleNavClick(event, target)}>
{Icon ? <Icon size={20} /> : label.slice(0, 1)}
<NavLink key={to} to={target} className={`icon-nav-item ${active ? "active" : ""}`} title={renderedLabel} onClick={(event) => handleNavClick(event, target)}>
{Icon ? <Icon size={20} /> : <span className="icon-nav-fallback">{renderedLabel.slice(0, 1)}</span>}
<span className="icon-nav-label">{renderedLabel}</span>
</NavLink>);
})}
@@ -72,7 +84,17 @@ export default function IconRail({
<div className="icon-rail-bottom">
<NavLink to="/settings" className={({ isActive }) => `icon-nav-item ${isActive ? "active" : ""}`} title={translateText("i18n:govoplan-core.settings.c7f73bb5")} onClick={(event) => handleNavClick(event, "/settings")}>
<Settings size={20} />
<span className="icon-nav-label">{translateText("i18n:govoplan-core.settings.c7f73bb5")}</span>
</NavLink>
<button
type="button"
className="icon-nav-item icon-rail-toggle"
aria-label={railExpanded ? "Collapse navigation" : "Expand navigation"}
title={railExpanded ? "Collapse navigation" : "Expand navigation"}
onClick={toggleExpanded}>
{railExpanded ? <PanelLeftClose size={20} aria-hidden="true" /> : <PanelLeftOpen size={20} aria-hidden="true" />}
<span className="icon-nav-label">{railExpanded ? "Collapse" : "Expand"}</span>
</button>
</div>
</>
}
@@ -107,7 +129,23 @@ function saveRememberedTargets(targets: Record<string, string>): void {
try {
window.localStorage.setItem(MODULE_NAV_STORAGE_KEY, JSON.stringify(targets));
} catch {
// Remembered navigation is a convenience only.
}}
function loadRailExpanded(): boolean {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem(RAIL_EXPANDED_STORAGE_KEY) === "true";
} catch {
return false;
}
}
function saveRailExpanded(expanded: boolean): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(RAIL_EXPANDED_STORAGE_KEY, expanded ? "true" : "false");
} catch {
// Rail width is a presentation preference only.
}
}

View File

@@ -1,20 +1,27 @@
import { useRef, useState, useEffect } from "react";
import { Check, LogOut, Settings, UserCircle } from "lucide-react";
import type { ApiSettings, AuthInfo, AuthTenantMembership, LoginResponse } from "../types";
import { Bell, Check, LogOut, Settings, UserCircle } from "lucide-react";
import type { ApiSettings, AuthInfo, AuthTenantMembership, AuthUpdate, LoginResponse } from "../types";
import HelpMenu from "./HelpMenu";
import LanguageMenu from "./LanguageMenu";
import LoginModal from "../features/auth/LoginModal";
import DismissibleAlert from "../components/DismissibleAlert";
import { useGuardedNavigate, useUnsavedChanges } from "../components/UnsavedChangesGuard";
import { apiFetch, isApiError } from "../api/client";
import { logout, switchTenant } from "../api/auth";
import { hasAnyScope } from "../utils/permissions";
import { usePlatformLanguage } from "../i18n/LanguageContext";
import { usePlatformModules } from "../platform/ModuleContext";
type NotificationSummary = {
unread: number;
show_unread_badge?: boolean;
};
type Props = {
settings: ApiSettings;
auth: AuthInfo | null;
onSettingsChange: (settings: ApiSettings) => void;
onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void;
onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;
maintenanceMode?: {enabled: boolean;message?: string | null;};
};
@@ -26,9 +33,11 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
const [loginOpen, setLoginOpen] = useState(false);
const [switchingTenantId, setSwitchingTenantId] = useState<string | null>(null);
const [tenantError, setTenantError] = useState("");
const [unreadNotificationCount, setUnreadNotificationCount] = useState(0);
const accountRef = useRef<HTMLDivElement>(null);
const tenantRef = useRef<HTMLDivElement>(null);
const { translateText } = usePlatformLanguage();
const modules = usePlatformModules();
const activeTenant = auth?.active_tenant ?? auth?.tenant ?? null;
const tenants = auth?.tenants ?? (activeTenant ? [activeTenant] : []);
@@ -41,6 +50,8 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
"system:tenants:suspend"]
);
const showTenantControl = Boolean(activeTenant && (canSwitchTenant || canAdministerTenants));
const notificationsAvailable = modules.some((module) => module.id === "notifications" && module.routes?.some((route) => route.path === "/notifications"));
const notificationBadgeLabel = unreadNotificationCount > 99 ? "99+" : String(unreadNotificationCount);
useEffect(() => {
function onPointerDown(event: MouseEvent) {
@@ -56,6 +67,41 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
return () => window.removeEventListener("mousedown", onPointerDown);
}, []);
useEffect(() => {
if (!auth || !notificationsAvailable) {
setUnreadNotificationCount(0);
return;
}
let cancelled = false;
async function refreshNotificationSummary() {
try {
const summary = await apiFetch<NotificationSummary>(settings, "/api/v1/notifications/summary", { cache: "no-store" });
if (!cancelled) {
setUnreadNotificationCount(summary.show_unread_badge === false ? 0 : Math.max(0, Number(summary.unread) || 0));
}
} catch (error) {
if (!cancelled) {
setUnreadNotificationCount(0);
}
if (!isApiError(error, 401, 403, 404)) {
console.error("Failed to load notification summary", error);
}
}
}
void refreshNotificationSummary();
const intervalId = window.setInterval(refreshNotificationSummary, 60_000);
window.addEventListener("focus", refreshNotificationSummary);
window.addEventListener("govoplan:notifications-changed", refreshNotificationSummary);
return () => {
cancelled = true;
window.clearInterval(intervalId);
window.removeEventListener("focus", refreshNotificationSummary);
window.removeEventListener("govoplan:notifications-changed", refreshNotificationSummary);
};
}, [activeTenant?.id, auth?.user?.id, notificationsAvailable, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
function handleLogin(response: LoginResponse) {
onAuthChange(response, "");
}
@@ -109,6 +155,10 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
navigate("/admin?section=system-settings");
}
function openNotificationCenter() {
navigate("/notifications");
}
return (
<header className="titlebar">
{maintenanceMode?.enabled &&
@@ -159,8 +209,19 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
<div className="titlebar-spacer" />
<HelpMenu />
<LanguageMenu />
<HelpMenu />
{auth && notificationsAvailable &&
<button className="titlebar-icon-link titlebar-notification-button" onClick={openNotificationCenter} title={translateText("i18n:govoplan-core.notifications.753a22b2")} aria-label={translateText("i18n:govoplan-core.notifications.753a22b2")}>
<Bell size={18} />
{unreadNotificationCount > 0 &&
<span className="titlebar-notification-badge" aria-label={`${unreadNotificationCount} unread`}>
{notificationBadgeLabel}
</span>
}
</button>
}
<div className="context-menu-wrap" ref={accountRef}>
<button className="account-pill" onClick={() => setAccountOpen(!accountOpen)}>

View File

@@ -1,4 +1,4 @@
import { Activity, Building2, CalendarDays, ClipboardPenLine, FileText, Folder, Form, LayoutDashboard, LayoutTemplate, Mail, Mails, RadioTower, Shield, Users, type LucideIcon } from "lucide-react";
import { Activity, Bell, BookUser, Building2, CalendarClock, CalendarDays, ClipboardPenLine, Folder, Form, LayoutDashboard, LayoutTemplate, Mail, Mails, RadioTower, Shield, Users, type LucideIcon } from "lucide-react";
import installedWebModules from "virtual:govoplan-installed-modules";
import type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, PlatformModuleInfo, PlatformNavItem, PlatformWebModule } from "../types";
import {
@@ -39,9 +39,12 @@ type RemoteAssetManifest = {
const iconByName: Record<string, LucideIcon> = {
activity: Activity,
admin: Shield,
bell: Bell,
"book-user": BookUser,
building: Building2,
"building-2": Building2,
calendar: CalendarDays,
"calendar-clock": CalendarClock,
campaign: Mails,
"clipboard-pen-line": ClipboardPenLine,
dashboard: LayoutDashboard,
@@ -51,9 +54,12 @@ const iconByName: Record<string, LucideIcon> = {
form: Form,
"layout-template": LayoutTemplate,
mail: Mail,
notifications: Bell,
organizations: Building2,
operator: RadioTower,
"radio-tower": RadioTower,
reports: FileText,
reports: ClipboardPenLine,
templates: LayoutTemplate,
users: Users
};

View File

@@ -17,8 +17,8 @@
overflow: auto;
height: 100%;
background:
radial-gradient(circle at 18% 18%, rgba(239, 107, 58, .13), transparent 24rem),
radial-gradient(circle at 78% 14%, rgba(126, 166, 197, .15), transparent 22rem),
radial-gradient(circle at 18% 18%, var(--accent-auth-glow), transparent 24rem),
radial-gradient(circle at 78% 14%, var(--blue-auth-glow), transparent 22rem),
var(--bg);
}
@@ -32,7 +32,7 @@
.public-card {
width: min(720px, 100%);
background: var(--panel);
border: 1px solid var(--line);
border: var(--border-line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 42px 48px;
@@ -134,9 +134,9 @@
.titlebar-link:hover,
.account-pill:hover,
.context-menu-wrap:focus-within .account-pill {
background: rgba(0,0,0,.055);
background: var(--titlebar-hover-bg);
color: var(--text-strong);
box-shadow: inset 0 0 0 1px rgba(0,0,0,.04);
box-shadow: inset 0 0 0 1px rgba(var(--shadow-color-rgb), .04);
}
.dropdown-menu {
@@ -151,7 +151,7 @@
.dropdown-item:hover,
.dropdown-item.active {
background: rgba(239, 107, 58, .10);
background: var(--accent-hover-bg);
color: var(--text-strong);
}

View File

@@ -1,6 +1,6 @@
.status-badge { display: inline-flex; align-items: center; height: 24px; border-radius: 99px; padding: 0 9px; font-size: 12px; font-weight: 800; background: #e7e4df; color: #666; text-transform: uppercase; }
.status-ready, .status-sent, .status-appended, .status-success, .status-active { background: #d6eee9; color: #34796d; }
.status-warning, .status-needs-review, .status-pending { background: #ffedc6; color: #a06b00; }
.status-blocked, .status-failed, .status-failed-permanent { background: #f8d1cc; color: #b13e35; }
.status-queued, .status-sending { background: #d8e8f4; color: #386a90; }
.status-inactive, .status-locked { background: #e7e4df; color: #666; }
.status-badge { display: inline-flex; align-items: center; height: 24px; border-radius: 99px; padding: 0 9px; font-size: 12px; font-weight: 800; background: var(--status-neutral-bg); color: var(--text-soft); text-transform: uppercase; }
.status-ready, .status-sent, .status-appended, .status-success, .status-active { background: var(--success-soft); color: var(--success-text-strong); }
.status-warning, .status-needs-review, .status-pending { background: var(--warning-soft); color: var(--warning-text-strong); }
.status-blocked, .status-failed, .status-failed-permanent { background: var(--danger-bg); color: var(--danger-text-strong); }
.status-queued, .status-sending { background: var(--info-soft); color: var(--info-text-strong); }
.status-inactive, .status-locked { background: var(--status-neutral-bg); color: var(--text-soft); }

View File

@@ -19,9 +19,9 @@
display: inline-block;
width: 18px;
height: 13px;
border: 2px solid #8c8881;
border: 2px solid var(--loading-line);
border-radius: 3px;
background: rgba(255,255,255,.7);
background: var(--loading-envelope-bg);
animation: loading-envelope-float .74s ease-in-out infinite alternate;
}
.loading-envelope::before,
@@ -31,7 +31,7 @@
top: 1px;
width: 10px;
height: 10px;
border-top: 2px solid #8c8881;
border-top: 2px solid var(--loading-line);
}
.loading-envelope::before {
left: 1px;
@@ -64,19 +64,19 @@
align-items: center;
gap: 7px;
max-width: 100%;
border: 1px solid #c9c5bd;
border: 1px solid var(--control-border);
border-radius: 999px;
background: linear-gradient(#ffffff, #f2f1ef);
box-shadow: inset 0 1px 0 rgba(255,255,255,.85), 0 1px 1px rgba(0,0,0,.05);
background: linear-gradient(var(--control-gradient-start), var(--control-gradient-end));
box-shadow: var(--shadow-control-strong);
padding: 5px 8px 5px 11px;
color: var(--text-strong);
font-size: 13px;
line-height: 1.2;
}
.email-chip.invalid {
border-color: #c96b63;
background: #f6e3df;
color: #873c35;
border-color: var(--danger-border);
background: var(--danger-soft);
color: var(--danger-text-deep);
}
.email-chip-main {
overflow: hidden;
@@ -99,14 +99,14 @@
height: 18px;
border: 0;
border-radius: 999px;
background: rgba(0,0,0,.08);
color: #57534d;
background: var(--hover-tint);
color: var(--control-text-muted);
cursor: pointer;
font-size: 14px;
line-height: 1;
padding: 0;
}
.email-chip-remove:hover { background: rgba(0,0,0,.15); }
.email-chip-remove:hover { background: var(--hover-tint-strong); }
.email-chip-empty {
color: var(--muted);
font-size: 13px;
@@ -148,7 +148,7 @@
align-items: center;
gap: 8px 10px;
padding: 8px 10px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--muted);
@@ -224,14 +224,40 @@
transform: translateY(-50%);
}
.password-field-toggle:hover {
background: rgba(0, 0, 0, .06);
background: var(--hover-tint-soft);
color: var(--text-strong);
}
.password-field-toggle:focus-visible {
outline: 2px solid color-mix(in srgb, var(--blue) 55%, #fff);
outline: var(--focus-outline);
outline-offset: 1px;
}
.credential-panel {
display: grid;
gap: 12px;
min-width: 0;
}
.credential-panel-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
align-items: start;
}
.credential-panel-heading {
grid-column: 1 / -1;
margin: 2px 0 -4px;
color: var(--text-strong);
font-size: 13px;
font-weight: 800;
}
.credential-panel-extra {
display: grid;
gap: 10px;
}
.color-picker-field,
.date-field,
.time-field {
@@ -261,9 +287,9 @@
z-index: 1;
width: 18px;
height: 18px;
border: 1px solid var(--line-dark);
border: var(--border-line-dark);
border-radius: 4px;
box-shadow: inset 0 1px 0 rgba(255,255,255,.45);
box-shadow: var(--shadow-inset-highlight);
}
.color-picker-trigger,
@@ -287,7 +313,7 @@
.color-picker-trigger:focus-visible,
.date-field-trigger:hover:not(:disabled),
.date-field-trigger:focus-visible {
background: rgba(0,0,0,.08);
background: var(--hover-tint);
color: var(--text-strong);
}
@@ -307,10 +333,10 @@
width: max-content;
min-width: 220px;
padding: 10px;
border: 1px solid var(--line-dark);
border: var(--border-line-dark);
border-radius: 8px;
background: var(--panel);
box-shadow: 0 16px 34px rgba(0,0,0,.18);
box-shadow: var(--shadow-menu);
}
.color-picker-grid {
@@ -322,10 +348,10 @@
.color-picker-grid button {
width: 28px;
height: 28px;
border: 1px solid rgba(0,0,0,.2);
border: 1px solid var(--black-border-soft);
border-radius: 6px;
cursor: pointer;
box-shadow: inset 0 1px 0 rgba(255,255,255,.45);
box-shadow: var(--shadow-inset-highlight);
}
.color-picker-grid button.is-selected {
@@ -355,7 +381,7 @@
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 4px;
background: var(--surface);
color: var(--text);
@@ -401,9 +427,9 @@
}
.date-field-grid button.is-selected {
border-color: #5aa99b;
border-color: var(--success-border);
background: var(--green);
color: #fff;
color: var(--on-accent);
font-weight: 800;
}
@@ -426,7 +452,7 @@
}
.admin-overview-link {
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 6px;
background: var(--panel-soft);
padding: 14px;
@@ -436,7 +462,7 @@
}
.admin-overview-link:hover {
background: #fff;
background: var(--surface);
border-color: var(--line-dark);
}
@@ -467,7 +493,7 @@
grid-template-columns: minmax(0, 1fr) 170px;
align-items: center;
gap: 18px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 6px;
background: var(--panel);
box-shadow: var(--shadow);
@@ -551,7 +577,7 @@
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 14px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 6px;
background: var(--panel);
box-shadow: var(--shadow);
@@ -616,7 +642,7 @@
.module-install-preflight {
display: grid;
gap: 10px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 6px;
background: var(--panel-soft);
padding: 12px 14px;
@@ -657,7 +683,7 @@
min-height: 30px;
padding: 6px 8px;
border-radius: 4px;
background: rgba(255,255,255,.58);
background: var(--panel-glass);
}
.module-install-preflight-issue strong {
@@ -686,9 +712,9 @@
gap: 4px;
align-content: start;
min-height: 86px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 6px;
background: rgba(255,255,255,.6);
background: var(--panel-glass);
padding: 9px 10px;
}
@@ -743,7 +769,7 @@
grid-template-columns: 130px 150px 170px minmax(260px, 1fr);
gap: 12px;
align-items: end;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 6px;
background: var(--panel);
box-shadow: var(--shadow);
@@ -818,7 +844,7 @@
grid-template-columns: minmax(0, 1fr) minmax(220px, 34%);
align-items: center;
gap: 14px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 6px;
background: var(--panel);
box-shadow: var(--shadow);
@@ -976,9 +1002,9 @@
max-height: 300px;
overflow: auto;
padding: 8px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--surface-muted, #f6f7f9);
background: var(--surface-muted);
}
.admin-selection-item {
@@ -992,7 +1018,7 @@
}
.admin-selection-item:hover {
background: var(--surface, #fff);
background: var(--surface);
}
.admin-selection-item.disabled {
@@ -1036,8 +1062,8 @@
.guided-config-sidebar {
min-width: 0;
border-right: 1px solid var(--line);
background: #f6f5f3;
border-right: var(--border-line);
background: var(--panel-soft);
}
.guided-config-sidebar .stepper {
@@ -1109,7 +1135,7 @@
gap: 14px;
min-width: 0;
padding: 15px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 7px;
background: var(--panel);
}
@@ -1134,7 +1160,7 @@
}
.advanced-options-panel {
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 7px;
background: var(--panel);
}
@@ -1248,7 +1274,7 @@
}
.action-blocker-technical {
background: rgba(255,255,255,.56);
background: var(--panel-glass);
}
.guided-review-list {
@@ -1271,7 +1297,7 @@
align-items: start;
min-width: 0;
padding: 10px 12px;
border: 1px solid var(--line);
border: var(--border-line);
border-left-width: 4px;
border-left-color: var(--line-dark);
border-radius: 7px;
@@ -1327,7 +1353,7 @@
.guided-config-sidebar {
border-right: 0;
border-bottom: 1px solid var(--line);
border-bottom: var(--border-line);
}
.guided-config-sidebar .stepper {
@@ -1350,19 +1376,19 @@
flex-wrap: wrap;
gap: 7px;
min-height: 44px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 8px;
background: #fff;
box-shadow: inset 0 1px 0 rgba(255,255,255,.75), 0 1px 2px rgba(0,0,0,.035);
background: var(--surface);
box-shadow: var(--shadow-control-surface);
padding: 7px 8px;
}
.email-address-editor:focus-within {
border-color: #9bb7d3;
box-shadow: 0 0 0 3px rgba(82, 130, 177, .14), inset 0 1px 0 rgba(255,255,255,.8);
border-color: var(--input-border-focus);
box-shadow: var(--focus-ring), var(--shadow-inset-highlight-strong);
}
.email-address-editor.has-error {
border-color: #c96b63;
box-shadow: 0 0 0 3px rgba(201, 107, 99, .13);
border-color: var(--danger-border);
box-shadow: var(--danger-focus-ring-soft);
}
.email-address-editor .email-chip-list {
display: flex;
@@ -1402,10 +1428,10 @@
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid #c7c2b8;
border: 1px solid var(--control-border);
border-radius: 999px;
background: linear-gradient(#fff, #f0efec);
color: #4f4b46;
background: linear-gradient(var(--control-gradient-start), var(--control-gradient-end-muted));
color: var(--control-text);
cursor: pointer;
font-size: 19px;
font-weight: 800;
@@ -1413,8 +1439,8 @@
padding: 0;
}
.email-address-plus:hover {
border-color: #aaa49a;
background: linear-gradient(#fff, #e9e7e3);
border-color: var(--control-border-hover);
background: linear-gradient(var(--control-gradient-start), var(--control-gradient-end-hover));
}
.email-address-popover {
position: absolute;
@@ -1424,9 +1450,9 @@
width: min(360px, calc(100vw - 64px));
display: grid;
gap: 10px;
border: 1px solid var(--line-dark);
border: var(--border-line-dark);
border-radius: 8px;
background: #fff;
background: var(--surface);
box-shadow: var(--shadow-popover);
padding: 14px;
}
@@ -1449,10 +1475,10 @@
display: grid;
gap: 4px;
max-width: 560px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 8px;
background: #fff;
box-shadow: 0 8px 24px rgba(0,0,0,.08);
background: var(--surface);
box-shadow: 0 8px 24px var(--hover-tint);
padding: 6px;
}
.email-address-suggestions button {
@@ -1521,10 +1547,10 @@
flex: 0 0 auto;
width: 42px;
height: 24px;
border: 1px solid #aeb4bb;
border: 1px solid var(--toggle-track-border);
border-radius: 999px;
background: #cfd4da;
box-shadow: inset 0 1px 2px rgba(0,0,0,.12);
background: var(--toggle-track-bg);
box-shadow: var(--shadow-control-inset-strong);
transition: background-color .16s ease, border-color .16s ease, box-shadow .16s ease;
}
.toggle-switch-thumb {
@@ -1534,19 +1560,19 @@
width: 18px;
height: 18px;
border-radius: 999px;
background: #fff;
box-shadow: 0 1px 2px rgba(0,0,0,.22);
background: var(--surface);
box-shadow: var(--shadow-thumb);
transition: transform .16s ease;
}
.toggle-switch-input:checked + .toggle-switch-track {
border-color: #0d6efd;
background: #0d6efd;
border-color: var(--primary);
background: var(--primary);
}
.toggle-switch-input:checked + .toggle-switch-track .toggle-switch-thumb {
transform: translateX(18px);
}
.toggle-switch-input:focus-visible + .toggle-switch-track {
box-shadow: 0 0 0 3px rgba(13,110,253,.2), inset 0 1px 2px rgba(0,0,0,.12);
box-shadow: var(--primary-focus-ring-soft), var(--shadow-control-inset-strong);
}
.toggle-switch-input:disabled + .toggle-switch-track {
filter: grayscale(.15);
@@ -1604,7 +1630,7 @@
width: 16px;
height: 16px;
border-radius: 999px;
color: #686560;
color: var(--text-subtle);
background: var(--line-dark);
font-size: 11px;
font-weight: 800;
@@ -1627,7 +1653,7 @@
.policy-table {
display: grid;
gap: 0;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: var(--radius-sm);
overflow: hidden;
}
@@ -1646,7 +1672,7 @@
gap: 14px;
padding: 12px 14px;
background: var(--surface);
border-top: 1px solid var(--line);
border-top: var(--border-line);
}
.policy-row:first-child {
border-top: 0;
@@ -1712,7 +1738,7 @@
width: max-content;
max-width: min(320px, calc(100vw - 48px));
transform: translate(-50%, 3px);
border: 1px solid var(--line-dark);
border: var(--border-line-dark);
border-radius: 7px;
background: var(--surface);
box-shadow: var(--shadow-popover);
@@ -1734,8 +1760,8 @@
top: 100%;
width: 9px;
height: 9px;
border-right: 1px solid var(--line-dark);
border-bottom: 1px solid var(--line-dark);
border-right: var(--border-line-dark);
border-bottom: var(--border-line-dark);
background: var(--surface);
transform: translate(-50%, -5px) rotate(45deg);
}
@@ -1767,8 +1793,8 @@
}
.field-input-missing {
border-color: #b42318 !important;
box-shadow: 0 0 0 3px rgba(180, 35, 24, .14) !important;
border-color: var(--danger-border-deep) !important;
box-shadow: var(--danger-focus-ring) !important;
}
.disabled-action-tooltip {
@@ -1776,19 +1802,27 @@
display: inline-flex;
}
.disabled-action-tooltip:focus-visible {
outline: none;
}
.disabled-action-tooltip:focus-visible > .btn {
box-shadow: var(--focus-ring);
}
.disabled-action-tooltip[data-tooltip]:not([data-tooltip=""]):hover::after,
.disabled-action-tooltip[data-tooltip]:not([data-tooltip=""]):focus-within::after {
position: absolute;
right: 0;
bottom: calc(100% + 10px);
z-index: 80;
z-index: 20000;
width: min(280px, 72vw);
padding: 9px 11px;
border: 1px solid rgba(180, 35, 24, .24);
border: 1px solid var(--border-danger-soft);
border-radius: var(--radius-sm, 8px);
color: #7a241c;
background: #fff7f5;
box-shadow: var(--shadow-popover, 0 16px 32px rgba(15, 23, 42, .18));
color: var(--danger-text-tooltip);
background: var(--danger-muted-bg);
box-shadow: var(--shadow-popover);
content: attr(data-tooltip);
font-size: 12px;
font-weight: 700;
@@ -1819,7 +1853,7 @@
min-height: 120px;
padding: 1.25rem;
border-radius: var(--radius-lg, 18px);
background: rgba(255, 255, 255, 0.00);
background: var(--transparent-surface);
backdrop-filter: blur(1.5px);
margin: -10px;
}
@@ -1829,11 +1863,11 @@
align-items: center;
gap: 0.65rem;
padding: 0.75rem 1rem;
border: 1px solid var(--border-soft, rgba(15, 23, 42, 0.12));
border: 1px solid var(--border-soft);
border-radius: 999px;
color: var(--text, #172033);
background: rgba(255, 255, 255, 0.86);
box-shadow: var(--shadow-soft, 0 10px 28px rgba(15, 23, 42, 0.12));
color: var(--text);
background: var(--panel-glass-strong);
box-shadow: var(--shadow-soft);
font-size: 0.9rem;
font-weight: 600;
}
@@ -1853,21 +1887,21 @@
justify-content: center;
width: 30px;
height: 30px;
border: 1px solid #c9c3b9;
border: 1px solid var(--control-border);
border-radius: 999px;
background: linear-gradient(#ffffff, #f1efeb);
color: #4f4a43;
background: linear-gradient(var(--control-gradient-start), var(--control-gradient-end-muted));
color: var(--control-text);
cursor: pointer;
box-shadow: inset 0 1px 0 rgba(255,255,255,.75), 0 1px 1px rgba(0,0,0,.05);
box-shadow: var(--shadow-control);
transition: transform .18s ease, background .18s ease, border-color .18s ease, box-shadow .18s ease;
}
.card-collapse-toggle:hover {
border-color: #aaa299;
background: linear-gradient(#ffffff, #e8e5df);
box-shadow: inset 0 1px 0 rgba(255,255,255,.82), 0 2px 5px rgba(0,0,0,.08);
border-color: var(--control-border-pressed);
background: linear-gradient(var(--control-gradient-start), var(--control-gradient-end-pressed));
box-shadow: var(--shadow-control-hover);
}
.card-collapse-toggle:focus-visible {
outline: 3px solid rgba(82, 130, 177, .22);
outline: 3px solid var(--focus-ring-color);
outline-offset: 2px;
}
.card-collapse-toggle svg {
@@ -1907,7 +1941,7 @@
justify-content: space-between;
gap: 12px;
position: relative;
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.08);
box-shadow: var(--shadow-dismissible);
}
.alert-dismissible .alert-message {
@@ -1932,9 +1966,9 @@
top: auto;
width: 100%;
margin: 0;
border: 1px solid rgba(15, 23, 42, 0.14);
border: 1px solid var(--border-soft);
border-radius: 12px;
box-shadow: 0 16px 38px rgba(15, 23, 42, 0.2);
box-shadow: var(--shadow-floating);
pointer-events: auto;
}
@@ -1953,7 +1987,7 @@
.alert-dismiss:hover,
.alert-dismiss:focus-visible {
opacity: 1;
background: rgba(255, 255, 255, 0.45);
background: var(--panel-glass-muted);
}
@@ -2015,7 +2049,7 @@
.explorer-tree-node-wrap:hover,
.explorer-tree-node-wrap:focus-within,
.explorer-tree-node-wrap.is-active {
background: rgba(13, 110, 253, .08);
background: var(--primary-soft);
outline: none;
}
@@ -2029,14 +2063,14 @@
flex-direction: column;
min-width: 0;
min-height: 0;
border-right: 1px solid var(--line);
border-right: var(--border-line);
background: linear-gradient(180deg, var(--panel-soft), var(--panel));
}
.file-tree-heading {
flex: 0 0 auto;
padding: 13px 14px;
border-bottom: 1px solid var(--line);
border-bottom: var(--border-line);
color: var(--muted);
font-size: 12px;
font-weight: 800;
@@ -2054,7 +2088,7 @@
.file-tree-space + .file-tree-space {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--line);
border-top: var(--border-line);
}
.file-tree-node-wrap {
@@ -2143,8 +2177,8 @@
}
.file-tree-node-wrap.is-selected .file-tree-select {
background: #0d6efd;
color: #fff;
background: var(--primary);
color: var(--on-accent);
}
.file-tree-children {
@@ -2159,10 +2193,10 @@
justify-content: start;
max-width: 100%;
overflow: hidden;
border: 1px solid #c9c3b9;
border: var(--border-line-dark);
border-radius: 8px;
background: #c9c3b9;
box-shadow: inset 0 1px 0 rgba(255,255,255,.75), 0 1px 1px rgba(0,0,0,.05);
background: var(--line-dark);
box-shadow: var(--shadow-control);
}
.segmented-control-size-content {
@@ -2186,18 +2220,18 @@
min-height: 32px;
border: 0;
border-radius: 0;
background: linear-gradient(#ffffff, #f1efeb);
color: #4f4a43;
background: linear-gradient(var(--surface), var(--panel));
color: var(--text);
cursor: pointer;
font: inherit;
font-size: 13px;
font-weight: 800;
box-shadow: inset 0 1px 0 rgba(255,255,255,.75), 0 1px 1px rgba(0,0,0,.05);
box-shadow: var(--shadow-control);
transition: background .18s ease, box-shadow .18s ease, color .18s ease;
}
.segmented-control-option + .segmented-control-option {
border-left: 1px solid #c9c3b9;
border-left: var(--border-line-dark);
}
.segmented-control-option:first-child {
@@ -2210,13 +2244,13 @@
.segmented-control-option:hover,
.segmented-control-option:focus-visible {
background: linear-gradient(#ffffff, #e8e5df);
color: #4f4a43;
box-shadow: inset 0 1px 0 rgba(255,255,255,.82), 0 2px 5px rgba(0,0,0,.08);
background: linear-gradient(var(--surface), var(--bar));
color: var(--text-strong);
box-shadow: var(--shadow-control-hover);
}
.segmented-control-option:focus-visible {
outline: 3px solid rgba(82, 130, 177, .22);
outline: 3px solid var(--focus-ring-color);
outline-offset: -1px;
}
@@ -2224,7 +2258,7 @@
.segmented-control-option.active {
background: var(--line);
color: var(--text-strong);
box-shadow: inset 0 2px 5px rgba(0,0,0,.12), inset 0 -1px 0 rgba(255,255,255,.45);
box-shadow: inset 0 2px 5px rgba(var(--shadow-color-rgb), .12), var(--shadow-inset-highlight);
}
.segmented-control-option.is-active:hover,
@@ -2232,7 +2266,7 @@
.segmented-control-option.active:hover,
.segmented-control-option.active:focus-visible {
background: var(--line);
box-shadow: inset 0 2px 5px rgba(0,0,0,.14), inset 0 -1px 0 rgba(255,255,255,.45);
box-shadow: inset 0 2px 5px rgba(var(--shadow-color-rgb), .14), var(--shadow-inset-highlight);
}
.segmented-control-option:disabled {
@@ -2240,6 +2274,84 @@
opacity: .55;
}
.theme-preview-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 10px;
}
.theme-preview {
--preview-bg: var(--theme-preview-light-bg);
--preview-bar: var(--theme-preview-light-bar);
--preview-surface: var(--theme-preview-light-surface);
--preview-line: var(--theme-preview-light-line);
--preview-text: var(--theme-preview-light-text);
--preview-muted: var(--theme-preview-light-muted);
display: grid;
gap: 0;
min-height: 112px;
overflow: hidden;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--preview-bg);
}
.theme-preview[data-preview-theme="dark"] {
--preview-bg: var(--theme-preview-dark-bg);
--preview-bar: var(--theme-preview-dark-bar);
--preview-surface: var(--theme-preview-dark-surface);
--preview-line: var(--theme-preview-dark-line);
--preview-text: var(--theme-preview-dark-text);
--preview-muted: var(--theme-preview-dark-muted);
}
.theme-preview-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border-bottom: 1px solid var(--preview-line);
background: var(--preview-bar);
color: var(--preview-text);
font-size: 12px;
font-weight: 800;
}
.theme-preview-header i {
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.theme-preview-body {
display: grid;
align-content: start;
gap: 8px;
padding: 12px;
background: var(--preview-surface);
color: var(--preview-text);
}
.theme-preview-body strong {
font-size: 13px;
}
.theme-preview-body span {
display: block;
height: 8px;
border-radius: 4px;
background: var(--preview-line);
}
.theme-preview-body span:last-child {
width: 68%;
background: var(--preview-muted);
opacity: .45;
}
.message-display-panel {
display: grid;
gap: 14px;
@@ -2327,7 +2439,7 @@
max-height: 420px;
margin: 0;
overflow: auto;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--surface-subtle);
color: var(--text);
@@ -2342,7 +2454,7 @@
.message-display-html-frame {
height: 420px;
background: #fff;
background: var(--white);
}
.message-display-attachments {
@@ -2372,7 +2484,7 @@
gap: 8px;
align-items: start;
padding: 8px 10px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--surface);
}
@@ -2413,7 +2525,7 @@
display: grid;
gap: 8px;
padding: 10px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--surface-subtle);
}
@@ -2514,7 +2626,7 @@
grid-column: 1 / -1;
}
.mail-server-toggle-row {
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 8px;
background: var(--panel-soft);
}
@@ -2544,7 +2656,7 @@
display: inline-flex;
align-items: center;
min-height: 24px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 99px;
background: var(--panel-soft);
padding: 3px 9px;
@@ -2559,6 +2671,7 @@
.form-grid.compact.mail-server-form-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 720px) {
.credential-panel-grid { grid-template-columns: 1fr; }
.form-grid.compact.mail-server-form-grid { grid-template-columns: 1fr; }
.mail-server-section-heading.split { align-items: flex-start; flex-direction: column; }
.mail-server-segmented-control { width: 100%; }
@@ -2588,20 +2701,20 @@
.file-drop-zone:hover,
.file-drop-zone:focus-visible,
.file-drop-zone.is-active {
border-color: #0d6efd;
background: rgba(13, 110, 253, .08);
border-color: var(--primary);
background: var(--primary-soft);
}
.file-drop-zone:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(13, 110, 253, .18);
box-shadow: var(--primary-focus-ring);
}
.file-drop-zone[aria-disabled="true"] {
cursor: not-allowed;
opacity: .65;
}
.file-drop-zone.is-busy {
border-color: #0d6efd;
background: rgba(13, 110, 253, .08);
border-color: var(--primary);
background: var(--primary-soft);
cursor: progress;
opacity: 1;
}
@@ -2613,7 +2726,7 @@
width: 58px;
height: 58px;
border-radius: 50%;
background: conic-gradient(#0d6efd var(--file-drop-progress), rgba(13, 110, 253, .16) 0);
background: conic-gradient(var(--primary) var(--file-drop-progress), var(--primary-soft-strong) 0);
color: var(--text-strong);
font-size: 12px;
font-weight: 800;
@@ -2625,14 +2738,14 @@
inset: 6px;
border-radius: 50%;
background: var(--panel-soft);
box-shadow: inset 0 0 0 1px rgba(13, 110, 253, .12);
box-shadow: var(--primary-inset-ring);
}
.file-drop-progress > span {
position: relative;
z-index: 1;
}
.file-drop-progress.is-indeterminate {
background: conic-gradient(#0d6efd 0 32%, rgba(13, 110, 253, .16) 32% 100%);
background: conic-gradient(var(--primary) 0 32%, var(--primary-soft-strong) 32% 100%);
animation: file-drop-progress-spin .85s linear infinite;
}
@keyframes file-drop-progress-spin {

View File

@@ -6,7 +6,7 @@
display: grid;
place-items: center;
padding: 24px;
background: rgba(37, 40, 42, 0.38);
background: var(--overlay-backdrop);
}
.modal-panel {
@@ -15,10 +15,10 @@
overflow: hidden;
display: flex;
flex-direction: column;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 8px;
background: #fff;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.26);
background: var(--surface);
box-shadow: var(--shadow-modal);
}
.modal-header {
@@ -27,7 +27,7 @@
align-items: center;
flex: 0 0 auto;
padding: 0 22px;
border-bottom: 1px solid var(--line);
border-bottom: var(--border-line);
}
.modal-header h2 {
@@ -58,7 +58,7 @@
justify-content: flex-end;
gap: 10px;
padding: 16px 22px;
border-top: 1px solid var(--line);
border-top: var(--border-line);
background: var(--panel-soft);
}
@@ -69,7 +69,7 @@
display: grid;
place-items: center;
padding: 1.5rem;
background: rgba(15, 23, 42, 0.36);
background: var(--dialog-backdrop);
backdrop-filter: blur(2px);
}
@@ -79,11 +79,11 @@
overflow: hidden;
display: flex;
flex-direction: column;
border: 1px solid var(--line, rgba(15, 23, 42, 0.14));
border: 1px solid var(--line);
border-radius: var(--radius-lg, 18px);
background: var(--surface, #fff);
color: var(--text, #172033);
box-shadow: var(--shadow-strong, 0 24px 64px rgba(15, 23, 42, 0.25));
background: var(--surface);
color: var(--text);
box-shadow: var(--shadow-strong);
}
.dialog-header {
@@ -94,13 +94,13 @@
justify-content: space-between;
gap: 1rem;
padding: 14px 18px;
border-bottom: 1px solid var(--line);
background: var(--panel, #f7f6f4);
border-bottom: var(--border-line);
background: var(--panel);
}
.dialog-title {
margin: 0;
color: var(--text-strong, #111827);
color: var(--text-strong);
font-size: 1.05rem;
}
@@ -113,7 +113,7 @@
align-items: center;
justify-content: center;
background: transparent;
color: var(--muted, #5f6b7a);
color: var(--muted);
cursor: pointer;
font-size: 1.5rem;
line-height: 1;
@@ -121,8 +121,8 @@
.dialog-close:hover,
.dialog-close:focus-visible {
background: rgba(15, 23, 42, 0.07);
color: var(--text-strong, #111827);
background: var(--hover-tint-soft);
color: var(--text-strong);
outline: none;
}
@@ -136,7 +136,7 @@
min-height: 0;
overflow: auto;
padding: 20px;
color: var(--text, #172033);
color: var(--text);
}
.dialog-footer {
@@ -145,8 +145,8 @@
justify-content: flex-end;
gap: 0.6rem;
padding: 12px 18px;
border-top: 1px solid var(--line);
background: var(--panel, #f7f6f4);
border-top: var(--border-line);
background: var(--panel);
}
.confirm-dialog {
@@ -155,12 +155,12 @@
.confirm-dialog > .dialog-header,
.confirm-dialog > .dialog-footer {
background: var(--panel, #fff);
background: var(--panel);
}
.confirm-dialog p {
margin: 0;
color: var(--muted, #5f6b7a);
color: var(--muted);
line-height: 1.5;
}
@@ -174,14 +174,14 @@
.admin-dialog > .dialog-header {
padding: 16px 18px;
border-bottom: 1px solid var(--line);
border-bottom: var(--border-line);
background: var(--panel);
}
.admin-dialog > .dialog-footer {
flex: 0 0 auto;
padding: 12px 18px;
border-top: 1px solid var(--line);
border-top: var(--border-line);
background: var(--panel);
box-shadow: 0 -8px 24px rgba(15, 23, 42, .06);
box-shadow: var(--shadow-footer);
}

View File

@@ -1,9 +1,9 @@
.form-grid { display: grid; gap: 18px; }
.form-grid.compact { grid-template-columns: 1fr 1fr; }
.form-field { display: grid; gap: 7px; }
.form-label { font-weight: 700; font-size: 13px; color: #6b6863; }
.form-label { font-weight: 700; font-size: 13px; color: var(--text-label); }
.form-help { font-size: 12px; color: var(--muted); }
input, select, textarea { border: 1px solid var(--line); border-radius: 5px; background: #fff; font: inherit; padding: 10px 12px; color: var(--text); width: 100%; box-shadow: inset 0 1px 2px rgba(0,0,0,.04); }
input, select, textarea { border: var(--border-line); border-radius: 5px; background: var(--surface); font: inherit; padding: 10px 12px; color: var(--text); width: 100%; box-shadow: var(--shadow-control-inset); }
textarea { resize: vertical; }
.form-field:has(input:disabled, select:disabled, textarea:disabled, input[readonly], textarea[readonly]) .form-label { color: var(--control-disabled-label); }
input:disabled:not([type="checkbox"]):not([type="radio"]),
@@ -14,7 +14,7 @@ textarea[readonly] {
border-color: var(--control-disabled-border);
background: var(--control-disabled-bg);
color: var(--control-disabled-text);
box-shadow: inset 0 1px 0 rgba(255,255,255,.32);
box-shadow: var(--shadow-inset-highlight-muted);
opacity: 1;
}
input:disabled:not([type="checkbox"]):not([type="radio"]),
@@ -33,8 +33,8 @@ textarea[readonly]::placeholder {
color: var(--control-disabled-placeholder);
opacity: 1;
}
.btn { border: 1px solid var(--line-dark); border-radius: 4px; padding: 9px 14px; font: inherit; font-weight: 700; cursor: pointer; background: #f8f8f7; color: var(--text); box-shadow: 0 1px 2px rgba(0,0,0,.15); }
.btn-primary { background: var(--green); border-color: #5aa99b; color: #fff; }
.btn { border: var(--border-line-dark); border-radius: 4px; padding: 9px 14px; font: inherit; font-weight: 700; cursor: pointer; background: var(--control-bg); color: var(--text); box-shadow: 0 1px 2px var(--hover-tint-strong); }
.btn-primary { background: var(--green); border-color: var(--success-border); color: var(--on-accent); }
.btn-ghost { border-color: transparent; background: transparent; box-shadow: none; }
.btn-danger { background: var(--red); color: #fff; border-color: #c94d43; }
.btn-danger { background: var(--red); color: var(--on-accent); border-color: var(--danger-border-strong); }
.btn:disabled { opacity: .55; cursor: not-allowed; }

View File

@@ -1,6 +1,8 @@
.app-shell { height: 100vh; min-height: 0; display: grid; grid-template-columns: 58px 1fr; overflow: hidden; }
.icon-rail { background: var(--rail-bg); color: #c7c6c0; display: flex; flex-direction: column; align-items: center; height: 100vh; min-height: 0; box-shadow: inset -1px 0 rgba(0,0,0,.35); z-index: 1000; }
.brand-mark { width: 34px; height: 34px; margin: 15px 0 14px; border-radius: 50%; background: conic-gradient(#ef6b3a 0 20%, #f2c66d 0 40%, #80b9b0 0 60%, #7e9fc0 0 80%, #56545f 0); color: transparent; font-size: 0; position: relative; }
.app-shell { height: 100vh; min-height: 0; display: grid; grid-template-columns: auto 1fr; overflow: hidden; }
.icon-rail { width: 58px; background: var(--rail-bg); color: var(--rail-text); display: flex; flex-direction: column; align-items: center; height: 100vh; min-height: 0; box-shadow: var(--shadow-rail); z-index: 1000; transition: width .16s ease; }
.icon-rail.expanded { width: 208px; align-items: stretch; }
.icon-rail-header { width: 100%; min-height: 63px; display: flex; align-items: center; justify-content: center; padding: 0 10px; box-sizing: border-box; }
.brand-mark { width: 34px; height: 34px; flex: 0 0 auto; border-radius: 50%; background: conic-gradient(var(--accent) 0 20%, var(--amber) 0 40%, var(--green) 0 60%, var(--blue) 0 80%, var(--muted) 0); color: transparent; font-size: 0; position: relative; }
.brand-mark::after { position: absolute;
top: 9px;
left: 9px;
@@ -10,18 +12,32 @@
content: "";
background-color: var(--rail-bg);
}
.icon-nav { width: 100%; display: flex; flex-direction: column; }
.icon-nav-item { height: 52px; display: grid; place-items: center; color: #a7a49f; border-left: 3px solid transparent; text-decoration: none; }
.icon-nav-item:hover, .icon-nav-item.active { background: var(--rail-bg-active); color: #fff; border-left-color: var(--accent); }
.icon-rail-toggle { border: 0; background: transparent; font: inherit; cursor: pointer; padding: 0; }
.icon-rail-toggle:hover,
.icon-rail-toggle:focus-visible { background: var(--rail-bg-active); color: var(--on-accent); outline: none; }
.icon-rail-toggle:focus-visible { box-shadow: inset 0 0 0 2px var(--accent); }
.icon-nav { width: 100%; display: flex; flex-direction: column; min-width: 0; }
.icon-nav-item { width: 100%; height: 52px; display: grid; grid-template-columns: 55px minmax(0, 1fr); align-items: center; color: var(--rail-text-muted); border-left: 3px solid transparent; text-decoration: none; box-sizing: border-box; }
.icon-nav-item svg,
.icon-nav-item > :first-child:not(.icon-nav-label) { justify-self: center; }
.icon-nav-fallback { justify-self: center; font-weight: 800; text-transform: uppercase; }
.icon-nav-label { display: none; min-width: 0; overflow: hidden; padding-right: 14px; font-size: 13px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
.icon-rail.expanded .icon-nav-label { display: block; }
.icon-nav-item:hover, .icon-nav-item.active { background: var(--rail-bg-active); color: var(--on-accent); border-left-color: var(--accent); }
.icon-rail.compact { width: 58px; }
.app-main { min-width: 0; min-height: 0; height: 100vh; display: grid; grid-template-rows: 64px 51px minmax(0, 1fr); }
.titlebar { position: relative; background: #fbfbfa; border-bottom: 1px solid var(--line); display: flex; align-items: center; padding: 0 18px; gap: 36px; z-index: 100; box-shadow: 0px 0px 10px 0px darkgrey; }
.tenant-selector { height: 40px; min-width: 210px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: #fff; display: flex; align-items: center; padding: 0 12px; gap: 5px; box-shadow: inset 0 1px 0 #fff, 0 1px 2px rgba(0,0,0,.05); }
.titlebar { position: relative; background: var(--titlebar-bg); border-bottom: var(--border-line); display: flex; align-items: center; padding: 0 18px; gap: 36px; z-index: 100; box-shadow: var(--shadow-chrome); }
.tenant-selector { height: 40px; min-width: 210px; border: var(--border-line); border-radius: var(--radius-sm); background: var(--surface); display: flex; align-items: center; padding: 0 12px; gap: 5px; box-shadow: var(--shadow-xs); }
.tenant-label, .tenant-caret, .muted { color: var(--muted); }
.titlebar-spacer { flex: 1; }
.titlebar-link, .account-pill { border: 0; background: transparent; display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font: inherit; }
.titlebar-link, .titlebar-icon-link, .account-pill { border: 0; background: transparent; display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font: inherit; }
.titlebar-icon-link { width: 34px; height: 34px; justify-content: center; border-radius: 4px; cursor: pointer; }
.titlebar-icon-link:hover, .titlebar-link:hover { background: var(--titlebar-hover-bg); color: var(--text-strong); }
.titlebar-notification-button { position: relative; }
.titlebar-notification-badge { position: absolute; top: 4px; right: 3px; min-width: 16px; height: 16px; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; padding: 0 4px; border: 2px solid var(--titlebar-bg); border-radius: 999px; background: var(--red); color: var(--on-accent); font-size: 10px; font-weight: 800; line-height: 1; transform: translate(35%, -35%); }
.account-pill { color: var(--text); }
.maintenance-topbar-link { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); border: 1px solid rgba(154, 92, 0, .26); background: #ffe1a3; color: #593700; border-radius: 6px; min-height: 32px; padding: 0 14px; font: inherit; font-weight: 800; box-shadow: 0 1px 2px rgba(0,0,0,.08); cursor: pointer; z-index: 1; }
.maintenance-topbar-link:hover { background: #ffd170; color: #3e2800; }
.maintenance-topbar-link { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); border: 1px solid var(--warning-border-soft); background: var(--warning-bg); color: var(--warning-text); border-radius: 6px; min-height: 32px; padding: 0 14px; font: inherit; font-weight: 800; box-shadow: 0 1px 2px var(--hover-tint); cursor: pointer; z-index: 1; }
.maintenance-topbar-link:hover { background: var(--warning-bg-hover); color: var(--warning-text-hover); }
.language-menu-button { min-width: 54px; justify-content: center; font-weight: 800; }
.language-menu-code, .language-option-code { font-size: 12px; letter-spacing: .06em; text-transform: uppercase; }
.language-menu { min-width: 210px; }
@@ -29,19 +45,19 @@
.language-menu .dropdown-item svg { margin-left: auto; }
.language-option-code { width: 34px; color: var(--muted); }
.api-mini { display: flex; gap: 6px; }
.api-mini input { width: 155px; height: 30px; border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 0 8px; }
.breadcrumb-bar { background: var(--bar); border-bottom: 1px solid var(--line-dark); display: flex; align-items: center; padding: 0 22px; box-shadow: 0px 0px 10px 0px darkgrey; z-index: 90; }
.breadcrumbs { display: flex; gap: 6px; text-transform: uppercase; font-weight: 700; font-size: 13px; color: #615f5c; }
.api-mini input { width: 155px; height: 30px; border: var(--border-line); border-radius: var(--radius-sm); padding: 0 8px; }
.breadcrumb-bar { background: var(--bar); border-bottom: var(--border-line-dark); display: flex; align-items: center; padding: 0 22px; box-shadow: var(--shadow-chrome); z-index: 90; }
.breadcrumbs { display: flex; gap: 6px; text-transform: uppercase; font-weight: 700; font-size: 13px; color: var(--text-breadcrumb); }
.crumb { display: inline-flex; align-items: center; gap: 4px; }
.breadcrumb-actions { margin-left: auto; display: flex; gap: 10px; }
.ghost-button { border: 0; background: transparent; color: #77736d; font-weight: 700; }
.ghost-button { border: 0; background: transparent; color: var(--muted); font-weight: 700; }
.app-content { min-height: 0; overflow: hidden; }
.workspace { height: 100%; min-height: 0; display: grid; grid-template-columns: 198px minmax(0, 1fr); }
.section-sidebar { background: #c8c4bf; border-right: 1px solid var(--line-dark); padding: 18px 0; border-left: 3px solid #afada9; box-shadow: 0px 0px 10px 0px darkgrey; z-index: 80; overflow-y: scroll; }
.section-title { font-size: 12px; font-weight: 800; color: #7f7b75; padding: 0 22px 14px; letter-spacing: .06em; }
.section-sidebar { background: var(--sidebar-bg); border-right: var(--border-line-dark); padding: 18px 0; border-left: 3px solid var(--sidebar-border); box-shadow: var(--shadow-chrome); z-index: 80; overflow-y: scroll; }
.section-title { font-size: 12px; font-weight: 800; color: var(--muted); padding: 0 22px 14px; letter-spacing: .06em; }
.section-title-lower { margin-top: 28px; }
.section-link { width: calc(100% + 3px); height: 48px; border: 0; padding: 0 10px 0 22px; background: transparent; text-align: left; color: #686560; font: inherit; cursor: pointer; margin-left: -3px; }
.section-link:hover, .section-link.active { background: rgba(255,255,255,.35); color: #3e3e3f; }
.section-link { width: calc(100% + 3px); height: 48px; border: 0; padding: 0 10px 0 22px; background: transparent; text-align: left; color: var(--text-subtle); font: inherit; cursor: pointer; margin-left: -3px; }
.section-link:hover, .section-link.active { background: var(--sidebar-hover-bg); color: var(--text-hover-strong); }
.section-link.active { border-left: 3px solid var(--accent); font-weight: 700; }
.section-link.subtle { font-size: 13px; }
.workspace-content { min-width: 0; max-width: 100%; min-height: 0; overflow: auto; }
@@ -64,49 +80,49 @@
position: sticky;
top: 0;
z-index: 75;
background: var(--bg, #f8f7f4);
border-bottom: 1px solid var(--line);
box-shadow: 0 8px 18px rgba(65, 60, 52, .08);
background: var(--bg);
border-bottom: var(--border-line);
box-shadow: var(--shadow-header);
margin: -28px -34px 22px;
padding: 18px 34px 16px;
}
.workspace-heading .mono-small { margin-top: 8px; }
.panel, .card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; }
.card-header { min-height: 56px; padding: 0 24px; border-bottom: 1px solid var(--line); display: flex; align-items: center; background: var(--panel-header); border-top-left-radius: var(--radius); border-top-right-radius: var(--radius); }
.panel, .card { background: var(--panel); border: var(--border-line); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; }
.card-header { min-height: 56px; padding: 0 24px; border-bottom: var(--border-line); display: flex; align-items: center; background: var(--panel-header); border-top-left-radius: var(--radius); border-top-right-radius: var(--radius); }
.card-header h2 { margin: 0; font-size: 16px; color: var(--text-strong); }
.card-actions { margin-left: auto; display: flex; gap: 10px; flex-wrap: wrap;}
.card-body { padding: 22px 24px; }
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(140px, 1fr)); gap: 12px; margin-bottom: 18px; }
.metric-grid.inside { margin: 14px 0; }
.metric-card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 18px; box-shadow: var(--shadow); border-top: 4px solid var(--line-dark); }
.metric-card { background: var(--panel); border: var(--border-line); border-radius: var(--radius); padding: 18px; box-shadow: var(--shadow); border-top: 4px solid var(--line-dark); }
.metric-good { border-top-color: var(--green); } .metric-warning { border-top-color: var(--amber); } .metric-danger { border-top-color: var(--red); } .metric-info { border-top-color: var(--blue); }
.metric-label { color: var(--muted); font-size: 12px; text-transform: uppercase; font-weight: 800; letter-spacing: .05em; }
.metric-value { margin-top: 7px; font-size: 30px; color: var(--text-strong); font-weight: 700; }
.metric-detail { margin-top: 4px; color: var(--muted); font-size: 13px; }
.dashboard-grid, .settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; align-items: start; }
.wizard-page { min-height: calc(100vh - 112px); display: grid; place-items: start center; padding: 42px; }
.wizard-card { width: min(980px, 100%); background: var(--panel); border: 1px solid var(--line); box-shadow: var(--shadow); border-radius: var(--radius); display: grid; grid-template-columns: 290px 1fr; overflow: hidden; }
.wizard-card { width: min(980px, 100%); background: var(--panel); border: var(--border-line); box-shadow: var(--shadow); border-radius: var(--radius); display: grid; grid-template-columns: 290px 1fr; overflow: hidden; }
.wizard-body { background: var(--panel-soft); padding: 28px; min-height: 620px; }
.wizard-heading { display: flex; justify-content: space-between; margin-bottom: 18px; }
.wizard-heading h1 { margin: 0; }
.save-state { color: var(--green); font-size: 13px; font-weight: 700; }
.wizard-footer { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px; }
.stepper { list-style: none; padding: 22px 0; margin: 0; background: #f6f5f3; border-right: 1px solid var(--line); }
.step button { width: 100%; min-height: 72px; border: 0; background: transparent; display: flex; gap: 14px; text-align: left; padding: 12px 20px; color: #999; cursor: pointer; }
.step.active button { background: #fff; color: var(--text-strong); }
.step-number { width: 32px; height: 32px; border-radius: 50%; background: #d8d6d2; color: #fff; display: grid; place-items: center; font-weight: 800; flex: 0 0 auto; }
.stepper { list-style: none; padding: 22px 0; margin: 0; background: var(--panel-soft); border-right: var(--border-line); }
.step button { width: 100%; min-height: 72px; border: 0; background: transparent; display: flex; gap: 14px; text-align: left; padding: 12px 20px; color: var(--muted); cursor: pointer; }
.step.active button { background: var(--surface); color: var(--text-strong); }
.step-number { width: 32px; height: 32px; border-radius: 50%; background: var(--step-number-bg); color: var(--on-accent); display: grid; place-items: center; font-weight: 800; flex: 0 0 auto; }
.step.active .step-number { background: var(--accent); }
.step small { display: block; margin-top: 3px; color: var(--muted); }
.step-intro h2 { margin: 0; color: var(--text-strong); }
.step-intro p { margin-top: 6px; color: var(--muted); }
.button-row { display: flex; gap: 10px; margin: 16px 0; flex-wrap: wrap; }
.alert { padding: 14px 16px; border-radius: var(--radius-sm); margin-bottom: 16px; }
.alert.warning { background: #ffe1a3; } .alert.danger { background: #f3c5be; }
.table-like { border: 1px solid var(--line); }
.table-row-link { display: grid; grid-template-columns: 1fr auto; text-decoration: none; color: var(--text); padding: 14px 16px; border-bottom: 1px solid var(--line); }
.alert.warning { background: var(--warning-bg); } .alert.danger { background: var(--danger-bg); }
.table-like { border: var(--border-line); }
.table-row-link { display: grid; grid-template-columns: 1fr auto; text-decoration: none; color: var(--text); padding: 14px 16px; border-bottom: var(--border-line); }
.table-row-link:hover { background: var(--panel-soft); }
.mono-small { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: var(--muted); }
.code-panel { background: #292929; color: #f1f1f1; padding: 18px; border-radius: 4px; overflow: auto; }
.code-panel { background: var(--code-bg); color: var(--code-text); padding: 18px; border-radius: 4px; overflow: auto; }
@media (max-width: 900px) {
.api-mini { display: none; }
.workspace { grid-template-columns: 1fr; }
@@ -119,29 +135,29 @@
/* Layout additions: login and help dropdown */
.tenant-selector.disabled { opacity: .86; cursor: default; }
.context-menu-wrap { position: relative; }
.dropdown-menu { position: absolute; right: 0; top: calc(100% + 10px); min-width: 230px; background: #fff; border: 1px solid var(--line); border-radius: 6px; box-shadow: var(--shadow-menu); padding: 8px; z-index: 5000; }
.dropdown-menu hr { border: 0; border-top: 1px solid var(--line); margin: 8px 0; }
.dropdown-menu { position: absolute; right: 0; top: calc(100% + 10px); min-width: 230px; background: var(--surface); border: var(--border-line); border-radius: 6px; box-shadow: var(--shadow-menu); padding: 8px; z-index: 5000; }
.dropdown-menu hr { border: 0; border-top: var(--border-line); margin: 8px 0; }
.dropdown-item { width: 100%; min-height: 34px; border: 0; background: transparent; display: flex; align-items: center; gap: 8px; padding: 7px 9px; border-radius: 4px; text-align: left; color: var(--text); font: inherit; text-decoration: none; cursor: pointer; }
.dropdown-item:hover { background: var(--panel-soft); color: var(--text-strong); }
.dropdown-item small { margin-left: auto; color: var(--muted); }
.account-menu { min-width: 260px; }
.account-menu-header { padding: 8px 9px 10px; border-bottom: 1px solid var(--line); margin-bottom: 8px; }
.account-menu-header { padding: 8px 9px 10px; border-bottom: var(--border-line); margin-bottom: 8px; }
.account-menu-header strong { display: block; color: var(--text-strong); }
.account-menu-header span { color: var(--muted); font-size: 13px; }
.login-hint { background: #f6f5f3; border: 1px solid var(--line); padding: 10px 12px; border-radius: 4px; color: var(--muted); font-size: 13px; }
.login-hint { background: var(--panel-soft); border: var(--border-line); padding: 10px 12px; border-radius: 4px; color: var(--muted); font-size: 13px; }
.help-panel-section { margin-bottom: 16px; }
.help-panel-section h3 { margin: 0 0 7px; color: var(--text-strong); }
.about-logo { width: 52px; height: 52px; border-radius: 50%; background: conic-gradient(#ef6b3a 0 20%, #f2c66d 0 40%, #80b9b0 0 60%, #7e9fc0 0 80%, #56545f 0); margin-bottom: 12px; }
.kbd { display: inline-flex; min-width: 22px; height: 22px; align-items: center; justify-content: center; border: 1px solid var(--line-dark); border-bottom-width: 2px; border-radius: 4px; padding: 0 6px; background: #fff; font-size: 12px; font-weight: 700; color: #666; }
.about-logo { width: 52px; height: 52px; border-radius: 50%; background: conic-gradient(var(--accent) 0 20%, var(--amber) 0 40%, var(--green) 0 60%, var(--blue) 0 80%, var(--muted) 0); margin-bottom: 12px; }
.kbd { display: inline-flex; min-width: 22px; height: 22px; align-items: center; justify-content: center; border: var(--border-line-dark); border-bottom-width: 2px; border-radius: 4px; padding: 0 6px; background: var(--surface); font-size: 12px; font-weight: 700; color: var(--text-soft); }
.titlebar .tenant-selector strong { max-width: 210px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Campaign workspace polish */
.crumb-link { color: inherit; text-decoration: none; border-radius: 4px; padding: 4px 5px; margin: -4px -5px; }
.crumb-link:hover { background: rgba(255,255,255,.35); color: var(--text-strong); }
.crumb-link:hover { background: var(--sidebar-hover-bg); color: var(--text-strong); }
.compact-actions { margin: 0; align-items: center; }
.below-grid { margin-top: 18px; }
.detail-list { display: grid; gap: 12px; margin: 0; }
.detail-list div { display: grid; grid-template-columns: 145px minmax(0, 1fr); align-items: center; gap: 18px; padding-bottom: 12px; border-bottom: 1px solid var(--line); }
.detail-list div { display: grid; grid-template-columns: 145px minmax(0, 1fr); align-items: center; gap: 18px; padding-bottom: 12px; border-bottom: var(--border-line); }
.detail-list div:last-child { border-bottom: 0; padding-bottom: 0; }
.detail-list dt { color: var(--muted); font-size: 12px; text-transform: uppercase; font-weight: 800; letter-spacing: .04em; }
.detail-list dd { margin: 0; min-width: 0; }
@@ -152,7 +168,7 @@
.next-action-warning { border-left-color: var(--amber); }
.next-action-info { border-left-color: var(--blue); }
.summary-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 14px; }
.summary-tile { background: var(--panel-soft); border: 1px solid var(--line); border-radius: 6px; padding: 14px; }
.summary-tile { background: var(--panel-soft); border: var(--border-line); border-radius: 6px; padding: 14px; }
.summary-tile span { display: block; color: var(--muted); font-size: 12px; text-transform: uppercase; font-weight: 800; letter-spacing: .04em; }
.summary-tile strong { display: block; margin-top: 6px; color: var(--text-strong); font-size: 24px; }
.empty-state { min-height: 220px; display: grid; place-items: center; text-align: center; padding: 32px; }
@@ -168,23 +184,23 @@
.docs-tree-node { min-width: 0; }
.docs-tree-row { display: grid; grid-template-columns: 22px minmax(0, 1fr); align-items: start; gap: 3px; }
.docs-tree-toggle, .docs-tree-toggle-placeholder { width: 22px; height: 32px; display: inline-grid; place-items: center; flex: 0 0 auto; }
.docs-tree-toggle { border: 0; border-radius: 4px; background: transparent; color: #686560; cursor: pointer; }
.docs-tree-toggle:hover { background: rgba(255,255,255,.35); color: var(--text-strong); }
.docs-tree-toggle { border: 0; border-radius: 4px; background: transparent; color: var(--text-subtle); cursor: pointer; }
.docs-tree-toggle:hover { background: var(--sidebar-hover-bg); color: var(--text-strong); }
.docs-tree-toggle-placeholder { opacity: 0; }
.docs-tree-children { display: grid; gap: 2px; }
.docs-tree-page { width: 100%; min-height: 32px; border: 0; border-radius: 4px; background: transparent; color: #686560; font: inherit; font-size: 13px; line-height: 1.25; text-align: left; cursor: pointer; padding: 7px 9px; overflow-wrap: anywhere; }
.docs-tree-page:hover, .docs-tree-page.is-active { background: rgba(255,255,255,.42); color: var(--text-strong); }
.docs-tree-page { width: 100%; min-height: 32px; border: 0; border-radius: 4px; background: transparent; color: var(--text-subtle); font: inherit; font-size: 13px; line-height: 1.25; text-align: left; cursor: pointer; padding: 7px 9px; overflow-wrap: anywhere; }
.docs-tree-page:hover, .docs-tree-page.is-active { background: var(--sidebar-hover-bg-strong); color: var(--text-strong); }
.docs-tree-page.is-active { box-shadow: inset 3px 0 var(--accent); font-weight: 700; }
.docs-tree-empty { color: var(--muted); font-size: 12px; line-height: 1.35; padding: 8px 9px; }
.docs-page { min-height: 100%; background: var(--panel); }
.docs-content { width: min(1180px, 100%); margin: 0 auto; display: grid; grid-template-columns: minmax(0, 1fr) 260px; align-items: start; gap: 32px; }
.docs-page-main { min-width: 0; }
.docs-page-outline-box { position: sticky; top: 24px; border: 1px solid var(--line); border-radius: 6px; background: #fbfaf8; padding: 16px; box-shadow: 0 1px 2px rgba(0,0,0,.05); }
.docs-page-outline-box { position: sticky; top: 24px; border: var(--border-line); border-radius: 6px; background: var(--surface-raised); padding: 16px; box-shadow: var(--shadow-xs); }
.docs-page-outline-box h2 { margin: 0 0 10px; color: var(--text-strong); font-size: 13px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; }
.docs-page-outline-box nav { display: grid; gap: 4px; }
.docs-page-outline-box a { border-radius: 4px; color: var(--muted); font-size: 13px; line-height: 1.3; padding: 5px 6px; text-decoration: none; }
.docs-page-outline-box a:hover { background: var(--panel); color: var(--text-strong); }
.docs-section { scroll-margin-top: 24px; padding-bottom: 34px; border-bottom: 1px solid var(--line); }
.docs-section { scroll-margin-top: 24px; padding-bottom: 34px; border-bottom: var(--border-line); }
.docs-section:last-child { border-bottom: 0; }
.docs-section h2 { margin: 0 0 18px; color: var(--text-strong); font-size: 24px; font-weight: 650; }
.docs-reference-block { scroll-margin-top: 24px; margin-top: 24px; }
@@ -221,3 +237,6 @@
.icon-rail-bottom .icon-nav-item {
width: 100%;
}
.icon-rail-bottom .icon-rail-toggle {
border-top: 1px solid var(--rail-bg-active);
}

View File

@@ -1,7 +1,7 @@
/* Legacy mapping / rule tables used by early wizard surfaces. */
.mapping-table {
margin-top: 18px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 6px;
overflow: hidden;
}
@@ -13,7 +13,7 @@
}
.mapping-header {
background: var(--bar);
color: #6b6863;
color: var(--text-label);
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
@@ -21,16 +21,16 @@
.mapping-header span,
.mapping-row span {
padding: 12px 14px;
border-right: 1px solid var(--line);
border-right: var(--border-line);
}
.mapping-row {
background: #fff;
border-top: 1px solid var(--line);
background: var(--surface);
border-top: var(--border-line);
}
.attachment-rule-card {
margin: 18px 0;
background: #fff;
border: 1px solid var(--line);
background: var(--surface);
border: var(--border-line);
border-radius: 6px;
padding: 18px;
}
@@ -48,11 +48,11 @@
.app-table {
width: 100%;
border-collapse: collapse;
background: #fff;
background: var(--surface);
}
.app-table thead {
background: var(--bar);
color: #625f5a;
color: var(--text-table-heading);
font-size: 12px;
letter-spacing: .04em;
text-transform: uppercase;
@@ -60,7 +60,7 @@
.app-table th {
height: 40px;
padding: 0 16px;
border-right: 1px solid var(--line-dark);
border-right: var(--border-line-dark);
font-weight: 800;
text-align: left;
white-space: nowrap;
@@ -69,7 +69,7 @@
.app-table td {
min-height: 56px;
padding: 16px;
border-top: 1px solid var(--line);
border-top: var(--border-line);
vertical-align: middle;
}
.app-table tbody tr:hover { background: var(--panel-soft); }
@@ -98,11 +98,11 @@
table-layout: fixed;
}
.campaign-table thead tr {
box-shadow: 0 12px 16px -18px rgba(45, 43, 40, .8);
box-shadow: var(--shadow-data-header);
}
.campaign-table thead th {
background: var(--bar);
border-right: 1px solid var(--line-dark);
border-right: var(--border-line-dark);
}
.campaign-table th:nth-child(1),
.campaign-table td:nth-child(1) { width: auto; min-width: 360px; }
@@ -115,7 +115,7 @@
.campaign-table th:nth-child(5),
.campaign-table td:nth-child(5) { width: 105px; text-align: right; }
.campaign-table td.updated-cell {
color: #696660;
color: var(--text-subtle);
font-size: 12px;
line-height: 1.35;
white-space: nowrap;
@@ -132,7 +132,7 @@
width: 100%;
min-width: 0;
overflow: hidden;
border: 1px solid var(--border);
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--surface);
}
@@ -156,7 +156,7 @@
width: 100%;
max-width: 100%;
min-width: 0;
box-shadow: var(--shadow-card, 0 2px 10px rgba(0,0,0,.06));
box-shadow: var(--shadow-card);
}
.card-body > .admin-table-surface:only-child {
@@ -244,8 +244,8 @@
max-width: 100%;
overflow: hidden;
padding: 11px 12px;
border-right: 1px solid rgba(189, 184, 176, 0.32);
border-bottom: 1px solid var(--border-soft, var(--line));
border-right: var(--border-muted-line);
border-bottom: var(--border-line);
background: var(--surface);
display: flex;
align-items: center;
@@ -279,8 +279,8 @@
top: 0;
z-index: 4;
background: var(--bar);
border-bottom: 1px solid var(--line-dark);
color: #625f5a;
border-bottom: var(--border-line-dark);
color: var(--text-table-heading);
font-size: 12px;
font-weight: 800;
letter-spacing: .04em;
@@ -293,7 +293,7 @@
}
.data-grid-body-cell.data-grid-row-odd {
background: #fbfaf8;
background: var(--surface-raised);
}
.data-grid-body-cell.data-grid-row-even {
@@ -359,7 +359,7 @@
.data-grid-filter-trigger {
border: 0;
background: transparent;
color: rgba(98, 95, 90, .68);
color: var(--text-table-heading-muted);
padding: 0;
display: inline-flex;
align-items: center;
@@ -384,20 +384,20 @@
.data-grid-filter-trigger:focus-visible,
.data-grid-filter-trigger.is-open {
color: var(--text-strong);
background: rgba(255, 255, 255, .45);
background: var(--panel-glass-muted);
outline: none;
}
.data-grid-filter-trigger.has-filter {
color: var(--text-strong);
background: rgba(255, 255, 255, .62);
box-shadow: inset 0 0 0 1px rgba(98, 95, 90, .24);
background: var(--panel-glass);
box-shadow: inset 0 0 0 1px var(--text-table-heading-muted-border);
}
.data-grid-filter-popover {
position: fixed;
z-index: 12000;
border: 1px solid var(--line-dark);
border: var(--border-line-dark);
border-radius: var(--radius-sm);
background: var(--surface);
box-shadow: var(--shadow-menu);
@@ -458,9 +458,9 @@
.data-grid-filter-field input,
.data-grid-filter-field select {
width: 100%;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: var(--radius-sm);
background: #fff;
background: var(--surface);
color: var(--text);
font: inherit;
font-weight: 500;
@@ -496,7 +496,7 @@
padding: 20px;
color: var(--muted);
background: var(--surface);
border-bottom: 1px solid var(--line);
border-bottom: var(--border-line);
}
.data-grid-cell.is-sticky-start,
@@ -507,19 +507,19 @@
}
.data-grid-cell.is-sticky-start {
border-right: 1px solid var(--line-dark);
box-shadow: 8px 0 14px -14px rgba(45, 43, 40, .6);
border-right: var(--border-line-dark);
box-shadow: var(--shadow-sticky-start);
}
.data-grid-cell.is-sticky-end {
border-left: 1px solid var(--line-dark);
box-shadow: -8px 0 14px -14px rgba(45, 43, 40, .6);
border-left: var(--border-line-dark);
box-shadow: var(--shadow-sticky-end);
}
.data-grid-header-cell.is-sticky-start,
.data-grid-header-cell.is-sticky-end {
z-index: 5;
border-bottom: 1px solid var(--line-dark);
border-bottom: var(--border-line-dark);
background: var(--bar);
}
@@ -534,9 +534,9 @@
.data-grid-list-select {
width: 100%;
min-width: 0;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: var(--radius-sm);
background: #fff;
background: var(--surface);
color: var(--text);
font: inherit;
padding: 6px 28px 6px 8px;
@@ -580,9 +580,9 @@
.data-grid-list-filter-options {
max-height: 230px;
overflow: auto;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: var(--radius-sm);
background: #fff;
background: var(--surface);
}
.data-grid-list-filter-row {
@@ -591,7 +591,7 @@
gap: 8px;
min-height: 38px;
padding: 6px 8px;
border-bottom: 1px solid var(--line);
border-bottom: var(--border-line);
}
.data-grid-list-filter-row:last-child {
@@ -645,9 +645,9 @@
.data-grid-list-option-add input {
width: 100%;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: var(--radius-sm);
background: #fff;
background: var(--surface);
color: var(--text);
font: inherit;
padding: 8px 10px;
@@ -694,7 +694,7 @@
.data-grid-pagination {
min-height: 52px;
padding: 8px 12px;
border-top: 1px solid var(--border-soft, var(--line));
border-top: 1px solid var(--line);
background: var(--panel);
display: flex;
align-items: center;
@@ -731,7 +731,7 @@
.data-grid-page-controls button {
width: 32px;
height: 32px;
border: 1px solid var(--border);
border: var(--border-line);
border-radius: var(--radius-xs, 5px);
background: var(--surface);
color: var(--text);
@@ -742,7 +742,7 @@
.data-grid-page-controls button:hover:not(:disabled),
.data-grid-page-controls button:focus-visible {
border-color: var(--line-dark);
background: var(--surface-strong, #f5f3ef);
background: var(--surface-strong);
}
.data-grid-page-controls button:disabled {
@@ -765,7 +765,7 @@
width: 100%;
min-width: 0;
overflow: hidden;
border: 1px solid var(--border);
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--surface);
}
@@ -779,7 +779,7 @@
.connection-tree-header {
min-height: 40px;
border-bottom: 1px solid var(--line-dark);
border-bottom: var(--border-line-dark);
background: var(--bar);
color: var(--muted);
font-size: 12px;
@@ -789,7 +789,7 @@
.connection-tree-row {
min-height: 58px;
border-bottom: 1px solid var(--line);
border-bottom: var(--border-line);
background: var(--surface);
}
@@ -806,7 +806,7 @@
align-items: center;
min-width: 0;
padding: 10px 12px;
border-right: 1px solid var(--line);
border-right: var(--border-line);
}
.connection-tree-cell:last-child {

View File

@@ -1,7 +1,16 @@
:root {
color-scheme: light;
--white: #ffffff;
--black: #000000;
--transparent: transparent;
--rail-bg: #25282a;
--rail-bg-active: #1c1e20;
--rail-text: #c7c6c0;
--rail-text-muted: #a7a49f;
--accent: #ef6b3a;
--accent-rgb: 239, 107, 58;
--accent-soft: rgba(239, 107, 58, .35);
--bg: #e9e7e4;
@@ -11,48 +20,242 @@
--panel-header: #ffffff;
--surface: #ffffff;
--surface-subtle: var(--panel-soft);
--surface-muted: #f6f7f9;
--surface-strong: #f5f3ef;
--surface-raised: #fbfaf8;
--titlebar-bg: #fbfbfa;
--sidebar-bg: #c8c4bf;
--sidebar-border: #afada9;
--sidebar-hover-bg: rgba(255,255,255,.35);
--sidebar-hover-bg-strong: rgba(255,255,255,.42);
--panel-glass: rgba(255,255,255,.56);
--panel-glass-muted: rgba(255, 255, 255, 0.45);
--panel-glass-bright: rgba(255, 255, 255, 0.9);
--panel-glass-strong: rgba(255,255,255,.86);
--line: #d6d2cc;
--line-dark: #bdb8b0;
--line-subtle: var(--line);
--line-strong: var(--line-dark);
--border: var(--line);
--border-line: 1px solid var(--line);
--border-line-dark: 1px solid var(--line-dark);
--border-soft-line: 1px solid var(--border-soft);
--border-muted-line: 1px solid var(--border-muted);
--border-soft: rgba(15, 23, 42, 0.12);
--border-muted: rgba(189, 184, 176, 0.32);
--border-danger-soft: rgba(180, 35, 24, .24);
--text: #48494c;
--text-strong: #303135;
--muted: #8a8781;
--muted-text: var(--muted);
--text-label: #6b6863;
--text-subtle: #686560;
--text-soft: #666666;
--text-table-heading: #625f5a;
--text-breadcrumb: #615f5c;
--text-hover-strong: #3e3e3f;
--neutral-950: #242424;
--neutral-900: #333333;
--neutral-800: #292929;
--control-disabled-bg: #ebe8e2;
--control-disabled-border: #c2bcb3;
--control-disabled-text: #706b63;
--control-disabled-placeholder: #969087;
--control-disabled-label: #7d776f;
--control-bg: #f8f8f7;
--control-border: #c9c5bd;
--control-border-hover: #aaa49a;
--control-border-pressed: #aaa299;
--control-gradient-start: #ffffff;
--control-gradient-end: #f2f1ef;
--control-gradient-end-muted: #f1efeb;
--control-gradient-end-hover: #e9e7e3;
--control-gradient-end-pressed: #e8e5df;
--control-text: #4f4b46;
--control-text-muted: #57534d;
--input-border-focus: #9bb7d3;
--toggle-track-bg: #cfd4da;
--toggle-track-border: #aeb4bb;
--green: #7bbcaf;
--amber: #f5c56c;
--red: #e06a5f;
--blue: #7ea6c5;
--primary: #0d6efd;
--primary-soft: rgba(13, 110, 253, .08);
--primary-soft-strong: rgba(13, 110, 253, .16);
--primary-border: rgba(13, 110, 253, .45);
--primary-focus-ring: 0 0 0 3px rgba(13, 110, 253, .18);
--primary-focus-ring-soft: 0 0 0 3px rgba(13,110,253,.2);
--primary-inset-ring: inset 0 0 0 1px rgba(13, 110, 253, .12);
--success-bg: #d8eee8;
--success-soft: #d6eee9;
--success-border: #5aa99b;
--success-border-soft: #78b7a9;
--success-line: var(--success-border);
--success: #157347;
--success-text: #315f55;
--success-text-strong: #34796d;
--info-bg: #dce9f3;
--info-soft: #d8e8f4;
--info-muted-bg: #e8eef5;
--info-muted-border: #c8d6e3;
--info-text: #365c76;
--info-text-strong: #386a90;
--info-text-muted: #36566f;
--info-text-deep: #294a61;
--warning-bg: #ffe1a3;
--warning-bg-hover: #ffd170;
--warning-soft: #ffedc6;
--warning-muted-bg: #fff1d0;
--warning-border: #d5a64a;
--warning-border-soft: rgba(154, 92, 0, .26);
--warning-text: #593700;
--warning-text-hover: #3e2800;
--warning-text-strong: #a06b00;
--warning: #8a5b00;
--warning-deep: #7c5412;
--danger-bg: #f8d1cc;
--danger-soft: #f6e3df;
--danger-muted-bg: #fff7f5;
--danger-border: #c96b63;
--danger-border-strong: #c94d43;
--danger-border-deep: #b42318;
--danger-focus-ring: 0 0 0 3px rgba(180, 35, 24, .14);
--danger-focus-ring-soft: 0 0 0 3px rgba(201, 107, 99, .13);
--danger-text: #a64840;
--danger-text-strong: #b13e35;
--danger-text-deep: #873c35;
--danger-text-tooltip: #7a241c;
--on-accent: #ffffff;
--on-dark: #ffffff;
--code-bg: #292929;
--code-text: #f1f1f1;
--status-neutral-bg: #e7e4df;
--step-number-bg: #d8d6d2;
--loading-line: #8c8881;
--brand-mark-gradient: conic-gradient(#ef6b3a 0 20%, #f2c66d 0 40%, #80b9b0 0 60%, #7e9fc0 0 80%, #56545f 0);
--focus-rgb: 82, 130, 177;
--focus-ring: 0 0 0 3px rgba(82, 130, 177, .14);
--focus-ring-color: rgba(82, 130, 177, .22);
--focus-ring-strong: 0 0 0 3px rgba(82, 130, 177, .22);
--focus-outline: 2px solid color-mix(in srgb, var(--blue) 55%, var(--white));
--text-table-heading-muted-border: rgba(98, 95, 90, .24);
--text-table-heading-muted: rgba(98, 95, 90, .68);
--overlay-backdrop: rgba(37, 40, 42, 0.38);
--dialog-backdrop: rgba(15, 23, 42, 0.36);
--hover-tint: rgba(0,0,0,.08);
--hover-tint-soft: rgba(0, 0, 0, .06);
--hover-tint-strong: rgba(0,0,0,.15);
--titlebar-hover-bg: rgba(0,0,0,.055);
--accent-hover-bg: rgba(239, 107, 58, .10);
--accent-auth-glow: rgba(239, 107, 58, .13);
--accent-ring-soft: 0 0 0 2px rgba(239, 107, 58, .16);
--blue-auth-glow: rgba(126, 166, 197, .15);
--transparent-surface: rgba(255, 255, 255, 0.00);
--loading-envelope-bg: rgba(255,255,255,.7);
--black-border-soft: rgba(0,0,0,.2);
--black-border-muted: rgba(0,0,0,.18);
--shadow-color-rgb: 0, 0, 0;
--shadow-warm-rgb: 45, 43, 40;
--shadow-slate-rgb: 15, 23, 42;
--shadow-highlight-rgb: 255, 255, 255;
--shadow: 0 1px 2px rgba(0,0,0,.15), 0 10px 30px rgba(0,0,0,.04);
--shadow-menu: 0 16px 40px rgba(0,0,0,.18);
--shadow-popover: 0 3px 8px -3px rgba(0,0,0,.16);
--shadow-card: 0 2px 10px rgba(0,0,0,.06);
--shadow-soft: 0 10px 28px rgba(15, 23, 42, 0.12);
--shadow-strong: 0 24px 64px rgba(15, 23, 42, 0.25);
--shadow-modal: 0 24px 80px rgba(0, 0, 0, 0.26);
--shadow-floating: 0 16px 38px rgba(15, 23, 42, 0.2);
--shadow-dismissible: 0 12px 24px rgba(15, 23, 42, 0.08);
--shadow-header: 0 8px 18px rgba(65, 60, 52, .08);
--shadow-chrome: 0 0 10px 0 darkgrey;
--shadow-control: inset 0 1px 0 rgba(255,255,255,.75), 0 1px 1px rgba(0,0,0,.05);
--shadow-control-strong: inset 0 1px 0 rgba(255,255,255,.85), 0 1px 1px rgba(0,0,0,.05);
--shadow-control-hover: inset 0 1px 0 rgba(255,255,255,.82), 0 2px 5px rgba(0,0,0,.08);
--shadow-control-inset: inset 0 1px 2px rgba(0,0,0,.04);
--shadow-control-inset-strong: inset 0 1px 2px rgba(0,0,0,.12);
--shadow-control-surface: inset 0 1px 0 rgba(255,255,255,.75), 0 1px 2px rgba(0,0,0,.035);
--shadow-inset-highlight: inset 0 1px 0 rgba(255,255,255,.45);
--shadow-inset-highlight-strong: inset 0 1px 0 rgba(255,255,255,.75);
--shadow-inset-highlight-muted: inset 0 1px 0 rgba(255,255,255,.32);
--shadow-sticky-start: 8px 0 14px -14px rgba(45, 43, 40, .6);
--shadow-sticky-end: -8px 0 14px -14px rgba(45, 43, 40, .6);
--shadow-data-header: 0 12px 16px -18px rgba(45, 43, 40, .8);
--shadow-rail: inset -1px 0 rgba(0,0,0,.35);
--shadow-xs: 0 1px 2px rgba(0,0,0,.05);
--shadow-thumb: 0 1px 2px rgba(0,0,0,.22);
--shadow-campaign-card: 0 14px 18px -22px rgba(45, 43, 40, .75);
--shadow-campaign-soft: 0 1px 2px rgba(38, 35, 30, .04);
--shadow-campaign-small: 0 4px 12px rgba(0,0,0,.09);
--shadow-footer: 0 -8px 24px rgba(15, 23, 42, .06);
--radius: 8px;
--radius-xs: 5px;
--radius-sm: 4px;
--radius-md: var(--radius);
--radius-lg: 12px;
--radius-pill: 999px;
--theme-preview-light-bg: #e9e7e4;
--theme-preview-light-bar: #d4d0ca;
--theme-preview-light-surface: #ffffff;
--theme-preview-light-line: #d6d2cc;
--theme-preview-light-text: #303135;
--theme-preview-light-muted: #8a8781;
--theme-preview-dark-bg: #202120;
--theme-preview-dark-bar: #34342f;
--theme-preview-dark-surface: #262724;
--theme-preview-dark-line: #454740;
--theme-preview-dark-text: #f6f4ed;
--theme-preview-dark-muted: #aaa79d;
--calendar-event-bg-default: #edf8f5;
--calendar-event-border-default: #b9d7d0;
--calendar-event-text: #21453e;
--calendar-event-muted-text: #4d6764;
--calendar-grid-bg: #f8f6f2;
--calendar-muted-bg: #f1efeb;
--calendar-today-bg: #e3f3ef;
--calendar-selected-bg: #e0f0ea;
--calendar-off-hours-rgb: 241, 239, 235;
--calendar-day-shade: rgba(241, 239, 235, .74);
--calendar-success-border: #4f9489;
--calendar-danger-bg: #fff2f0;
--calendar-danger-border: #f0b8b2;
--calendar-overlay: rgba(33, 69, 62, .46);
--calendar-switch-bg: #d8d3cb;
--calendar-focus-inset: inset 0 0 0 2px rgba(90, 169, 155, .55);
--calendar-focus-outline: 2px solid rgba(90, 169, 155, .35);
--review-flow-purple: #9b86c7;
--campaign-accent: #5d776c;
--campaign-card-bg: rgba(247, 246, 244, .96);
--campaign-card-bg-soft: rgba(247, 246, 244, .46);
--campaign-panel-border: rgba(189, 184, 176, .8);
--campaign-panel-bg: rgba(255, 255, 255, .91);
--campaign-stage-shadow: 0 4px 12px rgba(48, 49, 53, .10);
--campaign-stage-shadow-color: rgba(48, 49, 53, .10);
--campaign-panel-shadow: 0 8px 24px rgba(48, 49, 53, .10);
--campaign-panel-shadow-color: rgba(48, 49, 53, .14);
--campaign-panel-shadow-strong: 0 12px 32px rgba(48, 49, 53, .14);
--campaign-card-shadow: 0 14px 18px -22px rgba(45, 43, 40, .75);
--font: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
:root[data-theme="dark"] {
color-scheme: dark;
--rail-bg: #171819;
--rail-bg-active: #0f1011;
--rail-text: #dfddd7;
--rail-text-muted: #aaa79d;
--bg: #202120;
--bar: #34342f;
--panel: #2b2c2a;
@@ -60,21 +263,120 @@
--panel-header: #30312e;
--surface: #262724;
--surface-subtle: #2e302c;
--surface-muted: #2e302c;
--surface-strong: #34342f;
--surface-raised: #242523;
--titlebar-bg: #242523;
--sidebar-bg: #2b2c2a;
--sidebar-border: #454740;
--sidebar-hover-bg: rgba(255,255,255,.08);
--sidebar-hover-bg-strong: rgba(255,255,255,.12);
--panel-glass: rgba(36,37,35,.72);
--panel-glass-muted: rgba(255,255,255,.08);
--panel-glass-bright: rgba(38,39,36,.94);
--panel-glass-strong: rgba(38,39,36,.92);
--line: #454740;
--line-dark: #5b5d54;
--border: var(--line);
--border-soft: rgba(255,255,255,.10);
--border-muted: rgba(91, 93, 84, .55);
--text: #dfddd7;
--text-strong: #f6f4ed;
--muted: #aaa79d;
--muted-text: var(--muted);
--text-label: #c9c5bd;
--text-subtle: #c9c5bd;
--text-soft: #aaa79d;
--text-table-heading: #dfddd7;
--text-breadcrumb: #dfddd7;
--text-hover-strong: #f6f4ed;
--control-disabled-bg: #33352f;
--control-disabled-border: #5f6058;
--control-disabled-text: #b8b4aa;
--control-disabled-placeholder: #918d83;
--control-disabled-label: #aaa69b;
--control-bg: #30312e;
--control-border: #5b5d54;
--control-border-hover: #74776e;
--control-border-pressed: #74776e;
--control-gradient-start: #34342f;
--control-gradient-end: #2b2c2a;
--control-gradient-end-muted: #2b2c2a;
--control-gradient-end-hover: #3b3c36;
--control-gradient-end-pressed: #242523;
--control-text: #f6f4ed;
--control-text-muted: #dfddd7;
--input-border-focus: #7ea6c5;
--toggle-track-bg: #454740;
--toggle-track-border: #5b5d54;
--status-neutral-bg: #34342f;
--step-number-bg: #454740;
--loading-line: #aaa79d;
--code-bg: #171819;
--code-text: #f6f4ed;
--overlay-backdrop: rgba(15, 16, 17, 0.58);
--dialog-backdrop: rgba(15, 16, 17, 0.62);
--hover-tint: rgba(255,255,255,.08);
--hover-tint-soft: rgba(255,255,255,.06);
--hover-tint-strong: rgba(255,255,255,.14);
--titlebar-hover-bg: rgba(255,255,255,.08);
--accent-hover-bg: rgba(239, 107, 58, .16);
--accent-auth-glow: rgba(239, 107, 58, .18);
--blue-auth-glow: rgba(126, 166, 197, .18);
--transparent-surface: rgba(38, 39, 36, 0);
--warning-bg: #7c5412;
--warning-bg-hover: #8a5b00;
--warning-soft: #5a431f;
--warning-muted-bg: #4b3b22;
--warning-text: #ffe1a3;
--warning-text-hover: #ffedc6;
--warning-text-strong: #ffe1a3;
--danger-bg: #5f2e2a;
--danger-soft: #4f2d2a;
--danger-muted-bg: #3c2523;
--danger-text: #f0b8b2;
--danger-text-strong: #f8d1cc;
--danger-text-deep: #f8d1cc;
--danger-text-tooltip: #f8d1cc;
--success-bg: #24473f;
--success-soft: #24473f;
--success-text: #d8eee8;
--success-text-strong: #d6eee9;
--info-bg: #243d4e;
--info-soft: #243d4e;
--info-text: #dce9f3;
--info-text-strong: #d8e8f4;
--shadow: 0 1px 2px rgba(0,0,0,.35), 0 10px 30px rgba(0,0,0,.18);
--shadow-menu: 0 18px 44px rgba(0,0,0,.42);
--shadow-popover: 0 8px 22px rgba(0,0,0,.36);
--shadow-card: 0 2px 10px rgba(0,0,0,.22);
--shadow-soft: 0 10px 28px rgba(0,0,0,.28);
--shadow-strong: 0 24px 64px rgba(0,0,0,.46);
--shadow-modal: 0 24px 80px rgba(0,0,0,.52);
--shadow-floating: 0 18px 44px rgba(0,0,0,.42);
--shadow-dismissible: 0 12px 24px rgba(0,0,0,.28);
--shadow-header: 0 8px 18px rgba(0,0,0,.24);
--shadow-chrome: 0 0 10px 0 rgba(0,0,0,.42);
--shadow-data-header: 0 12px 16px -18px rgba(0,0,0,.9);
--shadow-rail: inset -1px 0 rgba(0,0,0,.52);
--shadow-xs: 0 1px 2px rgba(0,0,0,.28);
--shadow-thumb: 0 1px 2px rgba(0,0,0,.42);
--shadow-campaign-card: 0 14px 18px -22px rgba(0,0,0,.85);
--shadow-campaign-soft: 0 1px 2px rgba(0,0,0,.24);
--shadow-campaign-small: 0 4px 12px rgba(0,0,0,.32);
--shadow-footer: 0 -8px 24px rgba(0,0,0,.28);
--calendar-grid-bg: #242523;
--calendar-muted-bg: #34342f;
--calendar-today-bg: #24473f;
--calendar-selected-bg: #24473f;
--calendar-day-shade: rgba(52, 52, 47, .72);
--calendar-danger-bg: #4f2d2a;
--calendar-overlay: rgba(15, 16, 17, .56);
--calendar-switch-bg: #454740;
--campaign-card-bg: rgba(43, 44, 42, .96);
--campaign-card-bg-soft: rgba(43, 44, 42, .58);
--campaign-panel-border: rgba(91, 93, 84, .8);
--campaign-panel-bg: rgba(38, 39, 36, .94);
}
.ui-reduce-motion *,

View File

@@ -110,6 +110,59 @@ export type AuthInfo = {
available_languages?: Array<{ code: string; label: string; native_label?: string | null }>;
enabled_language_codes?: string[];
default_language?: string;
profile_loaded: boolean;
roles_loaded: boolean;
groups_loaded: boolean;
};
export type AuthUpdate = Partial<Omit<AuthInfo, "user" | "tenant" | "active_tenant" | "tenants">> & {
user?: Partial<AuthUser> | null;
tenant?: AuthTenant | null;
active_tenant?: AuthTenant | null;
tenants?: AuthTenantMembership[] | null;
};
export type AuthSessionInfo = {
authenticated: boolean;
auth_method: "session" | "api_key";
user: Pick<AuthUser, "id" | "account_id" | "email" | "display_name" | "tenant_display_name" | "is_tenant_admin" | "password_reset_required">;
tenant: AuthTenant;
active_tenant: AuthTenant;
session_id?: string | null;
api_key_id?: string | null;
expires_at?: string | null;
};
export type AuthShellInfo = {
user: AuthUser;
tenant: AuthTenant;
active_tenant: AuthTenant;
tenants?: AuthTenantMembership[];
scopes: string[];
principal?: PrincipalContext | null;
profile_loaded?: boolean;
roles_loaded?: boolean;
groups_loaded?: boolean;
};
export type AuthProfileInfo = AuthUpdate & {
user: AuthUser;
tenant: AuthTenant;
active_tenant: AuthTenant;
available_languages?: Array<{ code: string; label: string; native_label?: string | null }>;
enabled_language_codes?: string[];
default_language?: string;
profile_loaded: true;
};
export type AuthRolesInfo = {
roles: AuthRole[];
roles_loaded: true;
};
export type AuthGroupsInfo = {
groups: AuthGroup[];
groups_loaded: true;
};
export type LoginResponse = AuthInfo & {
@@ -189,7 +242,7 @@ export type PlatformNavItem = {
export type PlatformRouteContext = {
settings: ApiSettings;
auth: AuthInfo;
onAuthChange?: (auth: AuthInfo | null, accessToken?: string) => void;
onAuthChange?: (auth: AuthUpdate | null, accessToken?: string) => void;
};
export type AdminSectionGroup = "ROOT" | "SYSTEM" | "TENANT" | "GROUP" | "USER" | string;
@@ -214,6 +267,29 @@ export type AdminSectionsUiCapability = {
sections: AdminSectionContribution[];
};
export type SettingsSectionGroup = "account" | "ui" | string;
export type SettingsSectionRenderContext = PlatformRouteContext & {
activeSection: string;
availableSections: ReadonlySet<string>;
selectSection: (section: string) => void;
};
export type SettingsSectionContribution = {
id: string;
label: string;
group?: SettingsSectionGroup;
groupTitle?: string;
order?: number;
anyOf?: string[];
allOf?: string[];
render: (context: SettingsSectionRenderContext) => ReactNode;
};
export type SettingsSectionsUiCapability = {
sections: SettingsSectionContribution[];
};
export type PlatformRouteContribution = {
path: string;
anyOf?: string[];
@@ -378,6 +454,7 @@ export type MailServerProfile = {
export type MailCredentialPolicy = {
inherit?: boolean | null;
allow_override?: boolean | null;
};
export type MailProfilePatternKey = "smtp_hosts" | "imap_hosts" | "envelope_senders" | "from_headers" | "recipient_domains";
@@ -391,6 +468,7 @@ export type MailProfilePolicy = {
imap_credentials?: MailCredentialPolicy | null;
whitelist?: Partial<Record<MailProfilePatternKey, string[]>> | null;
blacklist?: Partial<Record<MailProfilePatternKey, string[]>> | null;
allow_lower_level_limits?: Partial<Record<string, boolean>> | null;
};
export type MailPolicyValidationInput = {

View File

@@ -13,9 +13,12 @@ function assertDeepEqual(actual: unknown, expected: unknown, message = "values s
}
import { renderToStaticMarkup } from "react-dom/server";
import CredentialPanel from "../src/components/CredentialPanel";
import PasswordField from "../src/components/PasswordField";
import MessageDisplayPanel from "../src/components/MessageDisplayPanel";
import MailServerSettingsPanel from "../src/components/mail/MailServerSettingsPanel";
import EmailAddressInput from "../src/components/email/EmailAddressInput";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
function noop() {}
@@ -31,6 +34,35 @@ const typedPassword = renderToStaticMarkup(
assert(typedPassword.includes('aria-label="i18n:govoplan-core.show_password.044b852f"'), "typed password shows reveal button");
assert(typedPassword.includes("password-field-toggle"), "typed password gets toggle class");
const translatedAddressInput = renderToStaticMarkup(
<PlatformLanguageProvider>
<EmailAddressInput
value={[]}
onChange={noop}
addLabel="i18n:govoplan-core.add_address.a71075c4"
emptyText="i18n:govoplan-core.no_address_added_yet.809c4247"
suggestions={[{ name: "Ada Lovelace", email: "ada@example.local" }]}
/>
</PlatformLanguageProvider>
);
assert(translatedAddressInput.includes("No address added yet"), "email input empty text is translated");
assert(!translatedAddressInput.includes("i18n:govoplan-core.no_address_added_yet.809c4247"), "email input does not leak empty-text i18n key");
assert(translatedAddressInput.includes('aria-label="Type a name and email address, then press Enter"'), "email input aria label is translated");
const savedCredentialPanel = renderToStaticMarkup(
<CredentialPanel
values={{ username: "sender", password: "" }}
onChange={noop}
savedPassword
savedPasswordPlaceholder="Saved credential"
heading="Credentials"
/>
);
assert(savedCredentialPanel.includes("Credentials"), "credential panel renders heading");
assert(savedCredentialPanel.includes('value="sender"'), "credential panel renders username");
assert(savedCredentialPanel.includes('placeholder="Saved credential"'), "credential panel renders saved password placeholder");
assert(!savedCredentialPanel.includes("password-field-toggle"), "saved empty credential does not show reveal button");
const settingsPanel = renderToStaticMarkup(
<MailServerSettingsPanel
smtp={{ host: "smtp.example.org", port: 587, username: "sender", password: "", security: "starttls", timeout_seconds: 30 }}
@@ -142,6 +174,39 @@ const disabledAppendFolderPanel = renderToStaticMarkup(
assert(!disabledAppendFolderPanel.includes("i18n:govoplan-core.folders.c603ab65"), "folder lookup action is hidden when append target is disabled");
assert(!disabledAppendFolderPanel.includes("i18n:govoplan-core.use_detected_folder.5ec4965c"), "detected folder action is hidden when append target is disabled");
const smtpServerOnlyPanel = renderToStaticMarkup(
<MailServerSettingsPanel
visibleSections={["smtp"]}
mode="server"
smtp={{ host: "smtp.example.org", port: 587, username: "sender", password: "", security: "starttls", timeout_seconds: 30 }}
imap={{ host: "imap.example.org", port: 993, username: "reader", password: "", security: "tls", sent_folder: "auto", timeout_seconds: 30 }}
onSmtpChange={noop}
onImapChange={noop}
/>
);
assert(smtpServerOnlyPanel.includes("i18n:govoplan-core.server.cb0cb170"), "focused server panel renders server fields");
assert(!smtpServerOnlyPanel.includes("i18n:govoplan-core.credentials.dd097a22"), "focused server panel hides credential fields");
assert(!smtpServerOnlyPanel.includes("i18n:govoplan-core.mail_server_settings_sections.40a163b7"), "single focused section hides section switcher");
assert(!smtpServerOnlyPanel.includes("imap.example.org"), "focused SMTP server panel does not render IMAP fields");
const imapCredentialsOnlyPanel = renderToStaticMarkup(
<MailServerSettingsPanel
initialSection="imap"
visibleSections={["imap"]}
mode="credentials"
smtp={{ host: "smtp.example.org", port: 587, username: "sender", password: "", security: "starttls", timeout_seconds: 30 }}
imap={{ host: "imap.example.org", port: 993, username: "reader", password: "", security: "tls", sent_folder: "auto", timeout_seconds: 30 }}
onSmtpChange={noop}
onImapChange={noop}
imapPasswordSaved
imapSavedPasswordPlaceholder="Saved IMAP password"
/>
);
assert(imapCredentialsOnlyPanel.includes("i18n:govoplan-core.credentials.dd097a22"), "focused credentials panel renders credential fields");
assert(!imapCredentialsOnlyPanel.includes("i18n:govoplan-core.server.cb0cb170"), "focused credentials panel hides server fields");
assert(imapCredentialsOnlyPanel.includes('placeholder="Saved IMAP password"'), "focused credentials panel preserves saved password UX");
assert(!imapCredentialsOnlyPanel.includes("smtp.example.org"), "focused IMAP credentials panel does not render SMTP fields");
const defaultPortSettingsPanel = renderToStaticMarkup(
<MailServerSettingsPanel
smtp={{ host: "smtp.example.org", security: "starttls" }}

View File

@@ -19,6 +19,8 @@
},
"include": [
"tests/mail-components.test.tsx",
"src/components/CredentialPanel.tsx",
"src/components/email/EmailAddressInput.tsx",
"src/components/PasswordField.tsx",
"src/components/MessageDisplayPanel.tsx",
"src/components/mail/MailServerSettingsPanel.tsx",

View File

@@ -13,6 +13,7 @@ const resolvedInstalledModulesVirtualId = `\0${installedModulesVirtualId}`;
const defaultWebModulePackages = [
"@govoplan/access-webui",
"@govoplan/admin-webui",
"@govoplan/addresses-webui",
"@govoplan/audit-webui",
"@govoplan/calendar-webui",
"@govoplan/campaign-webui",
@@ -21,6 +22,7 @@ const defaultWebModulePackages = [
"@govoplan/files-webui",
"@govoplan/idm-webui",
"@govoplan/mail-webui",
"@govoplan/notifications-webui",
"@govoplan/organizations-webui",
"@govoplan/ops-webui",
"@govoplan/policy-webui",
@@ -95,6 +97,7 @@ export default defineConfig({
fileURLToPath(new URL('.', import.meta.url)),
fileURLToPath(new URL('../../govoplan-access/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-admin/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-addresses/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-audit/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-calendar/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-dashboard/webui', import.meta.url)),
@@ -102,10 +105,12 @@ export default defineConfig({
fileURLToPath(new URL('../../govoplan-files/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-idm/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-mail/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-notifications/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-organizations/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-campaign/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-ops/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-policy/webui', import.meta.url))
fileURLToPath(new URL('../../govoplan-policy/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-scheduling/webui', import.meta.url))
]
},
proxy: {