Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
b9aa06e
Manage OAuth sign-out flag & refactor API routing
Codebmk Jun 10, 2026
de3f297
Update changelog.md
Codebmk Jun 10, 2026
7dcd2ec
Check signed-out flag AFTER consuming OAuth token, not before
Codebmk Jun 10, 2026
9d03961
Add access control for forbidden errors across multiple pages
OchiengPaul442 Jun 10, 2026
adc3b6d
Refactor permission checks to use SYSTEM_ADMIN role across various pages
OchiengPaul442 Jun 10, 2026
4edec79
Merge branch 'staging' into permissions-update
OchiengPaul442 Jun 10, 2026
ed2e60a
Add HCaptcha integration to NetworkCoverageAddMonitorDialog and updat…
OchiengPaul442 Jun 10, 2026
6636795
Add HCaptcha site key configuration and validation message to Network…
OchiengPaul442 Jun 10, 2026
b4cf57c
feat(mobile): add lazy loading and shimmer placeholders
Mozart299 Jun 10, 2026
ad750bd
Merge pull request #3621 from airqo-platform/website-hcapture
Baalmart Jun 10, 2026
8da2513
Merge pull request #3618 from airqo-platform/permissions-update
Baalmart Jun 10, 2026
941a515
Update next platform staging image tag to stage-8da25131-1781120225
github-actions[bot] Jun 10, 2026
c3e8e05
fix(mobile): correct location header label logic and fix shimmer over…
Mozart299 Jun 10, 2026
bdff903
Merge branch 'staging' into fix-login-vertex
Codebmk Jun 11, 2026
46d8749
Update changelog.md
Codebmk Jun 11, 2026
19626e4
Prevent duplicate bootstrap in TokenHandoffHandler
Codebmk Jun 11, 2026
a8e0b51
checking shouldSkipBackendOAuthBootstrap() before handling handoff.token
Codebmk Jun 11, 2026
bd67908
Update api-routing.ts
Codebmk Jun 11, 2026
3e25dea
Update changelog.md
Codebmk Jun 11, 2026
f014b3b
Merge pull request #3613 from airqo-platform/fix-login-vertex
Baalmart Jun 11, 2026
e09d212
Merge pull request #3624 from airqo-platform/feat/mobile-lazy-loading
Baalmart Jun 11, 2026
ff1bc1e
Update vertex staging image tag to stage-f014b3b6-1781155767
github-actions[bot] Jun 11, 2026
331b4ed
Integrate hCaptcha into the Sensor Manufacturer Request Form
BwanikaRobert Jun 11, 2026
6efa4d2
Add hcaptchaEnabled compatibility to network request dialog
BwanikaRobert Jun 11, 2026
4c01b27
Merge branch 'staging' into Integrate-hCaptcha-into-the-Sensor-Manufa…
BwanikaRobert Jun 11, 2026
07e15b5
Block programmatic netwot submission
BwanikaRobert Jun 11, 2026
3996f39
Fix two-dot diff triggering unnecessary PR preview deployments
Baalmart Jun 11, 2026
61b087c
Merge pull request #3630 from airqo-platform/fix-pr-diff
Baalmart Jun 11, 2026
adfdff5
Merge pull request #3628 from BwanikaRobert/Integrate-hCaptcha-into-t…
Baalmart Jun 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions .github/workflows/deploy-frontend-azurepreview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,11 @@ jobs:
echo "=============== list modified files ==============="
git fetch origin ${{ github.base_ref }}
git diff --name-only \
origin/${{ github.base_ref }} \
${{ github.event.pull_request.head.sha }}
origin/${{ github.base_ref }}...${{ github.event.pull_request.head.sha }}

echo "========== check paths of modified files =========="
git diff --name-only \
origin/${{ github.base_ref }} \
${{ github.event.pull_request.head.sha }} > files.txt
origin/${{ github.base_ref }}...${{ github.event.pull_request.head.sha }} > files.txt

