Skip to main content

Domain 01: Authentication & Authorization

Domain Owner: Backend (ASP.NET Core Identity + JWT) / Mobile (Flutter + Dio + SecureStorage) Last Updated: 2026-03-10 Workflows: WF-AUTH-01 through WF-AUTH-07

Domain Introduction

The Authentication & Authorization domain handles user identity, credential management, token lifecycle, permission enforcement, and multi-role dashboard routing across the Almafrica platform. The backend uses ASP.NET Core Identity with JWT bearer tokens, while the mobile client uses Dio HTTP interceptors, Flutter Secure Storage for token persistence, and a layered permission system that combines backend-sourced permissions with a local role-based feature matrix fallback.

Key architectural principles:

  • JWT-based stateless auth: Access tokens carry all role and permission claims; the backend validates against the full permission set.
  • No active role restriction: Users keep ALL permissions from ALL their roles. The mobile app never scopes permissions to a single active role.
  • Dashboard Hub navigation: Multi-role users pick where to navigate (which dashboard), not which role to lock into.
  • Offline-first: Cached credentials, a 30-day offline PIN expiry window, and locally stored permissions allow field agents to work without connectivity.
  • Real-time permission sync: SignalR pushes permission changes to connected mobile clients, with graceful revocation handling that respects unsaved form data.

Domain Summary Diagram

graph LR
subgraph "Mobile Client"
LOGIN((User Login))
REFRESH[Token Refresh Interceptor]
HUB[Dashboard Hub Router]
OFFLINE[Offline Auth Fallback]
PERMSYNC[Permission Sync Listener]
end

subgraph "Backend API"
AUTH_EP[AuthController]
TFA_EP[TwoFactorController]
PERM_HUB_EP[PermissionHub - SignalR]
OTP_SVC[OtpService]
end

subgraph "Storage"
SEC_STORE[[FlutterSecureStorage]]
SHARED_PREFS[[SharedPreferences]]
DB[[AlmafricaDbContext]]
end

LOGIN -->|POST /api/auth/login| AUTH_EP
AUTH_EP -->|JWT + roles + permissions| LOGIN
LOGIN -->|store tokens| SEC_STORE
LOGIN -->|store user info + permissions| SHARED_PREFS
LOGIN -->|fire-and-forget| AUTH_EP
LOGIN -.->|WF-AUTH-07| AUTH_EP

REFRESH -->|POST /api/auth/refresh-token| AUTH_EP
AUTH_EP -->|new JWT pair| REFRESH

LOGIN -->|role count check| HUB
HUB -->|single dashboard| OFFLINE
HUB -->|multi-dashboard| HUB

PERM_HUB_EP -->|PermissionsUpdated| PERMSYNC
PERMSYNC -->|update| SHARED_PREFS

TFA_EP -->|generate OTP| OTP_SVC
OTP_SVC -->|SMS via provider| OTP_SVC

AUTH_EP --> DB
OTP_SVC --> DB

Workflows


WF-AUTH-01: User Login Flow

Trigger: User submits credentials on the login screen Frequency: On-demand (each login attempt) Offline Support: No (requires network for initial login; see WF-AUTH-06 for offline fallback)

Workflow Diagram

graph TD
A((User taps Login)) --> B[Collect emailOrUsername + password + rememberMe]
B --> C[POST /api/auth/login via Dio]
C --> D{Backend: User exists?}
D -->|No| E[Throw UnauthorizedAccessException]
D -->|Yes| F{Backend: Password valid?}
F -->|No| G[Increment AccessFailedCount]
G --> H{Account locked out?}
H -->|Yes| I[Return 401 - Account locked]
H -->|No| J[Return 401 - Invalid credentials]
F -->|Yes| K[Reset AccessFailedCount]
K --> L[GenerateAuthResponseAsync]
L --> M[Build JWT with claims: sub, roles, AgentId, ClientId, collection_point_id]
M --> N[Generate RefreshToken - 64-byte random]
N --> O[Return AuthResponse: accessToken, refreshToken, roles, permissions, userId]
O --> P[Mobile: _storeAuthData]
P --> Q[Write tokens to FlutterSecureStorage]
Q --> R[Write UserInfo + roles to SharedPreferences]
R --> S[Store flat permissions via PermissionStorage]
S --> T[Fire-and-forget: _fetchAndStoreRolePermissions - WF-AUTH-07]
T --> U{Remember Me checked?}
U -->|Yes| V[Save credentials to FlutterSecureStorage]
U -->|No| W[Continue]
V --> W
W --> X{User change detected?}
X -->|Yes| Y[Wipe local database]
X -->|No| Z[Navigate to SplashScreen routing - WF-AUTH-04]
Y --> Z

