mirror of
https://github.com/ente-io/ente.git
synced 2025-08-09 07:48:52 +00:00
[auth] Add hook to share code
This commit is contained in:
parent
af9e865745
commit
9ccb597e6e
@ -118,6 +118,7 @@
|
|||||||
"emailVerificationToggle": "Email verification",
|
"emailVerificationToggle": "Email verification",
|
||||||
"emailVerificationEnableWarning": "To avoid getting locked out of your account, be sure to store a copy of your email 2FA outside of Ente Auth before enabling email verification.",
|
"emailVerificationEnableWarning": "To avoid getting locked out of your account, be sure to store a copy of your email 2FA outside of Ente Auth before enabling email verification.",
|
||||||
"authToChangeEmailVerificationSetting": "Please authenticate to change email verification",
|
"authToChangeEmailVerificationSetting": "Please authenticate to change email verification",
|
||||||
|
"authenticateGeneric": "Please authenticate",
|
||||||
"authToViewYourRecoveryKey": "Please authenticate to view your recovery key",
|
"authToViewYourRecoveryKey": "Please authenticate to view your recovery key",
|
||||||
"authToChangeYourEmail": "Please authenticate to change your email",
|
"authToChangeYourEmail": "Please authenticate to change your email",
|
||||||
"authToChangeYourPassword": "Please authenticate to change your password",
|
"authToChangeYourPassword": "Please authenticate to change your password",
|
||||||
@ -211,6 +212,7 @@
|
|||||||
"scanAQrCode": "Scan a QR code",
|
"scanAQrCode": "Scan a QR code",
|
||||||
"enterDetailsManually": "Enter details manually",
|
"enterDetailsManually": "Enter details manually",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
|
"share": "Share",
|
||||||
"restore": "Restore",
|
"restore": "Restore",
|
||||||
"copiedToClipboard": "Copied to clipboard",
|
"copiedToClipboard": "Copied to clipboard",
|
||||||
"copiedNextToClipboard": "Copied next code to clipboard",
|
"copiedNextToClipboard": "Copied next code to clipboard",
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
import 'dart:ui' as ui;
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
import 'package:auto_size_text/auto_size_text.dart';
|
import 'package:auto_size_text/auto_size_text.dart';
|
||||||
@ -15,6 +17,7 @@ import 'package:ente_auth/services/preference_service.dart';
|
|||||||
import 'package:ente_auth/store/code_store.dart';
|
import 'package:ente_auth/store/code_store.dart';
|
||||||
import 'package:ente_auth/theme/ente_theme.dart';
|
import 'package:ente_auth/theme/ente_theme.dart';
|
||||||
import 'package:ente_auth/ui/code_timer_progress.dart';
|
import 'package:ente_auth/ui/code_timer_progress.dart';
|
||||||
|
import 'package:ente_auth/ui/share/code_share.dart';
|
||||||
import 'package:ente_auth/ui/utils/icon_utils.dart';
|
import 'package:ente_auth/ui/utils/icon_utils.dart';
|
||||||
import 'package:ente_auth/utils/dialog_util.dart';
|
import 'package:ente_auth/utils/dialog_util.dart';
|
||||||
import 'package:ente_auth/utils/platform_util.dart';
|
import 'package:ente_auth/utils/platform_util.dart';
|
||||||
@ -291,13 +294,13 @@ class _CodeWidgetState extends State<CodeWidget> {
|
|||||||
if (!widget.code.isTrashed) SizedBox(width: slideSpace),
|
if (!widget.code.isTrashed) SizedBox(width: slideSpace),
|
||||||
if (!widget.code.isTrashed)
|
if (!widget.code.isTrashed)
|
||||||
SlidableAction(
|
SlidableAction(
|
||||||
onPressed: _onShowQrPressed,
|
onPressed: _onSharePressed,
|
||||||
backgroundColor: Colors.grey.withOpacity(0.1),
|
backgroundColor: Colors.grey.withOpacity(0.1),
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||||
foregroundColor:
|
foregroundColor:
|
||||||
Theme.of(context).colorScheme.inverseBackgroundColor,
|
Theme.of(context).colorScheme.inverseBackgroundColor,
|
||||||
icon: Icons.qr_code_2_outlined,
|
icon: Icons.adaptive.share_outlined,
|
||||||
label: "QR",
|
label: l10n.share,
|
||||||
padding: const EdgeInsets.only(left: 4, right: 0),
|
padding: const EdgeInsets.only(left: 4, right: 0),
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
),
|
),
|
||||||
@ -605,6 +608,16 @@ class _CodeWidgetState extends State<CodeWidget> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _onSharePressed(_) async {
|
||||||
|
bool isAuthSuccessful = await LocalAuthenticationService.instance
|
||||||
|
.requestLocalAuthentication(context, context.l10n.authenticateGeneric);
|
||||||
|
await PlatformUtil.refocusWindows();
|
||||||
|
if (!isAuthSuccessful) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showShareDialog(context, widget.code);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _onPinPressed(_) async {
|
Future<void> _onPinPressed(_) async {
|
||||||
bool currentlyPinned = widget.code.isPinned;
|
bool currentlyPinned = widget.code.isPinned;
|
||||||
final display = widget.code.display;
|
final display = widget.code.display;
|
||||||
|
159
auth/lib/ui/share/code_share.dart
Normal file
159
auth/lib/ui/share/code_share.dart
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:math';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:dropdown_button2/dropdown_button2.dart';
|
||||||
|
import 'package:ente_auth/l10n/l10n.dart';
|
||||||
|
import 'package:ente_auth/models/code.dart';
|
||||||
|
import 'package:ente_auth/ui/components/buttons/button_widget.dart';
|
||||||
|
import 'package:ente_auth/ui/components/models/button_type.dart';
|
||||||
|
import 'package:ente_auth/utils/dialog_util.dart';
|
||||||
|
import 'package:ente_auth/utils/share_utils.dart';
|
||||||
|
import 'package:ente_auth/utils/totp_util.dart';
|
||||||
|
import 'package:ente_crypto_dart/ente_crypto_dart.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
|
class ShareCodeDialog extends StatefulWidget {
|
||||||
|
final Code code;
|
||||||
|
const ShareCodeDialog({super.key, required this.code});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ShareCodeDialog> createState() => _ShareCodeDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ShareCodeDialogState extends State<ShareCodeDialog> {
|
||||||
|
final Logger logger = Logger('_ShareCodeDialogState');
|
||||||
|
List<int> items = [5, 15, 30, 60];
|
||||||
|
|
||||||
|
String getItemLabel(int min) {
|
||||||
|
if (min == 60) return '1 hour';
|
||||||
|
if (min > 60) {
|
||||||
|
var hour = '${min ~/ 60}';
|
||||||
|
if (min % 60 == 0) return '$hour hour';
|
||||||
|
var minx = '${min % 60}';
|
||||||
|
return '$hour hr $minx min';
|
||||||
|
}
|
||||||
|
return '$min min';
|
||||||
|
}
|
||||||
|
|
||||||
|
int selectedValue = 15;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Share codes'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Select the duration for which you want to share codes.',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
DropdownButtonHideUnderline(
|
||||||
|
child: DropdownButton2(
|
||||||
|
hint: const Text('Select an option'),
|
||||||
|
items: items
|
||||||
|
.map(
|
||||||
|
(item) => DropdownMenuItem<int>(
|
||||||
|
value: item,
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text(getItemLabel(item)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
value: selectedValue,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
selectedValue = value ?? 15;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
ButtonWidget(
|
||||||
|
buttonType: ButtonType.primary,
|
||||||
|
buttonSize: ButtonSize.large,
|
||||||
|
labelText: context.l10n.share,
|
||||||
|
onTap: () async {
|
||||||
|
try {
|
||||||
|
await shareCode();
|
||||||
|
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
} catch (e) {
|
||||||
|
logger.warning('Failed to share code: ${e.toString()}');
|
||||||
|
showGenericErrorDialog(context: context, error: e).ignore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ButtonWidget(
|
||||||
|
buttonType: ButtonType.secondary,
|
||||||
|
buttonSize: ButtonSize.large,
|
||||||
|
labelText: context.l10n.cancel,
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> shareCode() async {
|
||||||
|
final result = generateFutureTotpCodes(widget.code, 30);
|
||||||
|
// show toast with total time taken
|
||||||
|
Map<String, dynamic> data = {
|
||||||
|
'startTime': result.$1,
|
||||||
|
'step': widget.code.period,
|
||||||
|
'codes': result.$2.join(","),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
// generated 128 bit crypto secure random key
|
||||||
|
final Uint8List key = generate256BitKey();
|
||||||
|
Uint8List input = utf8.encode(jsonEncode(data));
|
||||||
|
final encResult = CryptoUtil.encryptSync(input, key);
|
||||||
|
String url =
|
||||||
|
'https://auth.io/share?data=${uint8ListToUrlSafeBase64(encResult.encryptedData!)}&nonce=${uint8ListToUrlSafeBase64(encResult.nonce!)}#key=${uint8ListToUrlSafeBase64(key)}';
|
||||||
|
logger.info('url: $url');
|
||||||
|
shareText(url, context: context).ignore();
|
||||||
|
} catch (e) {
|
||||||
|
logger.warning('Failed to encrypt data: ${e.toString()}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String uint8ListToUrlSafeBase64(Uint8List data) {
|
||||||
|
String base64Str = base64UrlEncode(data);
|
||||||
|
return base64Str.replaceAll('=', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
Uint8List generate256BitKey() {
|
||||||
|
final random = Random.secure();
|
||||||
|
final bytes = Uint8List(32); // 32 bytes = 32 * 8 bits = 256 bits
|
||||||
|
for (int i = 0; i < bytes.length; i++) {
|
||||||
|
bytes[i] = random
|
||||||
|
.nextInt(256); // Generates a random number between 0 and 255 (1 byte)
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void showShareDialog(BuildContext context, Code code) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return ShareCodeDialog(
|
||||||
|
code: code,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
@ -5,6 +5,8 @@ import 'package:ente_auth/ui/components/buttons/button_widget.dart';
|
|||||||
import 'package:ente_auth/ui/components/dialog_widget.dart';
|
import 'package:ente_auth/ui/components/dialog_widget.dart';
|
||||||
import 'package:ente_auth/ui/components/models/button_type.dart';
|
import 'package:ente_auth/ui/components/models/button_type.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
|
||||||
Future<void> shareDialog(
|
Future<void> shareDialog(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
@ -49,3 +51,52 @@ Future<void> shareDialog(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Rect _sharePosOrigin(BuildContext? context, GlobalKey? key) {
|
||||||
|
late final Rect rect;
|
||||||
|
if (context != null) {
|
||||||
|
rect = shareButtonRect(context, key);
|
||||||
|
} else {
|
||||||
|
rect = const Offset(20.0, 20.0) & const Size(10, 10);
|
||||||
|
}
|
||||||
|
return rect;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the rect of button if context and key are not null
|
||||||
|
/// If key is null, returned rect will be at the center of the screen
|
||||||
|
Rect shareButtonRect(BuildContext context, GlobalKey? shareButtonKey) {
|
||||||
|
Size size = MediaQuery.sizeOf(context);
|
||||||
|
final RenderObject? renderObject =
|
||||||
|
shareButtonKey?.currentContext?.findRenderObject();
|
||||||
|
RenderBox? renderBox;
|
||||||
|
if (renderObject != null && renderObject is RenderBox) {
|
||||||
|
renderBox = renderObject;
|
||||||
|
}
|
||||||
|
if (renderBox == null) {
|
||||||
|
return Rect.fromLTWH(0, 0, size.width, size.height / 2);
|
||||||
|
}
|
||||||
|
size = renderBox.size;
|
||||||
|
final Offset position = renderBox.localToGlobal(Offset.zero);
|
||||||
|
return Rect.fromCenter(
|
||||||
|
center: position + Offset(size.width / 2, size.height / 2),
|
||||||
|
width: size.width,
|
||||||
|
height: size.height,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ShareResult> shareText(
|
||||||
|
String text, {
|
||||||
|
BuildContext? context,
|
||||||
|
GlobalKey? key,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final sharePosOrigin = _sharePosOrigin(context, key);
|
||||||
|
return Share.share(
|
||||||
|
text,
|
||||||
|
sharePositionOrigin: sharePosOrigin,
|
||||||
|
);
|
||||||
|
} catch (e, s) {
|
||||||
|
Logger("ShareUtil").severe("failed to share text", e, s);
|
||||||
|
return ShareResult.unavailable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user