intermittent commit
This commit is contained in:
45
webui/package-lock.json
generated
45
webui/package-lock.json
generated
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
56
webui/src/api/adminCommon.ts
Normal file
56
webui/src/api/adminCommon.ts
Normal 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");
|
||||
}
|
||||
@@ -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)
|
||||
});
|
||||
|
||||
@@ -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`);
|
||||
|
||||
143
webui/src/api/mailContracts.ts
Normal file
143
webui/src/api/mailContracts.ts
Normal 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;
|
||||
};
|
||||
@@ -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 }) }
|
||||
);
|
||||
}
|
||||
|
||||
103
webui/src/components/CredentialPanel.tsx
Normal file
103
webui/src/components/CredentialPanel.tsx
Normal 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);
|
||||
}
|
||||
21
webui/src/components/DisabledActionTooltip.tsx
Normal file
21
webui/src/components/DisabledActionTooltip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
228
webui/src/components/HoverTooltip.tsx
Normal file
228
webui/src/components/HoverTooltip.tsx
Normal 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
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>);
|
||||
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 *,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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" }}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user