mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: add domain rules management feature
- Introduced a new DomainRulesPage component for managing custom and global equivalent domains. - Updated AppMainRoutes to include a route for domain rules. - Added API functions to fetch and save domain rules. - Enhanced localization with new strings for domain rules in multiple languages. - Updated styles for the new domain rules interface and ensured responsiveness. - Added types for domain rules in the TypeScript definitions.
This commit is contained in:
+69
-1
@@ -23,6 +23,7 @@ import {
|
||||
stripProfileSecrets,
|
||||
} from '@/lib/api/auth';
|
||||
import { listAdminInvites, listAdminUsers } from '@/lib/api/admin';
|
||||
import { getDomainRules, saveDomainRules } from '@/lib/api/domains';
|
||||
import { getSends } from '@/lib/api/send';
|
||||
import { getCachedVaultCoreSnapshot, loadVaultCoreSyncSnapshot } from '@/lib/api/vault-sync';
|
||||
import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair';
|
||||
@@ -68,7 +69,7 @@ import {
|
||||
createDemoMainRoutesProps,
|
||||
} from '@/lib/demo';
|
||||
import type { AdminBackupSettings } from '@/lib/api/backup';
|
||||
import type { AdminInvite, AdminUser, AppPhase, AuthorizedDevice, Cipher, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types';
|
||||
import type { AdminInvite, AdminUser, AppPhase, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types';
|
||||
import type { VaultCoreSnapshot } from '@/lib/vault-cache';
|
||||
|
||||
function isBackupProgressDetail(value: unknown): value is BackupProgressDetail {
|
||||
@@ -87,6 +88,7 @@ const IMPORT_ROUTE_PATHS = [IMPORT_ROUTE, '/tools/import', '/tools/import-export
|
||||
const IMPORT_ROUTE_ALIASES: ReadonlySet<string> = new Set(IMPORT_ROUTE_PATHS.filter((path) => path !== IMPORT_ROUTE));
|
||||
const SETTINGS_HOME_ROUTE = '/settings';
|
||||
const SETTINGS_ACCOUNT_ROUTE = '/settings/account';
|
||||
const SETTINGS_DOMAIN_RULES_ROUTE = '/settings/domain-rules';
|
||||
const AUTH_ROUTE_PATHS = ['/', '/login', '/register', '/lock', '/recover-2fa'] as const;
|
||||
const APP_ROUTE_PATHS = [
|
||||
'/',
|
||||
@@ -98,6 +100,7 @@ const APP_ROUTE_PATHS = [
|
||||
'/backup',
|
||||
'/settings',
|
||||
SETTINGS_ACCOUNT_ROUTE,
|
||||
SETTINGS_DOMAIN_RULES_ROUTE,
|
||||
'/help',
|
||||
...IMPORT_ROUTE_PATHS,
|
||||
] as const;
|
||||
@@ -227,6 +230,7 @@ export default function App() {
|
||||
const pendingVaultCoreQueryRefreshRef = useRef<Promise<{ data?: VaultCoreSnapshot } | unknown> | null>(null);
|
||||
const pendingVaultCoreRefreshRef = useRef<Promise<unknown> | null>(null);
|
||||
const notificationRefreshTimerRef = useRef<number | null>(null);
|
||||
const domainRulesSaveSeqRef = useRef(0);
|
||||
const { toasts, pushToast, removeToast } = useToastManager();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -953,6 +957,45 @@ export default function App() {
|
||||
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const domainRulesQueryKey = useMemo(() => ['domain-rules', vaultCacheKey || session?.email] as const, [vaultCacheKey, session?.email]);
|
||||
const domainRulesQuery = useQuery({
|
||||
queryKey: domainRulesQueryKey,
|
||||
queryFn: () => getDomainRules(authedFetch),
|
||||
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone && location === SETTINGS_DOMAIN_RULES_ROUTE,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
function handleSaveDomainRules(customEquivalentDomains: CustomEquivalentDomain[], excludedGlobalEquivalentDomains: number[]): Promise<void> {
|
||||
const equivalentDomains = customEquivalentDomains.filter((rule) => !rule.excluded).map((rule) => rule.domains);
|
||||
const excludedGlobalTypes = new Set(excludedGlobalEquivalentDomains);
|
||||
const currentRules = queryClient.getQueryData<DomainRules>(domainRulesQueryKey) || domainRulesQuery.data;
|
||||
const optimisticRules: DomainRules = {
|
||||
object: 'domains',
|
||||
equivalentDomains,
|
||||
customEquivalentDomains,
|
||||
globalEquivalentDomains: (currentRules?.globalEquivalentDomains || []).map((rule) => ({
|
||||
...rule,
|
||||
excluded: excludedGlobalTypes.has(rule.type),
|
||||
})),
|
||||
};
|
||||
const saveSeq = ++domainRulesSaveSeqRef.current;
|
||||
queryClient.setQueryData(domainRulesQueryKey, optimisticRules);
|
||||
|
||||
void saveDomainRules(authedFetch, {
|
||||
customEquivalentDomains,
|
||||
equivalentDomains,
|
||||
excludedGlobalEquivalentDomains,
|
||||
}).then((updated) => {
|
||||
if (domainRulesSaveSeqRef.current !== saveSeq) return;
|
||||
queryClient.setQueryData(domainRulesQueryKey, updated);
|
||||
void queryClient.invalidateQueries({ queryKey: ['vault-core', vaultCacheKey] });
|
||||
}).catch((error) => {
|
||||
if (domainRulesSaveSeqRef.current !== saveSeq) return;
|
||||
pushToast('error', error instanceof Error ? error.message : t('txt_domain_rules_save_failed'));
|
||||
void domainRulesQuery.refetch();
|
||||
});
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
useQuery({
|
||||
queryKey: ['admin-backup-settings', vaultCacheKey],
|
||||
queryFn: () => backupActions.loadSettings(),
|
||||
@@ -1317,6 +1360,23 @@ export default function App() {
|
||||
const isImportRoute = routeLocation === IMPORT_ROUTE || IMPORT_ROUTE_ALIASES.has(routeLocation);
|
||||
const showSidebarToggle = mobileLayout && (location === '/vault' || location === '/sends');
|
||||
const sidebarToggleTitle = location === '/vault' ? t('txt_folders') : t('txt_type');
|
||||
const demoDomainRules = useMemo<DomainRules>(() => ({
|
||||
equivalentDomains: [
|
||||
['nodewarden.example', 'nw.example'],
|
||||
['staging.nodewarden.example', 'preview.nodewarden.example'],
|
||||
],
|
||||
customEquivalentDomains: [
|
||||
{ id: 'demo-custom-1', domains: ['nodewarden.example', 'nw.example'], excluded: false },
|
||||
{ id: 'demo-custom-2', domains: ['staging.nodewarden.example', 'preview.nodewarden.example'], excluded: false },
|
||||
],
|
||||
globalEquivalentDomains: [
|
||||
{ type: 0, domains: ['youtube.com', 'google.com', 'gmail.com'], excluded: false },
|
||||
{ type: 1, domains: ['apple.com', 'icloud.com'], excluded: false },
|
||||
{ type: 10, domains: ['microsoft.com', 'office.com', 'xbox.com'], excluded: true },
|
||||
{ type: -10001, domains: ['nodewarden.example', 'nw.example'], excluded: false },
|
||||
],
|
||||
object: 'domains',
|
||||
}), []);
|
||||
const mobilePrimaryRoute =
|
||||
location === '/sends'
|
||||
? '/sends'
|
||||
@@ -1330,6 +1390,7 @@ export default function App() {
|
||||
if (location === '/sends') return t('nav_sends');
|
||||
if (location === '/admin') return t('nav_admin_panel');
|
||||
if (location === '/security/devices') return t('nav_device_management');
|
||||
if (location === SETTINGS_DOMAIN_RULES_ROUTE) return t('nav_domain_rules');
|
||||
if (location === '/backup') return t('nav_backup_strategy');
|
||||
if (isImportRoute) return t('nav_import_export');
|
||||
if (location === SETTINGS_ACCOUNT_ROUTE) return t('nav_account_settings');
|
||||
@@ -1385,6 +1446,9 @@ export default function App() {
|
||||
authorizedDevices: authorizedDevicesQuery.data || [],
|
||||
authorizedDevicesLoading: authorizedDevicesQuery.isFetching,
|
||||
authorizedDevicesError: authorizedDevicesQuery.isError && !authorizedDevicesQuery.data ? t('txt_load_devices_failed') : '',
|
||||
domainRules: IS_DEMO_MODE ? demoDomainRules : domainRulesQuery.data || null,
|
||||
domainRulesLoading: domainRulesQuery.isFetching && !domainRulesQuery.data,
|
||||
domainRulesError: domainRulesQuery.isError && !domainRulesQuery.data ? t('txt_domain_rules_load_failed') : '',
|
||||
onNavigate: navigate,
|
||||
onLogout: handleLogout,
|
||||
onNotify: pushToast,
|
||||
@@ -1432,6 +1496,10 @@ export default function App() {
|
||||
onLockTimeoutChange: setLockTimeoutMinutes,
|
||||
onSessionTimeoutActionChange: setSessionTimeoutAction,
|
||||
onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices,
|
||||
onRefreshDomainRules: () => {
|
||||
void domainRulesQuery.refetch();
|
||||
},
|
||||
onSaveDomainRules: handleSaveDomainRules,
|
||||
onRenameAuthorizedDevice: accountSecurityActions.renameAuthorizedDevice,
|
||||
onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust,
|
||||
onRemoveDevice: accountSecurityActions.openRemoveDevice,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ArrowUpDown, Cloud, Clock3, Folder as FolderIcon, KeyRound, Lock, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
|
||||
import { ArrowUpDown, Cloud, Clock3, Folder as FolderIcon, Globe2, KeyRound, Lock, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
|
||||
import { Link } from 'wouter';
|
||||
import AppMainRoutes from '@/components/AppMainRoutes';
|
||||
import ThemeSwitch from '@/components/ThemeSwitch';
|
||||
@@ -102,6 +102,10 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
||||
<Shield size={16} />
|
||||
<span>{t('nav_device_management')}</span>
|
||||
</Link>
|
||||
<Link href="/settings/domain-rules" className={`side-link ${props.location === '/settings/domain-rules' ? 'active' : ''}`}>
|
||||
<Globe2 size={16} />
|
||||
<span>{t('nav_domain_rules')}</span>
|
||||
</Link>
|
||||
{isAdmin && (
|
||||
<Link href="/backup" className={`side-link ${props.location === '/backup' ? 'active' : ''}`}>
|
||||
<Cloud size={16} />
|
||||
@@ -114,7 +118,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
||||
</Link>
|
||||
</aside>
|
||||
<main className="content">
|
||||
<div key={routeAnimationKey} className="route-stage">
|
||||
<div key={routeAnimationKey} className={`route-stage ${props.location === '/settings/domain-rules' ? 'route-stage-fixed' : ''}`}>
|
||||
<AppMainRoutes {...props.mainRoutesProps} />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { lazy, Suspense } from 'preact/compat';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
import { Link, Route, Switch } from 'wouter';
|
||||
import { ArrowUpDown, Cloud, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
|
||||
import { ArrowUpDown, Cloud, Globe2, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
|
||||
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
|
||||
import LoadingState from '@/components/LoadingState';
|
||||
import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup';
|
||||
import type { CiphersImportPayload } from '@/lib/api/vault';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { AdminInvite, AdminUser, AuthorizedDevice, Cipher, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types';
|
||||
import type { AdminInvite, AdminUser, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types';
|
||||
import type { ExportRequest } from '@/lib/export-formats';
|
||||
|
||||
const VaultPage = lazy(() => import('@/components/VaultPage'));
|
||||
const SendsPage = lazy(() => import('@/components/SendsPage'));
|
||||
const TotpCodesPage = lazy(() => import('@/components/TotpCodesPage'));
|
||||
const SettingsPage = lazy(() => import('@/components/SettingsPage'));
|
||||
const DomainRulesPage = lazy(() => import('@/components/DomainRulesPage'));
|
||||
const SecurityDevicesPage = lazy(() => import('@/components/SecurityDevicesPage'));
|
||||
const AdminPage = lazy(() => import('@/components/AdminPage'));
|
||||
const BackupCenterPage = lazy(() => import('@/components/BackupCenterPage'));
|
||||
@@ -56,6 +57,9 @@ export interface AppMainRoutesProps {
|
||||
authorizedDevices: AuthorizedDevice[];
|
||||
authorizedDevicesLoading: boolean;
|
||||
authorizedDevicesError: string;
|
||||
domainRules: DomainRules | null;
|
||||
domainRulesLoading: boolean;
|
||||
domainRulesError: string;
|
||||
onNavigate: (path: string) => void;
|
||||
onLogout: () => void;
|
||||
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||
@@ -108,6 +112,8 @@ export interface AppMainRoutesProps {
|
||||
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
|
||||
onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void;
|
||||
onRefreshAuthorizedDevices: () => Promise<void>;
|
||||
onRefreshDomainRules: () => void;
|
||||
onSaveDomainRules: (customEquivalentDomains: CustomEquivalentDomain[], excludedGlobalEquivalentDomains: number[]) => Promise<void>;
|
||||
onRenameAuthorizedDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
|
||||
onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
|
||||
onRemoveDevice: (device: AuthorizedDevice) => void;
|
||||
@@ -268,6 +274,10 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
<Shield size={18} />
|
||||
<span>{t('nav_device_management')}</span>
|
||||
</Link>
|
||||
<Link href="/settings/domain-rules" className="mobile-settings-link">
|
||||
<Globe2 size={18} />
|
||||
<span>{t('nav_domain_rules')}</span>
|
||||
</Link>
|
||||
<Link href={props.importRoute} className="mobile-settings-link">
|
||||
<ArrowUpDown size={18} />
|
||||
<span>{t('nav_import_export')}</span>
|
||||
@@ -319,6 +329,28 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
</Suspense>
|
||||
</div>
|
||||
</Route>
|
||||
<Route path="/settings/domain-rules">
|
||||
<div className="stack domain-rules-route">
|
||||
{props.mobileLayout && (
|
||||
<div className="mobile-settings-subhead">
|
||||
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => props.onNavigate(props.settingsHomeRoute)}>
|
||||
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
||||
{t('txt_back')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<DomainRulesPage
|
||||
rules={props.domainRules}
|
||||
loading={props.domainRulesLoading}
|
||||
error={props.domainRulesError}
|
||||
onRefresh={props.onRefreshDomainRules}
|
||||
onSave={props.onSaveDomainRules}
|
||||
onNotify={props.onNotify}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</Route>
|
||||
<Route path="/admin">
|
||||
<div className="stack">
|
||||
{props.mobileLayout && (
|
||||
|
||||
@@ -0,0 +1,529 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import { Check, ChevronDown, ChevronUp, ExternalLink, Pencil, Plus, RefreshCw, Save, Trash2, X } from 'lucide-preact';
|
||||
import LoadingState from '@/components/LoadingState';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { CustomEquivalentDomain, DomainRules } from '@/lib/types';
|
||||
import { normalizeEquivalentDomain } from '@shared/domain-normalize';
|
||||
|
||||
const CUSTOM_GLOBAL_DOMAINS_PR_URL = 'https://github.com/shuaiplus/nodewarden/edit/main/src/static/global_domains.custom.json';
|
||||
|
||||
interface DomainRulesPageProps {
|
||||
rules: DomainRules | null;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
onRefresh: () => void;
|
||||
onSave: (customEquivalentDomains: CustomEquivalentDomain[], excludedGlobalEquivalentDomains: number[]) => Promise<void>;
|
||||
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||
}
|
||||
|
||||
interface DomainRuleSummaryProps {
|
||||
text: string;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
function normalizeDomain(value: string): string {
|
||||
return normalizeEquivalentDomain(value);
|
||||
}
|
||||
|
||||
function normalizeDomainList(domains: string[]): string[] {
|
||||
return Array.from(new Set(domains.map(normalizeDomain).filter(Boolean)));
|
||||
}
|
||||
|
||||
function isValidDomainName(value: string): boolean {
|
||||
return !!normalizeEquivalentDomain(value);
|
||||
}
|
||||
|
||||
function getInvalidDomainIndexes(domains: string[]): Set<number> {
|
||||
const invalid = new Set<number>();
|
||||
domains.forEach((domain, index) => {
|
||||
if (!isValidDomainName(domain)) invalid.add(index);
|
||||
});
|
||||
return invalid;
|
||||
}
|
||||
|
||||
function createDraftId(): string {
|
||||
return `custom-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function createEmptyDomains(): string[] {
|
||||
return ['', ''];
|
||||
}
|
||||
|
||||
function DomainRuleSummary(props: DomainRuleSummaryProps) {
|
||||
const textRef = useRef<HTMLSpanElement>(null);
|
||||
const [canExpand, setCanExpand] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const node = textRef.current;
|
||||
if (!node) return undefined;
|
||||
|
||||
const measure = () => {
|
||||
const width = node.getBoundingClientRect().width;
|
||||
if (!width || typeof document === 'undefined') {
|
||||
setCanExpand(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const probe = document.createElement('span');
|
||||
const styles = window.getComputedStyle(node);
|
||||
probe.textContent = props.text;
|
||||
probe.style.position = 'absolute';
|
||||
probe.style.visibility = 'hidden';
|
||||
probe.style.whiteSpace = 'nowrap';
|
||||
probe.style.font = styles.font;
|
||||
probe.style.letterSpacing = styles.letterSpacing;
|
||||
probe.style.left = '-9999px';
|
||||
probe.style.top = '-9999px';
|
||||
document.body.appendChild(probe);
|
||||
const fullWidth = probe.getBoundingClientRect().width;
|
||||
probe.remove();
|
||||
setCanExpand(fullWidth > width + 1);
|
||||
};
|
||||
|
||||
measure();
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
window.addEventListener('resize', measure);
|
||||
return () => window.removeEventListener('resize', measure);
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, [props.text]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
ref={textRef}
|
||||
className={`domain-rule-domains${props.expanded ? ' domain-rule-domains-expanded' : ''}`}
|
||||
>
|
||||
{props.text}
|
||||
</span>
|
||||
{canExpand && (
|
||||
<button
|
||||
type="button"
|
||||
className="domain-rule-expand-btn"
|
||||
title={props.expanded ? t('txt_collapse') : t('txt_expand')}
|
||||
aria-label={props.expanded ? t('txt_collapse') : t('txt_expand')}
|
||||
onClick={props.onToggle}
|
||||
>
|
||||
{props.expanded ? <ChevronUp size={15} /> : <ChevronDown size={15} />}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function toEditableCustomRules(rules: DomainRules | null): CustomEquivalentDomain[] {
|
||||
const source = rules?.customEquivalentDomains?.length
|
||||
? rules.customEquivalentDomains
|
||||
: (rules?.equivalentDomains || []).map((domains, index) => ({
|
||||
id: `custom-${index}`,
|
||||
domains,
|
||||
excluded: false,
|
||||
}));
|
||||
return source.map((rule, index) => ({
|
||||
id: String(rule.id || `custom-${index}`),
|
||||
domains: rule.domains.length >= 2 ? [...rule.domains] : createEmptyDomains(),
|
||||
excluded: !!rule.excluded,
|
||||
}));
|
||||
}
|
||||
|
||||
export default function DomainRulesPage(props: DomainRulesPageProps) {
|
||||
const [customRules, setCustomRules] = useState<CustomEquivalentDomain[]>([]);
|
||||
const [newRuleDomains, setNewRuleDomains] = useState<string[] | null>(null);
|
||||
const [editingRuleId, setEditingRuleId] = useState<string | null>(null);
|
||||
const [editingDomains, setEditingDomains] = useState<string[]>(createEmptyDomains);
|
||||
const [newRuleInvalidIndexes, setNewRuleInvalidIndexes] = useState<Set<number>>(new Set());
|
||||
const [editingInvalidIndexes, setEditingInvalidIndexes] = useState<Set<number>>(new Set());
|
||||
const [excludedTypes, setExcludedTypes] = useState<Set<number>>(new Set());
|
||||
const [expandedCustomRules, setExpandedCustomRules] = useState<Set<string>>(new Set());
|
||||
const [expandedGlobalRules, setExpandedGlobalRules] = useState<Set<number>>(new Set());
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setCustomRules(toEditableCustomRules(props.rules));
|
||||
setNewRuleDomains(null);
|
||||
setEditingRuleId(null);
|
||||
setEditingDomains(createEmptyDomains());
|
||||
setNewRuleInvalidIndexes(new Set());
|
||||
setEditingInvalidIndexes(new Set());
|
||||
setExpandedCustomRules(new Set());
|
||||
setExpandedGlobalRules(new Set());
|
||||
setExcludedTypes(new Set((props.rules?.globalEquivalentDomains || []).filter((entry) => entry.excluded).map((entry) => entry.type)));
|
||||
}, [props.rules]);
|
||||
|
||||
const sortedGlobals = useMemo(() => {
|
||||
return [...(props.rules?.globalEquivalentDomains || [])].sort((a, b) => {
|
||||
const aKey = a.domains[0] || '';
|
||||
const bKey = b.domains[0] || '';
|
||||
return aKey.localeCompare(bKey, undefined, { sensitivity: 'base' });
|
||||
});
|
||||
}, [props.rules]);
|
||||
|
||||
const filteredGlobals = useMemo(() => {
|
||||
const needle = filter.trim().toLowerCase();
|
||||
if (!needle) return sortedGlobals;
|
||||
return sortedGlobals.filter((entry) => entry.domains.some((domain) => domain.includes(needle)));
|
||||
}, [filter, sortedGlobals]);
|
||||
|
||||
function setCustomRuleEnabled(index: number, enabled: boolean): void {
|
||||
setCustomRules((rules) => rules.map((rule, ruleIndex) => ruleIndex === index ? { ...rule, excluded: !enabled } : rule));
|
||||
}
|
||||
|
||||
function beginEditCustomRule(rule: CustomEquivalentDomain): void {
|
||||
setNewRuleDomains(null);
|
||||
setEditingRuleId(rule.id);
|
||||
setEditingDomains(rule.domains.length >= 2 ? [...rule.domains] : createEmptyDomains());
|
||||
setEditingInvalidIndexes(new Set());
|
||||
}
|
||||
|
||||
function confirmEditCustomRule(): void {
|
||||
if (!editingRuleId) return;
|
||||
const invalidIndexes = getInvalidDomainIndexes(editingDomains);
|
||||
setEditingInvalidIndexes(invalidIndexes);
|
||||
if (invalidIndexes.size) {
|
||||
props.onNotify('warning', t('txt_domain_rule_invalid_domains'));
|
||||
return;
|
||||
}
|
||||
const domains = normalizeDomainList(editingDomains);
|
||||
if (domains.length < 2) {
|
||||
props.onNotify('warning', t('txt_domain_rule_needs_two_domains'));
|
||||
return;
|
||||
}
|
||||
setCustomRules((rules) => rules.map((rule) => rule.id === editingRuleId ? { ...rule, domains } : rule));
|
||||
setEditingRuleId(null);
|
||||
setEditingDomains(createEmptyDomains());
|
||||
}
|
||||
|
||||
function cancelEditCustomRule(): void {
|
||||
setEditingRuleId(null);
|
||||
setEditingDomains(createEmptyDomains());
|
||||
setEditingInvalidIndexes(new Set());
|
||||
}
|
||||
|
||||
function addNewRule(): void {
|
||||
const invalidIndexes = getInvalidDomainIndexes(newRuleDomains || []);
|
||||
setNewRuleInvalidIndexes(invalidIndexes);
|
||||
if (invalidIndexes.size) {
|
||||
props.onNotify('warning', t('txt_domain_rule_invalid_domains'));
|
||||
return;
|
||||
}
|
||||
const domains = normalizeDomainList(newRuleDomains || []);
|
||||
if (domains.length < 2) {
|
||||
props.onNotify('warning', t('txt_domain_rule_needs_two_domains'));
|
||||
return;
|
||||
}
|
||||
setCustomRules((rules) => [
|
||||
{
|
||||
id: createDraftId(),
|
||||
domains,
|
||||
excluded: false,
|
||||
},
|
||||
...rules,
|
||||
]);
|
||||
setNewRuleDomains(null);
|
||||
setNewRuleInvalidIndexes(new Set());
|
||||
}
|
||||
|
||||
function removeCustomRule(index: number): void {
|
||||
setCustomRules((rules) => rules.filter((_, currentIndex) => currentIndex !== index));
|
||||
}
|
||||
|
||||
function toggleGlobal(type: number): void {
|
||||
setExcludedTypes((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(type)) {
|
||||
next.delete(type);
|
||||
} else {
|
||||
next.add(type);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleExpandedCustomRule(id: string): void {
|
||||
setExpandedCustomRules((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleExpandedGlobalRule(type: number): void {
|
||||
setExpandedGlobalRules((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(type)) next.delete(type);
|
||||
else next.add(type);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
const normalizedCustomRules = customRules.map((rule) => ({
|
||||
...rule,
|
||||
domains: normalizeDomainList(rule.domains),
|
||||
}));
|
||||
if (normalizedCustomRules.some((rule) => rule.domains.some((domain) => !isValidDomainName(domain)))) {
|
||||
props.onNotify('warning', t('txt_domain_rule_invalid_domains'));
|
||||
return;
|
||||
}
|
||||
if (normalizedCustomRules.some((rule) => rule.domains.length < 2)) {
|
||||
props.onNotify('warning', t('txt_domain_rule_needs_two_domains'));
|
||||
return;
|
||||
}
|
||||
|
||||
const excludedGlobalEquivalentDomains = (props.rules?.globalEquivalentDomains || [])
|
||||
.filter((entry) => excludedTypes.has(entry.type))
|
||||
.map((entry) => entry.type);
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await props.onSave(normalizedCustomRules, excludedGlobalEquivalentDomains);
|
||||
props.onNotify('success', t('txt_domain_rules_saved'));
|
||||
} catch (error) {
|
||||
props.onNotify('error', error instanceof Error ? error.message : t('txt_domain_rules_save_failed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function renderDomainInputs(domains: string[], invalidIndexes: Set<number>, onChange: (index: number, value: string) => void, onAdd: () => void, onRemove?: (index: number) => void) {
|
||||
return (
|
||||
<div className="domain-rule-inputs">
|
||||
{domains.map((domain, index) => (
|
||||
<div key={index} className="domain-rule-input-piece">
|
||||
<input
|
||||
className={`input domain-rule-inline-input${invalidIndexes.has(index) ? ' domain-rule-input-invalid' : ''}`}
|
||||
value={domain}
|
||||
placeholder="example.com"
|
||||
aria-invalid={invalidIndexes.has(index)}
|
||||
onInput={(event) => onChange(index, (event.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
{domains.length > 2 && onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
className="domain-rule-input-remove"
|
||||
title={t('txt_remove_domain')}
|
||||
aria-label={t('txt_remove_domain')}
|
||||
onClick={() => onRemove(index)}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
{index < domains.length - 1 && <span className="domain-rule-operator">,</span>}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary small domain-rule-mini-btn"
|
||||
title={t('txt_add_domain')}
|
||||
aria-label={t('txt_add_domain')}
|
||||
onClick={onAdd}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.loading && !props.rules) {
|
||||
return <LoadingState card lines={6} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="domain-rules-page">
|
||||
<div className="domain-rules-toolbar">
|
||||
<div className="domain-rules-toolbar-copy">
|
||||
<div className="domain-rules-toolbar-title">{t('nav_domain_rules')}</div>
|
||||
<p>{t('txt_domain_rules_description')}</p>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button type="button" className="btn btn-primary" disabled={saving} onClick={() => void save()}>
|
||||
<Save size={14} className="btn-icon" />
|
||||
{saving ? t('txt_saving') : t('txt_save')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" disabled={props.loading} onClick={props.onRefresh}>
|
||||
<RefreshCw size={14} className="btn-icon" />
|
||||
{t('txt_sync')}
|
||||
</button>
|
||||
<a className="btn btn-secondary" href={CUSTOM_GLOBAL_DOMAINS_PR_URL} target="_blank" rel="noreferrer">
|
||||
<ExternalLink size={14} className="btn-icon" />
|
||||
{t('txt_submit_pr')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-modules-grid domain-rules-grid">
|
||||
<section className="card settings-module domain-rules-custom">
|
||||
<div className="section-heading-row">
|
||||
<h3>{t('txt_custom_equivalent_domains')}</h3>
|
||||
<button type="button" className="btn btn-secondary small" onClick={() => {
|
||||
setEditingRuleId(null);
|
||||
setEditingInvalidIndexes(new Set());
|
||||
setNewRuleDomains((current) => current || createEmptyDomains());
|
||||
setNewRuleInvalidIndexes(new Set());
|
||||
}}>
|
||||
<Plus size={14} className="btn-icon" />
|
||||
{t('txt_add')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{props.error && <div className="status-error">{props.error}</div>}
|
||||
|
||||
{newRuleDomains && (
|
||||
<div className="domain-rule-row domain-rule-editing-row domain-rule-new-row">
|
||||
<div className="domain-rule-main">
|
||||
{renderDomainInputs(
|
||||
newRuleDomains,
|
||||
newRuleInvalidIndexes,
|
||||
(index, value) => {
|
||||
setNewRuleDomains((domains) => (domains || createEmptyDomains()).map((domain, currentIndex) => currentIndex === index ? value : domain));
|
||||
setNewRuleInvalidIndexes((current) => {
|
||||
const next = new Set(current);
|
||||
next.delete(index);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
() => {
|
||||
setNewRuleDomains((domains) => [...(domains || createEmptyDomains()), '']);
|
||||
setNewRuleInvalidIndexes(new Set());
|
||||
},
|
||||
(index) => setNewRuleDomains((domains) => {
|
||||
const current = domains || createEmptyDomains();
|
||||
setNewRuleInvalidIndexes(new Set());
|
||||
return current.length > 2 ? current.filter((_, currentIndex) => currentIndex !== index) : current;
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<div className="domain-rule-row-actions">
|
||||
<button type="button" className="btn btn-primary small" onClick={addNewRule}>
|
||||
<Check size={14} className="btn-icon" />
|
||||
{t('txt_confirm')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary small" onClick={() => {
|
||||
setNewRuleDomains(null);
|
||||
setNewRuleInvalidIndexes(new Set());
|
||||
}}>
|
||||
<X size={14} className="btn-icon" />
|
||||
{t('txt_cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="domain-rules-table">
|
||||
{customRules.map((rule, ruleIndex) => (
|
||||
editingRuleId === rule.id ? (
|
||||
<div key={rule.id} className="domain-rule-row domain-rule-editing-row">
|
||||
<div className="domain-rule-main">
|
||||
{renderDomainInputs(
|
||||
editingDomains,
|
||||
editingInvalidIndexes,
|
||||
(domainIndex, value) => {
|
||||
setEditingDomains((domains) => domains.map((domain, currentIndex) => currentIndex === domainIndex ? value : domain));
|
||||
setEditingInvalidIndexes((current) => {
|
||||
const next = new Set(current);
|
||||
next.delete(domainIndex);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
() => {
|
||||
setEditingDomains((domains) => [...domains, '']);
|
||||
setEditingInvalidIndexes(new Set());
|
||||
},
|
||||
(domainIndex) => {
|
||||
setEditingInvalidIndexes(new Set());
|
||||
setEditingDomains((domains) => domains.length > 2 ? domains.filter((_, currentIndex) => currentIndex !== domainIndex) : domains);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
<div className="domain-rule-row-actions">
|
||||
<button type="button" className="btn btn-primary small" onClick={confirmEditCustomRule}>
|
||||
<Check size={14} className="btn-icon" />
|
||||
{t('txt_confirm')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary small" onClick={cancelEditCustomRule}>
|
||||
<X size={14} className="btn-icon" />
|
||||
{t('txt_cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div key={rule.id} className={`domain-rule-row${expandedCustomRules.has(rule.id) ? ' domain-rule-row-expanded' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!rule.excluded}
|
||||
aria-label={t('txt_enabled')}
|
||||
onChange={(event) => setCustomRuleEnabled(ruleIndex, (event.currentTarget as HTMLInputElement).checked)}
|
||||
/>
|
||||
<DomainRuleSummary
|
||||
text={rule.domains.join(', ')}
|
||||
expanded={expandedCustomRules.has(rule.id)}
|
||||
onToggle={() => toggleExpandedCustomRule(rule.id)}
|
||||
/>
|
||||
<div className="domain-rule-row-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary small domain-rule-icon-btn"
|
||||
title={t('txt_edit')}
|
||||
aria-label={t('txt_edit')}
|
||||
onClick={() => beginEditCustomRule(rule)}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary small domain-rule-icon-btn"
|
||||
title={t('txt_delete')}
|
||||
aria-label={t('txt_delete')}
|
||||
onClick={() => removeCustomRule(ruleIndex)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
{!customRules.length && !newRuleDomains && <div className="empty empty-comfortable">{t('txt_no_custom_domain_rules')}</div>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card settings-module domain-rules-global">
|
||||
<div className="section-heading-row">
|
||||
<h3>{t('txt_global_equivalent_domains')}</h3>
|
||||
<div className="domain-rules-heading-actions">
|
||||
<input
|
||||
className="input domain-rules-filter"
|
||||
value={filter}
|
||||
placeholder={t('txt_search_domains')}
|
||||
onInput={(event) => setFilter((event.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="domain-rules-table">
|
||||
{filteredGlobals.map((entry) => (
|
||||
<div key={entry.type} className={`domain-rule-row domain-rule-readonly-row${expandedGlobalRules.has(entry.type) ? ' domain-rule-row-expanded' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!excludedTypes.has(entry.type)}
|
||||
onChange={() => toggleGlobal(entry.type)}
|
||||
/>
|
||||
<DomainRuleSummary
|
||||
text={entry.domains.join(', ')}
|
||||
expanded={expandedGlobalRules.has(entry.type)}
|
||||
onToggle={() => toggleExpandedGlobalRule(entry.type)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{!filteredGlobals.length && <div className="empty empty-comfortable">{t('txt_no_domain_rules_found')}</div>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { DomainRules, TokenError } from '@/lib/types';
|
||||
import { parseErrorMessage, parseJson, type AuthedFetch } from './shared';
|
||||
|
||||
function normalizeDomainsResponse(body: Partial<DomainRules> & Record<string, unknown>): DomainRules {
|
||||
const equivalentDomains = Array.isArray(body.equivalentDomains)
|
||||
? body.equivalentDomains
|
||||
: Array.isArray(body.EquivalentDomains)
|
||||
? body.EquivalentDomains as string[][]
|
||||
: [];
|
||||
const globalEquivalentDomains = Array.isArray(body.globalEquivalentDomains)
|
||||
? body.globalEquivalentDomains
|
||||
: Array.isArray(body.GlobalEquivalentDomains)
|
||||
? body.GlobalEquivalentDomains as DomainRules['globalEquivalentDomains']
|
||||
: [];
|
||||
const customEquivalentDomains = Array.isArray(body.customEquivalentDomains)
|
||||
? body.customEquivalentDomains as DomainRules['customEquivalentDomains']
|
||||
: Array.isArray(body.CustomEquivalentDomains)
|
||||
? body.CustomEquivalentDomains as DomainRules['customEquivalentDomains']
|
||||
: equivalentDomains.map((domains, index) => ({
|
||||
id: `custom:${index}`,
|
||||
domains,
|
||||
excluded: false,
|
||||
}));
|
||||
|
||||
return {
|
||||
equivalentDomains,
|
||||
customEquivalentDomains,
|
||||
globalEquivalentDomains,
|
||||
object: 'domains',
|
||||
};
|
||||
}
|
||||
|
||||
export async function getDomainRules(authedFetch: AuthedFetch): Promise<DomainRules> {
|
||||
const resp = await authedFetch('/api/settings/domains');
|
||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_domain_rules_load_failed')));
|
||||
const body = await parseJson<Partial<DomainRules> & Record<string, unknown>>(resp);
|
||||
if (!body) throw new Error(t('txt_domain_rules_invalid_response'));
|
||||
return normalizeDomainsResponse(body);
|
||||
}
|
||||
|
||||
export async function saveDomainRules(
|
||||
authedFetch: AuthedFetch,
|
||||
payload: {
|
||||
customEquivalentDomains: DomainRules['customEquivalentDomains'];
|
||||
equivalentDomains: string[][];
|
||||
excludedGlobalEquivalentDomains: number[];
|
||||
}
|
||||
): Promise<DomainRules> {
|
||||
const resp = await authedFetch('/api/settings/domains', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(body?.error_description || body?.error || t('txt_domain_rules_save_failed'));
|
||||
}
|
||||
const body = await parseJson<Partial<DomainRules> & Record<string, unknown>>(resp);
|
||||
if (!body) throw new Error(t('txt_domain_rules_invalid_response'));
|
||||
return normalizeDomainsResponse(body);
|
||||
}
|
||||
@@ -909,6 +909,8 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
|
||||
authorizedDevices: state.authorizedDevices,
|
||||
authorizedDevicesLoading: false,
|
||||
authorizedDevicesError: '',
|
||||
domainRulesLoading: false,
|
||||
domainRulesError: '',
|
||||
onImport: async () => {
|
||||
await readonly();
|
||||
return createDemoImportResult();
|
||||
@@ -1055,6 +1057,10 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
|
||||
onRefreshAuthorizedDevices: async () => {
|
||||
notify('success', t('txt_demo_devices_refreshed'));
|
||||
},
|
||||
onRefreshDomainRules: () => {
|
||||
notify('success', t('txt_domain_rules_refreshed'));
|
||||
},
|
||||
onSaveDomainRules: readonly,
|
||||
onRenameAuthorizedDevice: async (device, name) => {
|
||||
const normalized = String(name || '').trim();
|
||||
if (!normalized) {
|
||||
|
||||
@@ -874,7 +874,28 @@ const en: Record<string, string> = {
|
||||
"txt_status_inactive": "Inactive",
|
||||
"txt_language": "Language",
|
||||
"txt_display_language": "Display language",
|
||||
"txt_language_saved_locally": "This preference is saved in this browser and used before the app loads next time."
|
||||
"txt_language_saved_locally": "This preference is saved in this browser and used before the app loads next time.",
|
||||
"nav_domain_rules": "Domain Rules",
|
||||
"txt_domain_rules_description": "Mark sites that share one login as equivalent domains. Global rules come from the preset list; custom rules only affect your own matching.",
|
||||
"txt_submit_pr": "Submit PR",
|
||||
"txt_custom_equivalent_domains": "Custom equivalent domains",
|
||||
"txt_global_equivalent_domains": "Global equivalent domains",
|
||||
"txt_domain_group": "Domain group",
|
||||
"txt_no_custom_domain_rules": "No custom domain rules",
|
||||
"txt_no_domain_rules_found": "No domain rules found",
|
||||
"txt_search_domains": "Search domains",
|
||||
"txt_domain_rules_saved": "Domain rules saved",
|
||||
"txt_domain_rules_save_failed": "Saving domain rules failed",
|
||||
"txt_domain_rules_load_failed": "Loading domain rules failed",
|
||||
"txt_domain_rules_invalid_response": "Invalid domain rules response",
|
||||
"txt_domain_rules_refreshed": "Domain rules refreshed",
|
||||
"txt_saving": "Saving...",
|
||||
"txt_domain_rule_needs_two_domains": "Each domain rule needs at least two domains.",
|
||||
"txt_domain_rule_invalid_domains": "Please enter valid domains, such as example.com.",
|
||||
"txt_add_domain": "Add domain",
|
||||
"txt_expand": "Expand",
|
||||
"txt_collapse": "Collapse",
|
||||
"txt_remove_domain": "Remove domain"
|
||||
};
|
||||
|
||||
export default en;
|
||||
|
||||
@@ -874,7 +874,28 @@ const es: Record<string, string> = {
|
||||
"txt_status_inactive": "Inactivo",
|
||||
"txt_language": "Idioma",
|
||||
"txt_display_language": "Idioma de visualización",
|
||||
"txt_language_saved_locally": "Esta preferencia se guarda en este navegador y se usa antes de que la aplicación cargue la próxima vez."
|
||||
"txt_language_saved_locally": "Esta preferencia se guarda en este navegador y se usa antes de que la aplicación cargue la próxima vez.",
|
||||
"nav_domain_rules": "Reglas de dominio",
|
||||
"txt_domain_rules_description": "Marca los sitios que comparten un inicio de sesión como dominios equivalentes. Las reglas globales vienen de la lista predefinida; las personalizadas solo afectan tus coincidencias.",
|
||||
"txt_submit_pr": "Enviar PR",
|
||||
"txt_custom_equivalent_domains": "Dominios equivalentes personalizados",
|
||||
"txt_global_equivalent_domains": "Dominios equivalentes globales",
|
||||
"txt_domain_group": "Grupo de dominios",
|
||||
"txt_no_custom_domain_rules": "No hay reglas de dominio personalizadas",
|
||||
"txt_no_domain_rules_found": "No se encontraron reglas de dominio",
|
||||
"txt_search_domains": "Buscar dominios",
|
||||
"txt_domain_rules_saved": "Reglas de dominio guardadas",
|
||||
"txt_domain_rules_save_failed": "No se pudieron guardar las reglas de dominio",
|
||||
"txt_domain_rules_load_failed": "No se pudieron cargar las reglas de dominio",
|
||||
"txt_domain_rules_invalid_response": "Respuesta de reglas de dominio no válida",
|
||||
"txt_domain_rules_refreshed": "Reglas de dominio actualizadas",
|
||||
"txt_saving": "Guardando...",
|
||||
"txt_domain_rule_needs_two_domains": "Cada regla de dominio necesita al menos dos dominios.",
|
||||
"txt_domain_rule_invalid_domains": "Introduce dominios válidos, como example.com.",
|
||||
"txt_add_domain": "Añadir dominio",
|
||||
"txt_expand": "Expandir",
|
||||
"txt_collapse": "Contraer",
|
||||
"txt_remove_domain": "Quitar dominio"
|
||||
};
|
||||
|
||||
export default es;
|
||||
|
||||
@@ -874,7 +874,28 @@ const ru: Record<string, string> = {
|
||||
"txt_status_inactive": "Неактивный",
|
||||
"txt_language": "Язык",
|
||||
"txt_display_language": "Язык дисплея",
|
||||
"txt_language_saved_locally": "Этот выбор сохраняется в текущем браузере и применяется при следующей загрузке приложения."
|
||||
"txt_language_saved_locally": "Этот выбор сохраняется в текущем браузере и применяется при следующей загрузке приложения.",
|
||||
"nav_domain_rules": "Правила доменов",
|
||||
"txt_domain_rules_description": "Отмечайте сайты с одним логином как эквивалентные домены. Глобальные правила берутся из готового списка, а пользовательские влияют только на ваши совпадения.",
|
||||
"txt_submit_pr": "Отправить PR",
|
||||
"txt_custom_equivalent_domains": "Пользовательские эквивалентные домены",
|
||||
"txt_global_equivalent_domains": "Глобальные эквивалентные домены",
|
||||
"txt_domain_group": "Группа доменов",
|
||||
"txt_no_custom_domain_rules": "Нет пользовательских правил доменов",
|
||||
"txt_no_domain_rules_found": "Правила доменов не найдены",
|
||||
"txt_search_domains": "Поиск доменов",
|
||||
"txt_domain_rules_saved": "Правила доменов сохранены",
|
||||
"txt_domain_rules_save_failed": "Не удалось сохранить правила доменов",
|
||||
"txt_domain_rules_load_failed": "Не удалось загрузить правила доменов",
|
||||
"txt_domain_rules_invalid_response": "Недопустимый ответ правил доменов",
|
||||
"txt_domain_rules_refreshed": "Правила доменов обновлены",
|
||||
"txt_saving": "Сохранение...",
|
||||
"txt_domain_rule_needs_two_domains": "В каждом правиле доменов должно быть не менее двух доменов.",
|
||||
"txt_domain_rule_invalid_domains": "Введите корректные домены, например example.com.",
|
||||
"txt_add_domain": "Добавить домен",
|
||||
"txt_expand": "Развернуть",
|
||||
"txt_collapse": "Свернуть",
|
||||
"txt_remove_domain": "Удалить домен"
|
||||
};
|
||||
|
||||
export default ru;
|
||||
|
||||
@@ -874,7 +874,28 @@ const zhCN: Record<string, string> = {
|
||||
"txt_status_inactive": "未激活",
|
||||
"txt_language": "语言",
|
||||
"txt_display_language": "显示语言",
|
||||
"txt_language_saved_locally": "此偏好会保存在当前浏览器中,下次打开应用前就会生效。"
|
||||
"txt_language_saved_locally": "此偏好会保存在当前浏览器中,下次打开应用前就会生效。",
|
||||
"nav_domain_rules": "域名规则",
|
||||
"txt_domain_rules_description": "多个网站共用同一登录信息时,可将它们设为等效域名;全局规则来自预置列表,自定义规则只影响你自己的匹配。",
|
||||
"txt_submit_pr": "提交 PR",
|
||||
"txt_custom_equivalent_domains": "自定义等效域名",
|
||||
"txt_global_equivalent_domains": "全局等效域名",
|
||||
"txt_domain_group": "域名组",
|
||||
"txt_no_custom_domain_rules": "暂无自定义域名规则",
|
||||
"txt_no_domain_rules_found": "未找到域名规则",
|
||||
"txt_search_domains": "搜索域名",
|
||||
"txt_domain_rules_saved": "域名规则已保存",
|
||||
"txt_domain_rules_save_failed": "保存域名规则失败",
|
||||
"txt_domain_rules_load_failed": "加载域名规则失败",
|
||||
"txt_domain_rules_invalid_response": "域名规则响应无效",
|
||||
"txt_domain_rules_refreshed": "域名规则已刷新",
|
||||
"txt_saving": "保存中...",
|
||||
"txt_domain_rule_needs_two_domains": "每条域名规则至少需要两个域名。",
|
||||
"txt_domain_rule_invalid_domains": "请输入有效域名,例如 example.com。",
|
||||
"txt_add_domain": "新增域名",
|
||||
"txt_expand": "展开",
|
||||
"txt_collapse": "收起",
|
||||
"txt_remove_domain": "移除域名"
|
||||
};
|
||||
|
||||
export default zhCN;
|
||||
|
||||
@@ -874,7 +874,28 @@ const zhTW: Record<string, string> = {
|
||||
"txt_status_inactive": "未激活",
|
||||
"txt_language": "語言",
|
||||
"txt_display_language": "顯示語言",
|
||||
"txt_language_saved_locally": "此偏好會保存在當前瀏覽器中,下次打開應用前就會生效。"
|
||||
"txt_language_saved_locally": "此偏好會保存在當前瀏覽器中,下次打開應用前就會生效。",
|
||||
"nav_domain_rules": "域名規則",
|
||||
"txt_domain_rules_description": "多個網站共用同一登入資訊時,可將它們設為等效域名;全局規則來自預置列表,自定義規則只影響你自己的匹配。",
|
||||
"txt_submit_pr": "提交 PR",
|
||||
"txt_custom_equivalent_domains": "自定義等效域名",
|
||||
"txt_global_equivalent_domains": "全局等效域名",
|
||||
"txt_domain_group": "域名組",
|
||||
"txt_no_custom_domain_rules": "暫無自定義域名規則",
|
||||
"txt_no_domain_rules_found": "未找到域名規則",
|
||||
"txt_search_domains": "搜索域名",
|
||||
"txt_domain_rules_saved": "域名規則已保存",
|
||||
"txt_domain_rules_save_failed": "保存域名規則失敗",
|
||||
"txt_domain_rules_load_failed": "加載域名規則失敗",
|
||||
"txt_domain_rules_invalid_response": "域名規則響應無效",
|
||||
"txt_domain_rules_refreshed": "域名規則已刷新",
|
||||
"txt_saving": "保存中...",
|
||||
"txt_domain_rule_needs_two_domains": "每條域名規則至少需要兩個域名。",
|
||||
"txt_domain_rule_invalid_domains": "請輸入有效域名,例如 example.com。",
|
||||
"txt_add_domain": "新增域名",
|
||||
"txt_expand": "展開",
|
||||
"txt_collapse": "收起",
|
||||
"txt_remove_domain": "移除域名"
|
||||
};
|
||||
|
||||
export default zhTW;
|
||||
|
||||
@@ -359,3 +359,22 @@ export interface AuthorizedDevice {
|
||||
trustedTokenCount: number;
|
||||
trustedUntil: string | null;
|
||||
}
|
||||
|
||||
export interface GlobalEquivalentDomain {
|
||||
type: number;
|
||||
domains: string[];
|
||||
excluded: boolean;
|
||||
}
|
||||
|
||||
export interface CustomEquivalentDomain {
|
||||
id: string;
|
||||
domains: string[];
|
||||
excluded: boolean;
|
||||
}
|
||||
|
||||
export interface DomainRules {
|
||||
equivalentDomains: string[][];
|
||||
customEquivalentDomains: CustomEquivalentDomain[];
|
||||
globalEquivalentDomains: GlobalEquivalentDomain[];
|
||||
object: 'domains';
|
||||
}
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
@apply grid gap-3;
|
||||
}
|
||||
|
||||
.domain-rules-route {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.import-export-page {
|
||||
@apply grid gap-3;
|
||||
}
|
||||
@@ -19,9 +26,8 @@
|
||||
.backup-operations-sidebar,
|
||||
.backup-destination-sidebar,
|
||||
.backup-detail-panel {
|
||||
@apply min-w-0 rounded-xl bg-white p-3;
|
||||
border: 1px solid #d8dee8;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
|
||||
@apply min-w-0 rounded-2xl border bg-panel p-3 shadow-soft;
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
.backup-actions-stack {
|
||||
@@ -305,7 +311,7 @@
|
||||
}
|
||||
|
||||
.backup-browser-list {
|
||||
@apply overflow-hidden rounded-xl bg-white;
|
||||
@apply overflow-hidden rounded-xl border bg-white;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
@@ -875,3 +881,275 @@
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.section-heading-row {
|
||||
@apply mb-3.5 flex items-center justify-between gap-3;
|
||||
}
|
||||
|
||||
.section-heading-row h3 {
|
||||
@apply mb-0;
|
||||
}
|
||||
|
||||
.domain-rules-page {
|
||||
@apply grid min-h-0 gap-3.5;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.domain-rules-toolbar {
|
||||
@apply flex flex-wrap items-start justify-between gap-3;
|
||||
}
|
||||
|
||||
.domain-rules-toolbar-copy {
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.domain-rules-toolbar-title {
|
||||
@apply text-base font-bold;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.domain-rules-toolbar-copy p {
|
||||
@apply mt-1.5 text-sm leading-6;
|
||||
color: var(--muted-strong);
|
||||
}
|
||||
|
||||
.domain-rules-grid {
|
||||
min-height: 0;
|
||||
grid-template-columns: minmax(380px, 1fr) minmax(420px, 1.08fr);
|
||||
}
|
||||
|
||||
.domain-rules-custom,
|
||||
.domain-rules-global {
|
||||
@apply flex min-h-0 flex-col rounded-2xl border bg-panel shadow-soft;
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
.domain-rules-heading-actions {
|
||||
@apply flex flex-wrap items-center justify-end gap-2;
|
||||
}
|
||||
|
||||
.domain-rules-filter {
|
||||
width: min(240px, 100%);
|
||||
}
|
||||
|
||||
.domain-rules-table {
|
||||
@apply grid min-h-0 flex-1 content-start gap-2 overflow-auto pr-0.5;
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
.domain-rule-row {
|
||||
@apply grid items-center gap-2.5 rounded-md px-2.5 py-2.5;
|
||||
grid-template-columns: 18px minmax(0, 1fr) auto auto;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.domain-rule-row > input[type='checkbox'] {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.domain-rule-readonly-row {
|
||||
grid-template-columns: 18px minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.domain-rule-editing-row {
|
||||
@apply items-start;
|
||||
grid-template-columns: minmax(360px, 1fr) auto;
|
||||
column-gap: 12px;
|
||||
}
|
||||
|
||||
.domain-rule-domains {
|
||||
display: block;
|
||||
line-height: 20px;
|
||||
min-width: 0;
|
||||
max-height: 20px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
max-height 180ms var(--ease-out-soft),
|
||||
opacity 140ms var(--ease-smooth);
|
||||
}
|
||||
|
||||
.domain-rule-row-expanded {
|
||||
@apply items-start;
|
||||
}
|
||||
|
||||
.domain-rule-row-expanded > input[type='checkbox'],
|
||||
.domain-rule-row-expanded .domain-rule-expand-btn,
|
||||
.domain-rule-row-expanded .domain-rule-row-actions {
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.domain-rule-row-expanded .domain-rule-domains {
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.domain-rule-domains-expanded {
|
||||
max-height: 220px;
|
||||
}
|
||||
|
||||
.domain-rule-expand-btn {
|
||||
@apply flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-0 p-0;
|
||||
background: transparent;
|
||||
color: var(--muted-strong);
|
||||
transition:
|
||||
background-color var(--dur-fast) var(--ease-smooth),
|
||||
color var(--dur-fast) var(--ease-smooth),
|
||||
transform var(--dur-fast) var(--ease-out-soft);
|
||||
}
|
||||
|
||||
.domain-rule-expand-btn:hover {
|
||||
background: var(--panel-soft);
|
||||
color: var(--primary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.domain-rule-main {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.domain-rule-inputs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px 18px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.domain-rule-input-piece {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
@apply flex items-center;
|
||||
}
|
||||
|
||||
.domain-rule-inline-input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding-right: 52px;
|
||||
}
|
||||
|
||||
.domain-rule-inline-input.domain-rule-input-invalid {
|
||||
border-color: rgba(217, 45, 87, 0.78);
|
||||
background: color-mix(in srgb, var(--danger) 5%, var(--panel));
|
||||
box-shadow: 0 0 0 3px rgba(217, 45, 87, 0.10), inset 0 1px 0 rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.domain-rule-inline-input.domain-rule-input-invalid:focus {
|
||||
border-color: rgba(217, 45, 87, 0.86);
|
||||
box-shadow: 0 0 0 4px rgba(217, 45, 87, 0.14), inset 0 1px 0 rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
.domain-rule-operator {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: -12px;
|
||||
transform: translateY(-50%);
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.domain-rule-input-piece:nth-child(even) .domain-rule-operator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.domain-rule-mini-btn,
|
||||
.domain-rule-icon-btn {
|
||||
@apply h-9 w-9 justify-center p-0;
|
||||
}
|
||||
|
||||
.domain-rule-input-remove {
|
||||
@apply absolute top-1/2 flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-full border-0 p-0;
|
||||
right: 1rem;
|
||||
background: var(--panel-soft);
|
||||
color: var(--primary);
|
||||
transition:
|
||||
background-color var(--dur-fast) var(--ease-smooth),
|
||||
color var(--dur-fast) var(--ease-smooth),
|
||||
transform var(--dur-fast) var(--ease-out-soft);
|
||||
}
|
||||
|
||||
.domain-rule-input-remove:hover {
|
||||
background: color-mix(in srgb, var(--danger) 12%, var(--panel));
|
||||
color: var(--danger);
|
||||
transform: translateY(-50%) scale(1.04);
|
||||
}
|
||||
|
||||
.domain-rule-row-actions {
|
||||
@apply flex items-center self-center gap-2;
|
||||
}
|
||||
|
||||
.domain-rule-row-actions .btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.domain-rule-editing-row .domain-rule-row-actions {
|
||||
align-self: start;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.domain-rule-new-row {
|
||||
@apply mb-2;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.route-stage-fixed {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.domain-rules-route {
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
grid-template-rows: auto;
|
||||
}
|
||||
|
||||
.domain-rules-page {
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.domain-rules-grid {
|
||||
grid-auto-rows: minmax(320px, min(54vh, 560px));
|
||||
}
|
||||
|
||||
.domain-rules-table {
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.domain-rules-page {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.domain-rules-grid {
|
||||
grid-auto-rows: auto;
|
||||
}
|
||||
|
||||
.domain-rules-table {
|
||||
max-height: 56vh;
|
||||
}
|
||||
|
||||
.domain-rule-editing-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.domain-rule-inputs {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.domain-rule-operator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.domain-rule-editing-row .domain-rule-row-actions {
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,6 +417,10 @@
|
||||
@apply rounded-2xl;
|
||||
}
|
||||
|
||||
.detail-col {
|
||||
@apply overflow-visible rounded-2xl border-0 bg-transparent p-0 shadow-none;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply p-3.5;
|
||||
}
|
||||
@@ -477,6 +481,7 @@
|
||||
}
|
||||
|
||||
.settings-modules-grid,
|
||||
.domain-rules-grid,
|
||||
.password-settings-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -189,6 +189,10 @@
|
||||
@apply h-full min-h-0 overflow-auto;
|
||||
}
|
||||
|
||||
.route-stage-fixed {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mobile-sidebar-mask {
|
||||
@apply pointer-events-none invisible fixed inset-0 opacity-0;
|
||||
background: rgba(15, 23, 42, 0.36);
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
|
||||
.sidebar,
|
||||
.list-panel,
|
||||
.card {
|
||||
.card,
|
||||
.detail-col {
|
||||
@apply rounded-2xl border bg-panel shadow-soft;
|
||||
border-color: var(--line);
|
||||
}
|
||||
@@ -483,7 +484,7 @@
|
||||
}
|
||||
|
||||
.detail-col {
|
||||
@apply min-h-0 overflow-auto;
|
||||
@apply min-h-0 overflow-auto p-2;
|
||||
}
|
||||
|
||||
.mobile-panel-head {
|
||||
|
||||
Reference in New Issue
Block a user