Commit 214bb264 by Michael Brachmann

device management progress

parent 19737eac
...@@ -34,6 +34,8 @@ type ...@@ -34,6 +34,8 @@ 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 DeletePendingDevice(const DeviceName: string): TJSONObject;
end; end;
implementation implementation
......
...@@ -36,6 +36,8 @@ type ...@@ -36,6 +36,8 @@ 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 DeletePendingDevice(const DeviceName: string): TJSONObject;
end; end;
implementation implementation
...@@ -1261,7 +1263,7 @@ begin ...@@ -1261,7 +1263,7 @@ begin
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, user_agent, ' +
' registered_at, revoked_at, revoked_by ' + ' 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';
q.Open; q.Open;
...@@ -1281,6 +1283,7 @@ begin ...@@ -1281,6 +1283,7 @@ begin
else else
item.revoked_at := q.FieldByName('revoked_at').AsString; item.revoked_at := q.FieldByName('revoked_at').AsString;
item.revoked_by := q.FieldByName('revoked_by').AsString; item.revoked_by := q.FieldByName('revoked_by').AsString;
item.status := q.FieldByName('status').AsString;
Result.data.Add(item); Result.data.Add(item);
q.Next; q.Next;
...@@ -1361,8 +1364,8 @@ begin ...@@ -1361,8 +1364,8 @@ begin
q.Connection := conn; q.Connection := conn;
q.SQL.Text := q.SQL.Text :=
'UPDATE lems.device_registrations ' + 'UPDATE lems.device_registrations ' +
'SET revoked_at = NOW(), revoked_by = :REVOKED_BY ' + 'SET revoked_at = NOW(), revoked_by = :REVOKED_BY, status = ''revoked'' ' +
'WHERE credential_id = :CID AND revoked_at IS NULL'; 'WHERE credential_id = :CID AND status = ''active''';
q.ParamByName('REVOKED_BY').AsString := revokedBy; q.ParamByName('REVOKED_BY').AsString := revokedBy;
q.ParamByName('CID').AsString := Trim(CredentialId); q.ParamByName('CID').AsString := Trim(CredentialId);
q.ExecSQL; q.ExecSQL;
...@@ -1388,6 +1391,108 @@ begin ...@@ -1388,6 +1391,108 @@ begin
end; end;
function TApiService.AddPendingDevice(const DeviceName: string): TJSONObject;
var
conn: TUniConnection;
q: TUniQuery;
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;
end;
conn := OpenLemsConnection;
try
q := TUniQuery.Create(nil);
try
q.Connection := conn;
// Reject if a pending entry with this name 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);
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.');
Exit;
end;
q.Close;
q.SQL.Text :=
'INSERT INTO lems.device_registrations (device_name, status) ' +
'VALUES (:NAME, ''pending'')';
q.ParamByName('NAME').AsString := Trim(DeviceName);
q.ExecSQL;
Logger.Log(2, 'TApiService.AddPendingDevice - added "' + Trim(DeviceName) + '"');
Result.AddPair('status', 'ok');
Result.AddPair('message', 'Pending device added.');
finally
q.Free;
end;
finally
conn.Free;
end;
end;
function TApiService.DeletePendingDevice(const DeviceName: string): TJSONObject;
var
conn: TUniConnection;
q: TUniQuery;
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;
end;
conn := OpenLemsConnection;
try
q := TUniQuery.Create(nil);
try
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);
q.ExecSQL;
if q.RowsAffected > 0 then
begin
Logger.Log(2, 'TApiService.DeletePendingDevice - deleted "' + Trim(DeviceName) + '"');
Result.AddPair('status', 'ok');
Result.AddPair('message', 'Pending device removed.');
end
else
begin
Result.AddPair('status', 'error');
Result.AddPair('message', 'Pending device not found.');
end;
finally
q.Free;
end;
finally
conn.Free;
end;
end;
initialization initialization
RegisterServiceType(TApiService); RegisterServiceType(TApiService);
......
...@@ -49,6 +49,7 @@ type ...@@ -49,6 +49,7 @@ type
registered_at: string; registered_at: string;
revoked_at: string; revoked_at: string;
revoked_by: string; revoked_by: string;
status: string;
end; end;
TDeviceList = class TDeviceList = class
......
...@@ -162,12 +162,42 @@ end; ...@@ -162,12 +162,42 @@ end;
function TAuthService.BeginRegistration(const DeviceName: string): TJSONObject; function TAuthService.BeginRegistration(const DeviceName: string): TJSONObject;
var var
token, challengeB64: string; token, challengeB64: string;
q: TUniQuery;
begin begin
Logger.Log(2, 'AuthService.BeginRegistration - deviceName: "' + DeviceName + '"'); Logger.Log(2, 'AuthService.BeginRegistration - deviceName: "' + DeviceName + '"');
Result := TJSONObject.Create; Result := TJSONObject.Create;
TXDataOperationContext.Current.Handler.ManagedObjects.Add(Result); TXDataOperationContext.Current.Handler.ManagedObjects.Add(Result);
if Trim(DeviceName) = '' then
begin
Result.AddPair('status', 'error');
Result.AddPair('message', 'Device name is required.');
Exit;
end;
// Verify device name 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);
q.Open;
if q.IsEmpty then
begin
q.Close;
Logger.Log(2, 'BeginRegistration - device name not pre-authorized: "' + DeviceName + '"');
Result.AddPair('status', 'error');
Result.AddPair('message', 'Device name not recognized. Contact your administrator.');
Exit;
end;
q.Close;
finally
q.Free;
end;
token := MakeChallengeToken('reg'); token := MakeChallengeToken('reg');
challengeB64 := token.Split([':'], 4)[0]; challengeB64 := token.Split([':'], 4)[0];
...@@ -321,60 +351,44 @@ begin ...@@ -321,60 +351,44 @@ begin
Exit; Exit;
end; end;
// 9. Store credential // 9. Activate the pending row — UPDATE instead of INSERT
q := TUniQuery.Create(nil); q := TUniQuery.Create(nil);
try try
q.Connection := authDB.ucLemsOCSO; q.Connection := authDB.ucLemsOCSO;
// Check if already registered (re-registration attempt)
q.SQL.Text :=
'SELECT revoked_at FROM lems.device_registrations WHERE credential_id = :CID';
q.ParamByName('CID').AsString := Trim(CredentialId);
q.Open;
if not q.IsEmpty then
begin
if not q.FieldByName('revoked_at').IsNull then
begin
q.Close;
Logger.Log(2, 'CompleteRegistration - revoked credential: ' + Copy(CredentialId, 1, 20));
Result.AddPair('status', 'revoked');
Result.AddPair('message', 'Device access has been revoked by an administrator.');
Exit;
end;
// Already active — treat as success (idempotent)
q.Close;
Logger.Log(3, 'CompleteRegistration - already registered');
Result.AddPair('status', 'ok');
Result.AddPair('message', 'Device already registered.');
Result.AddPair('credentialId', Trim(CredentialId));
Exit;
end;
q.Close;
var ctx := THttpServerContext.Current; var ctx := THttpServerContext.Current;
var userAgent: string := ''; var userAgent: string := '';
if ctx <> nil then if ctx <> nil then
userAgent := ctx.Request.Headers.Get('User-Agent'); userAgent := ctx.Request.Headers.Get('User-Agent');
q.SQL.Text := q.SQL.Text :=
'INSERT INTO lems.device_registrations ' + 'UPDATE lems.device_registrations ' +
' (credential_id, device_name, user_agent, public_key_x, public_key_y, public_key_alg, sign_count) ' + 'SET credential_id = :CID, user_agent = :AGENT, ' +
'VALUES (:CID, :NAME, :AGENT, :KEYX, :KEYY, :ALG, :CNT)'; ' 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''';
q.ParamByName('CID').AsString := Trim(CredentialId); q.ParamByName('CID').AsString := Trim(CredentialId);
q.ParamByName('NAME').AsString := Trim(DeviceName);
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.ExecSQL; q.ExecSQL;
if q.RowsAffected = 0 then
begin
Logger.Log(2, 'CompleteRegistration - no pending row for "' + DeviceName + '"');
Result.AddPair('status', 'error');
Result.AddPair('message', 'Device name is not pending registration. Contact your administrator.');
Exit;
end;
finally finally
q.Free; q.Free;
end; end;
Logger.Log(2, 'CompleteRegistration - stored credential for "' + DeviceName + '"'); Logger.Log(2, 'CompleteRegistration - activated credential for "' + DeviceName + '"');
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));
......
-- Migration: add pending-device support to device_registrations
-- Run once against the lems database.
-- 1. Add status column (active for all existing rows)
ALTER TABLE lems.device_registrations
ADD COLUMN IF NOT EXISTS status VARCHAR(10) NOT NULL DEFAULT 'active';
-- 2. Mark already-revoked rows correctly
UPDATE lems.device_registrations
SET status = 'revoked'
WHERE revoked_at IS NOT NULL AND status = 'active';
-- 3. Allow credential_id / public-key columns to be NULL for pending rows
ALTER TABLE lems.device_registrations
ALTER COLUMN credential_id DROP NOT NULL;
ALTER TABLE lems.device_registrations
ALTER COLUMN public_key_x DROP NOT NULL;
ALTER TABLE lems.device_registrations
ALTER COLUMN public_key_y DROP NOT NULL;
...@@ -155,13 +155,13 @@ procedure TAuthService.BeginRegistration(ADeviceName: string; ...@@ -155,13 +155,13 @@ procedure TAuthService.BeginRegistration(ADeviceName: string;
procedure OnLoad(Response: TXDataClientResponse); procedure OnLoad(Response: TXDataClientResponse);
var var
resp: JS.TJSObject; resp: JS.TJSObject;
challenge, token, errMsg: string; challenge, token, status: string;
begin begin
resp := JS.TJSObject(Response.Result); resp := JS.TJSObject(Response.Result);
errMsg := JS.toString(resp.Properties['error']); status := JS.toString(resp.Properties['status']);
if errMsg <> '' then if status = 'error' then
begin begin
AError(errMsg); AError(JS.toString(resp.Properties['message']));
Exit; Exit;
end; end;
challenge := JS.toString(resp.Properties['challenge']); challenge := JS.toString(resp.Properties['challenge']);
......
...@@ -36,6 +36,28 @@ object FViewDeviceManager: TFViewDeviceManager ...@@ -36,6 +36,28 @@ object FViewDeviceManager: TFViewDeviceManager
OnClick = btnCloseNotificationClick OnClick = btnCloseNotificationClick
end end
end end
object edtNewDeviceName: TWebEdit
Left = 8
Top = 50
Width = 200
Height = 25
ElementID = 'view.devmgr.newname'
HeightPercent = 100.000000000000000000
WidthPercent = 100.000000000000000000
TabOrder = 1
end
object btnAddDevice: TWebButton
Left = 220
Top = 50
Width = 75
Height = 25
Caption = 'Add'
ElementID = 'view.devmgr.btnadd'
HeightPercent = 100.000000000000000000
WidthPercent = 100.000000000000000000
TabOrder = 2
OnClick = btnAddDeviceClick
end
object XDataWebClient: TXDataWebClient object XDataWebClient: TXDataWebClient
Connection = DMConnection.ApiConnection Connection = DMConnection.ApiConnection
Left = 800 Left = 800
......
...@@ -20,6 +20,22 @@ ...@@ -20,6 +20,22 @@
aria-label="Close"></button> aria-label="Close"></button>
</div> </div>
<!-- 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>
<input type="text"
id="view.devmgr.newname"
class="form-control form-control-sm"
placeholder="Device name"
style="max-width: 220px;">
<button id="view.devmgr.btnadd"
class="btn btn-primary btn-sm">Add</button>
</div>
</div>
</div>
<!-- Device table --> <!-- Device table -->
<div class="table-responsive flex-grow-1"> <div class="table-responsive flex-grow-1">
<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">
......
...@@ -13,16 +13,21 @@ type ...@@ -13,16 +13,21 @@ type
pnlMessage: TWebPanel; pnlMessage: TWebPanel;
lblMessage: TWebLabel; lblMessage: TWebLabel;
btnCloseNotification: TWebButton; btnCloseNotification: TWebButton;
edtNewDeviceName: TWebEdit;
btnAddDevice: TWebButton;
procedure WebFormCreate(Sender: TObject); procedure WebFormCreate(Sender: TObject);
procedure btnCloseNotificationClick(Sender: TObject); procedure btnCloseNotificationClick(Sender: TObject);
procedure btnAddDeviceClick(Sender: TObject);
private private
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, AUserAgent,
ARegisteredAt, ARevokedAt: string); 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 AName: string);
public public
end; end;
...@@ -44,6 +49,19 @@ begin ...@@ -44,6 +49,19 @@ 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;
...@@ -76,22 +94,21 @@ begin ...@@ -76,22 +94,21 @@ begin
end; end;
procedure TFViewDeviceManager.AddDeviceRow(const ACredentialId, AName, AUserAgent, procedure TFViewDeviceManager.AddDeviceRow(const ACredentialId, AName, AUserAgent,
ARegisteredAt, ARevokedAt: string); ARegisteredAt, AStatus: string);
var var
tbody, tr, tdName, tdAgent, tdReg, tdStatus, tdAction: TJSHTMLElement; tbody, tr, tdName, tdAgent, tdReg, tdStatus, tdAction: TJSHTMLElement;
btn: TJSHTMLElement; btn: TJSHTMLElement;
isRevoked: Boolean;
displayDate: string; displayDate: string;
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;
isRevoked := ARevokedAt <> '';
tr := TJSHTMLElement(document.createElement('tr')); tr := TJSHTMLElement(document.createElement('tr'));
if isRevoked then if AStatus = 'revoked' then
tr.classList.add('table-secondary'); tr.classList.add('table-secondary')
else if AStatus = 'pending' then
tr.classList.add('table-warning');
// Device name // Device name
tdName := TJSHTMLElement(document.createElement('td')); tdName := TJSHTMLElement(document.createElement('td'));
...@@ -108,7 +125,10 @@ begin ...@@ -108,7 +125,10 @@ begin
tdAgent.style.setProperty('overflow', 'hidden'); tdAgent.style.setProperty('overflow', 'hidden');
tdAgent.style.setProperty('text-overflow', 'ellipsis'); tdAgent.style.setProperty('text-overflow', 'ellipsis');
tdAgent.style.setProperty('white-space', 'nowrap'); tdAgent.style.setProperty('white-space', 'nowrap');
tdAgent.innerText := AUserAgent; if AStatus = 'pending' then
tdAgent.innerHTML := '<em class="text-muted">Not yet registered</em>'
else
tdAgent.innerText := AUserAgent;
tr.appendChild(tdAgent); tr.appendChild(tdAgent);
// Registered at — trim to seconds, replace T with space // Registered at — trim to seconds, replace T with space
...@@ -116,12 +136,17 @@ begin ...@@ -116,12 +136,17 @@ begin
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'));
tdReg.innerText := displayDate; if AStatus = 'pending' then
tdReg.innerHTML := '<em class="text-muted">—</em>'
else
tdReg.innerText := displayDate;
tr.appendChild(tdReg); tr.appendChild(tdReg);
// Status badge // Status badge
tdStatus := TJSHTMLElement(document.createElement('td')); tdStatus := TJSHTMLElement(document.createElement('td'));
if isRevoked then if AStatus = 'pending' then
tdStatus.innerHTML := '<span class="badge bg-warning text-dark">Pending</span>'
else if AStatus = 'revoked' 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>';
...@@ -129,12 +154,22 @@ begin ...@@ -129,12 +154,22 @@ begin
// Action button // Action button
tdAction := TJSHTMLElement(document.createElement('td')); tdAction := TJSHTMLElement(document.createElement('td'));
if not isRevoked then if AStatus = 'pending' 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);
end);
tdAction.appendChild(btn);
end
else if AStatus = 'active' 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';
btn.innerText := 'Revoke'; btn.innerText := 'Revoke';
// Capture token and name in a Delphi closure
btn.addEventListener('click', procedure(Event: TJSMouseEvent) btn.addEventListener('click', procedure(Event: TJSMouseEvent)
begin begin
RevokeDevice(ACredentialId, AName); RevokeDevice(ACredentialId, AName);
...@@ -182,7 +217,7 @@ begin ...@@ -182,7 +217,7 @@ begin
string(item['device_name']), string(item['device_name']),
string(item['user_agent']), string(item['user_agent']),
string(item['registered_at']), string(item['registered_at']),
string(item['revoked_at']) string(item['status'])
); );
end; end;
...@@ -216,4 +251,53 @@ begin ...@@ -216,4 +251,53 @@ begin
end; end;
end; end;
procedure TFViewDeviceManager.AddPendingDevice(const AName: string);
var
resp: TXDataClientResponse;
res: TJSObject;
status: string;
begin
try
resp := await(XDataWebClient.RawInvokeAsync('IApiService.AddPendingDevice', [AName]));
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);
LoadDevices;
end
else
ShowNotification(string(res['message']));
except
on E: Exception do
ShowNotification('Add failed: ' + E.Message);
end;
end;
procedure TFViewDeviceManager.DeletePendingDevice(const AName: string);
var
resp: TXDataClientResponse;
res: TJSObject;
status: string;
begin
try
resp := await(XDataWebClient.RawInvokeAsync('IApiService.DeletePendingDevice', [AName]));
res := TJSObject(resp.Result);
status := string(res['status']);
if status = 'ok' then
begin
ShowNotification('Pending device "' + AName + '" has been cancelled.', False);
LoadDevices;
end
else
ShowNotification(string(res['message']));
except
on E: Exception do
ShowNotification('Cancel failed: ' + E.Message);
end;
end;
end. end.
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