Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
E
emiMobile
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Mac Stephens
emiMobile
Commits
27821f79
Commit
27821f79
authored
Sep 02, 2026
by
Michael Brachmann
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
app links and phone number
parent
45db3678
Show whitespace changes
Inline
Side-by-side
Showing
13 changed files
with
579 additions
and
142 deletions
+579
-142
Api.Service.pas
emiMobileServer/Source/Api.Service.pas
+3
-2
Api.ServiceImpl.pas
emiMobileServer/Source/Api.ServiceImpl.pas
+167
-18
Auth.Service.pas
emiMobileServer/Source/Auth.Service.pas
+5
-4
Auth.ServiceImpl.pas
emiMobileServer/Source/Auth.ServiceImpl.pas
+58
-19
Common.Config.pas
emiMobileServer/Source/Common.Config.pas
+7
-0
device_registrations_phone.sql
emiMobileServer/device_registrations_phone.sql
+118
-0
Auth.Service.pas
webEMIMobile/Auth.Service.pas
+6
-6
View.DeviceManager.dfm
webEMIMobile/View.DeviceManager.dfm
+15
-4
View.DeviceManager.html
webEMIMobile/View.DeviceManager.html
+13
-7
View.DeviceManager.pas
webEMIMobile/View.DeviceManager.pas
+146
-56
View.DeviceRegistration.dfm
webEMIMobile/View.DeviceRegistration.dfm
+3
-3
View.DeviceRegistration.html
webEMIMobile/View.DeviceRegistration.html
+6
-6
View.DeviceRegistration.pas
webEMIMobile/View.DeviceRegistration.pas
+32
-17
No files found.
emiMobileServer/Source/Api.Service.pas
View file @
27821f79
...
@@ -34,8 +34,9 @@ type
...
@@ -34,8 +34,9 @@ type
// Device management — requires valid JWT; caller must also have user_admin = true
// Device management — requires valid JWT; caller must also have user_admin = true
[
HttpGet
]
function
GetDeviceList
:
TDeviceList
;
[
HttpGet
]
function
GetDeviceList
:
TDeviceList
;
function
RevokeDevice
(
const
CredentialId
:
string
):
TJSONObject
;
function
RevokeDevice
(
const
CredentialId
:
string
):
TJSONObject
;
function
AddPendingDevice
(
const
DeviceName
:
string
):
TJSONObject
;
function
AddPendingDevice
(
const
DeviceName
,
PhoneNumber
:
string
):
TJSONObject
;
function
DeletePendingDevice
(
const
DeviceName
:
string
):
TJSONObject
;
function
DeletePendingDevice
(
const
PhoneNumber
:
string
):
TJSONObject
;
function
SendAppLink
(
const
PhoneNumber
:
string
):
TJSONObject
;
end
;
end
;
implementation
implementation
...
...
emiMobileServer/Source/Api.ServiceImpl.pas
View file @
27821f79
...
@@ -7,7 +7,8 @@ uses
...
@@ -7,7 +7,8 @@ uses
System
.
SysUtils
,
System
.
Generics
.
Collections
,
XData
.
Sys
.
Exceptions
,
System
.
StrUtils
,
System
.
SysUtils
,
System
.
Generics
.
Collections
,
XData
.
Sys
.
Exceptions
,
System
.
StrUtils
,
System
.
Hash
,
System
.
Classes
,
Common
.
Logging
,
System
.
JSON
,
Api
.
Service
,
VCL
.
Forms
,
System
.
Hash
,
System
.
Classes
,
Common
.
Logging
,
System
.
JSON
,
Api
.
Service
,
VCL
.
Forms
,
Auth
.
Service
,
Uni
,
UniProvider
,
PostgreSQLUniProvider
,
Common
.
Ini
,
Auth
.
Service
,
Uni
,
UniProvider
,
PostgreSQLUniProvider
,
Common
.
Ini
,
Sparkle
.
HttpServer
.
Context
,
System
.
NetEncoding
;
Sparkle
.
HttpServer
.
Context
,
System
.
NetEncoding
,
System
.
Net
.
HttpClient
,
System
.
Net
.
URLClient
,
System
.
Classes
;
type
type
...
@@ -37,8 +38,9 @@ type
...
@@ -37,8 +38,9 @@ type
function
GetComplaintMemos
(
const
CfsId
:
string
):
TJSONObject
;
function
GetComplaintMemos
(
const
CfsId
:
string
):
TJSONObject
;
function
GetDeviceList
:
TDeviceList
;
function
GetDeviceList
:
TDeviceList
;
function
RevokeDevice
(
const
CredentialId
:
string
):
TJSONObject
;
function
RevokeDevice
(
const
CredentialId
:
string
):
TJSONObject
;
function
AddPendingDevice
(
const
DeviceName
:
string
):
TJSONObject
;
function
AddPendingDevice
(
const
DeviceName
,
PhoneNumber
:
string
):
TJSONObject
;
function
DeletePendingDevice
(
const
DeviceName
:
string
):
TJSONObject
;
function
DeletePendingDevice
(
const
PhoneNumber
:
string
):
TJSONObject
;
function
SendAppLink
(
const
PhoneNumber
:
string
):
TJSONObject
;
end
;
end
;
implementation
implementation
...
@@ -1328,7 +1330,7 @@ begin
...
@@ -1328,7 +1330,7 @@ begin
try
try
q
.
Connection
:=
conn
;
q
.
Connection
:=
conn
;
q
.
SQL
.
Text
:=
q
.
SQL
.
Text
:=
'SELECT id, credential_id, device_name, user_agent, '
+
'SELECT id, credential_id, device_name,
phone_number,
user_agent, '
+
' registered_at, revoked_at, revoked_by, status '
+
' registered_at, revoked_at, revoked_by, status '
+
'FROM lems.device_registrations '
+
'FROM lems.device_registrations '
+
'ORDER BY registered_at DESC'
;
'ORDER BY registered_at DESC'
;
...
@@ -1342,6 +1344,7 @@ begin
...
@@ -1342,6 +1344,7 @@ begin
item
.
id
:=
q
.
FieldByName
(
'id'
).
AsInteger
;
item
.
id
:=
q
.
FieldByName
(
'id'
).
AsInteger
;
item
.
credential_id
:=
q
.
FieldByName
(
'credential_id'
).
AsString
;
item
.
credential_id
:=
q
.
FieldByName
(
'credential_id'
).
AsString
;
item
.
device_name
:=
q
.
FieldByName
(
'device_name'
).
AsString
;
item
.
device_name
:=
q
.
FieldByName
(
'device_name'
).
AsString
;
item
.
phone_number
:=
q
.
FieldByName
(
'phone_number'
).
AsString
;
item
.
user_agent
:=
q
.
FieldByName
(
'user_agent'
).
AsString
;
item
.
user_agent
:=
q
.
FieldByName
(
'user_agent'
).
AsString
;
item
.
registered_at
:=
q
.
FieldByName
(
'registered_at'
).
AsString
;
item
.
registered_at
:=
q
.
FieldByName
(
'registered_at'
).
AsString
;
if
q
.
FieldByName
(
'revoked_at'
).
IsNull
then
if
q
.
FieldByName
(
'revoked_at'
).
IsNull
then
...
@@ -1457,10 +1460,27 @@ begin
...
@@ -1457,10 +1460,27 @@ begin
end
;
end
;
function
TApiService
.
AddPendingDevice
(
const
DeviceName
:
string
):
TJSONObject
;
function
NormalizePhoneE164Api
(
const
APhone
:
string
):
string
;
var
digits
:
string
;
i
:
Integer
;
begin
digits
:=
''
;
for
i
:=
1
to
Length
(
APhone
)
do
if
CharInSet
(
APhone
[
i
],
[
'0'
..
'9'
])
then
digits
:=
digits
+
APhone
[
i
];
if
(
Length
(
digits
)
=
11
)
and
(
digits
[
1
]
=
'1'
)
then
Delete
(
digits
,
1
,
1
);
if
Length
(
digits
)
<>
10
then
raise
Exception
.
Create
(
'Invalid phone number. Enter a 10-digit US number.'
);
Result
:=
'+1'
+
digits
;
end
;
function
TApiService
.
AddPendingDevice
(
const
DeviceName
,
PhoneNumber
:
string
):
TJSONObject
;
var
var
conn
:
TUniConnection
;
conn
:
TUniConnection
;
q
:
TUniQuery
;
q
:
TUniQuery
;
normalizedPhone
:
string
;
begin
begin
RequireAdmin
;
RequireAdmin
;
...
@@ -1474,34 +1494,46 @@ begin
...
@@ -1474,34 +1494,46 @@ begin
Exit
;
Exit
;
end
;
end
;
try
normalizedPhone
:=
NormalizePhoneE164Api
(
PhoneNumber
);
except
on
E
:
Exception
do
begin
Result
.
AddPair
(
'status'
,
'error'
);
Result
.
AddPair
(
'message'
,
E
.
Message
);
Exit
;
end
;
end
;
conn
:=
OpenLemsConnection
;
conn
:=
OpenLemsConnection
;
try
try
q
:=
TUniQuery
.
Create
(
nil
);
q
:=
TUniQuery
.
Create
(
nil
);
try
try
q
.
Connection
:=
conn
;
q
.
Connection
:=
conn
;
// Reject if a pending entry with this
nam
e already exists
// Reject if a pending entry with this
phon
e already exists
q
.
SQL
.
Text
:=
q
.
SQL
.
Text
:=
'SELECT id FROM lems.device_registrations '
+
'SELECT id FROM lems.device_registrations '
+
'WHERE
LOWER(device_name) = LOWER(:NAME)
AND status = ''pending'''
;
'WHERE
phone_number = :PHONE
AND status = ''pending'''
;
q
.
ParamByName
(
'
NAME'
).
AsString
:=
Trim
(
DeviceName
)
;
q
.
ParamByName
(
'
PHONE'
).
AsString
:=
normalizedPhone
;
q
.
Open
;
q
.
Open
;
if
not
q
.
IsEmpty
then
if
not
q
.
IsEmpty
then
begin
begin
q
.
Close
;
q
.
Close
;
Result
.
AddPair
(
'status'
,
'error'
);
Result
.
AddPair
(
'status'
,
'error'
);
Result
.
AddPair
(
'message'
,
'A pending entry for that
device name
already exists.'
);
Result
.
AddPair
(
'message'
,
'A pending entry for that
phone number
already exists.'
);
Exit
;
Exit
;
end
;
end
;
q
.
Close
;
q
.
Close
;
q
.
SQL
.
Text
:=
q
.
SQL
.
Text
:=
'INSERT INTO lems.device_registrations (device_name, status) '
+
'INSERT INTO lems.device_registrations (device_name,
phone_number,
status) '
+
'VALUES (:NAME, ''pending'')'
;
'VALUES (:NAME,
:PHONE,
''pending'')'
;
q
.
ParamByName
(
'NAME'
).
AsString
:=
Trim
(
DeviceName
);
q
.
ParamByName
(
'NAME'
).
AsString
:=
Trim
(
DeviceName
);
q
.
ParamByName
(
'PHONE'
).
AsString
:=
normalizedPhone
;
q
.
ExecSQL
;
q
.
ExecSQL
;
Logger
.
Log
(
2
,
'TApiService.AddPendingDevice - added "'
+
Trim
(
DeviceName
)
+
'"
'
);
Logger
.
Log
(
2
,
'TApiService.AddPendingDevice - added "'
+
Trim
(
DeviceName
)
+
'"
phone: '
+
normalizedPhone
);
Result
.
AddPair
(
'status'
,
'ok'
);
Result
.
AddPair
(
'status'
,
'ok'
);
Result
.
AddPair
(
'message'
,
'Pending device added.'
);
Result
.
AddPair
(
'message'
,
'Pending device added.'
);
finally
finally
...
@@ -1512,22 +1544,27 @@ begin
...
@@ -1512,22 +1544,27 @@ begin
end
;
end
;
end
;
end
;
function
TApiService
.
DeletePendingDevice
(
const
DeviceName
:
string
):
TJSONObject
;
function
TApiService
.
DeletePendingDevice
(
const
PhoneNumber
:
string
):
TJSONObject
;
var
var
conn
:
TUniConnection
;
conn
:
TUniConnection
;
q
:
TUniQuery
;
q
:
TUniQuery
;
normalizedPhone
:
string
;
begin
begin
RequireAdmin
;
RequireAdmin
;
Result
:=
TJSONObject
.
Create
;
Result
:=
TJSONObject
.
Create
;
TXDataOperationContext
.
Current
.
Handler
.
ManagedObjects
.
Add
(
Result
);
TXDataOperationContext
.
Current
.
Handler
.
ManagedObjects
.
Add
(
Result
);
if
Trim
(
DeviceName
)
=
''
then
try
normalizedPhone
:=
NormalizePhoneE164Api
(
PhoneNumber
);
except
on
E
:
Exception
do
begin
begin
Result
.
AddPair
(
'status'
,
'error'
);
Result
.
AddPair
(
'status'
,
'error'
);
Result
.
AddPair
(
'message'
,
'Device name is required.'
);
Result
.
AddPair
(
'message'
,
E
.
Message
);
Exit
;
Exit
;
end
;
end
;
end
;
conn
:=
OpenLemsConnection
;
conn
:=
OpenLemsConnection
;
try
try
...
@@ -1536,13 +1573,13 @@ begin
...
@@ -1536,13 +1573,13 @@ begin
q
.
Connection
:=
conn
;
q
.
Connection
:=
conn
;
q
.
SQL
.
Text
:=
q
.
SQL
.
Text
:=
'DELETE FROM lems.device_registrations '
+
'DELETE FROM lems.device_registrations '
+
'WHERE
LOWER(device_name) = LOWER(:NAME)
AND status = ''pending'''
;
'WHERE
phone_number = :PHONE
AND status = ''pending'''
;
q
.
ParamByName
(
'
NAME'
).
AsString
:=
Trim
(
DeviceName
)
;
q
.
ParamByName
(
'
PHONE'
).
AsString
:=
normalizedPhone
;
q
.
ExecSQL
;
q
.
ExecSQL
;
if
q
.
RowsAffected
>
0
then
if
q
.
RowsAffected
>
0
then
begin
begin
Logger
.
Log
(
2
,
'TApiService.DeletePendingDevice - deleted
"'
+
Trim
(
DeviceName
)
+
'"'
);
Logger
.
Log
(
2
,
'TApiService.DeletePendingDevice - deleted
phone: '
+
normalizedPhone
);
Result
.
AddPair
(
'status'
,
'ok'
);
Result
.
AddPair
(
'status'
,
'ok'
);
Result
.
AddPair
(
'message'
,
'Pending device removed.'
);
Result
.
AddPair
(
'message'
,
'Pending device removed.'
);
end
end
...
@@ -1559,6 +1596,118 @@ begin
...
@@ -1559,6 +1596,118 @@ begin
end
;
end
;
end
;
end
;
function
TApiService
.
SendAppLink
(
const
PhoneNumber
:
string
):
TJSONObject
;
var
conn
:
TUniConnection
;
q
:
TUniQuery
;
normalizedPhone
,
redeemCode
,
linkUrl
,
msgBody
:
string
;
httpClient
:
THTTPClient
;
bodyStream
:
TStringStream
;
formData
,
authStr
:
string
;
response
:
IHTTPResponse
;
begin
RequireAdmin
;
Result
:=
TJSONObject
.
Create
;
TXDataOperationContext
.
Current
.
Handler
.
ManagedObjects
.
Add
(
Result
);
try
normalizedPhone
:=
NormalizePhoneE164Api
(
PhoneNumber
);
except
on
E
:
Exception
do
begin
Result
.
AddPair
(
'status'
,
'error'
);
Result
.
AddPair
(
'message'
,
E
.
Message
);
Exit
;
end
;
end
;
if
(
ServerConfig
.
twilioAccountSid
=
''
)
or
(
ServerConfig
.
twilioAuthToken
=
''
)
or
(
ServerConfig
.
twilioFromNumber
=
''
)
then
begin
Result
.
AddPair
(
'status'
,
'error'
);
Result
.
AddPair
(
'message'
,
'Twilio is not configured on the server.'
);
Exit
;
end
;
// Pick an unused redeem code (SKIP LOCKED for concurrent safety)
redeemCode
:=
''
;
conn
:=
OpenLemsConnection
;
try
q
:=
TUniQuery
.
Create
(
nil
);
try
q
.
Connection
:=
conn
;
conn
.
StartTransaction
;
try
q
.
SQL
.
Text
:=
'SELECT code FROM lems.redeem_codes '
+
'WHERE used_at IS NULL '
+
'ORDER BY id '
+
'LIMIT 1 FOR UPDATE SKIP LOCKED'
;
q
.
Open
;
if
q
.
IsEmpty
then
begin
q
.
Close
;
conn
.
Rollback
;
Result
.
AddPair
(
'status'
,
'error'
);
Result
.
AddPair
(
'message'
,
'No unused redeem codes available.'
);
Exit
;
end
;
redeemCode
:=
q
.
FieldByName
(
'code'
).
AsString
;
q
.
Close
;
q
.
SQL
.
Text
:=
'UPDATE lems.redeem_codes SET used_at = NOW(), used_for = :PHONE '
+
'WHERE code = :CODE'
;
q
.
ParamByName
(
'PHONE'
).
AsString
:=
normalizedPhone
;
q
.
ParamByName
(
'CODE'
).
AsString
:=
redeemCode
;
q
.
ExecSQL
;
conn
.
Commit
;
except
conn
.
Rollback
;
raise
;
end
;
finally
q
.
Free
;
end
;
finally
conn
.
Free
;
end
;
// Build and send Twilio SMS
linkUrl
:=
'https://apps.apple.com/redeem?code='
+
redeemCode
+
'&ctx=apps'
;
msgBody
:=
'Your emiMobile app download link: '
+
linkUrl
;
authStr
:=
TNetEncoding
.
Base64
.
Encode
(
ServerConfig
.
twilioAccountSid
+
':'
+
ServerConfig
.
twilioAuthToken
);
formData
:=
'From='
+
TNetEncoding
.
URL
.
Encode
(
ServerConfig
.
twilioFromNumber
)
+
'&To='
+
TNetEncoding
.
URL
.
Encode
(
normalizedPhone
)
+
'&Body='
+
TNetEncoding
.
URL
.
Encode
(
msgBody
);
httpClient
:=
THTTPClient
.
Create
;
try
httpClient
.
ContentType
:=
'application/x-www-form-urlencoded'
;
httpClient
.
CustomHeaders
[
'Authorization'
]
:=
'Basic '
+
authStr
;
bodyStream
:=
TStringStream
.
Create
(
formData
,
TEncoding
.
UTF8
);
try
response
:=
httpClient
.
Post
(
'https://api.twilio.com/2010-04-01/Accounts/'
+
ServerConfig
.
twilioAccountSid
+
'/Messages.json'
,
bodyStream
);
if
response
.
StatusCode
>=
300
then
raise
Exception
.
CreateFmt
(
'Twilio error %d: %s'
,
[
response
.
StatusCode
,
response
.
ContentAsString
]);
finally
bodyStream
.
Free
;
end
;
finally
httpClient
.
Free
;
end
;
Logger
.
Log
(
2
,
Format
(
'TApiService.SendAppLink - sent to %s code %s'
,
[
normalizedPhone
,
redeemCode
]));
Result
.
AddPair
(
'status'
,
'ok'
);
Result
.
AddPair
(
'message'
,
'App link sent via SMS.'
);
Result
.
AddPair
(
'code'
,
redeemCode
);
end
;
initialization
initialization
RegisterServiceType
(
TApiService
);
RegisterServiceType
(
TApiService
);
...
...
emiMobileServer/Source/Auth.Service.pas
View file @
27821f79
...
@@ -45,6 +45,7 @@ type
...
@@ -45,6 +45,7 @@ type
id
:
Integer
;
id
:
Integer
;
credential_id
:
string
;
credential_id
:
string
;
device_name
:
string
;
device_name
:
string
;
phone_number
:
string
;
user_agent
:
string
;
user_agent
:
string
;
registered_at
:
string
;
registered_at
:
string
;
revoked_at
:
string
;
revoked_at
:
string
;
...
@@ -71,10 +72,10 @@ type
...
@@ -71,10 +72,10 @@ type
[
HttpGet
]
function
GetAgencyConfigList
:
TAgencyConfigList
;
[
HttpGet
]
function
GetAgencyConfigList
:
TAgencyConfigList
;
function
VerifyVersion
(
ClientVersion
:
string
):
TJSONObject
;
function
VerifyVersion
(
ClientVersion
:
string
):
TJSONObject
;
// WebAuthn registration — step 1: server
returns challenge + rpId/rpNam
e
// WebAuthn registration — step 1: server
checks phone pre-auth, returns challeng
e
function
BeginRegistration
(
const
DeviceName
:
string
):
TJSONObject
;
function
BeginRegistration
(
const
PhoneNumber
:
string
):
TJSONObject
;
// WebAuthn registration — step 2: client submits credential, server verifies +
stores
// WebAuthn registration — step 2: client submits credential, server verifies +
activates pending row
function
CompleteRegistration
(
const
DeviceName
,
CredentialId
,
function
CompleteRegistration
(
const
PhoneNumber
,
CredentialId
,
AttestationObject
,
ClientDataJSON
,
AttestationObject
,
ClientDataJSON
,
ChallengeToken
:
string
):
TJSONObject
;
ChallengeToken
:
string
):
TJSONObject
;
// WebAuthn authentication challenge — called before Login
// WebAuthn authentication challenge — called before Login
...
...
emiMobileServer/Source/Auth.ServiceImpl.pas
View file @
27821f79
...
@@ -36,8 +36,8 @@ type
...
@@ -36,8 +36,8 @@ type
signature
:
string
):
string
;
signature
:
string
):
string
;
function
GetAgencieslist
():
TAgenciesList
;
function
GetAgencieslist
():
TAgenciesList
;
function
GetAgencyConfiglist
:
TAgencyConfigList
;
function
GetAgencyConfiglist
:
TAgencyConfigList
;
function
BeginRegistration
(
const
DeviceName
:
string
):
TJSONObject
;
function
BeginRegistration
(
const
PhoneNumber
:
string
):
TJSONObject
;
function
CompleteRegistration
(
const
DeviceName
,
CredentialId
,
function
CompleteRegistration
(
const
PhoneNumber
,
CredentialId
,
AttestationObject
,
ClientDataJSON
,
AttestationObject
,
ClientDataJSON
,
ChallengeToken
:
string
):
TJSONObject
;
ChallengeToken
:
string
):
TJSONObject
;
function
BeginAuthentication
(
const
CredentialId
:
string
):
TJSONObject
;
function
BeginAuthentication
(
const
CredentialId
:
string
):
TJSONObject
;
...
@@ -155,42 +155,69 @@ begin
...
@@ -155,42 +155,69 @@ begin
Result
:=
LowerCase
(
Trim
(
s
));
Result
:=
LowerCase
(
Trim
(
s
));
end
;
end
;
function
NormalizePhoneE164
(
const
APhone
:
string
):
string
;
var
digits
:
string
;
i
:
Integer
;
begin
digits
:=
''
;
for
i
:=
1
to
Length
(
APhone
)
do
if
CharInSet
(
APhone
[
i
],
[
'0'
..
'9'
])
then
digits
:=
digits
+
APhone
[
i
];
if
(
Length
(
digits
)
=
11
)
and
(
digits
[
1
]
=
'1'
)
then
Delete
(
digits
,
1
,
1
);
if
Length
(
digits
)
<>
10
then
raise
Exception
.
Create
(
'Invalid phone number. Enter a 10-digit US number.'
);
Result
:=
'+1'
+
digits
;
end
;
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// BeginRegistration
// BeginRegistration
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
function
TAuthService
.
BeginRegistration
(
const
DeviceName
:
string
):
TJSONObject
;
function
TAuthService
.
BeginRegistration
(
const
PhoneNumber
:
string
):
TJSONObject
;
var
var
token
,
challengeB64
:
string
;
token
,
challengeB64
,
normalizedPhone
:
string
;
q
:
TUniQuery
;
q
:
TUniQuery
;
begin
begin
Logger
.
Log
(
2
,
'AuthService.BeginRegistration -
deviceName: "'
+
DeviceName
+
'"'
);
Logger
.
Log
(
2
,
'AuthService.BeginRegistration -
phone: "'
+
PhoneNumber
+
'"'
);
Result
:=
TJSONObject
.
Create
;
Result
:=
TJSONObject
.
Create
;
TXDataOperationContext
.
Current
.
Handler
.
ManagedObjects
.
Add
(
Result
);
TXDataOperationContext
.
Current
.
Handler
.
ManagedObjects
.
Add
(
Result
);
if
Trim
(
DeviceName
)
=
''
then
if
Trim
(
PhoneNumber
)
=
''
then
begin
begin
Result
.
AddPair
(
'status'
,
'error'
);
Result
.
AddPair
(
'status'
,
'error'
);
Result
.
AddPair
(
'message'
,
'
Device name
is required.'
);
Result
.
AddPair
(
'message'
,
'
Phone number
is required.'
);
Exit
;
Exit
;
end
;
end
;
// Verify device name was pre-authorized by admin
try
normalizedPhone
:=
NormalizePhoneE164
(
PhoneNumber
);
except
on
E
:
Exception
do
begin
Result
.
AddPair
(
'status'
,
'error'
);
Result
.
AddPair
(
'message'
,
E
.
Message
);
Exit
;
end
;
end
;
// Verify phone number was pre-authorized by admin
q
:=
TUniQuery
.
Create
(
nil
);
q
:=
TUniQuery
.
Create
(
nil
);
try
try
q
.
Connection
:=
authDB
.
ucLemsOCSO
;
q
.
Connection
:=
authDB
.
ucLemsOCSO
;
q
.
SQL
.
Text
:=
q
.
SQL
.
Text
:=
'SELECT id FROM lems.device_registrations '
+
'SELECT id FROM lems.device_registrations '
+
'WHERE
LOWER(device_name) = LOWER(:NAME)
AND status = ''pending'''
;
'WHERE
phone_number = :PHONE
AND status = ''pending'''
;
q
.
ParamByName
(
'
NAME'
).
AsString
:=
Trim
(
DeviceName
)
;
q
.
ParamByName
(
'
PHONE'
).
AsString
:=
normalizedPhone
;
q
.
Open
;
q
.
Open
;
if
q
.
IsEmpty
then
if
q
.
IsEmpty
then
begin
begin
q
.
Close
;
q
.
Close
;
Logger
.
Log
(
2
,
'BeginRegistration -
device name not pre-authorized: "'
+
DeviceNam
e
+
'"'
);
Logger
.
Log
(
2
,
'BeginRegistration -
phone not pre-authorized: "'
+
normalizedPhon
e
+
'"'
);
Result
.
AddPair
(
'status'
,
'error'
);
Result
.
AddPair
(
'status'
,
'error'
);
Result
.
AddPair
(
'message'
,
'
Device name
not recognized. Contact your administrator.'
);
Result
.
AddPair
(
'message'
,
'
Phone number
not recognized. Contact your administrator.'
);
Exit
;
Exit
;
end
;
end
;
q
.
Close
;
q
.
Close
;
...
@@ -211,7 +238,7 @@ end;
...
@@ -211,7 +238,7 @@ end;
// CompleteRegistration
// CompleteRegistration
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
function
TAuthService
.
CompleteRegistration
(
const
DeviceName
,
CredentialId
,
function
TAuthService
.
CompleteRegistration
(
const
PhoneNumber
,
CredentialId
,
AttestationObject
,
ClientDataJSON
,
ChallengeToken
:
string
):
TJSONObject
;
AttestationObject
,
ClientDataJSON
,
ChallengeToken
:
string
):
TJSONObject
;
var
var
challengeB64
:
string
;
challengeB64
:
string
;
...
@@ -351,7 +378,19 @@ begin
...
@@ -351,7 +378,19 @@ begin
Exit
;
Exit
;
end
;
end
;
// 9. Activate the pending row — UPDATE instead of INSERT
// 9. Normalize phone and activate the pending row
var
normalizedPhone
:
string
:=
''
;
try
normalizedPhone
:=
NormalizePhoneE164
(
PhoneNumber
);
except
on
E
:
Exception
do
begin
Result
.
AddPair
(
'status'
,
'error'
);
Result
.
AddPair
(
'message'
,
E
.
Message
);
Exit
;
end
;
end
;
q
:=
TUniQuery
.
Create
(
nil
);
q
:=
TUniQuery
.
Create
(
nil
);
try
try
q
.
Connection
:=
authDB
.
ucLemsOCSO
;
q
.
Connection
:=
authDB
.
ucLemsOCSO
;
...
@@ -367,28 +406,28 @@ begin
...
@@ -367,28 +406,28 @@ begin
' public_key_x = :KEYX, public_key_y = :KEYY, '
+
' public_key_x = :KEYX, public_key_y = :KEYY, '
+
' public_key_alg = :ALG, sign_count = :CNT, '
+
' public_key_alg = :ALG, sign_count = :CNT, '
+
' status = ''active'', registered_at = NOW() '
+
' status = ''active'', registered_at = NOW() '
+
'WHERE
LOWER(device_name) = LOWER(:NAME)
AND status = ''pending'''
;
'WHERE
phone_number = :PHONE
AND status = ''pending'''
;
q
.
ParamByName
(
'CID'
).
AsString
:=
Trim
(
CredentialId
);
q
.
ParamByName
(
'CID'
).
AsString
:=
Trim
(
CredentialId
);
q
.
ParamByName
(
'AGENT'
).
AsString
:=
userAgent
;
q
.
ParamByName
(
'AGENT'
).
AsString
:=
userAgent
;
q
.
ParamByName
(
'KEYX'
).
AsBytes
:=
pubKeyX
;
q
.
ParamByName
(
'KEYX'
).
AsBytes
:=
pubKeyX
;
q
.
ParamByName
(
'KEYY'
).
AsBytes
:=
pubKeyY
;
q
.
ParamByName
(
'KEYY'
).
AsBytes
:=
pubKeyY
;
q
.
ParamByName
(
'ALG'
).
AsInteger
:=
pubKeyAlg
;
q
.
ParamByName
(
'ALG'
).
AsInteger
:=
pubKeyAlg
;
q
.
ParamByName
(
'CNT'
).
AsInteger
:=
Integer
(
signCount
);
q
.
ParamByName
(
'CNT'
).
AsInteger
:=
Integer
(
signCount
);
q
.
ParamByName
(
'
NAME'
).
AsString
:=
Trim
(
DeviceName
)
;
q
.
ParamByName
(
'
PHONE'
).
AsString
:=
normalizedPhone
;
q
.
ExecSQL
;
q
.
ExecSQL
;
if
q
.
RowsAffected
=
0
then
if
q
.
RowsAffected
=
0
then
begin
begin
Logger
.
Log
(
2
,
'CompleteRegistration - no pending row for
"'
+
DeviceNam
e
+
'"'
);
Logger
.
Log
(
2
,
'CompleteRegistration - no pending row for
phone "'
+
normalizedPhon
e
+
'"'
);
Result
.
AddPair
(
'status'
,
'error'
);
Result
.
AddPair
(
'status'
,
'error'
);
Result
.
AddPair
(
'message'
,
'
Device name
is not pending registration. Contact your administrator.'
);
Result
.
AddPair
(
'message'
,
'
Phone number
is not pending registration. Contact your administrator.'
);
Exit
;
Exit
;
end
;
end
;
finally
finally
q
.
Free
;
q
.
Free
;
end
;
end
;
Logger
.
Log
(
2
,
'CompleteRegistration - activated credential for
"'
+
DeviceNam
e
+
'"'
);
Logger
.
Log
(
2
,
'CompleteRegistration - activated credential for
phone "'
+
normalizedPhon
e
+
'"'
);
Result
.
AddPair
(
'status'
,
'ok'
);
Result
.
AddPair
(
'status'
,
'ok'
);
Result
.
AddPair
(
'message'
,
'Device registered successfully.'
);
Result
.
AddPair
(
'message'
,
'Device registered successfully.'
);
Result
.
AddPair
(
'credentialId'
,
Trim
(
CredentialId
));
Result
.
AddPair
(
'credentialId'
,
Trim
(
CredentialId
));
...
...
emiMobileServer/Source/Common.Config.pas
View file @
27821f79
...
@@ -18,6 +18,9 @@ type
...
@@ -18,6 +18,9 @@ type
FAuditEnabled
:
Boolean
;
FAuditEnabled
:
Boolean
;
FRpId
:
string
;
FRpId
:
string
;
FRpName
:
string
;
FRpName
:
string
;
FTwilioAccountSid
:
string
;
FTwilioAuthToken
:
string
;
FTwilioFromNumber
:
string
;
public
public
constructor
Create
;
constructor
Create
;
property
url
:
string
read
FUrl
write
FUrl
;
property
url
:
string
read
FUrl
write
FUrl
;
...
@@ -31,6 +34,10 @@ type
...
@@ -31,6 +34,10 @@ type
// WebAuthn Relying Party — must match the domain serving the app (e.g. "localhost")
// WebAuthn Relying Party — must match the domain serving the app (e.g. "localhost")
property
rpId
:
string
read
FRpId
write
FRpId
;
property
rpId
:
string
read
FRpId
write
FRpId
;
property
rpName
:
string
read
FRpName
write
FRpName
;
property
rpName
:
string
read
FRpName
write
FRpName
;
// Twilio SMS (for sending App Store redemption links)
property
twilioAccountSid
:
string
read
FTwilioAccountSid
write
FTwilioAccountSid
;
property
twilioAuthToken
:
string
read
FTwilioAuthToken
write
FTwilioAuthToken
;
property
twilioFromNumber
:
string
read
FTwilioFromNumber
write
FTwilioFromNumber
;
end
;
end
;
procedure
LoadServerConfig
;
procedure
LoadServerConfig
;
...
...
emiMobileServer/device_registrations_phone.sql
0 → 100644
View file @
27821f79
-- Migration: add phone_number to device_registrations + create redeem_codes table
-- Run once against the lems database (after device_registrations_pending.sql).
-- 1. Add phone_number column (nullable; unique among non-revoked rows via app logic)
ALTER
TABLE
lems
.
device_registrations
ADD
COLUMN
IF
NOT
EXISTS
phone_number
VARCHAR
(
20
);
-- 2. Create redeem_codes table for App Store redemption links
CREATE
TABLE
IF
NOT
EXISTS
lems
.
redeem_codes
(
id
SERIAL
PRIMARY
KEY
,
code
VARCHAR
(
20
)
NOT
NULL
UNIQUE
,
used_at
TIMESTAMPTZ
,
used_for
VARCHAR
(
20
)
-- E.164 phone number this code was sent to
);
-- 3. Seed redeem codes from wyoming_redeem_codes_03_2023.csv
INSERT
INTO
lems
.
redeem_codes
(
code
)
VALUES
(
'6KMHNH7A6LN9'
),
(
'6MXT9NTX6L9E'
),
(
'YAJWXLFYHL64'
),
(
'PPYK7L6YKEER'
),
(
'A37L4E733ENH'
),
(
'33JAW639P3YX'
),
(
'HHAKYY9T36PJ'
),
(
'T9FJ7KRML43Y'
),
(
'E73AARXT946K'
),
(
'AEAFJT7XRJWN'
),
(
'PXFA7E69LKNT'
),
(
'PHKAP6M6N76T'
),
(
'67EWNTXFKKHN'
),
(
'3HR7479KMKJ7'
),
(
'EYYR7Y79T3PL'
),
(
'TYJYHY6FKX7E'
),
(
'9N6E9X4KYHHJ'
),
(
'FTMFLJ43ANN6'
),
(
'R6HKEHMM3MRL'
),
(
'7M3JNAKYTJ6H'
),
(
'TRWMRNE6TA33'
),
(
'3NJ7YX3NFAFX'
),
(
'NRJTARKHMEHH'
),
(
'EP4APXP46RWN'
),
(
'TAMPH6EK3XA6'
),
(
'6K9AAT7WA3PF'
),
(
'AJXYJTL6A37W'
),
(
'YKPA6LR3LFP6'
),
(
'XPXRPFHH7EXY'
),
(
'9NN693L766JN'
),
(
'4F9EPN9YWFTW'
),
(
'FJ9RXNNL6M3R'
),
(
'A4HMWTR4Y6YR'
),
(
'JL9RMJYWH3RW'
),
(
'NYXEELXHEEWR'
),
(
'TPTYRPWEFHLL'
),
(
'JWLEXF3T3HPX'
),
(
'WPYFWTJHMNEA'
),
(
'JLYHHAR9LAJ7'
),
(
'KW9YX4AY4F7F'
),
(
'LMJAJHE7HH69'
),
(
'LAWPM9WJYMLH'
),
(
'NLFMEA744NRJ'
),
(
'ETN39J3KYA3N'
),
(
'6Y7R3NX6K497'
),
(
'N3RN7WRRTEH6'
),
(
'LJ7REJWYPHWN'
),
(
'RWEMWAFWN4EP'
),
(
'M4XKLLAN7YNF'
),
(
'6NHRYRM4RTL7'
),
(
'WR3TA9KE47JN'
),
(
'H7TWKXYPNJEK'
),
(
'X4XHH6AHXMET'
),
(
'MPWKKNMKWML7'
),
(
'J3HEW4JWRKN3'
),
(
'E766HNR7AWAN'
),
(
'P6PTAFJJF6WY'
),
(
'XM7MR44FYPT4'
),
(
'TNM64T3FAHML'
),
(
'MEJ4YP7F9NJT'
),
(
'LLWHYLEAXJRX'
),
(
'TT7JPF7LRFNY'
),
(
'FXN6EFHXLHAY'
),
(
'M934TNKH7N6K'
),
(
'X94WXYT9PML7'
),
(
'L9AKMW376JTP'
),
(
'LTLKEWWA47JT'
),
(
'KRRNJYXPKHWH'
),
(
'NMTNKTKRXRPR'
),
(
'XMLFRRYA669X'
),
(
'EM96HJHJ64YA'
),
(
'XTF9TM94EKXH'
),
(
'P944369MR7N4'
),
(
'LJTFEAJFJ9F9'
),
(
'AX749RNJNFXP'
),
(
'XNY3W6JKHALK'
),
(
'XLK79WPMRP7R'
),
(
'T49KNHXKJ7L3'
),
(
'LRHAP7LNRLNL'
),
(
'NNJWPN69YATJ'
),
(
'LAALW6EH3L3A'
),
(
'FH3A7NYMX9YY'
),
(
'F369PH7W4HYL'
),
(
'FEAMK994PEL7'
),
(
'9AE7A4TYKPFM'
),
(
'4TYJHWMA99KX'
),
(
'MPR643T4E47R'
),
(
'3JEMLFRNTLP4'
),
(
'K7EWYHFJ96AW'
),
(
'P9YKYAPYHXWE'
),
(
'3R96N4FMHTEA'
),
(
'XJPPTEJT7YAM'
),
(
'396PF9KKMK4P'
),
(
'J9WJRKYNXMAR'
),
(
'RYTTTP44RTMJ'
),
(
'R6JHY44LKE7N'
),
(
'L7L7K3KR9TLP'
),
(
'AX7YFHTN63KP'
),
(
'WTKPYRF9FMJN'
),
(
'4YL3MN7FWFP7'
)
ON
CONFLICT
(
code
)
DO
NOTHING
;
webEMIMobile/Auth.Service.pas
View file @
27821f79
...
@@ -42,9 +42,9 @@ type
...
@@ -42,9 +42,9 @@ type
procedure
ClearCredentialId
;
procedure
ClearCredentialId
;
// WebAuthn registration — two-step
// WebAuthn registration — two-step
procedure
BeginRegistration
(
A
DeviceName
:
string
;
procedure
BeginRegistration
(
A
PhoneNumber
:
string
;
ASuccess
:
TOnBeginSuccess
;
AError
:
TOnDeviceError
);
ASuccess
:
TOnBeginSuccess
;
AError
:
TOnDeviceError
);
procedure
CompleteRegistration
(
A
DeviceName
,
ACredentialId
,
procedure
CompleteRegistration
(
A
PhoneNumber
,
ACredentialId
,
AAttestationObject
,
AClientDataJSON
,
AChallengeToken
:
string
;
AAttestationObject
,
AClientDataJSON
,
AChallengeToken
:
string
;
ASuccess
:
TOnDeviceSuccess
;
AError
:
TOnDeviceError
);
ASuccess
:
TOnDeviceSuccess
;
AError
:
TOnDeviceError
);
...
@@ -149,7 +149,7 @@ end;
...
@@ -149,7 +149,7 @@ end;
// ---- WebAuthn registration ----
// ---- WebAuthn registration ----
procedure
TAuthService
.
BeginRegistration
(
A
DeviceName
:
string
;
procedure
TAuthService
.
BeginRegistration
(
A
PhoneNumber
:
string
;
ASuccess
:
TOnBeginSuccess
;
AError
:
TOnDeviceError
);
ASuccess
:
TOnBeginSuccess
;
AError
:
TOnDeviceError
);
procedure
OnLoad
(
Response
:
TXDataClientResponse
);
procedure
OnLoad
(
Response
:
TXDataClientResponse
);
...
@@ -177,12 +177,12 @@ procedure TAuthService.BeginRegistration(ADeviceName: string;
...
@@ -177,12 +177,12 @@ procedure TAuthService.BeginRegistration(ADeviceName: string;
begin
begin
FClient
.
RawInvoke
(
FClient
.
RawInvoke
(
'IAuthService.BeginRegistration'
,
'IAuthService.BeginRegistration'
,
[
A
DeviceName
],
[
A
PhoneNumber
],
@
OnLoad
,
@
OnError
@
OnLoad
,
@
OnError
);
);
end
;
end
;
procedure
TAuthService
.
CompleteRegistration
(
A
DeviceName
,
ACredentialId
,
procedure
TAuthService
.
CompleteRegistration
(
A
PhoneNumber
,
ACredentialId
,
AAttestationObject
,
AClientDataJSON
,
AChallengeToken
:
string
;
AAttestationObject
,
AClientDataJSON
,
AChallengeToken
:
string
;
ASuccess
:
TOnDeviceSuccess
;
AError
:
TOnDeviceError
);
ASuccess
:
TOnDeviceSuccess
;
AError
:
TOnDeviceError
);
...
@@ -215,7 +215,7 @@ procedure TAuthService.CompleteRegistration(ADeviceName, ACredentialId,
...
@@ -215,7 +215,7 @@ procedure TAuthService.CompleteRegistration(ADeviceName, ACredentialId,
begin
begin
FClient
.
RawInvoke
(
FClient
.
RawInvoke
(
'IAuthService.CompleteRegistration'
,
'IAuthService.CompleteRegistration'
,
[
A
DeviceName
,
ACredentialId
,
AAttestationObject
,
AClientDataJSON
,
AChallengeToken
],
[
A
PhoneNumber
,
ACredentialId
,
AAttestationObject
,
AClientDataJSON
,
AChallengeToken
],
@
OnLoad
,
@
OnError
@
OnLoad
,
@
OnError
);
);
end
;
end
;
...
...
webEMIMobile/View.DeviceManager.dfm
View file @
27821f79
...
@@ -39,23 +39,34 @@ object FViewDeviceManager: TFViewDeviceManager
...
@@ -39,23 +39,34 @@ object FViewDeviceManager: TFViewDeviceManager
object edtNewDeviceName: TWebEdit
object edtNewDeviceName: TWebEdit
Left = 8
Left = 8
Top = 50
Top = 50
Width =
200
Width =
175
Height = 25
Height = 25
ElementID = 'view.devmgr.newname'
ElementID = 'view.devmgr.newname'
HeightPercent = 100.000000000000000000
HeightPercent = 100.000000000000000000
WidthPercent = 100.000000000000000000
WidthPercent = 100.000000000000000000
TabOrder = 1
TabOrder = 1
end
end
object edtNewPhoneNumber: TWebEdit
Left = 192
Top = 50
Width = 155
Height = 25
ElementID = 'view.devmgr.newphone'
HeightPercent = 100.000000000000000000
TextHint = '(303) 555-1234'
WidthPercent = 100.000000000000000000
TabOrder = 2
end
object btnAddDevice: TWebButton
object btnAddDevice: TWebButton
Left =
220
Left =
356
Top = 50
Top = 50
Width =
75
Width =
60
Height = 25
Height = 25
Caption = 'Add'
Caption = 'Add'
ElementID = 'view.devmgr.btnadd'
ElementID = 'view.devmgr.btnadd'
HeightPercent = 100.000000000000000000
HeightPercent = 100.000000000000000000
WidthPercent = 100.000000000000000000
WidthPercent = 100.000000000000000000
TabOrder =
2
TabOrder =
3
OnClick = btnAddDeviceClick
OnClick = btnAddDeviceClick
end
end
object XDataWebClient: TXDataWebClient
object XDataWebClient: TXDataWebClient
...
...
webEMIMobile/View.DeviceManager.html
View file @
27821f79
<div
class=
"container-fluid p-3 h-100 d-flex flex-column"
>
<div
class=
"container-fluid p-3 h-100 d-flex flex-column"
>
<div
class=
"d-flex align-items-center mb-3"
>
<div
class=
"d-flex align-items-center mb-3"
>
<h5
class=
"mb-0 me-auto"
>
Registered Devices
</h5>
<h5
class=
"mb-0 me-auto"
>
Device Management
</h5>
<button
id=
"view.devmgr.btnrefresh"
<button
id=
"view.devmgr.btnrefresh"
class=
"btn btn-outline-secondary btn-sm"
class=
"btn btn-outline-secondary btn-sm"
onclick=
"document.dispatchEvent(new CustomEvent('devmgr-refresh'))"
>
onclick=
"document.dispatchEvent(new CustomEvent('devmgr-refresh'))"
>
...
@@ -23,13 +23,18 @@
...
@@ -23,13 +23,18 @@
<!-- Add pending device -->
<!-- Add pending device -->
<div
class=
"card mb-3"
>
<div
class=
"card mb-3"
>
<div
class=
"card-body py-2"
>
<div
class=
"card-body py-2"
>
<div
class=
"d-flex align-items-center gap-2"
>
<div
class=
"d-flex align-items-center gap-2
flex-wrap
"
>
<
label
class=
"form-label mb-0 me-1 fw-semibold text-nowrap"
>
Add Device:
</label
>
<
span
class=
"fw-semibold text-nowrap small"
>
Add Device:
</span
>
<input
type=
"text"
<input
type=
"text"
id=
"view.devmgr.newname"
id=
"view.devmgr.newname"
class=
"form-control form-control-sm"
class=
"form-control form-control-sm"
placeholder=
"Device name"
placeholder=
"Device name"
style=
"max-width: 220px;"
>
style=
"max-width:180px;"
>
<input
type=
"tel"
id=
"view.devmgr.newphone"
class=
"form-control form-control-sm"
placeholder=
"(303) 555-1234"
style=
"max-width:160px;"
>
<button
id=
"view.devmgr.btnadd"
<button
id=
"view.devmgr.btnadd"
class=
"btn btn-primary btn-sm"
>
Add
</button>
class=
"btn btn-primary btn-sm"
>
Add
</button>
</div>
</div>
...
@@ -41,18 +46,19 @@
...
@@ -41,18 +46,19 @@
<table
class=
"table table-sm table-hover align-middle"
id=
"view.devmgr.table"
>
<table
class=
"table table-sm table-hover align-middle"
id=
"view.devmgr.table"
>
<thead
class=
"table-light sticky-top"
>
<thead
class=
"table-light sticky-top"
>
<tr>
<tr>
<th
style=
"min-width:140px;"
>
Device Name
</th>
<th
style=
"min-width:130px;"
>
Device Name
</th>
<th
style=
"min-width:120px;"
>
Phone
</th>
<th>
Browser / User Agent
</th>
<th>
Browser / User Agent
</th>
<th
style=
"min-width:135px;"
>
Registered
</th>
<th
style=
"min-width:135px;"
>
Registered
</th>
<th
style=
"min-width:80px;"
>
Status
</th>
<th
style=
"min-width:80px;"
>
Status
</th>
<th
style=
"min-width:
80px;"
>
Action
</th>
<th
style=
"min-width:
160px;"
>
Actions
</th>
</tr>
</tr>
</thead>
</thead>
<tbody
id=
"view.devmgr.tbody"
>
<tbody
id=
"view.devmgr.tbody"
>
</tbody>
</tbody>
</table>
</table>
<p
id=
"view.devmgr.empty"
class=
"text-muted d-none text-center py-4"
>
<p
id=
"view.devmgr.empty"
class=
"text-muted d-none text-center py-4"
>
No devices
registere
d.
No devices
foun
d.
</p>
</p>
</div>
</div>
...
...
webEMIMobile/View.DeviceManager.pas
View file @
27821f79
...
@@ -14,6 +14,7 @@ type
...
@@ -14,6 +14,7 @@ type
lblMessage
:
TWebLabel
;
lblMessage
:
TWebLabel
;
btnCloseNotification
:
TWebButton
;
btnCloseNotification
:
TWebButton
;
edtNewDeviceName
:
TWebEdit
;
edtNewDeviceName
:
TWebEdit
;
edtNewPhoneNumber
:
TWebEdit
;
btnAddDevice
:
TWebButton
;
btnAddDevice
:
TWebButton
;
procedure
WebFormCreate
(
Sender
:
TObject
);
procedure
WebFormCreate
(
Sender
:
TObject
);
procedure
btnCloseNotificationClick
(
Sender
:
TObject
);
procedure
btnCloseNotificationClick
(
Sender
:
TObject
);
...
@@ -22,12 +23,13 @@ type
...
@@ -22,12 +23,13 @@ type
procedure
ShowNotification
(
const
AMsg
:
string
;
AIsError
:
Boolean
=
True
);
procedure
ShowNotification
(
const
AMsg
:
string
;
AIsError
:
Boolean
=
True
);
procedure
HideNotification
;
procedure
HideNotification
;
procedure
ClearTable
;
procedure
ClearTable
;
procedure
AddDeviceRow
(
const
ACredentialId
,
AName
,
A
UserAgent
,
procedure
AddDeviceRow
(
const
ACredentialId
,
AName
,
A
PhoneNumber
,
ARegisteredAt
,
AStatus
:
string
);
A
UserAgent
,
A
RegisteredAt
,
AStatus
:
string
);
[
async
]
procedure
LoadDevices
;
[
async
]
procedure
LoadDevices
;
[
async
]
procedure
RevokeDevice
(
const
ACredentialId
,
AName
:
string
);
[
async
]
procedure
RevokeDevice
(
const
ACredentialId
,
AName
:
string
);
[
async
]
procedure
AddPendingDevice
(
const
AName
:
string
);
[
async
]
procedure
DeletePendingDevice
(
const
APhoneNumber
,
AName
:
string
);
[
async
]
procedure
DeletePendingDevice
(
const
AName
:
string
);
[
async
]
procedure
SendAppLink
(
const
APhoneNumber
,
AName
:
string
);
[
async
]
procedure
AddPendingDevice
;
public
public
end
;
end
;
...
@@ -38,10 +40,43 @@ implementation
...
@@ -38,10 +40,43 @@ implementation
{$R *.dfm}
{$R *.dfm}
function
FormatPhoneDisplay
(
const
AE164
:
string
):
string
;
var
digits
:
string
;
begin
if
Length
(
AE164
)
=
12
then
digits
:=
Copy
(
AE164
,
3
,
10
)
else
digits
:=
AE164
;
if
Length
(
digits
)
=
10
then
Result
:=
'('
+
Copy
(
digits
,
1
,
3
)
+
') '
+
Copy
(
digits
,
4
,
3
)
+
'-'
+
Copy
(
digits
,
7
,
4
)
else
Result
:=
AE164
;
end
;
procedure
TFViewDeviceManager
.
WebFormCreate
(
Sender
:
TObject
);
procedure
TFViewDeviceManager
.
WebFormCreate
(
Sender
:
TObject
);
begin
begin
HideNotification
;
HideNotification
;
LoadDevices
;
LoadDevices
;
asm
// Format phone input on-the-fly
var
inp
=
document
.
getElementById
(
'view.devmgr.newphone'
);
if
(
inp
)
{
inp.addEventListener('input', function() {
var digits = inp.value.replace(/\D/g, '');
if (digits.length > 10) digits = digits.slice(0, 10);
var fmt = '';
if (digits.length > 6)
fmt = '(' + digits.slice(0,3) + ') ' + digits.slice(3,6) + '-' + digits.slice(6);
else if (digits.length > 3)
fmt = '(' + digits.slice(0,3) + ') ' + digits.slice(3);
else if (digits.length > 0)
fmt = '(' + digits;
inp.value = fmt;
}
);
}
end
;
end
;
end
;
procedure
TFViewDeviceManager
.
btnCloseNotificationClick
(
Sender
:
TObject
);
procedure
TFViewDeviceManager
.
btnCloseNotificationClick
(
Sender
:
TObject
);
...
@@ -49,19 +84,6 @@ begin
...
@@ -49,19 +84,6 @@ begin
HideNotification
;
HideNotification
;
end
;
end
;
procedure
TFViewDeviceManager
.
btnAddDeviceClick
(
Sender
:
TObject
);
var
name
:
string
;
begin
name
:=
Trim
(
edtNewDeviceName
.
Text
);
if
name
=
''
then
begin
ShowNotification
(
'Please enter a device name.'
);
Exit
;
end
;
AddPendingDevice
(
name
);
end
;
procedure
TFViewDeviceManager
.
ShowNotification
(
const
AMsg
:
string
;
AIsError
:
Boolean
);
procedure
TFViewDeviceManager
.
ShowNotification
(
const
AMsg
:
string
;
AIsError
:
Boolean
);
begin
begin
lblMessage
.
Caption
:=
AMsg
;
lblMessage
.
Caption
:=
AMsg
;
...
@@ -93,22 +115,24 @@ begin
...
@@ -93,22 +115,24 @@ begin
end
;
end
;
end
;
end
;
procedure
TFViewDeviceManager
.
AddDeviceRow
(
const
ACredentialId
,
AName
,
AUserAgent
,
procedure
TFViewDeviceManager
.
AddDeviceRow
(
const
ACredentialId
,
AName
,
ARegisteredAt
,
AStatus
:
string
);
A
PhoneNumber
,
AUserAgent
,
A
RegisteredAt
,
AStatus
:
string
);
var
var
tbody
,
tr
,
tdName
,
tdAgent
,
tdReg
,
tdStatus
,
tdAction
:
TJSHTMLElement
;
tbody
,
tr
,
tdName
,
tdPhone
,
tdAgent
,
tdReg
,
tdStatus
,
tdAction
:
TJSHTMLElement
;
btn
:
TJSHTMLElement
;
btn
,
btnSend
:
TJSHTMLElement
;
displayDate
:
string
;
displayDate
,
displayPhone
:
string
;
isPending
,
isRevoked
:
Boolean
;
begin
begin
tbody
:=
TJSHTMLElement
(
document
.
getElementById
(
'view.devmgr.tbody'
));
tbody
:=
TJSHTMLElement
(
document
.
getElementById
(
'view.devmgr.tbody'
));
if
not
Assigned
(
tbody
)
then
if
not
Assigned
(
tbody
)
then
Exit
;
Exit
;
isPending
:=
AStatus
=
'pending'
;
isRevoked
:=
AStatus
=
'revoked'
;
displayPhone
:=
FormatPhoneDisplay
(
APhoneNumber
);
tr
:=
TJSHTMLElement
(
document
.
createElement
(
'tr'
));
tr
:=
TJSHTMLElement
(
document
.
createElement
(
'tr'
));
if
AStatus
=
'revoked'
then
if
isRevoked
then
tr
.
classList
.
add
(
'table-secondary'
);
tr
.
classList
.
add
(
'table-secondary'
)
if
isPending
then
tr
.
classList
.
add
(
'table-warning'
);
else
if
AStatus
=
'pending'
then
tr
.
classList
.
add
(
'table-warning'
);
// Device name
// Device name
tdName
:=
TJSHTMLElement
(
document
.
createElement
(
'td'
));
tdName
:=
TJSHTMLElement
(
document
.
createElement
(
'td'
));
...
@@ -118,54 +142,63 @@ begin
...
@@ -118,54 +142,63 @@ begin
tdName
.
innerHTML
:=
'<em class="text-muted">unnamed</em>'
;
tdName
.
innerHTML
:=
'<em class="text-muted">unnamed</em>'
;
tr
.
appendChild
(
tdName
);
tr
.
appendChild
(
tdName
);
// User agent (truncated via CSS)
// Phone number
tdPhone
:=
TJSHTMLElement
(
document
.
createElement
(
'td'
));
tdPhone
.
innerText
:=
displayPhone
;
tr
.
appendChild
(
tdPhone
);
// User agent (truncated)
tdAgent
:=
TJSHTMLElement
(
document
.
createElement
(
'td'
));
tdAgent
:=
TJSHTMLElement
(
document
.
createElement
(
'td'
));
if
not
isPending
then
begin
tdAgent
.
setAttribute
(
'title'
,
AUserAgent
);
tdAgent
.
setAttribute
(
'title'
,
AUserAgent
);
tdAgent
.
style
.
setProperty
(
'max-width'
,
'26
0px'
);
tdAgent
.
style
.
setProperty
(
'max-width'
,
'22
0px'
);
tdAgent
.
style
.
setProperty
(
'overflow'
,
'hidden'
);
tdAgent
.
style
.
setProperty
(
'overflow'
,
'hidden'
);
tdAgent
.
style
.
setProperty
(
'text-overflow'
,
'ellipsis'
);
tdAgent
.
style
.
setProperty
(
'text-overflow'
,
'ellipsis'
);
tdAgent
.
style
.
setProperty
(
'white-space'
,
'nowrap'
);
tdAgent
.
style
.
setProperty
(
'white-space'
,
'nowrap'
);
if
AStatus
=
'pending'
then
tdAgent
.
innerHTML
:=
'<em class="text-muted">Not yet registered</em>'
else
tdAgent
.
innerText
:=
AUserAgent
;
tdAgent
.
innerText
:=
AUserAgent
;
end
else
tdAgent
.
innerHTML
:=
'<em class="text-muted small">awaiting registration</em>'
;
tr
.
appendChild
(
tdAgent
);
tr
.
appendChild
(
tdAgent
);
// Registered at
— trim to seconds, replace T with space
// Registered at
displayDate
:=
ARegisteredAt
;
displayDate
:=
ARegisteredAt
;
if
Length
(
displayDate
)
>=
19
then
if
Length
(
displayDate
)
>=
19
then
displayDate
:=
Copy
(
displayDate
,
1
,
19
).
Replace
(
'T'
,
' '
);
displayDate
:=
Copy
(
displayDate
,
1
,
19
).
Replace
(
'T'
,
' '
);
tdReg
:=
TJSHTMLElement
(
document
.
createElement
(
'td'
));
tdReg
:=
TJSHTMLElement
(
document
.
createElement
(
'td'
));
if
AStatus
=
'pending'
then
if
not
isPending
then
tdReg
.
inner
HTML
:=
'<em class="text-muted">—</em>'
tdReg
.
inner
Text
:=
displayDate
else
else
tdReg
.
inner
Text
:=
displayDate
;
tdReg
.
inner
HTML
:=
'<em class="text-muted small">—</em>'
;
tr
.
appendChild
(
tdReg
);
tr
.
appendChild
(
tdReg
);
// Status badge
// Status badge
tdStatus
:=
TJSHTMLElement
(
document
.
createElement
(
'td'
));
tdStatus
:=
TJSHTMLElement
(
document
.
createElement
(
'td'
));
if
AStatus
=
'pending'
then
if
isPending
then
tdStatus
.
innerHTML
:=
'<span class="badge bg-warning text-dark">Pending</span>'
tdStatus
.
innerHTML
:=
'<span class="badge bg-warning text-dark">Pending</span>'
else
if
AStatus
=
'revoked'
then
else
if
isRevoked
then
tdStatus
.
innerHTML
:=
'<span class="badge bg-secondary">Revoked</span>'
tdStatus
.
innerHTML
:=
'<span class="badge bg-secondary">Revoked</span>'
else
else
tdStatus
.
innerHTML
:=
'<span class="badge bg-success">Active</span>'
;
tdStatus
.
innerHTML
:=
'<span class="badge bg-success">Active</span>'
;
tr
.
appendChild
(
tdStatus
);
tr
.
appendChild
(
tdStatus
);
// Action
button
// Action
s
tdAction
:=
TJSHTMLElement
(
document
.
createElement
(
'td'
));
tdAction
:=
TJSHTMLElement
(
document
.
createElement
(
'td'
));
if
AStatus
=
'pending'
then
tdAction
.
className
:=
'd-flex gap-1 flex-wrap'
;
if
isPending
then
begin
begin
btn
:=
TJSHTMLElement
(
document
.
createElement
(
'button'
));
btn
:=
TJSHTMLElement
(
document
.
createElement
(
'button'
));
btn
.
className
:=
'btn btn-outline-danger btn-sm'
;
btn
.
className
:=
'btn btn-outline-danger btn-sm'
;
btn
.
innerText
:=
'Cancel'
;
btn
.
innerText
:=
'Cancel'
;
btn
.
addEventListener
(
'click'
,
procedure
(
Event
:
TJSMouseEvent
)
btn
.
addEventListener
(
'click'
,
procedure
(
Event
:
TJSMouseEvent
)
begin
begin
DeletePendingDevice
(
AName
);
DeletePendingDevice
(
A
PhoneNumber
,
A
Name
);
end
);
end
);
tdAction
.
appendChild
(
btn
);
tdAction
.
appendChild
(
btn
);
end
end
else
if
AStatus
=
'active'
then
else
if
not
isRevoked
then
begin
begin
btn
:=
TJSHTMLElement
(
document
.
createElement
(
'button'
));
btn
:=
TJSHTMLElement
(
document
.
createElement
(
'button'
));
btn
.
className
:=
'btn btn-danger btn-sm'
;
btn
.
className
:=
'btn btn-danger btn-sm'
;
...
@@ -175,11 +208,25 @@ begin
...
@@ -175,11 +208,25 @@ begin
RevokeDevice
(
ACredentialId
,
AName
);
RevokeDevice
(
ACredentialId
,
AName
);
end
);
end
);
tdAction
.
appendChild
(
btn
);
tdAction
.
appendChild
(
btn
);
end
end
;
else
// Send App Link for pending and active rows with a phone number
if
(
not
isRevoked
)
and
(
APhoneNumber
<>
''
)
then
begin
btnSend
:=
TJSHTMLElement
(
document
.
createElement
(
'button'
));
btnSend
.
className
:=
'btn btn-outline-primary btn-sm'
;
btnSend
.
innerText
:=
'Send Link'
;
btnSend
.
addEventListener
(
'click'
,
procedure
(
Event
:
TJSMouseEvent
)
begin
SendAppLink
(
APhoneNumber
,
AName
);
end
);
tdAction
.
appendChild
(
btnSend
);
end
;
if
tdAction
.
children
.
length
=
0
then
tdAction
.
innerText
:=
'—'
;
tdAction
.
innerText
:=
'—'
;
tr
.
appendChild
(
tdAction
);
tr
.
appendChild
(
tdAction
);
tbody
.
appendChild
(
tr
);
tbody
.
appendChild
(
tr
);
end
;
end
;
...
@@ -215,6 +262,7 @@ begin
...
@@ -215,6 +262,7 @@ begin
AddDeviceRow
(
AddDeviceRow
(
string
(
item
[
'credential_id'
]),
string
(
item
[
'credential_id'
]),
string
(
item
[
'device_name'
]),
string
(
item
[
'device_name'
]),
string
(
item
[
'phone_number'
]),
string
(
item
[
'user_agent'
]),
string
(
item
[
'user_agent'
]),
string
(
item
[
'registered_at'
]),
string
(
item
[
'registered_at'
]),
string
(
item
[
'status'
])
string
(
item
[
'status'
])
...
@@ -240,7 +288,7 @@ begin
...
@@ -240,7 +288,7 @@ begin
if
status
=
'ok'
then
if
status
=
'ok'
then
begin
begin
ShowNotification
(
'Device "'
+
AName
+
'"
access
has been revoked.'
,
False
);
ShowNotification
(
'Device "'
+
AName
+
'" has been revoked.'
,
False
);
LoadDevices
;
LoadDevices
;
end
end
else
else
...
@@ -251,52 +299,94 @@ begin
...
@@ -251,52 +299,94 @@ begin
end
;
end
;
end
;
end
;
procedure
TFViewDeviceManager
.
AddPendingDevice
(
const
AName
:
string
);
procedure
TFViewDeviceManager
.
DeletePendingDevice
(
const
APhoneNumber
,
AName
:
string
);
var
var
resp
:
TXDataClientResponse
;
resp
:
TXDataClientResponse
;
res
:
TJSObject
;
res
:
TJSObject
;
status
:
string
;
status
:
string
;
begin
begin
try
try
resp
:=
await
(
XDataWebClient
.
RawInvokeAsync
(
'IApiService.
AddPendingDevice'
,
[
AName
]));
resp
:=
await
(
XDataWebClient
.
RawInvokeAsync
(
'IApiService.
DeletePendingDevice'
,
[
APhoneNumber
]));
res
:=
TJSObject
(
resp
.
Result
);
res
:=
TJSObject
(
resp
.
Result
);
status
:=
string
(
res
[
'status'
]);
status
:=
string
(
res
[
'status'
]);
if
status
=
'ok'
then
if
status
=
'ok'
then
begin
begin
edtNewDeviceName
.
Text
:=
''
;
ShowNotification
(
'Pending device "'
+
AName
+
'" removed.'
,
False
);
ShowNotification
(
'Device "'
+
AName
+
'" added — waiting for user to register.'
,
False
);
LoadDevices
;
LoadDevices
;
end
end
else
else
ShowNotification
(
string
(
res
[
'message'
]));
ShowNotification
(
string
(
res
[
'message'
]));
except
except
on
E
:
Exception
do
on
E
:
Exception
do
ShowNotification
(
'Add failed: '
+
E
.
Message
);
ShowNotification
(
'Delete failed: '
+
E
.
Message
);
end
;
end
;
procedure
TFViewDeviceManager
.
SendAppLink
(
const
APhoneNumber
,
AName
:
string
);
var
resp
:
TXDataClientResponse
;
res
:
TJSObject
;
status
:
string
;
begin
try
resp
:=
await
(
XDataWebClient
.
RawInvokeAsync
(
'IApiService.SendAppLink'
,
[
APhoneNumber
]));
res
:=
TJSObject
(
resp
.
Result
);
status
:=
string
(
res
[
'status'
]);
if
status
=
'ok'
then
ShowNotification
(
'App link sent to "'
+
AName
+
'" via SMS.'
,
False
)
else
ShowNotification
(
string
(
res
[
'message'
]));
except
on
E
:
Exception
do
ShowNotification
(
'Send failed: '
+
E
.
Message
);
end
;
end
;
end
;
end
;
procedure
TFViewDeviceManager
.
DeletePendingDevice
(
const
AName
:
string
);
procedure
TFViewDeviceManager
.
btnAddDeviceClick
(
Sender
:
TObject
);
begin
AddPendingDevice
;
end
;
procedure
TFViewDeviceManager
.
AddPendingDevice
;
var
var
deviceName
,
phoneNumber
:
string
;
resp
:
TXDataClientResponse
;
resp
:
TXDataClientResponse
;
res
:
TJSObject
;
res
:
TJSObject
;
status
:
string
;
status
:
string
;
begin
begin
deviceName
:=
Trim
(
edtNewDeviceName
.
Text
);
phoneNumber
:=
Trim
(
edtNewPhoneNumber
.
Text
);
if
deviceName
=
''
then
begin
ShowNotification
(
'Please enter a device name.'
);
Exit
;
end
;
if
phoneNumber
=
''
then
begin
ShowNotification
(
'Please enter a phone number.'
);
Exit
;
end
;
try
try
resp
:=
await
(
XDataWebClient
.
RawInvokeAsync
(
'IApiService.
DeletePendingDevice'
,
[
AName
]));
resp
:=
await
(
XDataWebClient
.
RawInvokeAsync
(
'IApiService.
AddPendingDevice'
,
[
deviceName
,
phoneNumber
]));
res
:=
TJSObject
(
resp
.
Result
);
res
:=
TJSObject
(
resp
.
Result
);
status
:=
string
(
res
[
'status'
]);
status
:=
string
(
res
[
'status'
]);
if
status
=
'ok'
then
if
status
=
'ok'
then
begin
begin
ShowNotification
(
'Pending device "'
+
AName
+
'" has been cancelled.'
,
False
);
edtNewDeviceName
.
Text
:=
''
;
edtNewPhoneNumber
.
Text
:=
''
;
ShowNotification
(
'Device "'
+
deviceName
+
'" added — waiting for user to register.'
,
False
);
LoadDevices
;
LoadDevices
;
end
end
else
else
ShowNotification
(
string
(
res
[
'message'
]));
ShowNotification
(
string
(
res
[
'message'
]));
except
except
on
E
:
Exception
do
on
E
:
Exception
do
ShowNotification
(
'
Cancel
failed: '
+
E
.
Message
);
ShowNotification
(
'
Add
failed: '
+
E
.
Message
);
end
;
end
;
end
;
end
;
...
...
webEMIMobile/View.DeviceRegistration.dfm
View file @
27821f79
...
@@ -8,14 +8,14 @@ object FViewDeviceRegistration: TFViewDeviceRegistration
...
@@ -8,14 +8,14 @@ object FViewDeviceRegistration: TFViewDeviceRegistration
Font.Style = []
Font.Style = []
ParentFont = False
ParentFont = False
OnCreate = WebFormCreate
OnCreate = WebFormCreate
object edt
DeviceName
: TWebEdit
object edt
PhoneNumber
: TWebEdit
Left = 240
Left = 240
Top = 136
Top = 136
Width = 121
Width = 121
Height = 21
Height = 21
ElementID = 'view.devicereg.edt
devicename
'
ElementID = 'view.devicereg.edt
phonenumber
'
HeightPercent = 100.000000000000000000
HeightPercent = 100.000000000000000000
TextHint = '
Device name (optional)
'
TextHint = '
(303) 555-1234
'
WidthPercent = 100.000000000000000000
WidthPercent = 100.000000000000000000
end
end
object btnRegister: TWebButton
object btnRegister: TWebButton
...
...
webEMIMobile/View.DeviceRegistration.html
View file @
27821f79
...
@@ -33,17 +33,17 @@
...
@@ -33,17 +33,17 @@
<p
class=
"text-muted small mb-3"
>
<p
class=
"text-muted small mb-3"
>
This browser has not been registered for emiMobile access.
This browser has not been registered for emiMobile access.
Give the device a recognisable name, then click
<strong>
Register
</strong>
.
Enter the phone number your administrator registered for this device,
then click
<strong>
Register
</strong>
.
Your browser will prompt you to verify with a PIN, fingerprint, or security key.
Your browser will prompt you to verify with a PIN, fingerprint, or security key.
An administrator can revoke access for any registered device at any time.
</p>
</p>
<div
class=
"mb-3"
>
<div
class=
"mb-3"
>
<label
class=
"form-label small text-muted"
>
Device name
</label>
<label
class=
"form-label small text-muted"
>
Phone number
</label>
<input
id=
"view.devicereg.edt
devicename
"
<input
id=
"view.devicereg.edt
phonenumber
"
class=
"form-control"
class=
"form-control"
type=
"te
xt
"
type=
"te
l
"
placeholder=
"
e.g. Dispatch Console, Patrol Laptop
"
placeholder=
"
(303) 555-1234
"
autofocus
>
autofocus
>
</div>
</div>
...
...
webEMIMobile/View.DeviceRegistration.pas
View file @
27821f79
...
@@ -10,7 +10,7 @@ uses
...
@@ -10,7 +10,7 @@ uses
type
type
TFViewDeviceRegistration
=
class
(
TWebForm
)
TFViewDeviceRegistration
=
class
(
TWebForm
)
edt
DeviceName
:
TWebEdit
;
edt
PhoneNumber
:
TWebEdit
;
btnRegister
:
TWebButton
;
btnRegister
:
TWebButton
;
pnlMessage
:
TWebPanel
;
pnlMessage
:
TWebPanel
;
lblMessage
:
TWebLabel
;
lblMessage
:
TWebLabel
;
...
@@ -24,7 +24,7 @@ type
...
@@ -24,7 +24,7 @@ type
procedure
ShowNotification
(
const
AMsg
:
string
;
AIsError
:
Boolean
=
True
);
procedure
ShowNotification
(
const
AMsg
:
string
;
AIsError
:
Boolean
=
True
);
procedure
HideNotification
;
procedure
HideNotification
;
procedure
SetBusy
(
ABusy
:
Boolean
);
procedure
SetBusy
(
ABusy
:
Boolean
);
procedure
DoWebAuthnCreate
(
A
DeviceName
,
AChallenge
,
AChallengeToken
:
string
);
procedure
DoWebAuthnCreate
(
A
PhoneNumber
,
AChallenge
,
AChallengeToken
:
string
);
public
public
class
procedure
Display
(
ARegistrationProc
:
TSuccessProc
);
class
procedure
Display
(
ARegistrationProc
:
TSuccessProc
);
end
;
end
;
...
@@ -60,6 +60,23 @@ begin
...
@@ -60,6 +60,23 @@ begin
asm
asm
var
el
=
document
.
getElementById
(
'view.devicereg.useragent'
);
var
el
=
document
.
getElementById
(
'view.devicereg.useragent'
);
if
(
el
)
el
.
textContent
=
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
;
end
;
end
;
...
@@ -76,11 +93,11 @@ end;
...
@@ -76,11 +93,11 @@ end;
procedure
TFViewDeviceRegistration
.
btnRegisterClick
(
Sender
:
TObject
);
procedure
TFViewDeviceRegistration
.
btnRegisterClick
(
Sender
:
TObject
);
var
var
deviceName
:
string
;
phoneNumber
:
string
;
procedure
OnBeginOK
(
AChallenge
,
AChallengeToken
:
string
);
procedure
OnBeginOK
(
AChallenge
,
AChallengeToken
:
string
);
begin
begin
DoWebAuthnCreate
(
deviceName
,
AChallenge
,
AChallengeToken
);
DoWebAuthnCreate
(
phoneNumber
,
AChallenge
,
AChallengeToken
);
end
;
end
;
procedure
OnBeginError
(
AMsg
:
string
);
procedure
OnBeginError
(
AMsg
:
string
);
...
@@ -90,24 +107,26 @@ var
...
@@ -90,24 +107,26 @@ var
end
;
end
;
begin
begin
deviceName
:=
Trim
(
edtDeviceName
.
Text
);
phoneNumber
:=
Trim
(
edtPhoneNumber
.
Text
);
if
deviceName
=
''
then
if
phoneNumber
=
''
then
deviceName
:=
'Unnamed Device'
;
begin
ShowNotification
(
'Please enter your phone number.'
);
Exit
;
end
;
SetBusy
(
True
);
SetBusy
(
True
);
HideNotification
;
HideNotification
;
AuthService
.
BeginRegistration
(
deviceName
,
@
OnBeginOK
,
@
OnBeginError
);
AuthService
.
BeginRegistration
(
phoneNumber
,
@
OnBeginOK
,
@
OnBeginError
);
end
;
end
;
procedure
TFViewDeviceRegistration
.
DoWebAuthnCreate
(
procedure
TFViewDeviceRegistration
.
DoWebAuthnCreate
(
A
DeviceName
,
AChallenge
,
AChallengeToken
:
string
);
A
PhoneNumber
,
AChallenge
,
AChallengeToken
:
string
);
var
var
deviceName
,
challenge
,
challengeToken
:
string
;
phoneNumber
,
challenge
,
challengeToken
:
string
;
procedure
OnCompleteOK
;
procedure
OnCompleteOK
;
begin
begin
// Registration complete — proceed to login
FRegistrationProc
;
FRegistrationProc
;
end
;
end
;
...
@@ -120,7 +139,7 @@ var
...
@@ -120,7 +139,7 @@ var
procedure
OnCredential
(
ACredentialId
,
AAttestationObject
,
AClientDataJSON
:
string
);
procedure
OnCredential
(
ACredentialId
,
AAttestationObject
,
AClientDataJSON
:
string
);
begin
begin
AuthService
.
CompleteRegistration
(
AuthService
.
CompleteRegistration
(
deviceName
,
ACredentialId
,
AAttestationObject
,
AClientDataJSON
,
challengeToken
,
phoneNumber
,
ACredentialId
,
AAttestationObject
,
AClientDataJSON
,
challengeToken
,
@
OnCompleteOK
,
@
OnCompleteError
@
OnCompleteOK
,
@
OnCompleteError
);
);
end
;
end
;
...
@@ -132,14 +151,12 @@ var
...
@@ -132,14 +151,12 @@ var
end
;
end
;
begin
begin
deviceName
:=
ADeviceName
;
phoneNumber
:=
APhoneNumber
;
challenge
:=
AChallenge
;
challenge
:=
AChallenge
;
challengeToken
:=
AChallengeToken
;
challengeToken
:=
AChallengeToken
;
// Call navigator.credentials.create() via WebAuthn API
asm
asm
(
function
()
{
(
function
()
{
// Decode base64url challenge to Uint8Array
function b64urlToArr(b64) {
function b64urlToArr(b64) {
b64 = b64.replace(/-/g, '+').replace(/_/g, '/');
b64 = b64.replace(/-/g, '+').replace(/_/g, '/');
while (b64.length % 4) b64 += '=';
while (b64.length % 4) b64 += '=';
...
@@ -148,13 +165,11 @@ begin
...
@@ -148,13 +165,11 @@ begin
for (var i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
for (var i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
return arr;
return arr;
}
}
// Encode ArrayBuffer to base64url
function
arrToB64url
(
buf
)
{
function
arrToB64url
(
buf
)
{
var bin = String.fromCharCode.apply(null, new Uint8Array(buf));
var bin = String.fromCharCode.apply(null, new Uint8Array(buf));
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
}
// Random 16-byte user handle (not linked to user identity for platform keys)
var
userId
=
new
Uint8Array
(
16
);
var
userId
=
new
Uint8Array
(
16
);
crypto
.
getRandomValues
(
userId
);
crypto
.
getRandomValues
(
userId
);
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment