Phase 5A: implement tenant auth foundation across broker, app, and agent

This commit is contained in:
2026-05-21 11:23:19 +08:00
parent c85c4a3f5b
commit c43b7dd7b9
5 changed files with 219 additions and 30 deletions

View File

@@ -81,11 +81,16 @@ class _UploadPageState extends State<UploadPage> {
Timer? _statusTimer;
DirectUploadConfig _directConfig = DirectUploadConfig.defaults;
late final TextEditingController _tokenController;
late final TextEditingController _googleIdTokenController;
String _accessToken = '';
String _refreshToken = '';
@override
void initState() {
super.initState();
_tokenController = TextEditingController();
_googleIdTokenController = TextEditingController();
_loadBrokerSession();
_loadDirectUploadConfig();
_loadGallery();
_statusTimer = Timer.periodic(
@@ -98,9 +103,95 @@ class _UploadPageState extends State<UploadPage> {
void dispose() {
_statusTimer?.cancel();
_tokenController.dispose();
_googleIdTokenController.dispose();
super.dispose();
}
Future<void> _loadBrokerSession() async {
final prefs = await SharedPreferences.getInstance();
if (!mounted) return;
setState(() {
_accessToken = prefs.getString('broker_access_token') ?? '';
_refreshToken = prefs.getString('broker_refresh_token') ?? '';
});
}
Future<void> _saveBrokerSession({
required String accessToken,
required String refreshToken,
}) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('broker_access_token', accessToken);
await prefs.setString('broker_refresh_token', refreshToken);
if (!mounted) return;
setState(() {
_accessToken = accessToken;
_refreshToken = refreshToken;
});
}
Future<void> _clearBrokerSession() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove('broker_access_token');
await prefs.remove('broker_refresh_token');
if (!mounted) return;
setState(() {
_accessToken = '';
_refreshToken = '';
});
}
Future<bool> _signInWithGoogleIdToken() async {
final idToken = _googleIdTokenController.text.trim();
if (idToken.isEmpty) return false;
try {
final res = await http.post(
Uri.parse('$brokerUrl/auth/google'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'id_token': idToken}),
);
if (res.statusCode != 200) return false;
final body = jsonDecode(res.body);
final accessToken = (body['access_token'] ?? '').toString();
final refreshToken = (body['refresh_token'] ?? '').toString();
if (accessToken.isEmpty || refreshToken.isEmpty) return false;
await _saveBrokerSession(
accessToken: accessToken,
refreshToken: refreshToken,
);
return true;
} catch (_) {
return false;
}
}
Future<String?> _getValidAccessToken() async {
if (_accessToken.isNotEmpty) return _accessToken;
if (_refreshToken.isEmpty) return null;
try {
final res = await http.post(
Uri.parse('$brokerUrl/auth/refresh'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'refresh_token': _refreshToken}),
);
if (res.statusCode != 200) {
await _clearBrokerSession();
return null;
}
final body = jsonDecode(res.body);
final accessToken = (body['access_token'] ?? '').toString();
if (accessToken.isEmpty) return null;
await _saveBrokerSession(
accessToken: accessToken,
refreshToken: _refreshToken,
);
return accessToken;
} catch (_) {
return null;
}
}
Future<void> _loadDirectUploadConfig() async {
final prefs = await SharedPreferences.getInstance();
final cfg = DirectUploadConfig(
@@ -207,7 +298,12 @@ class _UploadPageState extends State<UploadPage> {
Future<List<Uri>> _fetchAgentHints() async {
try {
final res = await http.get(Uri.parse('$brokerUrl/agent/hints'));
final token = await _getValidAccessToken();
if (token == null) return const [];
final res = await http.get(
Uri.parse('$brokerUrl/agent/hints'),
headers: {'Authorization': 'Bearer $token'},
);
if (res.statusCode != 200) return const [];
final body = jsonDecode(res.body);
if (body is! Map<String, dynamic>) return const [];
@@ -244,11 +340,16 @@ class _UploadPageState extends State<UploadPage> {
}
Future<http.StreamedResponse> _uploadViaBroker(File file) async {
final token = await _getValidAccessToken();
if (token == null) {
throw const HttpException('missing broker auth session');
}
final filename = p.basename(file.path);
final req = http.MultipartRequest(
'POST',
Uri.parse('$brokerUrl/upload/$filename'),
);
req.headers['Authorization'] = 'Bearer $token';
req.files.add(await http.MultipartFile.fromPath('file', file.path));
return req.send().timeout(const Duration(seconds: 20));
}
@@ -271,12 +372,15 @@ class _UploadPageState extends State<UploadPage> {
Future<void> _indexDirectUploadToBroker(File file) async {
try {
final token = await _getValidAccessToken();
if (token == null) return;
final thumbBytes = await _buildPhoneThumbnail(file);
if (thumbBytes == null) return;
final req = http.MultipartRequest(
'POST',
Uri.parse('$brokerUrl/upload-index'),
);
req.headers['Authorization'] = 'Bearer $token';
req.fields['filename'] = p.basename(file.path);
req.fields['size_bytes'] = (await file.length()).toString();
req.fields['content_type'] = 'image/jpeg';
@@ -317,7 +421,18 @@ class _UploadPageState extends State<UploadPage> {
});
try {
await _refreshAgentStatus();
final res = await http.get(Uri.parse('$brokerUrl/photos'));
final token = await _getValidAccessToken();
if (token == null) {
setState(() {
_galleryLoading = false;
_galleryError = 'Sign in is required to load gallery.';
});
return;
}
final res = await http.get(
Uri.parse('$brokerUrl/photos'),
headers: {'Authorization': 'Bearer $token'},
);
if (res.statusCode != 200) {
setState(() {
_galleryLoading = false;
@@ -329,7 +444,7 @@ class _UploadPageState extends State<UploadPage> {
final body = jsonDecode(res.body);
final items = _extractPhotoItems(body);
final photos = items
.map(_toGalleryPhoto)
.map((x) => _toGalleryPhoto(x, token))
.whereType<GalleryPhoto>()
.toList(growable: false);
@@ -361,7 +476,17 @@ class _UploadPageState extends State<UploadPage> {
Future<void> _refreshAgentStatus() async {
try {
final res = await http.get(Uri.parse('$brokerUrl/'));
final token = await _getValidAccessToken();
if (token == null) {
if (mounted) {
setState(() => _agentConnected = false);
}
return;
}
final res = await http.get(
Uri.parse('$brokerUrl/agent/hints'),
headers: {'Authorization': 'Bearer $token'},
);
if (res.statusCode != 200) {
if (mounted) {
setState(() => _agentConnected = false);
@@ -392,11 +517,11 @@ class _UploadPageState extends State<UploadPage> {
.toList(growable: false);
}
GalleryPhoto? _toGalleryPhoto(Map<String, dynamic> item) {
GalleryPhoto? _toGalleryPhoto(Map<String, dynamic> item, String accessToken) {
final thumbRaw = item['thumbnail_url'];
if (thumbRaw is! String) return null;
final thumb = _resolveUrl(thumbRaw);
final thumb = _resolveUrl(thumbRaw, accessToken);
if (thumb.isEmpty) return null;
final rawFilename = item['filename'];
@@ -407,7 +532,7 @@ class _UploadPageState extends State<UploadPage> {
String full;
if (explicitFull is String && explicitFull.trim().isNotEmpty) {
full = _resolveUrl(explicitFull);
full = _resolveUrl(explicitFull, accessToken);
} else if (filename != null) {
final encoded = Uri.encodeComponent(filename);
full = '$brokerUrl/photo/$encoded';
@@ -424,12 +549,15 @@ class _UploadPageState extends State<UploadPage> {
);
}
String _resolveUrl(String value) {
String _resolveUrl(String value, String accessToken) {
final trimmed = value.trim();
if (trimmed.isEmpty) return '';
final uri = Uri.tryParse(trimmed);
if (uri != null && uri.hasScheme && uri.hasAuthority) return trimmed;
return Uri.parse(brokerUrl).resolve(trimmed).toString();
final resolved = (uri != null && uri.hasScheme && uri.hasAuthority)
? trimmed
: Uri.parse(brokerUrl).resolve(trimmed).toString();
final sep = resolved.contains('?') ? '&' : '?';
return '$resolved${sep}access_token=${Uri.encodeQueryComponent(accessToken)}';
}
Future<void> _openFullPhoto(GalleryPhoto photo) async {
@@ -580,6 +708,42 @@ class _UploadPageState extends State<UploadPage> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_accessToken.isEmpty
? 'Broker session: not signed in'
: 'Broker session: signed in',
),
const SizedBox(height: 8),
TextField(
controller: _googleIdTokenController,
decoration: const InputDecoration(
labelText: 'Google ID token',
helperText: 'Paste Google ID token, then sign in.',
),
obscureText: true,
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: FilledButton(
onPressed: () async {
final ok = await _signInWithGoogleIdToken();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
ok ? 'Broker sign-in successful.' : 'Broker sign-in failed.',
),
),
);
if (ok) {
await _loadGallery();
}
},
child: const Text('Sign In'),
),
),
const Divider(height: 20),
const Text(
'Direct upload is automatic using broker-provided agent LAN hints. '
'If direct fails, upload falls back to broker.',