echo "run_calibrate_app=false" >>$GITHUB_OUTPUT
echo "run_analytics_platform=false" >>$GITHUB_OUTPUT
Expand Down
3 changes: 1 addition & 2 deletions .github/workflows/safe-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,7 @@ jobs:
run: |
git fetch origin ${{ github.base_ref }}
git diff --name-only \
origin/${{ github.base_ref }} \
${{ github.event.pull_request.head.sha }} > files.txt
origin/${{ github.base_ref }}...${{ github.event.pull_request.head.sha }} > files.txt
cat files.txt

echo "website=false" >> $GITHUB_OUTPUT
Expand Down
2 changes: 1 addition & 1 deletion k8s/platform/k8s-aks/airqo-platform/values-stage.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
replicaCount: 1
image:
repository: airqoacr.azurecr.io/airqo-stage-next-platform
tag: stage-ddb8fce8-1781099684
tag: stage-8da25131-1781120225
pullPolicy: IfNotPresent
imagePullSecrets: []
nameOverride: ''
Expand Down
2 changes: 1 addition & 1 deletion k8s/vertex/k8s-aks/airqo-vertex/values-stage.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
replicaCount: 2
image:
repository: airqoacr.azurecr.io/airqo-stage-vertex
tag: stage-ddb8fce8-1781099684
tag: stage-f014b3b6-1781155767
pullPolicy: IfNotPresent
imagePullSecrets: []
nameOverride: ''
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -300,88 +300,120 @@ class LocationListView extends StatelessWidget with UiLoggy {
loggy.debug(
'Split measurements: ${selectedMeasurements.length} selected, ${unselectedMeasurements.length} unselected');

return ListView(
children: [
if (selectedMeasurements.isNotEmpty) ...[
Container(
margin: const EdgeInsets.only(top: 12, bottom: 4),
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Theme.of(context).brightness == Brightness.dark
? AppColors.darkHighlight.withValues(alpha: 0.5)
: AppColors.lightHighlight.withValues(alpha: 0.5),
),
child: Row(
children: [
Icon(
Icons.favorite,
size: 18,
color: AppColors.primaryColor,
),
const SizedBox(width: 8),
TranslatedText(
"Favorite Locations",
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Theme.of(context).textTheme.headlineSmall?.color,
),
),
const Spacer(),
Text(
"${selectedMeasurements.length}",
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: AppColors.primaryColor,
),
),
],
),
),
...selectedMeasurements
.map((m) => _buildLocationTile(m, context: context)),
const Divider(thickness: 1, height: 16),
],
Container(
margin: const EdgeInsets.only(top: 8, bottom: 4),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Theme.of(context).brightness == Brightness.dark
? AppColors.darkHighlight.withValues(alpha: 0.5)
: AppColors.lightHighlight.withValues(alpha: 0.5),
),
child: Row(
children: [
Icon(
Icons.location_on,
size: 18,
color: Theme.of(context).textTheme.headlineSmall?.color,
),
const SizedBox(width: 8),
TranslatedText(
unselectedMeasurements.isEmpty &&
selectedMeasurements.isNotEmpty
? "Other Locations"
: "All Locations",
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Theme.of(context).textTheme.headlineSmall?.color,
),
),
],
),
),
...unselectedMeasurements
.map((m) => _buildLocationTile(m, context: context)),
],
return ListView.builder(
itemCount: _mainListItemCount(selectedMeasurements, unselectedMeasurements),
itemBuilder: (context, index) => _buildMainListItem(
context,
index,
selectedMeasurements,
unselectedMeasurements,
),
);
},
);
}

int _mainListItemCount(List<Measurement> selected, List<Measurement> unselected) {
// With favorites: [favHeader, ...selected, divider, allHeader, ...unselected]
// Without: [allHeader, ...unselected]
return selected.isNotEmpty
? 1 + selected.length + 1 + 1 + unselected.length
: 1 + unselected.length;
}

