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
Hide whitespace changes
Inline
Side-by-side
Showing
13 changed files
with
587 additions
and
150 deletions
+587
-150
Api.Service.pas
emiMobileServer/Source/Api.Service.pas
+3
-2
Api.ServiceImpl.pas
emiMobileServer/Source/Api.ServiceImpl.pas
+171
-22
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
+150
-60
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
// Device management — requires valid JWT; caller must also have user_admin = true
[
HttpGet
]
function
GetDeviceList
:
TDeviceList
;
function
RevokeDevice
(
const
CredentialId
:
string
):
TJSONObject
;
function
AddPendingDevice
(
const
DeviceName
:
string
):
TJSONObject
;
function
DeletePendingDevice
(
const
DeviceName
:
string
):
TJSONObject
;
function
AddPendingDevice
(
const
DeviceName
,
PhoneNumber
:
string
):
TJSONObject
;
function
DeletePendingDevice
(
const
PhoneNumber
:
string
):
TJSONObject
;
function
SendAppLink
(
const
PhoneNumber
:
string
):
TJSONObject
;
end
;
implementation
...
...
emiMobileServer/Source/Api.ServiceImpl.pas
View file @
27821f79
...
...
@@ -7,7 +7,8 @@ uses
System
.
SysUtils
,
System
.
Generics
.
Collections
,
XData
.
Sys
.
Exceptions
,
System
.
StrUtils
,
System
.
Hash
,
System
.
Classes
,
Common
.
Logging
,
System
.
JSON
,
Api
.
Service
,
VCL
.
Forms
,
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
...
...
@@ -37,8 +38,9 @@ type
function
GetComplaintMemos
(
const
CfsId
:
string
):
TJSONObject
;
function
GetDeviceList
:
TDeviceList
;
function
RevokeDevice
(
const
CredentialId
:
string
):
TJSONObject
;
function
AddPendingDevice
(
const
DeviceName
:
string
):
TJSONObject
;
function
DeletePendingDevice
(
const
DeviceName
:
string
):
TJSONObject
;
function
AddPendingDevice
(
const
DeviceName
,
PhoneNumber
:
string
):
TJSONObject
;
function
DeletePendingDevice
(
const
PhoneNumber
:
string
):
TJSONObject
;
function
SendAppLink
(
const
PhoneNumber
:
string
):
TJSONObject
;
end
;
implementation
...
...
@@ -1328,7 +1330,7 @@ begin
try
q
.
Connection
:=
conn
;
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 '
+
'FROM lems.device_registrations '
+
'ORDER BY registered_at DESC'
;
...
...
@@ -1342,6 +1344,7 @@ begin
item
.
id
:=
q
.
FieldByName
(
'id'
).
AsInteger
;
item
.
credential_id
:=
q
.
FieldByName
(
'credential_id'
).
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
.
registered_at
:=
q
.
FieldByName
(
'registered_at'
).
AsString
;
if
q
.
FieldByName
(
'revoked_at'
).
IsNull
then
...
...
@@ -1457,10 +1460,27 @@ begin
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
conn
:
TUniConnection
;
q
:
TUniQuery
;
normalizedPhone
:
string
;
begin
RequireAdmin
;
...
...
@@ -1474,34 +1494,46 @@ begin
Exit
;
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
;
try
q
:=
TUniQuery
.
Create
(
nil
);
try
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
:=
'SELECT id FROM lems.device_registrations '
+
'WHERE
LOWER(device_name) = LOWER(:NAME)
AND status = ''pending'''
;
q
.
ParamByName
(
'
NAME'
).
AsString
:=
Trim
(
DeviceName
)
;
'WHERE
phone_number = :PHONE
AND status = ''pending'''
;
q
.
ParamByName
(
'
PHONE'
).
AsString
:=
normalizedPhone
;
q
.
Open
;
if
not
q
.
IsEmpty
then
begin
q
.
Close
;
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
;
end
;
q
.
Close
;
q
.
SQL
.
Text
:=
'INSERT INTO lems.device_registrations (device_name, status) '
+
'VALUES (:NAME, ''pending'')'
;
q
.
ParamByName
(
'NAME'
).
AsString
:=
Trim
(
DeviceName
);
'INSERT INTO lems.device_registrations (device_name, phone_number, status) '
+
'VALUES (:NAME, :PHONE, ''pending'')'
;
q
.
ParamByName
(
'NAME'
).
AsString
:=
Trim
(
DeviceName
);
q
.
ParamByName
(
'PHONE'
).
AsString
:=
normalizedPhone
;
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
(
'message'
,
'Pending device added.'
);
finally
...
...
@@ -1512,21 +1544,26 @@ begin
end
;
end
;
function
TApiService
.
DeletePendingDevice
(
const
DeviceName
:
string
):
TJSONObject
;
function
TApiService
.
DeletePendingDevice
(
const
PhoneNumber
:
string
):
TJSONObject
;
var
conn
:
TUniConnection
;
q
:
TUniQuery
;
normalizedPhone
:
string
;
begin
RequireAdmin
;
Result
:=
TJSONObject
.
Create
;
TXDataOperationContext
.
Current
.
Handler
.
ManagedObjects
.
Add
(
Result
);
if
Trim
(
DeviceName
)
=
''
then
begin
Result
.
AddPair
(
'status'
,
'error'
);
Result
.
AddPair
(
'message'
,
'Device name is required.'
);
Exit
;
try
normalizedPhone
:=
NormalizePhoneE164Api
(
PhoneNumber
);
except
on
E
:
Exception
do
begin
Result
.
AddPair
(
'status'
,
'error'
);
Result
.
AddPair
(
'message'
,
E
.
Message
);
Exit
;
end
;
end
;
conn
:=
OpenLemsConnection
;
...
...
@@ -1536,13 +1573,13 @@ begin
q
.
Connection
:=
conn
;
q
.
SQL
.
Text
:=
'DELETE FROM lems.device_registrations '
+
'WHERE
LOWER(device_name) = LOWER(:NAME)
AND status = ''pending'''
;
q
.
ParamByName
(
'
NAME'
).
AsString
:=
Trim
(
DeviceName
)
;
'WHERE
phone_number = :PHONE
AND status = ''pending'''
;
q
.
ParamByName
(
'
PHONE'
).
AsString
:=
normalizedPhone
;
q
.
ExecSQL
;
if
q
.
RowsAffected
>
0
then
begin
Logger
.
Log
(
2
,
'TApiService.DeletePendingDevice - deleted
"'
+
Trim
(
DeviceName
)
+
'"'
);
Logger
.
Log
(
2
,
'TApiService.DeletePendingDevice - deleted
phone: '
+
normalizedPhone
);
Result
.
AddPair
(
'status'
,
'ok'
);
Result
.
AddPair
(
'message'
,
'Pending device removed.'
);
end
...
...
@@ -1559,6 +1596,118 @@ begin
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
RegisterServiceType
(
TApiService
);
...
...
emiMobileServer/Source/Auth.Service.pas
View file @
27821f79
...
...
@@ -45,6 +45,7 @@ type
id
:
Integer
;
credential_id
:
string
;
device_name
:
string
;
phone_number
:
string
;
user_agent
:
string
;
registered_at
:
string
;
revoked_at
:
string
;
...
...
@@ -71,10 +72,10 @@ type
[
HttpGet
]
function
GetAgencyConfigList
:
TAgencyConfigList
;
function
VerifyVersion
(
ClientVersion
:
string
):
TJSONObject
;
// WebAuthn registration — step 1: server
returns challenge + rpId/rpNam
e
function
BeginRegistration
(
const
DeviceName
:
string
):
TJSONObject
;
// WebAuthn registration — step 2: client submits credential, server verifies +
stores
function
CompleteRegistration
(
const
DeviceName
,
CredentialId
,
// WebAuthn registration — step 1: server
checks phone pre-auth, returns challeng
e
function
BeginRegistration
(
const
PhoneNumber
:
string
):
TJSONObject
;
// WebAuthn registration — step 2: client submits credential, server verifies +
activates pending row
function
CompleteRegistration
(
const
PhoneNumber
,
CredentialId
,
AttestationObject
,
ClientDataJSON
,
ChallengeToken
:
string
):
TJSONObject
;
// WebAuthn authentication challenge — called before Login
...
...
emiMobileServer/Source/Auth.ServiceImpl.pas
View file @
27821f79
...
...
@@ -36,8 +36,8 @@ type
signature
:
string
):
string
;
function
GetAgencieslist
():
TAgenciesList
;
function
GetAgencyConfiglist
:
TAgencyConfigList
;
function
BeginRegistration
(
const
DeviceName
:
string
):
TJSONObject
;
function
CompleteRegistration
(
const
DeviceName
,
CredentialId
,
function
BeginRegistration
(
const
PhoneNumber
:
string
):
TJSONObject
;
function
CompleteRegistration
(
const
PhoneNumber
,
CredentialId
,
AttestationObject
,
ClientDataJSON
,
ChallengeToken
:
string
):
TJSONObject
;
function
BeginAuthentication
(
const
CredentialId
:
string
):
TJSONObject
;
...
...
@@ -155,42 +155,69 @@ begin
Result
:=
LowerCase
(
Trim
(
s
));
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
// ---------------------------------------------------------------------------
function
TAuthService
.
BeginRegistration
(
const
DeviceName
:
string
):
TJSONObject
;
function
TAuthService
.
BeginRegistration
(
const
PhoneNumber
:
string
):
TJSONObject
;
var
token
,
challengeB64
:
string
;
token
,
challengeB64
,
normalizedPhone
:
string
;
q
:
TUniQuery
;
begin
Logger
.
Log
(
2
,
'AuthService.BeginRegistration -
deviceName: "'
+
DeviceName
+
'"'
);
Logger
.
Log
(
2
,
'AuthService.BeginRegistration -
phone: "'
+
PhoneNumber
+
'"'
);
Result
:=
TJSONObject
.
Create
;
TXDataOperationContext
.
Current
.
Handler
.
ManagedObjects
.
Add
(
Result
);
if
Trim
(
DeviceName
)
=
''
then
if
Trim
(
PhoneNumber
)
=
''
then
begin
Result
.
AddPair
(
'status'
,
'error'
);
Result
.
AddPair
(
'message'
,
'
Device name
is required.'
);
Result
.
AddPair
(
'message'
,
'
Phone number
is required.'
);
Exit
;
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
);
try
q
.
Connection
:=
authDB
.
ucLemsOCSO
;
q
.
SQL
.
Text
:=
'SELECT id FROM lems.device_registrations '
+
'WHERE
LOWER(device_name) = LOWER(:NAME)
AND status = ''pending'''
;
q
.
ParamByName
(
'
NAME'
).
AsString
:=
Trim
(
DeviceName
)
;
'WHERE
phone_number = :PHONE
AND status = ''pending'''
;
q
.
ParamByName
(
'
PHONE'
).
AsString
:=
normalizedPhone
;
q
.
Open
;
if
q
.
IsEmpty
then
begin
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
(
'message'
,
'
Device name
not recognized. Contact your administrator.'
);
Result
.
AddPair
(
'message'
,
'
Phone number
not recognized. Contact your administrator.'
);
Exit
;
end
;
q
.
Close
;
...
...
@@ -211,7 +238,7 @@ end;
// CompleteRegistration
// ---------------------------------------------------------------------------
function
TAuthService
.
CompleteRegistration
(
const
DeviceName
,
CredentialId
,
function
TAuthService
.
CompleteRegistration
(
const
PhoneNumber
,
CredentialId
,
AttestationObject
,
ClientDataJSON
,
ChallengeToken
:
string
):
TJSONObject
;
var
challengeB64
:
string
;
...
...
@@ -351,7 +378,19 @@ begin
Exit
;
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
);
try
q
.
Connection
:=
authDB
.
ucLemsOCSO
;
...
...
@@ -367,28 +406,28 @@ begin
' public_key_x = :KEYX, public_key_y = :KEYY, '
+
' public_key_alg = :ALG, sign_count = :CNT, '
+
' 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
(
'AGENT'
).
AsString
:=
userAgent
;
q
.
ParamByName
(
'KEYX'
).
AsBytes
:=
pubKeyX
;
q
.
ParamByName
(
'KEYY'
).
AsBytes
:=
pubKeyY
;
q
.
ParamByName
(
'ALG'
).
AsInteger
:=
pubKeyAlg
;
q
.
ParamByName
(
'CNT'
).
AsInteger
:=
Integer
(
signCount
);
q
.
ParamByName
(
'
NAME'
).
AsString
:=
Trim
(
DeviceName
)
;
q
.
ParamByName
(
'
PHONE'
).
AsString
:=
normalizedPhone
;
q
.
ExecSQL
;
if
q
.
RowsAffected
=
0
then
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
(
'message'
,
'
Device name
is not pending registration. Contact your administrator.'
);
Result
.
AddPair
(
'message'
,
'
Phone number
is not pending registration. Contact your administrator.'
);
Exit
;
end
;
finally
q
.
Free
;
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
(
'message'
,
'Device registered successfully.'
);
Result
.
AddPair
(
'credentialId'
,
Trim
(
CredentialId
));
...
...
emiMobileServer/Source/Common.Config.pas
View file @
27821f79
...
...
@@ -18,6 +18,9 @@ type
FAuditEnabled
:
Boolean
;
FRpId
:
string
;
FRpName
:
string
;
FTwilioAccountSid
:
string
;
FTwilioAuthToken
:
string
;
FTwilioFromNumber
:
string
;
public
constructor
Create
;
property
url
:
string
read
FUrl
write
FUrl
;
...
...
@@ -31,6 +34,10 @@ type
// WebAuthn Relying Party — must match the domain serving the app (e.g. "localhost")
property
rpId
:
string
read
FRpId
write
FRpId
;
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
;
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
procedure
ClearCredentialId
;
// WebAuthn registration — two-step
procedure
BeginRegistration
(
A
DeviceName
:
string
;
procedure
BeginRegistration
(
A
PhoneNumber
:
string
;
ASuccess
:
TOnBeginSuccess
;
AError
:
TOnDeviceError
);
procedure
CompleteRegistration
(
A
DeviceName
,
ACredentialId
,
procedure
CompleteRegistration
(
A
PhoneNumber
,
ACredentialId
,
AAttestationObject
,
AClientDataJSON
,
AChallengeToken
:
string
;
ASuccess
:
TOnDeviceSuccess
;
AError
:
TOnDeviceError
);
...
...
@@ -149,7 +149,7 @@ end;
// ---- WebAuthn registration ----
procedure
TAuthService
.
BeginRegistration
(
A
DeviceName
:
string
;
procedure
TAuthService
.
BeginRegistration
(
A
PhoneNumber
:
string
;
ASuccess
:
TOnBeginSuccess
;
AError
:
TOnDeviceError
);
procedure
OnLoad
(
Response
:
TXDataClientResponse
);
...
...
@@ -177,12 +177,12 @@ procedure TAuthService.BeginRegistration(ADeviceName: string;
begin
FClient
.
RawInvoke
(
'IAuthService.BeginRegistration'
,
[
A
DeviceName
],
[
A
PhoneNumber
],
@
OnLoad
,
@
OnError
);
end
;
procedure
TAuthService
.
CompleteRegistration
(
A
DeviceName
,
ACredentialId
,
procedure
TAuthService
.
CompleteRegistration
(
A
PhoneNumber
,
ACredentialId
,
AAttestationObject
,
AClientDataJSON
,
AChallengeToken
:
string
;
ASuccess
:
TOnDeviceSuccess
;
AError
:
TOnDeviceError
);
...
...
@@ -215,7 +215,7 @@ procedure TAuthService.CompleteRegistration(ADeviceName, ACredentialId,
begin
FClient
.
RawInvoke
(
'IAuthService.CompleteRegistration'
,
[
A
DeviceName
,
ACredentialId
,
AAttestationObject
,
AClientDataJSON
,
AChallengeToken
],
[
A
PhoneNumber
,
ACredentialId
,
AAttestationObject
,
AClientDataJSON
,
AChallengeToken
],
@
OnLoad
,
@
OnError
);
end
;
...
...
webEMIMobile/View.DeviceManager.dfm
View file @
27821f79
...
...
@@ -39,23 +39,34 @@ object FViewDeviceManager: TFViewDeviceManager
object edtNewDeviceName: TWebEdit
Left = 8
Top = 50
Width =
200
Width =
175
Height = 25
ElementID = 'view.devmgr.newname'
HeightPercent = 100.000000000000000000
WidthPercent = 100.000000000000000000
TabOrder = 1
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
Left =
220
Left =
356
Top = 50
Width =
75
Width =
60
Height = 25
Caption = 'Add'
ElementID = 'view.devmgr.btnadd'
HeightPercent = 100.000000000000000000
WidthPercent = 100.000000000000000000
TabOrder =
2
TabOrder =
3
OnClick = btnAddDeviceClick
end
object XDataWebClient: TXDataWebClient
...
...
webEMIMobile/View.DeviceManager.html
View file @
27821f79
<div
class=
"container-fluid p-3 h-100 d-flex flex-column"
>
<div
class=
"d-flex align-items-center mb-3"
>
<h5
class=
"mb-0 me-auto"
>
Registered Devices
</h5>
<h5
class=
"mb-0 me-auto"
>
Device Management
</h5>
<button
id=
"view.devmgr.btnrefresh"
class=
"btn btn-outline-secondary btn-sm"
onclick=
"document.dispatchEvent(new CustomEvent('devmgr-refresh'))"
>
...
...
@@ -23,13 +23,18 @@
<!-- Add pending device -->
<div
class=
"card mb-3"
>
<div
class=
"card-body py-2"
>
<div
class=
"d-flex align-items-center gap-2"
>
<
label
class=
"form-label mb-0 me-1 fw-semibold text-nowrap"
>
Add Device:
</label
>
<div
class=
"d-flex align-items-center gap-2
flex-wrap
"
>
<
span
class=
"fw-semibold text-nowrap small"
>
Add Device:
</span
>
<input
type=
"text"
id=
"view.devmgr.newname"
class=
"form-control form-control-sm"
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"
class=
"btn btn-primary btn-sm"
>
Add
</button>
</div>
...
...
@@ -41,18 +46,19 @@
<table
class=
"table table-sm table-hover align-middle"
id=
"view.devmgr.table"
>
<thead
class=
"table-light sticky-top"
>
<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
style=
"min-width:135px;"
>
Registered
</th>
<th
style=
"min-width:80px;"
>
Status
</th>
<th
style=
"min-width:
80px;"
>
Action
</th>
<th
style=
"min-width:
160px;"
>
Actions
</th>
</tr>
</thead>
<tbody
id=
"view.devmgr.tbody"
>
</tbody>
</table>
<p
id=
"view.devmgr.empty"
class=
"text-muted d-none text-center py-4"
>
No devices
registere
d.
No devices
foun
d.
</p>
</div>
...
...
webEMIMobile/View.DeviceManager.pas
View file @
27821f79
...
...
@@ -14,6 +14,7 @@ type
lblMessage
:
TWebLabel
;
btnCloseNotification
:
TWebButton
;
edtNewDeviceName
:
TWebEdit
;
edtNewPhoneNumber
:
TWebEdit
;
btnAddDevice
:
TWebButton
;
procedure
WebFormCreate
(
Sender
:
TObject
);
procedure
btnCloseNotificationClick
(
Sender
:
TObject
);
...
...
@@ -22,12 +23,13 @@ type
procedure
ShowNotification
(
const
AMsg
:
string
;
AIsError
:
Boolean
=
True
);
procedure
HideNotification
;
procedure
ClearTable
;
procedure
AddDeviceRow
(
const
ACredentialId
,
AName
,
A
UserAgent
,
ARegisteredAt
,
AStatus
:
string
);
procedure
AddDeviceRow
(
const
ACredentialId
,
AName
,
A
PhoneNumber
,
A
UserAgent
,
A
RegisteredAt
,
AStatus
:
string
);
[
async
]
procedure
LoadDevices
;
[
async
]
procedure
RevokeDevice
(
const
ACredentialId
,
AName
:
string
);
[
async
]
procedure
AddPendingDevice
(
const
AName
:
string
);
[
async
]
procedure
DeletePendingDevice
(
const
AName
:
string
);
[
async
]
procedure
DeletePendingDevice
(
const
APhoneNumber
,
AName
:
string
);
[
async
]
procedure
SendAppLink
(
const
APhoneNumber
,
AName
:
string
);
[
async
]
procedure
AddPendingDevice
;
public
end
;
...
...
@@ -38,10 +40,43 @@ implementation
{$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
);
begin
HideNotification
;
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
;
procedure
TFViewDeviceManager
.
btnCloseNotificationClick
(
Sender
:
TObject
);
...
...
@@ -49,19 +84,6 @@ begin
HideNotification
;
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
);
begin
lblMessage
.
Caption
:=
AMsg
;
...
...
@@ -93,22 +115,24 @@ begin
end
;
end
;
procedure
TFViewDeviceManager
.
AddDeviceRow
(
const
ACredentialId
,
AName
,
AUserAgent
,
ARegisteredAt
,
AStatus
:
string
);
procedure
TFViewDeviceManager
.
AddDeviceRow
(
const
ACredentialId
,
AName
,
A
PhoneNumber
,
AUserAgent
,
A
RegisteredAt
,
AStatus
:
string
);
var
tbody
,
tr
,
tdName
,
tdAgent
,
tdReg
,
tdStatus
,
tdAction
:
TJSHTMLElement
;
btn
:
TJSHTMLElement
;
displayDate
:
string
;
tbody
,
tr
,
tdName
,
tdPhone
,
tdAgent
,
tdReg
,
tdStatus
,
tdAction
:
TJSHTMLElement
;
btn
,
btnSend
:
TJSHTMLElement
;
displayDate
,
displayPhone
:
string
;
isPending
,
isRevoked
:
Boolean
;
begin
tbody
:=
TJSHTMLElement
(
document
.
getElementById
(
'view.devmgr.tbody'
));
if
not
Assigned
(
tbody
)
then
Exit
;
if
not
Assigned
(
tbody
)
then
Exit
;
isPending
:=
AStatus
=
'pending'
;
isRevoked
:=
AStatus
=
'revoked'
;
displayPhone
:=
FormatPhoneDisplay
(
APhoneNumber
);
tr
:=
TJSHTMLElement
(
document
.
createElement
(
'tr'
));
if
AStatus
=
'revoked'
then
tr
.
classList
.
add
(
'table-secondary'
)
else
if
AStatus
=
'pending'
then
tr
.
classList
.
add
(
'table-warning'
);
if
isRevoked
then
tr
.
classList
.
add
(
'table-secondary'
);
if
isPending
then
tr
.
classList
.
add
(
'table-warning'
);
// Device name
tdName
:=
TJSHTMLElement
(
document
.
createElement
(
'td'
));
...
...
@@ -118,54 +142,63 @@ begin
tdName
.
innerHTML
:=
'<em class="text-muted">unnamed</em>'
;
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
.
setAttribute
(
'title'
,
AUserAgent
);
tdAgent
.
style
.
setProperty
(
'max-width'
,
'260px'
);
tdAgent
.
style
.
setProperty
(
'overflow'
,
'hidden'
);
tdAgent
.
style
.
setProperty
(
'text-overflow'
,
'ellipsis'
);
tdAgent
.
style
.
setProperty
(
'white-space'
,
'nowrap'
);
if
AStatus
=
'pending'
then
tdAgent
.
innerHTML
:=
'<em class="text-muted">Not yet registered</em>'
else
if
not
isPending
then
begin
tdAgent
.
setAttribute
(
'title'
,
AUserAgent
);
tdAgent
.
style
.
setProperty
(
'max-width'
,
'220px'
);
tdAgent
.
style
.
setProperty
(
'overflow'
,
'hidden'
);
tdAgent
.
style
.
setProperty
(
'text-overflow'
,
'ellipsis'
);
tdAgent
.
style
.
setProperty
(
'white-space'
,
'nowrap'
);
tdAgent
.
innerText
:=
AUserAgent
;
end
else
tdAgent
.
innerHTML
:=
'<em class="text-muted small">awaiting registration</em>'
;
tr
.
appendChild
(
tdAgent
);
// Registered at
— trim to seconds, replace T with space
// Registered at
displayDate
:=
ARegisteredAt
;
if
Length
(
displayDate
)
>=
19
then
displayDate
:=
Copy
(
displayDate
,
1
,
19
).
Replace
(
'T'
,
' '
);
tdReg
:=
TJSHTMLElement
(
document
.
createElement
(
'td'
));
if
AStatus
=
'pending'
then
tdReg
.
inner
HTML
:=
'<em class="text-muted">—</em>'
if
not
isPending
then
tdReg
.
inner
Text
:=
displayDate
else
tdReg
.
inner
Text
:=
displayDate
;
tdReg
.
inner
HTML
:=
'<em class="text-muted small">—</em>'
;
tr
.
appendChild
(
tdReg
);
// Status badge
tdStatus
:=
TJSHTMLElement
(
document
.
createElement
(
'td'
));
if
AStatus
=
'pending'
then
if
isPending
then
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>'
else
tdStatus
.
innerHTML
:=
'<span class="badge bg-success">Active</span>'
;
tr
.
appendChild
(
tdStatus
);
// Action
button
// Action
s
tdAction
:=
TJSHTMLElement
(
document
.
createElement
(
'td'
));
if
AStatus
=
'pending'
then
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
(
AName
);
DeletePendingDevice
(
A
PhoneNumber
,
A
Name
);
end
);
tdAction
.
appendChild
(
btn
);
end
else
if
AStatus
=
'active'
then
else
if
not
isRevoked
then
begin
btn
:=
TJSHTMLElement
(
document
.
createElement
(
'button'
));
btn
.
className
:=
'btn btn-danger btn-sm'
;
...
...
@@ -175,11 +208,25 @@ begin
RevokeDevice
(
ACredentialId
,
AName
);
end
);
tdAction
.
appendChild
(
btn
);
end
else
end
;
// 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
:=
'—'
;
tr
.
appendChild
(
tdAction
);
tr
.
appendChild
(
tdAction
);
tbody
.
appendChild
(
tr
);
end
;
...
...
@@ -215,6 +262,7 @@ begin
AddDeviceRow
(
string
(
item
[
'credential_id'
]),
string
(
item
[
'device_name'
]),
string
(
item
[
'phone_number'
]),
string
(
item
[
'user_agent'
]),
string
(
item
[
'registered_at'
]),
string
(
item
[
'status'
])
...
...
@@ -240,7 +288,7 @@ begin
if
status
=
'ok'
then
begin
ShowNotification
(
'Device "'
+
AName
+
'"
access
has been revoked.'
,
False
);
ShowNotification
(
'Device "'
+
AName
+
'" has been revoked.'
,
False
);
LoadDevices
;
end
else
...
...
@@ -251,52 +299,94 @@ begin
end
;
end
;
procedure
TFViewDeviceManager
.
AddPendingDevice
(
const
AName
:
string
);
procedure
TFViewDeviceManager
.
DeletePendingDevice
(
const
APhoneNumber
,
AName
:
string
);
var
resp
:
TXDataClientResponse
;
res
:
TJSObject
;
status
:
string
;
begin
try
resp
:=
await
(
XDataWebClient
.
RawInvokeAsync
(
'IApiService.
AddPendingDevice'
,
[
AName
]));
resp
:=
await
(
XDataWebClient
.
RawInvokeAsync
(
'IApiService.
DeletePendingDevice'
,
[
APhoneNumber
]));
res
:=
TJSObject
(
resp
.
Result
);
status
:=
string
(
res
[
'status'
]);
if
status
=
'ok'
then
begin
edtNewDeviceName
.
Text
:=
''
;
ShowNotification
(
'Device "'
+
AName
+
'" added — waiting for user to register.'
,
False
);
ShowNotification
(
'Pending device "'
+
AName
+
'" removed.'
,
False
);
LoadDevices
;
end
else
ShowNotification
(
string
(
res
[
'message'
]));
except
on
E
:
Exception
do
ShowNotification
(
'
Add
failed: '
+
E
.
Message
);
ShowNotification
(
'
Delete
failed: '
+
E
.
Message
);
end
;
end
;
procedure
TFViewDeviceManager
.
DeletePendingDevice
(
const
AName
:
string
);
procedure
TFViewDeviceManager
.
SendAppLink
(
const
APhoneNumber
,
AName
:
string
);
var
resp
:
TXDataClientResponse
;
res
:
TJSObject
;
status
:
string
;
begin
try
resp
:=
await
(
XDataWebClient
.
RawInvokeAsync
(
'IApiService.DeletePendingDevice'
,
[
AName
]));
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
;
procedure
TFViewDeviceManager
.
btnAddDeviceClick
(
Sender
:
TObject
);
begin
AddPendingDevice
;
end
;
procedure
TFViewDeviceManager
.
AddPendingDevice
;
var
deviceName
,
phoneNumber
:
string
;
resp
:
TXDataClientResponse
;
res
:
TJSObject
;
status
:
string
;
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
resp
:=
await
(
XDataWebClient
.
RawInvokeAsync
(
'IApiService.AddPendingDevice'
,
[
deviceName
,
phoneNumber
]));
res
:=
TJSObject
(
resp
.
Result
);
status
:=
string
(
res
[
'status'
]);
if
status
=
'ok'
then
begin
ShowNotification
(
'Pending device "'
+
AName
+
'" has been cancelled.'
,
False
);
edtNewDeviceName
.
Text
:=
''
;
edtNewPhoneNumber
.
Text
:=
''
;
ShowNotification
(
'Device "'
+
deviceName
+
'" added — waiting for user to register.'
,
False
);
LoadDevices
;
end
else
ShowNotification
(
string
(
res
[
'message'
]));
except
on
E
:
Exception
do
ShowNotification
(
'
Cancel
failed: '
+
E
.
Message
);
ShowNotification
(
'
Add
failed: '
+
E
.
Message
);
end
;
end
;
...
...
webEMIMobile/View.DeviceRegistration.dfm
View file @
27821f79
...
...
@@ -8,14 +8,14 @@ object FViewDeviceRegistration: TFViewDeviceRegistration
Font.Style = []
ParentFont = False
OnCreate = WebFormCreate
object edt
DeviceName
: TWebEdit
object edt
PhoneNumber
: TWebEdit
Left = 240
Top = 136
Width = 121
Height = 21
ElementID = 'view.devicereg.edt
devicename
'
ElementID = 'view.devicereg.edt
phonenumber
'
HeightPercent = 100.000000000000000000
TextHint = '
Device name (optional)
'
TextHint = '
(303) 555-1234
'
WidthPercent = 100.000000000000000000
end
object btnRegister: TWebButton
...
...
webEMIMobile/View.DeviceRegistration.html
View file @
27821f79
...
...
@@ -33,17 +33,17 @@
<p
class=
"text-muted small mb-3"
>
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.
An administrator can revoke access for any registered device at any time.
</p>
<div
class=
"mb-3"
>
<label
class=
"form-label small text-muted"
>
Device name
</label>
<input
id=
"view.devicereg.edt
devicename
"
<label
class=
"form-label small text-muted"
>
Phone number
</label>
<input
id=
"view.devicereg.edt
phonenumber
"
class=
"form-control"
type=
"te
xt
"
placeholder=
"
e.g. Dispatch Console, Patrol Laptop
"
type=
"te
l
"
placeholder=
"
(303) 555-1234
"
autofocus
>
</div>
...
...
webEMIMobile/View.DeviceRegistration.pas
View file @
27821f79
...
...
@@ -10,7 +10,7 @@ uses
type
TFViewDeviceRegistration
=
class
(
TWebForm
)
edt
DeviceName
:
TWebEdit
;
edt
PhoneNumber
:
TWebEdit
;
btnRegister
:
TWebButton
;
pnlMessage
:
TWebPanel
;
lblMessage
:
TWebLabel
;
...
...
@@ -24,7 +24,7 @@ type
procedure
ShowNotification
(
const
AMsg
:
string
;
AIsError
:
Boolean
=
True
);
procedure
HideNotification
;
procedure
SetBusy
(
ABusy
:
Boolean
);
procedure
DoWebAuthnCreate
(
A
DeviceName
,
AChallenge
,
AChallengeToken
:
string
);
procedure
DoWebAuthnCreate
(
A
PhoneNumber
,
AChallenge
,
AChallengeToken
:
string
);
public
class
procedure
Display
(
ARegistrationProc
:
TSuccessProc
);
end
;
...
...
@@ -60,6 +60,23 @@ begin
asm
var
el
=
document
.
getElementById
(
'view.devicereg.useragent'
);
if
(
el
)
el
.
textContent
=
userAgent
;
// Format phone number on-the-fly as the user types
var
inp
=
document
.
getElementById
(
'view.devicereg.edtphonenumber'
);
if
(
inp
)
{
inp.addEventListener('input', function() {
var digits = inp.value.replace(/\D/g, '');
if (digits.length > 10) digits = digits.slice(0, 10);
var formatted = '';
if (digits.length > 6)
formatted = '(' + digits.slice(0,3) + ') ' + digits.slice(3,6) + '-' + digits.slice(6);
else if (digits.length > 3)
formatted = '(' + digits.slice(0,3) + ') ' + digits.slice(3);
else if (digits.length > 0)
formatted = '(' + digits;
inp.value = formatted;
}
);
}
end
;
end
;
...
...
@@ -76,11 +93,11 @@ end;
procedure
TFViewDeviceRegistration
.
btnRegisterClick
(
Sender
:
TObject
);
var
deviceName
:
string
;
phoneNumber
:
string
;
procedure
OnBeginOK
(
AChallenge
,
AChallengeToken
:
string
);
begin
DoWebAuthnCreate
(
deviceName
,
AChallenge
,
AChallengeToken
);
DoWebAuthnCreate
(
phoneNumber
,
AChallenge
,
AChallengeToken
);
end
;
procedure
OnBeginError
(
AMsg
:
string
);
...
...
@@ -90,24 +107,26 @@ var
end
;
begin
deviceName
:=
Trim
(
edtDeviceName
.
Text
);
if
deviceName
=
''
then
deviceName
:=
'Unnamed Device'
;
phoneNumber
:=
Trim
(
edtPhoneNumber
.
Text
);
if
phoneNumber
=
''
then
begin
ShowNotification
(
'Please enter your phone number.'
);
Exit
;
end
;
SetBusy
(
True
);
HideNotification
;
AuthService
.
BeginRegistration
(
deviceName
,
@
OnBeginOK
,
@
OnBeginError
);
AuthService
.
BeginRegistration
(
phoneNumber
,
@
OnBeginOK
,
@
OnBeginError
);
end
;
procedure
TFViewDeviceRegistration
.
DoWebAuthnCreate
(
A
DeviceName
,
AChallenge
,
AChallengeToken
:
string
);
A
PhoneNumber
,
AChallenge
,
AChallengeToken
:
string
);
var
deviceName
,
challenge
,
challengeToken
:
string
;
phoneNumber
,
challenge
,
challengeToken
:
string
;
procedure
OnCompleteOK
;
begin
// Registration complete — proceed to login
FRegistrationProc
;
end
;
...
...
@@ -120,7 +139,7 @@ var
procedure
OnCredential
(
ACredentialId
,
AAttestationObject
,
AClientDataJSON
:
string
);
begin
AuthService
.
CompleteRegistration
(
deviceName
,
ACredentialId
,
AAttestationObject
,
AClientDataJSON
,
challengeToken
,
phoneNumber
,
ACredentialId
,
AAttestationObject
,
AClientDataJSON
,
challengeToken
,
@
OnCompleteOK
,
@
OnCompleteError
);
end
;
...
...
@@ -132,14 +151,12 @@ var
end
;
begin
deviceName
:=
ADeviceName
;
phoneNumber
:=
APhoneNumber
;
challenge
:=
AChallenge
;
challengeToken
:=
AChallengeToken
;
// Call navigator.credentials.create() via WebAuthn API
asm
(
function
()
{
// Decode base64url challenge to Uint8Array
function b64urlToArr(b64) {
b64 = b64.replace(/-/g, '+').replace(/_/g, '/');
while (b64.length % 4) b64 += '=';
...
...
@@ -148,13 +165,11 @@ begin
for (var i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
return arr;
}
// Encode ArrayBuffer to base64url
function
arrToB64url
(
buf
)
{
var bin = String.fromCharCode.apply(null, new Uint8Array(buf));
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
);
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