Files
echoboard/packages/web/src/pages/PrivacyPage.tsx

244 lines
12 KiB
TypeScript

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
purpose: string
retention: string
deletable: boolean
}
interface Manifest {
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)
useEffect(() => {
api.get<Manifest>('/privacy/data-manifest')
.then(setManifest)
.catch(() => {})
.finally(() => setLoading(false))
}, [])
const body = (color: string) => ({ color, lineHeight: '1.7', fontSize: 'var(--text-sm)' as const })
return (
<div style={{ maxWidth: 680, margin: '0 auto', padding: '32px 24px' }}>
<h1
className="font-bold mb-2"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-3xl)' }}
>
Privacy
</h1>
<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="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>
<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>
<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)' }}
>
{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>
<p style={{ ...body('var(--text-secondary)'), marginTop: 12 }}>
No external domains are whitelisted. The client never contacts a third party.
</p>
</Section>
<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} style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)' }}>- {tp}</li>
))}
</ul>
) : (
<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>
)
}