feat(legal): public privacy policy, terms, and homepage for OAuth verification
Adds login-free /privacy and /terms pages (rendered outside the authenticated tree via a pathname switch in main.tsx) carrying the Google API Services Limited Use disclosure, YouTube ToS / Google Privacy links, and contact + data-deletion info. The sign-in screen now describes the app and links to both, satisfying Google's homepage + privacy-policy requirements for the OAuth consent screen.
This commit is contained in:
parent
e0980487af
commit
6a71e3d943
5 changed files with 245 additions and 6 deletions
|
|
@ -35,9 +35,9 @@ export default function Login() {
|
||||||
Sub<span className="text-accent">feed</span>
|
Sub<span className="text-accent">feed</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted my-5 leading-relaxed">
|
<p className="text-muted my-5 leading-relaxed">
|
||||||
Your subscriptions, filtered your way.
|
A self-hosted, filterable feed of your YouTube subscriptions — sort, tag, and
|
||||||
<br />
|
tune out the noise. Sign in with Google to get started; YouTube access is an
|
||||||
Sign in with your Google account.
|
optional, separate step you control.
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href="/auth/login"
|
href="/auth/login"
|
||||||
|
|
@ -87,6 +87,10 @@ export default function Login() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<footer className="mt-5 flex gap-4 text-xs text-muted">
|
||||||
|
<a href="/privacy" className="hover:text-fg transition">Privacy Policy</a>
|
||||||
|
<a href="/terms" className="hover:text-fg transition">Terms of Service</a>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
47
frontend/src/components/legal/LegalLayout.tsx
Normal file
47
frontend/src/components/legal/LegalLayout.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
// Shared shell for the public, login-free legal pages (/privacy, /terms). These must be
|
||||||
|
// reachable unauthenticated — Google's OAuth consent screen links to them and reviewers
|
||||||
|
// fetch them directly — so they render outside the authenticated App tree (see main.tsx).
|
||||||
|
|
||||||
|
export const CONTACT_EMAIL = "b1fr0stmailops@gmail.com";
|
||||||
|
export const LAST_UPDATED = "June 14, 2026";
|
||||||
|
|
||||||
|
export function LegalLink({ href, children }: { href: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<a href={href} target="_blank" rel="noreferrer" className="text-accent hover:underline">
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LegalLayout({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-bg text-fg">
|
||||||
|
<div className="mx-auto w-[min(92vw,720px)] py-12">
|
||||||
|
<a href="/" className="inline-block text-2xl font-bold tracking-tight mb-8">
|
||||||
|
Sub<span className="text-accent">feed</span>
|
||||||
|
</a>
|
||||||
|
<div className="glass rounded-2xl p-8">
|
||||||
|
<h1 className="text-2xl font-semibold">{title}</h1>
|
||||||
|
<p className="text-xs text-muted mt-1 mb-6">Last updated: {LAST_UPDATED}</p>
|
||||||
|
<div className="space-y-5 text-sm leading-relaxed text-fg/90">{children}</div>
|
||||||
|
</div>
|
||||||
|
<footer className="mt-6 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted">
|
||||||
|
<a href="/" className="hover:text-fg transition">Home</a>
|
||||||
|
<a href="/privacy" className="hover:text-fg transition">Privacy Policy</a>
|
||||||
|
<a href="/terms" className="hover:text-fg transition">Terms of Service</a>
|
||||||
|
<a href={`mailto:${CONTACT_EMAIL}`} className="hover:text-fg transition">Contact</a>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function H2({ children }: { children: React.ReactNode }) {
|
||||||
|
return <h2 className="text-base font-semibold text-fg pt-2">{children}</h2>;
|
||||||
|
}
|
||||||
111
frontend/src/components/legal/PrivacyPolicy.tsx
Normal file
111
frontend/src/components/legal/PrivacyPolicy.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import LegalLayout, { CONTACT_EMAIL, H2, LegalLink } from "./LegalLayout";
|
||||||
|
|
||||||
|
export default function PrivacyPolicy() {
|
||||||
|
return (
|
||||||
|
<LegalLayout title="Privacy Policy">
|
||||||
|
<p>
|
||||||
|
Subfeed is a personal, non-commercial project operated by an individual in Hungary
|
||||||
|
("we", "us"). It lets you view and organize your own YouTube subscriptions as a
|
||||||
|
filterable feed. This policy explains what data Subfeed accesses, how it is used, and
|
||||||
|
the choices you have. By using Subfeed you agree to this policy.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<H2>What we access</H2>
|
||||||
|
<p>Subfeed only accesses data you explicitly authorize, in two separate steps:</p>
|
||||||
|
<ul className="list-disc pl-5 space-y-1">
|
||||||
|
<li>
|
||||||
|
<span className="text-fg font-medium">Sign-in (always):</span> your basic Google
|
||||||
|
profile — name, email address, profile picture, and Google account identifier — used
|
||||||
|
solely to create and identify your account.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span className="text-fg font-medium">YouTube read (optional, opt-in):</span> with the{" "}
|
||||||
|
<code className="text-xs">youtube.readonly</code> scope, your list of YouTube
|
||||||
|
subscriptions and the public metadata of those channels and their videos (titles,
|
||||||
|
thumbnails, durations, view counts, publish dates), used to build your feed.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span className="text-fg font-medium">YouTube manage (optional, opt-in):</span> with
|
||||||
|
the <code className="text-xs">youtube</code> scope, the ability to unsubscribe from a
|
||||||
|
channel on YouTube — performed only when you click to do so.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
You can use Subfeed with sign-in alone, and grant or decline each YouTube permission
|
||||||
|
independently.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<H2>How we use it</H2>
|
||||||
|
<p>
|
||||||
|
Your data is used exclusively to provide Subfeed's features to you: building and
|
||||||
|
displaying your personalized subscription feed, and carrying out actions (such as
|
||||||
|
unsubscribing) that you initiate. We do not use it for advertising, profiling, or any
|
||||||
|
purpose unrelated to the app.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<H2>Limited Use disclosure</H2>
|
||||||
|
<p>
|
||||||
|
Subfeed's use and transfer of information received from Google APIs to any other app
|
||||||
|
will adhere to the{" "}
|
||||||
|
<LegalLink href="https://developers.google.com/terms/api-services-user-data-policy">
|
||||||
|
Google API Services User Data Policy
|
||||||
|
</LegalLink>
|
||||||
|
, including the Limited Use requirements. We do not sell your data, do not transfer it
|
||||||
|
to third parties (except as needed to operate the app, e.g. the hosting infrastructure
|
||||||
|
we control), and do not use it for advertising.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<H2>Storage & security</H2>
|
||||||
|
<p>
|
||||||
|
Data is stored in a private PostgreSQL database on a server we control, accessible only
|
||||||
|
to the operator. Google OAuth refresh tokens are encrypted at rest. Access is restricted
|
||||||
|
to the emails explicitly invited to the app. No system is perfectly secure, but we take
|
||||||
|
reasonable measures to protect your data.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<H2>Retention & deletion</H2>
|
||||||
|
<p>
|
||||||
|
We keep your data while your account is active. You can revoke Subfeed's access to your
|
||||||
|
Google data at any time at{" "}
|
||||||
|
<LegalLink href="https://myaccount.google.com/permissions">
|
||||||
|
myaccount.google.com/permissions
|
||||||
|
</LegalLink>
|
||||||
|
; once revoked, the stored tokens can no longer be used. To have your account and
|
||||||
|
associated data deleted, email{" "}
|
||||||
|
<a href={`mailto:${CONTACT_EMAIL}`} className="text-accent hover:underline">
|
||||||
|
{CONTACT_EMAIL}
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<H2>Cookies</H2>
|
||||||
|
<p>
|
||||||
|
Subfeed sets a single, signed session cookie to keep you logged in. It is not used for
|
||||||
|
tracking or advertising, and there are no third-party analytics cookies.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<H2>YouTube & Google</H2>
|
||||||
|
<p>
|
||||||
|
Subfeed uses YouTube API Services. By using Subfeed you also agree to the{" "}
|
||||||
|
<LegalLink href="https://www.youtube.com/t/terms">YouTube Terms of Service</LegalLink>,
|
||||||
|
and your use of Google data is subject to the{" "}
|
||||||
|
<LegalLink href="https://policies.google.com/privacy">Google Privacy Policy</LegalLink>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<H2>Changes</H2>
|
||||||
|
<p>
|
||||||
|
We may update this policy; material changes will be reflected by the "Last updated" date
|
||||||
|
above. Continued use after a change constitutes acceptance.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<H2>Contact</H2>
|
||||||
|
<p>
|
||||||
|
Questions about this policy or your data:{" "}
|
||||||
|
<a href={`mailto:${CONTACT_EMAIL}`} className="text-accent hover:underline">
|
||||||
|
{CONTACT_EMAIL}
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</LegalLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
frontend/src/components/legal/Terms.tsx
Normal file
67
frontend/src/components/legal/Terms.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import LegalLayout, { CONTACT_EMAIL, H2, LegalLink } from "./LegalLayout";
|
||||||
|
|
||||||
|
export default function Terms() {
|
||||||
|
return (
|
||||||
|
<LegalLayout title="Terms of Service">
|
||||||
|
<p>
|
||||||
|
Subfeed is a personal, non-commercial project operated by an individual in Hungary. It
|
||||||
|
provides a filterable view of your own YouTube subscriptions. By accessing or using
|
||||||
|
Subfeed, you agree to these Terms. If you do not agree, do not use the service.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<H2>The service</H2>
|
||||||
|
<p>
|
||||||
|
Subfeed is offered on an invite-only basis, free of charge, and "as is", without
|
||||||
|
warranties of any kind. It is a hobby project: features may change, and the service may
|
||||||
|
be slowed, interrupted, or discontinued at any time without notice.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<H2>Your account & acceptable use</H2>
|
||||||
|
<p>
|
||||||
|
You sign in with your Google account and may only access Subfeed with an email that has
|
||||||
|
been invited. You agree to use the service lawfully, not to abuse, disrupt, probe, or
|
||||||
|
overload it, and not to attempt to access other users' data. We may suspend or revoke
|
||||||
|
access at our discretion, for example in case of misuse.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<H2>Third-party services</H2>
|
||||||
|
<p>
|
||||||
|
Subfeed uses YouTube API Services. By using Subfeed you agree to be bound by the{" "}
|
||||||
|
<LegalLink href="https://www.youtube.com/t/terms">YouTube Terms of Service</LegalLink>.
|
||||||
|
Your data is handled as described in our{" "}
|
||||||
|
<a href="/privacy" className="text-accent hover:underline">Privacy Policy</a> and, for
|
||||||
|
Google data, the{" "}
|
||||||
|
<LegalLink href="https://policies.google.com/privacy">Google Privacy Policy</LegalLink>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<H2>No warranty & limitation of liability</H2>
|
||||||
|
<p>
|
||||||
|
To the maximum extent permitted by law, the service is provided without warranty, and
|
||||||
|
the operator is not liable for any indirect, incidental, or consequential damages, or
|
||||||
|
for any loss of data, arising from your use of Subfeed. Your sole remedy if you are
|
||||||
|
dissatisfied is to stop using the service and revoke its access to your account.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<H2>Governing law</H2>
|
||||||
|
<p>
|
||||||
|
These Terms are governed by the laws of Hungary and applicable European Union law,
|
||||||
|
without regard to conflict-of-law rules.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<H2>Changes</H2>
|
||||||
|
<p>
|
||||||
|
We may update these Terms; the "Last updated" date above reflects the latest version.
|
||||||
|
Continued use after a change constitutes acceptance.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<H2>Contact</H2>
|
||||||
|
<p>
|
||||||
|
Questions:{" "}
|
||||||
|
<a href={`mailto:${CONTACT_EMAIL}`} className="text-accent hover:underline">
|
||||||
|
{CONTACT_EMAIL}
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</LegalLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,18 +3,28 @@ import { createRoot } from "react-dom/client";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import ErrorBoundary from "./components/ErrorBoundary";
|
import ErrorBoundary from "./components/ErrorBoundary";
|
||||||
|
import PrivacyPolicy from "./components/legal/PrivacyPolicy";
|
||||||
|
import Terms from "./components/legal/Terms";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: { queries: { retry: false, staleTime: 30_000, refetchOnWindowFocus: false } },
|
defaultOptions: { queries: { retry: false, staleTime: 30_000, refetchOnWindowFocus: false } },
|
||||||
});
|
});
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
// Public, login-free legal pages. They live outside the authenticated App tree (no /api/me)
|
||||||
<React.StrictMode>
|
// so Google's consent screen and reviewers can fetch them directly. Reached by full-page
|
||||||
|
// navigation (plain <a href>), so a simple pathname switch is enough — no router needed.
|
||||||
|
const path = window.location.pathname;
|
||||||
|
const root =
|
||||||
|
path === "/privacy" ? <PrivacyPolicy /> :
|
||||||
|
path === "/terms" ? <Terms /> : (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<App />
|
<App />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</React.StrictMode>
|
);
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>{root}</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue