Skip to content

Commit bd24f7b

Browse files
authored
feat(ui): add user details page (#1083)
Signed-off-by: Guillaume Belanger <guillaume.belanger27@gmail.com>
1 parent 06432ed commit bd24f7b

29 files changed

+1957
-528
lines changed

client/logs.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,32 @@ func (c *Client) ListAuditLogs(ctx context.Context, p *ListParams) (*ListAuditLo
5858
return &auditLogs, nil
5959
}
6060

61+
// ListAuditLogsByActor retrieves a paginated list of audit logs filtered by actor email.
62+
func (c *Client) ListAuditLogsByActor(ctx context.Context, actor string, p *ListParams) (*ListAuditLogsResponse, error) {
63+
resp, err := c.Requester.Do(ctx, &RequestOptions{
64+
Type: SyncRequest,
65+
Method: "GET",
66+
Path: "api/v1/logs/audit",
67+
Query: url.Values{
68+
"page": {fmt.Sprintf("%d", p.Page)},
69+
"per_page": {fmt.Sprintf("%d", p.PerPage)},
70+
"actor": {actor},
71+
},
72+
})
73+
if err != nil {
74+
return nil, err
75+
}
76+
77+
var auditLogs ListAuditLogsResponse
78+
79+
err = resp.DecodeResult(&auditLogs)
80+
if err != nil {
81+
return nil, err
82+
}
83+
84+
return &auditLogs, nil
85+
}
86+
6187
// GetAuditLogRetentionPolicy retrieves the current audit log retention policy.
6288
func (c *Client) GetAuditLogRetentionPolicy(ctx context.Context) (*GetAuditLogsRetentionPolicy, error) {
6389
resp, err := c.Requester.Do(ctx, &RequestOptions{

client/logs_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,3 +188,63 @@ func TestUpdateAuditLogRetentionPolicy_Failure(t *testing.T) {
188188
t.Fatalf("expected error, got none")
189189
}
190190
}
191+
192+
func TestListAuditLogsByActor_Success(t *testing.T) {
193+
fake := &fakeRequester{
194+
response: &client.RequestResponse{
195+
StatusCode: 200,
196+
Headers: http.Header{},
197+
Result: []byte(`{"items": [{"id": 1, "timestamp": "2023-10-01T12:00:00Z", "level": "info", "actor": "admin@example.com", "action": "create_user", "ip": "1.2.3.4", "details": "Created user"}], "page": 1, "per_page": 10, "total_count": 1}`),
198+
},
199+
err: nil,
200+
}
201+
clientObj := &client.Client{
202+
Requester: fake,
203+
}
204+
205+
ctx := context.Background()
206+
207+
params := &client.ListParams{
208+
Page: 1,
209+
PerPage: 10,
210+
}
211+
212+
resp, err := clientObj.ListAuditLogsByActor(ctx, "admin@example.com", params)
213+
if err != nil {
214+
t.Fatalf("expected no error, got: %v", err)
215+
}
216+
217+
if len(resp.Items) != 1 {
218+
t.Fatalf("expected 1 audit log, got %d", len(resp.Items))
219+
}
220+
221+
if resp.Items[0].Actor != "admin@example.com" {
222+
t.Fatalf("expected actor 'admin@example.com', got '%s'", resp.Items[0].Actor)
223+
}
224+
}
225+
226+
func TestListAuditLogsByActor_Failure(t *testing.T) {
227+
fake := &fakeRequester{
228+
response: &client.RequestResponse{
229+
StatusCode: 500,
230+
Headers: http.Header{},
231+
Result: []byte(`{"error": "Internal server error"}`),
232+
},
233+
err: errors.New("requester error"),
234+
}
235+
clientObj := &client.Client{
236+
Requester: fake,
237+
}
238+
239+
ctx := context.Background()
240+
241+
params := &client.ListParams{
242+
Page: 1,
243+
PerPage: 10,
244+
}
245+
246+
_, err := clientObj.ListAuditLogsByActor(ctx, "admin@example.com", params)
247+
if err == nil {
248+
t.Fatalf("expected error, got none")
249+
}
250+
}

client/users.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,3 +206,79 @@ func (c *Client) DeleteMyAPIToken(ctx context.Context, tokenID string) error {
206206

207207
return nil
208208
}
209+
210+
// ListUserAPITokens lists API tokens for a specific user with pagination. Requires admin privileges.
211+
func (c *Client) ListUserAPITokens(ctx context.Context, email string, p *ListParams) (*ListAPITokensResponse, error) {
212+
resp, err := c.Requester.Do(ctx, &RequestOptions{
213+
Type: SyncRequest,
214+
Method: "GET",
215+
Path: "api/v1/users/" + email + "/api-tokens",
216+
Query: url.Values{
217+
"page": {fmt.Sprintf("%d", p.Page)},
218+
"per_page": {fmt.Sprintf("%d", p.PerPage)},
219+
},
220+
})
221+
if err != nil {
222+
return nil, err
223+
}
224+
225+
var tokens ListAPITokensResponse
226+
227+
err = resp.DecodeResult(&tokens)
228+
if err != nil {
229+
return nil, err
230+
}
231+
232+
return &tokens, nil
233+
}
234+
235+
// CreateUserAPIToken creates a new API token for the specified user. Requires admin privileges.
236+
func (c *Client) CreateUserAPIToken(ctx context.Context, email string, opts *CreateAPITokenOptions) (*CreateAPITokenResponse, error) {
237+
payload := struct {
238+
Name string `json:"name"`
239+
Expiry string `json:"expiry,omitempty"`
240+
}{
241+
Name: opts.Name,
242+
Expiry: opts.Expiry,
243+
}
244+
245+
var body bytes.Buffer
246+
247+
err := json.NewEncoder(&body).Encode(payload)
248+
if err != nil {
249+
return nil, err
250+
}
251+
252+
resp, err := c.Requester.Do(ctx, &RequestOptions{
253+
Type: SyncRequest,
254+
Method: "POST",
255+
Path: "api/v1/users/" + email + "/api-tokens",
256+
Body: &body,
257+
})
258+
if err != nil {
259+
return nil, err
260+
}
261+
262+
var tokenResponse CreateAPITokenResponse
263+
264+
err = resp.DecodeResult(&tokenResponse)
265+
if err != nil {
266+
return nil, err
267+
}
268+
269+
return &tokenResponse, nil
270+
}
271+
272+
// DeleteUserAPIToken deletes an API token for the specified user by token ID. Requires admin privileges.
273+
func (c *Client) DeleteUserAPIToken(ctx context.Context, email string, tokenID string) error {
274+
_, err := c.Requester.Do(ctx, &RequestOptions{
275+
Type: SyncRequest,
276+
Method: "DELETE",
277+
Path: "api/v1/users/" + email + "/api-tokens/" + tokenID,
278+
})
279+
if err != nil {
280+
return err
281+
}
282+
283+
return nil
284+
}

client/users_test.go

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,3 +328,167 @@ func TestListMyAPITokens_Failure(t *testing.T) {
328328
t.Fatalf("expected no tokens, got: %v", tokens)
329329
}
330330
}
331+
332+
func TestListUserAPITokens_Success(t *testing.T) {
333+
fake := &fakeRequester{
334+
response: &client.RequestResponse{
335+
StatusCode: 200,
336+
Headers: http.Header{},
337+
Result: []byte(`{"items": [{"name": "agent-token"}], "page": 1, "per_page": 10, "total_count": 1}`),
338+
},
339+
err: nil,
340+
}
341+
clientObj := &client.Client{
342+
Requester: fake,
343+
}
344+
345+
ctx := context.Background()
346+
347+
param := &client.ListParams{
348+
Page: 1,
349+
PerPage: 10,
350+
}
351+
352+
resp, err := clientObj.ListUserAPITokens(ctx, "user@example.com", param)
353+
if err != nil {
354+
t.Fatalf("expected no error, got: %v", err)
355+
}
356+
357+
if len(resp.Items) != 1 {
358+
t.Fatalf("expected 1 token, got: %d", len(resp.Items))
359+
}
360+
361+
if resp.Items[0].Name != "agent-token" {
362+
t.Fatalf("expected token name 'agent-token', got: %s", resp.Items[0].Name)
363+
}
364+
}
365+
366+
func TestListUserAPITokens_Failure(t *testing.T) {
367+
fake := &fakeRequester{
368+
response: &client.RequestResponse{
369+
StatusCode: 404,
370+
Headers: http.Header{},
371+
Result: []byte(`{"error": "User not found"}`),
372+
},
373+
err: errors.New("requester error"),
374+
}
375+
clientObj := &client.Client{
376+
Requester: fake,
377+
}
378+
379+
ctx := context.Background()
380+
381+
param := &client.ListParams{
382+
Page: 1,
383+
PerPage: 10,
384+
}
385+
386+
tokens, err := clientObj.ListUserAPITokens(ctx, "nonexistent@example.com", param)
387+
if err == nil {
388+
t.Fatalf("expected error, got none")
389+
}
390+
391+
if tokens != nil {
392+
t.Fatalf("expected no tokens, got: %v", tokens)
393+
}
394+
}
395+
396+
func TestCreateUserAPIToken_Success(t *testing.T) {
397+
fake := &fakeRequester{
398+
response: &client.RequestResponse{
399+
StatusCode: 201,
400+
Headers: http.Header{},
401+
Result: []byte(`{"token": "ellacore_abc123_secret456"}`),
402+
},
403+
err: nil,
404+
}
405+
clientObj := &client.Client{
406+
Requester: fake,
407+
}
408+
409+
ctx := context.Background()
410+
411+
opts := &client.CreateAPITokenOptions{
412+
Name: "ci-pipeline",
413+
}
414+
415+
resp, err := clientObj.CreateUserAPIToken(ctx, "user@example.com", opts)
416+
if err != nil {
417+
t.Fatalf("expected no error, got: %v", err)
418+
}
419+
420+
if resp.Token != "ellacore_abc123_secret456" {
421+
t.Fatalf("expected token 'ellacore_abc123_secret456', got: %s", resp.Token)
422+
}
423+
}
424+
425+
func TestCreateUserAPIToken_Failure(t *testing.T) {
426+
fake := &fakeRequester{
427+
response: &client.RequestResponse{
428+
StatusCode: 400,
429+
Headers: http.Header{},
430+
Result: []byte(`{"error": "Token name must be between 3 and 50 characters"}`),
431+
},
432+
err: errors.New("requester error"),
433+
}
434+
clientObj := &client.Client{
435+
Requester: fake,
436+
}
437+
438+
ctx := context.Background()
439+
440+
opts := &client.CreateAPITokenOptions{
441+
Name: "ab",
442+
}
443+
444+
resp, err := clientObj.CreateUserAPIToken(ctx, "user@example.com", opts)
445+
if err == nil {
446+
t.Fatalf("expected error, got none")
447+
}
448+
449+
if resp != nil {
450+
t.Fatalf("expected no response, got: %v", resp)
451+
}
452+
}
453+
454+
func TestDeleteUserAPIToken_Success(t *testing.T) {
455+
fake := &fakeRequester{
456+
response: &client.RequestResponse{
457+
StatusCode: 200,
458+
Headers: http.Header{},
459+
Result: []byte(`{"message": "API token deleted successfully"}`),
460+
},
461+
err: nil,
462+
}
463+
clientObj := &client.Client{
464+
Requester: fake,
465+
}
466+
467+
ctx := context.Background()
468+
469+
err := clientObj.DeleteUserAPIToken(ctx, "user@example.com", "token-id-123")
470+
if err != nil {
471+
t.Fatalf("expected no error, got: %v", err)
472+
}
473+
}
474+
475+
func TestDeleteUserAPIToken_Failure(t *testing.T) {
476+
fake := &fakeRequester{
477+
response: &client.RequestResponse{
478+
StatusCode: 404,
479+
Headers: http.Header{},
480+
Result: []byte(`{"error": "API token not found"}`),
481+
},
482+
err: errors.New("requester error"),
483+
}
484+
clientObj := &client.Client{
485+
Requester: fake,
486+
}
487+
488+
ctx := context.Background()
489+
490+
err := clientObj.DeleteUserAPIToken(ctx, "user@example.com", "nonexistent-id")
491+
if err == nil {
492+
t.Fatalf("expected error, got none")
493+
}
494+
}

docs/how_to/ai_agents.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,15 @@ Ella Core ships with an [Agent Skill](https://agentskills.io/) that lets AI agen
1111
Before using the skill, you need:
1212

1313
1. **A running Ella Core instance** with its API accessible (e.g. `http://192.168.1.10:5000`).
14-
2. **An API token** — create one in the Ella Core UI under your user profile, or via the API. Tokens are prefixed with `ellacore_`.
14+
2. **A user for your AI agent with an API token** — create a user for your agent in the UI with a role that matches the permissions you want to grant (e.g. "network manager" for full network access, "read only" for monitoring). Then generate an API token for that user and copy it.
1515

1616
## 1. Install the skill
1717

1818
Download [`SKILL.md`](https://raw.githubusercontent.com/ellanetworks/core/main/.github/skills/ella-core-api/SKILL.md) and place it in a skills directory that your AI tool can discover (e.g. `<project>/.agents/skills/ella-core-api/SKILL.md`).
1919

2020
## 2. Prompt the agent
2121

22-
Once the skill is active, you can ask things like "Which subscribers used the most data over the last 7 days?".
22+
Once the skill is active, you can ask things like "Which subscribers used the most data over the last 7 days?". The agent will ask you for the Ella Core URL and an API token — use the token you generated earlier.
2323

2424
<figure markdown="span">
2525
<div data-cast="../../casts/ella-demo.cast"></div>

docs/reference/api/audit_logs.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,11 @@ This path returns the list of audit logs.
1616

1717
### Query Parameters
1818

19-
| Name | In | Type | Default | Allowed | Description |
20-
| ---------- | ----- | ---- | ------- | ------- | ----------------------------- |
21-
| `page` | query | int | `1` | `>= 1` | 1-based page index. |
22-
| `per_page` | query | int | `25` | `1…100` | Number of items per page. |
19+
| Name | In | Type | Default | Allowed | Description |
20+
| ---------- | ----- | ------ | ------- | ------- | --------------------------------------------------------------------------- |
21+
| `page` | query | int | `1` | `>= 1` | 1-based page index. |
22+
| `per_page` | query | int | `25` | `1…100` | Number of items per page. |
23+
| `actor` | query | string ||| Filter audit logs by actor email. When omitted, all audit logs are returned. |
2324

2425
### Sample Response
2526

0 commit comments

Comments
 (0)