Commit 27821f79 by Michael Brachmann

app links and phone number

parent 45db3678
...@@ -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
......
...@@ -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 name already exists // Reject if a pending entry with this phone 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,21 +1544,26 @@ begin ...@@ -1512,21 +1544,26 @@ 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
begin normalizedPhone := NormalizePhoneE164Api(PhoneNumber);
Result.AddPair('status', 'error'); except
Result.AddPair('message', 'Device name is required.'); on E: Exception do
Exit; begin
Result.AddPair('status', 'error');
Result.AddPair('message', E.Message);
Exit;
end;
end; end;
conn := OpenLemsConnection; conn := OpenLemsConnection;
...@@ -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);
......
...@@ -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/rpName // WebAuthn registration — step 1: server checks phone pre-auth, returns challenge
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
......
...@@ -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: "' + DeviceName + '"'); Logger.Log(2, 'BeginRegistration - phone not pre-authorized: "' + normalizedPhone + '"');
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 "' + DeviceName + '"'); Logger.Log(2, 'CompleteRegistration - no pending row for phone "' + normalizedPhone + '"');
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 "' + DeviceName + '"'); Logger.Log(2, 'CompleteRegistration - activated credential for phone "' + normalizedPhone + '"');
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));
......
...@@ -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;
......
-- 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;
...@@ -42,9 +42,9 @@ type ...@@ -42,9 +42,9 @@ type
procedure ClearCredentialId; procedure ClearCredentialId;
// WebAuthn registration — two-step // WebAuthn registration — two-step
procedure BeginRegistration(ADeviceName: string; procedure BeginRegistration(APhoneNumber: string;
ASuccess: TOnBeginSuccess; AError: TOnDeviceError); ASuccess: TOnBeginSuccess; AError: TOnDeviceError);
procedure CompleteRegistration(ADeviceName, ACredentialId, procedure CompleteRegistration(APhoneNumber, 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(ADeviceName: string; procedure TAuthService.BeginRegistration(APhoneNumber: 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',
[ADeviceName], [APhoneNumber],
@OnLoad, @OnError @OnLoad, @OnError
); );
end; end;
procedure TAuthService.CompleteRegistration(ADeviceName, ACredentialId, procedure TAuthService.CompleteRegistration(APhoneNumber, 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',
[ADeviceName, ACredentialId, AAttestationObject, AClientDataJSON, AChallengeToken], [APhoneNumber, ACredentialId, AAttestationObject, AClientDataJSON, AChallengeToken],
@OnLoad, @OnError @OnLoad, @OnError
); );
end; end;
......
...@@ -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
......
<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 registered. No devices found.
</p> </p>
</div> </div>
......
...@@ -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, AUserAgent, procedure AddDeviceRow(const ACredentialId, AName, APhoneNumber,
ARegisteredAt, AStatus: string); AUserAgent, ARegisteredAt, 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); APhoneNumber, AUserAgent, ARegisteredAt, 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'));
tdAgent.setAttribute('title', AUserAgent); if not isPending then
tdAgent.style.setProperty('max-width', '260px'); begin
tdAgent.style.setProperty('overflow', 'hidden'); tdAgent.setAttribute('title', AUserAgent);
tdAgent.style.setProperty('text-overflow', 'ellipsis'); tdAgent.style.setProperty('max-width', '220px');
tdAgent.style.setProperty('white-space', 'nowrap'); tdAgent.style.setProperty('overflow', 'hidden');
if AStatus = 'pending' then tdAgent.style.setProperty('text-overflow', 'ellipsis');
tdAgent.innerHTML := '<em class="text-muted">Not yet registered</em>' tdAgent.style.setProperty('white-space', 'nowrap');
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.innerHTML := '<em class="text-muted">—</em>' tdReg.innerText := displayDate
else else
tdReg.innerText := displayDate; tdReg.innerHTML := '<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 // Actions
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(APhoneNumber, AName);
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;
end; end;
procedure TFViewDeviceManager.DeletePendingDevice(const AName: string); procedure TFViewDeviceManager.SendAppLink(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.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); 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;
......
...@@ -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 edtDeviceName: TWebEdit object edtPhoneNumber: TWebEdit
Left = 240 Left = 240
Top = 136 Top = 136
Width = 121 Width = 121
Height = 21 Height = 21
ElementID = 'view.devicereg.edtdevicename' ElementID = 'view.devicereg.edtphonenumber'
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
......
...@@ -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.edtdevicename" <input id="view.devicereg.edtphonenumber"
class="form-control" class="form-control"
type="text" type="tel"
placeholder="e.g. Dispatch Console, Patrol Laptop" placeholder="(303) 555-1234"
autofocus> autofocus>
</div> </div>
......
...@@ -10,7 +10,7 @@ uses ...@@ -10,7 +10,7 @@ uses
type type
TFViewDeviceRegistration = class(TWebForm) TFViewDeviceRegistration = class(TWebForm)
edtDeviceName: TWebEdit; edtPhoneNumber: 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(ADeviceName, AChallenge, AChallengeToken: string); procedure DoWebAuthnCreate(APhoneNumber, 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(
ADeviceName, AChallenge, AChallengeToken: string); APhoneNumber, 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);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment