From d71fa962d70f58c8cb95de63bdb5bf3da2a6bce6 Mon Sep 17 00:00:00 2001 From: Shane Rosenthal Date: Fri, 23 Oct 2026 12:55:28 -0400 Subject: [PATCH 1/4] Ask people to agree to the terms and privacy policy before they sign in Neither Sign in with GitHub nor Sign in with Apple works until the box is ticked, and links open the terms and privacy policy in the app's browser. The phone tells devnull which version it showed as it signs in (terms_version), which devnull records. Co-Authored-By: Claude Sonnet 5.5 --- app/Devnull/Client.php | 17 +++++-- app/NativeComponents/Onboarding.php | 29 +++++++++-- resources/views/native/onboarding.blade.php | 26 +++++++++- tests/Feature/Screens/OnboardingAppleTest.php | 44 ++++++++--------- tests/Feature/Screens/OnboardingTest.php | 49 ++++++++++++++----- tests/Pest.php | 15 ++++++ 6 files changed, 138 insertions(+), 42 deletions(-) diff --git a/app/Devnull/Client.php b/app/Devnull/Client.php index c4da605a..17aa2f2b 100644 --- a/app/Devnull/Client.php +++ b/app/Devnull/Client.php @@ -38,6 +38,12 @@ */ public const string SignInPath = 'devices'; + /** + * The version of the terms and privacy policy the sign-in screen asks people to accept, which devnull records when + * the phone signs in. It's the date they took effect, and has to be the one devnull has in force. + */ + public const string TermsVersion = '2026-10-23'; + /** * Where the signed-in account's Apple ID is linked and unlinked. */ @@ -131,9 +137,13 @@ public static function pastelPath(string $id): string * phone knows, for the user it approved this phone for. It's signed like * every request, so the code only works for the key it was made for. */ - public function redeemSignIn(string $code, #[SensitiveParameter] string $verifier): User + public function redeemSignIn(string $code, #[SensitiveParameter] string $verifier, ?string $termsVersion = null): User { - return User::fromArray($this->data($this->send('POST', self::SignInPath, payload: ['code' => $code, 'code_verifier' => $verifier]))); + return User::fromArray($this->data($this->send('POST', self::SignInPath, payload: [ + 'code' => $code, + 'code_verifier' => $verifier, + ...($termsVersion === null ? [] : ['terms_version' => $termsVersion]), + ]))); } /** @@ -141,12 +151,13 @@ public function redeemSignIn(string $code, #[SensitiveParameter] string $verifie * an account, which approves this phone's key. Apple's identity token * carries sha256 of the nonce, and devnull checks it against the raw one. */ - public function signInWithApple(#[SensitiveParameter] string $identityToken, #[SensitiveParameter] string $nonce, string $deviceName): User + public function signInWithApple(#[SensitiveParameter] string $identityToken, #[SensitiveParameter] string $nonce, string $deviceName, ?string $termsVersion = null): User { return User::fromArray($this->data($this->send('POST', self::SignInPath, payload: [ 'apple_identity_token' => $identityToken, 'nonce' => $nonce, 'device_name' => $deviceName, + ...($termsVersion === null ? [] : ['terms_version' => $termsVersion]), ]))); } diff --git a/app/NativeComponents/Onboarding.php b/app/NativeComponents/Onboarding.php index 0b585aae..59e57d1b 100644 --- a/app/NativeComponents/Onboarding.php +++ b/app/NativeComponents/Onboarding.php @@ -53,6 +53,12 @@ class Onboarding extends NativeComponent */ public ?string $appleError = null; + /** + * Whether the box for the terms and privacy policy is ticked. Neither way in works until it is, and devnull is told + * which version was accepted as the phone signs in. + */ + public bool $acceptedTerms = false; + private string $deviceName = ''; /** @@ -93,13 +99,28 @@ public function onResume(): void $this->finishSignIn(); } + public function toggleTerms(): void + { + $this->acceptedTerms = ! $this->acceptedTerms; + } + + public function openTerms(): void + { + Browser::inApp(rtrim((string) config('devnull.url'), '/').'/terms'); + } + + public function openPrivacy(): void + { + Browser::inApp(rtrim((string) config('devnull.url'), '/').'/privacy'); + } + /** * Opens dev-null.sh with a fresh verifier and state. Dismissing the sheet * sends nothing back, so the screen simply stays as it is. */ public function signIn(): void { - if ($this->fingerprint === '') { + if ($this->fingerprint === '' || ! $this->acceptedTerms) { return; } @@ -117,7 +138,7 @@ public function signIn(): void */ public function signInWithApple(): void { - if ($this->fingerprint === '' || ! AppleSignIn::isAvailable()) { + if ($this->fingerprint === '' || ! $this->acceptedTerms || ! AppleSignIn::isAvailable()) { return; } @@ -149,7 +170,7 @@ public function render(): View protected function useAppleToken(#[SensitiveParameter] string $identityToken, #[SensitiveParameter] string $nonce): void { try { - $user = app(Client::class)->signInWithApple($identityToken, $nonce, $this->deviceName()); + $user = app(Client::class)->signInWithApple($identityToken, $nonce, $this->deviceName(), $this->acceptedTerms ? Client::TermsVersion : null); } catch (ApiException $exception) { $this->appleError = $exception->firstError(); @@ -220,7 +241,7 @@ private function finishSignIn(): void } try { - $user = app(Client::class)->redeemSignIn($code, $attempt->verifier()); + $user = app(Client::class)->redeemSignIn($code, $attempt->verifier(), $this->acceptedTerms ? Client::TermsVersion : null); } catch (ApiException $exception) { $this->error = $exception->firstError(); diff --git a/resources/views/native/onboarding.blade.php b/resources/views/native/onboarding.blade.php index c20b669e..c29a5fc4 100644 --- a/resources/views/native/onboarding.blade.php +++ b/resources/views/native/onboarding.blade.php @@ -17,12 +17,34 @@ {{ $appleError }} @endif + {{-- What Apple asks of an app with user-generated content: agree to the terms before you're in. Neither button works until it's ticked. --}} + + + + @if ($acceptedTerms) + + @endif + + + + I agree to the terms of service and privacy policy. There's no tolerance for objectionable content or abusive users. + + + Terms of service + + + Privacy policy + + + + + {{-- Black with white text (white with black in dark mode, the theme's accent), large, with GitHub's mark. --}} - {{ $error && $fingerprint !== '' ? 'Try again' : 'Sign in with GitHub' }} + {{ $error && $fingerprint !== '' ? 'Try again' : 'Sign in with GitHub' }} {{-- Apple's button the same: the accent with its logo, as big as GitHub's, as Apple's guidelines ask. --}} @if ($offersApple) - Sign in with Apple + Sign in with Apple @endif diff --git a/tests/Feature/Screens/OnboardingAppleTest.php b/tests/Feature/Screens/OnboardingAppleTest.php index 04bfbf85..1f644bca 100644 --- a/tests/Feature/Screens/OnboardingAppleTest.php +++ b/tests/Feature/Screens/OnboardingAppleTest.php @@ -30,8 +30,8 @@ $buttons = findNodes($screen->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'button'); expect(array_column($buttons, 'ref'))->toBe(['sign-in', 'sign-in-apple']) - ->and(childRefs($screen, 'actions'))->toBe(['sign-in', 'sign-in-apple']) - ->and(shownText($screen))->toBe([]) + ->and(childRefs($screen, 'actions'))->toBe(['terms', 'sign-in', 'sign-in-apple']) + ->and(shownText($screen))->toBe(termsText()) ->and($buttons[1]['props'])->toMatchArray(['label' => 'Sign in with Apple', 'variant' => 'accent', 'size' => 'lg', 'leading_icon' => 'apple.logo']) ->and($buttons[0]['props'])->toMatchArray(['variant' => 'accent', 'size' => 'lg', 'leading_icon' => 'custom:github']) ->and($buttons[1]['layout'])->toEqual($buttons[0]['layout'])->toEqual(['width' => 'fill']) @@ -49,7 +49,7 @@ expect(AppleSignIn::isAvailable())->toBeFalse() ->and((new NativeServiceProvider(app()))->plugins())->not->toContain(NativephpSocialAuthServiceProvider::class) - ->and(childRefs($screen, 'actions'))->toBe(['sign-in']); + ->and(childRefs($screen, 'actions'))->toBe(['terms', 'sign-in']); fakeApi(['GET /api/v1/me' => [['data' => userWithCountsJson(), 'unread_notifications_count' => 0, 'apple_linked' => false]]]); signIn(); @@ -64,14 +64,14 @@ $bridge = appleBridge(); $screen = Native::visit('/onboarding', platform: 'ios'); - $screen->tap('sign-in-apple')->assertNoNavigation(); + $screen->set('acceptedTerms', true)->tap('sign-in-apple')->assertNoNavigation(); $first = appleSheetParams($bridge); expect($first['nonce'])->toBe(heldAppleNonce())->toMatch('/^[0-9a-f]{64}$/') ->and($first['scopes'])->toBe([]) ->and($first['state'])->toBeNull(); - $screen->tap('sign-in-apple'); + $screen->set('acceptedTerms', true)->tap('sign-in-apple'); $second = appleSheetParams($bridge); expect($second['nonce'])->toBe(heldAppleNonce())->not->toBe($first['nonce']); @@ -86,7 +86,7 @@ 'POST /api/v1/devices' => [['data' => userWithCountsJson()]], 'GET /api/v1/users/local_dev' => [['data' => userWithCountsJson()]], ]); - $screen = Native::visit('/onboarding', platform: 'ios')->tap('sign-in-apple'); + $screen = Native::visit('/onboarding', platform: 'ios')->set('acceptedTerms', true)->tap('sign-in-apple'); $nonce = heldAppleNonce(); appleAnswers($screen, $nonce)->assertReplacedWith('/log'); @@ -99,7 +99,7 @@ Http::assertSentCount(2); Http::assertSent(fn (Request $request): bool => $request->method() === 'POST' && $request->url() === 'http://devnull.test/api/v1/'.Client::SignInPath - && $request->body() === json_encode(['apple_identity_token' => AppleIdentityToken, 'nonce' => $nonce, 'device_name' => 'devnull on iPhone']) + && $request->body() === json_encode(['apple_identity_token' => AppleIdentityToken, 'nonce' => $nonce, 'device_name' => 'devnull on iPhone', 'terms_version' => Client::TermsVersion]) && $request->hasHeader('X-SSH-Signature')); // Apple's token carries sha256(nonce) as hex, which is what devnull checks against the raw nonce. @@ -110,7 +110,7 @@ appleBridge(); $message = ErrorCode::AppleNotLinked->message(); fakeApi(['POST /api/v1/devices' => [['message' => 'No /dev/null account is linked to this Apple ID.', 'code' => 'apple_not_linked'], 404]]); - $screen = Native::visit('/onboarding', platform: 'ios')->tap('sign-in-apple'); + $screen = Native::visit('/onboarding', platform: 'ios')->set('acceptedTerms', true)->tap('sign-in-apple'); $screen = appleAnswers($screen, heldAppleNonce()) ->assertNoNavigation() @@ -119,8 +119,8 @@ ->assertSet('error', null) ->assertElement('button', fn (array $node): bool => ($node['ref'] ?? null) === 'sign-in' && $node['props']['label'] === 'Sign in with GitHub'); - expect(childRefs($screen, 'actions'))->toBe(['apple-error', 'sign-in', 'sign-in-apple']) - ->and(shownText($screen))->toBe([$message]); + expect(childRefs($screen, 'actions'))->toBe(['apple-error', 'terms', 'sign-in', 'sign-in-apple']) + ->and(shownText($screen))->toBe([$message, ...termsText()]); expect(app(Account::class)->user())->toBeNull(); }); @@ -128,7 +128,7 @@ it("says when devnull can't verify Apple's token, without turning GitHub into Try again", function () { appleBridge(); fakeApi(['POST /api/v1/devices' => [['message' => 'That Apple sign-in could not be verified.', 'code' => 'invalid_apple_token'], 422]]); - $screen = Native::visit('/onboarding', platform: 'ios')->tap('sign-in-apple'); + $screen = Native::visit('/onboarding', platform: 'ios')->set('acceptedTerms', true)->tap('sign-in-apple'); appleAnswers($screen, heldAppleNonce()) ->assertNoNavigation() @@ -142,7 +142,7 @@ it("returns quietly when Apple's sheet is closed", function () { appleBridge(); fakeApi([]); - $screen = Native::visit('/onboarding', platform: 'ios')->tap('sign-in-apple'); + $screen = Native::visit('/onboarding', platform: 'ios')->set('acceptedTerms', true)->tap('sign-in-apple'); $screen->emitNative(SignInFailed::class, ['provider' => 'apple', 'error' => 'Sign in with Apple was cancelled.', 'errorCode' => AppleSignIn::Cancelled]) ->assertNoNavigation() @@ -150,7 +150,7 @@ ->assertSet('error', null) ->assertDontSee('cancelled'); - expect(shownText($screen))->toBe([]); + expect(shownText($screen))->toBe(termsText()); expect(heldAppleNonce())->toBeNull(); Http::assertNothingSent(); @@ -159,7 +159,7 @@ it("says so when Apple's sign-in fails another way, and ignores Google's failures", function () { appleBridge(); fakeApi([]); - $screen = Native::visit('/onboarding', platform: 'ios')->tap('sign-in-apple'); + $screen = Native::visit('/onboarding', platform: 'ios')->set('acceptedTerms', true)->tap('sign-in-apple'); $screen->emitNative(SignInFailed::class, ['provider' => 'google', 'error' => 'Nope.', 'errorCode' => 'SIGN_IN_FAILED']) ->assertSet('appleError', null); @@ -176,7 +176,7 @@ it('rejects an answer whose nonce is not the one it sent, and sends nothing', function (?string $nonce) { appleBridge(); fakeApi([]); - $screen = Native::visit('/onboarding', platform: 'ios')->tap('sign-in-apple'); + $screen = Native::visit('/onboarding', platform: 'ios')->set('acceptedTerms', true)->tap('sign-in-apple'); appleAnswers($screen, '', ['nonce' => $nonce]) ->assertNoNavigation() @@ -201,7 +201,7 @@ it('spends an attempt on its first answer, so a replayed one is rejected', function () { appleBridge(); fakeApi(['POST /api/v1/devices' => [['message' => 'That Apple sign-in could not be verified.', 'code' => 'invalid_apple_token'], 422]]); - $screen = Native::visit('/onboarding', platform: 'ios')->tap('sign-in-apple'); + $screen = Native::visit('/onboarding', platform: 'ios')->set('acceptedTerms', true)->tap('sign-in-apple'); $nonce = heldAppleNonce(); appleAnswers($screen, $nonce); @@ -213,7 +213,7 @@ it('says so when Apple sends no identity token', function () { appleBridge(); fakeApi([]); - $screen = Native::visit('/onboarding', platform: 'ios')->tap('sign-in-apple'); + $screen = Native::visit('/onboarding', platform: 'ios')->set('acceptedTerms', true)->tap('sign-in-apple'); appleAnswers($screen, heldAppleNonce(), ['identityToken' => null])->assertSet('appleError', AppleSignIn::NoToken); @@ -224,7 +224,7 @@ fakeKeychain(testSeed())->respondTo('NativephpSocialAuth.AppleSignIn', null); Native::visit('/onboarding', platform: 'ios') - ->tap('sign-in-apple') + ->set('acceptedTerms', true)->tap('sign-in-apple') ->assertSee(AppleSignIn::CouldNotOpen); expect(heldAppleNonce())->toBeNull(); @@ -238,7 +238,7 @@ }); $bridge = appleBridge(); fakeApi(['POST /api/v1/devices' => [['data' => userWithCountsJson()]]]); - $screen = Native::visit('/onboarding', platform: 'ios')->tap('sign-in-apple'); + $screen = Native::visit('/onboarding', platform: 'ios')->set('acceptedTerms', true)->tap('sign-in-apple'); $nonce = heldAppleNonce(); expect(print_r(app(AppleSignIn::class), true))->not->toContain($nonce) @@ -267,7 +267,7 @@ Native::visit('/onboarding', platform: 'ios') ->assertElement('button', fn (array $node): bool => ($node['ref'] ?? null) === 'sign-in-apple' && ($node['props']['disabled'] ?? false) === true) - ->call('signInWithApple'); + ->set('acceptedTerms', true)->call('signInWithApple'); $bridge->assertNotCalled('NativephpSocialAuth.AppleSignIn'); }); @@ -277,7 +277,7 @@ $screen = Native::visit('/onboarding', platform: 'android') ->assertDontSee('Sign in with Apple') - ->call('signInWithApple'); + ->set('acceptedTerms', true)->call('signInWithApple'); expect(findNodes($screen->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'button'))->toHaveCount(1); $bridge->assertNotCalled('NativephpSocialAuth.AppleSignIn'); @@ -286,7 +286,7 @@ it("asks whether the phone has an Apple Account when Apple's error says nothing more", function () { appleBridge(); fakeApi([]); - $screen = Native::visit('/onboarding', platform: 'ios')->tap('sign-in-apple'); + $screen = Native::visit('/onboarding', platform: 'ios')->set('acceptedTerms', true)->tap('sign-in-apple'); // What closing iOS's "Sign in to your Apple Account" alert sends back. $screen->emitNative(SignInFailed::class, ['provider' => 'apple', 'error' => 'The operation couldn’t be completed. (com.apple.AuthenticationServices.AuthorizationError error 1000.) (ASAuthorizationError.unknown)', 'errorCode' => 'SIGN_IN_FAILED']) diff --git a/tests/Feature/Screens/OnboardingTest.php b/tests/Feature/Screens/OnboardingTest.php index b418b367..be53cd8d 100644 --- a/tests/Feature/Screens/OnboardingTest.php +++ b/tests/Feature/Screens/OnboardingTest.php @@ -60,7 +60,7 @@ function openedAuthQuery(FakeBridge $bridge): array */ function tapSignIn(TestableComponent $screen, FakeBridge $bridge): string { - $screen->tap('sign-in'); + $screen->set('acceptedTerms', true)->tap('sign-in'); return openedAuthQuery($bridge)['state']; } @@ -97,7 +97,7 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent $screen = Native::visit('/onboarding'); - expect(shownText($screen))->toBe([]); + expect(shownText($screen))->toBe(termsText()); $screen->assertDontSee('This phone makes its own key') ->assertSet('fingerprint', app(DeviceKey::class)->current()->fingerprint()) @@ -128,7 +128,7 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent ->and($space['layout'])->toMatchArray(['width' => 'fill', 'flex_grow' => 1, 'align_items' => AlignItems::Center->value, 'justify_content' => JustifyContent::Center->value]) ->and(array_column($screen->tree()['children'], 'ref'))->toBe(['logo-space', 'actions']) ->and($screen->tree()['layout'])->toMatchArray(['height' => 'fill', 'safe_area' => 1]) - ->and(childRefs($screen, 'actions'))->toBe(['sign-in']) + ->and(childRefs($screen, 'actions'))->toBe(['terms', 'sign-in']) // Drawn at up to 293pt on the widest iPhone, so it's rendered 300pt wide at @3x, at the lockup's own 15:4. ->and([$width, $height])->toBe([900, 240]) ->and(getimagesize(public_path(TabsLayout::DarkLogo))[0])->toBe(900); @@ -152,7 +152,7 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent $bridge = signInBridge(testSeed()); $screen = Native::visit('/onboarding'); - $screen->tap('sign-in')->assertNoNavigation(); + $screen->set('acceptedTerms', true)->tap('sign-in')->assertNoNavigation(); $url = $bridge->callsTo('Browser.OpenAuth')[0]['params']['url']; $query = openedAuthQuery($bridge); @@ -276,7 +276,7 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent ->assertNoNavigation() ->assertSet('error', null); - expect(shownText($screen))->toBe([]); + expect(shownText($screen))->toBe(termsText()); $screen ->assertElement('button', fn (array $node): bool => ($node['ref'] ?? null) === 'sign-in' && $node['props']['label'] === 'Sign in with GitHub'); @@ -295,8 +295,8 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent ->assertElement('button', fn (array $node): bool => ($node['ref'] ?? null) === 'sign-in' && $node['props']['label'] === 'Try again'); // Just above the buttons, and the only text on the screen. - expect(childRefs($screen, 'actions'))->toBe(['error', 'sign-in']) - ->and(shownText($screen))->toBe(['Something broke on our side.']); + expect(childRefs($screen, 'actions'))->toBe(['error', 'terms', 'sign-in']) + ->and(shownText($screen))->toBe(['Something broke on our side.', ...termsText()]); $retry = tapSignIn($screen, $bridge); @@ -377,7 +377,7 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent it('says so when the auth browser does not open', function () { fakeKeychain(testSeed())->respondTo('Browser.OpenAuth', ['success' => false]); - Native::visit('/onboarding')->tap('sign-in')->assertSee('fatal: could not open dev-null.sh to sign in.'); + Native::visit('/onboarding')->set('acceptedTerms', true)->tap('sign-in')->assertSee('fatal: could not open dev-null.sh to sign in.'); }); it("doesn't say why a 401 sent it here: the buttons are enough", function (string $message) { @@ -385,8 +385,8 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent $screen = Native::visit('/onboarding', ['message' => $message])->assertDontSee($message); - expect(shownText($screen))->toBe([]) - ->and(childRefs($screen, 'actions'))->toBe(['sign-in']); + expect(shownText($screen))->toBe(termsText()) + ->and(childRefs($screen, 'actions'))->toBe(['terms', 'sign-in']); })->with([ 'a key devnull no longer takes' => ['This key is no longer accepted.'], 'a phone to approve' => [ErrorCode::DeviceNotApproved->message()], @@ -402,7 +402,7 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent ->assertSee('fatal: could not read the device key from the Keychain. Device is locked.') ->assertSet('fingerprint', '') ->assertElement('button', fn (array $node): bool => ($node['ref'] ?? null) === 'sign-in' && ($node['props']['disabled'] ?? false) === true) - ->call('signIn'); + ->set('acceptedTerms', true)->call('signIn'); $bridge->assertNotCalled('SecureStorage.Set')->assertNotCalled('Browser.OpenAuth'); }); @@ -454,3 +454,30 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent ->and(file_get_contents(base_path('.env.example')))->not->toContain('DEVNULL_GITHUB_CLIENT_ID')->toContain("NATIVEPHP_DEEPLINK_SCHEME=devnull\n") ->and(file_get_contents(base_path('README.md')))->not->toContain('DEVNULL_GITHUB_CLIENT_ID')->not->toContain('device flow'); }); + + +it('keeps both ways in shut until the terms and privacy policy are agreed to, and links to them', function () { + $bridge = signInBridge(); + $bridge->respondTo('Browser.OpenInApp', ['success' => true]); + + $screen = Native::visit('/onboarding'); + + $disabled = fn (string $ref): bool => (bool) ($screen->tree() && (nodeByRef($screen, $ref)['props']['disabled'] ?? false)); + + expect($disabled('sign-in'))->toBeTrue(); + + $screen->call('signIn'); + + expect($bridge->callsTo('Browser.OpenAuth'))->toBe([]); + + $screen->press('accept-terms')->assertSet('acceptedTerms', true)->call('signIn'); + + expect($bridge->callsTo('Browser.OpenAuth'))->toHaveCount(1); + + $screen->press('terms-link') + ->assertNativeCalled('Browser.OpenInApp', fn (array $params): bool => $params['url'] === 'http://devnull.test/terms') + ->press('privacy-link') + ->assertNativeCalled('Browser.OpenInApp', fn (array $params): bool => $params['url'] === 'http://devnull.test/privacy'); + + $screen->press('accept-terms')->assertSet('acceptedTerms', false); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 9ea9bd60..e91bb66f 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -951,3 +951,18 @@ function sentTo(string $method, string $path): array ->values() ->all(); } + + +/** + * What the sign-in screen says about the terms, above its buttons. + * + * @return list + */ +function termsText(): array +{ + return [ + 'I agree to the terms of service and privacy policy. There\'s no tolerance for objectionable content or abusive users.', + 'Terms of service', + 'Privacy policy', + ]; +} From 7f7a162dba1c7bfe563cf5ddd2214ab459627c4c Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Mon, 2 Nov 2026 13:22:15 +0000 Subject: [PATCH 2/4] Say the terms were accepted instead of naming a version The phone sent a hard-coded terms version and the server refuses anything but the one in force, so the 1 November terms update would have made sign-in fail with a 422, and every later update would do the same to builds already installed. The server now takes a yes (dev-null-sh/dev-null.sh#1385) and works out the version from when it hears. Both Client methods always send it. A sign-in can't start without the tick, and reading the tick when the sign-in finishes lost it whenever the browser's answer landed on a new onboarding screen. Co-Authored-By: Claude Opus 5.5 --- app/Devnull/Client.php | 18 ++++++++---------- app/NativeComponents/Onboarding.php | 8 ++++---- tests/Feature/Devnull/ClientTest.php | 4 ++-- tests/Feature/Screens/OnboardingAppleTest.php | 2 +- tests/Feature/Screens/OnboardingTest.php | 7 +++++-- 5 files changed, 20 insertions(+), 19 deletions(-) diff --git a/app/Devnull/Client.php b/app/Devnull/Client.php index 64583006..89b28f2e 100644 --- a/app/Devnull/Client.php +++ b/app/Devnull/Client.php @@ -42,12 +42,6 @@ */ public const string SignInPath = 'devices'; - /** - * The version of the terms and privacy policy the sign-in screen asks people to accept, which devnull records when - * the phone signs in. It's the date they took effect, and has to be the one devnull has in force. - */ - public const string TermsVersion = '2026-10-23'; - /** * Where the signed-in account's Apple ID is linked and unlinked. */ @@ -190,13 +184,16 @@ public static function pastelPath(string $id): string * Trades the code dev-null.sh sent back, with the PKCE verifier only this * phone knows, for the user it approved this phone for. It's signed like * every request, so the code only works for the key it was made for. + * + * No sign-in starts until the terms are accepted, so this says they were. + * devnull works out which version that means from when it hears. */ - public function redeemSignIn(string $code, #[SensitiveParameter] string $verifier, ?string $termsVersion = null): User + public function redeemSignIn(string $code, #[SensitiveParameter] string $verifier): User { return User::fromArray($this->data($this->send('POST', self::SignInPath, payload: [ 'code' => $code, 'code_verifier' => $verifier, - ...($termsVersion === null ? [] : ['terms_version' => $termsVersion]), + 'terms_accepted' => true, ]))); } @@ -204,14 +201,15 @@ public function redeemSignIn(string $code, #[SensitiveParameter] string $verifie * Signs in on a phone devnull doesn't know yet with an Apple ID linked to * an account, which approves this phone's key. Apple's identity token * carries sha256 of the nonce, and devnull checks it against the raw one. + * It says the terms were accepted, as redeemSignIn() does. */ - public function signInWithApple(#[SensitiveParameter] string $identityToken, #[SensitiveParameter] string $nonce, string $deviceName, ?string $termsVersion = null): User + public function signInWithApple(#[SensitiveParameter] string $identityToken, #[SensitiveParameter] string $nonce, string $deviceName): User { return User::fromArray($this->data($this->send('POST', self::SignInPath, payload: [ 'apple_identity_token' => $identityToken, 'nonce' => $nonce, 'device_name' => $deviceName, - ...($termsVersion === null ? [] : ['terms_version' => $termsVersion]), + 'terms_accepted' => true, ]))); } diff --git a/app/NativeComponents/Onboarding.php b/app/NativeComponents/Onboarding.php index 022bcefb..f83988ab 100644 --- a/app/NativeComponents/Onboarding.php +++ b/app/NativeComponents/Onboarding.php @@ -54,8 +54,8 @@ class Onboarding extends NativeComponent public ?string $appleError = null; /** - * Whether the box for the terms and privacy policy is ticked. Neither way in works until it is, and devnull is told - * which version was accepted as the phone signs in. + * Whether the box for the terms and privacy policy is ticked. Neither way in starts until it is. Don't read it when + * a sign-in finishes: the browser's answer can land on a new screen, where it's false again. */ public bool $acceptedTerms = false; @@ -170,7 +170,7 @@ public function render(): View protected function useAppleToken(#[SensitiveParameter] string $identityToken, #[SensitiveParameter] string $nonce): void { try { - $user = app(Client::class)->signInWithApple($identityToken, $nonce, $this->deviceName(), $this->acceptedTerms ? Client::TermsVersion : null); + $user = app(Client::class)->signInWithApple($identityToken, $nonce, $this->deviceName()); } catch (ApiException $exception) { $this->appleError = $exception->firstError(); @@ -241,7 +241,7 @@ private function finishSignIn(): void } try { - $user = app(Client::class)->redeemSignIn($code, $attempt->verifier(), $this->acceptedTerms ? Client::TermsVersion : null); + $user = app(Client::class)->redeemSignIn($code, $attempt->verifier()); } catch (ApiException $exception) { $this->error = $exception->firstError(); diff --git a/tests/Feature/Devnull/ClientTest.php b/tests/Feature/Devnull/ClientTest.php index 6d7115d5..67c0af76 100644 --- a/tests/Feature/Devnull/ClientTest.php +++ b/tests/Feature/Devnull/ClientTest.php @@ -297,7 +297,7 @@ function receivedMessage(Request $request): string ->and($user->username)->toBe('local_dev'); Http::assertSent(function (Request $request): bool { - $body = '{"code":"the-code","code_verifier":"the-verifier"}'; + $body = '{"code":"the-code","code_verifier":"the-verifier","terms_accepted":true}'; expect($request->method())->toBe('POST') ->and($request->url())->toBe('http://devnull.test/api/v1/'.Client::SignInPath) @@ -677,7 +677,7 @@ function receivedMessage(Request $request): string expect($user->username)->toBe('local_dev'); Http::assertSent(function (Request $request): bool { - $body = '{"apple_identity_token":"the.identity.token","nonce":"raw-nonce","device_name":"devnull on iPhone"}'; + $body = '{"apple_identity_token":"the.identity.token","nonce":"raw-nonce","device_name":"devnull on iPhone","terms_accepted":true}'; expect($request->method())->toBe('POST') ->and($request->url())->toBe('http://devnull.test/api/v1/'.Client::SignInPath) diff --git a/tests/Feature/Screens/OnboardingAppleTest.php b/tests/Feature/Screens/OnboardingAppleTest.php index 7cb5df89..e20a22cd 100644 --- a/tests/Feature/Screens/OnboardingAppleTest.php +++ b/tests/Feature/Screens/OnboardingAppleTest.php @@ -99,7 +99,7 @@ Http::assertSentCount(2); Http::assertSent(fn (Request $request): bool => $request->method() === 'POST' && $request->url() === 'http://devnull.test/api/v1/'.Client::SignInPath - && $request->body() === json_encode(['apple_identity_token' => AppleIdentityToken, 'nonce' => $nonce, 'device_name' => 'devnull on iPhone', 'terms_version' => Client::TermsVersion]) + && $request->body() === json_encode(['apple_identity_token' => AppleIdentityToken, 'nonce' => $nonce, 'device_name' => 'devnull on iPhone', 'terms_accepted' => true]) && $request->hasHeader('X-SSH-Signature')); // Apple's token carries sha256(nonce) as hex, which is what devnull checks against the raw nonce. diff --git a/tests/Feature/Screens/OnboardingTest.php b/tests/Feature/Screens/OnboardingTest.php index 8715edfd..a2415a17 100644 --- a/tests/Feature/Screens/OnboardingTest.php +++ b/tests/Feature/Screens/OnboardingTest.php @@ -206,7 +206,7 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent Http::assertSentCount(2); Http::assertSent(fn (Request $request): bool => $request->method() === 'POST' && $request->url() === 'http://devnull.test/api/v1/'.Client::SignInPath - && $request->body() === json_encode(['code' => 'the-code', 'code_verifier' => $verifier]) + && $request->body() === json_encode(['code' => 'the-code', 'code_verifier' => $verifier, 'terms_accepted' => true]) && $request->hasHeader('X-SSH-Signature')); }); @@ -215,6 +215,7 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent fakeApi(['POST /api/v1/devices' => [['data' => userWithCountsJson()]]]); $screen = Native::visit('/onboarding'); $state = tapSignIn($screen, $bridge); + $verifier = pendingAttempt()->verifier(); // On a phone the callback screen is pushed over onboarding, so it pops back rather than replacing. $callback = new AuthCallback; @@ -229,6 +230,9 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent expect($callback->getNavigationIntent()?->type)->toBe(NavigationIntent::BACK); $screen->call('onResume')->assertReplacedWith('/log'); + + Http::assertSent(fn (Request $request): bool => $request->method() === 'POST' + && $request->body() === json_encode(['code' => 'c', 'code_verifier' => $verifier, 'terms_accepted' => true])); }); it('rejects a callback whose state is not the one it sent, and redeems nothing', function () { @@ -451,7 +455,6 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent ->and(file_get_contents(base_path('README.md')))->not->toContain('DEVNULL_GITHUB_CLIENT_ID')->not->toContain('device flow'); }); - it('keeps both ways in shut until the terms and privacy policy are agreed to, and links to them', function () { $bridge = signInBridge(); $bridge->respondTo('Browser.OpenInApp', ['success' => true]); From 9a390405cbf23257814f9ffa6a449df9958260ae Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Tue, 3 Nov 2026 12:58:58 +0000 Subject: [PATCH 3/4] Ask for terms that changed before anything else, and show them in Settings When devnull says the terms in force haven't been accepted, the app now opens on a screen that asks for them, like the web's terms dialog. Launch goes straight there when the last answer said so, and any /me answer that says so sends you there from whatever screen you're on. Accepting sends the version /me named, so terms that change again while the screen is open get a 422 and are asked for again. Sign out and Delete account are the other ways out. Account settings gets a Terms and privacy section just before Delete account: links to both pages in the in-app browser, and the version in force beside the one you agreed to and when. devnull only keeps when you accepted, so an older version shows as "an older version" rather than a date. Sign out and delete account move into a LeavesDevnull concern so the new screen can share them with Settings, and the two page links into OpensLegalPages. Co-Authored-By: Claude Opus 5.5 --- app/Devnull/Account.php | 29 ++- app/Devnull/Client.php | 18 ++ app/Devnull/Data/CurrentUser.php | 26 +++ app/Devnull/Display.php | 8 + app/NativeComponents/AcceptTerms.php | 161 ++++++++++++++ .../Concerns/LeavesDevnull.php | 81 +++++++ .../Concerns/OpensLegalPages.php | 22 ++ .../Concerns/RefreshesInBackground.php | 25 ++- app/NativeComponents/Launch.php | 5 +- app/NativeComponents/Onboarding.php | 20 +- app/NativeComponents/ThisDevice.php | 93 +++----- resources/views/native/accept-terms.blade.php | 31 +++ resources/views/native/this-device.blade.php | 33 +++ routes/mobile.php | 4 + tests/Feature/Screens/AcceptTermsTest.php | 205 ++++++++++++++++++ tests/Feature/Screens/AccountTermsTest.php | 72 ++++++ tests/Feature/Screens/LaunchTest.php | 8 + .../Screens/OnboardingSignInAgainTest.php | 13 ++ tests/Pest.php | 11 + 19 files changed, 787 insertions(+), 78 deletions(-) create mode 100644 app/NativeComponents/AcceptTerms.php create mode 100644 app/NativeComponents/Concerns/LeavesDevnull.php create mode 100644 app/NativeComponents/Concerns/OpensLegalPages.php create mode 100644 resources/views/native/accept-terms.blade.php create mode 100644 tests/Feature/Screens/AcceptTermsTest.php create mode 100644 tests/Feature/Screens/AccountTermsTest.php diff --git a/app/Devnull/Account.php b/app/Devnull/Account.php index 4bd99be0..22917423 100644 --- a/app/Devnull/Account.php +++ b/app/Devnull/Account.php @@ -34,6 +34,8 @@ final class Account private const string TimezoneKey = 'devnull.timezone'; + private const string TermsKey = 'devnull.terms-to-accept'; + public function user(): ?User { $attributes = Cache::get(self::CacheKey); @@ -191,8 +193,30 @@ public function rememberTimezone(?string $timezone): void } /** - * Signs this phone out: who it was, the badges, its pastéis and Remotes, your timezone, every answer, image and - * voice note kept for a quick start, and your unsent drafts and recordings. + * The version of the terms devnull last said you have yet to accept, so the app opens on them (see AcceptTerms), + * or null when you're up to date. + */ + public function termsToAccept(): ?string + { + $version = Cache::get(self::TermsKey); + + return is_string($version) && $version !== '' ? $version : null; + } + + public function rememberTermsToAccept(?string $version): void + { + if ($version === null) { + Cache::forget(self::TermsKey); + + return; + } + + Cache::forever(self::TermsKey, $version); + } + + /** + * Signs this phone out: who it was, the badges, its pastéis and Remotes, your timezone, the terms you owe, every + * answer, image and voice note kept for a quick start, and your unsent drafts and recordings. */ public function forget(): void { @@ -209,5 +233,6 @@ public function forget(): void Cache::forget(self::ProfileKey); Cache::forget(self::PinWarningKey); Cache::forget(self::TimezoneKey); + Cache::forget(self::TermsKey); } } diff --git a/app/Devnull/Client.php b/app/Devnull/Client.php index 89b28f2e..e2579cac 100644 --- a/app/Devnull/Client.php +++ b/app/Devnull/Client.php @@ -123,6 +123,11 @@ public const string SettingsPath = 'me/settings'; + /** + * Where the signed-in account accepts the terms and privacy policy in force. + */ + public const string TermsPath = 'me/terms'; + /** * The commits the signed-in account scheduled, as the web's Scheduled * commits page lists them. @@ -241,6 +246,19 @@ public function me(): CurrentUser return CurrentUser::fromArray($this->get('me')); } + /** + * Accepts the terms and privacy policy, naming the version devnull said is in force. When they changed again + * since, devnull refuses with a 422 rather than take a yes to a version nobody showed you. It answers as + * `GET /me` does, and every kept copy of you takes it. + */ + public function acceptTerms(string $version): CurrentUser + { + $json = $this->json($this->send('PUT', self::TermsPath, payload: ['version' => $version])); + $this->cache->meChanged($json); + + return CurrentUser::fromArray($json); + } + /** * Deletes the account this phone is signed in as, the way the web's Delete * account does. devnull deletes its devices with it, so this phone's key diff --git a/app/Devnull/Data/CurrentUser.php b/app/Devnull/Data/CurrentUser.php index c082edb8..9fead8f8 100644 --- a/app/Devnull/Data/CurrentUser.php +++ b/app/Devnull/Data/CurrentUser.php @@ -15,6 +15,10 @@ * it only was when you signed in). It has whether pastéis mode is on, how * many pastéis you can still give today, and how many others have given you, * and your Remotes settings, or null while you have Remotes off. + * + * And the terms: the version in force, which is the moment it took effect, when + * you last accepted them, and whether that was this version. A devnull too old + * to say leaves all three null. */ final readonly class CurrentUser { @@ -30,6 +34,9 @@ public function __construct( public Pasteis $pasteisToday = new Pasteis, public int $pasteisReceived = 0, public ?Remotes $remotes = null, + public ?string $termsVersion = null, + public ?CarbonImmutable $termsAcceptedAt = null, + public ?bool $termsAccepted = null, ) {} /** @@ -59,9 +66,28 @@ public static function fromArray(array $json): self pasteisReceived: is_int($data['pasteis_received'] ?? null) ? max(0, $data['pasteis_received']) : 0, // Only devnull saying null means they're off: a devnull too old to say has them on, as everyone starts. remotes: array_key_exists('remotes', $data) ? Remotes::fromArray($data['remotes']) : new Remotes, + termsVersion: is_string($data['terms_version'] ?? null) && $data['terms_version'] !== '' ? $data['terms_version'] : null, + termsAcceptedAt: Commit::date($data['terms_accepted_at'] ?? null), + termsAccepted: is_bool($data['terms_accepted'] ?? null) ? $data['terms_accepted'] : null, ); } + /** + * The version of the terms devnull is waiting for you to accept, exactly as it sent it, or null when you have. + */ + public function termsToAccept(): ?string + { + return $this->termsAccepted === false ? $this->termsVersion : null; + } + + /** + * When the terms in force took effect. + */ + public function termsTookEffect(): ?CarbonImmutable + { + return Commit::date($this->termsVersion); + } + /** * You as this phone remembers you, until devnull says the rest. */ diff --git a/app/Devnull/Display.php b/app/Devnull/Display.php index 441cc2dc..43fbd5f4 100644 --- a/app/Devnull/Display.php +++ b/app/Devnull/Display.php @@ -138,6 +138,14 @@ public static function fullDateTime(CarbonInterface $date, ?string $timezone = n return CarbonImmutable::instance($date)->setTimezone($timezone ?? app(Timezone::class)->yours())->format('g:i A · M j, Y'); } + /** + * The day something happened, with no time: "Nov 1, 2026", in your timezone. + */ + public static function day(CarbonInterface $date, ?string $timezone = null): string + { + return CarbonImmutable::instance($date)->setTimezone($timezone ?? app(Timezone::class)->yours())->format('M j, Y'); + } + /** * When a scheduled commit is due, as the web's Scheduled commits page * prints it: "Oct 16, 2026, 10:30 AM", in the phone's timezone. diff --git a/app/NativeComponents/AcceptTerms.php b/app/NativeComponents/AcceptTerms.php new file mode 100644 index 00000000..9804ae4e --- /dev/null +++ b/app/NativeComponents/AcceptTerms.php @@ -0,0 +1,161 @@ +version = app(Account::class)->termsToAccept(); + + if ($this->version === null) { + $this->replace('/log'); + + return; + } + + $kept = $this->cached('me'); + $this->acceptedBefore = $kept !== null && CurrentUser::fromArray($kept)->termsAcceptedAt !== null; + + $this->revalidate(); + } + + protected function revalidate(): void + { + $this->refresh('me', [], fn (array $json) => $this->takeTerms(CurrentUser::fromArray($json)), quiet: true); + } + + protected function showsContent(): bool + { + return true; + } + + public function accept(): void + { + if ($this->version === null) { + return; + } + + try { + $current = app(Client::class)->acceptTerms($this->version); + } catch (Unauthenticated $exception) { + $this->signOut($exception); + + return; + } catch (ApiException $exception) { + if ($exception->status !== 422) { + $this->error = $exception->firstError(); + + return; + } + + // The version in force moved on since devnull said which, so this asks it for the new one. + $this->error = self::ChangedAgain; + $this->retry(); + + return; + } catch (KeychainUnavailable $exception) { + $this->error = $exception->getMessage(); + + return; + } + + $this->error = null; + $this->takeTerms($current); + } + + #[On(ButtonPressed::class)] + public function onAlertButton(string $label = '', ?string $id = null): void + { + if ($id === self::SignOutAlert && $label === 'Sign out') { + $this->forget(); + } + + if ($id === self::DeleteAccountAlert && $label === 'Delete') { + $this->deleteAccount(); + } + } + + public function render(): View + { + $tookEffect = Commit::date($this->version); + + return view('native.accept-terms', [ + 'logo' => TabsLayout::logo(), + 'title' => $this->acceptedBefore ? 'We updated our terms' : 'Accept our terms', + 'explanation' => $this->acceptedBefore && $tookEffect !== null + ? 'Our terms of service changed on '.Display::day($tookEffect).'. Read them, and accept them to keep using /dev/null.' + : 'Read the terms of service and privacy policy, and accept them to keep using /dev/null.', + ]); + } + + /** + * Takes devnull's word on the terms: on to the log once you're up to date with them, as after accepting them + * here or on the web, or else the version it's waiting for now. + */ + protected function takeTerms(CurrentUser $current): void + { + if ($current->termsAccepted === null || $current->user->username === '') { + return; + } + + app(Account::class)->remember($current->user); + app(Account::class)->rememberTermsToAccept($current->termsToAccept()); + + if ($current->termsToAccept() === null) { + $this->replace('/log'); + + return; + } + + $this->version = $current->termsToAccept(); + $this->acceptedBefore = $current->termsAcceptedAt !== null; + } +} diff --git a/app/NativeComponents/Concerns/LeavesDevnull.php b/app/NativeComponents/Concerns/LeavesDevnull.php new file mode 100644 index 00000000..5dfb31b4 --- /dev/null +++ b/app/NativeComponents/Concerns/LeavesDevnull.php @@ -0,0 +1,81 @@ + 'Cancel', 'style' => 'cancel'], + ['label' => 'Sign out', 'style' => 'destructive'], + ], + )->id(self::SignOutAlert); + } + + public function confirmDeleteAccount(): void + { + Dialog::alert( + 'Delete your account?', + 'Your profile, commits, replies, stars and follows are removed from /dev/null. There is no undo.', + [ + ['label' => 'Cancel', 'style' => 'cancel'], + ['label' => 'Delete', 'style' => 'destructive'], + ], + )->id(self::DeleteAccountAlert); + } + + /** + * Signs out: the key goes from this phone, and with it who you are and + * every answer kept for you (see Account::forget). + */ + public function forget(): void + { + app(DeviceKey::class)->forget(); + app(Account::class)->forget(); + + $this->replace('/onboarding'); + } + + /** + * Deletes the account on devnull, which takes its devices with it, then + * signs out. When devnull can't, the error shows and you stay signed in. + */ + public function deleteAccount(): void + { + $deleted = $this->attempt(function (Client $devnull): bool { + $devnull->deleteAccount(); + + return true; + }); + + if ($deleted === true) { + $this->forget(); + } + } +} diff --git a/app/NativeComponents/Concerns/OpensLegalPages.php b/app/NativeComponents/Concerns/OpensLegalPages.php new file mode 100644 index 00000000..97a0fb5b --- /dev/null +++ b/app/NativeComponents/Concerns/OpensLegalPages.php @@ -0,0 +1,22 @@ +remember($current->user); app(Account::class)->rememberPasteis($current->pasteis()); app(Account::class)->rememberRemotes($current->remotes); + $this->takeTerms($current); } app(Account::class)->rememberUnreadNotifications($current->unreadNotificationsCount); }, quiet: true); } + /** + * Keeps whether devnull is waiting for you to accept its terms, when its + * answer says, and sends you to accept them the moment it is, whichever + * screen you're on (see AcceptTerms). + */ + protected function takeTerms(CurrentUser $current): void + { + if ($current->termsAccepted === null) { + return; + } + + app(Account::class)->rememberTermsToAccept($current->termsToAccept()); + + if ($current->termsToAccept() !== null) { + $this->replace(AcceptTerms::Path); + } + } + /** * Asks devnull for what only you see in the web's settings, and keeps the * timezone you chose there, which `/me` doesn't carry, so the app shows diff --git a/app/NativeComponents/Launch.php b/app/NativeComponents/Launch.php index fb249a69..4c556338 100644 --- a/app/NativeComponents/Launch.php +++ b/app/NativeComponents/Launch.php @@ -9,7 +9,8 @@ use Native\Mobile\Edge\NativeComponent; /** - * The first screen: straight to the log for a verified device, otherwise onboarding. + * The first screen: straight to the log for a verified device, otherwise onboarding. A verified device whose account + * owed terms when devnull last said goes to accept them first (see AcceptTerms). * * A verified device whose key has gone, as on a new phone restored from a * backup, or when the build's signing team changed and the Keychain with it, @@ -35,7 +36,7 @@ public function mount(Account $account, DeviceKey $deviceKey): void return; } - $this->replace('/log'); + $this->replace($account->termsToAccept() === null ? '/log' : AcceptTerms::Path); } public function render(): View diff --git a/app/NativeComponents/Onboarding.php b/app/NativeComponents/Onboarding.php index 16370283..2b3cef0a 100644 --- a/app/NativeComponents/Onboarding.php +++ b/app/NativeComponents/Onboarding.php @@ -10,6 +10,7 @@ use App\Devnull\Exceptions\ApiException; use App\Devnull\Exceptions\KeychainUnavailable; use App\Devnull\SignIn; +use App\NativeComponents\Concerns\OpensLegalPages; use App\NativeComponents\Concerns\SignsInWithApple; use App\NativeComponents\Layouts\TabsLayout; use Illuminate\Support\Facades\Log; @@ -40,6 +41,7 @@ */ class Onboarding extends NativeComponent { + use OpensLegalPages; use SignsInWithApple; public const string NotStartedHere = "fatal: that sign-in didn't start on this phone, so it was ignored."; @@ -105,16 +107,6 @@ public function toggleTerms(): void $this->acceptedTerms = ! $this->acceptedTerms; } - public function openTerms(): void - { - Browser::inApp(rtrim((string) config('devnull.url'), '/').'/terms'); - } - - public function openPrivacy(): void - { - Browser::inApp(rtrim((string) config('devnull.url'), '/').'/privacy'); - } - /** * Opens dev-null.sh with a fresh verifier and state. Dismissing the sheet * sends nothing back, so the screen simply stays as it is. @@ -260,8 +252,9 @@ private function finishSignIn(): void /** * A key devnull already knows and approved signs in with nothing to do, - * as after the app's stored sign-in was lost. Any other answer leaves - * onboarding as it is, without an error: there was nothing to ask for. + * as after the app's stored sign-in was lost, then asks for any terms + * still to accept. Any other answer leaves onboarding as it is, without + * an error: there was nothing to ask for. */ private function signInWithExistingKey(): void { @@ -279,8 +272,9 @@ private function signInWithExistingKey(): void app(Account::class)->rememberUnreadNotifications($current->unreadNotificationsCount); app(Account::class)->rememberPasteis($current->pasteis()); app(Account::class)->rememberRemotes($current->remotes); + app(Account::class)->rememberTermsToAccept($current->termsToAccept()); $this->keepProfile($current->user); - $this->replace('/log'); + $this->replace($current->termsToAccept() === null ? '/log' : AcceptTerms::Path); } /** diff --git a/app/NativeComponents/ThisDevice.php b/app/NativeComponents/ThisDevice.php index 1630f33c..f95378d2 100644 --- a/app/NativeComponents/ThisDevice.php +++ b/app/NativeComponents/ThisDevice.php @@ -15,6 +15,8 @@ use App\Devnull\ResponseCache; use App\Devnull\Timezone; use App\Devnull\WhoToFollow; +use App\NativeComponents\Concerns\LeavesDevnull; +use App\NativeComponents\Concerns\OpensLegalPages; use App\NativeComponents\Concerns\RefreshesInBackground; use App\NativeComponents\Concerns\SignsInWithApple; use App\NativeComponents\Concerns\SyncsOutbox; @@ -33,8 +35,9 @@ * Your account, reached from your own profile, laid out like the web's profile * settings: your profile as devnull mirrors it from GitHub, with a sync, your * colour, your timezone, pastéis mode, Remotes and their settings, the people - * you mute and block, then this phone's fingerprint, signing out and deleting - * the account. + * you mute and block, then this phone's fingerprint, signing out, the terms + * and privacy policy with the version you agreed to beside the one in force, + * and deleting the account. * Where the app talks to only shows when it isn't dev-null.sh. * * It opens on you as devnull last said (or as this phone remembers you) and @@ -47,18 +50,16 @@ */ class ThisDevice extends NativeComponent { + use LeavesDevnull; + use OpensLegalPages; use RefreshesInBackground; use SignsInWithApple; use SyncsOutbox; use TalksToDevnull; use WritesInBackground; - public const string SignOutAlert = 'sign-out'; - public const string UnlinkAppleAlert = 'unlink-apple'; - public const string DeleteAccountAlert = 'delete-account'; - /** * Where Edit on GitHub goes, as on the web. */ @@ -583,30 +584,6 @@ public function openDevices(): void Browser::inApp(rtrim((string) config('devnull.url'), '/').'/settings/devices'); } - public function confirmSignOut(): void - { - Dialog::alert( - 'Sign out?', - 'This deletes the key from this phone. The phone stays listed on dev-null.sh until you remove it there, and signing in again makes a new key.', - [ - ['label' => 'Cancel', 'style' => 'cancel'], - ['label' => 'Sign out', 'style' => 'destructive'], - ], - )->id(self::SignOutAlert); - } - - public function confirmDeleteAccount(): void - { - Dialog::alert( - 'Delete your account?', - 'Your profile, commits, replies, stars and follows are removed from /dev/null. There is no undo.', - [ - ['label' => 'Cancel', 'style' => 'cancel'], - ['label' => 'Delete', 'style' => 'destructive'], - ], - )->id(self::DeleteAccountAlert); - } - /** * Opens Apple's sheet. Its answer arrives as an event (see SignsInWithApple). */ @@ -661,35 +638,6 @@ public function onAlertButton(string $label = '', ?string $id = null): void } } - /** - * Signs out: the key goes from this phone, and with it who you are and - * every answer kept for you (see Account::forget). - */ - public function forget(): void - { - app(DeviceKey::class)->forget(); - app(Account::class)->forget(); - - $this->replace('/onboarding'); - } - - /** - * Deletes the account on devnull, which takes its devices with it, then - * signs out. When devnull can't, the error shows and you stay signed in. - */ - public function deleteAccount(): void - { - $deleted = $this->attempt(function (Client $devnull): bool { - $devnull->deleteAccount(); - - return true; - }); - - if ($deleted === true) { - $this->forget(); - } - } - public function render(): View { $current = $this->current; @@ -716,9 +664,35 @@ public function render(): View false => 'not linked', null => 'unknown', }, + 'terms' => $current === null ? null : self::terms($current), ]); } + /** + * The terms in force, by the day they took effect, beside the version you agreed to and when. devnull keeps only + * when you accepted, so a version older than the one in force can't be named. Null until devnull has said. + * + * @return array{current: string, agreed: string, accepted: ?string}|null + */ + private static function terms(CurrentUser $current): ?array + { + $tookEffect = $current->termsTookEffect(); + + if ($tookEffect === null || $current->termsAccepted === null) { + return null; + } + + return [ + 'current' => Display::day($tookEffect), + 'agreed' => match (true) { + $current->termsAccepted => Display::day($tookEffect), + $current->termsAcceptedAt === null => 'none yet', + default => 'an older version', + }, + 'accepted' => $current->termsAcceptedAt === null ? null : Display::day($current->termsAcceptedAt), + ]; + } + /** * What devnull mirrors from GitHub, in the web's order, with null for what GitHub doesn't have. * @@ -913,6 +887,7 @@ private function takeMe(array $json): void app(Account::class)->rememberPasteis($current->pasteis()); app(Account::class)->rememberRemotes($current->remotes); app(ResponseCache::class)->meChanged($json); + $this->takeTerms($current); } /** diff --git a/resources/views/native/accept-terms.blade.php b/resources/views/native/accept-terms.blade.php new file mode 100644 index 00000000..aee64a70 --- /dev/null +++ b/resources/views/native/accept-terms.blade.php @@ -0,0 +1,31 @@ +@use('App\Icons\Lucide') + + + + + + + {{ $title }} + {{ $explanation }} + There's no tolerance for objectionable content or abusive users. + If you don't accept them, you can sign out, or delete your account. + + + Terms of service + Privacy policy + + + + + + @if ($error) + {{ $error }} + @endif + + Accept the terms + Sign out + + Delete account + + + diff --git a/resources/views/native/this-device.blade.php b/resources/views/native/this-device.blade.php index cededaeb..61d0abac 100644 --- a/resources/views/native/this-device.blade.php +++ b/resources/views/native/this-device.blade.php @@ -318,6 +318,39 @@ Deletes the key from this phone and signs you out. + + @include('native.partials.settings-heading', [ + 'title' => 'Terms and privacy', + 'description' => 'What you agreed to, beside the terms in force now. Each version goes by the day it took effect.', + ]) + + @if ($terms !== null) + + + current + {{ $terms['current'] }} + + + + agreed to + {{ $terms['agreed'] }} + + @if ($terms['accepted'] !== null) + + + accepted + {{ $terms['accepted'] }} + + @endif + + @endif + + + Terms of service + Privacy policy + + + @include('native.partials.settings-heading', [ 'title' => 'Delete account', diff --git a/routes/mobile.php b/routes/mobile.php index d77f027a..694e55f3 100644 --- a/routes/mobile.php +++ b/routes/mobile.php @@ -1,5 +1,6 @@ 'http://devnull.test']); + signIn(); + app(Account::class)->rememberTimezone('Europe/Lisbon'); +}); + +/** + * devnull said, the last time it was asked, that the terms in force since Nov 1, 2026 are still to be accepted. + */ +function oweTerms(?string $acceptedAt = '2026-10-05T10:00:00Z'): void +{ + keep('me', currentUserTermsJson(false, $acceptedAt)); + app(Account::class)->rememberTermsToAccept('2026-11-01T14:35:00Z'); +} + +/** + * devnull's answer to a version that isn't the one in force. + * + * @return array{0: array, 1: int} + */ +function staleTermsVersion(): array +{ + return [['message' => 'The selected version is invalid.', 'errors' => ['version' => ['The selected version is invalid.']]], 422]; +} + +it('sends you to accept the terms the moment devnull says you owe them', function () { + fakeApi([ + 'GET /api/v1/log/following' => [pageJson([commitJson()])], + 'GET /api/v1/me' => [currentUserTermsJson(false)], + ]); + + Native::visit('/log')->assertReplacedWith(AcceptTerms::Path); + + expect(app(Account::class)->termsToAccept())->toBe('2026-11-01T14:35:00Z'); +}); + +it('sends you to accept them from your account too', function () { + fakeApi(['GET /api/v1/me' => [currentUserTermsJson(false)]]); + + Native::visit('/profile/device')->assertReplacedWith(AcceptTerms::Path); +}); + +it('lets you carry on, and forgets terms owed before, once devnull says you accepted them', function () { + app(Account::class)->rememberTermsToAccept('2026-10-01T00:00:00Z'); + fakeApi([ + 'GET /api/v1/log/following' => [pageJson([commitJson()])], + 'GET /api/v1/me' => [currentUserTermsJson(true, '2026-11-02T09:00:00Z')], + ]); + + Native::visit('/log')->assertNoNavigation(); + + expect(app(Account::class)->termsToAccept())->toBeNull(); +}); + +it('asks for nothing when devnull does not say which terms are in force', function () { + fakeApi([ + 'GET /api/v1/log/following' => [pageJson([commitJson()])], + 'GET /api/v1/me' => [currentUserJson()], + ]); + + Native::visit('/log')->assertNoNavigation(); + + expect(app(Account::class)->termsToAccept())->toBeNull(); +}); + +it('says the terms changed, and when, to someone who accepted them before', function () { + oweTerms(); + fakeApi(['GET /api/v1/me' => [currentUserTermsJson(false)]]); + + Native::visit(AcceptTerms::Path) + ->assertNoNavigation() + ->assertSee('We updated our terms') + ->assertSee('Our terms of service changed on Nov 1, 2026. Read them, and accept them to keep using /dev/null.') + ->assertSee("There's no tolerance for objectionable content or abusive users.") + ->assertSee("If you don't accept them, you can sign out, or delete your account.") + ->assertSee('Accept the terms') + ->assertSee('Sign out') + ->assertSee('Delete account'); +}); + +it('only asks someone who never accepted any', function () { + oweTerms(acceptedAt: null); + fakeApi(['GET /api/v1/me' => [currentUserTermsJson(false, null)]]); + + Native::visit(AcceptTerms::Path) + ->assertSee('Accept our terms') + ->assertSee('Read the terms of service and privacy policy, and accept them to keep using /dev/null.') + ->assertDontSee('We updated our terms'); +}); + +it('accepts the version devnull said, then carries on to the log', function () { + oweTerms(); + fakeApi([ + 'GET /api/v1/me' => [currentUserTermsJson(false)], + 'PUT /api/v1/me/terms' => [currentUserTermsJson(true, '2026-11-03T12:00:00Z')], + ]); + + Native::visit(AcceptTerms::Path)->tap('accept')->assertReplacedWith('/log'); + + expect(sentTo('PUT', 'me/terms'))->toBe([json_encode(['version' => '2026-11-01T14:35:00Z'])]) + ->and(app(Account::class)->termsToAccept())->toBeNull() + ->and(CurrentUser::fromArray(kept('me'))->termsAccepted)->toBeTrue(); +}); + +it('asks again when the terms changed while it was open, then accepts the new ones', function () { + oweTerms(); + serveApi(['GET /api/v1/me' => [currentUserTermsJson(false)], 'PUT /api/v1/me/terms' => staleTermsVersion()]); + + $screen = Native::visit(AcceptTerms::Path); + + serveApi(['GET /api/v1/me' => [currentUserTermsJson(false, version: '2026-12-01T09:00:00Z')], 'PUT /api/v1/me/terms' => staleTermsVersion()]); + + $screen->tap('accept') + ->assertNoNavigation() + ->assertSee(AcceptTerms::ChangedAgain) + ->assertSet('version', '2026-12-01T09:00:00Z') + ->assertSee('Our terms of service changed on Dec 1, 2026. Read them, and accept them to keep using /dev/null.'); + + serveApi(['GET /api/v1/me' => [currentUserTermsJson(false, version: '2026-12-01T09:00:00Z')], 'PUT /api/v1/me/terms' => [currentUserTermsJson(true, '2026-12-02T09:00:00Z', '2026-12-01T09:00:00Z')]]); + + $screen->tap('accept')->assertReplacedWith('/log'); + + expect(sentTo('PUT', 'me/terms'))->toBe([ + json_encode(['version' => '2026-11-01T14:35:00Z']), + json_encode(['version' => '2026-12-01T09:00:00Z']), + ]); +}); + +it('says why when devnull cannot take the yes, and stays', function () { + oweTerms(); + serveApi(['GET /api/v1/me' => [currentUserTermsJson(false)], 'PUT /api/v1/me/terms' => [['message' => 'Server Error'], 500]]); + + Native::visit(AcceptTerms::Path) + ->tap('accept') + ->assertNoNavigation() + ->assertSet('error', 'Server Error') + ->assertSee('Server Error'); + + expect(app(Account::class)->termsToAccept())->toBe('2026-11-01T14:35:00Z'); +}); + +it('lets you straight through when you accepted them on the web since', function () { + oweTerms(); + fakeApi(['GET /api/v1/me' => [currentUserTermsJson(true, '2026-11-02T09:00:00Z')]]); + + Native::visit(AcceptTerms::Path)->assertReplacedWith('/log'); + + expect(app(Account::class)->termsToAccept())->toBeNull(); +}); + +it('goes to the log when there are no terms to accept', function () { + fakeApi([]); + + Native::visit(AcceptTerms::Path)->assertReplacedWith('/log'); + + Http::assertNothingSent(); +}); + +it('opens the terms and privacy policy in the app', function () { + oweTerms(); + fakeApi(['GET /api/v1/me' => [currentUserTermsJson(false)]]); + + Native::visit(AcceptTerms::Path) + ->tap('terms-link') + ->assertNativeCalled('Browser.OpenInApp', fn (array $params): bool => $params['url'] === 'http://devnull.test/terms') + ->tap('privacy-link') + ->assertNativeCalled('Browser.OpenInApp', fn (array $params): bool => $params['url'] === 'http://devnull.test/privacy'); +}); + +it('signs out after a confirm, and forgets the terms owed', function () { + oweTerms(); + fakeApi(['GET /api/v1/me' => [currentUserTermsJson(false)]]); + + Native::visit(AcceptTerms::Path) + ->tap('sign-out') + ->assertNativeCalled('Dialog.Alert', fn (array $params): bool => $params['title'] === 'Sign out?') + ->emitNative(ButtonPressed::class, ['index' => 1, 'label' => 'Sign out', 'id' => AcceptTerms::SignOutAlert]) + ->assertReplacedWith('/onboarding'); + + expect(app(Account::class)->user())->toBeNull() + ->and(app(Account::class)->termsToAccept())->toBeNull(); +}); + +it('deletes the account after a confirm', function () { + oweTerms(); + fakeApi(['GET /api/v1/me' => [currentUserTermsJson(false)], 'DELETE /api/v1/me' => [null, 204]]); + + Native::visit(AcceptTerms::Path) + ->press('delete-account') + ->assertNativeCalled('Dialog.Alert', fn (array $params): bool => $params['title'] === 'Delete your account?') + ->emitNative(ButtonPressed::class, ['index' => 1, 'label' => 'Delete', 'id' => AcceptTerms::DeleteAccountAlert]) + ->assertReplacedWith('/onboarding'); + + expect(sentTo('DELETE', 'me'))->toHaveCount(1) + ->and(app(Account::class)->user())->toBeNull(); +}); diff --git a/tests/Feature/Screens/AccountTermsTest.php b/tests/Feature/Screens/AccountTermsTest.php new file mode 100644 index 00000000..db5e96cf --- /dev/null +++ b/tests/Feature/Screens/AccountTermsTest.php @@ -0,0 +1,72 @@ + 'http://devnull.test']); + signIn(); + app(Account::class)->rememberTimezone('Europe/Lisbon'); +}); + +function termsRow(TestableComponent $screen, string $ref): ?string +{ + $node = nodeByRef($screen, $ref); + + return $node['text'] ?? $node['props']['text'] ?? null; +} + +it('shows the terms in force beside the version you agreed to, just before deleting the account', function () { + fakeApi(['GET /api/v1/me' => [currentUserTermsJson(true, '2026-11-02T09:00:00Z')]]); + + $screen = Native::visit('/profile/device') + ->assertNoNavigation() + ->assertSee('Terms and privacy') + ->assertSee('current') + ->assertSee('agreed to') + ->assertSee('accepted'); + + $sections = findNodes($screen->tree(), fn (array $node): bool => in_array($node['ref'] ?? null, ['sign-out', 'terms-section', 'delete-section'], true)); + + expect(termsRow($screen, 'terms-current'))->toBe('Nov 1, 2026') + ->and(termsRow($screen, 'terms-agreed'))->toBe('Nov 1, 2026') + ->and(termsRow($screen, 'terms-accepted'))->toBe('Nov 2, 2026') + ->and(array_column($sections, 'ref'))->toBe(['sign-out', 'terms-section', 'delete-section']); +}); + +it('says when what you agreed to is not the version in force, until devnull sends you to accept it', function (?string $acceptedAt, string $agreed, ?string $accepted) { + keep('me', currentUserTermsJson(false, $acceptedAt)); + holdRefreshes(); + fakeApi(['GET /api/v1/me' => [currentUserTermsJson(false, $acceptedAt)]]); + + $screen = Native::visit('/profile/device'); + + expect(termsRow($screen, 'terms-current'))->toBe('Nov 1, 2026') + ->and(termsRow($screen, 'terms-agreed'))->toBe($agreed) + ->and(rendersRef($screen, 'terms-accepted') ? termsRow($screen, 'terms-accepted') : null)->toBe($accepted); + + finishRefreshes($screen)->assertReplacedWith(AcceptTerms::Path); +})->with([ + 'an older version' => ['2026-10-05T10:00:00Z', 'an older version', 'Oct 5, 2026'], + 'never accepted' => [null, 'none yet', null], +]); + +it('shows only the links when devnull does not say which terms are in force', function () { + fakeApi(['GET /api/v1/me' => [currentUserJson()]]); + + $screen = Native::visit('/profile/device')->assertSee('Terms of service')->assertSee('Privacy policy'); + + expect(rendersRef($screen, 'terms-current'))->toBeFalse(); +}); + +it('opens the terms and privacy policy in the app', function () { + fakeApi(['GET /api/v1/me' => [currentUserTermsJson(true, '2026-11-02T09:00:00Z')]]); + + Native::visit('/profile/device') + ->tap('terms-link') + ->assertNativeCalled('Browser.OpenInApp', fn (array $params): bool => $params['url'] === 'http://devnull.test/terms') + ->tap('privacy-link') + ->assertNativeCalled('Browser.OpenInApp', fn (array $params): bool => $params['url'] === 'http://devnull.test/privacy'); +}); diff --git a/tests/Feature/Screens/LaunchTest.php b/tests/Feature/Screens/LaunchTest.php index 77c5a8d9..57ea0653 100644 --- a/tests/Feature/Screens/LaunchTest.php +++ b/tests/Feature/Screens/LaunchTest.php @@ -2,6 +2,7 @@ use App\Devnull\Account; use App\Devnull\Data\User; +use App\NativeComponents\AcceptTerms; use App\NativeComponents\Launch; use Native\Mobile\Testing\Native; @@ -17,6 +18,13 @@ Native::visit('/')->assertReplacedWith('/log'); }); +it('sends a verified device to accept the terms first when devnull last said they are owed', function () { + signIn(); + app(Account::class)->rememberTermsToAccept('2026-11-01T14:35:00Z'); + + Native::visit('/')->assertReplacedWith(AcceptTerms::Path); +}); + it('stays signed in across a rebuild, which keeps the key and the stored sign-in', function () { $bridge = signIn(); diff --git a/tests/Feature/Screens/OnboardingSignInAgainTest.php b/tests/Feature/Screens/OnboardingSignInAgainTest.php index fec81c5e..91a4a9bd 100644 --- a/tests/Feature/Screens/OnboardingSignInAgainTest.php +++ b/tests/Feature/Screens/OnboardingSignInAgainTest.php @@ -3,6 +3,7 @@ use App\Devnull\Account; use App\Devnull\DeviceKey; use App\Devnull\ErrorCode; +use App\NativeComponents\AcceptTerms; use App\NativeComponents\Launch; use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; @@ -35,6 +36,18 @@ $bridge->assertNotCalled('SecureStorage.Set'); }); +it('signs straight back in to the terms when they are still to be accepted', function () { + fakeKeychain(testSeed()); + fakeApi(['GET /api/v1/me' => [currentUserTermsJson(false)]]); + + $screen = Native::visit('/onboarding'); + + $this->travel(1)->seconds(); + $screen->firePoll('tick')->assertReplacedWith(AcceptTerms::Path); + + expect(app(Account::class)->termsToAccept())->toBe('2026-11-01T14:35:00Z'); +}); + it('asks only once, and says nothing, when devnull does not know the key', function (int $status) { fakeKeychain(testSeed()); fakeApi(['GET /api/v1/me' => [['message' => 'Unauthenticated.'], $status]]); diff --git a/tests/Pest.php b/tests/Pest.php index 5d3b3500..b96e1793 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -458,6 +458,17 @@ function currentUserJson(array $overrides = [], array $extra = []): array ]; } +/** + * `GET /me` with the terms in force since Nov 1, 2026, or the version given, for someone who last accepted the terms + * when given (null for never), and whether that was this version. + * + * @return array + */ +function currentUserTermsJson(bool $accepted, ?string $acceptedAt = '2026-10-05T10:00:00Z', string $version = '2026-11-01T14:35:00Z'): array +{ + return currentUserJson(['terms_version' => $version, 'terms_accepted_at' => $acceptedAt, 'terms_accepted' => $accepted]); +} + /** * A commit as the API sends it to a signed request. The short hash and the link * follow the id and the author, and the body's segments follow the body, unless From e94e828b173dd1ec27aeb76a94bcec58b951074a Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Tue, 3 Nov 2026 16:43:45 +0000 Subject: [PATCH 4/4] Say which terms you accepted in one sentence in Settings The current, agreed to and accepted rows put two version names and one acceptance day side by side, all as dates, and agreed to always matched current because the terms screen takes over when they differ. One sentence says it plainly: "You accepted the terms of Nov 1, 2026 on Nov 3, 2026." Co-Authored-By: Claude Opus 5.5 --- app/NativeComponents/ThisDevice.php | 30 ++++++------- resources/views/native/this-device.blade.php | 26 +---------- tests/Feature/Screens/AccountTermsTest.php | 45 +++++++------------- 3 files changed, 31 insertions(+), 70 deletions(-) diff --git a/app/NativeComponents/ThisDevice.php b/app/NativeComponents/ThisDevice.php index f95378d2..f8516beb 100644 --- a/app/NativeComponents/ThisDevice.php +++ b/app/NativeComponents/ThisDevice.php @@ -36,8 +36,8 @@ * settings: your profile as devnull mirrors it from GitHub, with a sync, your * colour, your timezone, pastéis mode, Remotes and their settings, the people * you mute and block, then this phone's fingerprint, signing out, the terms - * and privacy policy with the version you agreed to beside the one in force, - * and deleting the account. + * and privacy policy with which version you accepted and when, and deleting + * the account. * Where the app talks to only shows when it isn't dev-null.sh. * * It opens on you as devnull last said (or as this phone remembers you) and @@ -669,12 +669,10 @@ public function render(): View } /** - * The terms in force, by the day they took effect, beside the version you agreed to and when. devnull keeps only - * when you accepted, so a version older than the one in force can't be named. Null until devnull has said. - * - * @return array{current: string, agreed: string, accepted: ?string}|null + * Which terms you accepted and when, each version named by the day it took effect. devnull keeps only when you + * accepted, so a version older than the one in force can't be named. Null until devnull has said. */ - private static function terms(CurrentUser $current): ?array + private static function terms(CurrentUser $current): ?string { $tookEffect = $current->termsTookEffect(); @@ -682,15 +680,15 @@ private static function terms(CurrentUser $current): ?array return null; } - return [ - 'current' => Display::day($tookEffect), - 'agreed' => match (true) { - $current->termsAccepted => Display::day($tookEffect), - $current->termsAcceptedAt === null => 'none yet', - default => 'an older version', - }, - 'accepted' => $current->termsAcceptedAt === null ? null : Display::day($current->termsAcceptedAt), - ]; + $terms = Display::day($tookEffect); + $acceptedOn = $current->termsAcceptedAt === null ? null : Display::day($current->termsAcceptedAt); + + return match (true) { + $current->termsAccepted && $acceptedOn !== null => "You accepted the terms of {$terms} on {$acceptedOn}.", + $current->termsAccepted => "You accepted the terms of {$terms}.", + $acceptedOn !== null => "You accepted an earlier version on {$acceptedOn}. The terms changed on {$terms}.", + default => "You haven't accepted the terms of {$terms} yet.", + }; } /** diff --git a/resources/views/native/this-device.blade.php b/resources/views/native/this-device.blade.php index 61d0abac..93c613b1 100644 --- a/resources/views/native/this-device.blade.php +++ b/resources/views/native/this-device.blade.php @@ -319,31 +319,7 @@ - @include('native.partials.settings-heading', [ - 'title' => 'Terms and privacy', - 'description' => 'What you agreed to, beside the terms in force now. Each version goes by the day it took effect.', - ]) - - @if ($terms !== null) - - - current - {{ $terms['current'] }} - - - - agreed to - {{ $terms['agreed'] }} - - @if ($terms['accepted'] !== null) - - - accepted - {{ $terms['accepted'] }} - - @endif - - @endif + @include('native.partials.settings-heading', ['title' => 'Terms and privacy', 'description' => $terms]) Terms of service diff --git a/tests/Feature/Screens/AccountTermsTest.php b/tests/Feature/Screens/AccountTermsTest.php index db5e96cf..74f00834 100644 --- a/tests/Feature/Screens/AccountTermsTest.php +++ b/tests/Feature/Screens/AccountTermsTest.php @@ -3,7 +3,6 @@ use App\Devnull\Account; use App\NativeComponents\AcceptTerms; use Native\Mobile\Testing\Native; -use Native\Mobile\Testing\TestableComponent; beforeEach(function () { config(['devnull.url' => 'http://devnull.test']); @@ -11,54 +10,42 @@ app(Account::class)->rememberTimezone('Europe/Lisbon'); }); -function termsRow(TestableComponent $screen, string $ref): ?string -{ - $node = nodeByRef($screen, $ref); - - return $node['text'] ?? $node['props']['text'] ?? null; -} - -it('shows the terms in force beside the version you agreed to, just before deleting the account', function () { - fakeApi(['GET /api/v1/me' => [currentUserTermsJson(true, '2026-11-02T09:00:00Z')]]); +it('says which terms you accepted and when, just before deleting the account', function (?string $acceptedAt, string $sentence) { + fakeApi(['GET /api/v1/me' => [currentUserTermsJson(true, $acceptedAt)]]); $screen = Native::visit('/profile/device') ->assertNoNavigation() ->assertSee('Terms and privacy') - ->assertSee('current') - ->assertSee('agreed to') - ->assertSee('accepted'); + ->assertSee($sentence); $sections = findNodes($screen->tree(), fn (array $node): bool => in_array($node['ref'] ?? null, ['sign-out', 'terms-section', 'delete-section'], true)); - expect(termsRow($screen, 'terms-current'))->toBe('Nov 1, 2026') - ->and(termsRow($screen, 'terms-agreed'))->toBe('Nov 1, 2026') - ->and(termsRow($screen, 'terms-accepted'))->toBe('Nov 2, 2026') - ->and(array_column($sections, 'ref'))->toBe(['sign-out', 'terms-section', 'delete-section']); -}); + expect(array_column($sections, 'ref'))->toBe(['sign-out', 'terms-section', 'delete-section']); +})->with([ + 'with the day' => ['2026-11-02T09:00:00Z', 'You accepted the terms of Nov 1, 2026 on Nov 2, 2026.'], + 'without the day' => [null, 'You accepted the terms of Nov 1, 2026.'], +]); -it('says when what you agreed to is not the version in force, until devnull sends you to accept it', function (?string $acceptedAt, string $agreed, ?string $accepted) { +it('says when you have not accepted the terms in force, until devnull sends you to accept them', function (?string $acceptedAt, string $sentence) { keep('me', currentUserTermsJson(false, $acceptedAt)); holdRefreshes(); fakeApi(['GET /api/v1/me' => [currentUserTermsJson(false, $acceptedAt)]]); - $screen = Native::visit('/profile/device'); - - expect(termsRow($screen, 'terms-current'))->toBe('Nov 1, 2026') - ->and(termsRow($screen, 'terms-agreed'))->toBe($agreed) - ->and(rendersRef($screen, 'terms-accepted') ? termsRow($screen, 'terms-accepted') : null)->toBe($accepted); + $screen = Native::visit('/profile/device')->assertSee($sentence); finishRefreshes($screen)->assertReplacedWith(AcceptTerms::Path); })->with([ - 'an older version' => ['2026-10-05T10:00:00Z', 'an older version', 'Oct 5, 2026'], - 'never accepted' => [null, 'none yet', null], + 'an earlier version' => ['2026-10-05T10:00:00Z', 'You accepted an earlier version on Oct 5, 2026. The terms changed on Nov 1, 2026.'], + 'never accepted' => [null, "You haven't accepted the terms of Nov 1, 2026 yet."], ]); it('shows only the links when devnull does not say which terms are in force', function () { fakeApi(['GET /api/v1/me' => [currentUserJson()]]); - $screen = Native::visit('/profile/device')->assertSee('Terms of service')->assertSee('Privacy policy'); - - expect(rendersRef($screen, 'terms-current'))->toBeFalse(); + Native::visit('/profile/device') + ->assertSee('Terms of service') + ->assertSee('Privacy policy') + ->assertDontSee('accepted'); }); it('opens the terms and privacy policy in the app', function () {