The Gotify and ntfy tokens, the SMTP password and the webhook secret were stored as plain text in the settings table. They are now encrypted with the same key as integration credentials (CREDENTIALS_ENCRYPTION_KEY), marked with an "enc:v1:" prefix. Settings are decrypted when read and encrypted when written, so nothing else changes; values saved before this are converted at startup. An edit that doesn't touch a credential keeps its stored ciphertext, so a wrong or missing key (which reads as empty) can't be made permanent by an unrelated edit. Without a key new credentials fall back to plain storage, and the startup warning, .env.example and the Privacy page say so. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
336 lines
16 KiB
TypeScript
336 lines
16 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { Link } from "react-router-dom";
|
|
import { api, type PrivacyOverview } from "../api/client";
|
|
import { formatDateTime } from "../utils/date";
|
|
import { formatAgo } from "../utils/duration";
|
|
import { readableError } from "../utils/errors";
|
|
|
|
const CHANNEL_LABELS = { gotify: "Gotify", ntfy: "ntfy", smtp: "email (SMTP)", webhook: "webhook" } as const;
|
|
|
|
/** SQLite timestamps ("2026-09-26 04:07:21") are UTC without a zone marker. */
|
|
function parseTime(value: string): Date {
|
|
return new Date(value.includes("T") ? value : `${value.replace(" ", "T")}Z`);
|
|
}
|
|
|
|
export default function Privacy() {
|
|
const [data, setData] = useState<PrivacyOverview | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [exporting, setExporting] = useState(false);
|
|
|
|
useEffect(() => {
|
|
api.privacy
|
|
.overview()
|
|
.then(setData)
|
|
.catch((err) => setError(readableError(err)));
|
|
}, []);
|
|
|
|
async function download() {
|
|
setExporting(true);
|
|
setError(null);
|
|
try {
|
|
const blob = await api.privacy.exportOwnData();
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = "homelab-manager-my-data.json";
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
} catch (err) {
|
|
setError(readableError(err));
|
|
} finally {
|
|
setExporting(false);
|
|
}
|
|
}
|
|
|
|
const retention = data?.retention;
|
|
const outbound = data?.outbound;
|
|
const enabledChannels = outbound ? (Object.keys(CHANNEL_LABELS) as (keyof typeof CHANNEL_LABELS)[]).filter((c) => outbound.channels[c].enabled) : [];
|
|
|
|
return (
|
|
<>
|
|
<h2 className="page-title mb-3">Privacy</h2>
|
|
<div className="card mb-3">
|
|
<div className="card-body">
|
|
Homelab Manager runs on your own server, and what it stores stays there unless it's listed under{" "}
|
|
<a href="#where-it-goes">Where data goes</a>. It has no analytics or telemetry, loads no third-party scripts, fonts or
|
|
trackers, and sets no tracking or advertising cookies. This page describes what it actually does and shows live values
|
|
for this installation.
|
|
</div>
|
|
</div>
|
|
{error && <div className="alert alert-danger">{error}</div>}
|
|
|
|
{data && (
|
|
<div className="card mb-3">
|
|
<div className="card-header">
|
|
<h3 className="card-title">About you</h3>
|
|
<div className="card-actions">
|
|
<button className="btn btn-sm btn-outline-primary" onClick={() => void download()} disabled={exporting}>
|
|
{exporting ? "Preparing…" : "Download my data"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="card-body">
|
|
<dl className="row mb-0">
|
|
<dt className="col-sm-3">Name</dt>
|
|
<dd className="col-sm-9">{data.me.user.name ?? "—"}</dd>
|
|
<dt className="col-sm-3">Email</dt>
|
|
<dd className="col-sm-9">{data.me.user.email ?? "—"}</dd>
|
|
<dt className="col-sm-3">Role</dt>
|
|
<dd className="col-sm-9">{data.me.user.role}</dd>
|
|
<dt className="col-sm-3">Sign-in ID</dt>
|
|
<dd className="col-sm-9 text-break">
|
|
<code>{data.me.user.subject}</code>
|
|
</dd>
|
|
<dt className="col-sm-3">First signed in</dt>
|
|
<dd className="col-sm-9">{formatDateTime(parseTime(data.me.user.createdAt))}</dd>
|
|
<dt className="col-sm-3">Last signed in</dt>
|
|
<dd className="col-sm-9">{data.me.user.lastLoginAt ? formatDateTime(parseTime(data.me.user.lastLoginAt)) : "—"}</dd>
|
|
<dt className="col-sm-3">Recorded actions</dt>
|
|
<dd className="col-sm-9">
|
|
{data.me.auditEntries} change{data.me.auditEntries === 1 ? "" : "s"} you made are in the audit log under your name
|
|
</dd>
|
|
</dl>
|
|
<div className="text-secondary small mt-2">
|
|
Your name, email and sign-in ID come from Authentik and are refreshed each time you sign in.
|
|
</div>
|
|
</div>
|
|
<div className="card-body border-top">
|
|
<div className="fw-bold mb-2">Your active sign-ins</div>
|
|
<div className="table-responsive">
|
|
<table className="table table-sm table-vcenter mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th>From</th>
|
|
<th>Browser</th>
|
|
<th>Last active</th>
|
|
<th>Ends</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{data.me.sessions.map((s, i) => (
|
|
<tr key={i}>
|
|
<td>
|
|
{s.ip ?? "—"} {s.current && <span className="badge bg-green-lt text-green ms-1">this browser</span>}
|
|
</td>
|
|
<td className="text-secondary text-break">{s.userAgent ?? "—"}</td>
|
|
<td className="text-secondary">{formatAgo(s.lastAccess)}</td>
|
|
<td className="text-secondary">{s.expiresAt ? formatDateTime(new Date(s.expiresAt)) : "—"}</td>
|
|
</tr>
|
|
))}
|
|
{data.me.sessions.length === 0 && (
|
|
<tr>
|
|
<td colSpan={4} className="text-secondary">
|
|
No active sign-ins found.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div className="text-secondary small mt-2">
|
|
“Download my data” gives you these details and every audit-log entry made under your account as a file — only yours.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="card mb-3">
|
|
<div className="card-header">
|
|
<h3 className="card-title">What is stored</h3>
|
|
</div>
|
|
<div className="table-responsive">
|
|
<table className="table table-vcenter card-table">
|
|
<thead>
|
|
<tr>
|
|
<th>What</th>
|
|
<th>Contains</th>
|
|
<th>Kept</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td>Accounts</td>
|
|
<td>Sign-in ID, email and name (from Authentik), role, first and last sign-in.</td>
|
|
<td>Until an administrator removes the account from the database — the app can change roles but has no delete-account function.</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Sign-in sessions</td>
|
|
<td>
|
|
Your sign-in ID, email and name, the ID token Authentik issued (needed to sign you out there), and the IP address and
|
|
browser name from the sign-in.
|
|
</td>
|
|
<td>7 days after sign-in, or until you sign out or an administrator ends the session.</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Audit log</td>
|
|
<td>
|
|
Who changed what: your name (or email) as it was at the time, the action, what it was done to, and details of the change.
|
|
Also each time you sign in or out — with the IP address you came from — and when your account was first created.
|
|
</td>
|
|
<td>
|
|
{retention?.enabled
|
|
? `Entries older than ${retention.retentionDays} days are deleted (checked every ${retention.intervalHours} h).`
|
|
: "Indefinitely — automatic deletion is off (Settings → Logs)."}{" "}
|
|
The name stays in old entries even if the account is removed.
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Diagnostic log</td>
|
|
<td>Each call to a DNS provider or integration: which service, what operation, success or failure, how long it took, any error text. It isn't about people.</td>
|
|
<td>
|
|
{retention?.enabled ? `Deleted after ${retention.retentionDays} days.` : "Indefinitely — automatic deletion is off."}
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Server reports</td>
|
|
<td>
|
|
From each agent: hostname, IP addresses, CPU, memory and disk usage, listening ports with the program using them,
|
|
and cron/systemd tasks <em>including their commands</em> — which can contain sensitive text.
|
|
</td>
|
|
<td>The latest report; removed with the server.</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Secrets tracker</td>
|
|
<td>Names, types, descriptions, expiry dates, notes, and a host to check for certificates. Never the secret values themselves.</td>
|
|
<td>Until deleted.</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Integration, DNS and notification credentials</td>
|
|
<td>
|
|
API tokens and passwords — including the Gotify/ntfy tokens, SMTP password and webhook secret — encrypted (AES-256-GCM) with a key held in
|
|
the server's environment, not in the database. (If that key isn't set, notification credentials are kept unencrypted and the server says so at startup.)
|
|
</td>
|
|
<td>Until deleted. Settings → Backup exports include them, encrypted with a passphrase you choose.</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Inventory</td>
|
|
<td>IP addresses, cached DNS records, domain registrations, port notes, tags and maintenance windows.</td>
|
|
<td>Until deleted.</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card mb-3" id="where-it-goes">
|
|
<div className="card-header">
|
|
<h3 className="card-title">Where data goes</h3>
|
|
</div>
|
|
<div className="card-body">
|
|
<ul className="mb-0">
|
|
<li className="mb-2">
|
|
<strong>Authentik</strong> — you sign in there. The app receives your name, email and sign-in ID; it never sees your password.
|
|
</li>
|
|
<li className="mb-2">
|
|
<strong>Your integrations</strong>
|
|
{outbound && outbound.integrationTypes.length > 0 ? ` (${outbound.integrationTypes.join(", ")})` : ""} — API calls to systems
|
|
you configured, to read status and, when someone with permission asks, to act (start a VM, run a template…).
|
|
</li>
|
|
<li className="mb-2">
|
|
<strong>DNS providers</strong>
|
|
{outbound && outbound.dnsProviderTypes.length > 0 ? ` (${outbound.dnsProviderTypes.join(", ")})` : ""} — zones and records are read
|
|
from, and changed at, the provider.
|
|
</li>
|
|
<li className="mb-2">
|
|
<strong>Notifications</strong> —{" "}
|
|
{enabledChannels.length === 0 ? (
|
|
"no channel is switched on, so no alerts leave the server."
|
|
) : (
|
|
<>
|
|
alerts are sent to{" "}
|
|
{enabledChannels
|
|
.map((c) => `${CHANNEL_LABELS[c]}${outbound!.channels[c].host ? ` (${outbound!.channels[c].host})` : ""}`)
|
|
.join(", ")}
|
|
. They can mention server names, addresses, domain names and secret names. If a channel points at a public service such as
|
|
ntfy.sh, that service sees the text.
|
|
</>
|
|
)}
|
|
</li>
|
|
<li className="mb-2">
|
|
<strong>Domain registries</strong> — the {outbound?.domainsTracked ?? 0} domain name{outbound?.domainsTracked === 1 ? "" : "s"} tracked
|
|
on the Domains page are sent to IANA and each registry's RDAP or WHOIS server to read the expiry date. Nothing but the names.
|
|
</li>
|
|
<li className="mb-2">
|
|
<strong>Certificate checks</strong> — for {outbound?.tlsCertificateChecks ?? 0} secret{outbound?.tlsCertificateChecks === 1 ? "" : "s"} with
|
|
a host to check, the app connects to that host to read its certificate.
|
|
</li>
|
|
<li className="mb-2">
|
|
<strong>Skatteverket</strong> — only when an admin clicks "Import from Skatteverket" under Settings → Names, the app asks
|
|
Skatteverket's open name statistics for the most common given names. The request carries a sex and a birth year, nothing about you
|
|
or your servers.
|
|
</li>
|
|
<li className="mb-2">
|
|
<strong>Servers and agents</strong> — {outbound?.serversWithAgent ?? 0} of {outbound?.servers ?? 0} servers have reported in. Agents push
|
|
their reports to the app; the app doesn't connect out to them, apart from port scans, which run only when an operator starts one
|
|
and only against private addresses.
|
|
</li>
|
|
<li>
|
|
<strong>Nothing else.</strong> No telemetry, no update checks, no crash reports, no third-party analytics.
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="row row-cards mb-3">
|
|
<div className="col-lg-6">
|
|
<div className="card h-100">
|
|
<div className="card-header">
|
|
<h3 className="card-title">In your browser</h3>
|
|
</div>
|
|
<div className="card-body">
|
|
<ul className="mb-0">
|
|
<li className="mb-2">
|
|
One <strong>session cookie</strong>: scripts on the page can't read it, it's restricted from cross-site requests, it's marked secure over
|
|
HTTPS, and it lasts 7 days.
|
|
</li>
|
|
<li className="mb-2">
|
|
One <strong>saved preference</strong> in this browser: whether you chose light or dark mode.
|
|
</li>
|
|
<li>If you install the app to your device, it stores no pages or data for offline use.</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="col-lg-6">
|
|
<div className="card h-100">
|
|
<div className="card-header">
|
|
<h3 className="card-title">Who can see what</h3>
|
|
</div>
|
|
<div className="card-body">
|
|
<ul className="mb-0">
|
|
<li className="mb-2">Everyone signed in: the inventory and status pages, including server, IP, domain and secret-name details.</li>
|
|
<li className="mb-2">Operators and admins: also the audit log — who did what, and who signed in from where.</li>
|
|
<li>Admins only: the user list, everyone's active sign-ins (with IP and browser), the diagnostic log and settings.</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card">
|
|
<div className="card-header">
|
|
<h3 className="card-title">Removing or limiting data</h3>
|
|
</div>
|
|
<div className="card-body">
|
|
<ul className="mb-0">
|
|
<li className="mb-2">
|
|
<strong>Sign out</strong> to end your session now. Administrators can also end anyone's on the <Link to="/sessions">Sessions</Link> page.
|
|
</li>
|
|
<li className="mb-2">
|
|
<strong>Audit and diagnostic logs</strong> can be deleted automatically after a set number of days under Settings → Logs
|
|
{retention ? ` — currently ${retention.enabled ? `on, ${retention.retentionDays} days` : "off"}` : ""}.
|
|
</li>
|
|
<li className="mb-2">
|
|
<strong>Your account</strong> can only be removed by an administrator deleting it from the database; the app doesn't do that
|
|
itself. Old audit entries keep your name until the log is purged.
|
|
</li>
|
|
<li>
|
|
<strong>A copy of your data</strong> is the “Download my data” button above.
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|