Node Descriptions

#n8n Node TypeComponentFunctionError Handling
1Manual TriggerLogin Screen UIUser submits email/username + passwordValidation errors shown inline
2HTTP RequestJwtAuthService.login()POST /api/auth/login via DioDioException caught; rethrown for UI handling
3FunctionAuthService.LoginAsync()Validate credentials via UserManager.CheckPasswordAsyncUnauthorizedAccessException for invalid creds
4FunctionAuthService.GenerateAuthResponseAsync()Build JWT claims (sub, roles, AgentId, etc.) + refresh tokenConfig errors if JWT SecretKey missing
5FunctionJwtAuthService._storeAuthData()Write tokens to FlutterSecureStorage, user info to SharedPreferencesErrors logged, non-fatal
6FunctionPermissionStorage.setPermissions()Store flat permission list from AuthResponse.permissionsErrors rethrown
7HTTP RequestJwtAuthService._fetchAndStoreRolePermissions()GET /api/auth/my-permissions-by-role (fire-and-forget)Caught silently; non-blocking
8FunctionJwtAuthService.isUserChange()Compare currentAuthUserId with _lastLoggedInUserIdKey in SharedPreferencesFalse on error (safe default)

Data Transformations

  • Input: LoginRequest { emailOrUsername, password, rememberMe }
  • Processing: Backend looks up ApplicationUser, validates password, generates JWT with all role claims, builds AuthResponse
  • Output: JwtAuthResponse { accessToken, refreshToken, expiresIn, tokenType, authUserId, username, email, fullName, roles[], permissions[], userId }

Error Handling

  • Invalid credentials: Backend returns 401; mobile shows localized error message
  • Account lockout: After multiple failed attempts, UserManager.IsLockedOutAsync returns true; 401 with lockout message
  • Network failure: DioException with connectionError type; login screen shows connectivity error
  • Token storage failure: Logged but non-fatal; user may need to re-login on next app launch

Cross-References

  • Triggers: WF-AUTH-04 (Dashboard Hub routing after successful login)
  • Triggers: WF-AUTH-07 (Per-role permission fetch, fire-and-forget)
  • Triggered by: WF-AUTH-06 (offline fallback may redirect to online login when expired)

WF-AUTH-02: Token Refresh Flow

Trigger: Dio interceptor receives a 401 response on a non-auth endpoint Frequency: Event-driven (whenever access token expires mid-session) Offline Support: No (requires network to exchange refresh token)

Workflow Diagram

graph TD
A((Dio onError: 401 received)) --> B{Is auth endpoint?}
B -->|Yes /login, /register, /refresh-token| C[Pass through - handler.next]
B -->|No| D{skipAuthRetry flag?}
D -->|Yes| C
D -->|No| E{Already retried - authRetried flag?}
E -->|Yes| C
E -->|No| F[Call JwtAuthService.refreshToken]
F --> G[Read refreshToken from FlutterSecureStorage]
G --> H{Refresh token exists?}
H -->|No| I[Return false - keep current auth state]
H -->|Yes| J[POST /api/auth/refresh-token with skipAuthRetry=true]
J --> K{Backend: Token valid + not expired?}
K -->|No| L[Return false - log warning]
K -->|Yes| M[GenerateAuthResponseAsync - new JWT pair]
M --> N[_storeAuthData - update tokens + permissions]
N --> O[Set authRetried=true on original request]
O --> P[Retry original request with new Bearer token]
P --> Q[handler.resolve - return response to caller]
I --> R[handler.next - propagate 401]
L --> R

