feat: complete M0 — legal pages, consent, tip_views metrics, account deletion UI

- /legal/terms and /legal/privacy pages (linked from sign-in)
- Consent (consentGiven=true) recorded on first Google sign-in
- tip_views table: one row per tip served — enables activation + reaction rate queries
- tip_views purged on account deletion
- Delete account button on /connect (confirm → revoke tokens → purge data → sign out)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-15 09:09:08 +00:00
parent 888f8b9a99
commit f6c890213b
18 changed files with 438 additions and 9 deletions

View File

@@ -2,7 +2,7 @@
import { useEffect, useState, useCallback } from 'react';
import { useSearchParams } from 'next/navigation';
import { getIntegrations, disconnectIntegration } from '@/lib/api';
import { getIntegrations, disconnectIntegration, deleteAccount, logout } from '@/lib/api';
import type { Integration } from '@oo/shared-types';
import { Suspense } from 'react';
@@ -11,6 +11,7 @@ function ConnectPageInner() {
const [integrations, setIntegrations] = useState<Integration[]>([]);
const [loading, setLoading] = useState(true);
const [disconnecting, setDisconnecting] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const load = useCallback(async () => {
const { integrations: list } = await getIntegrations();
@@ -26,6 +27,14 @@ function ConnectPageInner() {
const isConnected = (provider: string) =>
integrations.some((i) => i.provider === provider && i.status === 'connected');
const handleDeleteAccount = async () => {
if (!confirm('Delete your account? This cannot be undone.')) return;
setDeleting(true);
await deleteAccount();
await logout();
window.location.href = '/sign-in';
};
const handleDisconnect = async (provider: string) => {
setDisconnecting(provider);
await disconnectIntegration(provider);
@@ -140,6 +149,23 @@ function ConnectPageInner() {
</a>
</div>
)}
<div style={{ marginTop: '4rem', borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: '2rem' }}>
<button
onClick={handleDeleteAccount}
disabled={deleting}
style={{
background: 'transparent',
border: 'none',
color: 'rgba(255,255,255,0.2)',
fontSize: '0.8rem',
cursor: 'pointer',
padding: 0,
}}
>
{deleting ? 'Deleting…' : 'Delete account'}
</button>
</div>
</main>
);
}

View File

@@ -0,0 +1,41 @@
export default function Privacy() {
return (
<main style={{ maxWidth: '640px', margin: '0 auto', padding: '4rem 2rem', lineHeight: 1.7 }}>
<h1 style={{ fontSize: '1.5rem', fontWeight: 300, marginBottom: '2rem', letterSpacing: '-0.02em' }}>Privacy Policy</h1>
<p style={{ color: 'rgba(255,255,255,0.5)', fontSize: '0.8rem', marginBottom: '2.5rem' }}>Effective: 1 April 2026</p>
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 500, marginBottom: '0.75rem' }}>What we collect</h2>
<ul style={{ color: 'rgba(255,255,255,0.7)', fontSize: '0.9rem', paddingLeft: '1.25rem' }}>
<li style={{ marginBottom: '0.5rem' }}>Your Google account email, name, and profile picture to identify you.</li>
<li style={{ marginBottom: '0.5rem' }}>OAuth tokens for integrations you explicitly connect.</li>
<li style={{ marginBottom: '0.5rem' }}>Your reactions to tips (done / snooze / dismiss) to improve recommendations.</li>
</ul>
</section>
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 500, marginBottom: '0.75rem' }}>What we don't collect</h2>
<p style={{ color: 'rgba(255,255,255,0.7)', fontSize: '0.9rem' }}>
We do not copy your tasks, calendar events, or any third-party app content into our database. Data is fetched on demand and held in memory for at most 30 seconds.
</p>
</section>
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 500, marginBottom: '0.75rem' }}>How we use it</h2>
<p style={{ color: 'rgba(255,255,255,0.7)', fontSize: '0.9rem' }}>
Solely to operate the recommendation engine. We do not sell data, share it with third parties, or use it for advertising.
</p>
</section>
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 500, marginBottom: '0.75rem' }}>Your rights</h2>
<p style={{ color: 'rgba(255,255,255,0.7)', fontSize: '0.9rem' }}>
You can disconnect any integration at any time from the Connect page. You can delete your account, which permanently purges all stored data. Contact the owner for data export requests.
</p>
</section>
<a href="/sign-in" style={{ color: 'rgba(255,255,255,0.35)', fontSize: '0.8rem' }}> Back</a>
</main>
);
}

