Skip to content
This repository was archived by the owner on Jul 12, 2023. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 0 additions & 8 deletions cmd/server/assets/realmadmin/stats.html
Original file line number Diff line number Diff line change
Expand Up @@ -259,14 +259,6 @@ <h5 class="modal-title">Codes issued by external issuers by day</h5>
drawExternalIssuersTable();
}

// utcDate parses the given RFC-3339 date as a javascript date, then
// converts it to a UTC date.
function utcDate(str) {
let d = new Date(str);
let offset = d.getTimezoneOffset() * 60 * 1000;
return new Date(d.getTime() + offset);
}

function drawRealmCharts() {
$.ajax({
url: '/realm/stats.json',
Expand Down
8 changes: 8 additions & 0 deletions cmd/server/assets/static/js/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -694,3 +694,11 @@ function setTimeOrExpired(element, time, expiredCallback) {
}
return element.html(`${prefix} ${time}`.trim());
}

// utcDate parses the given RFC-3339 date as a javascript date, then converts it
// to a UTC date.
function utcDate(str) {
let d = new Date(str);
let offset = d.getTimezoneOffset() * 60 * 1000;
return new Date(d.getTime() + offset);
}
179 changes: 115 additions & 64 deletions cmd/server/assets/users/show.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,17 @@
{{template "flash" .}}

<h1>{{$user.Name}}</h1>
<p class="float-right">
<a href="/realm/users/{{$user.ID}}/edit">Edit</a>
</p>
<p>
Here is information about the user.
</p>
<p>Here is information about the user.</p>

<div class="card mb-3 shadow-sm">
<div class="card-header">Details</div>
<div class="card-body">
<div class="card-header">
<span class="oi oi-person mr-2 ml-n1" aria-hidden="true"></span>
Details about {{$user.Name}}
<a href="/realm/users/{{$user.ID}}/edit" class="float-right mr-n1 text-body" id="edit" data-toggle="tooltip" title="Edit this user">
<span class="oi oi-pencil" aria-hidden="true"></span>
</a>
</div>
<div class="card-body mb-n3">
<h6 class="card-title">Name</h6>
<div class="mb-3 mt-n2">
{{$user.Name}}
Expand All @@ -37,6 +38,15 @@ <h6 class="card-title">Email</h6>
{{$user.Email}}
</div>

<h6 class="card-title">Password</h6>
<div class="mb-3 mt-n2">
<a href="/realm/users/{{$user.ID}}/reset-password"
data-method="POST"
data-confirm="Are you sure you want to reset this user's password?">
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for the data-confirm

Send password reset
</a>
</div>

<h6 class="card-title">Realm admin</h6>
<div class="mb-3 mt-n2">
{{if $user.CanAdminRealm $currentRealm.ID}}
Expand All @@ -45,74 +55,115 @@ <h6 class="card-title">Realm admin</h6>
<div class="card-text mb-3 mt-n2">Disabled</div>
{{end}}
</div>

<a href="/realm/users/{{$user.ID}}/reset-password" data-method="POST" class="btn btn-primary btn-block">Send password reset</a>
</div>
</div>

<div class="card mb-3 shadow-sm">
<div class="card-header">Statistics</div>
<div class="card-body table-responsive">
{{if $stats}}
<div id="chart" class="mb-3" style="height: 300px;">
<span>Loading chart...</span>
</div>
<table class="table table-bordered table-striped">
<thead>
<tr>
<th scope="col" width="125px">Date</th>
<th scope="col">Keys issued</th>
</tr>
</thead>
<tbody>
{{range $stat := $stats}}
<tr>
<td>{{$stat.Date.Format "2006-01-02"}}</td>
<td>{{$stat.CodesIssued}}</td>
</tr>
{{end}}
</tbody>
</table>
<div class="font-italic">
This data is refreshed every 5 minutes.
<div class="card-header">
<span class="oi oi-bar-chart mr-2 ml-n1"></span>
Statistics
</div>
<div class="card-body">
<div id="user_stats_chart" class="container d-flex h-100 w-100" style="min-height:300px;">
<p class="justify-content-center align-self-center text-center font-italic w-100">Loading chart...</p>
</div>
{{else}}
<p>This user has not recently issued any codes.</p>
{{end}}
</div>
<small class="card-footer d-flex justify-content-between text-muted">
<span>
This data is refreshed every 30 minutes.
<a href="#" data-toggle="modal" data-target="#user-stats-modal">Learn more</a>
</span>
<span>
<span class="mr-1">Export as:</span>
<a href="/realm/users/{{$user.ID}}/stats.csv" class="mr-1">CSV</a>
<a href="/realm/users/{{$user.ID}}/stats.json">JSON</a>
</span>
</small>
</div>

<a class="card-link" href="/realm/users">&larr; All users</a>
<div class="modal fade" id="user-stats-modal" data-backdrop="static" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Codes issued by {{$user.Name}}</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body mb-n3">
<p>
This chart reflects the number of codes issued each by
{{$user.Name}} ({{$user.Email}}) for {{$currentRealm.Name}}.
</p>
<p>
This chart does <u>not</u> include codes that were issued via the
API, codes that were issued by other users of the system, or codes
that were issued by this user against a different realm.
</p>
</div>
</div>
</div>
</div>
</main>

{{if $stats}}
<script src="https://www.gstatic.com/charts/loader.js" type="text/javascript"></script>
<script type="text/javascript">
google.charts.load('current', {packages: ['line']});
google.charts.setOnLoadCallback(drawChart)

function drawChart() {
let arr = [
{{range $stat := $stats}}
['{{$stat.Date.Format "Jan 2"}}', {{$stat.CodesIssued}}],
{{end}}
];
// Reverse the array, so the dates are in ascending order.
arr = arr.reverse();
arr.unshift(['Date', 'Codes issued']);
let data = google.visualization.arrayToDataTable(arr);

let options = {
colors: ['#007bff'],
legend: {position: 'none'},
tooltip: {trigger: 'focus'},
};

let chart = new google.charts.Line(document.getElementById('chart'));
chart.draw(data, google.charts.Line.convertOptions(options));
<script src="https://www.gstatic.com/charts/loader.js"></script>
<script>
let userStatsChartDiv = document.getElementById('user_stats_chart');
let dateFormatter;

google.charts.load('current', {
packages: ['corechart'],
callback: function() {
dateFormatter = new google.visualization.DateFormat({
pattern: 'MMM dd',
});

drawStats();
},
});

function drawStats() {
$.ajax({
url: '/realm/users/{{$user.ID}}/stats.json',
dataType: 'json',
})
.done(function(data, status, xhr) {
if (!data.statistics) {
$(userStatsChartDiv).find('p').text('This user has not yet issued any codes.');
return;
}

var dataTable = new google.visualization.DataTable();
dataTable.addColumn('date', 'Date');
dataTable.addColumn('number', 'Issued');

data.statistics.reverse().forEach(function(row) {
dataTable.addRow([utcDate(row.date), row.data.codes_issued]);
});

dateFormatter.format(dataTable, 0);

let options = {
colors: ['#007bff'],
chartArea: {
left: 30, // leave room for y-axis labels
top: 20,
bottom: 20,
width: '100%',
height: '100%',
},
hAxis: { format: 'M/d' },
legend: 'none',
};

let chart = new google.visualization.LineChart(userStatsChartDiv);
chart.draw(dataTable, options);
})
.fail(function(xhr, status, err) {
flash.error('Failed to render user stats: ' + err);
});
}
</script>
{{end}}
</body>
</html>
{{end}}
2 changes: 2 additions & 0 deletions internal/routes/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,8 @@ func userRoutes(r *mux.Router, c *user.Controller) {
r.Handle("/{id:[0-9]+}", c.HandleShow()).Methods("GET")
r.Handle("/{id:[0-9]+}", c.HandleUpdate()).Methods("PATCH")
r.Handle("/{id:[0-9]+}", c.HandleDelete()).Methods("DELETE")
r.Handle("/{id:[0-9]+}/stats.json", c.HandleUserStats()).Methods("GET")
r.Handle("/{id:[0-9]+}/stats.csv", c.HandleUserStats()).Methods("GET")
r.Handle("/{id:[0-9]+}/reset-password", c.HandleResetPassword()).Methods("POST")
}

Expand Down
6 changes: 6 additions & 0 deletions internal/routes/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,12 @@ func TestRoutes_userRoutes(t *testing.T) {
{
req: httptest.NewRequest("DELETE", "/12345", nil),
},
{
req: httptest.NewRequest("GET", "/12345/stats.json", nil),
},
{
req: httptest.NewRequest("GET", "/12345/stats.csv", nil),
},
{
req: httptest.NewRequest("POST", "/12345/reset-password", nil),
},
Expand Down
7 changes: 7 additions & 0 deletions pkg/controller/user/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,10 @@ func New(
h: h,
}
}

func (c *Controller) findUser(currentUser *database.User, realm *database.Realm, id interface{}) (*database.User, error) {
if currentUser.SystemAdmin {
return c.db.FindUser(id)
}
return realm.FindUser(c.db, id)
}
8 changes: 1 addition & 7 deletions pkg/controller/user/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,13 +104,7 @@ func (c *Controller) HandleCreate() http.Handler {

flash.Alert("Successfully created user %v.", user.Name)

stats, err := c.getStats(ctx, user, realm)
if err != nil {
controller.InternalError(w, r, c.h, err)
return
}

c.renderShow(ctx, w, user, stats)
c.renderShow(ctx, w, user)
})
}

Expand Down
38 changes: 2 additions & 36 deletions pkg/controller/user/show.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,8 @@ package user

import (
"context"
"fmt"
"net/http"
"time"

"github.com/google/exposure-notifications-verification-server/pkg/cache"
"github.com/google/exposure-notifications-verification-server/pkg/controller"
"github.com/google/exposure-notifications-verification-server/pkg/database"
"github.com/gorilla/mux"
Expand Down Expand Up @@ -66,43 +63,12 @@ func (c *Controller) Show(w http.ResponseWriter, r *http.Request, resetPassword
return
}

userStats, err := c.getStats(ctx, user, realm)
if err != nil {
controller.InternalError(w, r, c.h, err)
return
}

c.renderShow(ctx, w, user, userStats)
}

// Get and cache the stats for this user.
func (c *Controller) getStats(ctx context.Context, user *database.User, realm *database.Realm) ([]*database.UserStats, error) {
var stats []*database.UserStats
cacheKey := &cache.Key{
Namespace: "stats:user",
Key: fmt.Sprintf("%d:%d", realm.ID, user.ID),
}
if err := c.cacher.Fetch(ctx, cacheKey, &stats, 5*time.Minute, func() (interface{}, error) {
now := time.Now().UTC()
past := now.Add(-14 * 24 * time.Hour)
return user.Stats(c.db, realm.ID, past, now)
}); err != nil {
return nil, err
}
return stats, nil
}

func (c *Controller) findUser(currentUser *database.User, realm *database.Realm, id interface{}) (*database.User, error) {
if currentUser.SystemAdmin {
return c.db.FindUser(id)
}
return realm.FindUser(c.db, id)
c.renderShow(ctx, w, user)
}

func (c *Controller) renderShow(ctx context.Context, w http.ResponseWriter, user *database.User, stats []*database.UserStats) {
func (c *Controller) renderShow(ctx context.Context, w http.ResponseWriter, user *database.User) {
m := controller.TemplateMapFromContext(ctx)
m.Title("User: %s", user.Name)
m["user"] = user
m["stats"] = stats
c.h.RenderHTML(w, "users/show", m)
}
Loading