Node Descriptions

#n8n Node TypeComponentFunctionError Handling
1Webhook TriggerDio InterceptorsWrapper.onErrorIntercept 401 responsesGuards: skip auth endpoints, skip retried requests
2IFGuard checksCheck isAuthEndpoint, skipAuthRetry, alreadyRetriedPass-through if any guard matches
3HTTP RequestJwtAuthService.refreshToken()POST /api/auth/refresh-tokenReturns false on any error
4FunctionAuthService.RefreshTokenAsync()Validate stored RefreshToken and RefreshTokenExpiryTimeUnauthorizedAccessException for invalid/expired
5FunctionJwtAuthService._storeAuthData()Overwrite tokens and permissions in local storageErrors logged
6HTTP Request_dio.fetch(requestOptions)Retry the original failed request with new tokenErrors propagate normally

Data Transformations

  • Input: RefreshTokenRequest { refreshToken } (read from FlutterSecureStorage)
  • Processing: Backend finds user by RefreshToken, checks RefreshTokenExpiryTime > UtcNow, generates new JWT pair
  • Output: AuthResponse with fresh accessToken and refreshToken

Error Handling

  • No refresh token stored: Returns false; original 401 propagates. Auth state is NOT cleared (transient failures tolerated).
  • Expired refresh token: Backend returns 401; refresh returns false; original error propagates.
  • Network failure during refresh: DioException caught; returns false; keeps current auth state.
  • Critical design choice: A single 401 does NOT trigger automatic logout -- the comment in the interceptor says this can be transient during post-login background requests.

Cross-References

  • Triggered by: Any authenticated API call that receives 401
  • Triggers: None directly (but updated tokens flow into subsequent requests)

WF-AUTH-03: Two-Factor Authentication

Trigger: User initiates 2FA setup from account settings, or login requires 2FA verification Frequency: On-demand (setup once; verification on each login if enabled) Offline Support: No (requires network for OTP generation and verification)

Workflow Diagram

graph TD
A((User initiates 2FA setup)) --> B[POST /api/user-management/2fa/setup]
B --> C[TwoFactorService.GenerateSetupDataAsync]
C --> D[Return QR code URL + manual entry key]
D --> E[User scans QR or enters key in authenticator app]
E --> F[User enters TOTP code]
F --> G[POST /api/user-management/2fa/verify]
G --> H[TwoFactorService.EnableTwoFactorAsync]
H --> I{Code valid?}
I -->|No| J[Return 400 - Invalid code]
I -->|Yes| K[Enable 2FA + generate backup codes]
K --> L[Return TwoFactorVerifyResponse with backup codes]

