Compare commits
No commits in common. "b840db0cff1ee8e0b5254c99db38bdff93078eed" and "e64d21dc2e968653cbf29b2633e75bae8dadea12" have entirely different histories.
b840db0cff
...
e64d21dc2e
File diff suppressed because one or more lines are too long
5
.gitignore
vendored
5
.gitignore
vendored
@ -1,6 +1,3 @@
|
|||||||
# VM hosting (machine-specific paths)
|
|
||||||
hosting/hosting.env
|
|
||||||
|
|
||||||
# Flutter/Dart
|
# Flutter/Dart
|
||||||
.dart_tool/
|
.dart_tool/
|
||||||
.packages
|
.packages
|
||||||
@ -80,5 +77,3 @@ coverage/
|
|||||||
migrate_working_dir/
|
migrate_working_dir/
|
||||||
*.env
|
*.env
|
||||||
*.env.*
|
*.env.*
|
||||||
!server/.env.example
|
|
||||||
!hosting/hosting.env.example
|
|
||||||
|
|||||||
@ -5,12 +5,8 @@
|
|||||||
"oAuthBrandDisplayName": "Cyber Hybrid Hub",
|
"oAuthBrandDisplayName": "Cyber Hybrid Hub",
|
||||||
"supportEmail": "support@cyberhybridhub.firebaseapp.com",
|
"supportEmail": "support@cyberhybridhub.firebaseapp.com",
|
||||||
"authorizedRedirectUris": [
|
"authorizedRedirectUris": [
|
||||||
"http://localhost",
|
"http://localhost"
|
||||||
"https://cyberhybridhub.firebaseapp.com",
|
]
|
||||||
"https://cyberhybridhub.com",
|
|
||||||
"https://www.cyberhybridhub.com"
|
|
||||||
],
|
|
||||||
"redirectUri": "https://cyberhybridhub.firebaseapp.com"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,88 +0,0 @@
|
|||||||
# VM hosting (Option A)
|
|
||||||
|
|
||||||
Production layout on the app VM:
|
|
||||||
|
|
||||||
| Service | systemd unit | Listen | Process |
|
|
||||||
|---------|----------------|--------|---------|
|
|
||||||
| Dart API | `cyberhybridhub-api` | `0.0.0.0:3000` | `dart run bin/server.dart` in `server/` |
|
|
||||||
| Flutter web | `cyberhybridhub-web` | `0.0.0.0:80` | `scripts/web_static_server.dart` → `build/web` |
|
|
||||||
|
|
||||||
**OpenWrt / HAProxy** terminates HTTPS on `:443` and should proxy:
|
|
||||||
|
|
||||||
- `/` → `10.10.101.106:80` (Flutter static)
|
|
||||||
- `/v1`, `/hubs` → `10.10.101.106:3000` (API + SignalR)
|
|
||||||
|
|
||||||
Port **3000 is not published on the WAN**. See `haproxy-openwrt.example.cfg`.
|
|
||||||
|
|
||||||
Local development uses `./startup.sh` (API 3000, web 8080, localhost-only).
|
|
||||||
|
|
||||||
## API URL for Flutter web builds
|
|
||||||
|
|
||||||
Set the same public origin the browser uses (no `:3000` on WAN):
|
|
||||||
|
|
||||||
| File | Variable |
|
|
||||||
|------|----------|
|
|
||||||
| `hosting/hosting.env` | `API_PUBLIC_URL=https://cyberhybridhub.com` |
|
|
||||||
| `server/.env` | `API_BASE_URL=https://cyberhybridhub.com` (optional; used if hosting unset) |
|
|
||||||
|
|
||||||
Build (reads hosting → server → default):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./scripts/build-production-web.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Verify baked URL:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -o 'https://cyberhybridhub.com[^"]*' build/web/main.dart.js | head -5
|
|
||||||
```
|
|
||||||
|
|
||||||
Should show `https://cyberhybridhub.com` for API calls, not `:3000`.
|
|
||||||
|
|
||||||
## First-time setup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/chh/cyberhybridhub.com
|
|
||||||
|
|
||||||
cp server/.env.example server/.env
|
|
||||||
nano server/.env
|
|
||||||
|
|
||||||
cp hosting/hosting.env.example hosting/hosting.env
|
|
||||||
nano hosting/hosting.env
|
|
||||||
|
|
||||||
source scripts/flutter-env.sh
|
|
||||||
./scripts/deploy-production.sh
|
|
||||||
|
|
||||||
chmod +x hosting/install-systemd.sh scripts/*.sh
|
|
||||||
./hosting/install-systemd.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## OpenWrt HAProxy
|
|
||||||
|
|
||||||
Copy `hosting/haproxy-openwrt.example.cfg` into `/etc/haproxy.cfg` (merge with mygit rules), then:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
haproxy -c -f /etc/haproxy.cfg && /etc/init.d/haproxy reload
|
|
||||||
curl -skI https://cyberhybridhub.com/v1/me/profile | head -3
|
|
||||||
```
|
|
||||||
|
|
||||||
## Updates
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git pull
|
|
||||||
./scripts/deploy-production.sh
|
|
||||||
sudo systemctl restart cyberhybridhub-api cyberhybridhub-web
|
|
||||||
```
|
|
||||||
|
|
||||||
## Firebase authorized domains
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./scripts/check-firebase-auth-domains.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Must include `cyberhybridhub.com` (and `www.cyberhybridhub.com` if used).
|
|
||||||
|
|
||||||
## Legacy Fresh app
|
|
||||||
|
|
||||||
The old unit `cyberhybridhub.service` pointed at `/home/chh/publish/server` on port 80.
|
|
||||||
`install-systemd.sh` stops and disables it when installing the Flutter units.
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=CyberHybridHub Dart API
|
|
||||||
After=network.target postgresql.service
|
|
||||||
Wants=postgresql.service
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=@APP_USER@
|
|
||||||
WorkingDirectory=@APP_ROOT@/server
|
|
||||||
Environment=PATH=@FLUTTER_BIN_DIR@:/usr/bin:/bin
|
|
||||||
EnvironmentFile=-@APP_ROOT@/server/.env
|
|
||||||
ExecStart=@DART_BIN@ run bin/server.dart
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=5
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=CyberHybridHub Flutter web (static)
|
|
||||||
After=network.target cyberhybridhub-api.service
|
|
||||||
Requires=cyberhybridhub-api.service
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=@APP_USER@
|
|
||||||
WorkingDirectory=@APP_ROOT@
|
|
||||||
Environment=PATH=@FLUTTER_BIN_DIR@:/usr/bin:/bin
|
|
||||||
Environment=STATIC_SERVER_PRODUCTION=1
|
|
||||||
ExecStartPre=/usr/bin/test -f @APP_ROOT@/build/web/index.html
|
|
||||||
ExecStart=@DART_BIN@ run scripts/web_static_server.dart @APP_ROOT@/build/web @WEB_PORT@ 0.0.0.0
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=5
|
|
||||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
|
||||||
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
# OpenWrt HAProxy example: TLS on :443, API paths to VM :3000, web to VM :80.
|
|
||||||
# Merge into /etc/haproxy.cfg on the router (adjust IPs as needed).
|
|
||||||
#
|
|
||||||
# Verify after reload:
|
|
||||||
# curl -skI https://cyberhybridhub.com/v1/me/profile | head -3 # expect 401
|
|
||||||
# curl -skI https://cyberhybridhub.com/ | head -3 # expect 200
|
|
||||||
|
|
||||||
global
|
|
||||||
log 127.0.0.1 local0
|
|
||||||
maxconn 4096
|
|
||||||
daemon
|
|
||||||
|
|
||||||
defaults
|
|
||||||
mode http
|
|
||||||
log global
|
|
||||||
option httplog
|
|
||||||
timeout connect 10s
|
|
||||||
timeout client 30s
|
|
||||||
timeout server 30s
|
|
||||||
|
|
||||||
frontend https_frontend
|
|
||||||
bind *:443 ssl crt /etc/haproxy/certs/cyberhybridhub.pem alpn h2,http/1.1
|
|
||||||
http-response set-header Strict-Transport-Security "max-age=15768000"
|
|
||||||
acl host_cyberhybridhub hdr(host) -i cyberhybridhub.com
|
|
||||||
acl host_www hdr(host) -i www.cyberhybridhub.com
|
|
||||||
acl host_mygit hdr(host) -i mygit.cyberhybridhub.com
|
|
||||||
acl path_api path_beg /v1 /hubs
|
|
||||||
http-request deny deny_status 403 if !host_cyberhybridhub !host_www !host_mygit
|
|
||||||
use_backend cyberhybridhub_api if host_cyberhybridhub path_api
|
|
||||||
use_backend cyberhybridhub_api if host_www path_api
|
|
||||||
use_backend cyberhybridhub_backend if host_cyberhybridhub
|
|
||||||
use_backend cyberhybridhub_backend if host_www
|
|
||||||
use_backend mygit_http if host_mygit
|
|
||||||
|
|
||||||
backend cyberhybridhub_backend
|
|
||||||
mode http
|
|
||||||
option forwardfor
|
|
||||||
http-request set-header X-Forwarded-Proto https if { ssl_fc }
|
|
||||||
server cyberhybridhub_server 10.10.101.106:80 check
|
|
||||||
|
|
||||||
backend cyberhybridhub_api
|
|
||||||
mode http
|
|
||||||
option forwardfor
|
|
||||||
http-request set-header X-Forwarded-Proto https if { ssl_fc }
|
|
||||||
timeout tunnel 1h
|
|
||||||
server api 10.10.101.106:3000 check
|
|
||||||
|
|
||||||
backend mygit_http
|
|
||||||
mode http
|
|
||||||
option forwardfor
|
|
||||||
http-request set-header X-Forwarded-Proto https if { ssl_fc }
|
|
||||||
server mygit_server 10.10.101.5:80 check
|
|
||||||
|
|
||||||
frontend http_redirect
|
|
||||||
bind *:80
|
|
||||||
mode http
|
|
||||||
http-request redirect scheme https code 301
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
# Copy to hosting/hosting.env and edit for this VM, then run:
|
|
||||||
# ./hosting/install-systemd.sh
|
|
||||||
|
|
||||||
APP_ROOT=/home/chh/cyberhybridhub.com
|
|
||||||
APP_USER=chh
|
|
||||||
FLUTTER_BIN_DIR=/home/chh/github-open/flutter/bin
|
|
||||||
# Use Flutter SDK dart (install: git clone flutter to ~/github-open/flutter)
|
|
||||||
DART_BIN=/home/chh/github-open/flutter/bin/dart
|
|
||||||
|
|
||||||
# Public URL for Flutter web build (no trailing slash, no :3000 on WAN setups).
|
|
||||||
# Must be https when the site is served over TLS.
|
|
||||||
API_PUBLIC_URL=https://cyberhybridhub.com
|
|
||||||
|
|
||||||
WEB_PORT=80
|
|
||||||
API_PORT=3000
|
|
||||||
@ -1,79 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Install cyberhybridhub-api + cyberhybridhub-web systemd units (requires sudo).
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
ENV_FILE="$ROOT/hosting/hosting.env"
|
|
||||||
|
|
||||||
if [ ! -f "$ENV_FILE" ]; then
|
|
||||||
echo "Missing $ENV_FILE" >&2
|
|
||||||
echo " cp hosting/hosting.env.example hosting/hosting.env" >&2
|
|
||||||
echo " # edit paths and API_PUBLIC_URL for this VM" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# shellcheck source=/dev/null
|
|
||||||
source "$ENV_FILE"
|
|
||||||
|
|
||||||
: "${APP_ROOT:?APP_ROOT required in hosting/hosting.env}"
|
|
||||||
: "${APP_USER:?APP_USER required}"
|
|
||||||
: "${FLUTTER_BIN_DIR:?FLUTTER_BIN_DIR required}"
|
|
||||||
: "${DART_BIN:=/usr/bin/dart}"
|
|
||||||
: "${WEB_PORT:=80}"
|
|
||||||
: "${API_PORT:=3000}"
|
|
||||||
|
|
||||||
if [ ! -x "$DART_BIN" ]; then
|
|
||||||
echo "DART_BIN not executable: $DART_BIN" >&2
|
|
||||||
echo " Install Flutter (see hosting/README.md) or set DART_BIN in hosting/hosting.env" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -f "$APP_ROOT/build/web/index.html" ]; then
|
|
||||||
echo "Missing $APP_ROOT/build/web/index.html" >&2
|
|
||||||
echo " Run: ./scripts/deploy-production.sh" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -f "$APP_ROOT/server/.env" ]; then
|
|
||||||
echo "Missing $APP_ROOT/server/.env" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
substitute() {
|
|
||||||
local template=$1
|
|
||||||
local dest=$2
|
|
||||||
sed \
|
|
||||||
-e "s|@APP_ROOT@|${APP_ROOT}|g" \
|
|
||||||
-e "s|@APP_USER@|${APP_USER}|g" \
|
|
||||||
-e "s|@FLUTTER_BIN_DIR@|${FLUTTER_BIN_DIR}|g" \
|
|
||||||
-e "s|@DART_BIN@|${DART_BIN}|g" \
|
|
||||||
-e "s|@WEB_PORT@|${WEB_PORT}|g" \
|
|
||||||
-e "s|@API_PORT@|${API_PORT}|g" \
|
|
||||||
"$template" >"$dest"
|
|
||||||
}
|
|
||||||
|
|
||||||
TMPDIR="$(mktemp -d)"
|
|
||||||
trap 'rm -rf "$TMPDIR"' EXIT
|
|
||||||
|
|
||||||
substitute "$ROOT/hosting/cyberhybridhub-api.service" "$TMPDIR/cyberhybridhub-api.service"
|
|
||||||
substitute "$ROOT/hosting/cyberhybridhub-web.service" "$TMPDIR/cyberhybridhub-web.service"
|
|
||||||
|
|
||||||
echo "Installing systemd units..."
|
|
||||||
sudo cp "$TMPDIR/cyberhybridhub-api.service" /etc/systemd/system/
|
|
||||||
sudo cp "$TMPDIR/cyberhybridhub-web.service" /etc/systemd/system/
|
|
||||||
|
|
||||||
echo "Disabling legacy Fresh unit (if present)..."
|
|
||||||
sudo systemctl disable cyberhybridhub 2>/dev/null || true
|
|
||||||
sudo systemctl stop cyberhybridhub 2>/dev/null || true
|
|
||||||
|
|
||||||
sudo systemctl daemon-reload
|
|
||||||
sudo systemctl enable cyberhybridhub-api cyberhybridhub-web
|
|
||||||
sudo systemctl restart cyberhybridhub-api cyberhybridhub-web
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
sudo systemctl status cyberhybridhub-api cyberhybridhub-web --no-pager || true
|
|
||||||
echo ""
|
|
||||||
echo "Verify:"
|
|
||||||
echo " sudo ss -tlnp | grep -E ':${WEB_PORT}|:${API_PORT}'"
|
|
||||||
echo " curl -sI http://127.0.0.1:${WEB_PORT}/"
|
|
||||||
echo " curl -sI http://127.0.0.1:${API_PORT}/"
|
|
||||||
@ -1,13 +1,12 @@
|
|||||||
/// Web OAuth 2.0 client ID (Google Cloud → Credentials → Web application).
|
/// Web OAuth 2.0 client ID (Google Cloud → Credentials → Web application).
|
||||||
|
///
|
||||||
/// Used for:
|
/// Used for:
|
||||||
/// - **Android**: `serverClientId` when not in `google-services.json`
|
/// - **Android**: `serverClientId` when not in `google-services.json`
|
||||||
/// - **Linux desktop**: `google_sign_in_all_platforms` clientId
|
/// - **Linux desktop**: `google_sign_in_all_platforms` clientId
|
||||||
///
|
///
|
||||||
/// **Web (required for Firefox / strict privacy):** `clientId` for the
|
/// **Web (required for Firefox / strict privacy):** `clientId` for the
|
||||||
/// Google Identity Services fallback when Firebase popup sign-in fails.
|
/// Google Identity Services fallback when Firebase popup sign-in fails.
|
||||||
/// Web: Firebase popup is tried first; full-page redirect is used if popup fails.
|
/// Popup is tried first; redirect is not used (sessionStorage breaks under ETP).
|
||||||
/// (google_sign_in `authenticate()` is not available on web — use renderButton
|
|
||||||
/// only if you replace the custom sign-in button with GIS UI.)
|
|
||||||
///
|
///
|
||||||
/// Find it: Firebase Console → Project settings → Your apps → Web app,
|
/// Find it: Firebase Console → Project settings → Your apps → Web app,
|
||||||
/// or Authentication → Sign-in method → Google → Web SDK configuration.
|
/// or Authentication → Sign-in method → Google → Web SDK configuration.
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import 'package:flutter/foundation.dart' show kIsWeb;
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
import '../utils/auth_log.dart';
|
|
||||||
import '../widgets/benefit_row.dart';
|
import '../widgets/benefit_row.dart';
|
||||||
import '../widgets/google_sign_in_button.dart';
|
import '../widgets/google_sign_in_button.dart';
|
||||||
|
|
||||||
@ -16,7 +15,6 @@ class LandingScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _LandingScreenState extends State<LandingScreen> {
|
class _LandingScreenState extends State<LandingScreen> {
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
String? _errorDetail;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -46,12 +44,10 @@ class _LandingScreenState extends State<LandingScreen> {
|
|||||||
child: wide
|
child: wide
|
||||||
? _WideLayout(
|
? _WideLayout(
|
||||||
errorMessage: _errorMessage,
|
errorMessage: _errorMessage,
|
||||||
errorDetail: _errorDetail,
|
|
||||||
onError: _handleError,
|
onError: _handleError,
|
||||||
)
|
)
|
||||||
: _NarrowLayout(
|
: _NarrowLayout(
|
||||||
errorMessage: _errorMessage,
|
errorMessage: _errorMessage,
|
||||||
errorDetail: _errorDetail,
|
|
||||||
onError: _handleError,
|
onError: _handleError,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -65,23 +61,9 @@ class _LandingScreenState extends State<LandingScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleError(Object error, [StackTrace? stackTrace]) {
|
void _handleError(Object error) {
|
||||||
authLog(
|
|
||||||
'LandingScreen sign-in error',
|
|
||||||
error: error,
|
|
||||||
stackTrace: stackTrace,
|
|
||||||
context: kIsWeb
|
|
||||||
? <String, Object?>{
|
|
||||||
'pageOrigin': Uri.base.origin,
|
|
||||||
'pageHost': Uri.base.host,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_errorMessage = _friendlyError(error);
|
_errorMessage = _friendlyError(error);
|
||||||
_errorDetail = authVerboseLoggingEnabled
|
|
||||||
? describeAuthError(error)
|
|
||||||
: null;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -89,12 +71,9 @@ class _LandingScreenState extends State<LandingScreen> {
|
|||||||
if (error is FirebaseAuthException) {
|
if (error is FirebaseAuthException) {
|
||||||
switch (error.code) {
|
switch (error.code) {
|
||||||
case 'unauthorized-domain':
|
case 'unauthorized-domain':
|
||||||
case 'auth/unauthorized-domain':
|
final String origin = kIsWeb ? Uri.base.origin : 'this app';
|
||||||
final String host = kIsWeb ? Uri.base.host : 'your domain';
|
return 'Add $origin under Firebase Console → Authentication → '
|
||||||
return 'Firebase does not authorize "$host" yet. In project '
|
'Settings → Authorized domains.';
|
||||||
'cyberhybridhub → Authentication → Settings → Authorized domains, '
|
|
||||||
'add exactly: $host (no https://). Run '
|
|
||||||
'scripts/check-firebase-auth-domains.sh on the server to verify.';
|
|
||||||
case 'operation-not-allowed':
|
case 'operation-not-allowed':
|
||||||
return 'Google sign-in is not enabled. Turn on the Google provider '
|
return 'Google sign-in is not enabled. Turn on the Google provider '
|
||||||
'in Firebase Console → Authentication → Sign-in method.';
|
'in Firebase Console → Authentication → Sign-in method.';
|
||||||
@ -103,11 +82,7 @@ class _LandingScreenState extends State<LandingScreen> {
|
|||||||
'or try again.';
|
'or try again.';
|
||||||
case 'popup-closed-by-user':
|
case 'popup-closed-by-user':
|
||||||
case 'cancelled-popup-request':
|
case 'cancelled-popup-request':
|
||||||
case 'auth/popup-closed-by-user':
|
|
||||||
return 'Sign-in was cancelled. Tap below to try again.';
|
return 'Sign-in was cancelled. Tap below to try again.';
|
||||||
case 'auth/unauthorized-domain':
|
|
||||||
return 'This site is not authorized for Firebase Auth. Add your '
|
|
||||||
'domain in Firebase Console → Authentication → Authorized domains.';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -127,10 +102,6 @@ class _LandingScreenState extends State<LandingScreen> {
|
|||||||
if (raw.contains('googleWebOAuthClientId')) {
|
if (raw.contains('googleWebOAuthClientId')) {
|
||||||
return raw.replaceFirst('Bad state: ', '');
|
return raw.replaceFirst('Bad state: ', '');
|
||||||
}
|
}
|
||||||
if (raw.contains('authenticate is not supported on the web')) {
|
|
||||||
return 'Sign-in is retrying with a full-page redirect. If nothing happens, '
|
|
||||||
'allow popups for this site or hard-refresh and try again.';
|
|
||||||
}
|
|
||||||
return 'Could not sign in. Please try again.';
|
return 'Could not sign in. Please try again.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -184,14 +155,9 @@ class _BackgroundGlow extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _NarrowLayout extends StatelessWidget {
|
class _NarrowLayout extends StatelessWidget {
|
||||||
const _NarrowLayout({
|
const _NarrowLayout({required this.errorMessage, required this.onError});
|
||||||
required this.errorMessage,
|
|
||||||
required this.errorDetail,
|
|
||||||
required this.onError,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
final String? errorDetail;
|
|
||||||
final void Function(Object error) onError;
|
final void Function(Object error) onError;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -207,11 +173,7 @@ class _NarrowLayout extends StatelessWidget {
|
|||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
const _BenefitsList(),
|
const _BenefitsList(),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
_CtaSection(
|
_CtaSection(errorMessage: errorMessage, onError: onError),
|
||||||
errorMessage: errorMessage,
|
|
||||||
errorDetail: errorDetail,
|
|
||||||
onError: onError,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
const _TrustFooter(),
|
const _TrustFooter(),
|
||||||
],
|
],
|
||||||
@ -221,14 +183,9 @@ class _NarrowLayout extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _WideLayout extends StatelessWidget {
|
class _WideLayout extends StatelessWidget {
|
||||||
const _WideLayout({
|
const _WideLayout({required this.errorMessage, required this.onError});
|
||||||
required this.errorMessage,
|
|
||||||
required this.errorDetail,
|
|
||||||
required this.onError,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
final String? errorDetail;
|
|
||||||
final void Function(Object error) onError;
|
final void Function(Object error) onError;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -254,11 +211,7 @@ class _WideLayout extends StatelessWidget {
|
|||||||
const SizedBox(width: 48),
|
const SizedBox(width: 48),
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 4,
|
flex: 4,
|
||||||
child: _CtaCard(
|
child: _CtaCard(errorMessage: errorMessage, onError: onError),
|
||||||
errorMessage: errorMessage,
|
|
||||||
errorDetail: errorDetail,
|
|
||||||
onError: onError,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -391,35 +344,21 @@ class _BenefitsList extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _CtaSection extends StatelessWidget {
|
class _CtaSection extends StatelessWidget {
|
||||||
const _CtaSection({
|
const _CtaSection({required this.errorMessage, required this.onError});
|
||||||
required this.errorMessage,
|
|
||||||
required this.errorDetail,
|
|
||||||
required this.onError,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
final String? errorDetail;
|
|
||||||
final void Function(Object error) onError;
|
final void Function(Object error) onError;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return _CtaCard(
|
return _CtaCard(errorMessage: errorMessage, onError: onError);
|
||||||
errorMessage: errorMessage,
|
|
||||||
errorDetail: errorDetail,
|
|
||||||
onError: onError,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CtaCard extends StatelessWidget {
|
class _CtaCard extends StatelessWidget {
|
||||||
const _CtaCard({
|
const _CtaCard({required this.errorMessage, required this.onError});
|
||||||
required this.errorMessage,
|
|
||||||
required this.errorDetail,
|
|
||||||
required this.onError,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
final String? errorDetail;
|
|
||||||
final void Function(Object error) onError;
|
final void Function(Object error) onError;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -464,32 +403,13 @@ class _CtaCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Text(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
errorMessage!,
|
||||||
children: <Widget>[
|
style: TextStyle(
|
||||||
Text(
|
color: Theme.of(context).colorScheme.error,
|
||||||
errorMessage!,
|
fontSize: 14,
|
||||||
style: TextStyle(
|
),
|
||||||
color: Theme.of(context).colorScheme.error,
|
textAlign: TextAlign.center,
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
if (errorDetail != null &&
|
|
||||||
errorDetail != errorMessage) ...<Widget>[
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
errorDetail!,
|
|
||||||
style: TextStyle(
|
|
||||||
color: Theme.of(context).colorScheme.error
|
|
||||||
.withValues(alpha: 0.85),
|
|
||||||
fontSize: 11,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|||||||
@ -1,13 +1,9 @@
|
|||||||
import 'package:firebase_auth/firebase_auth.dart';
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
|
|
||||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
import 'package:google_sign_in/google_sign_in.dart';
|
import 'package:google_sign_in/google_sign_in.dart';
|
||||||
|
|
||||||
import '../config/auth_config.dart';
|
import '../config/auth_config.dart';
|
||||||
import '../firebase_options.dart';
|
|
||||||
import '../models/app_user.dart';
|
import '../models/app_user.dart';
|
||||||
import '../utils/auth_log.dart';
|
|
||||||
import '../utils/firebase_auth_domain_check.dart';
|
|
||||||
|
|
||||||
class AuthServiceFirebase {
|
class AuthServiceFirebase {
|
||||||
AuthServiceFirebase._();
|
AuthServiceFirebase._();
|
||||||
@ -25,11 +21,7 @@ class AuthServiceFirebase {
|
|||||||
AppUser? get currentUser => _mapUser(_firebaseAuth.currentUser);
|
AppUser? get currentUser => _mapUser(_firebaseAuth.currentUser);
|
||||||
|
|
||||||
Future<void> initialize() async {
|
Future<void> initialize() async {
|
||||||
authLog('initialize', context: _authEnvironmentContext());
|
|
||||||
|
|
||||||
if (kIsWeb) {
|
if (kIsWeb) {
|
||||||
await logFirebaseAuthorizedDomainsIfWeb();
|
|
||||||
await _completeWebRedirectIfAny();
|
|
||||||
// Skip eager google_sign_in init on web. Firebase Auth's signInWithPopup
|
// Skip eager google_sign_in init on web. Firebase Auth's signInWithPopup
|
||||||
// already loads Google Identity Services, and double-initializing logs
|
// already loads Google Identity Services, and double-initializing logs
|
||||||
// a "google.accounts.id.initialize() is called multiple times" warning.
|
// a "google.accounts.id.initialize() is called multiple times" warning.
|
||||||
@ -46,45 +38,9 @@ class AuthServiceFirebase {
|
|||||||
await _googleSignIn.initialize();
|
await _googleSignIn.initialize();
|
||||||
}
|
}
|
||||||
_googleSignInReady = true;
|
_googleSignInReady = true;
|
||||||
authLog('google_sign_in initialized (mobile/desktop)');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// After a redirect-based OAuth round-trip, Firebase completes sign-in here.
|
|
||||||
Future<void> _completeWebRedirectIfAny() async {
|
|
||||||
authLog('web: checking getRedirectResult (return from redirect flow)');
|
|
||||||
try {
|
|
||||||
final UserCredential credential = await _firebaseAuth.getRedirectResult();
|
|
||||||
final User? user = credential.user;
|
|
||||||
if (user != null) {
|
|
||||||
authLog(
|
|
||||||
'web: redirect sign-in completed',
|
|
||||||
context: <String, Object?>{
|
|
||||||
'uid': user.uid,
|
|
||||||
if (user.email != null) 'email': user.email!,
|
|
||||||
'provider': credential.credential?.providerId ?? 'unknown',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
authLog('web: getRedirectResult — no user (normal on fresh load)');
|
|
||||||
}
|
|
||||||
} on FirebaseAuthException catch (e, st) {
|
|
||||||
authLog(
|
|
||||||
'web: getRedirectResult FirebaseAuthException',
|
|
||||||
error: e,
|
|
||||||
stackTrace: st,
|
|
||||||
context: firebaseExceptionContext(e),
|
|
||||||
);
|
|
||||||
} catch (e, st) {
|
|
||||||
authLog(
|
|
||||||
'web: getRedirectResult failed',
|
|
||||||
error: e,
|
|
||||||
stackTrace: st,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> signInWithGoogle() async {
|
Future<void> signInWithGoogle() async {
|
||||||
authLog('signInWithGoogle start', context: _authEnvironmentContext());
|
|
||||||
if (kIsWeb) {
|
if (kIsWeb) {
|
||||||
await _signInWithGoogleWeb();
|
await _signInWithGoogleWeb();
|
||||||
return;
|
return;
|
||||||
@ -92,106 +48,55 @@ class AuthServiceFirebase {
|
|||||||
|
|
||||||
_ensureGoogleSignInReady();
|
_ensureGoogleSignInReady();
|
||||||
|
|
||||||
try {
|
final GoogleSignInAccount account = await _googleSignIn.authenticate();
|
||||||
final GoogleSignInAccount account = await _googleSignIn.authenticate();
|
final String? idToken = account.authentication.idToken;
|
||||||
final String? idToken = account.authentication.idToken;
|
if (idToken == null) {
|
||||||
if (idToken == null) {
|
throw StateError('Google Sign-In did not return an ID token.');
|
||||||
throw StateError('Google Sign-In did not return an ID token.');
|
|
||||||
}
|
|
||||||
|
|
||||||
final credential = GoogleAuthProvider.credential(idToken: idToken);
|
|
||||||
await _firebaseAuth.signInWithCredential(credential);
|
|
||||||
authLog('signInWithCredential succeeded', context: <String, Object?>{
|
|
||||||
'uid': _firebaseAuth.currentUser?.uid ?? '',
|
|
||||||
});
|
|
||||||
} catch (e, st) {
|
|
||||||
authLog('native sign-in failed', error: e, stackTrace: st);
|
|
||||||
rethrow;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final credential = GoogleAuthProvider.credential(idToken: idToken);
|
||||||
|
await _firebaseAuth.signInWithCredential(credential);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Web: try Firebase popup first; on failure use full-page redirect.
|
/// Web: popup avoids Firebase redirect sessionStorage (broken under ETP).
|
||||||
///
|
/// Falls back to Google Identity Services + [signInWithCredential] when set up.
|
||||||
/// [GoogleSignIn.authenticate] is not implemented on web (google_sign_in 7.x);
|
|
||||||
/// use [signInWithRedirect] instead of a GIS programmatic fallback.
|
|
||||||
Future<void> _signInWithGoogleWeb() async {
|
Future<void> _signInWithGoogleWeb() async {
|
||||||
authLog('web: sign-in start', context: _authEnvironmentContext());
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
authLog('web: attempting signInWithPopup(GoogleAuthProvider)');
|
await _firebaseAuth.signInWithPopup(GoogleAuthProvider());
|
||||||
final UserCredential credential = await _firebaseAuth.signInWithPopup(
|
|
||||||
GoogleAuthProvider(),
|
|
||||||
);
|
|
||||||
authLog(
|
|
||||||
'web: signInWithPopup succeeded',
|
|
||||||
context: <String, Object?>{
|
|
||||||
'uid': credential.user?.uid ?? '',
|
|
||||||
if (credential.user?.email != null) 'email': credential.user!.email!,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
} on FirebaseAuthException catch (e, st) {
|
} on FirebaseAuthException catch (e) {
|
||||||
authLog(
|
|
||||||
'web: signInWithPopup FirebaseAuthException',
|
|
||||||
error: e,
|
|
||||||
stackTrace: st,
|
|
||||||
context: firebaseExceptionContext(e),
|
|
||||||
);
|
|
||||||
if (_isUserCancelledAuth(e)) {
|
if (_isUserCancelledAuth(e)) {
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
await _signInWithGoogleRedirect(triggerCode: e.code);
|
|
||||||
return;
|
|
||||||
} catch (e, st) {
|
|
||||||
authLog(
|
|
||||||
'web: signInWithPopup unexpected error',
|
|
||||||
error: e,
|
|
||||||
stackTrace: st,
|
|
||||||
);
|
|
||||||
if (e is UnimplementedError) {
|
|
||||||
authLog(
|
|
||||||
'web: caught UnimplementedError from popup path — using redirect',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await _signInWithGoogleRedirect(triggerCode: e.runtimeType.toString());
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Full-page redirect when popup/iframe auth fails.
|
if (googleWebOAuthClientId == null) {
|
||||||
Future<void> _signInWithGoogleRedirect({String? triggerCode}) async {
|
throw StateError(
|
||||||
authLog(
|
'Google sign-in failed in this browser (often Firefox with strict '
|
||||||
'web: attempting signInWithRedirect after popup failure',
|
'privacy). Set googleWebOAuthClientId in lib/config/auth_config.dart '
|
||||||
context: <String, Object?>{
|
'(Firebase Console → Authentication → Google → Web SDK configuration) '
|
||||||
'trigger': triggerCode ?? 'unknown',
|
'and add ${Uri.base.origin} to that client\'s authorized JavaScript '
|
||||||
..._authEnvironmentContext(),
|
'origins in Google Cloud Console.',
|
||||||
},
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
await _firebaseAuth.signInWithRedirect(GoogleAuthProvider());
|
|
||||||
authLog(
|
|
||||||
'web: signInWithRedirect invoked — page will navigate away; '
|
|
||||||
'on return, getRedirectResult runs in initialize()',
|
|
||||||
);
|
);
|
||||||
} on FirebaseAuthException catch (e, st) {
|
|
||||||
authLog(
|
|
||||||
'web: signInWithRedirect FirebaseAuthException',
|
|
||||||
error: e,
|
|
||||||
stackTrace: st,
|
|
||||||
context: firebaseExceptionContext(e),
|
|
||||||
);
|
|
||||||
rethrow;
|
|
||||||
} catch (e, st) {
|
|
||||||
authLog(
|
|
||||||
'web: signInWithRedirect failed',
|
|
||||||
error: e,
|
|
||||||
stackTrace: st,
|
|
||||||
);
|
|
||||||
rethrow;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!_googleSignInReady) {
|
||||||
|
await _googleSignIn.initialize(clientId: googleWebOAuthClientId);
|
||||||
|
_googleSignInReady = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
final GoogleSignInAccount account = await _googleSignIn.authenticate();
|
||||||
|
final String? idToken = account.authentication.idToken;
|
||||||
|
if (idToken == null) {
|
||||||
|
throw StateError('Google Sign-In did not return an ID token.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await _firebaseAuth.signInWithCredential(
|
||||||
|
GoogleAuthProvider.credential(idToken: idToken),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> signOut() async {
|
Future<void> signOut() async {
|
||||||
authLog('signOut');
|
|
||||||
if (_googleSignInReady) {
|
if (_googleSignInReady) {
|
||||||
await _googleSignIn.signOut();
|
await _googleSignIn.signOut();
|
||||||
}
|
}
|
||||||
@ -203,17 +108,7 @@ class AuthServiceFirebase {
|
|||||||
if (user == null) {
|
if (user == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
return user.getIdToken(forceRefresh);
|
||||||
return await user.getIdToken(forceRefresh);
|
|
||||||
} catch (e, st) {
|
|
||||||
authLog(
|
|
||||||
'getIdToken failed',
|
|
||||||
error: e,
|
|
||||||
stackTrace: st,
|
|
||||||
context: <String, Object?>{'uid': user.uid},
|
|
||||||
);
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _ensureGoogleSignInReady() {
|
void _ensureGoogleSignInReady() {
|
||||||
@ -229,20 +124,7 @@ class AuthServiceFirebase {
|
|||||||
|
|
||||||
bool _isUserCancelledAuth(FirebaseAuthException e) {
|
bool _isUserCancelledAuth(FirebaseAuthException e) {
|
||||||
return e.code == 'popup-closed-by-user' ||
|
return e.code == 'popup-closed-by-user' ||
|
||||||
e.code == 'cancelled-popup-request' ||
|
e.code == 'cancelled-popup-request';
|
||||||
e.code == 'auth/popup-closed-by-user';
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, Object?> _authEnvironmentContext() {
|
|
||||||
final FirebaseOptions options = DefaultFirebaseOptions.currentPlatform;
|
|
||||||
return <String, Object?>{
|
|
||||||
if (kIsWeb) 'pageOrigin': Uri.base.origin,
|
|
||||||
if (kIsWeb) 'pageHost': Uri.base.host,
|
|
||||||
'authDomain': options.authDomain ?? '',
|
|
||||||
'projectId': options.projectId,
|
|
||||||
'hasGoogleWebClientId': googleWebOAuthClientId != null,
|
|
||||||
'platform': kIsWeb ? 'web' : 'native',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
AppUser? _mapUser(User? user) {
|
AppUser? _mapUser(User? user) {
|
||||||
|
|||||||
@ -1,70 +0,0 @@
|
|||||||
import 'dart:developer' as developer;
|
|
||||||
|
|
||||||
import 'package:firebase_auth/firebase_auth.dart';
|
|
||||||
import 'package:flutter/foundation.dart' show kDebugMode, kIsWeb;
|
|
||||||
|
|
||||||
/// Verbose auth logging (browser console / `journalctl` when enabled).
|
|
||||||
///
|
|
||||||
/// On by default for web builds. Force in any build:
|
|
||||||
/// --dart-define=AUTH_VERBOSE_LOG=true
|
|
||||||
const bool _authVerboseFromEnv = bool.fromEnvironment('AUTH_VERBOSE_LOG');
|
|
||||||
|
|
||||||
bool get authVerboseLoggingEnabled =>
|
|
||||||
kDebugMode || _authVerboseFromEnv || kIsWeb;
|
|
||||||
|
|
||||||
/// Structured auth diagnostics (no tokens or secrets).
|
|
||||||
void authLog(
|
|
||||||
String message, {
|
|
||||||
Object? error,
|
|
||||||
StackTrace? stackTrace,
|
|
||||||
Map<String, Object?>? context,
|
|
||||||
}) {
|
|
||||||
if (!authVerboseLoggingEnabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final StringBuffer buf = StringBuffer('[Auth] $message');
|
|
||||||
if (context != null && context.isNotEmpty) {
|
|
||||||
buf.write(' | ');
|
|
||||||
buf.write(
|
|
||||||
context.entries
|
|
||||||
.map((MapEntry<String, Object?> e) => '${e.key}=${e.value}')
|
|
||||||
.join(' '),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (error != null) {
|
|
||||||
buf.write(' | error=${describeAuthError(error)}');
|
|
||||||
}
|
|
||||||
|
|
||||||
developer.log(
|
|
||||||
buf.toString(),
|
|
||||||
name: 'cyberhybridhub.auth',
|
|
||||||
error: error,
|
|
||||||
stackTrace: stackTrace,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// User-facing detail string; safe to show in support UI (no secrets).
|
|
||||||
String describeAuthError(Object error) {
|
|
||||||
if (error is FirebaseAuthException) {
|
|
||||||
final StringBuffer buf = StringBuffer('FirebaseAuthException');
|
|
||||||
buf.write(' code=${error.code}');
|
|
||||||
if (error.message != null && error.message!.isNotEmpty) {
|
|
||||||
buf.write(' message=${error.message}');
|
|
||||||
}
|
|
||||||
if (error.email != null && error.email!.isNotEmpty) {
|
|
||||||
buf.write(' email=${error.email}');
|
|
||||||
}
|
|
||||||
return buf.toString();
|
|
||||||
}
|
|
||||||
return error.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, Object?> firebaseExceptionContext(FirebaseAuthException e) {
|
|
||||||
return <String, Object?>{
|
|
||||||
'code': e.code,
|
|
||||||
if (e.message != null) 'message': e.message!,
|
|
||||||
if (e.email != null) 'email': e.email!,
|
|
||||||
if (e.credential != null) 'credentialProvider': e.credential!.providerId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,66 +0,0 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
|
||||||
import 'package:http/http.dart' as http;
|
|
||||||
|
|
||||||
import '../firebase_options.dart';
|
|
||||||
import 'auth_log.dart';
|
|
||||||
|
|
||||||
/// Fetches live Firebase Auth authorized domains (public config API) and logs
|
|
||||||
/// whether [Uri.base.host] is allowed. Helps when the console UI disagrees with
|
|
||||||
/// what the backend actually has registered.
|
|
||||||
Future<void> logFirebaseAuthorizedDomainsIfWeb() async {
|
|
||||||
if (!kIsWeb) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final String host = Uri.base.host;
|
|
||||||
final String apiKey = DefaultFirebaseOptions.web.apiKey;
|
|
||||||
final Uri uri = Uri.parse(
|
|
||||||
'https://www.googleapis.com/identitytoolkit/v3/relyingparty/getProjectConfig'
|
|
||||||
'?key=$apiKey',
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
final http.Response response = await http.get(uri);
|
|
||||||
if (response.statusCode != 200) {
|
|
||||||
authLog(
|
|
||||||
'could not load Firebase project config',
|
|
||||||
context: <String, Object?>{
|
|
||||||
'status': response.statusCode,
|
|
||||||
'host': host,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final Object? decoded = jsonDecode(response.body);
|
|
||||||
if (decoded is! Map<String, dynamic>) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final List<String> domains =
|
|
||||||
(decoded['authorizedDomains'] as List<dynamic>?)
|
|
||||||
?.map((dynamic d) => d.toString())
|
|
||||||
.toList() ??
|
|
||||||
<String>[];
|
|
||||||
|
|
||||||
final bool allowed = domains.contains(host);
|
|
||||||
authLog(
|
|
||||||
allowed
|
|
||||||
? 'Firebase authorizedDomains includes this host'
|
|
||||||
: 'Firebase authorizedDomains MISSING this host — add it in Console',
|
|
||||||
context: <String, Object?>{
|
|
||||||
'host': host,
|
|
||||||
'projectId': decoded['projectId'] ?? DefaultFirebaseOptions.web.projectId,
|
|
||||||
'authorizedDomains': domains.join(', '),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (e, st) {
|
|
||||||
authLog(
|
|
||||||
'Firebase authorizedDomains check failed',
|
|
||||||
error: e,
|
|
||||||
stackTrace: st,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import '../services/auth_service.dart';
|
import '../services/auth_service.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
import '../utils/auth_log.dart';
|
|
||||||
|
|
||||||
class GoogleSignInButton extends StatefulWidget {
|
class GoogleSignInButton extends StatefulWidget {
|
||||||
const GoogleSignInButton({
|
const GoogleSignInButton({
|
||||||
@ -27,17 +26,10 @@ class _GoogleSignInButtonState extends State<GoogleSignInButton> {
|
|||||||
|
|
||||||
Future<void> _signIn() async {
|
Future<void> _signIn() async {
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
authLog('GoogleSignInButton tapped');
|
|
||||||
try {
|
try {
|
||||||
await AuthService.instance.signInWithGoogle();
|
await AuthService.instance.signInWithGoogle();
|
||||||
authLog('GoogleSignInButton sign-in completed without error');
|
|
||||||
widget.onSignedIn?.call();
|
widget.onSignedIn?.call();
|
||||||
} catch (error, st) {
|
} catch (error) {
|
||||||
authLog(
|
|
||||||
'GoogleSignInButton sign-in failed',
|
|
||||||
error: error,
|
|
||||||
stackTrace: st,
|
|
||||||
);
|
|
||||||
widget.onError?.call(error);
|
widget.onError?.call(error);
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|||||||
@ -15,14 +15,9 @@ const double _centerSideInset = _holdEdgeButtonWidth + 8;
|
|||||||
|
|
||||||
enum _LockedDragAxis { horizontal, vertical, rejected }
|
enum _LockedDragAxis { horizontal, vertical, rejected }
|
||||||
|
|
||||||
/// Whether a hold-to-swipe edge action was started from pointer or keyboard.
|
|
||||||
enum _HoldInputSource { none, pointer, keyboard }
|
|
||||||
|
|
||||||
/// Swipeable question card: right submits, left defers (does not resolve).
|
/// Swipeable question card: right submits, left defers (does not resolve).
|
||||||
///
|
///
|
||||||
/// The center band supports vertical drag as a slider from -10 (down) to 10 (up).
|
/// The center band supports vertical drag as a slider from -10 (down) to 10 (up).
|
||||||
/// Arrow up/down step the value by one; hold arrow left/right to defer or save
|
|
||||||
/// (same as the edge hold buttons).
|
|
||||||
///
|
///
|
||||||
/// Pan gestures accumulate until [swipeAxisToleranceDegrees] identifies a
|
/// Pan gestures accumulate until [swipeAxisToleranceDegrees] identifies a
|
||||||
/// cardinal axis; diagonal swipes are ignored.
|
/// cardinal axis; diagonal swipes are ignored.
|
||||||
@ -57,10 +52,9 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
|||||||
double _scrollAccumulatedDy = 0;
|
double _scrollAccumulatedDy = 0;
|
||||||
_LockedDragAxis? _lockedDragAxis;
|
_LockedDragAxis? _lockedDragAxis;
|
||||||
bool _acting = false;
|
bool _acting = false;
|
||||||
_HoldInputSource _holdSaveInput = _HoldInputSource.none;
|
bool _holdSavePointerDown = false;
|
||||||
_HoldInputSource _holdDeferInput = _HoldInputSource.none;
|
bool _holdDeferPointerDown = false;
|
||||||
int _lastSnappedValue = 0;
|
int _lastSnappedValue = 0;
|
||||||
late final FocusNode _focusNode;
|
|
||||||
late final AnimationController _snapController;
|
late final AnimationController _snapController;
|
||||||
late final AnimationController _holdSaveController;
|
late final AnimationController _holdSaveController;
|
||||||
late final AnimationController _holdDeferController;
|
late final AnimationController _holdDeferController;
|
||||||
@ -84,12 +78,10 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
|||||||
bool get _holdDeferEnabled => !widget.busy && !_acting;
|
bool get _holdDeferEnabled => !widget.busy && !_acting;
|
||||||
|
|
||||||
bool get _holdSaveActive =>
|
bool get _holdSaveActive =>
|
||||||
_holdSaveInput != _HoldInputSource.none ||
|
_holdSavePointerDown || _holdSaveController.value > 0;
|
||||||
_holdSaveController.value > 0;
|
|
||||||
|
|
||||||
bool get _holdDeferActive =>
|
bool get _holdDeferActive =>
|
||||||
_holdDeferInput != _HoldInputSource.none ||
|
_holdDeferPointerDown || _holdDeferController.value > 0;
|
||||||
_holdDeferController.value > 0;
|
|
||||||
|
|
||||||
bool get _holdEdgeActive => _holdSaveActive || _holdDeferActive;
|
bool get _holdEdgeActive => _holdSaveActive || _holdDeferActive;
|
||||||
|
|
||||||
@ -98,7 +90,6 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_focusNode = FocusNode()..addListener(_onFocusChange);
|
|
||||||
_snapController = AnimationController(
|
_snapController = AnimationController(
|
||||||
vsync: this,
|
vsync: this,
|
||||||
duration: const Duration(milliseconds: 140),
|
duration: const Duration(milliseconds: 140),
|
||||||
@ -111,49 +102,16 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
|||||||
AnimationController(vsync: this, duration: _holdEdgeDuration)
|
AnimationController(vsync: this, duration: _holdEdgeDuration)
|
||||||
..addListener(_syncHoldDeferDrag)
|
..addListener(_syncHoldDeferDrag)
|
||||||
..addStatusListener(_onHoldDeferStatus);
|
..addStatusListener(_onHoldDeferStatus);
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => _requestTileFocus());
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didUpdateWidget(SwipeQuestionTile oldWidget) {
|
|
||||||
super.didUpdateWidget(oldWidget);
|
|
||||||
if (oldWidget.questionId != widget.questionId) {
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => _requestTileFocus());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _requestTileFocus() {
|
|
||||||
if (!mounted || widget.busy) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_focusNode.requestFocus();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_focusNode.dispose();
|
|
||||||
_snapController.dispose();
|
_snapController.dispose();
|
||||||
_holdSaveController.dispose();
|
_holdSaveController.dispose();
|
||||||
_holdDeferController.dispose();
|
_holdDeferController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onFocusChange() {
|
|
||||||
if (_focusNode.hasFocus) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_cancelKeyboardHolds();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _cancelKeyboardHolds() {
|
|
||||||
if (_holdSaveInput == _HoldInputSource.keyboard) {
|
|
||||||
_endHoldSave(_HoldInputSource.keyboard);
|
|
||||||
}
|
|
||||||
if (_holdDeferInput == _HoldInputSource.keyboard) {
|
|
||||||
_endHoldDefer(_HoldInputSource.keyboard);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _syncHoldSaveDrag() {
|
void _syncHoldSaveDrag() {
|
||||||
if (!mounted || _acting || _holdDeferActive) {
|
if (!mounted || _acting || _holdDeferActive) {
|
||||||
return;
|
return;
|
||||||
@ -190,9 +148,9 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
|||||||
_onHoldEdgeDismissed();
|
_onHoldEdgeDismissed();
|
||||||
}
|
}
|
||||||
} else if (status == AnimationStatus.completed &&
|
} else if (status == AnimationStatus.completed &&
|
||||||
_holdSaveInput != _HoldInputSource.none &&
|
_holdSavePointerDown &&
|
||||||
mounted) {
|
mounted) {
|
||||||
_holdSaveInput = _HoldInputSource.none;
|
_holdSavePointerDown = false;
|
||||||
unawaited(_completeHoldSave());
|
unawaited(_completeHoldSave());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -203,26 +161,26 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
|||||||
_onHoldEdgeDismissed();
|
_onHoldEdgeDismissed();
|
||||||
}
|
}
|
||||||
} else if (status == AnimationStatus.completed &&
|
} else if (status == AnimationStatus.completed &&
|
||||||
_holdDeferInput != _HoldInputSource.none &&
|
_holdDeferPointerDown &&
|
||||||
mounted) {
|
mounted) {
|
||||||
_holdDeferInput = _HoldInputSource.none;
|
_holdDeferPointerDown = false;
|
||||||
unawaited(_completeHoldDefer());
|
unawaited(_completeHoldDefer());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _beginHoldSave(_HoldInputSource source) {
|
void _onHoldSavePointerDown(PointerDownEvent event) {
|
||||||
if (!_holdSaveEnabled || _holdDeferActive || _holdSaveInput != _HoldInputSource.none) {
|
if (!_holdSaveEnabled || _holdDeferActive) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_holdSaveInput = source;
|
_holdSavePointerDown = true;
|
||||||
_holdSaveController.forward(from: _holdSaveController.value);
|
_holdSaveController.forward(from: _holdSaveController.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _endHoldSave(_HoldInputSource source) {
|
void _onHoldSavePointerUp(PointerEvent event) {
|
||||||
if (_holdSaveInput != source) {
|
if (!_holdSavePointerDown) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_holdSaveInput = _HoldInputSource.none;
|
_holdSavePointerDown = false;
|
||||||
if (_holdSaveController.value >= 1.0) {
|
if (_holdSaveController.value >= 1.0) {
|
||||||
unawaited(_completeHoldSave());
|
unawaited(_completeHoldSave());
|
||||||
return;
|
return;
|
||||||
@ -230,14 +188,6 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
|||||||
unawaited(_holdSaveController.reverse());
|
unawaited(_holdSaveController.reverse());
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onHoldSavePointerDown(PointerDownEvent event) {
|
|
||||||
_beginHoldSave(_HoldInputSource.pointer);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onHoldSavePointerUp(PointerEvent event) {
|
|
||||||
_endHoldSave(_HoldInputSource.pointer);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _completeHoldSave() async {
|
Future<void> _completeHoldSave() async {
|
||||||
if (_acting || widget.busy || _atZero || !mounted) {
|
if (_acting || widget.busy || _atZero || !mounted) {
|
||||||
return;
|
return;
|
||||||
@ -257,19 +207,19 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _beginHoldDefer(_HoldInputSource source) {
|
void _onHoldDeferPointerDown(PointerDownEvent event) {
|
||||||
if (!_holdDeferEnabled || _holdSaveActive || _holdDeferInput != _HoldInputSource.none) {
|
if (!_holdDeferEnabled || _holdSaveActive) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_holdDeferInput = source;
|
_holdDeferPointerDown = true;
|
||||||
_holdDeferController.forward(from: _holdDeferController.value);
|
_holdDeferController.forward(from: _holdDeferController.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _endHoldDefer(_HoldInputSource source) {
|
void _onHoldDeferPointerUp(PointerEvent event) {
|
||||||
if (_holdDeferInput != source) {
|
if (!_holdDeferPointerDown) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_holdDeferInput = _HoldInputSource.none;
|
_holdDeferPointerDown = false;
|
||||||
if (_holdDeferController.value >= 1.0) {
|
if (_holdDeferController.value >= 1.0) {
|
||||||
unawaited(_completeHoldDefer());
|
unawaited(_completeHoldDefer());
|
||||||
return;
|
return;
|
||||||
@ -277,14 +227,6 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
|||||||
unawaited(_holdDeferController.reverse());
|
unawaited(_holdDeferController.reverse());
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onHoldDeferPointerDown(PointerDownEvent event) {
|
|
||||||
_beginHoldDefer(_HoldInputSource.pointer);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onHoldDeferPointerUp(PointerEvent event) {
|
|
||||||
_endHoldDefer(_HoldInputSource.pointer);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _completeHoldDefer() async {
|
Future<void> _completeHoldDefer() async {
|
||||||
if (_acting || widget.busy || !mounted) {
|
if (_acting || widget.busy || !mounted) {
|
||||||
return;
|
return;
|
||||||
@ -458,12 +400,15 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Steps the slider by [delta] integers, clamped to [-10, 10].
|
/// One wheel notch / trackpad tick → one integer on the [-10, 10] scale.
|
||||||
void _stepSliderBy(int delta) {
|
void _stepSliderFromScroll(double scrollDy) {
|
||||||
if (widget.busy || _acting || delta == 0) {
|
if (scrollDy == 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final int next = (_snappedSliderValue + delta).clamp(_sliderMin, _sliderMax);
|
final int next = (_snappedSliderValue + (scrollDy > 0 ? -1 : 1)).clamp(
|
||||||
|
_sliderMin,
|
||||||
|
_sliderMax,
|
||||||
|
);
|
||||||
if (next == _snappedSliderValue) {
|
if (next == _snappedSliderValue) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -476,44 +421,6 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One wheel notch / trackpad tick → one integer on the [-10, 10] scale.
|
|
||||||
void _stepSliderFromScroll(double scrollDy) {
|
|
||||||
if (scrollDy == 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_stepSliderBy(scrollDy > 0 ? -1 : 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Arrow left/right (held): defer / save swipe. Up/down use [CallbackShortcuts].
|
|
||||||
KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) {
|
|
||||||
if (widget.busy || _acting) {
|
|
||||||
return KeyEventResult.ignored;
|
|
||||||
}
|
|
||||||
|
|
||||||
final LogicalKeyboardKey key = event.logicalKey;
|
|
||||||
const _HoldInputSource kb = _HoldInputSource.keyboard;
|
|
||||||
|
|
||||||
if (key == LogicalKeyboardKey.arrowRight) {
|
|
||||||
if (event is KeyDownEvent) {
|
|
||||||
_beginHoldSave(kb);
|
|
||||||
} else if (event is KeyUpEvent) {
|
|
||||||
_endHoldSave(kb);
|
|
||||||
}
|
|
||||||
return KeyEventResult.handled;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key == LogicalKeyboardKey.arrowLeft) {
|
|
||||||
if (event is KeyDownEvent) {
|
|
||||||
_beginHoldDefer(kb);
|
|
||||||
} else if (event is KeyUpEvent) {
|
|
||||||
_endHoldDefer(kb);
|
|
||||||
}
|
|
||||||
return KeyEventResult.handled;
|
|
||||||
}
|
|
||||||
|
|
||||||
return KeyEventResult.ignored;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _accumulateScrollDelta(double dy) {
|
void _accumulateScrollDelta(double dy) {
|
||||||
if (widget.busy || _acting || dy == 0) {
|
if (widget.busy || _acting || dy == 0) {
|
||||||
return;
|
return;
|
||||||
@ -603,22 +510,10 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
|||||||
40,
|
40,
|
||||||
);
|
);
|
||||||
|
|
||||||
return Focus(
|
return Stack(
|
||||||
focusNode: _focusNode,
|
alignment: Alignment.center,
|
||||||
autofocus: true,
|
clipBehavior: Clip.none,
|
||||||
descendantsAreFocusable: false,
|
children: <Widget>[
|
||||||
onKeyEvent: _onKeyEvent,
|
|
||||||
child: CallbackShortcuts(
|
|
||||||
bindings: <ShortcutActivator, VoidCallback>{
|
|
||||||
const SingleActivator(LogicalKeyboardKey.arrowUp, repeat: true):
|
|
||||||
() => _stepSliderBy(1),
|
|
||||||
const SingleActivator(LogicalKeyboardKey.arrowDown, repeat: true):
|
|
||||||
() => _stepSliderBy(-1),
|
|
||||||
},
|
|
||||||
child: Stack(
|
|
||||||
alignment: Alignment.center,
|
|
||||||
clipBehavior: Clip.none,
|
|
||||||
children: <Widget>[
|
|
||||||
Transform.translate(
|
Transform.translate(
|
||||||
offset: Offset(_dragOffset, 0),
|
offset: Offset(_dragOffset, 0),
|
||||||
child: AnimatedBuilder(
|
child: AnimatedBuilder(
|
||||||
@ -634,9 +529,6 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
|||||||
},
|
},
|
||||||
child: Listener(
|
child: Listener(
|
||||||
behavior: HitTestBehavior.translucent,
|
behavior: HitTestBehavior.translucent,
|
||||||
onPointerDown: widget.busy || _acting
|
|
||||||
? null
|
|
||||||
: (_) => _requestTileFocus(),
|
|
||||||
onPointerSignal: widget.busy || _acting
|
onPointerSignal: widget.busy || _acting
|
||||||
? null
|
? null
|
||||||
: _onPointerSignal,
|
: _onPointerSignal,
|
||||||
@ -718,7 +610,7 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
|||||||
side: _HoldSwipeEdgeSide.left,
|
side: _HoldSwipeEdgeSide.left,
|
||||||
enabled: _holdDeferEnabled,
|
enabled: _holdDeferEnabled,
|
||||||
progress: _holdDeferController.value,
|
progress: _holdDeferController.value,
|
||||||
active: _holdDeferInput != _HoldInputSource.none,
|
active: _holdDeferPointerDown,
|
||||||
icon: Icons.reply,
|
icon: Icons.reply,
|
||||||
ringColor: AppColors.accent,
|
ringColor: AppColors.accent,
|
||||||
tooltipEnabled: 'Hold to defer question',
|
tooltipEnabled: 'Hold to defer question',
|
||||||
@ -737,7 +629,7 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
|||||||
side: _HoldSwipeEdgeSide.right,
|
side: _HoldSwipeEdgeSide.right,
|
||||||
enabled: _holdSaveEnabled,
|
enabled: _holdSaveEnabled,
|
||||||
progress: _holdSaveController.value,
|
progress: _holdSaveController.value,
|
||||||
active: _holdSaveInput != _HoldInputSource.none,
|
active: _holdSavePointerDown,
|
||||||
icon: Icons.save,
|
icon: Icons.save,
|
||||||
ringColor: AppColors.success,
|
ringColor: AppColors.success,
|
||||||
tooltipEnabled: 'Hold to save answer',
|
tooltipEnabled: 'Hold to save answer',
|
||||||
@ -768,9 +660,7 @@ class _SwipeQuestionTileState extends State<SwipeQuestionTile>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,61 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Append hostname(s) to Firebase Auth authorizedDomains via Identity Platform API v2.
|
|
||||||
# Usage: ./scripts/add-firebase-auth-domain.sh [domain ...]
|
|
||||||
# Example: ./scripts/add-firebase-auth-domain.sh cyberhybridhub.com www.cyberhybridhub.com
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
PROJECT="${FIREBASE_PROJECT_ID:-cyberhybridhub}"
|
|
||||||
DOMAINS=("$@")
|
|
||||||
|
|
||||||
if [ "${#DOMAINS[@]}" -eq 0 ]; then
|
|
||||||
DOMAINS=(cyberhybridhub.com www.cyberhybridhub.com)
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! command -v gcloud >/dev/null 2>&1; then
|
|
||||||
echo "gcloud not found. Add domains manually in Firebase Console, or install gcloud and run:" >&2
|
|
||||||
echo " gcloud auth login" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
TOKEN="$(gcloud auth print-access-token)"
|
|
||||||
BASE="https://identitytoolkit.googleapis.com/v2/projects/${PROJECT}/config"
|
|
||||||
|
|
||||||
echo "Reading current config for project ${PROJECT}..."
|
|
||||||
CURRENT="$(curl -s -H "Authorization: Bearer ${TOKEN}" -H "x-goog-user-project: ${PROJECT}" "${BASE}")"
|
|
||||||
|
|
||||||
if ! echo "$CURRENT" | python3 -c "import json,sys; json.load(sys.stdin)" 2>/dev/null; then
|
|
||||||
echo "Failed to read config:" >&2
|
|
||||||
echo "$CURRENT" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
UPDATED="$(echo "$CURRENT" | DOMAINS="${DOMAINS[*]}" python3 -c "
|
|
||||||
import json, os, sys
|
|
||||||
data = json.load(sys.stdin)
|
|
||||||
domains = list(data.get('authorizedDomains', []))
|
|
||||||
for d in os.environ['DOMAINS'].split():
|
|
||||||
d = d.strip().lower()
|
|
||||||
if d and d not in domains:
|
|
||||||
domains.append(d)
|
|
||||||
data['authorizedDomains'] = domains
|
|
||||||
print(json.dumps({'authorizedDomains': domains}))
|
|
||||||
")"
|
|
||||||
|
|
||||||
echo "Patching authorizedDomains: ${DOMAINS[*]}"
|
|
||||||
RESPONSE="$(curl -s -X PATCH \
|
|
||||||
-H "Authorization: Bearer ${TOKEN}" \
|
|
||||||
-H "x-goog-user-project: ${PROJECT}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"${BASE}?updateMask=authorizedDomains" \
|
|
||||||
-d "${UPDATED}")"
|
|
||||||
|
|
||||||
if echo "$RESPONSE" | python3 -c "import json,sys; json.load(sys.stdin)" 2>/dev/null; then
|
|
||||||
echo "$RESPONSE" | python3 -m json.tool
|
|
||||||
echo ""
|
|
||||||
echo "Done. Verify:"
|
|
||||||
echo " ./scripts/check-firebase-auth-domains.sh"
|
|
||||||
else
|
|
||||||
echo "Patch failed:" >&2
|
|
||||||
echo "$RESPONSE" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Build release Flutter web for VM hosting.
|
|
||||||
# API URL: shell API_BASE_URL > hosting/hosting.env API_PUBLIC_URL >
|
|
||||||
# server/.env API_BASE_URL > default https://cyberhybridhub.com
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
cd "$ROOT"
|
|
||||||
|
|
||||||
if [ -f "$ROOT/scripts/flutter-env.sh" ]; then
|
|
||||||
# shellcheck source=/dev/null
|
|
||||||
source "$ROOT/scripts/flutter-env.sh"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! command -v flutter >/dev/null 2>&1; then
|
|
||||||
echo "flutter not found. Install Flutter or set FLUTTER_ROOT, then:" >&2
|
|
||||||
echo " source scripts/flutter-env.sh" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
_CLI_OVERRIDE="${API_BASE_URL:-}"
|
|
||||||
|
|
||||||
_load_env_api_url() {
|
|
||||||
local file=$1
|
|
||||||
[ -f "$file" ] || return 0
|
|
||||||
local line
|
|
||||||
line="$(grep -E '^API_BASE_URL=' "$file" 2>/dev/null | tail -1 || true)"
|
|
||||||
[ -n "$line" ] || return 0
|
|
||||||
line="${line#API_BASE_URL=}"
|
|
||||||
line="${line%\"}"
|
|
||||||
line="${line#\"}"
|
|
||||||
printf '%s' "$line"
|
|
||||||
}
|
|
||||||
|
|
||||||
API_BASE_URL="https://cyberhybridhub.com"
|
|
||||||
|
|
||||||
_server_api="$(_load_env_api_url "$ROOT/server/.env")"
|
|
||||||
if [ -n "$_server_api" ]; then
|
|
||||||
API_BASE_URL="$_server_api"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -f "$ROOT/hosting/hosting.env" ]; then
|
|
||||||
# shellcheck source=/dev/null
|
|
||||||
source "$ROOT/hosting/hosting.env"
|
|
||||||
API_BASE_URL="${API_PUBLIC_URL:-$API_BASE_URL}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -n "$_CLI_OVERRIDE" ]; then
|
|
||||||
API_BASE_URL="$_CLI_OVERRIDE"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Building release web with API_BASE_URL=$API_BASE_URL"
|
|
||||||
flutter build web --release \
|
|
||||||
--dart-define=API_BASE_URL="$API_BASE_URL" \
|
|
||||||
--dart-define=AUTH_VERBOSE_LOG=true
|
|
||||||
|
|
||||||
if [ -x "$ROOT/scripts/copy_flutter_js_source_map.sh" ]; then
|
|
||||||
"$ROOT/scripts/copy_flutter_js_source_map.sh" "$ROOT/build/web" || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Done: $ROOT/build/web"
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Print Firebase Auth authorized domains and verify production hostnames.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
API_KEY="${FIREBASE_WEB_API_KEY:-AIzaSyDbYBh6nO-UasqwM-dkVVJAvw5wMhRTsIs}"
|
|
||||||
|
|
||||||
# Hostnames the app uses (no scheme, no port).
|
|
||||||
REQUIRED_DOMAINS=(
|
|
||||||
cyberhybridhub.com
|
|
||||||
)
|
|
||||||
|
|
||||||
OPTIONAL_DOMAINS=(
|
|
||||||
www.cyberhybridhub.com
|
|
||||||
)
|
|
||||||
|
|
||||||
echo "Fetching authorized domains for project cyberhybridhub..."
|
|
||||||
JSON="$(curl -s "https://www.googleapis.com/identitytoolkit/v3/relyingparty/getProjectConfig?key=${API_KEY}")"
|
|
||||||
echo "$JSON" | python3 -m json.tool
|
|
||||||
|
|
||||||
DOMAINS="$(echo "$JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); print('\n'.join(d.get('authorizedDomains',[])))")"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== Check ==="
|
|
||||||
MISSING=0
|
|
||||||
for domain in "${REQUIRED_DOMAINS[@]}"; do
|
|
||||||
if echo "$DOMAINS" | grep -Fxq "$domain"; then
|
|
||||||
echo "OK $domain"
|
|
||||||
else
|
|
||||||
echo "FAIL $domain (required — Google sign-in will not work on https://$domain)"
|
|
||||||
MISSING=1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
for domain in "${OPTIONAL_DOMAINS[@]}"; do
|
|
||||||
if echo "$DOMAINS" | grep -Fxq "$domain"; then
|
|
||||||
echo "OK $domain"
|
|
||||||
else
|
|
||||||
echo "WARN $domain (optional; add if users visit https://$domain)"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ "$MISSING" -ne 0 ]; then
|
|
||||||
echo ""
|
|
||||||
echo "Fix (pick one):"
|
|
||||||
echo " 1. Firebase Console → project cyberhybridhub → Authentication → Settings"
|
|
||||||
echo " → Authorized domains → Add domain → cyberhybridhub.com (no https://)"
|
|
||||||
echo " 2. After editing firebase.json redirect URIs:"
|
|
||||||
echo " firebase login && firebase deploy --only auth --project cyberhybridhub"
|
|
||||||
echo " 3. Run: ./scripts/add-firebase-auth-domain.sh cyberhybridhub.com"
|
|
||||||
echo " (needs gcloud auth with Firebase Admin permissions)"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "All required domains are authorized."
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Prepare a production deploy (no sudo). After this succeeds, install/restart systemd:
|
|
||||||
# ./hosting/install-systemd.sh
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
cd "$ROOT"
|
|
||||||
|
|
||||||
echo "==> Flutter web (release)"
|
|
||||||
"$ROOT/scripts/build-production-web.sh"
|
|
||||||
|
|
||||||
echo "==> API dependencies"
|
|
||||||
(
|
|
||||||
cd "$ROOT/server"
|
|
||||||
if ! command -v dart >/dev/null 2>&1; then
|
|
||||||
echo "dart not found on PATH" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
dart pub get
|
|
||||||
)
|
|
||||||
|
|
||||||
if [ ! -f "$ROOT/server/.env" ]; then
|
|
||||||
echo ""
|
|
||||||
echo "Warning: $ROOT/server/.env is missing."
|
|
||||||
echo " Create and configure it before starting cyberhybridhub-api."
|
|
||||||
if [ -f "$ROOT/server/.env.example" ]; then
|
|
||||||
echo " cp server/.env.example server/.env"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Deploy artifacts ready."
|
|
||||||
echo "Next (requires sudo):"
|
|
||||||
echo " cp hosting/hosting.env.example hosting/hosting.env # if not done"
|
|
||||||
echo " edit hosting/hosting.env"
|
|
||||||
echo " ./hosting/install-systemd.sh"
|
|
||||||
@ -1,11 +1,3 @@
|
|||||||
# Source before flutter/dart commands:
|
# Source this file before flutter/dart commands in any shell:
|
||||||
# source /home/chh/cyberhybridhub.com/scripts/flutter-env.sh
|
# source /home/nathan/cyberhybridhub.com/scripts/flutter-env.sh
|
||||||
#
|
export PATH="$HOME/github-open/flutter/bin:$PATH"
|
||||||
# Override install location:
|
|
||||||
# export FLUTTER_ROOT="$HOME/path/to/flutter"
|
|
||||||
|
|
||||||
if [ -n "${FLUTTER_ROOT:-}" ] && [ -d "$FLUTTER_ROOT/bin" ]; then
|
|
||||||
export PATH="$FLUTTER_ROOT/bin:$PATH"
|
|
||||||
elif [ -d "$HOME/github-open/flutter/bin" ]; then
|
|
||||||
export PATH="$HOME/github-open/flutter/bin:$PATH"
|
|
||||||
fi
|
|
||||||
|
|||||||
@ -8,16 +8,12 @@
|
|||||||
// * Serves files from a root directory passed on the command line.
|
// * Serves files from a root directory passed on the command line.
|
||||||
// * SPA fallback: requests with no file extension that don't match a real
|
// * SPA fallback: requests with no file extension that don't match a real
|
||||||
// file fall back to index.html so client-side routing works.
|
// file fall back to index.html so client-side routing works.
|
||||||
// * Dev (default): no-cache on every response.
|
// * No-cache headers on every response so a fresh `flutter build web`
|
||||||
// * Production (STATIC_SERVER_PRODUCTION=1): cache static assets; no-cache
|
// always reaches the browser without a hard refresh.
|
||||||
// for HTML entrypoints.
|
|
||||||
// * Rejects path traversal (`..` segments).
|
// * Rejects path traversal (`..` segments).
|
||||||
//
|
//
|
||||||
// Usage:
|
// Usage:
|
||||||
// dart run scripts/web_static_server.dart <root-dir> <port> [bind-host]
|
// dart run scripts/web_static_server.dart <root-dir> <port>
|
||||||
//
|
|
||||||
// bind-host: 127.0.0.1 (default), localhost, 0.0.0.0, or any
|
|
||||||
// Override bind via WEB_BIND_HOST env.
|
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
@ -25,51 +21,25 @@ import 'dart:io';
|
|||||||
Future<void> main(List<String> args) async {
|
Future<void> main(List<String> args) async {
|
||||||
final String rootArg = args.isNotEmpty ? args[0] : 'build/web';
|
final String rootArg = args.isNotEmpty ? args[0] : 'build/web';
|
||||||
final int port = args.length > 1 ? int.parse(args[1]) : 8080;
|
final int port = args.length > 1 ? int.parse(args[1]) : 8080;
|
||||||
final String bindHost = args.length > 2
|
|
||||||
? args[2]
|
|
||||||
: (Platform.environment['WEB_BIND_HOST'] ?? '127.0.0.1');
|
|
||||||
|
|
||||||
final Directory rootDir = Directory(rootArg).absolute;
|
final Directory rootDir = Directory(rootArg).absolute;
|
||||||
if (!rootDir.existsSync()) {
|
if (!rootDir.existsSync()) {
|
||||||
stderr.writeln('Static root not found: ${rootDir.path}');
|
stderr.writeln('Static root not found: ${rootDir.path}');
|
||||||
stderr.writeln(
|
stderr.writeln(
|
||||||
'Hint: run `flutter build web` before starting this server.',
|
'Hint: run `flutter build web --debug` before starting this server.',
|
||||||
);
|
);
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
final InternetAddress address = _parseBindHost(bindHost);
|
final HttpServer server = await HttpServer.bind('localhost', port);
|
||||||
final HttpServer server = await HttpServer.bind(address, port);
|
stdout.writeln('Static server listening on http://localhost:$port');
|
||||||
final String displayHost = address.type == InternetAddressType.unix
|
|
||||||
? bindHost
|
|
||||||
: (address.isLoopback ? '127.0.0.1' : '0.0.0.0');
|
|
||||||
stdout.writeln('Static server listening on http://$displayHost:$port');
|
|
||||||
stdout.writeln('Serving: ${rootDir.path}');
|
stdout.writeln('Serving: ${rootDir.path}');
|
||||||
if (Platform.environment['STATIC_SERVER_PRODUCTION'] == '1') {
|
|
||||||
stdout.writeln('Production mode: cache enabled for static assets');
|
|
||||||
}
|
|
||||||
|
|
||||||
await for (final HttpRequest req in server) {
|
await for (final HttpRequest req in server) {
|
||||||
unawaited(_handle(req, rootDir));
|
unawaited(_handle(req, rootDir));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
InternetAddress _parseBindHost(String host) {
|
|
||||||
final String normalized = host.trim().toLowerCase();
|
|
||||||
if (normalized == '0.0.0.0' ||
|
|
||||||
normalized == 'any' ||
|
|
||||||
normalized == '*' ||
|
|
||||||
normalized == 'all') {
|
|
||||||
return InternetAddress.anyIPv4;
|
|
||||||
}
|
|
||||||
if (normalized == 'localhost' ||
|
|
||||||
normalized == '127.0.0.1' ||
|
|
||||||
normalized == 'loopback') {
|
|
||||||
return InternetAddress.loopbackIPv4;
|
|
||||||
}
|
|
||||||
return InternetAddress(normalized);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _handle(HttpRequest req, Directory root) async {
|
Future<void> _handle(HttpRequest req, Directory root) async {
|
||||||
try {
|
try {
|
||||||
String requestPath = req.uri.path;
|
String requestPath = req.uri.path;
|
||||||
@ -109,8 +79,14 @@ Future<void> _handle(HttpRequest req, Directory root) async {
|
|||||||
|
|
||||||
req.response
|
req.response
|
||||||
..statusCode = HttpStatus.ok
|
..statusCode = HttpStatus.ok
|
||||||
..headers.contentType = ContentType.parse(_mimeFor(target.path));
|
..headers.contentType = ContentType.parse(_mimeFor(target.path))
|
||||||
_applyCacheHeaders(req.response, target.path);
|
// Disable caching so dev rebuilds always reach the browser.
|
||||||
|
..headers.set(
|
||||||
|
'Cache-Control',
|
||||||
|
'no-store, no-cache, must-revalidate, max-age=0',
|
||||||
|
)
|
||||||
|
..headers.set('Pragma', 'no-cache')
|
||||||
|
..headers.set('Expires', '0');
|
||||||
|
|
||||||
await target.openRead().pipe(req.response);
|
await target.openRead().pipe(req.response);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
@ -124,31 +100,6 @@ Future<void> _handle(HttpRequest req, Directory root) async {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _applyCacheHeaders(HttpResponse response, String filePath) {
|
|
||||||
final bool production =
|
|
||||||
Platform.environment['STATIC_SERVER_PRODUCTION'] == '1';
|
|
||||||
if (!production) {
|
|
||||||
response.headers.set(
|
|
||||||
'Cache-Control',
|
|
||||||
'no-store, no-cache, must-revalidate, max-age=0',
|
|
||||||
);
|
|
||||||
response.headers.set('Pragma', 'no-cache');
|
|
||||||
response.headers.set('Expires', '0');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final String lower = filePath.toLowerCase();
|
|
||||||
if (lower.endsWith('.html') || lower.endsWith('.htm')) {
|
|
||||||
response.headers.set(
|
|
||||||
'Cache-Control',
|
|
||||||
'no-cache, must-revalidate',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
response.headers.set('Cache-Control', 'public, max-age=86400');
|
|
||||||
}
|
|
||||||
|
|
||||||
String _mimeFor(String filePath) {
|
String _mimeFor(String filePath) {
|
||||||
final int dot = filePath.lastIndexOf('.');
|
final int dot = filePath.lastIndexOf('.');
|
||||||
final String ext = dot < 0 ? '' : filePath.substring(dot + 1).toLowerCase();
|
final String ext = dot < 0 ? '' : filePath.substring(dot + 1).toLowerCase();
|
||||||
|
|||||||
@ -1,39 +0,0 @@
|
|||||||
# Copy to server/.env and configure for this VM.
|
|
||||||
# Required for cyberhybridhub-api systemd service.
|
|
||||||
|
|
||||||
DATABASE_URL=postgresql://postgres:PASSWORD@localhost:5432/cyberhybridhub
|
|
||||||
FIREBASE_WEB_API_KEY=
|
|
||||||
|
|
||||||
PORT=3000
|
|
||||||
|
|
||||||
QUESTION_WORKER_ENABLED=true
|
|
||||||
QUESTION_WORKER_INTERVAL_SECONDS=60
|
|
||||||
QUESTION_PIPELINE_TEST_MODE=false
|
|
||||||
PROSPECTIVE_ANSWER_CLOSENESS_ENABLED=false
|
|
||||||
|
|
||||||
# Required when MARKET_HISTORY_SYNC_ENABLED=true (server refuses to start otherwise).
|
|
||||||
TRADING_ENABLED=true
|
|
||||||
TRADING_WORKER_INGEST_ENABLED=true
|
|
||||||
TRADING_WORKER_EVAL_ENABLED=true
|
|
||||||
TRADING_DEV_ENDPOINTS_ENABLED=false
|
|
||||||
|
|
||||||
# Must be true for /v1/admin/* routes and the home-screen admin icon probe.
|
|
||||||
ADMIN_PORTAL_ENABLED=true
|
|
||||||
# Comma-separated Firebase Auth UIDs (Console → Authentication → Users).
|
|
||||||
ADMIN_FIREBASE_UIDS=
|
|
||||||
|
|
||||||
# Alpaca (required when TRADING_ENABLED=true or MARKET_HISTORY_SYNC_ENABLED=true)
|
|
||||||
ALPACA_API_KEY_ID=
|
|
||||||
ALPACA_API_SECRET_KEY=
|
|
||||||
ALPACA_TRADING_BASE_URL=https://paper-api.alpaca.markets
|
|
||||||
ALPACA_DATA_BASE_URL=https://data.alpaca.markets
|
|
||||||
ALPACA_DATA_FEED=iex
|
|
||||||
ALPACA_ALLOW_LIVE=false
|
|
||||||
|
|
||||||
MARKET_HISTORY_SYNC_ENABLED=true
|
|
||||||
MARKET_HISTORY_WINDOW_DAYS=5
|
|
||||||
MARKET_HISTORY_RETENTION_DAYS=5
|
|
||||||
|
|
||||||
# Public API URL baked into Flutter web builds (see scripts/build-production-web.sh).
|
|
||||||
# Use same origin as the site when HAProxy proxies /v1 and /hubs on :443.
|
|
||||||
API_BASE_URL=https://cyberhybridhub.com
|
|
||||||
@ -103,7 +103,7 @@ fi
|
|||||||
log "Starting web app on http://localhost:${WEB_PORT} ..."
|
log "Starting web app on http://localhost:${WEB_PORT} ..."
|
||||||
(
|
(
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
dart run scripts/web_static_server.dart build/web "${WEB_PORT}" 127.0.0.1 \
|
dart run scripts/web_static_server.dart build/web "${WEB_PORT}" \
|
||||||
2>&1 | prefix_lines web
|
2>&1 | prefix_lines web
|
||||||
) &
|
) &
|
||||||
WEB_PID=$!
|
WEB_PID=$!
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user