From 90267176a9a4e74d89d3a6d1a5fc4ff0dd33d10f Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Tue, 27 Oct 2026 17:34:56 +0000 Subject: [PATCH] feat: require signed-in users to accept terms and privacy policy Share a `termsToAccept` prop from Inertia and show a non-dismissable TermsDialog in the app layout and onboarding until the current terms version is accepted. A new version brings the dialog back. Accepting goes through `PUT settings/terms`, which validates that the submitted version matches the current one so a stale tab can't accept a version it never showed. DialogContent gains a `showCloseButton` prop, and the knowledge base documents the behavior. --- KNOWLEDGE_BASE.md | 2 +- app/Http/Controllers/UserTermsController.php | 24 ++++ app/Http/Middleware/HandleInertiaRequests.php | 5 + app/Http/Requests/UpdateUserTermsRequest.php | 40 ++++++ resources/js/components/terms-dialog.tsx | 129 ++++++++++++++++++ resources/js/components/ui/dialog.tsx | 7 +- resources/js/layouts/app-layout.tsx | 3 +- resources/js/pages/onboarding/show.tsx | 2 + resources/js/types/auth.ts | 1 + routes/web.php | 4 + 10 files changed, 214 insertions(+), 3 deletions(-) create mode 100644 app/Http/Controllers/UserTermsController.php create mode 100644 app/Http/Requests/UpdateUserTermsRequest.php create mode 100644 resources/js/components/terms-dialog.tsx diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 97b28096d..3840608ea 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -572,7 +572,7 @@ Sources: [status transitions](app/Actions/UpdateCommitStatuses.php), [timeouts]( ### About, changelog, and policies -The public **About** page describes the livestream project and lists up to 1,000 GitHub contributor accounts, excluding bots and known agents, most commits first; the list is cached for an hour and GitHub may take several hours to update its counts; when GitHub cannot be reached the page says so instead of showing the list. The **Changelog**, opened from the navigation or the side column, lists the pull requests merged into `production` on GitHub, a few days at a time. The **Brand** page at `/brand` offers the logo, wordmark, colors, typography, and wallpapers; right-clicking the logo in the navigation also copies it or links there. **Terms** and **Privacy** explain acceptable use and data handling. Public posts may be indexed or copied outside /dev/null; content and search queries can be processed by automated or AI services. Read those policies before posting sensitive information or relying on deletion to remove every external copy. +The public **About** page describes the livestream project and lists up to 1,000 GitHub contributor accounts, excluding bots and known agents, most commits first; the list is cached for an hour and GitHub may take several hours to update its counts; when GitHub cannot be reached the page says so instead of showing the list. The **Changelog**, opened from the navigation or the side column, lists the pull requests merged into `production` on GitHub, a few days at a time. The **Brand** page at `/brand` offers the logo, wordmark, colors, typography, and wallpapers; right-clicking the logo in the navigation also copies it or links there. **Terms** and **Privacy** explain acceptable use and data handling. Signed-in people must accept both before they can keep using /dev/null, on the web and in the apps, and must accept them again whenever a new version takes effect; the web asks in a dialog that only closes by accepting or logging out. Public posts may be indexed or copied outside /dev/null; content and search queries can be processed by automated or AI services. Read those policies before posting sensitive information or relying on deletion to remove every external copy. To count active people, /dev/null remembers only the **last day** (not the time) you were signed in and used the site or the API; background refreshes do not count. That day is never shown to other people, is included in your export, and is deleted with your account. Daily active-people numbers are kept as anonymous counts. Visitor analytics come from **Fathom**, which counts anonymously: the browser sends a `guest-visit` event once per session when you are not signed in, a `signin-click` event when you tap to sign in, and a `signup` event right after an account is created, with nothing that identifies you. diff --git a/app/Http/Controllers/UserTermsController.php b/app/Http/Controllers/UserTermsController.php new file mode 100644 index 000000000..8d9b7406f --- /dev/null +++ b/app/Http/Controllers/UserTermsController.php @@ -0,0 +1,24 @@ +handle($user); + + return back(); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index c571ed250..6368a6c53 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -93,6 +93,11 @@ public function share(Request $request): array 'isAdmin' => $isAdmin, 'automaticTranslation' => $user->automatic_translation ?? true, 'mustReadCommitRules' => $user instanceof User && $user->commit_rules_accepted_at === null, + // The version of the terms they still have to accept, and whether they accepted an earlier one... + 'termsToAccept' => $user instanceof User && ! $user->hasAcceptedCurrentTerms() ? [ + 'version' => config('legal.terms_version'), + 'updated' => $user->terms_accepted_at !== null, + ] : null, 'log' => fn (): ?Log => $request->user()?->log, 'unreadNotificationsCount' => fn (): int => $user instanceof User ? resolve(NotificationGroupsQuery::class)->unreadCount($user, ListNotificationsRequest::rememberedType($request, $user)) : 0, 'unreadMessagesCount' => fn (): int => $user?->remotes_enabled ? resolve(ConversationsQuery::class)->unreadCount($user) : 0, diff --git a/app/Http/Requests/UpdateUserTermsRequest.php b/app/Http/Requests/UpdateUserTermsRequest.php new file mode 100644 index 000000000..10ddfdcbf --- /dev/null +++ b/app/Http/Requests/UpdateUserTermsRequest.php @@ -0,0 +1,40 @@ +> + */ + public function rules(): array + { + return [ + 'version' => ['required', 'string', Rule::in([config('legal.terms_version')])], + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'version.in' => 'The terms changed while this page was open. Reload the page to read the new version.', + ]; + } +} diff --git a/resources/js/components/terms-dialog.tsx b/resources/js/components/terms-dialog.tsx new file mode 100644 index 000000000..acee736e7 --- /dev/null +++ b/resources/js/components/terms-dialog.tsx @@ -0,0 +1,129 @@ +import { Form, Link, router, usePage } from '@inertiajs/react'; +import PrivacyController from '@/actions/App/Http/Controllers/PrivacyController'; +import TermsController from '@/actions/App/Http/Controllers/TermsController'; +import UserTermsController from '@/actions/App/Http/Controllers/UserTermsController'; +import InputError from '@/components/input-error'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogTitle, +} from '@/components/ui/dialog'; +import { WordmarkText } from '@/components/wordmark-text'; +import { clearRecentSearches } from '@/hooks/use-recent-searches'; +import { clearStashes } from '@/hooks/use-stash'; +import { TERMS_EFFECTIVE_DATE } from '@/lib/legal'; +import { logout } from '@/routes'; + +/** + * Until they accept the terms and privacy policy in force, a signed-in person can't use the web: the dialog can't be + * dismissed, and a new version brings it back. The terms and privacy pages open in a new tab, where it isn't shown. + */ +export function TermsDialog() { + const { component, props } = usePage(); + const { user, termsToAccept } = props.auth; + + if ( + user === null || + termsToAccept === null || + component === 'terms/show' || + component === 'privacy/show' + ) { + return null; + } + + const linkClassName = 'text-foreground underline underline-offset-4'; + + return ( + + event.preventDefault()} + onInteractOutside={(event) => event.preventDefault()} + data-test="terms-dialog" + > + + {termsToAccept.updated + ? 'We updated our terms' + : 'Terms and privacy'} + + +
+

+ + {termsToAccept.updated + ? `The terms of service and the privacy policy changed on ${TERMS_EFFECTIVE_DATE}. To keep using /dev/null, read them and accept them.` + : 'To keep using /dev/null, read and accept the terms of service and the privacy policy.'} + +

+

+ Read the{' '} + + terms of service + {' '} + and the{' '} + + privacy policy + + . +

+
+
+ +
+ {({ processing, errors }) => ( + <> + + + + + + + + )} + +
+
+ ); +} diff --git a/resources/js/components/ui/dialog.tsx b/resources/js/components/ui/dialog.tsx index 4b4c3b3b2..c1506603f 100644 --- a/resources/js/components/ui/dialog.tsx +++ b/resources/js/components/ui/dialog.tsx @@ -47,8 +47,11 @@ function DialogOverlay({ function DialogContent({ className, children, + showCloseButton = true, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + showCloseButton?: boolean +}) { return ( @@ -61,10 +64,12 @@ function DialogContent({ {...props} > {children} + {showCloseButton && ( Close + )} ) diff --git a/resources/js/layouts/app-layout.tsx b/resources/js/layouts/app-layout.tsx index 3e87fb4c4..b74a565e2 100644 --- a/resources/js/layouts/app-layout.tsx +++ b/resources/js/layouts/app-layout.tsx @@ -21,6 +21,7 @@ import { KeyboardShortcutsDialog } from '@/components/keyboard-shortcuts-dialog' import { LocalSignInDialog } from '@/components/local-sign-in-dialog'; import { ProductHuntBanner } from '@/components/product-hunt-banner'; import { SearchDialog } from '@/components/search-dialog'; +import { TermsDialog } from '@/components/terms-dialog'; import { useChat, useRemoteAlerts, useUnreadMessages } from '@/hooks/use-chat'; import { useIsComposerInView } from '@/hooks/use-composer-visibility'; import { usePageDeletion } from '@/hooks/use-deleted-resources'; @@ -187,7 +188,7 @@ export default function AppLayout({ children }: AppLayoutProps) { canModerate={auth.isAdmin} /> - {isGuest && } + {isGuest ? : } {isGuest && } diff --git a/resources/js/pages/onboarding/show.tsx b/resources/js/pages/onboarding/show.tsx index 4f0e34a6b..0373648ec 100644 --- a/resources/js/pages/onboarding/show.tsx +++ b/resources/js/pages/onboarding/show.tsx @@ -19,6 +19,7 @@ import { GitHubProfile } from '@/components/github-profile'; import InputError from '@/components/input-error'; import { KeyboardShortcutsDialog } from '@/components/keyboard-shortcuts-dialog'; import { LaneOptions, LanePreview } from '@/components/lane-picker'; +import { TermsDialog } from '@/components/terms-dialog'; import { Button } from '@/components/ui/button'; import { Spinner } from '@/components/ui/spinner'; import { UserAvatar } from '@/components/user-avatar'; @@ -935,6 +936,7 @@ export default function Show({ onOpenChange={setIsShowingShortcuts} canModerate={auth.isAdmin} /> + ); } diff --git a/resources/js/types/auth.ts b/resources/js/types/auth.ts index b8d2bfd0e..0aac69806 100644 --- a/resources/js/types/auth.ts +++ b/resources/js/types/auth.ts @@ -47,6 +47,7 @@ export type Auth = { isAdmin: boolean; automaticTranslation: boolean; mustReadCommitRules: boolean; + termsToAccept: { version: string; updated: boolean } | null; log: Log | null; unreadNotificationsCount: number; unreadMessagesCount: number; diff --git a/routes/web.php b/routes/web.php index 967683c04..f90ded61b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -124,6 +124,7 @@ use App\Http\Controllers\UserRemotesController; use App\Http\Controllers\UserSearchController; use App\Http\Controllers\UserSitemapController; +use App\Http\Controllers\UserTermsController; use App\Http\Controllers\UserTimeoutController; use App\Http\Controllers\UserTimezoneController; use App\Http\Controllers\UserTranslationLanguageController; @@ -507,6 +508,9 @@ Route::put('settings/remotes', [UserRemotesController::class, 'update'])->name('user-remotes.update'); + // User Terms, accepted from the dialog every page shows until the current version is... + Route::put('settings/terms', [UserTermsController::class, 'update'])->name('user-terms.update'); + // User Live... Route::put('settings/live', [UserLiveController::class, 'update']) ->middleware(PreventPostingInTimeout::class)