M((OTP SMS Flow)) --> N[OtpService.SendOtpAsync]
N --> O{Development mode?}
O -->|Yes| P[Log OTP 000000 to console]
O -->|No| Q[Generate 6-digit random OTP]
Q --> R[Store OtpCode in database with 10-min expiry]
R --> S[Send SMS via provider - Africa's Talking planned]
S --> T[Return success]
P --> R

U((User enters OTP)) --> V[OtpService.VerifyOtpAsync]
V --> W{OTP record exists + not expired + not used?}
W -->|No| X[Return false]
W -->|Yes| Y{Attempt count < 5?}
Y -->|No| Z[Mark OTP as used - max attempts]
Y -->|Yes| AA{Code matches?}
AA -->|No| AB[Increment attempt count]
AA -->|Yes| AC[Mark OTP as used - verified]

Node Descriptions

#n8n Node TypeComponentFunctionError Handling
1HTTP RequestTwoFactorController.Setup()POST /api/user-management/2fa/setupReturns 400 if setup fails
2FunctionTwoFactorService.GenerateSetupDataAsync()Generate QR code + TOTP secretResult.IsSuccess pattern
3HTTP RequestTwoFactorController.Verify()POST /api/user-management/2fa/verifyReturns 400 for invalid code
4FunctionOtpService.SendOtpAsync()Generate 6-digit OTP, store in OtpCodes table, send via SMSReturns false on failure; dev mode logs bypass code
5FunctionOtpService.VerifyOtpAsync()Look up most recent valid OTP, check attempt count, verify codeMax 5 attempts per OTP; expired OTPs rejected
6ScheduledOtpService.CleanupExpiredOtpsAsync()Remove OTPs older than 7 daysErrors logged

Data Transformations

  • OTP Generation: Phone number + purpose -> 6-digit code stored in OtpCodes table with ExpiresAt = UtcNow + 10 minutes
  • OTP Storage: OtpCode { PhoneNumber, Code, Purpose, ExpiresAt, IsUsed, AttemptCount }
  • TOTP Setup: User ID -> QR code URI + manual key via TwoFactorSetupResponse
  • Backup Codes: Generated on 2FA enable; RegenerateBackupCodesResponse returned once

Error Handling

  • Invalid phone format: Rejected if not starting with +
  • Max attempts exceeded: OTP marked as used after 5 failed attempts
  • Development mode: Bypass code 000000 always accepted; real OTP not sent
  • SMS provider failure: Planned integration with Africa's Talking; currently dev-mode only

Cross-References

  • Triggered by: WF-AUTH-01 (login may require 2FA step)
  • Triggers: None (2FA verification gates login completion)

WF-AUTH-04: Multi-Role Dashboard Hub Routing

Trigger: Successful authentication completes and SplashScreen._navigateToNextScreen() runs Frequency: On every app launch / login Offline Support: Yes (uses cached roles from SharedPreferences)

Workflow Diagram

graph TD
A((SplashScreen._navigateToNextScreen)) --> B{Onboarding completed?}
B -->|No| C[Navigate to /onboarding]
B -->|Yes| D{Token exists in SecureStorage?}
D -->|No| E[Navigate to /login]
D -->|Yes| F{Initial sync completed?}
F -->|No| G[Navigate to /initial-sync]
F -->|Yes| H[ActiveRoleManager.initialize]
H --> I[RoleManager.getCurrentUserRoles -> List of UserRole]
I --> J{hasOnlyWebOnlyRoles - admin/manager only?}
J -->|Yes| K[Logout + Navigate to /login]
J -->|No| L{hasMultipleDashboards?}
L -->|Yes| M[Navigate to /role-selection - Dashboard Hub]
L -->|No| N{Match single role to route}
N -->|warehouseManager/Operator| O[setActiveRole + /warehouse-home]
N -->|fsa| P[setActiveRole + /fsa-home]
N -->|csa| Q[setActiveRole + /csa-home]
N -->|dataAgent| R[setActiveRole + /data-agent-home]
N -->|agent/superAgent fallback| M
N -->|other| S[Resolve fallback route from role list]
S --> T{Route found?}
T -->|Yes| U[setActiveRole + navigate to route]
T -->|No| V[Navigate to /login - preserve auth for offline]

M --> W((Dashboard Hub Screen))
W --> X[Show availableDashboards as cards]
X --> Y[User taps a dashboard card]
Y --> Z[ActiveRoleManager.setActiveRole]
Z --> AA[Navigator.pushReplacementNamed to dashboard.route]
AA --> AB[Fire-and-forget: SyncManager.requestSync]

Node Descriptions

#n8n Node TypeComponentFunctionError Handling
1FunctionSplashScreen._navigateToNextScreen()Determine next route based on auth state + rolesFalls back to /login on any error
2FunctionActiveRoleManager.initialize()Load roles from JwtAuthService.getUserRoles(), resolve persisted active roleTimeout after 3 seconds
3IFActiveRoleManager.hasMultipleDashboardsCheck if availableDashboards.length > 1N/A
4FunctionActiveRoleManager.availableDashboards getterDeduplicate dashboard routes; special handling for agent (FSA+CSA) and superAgent (FSA+CSA+DataAgent)Web-only roles excluded
5UIRoleSelectionScreenDisplay dashboard cards with icon, name, description; user taps to selectLoading overlay during navigation
6FunctionActiveRoleManager.setActiveRole()Persist selected role to SharedPreferences._activeRoleKeyIgnores unavailable roles
7FunctionSyncManager.requestSync()Fire-and-forget background data sync after dashboard selectionErrors caught silently

Data Transformations

  • Input: User's List<UserRole> from cached auth data
  • Processing: ActiveRoleManager.availableDashboards maps roles to DashboardDestination { route, role, name, description, icon } with deduplication:
    • agent expands to /fsa-home + /csa-home
    • superAgent expands to /fsa-home + /csa-home + /data-agent-home
    • warehouseManager and warehouseOperator stay distinct despite sharing /warehouse-home
    • admin/manager excluded (web-only)
  • Output: Navigation to a specific dashboard route or the Dashboard Hub

Error Handling

  • No roles resolved: Falls back to /login
  • Web-only roles only: Logs out user, navigates to /login
  • No mobile dashboard route resolved: Navigates to /login but preserves auth data for offline access
  • ActiveRoleManager timeout: 3-second timeout in splash; continues to navigation regardless

Cross-References

  • Triggered by: WF-AUTH-01 (after successful login)
  • Triggered by: WF-AUTH-06 (offline auth completes, app needs routing)
  • Triggers: Background data sync via SyncManager

WF-AUTH-05: Permission Sync via SignalR

Trigger: Backend pushes PermissionsUpdated message via PermissionHub Frequency: Event-driven (whenever admin changes a user's role or permissions) Offline Support: No (requires active SignalR connection)

Workflow Diagram

graph TD
A((Backend: Admin changes user permissions)) --> B[Send PermissionsUpdated via PermissionHub]
B --> C[SignalR sends to user's connection]

D((Mobile: PermissionSyncService._handlePermissionsUpdated)) --> E{Valid message format?}
E -->|No| F[Log error and return]
E -->|Yes| G[Parse newPermissions from message]
G --> H[Read oldPermissions from PermissionStorage]
H --> I[PermissionStorage.setPermissions - store new list]
I --> J[Fire-and-forget: _refreshRolePermissions via GET /api/auth/my-permissions-by-role]
J --> K[Compute revokedPermissions = old - new]
K --> L{Any permissions revoked?}
L -->|No| M[Done - permissions silently updated]
L -->|Yes| N[Emit PermissionRevocationEvent to stream]

N --> O((PermissionRevokedHandler._handleRevocationEvent))
O --> P{User on form screen?}
P -->|Yes| Q[Set _logoutPending = true]
Q --> R[Wait for unregisterFormScreen]
R --> S{Still forms active?}
S -->|Yes| R
S -->|No| T[Execute graceful logout]
P -->|No| T
T --> U[Show SnackBar: permissions updated message]
U --> V[JwtAuthService.clearAuthData]
V --> W[Navigator.pushNamedAndRemoveUntil /login]

X((SignalR Connection Lifecycle))
X --> Y[_establishConnection with JWT bearer]
Y --> Z{Connected?}
Z -->|Yes| AA[Register PermissionsUpdated + DataChanged handlers]
Z -->|No| AB[Schedule reconnect with exponential backoff]
AB --> AC[Delay: 1s, 2s, 4s, 8s, ... max 30s]
AC --> Y

AD((Network restored)) --> AE[ConnectivityService stream fires]
AE --> AF{shouldReconnect + disconnected?}
AF -->|Yes| Y

Node Descriptions

#n8n Node TypeComponentFunctionError Handling
1Webhook TriggerPermissionHub (backend)SignalR hub authenticated with [Authorize(AuthenticationSchemes = "Bearer")]Logs connect/disconnect with userId
2FunctionPermissionSyncService._handlePermissionsUpdated()Parse incoming message (Map or List format), compare old vs new permissionsErrors caught; logged
3FunctionPermissionStorage.setPermissions()Overwrite user_permissions key in SharedPreferencesErrors rethrown
4HTTP RequestPermissionSyncService._refreshRolePermissions()GET /api/auth/my-permissions-by-role to refresh per-role mapFire-and-forget; errors logged
5FunctionRevocation detectionCompute oldPermissions.where(!newPermissions.contains)N/A
6Event Emitter_revocationController.add()Broadcast PermissionRevocationEvent to listenersChecks !_revocationController.isClosed
7FunctionPermissionRevokedHandler._handleRevocationEvent()Check _activeFormScreens, defer or execute logoutDefers if unsaved forms active
8FunctionPermissionRevokedHandler._executeLogout()Show SnackBar, clear auth, navigate to loginFalls back if no Navigator available

Data Transformations

  • Input (SignalR message): PermissionUpdateMessage { UserId, Permissions[], Timestamp }
  • Processing: Compare with locally stored List<String> permissions; detect revocations
  • Output: Updated local permission cache + optional PermissionRevocationEvent { revokedPermissions, timestamp }

Error Handling

  • Connection lost: Exponential backoff reconnect (1s, 2s, 4s, ... up to 30s max)
  • No JWT token: Cannot connect; stays disconnected
  • No network: Listens to ConnectivityService.connectionStream; reconnects when network restored
  • Form screen active during revocation: Logout deferred via _logoutPending flag; executes when unregisterFormScreen() empties the set
  • Graceful logout message: "Your permissions have been updated. Please log in again to continue."

Cross-References

  • Triggered by: Backend admin permission changes
  • Triggers: Graceful logout -> WF-AUTH-01 (user must re-login)
  • Related: WF-AUTH-07 (per-role permissions refreshed alongside flat list)

WF-AUTH-06: Offline Authentication Fallback

Trigger: User opens the app without network connectivity Frequency: On-demand (whenever the app launches offline) Offline Support: Yes (this IS the offline mechanism)

Workflow Diagram

graph TD
A((App launch - no connectivity)) --> B[OfflineAuthService.canWorkOffline]
B --> C{Offline mode enabled?}
C -->|No| D[Return false - show login screen]
C -->|Yes| E{Offline session expired? - 30 days}
E -->|Yes| F[Return false - requires online login]
E -->|No| G{Access token exists in SecureStorage?}
G -->|No| H[Return false]
G -->|Yes| I{PIN hard-locked? - 10 failed attempts}
I -->|Yes| J[Return false - requires online re-auth]
I -->|No| K[Return true - offline mode available]

K --> L((Show offline PIN screen))
L --> M[User enters 4-6 digit PIN]
M --> N[OfflineAuthService.verifyOfflinePinWithStatus]
N --> O{Username matches stored offline username?}
O -->|No| P[Return failed - profile mismatch]
O -->|Yes| Q[Read stored hash + salt from FlutterSecureStorage]
Q --> R[Hash input PIN with SHA-256 + stored salt]
R --> S{Hash matches?}
S -->|Yes| T[Reset failed attempt state]
T --> U[Return success - proceed to dashboard routing WF-AUTH-04]
S -->|No| V[Register failed attempt]
V --> W{Attempts >= 10?}
W -->|Yes| X[Hard lockout - set _pinHardLockoutKey]
W -->|No| Y{Attempts >= 5?}
Y -->|Yes| Z[Soft lockout - escalating delays]
Z --> AA[30s -> 1m -> 5m -> 15m]
Y -->|No| AB[Return failed with remaining attempts count]

AC((Initial PIN Setup)) --> AD{User authenticated online?}
AD -->|No| AE[Reject - must be online first]
AD -->|Yes| AF[Validate PIN: 4-6 digits regex]
AF --> AG[Generate 16-byte random salt]
AG --> AH[SHA-256 hash: PIN + salt]
AH --> AI[Store hash + salt in FlutterSecureStorage]
AI --> AJ[Set offline_mode_enabled + last_online_login + username]

Node Descriptions

#n8n Node TypeComponentFunctionError Handling
1FunctionOfflineAuthService.canWorkOffline()Check enabled flag, 30-day expiry, token existence, lockout stateReturns false on any error (safe default)
2FunctionOfflineAuthService.isOfflineModeExpired()Compare _lastOnlineLoginKey timestamp with 30-day windowReturns true (expired) on parse error
3FunctionOfflineAuthService.verifyOfflinePinWithStatus()Full PIN verification with lockout awarenessReturns OfflinePinVerificationResult with status + message
4FunctionOfflineAuthService._hashPin()SHA-256 of pin + salt via crypto packageN/A (pure function)
5FunctionOfflineAuthService._registerFailedAttempt()Increment counter; apply soft or hard lockoutWrites to FlutterSecureStorage
6FunctionOfflineAuthService.setupOfflinePin()Generate salt, hash PIN, store in FlutterSecureStorageRequires online auth first
7FunctionOfflineAuthService.updateLastOnlineLogin()Update timestamp + reset PIN attempts on successful online loginErrors logged

Data Transformations

  • PIN Setup: pin (4-6 digits) + username -> SHA-256 hash stored in FlutterSecureStorage
  • PIN Verification: Input PIN + stored salt -> SHA-256 hash -> compare with stored hash
  • Lockout State: OfflinePinLockoutStatus { failedAttempts, retryAfter, requiresOnlineReauthentication }
  • Escalating lockouts: Attempt 5 = 30s, Attempt 6 = 1m, Attempt 7 = 5m, Attempt 8 = 15m, Attempt 10 = hard lock

Error Handling

  • Expired offline session: After 30 days without online login, offline mode disabled; user must go online
  • Soft lockout: After 5 failed PIN attempts, escalating delays (30s, 1m, 5m, 15m)
  • Hard lockout: After 10 failed PIN attempts, _pinHardLockoutKey set; requires online re-authentication to reset
  • Username mismatch: If stored offline username differs from current, verification fails
  • PIN not configured: Returns failed with informative message

Cross-References

  • Triggered by: App launch without connectivity
  • Triggers: WF-AUTH-04 (dashboard routing after successful PIN verification)
  • Depends on: WF-AUTH-01 (initial online login to set up offline PIN)

WF-AUTH-07: Per-Role Permission Fetch

Trigger: Successful login (fire-and-forget from _storeAuthData) or SignalR reconnection Frequency: On each login + on each SignalR PermissionsUpdated event Offline Support: Partial (cached per-role permissions available offline; refresh requires network)

Workflow Diagram

graph TD
A((Login success: _storeAuthData completes)) --> B[_fetchAndStoreRolePermissions - fire-and-forget]
B --> C[GET /api/auth/my-permissions-by-role with Bearer token]
C --> D{Response 200?}
D -->|No| E[Log error - non-blocking]
D -->|Yes| F[Parse rolePermissions map from response]
F --> G[PermissionStorage.setRolePermissions]
G --> H[Store as JSON in SharedPreferences key: role_permissions_map]

I((SignalR PermissionsUpdated received)) --> J[PermissionSyncService._refreshRolePermissions]
J --> K[Create new Dio instance with current Bearer token]
K --> C

L((Backend: GetPermissionsByRoleAsync)) --> M[UserManager.GetRolesAsync - get user roles]
M --> N[Query ApplicationRolePermission table]
N --> O[Include ParentRoleId inherited permissions]
O --> P[Build Dictionary: roleName -> List of permission strings]
P --> Q[Return PermissionsByRoleResponse]

R((Mobile reads per-role cache)) --> S[PermissionStorage.getRolePermissions]
S --> T[Decode JSON map from SharedPreferences]
T --> U[Return Map of String to List of String]

V((Mobile reads single role)) --> W[PermissionStorage.getPermissionsForRole - roleName]
W --> X[Lookup by lowercase key then original key]
X --> Y[Return List of String or empty]

Node Descriptions

#n8n Node TypeComponentFunctionError Handling
1HTTP RequestJwtAuthService._fetchAndStoreRolePermissions()GET /api/auth/my-permissions-by-roleErrors caught + rethrown; non-blocking via .catchError
2FunctionAuthService.GetPermissionsByRoleAsync()Query ApplicationRolePermission grouped by role, including parent role inheritanceUnauthorizedAccessException if user not found
3FunctionPermissionStorage.setRolePermissions()JSON-encode Map<String, List<String>> to SharedPreferencesErrors rethrown
4HTTP RequestPermissionSyncService._refreshRolePermissions()Same endpoint, called from SignalR handlerErrors rethrown; caught by caller
5FunctionPermissionStorage.getPermissionsForRole()Look up by role name (case-insensitive fallback)Errors rethrown

Data Transformations

  • Input: Authenticated user ID (from JWT sub claim)
  • Backend Processing:
    1. UserManager.GetRolesAsync(user) -> role names
    2. Query ApplicationRolePermission where RoleId in user's roles + parent roles
    3. Build Dictionary<string, List<string>> with role name -> sorted permission list
    4. Parent role permissions merged via HashSet.UnionWith
  • Output: PermissionsByRoleResponse { rolePermissions: { "agent": ["CreateFarmers", "EditFarmers", ...], "warehouseOperator": [...] } }
  • Mobile Storage: JSON-serialized to SharedPreferences under key role_permissions_map

Error Handling

  • Login-time fetch failure: Caught silently by _fetchAndStoreRolePermissions().catchError(); login succeeds regardless; flat permissions still available
  • SignalR refresh failure: Logged as warning; previous per-role cache remains valid
  • No permissions for role: Returns empty list; RoleManager falls back to feature matrix
  • Stale cache: Refreshed on every login + every SignalR permission update

Cross-References

  • Triggered by: WF-AUTH-01 (fire-and-forget after login)
  • Triggered by: WF-AUTH-05 (SignalR PermissionsUpdated handler)
  • Used by: RoleManager permission checks (storage-first, matrix fallback pattern)

File Reference Index

FileDomain Role
mobile/mon_jardin/lib/data/services/jwt_auth_service.dartLogin, token refresh, interceptor, credential storage, per-role permission fetch
mobile/mon_jardin/lib/core/auth/role_manager.dartPermission checks (storage-first + feature matrix fallback), role utilities
mobile/mon_jardin/lib/core/auth/active_role_manager.dartDashboard navigation, multi-role dashboard deduplication
mobile/mon_jardin/lib/core/auth/permission_storage.dartFlat + per-role permission persistence in SharedPreferences
mobile/mon_jardin/lib/core/auth/permission_revoked_handler.dartGraceful logout on permission revocation with form-screen deferral
mobile/mon_jardin/lib/data/services/permission_sync_service.dartSignalR connection lifecycle, permission update handling
mobile/mon_jardin/lib/data/services/offline_auth_service.dartOffline PIN setup/verification, lockout management, 30-day expiry
mobile/mon_jardin/lib/presentation/splash_screen/splash_screen.dartApp launch routing logic, multi-dashboard detection
mobile/mon_jardin/lib/presentation/role_selection/role_selection_screen.dartDashboard Hub UI, dashboard card selection
backend/Almafrica.API/Controllers/AuthController.csAuth endpoints: login, register, refresh, logout, my-permissions-by-role
backend/Almafrica.Infrastructure/Services/AuthService.csAuth business logic: credential validation, JWT generation, permission queries
backend/Almafrica.API/Hubs/PermissionHub.csSignalR hub for real-time permission broadcasting
backend/Almafrica.API/Controllers/TwoFactorController.cs2FA management: setup, verify, disable, backup codes
backend/Almafrica.Infrastructure/Services/OtpService.csOTP generation, SMS dispatch (dev mode + production), verification with attempt limits