View File

@@ -0,0 +1,39 @@
export default function Terms() {
return (
<main style={{ maxWidth: '640px', margin: '0 auto', padding: '4rem 2rem', lineHeight: 1.7 }}>
<h1 style={{ fontSize: '1.5rem', fontWeight: 300, marginBottom: '2rem', letterSpacing: '-0.02em' }}>Terms of Service</h1>
<p style={{ color: 'rgba(255,255,255,0.5)', fontSize: '0.8rem', marginBottom: '2.5rem' }}>Effective: 1 April 2026</p>
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 500, marginBottom: '0.75rem' }}>1. The service</h2>
<p style={{ color: 'rgba(255,255,255,0.7)', fontSize: '0.9rem' }}>
oO is a personal recommendation system. It reads signals from apps you connect and surfaces one tip at a time. The service is provided as-is during the prototype phase.
</p>
</section>
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 500, marginBottom: '0.75rem' }}>2. Your data</h2>
<p style={{ color: 'rgba(255,255,255,0.7)', fontSize: '0.9rem' }}>
We store OAuth tokens for integrations you connect. We fetch your tasks on demand we do not copy or cache raw data beyond a 30-second in-memory buffer. You can revoke access or delete your account at any time.
</p>
</section>
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 500, marginBottom: '0.75rem' }}>3. Account deletion</h2>
<p style={{ color: 'rgba(255,255,255,0.7)', fontSize: '0.9rem' }}>
Deleting your account revokes all integration tokens, purges your feedback history, and anonymises your identity record. No data is retained in identifiable form.
</p>
</section>
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 500, marginBottom: '0.75rem' }}>4. Limitations</h2>
<p style={{ color: 'rgba(255,255,255,0.7)', fontSize: '0.9rem' }}>
This is a prototype. We make no uptime guarantees. The service may change or be discontinued with reasonable notice.
</p>
</section>
<a href="/sign-in" style={{ color: 'rgba(255,255,255,0.35)', fontSize: '0.8rem' }}> Back</a>
</main>
);
}

View File

@@ -88,6 +88,7 @@ export default function TipPage() {
return (
<>
<style>{`
@keyframes breathe {
0%, 100% { opacity: 0.3; }
@@ -100,12 +101,11 @@ export default function TipPage() {
onPointerUp={onPointerUp}
onPointerLeave={onPointerUp}
style={{
minHeight: '100vh',
height: '100dvh',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '3rem 2rem',
userSelect: 'none',
WebkitUserSelect: 'none',
cursor: state === 'tip' ? 'default' : 'auto',
@@ -127,7 +127,7 @@ export default function TipPage() {
{/* Loading label */}
{(state === 'loading' || state === 'done') && (
<p style={{
marginTop: '1.25rem',
margin: 0,
color: 'rgba(255,255,255,0.55)',
fontSize: '0.7rem',
letterSpacing: '0.18em',
@@ -140,7 +140,7 @@ export default function TipPage() {
{/* Tip */}
{(state === 'tip' || state === 'actions') && tip && (
<Fade visible={visible && state !== 'actions'} style={{ textAlign: 'center', maxWidth: '420px' }}>
<Fade visible={visible && state !== 'actions'} style={{ textAlign: 'center', maxWidth: '420px', padding: '0 2rem' }}>
<p style={{
fontSize: 'clamp(1.25rem, 4vw, 1.75rem)',
fontWeight: 300,

File diff suppressed because one or more lines are too long