Commit 80d51ec3 by Mac Stephens

Complete BFD shared-server integration and simplify common code structure

parent ab1d1cac
...@@ -33,3 +33,5 @@ emiMobileServer/Clients/*/Web/**/__history/ ...@@ -33,3 +33,5 @@ emiMobileServer/Clients/*/Web/**/__history/
emiMobileServer/Clients/*/Web/**/__recovery/ emiMobileServer/Clients/*/Web/**/__recovery/
emiMobileServer/Clients/*/Web/TMSWeb/ emiMobileServer/Clients/*/Web/TMSWeb/
emiMobileServer/Clients/*/Web/Win32/ emiMobileServer/Clients/*/Web/Win32/
emiMobileServer/Shared/Server/__history/
...@@ -6,7 +6,7 @@ October 17, 2026 ...@@ -6,7 +6,7 @@ October 17, 2026
We're moving toward one emiMobile server project with shared code and separate pieces for each client. Each client will still run on its own server machine. The INI will tell the server which client it is running and which web folder to use. We're moving toward one emiMobile server project with shared code and separate pieces for each client. Each client will still run on its own server machine. The INI will tell the server which client it is running and which web folder to use.
WC will stay on PostgreSQL and BFD will stay on Oracle. Device registrations and verification data will go in each client's existing database. The queries will stay in the UniQueries in each client's database module. WC will stay on PostgreSQL and BFD will stay on Oracle. Device registrations and verification data will go in each client's existing database. CAD queries stay in each client's database module. Device-storage queries are shared by database type, using separate PostgreSQL and Oracle modules.
## What we've done ## What we've done
...@@ -22,12 +22,28 @@ WC will stay on PostgreSQL and BFD will stay on Oracle. Device registrations and ...@@ -22,12 +22,28 @@ WC will stay on PostgreSQL and BFD will stay on Oracle. Device registrations and
WC is working after the database, response-building, and shared client-selection changes. The server compiled successfully, and I tested the latest changes and confirmed they work. WC is working after the database, response-building, and shared client-selection changes. The server compiled successfully, and I tested the latest changes and confirmed they work.
BFD still uses its existing Oracle server. We haven't connected it to the common server yet. BFD's database, response builders, and shared data contract are in place and tested. Device storage now has a shared interface with PostgreSQL and Oracle implementations. WC's authentication, device management, and WebSocket device checks use that interface. Both server projects build, and offline checks passed. The WC WebSocket login issue was resolved by logging out and back in; registration and device-management testing is still pending. The Oracle queries are prepared, but no tables have been created and BFD isn't using the new authentication yet.
Each client's existing authentication database module now lives alongside its CAD module in its own Server folder. Both servers compile after this move; login behavior and queries are unchanged. User and agency SQL now lives in those modules' designer queries, while the services keep the login checks and responses. Both servers build; this step still needs a login and agency-list check.
## What's next ## What's next
Next we'll start bringing BFD into the same structure. That includes its Oracle queries and the Oracle tables/storage needed for the newer device registration and authentication features. Next we'll test WC's device flows after this extraction, then continue BFD's remaining authentication and common-server integration. That includes its Oracle queries and the Oracle tables/storage needed for the newer device registration and authentication features.
After that, we can connect BFD to the shared server functionality and bring its web client up to date, while keeping the things that are different for BFD, especially the maps, markers, and fire-specific information. After that, we can connect BFD to the shared server functionality and bring its web client up to date, while keeping the things that are different for BFD, especially the maps, markers, and fire-specific information.
We're doing this in pieces so we can keep building and testing as we go. The basic structure is in place now, but the BFD integration and the rest of the shared web/authentication work are still ahead. We're doing this in pieces so we can keep building and testing as we go. The basic structure is in place now, but the BFD integration and the rest of the shared web/authentication work are still ahead.
The user confirmed both clients work after the authentication-query move. We have now added a common authentication-data interface and connected WC through client selection. Both server projects compile; the latest interface change still needs a runtime login check. BFD remains on its standalone server, and Oracle device tables are still deferred.
Both clients were confirmed working with the shared authentication interface. BFD selection code is now prepared in the common server, with an explicit Oracle INI section available for its connections. Both projects compile. BFD activation remains blocked while its device tables and remaining integration are unfinished; its standalone server retains the current connection settings unless the Oracle section is added.
BFD now has WC's web login, registration/verification, and device-management screens. The TMS web build passed, and BFD's fire-specific screens and maps were preserved. This updated client requires the newer shared-server endpoints, so full testing waits on BFD server integration and Oracle device tables.
The Oracle tables were created manually in dbForge. The shared server now accepts BFD with explicit Oracle settings, and BFD has the newer WebSocket reconnect/session handling. Both builds passed. Local Oracle credentials and runtime checks are still needed. The connection continues to use the BFD TNS alias.
BFD browser registration, verification, and login were tested successfully on the shared server with Oracle. We then simplified the folder layout into Shared and Clients. Both server projects compile after the move; project locations and application behavior stay the same.
Simplified the client server units by moving response-building code directly into WC.Api.Database and BFD.Api.Database. Removed both Api.Data wrapper units. API and WebSockets still reuse the same methods, with separate database instances. Both server builds passed.
Cleaned up unused server units, queries, and old commented login code. Both database types now use Common.Ini for connection setup. The standalone BFD server is retired; its files remain as reference. The shared server builds, and offline WC/Oracle configuration checks passed.
object BFDAuthDatabase: TBFDAuthDatabase
OnCreate = DataModuleCreate
OnDestroy = DataModuleDestroy
Height = 373
Width = 649
PixelsPerInch = 144
object uqMisc: TUniQuery
Connection = ucENTCAD
FetchRows = 100
Left = 349
Top = 75
end
object uqAuth: TUniQuery
Connection = ucENTCAD
SQL.Strings = (
'select u.* from users u '
'where upper(user_name) = :USER_NAME '
'and u.dept = :AGENCY')
Left = 150
Top = 74
end
object OracleUniProvider1: TOracleUniProvider
Left = 345
Top = 247
end
object ucENTCAD: TUniConnection
ProviderName = 'Oracle'
Username = 'EMFIRECAD'
Server = 'BFD'
LoginPrompt = False
Left = 190
Top = 247
EncryptedPassword = 'BAFFB2FFB9FFB6FFADFFBAFFBCFFBEFFBBFF'
end
object uqAgencies: TUniQuery
Connection = ucENTCAD
SQL.Strings = (
'select agencyid as agency '
'from agency '
'where login = ''T'' '
'order by agencyid')
Left = 40
Top = 200
end
object uqAgencyConfig: TUniQuery
Connection = ucENTCAD
SQL.Strings = (
'select agencyid, agencyname '
'from agency '
'where login = ''T'' '
'order by agencyname')
Left = 160
Top = 200
end
end
unit BFD.Auth.Database;
interface
uses
Client.Auth, System.SysUtils, System.Classes, MemDS,
Data.DB, DBAccess, Uni, UniProvider, OracleUniProvider;
type
TBFDAuthDatabase = class(TClientAuth)
uqMisc: TUniQuery;
uqAuth: TUniQuery;
uqAgencies: TUniQuery;
uqAgencyConfig: TUniQuery;
OracleUniProvider1: TOracleUniProvider;
ucENTCAD: TUniConnection;
procedure DataModuleCreate(Sender: TObject);
procedure DataModuleDestroy(Sender: TObject);
private
{ Private declarations }
protected
function GetConnection: TUniConnection; override;
function GetUserData: TDataSet; override;
function GetAgencies: TDataSet; override;
function GetAgencyConfig: TDataSet; override;
public
{ Public declarations }
procedure OpenUser(User, Agency: string); override;
procedure OpenAgencies; override;
procedure OpenAgencyConfig; override;
procedure SetLoginAuditEntry( userStr: string ); override;
end;
var
BFDAuthDatabase: TBFDAuthDatabase;
implementation
uses
System.JSON,
Common.Config,
Common.Logging,
Common.Ini,
uLibrary;
{%CLASSGROUP 'Vcl.Controls.TControl'}
{$R *.dfm}
function TBFDAuthDatabase.GetConnection: TUniConnection;
begin
Result := ucENTCAD;
end;
function TBFDAuthDatabase.GetUserData: TDataSet;
begin
Result := uqAuth;
end;
function TBFDAuthDatabase.GetAgencies: TDataSet;
begin
Result := uqAgencies;
end;
function TBFDAuthDatabase.GetAgencyConfig: TDataSet;
begin
Result := uqAgencyConfig;
end;
procedure TBFDAuthDatabase.OpenUser(User, Agency: string);
begin
uqAuth.Close;
uqAuth.ParamByName('USER_NAME').AsString := UpperCase(Trim(User));
uqAuth.ParamByName('AGENCY').AsString := UpperCase(Trim(Agency));
uqAuth.Open;
end;
procedure TBFDAuthDatabase.OpenAgencies;
begin
uqAgencies.Close;
uqAgencies.Open;
end;
procedure TBFDAuthDatabase.OpenAgencyConfig;
begin
uqAgencyConfig.Close;
uqAgencyConfig.Open;
end;
procedure TBFDAuthDatabase.DataModuleCreate(Sender: TObject);
begin
IniEntries.ConfigureConnection(ucENTCAD);
try
Logger.Log(2, '');
Logger.Log(2, 'Connecting to Oracle auth database...');
if not ucENTCAD.Connected then
ucENTCAD.Connect;
Logger.Log(2, 'Oracle auth database connected');
except
on E: Exception do
begin
Logger.Log(2, Format('Failed to connect to auth database: %s', [E.Message]));
raise;
end;
end;
end;
procedure TBFDAuthDatabase.DataModuleDestroy(Sender: TObject);
begin
if ucENTCAD.Connected then
ucENTCAD.Disconnect;
end;
procedure TBFDAuthDatabase.SetLoginAuditEntry( userStr: string );
var
auditMasterId: string;
userInfo: TStringList;
username: string;
fullname: string;
agency: string;
userid: string;
personnelid: string;
begin
Logger.Log( 3, 'TBFDAuthDatabase.SetLoginAuditEntry - start' );
userInfo := TStringList.Create;
try
userInfo.Delimiter := '&';
userInfo.StrictDelimiter := True;
userInfo.DelimitedText := userStr;
username := userInfo.Values['username'];
fullname := userInfo.Values['fullname'];
agency := userInfo.Values['agency'];
userid := userInfo.Values['userId'];
personnelid := userInfo.Values['personnelid'];
if ServerConfig.auditEnabled then
begin
auditMasterId := GetNextSeqVal(uqMisc, 'SEQ_AUDIT_MASTERID');
SetMasterAuditEntry(uqMisc, auditMasterId, 'AUTH', '', agency, personnelid, fullname, 'Login', '', 'emiMobile');
end
else
begin
Logger.Log( 3, 'SetSearchAuditEntry->SetMasterAuditEntry - auditEnabled = false' );
end;
finally
userInfo.Free;
end;
end;
end.
...@@ -4,7 +4,7 @@ interface ...@@ -4,7 +4,7 @@ interface
uses uses
System.SysUtils, System.Classes, WEBLib.Modules, XData.Web.Connection, System.SysUtils, System.Classes, WEBLib.Modules, XData.Web.Connection,
App.Types, App.Config, XData.Web.Client; XData.Web.Client, App.Types, App.Config;
type type
TDMConnection = class(TWebDataModule) TDMConnection = class(TWebDataModule)
...@@ -18,12 +18,12 @@ type ...@@ -18,12 +18,12 @@ type
private private
FUnauthorizedAccessProc: TUnauthorizedAccessProc; FUnauthorizedAccessProc: TUnauthorizedAccessProc;
FWsUrl: string; FWsUrl: string;
procedure VerifyVersion(SuccessProc: TSuccessProc);
public public
property WsUrl: string read FWsUrl; property WsUrl: string read FWsUrl;
const clientVersion = '9.4.0'; const clientVersion = '0.9.4.9';
procedure InitApp(SuccessProc: TSuccessProc; procedure InitApp(SuccessProc: TSuccessProc;
UnauthorizedAccessProc: TUnauthorizedAccessProc); UnauthorizedAccessProc: TUnauthorizedAccessProc);
procedure SetClientConfig(Callback: TVersionCheckCallback);
end; end;
var var
...@@ -57,7 +57,8 @@ procedure TDMConnection.ApiConnectionResponse( ...@@ -57,7 +57,8 @@ procedure TDMConnection.ApiConnectionResponse(
Args: TXDataWebConnectionResponse); Args: TXDataWebConnectionResponse);
begin begin
if Args.Response.StatusCode = 401 then if Args.Response.StatusCode = 401 then
FUnauthorizedAccessProc(Format('%d: %s',[Args.Response.StatusCode, Args.Response.ContentAsText])); FUnauthorizedAccessProc(Format('%d: %s',
[Args.Response.StatusCode, Args.Response.ContentAsText]));
end; end;
procedure TDMConnection.AuthConnectionError(Error: TXDataWebConnectionError); procedure TDMConnection.AuthConnectionError(Error: TXDataWebConnectionError);
...@@ -79,7 +80,11 @@ procedure TDMConnection.InitApp(SuccessProc: TSuccessProc; ...@@ -79,7 +80,11 @@ procedure TDMConnection.InitApp(SuccessProc: TSuccessProc;
if Config.WsUrl <> '' then if Config.WsUrl <> '' then
FWsUrl := Config.WsUrl; FWsUrl := Config.WsUrl;
AuthConnection.Open(SuccessProc); AuthConnection.Open(
procedure
begin
VerifyVersion(SuccessProc);
end);
end; end;
begin begin
...@@ -87,8 +92,7 @@ begin ...@@ -87,8 +92,7 @@ begin
LoadConfig(@ConfigLoaded); LoadConfig(@ConfigLoaded);
end; end;
procedure TDMConnection.SetClientConfig(Callback: TVersionCheckCallback); procedure TDMConnection.VerifyVersion(SuccessProc: TSuccessProc);
begin begin
XDataWebClient1.Connection := AuthConnection; XDataWebClient1.Connection := AuthConnection;
...@@ -100,15 +104,21 @@ begin ...@@ -100,15 +104,21 @@ begin
begin begin
jsonResult := TJSObject(Response.Result); jsonResult := TJSObject(Response.Result);
if jsonResult.HasOwnProperty('error') then if Assigned(jsonResult) and jsonResult.HasOwnProperty('error') then
error := string(jsonResult['error']) error := string(jsonResult['error'])
else else
error := ''; error := '';
if error <> '' then if error <> '' then
Callback(False, error) TFViewErrorPage.Display(
error + sLineBreak + sLineBreak +
'Current client version: v' + clientVersion)
else else
Callback(True, ''); SuccessProc;
end,
procedure(Error: TXDataClientError)
begin
TFViewErrorPage.Display(Error.ErrorMessage);
end); end);
end; end;
......
...@@ -15,6 +15,7 @@ function FormatPhoneNumber(PhoneNumber: string): string; ...@@ -15,6 +15,7 @@ function FormatPhoneNumber(PhoneNumber: string): string;
procedure ApplyReportTitle(CurrentReportType: string); procedure ApplyReportTitle(CurrentReportType: string);
procedure ShowToast(const MessageText: string; const ToastType: string = 'success'); procedure ShowToast(const MessageText: string; const ToastType: string = 'success');
procedure ShowConfirmationModal(msg, leftLabel, rightLabel: string; ConfirmProc: TProc<Boolean>); procedure ShowConfirmationModal(msg, leftLabel, rightLabel: string; ConfirmProc: TProc<Boolean>);
procedure ShowInformationModal(const Title, MessageText: string);
// function FormatDollarValue(ValueStr: string): string; // function FormatDollarValue(ValueStr: string): string;
...@@ -206,12 +207,12 @@ begin ...@@ -206,12 +207,12 @@ begin
btnLeft.onclick = function () { btnLeft.onclick = function () {
bsModal.hide(); bsModal.hide();
ConfirmProc(true); // user confirmed ConfirmProc(false);
}; };
btnRight.onclick = function () { btnRight.onclick = function () {
bsModal.hide(); bsModal.hide();
ConfirmProc(false); // user canceled ConfirmProc(true);
}; };
bsModal = new bootstrap.Modal(modal, { keyboard: false }); bsModal = new bootstrap.Modal(modal, { keyboard: false });
...@@ -220,6 +221,28 @@ begin ...@@ -220,6 +221,28 @@ begin
end; end;
procedure ShowInformationModal(const Title, MessageText: string);
begin
asm
var modal = document.getElementById('main_information_modal');
var title = document.getElementById('main_information_modal_title');
var body = document.getElementById('main_information_modal_body');
if (!modal) return;
if (title) title.innerText = Title;
if (body) body.innerText = MessageText;
if (modal.parentNode !== document.body) {
document.body.appendChild(modal);
}
var bsModal = bootstrap.Modal.getOrCreateInstance(modal);
bsModal.show();
end;
end;
function CalculateAge(DateOfBirth: TDateTime): Integer; function CalculateAge(DateOfBirth: TDateTime): Integer;
var var
Today, BirthDate: TJSDate; Today, BirthDate: TJSDate;
......
object FViewDeviceManager: TFViewDeviceManager
Width = 900
Height = 600
Font.Charset = DEFAULT_CHARSET
Font.Color = clWindowText
Font.Height = -11
Font.Name = 'Tahoma'
Font.Style = []
ParentFont = False
OnCreate = WebFormCreate
object pnlMessage: TWebPanel
Left = 8
Top = 8
Width = 200
Height = 33
ElementID = 'view.devmgr.message'
TabOrder = 0
object lblMessage: TWebLabel
Left = 8
Top = 8
Width = 42
Height = 13
Caption = 'Message'
ElementID = 'view.devmgr.message.label'
HeightPercent = 100.000000000000000000
WidthPercent = 100.000000000000000000
end
object btnCloseNotification: TWebButton
Left = 170
Top = 4
Width = 22
Height = 25
ElementID = 'view.devmgr.message.button'
HeightPercent = 100.000000000000000000
WidthPercent = 100.000000000000000000
OnClick = btnCloseNotificationClick
end
end
object edtNewDeviceName: TWebEdit
Left = 8
Top = 50
Width = 175
Height = 25
ElementID = 'view.devmgr.newname'
HeightPercent = 100.000000000000000000
TabOrder = 1
WidthPercent = 100.000000000000000000
end
object edtNewPhoneNumber: TWebEdit
Left = 192
Top = 50
Width = 155
Height = 25
ElementID = 'view.devmgr.newphone'
HeightPercent = 100.000000000000000000
TabOrder = 2
TextHint = '(303) 555-1234'
WidthPercent = 100.000000000000000000
end
object btnAddDevice: TWebButton
Left = 356
Top = 50
Width = 60
Height = 25
Caption = 'Add'
ElementID = 'view.devmgr.btnadd'
HeightPercent = 100.000000000000000000
TabOrder = 3
WidthPercent = 100.000000000000000000
OnClick = btnAddDeviceClick
end
object btnLogout: TWebButton
Left = 720
Top = 50
Width = 75
Height = 25
Caption = 'Log Out'
ElementID = 'view.devmgr.btnlogout'
HeightPercent = 100.000000000000000000
Visible = False
WidthPercent = 100.000000000000000000
OnClick = btnLogoutClick
end
object XDataWebClient: TXDataWebClient
Connection = DMConnection.ApiConnection
Left = 800
Top = 8
end
end
<div class="container-fluid p-3 h-100 d-flex flex-column">
<div class="d-flex align-items-center mb-3">
<h5 class="mb-0 me-auto">Device Management</h5>
<button id="view.devmgr.btnlogout" class="btn btn-outline-secondary btn-sm me-2" type="button">
Log Out
</button>
<button id="view.devmgr.btnrefresh"
class="btn btn-outline-secondary btn-sm"
onclick="document.dispatchEvent(new CustomEvent('devmgr-refresh'))">
Refresh
</button>
</div>
<!-- Notification bar -->
<div id="view.devmgr.message"
class="alert alert-danger d-flex align-items-start d-none mb-3"
role="alert">
<span id="view.devmgr.message.label" class="me-auto"></span>
<button id="view.devmgr.message.button"
type="button"
class="btn-close ms-2"
aria-label="Close"></button>
</div>
<!-- Add pending device -->
<div class="card mb-3">
<div class="card-body py-2">
<div class="d-flex align-items-start gap-2 flex-wrap">
<span class="fw-semibold text-nowrap small mt-2">Add Device:</span>
<div style="width:180px;">
<input type="text"
id="view.devmgr.newname"
class="form-control form-control-sm"
placeholder="Device name"
required>
<div class="invalid-feedback small">Device name is required.</div>
</div>
<div style="width:160px;">
<input type="tel"
id="view.devmgr.newphone"
class="form-control form-control-sm"
placeholder="(303) 555-1234"
required>
<div class="invalid-feedback small">Phone number is required.</div>
</div>
<div style="width:140px;">
<select id="view.devmgr.newtype"
class="form-select form-select-sm"
required>
<option value="" selected>Device type</option>
<option value="iphone">iPhone</option>
<option value="android">Android</option>
<option value="browser">Browser</option>
<option value="other">Other</option>
</select>
<div class="invalid-feedback small">Device type is required.</div>
</div>
<button id="view.devmgr.btnadd"
class="btn btn-primary btn-sm">Add</button>
</div>
</div>
</div>
<!-- Device table -->
<div class="table-responsive flex-grow-1">
<table class="table table-sm table-hover align-middle" id="view.devmgr.table">
<thead class="table-light sticky-top">
<tr>
<th style="min-width:130px;">Device Name</th>
<th style="min-width:120px;">Phone</th>
<th style="min-width:145px;">Verification Code</th>
<th style="min-width:140px;">Username</th>
<th>Browser / User Agent</th>
<th style="min-width:135px;">Registered</th>
<th style="min-width:80px;">Status</th>
<th style="min-width:85px;">Access</th>
<th style="min-width:75px;">Delete</th>
<th style="min-width:90px;">Send Link</th>
</tr>
</thead>
<tbody id="view.devmgr.tbody">
</tbody>
</table>
<p id="view.devmgr.empty" class="text-muted d-none text-center py-4">
No devices found.
</p>
</div>
<!-- Information modal used by Utils.ShowInformationModal -->
<div class="modal fade" id="main_information_modal" tabindex="-1"
aria-labelledby="main_information_modal_title" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-sm mx-auto px-3">
<div class="modal-content shadow-lg">
<div class="modal-header">
<h5 class="modal-title" id="main_information_modal_title">Information</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body fs-6 fw-bold" id="main_information_modal_body"></div>
<div class="modal-footer">
<button type="button" class="btn btn-primary w-100" data-bs-dismiss="modal">OK</button>
</div>
</div>
</div>
</div>
<!-- Section: Confirmation modal used by Utils.ShowConfirmationModal -->
<div class="modal fade" id="main_confirmation_modal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content shadow-lg">
<div class="modal-header">
<h5 class="modal-title">Confirm</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body fw-bold" id="main_modal_body"></div>
<div class="modal-footer justify-content-center">
<button type="button" class="btn btn-secondary me-3" id="btn_confirm_left">Cancel</button>
<button type="button" class="btn btn-danger" id="btn_confirm_right">Delete</button>
</div>
</div>
</div>
</div>
</div>
object FViewDeviceRegistration: TFViewDeviceRegistration
Width = 640
Height = 480
Font.Charset = DEFAULT_CHARSET
Font.Color = clWindowText
Font.Height = -11
Font.Name = 'Tahoma'
Font.Style = []
ParentFont = False
OnCreate = WebFormCreate
object edtPhoneNumber: TWebEdit
Left = 240
Top = 136
Width = 121
Height = 21
ElementID = 'view.devicereg.edtphonenumber'
HeightPercent = 100.000000000000000000
TextHint = '(303) 555-1234'
WidthPercent = 100.000000000000000000
end
object btnRegister: TWebButton
Left = 240
Top = 163
Width = 121
Height = 25
Caption = 'Send Code'
ElementID = 'view.devicereg.btnregister'
HeightPercent = 100.000000000000000000
TabOrder = 1
WidthPercent = 100.000000000000000000
OnClick = btnRegisterClick
end
object pnlVerification: TWebPanel
Left = 240
Top = 194
Width = 177
Height = 58
ElementID = 'view.devicereg.verification'
TabOrder = 2
object edtVerificationCode: TWebEdit
Left = 0
Top = 0
Width = 121
Height = 21
ElementID = 'view.devicereg.edtverificationcode'
HeightPercent = 100.000000000000000000
TextHint = 'Six-digit code'
WidthPercent = 100.000000000000000000
end
object btnVerifyCode: TWebButton
Left = 0
Top = 27
Width = 121
Height = 25
Caption = 'Verify and Register'
ElementID = 'view.devicereg.btnverify'
HeightPercent = 100.000000000000000000
TabOrder = 1
WidthPercent = 100.000000000000000000
OnClick = btnVerifyCodeClick
end
end
object pnlMessage: TWebPanel
Left = 240
Top = 65
Width = 121
Height = 33
ElementID = 'view.devicereg.message'
TabOrder = 3
object lblMessage: TWebLabel
Left = 16
Top = 11
Width = 42
Height = 13
Caption = 'Message'
ElementID = 'view.devicereg.message.label'
HeightPercent = 100.000000000000000000
WidthPercent = 100.000000000000000000
end
object btnCloseNotification: TWebButton
Left = 96
Top = 3
Width = 22
Height = 25
ElementID = 'view.devicereg.message.button'
HeightPercent = 100.000000000000000000
WidthPercent = 100.000000000000000000
OnClick = btnCloseNotificationClick
end
end
object XDataWebClient: TXDataWebClient
Connection = DMConnection.AuthConnection
Left = 492
Top = 102
end
end
<div class="container">
<nav class="navbar navbar-light bg-light border rounded-bottom"
style="--bs-border-color: #d6d6d6; --bs-border-radius: 0.25rem;">
<div class="container-fluid">
<a id="view.devicereg.apptitle"
class="navbar-brand text-secondary small"
href="index.html">
emiMobile
</a>
</div>
</nav>
<div class="row justify-content-center mt-4">
<div class="col-12 col-sm-10 col-md-8 col-lg-5 col-xl-4">
<div class="card shadow-sm">
<div class="card-header">
<h5 class="card-title mb-0 text-center">
Register This Device
</h5>
</div>
<div class="card-body">
<!-- Notification bar -->
<div id="view.devicereg.message"
class="alert alert-danger d-flex align-items-start d-none mb-3"
role="alert">
<span id="view.devicereg.message.label" class="me-auto"></span>
<button id="view.devicereg.message.button"
type="button"
class="btn-close ms-2"
aria-label="Close"></button>
</div>
<p class="text-muted small mb-3">
This browser has not been registered for emiMobile access.
Enter the phone number your administrator registered for this device,
then click <strong>Send Code</strong>.
</p>
<div class="mb-3">
<label class="form-label small text-muted">Phone number</label>
<input id="view.devicereg.edtphonenumber"
class="form-control"
type="tel"
placeholder="(303) 555-1234"
autofocus>
</div>
<div id="view.devicereg.verification" class="mb-3 d-none">
<label class="form-label small text-muted">Verification code</label>
<input id="view.devicereg.edtverificationcode"
class="form-control mb-2"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="6"
placeholder="Six-digit code">
<button id="view.devicereg.btnverify"
class="btn btn-primary w-100">
Verify and Register
</button>
</div>
<div class="mb-3">
<p class="text-muted small mb-1">Browser</p>
<p id="view.devicereg.useragent"
class="text-muted small text-truncate"
style="max-width:100%; overflow:hidden; white-space:nowrap;"></p>
</div>
<button id="view.devicereg.btnregister"
class="btn btn-outline-primary w-100">
Send Code
</button>
</div>
<div class="card-footer text-muted small">
Registration is required once per browser. Clearing browser storage will require re-registration.
</div>
</div>
</div>
</div>
</div>
unit View.DeviceRegistration;
interface
uses
System.SysUtils, System.Classes, WEBLib.Graphics, WEBLib.Controls, WEBLib.Forms, WEBLib.Dialogs,
Vcl.Controls, Vcl.StdCtrls, WEBLib.StdCtrls, WEBLib.JSON,
JS, XData.Web.Connection, WEBLib.ExtCtrls,
App.Types, ConnectionModule, XData.Web.Client;
type
TFViewDeviceRegistration = class(TWebForm)
edtPhoneNumber: TWebEdit;
btnRegister: TWebButton;
pnlVerification: TWebPanel;
edtVerificationCode: TWebEdit;
btnVerifyCode: TWebButton;
pnlMessage: TWebPanel;
lblMessage: TWebLabel;
btnCloseNotification: TWebButton;
XDataWebClient: TXDataWebClient;
procedure btnRegisterClick(Sender: TObject);
procedure btnVerifyCodeClick(Sender: TObject);
procedure btnCloseNotificationClick(Sender: TObject);
procedure WebFormCreate(Sender: TObject);
private
FRegistrationProc: TSuccessProc;
procedure ShowNotification(const AMsg: string; AIsError: Boolean = True);
procedure HideNotification;
procedure SetBusy(ABusy: Boolean);
procedure SetVerifyBusy(ABusy: Boolean);
procedure DoWebAuthnCreate(APhoneNumber, AChallenge, AChallengeToken: string);
procedure DoSimpleKeyCreate(APhoneNumber, AChallengeToken: string);
public
class procedure Display(ARegistrationProc: TSuccessProc);
end;
var
FViewDeviceRegistration: TFViewDeviceRegistration;
implementation
uses
Auth.Service,
View.ErrorPage;
{$R *.dfm}
class procedure TFViewDeviceRegistration.Display(ARegistrationProc: TSuccessProc);
begin
if Assigned(FViewDeviceRegistration) then
FViewDeviceRegistration.Free;
FViewDeviceRegistration := TFViewDeviceRegistration.CreateNew;
FViewDeviceRegistration.FRegistrationProc := ARegistrationProc;
end;
procedure TFViewDeviceRegistration.WebFormCreate(Sender: TObject);
var
userAgent: string;
begin
HideNotification;
pnlVerification.ElementHandle.classList.add('d-none');
userAgent := '';
asm
userAgent = navigator.userAgent;
end;
asm
var el = document.getElementById('view.devicereg.useragent');
if (el) el.textContent = userAgent;
// Format phone number on-the-fly as the user types
var inp = document.getElementById('view.devicereg.edtphonenumber');
if (inp) {
inp.addEventListener('input', function() {
var digits = inp.value.replace(/\D/g, '');
if (digits.length > 10) digits = digits.slice(0, 10);
var formatted = '';
if (digits.length > 6)
formatted = '(' + digits.slice(0,3) + ') ' + digits.slice(3,6) + '-' + digits.slice(6);
else if (digits.length > 3)
formatted = '(' + digits.slice(0,3) + ') ' + digits.slice(3);
else if (digits.length > 0)
formatted = '(' + digits;
inp.value = formatted;
});
}
end;
end;
procedure TFViewDeviceRegistration.SetBusy(ABusy: Boolean);
begin
asm
var btn = document.getElementById('view.devicereg.btnregister');
if (btn) {
btn.disabled = ABusy;
btn.textContent = ABusy ? 'Sending...' : 'Send Code';
}
end;
end;
procedure TFViewDeviceRegistration.SetVerifyBusy(ABusy: Boolean);
begin
asm
var btn = document.getElementById('view.devicereg.btnverify');
if (btn) {
btn.disabled = ABusy;
btn.textContent = ABusy ? 'Verifying...' : 'Verify and Register';
}
end;
end;
procedure TFViewDeviceRegistration.btnRegisterClick(Sender: TObject);
var
phoneNumber: string;
procedure OnCodeSent;
begin
SetBusy(False);
edtPhoneNumber.Enabled := False;
pnlVerification.ElementHandle.classList.remove('d-none');
edtVerificationCode.SetFocus;
ShowNotification('Verification code sent. Enter the six-digit code to continue.', False);
end;
procedure OnCodeError(AMsg: string);
begin
SetBusy(False);
ShowNotification(AMsg);
end;
begin
phoneNumber := Trim(edtPhoneNumber.Text);
if phoneNumber = '' then
begin
ShowNotification('Please enter your phone number.');
Exit;
end;
SetBusy(True);
HideNotification;
AuthService.BeginRegistration(phoneNumber, @OnCodeSent, @OnCodeError);
end;
procedure TFViewDeviceRegistration.btnVerifyCodeClick(Sender: TObject);
var
phoneNumber, verificationCode: string;
i: Integer;
procedure OnVerified(AChallenge, AChallengeToken: string);
begin
DoSimpleKeyCreate(phoneNumber, AChallengeToken);
end;
procedure OnVerifyError(AMsg: string);
begin
SetVerifyBusy(False);
ShowNotification(AMsg);
end;
begin
phoneNumber := Trim(edtPhoneNumber.Text);
verificationCode := Trim(edtVerificationCode.Text);
if Length(verificationCode) <> 6 then
begin
ShowNotification('Please enter the six-digit verification code.');
Exit;
end;
for i := 1 to Length(verificationCode) do
if (verificationCode[i] < '0') or (verificationCode[i] > '9') then
begin
ShowNotification('Please enter the six-digit verification code.');
Exit;
end;
SetVerifyBusy(True);
HideNotification;
AuthService.VerifyRegistrationCode(phoneNumber, verificationCode,
@OnVerified, @OnVerifyError);
end;
procedure TFViewDeviceRegistration.DoWebAuthnCreate(
APhoneNumber, AChallenge, AChallengeToken: string);
var
phoneNumber, challenge, challengeToken: string;
procedure OnCompleteOK;
begin
FRegistrationProc;
end;
procedure OnCompleteError(AMsg: string);
begin
SetVerifyBusy(False);
ShowNotification(AMsg);
end;
procedure OnCredential(ACredentialId, AAttestationObject, AClientDataJSON: string);
begin
AuthService.CompleteRegistration(
phoneNumber, ACredentialId, AAttestationObject, AClientDataJSON, challengeToken,
@OnCompleteOK, @OnCompleteError
);
end;
procedure OnWebAuthnError(AMsg: string);
begin
SetVerifyBusy(False);
ShowNotification(AMsg);
end;
begin
phoneNumber := APhoneNumber;
challenge := AChallenge;
challengeToken := AChallengeToken;
asm
(function() {
function b64urlToArr(b64) {
b64 = b64.replace(/-/g, '+').replace(/_/g, '/');
while (b64.length % 4) b64 += '=';
var bin = atob(b64);
var arr = new Uint8Array(bin.length);
for (var i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
return arr;
}
function arrToB64url(buf) {
var bin = String.fromCharCode.apply(null, new Uint8Array(buf));
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
var userId = new Uint8Array(16);
crypto.getRandomValues(userId);
navigator.credentials.create({
publicKey: {
challenge: b64urlToArr(challenge),
rp: { id: window.location.hostname, name: 'emiMobile' },
user: { id: userId, name: 'emimobile-user', displayName: 'emiMobile User' },
pubKeyCredParams: [{ alg: -7, type: 'public-key' }],
timeout: 60000,
attestation: 'none',
authenticatorSelection: {
userVerification: 'required',
residentKey: 'preferred'
}
}
}).then(function(cred) {
var credId = arrToB64url(cred.rawId);
var attObj = arrToB64url(cred.response.attestationObject);
var cdJson = arrToB64url(cred.response.clientDataJSON);
OnCredential(credId, attObj, cdJson);
}).catch(function(err) {
OnWebAuthnError('WebAuthn error: ' + err.message);
});
})();
end;
end;
procedure TFViewDeviceRegistration.DoSimpleKeyCreate(APhoneNumber, AChallengeToken: string);
var
phoneNumber, challengeToken, deviceKey: string;
procedure OnCompleteOK;
begin
FRegistrationProc;
end;
procedure OnCompleteError(AMsg: string);
begin
SetVerifyBusy(False);
ShowNotification(AMsg);
end;
begin
phoneNumber := APhoneNumber;
challengeToken := AChallengeToken;
deviceKey := '';
asm
var keyBytes = new Uint8Array(32);
crypto.getRandomValues(keyBytes);
var bin = String.fromCharCode.apply(null, keyBytes);
deviceKey = btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
end;
if deviceKey = '' then
begin
SetVerifyBusy(False);
ShowNotification('Failed to generate device key.');
Exit;
end;
AuthService.CompleteRegistrationSimple(
phoneNumber, deviceKey, challengeToken,
@OnCompleteOK, @OnCompleteError
);
end;
procedure TFViewDeviceRegistration.btnCloseNotificationClick(Sender: TObject);
begin
HideNotification;
end;
procedure TFViewDeviceRegistration.ShowNotification(const AMsg: string; AIsError: Boolean);
begin
if AMsg <> '' then
begin
lblMessage.Caption := AMsg;
asm
var el = document.getElementById('view.devicereg.message');
if (el) {
el.classList.remove('alert-danger', 'alert-warning');
el.classList.add(AIsError ? 'alert-danger' : 'alert-warning');
el.classList.remove('d-none');
}
end;
end;
end;
procedure TFViewDeviceRegistration.HideNotification;
begin
asm
var el = document.getElementById('view.devicereg.message');
if (el) el.classList.add('d-none');
end;
end;
end.
...@@ -41,6 +41,18 @@ object FViewLogin: TFViewLogin ...@@ -41,6 +41,18 @@ object FViewLogin: TFViewLogin
TextHint = 'Password' TextHint = 'Password'
WidthPercent = 100.000000000000000000 WidthPercent = 100.000000000000000000
end end
object btnShowPassword: TWebButton
Left = 367
Top = 163
Width = 50
Height = 21
Caption = 'Show'
ElementID = 'view.login.btnshowpassword'
HeightPercent = 100.000000000000000000
TabOrder = 2
WidthPercent = 100.000000000000000000
OnClick = btnShowPasswordClick
end
object btnLogin: TWebButton object btnLogin: TWebButton
Left = 240 Left = 240
Top = 217 Top = 217
...@@ -49,7 +61,7 @@ object FViewLogin: TFViewLogin ...@@ -49,7 +61,7 @@ object FViewLogin: TFViewLogin
Caption = 'Login' Caption = 'Login'
ElementID = 'view.login.btnlogin' ElementID = 'view.login.btnlogin'
HeightPercent = 100.000000000000000000 HeightPercent = 100.000000000000000000
TabOrder = 2 TabOrder = 3
WidthPercent = 100.000000000000000000 WidthPercent = 100.000000000000000000
OnClick = btnLoginClick OnClick = btnLoginClick
end end
...@@ -59,7 +71,7 @@ object FViewLogin: TFViewLogin ...@@ -59,7 +71,7 @@ object FViewLogin: TFViewLogin
Width = 121 Width = 121
Height = 33 Height = 33
ElementID = 'view.login.message' ElementID = 'view.login.message'
TabOrder = 3 TabOrder = 4
object lblMessage: TWebLabel object lblMessage: TWebLabel
Left = 16 Left = 16
Top = 11 Top = 11
...@@ -97,6 +109,18 @@ object FViewLogin: TFViewLogin ...@@ -97,6 +109,18 @@ object FViewLogin: TFViewLogin
DisplayText = 'BUF - Buffalo Police Department' DisplayText = 'BUF - Buffalo Police Department'
end> end>
end end
object btnPasskeyLogin: TWebButton
Left = 240
Top = 244
Width = 121
Height = 25
Caption = 'Sign In with Passkey'
ElementID = 'view.login.btnpasskeylogin'
HeightPercent = 100.000000000000000000
TabOrder = 5
WidthPercent = 100.000000000000000000
OnClick = btnPasskeyLoginClick
end
object XDataWebClient: TXDataWebClient object XDataWebClient: TXDataWebClient
Connection = DMConnection.AuthConnection Connection = DMConnection.AuthConnection
Left = 492 Left = 492
......
...@@ -29,31 +29,53 @@ ...@@ -29,31 +29,53 @@
aria-label="Close"></button> aria-label="Close"></button>
</div> </div>
<div class="mb-3"> <!-- Auto-login section (shown when device has a stored username) -->
<input id="view.login.edtusername" <div id="view.login.autosection" class="d-none">
class="form-control" <p class="text-center text-muted mb-1 small">Signing in as</p>
type="text" <p id="view.login.autouserlabel"
placeholder="Username" class="text-center fw-semibold fs-5 mb-3"></p>
autofocus> <button id="view.login.btnpasskeylogin"
class="btn btn-primary w-100 mb-2">
Sign In with Passkey
</button>
<div class="text-center">
<a id="view.login.switchmanual" href="#"
class="small text-muted">Use a different account</a>
</div>
</div> </div>
<div class="mb-3">
<input id="view.login.edtpassword" <!-- Manual login section -->
class="form-control" <div id="view.login.manualsection">
type="password" <div class="mb-3">
placeholder="Password"> <input id="view.login.edtusername"
</div> class="form-control"
<div class="mb-3"> type="text"
<select id="view.login.edtagency" class="form-select"> placeholder="Username"
<!-- populated dynamically --> autofocus>
</select> </div>
<div class="input-group mb-3">
<input id="view.login.edtpassword"
class="form-control"
type="password"
placeholder="Password">
<button id="view.login.btnshowpassword"
class="btn btn-outline-secondary"
type="button">Show</button>
</div>
<div class="mb-3">
<select id="view.login.edtagency" class="form-select">
<!-- populated dynamically -->
</select>
</div>
<button id="view.login.btnlogin"
class="btn btn-primary w-100">
Login
</button>
</div> </div>
<button id="view.login.btnlogin"
class="btn btn-primary w-100">
Login
</button>
</div> </div>
<div class="card-footer text-muted small"> <div class="card-footer text-muted small d-flex justify-content-between">
Please use your lems username &amp; password to login. <span>Please use your lems username &amp; password to login.</span>
<span id="view.login.version" class="opacity-75"></span>
</div> </div>
</div> </div>
</div> </div>
......
...@@ -221,6 +221,21 @@ object FViewMain: TFViewMain ...@@ -221,6 +221,21 @@ object FViewMain: TFViewMain
WidthPercent = 100.000000000000000000 WidthPercent = 100.000000000000000000
OnClick = btnLogoutClick OnClick = btnLogoutClick
end end
object btnDevices: TWebButton
Left = 320
Top = 66
Width = 96
Height = 25
Caption = 'Devices'
ChildOrder = 17
ElementID = 'btn_devices'
ElementFont = efCSS
HeightStyle = ssAuto
HeightPercent = 100.000000000000000000
Visible = False
WidthPercent = 100.000000000000000000
OnClick = btnDevicesClick
end
object xdwcBadgeCounts: TXDataWebClient object xdwcBadgeCounts: TXDataWebClient
Connection = DMConnection.ApiConnection Connection = DMConnection.ApiConnection
Left = 44 Left = 44
......
<div class="d-flex flex-column vh-100"> <div class="d-flex flex-column vh-100">
<!-- Top Nav --> <!-- Top Nav -->
<nav class="navbar navbar-light bg-primary border-light text-light py-2 flex-shrink-0"> <nav id="top_nav" class="navbar navbar-light bg-primary border-light text-light py-2 flex-shrink-0">
<div class="container-fluid"> <div class="container-fluid">
<!-- App title + current view title --> <!-- App title + current view title -->
...@@ -11,11 +11,34 @@ ...@@ -11,11 +11,34 @@
<span id="lbl_main_title" class="navbar-brand text-light mb-0 ms-1"></span> <span id="lbl_main_title" class="navbar-brand text-light mb-0 ms-1"></span>
</div> </div>
<!-- Right: Connection / Logout --> <!-- Right: Connection / Menu -->
<div class="d-flex align-items-center gap-2 ms-auto"> <div class="d-flex align-items-center gap-2 ms-auto">
<span id="view.main.lblconnection" class="navbar-text text-light small"></span> <span id="view.main.lblconnection"
<button id="btn_logout" type="button" class="btn btn-outline-light btn-sm">Logout</button> class="connection-status connection-status-connecting"
role="status"
aria-live="polite"
aria-atomic="true"
aria-label="Live updates: Connecting"
title="Live updates: Connecting">
<span class="connection-status-dot" aria-hidden="true"></span>
<span id="view.main.lblconnectiontext" class="connection-status-text">Connecting</span>
</span>
<div class="dropdown">
<button type="button"
class="btn btn-outline-light btn-sm"
data-bs-toggle="dropdown"
aria-expanded="false"
aria-label="Menu">
<i class="fas fa-bars" aria-hidden="true"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><button id="btn_devices" type="button" class="dropdown-item d-none">Devices</button></li>
<li><button id="btn_logout" type="button" class="dropdown-item">Logout</button></li>
<li><hr class="dropdown-divider"></li>
<li><span id="view.main.version" class="dropdown-item-text text-muted small"></span></li>
</ul>
</div>
</div> </div>
</div> </div>
</nav> </nav>
...@@ -64,7 +87,7 @@ ...@@ -64,7 +87,7 @@
<i class="far fa-map me-1"></i>Map <i class="far fa-map me-1"></i>Map
</button> </button>
<button id="view.main.btncomplaints" type="button" class="btn btn-primary position-relative"> <button id="view.main.btncomplaints" type="button" class="btn btn-primary position-relative">
<i class="fa fa-exclamation-circle me-1"></i>Incidents <i class="fa fa-exclamation-circle me-1"></i>Complaints
<span id="view.main.badgecomplaints" <span id="view.main.badgecomplaints"
class="position-absolute top-0 start-100 translate-middle mt-1 badge rounded-pill bg-danger">0</span> class="position-absolute top-0 start-100 translate-middle mt-1 badge rounded-pill bg-danger">0</span>
</button> </button>
...@@ -78,6 +101,23 @@ ...@@ -78,6 +101,23 @@
</nav> </nav>
</div> </div>
<!-- Information modal -->
<div class="modal fade" id="main_information_modal" tabindex="-1"
aria-labelledby="main_information_modal_title" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-sm mx-auto px-3">
<div class="modal-content shadow-lg">
<div class="modal-header">
<h5 class="modal-title" id="main_information_modal_title">Information</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body fs-6 fw-bold" id="main_information_modal_body"></div>
<div class="modal-footer">
<button type="button" class="btn btn-primary w-100" data-bs-dismiss="modal">OK</button>
</div>
</div>
</div>
</div>
<!-- Spinner --> <!-- Spinner -->
<div id="spinner" class="position-absolute top-50 start-50 translate-middle d-none"> <div id="spinner" class="position-absolute top-50 start-50 translate-middle d-none">
<div class="lds-roller"> <div class="lds-roller">
......
unit View.Main; unit View.Main;
interface interface
...@@ -33,6 +33,7 @@ type ...@@ -33,6 +33,7 @@ type
pnlArchive: TWebPanel; pnlArchive: TWebPanel;
btnArchiveModalClose: TWebButton; btnArchiveModalClose: TWebButton;
btnLogout: TWebButton; btnLogout: TWebButton;
btnDevices: TWebButton;
procedure WebFormCreate(Sender: TObject); procedure WebFormCreate(Sender: TObject);
procedure mnuLogoutClick(Sender: TObject); procedure mnuLogoutClick(Sender: TObject);
procedure lblLogoutClick(Sender: TObject); procedure lblLogoutClick(Sender: TObject);
...@@ -44,6 +45,7 @@ type ...@@ -44,6 +45,7 @@ type
procedure btnDetailsModalCloseClick(Sender: TObject); procedure btnDetailsModalCloseClick(Sender: TObject);
procedure btnArchiveModalCloseClick(Sender: TObject); procedure btnArchiveModalCloseClick(Sender: TObject);
procedure btnLogoutClick(Sender: TObject); procedure btnLogoutClick(Sender: TObject);
procedure btnDevicesClick(Sender: TObject);
private private
{ Private declarations } { Private declarations }
FUserInfo: string; FUserInfo: string;
...@@ -54,7 +56,11 @@ type ...@@ -54,7 +56,11 @@ type
FDetailsForm: TWebForm; FDetailsForm: TWebForm;
FArchiveForm: TWebForm; FArchiveForm: TWebForm;
FLogoutProc: TLogoutProc; FLogoutProc: TLogoutProc;
FBadgeRefreshInProgress: Boolean;
FBadgeRefreshPending: Boolean;
FPendingWsBadgeCounts: TJSObject;
[async] procedure RefreshBadgesAsync; [async] procedure RefreshBadgesAsync;
procedure ApplyBadgeCounts(aData: TJSObject);
procedure ShowUnitDetails(UnitId: string); procedure ShowUnitDetails(UnitId: string);
procedure SetHeaderTitle(const title: string); procedure SetHeaderTitle(const title: string);
procedure HideDetailsModal; procedure HideDetailsModal;
...@@ -68,6 +74,10 @@ type ...@@ -68,6 +74,10 @@ type
procedure HandleWsComplaintMap(aData: TJSObject); procedure HandleWsComplaintMap(aData: TJSObject);
procedure HandleWsUnitList(aData: TJSObject); procedure HandleWsUnitList(aData: TJSObject);
procedure HandleWsComplaintList(aData: TJSObject); procedure HandleWsComplaintList(aData: TJSObject);
procedure HandleWsConnectionState(AState: TWsConnectionState);
procedure HandleWsRecoveryRequired;
procedure HandleWsAuthenticationExpired;
procedure ResyncLiveData;
type TActivePanel = (apNone, apMap, apUnits, apComplaints); type TActivePanel = (apNone, apMap, apUnits, apComplaints);
var var
...@@ -84,6 +94,7 @@ type ...@@ -84,6 +94,7 @@ type
public public
{ Public declarations } { Public declarations }
destructor Destroy; override;
class procedure Display(LogoutProc: TLogoutProc); class procedure Display(LogoutProc: TLogoutProc);
procedure ShowForm( AFormClass: TWebFormClass ); procedure ShowForm( AFormClass: TWebFormClass );
procedure ShowComplaintDetails(ComplaintId: string); procedure ShowComplaintDetails(ComplaintId: string);
...@@ -110,6 +121,7 @@ uses ...@@ -110,6 +121,7 @@ uses
View.EditUser, View.EditUser,
View.UnitDetails, View.UnitDetails,
View.ComplaintArchive, View.ComplaintArchive,
View.DeviceManager,
Utils; Utils;
{$R *.dfm} {$R *.dfm}
...@@ -121,9 +133,15 @@ const ...@@ -121,9 +133,15 @@ const
procedure TFViewMain.WebFormCreate(Sender: TObject); procedure TFViewMain.WebFormCreate(Sender: TObject);
var var
userName: string; userName: string;
el: TJSElement;
begin begin
userName := JS.toString(AuthService.TokenPayload.Properties['user_name']); userName := JS.toString(AuthService.TokenPayload.Properties['user_name']);
lblUsername.Caption := ' ' + userName.ToLower + ' '; lblUsername.Caption := ' ' + userName.ToLower + ' ';
el := Document.getElementById('view.main.version');
if Assigned(el) then
TJSHtmlElement(el).innerText := 'Version ' + TDMConnection.clientVersion;
FChildForm := nil; FChildForm := nil;
FDetailsForm := nil; FDetailsForm := nil;
FArchiveForm := nil; FArchiveForm := nil;
...@@ -133,9 +151,21 @@ begin ...@@ -133,9 +151,21 @@ begin
FMapRefreshTick := 0; FMapRefreshTick := 0;
FUnitsRefreshTick := 0; FUnitsRefreshTick := 0;
FComplaintsRefreshTick := 0; FComplaintsRefreshTick := 0;
FBadgeRefreshInProgress := False;
FBadgeRefreshPending := False;
FPendingWsBadgeCounts := nil;
if (not (JS.toBoolean(AuthService.TokenPayload.Properties['user_admin']))) then if JS.toBoolean(AuthService.TokenPayload.Properties['user_admin']) then
lblUsers.Visible := false; begin
// Show admin controls
btnDevices.Visible := True;
asm
var el = document.getElementById('btn_devices');
if (el) el.classList.remove('d-none');
end;
end
else
lblUsers.Visible := False;
Utils.HideSpinner('spinner'); Utils.HideSpinner('spinner');
...@@ -169,9 +199,27 @@ begin ...@@ -169,9 +199,27 @@ begin
dmWebsocket.OnComplaintMap := HandleWsComplaintMap; dmWebsocket.OnComplaintMap := HandleWsComplaintMap;
dmWebsocket.OnUnitList := HandleWsUnitList; dmWebsocket.OnUnitList := HandleWsUnitList;
dmWebsocket.OnComplaintList := HandleWsComplaintList; dmWebsocket.OnComplaintList := HandleWsComplaintList;
dmWebsocket.OnStateChanged := HandleWsConnectionState;
dmWebsocket.OnRecoveryRequired := HandleWsRecoveryRequired;
dmWebsocket.OnAuthenticationExpired := HandleWsAuthenticationExpired;
dmWebsocket.Connect(DMConnection.WsUrl); dmWebsocket.Connect(DMConnection.WsUrl);
end; end;
destructor TFViewMain.Destroy;
begin
if Assigned(dmWebsocket) and (dmWebsocket.Owner = Self) then
begin
dmWebsocket.Stop;
dmWebsocket.Free;
dmWebsocket := nil;
end;
if FViewMain = Self then
FViewMain := nil;
inherited;
end;
procedure TFViewMain.SetActivePanel(panel: TActivePanel); procedure TFViewMain.SetActivePanel(panel: TActivePanel);
begin begin
FActivePanel := panel; FActivePanel := panel;
...@@ -278,6 +326,11 @@ begin ...@@ -278,6 +326,11 @@ begin
FLogoutProc; FLogoutProc;
end; end;
procedure TFViewMain.btnDevicesClick(Sender: TObject);
begin
ShowForm(TFViewDeviceManager);
end;
procedure TFViewMain.btnMapClick(Sender: TObject); procedure TFViewMain.btnMapClick(Sender: TObject);
begin begin
ShowForm(TFViewMap); ShowForm(TFViewMap);
...@@ -462,10 +515,13 @@ end; ...@@ -462,10 +515,13 @@ end;
// WebSocket push handlers // WebSocket push handlers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
procedure TFViewMain.HandleWsBadgeCounts(aData: TJSObject); procedure TFViewMain.ApplyBadgeCounts(aData: TJSObject);
var var
el: TJSElement; el: TJSElement;
begin begin
if not Assigned(aData) then
Exit;
el := Document.getElementById('view.main.badgecomplaints'); el := Document.getElementById('view.main.badgecomplaints');
if Assigned(el) then if Assigned(el) then
TJSHtmlElement(el).innerText := string(aData['BadgeComplaints']); TJSHtmlElement(el).innerText := string(aData['BadgeComplaints']);
...@@ -475,6 +531,17 @@ begin ...@@ -475,6 +531,17 @@ begin
TJSHtmlElement(el).innerText := string(aData['BadgeUnits']); TJSHtmlElement(el).innerText := string(aData['BadgeUnits']);
end; end;
procedure TFViewMain.HandleWsBadgeCounts(aData: TJSObject);
begin
if FBadgeRefreshInProgress then
begin
FPendingWsBadgeCounts := aData;
Exit;
end;
ApplyBadgeCounts(aData);
end;
procedure TFViewMain.HandleWsUnitMap(aData: TJSObject); procedure TFViewMain.HandleWsUnitMap(aData: TJSObject);
begin begin
if Assigned(FMapForm) then if Assigned(FMapForm) then
...@@ -499,6 +566,83 @@ begin ...@@ -499,6 +566,83 @@ begin
FComplaintsForm.ApplyWsData(aData); FComplaintsForm.ApplyWsData(aData);
end; end;
procedure TFViewMain.HandleWsConnectionState(AState: TWsConnectionState);
var
statusElement: TJSHTMLElement;
textElement: TJSHTMLElement;
statusText: string;
statusClass: string;
begin
case AState of
wcsConnecting:
begin
statusText := 'Connecting';
statusClass := 'connecting';
end;
wcsConnected:
begin
statusText := 'Connected';
statusClass := 'connected';
end;
wcsReconnecting:
begin
statusText := 'Reconnecting';
statusClass := 'reconnecting';
end;
wcsOffline:
begin
statusText := 'Offline';
statusClass := 'offline';
end;
else
begin
statusText := 'Disconnected';
statusClass := 'stopped';
end;
end;
statusElement := TJSHTMLElement(Document.getElementById('view.main.lblconnection'));
if Assigned(statusElement) then
begin
statusElement.setAttribute('class', 'connection-status connection-status-' + statusClass);
statusElement.setAttribute('aria-label', 'Live updates: ' + statusText);
statusElement.setAttribute('title', 'Live updates: ' + statusText);
end;
textElement := TJSHTMLElement(Document.getElementById('view.main.lblconnectiontext'));
if Assigned(textElement) then
textElement.innerText := statusText;
end;
procedure TFViewMain.HandleWsRecoveryRequired;
begin
ResyncLiveData;
end;
procedure TFViewMain.HandleWsAuthenticationExpired;
begin
if Assigned(dmWebsocket) then
dmWebsocket.Stop;
if Assigned(FLogoutProc) then
FLogoutProc('Your session has expired. Please sign in again.');
end;
procedure TFViewMain.ResyncLiveData;
begin
if (not AuthService.Authenticated) or AuthService.TokenExpired then
begin
HandleWsAuthenticationExpired;
Exit;
end;
Console.Log('WS: resynchronizing live data');
RefreshBadgesAsync;
Inc(FGlobalRefreshTick);
RefreshActivePanelFromTimer;
end;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
procedure TFViewMain.tmrBadgeCountsTimer(Sender: TObject); procedure TFViewMain.tmrBadgeCountsTimer(Sender: TObject);
...@@ -522,25 +666,52 @@ var ...@@ -522,25 +666,52 @@ var
badgeObj: TJSObject; badgeObj: TJSObject;
el: TJSElement; el: TJSElement;
begin begin
if FBadgeRefreshInProgress then
begin
FBadgeRefreshPending := True;
Exit;
end;
FBadgeRefreshInProgress := True;
try try
resp := await(xdwcBadgeCounts.RawInvokeAsync('IApiService.GetBadgeCounts', [])); try
badgeObj := TJSObject(resp.Result); resp := await(xdwcBadgeCounts.RawInvokeAsync('IApiService.GetBadgeCounts', []));
badgeObj := TJSObject(resp.Result);
el := Document.getElementById('view.main.badgecomplaints');
if Assigned(el) then if Assigned(FPendingWsBadgeCounts) then
TJSHtmlElement(el).innerText := string(badgeObj['BadgeComplaints']); begin
ApplyBadgeCounts(FPendingWsBadgeCounts);
el := Document.getElementById('view.main.badgeunits'); FPendingWsBadgeCounts := nil;
if Assigned(el) then end
TJSHtmlElement(el).innerText := string(badgeObj['BadgeUnits']); else
except ApplyBadgeCounts(badgeObj);
on E: Exception do except
on E: Exception do
begin
if Assigned(FPendingWsBadgeCounts) then
begin
ApplyBadgeCounts(FPendingWsBadgeCounts);
FPendingWsBadgeCounts := nil;
end
else
begin
el := Document.getElementById('view.main.badgecomplaints');
if Assigned(el) then TJSHtmlElement(el).innerText := '�';
el := Document.getElementById('view.main.badgeunits');
if Assigned(el) then TJSHtmlElement(el).innerText := '�';
end;
Console.Log('Badge refresh error: ' + E.Message);
end;
end;
finally
FBadgeRefreshInProgress := False;
if FBadgeRefreshPending then
begin begin
el := Document.getElementById('view.main.badgecomplaints'); FBadgeRefreshPending := False;
if Assigned(el) then TJSHtmlElement(el).innerText := '�'; RefreshBadgesAsync;
el := Document.getElementById('view.main.badgeunits');
if Assigned(el) then TJSHtmlElement(el).innerText := '�';
Console.Log('Badge refresh error: ' + E.Message);
end; end;
end; end;
end; end;
......
program bfdEmiMobile; program bfdEmiMobile;
{$R *.dres} {$R *.dres}
uses uses
Vcl.Forms, Vcl.Forms,
System.SysUtils,
XData.Web.Connection, XData.Web.Connection,
Auth.Service in 'Auth.Service.pas', Auth.Service in 'Auth.Service.pas',
App.Types in 'App.Types.pas', App.Types in 'App.Types.pas',
...@@ -26,19 +27,25 @@ uses ...@@ -26,19 +27,25 @@ uses
uMapFilters in 'uMapFilters.pas', uMapFilters in 'uMapFilters.pas',
View.ComplaintArchive in 'View.ComplaintArchive.pas' {FViewComplaintArchive: TWebForm} {*.html}, View.ComplaintArchive in 'View.ComplaintArchive.pas' {FViewComplaintArchive: TWebForm} {*.html},
uMapMarkerJs in 'uMapMarkerJs.pas', uMapMarkerJs in 'uMapMarkerJs.pas',
Module.Websocket in 'Module.Websocket.pas' {dmWebsocket: TDataModule}; Module.Websocket in 'Module.Websocket.pas' {dmWebsocket: TDataModule},
View.DeviceRegistration in 'View.DeviceRegistration.pas' {FViewDeviceRegistration: TWebForm} {*.html},
View.DeviceManager in 'View.DeviceManager.pas' {FViewDeviceManager: TWebForm} {*.html};
{$R *.res} {$R *.res}
procedure DisplayLoginView(AMessage: string = ''); forward; procedure DisplayLoginView(AMessage: string = ''); forward;
procedure DisplayDeviceRegistrationView; forward;
procedure DisplayMainView; procedure DisplayMainView;
procedure ConnectProc; procedure ConnectProc;
begin begin
if Assigned(FViewLogin) then if Assigned(FViewLogin) then
FViewLogin.Free; FreeAndNil(FViewLogin);
TFViewMain.Display(@DisplayLoginView); if AuthService.DeviceManagementMode then
FViewDeviceManager := TFViewDeviceManager.CreateNew
else
TFViewMain.Display(@DisplayLoginView);
end; end;
begin begin
...@@ -52,11 +59,29 @@ procedure DisplayLoginView(AMessage: string); ...@@ -52,11 +59,29 @@ procedure DisplayLoginView(AMessage: string);
begin begin
AuthService.Logout; AuthService.Logout;
DMConnection.ApiConnection.Connected := False; DMConnection.ApiConnection.Connected := False;
if Assigned(FViewDeviceManager) then
FreeAndNil(FViewDeviceManager);
if Assigned(FViewMain) then if Assigned(FViewMain) then
FViewMain.Free; FViewMain.Free;
TFViewLogin.Display(@DisplayMainView, AMessage); TFViewLogin.Display(@DisplayMainView, AMessage);
end; end;
procedure DisplayDeviceRegistrationView;
procedure OnRegistered;
begin
// Device registered — proceed to login
if Assigned(FViewDeviceRegistration) then
FViewDeviceRegistration.Free;
TFViewLogin.Display(@DisplayMainView);
end;
begin
if Assigned(FViewDeviceRegistration) then
FViewDeviceRegistration.Free;
TFViewDeviceRegistration.Display(@OnRegistered);
end;
procedure UnauthorizedAccessProc(AMessage: string); procedure UnauthorizedAccessProc(AMessage: string);
begin begin
DisplayLoginView(AMessage); DisplayLoginView(AMessage);
...@@ -64,40 +89,24 @@ end; ...@@ -64,40 +89,24 @@ end;
procedure StartApplication; procedure StartApplication;
begin begin
DMConnection.SetClientConfig( if AuthService.DeviceManagementMode then
procedure(Success: Boolean; ErrorMessage: string) begin
begin DisplayLoginView;
if Success then Exit;
begin end;
if (not AuthService.Authenticated) or AuthService.TokenExpired then
DisplayLoginView // Step 1: device must be registered before login is allowed
else if not AuthService.IsDeviceRegistered then
DisplayMainView; begin
end DisplayDeviceRegistrationView;
else Exit;
begin end;
asm
var dlg = document.createElement("dialog"); // Step 2: normal JWT auth check
dlg.classList.add("shadow", "rounded", "border", "p-4"); if (not AuthService.Authenticated) or AuthService.TokenExpired then
dlg.style.maxWidth = "500px"; DisplayLoginView
dlg.style.width = "90%"; else
dlg.style.fontFamily = "system-ui, sans-serif"; DisplayMainView;
dlg.innerHTML =
"<h5 class='fw-bold mb-3 text-danger'>emiMobile</h5>" +
"<p class='mb-3' style='white-space: pre-wrap;'>" + ErrorMessage + "</p>" +
"<div class='text-end'>" +
"<button id='refreshBtn' class='btn btn-primary'>Reload</button></div>";
document.body.appendChild(dlg);
dlg.showModal();
document.getElementById("refreshBtn").addEventListener("click", function () {
location.reload(true); // Hard refresh
});
end;
end;
end);
end; end;
......
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup> <PropertyGroup>
<ProjectGuid>{DB6F5DBF-7E4B-45DA-AFFA-6C8DF15BA740}</ProjectGuid> <ProjectGuid>{12815250-11C0-427B-ABB7-1200FCC6061E}</ProjectGuid>
<ProjectVersion>20.4</ProjectVersion> <ProjectVersion>20.4</ProjectVersion>
<FrameworkType>VCL</FrameworkType> <FrameworkType>VCL</FrameworkType>
<MainSource>bfdEmiMobile.dpr</MainSource> <MainSource>bfdEmiMobile.dpr</MainSource>
...@@ -200,6 +200,14 @@ ...@@ -200,6 +200,14 @@
<FormType>dfm</FormType> <FormType>dfm</FormType>
<DesignClass>TDataModule</DesignClass> <DesignClass>TDataModule</DesignClass>
</DCCReference> </DCCReference>
<DCCReference Include="View.DeviceRegistration.pas">
<Form>FViewDeviceRegistration</Form>
<DesignClass>TWebForm</DesignClass>
</DCCReference>
<DCCReference Include="View.DeviceManager.pas">
<Form>FViewDeviceManager</Form>
<DesignClass>TWebForm</DesignClass>
</DCCReference>
<None Include="index.html"/> <None Include="index.html"/>
<None Include="css\app.css"/> <None Include="css\app.css"/>
<None Include="css\spinner.css"/> <None Include="css\spinner.css"/>
...@@ -493,13 +501,13 @@ ...@@ -493,13 +501,13 @@
<Overwrite>true</Overwrite> <Overwrite>true</Overwrite>
</Platform> </Platform>
</DeployFile> </DeployFile>
<DeployFile LocalName="Win32\Debug\webCharms.exe" Configuration="Debug" Class="ProjectOutput"/>
<DeployFile LocalName="Win32\Debug\bfdEmiMobile.exe" Configuration="Debug" Class="ProjectOutput"> <DeployFile LocalName="Win32\Debug\bfdEmiMobile.exe" Configuration="Debug" Class="ProjectOutput">
<Platform Name="Win32"> <Platform Name="Win32">
<RemoteName>bfdEmiMobile.exe</RemoteName> <RemoteName>bfdEmiMobile.exe</RemoteName>
<Overwrite>true</Overwrite> <Overwrite>true</Overwrite>
</Platform> </Platform>
</DeployFile> </DeployFile>
<DeployFile LocalName="Win32\Debug\webCharms.exe" Configuration="Debug" Class="ProjectOutput"/>
<DeployFile LocalName="Win32\Debug\webEnvoyCalls.exe" Configuration="Debug" Class="ProjectOutput"/> <DeployFile LocalName="Win32\Debug\webEnvoyCalls.exe" Configuration="Debug" Class="ProjectOutput"/>
<DeployFile LocalName="assets\bpddistricts-updated.geojson" Configuration="Debug" Class="ProjectFile"> <DeployFile LocalName="assets\bpddistricts-updated.geojson" Configuration="Debug" Class="ProjectFile">
<Platform Name="Win32"> <Platform Name="Win32">
......
{ {
"AuthUrl" : "http://localhost:2009/emimobile/auth/", "AuthUrl" : "http://localhost:2001/emsys/emimobile/auth/",
"ApiUrl" : "http://localhost:2009/emimobile/api/", "ApiUrl" : "http://localhost:2001/emsys/emimobile/api/",
"AppUrl" : "http://localhost:2009/emimobile/app/", "AppUrl" : "http://localhost:2001/emsys/emimobile/app/",
"WsUrl": "ws://localhost:8091/" "WsUrl": "ws://localhost:8091/"
} }
...@@ -121,3 +121,68 @@ html, body { ...@@ -121,3 +121,68 @@ html, body {
.connection-status {
display: inline-flex;
align-items: center;
gap: 0.35rem;
min-height: 1.75rem;
padding: 0.2rem 0.55rem;
border: 1px solid rgba(255, 255, 255, 0.35);
border-radius: 999px;
color: #fff;
font-size: 0.75rem;
font-weight: 600;
line-height: 1;
white-space: nowrap;
}
.connection-status-dot {
width: 0.55rem;
height: 0.55rem;
flex: 0 0 auto;
border-radius: 50%;
background-color: #adb5bd;
}
.connection-status-connected .connection-status-dot {
background-color: #75d995;
box-shadow: 0 0 0 0.14rem rgba(117, 217, 149, 0.2);
}
.connection-status-connecting .connection-status-dot,
.connection-status-reconnecting .connection-status-dot {
background-color: #ffd166;
animation: connection-status-pulse 1.4s ease-in-out infinite;
}
.connection-status-offline .connection-status-dot,
.connection-status-stopped .connection-status-dot {
background-color: #ff8b94;
}
@keyframes connection-status-pulse {
0%, 100% { opacity: 0.45; }
50% { opacity: 1; }
}
@media (max-width: 430px) {
.connection-status {
width: 1.75rem;
justify-content: center;
padding-right: 0;
padding-left: 0;
}
.connection-status-text {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.connection-status-connecting .connection-status-dot,
.connection-status-reconnecting .connection-status-dot {
animation: none;
}
}
object AuthDatabase: TAuthDatabase object WCAuthDatabase: TWCAuthDatabase
OnCreate = DataModuleCreate OnCreate = DataModuleCreate
OnDestroy = DataModuleDestroy OnDestroy = DataModuleDestroy
Height = 249 Height = 249
...@@ -18,15 +18,12 @@ object AuthDatabase: TAuthDatabase ...@@ -18,15 +18,12 @@ object AuthDatabase: TAuthDatabase
Top = 145 Top = 145
EncryptedPassword = '9AFF92FF8CFF86FF8CFFCFFFCEFF' EncryptedPassword = '9AFF92FF8CFF86FF8CFFCFFFCEFF'
end end
object uqUserPref: TUniQuery
Connection = ucLemsOCSO
SQL.Strings = (
'SELECT * FROM USER_PREFERENCES')
Left = 160
Top = 52
end
object uqAuth: TUniQuery object uqAuth: TUniQuery
Connection = ucLemsOCSO Connection = ucLemsOCSO
SQL.Strings = (
'select u.* from lems.users u '
'where upper(user_name) = :USER_NAME '
'and u.dept = :AGENCY')
Left = 88 Left = 88
Top = 48 Top = 48
end end
...@@ -34,4 +31,24 @@ object AuthDatabase: TAuthDatabase ...@@ -34,4 +31,24 @@ object AuthDatabase: TAuthDatabase
Left = 90 Left = 90
Top = 148 Top = 148
end end
object uqAgencies: TUniQuery
Connection = ucLemsOCSO
SQL.Strings = (
'select agencyid as agency '
'from lems.agency '
'where login = ''T'' '
'order by agencyid')
Left = 40
Top = 200
end
object uqAgencyConfig: TUniQuery
Connection = ucLemsOCSO
SQL.Strings = (
'select agencyid, agencyname '
'from lems.agency '
'where login = ''T'' '
'order by agencyname')
Left = 160
Top = 200
end
end end
unit Auth.Database; unit WC.Auth.Database;
interface interface
uses uses
System.SysUtils, System.Classes, MemDS, Client.Auth, System.SysUtils, System.Classes, MemDS,
Data.DB, DBAccess, Uni, UniProvider, PostgreSQLUniProvider; Data.DB, DBAccess, Uni, UniProvider, PostgreSQLUniProvider;
type type
TAuthDatabase = class(TDataModule) TWCAuthDatabase = class(TClientAuth)
uqMisc: TUniQuery; uqMisc: TUniQuery;
ucLemsOCSO: TUniConnection; ucLemsOCSO: TUniConnection;
uqUserPref: TUniQuery;
uqAuth: TUniQuery; uqAuth: TUniQuery;
uqAgencies: TUniQuery;
uqAgencyConfig: TUniQuery;
PostgreSQLUniProvider1: TPostgreSQLUniProvider; PostgreSQLUniProvider1: TPostgreSQLUniProvider;
procedure DataModuleCreate(Sender: TObject); procedure DataModuleCreate(Sender: TObject);
procedure DataModuleDestroy(Sender: TObject); procedure DataModuleDestroy(Sender: TObject);
private private
{ Private declarations } { Private declarations }
protected
function GetConnection: TUniConnection; override;
function GetUserData: TDataSet; override;
function GetAgencies: TDataSet; override;
function GetAgencyConfig: TDataSet; override;
public public
{ Public declarations } { Public declarations }
procedure SetLoginAuditEntry( userStr: string ); procedure OpenUser(User, Agency: string); override;
procedure OpenAgencies; override;
procedure OpenAgencyConfig; override;
procedure SetLoginAuditEntry( userStr: string ); override;
end; end;
var var
AuthDatabase: TAuthDatabase; WCAuthDatabase: TWCAuthDatabase;
implementation implementation
...@@ -38,15 +47,49 @@ uses ...@@ -38,15 +47,49 @@ uses
{$R *.dfm} {$R *.dfm}
procedure TAuthDatabase.DataModuleCreate(Sender: TObject); function TWCAuthDatabase.GetConnection: TUniConnection;
begin begin
ucLemsOCSO.ProviderName := 'PostgreSQL'; Result := ucLemsOCSO;
ucLemsOCSO.Server := IniEntries.DatabaseServer; end;
ucLemsOCSO.Port := IniEntries.DatabasePort;
ucLemsOCSO.Database := IniEntries.DatabaseName; function TWCAuthDatabase.GetUserData: TDataSet;
ucLemsOCSO.Username := IniEntries.DatabaseUsername; begin
ucLemsOCSO.Password := IniEntries.DatabasePassword; Result := uqAuth;
ucLemsOCSO.LoginPrompt := False; end;
function TWCAuthDatabase.GetAgencies: TDataSet;
begin
Result := uqAgencies;
end;
function TWCAuthDatabase.GetAgencyConfig: TDataSet;
begin
Result := uqAgencyConfig;
end;
procedure TWCAuthDatabase.OpenUser(User, Agency: string);
begin
uqAuth.Close;
uqAuth.ParamByName('USER_NAME').AsString := UpperCase(Trim(User));
uqAuth.ParamByName('AGENCY').AsString := UpperCase(Trim(Agency));
uqAuth.Open;
end;
procedure TWCAuthDatabase.OpenAgencies;
begin
uqAgencies.Close;
uqAgencies.Open;
end;
procedure TWCAuthDatabase.OpenAgencyConfig;
begin
uqAgencyConfig.Close;
uqAgencyConfig.Open;
end;
procedure TWCAuthDatabase.DataModuleCreate(Sender: TObject);
begin
IniEntries.ConfigureConnection(ucLemsOCSO);
try try
Logger.Log(2, ''); Logger.Log(2, '');
...@@ -60,13 +103,13 @@ begin ...@@ -60,13 +103,13 @@ begin
end; end;
procedure TAuthDatabase.DataModuleDestroy(Sender: TObject); procedure TWCAuthDatabase.DataModuleDestroy(Sender: TObject);
begin begin
if ucLemsOCSO.Connected then if ucLemsOCSO.Connected then
ucLemsOCSO.Disconnect; ucLemsOCSO.Disconnect;
end; end;
procedure TAuthDatabase.SetLoginAuditEntry( userStr: string ); procedure TWCAuthDatabase.SetLoginAuditEntry( userStr: string );
var var
auditMasterId: string; auditMasterId: string;
userInfo: TStringList; userInfo: TStringList;
...@@ -76,7 +119,7 @@ var ...@@ -76,7 +119,7 @@ var
userid: string; userid: string;
personnelid: string; personnelid: string;
begin begin
Logger.Log( 3, 'TAuthDatabase.SetLoginAuditEntry - start' ); Logger.Log( 3, 'TWCAuthDatabase.SetLoginAuditEntry - start' );
userInfo := TStringList.Create; userInfo := TStringList.Create;
try try
......
unit Client.Selection;
interface
uses
Client.Data;
procedure ValidateClient;
function CreateClientData: TClientData;
implementation
uses
System.SysUtils, Common.Ini, WC.Api.Data;
procedure ValidateClient;
begin
if IniEntries.Client = '' then
raise Exception.Create('Missing [Application] Client in the server INI file. Set Client=WC.');
if IniEntries.Client = 'BFD' then
raise Exception.Create('BFD is not supported by this server yet. Use the existing BFD server.');
if IniEntries.Client <> 'WC' then
raise Exception.CreateFmt('Unrecognized [Application] Client "%s". Supported client: WC.', [IniEntries.Client]);
end;
function CreateClientData: TClientData;
begin
ValidateClient;
Result := TWCAPIData.Create;
end;
end.
unit Client.Auth;
interface
uses
System.Classes, Data.DB, Uni;
type
TClientAuth = class abstract(TDataModule)
protected
function GetConnection: TUniConnection; virtual; abstract;
function GetUserData: TDataSet; virtual; abstract;
function GetAgencies: TDataSet; virtual; abstract;
function GetAgencyConfig: TDataSet; virtual; abstract;
public
procedure OpenUser(User, Agency: string); virtual; abstract;
procedure OpenAgencies; virtual; abstract;
procedure OpenAgencyConfig; virtual; abstract;
procedure SetLoginAuditEntry(userStr: string); virtual; abstract;
// Note: The module owns these datasets and the connection; callers must not free them.
property Connection: TUniConnection read GetConnection;
property UserData: TDataSet read GetUserData;
property Agencies: TDataSet read GetAgencies;
property AgencyConfig: TDataSet read GetAgencyConfig;
end;
implementation
end.
...@@ -3,11 +3,11 @@ unit Client.Data; ...@@ -3,11 +3,11 @@ unit Client.Data;
interface interface
uses uses
System.JSON; System.Classes, System.JSON;
type type
// Note: Each instance owns its data connection. Returned JSON belongs to the caller. // Note: Each instance owns its data connection. Returned JSON belongs to the caller.
TClientData = class abstract TClientData = class abstract(TDataModule)
public public
function EnsureConnected: Boolean; virtual; abstract; function EnsureConnected: Boolean; virtual; abstract;
procedure ConfigureQueryTimeouts; virtual; abstract; procedure ConfigureQueryTimeouts; virtual; abstract;
......
unit Client.Selection;
interface
uses
Client.Data, Client.Auth, Device.Store, Uni;
function CreateClientAuth: TClientAuth;
procedure ValidateClient;
function CreateDeviceStore(AConnection: TUniConnection = nil): TDeviceStore;
function CreateClientData: TClientData;
implementation
uses
System.SysUtils, Common.Ini, WC.Api.Database, WC.Auth.Database, PostgreSQL.Device.Database,
BFD.Api.Database, BFD.Auth.Database, Oracle.Device.Database;
function CreateClientAuth: TClientAuth;
begin
if IniEntries.Client = 'BFD' then
Result := TBFDAuthDatabase.Create(nil)
else
Result := TWCAuthDatabase.Create(nil);
end;
procedure ValidateClient;
begin
if IniEntries.Client = '' then
raise Exception.Create('Missing [Application] Client in the server INI file. Set Client=WC or Client=BFD.');
if (IniEntries.Client <> 'WC') and (IniEntries.Client <> 'BFD') then
raise Exception.CreateFmt('Unrecognized [Application] Client "%s". Supported clients: WC, BFD.', [IniEntries.Client]);
end;
function CreateClientData: TClientData;
begin
if IniEntries.Client = 'BFD' then
Result := TBFDAPIDataModule.Create(nil)
else
Result := TWCAPIDataModule.Create(nil);
end;
function CreateDeviceStore(AConnection: TUniConnection): TDeviceStore;
begin
if IniEntries.Client = 'BFD' then
Result := TOracleDeviceDatabase.Create(nil)
else
Result := TPostgreSQLDeviceDatabase.Create(nil);
try
if Assigned(AConnection) then
Result.UseConnection(AConnection)
else
begin
IniEntries.ConfigureConnection(Result.Connection);
Result.Connection.Connect;
end;
except
Result.Free;
raise;
end;
end;
end.
unit Device.Store;
interface
uses
System.Classes, System.SysUtils, Data.DB, Uni;
type
// Note: Returned datasets belong to this store. Read and close them; do not free them.
TDeviceStore = class abstract(TDataModule)
protected
function GetConnection: TUniConnection; virtual; abstract;
public
// Note: A supplied connection is borrowed and must outlive this store.
procedure UseConnection(AConnection: TUniConnection); virtual; abstract;
property Connection: TUniConnection read GetConnection;
function PendingDevice(PhoneNumber: string): TDataSet; virtual; abstract;
function SetVerificationCode(Code: string; PhoneNumber: string): Int64; virtual; abstract;
function ConsumeVerificationCode(PhoneNumber: string; Code: string): Int64; virtual; abstract;
function ActivateWebAuthnDevice(CredentialId: string; UserAgent: string; PublicKeyX: TBytes; PublicKeyY: TBytes; PublicKeyAlg: Integer; SignCount: Integer; PhoneNumber: string): Int64; virtual; abstract;
function UnrevokedDeviceUser(CredentialId: string): TDataSet; virtual; abstract;
function ActiveDeviceUser(CredentialId: string): TDataSet; virtual; abstract;
function UnrevokedDeviceKey(CredentialId: string): TDataSet; virtual; abstract;
function UpdateSignCount(SignCount: Integer; CredentialId: string): Int64; virtual; abstract;
function SaveDeviceUser(Username: string; Agency: string; CredentialId: string): Int64; virtual; abstract;
function ActiveDeviceKey(CredentialId: string): TDataSet; virtual; abstract;
function ActivateSimpleDevice(CredentialId: string; UserAgent: string; PhoneNumber: string): Int64; virtual; abstract;
function ActiveSimpleDeviceUser(CredentialId: string): TDataSet; virtual; abstract;
function ActiveSimpleDevice(CredentialId: string): TDataSet; virtual; abstract;
function DeviceStatus(CredentialId: string): TDataSet; virtual; abstract;
function DeviceList: TDataSet; virtual; abstract;
function RevokeDevice(RevokedBy: string; CredentialId: string): Int64; virtual; abstract;
function UnrevokeDevice(CredentialId: string): Int64; virtual; abstract;
function PendingPhone(PhoneNumber: string): TDataSet; virtual; abstract;
function AddPendingDevice(DeviceName: string; DeviceType: string; PhoneNumber: string): Int64; virtual; abstract;
function DeletePendingDevice(PhoneNumber: string): Int64; virtual; abstract;
function DeleteDevice(CredentialId: string): Int64; virtual; abstract;
function ReserveRedeemCode(PhoneNumber: string; out Code: string): Boolean; virtual; abstract;
function UpdateDeviceUsername(Username: string; CredentialId: string): Int64; virtual; abstract;
function ActiveDevice(CredentialId: string): TDataSet; virtual; abstract;
end;
implementation
end.
# Oracle device storage
Current configuration: Shared/Server/Common.Ini.pas reads [Oracle] Server, Username, Password for Client=BFD. Server is the existing TNS alias. The same ConfigureConnection method configures CAD, authentication and standalone device-store connections. Designer credentials are not a runtime fallback. Oracle.Connection has been removed.
Use the common server project for WC and BFD. The standalone BFD project is retired.
Prepared for Oracle 19c. No tables have been created and no database statements have been executed.
`Oracle.Device.Database.pas/.dfm` contains 25 designer UniQueries translated from WC's current authentication, device administration, and WebSocket device checks. It implements TDeviceStore and is referenced by the standalone BFD project for compilation and designer access, but is not auto-created or selected by application services. The connection has no credentials and starts disconnected; every query starts inactive. Connection configuration for Oracle belongs to the later integration step. UseConnection can borrow an existing connection; the store does not free it.
## Table assumptions
Unqualified uppercase names resolve in the same schema as the CAD tables for now. The final schema owner is still to be discussed.
- `DEVICE_REGISTRATIONS`: `ID`, `CREDENTIAL_ID`, `DEVICE_NAME`, `DEVICE_TYPE`, `PHONE_NUMBER`, `USER_AGENT`, `PUBLIC_KEY_X`, `PUBLIC_KEY_Y`, `PUBLIC_KEY_ALG`, `SIGN_COUNT`, `REGISTERED_AT`, `REVOKED_AT`, `REVOKED_BY`, `STATUS`, `KEY_TYPE`, `USERNAME`, `AGENCY`, `VERIFICATION_CODE`, `VERIFICATION_EXPIRES_AT`.
- `REDEEM_CODES`: `ID`, `CODE`, `USED_AT`, `USED_FOR`. This is needed for WC's app-download link workflow; no redeem-code seed data was copied.
The eventual schema must supply generated IDs, WC-compatible defaults, and credential/code uniqueness. Pending registrations need nullable credentials and public keys. Binary key parameters are `ftVarBytes` with size 32, intended for Oracle `RAW(32)` public-key coordinates. The user-agent parameter is `ftMemo`, intended for CLOB storage. Time fields should preserve WC's time-zone-aware expiry behavior; queries use `SYSTIMESTAMP`. Redeem codes retain WC's 20-character limit. Final DDL and remaining field sizes are deferred.
WC's checked-in SQL scripts do not define `DEVICE_TYPE`, `VERIFICATION_CODE`, or `VERIFICATION_EXPIRES_AT`, although current code uses them. Those requirements were taken from service code. Do not treat the old scripts as a complete schema or run their destructive table-recreation statements.
## Behavior retained
- Stored status/key-type values remain lowercase (`pending`, `active`, `revoked`, `simple`, `webauthn`); uppercase applies to identifiers.
- Existing active-status versus non-revoked filters remain distinct, matching their WC callers.
- Verification code consumption is a conditional UPDATE that checks pending status, code, and expiry and clears the code. Integration must check RowsAffected as WC does.
- Registration activation retains the pending-row condition. Integration must preserve its RowsAffected checks.
- Device-list verification activity uses a numeric CASE result (1/0), replacing PostgreSQL's boolean expression. Validate UniDAC field conversion when connected.
- `ReserveRedeemCode` starts a transaction, executes a PL/SQL cursor that fetches one unlocked unused code, marks it used, and commits. No available row returns False and rolls back. Exceptions roll back. Call it with a configured connection and no existing transaction. The eventual caller should keep WC's existing order: reserve/commit first, then send the SMS.
- No authentication bypass or test-mode boolean has been added.
Oracle cursor/locking reference: https://docs.oracle.com/en/database/oracle/oracle-database/19/lnpls/transaction-processing-and-control.html
## Validation and remaining work
The standalone BFD server builds successfully with this unit. An offline Delphi smoke test instantiated the data module, loaded its DFM, confirmed all 25 queries were inactive and attached to the disconnected connection, checked key/output parameter types, and freed the module. Static checks matched SQL bind names to DFM parameter declarations.
SQL execution, Oracle parameter binding, timestamp/boolean conversions, uniqueness behavior, and concurrent code allocation still require tests against the eventual tables. The shared Device.Store boundary and PostgreSQL counterpart are implemented, and WC services use them. Oracle operations implement the same contract but still require live validation and selection/configuration integration. Client=BFD remains disabled in the common server.
## Oracle connection configuration
The existing BFD connection uses an Oracle client/TNS alias. The new Oracle.Connection unit reads this section from the executable's INI:
```ini
[Oracle]
Server=BFD
Username=YOUR_ORACLE_USERNAME
Password=YOUR_ORACLE_PASSWORD
```
Use the actual credentials locally. Server is the TNS alias, not the PostgreSQL host from the old BFD [Database] section. No PostgreSQL port or database-name settings are applied to Oracle.
BFD CAD and auth modules apply this section before connecting. For compatibility with the standalone server, an absent Oracle section retains their existing designer settings. A present but incomplete section fails with a configuration error. A standalone Oracle device store requires the section; a store borrowing the auth connection uses that connection's settings.
The common server now compiles the BFD CAD/auth and Oracle device implementations and includes their selection branches. ValidateClient still rejects BFD before any branch executes. Removing that block alone is not a completed integration: require explicit Oracle configuration for the common-server CAD/auth path, finish the web/auth integration, create and test the deferred device tables, and validate Oracle listener behavior on the shared WebSocket worker before enabling BFD.
No running INI was changed and no database tables were created. Both server builds passed; Oracle INI connections have not been runtime-tested.
## Latest activation checkpoint
The user manually created both tables in dbForge. The common server now accepts Client=BFD and requires explicit Oracle INI settings. Earlier descriptions of the startup block are historical. Server=BFD still uses the existing TNS alias; credentials come from the INI. Both builds passed, but Oracle query/binding/listener and device-flow checks remain pending. Use webClientVersion=9.4.0 for the current BFD web project.
The earlier checkpoint text above is historical. Current schema was manually created by the user; real-number BFD browser registration and login were successful. ClearVerificationCode was removed after the send-before-save fix, leaving 24 Oracle designer queries.
# Device storage
`Core/Server/Device.Store.pas` defines the shared device-storage operations. PostgreSQL and Oracle designer data modules implement those operations with their own UniQueries and SQL.
## Ownership and use
- Authentication creates a store through `Client.Selection.CreateDeviceStore(authDB.ucLemsOCSO)` and borrows its existing connection. Free the store before the authentication module.
- API device operations and WebSocket activity checks create a store with `CreateDeviceStore`, which owns a separately configured connection. Free the store when that operation completes.
- Read operations return store-owned `TDataSet` results. Callers read/close those results; do not free them or retain them beyond the store's lifetime. Repeating the same operation reuses its query.
- Write operations return affected-row counts. Services retain their existing zero-row/not-found handling.
- `ReserveRedeemCode` owns its transaction and returns False when no unused code is available. Call without an existing transaction. Services still send the SMS only after reservation commits, matching WC.
- Authorization, phone validation, verification-code generation, cryptographic checks, JWT issuance, DTO/JSON creation, and SMS delivery remain in the services.
## Current selection
Only WC/PostgreSQL is enabled in Client.Selection. Authentication, API device management, and WebSocket device checks no longer contain device-storage SQL. CAD agency/user authentication queries remain separate and still need client-specific integration before BFD can use the common server.
The Oracle implementation shares the operation contract and retains its uppercase Oracle 19c queries. It is not selected at runtime. No Oracle tables were created; no bypass switch was introduced.
## Verification
Both server projects compiled into temporary output folders. An offline Delphi test loaded both data-module DFMs, checked all queries stayed inactive, checked borrowed/default connection assignment, and verified a borrowed connection survived store destruction. No database was connected by that test.
Static comparisons verified PostgreSQL's 26 query texts against the original service SQL, checked call arguments and parameter bindings, and preserved response assignments, access guards, and exception messages. Oracle's query DFM was unchanged by the shared-interface extraction.
Live WC verification is pending: registration/verification, password and automatic device login, device listing, revoke/unrevoke, pending-device changes, username changes, WebSocket access checks, and app-link reservation/delivery where configured. Oracle SQL execution and concurrency tests await the tables.
...@@ -66,6 +66,7 @@ var ...@@ -66,6 +66,7 @@ var
begin begin
Logger.Log(1, '--LoadServerConfig - start'); Logger.Log(1, '--LoadServerConfig - start');
ValidateClient; ValidateClient;
IniEntries.ValidateDatabaseSettings;
serverConfig.FWebAppFolder := TPath.Combine(TPath.Combine(ExtractFilePath(ParamStr(0)), 'static'), IniEntries.Client); serverConfig.FWebAppFolder := TPath.Combine(TPath.Combine(ExtractFilePath(ParamStr(0)), 'static'), IniEntries.Client);
Logger.Log(1, '-- Client: ' + IniEntries.Client + ' [from ini]'); Logger.Log(1, '-- Client: ' + IniEntries.Client + ' [from ini]');
......
...@@ -3,7 +3,7 @@ unit Common.Ini; ...@@ -3,7 +3,7 @@ unit Common.Ini;
interface interface
uses uses
System.SysUtils, System.IniFiles, Vcl.Forms; System.SysUtils, System.IniFiles, Vcl.Forms, Uni;
type type
TIniEntries = class TIniEntries = class
...@@ -41,6 +41,8 @@ type ...@@ -41,6 +41,8 @@ type
public public
constructor Create; constructor Create;
procedure ValidateDatabaseSettings;
procedure ConfigureConnection(Connection: TUniConnection);
property Client: string read FClient; property Client: string read FClient;
// [Settings] // [Settings]
...@@ -139,9 +141,48 @@ begin ...@@ -139,9 +141,48 @@ begin
FDatabasePassword := iniFile.ReadString('Database', 'Password', ''); FDatabasePassword := iniFile.ReadString('Database', 'Password', '');
FDatabasePasswordFromIni := iniFile.ValueExists('Database', 'Password'); FDatabasePasswordFromIni := iniFile.ValueExists('Database', 'Password');
// Note: Oracle uses a TNS alias; PostgreSQL uses host, port, and database.
if FClient = 'BFD' then
begin
FDatabaseServer := Trim(iniFile.ReadString('Oracle', 'Server', ''));
FDatabaseUsername := Trim(iniFile.ReadString('Oracle', 'Username', ''));
FDatabasePassword := iniFile.ReadString('Oracle', 'Password', '');
FDatabaseServerFromIni := iniFile.ValueExists('Oracle', 'Server');
FDatabaseUsernameFromIni := iniFile.ValueExists('Oracle', 'Username');
FDatabasePasswordFromIni := iniFile.ValueExists('Oracle', 'Password');
FDatabasePort := 0;
FDatabaseName := '';
FDatabasePortFromIni := False;
FDatabaseNameFromIni := False;
end;
finally finally
iniFile.Free; iniFile.Free;
end; end;
end; end;
procedure TIniEntries.ValidateDatabaseSettings;
begin
if (FClient = 'BFD') and
((FDatabaseServer = '') or (FDatabaseUsername = '') or (FDatabasePassword = '')) then
raise Exception.Create('Set Server, Username, and Password in the server INI [Oracle] section.');
end;
procedure TIniEntries.ConfigureConnection(Connection: TUniConnection);
begin
if FClient = 'BFD' then
Connection.ProviderName := 'Oracle'
else
begin
Connection.ProviderName := 'PostgreSQL';
Connection.Port := FDatabasePort;
Connection.Database := FDatabaseName;
end;
Connection.Server := FDatabaseServer;
Connection.Username := FDatabaseUsername;
Connection.Password := FDatabasePassword;
Connection.LoginPrompt := False;
end;
end. end.
...@@ -60,7 +60,6 @@ uses ...@@ -60,7 +60,6 @@ uses
Common.Config, Common.Config,
Common.Ini, Common.Ini,
Sparkle.Utils, Sparkle.Utils,
WC.Api.Database,
System.StrUtils; System.StrUtils;
{$R *.dfm} {$R *.dfm}
...@@ -191,12 +190,17 @@ begin ...@@ -191,12 +190,17 @@ begin
Logger.Log(1, LogValue('--Settings->LogFileNum', IniEntries.LogFileNum.ToString, IniEntries.LogFileNumFromIni)); Logger.Log(1, LogValue('--Settings->LogFileNum', IniEntries.LogFileNum.ToString, IniEntries.LogFileNumFromIni));
Logger.Log(1, LogValue('--Settings->webClientVersion', IniEntries.WebClientVersion, IniEntries.WebClientVersionFromIni)); Logger.Log(1, LogValue('--Settings->webClientVersion', IniEntries.WebClientVersion, IniEntries.WebClientVersionFromIni));
Logger.Log(1, '--- Database ---'); if IniEntries.Client = 'BFD' then
Logger.Log(1, LogValue('--Database->Server', IniEntries.DatabaseServer, IniEntries.DatabaseServerFromIni)); Logger.Log(1, '--- Oracle database: using [Oracle] INI settings ---')
Logger.Log(1, LogValue('--Database->Port', IniEntries.DatabasePort.ToString, IniEntries.DatabasePortFromIni)); else
Logger.Log(1, LogValue('--Database->Name', IniEntries.DatabaseName, IniEntries.DatabaseNameFromIni)); begin
Logger.Log(1, LogValue('--Database->Username', IniEntries.DataBaseUsername, IniEntries.DatabaseUsernameFromIni)); Logger.Log(1, '--- Database ---');
Logger.Log(1, LogValue('--Database->Password', IniEntries.DatabasePassword, IniEntries.DatabasePasswordFromIni)); Logger.Log(1, LogValue('--Database->Server', IniEntries.DatabaseServer, IniEntries.DatabaseServerFromIni));
Logger.Log(1, LogValue('--Database->Port', IniEntries.DatabasePort.ToString, IniEntries.DatabasePortFromIni));
Logger.Log(1, LogValue('--Database->Name', IniEntries.DatabaseName, IniEntries.DatabaseNameFromIni));
Logger.Log(1, LogValue('--Database->Username', IniEntries.DataBaseUsername, IniEntries.DatabaseUsernameFromIni));
Logger.Log(1, LogValue('--Database->Password', '[configured]', IniEntries.DatabasePasswordFromIni));
end;
Logger.Log(1, ''); Logger.Log(1, '');
Logger.Log(1, ''); Logger.Log(1, '');
......
...@@ -92,11 +92,11 @@ type ...@@ -92,11 +92,11 @@ type
implementation implementation
uses uses
Device.Store, Client.Selection, Data.DB,
System.DateUtils, System.DateUtils,
System.NetEncoding, System.NetEncoding,
IdStackConsts, IdStackConsts,
DBAccess, DBAccess,
Uni,
Bcl.JOSE.Core.JWT, Bcl.JOSE.Core.JWT,
Bcl.JOSE.Core.Builder, Bcl.JOSE.Core.Builder,
Common.Config, Common.Config,
...@@ -160,35 +160,13 @@ end; ...@@ -160,35 +160,13 @@ end;
function TWebSocketManager.IsDeviceActive(const ACredentialId: string): Boolean; function TWebSocketManager.IsDeviceActive(const ACredentialId: string): Boolean;
var var
connection: TUniConnection; store: TDeviceStore;
query: TUniQuery;
begin begin
Result := False; store := CreateDeviceStore;
connection := TUniConnection.Create(nil);
try try
connection.ProviderName := 'PostgreSQL'; Result := not store.ActiveDevice(ACredentialId).IsEmpty;
connection.Server := IniEntries.DatabaseServer;
connection.Port := IniEntries.DatabasePort;
connection.Database := IniEntries.DatabaseName;
connection.Username := IniEntries.DatabaseUsername;
connection.Password := IniEntries.DatabasePassword;
connection.LoginPrompt := False;
connection.Connect;
query := TUniQuery.Create(nil);
try
query.Connection := connection;
query.SQL.Text :=
'SELECT id FROM lems.device_registrations ' +
'WHERE credential_id = :CID AND status = ''active''';
query.ParamByName('CID').AsString := ACredentialId;
query.Open;
Result := not query.IsEmpty;
finally
query.Free;
end;
finally finally
connection.Free; store.Free;
end; end;
end; end;
......
unit Ws.DataModel; unit Ws.DataModel;
// Server-side WebSocket data model. // Server-side WebSocket data model.
// Note: Owns a main-thread PostgreSQL notification listener and a worker thread that // Note: Owns a main-thread client notification listener and a worker thread that
// queries the database for the five data sets used by the polling timers that // queries the database for the five data sets used by the polling timers that
// existed in each connected browser client, then broadcasts the results to // existed in each connected browser client, then broadcasts the results to
// every handshaked WebSocket connection via the supplied broadcast callback. // every handshaked WebSocket connection via the supplied broadcast callback.
...@@ -77,12 +77,16 @@ end; ...@@ -77,12 +77,16 @@ end;
destructor TWsDataModel.Destroy; destructor TWsDataModel.Destroy;
begin begin
FListenerTimer.Enabled := False; if Assigned(FListenerTimer) then
FListenerTimer.Enabled := False;
FListenerTimer.Free; FListenerTimer.Free;
FThread.Terminate; if Assigned(FThread) then
FStopEvent.SetEvent; begin
FThread.WaitFor; FThread.Terminate;
FThread.Free; FStopEvent.SetEvent;
FThread.WaitFor;
FThread.Free;
end;
FListenerData.Free; FListenerData.Free;
FStopEvent.Free; FStopEvent.Free;
inherited; inherited;
......
unit WebSocketServer;
interface
uses
System.SysUtils, System.Generics.Collections,
IdCustomTCPServer, IdTCPConnection, IdContext, IdIOHandler, IdGlobal, IdCoderMIME, IdHashSHA,
IdSSL, IdSSLOpenSSL;
type
TWebSocketServer = class(TIdCustomTCPServer)
private
IdServerIOHandlerSSLOpenSSL: TIdServerIOHandlerSSLOpenSSL;
HashSHA1: TIdHashSHA1;
protected
procedure DoConnect(AContext: TIdContext); override;
function DoExecute(AContext: TIdContext): Boolean; override;
public
procedure InitSSL(AIdServerIOHandlerSSLOpenSSL: TIdServerIOHandlerSSLOpenSSL);
property OnExecute;
constructor Create;
destructor Destroy; override;
end;
TWebSocketIOHandlerHelper = class(TIdIOHandler)
public
function ReadBytes: TArray<byte>;
function ReadString: string;
procedure WriteBytes(RawData: TArray<byte>);
procedure WriteString(const str: string);
end;
implementation
function HeadersParse(const msg: string): TDictionary<string, string>;
var
lines: TArray<string>;
line: string;
SplittedLine: TArray<string>;
begin
result := TDictionary<string, string>.Create;
lines := msg.Split([#13#10]);
for line in lines do
begin
SplittedLine := line.Split([': ']);
if Length(SplittedLine) > 1 then
result.AddOrSetValue(Trim(SplittedLine[0]), Trim(SplittedLine[1]));
end;
end;
{ TWebSocketServer }
constructor TWebSocketServer.Create;
begin
inherited Create;
HashSHA1 := TIdHashSHA1.Create;
IdServerIOHandlerSSLOpenSSL := nil;
end;
destructor TWebSocketServer.Destroy;
begin
HashSHA1.DisposeOf;
inherited;
end;
procedure TWebSocketServer.InitSSL(AIdServerIOHandlerSSLOpenSSL: TIdServerIOHandlerSSLOpenSSL);
var
CurrentActive: boolean;
begin
CurrentActive := Active;
if CurrentActive then
Active := false;
IdServerIOHandlerSSLOpenSSL := AIdServerIOHandlerSSLOpenSSL;
IOHandler := AIdServerIOHandlerSSLOpenSSL;
if CurrentActive then
Active := true;
end;
procedure TWebSocketServer.DoConnect(AContext: TIdContext);
begin
if AContext.Connection.IOHandler is TIdSSLIOHandlerSocketBase then
TIdSSLIOHandlerSocketBase(AContext.Connection.IOHandler).PassThrough := false;
// Mark connection as "not handshaked"
AContext.Connection.IOHandler.Tag := -1;
inherited;
end;
function TWebSocketServer.DoExecute(AContext: TIdContext): Boolean;
var
c: TIdIOHandler;
Bytes: TArray<byte>;
msg, SecWebSocketKey, Hash: string;
ParsedHeaders: TDictionary<string, string>;
begin
c := AContext.Connection.IOHandler;
// Handshake
if c.Tag = -1 then
begin
c.CheckForDataOnSource(10);
if not c.InputBufferIsEmpty then
begin
// Read string and parse HTTP headers
try
c.InputBuffer.ExtractToBytes(TIdBytes(Bytes));
msg := IndyTextEncoding_UTF8.GetString(TIdBytes(Bytes));
except
end;
ParsedHeaders := HeadersParse(msg);
if ParsedHeaders.ContainsKey('Upgrade') and (ParsedHeaders['Upgrade'] = 'websocket') and
ParsedHeaders.ContainsKey('Sec-WebSocket-Key') then
begin
// Handle handshake request
// https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_servers
SecWebSocketKey := ParsedHeaders['Sec-WebSocket-Key'];
// Send handshake response
Hash := TIdEncoderMIME.EncodeBytes(
HashSHA1.HashString(SecWebSocketKey + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'));
try
c.Write('HTTP/1.1 101 Switching Protocols'#13#10
+ 'Upgrade: websocket'#13#10
+ 'Connection: Upgrade'#13#10
+ 'Sec-WebSocket-Accept: ' + Hash
+ #13#10#13#10, IndyTextEncoding_UTF8);
except
end;
// Mark IOHandler as handshaked
c.Tag := 1;
end;
ParsedHeaders.DisposeOf;
end;
end;
Result := inherited;
end;
{ TWebSocketIOHandlerHelper }
function TWebSocketIOHandlerHelper.ReadBytes: TArray<byte>;
var
l: byte;
b: array [0..7] of byte;
i, DecodedSize: int64;
Mask: array [0..3] of byte;
begin
// https://stackoverflow.com/questions/8125507/how-can-i-send-and-receive-websocket-messages-on-the-server-side
try
if ReadByte = $81 then
begin
l := ReadByte;
case l of
$FE:
begin
b[1] := ReadByte; b[0] := ReadByte;
b[2] := 0; b[3] := 0; b[4] := 0; b[5] := 0; b[6] := 0; b[7] := 0;
DecodedSize := Int64(b);
end;
$FF:
begin
b[7] := ReadByte; b[6] := ReadByte; b[5] := ReadByte; b[4] := ReadByte;
b[3] := ReadByte; b[2] := ReadByte; b[1] := ReadByte; b[0] := ReadByte;
DecodedSize := Int64(b);
end;
else
DecodedSize := l - 128;
end;
Mask[0] := ReadByte; Mask[1] := ReadByte; Mask[2] := ReadByte; Mask[3] := ReadByte;
if DecodedSize < 1 then
begin
result := [];
exit;
end;
SetLength(result, DecodedSize);
inherited ReadBytes(TIdBytes(result), DecodedSize, False);
for i := 0 to DecodedSize - 1 do
result[i] := result[i] xor Mask[i mod 4];
end;
except
end;
end;
procedure TWebSocketIOHandlerHelper.WriteBytes(RawData: TArray<byte>);
var
Msg: TArray<byte>;
begin
// https://stackoverflow.com/questions/8125507/how-can-i-send-and-receive-websocket-messages-on-the-server-side
Msg := [$81];
if Length(RawData) <= 125 then
Msg := Msg + [Length(RawData)]
else if (Length(RawData) >= 126) and (Length(RawData) <= 65535) then
Msg := Msg + [126, (Length(RawData) shr 8) and 255, Length(RawData) and 255]
else
Msg := Msg + [127, (int64(Length(RawData)) shr 56) and 255, (int64(Length(RawData)) shr 48) and 255,
(int64(Length(RawData)) shr 40) and 255, (int64(Length(RawData)) shr 32) and 255,
(Length(RawData) shr 24) and 255, (Length(RawData) shr 16) and 255, (Length(RawData) shr 8) and 255, Length(RawData) and 255];
Msg := Msg + RawData;
try
Write(TIdBytes(Msg), Length(Msg));
except
end;
end;
function TWebSocketIOHandlerHelper.ReadString: string;
begin
result := IndyTextEncoding_UTF8.GetString(TIdBytes(ReadBytes));
end;
procedure TWebSocketIOHandlerHelper.WriteString(const str: string);
begin
WriteBytes(TArray<byte>(IndyTextEncoding_UTF8.GetBytes(str)));
end;
end.
unit Ws.Service;
interface
const
WS_MODEL = 'WsApi'; // retained for call-site compatibility in Main.pas
implementation
end.
unit Ws.ServiceImpl;
// Stub retained for project compatibility. Implementation moved to Ws.Server.Module.
interface
implementation
end.
unit LoginRequest;
interface
uses
Pkg.Json.DTO,
System.Generics.Collections,
REST.Json.Types,
BaseRequest;
{$M+}
type
TLoginInfo = class
private
FCarNumber: string;
FDistrict: string;
FMileage: string;
FShift: string;
FShiftDate: string;
FUnitName: string;
FUnitType: string;
published
property CarNumber: string read FCarNumber write FCarNumber;
property District: string read FDistrict write FDistrict;
property Mileage: string read FMileage write FMileage;
property Shift: string read FShift write FShift;
property ShiftDate: string read FShiftDate write FShiftDate;
property UnitName: string read FUnitName write FUnitName;
property UnitType: string read FUnitType write FUnitType;
end;
TLoginRequest = class(TRequest)
private
[JSONName('device_id')]
FDeviceId: string;
FLoginInfo: TLoginInfo;
FPass: string;
FRelogin: Boolean;
FRequestId: string;
FSessionId: string;
FUnitnumber: string;
FUser: string;
FVersion: string;
published
property DeviceId: string read FDeviceId write FDeviceId;
property LoginInfo: TLoginInfo read FLoginInfo;
property Pass: string read FPass write FPass;
property Relogin: Boolean read FRelogin write FRelogin;
property RequestId: string read FRequestId write FRequestId;
property SessionId: string read FSessionId write FSessionId;
property Unitnumber: string read FUnitnumber write FUnitnumber;
property User: string read FUser write FUser;
property Version: string read FVersion write FVersion;
public
constructor Create; override;
destructor Destroy; override;
end;
implementation
{ TLoginRequest }
constructor TLoginRequest.Create;
begin
inherited;
FLoginInfo := TLoginInfo.Create;
end;
destructor TLoginRequest.Destroy;
begin
FLoginInfo.Free;
inherited;
end;
end.
[Application] [Application]
Client=WC Client=BFD
[Settings] [Settings]
LogFileNum=154 LogFileNum=172
webClientVersion=0.9.4.9 webClientVersion=9.4.0
MemoLogLevel=3 MemoLogLevel=3
FileLogLevel=4 FileLogLevel=4
...@@ -23,3 +23,8 @@ Username=postgres ...@@ -23,3 +23,8 @@ Username=postgres
--Password=postgreSQL --Password=postgreSQL
Password=emsys01 Password=emsys01
--Postgre!SQL --Postgre!SQL
[Oracle]
Server=BFD
Username=EMFIRECAD
Password=EMFIRECAD
...@@ -8,36 +8,37 @@ uses ...@@ -8,36 +8,37 @@ uses
Vcl.StdCtrls, Vcl.StdCtrls,
IniFiles, IniFiles,
Vcl.Forms, Vcl.Forms,
Api.Server.Module in 'Source\Api.Server.Module.pas' {ApiServerModule: TDataModule}, Api.Server.Module in 'Shared\Server\Api.Server.Module.pas' {ApiServerModule: TDataModule},
Main in 'Source\Main.pas' {FMain}, Main in 'Shared\Server\Main.pas' {FMain},
Common.Logging in 'Source\Common.Logging.pas', Common.Logging in 'Shared\Server\Common.Logging.pas',
Client.Data in 'Core\Server\Client.Data.pas', BFD.Api.Database in 'Clients\BFD\Server\BFD.Api.Database.pas' {BFDAPIDataModule: TDataModule},
Client.Selection in 'Core\Server\Client.Selection.pas', BFD.Auth.Database in 'Clients\BFD\Server\BFD.Auth.Database.pas' {BFDAuthDatabase: TDataModule},
WC.Api.Data in 'Clients\WC\Server\WC.Api.Data.pas', Oracle.Device.Database in 'Shared\Database\Oracle\Oracle.Device.Database.pas' {OracleDeviceDatabase: TDataModule},
Device.Store in 'Shared\Clients\Device.Store.pas',
Client.Auth in 'Shared\Clients\Client.Auth.pas',
PostgreSQL.Device.Database in 'Shared\Database\PostgreSQL\PostgreSQL.Device.Database.pas' {PostgreSQLDeviceDatabase: TDataModule},
Client.Data in 'Shared\Clients\Client.Data.pas',
Client.Selection in 'Shared\Clients\Client.Selection.pas',
WC.Api.Database in 'Clients\WC\Server\WC.Api.Database.pas' {WCAPIDataModule: TDataModule}, WC.Api.Database in 'Clients\WC\Server\WC.Api.Database.pas' {WCAPIDataModule: TDataModule},
Common.Middleware.Logging in 'Source\Common.Middleware.Logging.pas', Common.Middleware.Logging in 'Shared\Server\Common.Middleware.Logging.pas',
Common.Config in 'Source\Common.Config.pas', Common.Config in 'Shared\Server\Common.Config.pas',
Auth.Server.Module in 'Source\Auth.Server.Module.pas' {AuthServerModule: TDataModule}, Auth.Server.Module in 'Shared\Server\Auth.Server.Module.pas' {AuthServerModule: TDataModule},
Auth.Database in 'Source\Auth.Database.pas' {AuthDatabase: TDataModule}, WC.Auth.Database in 'Clients\WC\Server\WC.Auth.Database.pas' {WCAuthDatabase: TDataModule},
uLibrary in 'Source\uLibrary.pas', uLibrary in 'Shared\Server\uLibrary.pas',
Auth.Service in 'Source\Auth.Service.pas', Auth.Service in 'Shared\Server\Auth.Service.pas',
Api.Service in 'Source\Api.Service.pas', Api.Service in 'Shared\Server\Api.Service.pas',
Auth.ServiceImpl in 'Source\Auth.ServiceImpl.pas', Auth.ServiceImpl in 'Shared\Server\Auth.ServiceImpl.pas',
Api.ServiceImpl in 'Source\Api.ServiceImpl.pas', Api.ServiceImpl in 'Shared\Server\Api.ServiceImpl.pas',
App.Server.Module in 'Source\App.Server.Module.pas' {AppServerModule: TDataModule}, App.Server.Module in 'Shared\Server\App.Server.Module.pas' {AppServerModule: TDataModule},
Common.Ini in 'Source\Common.Ini.pas', Common.Ini in 'Shared\Server\Common.Ini.pas',
BaseRequest in 'Source\shared\BaseRequest.pas', BaseRequest in 'Shared\Models\BaseRequest.pas',
LoginRequest in 'Source\shared\LoginRequest.pas', Pkg.Json.DTO in 'Shared\Models\Pkg.Json.DTO.pas',
Pkg.Json.DTO in 'Source\shared\Pkg.Json.DTO.pas', Ws.Server.Module in 'Shared\Server\Ws.Server.Module.pas' {WsServerModule: TDataModule},
DTOGeneratorUtils in 'Source\utils\DTOGeneratorUtils.pas', Ws.DataModel in 'Shared\Server\Ws.DataModel.pas',
Ws.Service in 'Source\Ws.Service.pas', WsMessages in 'Shared\Models\WsMessages.pas',
Ws.ServiceImpl in 'Source\Ws.ServiceImpl.pas', WebSocket.Manager in 'Shared\Server\WebSocket.Manager.pas',
Ws.Server.Module in 'Source\Ws.Server.Module.pas' {WsServerModule: TDataModule}, Webauthn.Cbor in 'Shared\Server\Webauthn.Cbor.pas',
Ws.DataModel in 'Source\Ws.DataModel.pas', Webauthn.Crypto in 'Shared\Server\Webauthn.Crypto.pas';
WsMessages in 'Source\shared\WsMessages.pas',
WebSocket.Manager in 'Source\WebSocket.Manager.pas',
Webauthn.Cbor in 'Source\Webauthn.Cbor.pas',
Webauthn.Crypto in 'Source\Webauthn.Crypto.pas';
type type
TMemoLogAppender = class( TInterfacedObject, ILogAppender ) TMemoLogAppender = class( TInterfacedObject, ILogAppender )
......
...@@ -134,57 +134,73 @@ ...@@ -134,57 +134,73 @@
<DelphiCompile Include="$(MainSource)"> <DelphiCompile Include="$(MainSource)">
<MainSource>MainSource</MainSource> <MainSource>MainSource</MainSource>
</DelphiCompile> </DelphiCompile>
<DCCReference Include="Source\Api.Server.Module.pas"> <DCCReference Include="Shared\Server\Api.Server.Module.pas">
<Form>ApiServerModule</Form> <Form>ApiServerModule</Form>
<DesignClass>TDataModule</DesignClass> <DesignClass>TDataModule</DesignClass>
</DCCReference> </DCCReference>
<DCCReference Include="Source\Main.pas"> <DCCReference Include="Shared\Server\Main.pas">
<Form>FMain</Form> <Form>FMain</Form>
</DCCReference> </DCCReference>
<DCCReference Include="Source\Common.Logging.pas"/> <DCCReference Include="Shared\Server\Common.Logging.pas"/>
<DCCReference Include="Core\Server\Client.Data.pas"/> <DCCReference Include="Clients\BFD\Server\BFD.Api.Database.pas">
<DCCReference Include="Core\Server\Client.Selection.pas"/> <Form>BFDAPIDataModule</Form>
<DCCReference Include="Clients\WC\Server\WC.Api.Data.pas"/> <FormType>dfm</FormType>
<DesignClass>TDataModule</DesignClass>
</DCCReference>
<DCCReference Include="Clients\BFD\Server\BFD.Auth.Database.pas">
<Form>BFDAuthDatabase</Form>
<FormType>dfm</FormType>
<DesignClass>TDataModule</DesignClass>
</DCCReference>
<DCCReference Include="Shared\Database\Oracle\Oracle.Device.Database.pas">
<Form>OracleDeviceDatabase</Form>
<FormType>dfm</FormType>
<DesignClass>TDataModule</DesignClass>
</DCCReference>
<DCCReference Include="Shared\Clients\Device.Store.pas"/>
<DCCReference Include="Shared\Clients\Client.Auth.pas"/>
<DCCReference Include="Shared\Database\PostgreSQL\PostgreSQL.Device.Database.pas">
<Form>PostgreSQLDeviceDatabase</Form>
<DesignClass>TDataModule</DesignClass>
</DCCReference>
<DCCReference Include="Shared\Clients\Client.Data.pas"/>
<DCCReference Include="Shared\Clients\Client.Selection.pas"/>
<DCCReference Include="Clients\WC\Server\WC.Api.Database.pas"> <DCCReference Include="Clients\WC\Server\WC.Api.Database.pas">
<Form>WCAPIDataModule</Form> <Form>WCAPIDataModule</Form>
<DesignClass>TDataModule</DesignClass> <DesignClass>TDataModule</DesignClass>
</DCCReference> </DCCReference>
<DCCReference Include="Source\Common.Middleware.Logging.pas"/> <DCCReference Include="Shared\Server\Common.Middleware.Logging.pas"/>
<DCCReference Include="Source\Common.Config.pas"/> <DCCReference Include="Shared\Server\Common.Config.pas"/>
<DCCReference Include="Source\Auth.Server.Module.pas"> <DCCReference Include="Shared\Server\Auth.Server.Module.pas">
<Form>AuthServerModule</Form> <Form>AuthServerModule</Form>
<DesignClass>TDataModule</DesignClass> <DesignClass>TDataModule</DesignClass>
</DCCReference> </DCCReference>
<DCCReference Include="Source\Auth.Database.pas"> <DCCReference Include="Clients\WC\Server\WC.Auth.Database.pas">
<Form>AuthDatabase</Form> <Form>WCAuthDatabase</Form>
<DesignClass>TDataModule</DesignClass> <DesignClass>TDataModule</DesignClass>
</DCCReference> </DCCReference>
<DCCReference Include="Source\uLibrary.pas"/> <DCCReference Include="Shared\Server\uLibrary.pas"/>
<DCCReference Include="Source\Auth.Service.pas"/> <DCCReference Include="Shared\Server\Auth.Service.pas"/>
<DCCReference Include="Source\Api.Service.pas"/> <DCCReference Include="Shared\Server\Api.Service.pas"/>
<DCCReference Include="Source\Auth.ServiceImpl.pas"/> <DCCReference Include="Shared\Server\Auth.ServiceImpl.pas"/>
<DCCReference Include="Source\Api.ServiceImpl.pas"/> <DCCReference Include="Shared\Server\Api.ServiceImpl.pas"/>
<DCCReference Include="Source\App.Server.Module.pas"> <DCCReference Include="Shared\Server\App.Server.Module.pas">
<Form>AppServerModule</Form> <Form>AppServerModule</Form>
<DesignClass>TDataModule</DesignClass> <DesignClass>TDataModule</DesignClass>
</DCCReference> </DCCReference>
<DCCReference Include="Source\Common.Ini.pas"/> <DCCReference Include="Shared\Server\Common.Ini.pas"/>
<DCCReference Include="Source\shared\BaseRequest.pas"/> <DCCReference Include="Shared\Models\BaseRequest.pas"/>
<DCCReference Include="Source\shared\LoginRequest.pas"/> <DCCReference Include="Shared\Models\Pkg.Json.DTO.pas"/>
<DCCReference Include="Source\shared\Pkg.Json.DTO.pas"/> <DCCReference Include="Shared\Server\Ws.Server.Module.pas">
<DCCReference Include="Source\utils\DTOGeneratorUtils.pas"/>
<DCCReference Include="Source\Ws.Service.pas"/>
<DCCReference Include="Source\Ws.ServiceImpl.pas"/>
<DCCReference Include="Source\Ws.Server.Module.pas">
<Form>WsServerModule</Form> <Form>WsServerModule</Form>
<FormType>dfm</FormType> <FormType>dfm</FormType>
<DesignClass>TDataModule</DesignClass> <DesignClass>TDataModule</DesignClass>
</DCCReference> </DCCReference>
<DCCReference Include="Source\Ws.DataModel.pas"/> <DCCReference Include="Shared\Server\Ws.DataModel.pas"/>
<DCCReference Include="Source\shared\WsMessages.pas"/> <DCCReference Include="Shared\Models\WsMessages.pas"/>
<DCCReference Include="Source\WebSocket.Manager.pas"/> <DCCReference Include="Shared\Server\WebSocket.Manager.pas"/>
<DCCReference Include="Source\Webauthn.Cbor.pas"/> <DCCReference Include="Shared\Server\Webauthn.Cbor.pas"/>
<DCCReference Include="Source\Webauthn.Crypto.pas"/> <DCCReference Include="Shared\Server\Webauthn.Crypto.pas"/>
<BuildConfiguration Include="Base"> <BuildConfiguration Include="Base">
<Key>Base</Key> <Key>Base</Key>
</BuildConfiguration> </BuildConfiguration>
......
emiMobile_BFD @ 13d204d6
Subproject commit 13d204d656b7ccc69148d12d46d95e642e8b0a44
# Development tools
DTOGeneratorUtils.pas is a retained Delphi source-generation utility. It is not part of the emiMobile server build. It uses UniDAC and Pkg.Json.DTO; this is reference tooling, not a standalone executable project.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment