Compare commits

..

13 Commits

Author SHA1 Message Date
masoodafar-web d38f50584c fix: resolve version conflict for Foursat.CMSMicroservice.Protobuf package
Build and Deploy to Production / build-and-deploy (push) Failing after 13m13s
2026-03-15 03:21:52 +03:30
masoodafar-web 1c67d7798a Merge branch 'kub-stage' into production 2026-03-15 03:21:44 +03:30
masoodafar-web 2a7bb0592d feat: add K8s deployment manifest with GW_URL and BLAZOR_ENVIRONMENT
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 15m26s
- Create k8s/staging/backoffice-deployment.yaml with env vars:
  GW_URL=https://cms.se.kbs1.ir (injected into appsettings at startup)
  BLAZOR_ENVIRONMENT=Staging
- Update CI/CD to apply K8s manifests on deploy (like CMS does)
- This makes configuration persistent across CI/CD deployments
2026-03-15 03:11:38 +03:30
masoodafar-web 9699833ac9 fix: exclude appsettings from service worker cache
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
appsettings*.json files were cached by the service worker, causing
stale GwUrl (cms.kbs1.ir) to persist in the browser even after the
server files were updated. Now appsettings files are always fetched
fresh from the server, where the entrypoint injects the correct GW_URL.
2026-03-15 03:07:14 +03:30
masoodafar-web b3e6066c90 fix: don't send expired JWT tokens with gRPC requests
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 15m19s
AppTokenProvider now checks token expiry before returning it.
Expired tokens are removed from localStorage and not sent.

