security hardening, team invites, granular locking, view counts, board subscriptions, scheduled changelog, mentions, recovery codes, accessibility and hover states
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { api } from '../lib/api'
|
||||
import { useDocumentTitle } from '../hooks/useDocumentTitle'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
interface DataField {
|
||||
field: string
|
||||
@@ -9,13 +11,75 @@ interface DataField {
|
||||
}
|
||||
|
||||
interface Manifest {
|
||||
fields: DataField[]
|
||||
anonymousUser: DataField[]
|
||||
passkeyUser: DataField[]
|
||||
cookieInfo: string
|
||||
dataLocation: string
|
||||
thirdParties: string[]
|
||||
neverStored: string[]
|
||||
securityHeaders: Record<string, string>
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="card card-static p-5 mb-5">
|
||||
<h2
|
||||
className="font-semibold mb-3"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-base)' }}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldTable({ fields }: { fields: DataField[] }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{fields.map((f) => (
|
||||
<div key={f.field} className="p-3" style={{ background: 'var(--bg)', borderRadius: 'var(--radius-md)' }}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="font-medium" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>{f.field}</span>
|
||||
{f.deletable && (
|
||||
<span
|
||||
className="px-1.5 py-0.5"
|
||||
style={{
|
||||
background: 'rgba(34, 197, 94, 0.12)',
|
||||
color: 'var(--success)',
|
||||
fontSize: 'var(--text-xs)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}
|
||||
>
|
||||
deletable
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-xs)' }}>{f.purpose}</p>
|
||||
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginTop: 2 }}>Retained: {f.retention}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PrivacySkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} className="card p-5" style={{ opacity: 1 - i * 0.15 }}>
|
||||
<div className="skeleton h-5 mb-3" style={{ width: '40%' }} />
|
||||
<div className="skeleton h-4 mb-2" style={{ width: '90%' }} />
|
||||
<div className="skeleton h-4 mb-2" style={{ width: '80%' }} />
|
||||
<div className="skeleton h-4" style={{ width: '60%' }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PrivacyPage() {
|
||||
useDocumentTitle('Privacy')
|
||||
const [manifest, setManifest] = useState<Manifest | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
@@ -26,137 +90,153 @@ export default function PrivacyPage() {
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const body = (color: string) => ({ color, lineHeight: '1.7', fontSize: 'var(--text-sm)' as const })
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4 py-8">
|
||||
<div style={{ maxWidth: 680, margin: '0 auto', padding: '32px 24px' }}>
|
||||
<h1
|
||||
className="text-2xl font-bold mb-2"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
className="font-bold mb-2"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-3xl)' }}
|
||||
>
|
||||
Privacy
|
||||
</h1>
|
||||
<p className="text-sm mb-8" style={{ color: 'var(--text-secondary)' }}>
|
||||
Here is exactly what data this Echoboard instance collects and why.
|
||||
<p className="mb-8" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>
|
||||
This page is generated from the application's actual configuration. It cannot drift out of sync with reality.
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div
|
||||
className="w-6 h-6 border-2 rounded-full"
|
||||
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--accent)', animation: 'spin 0.6s linear infinite' }}
|
||||
/>
|
||||
</div>
|
||||
) : manifest ? (
|
||||
<>
|
||||
{/* Quick summary */}
|
||||
<div className="card p-5 mb-6">
|
||||
<h2
|
||||
className="text-base font-semibold mb-3"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
>
|
||||
The short version
|
||||
</h2>
|
||||
<ul className="flex flex-col gap-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
<li className="flex items-start gap-2">
|
||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="var(--success)" strokeWidth={2.5} className="shrink-0 mt-0.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
No tracking scripts, no analytics, no third-party cookies
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="var(--success)" strokeWidth={2.5} className="shrink-0 mt-0.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
All data stays on this server - {manifest.dataLocation}
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="var(--success)" strokeWidth={2.5} className="shrink-0 mt-0.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
No external fonts or resources loaded
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="var(--success)" strokeWidth={2.5} className="shrink-0 mt-0.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
You can delete everything at any time
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Cookie info */}
|
||||
<div className="card p-5 mb-6">
|
||||
<h2
|
||||
className="text-base font-semibold mb-2"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
>
|
||||
Cookies
|
||||
</h2>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{manifest.cookieInfo}
|
||||
<div className="progress-bar mb-4" />
|
||||
<PrivacySkeleton />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Section title="How the cookie works">
|
||||
<p style={body('var(--text-secondary)')}>
|
||||
{manifest?.cookieInfo || 'This site uses exactly one cookie. It contains a random identifier used to connect you to your posts. It is a session cookie - it expires when you close your browser. It is not used for tracking, analytics, or advertising. No other cookies are set, ever.'}
|
||||
</p>
|
||||
</div>
|
||||
<p style={{ ...body('var(--text-secondary)'), marginTop: 12 }}>
|
||||
The cookie is <code className="px-1 py-0.5" style={{ background: 'var(--bg)', fontSize: 'var(--text-xs)', borderRadius: 'var(--radius-sm)' }}>httpOnly</code> (JavaScript cannot read it), <code className="px-1 py-0.5" style={{ background: 'var(--bg)', fontSize: 'var(--text-xs)', borderRadius: 'var(--radius-sm)' }}>Secure</code> (only sent over HTTPS), and <code className="px-1 py-0.5" style={{ background: 'var(--bg)', fontSize: 'var(--text-xs)', borderRadius: 'var(--radius-sm)' }}>SameSite=Strict</code> (never sent on cross-origin requests). Dark mode preference is stored in localStorage, not as a cookie.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
{/* Data fields */}
|
||||
<div className="card p-5 mb-6">
|
||||
<h2
|
||||
className="text-base font-semibold mb-4"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
>
|
||||
What we store
|
||||
</h2>
|
||||
<div className="flex flex-col gap-3">
|
||||
{manifest.fields.map((f) => (
|
||||
<div
|
||||
key={f.field}
|
||||
className="p-3 rounded-lg"
|
||||
style={{ background: 'var(--bg)' }}
|
||||
<Section title="How passkeys work">
|
||||
<p style={body('var(--text-secondary)')}>
|
||||
Passkeys use the WebAuthn standard. When you register, your device generates a public/private key pair. The private key stays on your device (or syncs via iCloud Keychain, Google Password Manager, Windows Hello). The server only stores the public key.
|
||||
</p>
|
||||
<p style={{ ...body('var(--text-secondary)'), marginTop: 12 }}>
|
||||
To log in, your device signs a challenge with the private key. The server verifies it against the stored public key. No email, no password, no personal data. Passkeys are phishing-resistant because they are bound to this domain and cannot be used on any other site.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="What we store - anonymous cookie users">
|
||||
{manifest?.anonymousUser ? (
|
||||
<FieldTable fields={manifest.anonymousUser} />
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>
|
||||
<li>- Token hash (SHA-256, irreversible)</li>
|
||||
<li>- Display name (encrypted at rest)</li>
|
||||
<li>- Dark mode preference</li>
|
||||
<li>- Creation timestamp</li>
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="What we store - passkey users">
|
||||
{manifest?.passkeyUser ? (
|
||||
<FieldTable fields={manifest.passkeyUser} />
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>
|
||||
<li>- Username (encrypted + blind indexed)</li>
|
||||
<li>- Display name (encrypted)</li>
|
||||
<li>- Passkey credential ID (encrypted + blind indexed)</li>
|
||||
<li>- Passkey public key (encrypted)</li>
|
||||
<li>- Passkey counter, device type, backup flag</li>
|
||||
<li>- Passkey transports (encrypted)</li>
|
||||
<li>- Dark mode preference</li>
|
||||
<li>- Creation timestamp</li>
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="What we never store">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(manifest?.neverStored || [
|
||||
'Email address', 'IP address', 'Browser fingerprint', 'User-agent string',
|
||||
'Referrer URL', 'Geolocation', 'Device identifiers', 'Behavioral data',
|
||||
'Session replays', 'Third-party tracking identifiers',
|
||||
]).map((item) => (
|
||||
<span
|
||||
key={item}
|
||||
className="px-2 py-1 rounded-full"
|
||||
style={{ background: 'rgba(239, 68, 68, 0.1)', color: 'var(--error)', fontSize: 'var(--text-xs)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--text)' }}>
|
||||
{f.field}
|
||||
</span>
|
||||
{f.deletable && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded" style={{ background: 'rgba(34, 197, 94, 0.15)', color: 'var(--success)' }}>
|
||||
deletable
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs mb-1" style={{ color: 'var(--text-secondary)' }}>
|
||||
{f.purpose}
|
||||
</p>
|
||||
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
Retained: {f.retention}
|
||||
</p>
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p style={{ ...body('var(--text-secondary)'), marginTop: 12 }}>
|
||||
IP addresses are used for rate limiting in memory only - they never touch the database. Request logs record method, path, and status code, but the IP is stripped before the log line is written.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="How encryption works">
|
||||
<p style={body('var(--text-secondary)')}>
|
||||
Every field that could identify a user is encrypted at rest using AES-256-GCM. This includes display names, usernames, passkey credentials, and push subscription endpoints. The anonymous token is stored as a one-way SHA-256 hash (irreversible, even stronger than encryption).
|
||||
</p>
|
||||
<p style={{ ...body('var(--text-secondary)'), marginTop: 12 }}>
|
||||
Posts, comments, vote counts, and board metadata are not encrypted because they are public content visible to every visitor. Encrypting them would add latency without a security benefit.
|
||||
</p>
|
||||
<p style={{ ...body('var(--text-secondary)'), marginTop: 12 }}>
|
||||
If someone gains access to the raw database, they see ciphertext blobs for identity fields and SHA-256 hashes for tokens. They cannot determine who wrote what, reconstruct display names, or extract passkey credentials.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Your data, your control">
|
||||
<p style={body('var(--text-secondary)')}>
|
||||
<strong style={{ color: 'var(--text)' }}>Export:</strong> Download everything this system holds about you as a JSON file from <Link to="/settings" style={{ color: 'var(--accent)' }}>your settings page</Link>. The file includes your posts, comments, votes, reactions, display name, and the data manifest.
|
||||
</p>
|
||||
<p style={{ ...body('var(--text-secondary)'), marginTop: 12 }}>
|
||||
<strong style={{ color: 'var(--text)' }}>Delete:</strong> One click deletes your identity. Your votes and reactions are removed, comments are anonymized to "[deleted]", posts become "[deleted by author]", and your user record is purged from the database. Not soft-deleted - actually removed.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Security headers in effect">
|
||||
<div className="flex flex-col gap-2">
|
||||
{Object.entries(manifest?.securityHeaders || {
|
||||
'Content-Security-Policy': "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-src 'none'; object-src 'none'",
|
||||
'Referrer-Policy': 'no-referrer',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Frame-Options': 'DENY',
|
||||
'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload',
|
||||
'Permissions-Policy': 'camera=(), microphone=(), geolocation=(), interest-cohort=()',
|
||||
'Cross-Origin-Opener-Policy': 'same-origin',
|
||||
}).map(([header, value]) => (
|
||||
<div key={header} className="p-2" style={{ background: 'var(--bg)', borderRadius: 'var(--radius-md)' }}>
|
||||
<span className="font-medium block" style={{ color: 'var(--text)', fontSize: 'var(--text-xs)' }}>{header}</span>
|
||||
<span className="break-all" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ ...body('var(--text-secondary)'), marginTop: 12 }}>
|
||||
No external domains are whitelisted. The client never contacts a third party.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
{/* Third parties */}
|
||||
{manifest.thirdParties.length > 0 && (
|
||||
<div className="card p-5 mb-6">
|
||||
<h2
|
||||
className="text-base font-semibold mb-2"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
>
|
||||
Third parties
|
||||
</h2>
|
||||
<Section title="Third-party services">
|
||||
{manifest?.thirdParties && manifest.thirdParties.length > 0 ? (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{manifest.thirdParties.map((tp) => (
|
||||
<li key={tp} className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
- {tp}
|
||||
</li>
|
||||
<li key={tp} style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>- {tp}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<p style={body('var(--text-secondary)')}>
|
||||
None. No external HTTP requests from the client, ever. No Google Fonts, no CDN scripts, no analytics, no tracking pixels. The only outbound requests the server makes are to external APIs configured by installed plugins (always your own infrastructure) and to push notification endpoints (browser-generated URLs).
|
||||
</p>
|
||||
)}
|
||||
</Section>
|
||||
</>
|
||||
) : (
|
||||
<div className="card p-5">
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
This Echoboard instance is self-hosted and privacy-focused. No tracking, no external services, no data sharing. Your identity is cookie-based unless you register a passkey. You can delete all your data at any time from the settings page.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user