mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +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:
@@ -0,0 +1,51 @@
|
|||||||
|
name: Sync Bitwarden global domains
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "17 4 * * 1"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
bitwarden_ref:
|
||||||
|
description: "bitwarden/server ref to sync"
|
||||||
|
required: false
|
||||||
|
default: "main"
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync-global-domains:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Sync generated Bitwarden domains
|
||||||
|
run: npm run domains:sync -- --ref "${{ inputs.bitwarden_ref || 'main' }}"
|
||||||
|
|
||||||
|
- name: Verify custom domains were not touched
|
||||||
|
run: git diff --exit-code -- src/static/global_domains.custom.json
|
||||||
|
|
||||||
|
- name: Create pull request
|
||||||
|
uses: peter-evans/create-pull-request@v6
|
||||||
|
with:
|
||||||
|
branch: chore/sync-bitwarden-global-domains
|
||||||
|
delete-branch: true
|
||||||
|
title: "chore: sync Bitwarden global domain rules"
|
||||||
|
commit-message: "chore: sync Bitwarden global domain rules"
|
||||||
|
body: |
|
||||||
|
Automated sync from bitwarden/server.
|
||||||
|
|
||||||
|
This PR only updates:
|
||||||
|
- `src/static/global_domains.bitwarden.json`
|
||||||
|
- `src/static/global_domains.bitwarden.meta.json`
|
||||||
|
|
||||||
|
`src/static/global_domains.custom.json` is intentionally left untouched.
|
||||||
|
add-paths: |
|
||||||
|
src/static/global_domains.bitwarden.json
|
||||||
|
src/static/global_domains.bitwarden.meta.json
|
||||||
@@ -33,6 +33,15 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS domain_settings (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
equivalent_domains TEXT NOT NULL DEFAULT '[]',
|
||||||
|
custom_equivalent_domains TEXT NOT NULL DEFAULT '[]',
|
||||||
|
excluded_global_equivalent_domains TEXT NOT NULL DEFAULT '[]',
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
-- Per-user sync revision date
|
-- Per-user sync revision date
|
||||||
CREATE TABLE IF NOT EXISTS user_revisions (
|
CREATE TABLE IF NOT EXISTS user_revisions (
|
||||||
user_id TEXT PRIMARY KEY,
|
user_id TEXT PRIMARY KEY,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"dev:demo": "vite --config webapp/vite.config.ts --mode demo --host 127.0.0.1 --port 5174",
|
"dev:demo": "vite --config webapp/vite.config.ts --mode demo --host 127.0.0.1 --port 5174",
|
||||||
"build": "vite build --config webapp/vite.config.ts",
|
"build": "vite build --config webapp/vite.config.ts",
|
||||||
"build:demo": "vite build --config webapp/vite.config.ts --mode demo && node scripts/pages-spa-redirects.cjs",
|
"build:demo": "vite build --config webapp/vite.config.ts --mode demo && node scripts/pages-spa-redirects.cjs",
|
||||||
|
"domains:sync": "node scripts/sync-global-domains.mjs",
|
||||||
"i18n": "node scripts/i18n-validate.cjs",
|
"i18n": "node scripts/i18n-validate.cjs",
|
||||||
"i18n:validate": "node scripts/i18n-validate.cjs",
|
"i18n:validate": "node scripts/i18n-validate.cjs",
|
||||||
"deploy": "wrangler deploy",
|
"deploy": "wrangler deploy",
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const DEFAULT_REF = 'main';
|
||||||
|
const OUTPUT_DIR = path.join(process.cwd(), 'src', 'static');
|
||||||
|
const OUT_FILE = path.join(OUTPUT_DIR, 'global_domains.bitwarden.json');
|
||||||
|
const META_FILE = path.join(OUTPUT_DIR, 'global_domains.bitwarden.meta.json');
|
||||||
|
const ENUM_PATH = 'src/Core/Enums/GlobalEquivalentDomainsType.cs';
|
||||||
|
const STATIC_STORE_PATH = 'src/Core/Utilities/StaticStore.cs';
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const args = { ref: process.env.BITWARDEN_SERVER_REF || DEFAULT_REF };
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const arg = argv[i];
|
||||||
|
if (arg === '--ref' && argv[i + 1]) {
|
||||||
|
args.ref = argv[i + 1];
|
||||||
|
i += 1;
|
||||||
|
} else if (arg.startsWith('--ref=')) {
|
||||||
|
args.ref = arg.slice('--ref='.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rawUrl(ref, filePath) {
|
||||||
|
return `https://raw.githubusercontent.com/bitwarden/server/${encodeURIComponent(ref)}/${filePath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchText(url) {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'NodeWarden global domains sync',
|
||||||
|
Accept: 'text/plain',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch ${url}: HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEnumTypes(source) {
|
||||||
|
const map = new Map();
|
||||||
|
const enumMatch = source.match(/enum\s+GlobalEquivalentDomainsType\b[\s\S]*?\{([\s\S]*?)\}/);
|
||||||
|
if (!enumMatch) {
|
||||||
|
throw new Error('GlobalEquivalentDomainsType enum was not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = enumMatch[1].replace(/\/\/.*$/gm, '');
|
||||||
|
const entryRe = /\b([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(\d+)\b/g;
|
||||||
|
let match;
|
||||||
|
while ((match = entryRe.exec(body)) !== null) {
|
||||||
|
map.set(match[1], Number(match[2]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map.size) {
|
||||||
|
throw new Error('No enum values were parsed from GlobalEquivalentDomainsType');
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStringList(source) {
|
||||||
|
const domains = [];
|
||||||
|
const stringRe = /"((?:\\.|[^"\\])*)"/g;
|
||||||
|
let match;
|
||||||
|
while ((match = stringRe.exec(source)) !== null) {
|
||||||
|
domains.push(match[1].replace(/\\"/g, '"').trim().toLowerCase());
|
||||||
|
}
|
||||||
|
return Array.from(new Set(domains.filter(Boolean)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGlobalDomains(source, enumTypes) {
|
||||||
|
const out = [];
|
||||||
|
const addRe = /GlobalDomains\.Add\s*\(\s*GlobalEquivalentDomainsType\.([A-Za-z_][A-Za-z0-9_]*)\s*,\s*new\s+List(?:<\s*string\s*>)?\s*\{([\s\S]*?)\}\s*\)\s*;/g;
|
||||||
|
let match;
|
||||||
|
while ((match = addRe.exec(source)) !== null) {
|
||||||
|
const name = match[1];
|
||||||
|
const type = enumTypes.get(name);
|
||||||
|
if (!Number.isInteger(type)) {
|
||||||
|
throw new Error(`GlobalDomains references unknown enum value ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const domains = parseStringList(match[2]);
|
||||||
|
if (domains.length < 2) {
|
||||||
|
throw new Error(`GlobalDomains.${name} has fewer than two domains`);
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push({
|
||||||
|
type,
|
||||||
|
domains,
|
||||||
|
excluded: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!out.length) {
|
||||||
|
throw new Error('No GlobalDomains.Add(...) rules were parsed from StaticStore.cs');
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ref } = parseArgs(process.argv.slice(2));
|
||||||
|
const enumUrl = rawUrl(ref, ENUM_PATH);
|
||||||
|
const staticStoreUrl = rawUrl(ref, STATIC_STORE_PATH);
|
||||||
|
|
||||||
|
const [enumSource, staticStoreSource] = await Promise.all([
|
||||||
|
fetchText(enumUrl),
|
||||||
|
fetchText(staticStoreUrl),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const enumTypes = parseEnumTypes(enumSource);
|
||||||
|
const rules = parseGlobalDomains(staticStoreSource, enumTypes);
|
||||||
|
const domainsCount = rules.reduce((sum, rule) => sum + rule.domains.length, 0);
|
||||||
|
const rulesJson = JSON.stringify(rules, null, 2);
|
||||||
|
|
||||||
|
async function readJsonFile(filePath) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(await readFile(filePath, 'utf8'));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingRules = await readJsonFile(OUT_FILE);
|
||||||
|
const existingMeta = await readJsonFile(META_FILE);
|
||||||
|
const unchangedRules = JSON.stringify(existingRules) === JSON.stringify(rules);
|
||||||
|
const unchangedRef = existingMeta?.ref === ref;
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
source: 'https://github.com/bitwarden/server',
|
||||||
|
ref,
|
||||||
|
generatedAt: unchangedRules && unchangedRef && existingMeta?.generatedAt
|
||||||
|
? existingMeta.generatedAt
|
||||||
|
: new Date().toISOString(),
|
||||||
|
rulesCount: rules.length,
|
||||||
|
domainsCount,
|
||||||
|
sourceFiles: [
|
||||||
|
ENUM_PATH,
|
||||||
|
STATIC_STORE_PATH,
|
||||||
|
],
|
||||||
|
sourceUrls: [
|
||||||
|
enumUrl,
|
||||||
|
staticStoreUrl,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await mkdir(OUTPUT_DIR, { recursive: true });
|
||||||
|
await writeFile(OUT_FILE, `${rulesJson}\n`, 'utf8');
|
||||||
|
await writeFile(META_FILE, `${JSON.stringify(meta, null, 2)}\n`, 'utf8');
|
||||||
|
|
||||||
|
console.log(`Wrote ${rules.length} global domain rules (${domainsCount} domains) from bitwarden/server@${ref}.`);
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
const MULTI_LABEL_PUBLIC_SUFFIXES = new Set([
|
||||||
|
'ac.cn',
|
||||||
|
'com.cn',
|
||||||
|
'edu.cn',
|
||||||
|
'gov.cn',
|
||||||
|
'net.cn',
|
||||||
|
'org.cn',
|
||||||
|
'ah.cn',
|
||||||
|
'bj.cn',
|
||||||
|
'cq.cn',
|
||||||
|
'fj.cn',
|
||||||
|
'gd.cn',
|
||||||
|
'gs.cn',
|
||||||
|
'gx.cn',
|
||||||
|
'gz.cn',
|
||||||
|
'ha.cn',
|
||||||
|
'hb.cn',
|
||||||
|
'he.cn',
|
||||||
|
'hi.cn',
|
||||||
|
'hk.cn',
|
||||||
|
'hl.cn',
|
||||||
|
'hn.cn',
|
||||||
|
'jl.cn',
|
||||||
|
'js.cn',
|
||||||
|
'jx.cn',
|
||||||
|
'ln.cn',
|
||||||
|
'mo.cn',
|
||||||
|
'nm.cn',
|
||||||
|
'nx.cn',
|
||||||
|
'qh.cn',
|
||||||
|
'sc.cn',
|
||||||
|
'sd.cn',
|
||||||
|
'sh.cn',
|
||||||
|
'sn.cn',
|
||||||
|
'sx.cn',
|
||||||
|
'tj.cn',
|
||||||
|
'tw.cn',
|
||||||
|
'xj.cn',
|
||||||
|
'xz.cn',
|
||||||
|
'yn.cn',
|
||||||
|
'zj.cn',
|
||||||
|
'co.uk',
|
||||||
|
'org.uk',
|
||||||
|
'net.uk',
|
||||||
|
'ac.uk',
|
||||||
|
'gov.uk',
|
||||||
|
'com.au',
|
||||||
|
'net.au',
|
||||||
|
'org.au',
|
||||||
|
'edu.au',
|
||||||
|
'gov.au',
|
||||||
|
'co.nz',
|
||||||
|
'org.nz',
|
||||||
|
'net.nz',
|
||||||
|
'com.br',
|
||||||
|
'com.mx',
|
||||||
|
'com.ar',
|
||||||
|
'com.tr',
|
||||||
|
'com.sg',
|
||||||
|
'com.my',
|
||||||
|
'com.hk',
|
||||||
|
'com.tw',
|
||||||
|
'co.jp',
|
||||||
|
'ne.jp',
|
||||||
|
'or.jp',
|
||||||
|
'co.kr',
|
||||||
|
'or.kr',
|
||||||
|
'co.in',
|
||||||
|
'firm.in',
|
||||||
|
'net.in',
|
||||||
|
'org.in',
|
||||||
|
'co.id',
|
||||||
|
'or.id',
|
||||||
|
'web.id',
|
||||||
|
'co.il',
|
||||||
|
'org.il',
|
||||||
|
'co.za',
|
||||||
|
'com.sa',
|
||||||
|
'com.ph',
|
||||||
|
'com.vn',
|
||||||
|
'com.pk',
|
||||||
|
'com.bd',
|
||||||
|
'com.ng',
|
||||||
|
'github.io',
|
||||||
|
'pages.dev',
|
||||||
|
'workers.dev',
|
||||||
|
'cloudflareaccess.com',
|
||||||
|
'vercel.app',
|
||||||
|
'netlify.app',
|
||||||
|
'web.app',
|
||||||
|
'firebaseapp.com',
|
||||||
|
'herokuapp.com',
|
||||||
|
'fly.dev',
|
||||||
|
'railway.app',
|
||||||
|
'render.com',
|
||||||
|
'onrender.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function extractHost(input: string): string {
|
||||||
|
let raw = input.trim().toLowerCase();
|
||||||
|
if (!raw) return '';
|
||||||
|
raw = raw.replace(/\\/g, '/');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const candidate = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`;
|
||||||
|
const parsed = new URL(candidate);
|
||||||
|
raw = parsed.hostname;
|
||||||
|
} catch {
|
||||||
|
raw = raw.split(/[/?#]/, 1)[0] || '';
|
||||||
|
const atIndex = raw.lastIndexOf('@');
|
||||||
|
if (atIndex >= 0) raw = raw.slice(atIndex + 1);
|
||||||
|
if (raw.startsWith('[')) return '';
|
||||||
|
const colonIndex = raw.lastIndexOf(':');
|
||||||
|
if (colonIndex > -1 && raw.indexOf(':') === colonIndex) raw = raw.slice(0, colonIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw
|
||||||
|
.replace(/^\*+\./, '')
|
||||||
|
.replace(/^\.+/, '')
|
||||||
|
.replace(/\.+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidHost(host: string): boolean {
|
||||||
|
if (!host || host.length > 253 || !host.includes('.')) return false;
|
||||||
|
if (host.includes('..') || /[:/\s]/.test(host)) return false;
|
||||||
|
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(host)) return false;
|
||||||
|
return host.split('.').every((label) => (
|
||||||
|
label.length > 0
|
||||||
|
&& label.length <= 63
|
||||||
|
&& /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeEquivalentDomain(value: unknown): string {
|
||||||
|
const host = extractHost(String(value || ''));
|
||||||
|
if (!isValidHost(host)) return '';
|
||||||
|
|
||||||
|
const labels = host.split('.');
|
||||||
|
for (let index = 0; index < labels.length; index += 1) {
|
||||||
|
const suffix = labels.slice(index).join('.');
|
||||||
|
if (!MULTI_LABEL_PUBLIC_SUFFIXES.has(suffix)) continue;
|
||||||
|
if (index === 0) return '';
|
||||||
|
return labels.slice(index - 1).join('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return labels.length >= 2 ? labels.slice(-2).join('.') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidEquivalentDomain(value: unknown): boolean {
|
||||||
|
return !!normalizeEquivalentDomain(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type { Env } from '../types';
|
||||||
|
import { StorageService } from '../services/storage';
|
||||||
|
import {
|
||||||
|
buildDomainsResponse,
|
||||||
|
customRulesToActiveEquivalentDomains,
|
||||||
|
normalizeCustomEquivalentDomains,
|
||||||
|
normalizeEquivalentDomains,
|
||||||
|
normalizeExcludedGlobalTypes,
|
||||||
|
} from '../services/domain-rules';
|
||||||
|
import { errorResponse, jsonResponse } from '../utils/response';
|
||||||
|
|
||||||
|
function firstPresent(payload: Record<string, unknown>, keys: string[]): unknown {
|
||||||
|
for (const key of keys) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(payload, key)) return payload[key];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readPayload(request: Request): Promise<Record<string, unknown>> {
|
||||||
|
try {
|
||||||
|
const parsed = await request.json();
|
||||||
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||||
|
? parsed as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleGetDomains(env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const settings = await storage.getUserDomainSettings(userId);
|
||||||
|
return jsonResponse(buildDomainsResponse(
|
||||||
|
settings.equivalentDomains,
|
||||||
|
settings.customEquivalentDomains,
|
||||||
|
settings.excludedGlobalEquivalentDomains
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleUpdateDomains(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const payload = await readPayload(request);
|
||||||
|
const current = await storage.getUserDomainSettings(userId);
|
||||||
|
const equivalentDomainsRaw = firstPresent(payload, [
|
||||||
|
'equivalentDomains',
|
||||||
|
'EquivalentDomains',
|
||||||
|
]);
|
||||||
|
const customEquivalentDomainsRaw = firstPresent(payload, [
|
||||||
|
'customEquivalentDomains',
|
||||||
|
'CustomEquivalentDomains',
|
||||||
|
]);
|
||||||
|
const excludedGlobalEquivalentDomainsRaw = firstPresent(payload, [
|
||||||
|
'excludedGlobalEquivalentDomains',
|
||||||
|
'ExcludedGlobalEquivalentDomains',
|
||||||
|
// Some older compatible clients send the excluded type list under this key.
|
||||||
|
'globalEquivalentDomains',
|
||||||
|
'GlobalEquivalentDomains',
|
||||||
|
]);
|
||||||
|
const customEquivalentDomains = customEquivalentDomainsRaw === undefined
|
||||||
|
? (equivalentDomainsRaw === undefined
|
||||||
|
? current.customEquivalentDomains
|
||||||
|
: normalizeCustomEquivalentDomains(normalizeEquivalentDomains(equivalentDomainsRaw)))
|
||||||
|
: normalizeCustomEquivalentDomains(customEquivalentDomainsRaw);
|
||||||
|
const equivalentDomains = customRulesToActiveEquivalentDomains(customEquivalentDomains);
|
||||||
|
const excludedGlobalEquivalentDomains = excludedGlobalEquivalentDomainsRaw === undefined
|
||||||
|
? current.excludedGlobalEquivalentDomains
|
||||||
|
: normalizeExcludedGlobalTypes(excludedGlobalEquivalentDomainsRaw);
|
||||||
|
|
||||||
|
await storage.saveUserDomainSettings(userId, equivalentDomains, customEquivalentDomains, excludedGlobalEquivalentDomains);
|
||||||
|
|
||||||
|
const settings = await storage.getUserDomainSettings(userId);
|
||||||
|
if (!settings) {
|
||||||
|
return errorResponse('Domain settings unavailable', 500);
|
||||||
|
}
|
||||||
|
return jsonResponse(buildDomainsResponse(
|
||||||
|
settings.equivalentDomains,
|
||||||
|
settings.customEquivalentDomains,
|
||||||
|
settings.excludedGlobalEquivalentDomains
|
||||||
|
));
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
buildUserDecryptionCompat,
|
buildUserDecryptionCompat,
|
||||||
buildUserDecryptionOptions,
|
buildUserDecryptionOptions,
|
||||||
} from '../utils/user-decryption';
|
} from '../utils/user-decryption';
|
||||||
|
import { buildDomainsResponse } from '../services/domain-rules';
|
||||||
|
|
||||||
function buildSyncCacheRequest(request: Request, userId: string, revisionDate: string, excludeDomains: boolean, excludeSends: boolean): Request {
|
function buildSyncCacheRequest(request: Request, userId: string, revisionDate: string, excludeDomains: boolean, excludeSends: boolean): Request {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
@@ -50,11 +51,12 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
return cachedResponse;
|
return cachedResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [ciphers, folders, sends, attachmentsByCipher] = await Promise.all([
|
const [ciphers, folders, sends, attachmentsByCipher, domainSettings] = await Promise.all([
|
||||||
storage.getAllCiphers(userId),
|
storage.getAllCiphers(userId),
|
||||||
storage.getAllFolders(userId),
|
storage.getAllFolders(userId),
|
||||||
excludeSends ? Promise.resolve([]) : storage.getAllSends(userId),
|
excludeSends ? Promise.resolve([]) : storage.getAllSends(userId),
|
||||||
storage.getAttachmentsByUserId(userId),
|
storage.getAttachmentsByUserId(userId),
|
||||||
|
excludeDomains ? Promise.resolve(null) : storage.getUserDomainSettings(userId),
|
||||||
]);
|
]);
|
||||||
const accountKeys = buildAccountKeys(user);
|
const accountKeys = buildAccountKeys(user);
|
||||||
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
||||||
@@ -111,11 +113,12 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
ciphers: cipherResponses,
|
ciphers: cipherResponses,
|
||||||
domains: excludeDomains
|
domains: excludeDomains
|
||||||
? null
|
? null
|
||||||
: {
|
: buildDomainsResponse(
|
||||||
equivalentDomains: [],
|
domainSettings?.equivalentDomains || [],
|
||||||
globalEquivalentDomains: [],
|
domainSettings?.customEquivalentDomains || [],
|
||||||
object: 'domains',
|
domainSettings?.excludedGlobalEquivalentDomains || [],
|
||||||
},
|
{ omitExcludedGlobals: true }
|
||||||
|
),
|
||||||
policies: [],
|
policies: [],
|
||||||
sends: sendResponses,
|
sends: sendResponses,
|
||||||
UserDecryption: {
|
UserDecryption: {
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ import {
|
|||||||
} from './handlers/attachments';
|
} from './handlers/attachments';
|
||||||
import { handleAuthenticatedDeviceRoute } from './router-devices';
|
import { handleAuthenticatedDeviceRoute } from './router-devices';
|
||||||
import { handleAdminRoute } from './router-admin';
|
import { handleAdminRoute } from './router-admin';
|
||||||
|
import { handleGetDomains, handleUpdateDomains } from './handlers/domains';
|
||||||
|
|
||||||
export async function handleAuthenticatedRoute(
|
export async function handleAuthenticatedRoute(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -297,14 +298,9 @@ export async function handleAuthenticatedRoute(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === '/api/settings/domains') {
|
if (path === '/api/settings/domains' || path === '/settings/domains') {
|
||||||
if (method === 'GET' || method === 'PUT' || method === 'POST') {
|
if (method === 'GET') return handleGetDomains(env, userId);
|
||||||
return jsonResponse({
|
if (method === 'PUT' || method === 'POST') return handleUpdateDomains(request, env, userId);
|
||||||
equivalentDomains: [],
|
|
||||||
globalEquivalentDomains: [],
|
|
||||||
object: 'domains',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import bitwardenGlobalDomainsRaw from '../static/global_domains.bitwarden.json';
|
||||||
|
import customGlobalDomainsRaw from '../static/global_domains.custom.json';
|
||||||
|
import type { CustomEquivalentDomain, DomainRulesResponse, GlobalEquivalentDomain } from '../types';
|
||||||
|
import { normalizeEquivalentDomain } from '../../shared/domain-normalize';
|
||||||
|
|
||||||
|
type RawGlobalDomain = Partial<GlobalEquivalentDomain> & {
|
||||||
|
Type?: unknown;
|
||||||
|
Domains?: unknown;
|
||||||
|
Excluded?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeDomain(value: unknown): string {
|
||||||
|
return normalizeEquivalentDomain(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGlobalDomain(entry: RawGlobalDomain): GlobalEquivalentDomain | null {
|
||||||
|
const type = Number(entry.type ?? entry.Type);
|
||||||
|
if (!Number.isInteger(type)) return null;
|
||||||
|
|
||||||
|
const rawDomains = entry.domains ?? entry.Domains;
|
||||||
|
if (!Array.isArray(rawDomains)) return null;
|
||||||
|
|
||||||
|
const domains = Array.from(new Set(rawDomains.map(normalizeDomain).filter(Boolean)));
|
||||||
|
if (domains.length < 2) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
domains,
|
||||||
|
excluded: Boolean(entry.excluded ?? entry.Excluded ?? false),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGlobalDomains(input: unknown): GlobalEquivalentDomain[] {
|
||||||
|
if (!Array.isArray(input)) return [];
|
||||||
|
|
||||||
|
const seen = new Set<number>();
|
||||||
|
const out: GlobalEquivalentDomain[] = [];
|
||||||
|
for (const entry of input) {
|
||||||
|
const normalized = normalizeGlobalDomain(entry as RawGlobalDomain);
|
||||||
|
if (!normalized || seen.has(normalized.type)) continue;
|
||||||
|
seen.add(normalized.type);
|
||||||
|
out.push(normalized);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bitwardenGlobalDomains = normalizeGlobalDomains(bitwardenGlobalDomainsRaw);
|
||||||
|
const customGlobalDomains = normalizeGlobalDomains(customGlobalDomainsRaw);
|
||||||
|
|
||||||
|
export const globalDomains: readonly GlobalEquivalentDomain[] = [
|
||||||
|
...bitwardenGlobalDomains,
|
||||||
|
...customGlobalDomains,
|
||||||
|
];
|
||||||
|
|
||||||
|
export function normalizeEquivalentDomains(input: unknown): string[][] {
|
||||||
|
if (!Array.isArray(input)) return [];
|
||||||
|
|
||||||
|
const groups: string[][] = [];
|
||||||
|
const seenGroups = new Set<string>();
|
||||||
|
for (const group of input) {
|
||||||
|
if (!Array.isArray(group)) continue;
|
||||||
|
const domains = Array.from(new Set(group.map(normalizeDomain).filter(Boolean)));
|
||||||
|
if (domains.length < 2) continue;
|
||||||
|
const key = domains.slice().sort().join('\n');
|
||||||
|
if (seenGroups.has(key)) continue;
|
||||||
|
seenGroups.add(key);
|
||||||
|
groups.push(domains);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeEquivalentDomainGroups(input: string[][]): string[][] {
|
||||||
|
const parent = new Map<string, string>();
|
||||||
|
|
||||||
|
function find(domain: string): string {
|
||||||
|
const current = parent.get(domain);
|
||||||
|
if (!current) {
|
||||||
|
parent.set(domain, domain);
|
||||||
|
return domain;
|
||||||
|
}
|
||||||
|
if (current === domain) return domain;
|
||||||
|
const root = find(current);
|
||||||
|
parent.set(domain, root);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function union(a: string, b: string): void {
|
||||||
|
const rootA = find(a);
|
||||||
|
const rootB = find(b);
|
||||||
|
if (rootA !== rootB) parent.set(rootB, rootA);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const group of normalizeEquivalentDomains(input)) {
|
||||||
|
if (group.length < 2) continue;
|
||||||
|
const [first, ...rest] = group;
|
||||||
|
find(first);
|
||||||
|
for (const domain of rest) union(first, domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
const components = new Map<string, string[]>();
|
||||||
|
for (const domain of parent.keys()) {
|
||||||
|
const root = find(domain);
|
||||||
|
const group = components.get(root) || [];
|
||||||
|
group.push(domain);
|
||||||
|
components.set(root, group);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(components.values())
|
||||||
|
.map((group) => group.sort())
|
||||||
|
.filter((group) => group.length >= 2)
|
||||||
|
.sort((a, b) => a[0].localeCompare(b[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expandCustomEquivalentDomainsWithGlobals(
|
||||||
|
customGroups: string[][],
|
||||||
|
activeGlobalGroups: string[][]
|
||||||
|
): string[][] {
|
||||||
|
const normalizedCustomGroups = normalizeEquivalentDomains(customGroups);
|
||||||
|
if (!normalizedCustomGroups.length) return [];
|
||||||
|
|
||||||
|
const customDomains = new Set(normalizedCustomGroups.flat());
|
||||||
|
return mergeEquivalentDomainGroups([
|
||||||
|
...activeGlobalGroups,
|
||||||
|
...normalizedCustomGroups,
|
||||||
|
]).filter((group) => group.some((domain) => customDomains.has(domain)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCustomDomainId(domains: string[], index: number): string {
|
||||||
|
return `custom:${domains.slice().sort().join('|')}:${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCustomEquivalentDomains(input: unknown): CustomEquivalentDomain[] {
|
||||||
|
if (!Array.isArray(input)) return [];
|
||||||
|
|
||||||
|
const rules: CustomEquivalentDomain[] = [];
|
||||||
|
const seenGroups = new Set<string>();
|
||||||
|
for (const [index, item] of input.entries()) {
|
||||||
|
const record = Array.isArray(item)
|
||||||
|
? { domains: item, excluded: false, id: '' }
|
||||||
|
: item && typeof item === 'object'
|
||||||
|
? item as Record<string, unknown>
|
||||||
|
: null;
|
||||||
|
if (!record) continue;
|
||||||
|
|
||||||
|
const domains = normalizeEquivalentDomains([record.domains ?? record.Domains])[0];
|
||||||
|
if (!domains) continue;
|
||||||
|
|
||||||
|
const key = domains.slice().sort().join('\n');
|
||||||
|
if (seenGroups.has(key)) continue;
|
||||||
|
seenGroups.add(key);
|
||||||
|
|
||||||
|
const rawId = String(record.id ?? record.Id ?? '').trim();
|
||||||
|
rules.push({
|
||||||
|
id: rawId || createCustomDomainId(domains, index),
|
||||||
|
domains,
|
||||||
|
excluded: Boolean(record.excluded ?? record.Excluded ?? false),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function customRulesToActiveEquivalentDomains(rules: CustomEquivalentDomain[]): string[][] {
|
||||||
|
return mergeEquivalentDomainGroups(rules
|
||||||
|
.filter((rule) => !rule.excluded)
|
||||||
|
.map((rule) => rule.domains));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeExcludedGlobalTypes(input: unknown): number[] {
|
||||||
|
if (!Array.isArray(input)) return [];
|
||||||
|
|
||||||
|
const validTypes = new Set(globalDomains.map((entry) => entry.type));
|
||||||
|
const seen = new Set<number>();
|
||||||
|
const out: number[] = [];
|
||||||
|
for (const item of input) {
|
||||||
|
const type = Number(typeof item === 'object' && item !== null ? (item as Record<string, unknown>).type : item);
|
||||||
|
const excluded = typeof item === 'object' && item !== null
|
||||||
|
? Boolean((item as Record<string, unknown>).excluded)
|
||||||
|
: true;
|
||||||
|
if (!excluded || !Number.isInteger(type) || !validTypes.has(type) || seen.has(type)) continue;
|
||||||
|
seen.add(type);
|
||||||
|
out.push(type);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDomainsResponse(
|
||||||
|
equivalentDomains: string[][],
|
||||||
|
customEquivalentDomains: CustomEquivalentDomain[],
|
||||||
|
excludedGlobalEquivalentDomains: number[],
|
||||||
|
options: { omitExcludedGlobals?: boolean } = {}
|
||||||
|
): DomainRulesResponse {
|
||||||
|
const excluded = new Set(excludedGlobalEquivalentDomains);
|
||||||
|
const activeGlobalDomainGroups = globalDomains
|
||||||
|
.filter((entry) => !excluded.has(entry.type))
|
||||||
|
.map((entry) => entry.domains);
|
||||||
|
const mergedEquivalentDomains = expandCustomEquivalentDomainsWithGlobals(
|
||||||
|
equivalentDomains,
|
||||||
|
activeGlobalDomainGroups
|
||||||
|
);
|
||||||
|
const globals = globalDomains
|
||||||
|
.map((entry) => ({
|
||||||
|
type: entry.type,
|
||||||
|
domains: entry.domains,
|
||||||
|
excluded: excluded.has(entry.type),
|
||||||
|
}))
|
||||||
|
.filter((entry) => !options.omitExcludedGlobals || !entry.excluded);
|
||||||
|
|
||||||
|
return {
|
||||||
|
equivalentDomains: mergedEquivalentDomains,
|
||||||
|
customEquivalentDomains,
|
||||||
|
globalEquivalentDomains: globals,
|
||||||
|
object: 'domains',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { UserDomainSettings } from '../types';
|
||||||
|
import { normalizeCustomEquivalentDomains, normalizeEquivalentDomains } from './domain-rules';
|
||||||
|
|
||||||
|
function parseJsonArray<T>(raw: string | null | undefined, fallback: T[]): T[] {
|
||||||
|
if (!raw) return fallback;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
return Array.isArray(parsed) ? parsed as T[] : fallback;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserDomainSettings(db: D1Database, userId: string): Promise<UserDomainSettings> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at FROM domain_settings WHERE user_id = ?')
|
||||||
|
.bind(userId)
|
||||||
|
.first<{
|
||||||
|
equivalent_domains: string | null;
|
||||||
|
custom_equivalent_domains: string | null;
|
||||||
|
excluded_global_equivalent_domains: string | null;
|
||||||
|
updated_at: string | null;
|
||||||
|
}>();
|
||||||
|
const equivalentDomains = normalizeEquivalentDomains(parseJsonArray<string[]>(row?.equivalent_domains, []));
|
||||||
|
const storedCustomEquivalentDomains = row?.custom_equivalent_domains
|
||||||
|
? normalizeCustomEquivalentDomains(parseJsonArray<unknown>(row.custom_equivalent_domains, []))
|
||||||
|
: [];
|
||||||
|
const customEquivalentDomains = storedCustomEquivalentDomains.length
|
||||||
|
? storedCustomEquivalentDomains
|
||||||
|
: normalizeCustomEquivalentDomains(equivalentDomains);
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId,
|
||||||
|
equivalentDomains,
|
||||||
|
customEquivalentDomains,
|
||||||
|
excludedGlobalEquivalentDomains: parseJsonArray<number>(row?.excluded_global_equivalent_domains, []),
|
||||||
|
updatedAt: row?.updated_at || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveUserDomainSettings(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
equivalentDomains: string[][],
|
||||||
|
customEquivalentDomains: UserDomainSettings['customEquivalentDomains'],
|
||||||
|
excludedGlobalEquivalentDomains: number[],
|
||||||
|
updatedAt: string
|
||||||
|
): Promise<void> {
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO domain_settings(user_id, equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at) ' +
|
||||||
|
'VALUES(?, ?, ?, ?, ?) ' +
|
||||||
|
'ON CONFLICT(user_id) DO UPDATE SET ' +
|
||||||
|
'equivalent_domains = excluded.equivalent_domains, ' +
|
||||||
|
'custom_equivalent_domains = excluded.custom_equivalent_domains, ' +
|
||||||
|
'excluded_global_equivalent_domains = excluded.excluded_global_equivalent_domains, ' +
|
||||||
|
'updated_at = excluded.updated_at'
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
userId,
|
||||||
|
JSON.stringify(equivalentDomains),
|
||||||
|
JSON.stringify(customEquivalentDomains),
|
||||||
|
JSON.stringify(excludedGlobalEquivalentDomains),
|
||||||
|
updatedAt
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
@@ -15,6 +15,11 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
|||||||
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
|
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
|
||||||
'ALTER TABLE users ADD COLUMN api_key TEXT',
|
'ALTER TABLE users ADD COLUMN api_key TEXT',
|
||||||
|
|
||||||
|
'CREATE TABLE IF NOT EXISTS domain_settings (' +
|
||||||
|
'user_id TEXT PRIMARY KEY, equivalent_domains TEXT NOT NULL DEFAULT \'[]\', custom_equivalent_domains TEXT NOT NULL DEFAULT \'[]\', excluded_global_equivalent_domains TEXT NOT NULL DEFAULT \'[]\', updated_at TEXT NOT NULL, ' +
|
||||||
|
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||||
|
'ALTER TABLE domain_settings ADD COLUMN custom_equivalent_domains TEXT NOT NULL DEFAULT \'[]\'',
|
||||||
|
|
||||||
'CREATE TABLE IF NOT EXISTS user_revisions (' +
|
'CREATE TABLE IF NOT EXISTS user_revisions (' +
|
||||||
'user_id TEXT PRIMARY KEY, revision_date TEXT NOT NULL, ' +
|
'user_id TEXT PRIMARY KEY, revision_date TEXT NOT NULL, ' +
|
||||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||||
|
|||||||
+29
-2
@@ -1,4 +1,4 @@
|
|||||||
import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord } from '../types';
|
import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord, CustomEquivalentDomain } from '../types';
|
||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
import { ensureStorageSchema } from './storage-schema';
|
import { ensureStorageSchema } from './storage-schema';
|
||||||
import {
|
import {
|
||||||
@@ -105,10 +105,14 @@ import {
|
|||||||
getRevisionDate as getStoredRevisionDate,
|
getRevisionDate as getStoredRevisionDate,
|
||||||
updateRevisionDate as updateStoredRevisionDate,
|
updateRevisionDate as updateStoredRevisionDate,
|
||||||
} from './storage-revision-repo';
|
} from './storage-revision-repo';
|
||||||
|
import {
|
||||||
|
getUserDomainSettings as getStoredUserDomainSettings,
|
||||||
|
saveUserDomainSettings as saveStoredUserDomainSettings,
|
||||||
|
} from './storage-domain-rules-repo';
|
||||||
|
|
||||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
|
const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
|
||||||
const STORAGE_SCHEMA_VERSION = '2026-04-28';
|
const STORAGE_SCHEMA_VERSION = '2026-05-05-domain-rules-v2';
|
||||||
|
|
||||||
// D1-backed storage.
|
// D1-backed storage.
|
||||||
// Contract:
|
// Contract:
|
||||||
@@ -270,6 +274,29 @@ export class StorageService {
|
|||||||
await createStoredAuditLog(this.db, log);
|
await createStoredAuditLog(this.db, log);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Domain rules ---
|
||||||
|
|
||||||
|
async getUserDomainSettings(userId: string) {
|
||||||
|
return getStoredUserDomainSettings(this.db, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveUserDomainSettings(
|
||||||
|
userId: string,
|
||||||
|
equivalentDomains: string[][],
|
||||||
|
customEquivalentDomains: CustomEquivalentDomain[],
|
||||||
|
excludedGlobalEquivalentDomains: number[]
|
||||||
|
): Promise<void> {
|
||||||
|
await saveStoredUserDomainSettings(
|
||||||
|
this.db,
|
||||||
|
userId,
|
||||||
|
equivalentDomains,
|
||||||
|
customEquivalentDomains,
|
||||||
|
excludedGlobalEquivalentDomains,
|
||||||
|
new Date().toISOString()
|
||||||
|
);
|
||||||
|
await this.updateRevisionDate(userId);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Ciphers ---
|
// --- Ciphers ---
|
||||||
|
|
||||||
async getCipher(id: string): Promise<Cipher | null> {
|
async getCipher(id: string): Promise<Cipher | null> {
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
[
|
||||||
|
{ "type": 2, "domains": ["ameritrade.com", "tdameritrade.com"], "excluded": false },
|
||||||
|
{ "type": 3, "domains": ["bankofamerica.com", "bofa.com", "mbna.com", "usecfo.com"], "excluded": false },
|
||||||
|
{ "type": 4, "domains": ["sprint.com", "sprintpcs.com", "nextel.com"], "excluded": false },
|
||||||
|
{ "type": 0, "domains": ["youtube.com", "google.com", "gmail.com"], "excluded": false },
|
||||||
|
{ "type": 1, "domains": ["apple.com", "icloud.com"], "excluded": false },
|
||||||
|
{ "type": 5, "domains": ["wellsfargo.com", "wf.com", "wellsfargoadvisors.com"], "excluded": false },
|
||||||
|
{ "type": 6, "domains": ["mymerrill.com", "ml.com", "merrilledge.com"], "excluded": false },
|
||||||
|
{ "type": 7, "domains": ["accountonline.com", "citi.com", "citibank.com", "citicards.com", "citibankonline.com"], "excluded": false },
|
||||||
|
{ "type": 8, "domains": ["cnet.com", "cnettv.com", "com.com", "download.com", "news.com", "search.com", "upload.com"], "excluded": false },
|
||||||
|
{ "type": 9, "domains": ["bananarepublic.com", "gap.com", "oldnavy.com", "piperlime.com"], "excluded": false },
|
||||||
|
{ "type": 10, "domains": ["bing.com", "hotmail.com", "live.com", "microsoft.com", "msn.com", "passport.net", "windows.com", "microsoftonline.com", "office.com", "office365.com", "microsoftstore.com", "xbox.com", "azure.com", "windowsazure.com", "cloud.microsoft"], "excluded": false },
|
||||||
|
{ "type": 11, "domains": ["ua2go.com", "ual.com", "united.com", "unitedwifi.com"], "excluded": false },
|
||||||
|
{ "type": 12, "domains": ["overture.com", "yahoo.com"], "excluded": false },
|
||||||
|
{ "type": 13, "domains": ["zonealarm.com", "zonelabs.com"], "excluded": false },
|
||||||
|
{ "type": 14, "domains": ["paypal.com", "paypal-search.com"], "excluded": false },
|
||||||
|
{ "type": 15, "domains": ["avon.com", "youravon.com"], "excluded": false },
|
||||||
|
{ "type": 16, "domains": ["diapers.com", "soap.com", "wag.com", "yoyo.com", "beautybar.com", "casa.com", "afterschool.com", "vine.com", "bookworm.com", "look.com", "vinemarket.com"], "excluded": false },
|
||||||
|
{ "type": 17, "domains": ["1800contacts.com", "800contacts.com"], "excluded": false },
|
||||||
|
{ "type": 18, "domains": ["amazon.com", "amazon.com.be", "amazon.ae", "amazon.ca", "amazon.co.uk", "amazon.com.au", "amazon.com.br", "amazon.com.mx", "amazon.com.tr", "amazon.de", "amazon.es", "amazon.fr", "amazon.in", "amazon.it", "amazon.nl", "amazon.pl", "amazon.sa", "amazon.se", "amazon.sg"], "excluded": false },
|
||||||
|
{ "type": 19, "domains": ["cox.com", "cox.net", "coxbusiness.com"], "excluded": false },
|
||||||
|
{ "type": 20, "domains": ["mynortonaccount.com", "norton.com"], "excluded": false },
|
||||||
|
{ "type": 21, "domains": ["verizon.com", "verizon.net"], "excluded": false },
|
||||||
|
{ "type": 22, "domains": ["rakuten.com", "buy.com"], "excluded": false },
|
||||||
|
{ "type": 23, "domains": ["siriusxm.com", "sirius.com"], "excluded": false },
|
||||||
|
{ "type": 24, "domains": ["ea.com", "origin.com", "play4free.com", "tiberiumalliance.com"], "excluded": false },
|
||||||
|
{ "type": 25, "domains": ["37signals.com", "basecamp.com", "basecamphq.com", "highrisehq.com"], "excluded": false },
|
||||||
|
{ "type": 26, "domains": ["steampowered.com", "steamcommunity.com", "steamgames.com"], "excluded": false },
|
||||||
|
{ "type": 27, "domains": ["chart.io", "chartio.com"], "excluded": false },
|
||||||
|
{ "type": 28, "domains": ["gotomeeting.com", "citrixonline.com"], "excluded": false },
|
||||||
|
{ "type": 29, "domains": ["gogoair.com", "gogoinflight.com"], "excluded": false },
|
||||||
|
{ "type": 30, "domains": ["mysql.com", "oracle.com"], "excluded": false },
|
||||||
|
{ "type": 31, "domains": ["discover.com", "discovercard.com"], "excluded": false },
|
||||||
|
{ "type": 32, "domains": ["dcu.org", "dcu-online.org"], "excluded": false },
|
||||||
|
{ "type": 33, "domains": ["healthcare.gov", "cuidadodesalud.gov", "cms.gov"], "excluded": false },
|
||||||
|
{ "type": 34, "domains": ["pepco.com", "pepcoholdings.com"], "excluded": false },
|
||||||
|
{ "type": 35, "domains": ["century21.com", "21online.com"], "excluded": false },
|
||||||
|
{ "type": 36, "domains": ["comcast.com", "comcast.net", "xfinity.com"], "excluded": false },
|
||||||
|
{ "type": 37, "domains": ["cricketwireless.com", "aiowireless.com"], "excluded": false },
|
||||||
|
{ "type": 38, "domains": ["mandtbank.com", "mtb.com"], "excluded": false },
|
||||||
|
{ "type": 39, "domains": ["dropbox.com", "getdropbox.com"], "excluded": false },
|
||||||
|
{ "type": 40, "domains": ["snapfish.com", "snapfish.ca"], "excluded": false },
|
||||||
|
{ "type": 41, "domains": ["alibaba.com", "aliexpress.com", "aliyun.com", "net.cn"], "excluded": false },
|
||||||
|
{ "type": 42, "domains": ["playstation.com", "sonyentertainmentnetwork.com"], "excluded": false },
|
||||||
|
{ "type": 43, "domains": ["mercadolivre.com", "mercadolivre.com.br", "mercadolibre.com", "mercadolibre.com.ar", "mercadolibre.com.mx"], "excluded": false },
|
||||||
|
{ "type": 44, "domains": ["zendesk.com", "zopim.com"], "excluded": false },
|
||||||
|
{ "type": 45, "domains": ["autodesk.com", "tinkercad.com"], "excluded": false },
|
||||||
|
{ "type": 46, "domains": ["railnation.ru", "railnation.de", "rail-nation.com", "railnation.gr", "railnation.us", "trucknation.de", "traviangames.com"], "excluded": false },
|
||||||
|
{ "type": 47, "domains": ["wpcu.coop", "wpcuonline.com"], "excluded": false },
|
||||||
|
{ "type": 48, "domains": ["mathletics.com", "mathletics.com.au", "mathletics.co.uk"], "excluded": false },
|
||||||
|
{ "type": 49, "domains": ["discountbank.co.il", "telebank.co.il"], "excluded": false },
|
||||||
|
{ "type": 50, "domains": ["mi.com", "xiaomi.com"], "excluded": false },
|
||||||
|
{ "type": 52, "domains": ["postepay.it", "poste.it"], "excluded": false },
|
||||||
|
{ "type": 51, "domains": ["facebook.com", "messenger.com"], "excluded": false },
|
||||||
|
{ "type": 53, "domains": ["skysports.com", "skybet.com", "skyvegas.com"], "excluded": false },
|
||||||
|
{ "type": 54, "domains": ["disneymoviesanywhere.com", "go.com", "disney.com", "dadt.com", "disneyplus.com"], "excluded": false },
|
||||||
|
{ "type": 55, "domains": ["pokemon-gl.com", "pokemon.com"], "excluded": false },
|
||||||
|
{ "type": 56, "domains": ["myuv.com", "uvvu.com"], "excluded": false },
|
||||||
|
{ "type": 58, "domains": ["mdsol.com", "imedidata.com"], "excluded": false },
|
||||||
|
{ "type": 57, "domains": ["bank-yahav.co.il", "bankhapoalim.co.il"], "excluded": false },
|
||||||
|
{ "type": 59, "domains": ["sears.com", "shld.net"], "excluded": false },
|
||||||
|
{ "type": 60, "domains": ["xiami.com", "alipay.com"], "excluded": false },
|
||||||
|
{ "type": 61, "domains": ["belkin.com", "seedonk.com"], "excluded": false },
|
||||||
|
{ "type": 62, "domains": ["turbotax.com", "intuit.com"], "excluded": false },
|
||||||
|
{ "type": 63, "domains": ["shopify.com", "myshopify.com"], "excluded": false },
|
||||||
|
{ "type": 64, "domains": ["ebay.com", "ebay.at", "ebay.be", "ebay.ca", "ebay.ch", "ebay.cn", "ebay.co.jp", "ebay.co.th", "ebay.co.uk", "ebay.com.au", "ebay.com.hk", "ebay.com.my", "ebay.com.sg", "ebay.com.tw", "ebay.de", "ebay.es", "ebay.fr", "ebay.ie", "ebay.in", "ebay.it", "ebay.nl", "ebay.ph", "ebay.pl"], "excluded": false },
|
||||||
|
{ "type": 65, "domains": ["techdata.com", "techdata.ch"], "excluded": false },
|
||||||
|
{ "type": 66, "domains": ["schwab.com", "schwabplan.com"], "excluded": false },
|
||||||
|
{ "type": 68, "domains": ["tesla.com", "teslamotors.com"], "excluded": false },
|
||||||
|
{ "type": 69, "domains": ["morganstanley.com", "morganstanleyclientserv.com", "stockplanconnect.com", "ms.com"], "excluded": false },
|
||||||
|
{ "type": 70, "domains": ["taxact.com", "taxactonline.com"], "excluded": false },
|
||||||
|
{ "type": 71, "domains": ["mediawiki.org", "wikibooks.org", "wikidata.org", "wikimedia.org", "wikinews.org", "wikipedia.org", "wikiquote.org", "wikisource.org", "wikiversity.org", "wikivoyage.org", "wiktionary.org"], "excluded": false },
|
||||||
|
{ "type": 72, "domains": ["airbnb.at", "airbnb.be", "airbnb.ca", "airbnb.ch", "airbnb.cl", "airbnb.co.cr", "airbnb.co.id", "airbnb.co.in", "airbnb.co.kr", "airbnb.co.nz", "airbnb.co.uk", "airbnb.co.ve", "airbnb.com", "airbnb.com.ar", "airbnb.com.au", "airbnb.com.bo", "airbnb.com.br", "airbnb.com.bz", "airbnb.com.co", "airbnb.com.ec", "airbnb.com.gt", "airbnb.com.hk", "airbnb.com.hn", "airbnb.com.mt", "airbnb.com.my", "airbnb.com.ni", "airbnb.com.pa", "airbnb.com.pe", "airbnb.com.py", "airbnb.com.sg", "airbnb.com.sv", "airbnb.com.tr", "airbnb.com.tw", "airbnb.cz", "airbnb.de", "airbnb.dk", "airbnb.es", "airbnb.fi", "airbnb.fr", "airbnb.gr", "airbnb.gy", "airbnb.hu", "airbnb.ie", "airbnb.is", "airbnb.it", "airbnb.jp", "airbnb.mx", "airbnb.nl", "airbnb.no", "airbnb.pl", "airbnb.pt", "airbnb.ru", "airbnb.se"], "excluded": false },
|
||||||
|
{ "type": 73, "domains": ["eventbrite.at", "eventbrite.be", "eventbrite.ca", "eventbrite.ch", "eventbrite.cl", "eventbrite.co", "eventbrite.co.nz", "eventbrite.co.uk", "eventbrite.com", "eventbrite.com.ar", "eventbrite.com.au", "eventbrite.com.br", "eventbrite.com.mx", "eventbrite.com.pe", "eventbrite.de", "eventbrite.dk", "eventbrite.es", "eventbrite.fi", "eventbrite.fr", "eventbrite.hk", "eventbrite.ie", "eventbrite.it", "eventbrite.nl", "eventbrite.pt", "eventbrite.se", "eventbrite.sg"], "excluded": false },
|
||||||
|
{ "type": 74, "domains": ["stackexchange.com", "superuser.com", "stackoverflow.com", "serverfault.com", "mathoverflow.net", "askubuntu.com", "stackapps.com"], "excluded": false },
|
||||||
|
{ "type": 75, "domains": ["docusign.com", "docusign.net"], "excluded": false },
|
||||||
|
{ "type": 76, "domains": ["envato.com", "themeforest.net", "codecanyon.net", "videohive.net", "audiojungle.net", "graphicriver.net", "photodune.net", "3docean.net"], "excluded": false },
|
||||||
|
{ "type": 77, "domains": ["x10hosting.com", "x10premium.com"], "excluded": false },
|
||||||
|
{ "type": 78, "domains": ["dnsomatic.com", "opendns.com", "umbrella.com"], "excluded": false },
|
||||||
|
{ "type": 79, "domains": ["cagreatamerica.com", "canadaswonderland.com", "carowinds.com", "cedarfair.com", "cedarpoint.com", "dorneypark.com", "kingsdominion.com", "knotts.com", "miadventure.com", "schlitterbahn.com", "valleyfair.com", "visitkingsisland.com", "worldsoffun.com"], "excluded": false },
|
||||||
|
{ "type": 80, "domains": ["ubnt.com", "ui.com"], "excluded": false },
|
||||||
|
{ "type": 81, "domains": ["discordapp.com", "discord.com"], "excluded": false },
|
||||||
|
{ "type": 82, "domains": ["netcup.de", "netcup.eu", "customercontrolpanel.de"], "excluded": false },
|
||||||
|
{ "type": 83, "domains": ["yandex.com", "ya.ru", "yandex.az", "yandex.by", "yandex.co.il", "yandex.com.am", "yandex.com.ge", "yandex.com.tr", "yandex.ee", "yandex.fi", "yandex.fr", "yandex.kg", "yandex.kz", "yandex.lt", "yandex.lv", "yandex.md", "yandex.pl", "yandex.ru", "yandex.tj", "yandex.tm", "yandex.ua", "yandex.uz"], "excluded": false },
|
||||||
|
{ "type": 84, "domains": ["sonyentertainmentnetwork.com", "sony.com"], "excluded": false },
|
||||||
|
{ "type": 85, "domains": ["proton.me", "protonmail.com", "protonvpn.com"], "excluded": false },
|
||||||
|
{ "type": 86, "domains": ["ubisoft.com", "ubi.com"], "excluded": false },
|
||||||
|
{ "type": 87, "domains": ["transferwise.com", "wise.com"], "excluded": false },
|
||||||
|
{ "type": 88, "domains": ["takeaway.com", "just-eat.dk", "just-eat.no", "just-eat.fr", "just-eat.ch", "lieferando.de", "lieferando.at", "thuisbezorgd.nl", "pyszne.pl"], "excluded": false },
|
||||||
|
{ "type": 89, "domains": ["atlassian.com", "bitbucket.org", "trello.com", "statuspage.io", "atlassian.net", "jira.com"], "excluded": false },
|
||||||
|
{ "type": 90, "domains": ["pinterest.com", "pinterest.com.au", "pinterest.cl", "pinterest.de", "pinterest.dk", "pinterest.es", "pinterest.fr", "pinterest.co.uk", "pinterest.jp", "pinterest.co.kr", "pinterest.nz", "pinterest.pt", "pinterest.se"], "excluded": false },
|
||||||
|
{ "type": 91, "domains": ["twitter.com", "x.com"], "excluded": false }
|
||||||
|
]
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"source": "https://github.com/bitwarden/server",
|
||||||
|
"ref": "main",
|
||||||
|
"generatedAt": "2026-05-05T00:00:00.000Z",
|
||||||
|
"rulesCount": 91,
|
||||||
|
"domainsCount": 436,
|
||||||
|
"sourceFiles": [
|
||||||
|
"src/Core/Enums/GlobalEquivalentDomainsType.cs",
|
||||||
|
"src/Core/Utilities/StaticStore.cs"
|
||||||
|
],
|
||||||
|
"sourceUrls": [
|
||||||
|
"https://raw.githubusercontent.com/bitwarden/server/main/src/Core/Enums/GlobalEquivalentDomainsType.cs",
|
||||||
|
"https://raw.githubusercontent.com/bitwarden/server/main/src/Core/Utilities/StaticStore.cs"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"type": -10001,
|
||||||
|
"domains": ["nodewarden.example", "nw.example"],
|
||||||
|
"excluded": false,
|
||||||
|
"source": "nodewarden"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -55,6 +55,34 @@ export interface User {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserDomainSettings {
|
||||||
|
userId: string;
|
||||||
|
equivalentDomains: string[][];
|
||||||
|
customEquivalentDomains: CustomEquivalentDomain[];
|
||||||
|
excludedGlobalEquivalentDomains: number[];
|
||||||
|
updatedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomEquivalentDomain {
|
||||||
|
id: string;
|
||||||
|
domains: string[];
|
||||||
|
excluded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GlobalEquivalentDomain {
|
||||||
|
type: number;
|
||||||
|
domains: string[];
|
||||||
|
excluded: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DomainRulesResponse {
|
||||||
|
equivalentDomains: string[][];
|
||||||
|
customEquivalentDomains: CustomEquivalentDomain[];
|
||||||
|
globalEquivalentDomains: GlobalEquivalentDomain[];
|
||||||
|
object: 'domains';
|
||||||
|
}
|
||||||
|
|
||||||
export interface Invite {
|
export interface Invite {
|
||||||
code: string;
|
code: string;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
|
|||||||
+69
-1
@@ -23,6 +23,7 @@ import {
|
|||||||
stripProfileSecrets,
|
stripProfileSecrets,
|
||||||
} from '@/lib/api/auth';
|
} from '@/lib/api/auth';
|
||||||
import { listAdminInvites, listAdminUsers } from '@/lib/api/admin';
|
import { listAdminInvites, listAdminUsers } from '@/lib/api/admin';
|
||||||
|
import { getDomainRules, saveDomainRules } from '@/lib/api/domains';
|
||||||
import { getSends } from '@/lib/api/send';
|
import { getSends } from '@/lib/api/send';
|
||||||
import { getCachedVaultCoreSnapshot, loadVaultCoreSyncSnapshot } from '@/lib/api/vault-sync';
|
import { getCachedVaultCoreSnapshot, loadVaultCoreSyncSnapshot } from '@/lib/api/vault-sync';
|
||||||
import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair';
|
import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair';
|
||||||
@@ -68,7 +69,7 @@ import {
|
|||||||
createDemoMainRoutesProps,
|
createDemoMainRoutesProps,
|
||||||
} from '@/lib/demo';
|
} from '@/lib/demo';
|
||||||
import type { AdminBackupSettings } from '@/lib/api/backup';
|
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';
|
import type { VaultCoreSnapshot } from '@/lib/vault-cache';
|
||||||
|
|
||||||
function isBackupProgressDetail(value: unknown): value is BackupProgressDetail {
|
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 IMPORT_ROUTE_ALIASES: ReadonlySet<string> = new Set(IMPORT_ROUTE_PATHS.filter((path) => path !== IMPORT_ROUTE));
|
||||||
const SETTINGS_HOME_ROUTE = '/settings';
|
const SETTINGS_HOME_ROUTE = '/settings';
|
||||||
const SETTINGS_ACCOUNT_ROUTE = '/settings/account';
|
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 AUTH_ROUTE_PATHS = ['/', '/login', '/register', '/lock', '/recover-2fa'] as const;
|
||||||
const APP_ROUTE_PATHS = [
|
const APP_ROUTE_PATHS = [
|
||||||
'/',
|
'/',
|
||||||
@@ -98,6 +100,7 @@ const APP_ROUTE_PATHS = [
|
|||||||
'/backup',
|
'/backup',
|
||||||
'/settings',
|
'/settings',
|
||||||
SETTINGS_ACCOUNT_ROUTE,
|
SETTINGS_ACCOUNT_ROUTE,
|
||||||
|
SETTINGS_DOMAIN_RULES_ROUTE,
|
||||||
'/help',
|
'/help',
|
||||||
...IMPORT_ROUTE_PATHS,
|
...IMPORT_ROUTE_PATHS,
|
||||||
] as const;
|
] as const;
|
||||||
@@ -227,6 +230,7 @@ export default function App() {
|
|||||||
const pendingVaultCoreQueryRefreshRef = useRef<Promise<{ data?: VaultCoreSnapshot } | unknown> | null>(null);
|
const pendingVaultCoreQueryRefreshRef = useRef<Promise<{ data?: VaultCoreSnapshot } | unknown> | null>(null);
|
||||||
const pendingVaultCoreRefreshRef = useRef<Promise<unknown> | null>(null);
|
const pendingVaultCoreRefreshRef = useRef<Promise<unknown> | null>(null);
|
||||||
const notificationRefreshTimerRef = useRef<number | null>(null);
|
const notificationRefreshTimerRef = useRef<number | null>(null);
|
||||||
|
const domainRulesSaveSeqRef = useRef(0);
|
||||||
const { toasts, pushToast, removeToast } = useToastManager();
|
const { toasts, pushToast, removeToast } = useToastManager();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -953,6 +957,45 @@ export default function App() {
|
|||||||
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
|
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
|
||||||
staleTime: 30_000,
|
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({
|
useQuery({
|
||||||
queryKey: ['admin-backup-settings', vaultCacheKey],
|
queryKey: ['admin-backup-settings', vaultCacheKey],
|
||||||
queryFn: () => backupActions.loadSettings(),
|
queryFn: () => backupActions.loadSettings(),
|
||||||
@@ -1317,6 +1360,23 @@ export default function App() {
|
|||||||
const isImportRoute = routeLocation === IMPORT_ROUTE || IMPORT_ROUTE_ALIASES.has(routeLocation);
|
const isImportRoute = routeLocation === IMPORT_ROUTE || IMPORT_ROUTE_ALIASES.has(routeLocation);
|
||||||
const showSidebarToggle = mobileLayout && (location === '/vault' || location === '/sends');
|
const showSidebarToggle = mobileLayout && (location === '/vault' || location === '/sends');
|
||||||
const sidebarToggleTitle = location === '/vault' ? t('txt_folders') : t('txt_type');
|
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 =
|
const mobilePrimaryRoute =
|
||||||
location === '/sends'
|
location === '/sends'
|
||||||
? '/sends'
|
? '/sends'
|
||||||
@@ -1330,6 +1390,7 @@ export default function App() {
|
|||||||
if (location === '/sends') return t('nav_sends');
|
if (location === '/sends') return t('nav_sends');
|
||||||
if (location === '/admin') return t('nav_admin_panel');
|
if (location === '/admin') return t('nav_admin_panel');
|
||||||
if (location === '/security/devices') return t('nav_device_management');
|
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 (location === '/backup') return t('nav_backup_strategy');
|
||||||
if (isImportRoute) return t('nav_import_export');
|
if (isImportRoute) return t('nav_import_export');
|
||||||
if (location === SETTINGS_ACCOUNT_ROUTE) return t('nav_account_settings');
|
if (location === SETTINGS_ACCOUNT_ROUTE) return t('nav_account_settings');
|
||||||
@@ -1385,6 +1446,9 @@ export default function App() {
|
|||||||
authorizedDevices: authorizedDevicesQuery.data || [],
|
authorizedDevices: authorizedDevicesQuery.data || [],
|
||||||
authorizedDevicesLoading: authorizedDevicesQuery.isFetching,
|
authorizedDevicesLoading: authorizedDevicesQuery.isFetching,
|
||||||
authorizedDevicesError: authorizedDevicesQuery.isError && !authorizedDevicesQuery.data ? t('txt_load_devices_failed') : '',
|
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,
|
onNavigate: navigate,
|
||||||
onLogout: handleLogout,
|
onLogout: handleLogout,
|
||||||
onNotify: pushToast,
|
onNotify: pushToast,
|
||||||
@@ -1432,6 +1496,10 @@ export default function App() {
|
|||||||
onLockTimeoutChange: setLockTimeoutMinutes,
|
onLockTimeoutChange: setLockTimeoutMinutes,
|
||||||
onSessionTimeoutActionChange: setSessionTimeoutAction,
|
onSessionTimeoutActionChange: setSessionTimeoutAction,
|
||||||
onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices,
|
onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices,
|
||||||
|
onRefreshDomainRules: () => {
|
||||||
|
void domainRulesQuery.refetch();
|
||||||
|
},
|
||||||
|
onSaveDomainRules: handleSaveDomainRules,
|
||||||
onRenameAuthorizedDevice: accountSecurityActions.renameAuthorizedDevice,
|
onRenameAuthorizedDevice: accountSecurityActions.renameAuthorizedDevice,
|
||||||
onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust,
|
onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust,
|
||||||
onRemoveDevice: accountSecurityActions.openRemoveDevice,
|
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 { Link } from 'wouter';
|
||||||
import AppMainRoutes from '@/components/AppMainRoutes';
|
import AppMainRoutes from '@/components/AppMainRoutes';
|
||||||
import ThemeSwitch from '@/components/ThemeSwitch';
|
import ThemeSwitch from '@/components/ThemeSwitch';
|
||||||
@@ -102,6 +102,10 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
|||||||
<Shield size={16} />
|
<Shield size={16} />
|
||||||
<span>{t('nav_device_management')}</span>
|
<span>{t('nav_device_management')}</span>
|
||||||
</Link>
|
</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 && (
|
{isAdmin && (
|
||||||
<Link href="/backup" className={`side-link ${props.location === '/backup' ? 'active' : ''}`}>
|
<Link href="/backup" className={`side-link ${props.location === '/backup' ? 'active' : ''}`}>
|
||||||
<Cloud size={16} />
|
<Cloud size={16} />
|
||||||
@@ -114,7 +118,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
|||||||
</Link>
|
</Link>
|
||||||
</aside>
|
</aside>
|
||||||
<main className="content">
|
<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} />
|
<AppMainRoutes {...props.mainRoutesProps} />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
import { lazy, Suspense } from 'preact/compat';
|
import { lazy, Suspense } from 'preact/compat';
|
||||||
import { useEffect } from 'preact/hooks';
|
import { useEffect } from 'preact/hooks';
|
||||||
import { Link, Route, Switch } from 'wouter';
|
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 type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
|
||||||
import LoadingState from '@/components/LoadingState';
|
import LoadingState from '@/components/LoadingState';
|
||||||
import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup';
|
import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup';
|
||||||
import type { CiphersImportPayload } from '@/lib/api/vault';
|
import type { CiphersImportPayload } from '@/lib/api/vault';
|
||||||
import { t } from '@/lib/i18n';
|
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';
|
import type { ExportRequest } from '@/lib/export-formats';
|
||||||
|
|
||||||
const VaultPage = lazy(() => import('@/components/VaultPage'));
|
const VaultPage = lazy(() => import('@/components/VaultPage'));
|
||||||
const SendsPage = lazy(() => import('@/components/SendsPage'));
|
const SendsPage = lazy(() => import('@/components/SendsPage'));
|
||||||
const TotpCodesPage = lazy(() => import('@/components/TotpCodesPage'));
|
const TotpCodesPage = lazy(() => import('@/components/TotpCodesPage'));
|
||||||
const SettingsPage = lazy(() => import('@/components/SettingsPage'));
|
const SettingsPage = lazy(() => import('@/components/SettingsPage'));
|
||||||
|
const DomainRulesPage = lazy(() => import('@/components/DomainRulesPage'));
|
||||||
const SecurityDevicesPage = lazy(() => import('@/components/SecurityDevicesPage'));
|
const SecurityDevicesPage = lazy(() => import('@/components/SecurityDevicesPage'));
|
||||||
const AdminPage = lazy(() => import('@/components/AdminPage'));
|
const AdminPage = lazy(() => import('@/components/AdminPage'));
|
||||||
const BackupCenterPage = lazy(() => import('@/components/BackupCenterPage'));
|
const BackupCenterPage = lazy(() => import('@/components/BackupCenterPage'));
|
||||||
@@ -56,6 +57,9 @@ export interface AppMainRoutesProps {
|
|||||||
authorizedDevices: AuthorizedDevice[];
|
authorizedDevices: AuthorizedDevice[];
|
||||||
authorizedDevicesLoading: boolean;
|
authorizedDevicesLoading: boolean;
|
||||||
authorizedDevicesError: string;
|
authorizedDevicesError: string;
|
||||||
|
domainRules: DomainRules | null;
|
||||||
|
domainRulesLoading: boolean;
|
||||||
|
domainRulesError: string;
|
||||||
onNavigate: (path: string) => void;
|
onNavigate: (path: string) => void;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
|
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||||
@@ -108,6 +112,8 @@ export interface AppMainRoutesProps {
|
|||||||
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
|
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
|
||||||
onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void;
|
onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void;
|
||||||
onRefreshAuthorizedDevices: () => Promise<void>;
|
onRefreshAuthorizedDevices: () => Promise<void>;
|
||||||
|
onRefreshDomainRules: () => void;
|
||||||
|
onSaveDomainRules: (customEquivalentDomains: CustomEquivalentDomain[], excludedGlobalEquivalentDomains: number[]) => Promise<void>;
|
||||||
onRenameAuthorizedDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
|
onRenameAuthorizedDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
|
||||||
onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
|
onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
|
||||||
onRemoveDevice: (device: AuthorizedDevice) => void;
|
onRemoveDevice: (device: AuthorizedDevice) => void;
|
||||||
@@ -268,6 +274,10 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
<Shield size={18} />
|
<Shield size={18} />
|
||||||
<span>{t('nav_device_management')}</span>
|
<span>{t('nav_device_management')}</span>
|
||||||
</Link>
|
</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">
|
<Link href={props.importRoute} className="mobile-settings-link">
|
||||||
<ArrowUpDown size={18} />
|
<ArrowUpDown size={18} />
|
||||||
<span>{t('nav_import_export')}</span>
|
<span>{t('nav_import_export')}</span>
|
||||||
@@ -319,6 +329,28 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</Route>
|
</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">
|
<Route path="/admin">
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
{props.mobileLayout && (
|
{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,
|
authorizedDevices: state.authorizedDevices,
|
||||||
authorizedDevicesLoading: false,
|
authorizedDevicesLoading: false,
|
||||||
authorizedDevicesError: '',
|
authorizedDevicesError: '',
|
||||||
|
domainRulesLoading: false,
|
||||||
|
domainRulesError: '',
|
||||||
onImport: async () => {
|
onImport: async () => {
|
||||||
await readonly();
|
await readonly();
|
||||||
return createDemoImportResult();
|
return createDemoImportResult();
|
||||||
@@ -1055,6 +1057,10 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
|
|||||||
onRefreshAuthorizedDevices: async () => {
|
onRefreshAuthorizedDevices: async () => {
|
||||||
notify('success', t('txt_demo_devices_refreshed'));
|
notify('success', t('txt_demo_devices_refreshed'));
|
||||||
},
|
},
|
||||||
|
onRefreshDomainRules: () => {
|
||||||
|
notify('success', t('txt_domain_rules_refreshed'));
|
||||||
|
},
|
||||||
|
onSaveDomainRules: readonly,
|
||||||
onRenameAuthorizedDevice: async (device, name) => {
|
onRenameAuthorizedDevice: async (device, name) => {
|
||||||
const normalized = String(name || '').trim();
|
const normalized = String(name || '').trim();
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
|
|||||||
@@ -874,7 +874,28 @@ const en: Record<string, string> = {
|
|||||||
"txt_status_inactive": "Inactive",
|
"txt_status_inactive": "Inactive",
|
||||||
"txt_language": "Language",
|
"txt_language": "Language",
|
||||||
"txt_display_language": "Display 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;
|
export default en;
|
||||||
|
|||||||
@@ -874,7 +874,28 @@ const es: Record<string, string> = {
|
|||||||
"txt_status_inactive": "Inactivo",
|
"txt_status_inactive": "Inactivo",
|
||||||
"txt_language": "Idioma",
|
"txt_language": "Idioma",
|
||||||
"txt_display_language": "Idioma de visualización",
|
"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;
|
export default es;
|
||||||
|
|||||||
@@ -874,7 +874,28 @@ const ru: Record<string, string> = {
|
|||||||
"txt_status_inactive": "Неактивный",
|
"txt_status_inactive": "Неактивный",
|
||||||
"txt_language": "Язык",
|
"txt_language": "Язык",
|
||||||
"txt_display_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;
|
export default ru;
|
||||||
|
|||||||
@@ -874,7 +874,28 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_status_inactive": "未激活",
|
"txt_status_inactive": "未激活",
|
||||||
"txt_language": "语言",
|
"txt_language": "语言",
|
||||||
"txt_display_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;
|
export default zhCN;
|
||||||
|
|||||||
@@ -874,7 +874,28 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_status_inactive": "未激活",
|
"txt_status_inactive": "未激活",
|
||||||
"txt_language": "語言",
|
"txt_language": "語言",
|
||||||
"txt_display_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;
|
export default zhTW;
|
||||||
|
|||||||
@@ -359,3 +359,22 @@ export interface AuthorizedDevice {
|
|||||||
trustedTokenCount: number;
|
trustedTokenCount: number;
|
||||||
trustedUntil: string | null;
|
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;
|
@apply grid gap-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.domain-rules-route {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
grid-template-rows: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
.import-export-page {
|
.import-export-page {
|
||||||
@apply grid gap-3;
|
@apply grid gap-3;
|
||||||
}
|
}
|
||||||
@@ -19,9 +26,8 @@
|
|||||||
.backup-operations-sidebar,
|
.backup-operations-sidebar,
|
||||||
.backup-destination-sidebar,
|
.backup-destination-sidebar,
|
||||||
.backup-detail-panel {
|
.backup-detail-panel {
|
||||||
@apply min-w-0 rounded-xl bg-white p-3;
|
@apply min-w-0 rounded-2xl border bg-panel p-3 shadow-soft;
|
||||||
border: 1px solid #d8dee8;
|
border-color: var(--line);
|
||||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.backup-actions-stack {
|
.backup-actions-stack {
|
||||||
@@ -305,7 +311,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.backup-browser-list {
|
.backup-browser-list {
|
||||||
@apply overflow-hidden rounded-xl bg-white;
|
@apply overflow-hidden rounded-xl border bg-white;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -875,3 +881,275 @@
|
|||||||
background: #e2e8f0;
|
background: #e2e8f0;
|
||||||
color: #475569;
|
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;
|
@apply rounded-2xl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-col {
|
||||||
|
@apply overflow-visible rounded-2xl border-0 bg-transparent p-0 shadow-none;
|
||||||
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
@apply p-3.5;
|
@apply p-3.5;
|
||||||
}
|
}
|
||||||
@@ -477,6 +481,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-modules-grid,
|
.settings-modules-grid,
|
||||||
|
.domain-rules-grid,
|
||||||
.password-settings-grid {
|
.password-settings-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -189,6 +189,10 @@
|
|||||||
@apply h-full min-h-0 overflow-auto;
|
@apply h-full min-h-0 overflow-auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.route-stage-fixed {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.mobile-sidebar-mask {
|
.mobile-sidebar-mask {
|
||||||
@apply pointer-events-none invisible fixed inset-0 opacity-0;
|
@apply pointer-events-none invisible fixed inset-0 opacity-0;
|
||||||
background: rgba(15, 23, 42, 0.36);
|
background: rgba(15, 23, 42, 0.36);
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
|
|
||||||
.sidebar,
|
.sidebar,
|
||||||
.list-panel,
|
.list-panel,
|
||||||
.card {
|
.card,
|
||||||
|
.detail-col {
|
||||||
@apply rounded-2xl border bg-panel shadow-soft;
|
@apply rounded-2xl border bg-panel shadow-soft;
|
||||||
border-color: var(--line);
|
border-color: var(--line);
|
||||||
}
|
}
|
||||||
@@ -483,7 +484,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.detail-col {
|
.detail-col {
|
||||||
@apply min-h-0 overflow-auto;
|
@apply min-h-0 overflow-auto p-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-panel-head {
|
.mobile-panel-head {
|
||||||
|
|||||||
Reference in New Issue
Block a user