One-tap GitHub connect via a baked-in OAuth App client id

Previously "Connect GitHub" (OAuth device flow) still required entering an
OAuth App client id and owner/repo — friction that returned on every
reinstall once shared_prefs were wiped.

- Bake the app's own device-flow OAuth App client id in as
  SyncSettings.defaultClientId and default to it in load() (alongside the
  existing kuhyx/todo-sync repo default). A device-flow client id is a
  public identifier, not a secret, so it is safe to commit.
- Settings now leads with a single "Connect GitHub" button; the manual
  client-id / token fields and Test connection move under an "Advanced"
  expander. Result: fresh install (or post-reinstall) is one tap →
  authorize the code in the browser → synced. No tokens, no setup.

Note: an OAuth App authorizes with the classic `repo` scope (all repos),
broader than the prior fine-grained PAT — the trade-off for one-tap
device-flow convenience. 151 tests, 100% line coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-06-15 22:21:34 +02:00
parent 6db9ee11d0
commit f5d79a6a57
4 changed files with 82 additions and 59 deletions

View File

@ -19,10 +19,15 @@ class SyncSettings {
final String token; final String token;
/// GitHub OAuth App client id used by the device-flow "Connect" button. /// GitHub OAuth App client id used by the device-flow "Connect" button.
/// Not a secret (device flow needs no client secret), so it is safe to /// Not a secret (device flow needs no client secret), so it is safe to ship
/// store here and could later be shipped as a compile-time default. /// as a compile-time default and commit to source see [defaultClientId].
final String clientId; final String clientId;
/// The app's own GitHub OAuth App (device-flow enabled) client id, baked in
/// so "Connect GitHub" works with zero setup even after a reinstall. A
/// device-flow client id is a public identifier, not a secret.
static const defaultClientId = 'Ov23li9tF2R46PqzJgch';
/// True when enough is set to attempt a sync. /// True when enough is set to attempt a sync.
bool get isConfigured => bool get isConfigured =>
owner.isNotEmpty && repo.isNotEmpty && token.isNotEmpty; owner.isNotEmpty && repo.isNotEmpty && token.isNotEmpty;
@ -35,15 +40,16 @@ class SyncSettings {
static const _kToken = 'sync.token'; static const _kToken = 'sync.token';
static const _kClientId = 'sync.clientId'; static const _kClientId = 'sync.clientId';
/// Loads settings, defaulting the repo to `kuhyx/todo-sync` so the user /// Loads settings, defaulting the repo to `kuhyx/todo-sync` and the client id
/// only has to authorize on first run. /// to the baked-in [defaultClientId] so first run (and any reinstall) needs
/// nothing but a single "Connect GitHub" tap.
static Future<SyncSettings> load() async { static Future<SyncSettings> load() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
return SyncSettings( return SyncSettings(
owner: prefs.getString(_kOwner) ?? 'kuhyx', owner: prefs.getString(_kOwner) ?? 'kuhyx',
repo: prefs.getString(_kRepo) ?? 'todo-sync', repo: prefs.getString(_kRepo) ?? 'todo-sync',
token: prefs.getString(_kToken) ?? '', token: prefs.getString(_kToken) ?? '',
clientId: prefs.getString(_kClientId) ?? '', clientId: prefs.getString(_kClientId) ?? defaultClientId,
); );
} }

View File

@ -215,6 +215,23 @@ class _SettingsScreenState extends State<SettingsScreen> {
body: ListView( body: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
Text(
'Connect to GitHub',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
'Authorize in your browser — no token to paste. Syncs to '
'kuhyx/todo-sync by default.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _connectGitHub,
icon: const Icon(Icons.login),
label: const Text('Connect GitHub'),
),
const SizedBox(height: 16),
TextField( TextField(
controller: _owner, controller: _owner,
decoration: const InputDecoration( decoration: const InputDecoration(
@ -230,65 +247,58 @@ class _SettingsScreenState extends State<SettingsScreen> {
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
), ),
const SizedBox(height: 24),
Text(
'Connect with GitHub',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8), const SizedBox(height: 8),
TextField( // Manual escape hatches most users never open this.
controller: _clientId, ExpansionTile(
decoration: const InputDecoration( title: const Text('Advanced'),
labelText: 'OAuth App client id', tilePadding: EdgeInsets.zero,
helperText: 'From your GitHub OAuth App (device flow enabled)', childrenPadding: const EdgeInsets.only(bottom: 8),
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _connectGitHub,
icon: const Icon(Icons.login),
label: const Text('Connect GitHub'),
),
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 8),
Text(
'Or paste a token (fallback)',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
TextField(
controller: _token,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Access token (fine-grained PAT)',
helperText: 'Contents: read/write on the sync repo only',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
Row(
children: [ children: [
OutlinedButton.icon( TextField(
onPressed: _testing ? null : _test, controller: _clientId,
icon: _testing decoration: const InputDecoration(
? const SizedBox( labelText: 'OAuth App client id',
width: 16, helperText:
height: 16, 'Leave as the baked-in default unless self-hosting',
child: CircularProgressIndicator(strokeWidth: 2), border: OutlineInputBorder(),
) ),
: const Icon(Icons.wifi_tethering),
label: const Text('Test connection'),
), ),
const Spacer(), const SizedBox(height: 12),
FilledButton.icon( TextField(
onPressed: _save, controller: _token,
icon: const Icon(Icons.save), obscureText: true,
label: const Text('Save'), decoration: const InputDecoration(
labelText: 'Access token (fallback)',
helperText: 'Contents: read/write on the sync repo only',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerLeft,
child: OutlinedButton.icon(
onPressed: _testing ? null : _test,
icon: _testing
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.wifi_tethering),
label: const Text('Test connection'),
),
), ),
], ],
), ),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: FilledButton.icon(
onPressed: _save,
icon: const Icon(Icons.save),
label: const Text('Save'),
),
),
const SizedBox(height: 24), const SizedBox(height: 24),
const Divider(), const Divider(),
const SizedBox(height: 8), const SizedBox(height: 8),

View File

@ -130,6 +130,8 @@ void main() {
final mock = MockClient((_) async => http.Response('{}', 200)); final mock = MockClient((_) async => http.Response('{}', 200));
await pumpSettings(tester, httpClient: mock); await pumpSettings(tester, httpClient: mock);
await tester.tap(find.text('Advanced')); // Test connection lives here now
await tester.pumpAndSettle();
await tester.tap(find.text('Test connection')); await tester.tap(find.text('Test connection'));
await tester.pump(); // start await tester.pump(); // start
await tester.pump(); // resolve future + rebuild await tester.pump(); // resolve future + rebuild
@ -141,6 +143,8 @@ void main() {
final mock = MockClient((_) async => http.Response('', 404)); final mock = MockClient((_) async => http.Response('', 404));
await pumpSettings(tester, httpClient: mock); await pumpSettings(tester, httpClient: mock);
await tester.tap(find.text('Advanced'));
await tester.pumpAndSettle();
await tester.tap(find.text('Test connection')); await tester.tap(find.text('Test connection'));
await tester.pump(); await tester.pump();
await tester.pump(); await tester.pump();
@ -152,6 +156,8 @@ void main() {
final mock = MockClient((_) async => throw Exception('offline')); final mock = MockClient((_) async => throw Exception('offline'));
await pumpSettings(tester, httpClient: mock); await pumpSettings(tester, httpClient: mock);
await tester.tap(find.text('Advanced'));
await tester.pumpAndSettle();
await tester.tap(find.text('Test connection')); await tester.tap(find.text('Test connection'));
await tester.pump(); await tester.pump();
await tester.pump(); await tester.pump();

View File

@ -11,7 +11,8 @@ void main() {
expect(s.owner, 'kuhyx'); expect(s.owner, 'kuhyx');
expect(s.repo, 'todo-sync'); expect(s.repo, 'todo-sync');
expect(s.token, ''); expect(s.token, '');
expect(s.clientId, ''); // Client id defaults to the baked-in OAuth App id (one-tap connect).
expect(s.clientId, SyncSettings.defaultClientId);
}, },
); );