This fixes 401 errors on anonymous endpoints (login/OTP) caused by
the gRPC channel attaching an old expired Bearer token to every request.
CMS validates the token even on unauthenticated endpoints and rejects
expired ones with 401.
2026-03-15 01:39:27 +03:30
masoodafar-web 148b8e4011 fix: inject GwUrl into appsettings at container startup
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 15m1s
The blazor-environment header is stripped by the Blazor WASM service
worker cache, causing Blazor to fall back to Production environment
and load appsettings.Production.json (GwUrl=https://cms.kbs1.ir).

Fix: use sed in docker-entrypoint.sh to replace GwUrl in ALL
appsettings*.json files at container startup with the GW_URL env var.
Default GW_URL=https://cms.se.kbs1.ir for staging deployment.
2026-03-15 00:42:38 +03:30
masoodafar-web 1ef5edf939 fix: add blazor-environment header to nginx for staging config
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 14m30s
BackOffice WASM app defaulted to Production environment because
nginx didn't set the blazor-environment response header. This caused
the app to load appsettings.Production.json (GwUrl=https://cms.kbs1.ir)
instead of appsettings.Staging.json (GwUrl=https://cms.se.kbs1.ir),
resulting in 401 gRPC errors on the login page.

- Add docker-entrypoint.sh that generates nginx config with blazor-environment header
- Default BLAZOR_ENVIRONMENT=Staging (configurable via K8s env var)
- This ensures the WASM app loads the correct staging configuration
2026-03-15 00:19:04 +03:30
masoodafar-web 76dbee7d41 ci: retry - pushed missing 9.0.12 SDK packages to Nexus
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 14m27s
2026-03-12 07:01:46 +03:30
masoodafar-web 148a9e0493 fix: remove local nupkg source from NuGet.config (breaks Docker builds)
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 11m28s
2026-03-12 02:28:07 +03:30
masoodafar-web 851a1bbc4f ci: retry build - all NuGet packages pushed to Nexus
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 1m21s
2026-03-12 01:14:19 +03:30
masoodafar-web aa1d21a616 Revert "fix: bypass broken Nexus, use nuget.org + local proto build"
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 1m28s
This reverts commit 7147e9930f.
2026-03-12 00:06:24 +03:30
masoodafar-web 7147e9930f fix: bypass broken Nexus, use nuget.org + local proto build
- NuGet.config: nuget.org for standard packages, local nupkg for proto
- Dockerfile: COPY nupkg/ before restore
- CI: build proto from CMS source before Docker build
2026-03-12 00:03:49 +03:30
masoodafar-web 85208da1b5 feat: manual payment - replace amount with package selector
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 1m39s
- Remove manual amount input field
- Add package selector dropdown (shows name + price)
- Pre-select base package, show price in alert
- Validate package selection before submit
- Send package_id to CMS, proto NuGet 0.0.192
2026-03-11 23:44:36 +03:30
8 changed files with 319 additions and 25 deletions
+81
View File
@@ -0,0 +1,81 @@
name: Build and Deploy to Kubernetes
on:
push:
branches:
- kub-stage
env:
REGISTRY: 194.5.195.53:30080
IMAGE_NAME: admin/backoffice
K8S_SERVER: 194.5.195.53
jobs:
build-and-deploy:
runs-on: ubuntu-latest
container:
image: 194.5.195.53:32082/docker-sshpass:latest
options: --privileged
steps:
- name: Start Docker daemon
run: |
mkdir -p /etc/docker
cat > /etc/docker/daemon.json << 'DAEMON'
{
"insecure-registries": ["194.5.195.53:30080", "194.5.195.53:32500", "194.5.195.53:32082"]
}
DAEMON
echo "🚀 Starting Docker daemon..."
dockerd --iptables=false --ip6tables=false --bridge=none --storage-driver=vfs &
# Wait up to 3 minutes for Docker to be ready
for i in $(seq 1 90); do
if docker info >/dev/null 2>&1; then
echo "✅ Docker daemon is ready (attempt $i)"
docker version
break
else
echo "⏳ Waiting for Docker daemon... (attempt $i/90)"
sleep 2
fi
done
# Final check
if ! docker info >/dev/null 2>&1; then
echo "❌ Docker daemon failed to start after 3 minutes"
exit 1
fi
- name: Checkout code
run: |
git clone --depth 1 --branch kub-stage http://gitea-svc:3000/admin/BackOffice.git .
- name: Login to Docker registries
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login 194.5.195.53:32082 -u admin --password-stdin
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u admin --password-stdin
- name: Build Docker Image
run: |
cd src
DOCKER_BUILDKIT=0 docker build --network host -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest -f BackOffice/Dockerfile .
- name: Push to Registry
run: |
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- name: Deploy to Kubernetes
run: |
export SSHPASS="${{ secrets.SERVER_PASSWORD }}"
# Copy K8s manifests to server
sshpass -e scp -o StrictHostKeyChecking=no k8s/staging/backoffice-deployment.yaml root@${{ env.K8S_SERVER }}:/tmp/backoffice-deployment.yaml
# Apply manifests and restart
sshpass -e ssh -o StrictHostKeyChecking=no root@${{ env.K8S_SERVER }} "
kubectl apply -f /tmp/backoffice-deployment.yaml &&
kubectl rollout restart deployment/backoffice &&
kubectl rollout status deployment/backoffice --timeout=180s &&
rm -f /tmp/backoffice-deployment.yaml
"
echo "✅ Deployed!"
+84
View File
@@ -0,0 +1,84 @@
---
# BackOffice UI Deployment (Blazor WASM + nginx)
apiVersion: apps/v1
kind: Deployment
metadata:
name: backoffice
namespace: default
labels:
app: backoffice
spec:
replicas: 1
selector:
matchLabels:
app: backoffice
template:
metadata:
labels:
app: backoffice
spec:
containers:
- name: backoffice
image: 194.5.195.53:30080/admin/backoffice:latest
imagePullPolicy: Always
ports:
- containerPort: 80
name: http
env:
- name: BLAZOR_ENVIRONMENT
value: "Staging"
- name: GW_URL
value: "https://cms.se.kbs1.ir"
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
imagePullSecrets:
- name: gitea-registry-secret
---
# BackOffice UI Service
apiVersion: v1
kind: Service
metadata:
name: backoffice-svc
namespace: default
spec:
selector:
app: backoffice
ports:
- port: 80
targetPort: 80
type: ClusterIP
---
# BackOffice UI Ingress
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: backoffice-ingress
namespace: default
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/service-upstream: "true"
spec:
ingressClassName: nginx
tls:
- hosts:
- backoffice.se.kbs1.ir
secretName: backoffice-tls
rules:
- host: backoffice.se.kbs1.ir
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: backoffice-svc
port:
number: 80
@@ -1,4 +1,5 @@
using BackOffice.Common.Utilities;
using System.IdentityModel.Tokens.Jwt;
using BackOffice.Common.Utilities;
using Blazored.LocalStorage;
namespace BackOffice.Common.Utilities;
@@ -19,9 +20,41 @@ public class AppTokenProvider : ITokenProvider
{
var authorizationToken = await _localStorage.GetItemAsync<string>(GlobalConstants.JwtTokenKey);
if (!string.IsNullOrEmpty(authorizationToken))
_token = authorizationToken.ToString().Replace("Bearer ", "");
{
var raw = authorizationToken.ToString().Replace("Bearer ", "");
// Don't send expired tokens — they cause 401 on anonymous endpoints like login
if (IsTokenExpired(raw))
{
await _localStorage.RemoveItemAsync(GlobalConstants.JwtTokenKey);
_token = null;
return _token;
}
_token = raw;
}
}
else if (IsTokenExpired(_token))
{
// Cached token has expired since last check
await _localStorage.RemoveItemAsync(GlobalConstants.JwtTokenKey);
_token = null;
}
return _token;
}
private static bool IsTokenExpired(string token)
{
try
{
var handler = new JwtSecurityTokenHandler();
var jwt = handler.ReadJwtToken(token);
// Add 30-second buffer to avoid edge-case race
return jwt.ValidTo < DateTime.UtcNow.AddSeconds(-30);
}
catch
{
// Malformed token — treat as expired
return true;
}
}
}
+9 -9
View File
@@ -25,14 +25,14 @@ RUN rm -rf /usr/share/nginx/html/*
# Copy published wwwroot (Blazor WASM output)
COPY --from=build /app/publish/wwwroot .
# Configure nginx for SPA routing
RUN echo 'server { \
listen 80; \
server_name _; \
location / { \
root /usr/share/nginx/html; \
try_files $uri $uri/ /index.html; \
} \
}' > /etc/nginx/conf.d/default.conf
# Copy entrypoint script that sets blazor-environment header
COPY ["BackOffice/docker-entrypoint.sh", "/docker-entrypoint.sh"]
RUN chmod +x /docker-entrypoint.sh
# Default Blazor environment (override via K8s env var BLAZOR_ENVIRONMENT)
ENV BLAZOR_ENVIRONMENT=Staging
# Default gateway URL for gRPC calls (injected into appsettings at startup)
ENV GW_URL=https://cms.se.kbs1.ir
EXPOSE 80
CMD ["/docker-entrypoint.sh"]
@@ -1,4 +1,5 @@
@using CMSMicroservice.Protobuf.Protos.ManualPayment
@using CMSMicroservice.Protobuf.Protos.Package
@using BackOffice.Pages.Payment.Components
@using BackOffice.Pages.AutoComplete
@using Microsoft.AspNetCore.Components.Forms
@@ -13,15 +14,28 @@
<UserAutoComplete Label="انتخاب کاربر"
@bind-SelectedUserId="_selectedUserId" />
<MudNumericField T="long"
Label="مبلغ"
<MudSelect T="long?"
Label="انتخاب پکیج"
Value="@_selectedPackageId"
ValueChanged="OnPackageChanged"
Variant="Variant.Outlined"
Adornment="Adornment.End"
AdornmentText="تومان"
Margin="Margin.Dense"
Required="true"
RequiredError="مبلغ الزامی است"
Min="1"
@bind-Value="_createModel.Amount" />
RequiredError="انتخاب پکیج الزامی است">
@foreach (var pkg in _packages)
{
<MudSelectItem T="long?" Value="@((long?)pkg.Id)">
@pkg.Title — @pkg.Price.ToString("N0") تومان
</MudSelectItem>
}
</MudSelect>
@if (_selectedPackagePrice.HasValue)
{
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Text">
مبلغ پکیج: <strong>@_selectedPackagePrice.Value.ToString("N0") تومان</strong> — این مبلغ به کیف‌پول کاربر اضافه می‌شود.
</MudAlert>
}
<MudSelect T="int"
Label="نوع پرداخت"
@@ -1,4 +1,5 @@
using CMSMicroservice.Protobuf.Protos.ManualPayment;
using CMSMicroservice.Protobuf.Protos.Package;
using Google.Protobuf;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Forms;
@@ -24,19 +25,23 @@ public partial class ManualPaymentDialog
{
[CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!;
[Inject] public ManualPaymentContract.ManualPaymentContractClient ManualPaymentClient { get; set; } = default!;
[Inject] public PackageContract.PackageContractClient PackageClient { get; set; } = default!;
// Snackbar is injected via _Imports.razor
[Parameter] public ManualPaymentDialogMode Mode { get; set; }
[Parameter] public ManualPaymentModel? Model { get; set; }
private ManualPaymentModel _createModel = new(){
Amount = 0,
};
private ManualPaymentModel _createModel = new(){ Amount = 0 };
private string? _adminNote;
// User selection
private long? _selectedUserId;
// Package selection
private long? _selectedPackageId;
private long? _selectedPackagePrice;
private List<GetAllPackageByFilterResponseModel> _packages = new();
// Type and Description
private int _selectedType = 1; // Default: CashDeposit
private string _description = "عضویت دستی باشگاه مشتریان";
@@ -48,6 +53,46 @@ public partial class ManualPaymentDialog
private bool _isSubmitting;
protected override async Task OnInitializedAsync()
{
await LoadPackages();
}
private async Task LoadPackages()
{
try
{
var request = new GetAllPackageByFilterRequest
{
PaginationState = new() { PageNumber = 1, PageSize = 100 },
Filter = new()
};
var result = await PackageClient.GetAllPackageByFilterAsync(request);
_packages = result.Models?.OrderBy(p => p.SortOrder).ToList()
?? new List<GetAllPackageByFilterResponseModel>();
// اگر پکیج پایه وجود دارد، پیش‌فرض انتخاب کن
var basePackage = _packages.FirstOrDefault(p => p.IsBasePackage);
if (basePackage != null)
{
_selectedPackageId = basePackage.Id;
_selectedPackagePrice = basePackage.Price;
}
}
catch
{
_packages = new List<GetAllPackageByFilterResponseModel>();
}
}
private void OnPackageChanged(long? packageId)
{
_selectedPackageId = packageId;
var pkg = _packages.FirstOrDefault(p => p.Id == packageId);
_selectedPackagePrice = pkg?.Price;
}
private async Task OnImageFileSelected(IBrowserFile? file)
{
if (file == null)
@@ -81,6 +126,12 @@ public partial class ManualPaymentDialog
return;
}
if (!_selectedPackageId.HasValue || _selectedPackageId.Value <= 0)
{
Snackbar.Add("لطفاً پکیج را انتخاب کنید.", Severity.Warning);
return;
}
if (_selectedType <= 0)
{
Snackbar.Add("لطفاً نوع پرداخت را انتخاب کنید.", Severity.Warning);
@@ -101,7 +152,8 @@ public partial class ManualPaymentDialog
var request = new CreateManualPaymentRequest
{
UserId = _selectedUserId.Value,
Amount = _createModel.Amount,
Amount = 0, // deprecated — مبلغ از پکیج خوانده می‌شود
PackageId = _selectedPackageId.Value,
Type = (ManualPaymentType)_selectedType,
Description = _description
};
+30
View File
@@ -0,0 +1,30 @@
#!/bin/sh
# Generate nginx config with blazor-environment header
# BLAZOR_ENVIRONMENT defaults to "Staging" if not set
BLAZOR_ENV="${BLAZOR_ENVIRONMENT:-Staging}"
cat > /etc/nginx/conf.d/default.conf <<NGINX_CONF
server {
listen 80;
server_name _;
location / {
root /usr/share/nginx/html;
try_files \$uri \$uri/ /index.html;
add_header blazor-environment $BLAZOR_ENV;
}
}
NGINX_CONF
# Inject GW_URL into all appsettings JSON files
# This overrides GwUrl regardless of which environment Blazor detects,
# which is critical because the service worker cache strips the
# blazor-environment header and Blazor falls back to Production.
WWWROOT="/usr/share/nginx/html"
if [ -n "$GW_URL" ]; then
echo "[entrypoint] Setting GwUrl to $GW_URL in all appsettings files"
for f in "$WWWROOT"/appsettings*.json; do
[ -f "$f" ] && sed -i "s|\"GwUrl\":\s*\"[^\"]*\"|\"GwUrl\": \"$GW_URL\"|g" "$f"
done
fi
exec nginx -g 'daemon off;'
@@ -9,7 +9,7 @@ self.addEventListener('fetch', event => event.respondWith(onFetch(event)));
const cacheNamePrefix = 'offline-cache-';
const cacheName = `${cacheNamePrefix}${self.assetsManifest.version}`;
const offlineAssetsInclude = [ /\.dll$/, /\.pdb$/, /\.wasm/, /\.html/, /\.js$/, /\.json$/, /\.css$/, /\.woff$/, /\.png$/, /\.jpe?g$/, /\.gif$/, /\.ico$/, /\.blat$/, /\.dat$/ ];
const offlineAssetsExclude = [ /^service-worker\.js$/ ];
const offlineAssetsExclude = [ /^service-worker\.js$/, /^appsettings.*\.json$/ ];
async function onInstall(event) {
console.info('Service worker: Install');