domma-cms 0.81.1 → 0.83.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/admin/css/admin.css +1 -1
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +5 -4
- package/admin/js/lib/avatar.js +1 -0
- package/admin/js/templates/login.html +16 -0
- package/admin/js/templates/my-profile.html +23 -3
- package/admin/js/views/login.js +2 -2
- package/admin/js/views/my-profile.js +1 -1
- package/bin/update.js +7 -3
- package/config/plugins.json +5 -0
- package/package.json +2 -1
- package/plugins/free-tier.lock.json +25 -1
- package/plugins/security/CLAUDE.md +41 -0
- package/plugins/security/admin/lib/blocks.js +46 -0
- package/plugins/security/admin/lib/health.js +148 -0
- package/plugins/security/admin/lib/ip.js +44 -0
- package/plugins/security/admin/lib/lockout.js +78 -0
- package/plugins/security/admin/lib/policy.js +32 -0
- package/plugins/security/admin/lib/probes.js +39 -0
- package/plugins/security/admin/lib/settings.js +86 -0
- package/plugins/security/admin/templates/security.html +170 -0
- package/plugins/security/admin/views/security.js +529 -0
- package/plugins/security/bin/unlock.js +81 -0
- package/plugins/security/config.js +7 -0
- package/plugins/security/plugin.js +303 -0
- package/plugins/security/plugin.json +46 -0
- package/plugins/security/server/common-passwords.NOTICE +3 -0
- package/plugins/security/server/common-passwords.txt +10001 -0
- package/plugins/security/server/facts.js +133 -0
- package/plugins/security/server/state.js +106 -0
- package/plugins/security/tests/security.test.js +134 -0
- package/server/routes/api/auth.js +241 -39
- package/server/routes/api/forms.js +3 -1
- package/server/routes/api/users.js +20 -0
- package/server/server.js +25 -7
- package/server/services/audit.js +63 -0
- package/server/services/authChecks.js +128 -0
- package/server/services/avatars.js +77 -0
- package/server/services/network.js +49 -0
- package/server/services/plugins.js +5 -1
- package/server/services/sessions.js +171 -0
- package/server/services/siteGitignore.js +1 -0
- package/server/services/users.js +5 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security - probe paths: what scanners ask for on every site they find, and a
|
|
3
|
+
* Domma CMS never serves. Answered with an empty 404 before any rendering, and
|
|
4
|
+
* counted: an address that keeps probing is blocked for a while. Pure.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Path prefixes and patterns, lower-cased; a trailing / matches everything under it. */
|
|
8
|
+
export const DEFAULT_PROBES = [
|
|
9
|
+
'/wp-login.php', '/wp-admin/', '/wp-content/', '/wp-includes/', '/wp-json/', '/xmlrpc.php', '/wordpress/',
|
|
10
|
+
'/.env', '/.git/', '/.svn/', '/.hg/', '/.ds_store', '/.htaccess', '/.htpasswd', '/.aws/', '/.ssh/', '/.vscode/', '/.idea/',
|
|
11
|
+
'/phpmyadmin/', '/pma/', '/myadmin/', '/adminer.php', '/phpinfo.php', '/info.php', '/server-status', '/server-info',
|
|
12
|
+
'/cgi-bin/', '/vendor/phpunit/', '/boaform/', '/hnap1', '/actuator/', '/solr/', '/owa/', '/ecp/', '/autodiscover/',
|
|
13
|
+
'/config.json', '/config.yml', '/web.config', '/backup.zip', '/backup.sql', '/database.sql', '/dump.sql', '/db.sql',
|
|
14
|
+
'/debug/default/view', '/telescope/', '/_ignition/', '/api/jsonws/', '/remote/login', '/global-protect/', '/sslvpn'
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
/** Extensions no Domma CMS page has: a request ending so is a probe. */
|
|
18
|
+
export const PROBE_EXTENSIONS = ['.php', '.asp', '.aspx', '.jsp', '.cgi', '.pl', '.bak', '.old', '.sql', '.swp', '.env'];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Is this path a probe?
|
|
22
|
+
*
|
|
23
|
+
* @param {string} path - the request path, without the query
|
|
24
|
+
* @param {{extra?: string[], allow?: string[]}} [opts] - the site's own additions; paths it DOES serve
|
|
25
|
+
*/
|
|
26
|
+
export function isProbe(path, {extra = [], allow = []} = {}) {
|
|
27
|
+
let p;
|
|
28
|
+
try { p = decodeURIComponent(String(path || '')).toLowerCase(); } catch { p = String(path || '').toLowerCase(); }
|
|
29
|
+
p = p.split('?')[0].replace(/\/{2,}/g, '/');
|
|
30
|
+
if (!p.startsWith('/')) p = `/${p}`;
|
|
31
|
+
if (allow.some(a => a && p.startsWith(String(a).toLowerCase()))) return false;
|
|
32
|
+
// A traversal attempt is a probe whatever it asks for.
|
|
33
|
+
if (p.includes('/../') || p.endsWith('/..')) return true;
|
|
34
|
+
const list = [...DEFAULT_PROBES, ...extra.map(e => String(e).toLowerCase()).filter(Boolean)];
|
|
35
|
+
for (const rule of list) {
|
|
36
|
+
if (rule.endsWith('/') ? (p === rule.slice(0, -1) || p.startsWith(rule)) : (p === rule || p.startsWith(`${rule}/`))) return true;
|
|
37
|
+
}
|
|
38
|
+
return PROBE_EXTENSIONS.some(ext => p.endsWith(ext));
|
|
39
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security - settings (config/plugins.json `security.settings`), checked the
|
|
3
|
+
* same way by the server and the settings slideover. Pure.
|
|
4
|
+
*/
|
|
5
|
+
import {DEFAULT_LOCKOUT} from './lockout.js';
|
|
6
|
+
import {DEFAULT_POLICY} from './policy.js';
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_SETTINGS = {
|
|
9
|
+
lockout: {...DEFAULT_LOCKOUT, enabled: true},
|
|
10
|
+
policy: {...DEFAULT_POLICY, enabled: true},
|
|
11
|
+
probes: {enabled: true, blockAfter: 5, withinMin: 10, blockForMin: 60, extra: [], allow: []},
|
|
12
|
+
signinDays: 30,
|
|
13
|
+
staleAdminDays: 90,
|
|
14
|
+
notify: {lockouts: true, successAfterFails: true, probeBlocks: false}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const int = (v, min, max) => {
|
|
18
|
+
const n = Number(v);
|
|
19
|
+
return Number.isInteger(n) && n >= min && n <= max ? n : null;
|
|
20
|
+
};
|
|
21
|
+
const bool = (v, d) => (v === undefined ? d : v === true || v === 'true');
|
|
22
|
+
const lines = (v) => (Array.isArray(v) ? v : String(v ?? '').split(/[\n,]+/)).map(s => String(s).trim()).filter(Boolean);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The stored settings merged over the defaults, every value checked.
|
|
26
|
+
*
|
|
27
|
+
* @param {object} input - a full or partial settings object
|
|
28
|
+
* @param {object} [base] - what is stored now (defaults when absent)
|
|
29
|
+
* @returns {{errors: Record<string, string>, settings: object}}
|
|
30
|
+
*/
|
|
31
|
+
export function checkSettings(input = {}, base = DEFAULT_SETTINGS) {
|
|
32
|
+
const errors = {};
|
|
33
|
+
const b = mergeDefaults(base);
|
|
34
|
+
const out = JSON.parse(JSON.stringify(b));
|
|
35
|
+
const num = (group, key, min, max, label) => {
|
|
36
|
+
const v = input?.[group]?.[key];
|
|
37
|
+
if (v === undefined) return;
|
|
38
|
+
const n = int(v, min, max);
|
|
39
|
+
if (n === null) errors[`${group}.${key}`] = `${label}: a whole number, ${min} to ${max}.`;
|
|
40
|
+
else out[group][key] = n;
|
|
41
|
+
};
|
|
42
|
+
for (const g of ['lockout', 'policy', 'probes']) if (input?.[g]?.enabled !== undefined) out[g].enabled = bool(input[g].enabled, true);
|
|
43
|
+
num('lockout', 'accountThreshold', 3, 50, 'Failures on one account');
|
|
44
|
+
num('lockout', 'addressThreshold', 5, 500, 'Failures from one address');
|
|
45
|
+
num('lockout', 'windowMin', 1, 1440, 'Counted over (minutes)');
|
|
46
|
+
num('lockout', 'baseMin', 1, 60, 'First lock (minutes)');
|
|
47
|
+
num('lockout', 'maxMin', 1, 10080, 'Longest lock (minutes)');
|
|
48
|
+
num('policy', 'minLength', 8, 64, 'Shortest password');
|
|
49
|
+
for (const k of ['blockCommon', 'blockPersonal']) if (input?.policy?.[k] !== undefined) out.policy[k] = bool(input.policy[k], true);
|
|
50
|
+
num('probes', 'blockAfter', 1, 100, 'Probes before a block');
|
|
51
|
+
num('probes', 'withinMin', 1, 1440, 'Probes counted over (minutes)');
|
|
52
|
+
num('probes', 'blockForMin', 1, 10080, 'Block for (minutes)');
|
|
53
|
+
for (const k of ['extra', 'allow']) {
|
|
54
|
+
if (input?.probes?.[k] === undefined) continue;
|
|
55
|
+
const list = lines(input.probes[k]);
|
|
56
|
+
const bad = list.filter(p => !p.startsWith('/'));
|
|
57
|
+
if (bad.length) errors[`probes.${k}`] = `Each starts with "/": ${bad.slice(0, 3).join(', ')}`;
|
|
58
|
+
else out.probes[k] = [...new Set(list)].slice(0, 200);
|
|
59
|
+
}
|
|
60
|
+
if (out.lockout.maxMin < out.lockout.baseMin) errors['lockout.maxMin'] = 'The longest lock cannot be shorter than the first.';
|
|
61
|
+
if (input.signinDays !== undefined) {
|
|
62
|
+
const n = int(input.signinDays, 7, 365);
|
|
63
|
+
if (n === null) errors.signinDays = 'Keep the sign-in log 7 to 365 days.';
|
|
64
|
+
else out.signinDays = n;
|
|
65
|
+
}
|
|
66
|
+
if (input.staleAdminDays !== undefined) {
|
|
67
|
+
const n = int(input.staleAdminDays, 14, 730);
|
|
68
|
+
if (n === null) errors.staleAdminDays = '14 to 730 days.';
|
|
69
|
+
else out.staleAdminDays = n;
|
|
70
|
+
}
|
|
71
|
+
for (const k of Object.keys(DEFAULT_SETTINGS.notify)) if (input?.notify?.[k] !== undefined) out.notify[k] = bool(input.notify[k], true);
|
|
72
|
+
return {errors, settings: out};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Stored settings (possibly old or partial) with every default filled in. */
|
|
76
|
+
export function mergeDefaults(s = {}) {
|
|
77
|
+
const d = DEFAULT_SETTINGS;
|
|
78
|
+
return {
|
|
79
|
+
lockout: {...d.lockout, ...(s.lockout || {})},
|
|
80
|
+
policy: {...d.policy, ...(s.policy || {})},
|
|
81
|
+
probes: {...d.probes, ...(s.probes || {})},
|
|
82
|
+
signinDays: s.signinDays ?? d.signinDays,
|
|
83
|
+
staleAdminDays: s.staleAdminDays ?? d.staleAdminDays,
|
|
84
|
+
notify: {...d.notify, ...(s.notify || {})}
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
<!-- Security. Bound with M.applyBindings - admin/views/security.js inserts the item templates (the router
|
|
2
|
+
renders a templateUrl through _.render, eating any {{…}}). Refresh and Settings are banner actions. -->
|
|
3
|
+
<div class="scx">
|
|
4
|
+
<p class="scx-note is-bad" data-bind-hidden="!error.value" data-bind-text="error.value"></p>
|
|
5
|
+
<p class="scx-note is-warn" data-bind-hidden="checksOk.value">This version of Domma CMS cannot run sign-in checks: lockout and password rules are off until it is updated (0.83 or later). Probe blocking and the health check work.</p>
|
|
6
|
+
|
|
7
|
+
<nav class="scx-tabs" role="tablist" aria-label="Security">
|
|
8
|
+
<button type="button" role="tab" data-on-click="tab_overview" data-bind-class="tab.value === 'overview' && 'is-on'"><span data-icon="shield" data-icon-size="14"></span> Overview</button>
|
|
9
|
+
<button type="button" role="tab" data-on-click="tab_signins" data-bind-class="tab.value === 'signins' && 'is-on'"><span data-icon="log-in" data-icon-size="14"></span> Sign-ins</button>
|
|
10
|
+
<button type="button" role="tab" data-on-click="tab_blocked" data-bind-class="tab.value === 'blocked' && 'is-on'"><span data-icon="x-circle" data-icon-size="14"></span> Blocked <small class="scx-count" data-bind-hidden="!blockedCount.value" data-bind-text="blockedCount.value"></small></button>
|
|
11
|
+
</nav>
|
|
12
|
+
|
|
13
|
+
<!-- Overview -->
|
|
14
|
+
<section class="scx-panel" data-bind-hidden="tab.value !== 'overview'" data-bind-class="loading.value && 'is-busy'">
|
|
15
|
+
<div class="scx-tiles">
|
|
16
|
+
<div class="scx-card scx-tile scx-score" data-bind-data-tone="scoreTone.value">
|
|
17
|
+
<small>Health <span data-help="The share of checks that pass (a check that could be better counts half; one that could not be answered is left out). The checks below say what each looks at and how to fix it."></span></small>
|
|
18
|
+
<strong data-bind-text="scoreText.value">—</strong>
|
|
19
|
+
<span class="scx-bar"><span data-bind-style-width="scoreWidth.value"></span></span>
|
|
20
|
+
</div>
|
|
21
|
+
<div class="scx-card scx-tile"><small>Need attention</small><strong data-bind-text="attentionText.value">—</strong></div>
|
|
22
|
+
<div class="scx-card scx-tile"><small>Failed sign-ins, 24h <span data-help="Failed sign-ins in the last 24 hours: a wrong password, an unknown email, a locked account."></span></small><strong data-bind-text="ov.value.failed">—</strong></div>
|
|
23
|
+
<div class="scx-card scx-tile"><small>Blocked now <span data-help="Addresses blocked by a rule or for probing, and accounts or addresses locked after wrong passwords."></span></small><strong data-bind-text="ov.value.blocked">—</strong></div>
|
|
24
|
+
<div class="scx-card scx-tile"><small>Probes refused today <span data-help="Requests for things no Domma site has - WordPress logins, .env files, database dumps. Scanners send them to every site they find; they are answered with an empty 'not found'."></span></small><strong data-bind-text="ov.value.probes">—</strong></div>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<div class="scx-card">
|
|
28
|
+
<div class="scx-card-title">Checks
|
|
29
|
+
<span data-help-title="Checks" data-help="What could let someone in, or let you down. Click a check for why it matters and how to fix it. They are worked out again every ten minutes, or now with Refresh (top right)."></span>
|
|
30
|
+
<span class="scx-spacer"></span>
|
|
31
|
+
<small class="scx-muted" data-bind-text="asOf.value"></small>
|
|
32
|
+
</div>
|
|
33
|
+
<div class="scx-checks" data-each="checks.value key=id"></div>
|
|
34
|
+
</div>
|
|
35
|
+
</section>
|
|
36
|
+
|
|
37
|
+
<!-- Sign-ins -->
|
|
38
|
+
<section class="scx-panel" data-bind-hidden="tab.value !== 'signins'">
|
|
39
|
+
<div class="scx-toolbar">
|
|
40
|
+
<div class="scx-seg" role="group" aria-label="Which sign-ins">
|
|
41
|
+
<button type="button" data-on-click="res_all" data-bind-class="result.value === 'all' && 'is-on'">All</button>
|
|
42
|
+
<button type="button" data-on-click="res_failed" data-bind-class="result.value === 'failed' && 'is-on'">Failed</button>
|
|
43
|
+
<button type="button" data-on-click="res_ok" data-bind-class="result.value === 'ok' && 'is-on'">Succeeded</button>
|
|
44
|
+
</div>
|
|
45
|
+
<select class="form-input scx-days" aria-label="Period" data-model="days.value">
|
|
46
|
+
<option value="1">Last 24 hours</option><option value="7">Last 7 days</option><option value="30">Last 30 days</option><option value="365">All kept</option>
|
|
47
|
+
</select>
|
|
48
|
+
<input type="search" class="form-input scx-search" placeholder="Email, address, browser…" aria-label="Search sign-ins" data-model="query.value">
|
|
49
|
+
<span class="scx-spacer"></span>
|
|
50
|
+
<small class="scx-muted" data-bind-text="signinText.value"></small>
|
|
51
|
+
<button type="button" class="btn btn-sm btn-ghost" data-on-click="exportCsv"><span data-icon="download" data-icon-size="13"></span> CSV</button>
|
|
52
|
+
<span data-help-title="Sign-ins" data-help="Every sign-in attempt, kept for the days set in the settings. Right-click (or ⋮) a row to block its address, unlock the account, or see only that address."></span>
|
|
53
|
+
</div>
|
|
54
|
+
<div class="scx-card scx-flush">
|
|
55
|
+
<p class="scx-note scx-pad" data-bind-hidden="signinRows.value.length" data-bind-text="signinEmpty.value"></p>
|
|
56
|
+
<div class="scx-list scx-signins" data-each="signinRows.value key=id"></div>
|
|
57
|
+
</div>
|
|
58
|
+
</section>
|
|
59
|
+
|
|
60
|
+
<!-- Blocked -->
|
|
61
|
+
<section class="scx-panel" data-bind-hidden="tab.value !== 'blocked'">
|
|
62
|
+
<div class="scx-card">
|
|
63
|
+
<div class="scx-card-title">Locked after wrong passwords
|
|
64
|
+
<span data-help="An account or address is locked for a while after repeated wrong passwords - longer each time. Unlock one here, or with the command line (see Settings) if you are locked out yourself."></span>
|
|
65
|
+
<span class="scx-spacer"></span>
|
|
66
|
+
<button type="button" class="btn btn-xs btn-ghost" data-on-click="unlockAll" data-bind-hidden="!lockRows.value.length" data-bind-disabled="!canManage.value">Unlock all</button>
|
|
67
|
+
</div>
|
|
68
|
+
<p class="scx-note" data-bind-hidden="lockRows.value.length">Nothing is locked.</p>
|
|
69
|
+
<div class="scx-list scx-locks" data-each="lockRows.value key=id"></div>
|
|
70
|
+
</div>
|
|
71
|
+
<div class="scx-card">
|
|
72
|
+
<div class="scx-card-title">Blocked for probing
|
|
73
|
+
<span data-help="Addresses that kept asking for things no Domma site has, blocked for the time set in the settings. An address someone signed in from in the last week is never blocked this way."></span></div>
|
|
74
|
+
<p class="scx-note" data-bind-hidden="tempRows.value.length">No address is blocked for probing.</p>
|
|
75
|
+
<div class="scx-list scx-temps" data-each="tempRows.value key=id"></div>
|
|
76
|
+
</div>
|
|
77
|
+
<div class="scx-card">
|
|
78
|
+
<div class="scx-card-title">Blocked by hand
|
|
79
|
+
<span data-help="Addresses or ranges that may not reach the site at all - 81.2.69.142, 192.168.1.*, 10.0.0.0/8 or an IPv6 prefix like 2a00:23c7:*. They get a bare 'Forbidden'."></span></div>
|
|
80
|
+
<form class="scx-add" data-on-submit="addRule" data-bind-hidden="!canManage.value">
|
|
81
|
+
<input type="text" class="form-input scx-mono" placeholder="Address or range" aria-label="Address or range" data-model="newRule.value">
|
|
82
|
+
<input type="text" class="form-input" placeholder="Why (optional)" aria-label="Why" data-model="newNote.value">
|
|
83
|
+
<button type="submit" class="btn btn-sm btn-primary" data-bind-disabled="!newRule.value"><span data-icon="plus" data-icon-size="12"></span> Block</button>
|
|
84
|
+
<small class="scx-muted" data-bind-text="youText.value"></small>
|
|
85
|
+
</form>
|
|
86
|
+
<p class="scx-note" data-bind-hidden="ruleRows.value.length">No address is blocked by hand.</p>
|
|
87
|
+
<div class="scx-list scx-rules" data-each="ruleRows.value key=id"></div>
|
|
88
|
+
</div>
|
|
89
|
+
</section>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<style>
|
|
93
|
+
.scx { display: flex; flex-direction: column; gap: 1rem; }
|
|
94
|
+
.scx [hidden] { display: none !important; }
|
|
95
|
+
.scx-panel { display: flex; flex-direction: column; gap: 1rem; transition: opacity .2s; }
|
|
96
|
+
.scx-panel.is-busy { opacity: .6; }
|
|
97
|
+
.scx-note { margin: 0; color: var(--dm-text-muted); font-size: var(--dm-text-sm); }
|
|
98
|
+
.scx-note.is-bad, .scx-note.is-warn { padding: .6rem .8rem; border-radius: var(--dm-radius-md); }
|
|
99
|
+
.scx-note.is-bad { background: color-mix(in srgb, var(--dm-danger) 14%, transparent); color: var(--dm-danger); }
|
|
100
|
+
.scx-note.is-warn { background: color-mix(in srgb, var(--dm-warning) 14%, transparent); color: var(--dm-text); }
|
|
101
|
+
.scx-pad { padding: .8rem .9rem; }
|
|
102
|
+
.scx-tabs { display: flex; gap: .15rem; border-bottom: 1px solid var(--dm-border); overflow-x: auto; }
|
|
103
|
+
.scx-tabs button { border: 0; background: none; color: var(--dm-text-muted); font: inherit; font-size: var(--dm-text-sm); padding: .45rem .75rem; cursor: pointer;
|
|
104
|
+
border-bottom: 2px solid transparent; margin-bottom: -1px; display: inline-flex; align-items: center; gap: .35rem; white-space: nowrap; }
|
|
105
|
+
.scx-tabs button:hover { color: var(--dm-text); }
|
|
106
|
+
.scx-tabs button.is-on { color: var(--dm-text); border-bottom-color: var(--dm-primary); font-weight: 600; }
|
|
107
|
+
.scx-count { font-size: var(--dm-text-xs); padding: 0 .4rem; border-radius: 999px; background: color-mix(in srgb, var(--dm-text) 10%, transparent); }
|
|
108
|
+
.scx-card { border: 1px solid var(--dm-border); border-radius: var(--dm-radius-md); padding: .75rem .9rem; display: flex; flex-direction: column; gap: .6rem; min-width: 0; }
|
|
109
|
+
.scx-flush { padding: 0; }
|
|
110
|
+
.scx-card-title { display: flex; align-items: center; gap: .4rem; font-weight: 600; flex-wrap: wrap; }
|
|
111
|
+
.scx-spacer { flex: 1; }
|
|
112
|
+
.scx-muted { color: var(--dm-text-muted); font-weight: 400; font-size: var(--dm-text-xs); }
|
|
113
|
+
.scx-mono { font-family: var(--dm-font-mono, monospace); }
|
|
114
|
+
.scx-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 10rem), 1fr)); gap: 1rem; }
|
|
115
|
+
.scx-tile { gap: .15rem; }
|
|
116
|
+
.scx-tile small { color: var(--dm-text-muted); font-size: var(--dm-text-sm); display: inline-flex; align-items: center; gap: .3rem; }
|
|
117
|
+
.scx-tile strong { font-size: var(--dm-text-2xl, 1.5rem); line-height: 1.15; font-weight: 600; }
|
|
118
|
+
.scx-bar { display: block; height: .35rem; border-radius: 999px; background: color-mix(in srgb, var(--dm-text) 10%, transparent); overflow: hidden; margin-top: .3rem; }
|
|
119
|
+
.scx-bar span { display: block; height: 100%; border-radius: inherit; background: var(--dm-primary); }
|
|
120
|
+
.scx-score[data-tone="good"] .scx-bar span { background: var(--dm-success); }
|
|
121
|
+
.scx-score[data-tone="poor"] .scx-bar span { background: var(--dm-warning); }
|
|
122
|
+
.scx-score[data-tone="bad"] .scx-bar span { background: var(--dm-danger); }
|
|
123
|
+
.scx-checks { display: flex; flex-direction: column; }
|
|
124
|
+
.scx-group { font-size: var(--dm-text-xs); text-transform: uppercase; letter-spacing: .05em; color: var(--dm-text-muted); padding: .7rem .3rem .25rem; font-weight: 600; }
|
|
125
|
+
.scx-check { display: grid; grid-template-columns: 1.6rem minmax(0, 1fr) 1.5rem; gap: .5rem; align-items: start; padding: .5rem .3rem; border-bottom: 1px solid var(--dm-border); cursor: pointer; }
|
|
126
|
+
.scx-check:hover, .scx-check:focus-visible { background: var(--dm-table-hover-bg, color-mix(in srgb, var(--dm-text) 6%, transparent)); outline: none; }
|
|
127
|
+
.scx-status { display: inline-flex; align-items: center; justify-content: center; width: 1.35rem; height: 1.35rem; border-radius: 50%; font-size: .75rem; font-weight: 700; margin-top: .1rem; }
|
|
128
|
+
.scx-status[data-status="pass"] { color: var(--dm-success); background: color-mix(in srgb, var(--dm-success) 14%, transparent); }
|
|
129
|
+
.scx-status[data-status="warn"] { color: var(--dm-warning); background: color-mix(in srgb, var(--dm-warning) 16%, transparent); }
|
|
130
|
+
.scx-status[data-status="fail"] { color: var(--dm-danger); background: color-mix(in srgb, var(--dm-danger) 14%, transparent); }
|
|
131
|
+
.scx-status[data-status="info"] { color: var(--dm-text-muted); background: color-mix(in srgb, var(--dm-text) 8%, transparent); }
|
|
132
|
+
.scx-check-main { display: flex; flex-direction: column; gap: .15rem; min-width: 0; }
|
|
133
|
+
.scx-check-main strong { font-size: var(--dm-text-sm); }
|
|
134
|
+
.scx-check-main small { color: var(--dm-text-muted); font-size: var(--dm-text-xs); }
|
|
135
|
+
.scx-more { display: flex; flex-direction: column; gap: .3rem; margin-top: .35rem; font-size: var(--dm-text-sm); }
|
|
136
|
+
.scx-more a { align-self: flex-start; }
|
|
137
|
+
.scx-toolbar { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
|
|
138
|
+
.scx-seg { display: inline-flex; border: 1px solid var(--dm-border); border-radius: var(--dm-radius-md); overflow: hidden; }
|
|
139
|
+
.scx-seg button { border: 0; background: none; color: var(--dm-text); padding: .3rem .7rem; cursor: pointer; font: inherit; font-size: var(--dm-text-sm); }
|
|
140
|
+
.scx-seg button + button { border-left: 1px solid var(--dm-border); }
|
|
141
|
+
.scx-seg button.is-on { background: var(--dm-primary); color: var(--dm-primary-text); }
|
|
142
|
+
.scx-days, .scx-search { width: auto; padding: .25rem .5rem; font-size: var(--dm-text-sm); }
|
|
143
|
+
.scx-search { flex: 0 1 15rem; }
|
|
144
|
+
.scx-list { display: flex; flex-direction: column; max-height: 34rem; overflow-y: auto; }
|
|
145
|
+
.scx-row { display: grid; align-items: center; gap: .6rem; padding: .45rem .8rem; border-bottom: 1px solid var(--dm-border); font-size: var(--dm-text-sm); cursor: default; }
|
|
146
|
+
.scx-row:last-child { border-bottom: 0; }
|
|
147
|
+
.scx-row:hover, .scx-row:focus-visible { background: var(--dm-table-hover-bg, color-mix(in srgb, var(--dm-text) 6%, transparent)); outline: none; }
|
|
148
|
+
.scx-signins .scx-row { grid-template-columns: 8.5rem 5.5rem minmax(8rem, 1.3fr) 8rem minmax(6rem, 1fr) 1.5rem; }
|
|
149
|
+
.scx-locks .scx-row, .scx-temps .scx-row, .scx-rules .scx-row { grid-template-columns: minmax(8rem, 1fr) minmax(8rem, 1.4fr) 10rem 1.5rem; padding: .45rem .3rem; }
|
|
150
|
+
.scx-cell { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
151
|
+
.scx-pill { display: inline-flex; align-items: center; justify-self: start; font-size: var(--dm-text-xs); font-weight: 600; padding: .05rem .5rem; border-radius: 999px; }
|
|
152
|
+
.scx-pill[data-ok="true"] { color: var(--dm-success); background: color-mix(in srgb, var(--dm-success) 14%, transparent); }
|
|
153
|
+
.scx-pill[data-ok="false"] { color: var(--dm-danger); background: color-mix(in srgb, var(--dm-danger) 12%, transparent); }
|
|
154
|
+
.scx-kebab { border: 0; background: none; cursor: pointer; font-size: var(--dm-text-lg); line-height: 1; color: var(--dm-text-muted); border-radius: var(--dm-radius-sm); padding: 0 .25rem; }
|
|
155
|
+
.scx-kebab:hover { color: var(--dm-primary); }
|
|
156
|
+
.scx-add { display: flex; gap: .4rem; flex-wrap: wrap; align-items: center; margin: 0; }
|
|
157
|
+
.scx-add .form-input { width: auto; flex: 1 1 11rem; padding: .25rem .5rem; font-size: var(--dm-text-sm); }
|
|
158
|
+
/* settings slideover */
|
|
159
|
+
.scx-form { display: flex; flex-direction: column; gap: 1.1rem; padding-bottom: 1rem; }
|
|
160
|
+
.scx-form h4 { margin: 0 0 .5rem; font-size: var(--dm-text-base); display: flex; align-items: center; gap: .5rem; }
|
|
161
|
+
.scx-form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr)); gap: .7rem 1rem; }
|
|
162
|
+
.scx-wide { grid-column: 1 / -1; }
|
|
163
|
+
.scx-err { color: var(--dm-danger); font-size: var(--dm-text-xs); }
|
|
164
|
+
.scx-actions { display: flex; align-items: center; gap: .5rem; border-top: 1px solid var(--dm-border); padding-top: .8rem; }
|
|
165
|
+
.scx-code { font-family: var(--dm-font-mono, monospace); font-size: var(--dm-text-xs); background: color-mix(in srgb, var(--dm-text) 7%, transparent); padding: .4rem .55rem; border-radius: var(--dm-radius-sm); white-space: pre-wrap; }
|
|
166
|
+
@media (max-width: 720px) {
|
|
167
|
+
.scx-signins .scx-row { grid-template-columns: 1fr 5rem 1.5rem; }
|
|
168
|
+
.scx-signins .scx-row .scx-hide-sm { display: none; }
|
|
169
|
+
}
|
|
170
|
+
</style>
|