]> git.proxmox.com Git - flutter/proxmox_login_manager.git/blob - lib/proxmox_login_form.dart
6db841726e019e918cc0476831296743fd1eb1c3
[flutter/proxmox_login_manager.git] / lib / proxmox_login_form.dart
1 import 'dart:io';
2 import 'dart:async';
3
4 import 'package:flutter/material.dart';
5 import 'package:collection/collection.dart';
6 import 'package:proxmox_dart_api_client/proxmox_dart_api_client.dart'
7 as proxclient;
8 import 'package:proxmox_dart_api_client/proxmox_dart_api_client.dart';
9 import 'package:proxmox_login_manager/proxmox_general_settings_form.dart';
10 import 'package:proxmox_login_manager/proxmox_general_settings_model.dart';
11 import 'package:proxmox_login_manager/proxmox_login_model.dart';
12 import 'package:proxmox_login_manager/proxmox_tfa_form.dart';
13 import 'package:proxmox_login_manager/extension.dart';
14 import 'package:proxmox_login_manager/proxmox_password_store.dart';
15
16 class ProxmoxProgressModel {
17 int inProgress = 0;
18 String message = 'Loading...';
19 ProxmoxProgressModel({
20 this.inProgress = 0,
21 this.message = 'Loading...',
22 });
23 }
24
25 // FIXME: copied from pve_flutter_frontend, re-use common set
26 class ProxmoxColors {
27 static const Color orange = Color(0xFFE57000);
28 static const Color supportGrey = Color(0xFFABBABA);
29 static const Color supportBlue = Color(0xFF00617F);
30 }
31
32 class ProxmoxLoginForm extends StatefulWidget {
33 final TextEditingController originController;
34 final FormFieldValidator<String> originValidator;
35 final TextEditingController usernameController;
36 final TextEditingController passwordController;
37 final List<PveAccessDomainModel?>? accessDomains;
38 final PveAccessDomainModel? selectedDomain;
39 final ValueChanged<PveAccessDomainModel?> onDomainChanged;
40 final Function? onPasswordSubmitted;
41 final Function onOriginSubmitted;
42 final Function? onSavePasswordChanged;
43 final bool? canSavePassword;
44 final bool? passwordSaved;
45
46 const ProxmoxLoginForm({
47 super.key,
48 required this.originController,
49 required this.usernameController,
50 required this.passwordController,
51 required this.accessDomains,
52 required this.originValidator,
53 this.selectedDomain,
54 required this.onDomainChanged,
55 this.onPasswordSubmitted,
56 required this.onOriginSubmitted,
57 this.onSavePasswordChanged,
58 this.canSavePassword,
59 this.passwordSaved,
60 });
61
62 @override
63 State<ProxmoxLoginForm> createState() => _ProxmoxLoginFormState();
64 }
65
66 class _ProxmoxLoginFormState extends State<ProxmoxLoginForm> {
67 bool _obscure = true;
68 bool? _savePwCheckbox;
69 FocusNode? passwordFocusNode;
70
71 @override
72 Widget build(BuildContext context) {
73 if (widget.accessDomains == null) {
74 return TextFormField(
75 decoration: const InputDecoration(
76 icon: Icon(Icons.vpn_lock),
77 labelText: 'Origin',
78 hintText: 'e.g. 192.168.1.2',
79 helperText:
80 'Protocol (https) and default port (8006 or 443) implied'),
81 textInputAction: TextInputAction.next,
82 controller: widget.originController,
83 validator: widget.originValidator,
84 onFieldSubmitted: (value) => widget.onOriginSubmitted(),
85 );
86 }
87
88 return AutofillGroup(
89 child: Column(
90 mainAxisAlignment: MainAxisAlignment.center,
91 children: [
92 TextFormField(
93 decoration: const InputDecoration(
94 icon: Icon(Icons.vpn_lock),
95 labelText: 'Origin',
96 ),
97 controller: widget.originController,
98 enabled: false,
99 ),
100 TextFormField(
101 decoration: const InputDecoration(
102 icon: Icon(Icons.person),
103 labelText: 'Username',
104 ),
105 controller: widget.usernameController,
106 validator: (value) {
107 if (value!.isEmpty) {
108 return 'Please enter username';
109 }
110 return null;
111 },
112 autofillHints: const [AutofillHints.username],
113 ),
114 DropdownButtonFormField(
115 decoration: const InputDecoration(icon: Icon(Icons.domain)),
116 items: widget.accessDomains!
117 .map((e) => DropdownMenuItem(
118 value: e,
119 child: ListTile(
120 title: Text(e!.realm),
121 subtitle: Text(e.comment ?? ''),
122 ),
123 ))
124 .toList(),
125 onChanged: widget.onDomainChanged,
126 selectedItemBuilder: (context) =>
127 widget.accessDomains!.map((e) => Text(e!.realm)).toList(),
128 value: widget.selectedDomain,
129 ),
130 Stack(
131 children: [
132 TextFormField(
133 decoration: const InputDecoration(
134 icon: Icon(Icons.lock),
135 labelText: 'Password',
136 ),
137 controller: widget.passwordController,
138 obscureText: _obscure,
139 autocorrect: false,
140 focusNode: passwordFocusNode,
141 validator: (value) {
142 if (value!.isEmpty) {
143 return 'Please enter password';
144 }
145 return null;
146 },
147 onFieldSubmitted: (value) => widget.onPasswordSubmitted!(),
148 autofillHints: const [AutofillHints.password],
149 ),
150 Align(
151 alignment: Alignment.bottomRight,
152 child: IconButton(
153 constraints: BoxConstraints.tight(const Size(58, 58)),
154 iconSize: 24,
155 icon:
156 Icon(_obscure ? Icons.visibility : Icons.visibility_off),
157 onPressed: () => setState(() {
158 _obscure = !_obscure;
159 }),
160 ),
161 )
162 ],
163 ),
164 if (widget.canSavePassword ?? false)
165 CheckboxListTile(
166 title: const Text('Save password'),
167 value: _savePwCheckbox ?? widget.passwordSaved ?? false,
168 onChanged: (value) {
169 if (widget.onSavePasswordChanged != null) {
170 widget.onSavePasswordChanged!(value!);
171 }
172 setState(() {
173 _savePwCheckbox = value!;
174 });
175 },
176 )
177 ],
178 ),
179 );
180 }
181
182 @override
183 void dispose() {
184 passwordFocusNode?.dispose();
185 super.dispose();
186 }
187 }
188
189 class ProxmoxLoginPage extends StatefulWidget {
190 final ProxmoxLoginModel? userModel;
191 final bool? isCreate;
192 final String? ticket;
193 final String? password;
194
195 const ProxmoxLoginPage({
196 super.key,
197 this.userModel,
198 this.isCreate,
199 this.ticket = '',
200 this.password,
201 });
202 @override
203 State<ProxmoxLoginPage> createState() => _ProxmoxLoginPageState();
204 }
205
206 class _ProxmoxLoginPageState extends State<ProxmoxLoginPage> {
207 final _originController = TextEditingController();
208 final _usernameController = TextEditingController();
209 final _passwordController = TextEditingController();
210 Future<List<PveAccessDomainModel?>?>? _accessDomains;
211 PveAccessDomainModel? _selectedDomain;
212 final _formKey = GlobalKey<FormState>();
213 ProxmoxProgressModel _progressModel = ProxmoxProgressModel();
214 bool _submittButtonEnabled = true;
215 bool _canSavePassword = false;
216 bool _savePasswordCB = false;
217
218 @override
219 void initState() {
220 super.initState();
221 final userModel = widget.userModel;
222 _progressModel = ProxmoxProgressModel();
223 if (!widget.isCreate! && userModel != null) {
224 _originController.text = userModel.origin?.toString() ?? '';
225 // Uri does not append 443 for https, so we do it manually
226 if (userModel.origin != null &&
227 userModel.origin!.scheme == "https" &&
228 userModel.origin!.port == 443) {
229 _originController.text += ":443";
230 }
231 _passwordController.text = widget.password ?? '';
232 _accessDomains = _getAccessDomains();
233 _usernameController.text = userModel.username!;
234 _savePasswordCB = widget.password != null;
235 if ((widget.ticket!.isNotEmpty && userModel.activeSession) ||
236 widget.password != null) {
237 _onLoginButtonPressed(ticket: widget.ticket!, mRealm: userModel.realm);
238 }
239 }
240 }
241
242 @override
243 Widget build(BuildContext context) {
244 return Theme(
245 //data: ThemeData.dark().copyWith(accentColor: Color(0xFFE47225)),
246 data: ThemeData.dark().copyWith(
247 textButtonTheme: TextButtonThemeData(
248 style: TextButton.styleFrom(
249 foregroundColor: Colors.white,
250 backgroundColor: ProxmoxColors.orange,
251 disabledBackgroundColor: Colors.grey,
252 ),
253 ),
254 colorScheme: const ColorScheme.dark().copyWith(
255 primary: ProxmoxColors.orange,
256 secondary: ProxmoxColors.orange,
257 onSecondary: ProxmoxColors.supportGrey),
258 checkboxTheme: CheckboxThemeData(
259 fillColor: MaterialStateProperty.resolveWith<Color?>(
260 (Set<MaterialState> states) {
261 if (states.contains(MaterialState.disabled)) {
262 return null;
263 }
264 if (states.contains(MaterialState.selected)) {
265 return ProxmoxColors.orange;
266 }
267 return null;
268 }),
269 ),
270 radioTheme: RadioThemeData(
271 fillColor: MaterialStateProperty.resolveWith<Color?>(
272 (Set<MaterialState> states) {
273 if (states.contains(MaterialState.disabled)) {
274 return null;
275 }
276 if (states.contains(MaterialState.selected)) {
277 return ProxmoxColors.orange;
278 }
279 return null;
280 }),
281 ),
282 switchTheme: SwitchThemeData(
283 thumbColor: MaterialStateProperty.resolveWith<Color?>(
284 (Set<MaterialState> states) {
285 if (states.contains(MaterialState.disabled)) {
286 return null;
287 }
288 if (states.contains(MaterialState.selected)) {
289 return ProxmoxColors.orange;
290 }
291 return null;
292 }),
293 trackColor: MaterialStateProperty.resolveWith<Color?>(
294 (Set<MaterialState> states) {
295 if (states.contains(MaterialState.disabled)) {
296 return null;
297 }
298 if (states.contains(MaterialState.selected)) {
299 return ProxmoxColors.orange;
300 }
301 return null;
302 }),
303 ),
304 ),
305 child: Scaffold(
306 backgroundColor: ProxmoxColors.supportBlue,
307 extendBodyBehindAppBar: true,
308 appBar: AppBar(
309 elevation: 0.0,
310 backgroundColor: Colors.transparent,
311 leading: IconButton(
312 icon: const Icon(Icons.close),
313 onPressed: () => Navigator.of(context).pop(),
314 ),
315 ),
316 body: Stack(
317 children: [
318 SingleChildScrollView(
319 child: ConstrainedBox(
320 constraints: BoxConstraints.tightFor(
321 height: MediaQuery.of(context).size.height),
322 child: SafeArea(
323 child: Padding(
324 padding: const EdgeInsets.all(8.0),
325 child: FutureBuilder<List<PveAccessDomainModel?>?>(
326 future: _accessDomains,
327 builder: (context, snapshot) {
328 return Form(
329 key: _formKey,
330 onChanged: () {
331 setState(() {
332 _submittButtonEnabled =
333 _formKey.currentState!.validate();
334 });
335 },
336 child: Column(
337 mainAxisAlignment: MainAxisAlignment.center,
338 children: [
339 Expanded(
340 child: Column(
341 mainAxisAlignment: MainAxisAlignment.center,
342 children: [
343 Image.asset(
344 'assets/images/proxmox_logo_symbol_wordmark.png',
345 package: 'proxmox_login_manager',
346 ),
347 ],
348 ),
349 ),
350 ProxmoxLoginForm(
351 originController: _originController,
352 originValidator: (value) {
353 if (value == null || value.isEmpty) {
354 return 'Please enter origin';
355 }
356 try {
357 normalizeUrl(value);
358 return null;
359 } on FormatException catch (_) {
360 return 'Invalid URI';
361 } on Exception catch (e) {
362 return 'Invalid URI: $e';
363 }
364 },
365 usernameController: _usernameController,
366 passwordController: _passwordController,
367 accessDomains: snapshot.data,
368 selectedDomain: _selectedDomain,
369 onSavePasswordChanged: (value) {
370 _savePasswordCB = value;
371 },
372 canSavePassword: _canSavePassword,
373 passwordSaved: widget.password != null,
374 onDomainChanged: (value) {
375 setState(() {
376 _selectedDomain = value;
377 });
378 },
379 onOriginSubmitted: () {
380 final isValid =
381 _formKey.currentState!.validate();
382 setState(() {
383 _submittButtonEnabled = isValid;
384 });
385 if (isValid) {
386 setState(() {
387 _accessDomains = _getAccessDomains();
388 });
389 }
390 },
391 onPasswordSubmitted: _submittButtonEnabled
392 ? () {
393 final isValid =
394 _formKey.currentState!.validate();
395 setState(() {
396 _submittButtonEnabled = isValid;
397 });
398 if (isValid) {
399 _onLoginButtonPressed();
400 }
401 }
402 : null,
403 ),
404 Expanded(
405 child: Align(
406 alignment: Alignment.bottomCenter,
407 child: SizedBox(
408 width: MediaQuery.of(context).size.width,
409 child: TextButton(
410 onPressed: _submittButtonEnabled
411 ? () {
412 final isValid = _formKey
413 .currentState!
414 .validate();
415 setState(() {
416 _submittButtonEnabled =
417 isValid;
418 });
419 if (isValid) {
420 if (snapshot.hasData) {
421 _onLoginButtonPressed();
422 } else {
423 setState(() {
424 _accessDomains =
425 _getAccessDomains();
426 });
427 }
428 }
429 }
430 : null,
431 child: const Text('Continue'),
432 ),
433 ),
434 ),
435 ),
436 ],
437 ),
438 );
439 }),
440 ),
441 ),
442 ),
443 ),
444 if (_progressModel.inProgress > 0)
445 ProxmoxProgressOverlay(message: _progressModel.message),
446 ],
447 ),
448 ),
449 );
450 }
451
452 Future<void> _onLoginButtonPressed(
453 {String ticket = '', String? mRealm}) async {
454 setState(() {
455 _progressModel
456 ..inProgress += 1
457 ..message = 'Authenticating...';
458 });
459
460 try {
461 final settings = await ProxmoxGeneralSettingsModel.fromLocalStorage();
462 //cleaned form fields
463 final origin = normalizeUrl(_originController.text.trim());
464 final username = _usernameController.text.trim();
465 final String enteredPassword = _passwordController.text.trim();
466 final String? savedPassword = widget.password;
467
468 final password = ticket.isNotEmpty ? ticket : enteredPassword;
469 final realm = _selectedDomain?.realm ?? mRealm;
470
471 var client = await proxclient.authenticate(
472 '$username@$realm', password, origin, settings.sslValidation!);
473
474 if (client.credentials.tfa != null &&
475 client.credentials.tfa!.kinds().isNotEmpty) {
476 if (!mounted) return;
477 ProxmoxApiClient? tfaclient =
478 await Navigator.of(context).push(MaterialPageRoute(
479 builder: (context) => ProxmoxTfaForm(
480 apiClient: client,
481 ),
482 ));
483
484 if (tfaclient != null) {
485 client = tfaclient;
486 } else {
487 setState(() {
488 _progressModel.inProgress -= 1;
489 });
490 return;
491 }
492 }
493
494 final status = await client.getClusterStatus();
495 final hostname =
496 status.singleWhereOrNull((element) => element.local ?? false)?.name;
497 var loginStorage = await ProxmoxLoginStorage.fromLocalStorage();
498
499 final savePW = enteredPassword != '' &&
500 _savePasswordCB &&
501 enteredPassword != savedPassword;
502 final deletePW = enteredPassword != '' && !savePW && !_savePasswordCB;
503 String? id;
504
505 if (widget.isCreate!) {
506 final newLogin = ProxmoxLoginModel((b) => b
507 ..origin = origin
508 ..username = username
509 ..realm = realm
510 ..productType = ProxmoxProductType.pve
511 ..ticket = client.credentials.ticket
512 ..passwordSaved = savePW
513 ..hostname = hostname);
514
515 loginStorage = loginStorage!.rebuild((b) => b..logins.add(newLogin));
516 id = newLogin.identifier;
517 } else {
518 loginStorage = loginStorage!.rebuild((b) => b
519 ..logins.rebuildWhere(
520 (m) => m == widget.userModel,
521 (b) => b
522 ..ticket = client.credentials.ticket
523 ..passwordSaved =
524 savePW || (deletePW ? false : b.passwordSaved ?? false)
525 ..hostname = hostname));
526 id = widget.userModel!.identifier;
527 }
528
529 if (id != null) {
530 try {
531 if (savePW) {
532 await savePassword(id, enteredPassword);
533 } else if (deletePW) {
534 await deletePassword(id);
535 }
536 } catch (e) {
537 if (!mounted) return;
538 await showDialog(
539 context: context,
540 builder: (context) => AlertDialog(
541 title: const Text('Password saving error'),
542 scrollable: true,
543 content: Column(
544 mainAxisSize: MainAxisSize.min,
545 children: [
546 const Text('Could not save or delete password.'),
547 ExpansionTile(
548 title: const Text('Details'),
549 children: [Text(e.toString())],
550 )
551 ],
552 ),
553 actions: [
554 TextButton(
555 onPressed: () => Navigator.of(context).pop(),
556 child: const Text('Continue')),
557 ],
558 ));
559 }
560 }
561 await loginStorage.saveToDisk();
562
563 if (mounted) {
564 Navigator.of(context).pop(client);
565 }
566 } on proxclient.ProxmoxApiException catch (e) {
567 print(e);
568 if (!mounted) return;
569 if (e.message.contains('No ticket')) {
570 showDialog(
571 context: context,
572 builder: (context) => AlertDialog(
573 title: const Text('Version Error'),
574 content: const Text(
575 'Proxmox VE version not supported, please update your instance to use this app.'),
576 actions: [
577 TextButton(
578 onPressed: () => Navigator.of(context).pop(),
579 child: const Text('Close'),
580 ),
581 ],
582 ),
583 );
584 } else {
585 showDialog(
586 context: context,
587 builder: (context) => ProxmoxApiErrorDialog(
588 exception: e,
589 ),
590 );
591 }
592 } catch (e, trace) {
593 print(e);
594 print(trace);
595 if (mounted) {
596 if (e.runtimeType == HandshakeException) {
597 showDialog(
598 context: context,
599 builder: (context) => const ProxmoxCertificateErrorDialog(),
600 );
601 } else {
602 showDialog(
603 context: context,
604 builder: (context) => ConnectionErrorDialog(exception: e),
605 );
606 }
607 }
608 }
609 setState(() {
610 _progressModel.inProgress -= 1;
611 });
612 }
613
614 Future<List<PveAccessDomainModel?>?> _loadAccessDomains(Uri uri) async {
615 final settings = await ProxmoxGeneralSettingsModel.fromLocalStorage();
616 List<PveAccessDomainModel?>? response;
617 try {
618 response = await proxclient.accessDomains(uri, settings.sslValidation!);
619 } on proxclient.ProxmoxApiException catch (e) {
620 if (mounted) {
621 showDialog(
622 context: context,
623 builder: (context) => ProxmoxApiErrorDialog(
624 exception: e,
625 ),
626 );
627 }
628 } on HandshakeException {
629 if (mounted) {
630 showDialog(
631 context: context,
632 builder: (context) => const ProxmoxCertificateErrorDialog(),
633 );
634 }
635 }
636 return response;
637 }
638
639 Future<List<PveAccessDomainModel?>?> _tryLoadAccessDomains(Uri uri) async {
640 List<PveAccessDomainModel?>? response;
641 try {
642 response = await _loadAccessDomains(uri);
643 } catch (e) {
644 if (mounted) {
645 showDialog(
646 context: context,
647 builder: (context) => ConnectionErrorDialog(
648 exception: e,
649 ),
650 );
651 }
652 }
653 return response;
654 }
655
656 Future<List<PveAccessDomainModel?>?> _getAccessDomains() async {
657 setState(() {
658 _progressModel
659 ..inProgress += 1
660 ..message = 'Connecting...';
661 });
662
663 final canSavePW = await canSavePassword();
664 setState(() {
665 _canSavePassword = canSavePW;
666 });
667
668 var host = _originController.text.trim();
669 var apiBaseUrl = normalizeUrl(host);
670
671 RegExp portRE = RegExp(r":\d{1,5}$");
672
673 List<PveAccessDomainModel?>? response;
674
675 if (portRE.hasMatch(host)) {
676 response = await _tryLoadAccessDomains(apiBaseUrl);
677 } else {
678 // try to guess the port, 8006 first, and then 443
679 apiBaseUrl = apiBaseUrl.replace(port: 8006);
680 try {
681 response = await _loadAccessDomains(apiBaseUrl);
682 if (response != null) {
683 _originController.text = '$host:8006';
684 }
685 } catch (e) {
686 // we were no port given, and we couldn't reach on port 8006, retry with 443
687 apiBaseUrl = apiBaseUrl.replace(port: 443);
688 response = await _tryLoadAccessDomains(apiBaseUrl);
689 if (response != null) {
690 _originController.text = '$host:443';
691 }
692 }
693 }
694
695 response?.sort((a, b) => a!.realm.compareTo(b!.realm));
696
697 final selection = response?.singleWhere(
698 (e) => e!.realm == widget.userModel?.realm,
699 orElse: () => response?.first,
700 );
701
702 setState(() {
703 _progressModel.inProgress -= 1;
704 _selectedDomain = selection;
705 });
706
707 return response;
708 }
709
710 @override
711 void dispose() {
712 _originController.dispose();
713 _usernameController.dispose();
714 _passwordController.dispose();
715 super.dispose();
716 }
717 }
718
719 class ProxmoxProgressOverlay extends StatelessWidget {
720 const ProxmoxProgressOverlay({
721 super.key,
722 required this.message,
723 });
724
725 final String message;
726
727 @override
728 Widget build(BuildContext context) {
729 return Container(
730 decoration: BoxDecoration(color: Colors.black.withOpacity(0.5)),
731 child: Center(
732 child: Column(
733 mainAxisAlignment: MainAxisAlignment.center,
734 children: [
735 Text(
736 message,
737 style: const TextStyle(
738 fontSize: 20,
739 ),
740 ),
741 const Padding(
742 padding: EdgeInsets.only(top: 20.0),
743 child: CircularProgressIndicator(),
744 )
745 ],
746 ),
747 ),
748 );
749 }
750 }
751
752 class ConnectionErrorDialog extends StatelessWidget {
753 final Object exception;
754
755 const ConnectionErrorDialog({
756 super.key,
757 required this.exception,
758 });
759
760 @override
761 Widget build(BuildContext context) {
762 return AlertDialog(
763 title: const Text('Connection error'),
764 content: Text('Could not establish connection: $exception'),
765 actions: [
766 TextButton(
767 onPressed: () => Navigator.of(context).pop(),
768 child: const Text('Close'),
769 ),
770 ],
771 );
772 }
773 }
774
775 class ProxmoxApiErrorDialog extends StatelessWidget {
776 final proxclient.ProxmoxApiException exception;
777
778 const ProxmoxApiErrorDialog({
779 super.key,
780 required this.exception,
781 });
782
783 @override
784 Widget build(BuildContext context) {
785 return AlertDialog(
786 title: const Text('API Error'),
787 content: SingleChildScrollView(
788 child: Text(exception.message),
789 ),
790 actions: [
791 TextButton(
792 onPressed: () => Navigator.of(context).pop(),
793 child: const Text('Close'),
794 ),
795 ],
796 );
797 }
798 }
799
800 class ProxmoxCertificateErrorDialog extends StatelessWidget {
801 const ProxmoxCertificateErrorDialog({
802 super.key,
803 });
804
805 @override
806 Widget build(BuildContext context) {
807 return AlertDialog(
808 title: const Text('Certificate error'),
809 content: SingleChildScrollView(
810 child: Column(
811 crossAxisAlignment: CrossAxisAlignment.start,
812 children: [
813 const Text('Your connection is not private.'),
814 Text(
815 'Note: Consider to disable SSL validation,'
816 ' if you use a self signed, not commonly trusted, certificate.',
817 style: Theme.of(context).textTheme.bodySmall,
818 ),
819 ],
820 ),
821 ),
822 actions: [
823 TextButton(
824 onPressed: () => Navigator.of(context).pop(),
825 child: const Text('Close'),
826 ),
827 TextButton(
828 onPressed: () => Navigator.of(context).pushReplacement(
829 MaterialPageRoute(
830 builder: (context) => const ProxmoxGeneralSettingsForm())),
831 child: const Text('Settings'),
832 )
833 ],
834 );
835 }
836 }
837
838 Uri normalizeUrl(String urlText) {
839 if (urlText.startsWith('https://')) {
840 urlText = urlText.substring('https://'.length);
841 }
842 if (urlText.startsWith('http://')) {
843 throw Exception("HTTP without TLS is not supported");
844 }
845
846 return Uri.https(urlText, '');
847 }