feat: complete leave approval MVP

This commit is contained in:
selfrelease
2026-07-18 19:20:07 +08:00
parent 2105fe3bac
commit 090a7e33ce
133 changed files with 7845 additions and 100 deletions
+17
View File
@@ -0,0 +1,17 @@
class AuthSession {
const AuthSession({
required this.accessToken,
required this.refreshToken,
required this.idToken,
required this.expiresAt,
});
final String accessToken;
final String? refreshToken;
final String? idToken;
final DateTime expiresAt;
bool get needsRefresh => expiresAt.isBefore(
DateTime.now().toUtc().add(const Duration(minutes: 1)),
);
}
@@ -0,0 +1,167 @@
import 'package:aioa_mobile/core/auth/auth_session.dart';
import 'package:aioa_mobile/core/config/runtime_config.dart';
import 'package:flutter_appauth/flutter_appauth.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
String get oidcIssuer => RuntimeConfig.oidcIssuer;
const oidcClientId = 'aioa-mobile';
const oidcRedirectUrl = 'aioa://oauth/callback';
const oidcLogoutRedirectUrl = 'aioa://oauth/logout';
final authSessionProvider =
AsyncNotifierProvider<AuthSessionController, AuthSession?>(
AuthSessionController.new,
);
class AuthSessionController extends AsyncNotifier<AuthSession?> {
static const _storage = FlutterSecureStorage();
static const _appAuth = FlutterAppAuth();
@override
Future<AuthSession?> build() => _restore();
Future<void> login() async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
final result = await _appAuth.authorizeAndExchangeCode(
AuthorizationTokenRequest(
oidcClientId,
oidcRedirectUrl,
issuer: oidcIssuer,
scopes: const ['openid', 'profile', 'email', 'offline_access'],
promptValues: const ['login'],
allowInsecureConnections: oidcIssuer.startsWith('http://'),
),
);
return _storeResponse(result);
});
}
Future<void> logout() async {
final current = state.value;
try {
if (current?.idToken != null) {
await _appAuth.endSession(
EndSessionRequest(
idTokenHint: current!.idToken,
postLogoutRedirectUrl: oidcLogoutRedirectUrl,
issuer: oidcIssuer,
allowInsecureConnections: oidcIssuer.startsWith('http://'),
),
);
}
} finally {
await _clear();
state = const AsyncData(null);
}
}
Future<void> invalidateDeviceSession() async {
await _clear();
state = const AsyncData(null);
}
Future<String?> validAccessToken() async {
final current = state.value;
if (current == null) return null;
if (!current.needsRefresh) return current.accessToken;
final refreshToken = current.refreshToken;
if (refreshToken == null) {
await _clear();
state = const AsyncData(null);
return null;
}
try {
final refreshed = await _refresh(current);
state = AsyncData(refreshed);
return refreshed.accessToken;
} catch (_) {
await _clear();
state = const AsyncData(null);
return null;
}
}
Future<AuthSession?> _restore() async {
final values = await Future.wait([
_storage.read(key: 'access_token'),
_storage.read(key: 'refresh_token'),
_storage.read(key: 'id_token'),
_storage.read(key: 'expires_at'),
]);
final accessToken = values[0];
final expiresAt = DateTime.tryParse(values[3] ?? '');
if (accessToken == null || expiresAt == null) return null;
final session = AuthSession(
accessToken: accessToken,
refreshToken: values[1],
idToken: values[2],
expiresAt: expiresAt,
);
if (session.needsRefresh) {
if (session.refreshToken == null) {
await _clear();
return null;
}
try {
return await _refresh(session);
} catch (_) {
await _clear();
return null;
}
}
return session;
}
Future<AuthSession> _refresh(AuthSession current) async {
final response = await _appAuth.token(
TokenRequest(
oidcClientId,
oidcRedirectUrl,
issuer: oidcIssuer,
refreshToken: current.refreshToken,
scopes: const ['openid', 'profile', 'email', 'offline_access'],
allowInsecureConnections: oidcIssuer.startsWith('http://'),
),
);
return _storeResponse(response, fallback: current);
}
Future<AuthSession> _storeResponse(
TokenResponse response, {
AuthSession? fallback,
}) async {
final accessToken = response.accessToken ?? fallback?.accessToken;
final expiresAt =
response.accessTokenExpirationDateTime ?? fallback?.expiresAt;
if (accessToken == null || expiresAt == null) {
throw StateError('OIDC token response is incomplete');
}
final session = AuthSession(
accessToken: accessToken,
refreshToken: response.refreshToken ?? fallback?.refreshToken,
idToken: response.idToken ?? fallback?.idToken,
expiresAt: expiresAt.toUtc(),
);
await Future.wait([
_storage.write(key: 'access_token', value: session.accessToken),
_storage.write(key: 'refresh_token', value: session.refreshToken),
_storage.write(key: 'id_token', value: session.idToken),
_storage.write(
key: 'expires_at',
value: session.expiresAt.toIso8601String(),
),
]);
return session;
}
Future<void> _clear() async {
await Future.wait([
_storage.delete(key: 'access_token'),
_storage.delete(key: 'refresh_token'),
_storage.delete(key: 'id_token'),
_storage.delete(key: 'expires_at'),
]);
}
}
@@ -0,0 +1,148 @@
import 'package:aioa_mobile/core/auth/auth_session_controller.dart';
import 'package:aioa_mobile/core/config/runtime_config.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
String get apiBaseUrl => RuntimeConfig.apiBaseUrl;
final authenticatedHttpClientProvider = Provider<http.Client>((ref) {
final inner = http.Client();
return AuthenticatedHttpClient(
inner: inner,
apiOrigin: Uri.parse(apiBaseUrl).origin,
tokenProvider: ref.read(authSessionProvider.notifier).validAccessToken,
deviceRegistration: ref.watch(deviceRegistrationProvider),
);
});
final deviceRegistrationProvider = Provider<DeviceRegistration>((ref) {
return DeviceRegistration(
inner: http.Client(),
baseUrl: apiBaseUrl,
onRevoked: ref.read(authSessionProvider.notifier).invalidateDeviceSession,
);
});
class AuthenticatedHttpClient extends http.BaseClient {
AuthenticatedHttpClient({
required this.inner,
required this.apiOrigin,
required this.tokenProvider,
this.deviceRegistration,
});
final http.Client inner;
final String apiOrigin;
final Future<String?> Function() tokenProvider;
final DeviceRegistration? deviceRegistration;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
if (request.url.origin == apiOrigin &&
!request.headers.containsKey('Authorization')) {
final token = await tokenProvider();
if (token != null) {
request.headers['Authorization'] = 'Bearer $token';
final registration = deviceRegistration;
if (registration != null) {
request.headers['X-AIOA-Device-Id'] = await registration.ensure(
token,
);
}
}
}
final response = await inner.send(request);
if (response.headers['x-aioa-auth-error'] == 'DEVICE_REVOKED') {
await deviceRegistration?.revoked();
}
return response;
}
@override
void close() => inner.close();
}
class DeviceRegistration {
DeviceRegistration({
required this.inner,
required this.baseUrl,
required this.onRevoked,
});
static const _storage = FlutterSecureStorage();
final http.Client inner;
final String baseUrl;
final Future<void> Function() onRevoked;
String? _registeredToken;
String? _deviceId;
Future<String> ensure(String token) async {
final id = _deviceId ??= await _loadOrCreateId();
if (_registeredToken == token) return id;
final platform = Platform.isIOS
? 'IOS'
: Platform.isAndroid
? 'ANDROID'
: 'OTHER';
final response = await inner.post(
Uri.parse('$baseUrl/devices/register'),
headers: {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
},
body: jsonEncode({
'id': id,
'name': Platform.localHostname.isEmpty
? '$platform 设备'
: Platform.localHostname,
'platform': platform,
}),
);
if (response.statusCode == 401 || response.statusCode == 403) {
await revoked();
throw const DeviceRevokedException();
}
if (response.statusCode < 200 || response.statusCode >= 300) {
throw DeviceRegistrationException(response.statusCode);
}
_registeredToken = token;
return id;
}
Future<void> revoked() async {
_registeredToken = null;
await onRevoked();
}
Future<String> currentId() async => _deviceId ??= await _loadOrCreateId();
Future<String> _loadOrCreateId() async {
final existing = await _storage.read(key: 'device_id');
if (existing != null) return existing;
final random = Random.secure();
final bytes = List<int>.generate(16, (_) => random.nextInt(256));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
String hex(int start, int end) => bytes
.sublist(start, end)
.map((value) => value.toRadixString(16).padLeft(2, '0'))
.join();
final id =
'${hex(0, 4)}-${hex(4, 6)}-${hex(6, 8)}-${hex(8, 10)}-${hex(10, 16)}';
await _storage.write(key: 'device_id', value: id);
return id;
}
}
class DeviceRevokedException implements Exception {
const DeviceRevokedException();
}
class DeviceRegistrationException implements Exception {
const DeviceRegistrationException(this.statusCode);
final int statusCode;
}
+74
View File
@@ -0,0 +1,74 @@
import 'package:aioa_mobile/app/theme.dart';
import 'package:aioa_mobile/core/auth/auth_session_controller.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class LoginPage extends ConsumerWidget {
const LoginPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final auth = ref.watch(authSessionProvider);
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: buildAioaTheme(),
home: Scaffold(
body: SafeArea(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Padding(
padding: const EdgeInsets.all(28),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const CircleAvatar(
radius: 36,
child: Icon(Icons.apartment_rounded, size: 38),
),
const SizedBox(height: 20),
Text(
'AIOA',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineMedium
?.copyWith(fontWeight: FontWeight.w900),
),
const SizedBox(height: 8),
const Text('使用企业账号安全登录', textAlign: TextAlign.center),
if (auth.hasError) ...[
const SizedBox(height: 16),
Text(
'登录失败:${auth.error}',
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
],
const SizedBox(height: 28),
FilledButton.icon(
onPressed: auth.isLoading
? null
: ref.read(authSessionProvider.notifier).login,
icon: auth.isLoading
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.login),
label: const Padding(
padding: EdgeInsets.symmetric(vertical: 14),
child: Text('Keycloak 登录'),
),
),
],
),
),
),
),
),
),
);
}
}