diff --git a/app/Devnull/Client.php b/app/Devnull/Client.php index c4da605..17aa2f2 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 0b585aa..59e57d1 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 c20b669..c29a5fc 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 04bfbf8..1f644bc 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 b418b36..be53cd8 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 9ea9bd6..e91bb66 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', + ]; +}