Phase 5A: implement tenant auth foundation across broker, app, and agent
This commit is contained in:
@@ -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.',
|
||||
|
||||
Reference in New Issue
Block a user