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 登录'),
),
),
],
),
),
),
),
),
),
);
}
}
@@ -0,0 +1,25 @@
import 'package:flutter/foundation.dart';
class RuntimeConfig {
static const _configuredApiBaseUrl = String.fromEnvironment(
'AIOA_API_BASE_URL',
);
static const _configuredOidcIssuer = String.fromEnvironment(
'AIOA_OIDC_ISSUER',
);
static String get apiBaseUrl => _configuredApiBaseUrl.isNotEmpty
? _configuredApiBaseUrl
: '${_localOrigin(8080)}/api/v1';
static String get oidcIssuer => _configuredOidcIssuer.isNotEmpty
? _configuredOidcIssuer
: 'http://localhost:8081/realms/aioa';
static String _localOrigin(int port) {
final host = defaultTargetPlatform == TargetPlatform.android
? '10.0.2.2'
: '127.0.0.1';
return 'http://$host:$port';
}
}
+85 -1
View File
@@ -10,6 +10,17 @@ class DynamicFormDefinition {
final JsonFormSchema dataSchema;
final FormUiSchema uiSchema;
factory DynamicFormDefinition.fromJson(Map<String, Object?> json) {
return DynamicFormDefinition(
dataSchema: JsonFormSchema.fromJson(
Map<String, Object?>.from(json['dataSchema']! as Map),
),
uiSchema: FormUiSchema.fromJson(
Map<String, Object?>.from(json['uiSchema']! as Map),
),
);
}
}
class JsonFormSchema {
@@ -24,6 +35,23 @@ class JsonFormSchema {
final String title;
final Map<String, JsonFieldSchema> properties;
final Set<String> required;
factory JsonFormSchema.fromJson(Map<String, Object?> json) {
final properties = Map<String, Object?>.from(json['properties']! as Map);
return JsonFormSchema(
id: json[r'$id']! as String,
title: json['title']! as String,
properties: properties.map(
(key, value) => MapEntry(
key,
JsonFieldSchema.fromJson(Map<String, Object?>.from(value as Map)),
),
),
required: ((json['required'] as List?) ?? const [])
.cast<String>()
.toSet(),
);
}
}
class JsonFieldSchema {
@@ -40,6 +68,16 @@ class JsonFieldSchema {
final List<String> enumValues;
final int? minLength;
final int? maxLength;
factory JsonFieldSchema.fromJson(Map<String, Object?> json) {
return JsonFieldSchema(
type: JsonValueType.values.byName(json['type']! as String),
format: json['format'] as String?,
enumValues: ((json['enum'] as List?) ?? const []).cast<String>(),
minLength: json['minLength'] as int?,
maxLength: json['maxLength'] as int?,
);
}
}
class FormUiSchema {
@@ -47,6 +85,19 @@ class FormUiSchema {
final String description;
final List<FormSectionSchema> sections;
factory FormUiSchema.fromJson(Map<String, Object?> json) {
return FormUiSchema(
description: json['description']! as String,
sections: (json['sections']! as List)
.map(
(value) => FormSectionSchema.fromJson(
Map<String, Object?>.from(value as Map),
),
)
.toList(),
);
}
}
class FormSectionSchema {
@@ -54,6 +105,19 @@ class FormSectionSchema {
final String title;
final List<FormControlSchema> controls;
factory FormSectionSchema.fromJson(Map<String, Object?> json) {
return FormSectionSchema(
title: json['title']! as String,
controls: (json['controls']! as List)
.map(
(value) => FormControlSchema.fromJson(
Map<String, Object?>.from(value as Map),
),
)
.toList(),
);
}
}
class FormControlSchema {
@@ -72,21 +136,41 @@ class FormControlSchema {
final String? placeholder;
final String? helperText;
final Map<String, String> optionLabels;
factory FormControlSchema.fromJson(Map<String, Object?> json) {
return FormControlSchema(
field: json['field']! as String,
label: json['label']! as String,
control: FormControlType.values.byName(json['control']! as String),
placeholder: json['placeholder'] as String?,
helperText: json['helperText'] as String?,
optionLabels: ((json['optionLabels'] as Map?) ?? const {})
.cast<String, String>(),
);
}
}
class DynamicFormState {
const DynamicFormState({this.values = const {}, this.errors = const {}});
const DynamicFormState({
this.values = const {},
this.errors = const {},
this.restoredAt,
});
final Map<String, Object?> values;
final Map<String, String> errors;
final DateTime? restoredAt;
DynamicFormState copyWith({
Map<String, Object?>? values,
Map<String, String>? errors,
DateTime? restoredAt,
bool clearRestoredAt = false,
}) {
return DynamicFormState(
values: values ?? this.values,
errors: errors ?? this.errors,
restoredAt: clearRestoredAt ? null : restoredAt ?? this.restoredAt,
);
}
}
@@ -0,0 +1,53 @@
import 'dart:convert';
import 'package:aioa_mobile/core/auth/authenticated_http_client.dart';
import 'package:aioa_mobile/core/config/runtime_config.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
final pushRegistrationProvider =
AsyncNotifierProvider<PushRegistrationController, bool>(
PushRegistrationController.new,
);
class PushRegistrationController extends AsyncNotifier<bool> {
@override
Future<bool> build() async {
try {
if (Firebase.apps.isEmpty) await Firebase.initializeApp();
final messaging = FirebaseMessaging.instance;
final settings = await messaging.requestPermission(
alert: true,
badge: true,
sound: true,
);
if (settings.authorizationStatus == AuthorizationStatus.denied) {
return false;
}
final token = await messaging.getToken();
if (token == null) return false;
await _upload(token);
messaging.onTokenRefresh.listen(_upload);
return true;
} catch (_) {
// 未提供 Firebase 平台配置时保留站内通知,应用仍可正常启动。
return false;
}
}
Future<void> _upload(String token) async {
final id = await ref.read(deviceRegistrationProvider).currentId();
final response = await ref
.read(authenticatedHttpClientProvider)
.put(
Uri.parse('${RuntimeConfig.apiBaseUrl}/devices/$id/push-token'),
headers: const {'Content-Type': 'application/json'},
body: jsonEncode({'token': token}),
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw http.ClientException('Push token registration failed');
}
}
}