From 7bdad6152befcffcb4fd1a1ea822ce3db7644e36 Mon Sep 17 00:00:00 2001 From: Jess Moore Date: Wed, 22 Jul 2026 16:34:12 +1000 Subject: [PATCH 1/6] add webid view and edit in user settings --- lib/solidui.dart | 3 + lib/src/services/solid_webid_service.dart | 135 ++++++++ lib/src/widgets/solid_link_pod_dialog.dart | 346 +++++++++++++++++++++ lib/src/widgets/solid_profile_editor.dart | 27 +- lib/src/widgets/solid_webid_section.dart | 174 +++++++++++ 5 files changed, 683 insertions(+), 2 deletions(-) create mode 100644 lib/src/services/solid_webid_service.dart create mode 100644 lib/src/widgets/solid_link_pod_dialog.dart create mode 100644 lib/src/widgets/solid_webid_section.dart diff --git a/lib/solidui.dart b/lib/solidui.dart index 1e5301cf..13908b8c 100644 --- a/lib/solidui.dart +++ b/lib/solidui.dart @@ -97,6 +97,9 @@ export 'src/widgets/solid_owner_avatar.dart'; export 'src/widgets/solid_profile_avatar.dart'; export 'src/widgets/solid_profile_crop_dialog.dart'; export 'src/widgets/solid_profile_editor.dart'; +export 'src/widgets/solid_webid_section.dart'; +export 'src/widgets/solid_link_pod_dialog.dart'; +export 'src/services/solid_webid_service.dart'; export 'src/widgets/secret_text_field.dart'; export 'src/widgets/security_key_ui.dart'; diff --git a/lib/src/services/solid_webid_service.dart b/lib/src/services/solid_webid_service.dart new file mode 100644 index 00000000..09c5207f --- /dev/null +++ b/lib/src/services/solid_webid_service.dart @@ -0,0 +1,135 @@ +/// Service for reading and patching the user's own WebID document. +/// +/// Copyright (C) 2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +library; + +import 'dart:convert' show utf8; + +import 'package:solidpod/solidpod.dart'; + +/// The `solid:` terms namespace used for OIDC issuer discovery and +/// registration-token verification on a WebID document. + +const String _solidTermsNs = 'http://www.w3.org/ns/solid/terms#'; + +const String _oidcIssuerPredicate = '${_solidTermsNs}oidcIssuer'; +const String _oidcIssuerRegistrationTokenPredicate = + '${_solidTermsNs}oidcIssuerRegistrationToken'; + +/// Reads and patches the RDF triples on the logged-in user's own WebID +/// document — specifically the `solid:oidcIssuerRegistrationToken` and +/// `solid:oidcIssuer` triples used to link the WebID to another Solid Pod +/// server's login. All writes are targeted SPARQL PATCH updates so the rest +/// of the WebID document (name, storage, other triples) is left untouched. + +class SolidWebIdService { + SolidWebIdService._(); + + static final SolidWebIdService _instance = SolidWebIdService._(); + + /// Singleton accessor. + + static SolidWebIdService get instance => _instance; + + /// Resolves the logged-in user's WebID together with its document URL + /// (the WebID with any `#fragment` stripped). + + Future<({String webId, String docUrl})> _current() async { + final webId = await getWebId(); + if (webId == null || webId.isEmpty) { + throw StateError('Cannot resolve the WebID: not logged in.'); + } + return (webId: webId, docUrl: webId.split('#').first); + } + + /// Fetches the raw turtle content of the logged-in user's own WebID + /// document. + + Future fetchWebIdTurtle() async { + if (!await isUserLoggedIn()) { + throw StateError('You must be logged in to view your WebID.'); + } + final docUrl = (await _current()).docUrl; + final bytes = await getResource(docUrl); + return utf8.decode(bytes); + } + + /// Looks for a pending `solid:oidcIssuerRegistrationToken` triple in + /// [turtle] (as previously fetched by [fetchWebIdTurtle]) and returns its + /// literal value, or `null` when none is present. + + String? findPendingRegistrationToken(String turtle) { + Map> map; + try { + map = turtleToTripleMap(turtle); + } catch (_) { + return null; + } + for (final entry in map.values) { + final value = entry[_oidcIssuerRegistrationTokenPredicate]; + if (value is String && value.trim().isNotEmpty) return value; + if (value is Iterable && value.isNotEmpty) { + final first = value.first; + if (first is String && first.trim().isNotEmpty) return first; + } + } + return null; + } + + /// Adds a `solid:oidcIssuerRegistrationToken` triple carrying [token] to + /// the user's WebID document, proving ownership to another Pod server + /// that is being linked. + + Future addRegistrationToken(String token) async { + final (:webId, :docUrl) = await _current(); + final escaped = _escapeLiteral(token); + final query = 'INSERT DATA ' + '{<$webId> <$_oidcIssuerRegistrationTokenPredicate> "$escaped"};'; + await updateFileByQuery(docUrl, query); + } + + /// Removes any `solid:oidcIssuerRegistrationToken` triple and adds a + /// `solid:oidcIssuer` triple pointing at [podIssuerUrl], completing a Pod + /// link once the other Pod server has verified the token. + + Future completeLink(String podIssuerUrl) async { + final (:webId, :docUrl) = await _current(); + final issuerUrl = podIssuerUrl.endsWith('/') + ? podIssuerUrl.substring(0, podIssuerUrl.length - 1) + : podIssuerUrl; + final query = 'DELETE {<$webId> <$_oidcIssuerRegistrationTokenPredicate>' + ' ?o} WHERE {<$webId> <$_oidcIssuerRegistrationTokenPredicate> ?o};' + ' INSERT DATA {<$webId> <$_oidcIssuerPredicate> <$issuerUrl>};'; + await updateFileByQuery(docUrl, query); + } + + // Escapes characters that would otherwise break out of the SPARQL string + // literal when interpolating untrusted user input (the registration + // token). + + String _escapeLiteral(String value) => + value.replaceAll('\\', '\\\\').replaceAll('"', '\\"'); +} diff --git a/lib/src/widgets/solid_link_pod_dialog.dart b/lib/src/widgets/solid_link_pod_dialog.dart new file mode 100644 index 00000000..211c509b --- /dev/null +++ b/lib/src/widgets/solid_link_pod_dialog.dart @@ -0,0 +1,346 @@ +/// A dialog for linking the user's WebID to another Solid Pod server. +/// +/// Copyright (C) 2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +library; + +import 'package:flutter/material.dart'; + +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:form_builder_validators/form_builder_validators.dart'; +import 'package:gap/gap.dart'; + +import 'package:solidui/src/services/solid_webid_service.dart'; +import 'package:solidui/src/utils/snack_bar.dart'; + +/// Which step of the Pod-linking flow the dialog is currently showing. + +enum _LinkStage { + /// Collect the target Pod's server URL and registration token, then add + /// the `solid:oidcIssuerRegistrationToken` proof-of-ownership triple. + + addToken, + + /// A registration token is already present on the WebID (just added, or + /// found on reopen) — collect/confirm the Pod URL and finish the link by + /// removing the token and adding the `solid:oidcIssuer` triple. + + finish, +} + +/// Guides the user through linking their WebID to another Solid Pod server: +/// +/// 1. **Add Token** — writes the `oidcIssuerRegistrationToken` triple the +/// other Pod's account page asked for, so the user can go verify it +/// there. +/// 2. **Finish Linking** — once verified externally, removes the token +/// triple and adds a `solid:oidcIssuer` triple pointing at that Pod, so +/// Solid-OIDC-aware apps can discover it from the WebID. +/// +/// Reopening the dialog re-detects a pending token already on the WebID and +/// jumps straight to the finish step. + +class SolidLinkPodDialog extends StatefulWidget { + const SolidLinkPodDialog({super.key}); + + /// Opens the dialog. + + static Future show(BuildContext context) => showDialog( + context: context, + builder: (_) => const SolidLinkPodDialog(), + ); + + @override + State createState() => _SolidLinkPodDialogState(); +} + +class _SolidLinkPodDialogState extends State { + static const _podUrlField = 'pod_url'; + static const _tokenField = 'registration_token'; + + final _formKey = GlobalKey(); + + bool _initialising = true; + bool _busy = false; + String? _message; + bool _messageIsError = false; + + _LinkStage _stage = _LinkStage.addToken; + String? _podUrl; + + @override + void initState() { + super.initState(); + _detectStage(); + } + + Future _detectStage() async { + try { + final turtle = await SolidWebIdService.instance.fetchWebIdTurtle(); + final pending = + SolidWebIdService.instance.findPendingRegistrationToken(turtle); + if (mounted && pending != null) { + setState(() => _stage = _LinkStage.finish); + } + } catch (_) { + // Fall back to the add-token stage; the form itself will surface any + // real error on submit. + } finally { + if (mounted) setState(() => _initialising = false); + } + } + + void _setMessage(String message, {bool error = false}) { + if (!mounted) return; + setState(() { + _message = message; + _messageIsError = error; + }); + } + + // Stage 1: add the registration token proving WebID ownership. + + Future _handleAddToken() async { + final valid = _formKey.currentState?.saveAndValidate() ?? false; + if (!valid) return; + + final values = _formKey.currentState!.value; + final podUrl = values[_podUrlField].toString().trim(); + final token = values[_tokenField].toString().trim(); + + setState(() { + _busy = true; + _message = null; + }); + + try { + await SolidWebIdService.instance.addRegistrationToken(token); + setState(() { + _podUrl = podUrl; + _stage = _LinkStage.finish; + }); + _setMessage( + 'Verification token added. Now go back to "$podUrl" and click ' + '"Link WebID" again there to verify. Once verified, click ' + '"Finish Linking" below.', + ); + } on Object catch (e) { + _setMessage('Failed to add the verification token: $e', error: true); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + // Stage 2: remove the token and register the Pod as an OIDC issuer. + + Future _handleFinishLinking() async { + final podUrl = _podUrl ?? _formKey.currentState?.value[_podUrlField]; + if (podUrl == null || podUrl.toString().trim().isEmpty) { + final valid = _formKey.currentState?.saveAndValidate() ?? false; + if (!valid) return; + } + final resolvedPodUrl = + (_podUrl ?? _formKey.currentState!.value[_podUrlField].toString()) + .trim(); + + setState(() { + _busy = true; + _message = null; + }); + + try { + await SolidWebIdService.instance.completeLink(resolvedPodUrl); + if (mounted) { + Navigator.of(context).pop(); + showSnackBar( + context, + 'Linked your WebID to "$resolvedPodUrl".', + Colors.green, + ); + } + } on Object catch (e) { + _setMessage('Failed to finish linking: $e', error: true); + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return AlertDialog( + title: const Row( + children: [ + Icon(Icons.link), + SizedBox(width: 12), + Text('Link another Pod'), + ], + ), + content: SizedBox( + width: 460, + child: SingleChildScrollView( + child: _initialising + ? const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center(child: CircularProgressIndicator()), + ) + : FormBuilder( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _stage == _LinkStage.addToken + ? 'Enter the Pod server you want to link and the ' + 'verification token it gave you. This adds a ' + 'temporary triple to your WebID proving you ' + 'own it.' + : 'Enter the Pod server you are linking (if not ' + 'already filled in). This removes the ' + 'temporary verification token and registers ' + 'the Pod server as a login issuer on your WebID.', + style: TextStyle(color: cs.onSurfaceVariant), + ), + const Gap(16), + if (_message != null) ...[ + _MessageBanner( + message: _message!, + isError: _messageIsError, + colorScheme: cs, + ), + const Gap(12), + ], + FormBuilderTextField( + name: _podUrlField, + initialValue: _podUrl, + enabled: !_busy, + decoration: const InputDecoration( + labelText: 'Pod Server URL', + hintText: 'https://pods.example.org', + border: OutlineInputBorder(), + ), + validator: FormBuilderValidators.compose([ + FormBuilderValidators.required( + errorText: 'Please enter the Pod server URL.', + ), + FormBuilderValidators.url( + errorText: 'Please enter a valid URL.', + ), + ]), + ), + if (_stage == _LinkStage.addToken) ...[ + const Gap(12), + FormBuilderTextField( + name: _tokenField, + enabled: !_busy, + decoration: const InputDecoration( + labelText: 'Registration Token', + border: OutlineInputBorder(), + ), + validator: FormBuilderValidators.required( + errorText: 'Please enter the registration token.', + ), + ), + ], + if (_busy) ...[ + const Gap(20), + const Center(child: CircularProgressIndicator()), + ], + ], + ), + ), + ), + ), + actions: [ + TextButton( + onPressed: _busy ? null : () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + if (!_initialising) + FilledButton( + onPressed: _busy + ? null + : (_stage == _LinkStage.addToken + ? _handleAddToken + : _handleFinishLinking), + child: Text( + _stage == _LinkStage.addToken ? 'Add Token' : 'Finish Linking', + ), + ), + ], + ); + } +} + +// A compact status banner mirroring solid_backup_dialog.dart's message +// style. + +class _MessageBanner extends StatelessWidget { + const _MessageBanner({ + required this.message, + required this.isError, + required this.colorScheme, + }); + + final String message; + final bool isError; + final ColorScheme colorScheme; + + @override + Widget build(BuildContext context) { + final background = + isError ? colorScheme.errorContainer : colorScheme.secondaryContainer; + final foreground = isError + ? colorScheme.onErrorContainer + : colorScheme.onSecondaryContainer; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + isError ? Icons.error_outline : Icons.check_circle_outline, + size: 20, + color: foreground, + ), + const Gap(8), + Expanded( + child: Text( + message, + style: TextStyle(color: foreground), + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/widgets/solid_profile_editor.dart b/lib/src/widgets/solid_profile_editor.dart index 37aaad37..f1093dc4 100644 --- a/lib/src/widgets/solid_profile_editor.dart +++ b/lib/src/widgets/solid_profile_editor.dart @@ -33,11 +33,13 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:file_picker/file_picker.dart'; +import 'package:solidpod/solidpod.dart' show isUserLoggedIn; import 'package:solidui/src/services/solid_profile_notifier.dart'; import 'package:solidui/src/services/solid_profile_service.dart'; import 'package:solidui/src/widgets/solid_profile_avatar.dart'; import 'package:solidui/src/widgets/solid_profile_crop_dialog.dart'; +import 'package:solidui/src/widgets/solid_webid_section.dart'; /// A dialog that lets the user upload/change/delete a profile picture and /// set a display name. Changes are persisted to the user's Solid POD. @@ -65,6 +67,7 @@ class _SolidProfileEditorState extends State { bool _avatarRemoved = false; bool _isSaving = false; late SolidProfilePrivacy _pendingPrivacy; + bool _loggedIn = false; @override void initState() { @@ -74,6 +77,13 @@ class _SolidProfileEditorState extends State { ); _pendingAvatar = solidProfileNotifier.avatarBytes; _pendingPrivacy = solidProfileNotifier.privacy; + + // The WebID section reads/writes the user's own Pod, so it only makes + // sense to show it while a session is active. + + isUserLoggedIn().then((loggedIn) { + if (mounted) setState(() => _loggedIn = loggedIn); + }); } @override @@ -248,8 +258,11 @@ class _SolidProfileEditorState extends State { return Dialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 400), - child: Padding( + constraints: BoxConstraints( + maxWidth: 400, + maxHeight: MediaQuery.of(context).size.height * 0.9, + ), + child: SingleChildScrollView( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, @@ -336,6 +349,16 @@ class _SolidProfileEditorState extends State { _buildPrivacySelector(theme), + if (_loggedIn) ...[ + const SizedBox(height: 16), + + // WebID viewer and Pod-linking entry point. Only shown while + // logged in since it reads/writes the user's own WebID + // document on their Pod. + + const SolidWebIdSection(), + ], + const SizedBox(height: 24), // Action buttons. diff --git a/lib/src/widgets/solid_webid_section.dart b/lib/src/widgets/solid_webid_section.dart new file mode 100644 index 00000000..e8428f11 --- /dev/null +++ b/lib/src/widgets/solid_webid_section.dart @@ -0,0 +1,174 @@ +/// Read-only WebID viewer and Pod-linking entry point for the Settings +/// dialog. +/// +/// Copyright (C) 2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +library; + +import 'package:flutter/material.dart'; + +import 'package:solidpod/solidpod.dart' show getWebId; + +import 'package:solidui/src/services/solid_webid_service.dart'; +import 'package:solidui/src/widgets/solid_link_pod_dialog.dart'; + +/// Shows the logged-in user's WebID and the raw turtle content of their +/// WebID document, with a "Link another Pod" action that opens +/// [SolidLinkPodDialog] to add proof-of-ownership and OIDC-issuer triples +/// needed to register the WebID as a login on another Solid Pod server. + +class SolidWebIdSection extends StatefulWidget { + const SolidWebIdSection({super.key}); + + @override + State createState() => _SolidWebIdSectionState(); +} + +class _SolidWebIdSectionState extends State { + bool _loading = true; + String? _webId; + String? _turtle; + String? _error; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() => _loading = true); + try { + final webId = await getWebId(); + final turtle = await SolidWebIdService.instance.fetchWebIdTurtle(); + if (!mounted) return; + setState(() { + _webId = webId; + _turtle = turtle; + _error = null; + }); + } catch (e) { + if (!mounted) return; + setState(() => _error = 'Failed to load your WebID: $e'); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _openLinkPodDialog() async { + await SolidLinkPodDialog.show(context); + + // Refresh so the displayed turtle reflects any triples the dialog added + // or removed. + + if (mounted) await _load(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + border: Border.all(color: theme.dividerColor), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.badge_outlined, + size: 18, + color: theme.colorScheme.onSurface, + ), + const SizedBox(width: 8), + Text( + 'Your WebID', + style: theme.textTheme.bodyMedium + ?.copyWith(fontWeight: FontWeight.w600), + ), + ], + ), + if (_webId != null) ...[ + const SizedBox(height: 4), + Text( + _webId!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall + ?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ], + const SizedBox(height: 8), + if (_loading) + const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ) + else if (_error != null) + Text( + _error!, + style: TextStyle(color: theme.colorScheme.error), + ) + else + Container( + width: double.infinity, + constraints: const BoxConstraints(maxHeight: 160), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: SingleChildScrollView( + child: SelectableText( + _turtle ?? '', + style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + ), + ), + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: _loading ? null : _openLinkPodDialog, + icon: const Icon(Icons.link, size: 18), + label: const Text('Link another Pod'), + ), + ), + ], + ), + ); + } +} From 35361a110fa859092b520f14ec4043da273272b3 Mon Sep 17 00:00:00 2001 From: Jess Moore Date: Wed, 22 Jul 2026 17:32:14 +1000 Subject: [PATCH 2/6] check whether additional pod server is already linked --- lib/src/services/solid_webid_service.dart | 46 +++++++++++++++++----- lib/src/widgets/solid_link_pod_dialog.dart | 17 ++++++++ 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/lib/src/services/solid_webid_service.dart b/lib/src/services/solid_webid_service.dart index 09c5207f..4a6ffed6 100644 --- a/lib/src/services/solid_webid_service.dart +++ b/lib/src/services/solid_webid_service.dart @@ -82,21 +82,49 @@ class SolidWebIdService { /// literal value, or `null` when none is present. String? findPendingRegistrationToken(String turtle) { + final values = _valuesOf(turtle, _oidcIssuerRegistrationTokenPredicate); + return values.isEmpty ? null : values.first; + } + + /// Returns every `solid:oidcIssuer` already registered in [turtle] (as + /// previously fetched by [fetchWebIdTurtle]), normalised (trailing slash + /// stripped) for comparison against a candidate Pod URL. + + List currentOidcIssuers(String turtle) => + _valuesOf(turtle, _oidcIssuerPredicate).map(_normalizeUrl).toList(); + + // Collects every literal/URI value of [predicate] on any subject in the + // (already-fetched) [turtle]. + + List _valuesOf(String turtle, String predicate) { Map> map; try { map = turtleToTripleMap(turtle); } catch (_) { - return null; + return const []; } + final values = []; for (final entry in map.values) { - final value = entry[_oidcIssuerRegistrationTokenPredicate]; - if (value is String && value.trim().isNotEmpty) return value; - if (value is Iterable && value.isNotEmpty) { - final first = value.first; - if (first is String && first.trim().isNotEmpty) return first; + final value = entry[predicate]; + if (value is String && value.trim().isNotEmpty) { + values.add(value); + } else if (value is Iterable) { + for (final item in value) { + if (item is String && item.trim().isNotEmpty) values.add(item); + } } } - return null; + return values; + } + + // Strips a trailing slash and surrounding whitespace so Pod URLs compare + // equal regardless of how they were entered/stored. + + String _normalizeUrl(String url) { + final trimmed = url.trim(); + return trimmed.endsWith('/') + ? trimmed.substring(0, trimmed.length - 1) + : trimmed; } /// Adds a `solid:oidcIssuerRegistrationToken` triple carrying [token] to @@ -117,9 +145,7 @@ class SolidWebIdService { Future completeLink(String podIssuerUrl) async { final (:webId, :docUrl) = await _current(); - final issuerUrl = podIssuerUrl.endsWith('/') - ? podIssuerUrl.substring(0, podIssuerUrl.length - 1) - : podIssuerUrl; + final issuerUrl = _normalizeUrl(podIssuerUrl); final query = 'DELETE {<$webId> <$_oidcIssuerRegistrationTokenPredicate>' ' ?o} WHERE {<$webId> <$_oidcIssuerRegistrationTokenPredicate> ?o};' ' INSERT DATA {<$webId> <$_oidcIssuerPredicate> <$issuerUrl>};'; diff --git a/lib/src/widgets/solid_link_pod_dialog.dart b/lib/src/widgets/solid_link_pod_dialog.dart index 211c509b..1bc675e0 100644 --- a/lib/src/widgets/solid_link_pod_dialog.dart +++ b/lib/src/widgets/solid_link_pod_dialog.dart @@ -136,6 +136,23 @@ class _SolidLinkPodDialogState extends State { }); try { + // Re-fetch immediately before writing so a Pod added since the dialog + // opened is still caught. + + final turtle = await SolidWebIdService.instance.fetchWebIdTurtle(); + final existingIssuers = + SolidWebIdService.instance.currentOidcIssuers(turtle); + final normalizedPodUrl = podUrl.endsWith('/') + ? podUrl.substring(0, podUrl.length - 1) + : podUrl; + if (existingIssuers.contains(normalizedPodUrl)) { + _setMessage( + '"$podUrl" is already linked as an OIDC issuer on your WebID.', + error: true, + ); + return; + } + await SolidWebIdService.instance.addRegistrationToken(token); setState(() { _podUrl = podUrl; From a18da587ddce1277f83733f379cf1196a63e3320 Mon Sep 17 00:00:00 2001 From: Jess Moore Date: Wed, 22 Jul 2026 17:32:40 +1000 Subject: [PATCH 3/6] make webid hyperlinked and selectable --- lib/src/widgets/solid_webid_section.dart | 29 +++++++++++++++++++----- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/lib/src/widgets/solid_webid_section.dart b/lib/src/widgets/solid_webid_section.dart index e8428f11..8d957c7e 100644 --- a/lib/src/widgets/solid_webid_section.dart +++ b/lib/src/widgets/solid_webid_section.dart @@ -30,6 +30,7 @@ library; import 'package:flutter/material.dart'; import 'package:solidpod/solidpod.dart' show getWebId; +import 'package:url_launcher/url_launcher.dart'; import 'package:solidui/src/services/solid_webid_service.dart'; import 'package:solidui/src/widgets/solid_link_pod_dialog.dart'; @@ -86,6 +87,17 @@ class _SolidWebIdSectionState extends State { if (mounted) await _load(); } + // Opens the WebID in the default browser, mirroring solid_status_bar.dart. + + Future _launchWebId() async { + final webId = _webId; + if (webId == null) return; + final uri = Uri.parse(webId); + if (await canLaunchUrl(uri)) { + await launchUrl(uri); + } + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -117,12 +129,17 @@ class _SolidWebIdSectionState extends State { ), if (_webId != null) ...[ const SizedBox(height: 4), - Text( - _webId!, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall - ?.copyWith(color: theme.colorScheme.onSurfaceVariant), + MouseRegion( + cursor: SystemMouseCursors.click, + child: SelectableText( + _webId!, + maxLines: 2, + onTap: _launchWebId, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.primary, + decoration: TextDecoration.underline, + ), + ), ), ], const SizedBox(height: 8), From 065ce60c1aed8a4d515d78c91cf4420e249a0f62 Mon Sep 17 00:00:00 2001 From: Jess Moore Date: Wed, 22 Jul 2026 19:40:22 +1000 Subject: [PATCH 4/6] scale dialog width to app window --- lib/src/widgets/solid_profile_editor.dart | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/src/widgets/solid_profile_editor.dart b/lib/src/widgets/solid_profile_editor.dart index f1093dc4..c2b6b15b 100644 --- a/lib/src/widgets/solid_profile_editor.dart +++ b/lib/src/widgets/solid_profile_editor.dart @@ -255,12 +255,20 @@ class _SolidProfileEditorState extends State { final theme = Theme.of(context); final hasAvatar = _pendingAvatar != null && _pendingAvatar!.isNotEmpty; + // Scale the dialog with the window instead of a fixed width — the raw + // WebID turtle content in SolidWebIdSection is much easier to read with + // more horizontal room on larger windows, while narrow/mobile windows + // still get a dialog sized to fit comfortably. + + final windowSize = MediaQuery.of(context).size; + final dialogWidth = (windowSize.width * 0.9).clamp(320.0, 640.0); + return Dialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), child: ConstrainedBox( constraints: BoxConstraints( - maxWidth: 400, - maxHeight: MediaQuery.of(context).size.height * 0.9, + maxWidth: dialogWidth, + maxHeight: windowSize.height * 0.9, ), child: SingleChildScrollView( padding: const EdgeInsets.all(24), From efcfae360288adcf658f9d4d5eb733e9f51f4b70 Mon Sep 17 00:00:00 2001 From: Jess Moore Date: Wed, 22 Jul 2026 23:59:40 +1000 Subject: [PATCH 5/6] fix typo --- lib/src/widgets/solid_link_pod_dialog.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/widgets/solid_link_pod_dialog.dart b/lib/src/widgets/solid_link_pod_dialog.dart index 1bc675e0..3799fda9 100644 --- a/lib/src/widgets/solid_link_pod_dialog.dart +++ b/lib/src/widgets/solid_link_pod_dialog.dart @@ -160,7 +160,7 @@ class _SolidLinkPodDialogState extends State { }); _setMessage( 'Verification token added. Now go back to "$podUrl" and click ' - '"Link WebID" again there to verify. Once verified, click ' + '"Link WebID to account" again there to verify. Once verified, click ' '"Finish Linking" below.', ); } on Object catch (e) { From 67e83f3cff07affeb2a495f55a7eb5a6dbe28ceb Mon Sep 17 00:00:00 2001 From: Jess Moore Date: Thu, 23 Jul 2026 10:28:47 +1000 Subject: [PATCH 6/6] support use case to relink webid with new token --- lib/src/services/solid_webid_service.dart | 19 ++++++ lib/src/widgets/solid_link_pod_dialog.dart | 71 +++++++++++++++++++++- 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/lib/src/services/solid_webid_service.dart b/lib/src/services/solid_webid_service.dart index 4a6ffed6..01c0b911 100644 --- a/lib/src/services/solid_webid_service.dart +++ b/lib/src/services/solid_webid_service.dart @@ -139,6 +139,25 @@ class SolidWebIdService { await updateFileByQuery(docUrl, query); } + /// Removes the `solid:oidcIssuer` triple pointing at [podIssuerUrl] and + /// adds a new `solid:oidcIssuerRegistrationToken` triple carrying [token], + /// in a single PATCH. Used to relink a Pod that is already registered as + /// an issuer — e.g. after the account on that Pod server was recreated and + /// needs to be linked again from scratch. + + Future removeIssuerAndAddToken( + String podIssuerUrl, + String token, + ) async { + final (:webId, :docUrl) = await _current(); + final issuerUrl = _normalizeUrl(podIssuerUrl); + final escaped = _escapeLiteral(token); + final query = 'DELETE DATA {<$webId> <$_oidcIssuerPredicate>' + ' <$issuerUrl>}; INSERT DATA {<$webId>' + ' <$_oidcIssuerRegistrationTokenPredicate> "$escaped"};'; + await updateFileByQuery(docUrl, query); + } + /// Removes any `solid:oidcIssuerRegistrationToken` triple and adds a /// `solid:oidcIssuer` triple pointing at [podIssuerUrl], completing a Pod /// link once the other Pod server has verified the token. diff --git a/lib/src/widgets/solid_link_pod_dialog.dart b/lib/src/widgets/solid_link_pod_dialog.dart index 3799fda9..bfce10b9 100644 --- a/lib/src/widgets/solid_link_pod_dialog.dart +++ b/lib/src/widgets/solid_link_pod_dialog.dart @@ -90,6 +90,13 @@ class _SolidLinkPodDialogState extends State { _LinkStage _stage = _LinkStage.addToken; String? _podUrl; + // True once _handleAddToken has detected that the entered Pod URL is + // already registered as a solid:oidcIssuer — the primary action then + // switches to removing that issuer triple before adding the token, so the + // Pod can be relinked from scratch. + + bool _issuerAlreadyLinked = false; + @override void initState() { super.initState(); @@ -146,8 +153,12 @@ class _SolidLinkPodDialogState extends State { ? podUrl.substring(0, podUrl.length - 1) : podUrl; if (existingIssuers.contains(normalizedPodUrl)) { + setState(() => _issuerAlreadyLinked = true); _setMessage( - '"$podUrl" is already linked as an OIDC issuer on your WebID.', + '"$podUrl" is already linked as an OIDC issuer on your WebID. If ' + 'you need to relink this Pod (e.g. its account was recreated), ' + 'click "Remove Issuer & Add Token" below to remove the existing ' + 'link and start over.', error: true, ); return; @@ -170,6 +181,46 @@ class _SolidLinkPodDialogState extends State { } } + // Stage 1 (relink path): the Pod is already registered as an issuer — + // remove that triple and add the registration token in one PATCH, so the + // Pod can be re-verified from scratch. + + Future _handleRemoveIssuerAndAddToken() async { + final valid = _formKey.currentState?.saveAndValidate() ?? false; + if (!valid) return; + + final values = _formKey.currentState!.value; + final podUrl = values[_podUrlField].toString().trim(); + final token = values[_tokenField].toString().trim(); + + setState(() { + _busy = true; + _message = null; + }); + + try { + await SolidWebIdService.instance.removeIssuerAndAddToken(podUrl, token); + setState(() { + _podUrl = podUrl; + _stage = _LinkStage.finish; + _issuerAlreadyLinked = false; + }); + _setMessage( + 'Removed the existing issuer link and added a new verification ' + 'token. Now go back to "$podUrl" and click "Link WebID to account" ' + 'again there to verify. Once verified, click "Finish Linking" ' + 'below.', + ); + } on Object catch (e) { + _setMessage( + 'Failed to remove the existing issuer link: $e', + error: true, + ); + } finally { + if (mounted) setState(() => _busy = false); + } + } + // Stage 2: remove the token and register the Pod as an OIDC issuer. Future _handleFinishLinking() async { @@ -259,6 +310,14 @@ class _SolidLinkPodDialogState extends State { hintText: 'https://pods.example.org', border: OutlineInputBorder(), ), + // A stale "already linked" warning (and its Remove + // Issuer action) no longer applies once the URL + // changes. + onChanged: (_) { + if (_issuerAlreadyLinked) { + setState(() => _issuerAlreadyLinked = false); + } + }, validator: FormBuilderValidators.compose([ FormBuilderValidators.required( errorText: 'Please enter the Pod server URL.', @@ -301,10 +360,16 @@ class _SolidLinkPodDialogState extends State { onPressed: _busy ? null : (_stage == _LinkStage.addToken - ? _handleAddToken + ? (_issuerAlreadyLinked + ? _handleRemoveIssuerAndAddToken + : _handleAddToken) : _handleFinishLinking), child: Text( - _stage == _LinkStage.addToken ? 'Add Token' : 'Finish Linking', + _stage == _LinkStage.addToken + ? (_issuerAlreadyLinked + ? 'Remove Issuer & Add Token' + : 'Add Token') + : 'Finish Linking', ), ), ],