♻️ Rework of the oidc login flow (wip)

This commit is contained in:
2025-11-16 17:07:34 +08:00
parent 4b03b45a0d
commit 4ec10ceb47
4 changed files with 281 additions and 42 deletions

View File

@@ -1,22 +1,71 @@
import 'dart:convert';
import 'package:easy_localization/easy_localization.dart';
import 'package:email_validator/email_validator.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:gap/gap.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/pods/websocket.dart';
import 'package:island/screens/account/me/profile_update.dart';
import 'package:island/services/event_bus.dart';
import 'package:island/services/notify.dart';
import 'package:island/widgets/alert.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'captcha.dart';
Widget getProviderIcon(String provider, {double size = 24, Color? color}) {
final providerLower = provider.toLowerCase();
// Check if we have an SVG for this provider
switch (providerLower) {
case 'apple':
case 'microsoft':
case 'google':
case 'github':
case 'discord':
case 'afdian':
case 'steam':
return SvgPicture.asset(
'assets/images/oidc/$providerLower.svg',
width: size,
height: size,
colorFilter:
color != null ? ColorFilter.mode(color, BlendMode.srcIn) : null,
);
case 'spotify':
return Image.asset(
'assets/images/oidc/spotify.png',
width: size,
height: size,
color: color,
);
default:
return Icon(Symbols.link, size: size);
}
}
class CreateAccountContent extends HookConsumerWidget {
const CreateAccountContent({super.key});
Map<String, dynamic> decodeJwt(String token) {
final parts = token.split('.');
if (parts.length != 3) throw FormatException('Invalid JWT');
final payload = parts[1];
final normalized = base64Url.normalize(payload);
final decoded = utf8.decode(base64Url.decode(normalized));
return json.decode(decoded);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(GlobalKey<FormState>.new, const []);
@@ -25,6 +74,8 @@ class CreateAccountContent extends HookConsumerWidget {
final usernameController = useTextEditingController();
final nicknameController = useTextEditingController();
final passwordController = useTextEditingController();
final waitingForOidc = useState(false);
final onboardingToken = useState<String?>(null);
void showPostCreateModal() {
showModalBottomSheet(
@@ -37,38 +88,129 @@ class CreateAccountContent extends HookConsumerWidget {
void performAction() async {
if (!formKey.currentState!.validate()) return;
final captchaTk = await CaptchaScreen.show(context);
if (captchaTk == null) return;
String endpoint = '/pass/accounts';
Map<String, dynamic> data = {};
if (onboardingToken.value != null) {
// OIDC onboarding
endpoint = '/pass/account/onboard';
data['onboarding_token'] = onboardingToken.value;
data['name'] = usernameController.text;
data['nick'] = nicknameController.text;
// Password is required in form, but might be optional
} else {
// Manual account creation
final captchaTk = await CaptchaScreen.show(context);
if (captchaTk == null) return;
if (!context.mounted) return;
data['captcha_token'] = captchaTk;
data['name'] = usernameController.text;
data['nick'] = nicknameController.text;
data['email'] = emailController.text;
data['password'] = passwordController.text;
data['language'] =
kServerSupportedLanguages[EasyLocalization.of(
context,
)!.currentLocale.toString()] ??
'en-us';
}
if (!context.mounted) return;
try {
showLoadingModal(context);
final client = ref.watch(apiClientProvider);
await client.post(
'/pass/accounts',
data: {
'name': usernameController.text,
'nick': nicknameController.text,
'email': emailController.text,
'password': passwordController.text,
'language':
kServerSupportedLanguages[EasyLocalization.of(
context,
)!.currentLocale.toString()] ??
'en-us',
'captcha_token': captchaTk,
},
);
if (!context.mounted) return;
hideLoadingModal(context);
showPostCreateModal();
final resp = await client.post(endpoint, data: data);
if (endpoint == '/pass/account/onboard') {
// Onboard response has tokens, set them
final token = resp.data['token'];
setToken(ref.watch(sharedPreferencesProvider), token);
ref.invalidate(tokenProvider);
final userNotifier = ref.read(userInfoProvider.notifier);
await userNotifier.fetchUser();
final apiClient = ref.read(apiClientProvider);
subscribePushNotification(apiClient);
final wsNotifier = ref.read(websocketStateProvider.notifier);
wsNotifier.connect();
if (context.mounted) Navigator.pop(context, true);
} else {
if (!context.mounted) return;
hideLoadingModal(context);
onboardingToken.value = null; // reset
showPostCreateModal();
}
} catch (err) {
if (context.mounted) hideLoadingModal(context);
showErrorAlert(err);
}
}
useEffect(() {
final subscription = eventBus.on<OidcAuthCallbackEvent>().listen((
event,
) async {
if (!waitingForOidc.value || !context.mounted) return;
waitingForOidc.value = false;
final client = ref.watch(apiClientProvider);
try {
// Exchange code for tokens
final resp = await client.post(
'/pass/auth/token',
data: {
'grant_type': 'authorization_code',
'code': event.challengeId,
},
);
final data = resp.data;
if (data.containsKey('onboarding_token')) {
// New user onboarding
final token = data['onboarding_token'] as String;
final decoded = decodeJwt(token);
final name = decoded['name'] as String?;
final email = decoded['email'] as String?;
final provider = decoded['provider'] as String?;
// Pre-fill form
usernameController.text = '';
nicknameController.text = name ?? '';
emailController.text = email ?? '';
passwordController.clear(); // User needs to set password
onboardingToken.value = token;
// Optionally show a message
showSnackBar('Pre-filled from ${provider ?? 'provider'}');
} else {
// Existing user, switch to login
showSnackBar('Account already exists. Redirecting to login.');
context.goNamed('login');
}
} catch (err) {
showErrorAlert(err);
}
});
return subscription.cancel;
}, [waitingForOidc.value, context.mounted]);
Future<void> withOidc(String provider) async {
waitingForOidc.value = true;
final serverUrl = ref.watch(serverUrlProvider);
final url =
Uri.parse('$serverUrl/pass/auth/login/${provider.toLowerCase()}')
.replace(
queryParameters: {'redirect_uri': 'solian://auth/callback'},
)
.toString();
final isLaunched = await launchUrlString(
url,
mode:
kIsWeb
? LaunchMode.platformDefault
: LaunchMode.externalApplication,
);
if (!isLaunched) {
waitingForOidc.value = false;
showErrorAlert('failedToLaunchBrowser'.tr());
}
}
return StyledWidget(
Container(
constraints: const BoxConstraints(maxWidth: 380),
@@ -90,6 +232,48 @@ class CreateAccountContent extends HookConsumerWidget {
fontWeight: FontWeight.w900,
),
).tr().padding(left: 4, bottom: 16),
if (!kIsWeb)
Row(
spacing: 6,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Text("orCreateWith").tr().fontSize(11).opacity(0.85),
const Gap(8),
Spacer(),
IconButton.filledTonal(
onPressed: () => withOidc('github'),
padding: EdgeInsets.zero,
icon: getProviderIcon(
"github",
size: 16,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
tooltip: 'GitHub',
),
IconButton.filledTonal(
onPressed: () => withOidc('google'),
padding: EdgeInsets.zero,
icon: getProviderIcon(
"google",
size: 16,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
tooltip: 'Google',
),
IconButton.filledTonal(
onPressed: () => withOidc('apple'),
padding: EdgeInsets.zero,
icon: getProviderIcon(
"apple",
size: 16,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
tooltip: 'Apple Account',
),
],
).padding(horizontal: 8, vertical: 8)
else
const Gap(12),
Form(
key: formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,

View File

@@ -15,7 +15,7 @@ import 'package:island/pods/network.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/pods/websocket.dart';
import 'package:island/screens/account/me/settings_connections.dart';
import 'package:island/screens/auth/oidc.dart';
import 'package:island/services/event_bus.dart';
import 'package:island/services/notify.dart';
import 'package:island/services/udid.dart';
import 'package:island/widgets/alert.dart';
@@ -503,12 +503,42 @@ class _LoginLookupScreen extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final isBusy = useState(false);
final usernameController = useTextEditingController();
final waitingForOidc = useState(false);
useEffect(() {
onBusy.call(isBusy.value);
return null;
}, [isBusy]);
useEffect(() {
final subscription = eventBus.on<OidcAuthCallbackEvent>().listen((
event,
) async {
if (!waitingForOidc.value || !context.mounted) return;
waitingForOidc.value = false;
final client = ref.watch(apiClientProvider);
try {
final resp = await client.get(
'/pass/auth/challenge/${event.challengeId}',
);
final challenge = SnAuthChallenge.fromJson(resp.data);
onChallenge(challenge);
final factorResp = await client.get(
'/pass/auth/challenge/${challenge.id}/factors',
);
onFactor(
List<SnAuthFactor>.from(
factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)),
),
);
onNext();
} catch (err) {
showErrorAlert(err);
}
});
return subscription.cancel;
}, [waitingForOidc.value, context.mounted]);
Future<void> requestResetPassword() async {
final uname = usernameController.value.text;
if (uname.isEmpty) {
@@ -618,28 +648,33 @@ class _LoginLookupScreen extends HookConsumerWidget {
}
Future<void> withOidc(String provider) async {
final challengeId = await Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute(
builder: (context) => OidcScreen(provider: provider.toLowerCase()),
),
waitingForOidc.value = true;
final serverUrl = ref.watch(serverUrlProvider);
final token = ref.watch(tokenProvider);
final deviceId = await getUdid();
final queryParams = <String, String>{
'redirect_uri': 'solian://auth/callback',
'device_id': deviceId,
};
if (token?.token != null) {
queryParams['token'] = token!.token;
}
final url =
Uri.parse(
'$serverUrl/pass/auth/login/${provider.toLowerCase()}',
).replace(queryParameters: queryParams).toString();
final isLaunched = await launchUrlString(
url,
mode:
kIsWeb
? LaunchMode.platformDefault
: LaunchMode.externalApplication,
webOnlyWindowName:
token?.token != null ? 'auth-${token!.token}' : 'auth',
);
final client = ref.watch(apiClientProvider);
try {
final resp = await client.get('/pass/auth/challenge/$challengeId');
final challenge = SnAuthChallenge.fromJson(resp.data);
onChallenge(challenge);
final factorResp = await client.get(
'/pass/auth/challenge/${challenge.id}/factors',
);
onFactor(
List<SnAuthFactor>.from(
factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)),
),
);
onNext();
} catch (err) {
showErrorAlert(err);
if (!isLaunched) {
waitingForOidc.value = false;
showErrorAlert('failedToLaunchBrowser'.tr());
}
}