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;
/// 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
/// store here and could later be shipped as a compile-time default.
/// Not a secret (device flow needs no client secret), so it is safe to ship
/// as a compile-time default and commit to source see [defaultClientId].
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.
bool get isConfigured =>
owner.isNotEmpty && repo.isNotEmpty && token.isNotEmpty;
@ -35,15 +40,16 @@ class SyncSettings {
static const _kToken = 'sync.token';
static const _kClientId = 'sync.clientId';
/// Loads settings, defaulting the repo to `kuhyx/todo-sync` so the user
/// only has to authorize on first run.
/// Loads settings, defaulting the repo to `kuhyx/todo-sync` and the client id
/// to the baked-in [defaultClientId] so first run (and any reinstall) needs
/// nothing but a single "Connect GitHub" tap.
static Future<SyncSettings> load() async {
final prefs = await SharedPreferences.getInstance();
return SyncSettings(
owner: prefs.getString(_kOwner) ?? 'kuhyx',
repo: prefs.getString(_kRepo) ?? 'todo-sync',
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(
padding: const EdgeInsets.all(16),
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(
controller: _owner,
decoration: const InputDecoration(
@ -230,47 +247,36 @@ class _SettingsScreenState extends State<SettingsScreen> {
border: OutlineInputBorder(),
),
),
const SizedBox(height: 24),
Text(
'Connect with GitHub',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
// Manual escape hatches most users never open this.
ExpansionTile(
title: const Text('Advanced'),
tilePadding: EdgeInsets.zero,
childrenPadding: const EdgeInsets.only(bottom: 8),
children: [
TextField(
controller: _clientId,
decoration: const InputDecoration(
labelText: 'OAuth App client id',
helperText: 'From your GitHub OAuth App (device flow enabled)',
helperText:
'Leave as the baked-in default unless self-hosting',
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)',
labelText: 'Access token (fallback)',
helperText: 'Contents: read/write on the sync repo only',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
Row(
children: [
OutlinedButton.icon(
const SizedBox(height: 12),
Align(
alignment: Alignment.centerLeft,
child: OutlinedButton.icon(
onPressed: _testing ? null : _test,
icon: _testing
? const SizedBox(
@ -281,13 +287,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
: const Icon(Icons.wifi_tethering),
label: const Text('Test connection'),
),
const Spacer(),
FilledButton.icon(
),
],
),
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 Divider(),

View File

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

View File

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