Commit f1cc5bac by Mac Stephens

Update to version 0.9.4.8 - Improve device management, verification, and live updates

parent 6a8f477c
......@@ -420,6 +420,7 @@ object ApiDatabaseModule: TApiDatabaseModule
' ca.COMPLAINT,'
' ca.AGENCY,'
' cdc.CODE_DESC AS DISPATCH_CODE_DESC,'
' cdc.CATEGORY AS DISPATCHCODECATEGORY,'
' ca.SOURCE,'
' ccs.CODE_DESC AS SOURCE_DESC,'
' ca.PRIORITY,'
......@@ -485,6 +486,11 @@ object ApiDatabaseModule: TApiDatabaseModule
ReadOnly = True
Size = 60
end
object uqComplaintListdispatchcodecategory: TStringField
FieldName = 'dispatchcodecategory'
ReadOnly = True
Size = 30
end
object uqComplaintListsource: TStringField
FieldName = 'source'
Size = 6
......@@ -591,6 +597,12 @@ object ApiDatabaseModule: TApiDatabaseModule
FieldName = 'complaintNumber'
Calculated = True
end
object uqComplaintListpngName: TStringField
FieldKind = fkCalculated
FieldName = 'pngName'
Size = 80
Calculated = True
end
end
object uqComplaintDetails: TUniQuery
Connection = ucENTCAD
......@@ -1333,6 +1345,7 @@ object ApiDatabaseModule: TApiDatabaseModule
' dul.log as LOG_TEXT'
'from DIS_UNIT_LOG dul'
'where dul.unitid = :UNITID'
' and dul.timestamp > current_timestamp - interval ''24 hours'''
'order by dul.timestamp desc')
ReadOnly = True
Left = 198
......
......@@ -32,6 +32,7 @@ type
uqComplaintListcomplaint: TStringField;
uqComplaintListagency: TStringField;
uqComplaintListdispatch_code_desc: TStringField;
uqComplaintListdispatchcodecategory: TStringField;
uqComplaintListsource: TStringField;
uqComplaintListsource_desc: TStringField;
uqComplaintListpriority: TStringField;
......@@ -59,6 +60,7 @@ type
uqComplaintListdistrict_desc: TStringField;
uqComplaintListsector_desc: TStringField;
uqComplaintListcomplaintNumber: TStringField;
uqComplaintListpngName: TStringField;
uqComplaintDetailscomplaintid: TFloatField;
uqComplaintDetailscfsid: TFloatField;
uqComplaintDetailscomplaint: TStringField;
......@@ -322,6 +324,8 @@ end;
procedure TApiDatabaseModule.uqComplaintListCalcFields(DataSet: TDataSet);
var
raw: string;
rawCategory: string;
statusKey: string;
begin
raw := uqComplaintListCOMPLAINT.AsString.Trim;
......@@ -331,6 +335,21 @@ begin
uqComplaintListcomplaintNumber.AsString := raw + '-'
else
uqComplaintListcomplaintNumber.AsString := raw;
rawCategory := uqComplaintListdispatchcodecategory.AsString;
if not uqComplaintListdatearrived.IsNull then
statusKey := 'onscene'
else if not uqComplaintListdatedispatched.IsNull then
statusKey := 'attached'
else
statusKey := 'notattached';
if Trim(rawCategory) = '' then
uqComplaintListpngName.AsString := Format('default_%s.png', [statusKey])
else
uqComplaintListpngName.AsString :=
Format('%s_%s.png', [HandleUniqueFilenames(rawCategory), statusKey]);
end;
......@@ -388,6 +407,9 @@ end;
procedure TApiDatabaseModule.UniAlerter1Event(Sender: TDAAlerter; const EventName, Message: string);
begin
// ADD THIS DELETE BEFORE DEPLOY
Logger.Log(1, Format('PostgreSQL alerter event received: "%s"', [EventName]));
if SameText(EventName, 'disupdate') then
MarkCADUpdate;
end;
......
......@@ -39,6 +39,7 @@ type
function UnrevokeDevice(const CredentialId: string): TJSONObject;
function AddPendingDevice(const DeviceName, PhoneNumber: string): TJSONObject;
function DeletePendingDevice(const PhoneNumber: string): TJSONObject;
function DeleteDevice(CredentialId: string): TJSONObject;
function SendAppLink(const PhoneNumber: string): TJSONObject;
function UpdateDeviceUsername(const CredentialId, Username: string): TJSONObject;
end;
......
......@@ -45,6 +45,7 @@ type
function UnrevokeDevice(const CredentialId: string): TJSONObject;
function AddPendingDevice(const DeviceName, PhoneNumber: string): TJSONObject;
function DeletePendingDevice(const PhoneNumber: string): TJSONObject;
function DeleteDevice(CredentialId: string): TJSONObject;
function SendAppLink(const PhoneNumber: string): TJSONObject;
function UpdateDeviceUsername(const CredentialId, Username: string): TJSONObject;
end;
......@@ -425,6 +426,7 @@ begin
item.AddPair('Agency', ApiDB.uqComplaintListAGENCY.AsString);
item.AddPair('Priority', ApiDB.uqComplaintListPRIORITY.AsString);
item.AddPair('DispatchCodeDesc', ApiDB.uqComplaintListDISPATCH_CODE_DESC.AsString);
item.AddPair('pngName', ApiDB.uqComplaintListpngName.AsString);
item.AddPair('Address', ApiDB.uqComplaintListADDRESS.AsString);
item.AddPair('Business', ApiDB.uqComplaintListBUSINESS.AsString);
item.AddPair('CFSId', ApiDB.uqComplaintListCFSID.AsString);
......@@ -1325,9 +1327,15 @@ begin
conn.Free;
end;
if deviceStatus = 'revoked' then
if deviceStatus = '' then
begin
Logger.Log(2, 'RequireActiveDevice - rejected revoked credential: ' + Copy(credId, 1, 20));
Logger.Log(2, 'RequireActiveDevice - rejected missing credential: ' + Copy(credId, 1, 20));
raise EXDataHttpException.Create(401, 'Device registration was not found.');
end;
if not SameText(deviceStatus, 'active') then
begin
Logger.Log(2, 'RequireActiveDevice - rejected inactive credential: ' + Copy(credId, 1, 20));
raise EXDataHttpException.Create(401, 'Device access has been revoked.');
end;
end;
......@@ -1367,7 +1375,10 @@ begin
q.Connection := conn;
q.SQL.Text :=
'SELECT id, credential_id, device_name, phone_number, username, ' +
' user_agent, registered_at, revoked_at, revoked_by, status ' +
' user_agent, registered_at, revoked_at, revoked_by, status, ' +
' verification_code, ' +
' COALESCE(status = ''pending'' AND verification_expires_at > NOW(), FALSE) ' +
' AS verification_code_active ' +
'FROM lems.device_registrations ' +
'ORDER BY registered_at DESC';
q.Open;
......@@ -1390,6 +1401,8 @@ begin
item.revoked_at := q.FieldByName('revoked_at').AsString;
item.revoked_by := q.FieldByName('revoked_by').AsString;
item.status := q.FieldByName('status').AsString;
item.verification_code := q.FieldByName('verification_code').AsString;
item.verification_code_active := q.FieldByName('verification_code_active').AsBoolean;
Result.data.Add(item);
q.Next;
......@@ -1684,6 +1697,54 @@ begin
end;
end;
function TApiService.DeleteDevice(CredentialId: string): TJSONObject;
var
conn: TUniConnection;
q: TUniQuery;
begin
RequireAdmin;
Result := TJSONObject.Create;
TXDataOperationContext.Current.Handler.ManagedObjects.Add(Result);
if Trim(CredentialId) = '' then
begin
Result.AddPair('status', 'error');
Result.AddPair('message', 'CredentialId is required.');
Exit;
end;
conn := OpenLemsConnection;
try
q := TUniQuery.Create(nil);
try
q.Connection := conn;
q.SQL.Text :=
'DELETE FROM lems.device_registrations ' +
'WHERE credential_id = :CID';
q.ParamByName('CID').AsString := Trim(CredentialId);
q.ExecSQL;
if q.RowsAffected > 0 then
begin
Logger.Log(2, 'TApiService.DeleteDevice - deleted credential: ' +
Copy(CredentialId, 1, 20));
Result.AddPair('status', 'ok');
Result.AddPair('message', 'Device deleted.');
end
else
begin
Result.AddPair('status', 'error');
Result.AddPair('message', 'Device not found.');
end;
finally
q.Free;
end;
finally
conn.Free;
end;
end;
function TApiService.SendAppLink(const PhoneNumber: string): TJSONObject;
var
conn: TUniConnection;
......
......@@ -53,6 +53,8 @@ type
revoked_at: string;
revoked_by: string;
status: string;
verification_code: string;
verification_code_active: Boolean;
end;
TDeviceList = class
......@@ -78,8 +80,8 @@ type
// Section: Device registration step 1 checks phone pre-auth and sends an SMS code
function BeginRegistration(const PhoneNumber: string): TJSONObject;
// Section: Device registration step 2 verifies the SMS code and returns a challenge
function VerifyRegistrationCode(const PhoneNumber, VerificationCode,
VerificationToken: string): TJSONObject;
function VerifyRegistrationCode(const PhoneNumber,
VerificationCode: string): TJSONObject;
// Section: WebAuthn registration verifies the credential and activates the pending row
function CompleteRegistration(const PhoneNumber, CredentialId,
AttestationObject, ClientDataJSON,
......
......@@ -29,9 +29,6 @@ type
function LoadUserByName(const User, Agency: string): Boolean;
function Decrypt(inStr, keyStr: AnsiString): AnsiString;
function GenerateVerificationCode: string;
function CreateVerificationToken(const PhoneNumber, Code: string): string;
function VerifyVerificationToken(const PhoneNumber, Code,
VerificationToken: string): Boolean;
procedure SendVerificationText(const PhoneNumber, Code: string);
// Returns True and sets AChallengeB64 if token is valid and of the expected type
function VerifyChallengeToken(const ChallengeToken, ExpectedType: string;
......@@ -46,7 +43,8 @@ type
function GetAgencieslist(): TAgenciesList;
function GetAgencyConfiglist: TAgencyConfigList;
function BeginRegistration(const PhoneNumber: string): TJSONObject;
function VerifyRegistrationCode(const PhoneNumber, VerificationCode, VerificationToken: string): TJSONObject;
function VerifyRegistrationCode(const PhoneNumber,
VerificationCode: string): TJSONObject;
function CompleteRegistration(const PhoneNumber, CredentialId, AttestationObject, ClientDataJSON, ChallengeToken: string): TJSONObject;
function BeginAuthentication(const CredentialId: string): TJSONObject;
function GetDeviceUser(const CredentialId: string): TJSONObject;
......@@ -79,24 +77,6 @@ uses
Webauthn.Crypto,
Webauthn.Cbor;
const
VERIFICATION_RESEND_SECONDS = 60;
MAX_VERIFICATION_ATTEMPTS = 5;
type
TVerificationAttempt = record
Attempts: Integer;
ExpiresAt: TDateTime;
end;
var
VerificationLock: TObject;
VerificationSentAt: TDictionary<string, TDateTime>;
VerificationAttempts: TDictionary<string, TVerificationAttempt>;
{ TAuthService }
constructor TAuthService.Create;
begin
Logger.Log(3, 'TAuthService.Create');
......@@ -229,51 +209,6 @@ begin
Result := FormatFloat('000000', value mod 1000000);
end;
function TAuthService.CreateVerificationToken(const PhoneNumber,
Code: string): string;
var
payload, expiry: string;
keyBytes, hmacBytes: TBytes;
begin
expiry := IntToStr(DateTimeToUnix(TTimeZone.Local.ToUniversalTime(IncMinute(Now, 5))));
payload := PhoneNumber + '|' + expiry + '|' + Base64UrlEncode(RandomBytes(16));
keyBytes := TEncoding.UTF8.GetBytes(ServerConfig.jwtTokenSecret);
hmacBytes := HMACSHA256Bytes(keyBytes, TEncoding.UTF8.GetBytes(payload + '|' + Code));
Result := Base64UrlEncode(TEncoding.UTF8.GetBytes(payload)) + '.' + Base64UrlEncode(hmacBytes);
end;
function TAuthService.VerifyVerificationToken(const PhoneNumber, Code,
VerificationToken: string): Boolean;
var
tokenParts, payloadParts: TArray<string>;
payload, expectedHmac: string;
expiry: Int64;
keyBytes, hmacBytes: TBytes;
begin
Result := False;
tokenParts := VerificationToken.Split(['.']);
if Length(tokenParts) <> 2 then Exit;
try
payload := TEncoding.UTF8.GetString(Base64UrlDecode(tokenParts[0]));
except
Exit;
end;
payloadParts := payload.Split(['|']);
if Length(payloadParts) <> 3 then Exit;
if payloadParts[0] <> PhoneNumber then Exit;
expiry := StrToInt64Def(payloadParts[1], 0);
if (expiry = 0) or
(DateTimeToUnix(TTimeZone.Local.ToUniversalTime(Now)) > expiry) then Exit;
keyBytes := TEncoding.UTF8.GetBytes(ServerConfig.jwtTokenSecret);
hmacBytes := HMACSHA256Bytes(keyBytes, TEncoding.UTF8.GetBytes(payload + '|' + Code));
expectedHmac := Base64UrlEncode(hmacBytes);
Result := expectedHmac = tokenParts[1];
end;
procedure TAuthService.SendVerificationText(const PhoneNumber, Code: string);
var
httpClient: THTTPClient;
......@@ -321,12 +256,8 @@ end;
function TAuthService.BeginRegistration(const PhoneNumber: string): TJSONObject;
var
code, normalizedPhone, verificationToken, deviceName: string;
code, normalizedPhone, deviceName: string;
deviceId: Integer;
secondsSinceLastSend: Integer;
secondsUntilRetry: Integer;
sentAt: TDateTime;
attempt: TVerificationAttempt;
q: TUniQuery;
begin
Logger.Log(3, '---TAuthService.BeginRegistration initiated with phone number: ' + PhoneNumber);
......@@ -376,45 +307,49 @@ begin
q.Free;
end;
secondsUntilRetry := 0;
TMonitor.Enter(VerificationLock);
try
if VerificationSentAt.TryGetValue(normalizedPhone, sentAt) then
begin
secondsSinceLastSend := SecondsBetween(Now, sentAt);
if secondsSinceLastSend < VERIFICATION_RESEND_SECONDS then
secondsUntilRetry := VERIFICATION_RESEND_SECONDS - secondsSinceLastSend;
end;
if secondsUntilRetry = 0 then
VerificationSentAt.AddOrSetValue(normalizedPhone, Now);
finally
TMonitor.Exit(VerificationLock);
end;
code := GenerateVerificationCode;
if secondsUntilRetry > 0 then
q := TUniQuery.Create(nil);
try
q.Connection := authDB.ucLemsOCSO;
q.SQL.Text :=
'UPDATE lems.device_registrations ' +
'SET verification_code = :CODE, ' +
' verification_expires_at = NOW() + INTERVAL ''5 minutes'' ' +
'WHERE phone_number = :PHONE AND status = ''pending''';
q.ParamByName('CODE').AsString := code;
q.ParamByName('PHONE').AsString := normalizedPhone;
q.ExecSQL;
if q.RowsAffected = 0 then
begin
Result.AddPair('status', 'error');
Result.AddPair('message', Format(
'Please wait %d seconds before requesting another verification code.',
[secondsUntilRetry]));
Result.AddPair('message', 'Phone number not recognized. Contact your administrator.');
Exit;
end;
code := GenerateVerificationCode;
verificationToken := CreateVerificationToken(normalizedPhone, code);
finally
q.Free;
end;
try
SendVerificationText(normalizedPhone, code);
except
on E: Exception do
begin
TMonitor.Enter(VerificationLock);
q := TUniQuery.Create(nil);
try
VerificationSentAt.Remove(normalizedPhone);
q.Connection := authDB.ucLemsOCSO;
q.SQL.Text :=
'UPDATE lems.device_registrations ' +
'SET verification_code = NULL, verification_expires_at = NULL ' +
'WHERE phone_number = :PHONE AND status = ''pending'' ' +
'AND verification_code = :CODE';
q.ParamByName('PHONE').AsString := normalizedPhone;
q.ParamByName('CODE').AsString := code;
q.ExecSQL;
finally
TMonitor.Exit(VerificationLock);
q.Free;
end;
Logger.Log(2, '--Error sending registration verification code: ' + E.Message);
Result.AddPair('status', 'error');
Result.AddPair('message', 'The verification code could not be sent. Please try again.');
......@@ -422,31 +357,19 @@ begin
end;
end;
attempt.Attempts := 0;
attempt.ExpiresAt := IncMinute(Now, 5);
TMonitor.Enter(VerificationLock);
try
VerificationAttempts.AddOrSetValue(normalizedPhone, attempt);
finally
TMonitor.Exit(VerificationLock);
end;
Logger.Log(3, Format(
'Verification code sent for device id %d and device name: %s',
[deviceId, deviceName]));
Result.AddPair('status', 'ok');
Result.AddPair('message', 'Verification code sent.');
Result.AddPair('verificationToken', verificationToken);
Logger.Log(3, '---TAuthService.BeginRegistration End');
end;
function TAuthService.VerifyRegistrationCode(const PhoneNumber,
VerificationCode, VerificationToken: string): TJSONObject;
VerificationCode: string): TJSONObject;
var
normalizedPhone, token, challengeB64: string;
attempt: TVerificationAttempt;
verificationValid: Boolean;
tooManyAttempts: Boolean;
q: TUniQuery;
begin
Result := TJSONObject.Create;
......@@ -463,69 +386,30 @@ begin
end;
end;
verificationValid := False;
if Length(Trim(VerificationCode)) = 6 then
begin
q := TUniQuery.Create(nil);
try
q.Connection := authDB.ucLemsOCSO;
q.SQL.Text :=
'SELECT id FROM lems.device_registrations ' +
'WHERE phone_number = :PHONE AND status = ''pending''';
'UPDATE lems.device_registrations ' +
'SET verification_code = NULL, verification_expires_at = NULL ' +
'WHERE phone_number = :PHONE AND status = ''pending'' ' +
'AND verification_code = :CODE AND verification_expires_at > NOW()';
q.ParamByName('PHONE').AsString := normalizedPhone;
q.Open;
if q.IsEmpty then
begin
q.Close;
Result.AddPair('status', 'error');
Result.AddPair('message', 'Phone number not recognized. Contact your administrator.');
Exit;
end;
q.Close;
q.ParamByName('CODE').AsString := Trim(VerificationCode);
q.ExecSQL;
verificationValid := q.RowsAffected > 0;
finally
q.Free;
end;
verificationValid := False;
tooManyAttempts := False;
TMonitor.Enter(VerificationLock);
try
if VerificationAttempts.TryGetValue(normalizedPhone, attempt) and
(attempt.ExpiresAt < Now) then
VerificationAttempts.Remove(normalizedPhone);
if VerificationAttempts.TryGetValue(normalizedPhone, attempt) and
(attempt.Attempts >= MAX_VERIFICATION_ATTEMPTS) then
tooManyAttempts := True
else
begin
verificationValid := (Length(Trim(VerificationCode)) = 6) and
VerifyVerificationToken(normalizedPhone, Trim(VerificationCode),
VerificationToken);
if verificationValid then
VerificationAttempts.Remove(normalizedPhone)
else
begin
if not VerificationAttempts.TryGetValue(normalizedPhone, attempt) then
begin
attempt.Attempts := 0;
attempt.ExpiresAt := IncMinute(Now, 5);
end;
Inc(attempt.Attempts);
VerificationAttempts.AddOrSetValue(normalizedPhone, attempt);
tooManyAttempts := attempt.Attempts >= MAX_VERIFICATION_ATTEMPTS;
end;
end;
finally
TMonitor.Exit(VerificationLock);
end;
if not verificationValid then
begin
Logger.Log(2, '--Verification failed for phone number: ' + normalizedPhone);
Result.AddPair('status', 'error');
if tooManyAttempts then
Result.AddPair('message',
'Too many incorrect attempts. Request a new verification code.')
else
Result.AddPair('message', 'Invalid or expired verification code.');
Exit;
end;
......@@ -713,6 +597,7 @@ begin
'SET credential_id = :CID, user_agent = :AGENT, ' +
' public_key_x = :KEYX, public_key_y = :KEYY, ' +
' public_key_alg = :ALG, sign_count = :CNT, ' +
' verification_code = NULL, verification_expires_at = NULL, ' +
' status = ''active'', registered_at = NOW() ' +
'WHERE phone_number = :PHONE AND status = ''pending''';
q.ParamByName('CID').AsString := Trim(CredentialId);
......@@ -1309,6 +1194,7 @@ begin
'UPDATE lems.device_registrations ' +
'SET credential_id = :CID, user_agent = :AGENT, ' +
' key_type = ''simple'', ' +
' verification_code = NULL, verification_expires_at = NULL, ' +
' status = ''active'', registered_at = NOW() ' +
'WHERE phone_number = :PHONE AND status = ''pending''';
q.ParamByName('CID').AsString := Trim(DeviceKey);
......@@ -1784,14 +1670,6 @@ begin
end;
initialization
VerificationLock := TObject.Create;
VerificationSentAt := TDictionary<string, TDateTime>.Create;
VerificationAttempts := TDictionary<string, TVerificationAttempt>.Create;
RegisterServiceType(TAuthService);
finalization
VerificationAttempts.Free;
VerificationSentAt.Free;
VerificationLock.Free;
end.
unit Common.Config;
unit Common.Config;
interface
const
defaultServerUrl = 'http://0.0.0.0:2009/emiMobile/';
defaultServerUrl = 'http://localhost:2009/emiMobile/';
type
TServerConfig = class
......
......@@ -94,7 +94,7 @@ object FMain: TFMain
Top = 18
Width = 141
Height = 25
Caption = 'Refresh Client'
Caption = 'Disconnect Client'
TabOrder = 0
OnClick = btnDisconnectClientClick
end
......
......@@ -178,6 +178,8 @@ begin
Logger.Log(1, '');
Logger.Log(1, '--- Settings ---');
Logger.Log(1, LogValue('--Settings->MemoLogLevel', IniEntries.MemoLogLevel.ToString, IniEntries.MemoLogLevelFromIni));
Logger.Log(1, LogValue('--Settings->FileLogLevel', IniEntries.FileLogLevel.ToString, IniEntries.FileLogLevelFromIni));
Logger.Log(1, LogValue('--Settings->LogFileNum', IniEntries.LogFileNum.ToString, IniEntries.LogFileNumFromIni));
Logger.Log(1, LogValue('--Settings->webClientVersion', IniEntries.WebClientVersion, IniEntries.WebClientVersionFromIni));
......
unit Ws.DataModel;
// Server-side WebSocket data model.
// Owns a worker thread that checks every FIntervalMs milliseconds, queries the
// database for the five data sets used by the polling timers that existed in
// each connected browser client, and broadcasts the results to every
// handshaked WebSocket connection via the supplied broadcast callback.
// Note: Owns a main-thread PostgreSQL notification listener and a worker thread 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
// every handshaked WebSocket connection via the supplied broadcast callback.
//
// All five result shapes mirror what the XData API methods return so that the
// client-side parsing code can be shared between the initial HTTP load and the
......@@ -16,6 +16,7 @@ uses
System.SysUtils, System.Classes, System.JSON,
System.Generics.Collections, System.SyncObjs,
Data.DB,
Vcl.ExtCtrls,
Api.Database,
WsMessages,
Common.Logging;
......@@ -26,20 +27,23 @@ type
TWsDataModel = class
private
FDb: TApiDatabaseModule;
FListenerDb: TApiDatabaseModule;
FListenerTimer: TTimer;
FThread: TThread;
FStopEvent: TEvent;
FIntervalMs: Integer;
FBroadcast: TWsBroadcastProc;
procedure ListenerTimerFire(Sender: TObject);
procedure Execute;
procedure ConfigureQueryTimeouts;
procedure BroadcastAll;
function BuildBadgeCountsJson: string;
function BuildUnitMapJson: string;
function BuildComplaintMapJson: string;
function BuildComplaintMapJson(out AComplaintCount: Integer): string;
function BuildUnitListJson: string;
function BuildComplaintListJson: string;
function BuildComplaintListJson(out AComplaintCount: Integer): string;
public
constructor Create(ABroadcast: TWsBroadcastProc; AIntervalMs: Integer = 5000);
destructor Destroy; override;
......@@ -61,8 +65,13 @@ begin
FBroadcast := ABroadcast;
FIntervalMs := AIntervalMs;
FStopEvent := TEvent.Create(nil, False, False, '');
FDb := TApiDatabaseModule.Create(nil);
ConfigureQueryTimeouts;
FDb := nil;
FListenerDb := TApiDatabaseModule.Create(nil);
FListenerTimer := TTimer.Create(nil);
FListenerTimer.Interval := FIntervalMs;
FListenerTimer.OnTimer := ListenerTimerFire;
FListenerTimer.Enabled := True;
ListenerTimerFire(nil);
FThread := TThread.CreateAnonymousThread(Execute);
FThread.FreeOnTerminate := False;
FThread.Start;
......@@ -70,22 +79,34 @@ end;
destructor TWsDataModel.Destroy;
begin
FListenerTimer.Enabled := False;
FListenerTimer.Free;
FThread.Terminate;
FStopEvent.SetEvent;
FThread.WaitFor;
FThread.Free;
FDb.Free;
FListenerDb.Free;
FStopEvent.Free;
inherited;
end;
procedure TWsDataModel.ListenerTimerFire(Sender: TObject);
begin
FListenerDb.StartUpdateListener;
end;
procedure TWsDataModel.Execute;
var
unitMapElapsedMs: Integer;
begin
unitMapElapsedMs := 0;
FDb := nil;
try
try
FDb := TApiDatabaseModule.Create(nil);
ConfigureQueryTimeouts;
while not TThread.CurrentThread.CheckTerminated do
begin
if FStopEvent.WaitFor(FIntervalMs) = wrSignaled then
......@@ -93,11 +114,13 @@ begin
Inc(unitMapElapsedMs, FIntervalMs);
if not FDb.StartUpdateListener then
if not FDb.EnsureConnected then
Continue;
if FDb.ConsumeCADUpdate then
if FListenerDb.ConsumeCADUpdate then
begin
// ADD THIS DELETE BEFORE DEPLOY
Logger.Log(1, 'WsDataModel: disupdate consumed; starting BroadcastAll');
BroadcastAll;
unitMapElapsedMs := 0;
end
......@@ -116,6 +139,10 @@ begin
on E: Exception do
Logger.Log(1, 'WsDataModel worker stopped: ' + E.Message);
end;
finally
FDb.Free;
FDb := nil;
end;
end;
procedure TWsDataModel.ConfigureQueryTimeouts;
......@@ -131,13 +158,31 @@ begin
end;
procedure TWsDataModel.BroadcastAll;
var
complaintMapJson, complaintListJson: string;
complaintMapCount, complaintListCount: Integer;
begin
complaintMapCount := -1;
complaintListCount := -1;
Logger.Log(5, 'WsDataModel: BroadcastAll initiated');
try FBroadcast(BuildBadgeCountsJson); except on E: Exception do Logger.Log(2, 'WsDataModel BADGE_COUNTS error: ' + E.Message); end;
try FBroadcast(BuildUnitMapJson); except on E: Exception do Logger.Log(2, 'WsDataModel UNIT_MAP error: ' + E.Message); end;
try FBroadcast(BuildComplaintMapJson); except on E: Exception do Logger.Log(2, 'WsDataModel COMPLAINT_MAP error: ' + E.Message); end;
try
complaintMapJson := BuildComplaintMapJson(complaintMapCount);
FBroadcast(complaintMapJson);
except
on E: Exception do Logger.Log(2, 'WsDataModel COMPLAINT_MAP error: ' + E.Message);
end;
try FBroadcast(BuildUnitListJson); except on E: Exception do Logger.Log(2, 'WsDataModel UNIT_LIST error: ' + E.Message); end;
try FBroadcast(BuildComplaintListJson); except on E: Exception do Logger.Log(2, 'WsDataModel COMPLAINT_LIST error: ' + E.Message); end;
try
complaintListJson := BuildComplaintListJson(complaintListCount);
FBroadcast(complaintListJson);
except
on E: Exception do Logger.Log(2, 'WsDataModel COMPLAINT_LIST error: ' + E.Message);
end;
Logger.Log(3, Format('WsDataModel: BroadcastAll complaint counts - map=%d, list=%d',
[complaintMapCount, complaintListCount]));
Logger.Log(5, 'WsDataModel: BroadcastAll end');
end;
......@@ -232,7 +277,7 @@ begin
end;
end;
function TWsDataModel.BuildComplaintMapJson: string;
function TWsDataModel.BuildComplaintMapJson(out AComplaintCount: Integer): string;
var
obj: TJSONObject;
data: TJSONArray;
......@@ -242,6 +287,7 @@ var
complaintId, unitStatus: string;
latestUpdate: TDateTime;
begin
AComplaintCount := 0;
obj := TJSONObject.Create;
data := TJSONArray.Create;
UnitsByComplaintMap := TDictionary<string, TJSONArray>.Create;
......@@ -330,8 +376,9 @@ begin
FDb.uqMapComplaints.Close;
end;
obj.AddPair('count', TJSONNumber.Create(data.Count));
obj.AddPair('returned', TJSONNumber.Create(data.Count));
AComplaintCount := data.Count;
obj.AddPair('count', TJSONNumber.Create(AComplaintCount));
obj.AddPair('returned', TJSONNumber.Create(AComplaintCount));
Result := obj.ToJSON;
finally
// Free the per-complaint unit arrays (not cloned copies — those are now owned by item)
......@@ -448,7 +495,7 @@ begin
end;
end;
function TWsDataModel.BuildComplaintListJson: string;
function TWsDataModel.BuildComplaintListJson(out AComplaintCount: Integer): string;
var
obj: TJSONObject;
data: TJSONArray;
......@@ -458,6 +505,7 @@ var
colorVal: Integer;
complaintNumber: string;
begin
AComplaintCount := 0;
obj := TJSONObject.Create;
data := TJSONArray.Create;
lastDistrict := '';
......@@ -509,6 +557,7 @@ begin
item.AddPair('Agency', FDb.uqComplaintListAGENCY.AsString);
item.AddPair('Priority', FDb.uqComplaintListPRIORITY.AsString);
item.AddPair('DispatchCodeDesc', FDb.uqComplaintListDISPATCH_CODE_DESC.AsString);
item.AddPair('pngName', FDb.uqComplaintListpngName.AsString);
item.AddPair('Address', FDb.uqComplaintListADDRESS.AsString);
item.AddPair('Business', FDb.uqComplaintListBUSINESS.AsString);
item.AddPair('CFSId', FDb.uqComplaintListCFSID.AsString);
......@@ -523,8 +572,9 @@ begin
end;
end;
obj.AddPair('count', TJSONNumber.Create(data.Count));
obj.AddPair('returned', TJSONNumber.Create(data.Count));
AComplaintCount := data.Count;
obj.AddPair('count', TJSONNumber.Create(AComplaintCount));
obj.AddPair('returned', TJSONNumber.Create(AComplaintCount));
obj.AddPair('data', data);
Result := obj.ToJSON;
finally
......
[Settings]
LogFileNum=130
webClientVersion=0.9.4.7
LogFileNum=148
webClientVersion=0.9.4.8
MemoLogLevel=3
FileLogLevel=4
[Twilio]
AccountSid=
AuthToken=
FromNumber=
AccountSid=AC717f06b3c10bbecff42487426e34fdb3
AuthToken=a0dec128c1b5a3231909ba81da8947b6
FromNumber=+18775346578
[Database]
--Server=192.168.91.129
......
AccountSid=AC717f06b3c10bbecff42487426e34fdb3
AuthToken=a0dec128c1b5a3231909ba81da8947b6
FromNumber=+18775346578
\ No newline at end of file
unit Auth.Service;
unit Auth.Service;
interface
......@@ -16,7 +16,7 @@ type
TOnLoginSuccess = reference to procedure;
TOnLoginError = reference to procedure(AMsg: string);
TOnCodeSentSuccess = reference to procedure(AVerificationToken: string);
TOnCodeSentSuccess = reference to procedure;
TOnBeginSuccess = reference to procedure(AChallenge, AChallengeToken: string);
TOnDeviceSuccess = reference to procedure;
TOnDeviceError = reference to procedure(AMsg: string);
......@@ -53,8 +53,8 @@ type
// Section: Device registration verification
procedure BeginRegistration(APhoneNumber: string;
ASuccess: TOnCodeSentSuccess; AError: TOnDeviceError);
procedure VerifyRegistrationCode(APhoneNumber, AVerificationCode,
AVerificationToken: string; ASuccess: TOnBeginSuccess;
procedure VerifyRegistrationCode(APhoneNumber, AVerificationCode: string;
ASuccess: TOnBeginSuccess;
AError: TOnDeviceError);
// Section: WebAuthn registration
procedure CompleteRegistration(APhoneNumber, ACredentialId,
......@@ -224,7 +224,7 @@ procedure TAuthService.BeginRegistration(APhoneNumber: string;
procedure OnLoad(Response: TXDataClientResponse);
var
resp: JS.TJSObject;
verificationToken, status: string;
status: string;
begin
resp := JS.TJSObject(Response.Result);
status := JS.toString(resp.Properties['status']);
......@@ -233,8 +233,7 @@ procedure TAuthService.BeginRegistration(APhoneNumber: string;
AError(JS.toString(resp.Properties['message']));
Exit;
end;
verificationToken := JS.toString(resp.Properties['verificationToken']);
ASuccess(verificationToken);
ASuccess;
end;
procedure OnError(Error: TXDataClientError);
......@@ -251,7 +250,7 @@ begin
end;
procedure TAuthService.VerifyRegistrationCode(APhoneNumber,
AVerificationCode, AVerificationToken: string; ASuccess: TOnBeginSuccess;
AVerificationCode: string; ASuccess: TOnBeginSuccess;
AError: TOnDeviceError);
procedure OnLoad(Response: TXDataClientResponse);
......@@ -279,7 +278,7 @@ procedure TAuthService.VerifyRegistrationCode(APhoneNumber,
begin
FClient.RawInvoke(
'IAuthService.VerifyRegistrationCode',
[APhoneNumber, AVerificationCode, AVerificationToken],
[APhoneNumber, AVerificationCode],
@OnLoad, @OnError
);
end;
......
......@@ -21,7 +21,7 @@ type
procedure VerifyVersion(SuccessProc: TSuccessProc);
public
property WsUrl: string read FWsUrl;
const clientVersion = '0.9.4.7';
const clientVersion = '0.9.4.8';
procedure InitApp(SuccessProc: TSuccessProc;
UnauthorizedAccessProc: TUnauthorizedAccessProc);
end;
......
......@@ -207,12 +207,12 @@ begin
btnLeft.onclick = function () {
bsModal.hide();
ConfirmProc(true); // user confirmed
ConfirmProc(false);
};
btnRight.onclick = function () {
bsModal.hide();
ConfirmProc(false); // user canceled
ConfirmProc(true);
};
bsModal = new bootstrap.Modal(modal, { keyboard: false });
......
......@@ -120,7 +120,7 @@ object FViewComplaintDetails: TFViewComplaintDetails
ElementHeaderClassName = 'table-light'
ElementTableClassName =
'table table-sm table-striped table-hover table-bordered mb-0 ali' +
'gn-middle'
'gn-middle small'
Footer.ButtonActiveElementClassName = 'btn btn-primary'
Footer.ButtonElementClassName = 'btn btn-light'
Footer.DropDownElementClassName = 'form-control'
......@@ -169,7 +169,7 @@ object FViewComplaintDetails: TFViewComplaintDetails
ElementHeaderClassName = 'table-light'
ElementTableClassName =
'table table-sm table-striped table-hover table-bordered mb-0 ali' +
'gn-middle'
'gn-middle small'
Footer.ButtonActiveElementClassName = 'btn btn-primary'
Footer.ButtonElementClassName = 'btn btn-light'
Footer.DropDownElementClassName = 'form-control'
......@@ -223,7 +223,7 @@ object FViewComplaintDetails: TFViewComplaintDetails
ElementHeaderClassName = 'table-light'
ElementTableClassName =
'table table-sm table-striped table-hover table-bordered mb-0 ali' +
'gn-middle'
'gn-middle small'
Footer.ButtonActiveElementClassName = 'btn btn-primary'
Footer.ButtonElementClassName = 'btn btn-light'
Footer.DropDownElementClassName = 'form-control'
......@@ -282,7 +282,7 @@ object FViewComplaintDetails: TFViewComplaintDetails
ElementHeaderClassName = 'table-light'
ElementTableClassName =
'table table-sm table-striped table-hover table-bordered mb-0 ali' +
'gn-middle'
'gn-middle small'
Footer.ButtonActiveElementClassName = 'btn btn-primary'
Footer.ButtonElementClassName = 'btn btn-light'
Footer.DropDownElementClassName = 'form-control'
......@@ -352,10 +352,11 @@ object FViewComplaintDetails: TFViewComplaintDetails
Style = lsListGroup
DataSource = wdsWarnings
ItemTemplate =
'<div class="card mb-2"> <div class="card-body py-2"> <div cl' +
'<div class="card mb-2 small"> <div class="card-body py-2"> <' +
'div cl' +
'ass="d-flex justify-content-between gap-2"> <div class="fw-' +
'semibold">(%CodeDesc%)</div> <div class="text-muted small t' +
'ext-end">(%ADDRESS%)</div> </div> <div class="small mt-1">' +
'semibold">(%CodeDesc%)</div> <div class="text-muted text-en' +
'd">(%ADDRESS%)</div> </div> <div class="mt-1">' +
'(%NOTES%)</div> </div></div>'
ListSource = wdsWarnings
end
......
......@@ -31,7 +31,7 @@
<div class="card-body py-2">
<table class="table table-sm mb-0 w-100">
<table class="table table-sm mb-0 w-100 small">
<tbody>
<tr>
<th scope="row" class="fw-semibold text-nowrap pe-2 w-auto">Priority:</th>
......
......@@ -45,7 +45,10 @@ object FViewComplaints: TFViewComplaints
'<div class="list-section-header small fw-semibold bg-secondary t' +
'ext-white rounded-1 px-2 mb-1">(%DistrictHeader%)</div><div clas' +
's="card border shadow-sm"><div class="card-body py-2 px-3 d-flex' +
' gap-2"><div class="flex-grow-1"><div class="fw-bold text-upperc' +
' gap-2"><img src="assets/markers/(%pngName%)" class="flex-shrink' +
'-0 align-self-center" style="width:28px;height:36px;object-fit:c' +
'ontain" alt=""><div class="flex-grow-1"><div class="fw-bold text' +
'-upperc' +
'ase small">(%Priority%): (%DispatchCodeDesc%)</div><div class="s' +
'mall">(%Address%)</div><div class="small d-none complaint-busine' +
'ss" data-business="(%Business%)">(%Business%)</div><div class="s' +
......@@ -82,6 +85,10 @@ object FViewComplaints: TFViewComplaints
object xdwdsComplaintsDispatchCodeDesc: TStringField
FieldName = 'DispatchCodeDesc'
end
object xdwdsComplaintspngName: TStringField
FieldName = 'pngName'
Size = 80
end
object xdwdsComplaintsAddress: TStringField
FieldName = 'Address'
end
......
......@@ -19,6 +19,7 @@ type
xdwdsComplaintsAgency: TStringField;
xdwdsComplaintsPriority: TStringField;
xdwdsComplaintsDispatchCodeDesc: TStringField;
xdwdsComplaintspngName: TStringField;
xdwdsComplaintsAddress: TStringField;
xdwdsComplaintsStatus: TStringField;
xdwdsComplaintsDispatchDistrict: TStringField;
......
......@@ -51,11 +51,14 @@
<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:180px;">Actions</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">
......@@ -66,4 +69,21 @@
</p>
</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>
......@@ -26,11 +26,15 @@ type
procedure HideNotification;
procedure ClearTable;
procedure AddDeviceRow(const ACredentialId, AName, APhoneNumber, AUsername,
AUserAgent, ARegisteredAt, AStatus: string);
AUserAgent, ARegisteredAt, AStatus, AVerificationCode: string;
AVerificationCodeActive: Boolean);
[async] procedure LoadDevices;
[async] procedure RevokeDevice(const ACredentialId, AName: string);
[async] procedure UnrevokeDevice(const ACredentialId, AName: string);
procedure ConfirmDeleteDevice(ACredentialId, APhoneNumber,
AName: string; AIsPending: Boolean);
[async] procedure DeletePendingDevice(const APhoneNumber, AName: string);
[async] procedure DeleteDevice(ACredentialId, AName: string);
[async] procedure SendAppLink(const APhoneNumber, AName: string);
[async] procedure AddPendingDevice;
[async] procedure UpdateDeviceUsername(const ACredentialId, AUsername: string);
......@@ -43,7 +47,8 @@ var
implementation
uses
Auth.Service;
Auth.Service,
Utils;
{$R *.dfm}
......@@ -130,10 +135,12 @@ begin
end;
procedure TFViewDeviceManager.AddDeviceRow(const ACredentialId, AName,
APhoneNumber, AUsername, AUserAgent, ARegisteredAt, AStatus: string);
APhoneNumber, AUsername, AUserAgent, ARegisteredAt, AStatus,
AVerificationCode: string; AVerificationCodeActive: Boolean);
var
tbody, tr, tdName, tdPhone, tdUser, tdAgent, tdReg, tdStatus, tdAction: TJSHTMLElement;
btn, btnSend, inp, saveBtn: TJSHTMLElement;
tbody, tr, tdName, tdPhone, tdCode, tdUser, tdAgent, tdReg, tdStatus,
tdAccess, tdDelete, tdSend: TJSHTMLElement;
btn, btnSend, inp: TJSHTMLElement;
displayDate, displayPhone: string;
isPending, isRevoked, isActive: Boolean;
begin
......@@ -162,24 +169,40 @@ begin
tdPhone.innerText := displayPhone;
tr.appendChild(tdPhone);
// Section: Verification code
tdCode := TJSHTMLElement(document.createElement('td'));
if isPending and (AVerificationCode <> '') then
begin
if AVerificationCodeActive then
tdCode.innerHTML := '<span class="font-monospace">' + AVerificationCode +
'</span> <span class="badge bg-success">Active</span>'
else
tdCode.innerHTML := '<span class="font-monospace">' + AVerificationCode +
'</span> <span class="badge bg-secondary">Inactive</span>';
end
else if isPending then
tdCode.innerHTML := '<em class="text-muted small">Not sent</em>'
else
tdCode.innerHTML := '<em class="text-muted small">—</em>';
tr.appendChild(tdCode);
// Username — editable for active devices
tdUser := TJSHTMLElement(document.createElement('td'));
if isActive then
begin
tdUser.innerHTML :=
'<div class="d-flex gap-1 align-items-center">' +
'<div>' +
'<input type="text" class="form-control form-control-sm devmgr-username-input" ' +
'style="max-width:110px;" value="' + AUsername + '" ' +
'placeholder="username">' +
'<button class="btn btn-outline-secondary btn-sm devmgr-username-save">Save</button>' +
'</div>';
inp := TJSHTMLElement(tdUser.querySelector('.devmgr-username-input'));
saveBtn := TJSHTMLElement(tdUser.querySelector('.devmgr-username-save'));
if Assigned(saveBtn) and Assigned(inp) then
saveBtn.addEventListener('click', procedure(Event: TJSMouseEvent)
if Assigned(inp) then
inp.addEventListener('change', TJSRawEventHandler(
procedure(Event: TJSEvent)
begin
UpdateDeviceUsername(ACredentialId, string(TJSHTMLInputElement(inp).value));
end);
end));
end
else if AUsername <> '' then
tdUser.innerText := AUsername
......@@ -223,22 +246,9 @@ begin
tdStatus.innerHTML := '<span class="badge bg-success">Active</span>';
tr.appendChild(tdStatus);
// Actions
tdAction := TJSHTMLElement(document.createElement('td'));
tdAction.className := 'd-flex gap-1 flex-wrap';
if isPending then
begin
btn := TJSHTMLElement(document.createElement('button'));
btn.className := 'btn btn-outline-danger btn-sm';
btn.innerText := 'Cancel';
btn.addEventListener('click', procedure(Event: TJSMouseEvent)
begin
DeletePendingDevice(APhoneNumber, AName);
end);
tdAction.appendChild(btn);
end
else if isRevoked then
// Section: Access action
tdAccess := TJSHTMLElement(document.createElement('td'));
if isRevoked then
begin
btn := TJSHTMLElement(document.createElement('button'));
btn.className := 'btn btn-outline-success btn-sm';
......@@ -247,9 +257,9 @@ begin
begin
UnrevokeDevice(ACredentialId, AName);
end);
tdAction.appendChild(btn);
tdAccess.appendChild(btn);
end
else
else if isActive then
begin
btn := TJSHTMLElement(document.createElement('button'));
btn.className := 'btn btn-danger btn-sm';
......@@ -258,10 +268,26 @@ begin
begin
RevokeDevice(ACredentialId, AName);
end);
tdAction.appendChild(btn);
end;
tdAccess.appendChild(btn);
end
else
tdAccess.innerHTML := '<span class="text-muted">—</span>';
tr.appendChild(tdAccess);
// Send App Link for pending and active rows with a phone number
// Section: Delete action
tdDelete := TJSHTMLElement(document.createElement('td'));
btn := TJSHTMLElement(document.createElement('button'));
btn.className := 'btn btn-outline-danger btn-sm';
btn.innerText := 'Delete';
btn.addEventListener('click', procedure(Event: TJSMouseEvent)
begin
ConfirmDeleteDevice(ACredentialId, APhoneNumber, AName, isPending);
end);
tdDelete.appendChild(btn);
tr.appendChild(tdDelete);
// Section: Send link action
tdSend := TJSHTMLElement(document.createElement('td'));
if (not isRevoked) and (APhoneNumber <> '') then
begin
btnSend := TJSHTMLElement(document.createElement('button'));
......@@ -271,13 +297,11 @@ begin
begin
SendAppLink(APhoneNumber, AName);
end);
tdAction.appendChild(btnSend);
end;
if tdAction.children.length = 0 then
tdAction.innerText := '—';
tr.appendChild(tdAction);
tdSend.appendChild(btnSend);
end
else
tdSend.innerHTML := '<span class="text-muted">—</span>';
tr.appendChild(tdSend);
tbody.appendChild(tr);
end;
......@@ -317,7 +341,9 @@ begin
string(item['username']),
string(item['user_agent']),
string(item['registered_at']),
string(item['status'])
string(item['status']),
string(item['verification_code']),
JS.toBoolean(item['verification_code_active'])
);
end;
......@@ -351,6 +377,25 @@ begin
end;
end;
procedure TFViewDeviceManager.ConfirmDeleteDevice(ACredentialId,
APhoneNumber, AName: string; AIsPending: Boolean);
begin
ShowConfirmationModal(
'Are you sure you want to permanently delete device "' + AName +
'"? This cannot be undone.',
'Cancel', 'Delete',
procedure(Confirmed: Boolean)
begin
if not Confirmed then
Exit;
if AIsPending then
DeletePendingDevice(APhoneNumber, AName)
else
DeleteDevice(ACredentialId, AName);
end);
end;
procedure TFViewDeviceManager.DeletePendingDevice(const APhoneNumber, AName: string);
var
resp: TXDataClientResponse;
......@@ -375,6 +420,30 @@ begin
end;
end;
procedure TFViewDeviceManager.DeleteDevice(ACredentialId, AName: string);
var
resp: TXDataClientResponse;
res: TJSObject;
status: string;
begin
try
resp := await(XDataWebClient.RawInvokeAsync('IApiService.DeleteDevice', [ACredentialId]));
res := TJSObject(resp.Result);
status := string(res['status']);
if status = 'ok' then
begin
ShowNotification('Device "' + AName + '" has been deleted.', False);
LoadDevices;
end
else
ShowNotification(string(res['message']));
except
on E: Exception do
ShowNotification('Delete failed: ' + E.Message);
end;
end;
procedure TFViewDeviceManager.SendAppLink(const APhoneNumber, AName: string);
var
resp: TXDataClientResponse;
......
......@@ -25,7 +25,6 @@ type
procedure WebFormCreate(Sender: TObject);
private
FRegistrationProc: TSuccessProc;
FVerificationToken: string;
procedure ShowNotification(const AMsg: string; AIsError: Boolean = True);
procedure HideNotification;
procedure SetBusy(ABusy: Boolean);
......@@ -60,7 +59,6 @@ var
userAgent: string;
begin
HideNotification;
FVerificationToken := '';
pnlVerification.ElementHandle.classList.add('d-none');
userAgent := '';
asm
......@@ -116,10 +114,9 @@ procedure TFViewDeviceRegistration.btnRegisterClick(Sender: TObject);
var
phoneNumber: string;
procedure OnCodeSent(AVerificationToken: string);
procedure OnCodeSent;
begin
SetBusy(False);
FVerificationToken := AVerificationToken;
edtPhoneNumber.Enabled := False;
pnlVerification.ElementHandle.classList.remove('d-none');
edtVerificationCode.SetFocus;
......@@ -182,7 +179,7 @@ begin
SetVerifyBusy(True);
HideNotification;
AuthService.VerifyRegistrationCode(phoneNumber, verificationCode,
FVerificationToken, @OnVerified, @OnVerifyError);
@OnVerified, @OnVerifyError);
end;
procedure TFViewDeviceRegistration.DoWebAuthnCreate(
......
......@@ -79,42 +79,13 @@ var
credId: string;
procedure OnDeviceUser(AUsername, AAgency: string);
procedure OnAutoLoginOK;
begin
FLoginProc;
end;
procedure OnAutoLoginError(AMsg: string);
begin
asm
var autoSection = document.getElementById('view.login.autosection');
var manualSection = document.getElementById('view.login.manualsection');
if (autoSection) autoSection.classList.add('d-none');
if (manualSection) manualSection.classList.remove('d-none');
end;
ShowNotification(AMsg);
end;
procedure OnBeginForSimpleOK(AChallenge, AChallengeToken: string);
begin
AuthService.LoginDeviceKey(credId, AChallengeToken, @OnAutoLoginOK, @OnAutoLoginError);
end;
procedure OnBeginForSimpleError(AMsg: string);
begin
asm
var autoSection = document.getElementById('view.login.autosection');
var manualSection = document.getElementById('view.login.manualsection');
if (autoSection) autoSection.classList.add('d-none');
if (manualSection) manualSection.classList.remove('d-none');
end;
ShowNotification('Login Error: ' + AMsg);
end;
begin
FAutoUsername := AUsername;
FAutoAgency := AAgency;
if AuthService.IsSimpleKey then
Exit;
if AUsername <> '' then
begin
asm
......@@ -135,16 +106,6 @@ var
});
}
end;
if AuthService.IsSimpleKey then
begin
// Hide passkey button — simple-key auto-login needs no hardware interaction
asm
var btn = document.getElementById('view.login.btnpasskeylogin');
if (btn) btn.style.display = 'none';
end;
AuthService.BeginAuthentication(credId, @OnBeginForSimpleOK, @OnBeginForSimpleError);
end;
end;
end;
......
......@@ -33,10 +33,12 @@
<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>
<button id="btn_devices" type="button" class="btn btn-outline-light btn-sm d-none">Devices</button>
</div>
</div>
</nav>
......
......@@ -133,10 +133,15 @@ const
procedure TFViewMain.WebFormCreate(Sender: TObject);
var
userName: string;
el: TJSElement;
begin
userName := JS.toString(AuthService.TokenPayload.Properties['user_name']);
lblUsername.Caption := ' ' + userName.ToLower + ' ';
el := Document.getElementById('view.main.version');
if Assigned(el) then
TJSHtmlElement(el).innerText := 'Version ' + TDMConnection.clientVersion;
FChildForm := nil;
FDetailsForm := nil;
FArchiveForm := nil;
......
......@@ -15,7 +15,7 @@ object FViewUnitDetails: TFViewUnitDetails
ElementHeaderClassName = 'table-light'
ElementTableClassName =
'table table-sm table-striped table-hover table-bordered mb-0 ali' +
'gn-middle'
'gn-middle small'
Footer.ButtonActiveElementClassName = 'btn btn-primary'
Footer.ButtonElementClassName = 'btn btn-light'
Footer.DropDownElementClassName = 'form-control'
......
......@@ -30,7 +30,7 @@
<div class="card border-0 shadow-sm mb-2">
<div class="card-body py-2">
<table class="table table-sm mb-0 w-100">
<table class="table table-sm mb-0 w-100 small">
<tbody>
<tr>
<th scope="row" class="fw-semibold text-nowrap pe-2 w-auto">Car:</th>
......
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