Widget _buildMainListItem(
BuildContext context,
int index,
List<Measurement> selected,
List<Measurement> unselected,
) {
if (selected.isNotEmpty) {
if (index == 0) return _buildFavoritesHeader(context, selected.length);
if (index <= selected.length) {
return _buildLocationTile(selected[index - 1], context: context);
}
if (index == selected.length + 1) return const Divider(thickness: 1, height: 16);
if (index == selected.length + 2) {
return _buildSectionHeader(context, selected, unselected);
}
return _buildLocationTile(unselected[index - selected.length - 3], context: context);
}
if (index == 0) return _buildSectionHeader(context, selected, unselected);
return _buildLocationTile(unselected[index - 1], context: context);
}

Widget _buildFavoritesHeader(BuildContext context, int count) {
return Container(
margin: const EdgeInsets.only(top: 12, bottom: 4),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Theme.of(context).brightness == Brightness.dark
? AppColors.darkHighlight.withValues(alpha: 0.5)
: AppColors.lightHighlight.withValues(alpha: 0.5),
),
child: Row(
children: [
Icon(Icons.favorite, size: 18, color: AppColors.primaryColor),
const SizedBox(width: 8),
TranslatedText(
"Favorite Locations",
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Theme.of(context).textTheme.headlineSmall?.color,
),
),
const Spacer(),
Text(
"$count",
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: AppColors.primaryColor,
),
),
],
),
);
}

Widget _buildSectionHeader(
BuildContext context,
List<Measurement> selected,
List<Measurement> unselected,
) {
final label = selected.isNotEmpty
? "Other Locations"
: "All Locations";
return Container(
margin: const EdgeInsets.only(top: 8, bottom: 4),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Theme.of(context).brightness == Brightness.dark
? AppColors.darkHighlight.withValues(alpha: 0.5)
: AppColors.lightHighlight.withValues(alpha: 0.5),
),
child: Row(
children: [
Icon(
Icons.location_on,
size: 18,
color: Theme.of(context).textTheme.headlineSmall?.color,
),
const SizedBox(width: 8),
TranslatedText(
label,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Theme.of(context).textTheme.headlineSmall?.color,
),
),
],
),
);
}

Widget _buildLocationTitle(
Measurement measurement, BuildContext context, bool isSelected) {
final details = measurement.siteDetails;
Expand Down
22 changes: 9 additions & 13 deletions src/mobile/lib/src/app/learn/pages/kya_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -166,27 +166,23 @@ class _KyaPageState extends State<KyaPage> with UiLoggy {
return BlocBuilder<KyaBloc, KyaState>(
builder: (context, state) {
if (state is LessonsLoading || _isRetrying) {
return Column(children: [
return ListView(children: [
ShimmerContainer(height: 200, borderRadius: 8, width: double.infinity),
const SizedBox(height: 16),
ShimmerContainer(height: 200, borderRadius: 8, width: double.infinity),
]);
} else if (state is LessonsLoaded) {
return SingleChildScrollView(
child: Column(
children: state.model.kyaLessons
.map((lesson) => KyaLessonContainer(lesson))
.toList(),
),
return ListView.builder(
itemCount: state.model.kyaLessons.length,
itemBuilder: (context, index) =>
KyaLessonContainer(state.model.kyaLessons[index]),
);
} else if (state is LessonsLoadingError) {
if (state.cachedModel != null) {
return SingleChildScrollView(
child: Column(
children: state.cachedModel!.kyaLessons
.map((lesson) => KyaLessonContainer(lesson))
.toList(),
),
return ListView.builder(
itemCount: state.cachedModel!.kyaLessons.length,
itemBuilder: (context, index) =>
KyaLessonContainer(state.cachedModel!.kyaLessons[index]),
);
}
return RefreshIndicator(
Expand Down
13 changes: 13 additions & 0 deletions src/mobile/lib/src/app/learn/widgets/kya_lesson_container.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'package:airqo/src/app/learn/models/lesson_response_model.dart';
import 'package:airqo/src/app/learn/pages/lesson_page.dart';
import 'package:airqo/src/app/shared/widgets/loading_widget.dart';
import 'package:airqo/src/app/shared/widgets/translated_text.dart';
import 'package:flutter/material.dart';

Expand Down Expand Up @@ -30,6 +31,18 @@ class KyaLessonContainer extends StatelessWidget {
child: Image.network(
kyaLesson.image,
fit: BoxFit.cover,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return ShimmerContainer(
height: double.infinity,
width: double.infinity,
borderRadius: 8,
);
},
errorBuilder: (context, error, stackTrace) => Container(
color: Theme.of(context).highlightColor,
child: const Center(child: Icon(Icons.broken_image, size: 48)),
),
),
),
),
Expand Down
4 changes: 4 additions & 0 deletions src/mobile/lib/src/app/profile/pages/profile_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,10 @@ class _ProfilePageState extends State<ProfilePage> with UiLoggy {
fit: BoxFit.cover,
width: 100,
height: 100,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return initialsWidget;
},
errorBuilder: (context, error, stackTrace) {
loggy.warning('Error loading profile image: $error');
return initialsWidget;
Expand Down
4 changes: 0 additions & 4 deletions src/platform/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,6 @@ CLOUDINARY_API_SECRET=
NEXT_PUBLIC_SLACK_WEBHOOK_URL=
NEXT_PUBLIC_ENABLE_SLACK_DEV_NOTIFS=false

# Recaptcha
NEXT_PUBLIC_RECAPTCHA_SITE_KEY=
RECAPTCHA_SECRET_KEY=

# Payments
NEXT_PUBLIC_PAYMENTS_STANDARD_PRICE_ID=
PAYMENTS_STANDARD_PRICE_ID=
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import { LoadingState } from '@/shared/components/ui/loading-state';
import { toast } from '@/shared/components/ui';
import { WarningBanner } from '@/shared/components/ui/banner';
import { formatDate, parseDate } from '@/shared/utils';
import { getUserFriendlyErrorMessage } from '@/shared/utils/errorMessages';
import { getUserFriendlyErrorMessage, isForbiddenError } from '@/shared/utils/errorMessages';
import { AccessDenied } from '@/shared/components/AccessDenied';
import { sanitizeErrorForLogging } from '@/shared/utils/sanitizeErrorForLogging';
import {
AqArrowLeft,
Expand All @@ -26,13 +27,12 @@ import EditClientDialog from '@/modules/api-client/components/EditClientDialog';
import InactiveClientDialog from '@/modules/api-client/components/InactiveClientDialog';
import TokenSecurityDialog from '@/modules/api-client/components/TokenSecurityDialog';
import { PermissionGuard } from '@/shared/components/PermissionGuard';
import { useRBAC, useUser } from '@/shared/hooks';
import { useRBAC } from '@/shared/hooks';

const ClientDetailsPage: React.FC = () => {
const params = useParams();
const router = useRouter();
const { hasAnyPermission } = useRBAC();
const { user } = useUser();
const clientId = params?.clientId as string;

const [editDialogOpen, setEditDialogOpen] = useState(false);
Expand Down Expand Up @@ -247,6 +247,20 @@ const ClientDetailsPage: React.FC = () => {
}

if (error || !client) {
if (isForbiddenError(error)) {
return (
<PermissionGuard
requiredPermissions={['SYSTEM_ADMIN']}
accessDeniedTitle="Access Restricted"
accessDeniedMessage="You need system administrator permissions to access API client details."
>
<AccessDenied
title="Access Denied"
message="You do not have the required permissions to view this client."
/>
</PermissionGuard>
);
}
return (
<div className="space-y-6">
<Button
Expand All @@ -268,10 +282,9 @@ const ClientDetailsPage: React.FC = () => {

return (
<PermissionGuard
requiredRoles={['AIRQO_SUPER_ADMIN']}
customCheck={() => !!user?.email?.toLowerCase().endsWith('@airqo.net')}
requiredPermissions={['SYSTEM_ADMIN']}
accessDeniedTitle="Access Restricted"
accessDeniedMessage="You need super admin privileges and a valid AirQo email to access API client details."
accessDeniedMessage="You need system administrator permissions to access API client details."
>
<div className="space-y-6">
{/* Back Button */}
Expand Down
Loading
Loading