Check if we have oauth information, only prompt if its missing

Also include a rough dashboard of information that we'll pull from
Fieldseeker
This commit is contained in:
Eli Ribble 2025-11-06 22:58:18 +00:00
parent fa89e0719f
commit 72cbe2de5e
No known key found for this signature in database
10 changed files with 567 additions and 100 deletions

View file

@ -17,6 +17,7 @@ import (
"github.com/Gleipnir-Technology/arcgis-go" "github.com/Gleipnir-Technology/arcgis-go"
"github.com/Gleipnir-Technology/nidus-sync/models" "github.com/Gleipnir-Technology/nidus-sync/models"
"github.com/Gleipnir-Technology/nidus-sync/sql"
"github.com/aarondl/opt/omit" "github.com/aarondl/opt/omit"
) )
@ -155,6 +156,13 @@ func handleOauthAccessCode(ctx context.Context, user *models.User, code string)
return nil return nil
} }
func hasFieldseekerConnection(ctx context.Context, user *models.User) (bool, error) {
result, err := sql.OauthTokenByUserId(user.ID).All(ctx, PGInstance.BobDB)
if err != nil {
return false, err
}
return len(result) > 0, nil
}
func redirectURL() string { func redirectURL() string {
return BaseURL + "/arcgis/oauth/callback" return BaseURL + "/arcgis/oauth/callback"
} }

View file

@ -167,7 +167,16 @@ func getRoot(w http.ResponseWriter, r *http.Request) {
errorCode := r.URL.Query().Get("error") errorCode := r.URL.Query().Get("error")
err = htmlSignin(w, errorCode) err = htmlSignin(w, errorCode)
} else { } else {
has, err := hasFieldseekerConnection(r.Context(), user)
if err != nil {
respondError(w, "Failed to check for ArcGIS connection", err, http.StatusInternalServerError)
return
}
if has {
err = htmlDashboard(w, user) err = htmlDashboard(w, user)
} else {
err = htmlOauthPrompt(w, user)
}
} }
if err != nil { if err != nil {
respondError(w, "Failed to render root", err, http.StatusInternalServerError) respondError(w, "Failed to render root", err, http.StatusInternalServerError)

12
html.go
View file

@ -15,6 +15,7 @@ import (
var ( var (
dashboard = newBuiltTemplate("dashboard", "authenticated") dashboard = newBuiltTemplate("dashboard", "authenticated")
oauthPrompt = newBuiltTemplate("oauth-prompt", "authenticated")
report = newBuiltTemplate("report", "base") report = newBuiltTemplate("report", "base")
reportConfirmation = newBuiltTemplate("report-confirmation", "base") reportConfirmation = newBuiltTemplate("report-confirmation", "base")
reportContribute = newBuiltTemplate("report-contribute", "base") reportContribute = newBuiltTemplate("report-contribute", "base")
@ -99,6 +100,17 @@ func htmlDashboard(w io.Writer, user *models.User) error {
return dashboard.ExecuteTemplate(w, data) return dashboard.ExecuteTemplate(w, data)
} }
func htmlOauthPrompt(w io.Writer, user *models.User) error {
data := ContentDashboard{
User: User{
DisplayName: user.DisplayName,
Initials: extractInitials(user.DisplayName),
Username: user.Username,
},
}
return oauthPrompt.ExecuteTemplate(w, data)
}
func htmlReport(w io.Writer) error { func htmlReport(w io.Writer) error {
url := BaseURL + "/report/t78fd3" url := BaseURL + "/report/t78fd3"
data := ContentReportDiagnostic{ data := ContentReportDiagnostic{

102
sql/oauth_by_user_id.bob.go Normal file
View file

@ -0,0 +1,102 @@
// Code generated by BobGen psql v0.41.1. DO NOT EDIT.
// This file is meant to be re-generated in place and/or deleted at any time.
package sql
import (
"context"
_ "embed"
"io"
"iter"
"time"
"github.com/stephenafamo/bob"
"github.com/stephenafamo/bob/dialect/psql"
"github.com/stephenafamo/bob/dialect/psql/dialect"
"github.com/stephenafamo/bob/orm"
"github.com/stephenafamo/scan"
)
//go:embed oauth_by_user_id.bob.sql
var formattedQueries_oauth_by_user_id string
var oauthTokenByUserIdSQL = formattedQueries_oauth_by_user_id[156:440]
type OauthTokenByUserIdQuery = orm.ModQuery[*dialect.SelectQuery, oauthTokenByUserId, OauthTokenByUserIdRow, []OauthTokenByUserIdRow, oauthTokenByUserIdTransformer]
func OauthTokenByUserId(UserID int32) *OauthTokenByUserIdQuery {
var expressionTypArgs oauthTokenByUserId
expressionTypArgs.UserID = psql.Arg(UserID)
return &OauthTokenByUserIdQuery{
Query: orm.Query[oauthTokenByUserId, OauthTokenByUserIdRow, []OauthTokenByUserIdRow, oauthTokenByUserIdTransformer]{
ExecQuery: orm.ExecQuery[oauthTokenByUserId]{
BaseQuery: bob.BaseQuery[oauthTokenByUserId]{
Expression: expressionTypArgs,
Dialect: dialect.Dialect,
QueryType: bob.QueryTypeSelect,
},
},
Scanner: func(context.Context, []string) (func(*scan.Row) (any, error), func(any) (OauthTokenByUserIdRow, error)) {
return func(row *scan.Row) (any, error) {
var t OauthTokenByUserIdRow
row.ScheduleScanByIndex(0, &t.ID)
row.ScheduleScanByIndex(1, &t.AccessToken)
row.ScheduleScanByIndex(2, &t.Expires)
row.ScheduleScanByIndex(3, &t.RefreshToken)
row.ScheduleScanByIndex(4, &t.Username)
row.ScheduleScanByIndex(5, &t.UserID)
return &t, nil
}, func(v any) (OauthTokenByUserIdRow, error) {
return *(v.(*OauthTokenByUserIdRow)), nil
}
},
},
Mod: bob.ModFunc[*dialect.SelectQuery](func(q *dialect.SelectQuery) {
q.AppendSelect(expressionTypArgs.subExpr(7, 247))
q.SetTable(expressionTypArgs.subExpr(253, 264))
q.AppendWhere(expressionTypArgs.subExpr(272, 284))
}),
}
}
type OauthTokenByUserIdRow = struct {
ID int32 `db:"id"`
AccessToken string `db:"access_token"`
Expires time.Time `db:"expires"`
RefreshToken string `db:"refresh_token"`
Username string `db:"username"`
UserID int32 `db:"user_id"`
}
type oauthTokenByUserIdTransformer = bob.SliceTransformer[OauthTokenByUserIdRow, []OauthTokenByUserIdRow]
type oauthTokenByUserId struct {
UserID bob.Expression
}
func (o oauthTokenByUserId) args() iter.Seq[orm.ArgWithPosition] {
return func(yield func(arg orm.ArgWithPosition) bool) {
if !yield(orm.ArgWithPosition{
Name: "userID",
Start: 282,
Stop: 284,
Expression: o.UserID,
}) {
return
}
}
}
func (o oauthTokenByUserId) raw(from, to int) string {
return oauthTokenByUserIdSQL[from:to]
}
func (o oauthTokenByUserId) subExpr(from, to int) bob.Expression {
return orm.ArgsToExpression(oauthTokenByUserIdSQL, from, to, o.args())
}
func (o oauthTokenByUserId) WriteSQL(ctx context.Context, w io.Writer, d bob.Dialect, start int) ([]any, error) {
return o.subExpr(0, len(oauthTokenByUserIdSQL)).WriteSQL(ctx, w, d, start)
}

View file

@ -0,0 +1,6 @@
-- Code generated by BobGen psql v0.41.1. DO NOT EDIT.
-- This file is meant to be re-generated in place and/or deleted at any time.
-- OauthTokenByUserId
SELECT "oauth_token"."id" AS "id", "oauth_token"."access_token" AS "access_token", "oauth_token"."expires" AS "expires", "oauth_token"."refresh_token" AS "refresh_token", "oauth_token"."username" AS "username", "oauth_token"."user_id" AS "user_id" FROM oauth_token WHERE
user_id = $1;

View file

@ -0,0 +1,115 @@
// Code generated by BobGen psql v0.41.1. DO NOT EDIT.
// This file is meant to be re-generated in place and/or deleted at any time.
package sql
import (
"context"
"fmt"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/stephenafamo/bob"
"github.com/stephenafamo/bob/dialect/psql"
testutils "github.com/stephenafamo/bob/test/utils"
)
func TestOauthTokenByUserId(t *testing.T) {
t.Run("Base", func(t *testing.T) {
var sb strings.Builder
query := OauthTokenByUserId(random_int32(nil))
if _, err := query.WriteQuery(t.Context(), &sb, 1); err != nil {
t.Fatal(err)
}
if diff := cmp.Diff(oauthTokenByUserIdSQL, sb.String()); diff != "" {
t.Fatalf("unexpected result (-got +want):\n%s", diff)
}
})
t.Run("Mod", func(t *testing.T) {
var sb strings.Builder
query := OauthTokenByUserId(random_int32(nil))
if _, err := psql.Select(query).WriteQuery(t.Context(), &sb, 1); err != nil {
t.Fatal(err)
}
queryDiff, err := testutils.QueryDiff(oauthTokenByUserIdSQL, sb.String(), formatQuery)
if err != nil {
t.Fatal(err)
}
if queryDiff != "" {
fmt.Println(sb.String())
t.Fatalf("unexpected result (-got +want):\n%s", queryDiff)
}
})
t.Run("Scanning", func(t *testing.T) {
if testDB == nil {
t.Skip("skipping test, no DSN provided")
}
ctxTx, cancel := context.WithCancel(t.Context())
defer cancel()
tx, err := testDB.Begin(ctxTx)
if err != nil {
t.Fatalf("Error starting transaction: %v", err)
}
defer func() {
if err := tx.Rollback(ctxTx); err != nil {
t.Fatalf("Error rolling back transaction: %v", err)
}
}()
query, args, err := bob.Build(ctxTx, psql.Select(OauthTokenByUserId(random_int32(nil))))
if err != nil {
t.Fatal(err)
}
rows, err := tx.QueryContext(ctxTx, query, args...)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
columns, err := rows.Columns()
if err != nil {
t.Fatal(err)
}
if len(columns) != 6 {
t.Fatalf("expected %d columns, got %d", 6, len(columns))
}
if columns[0] != "id" {
t.Fatalf("expected column %d to be %s, got %s", 0, "id", columns[0])
}
if columns[1] != "access_token" {
t.Fatalf("expected column %d to be %s, got %s", 1, "access_token", columns[1])
}
if columns[2] != "expires" {
t.Fatalf("expected column %d to be %s, got %s", 2, "expires", columns[2])
}
if columns[3] != "refresh_token" {
t.Fatalf("expected column %d to be %s, got %s", 3, "refresh_token", columns[3])
}
if columns[4] != "username" {
t.Fatalf("expected column %d to be %s, got %s", 4, "username", columns[4])
}
if columns[5] != "user_id" {
t.Fatalf("expected column %d to be %s, got %s", 5, "user_id", columns[5])
}
})
}

3
sql/oauth_by_user_id.sql Normal file
View file

@ -0,0 +1,3 @@
-- OauthTokenByUserId
SELECT * FROM oauth_token WHERE
user_id = $1;

View file

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{template "title" .}} - Nidus Sync</title> <title>{{template "title" .}} - Nidus Sync</title>
<link href="/static/vendor/css/bootstrap.min.css" rel="stylesheet"> <link href="/static/vendor/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<style> <style>
{{template "style" .}} {{template "style" .}}
</style> </style>

View file

@ -2,102 +2,207 @@
{{define "title"}}Dash{{end}} {{define "title"}}Dash{{end}}
{{define "style"}} {{define "style"}}
.connect-container { body {
max-width: 800px; background-color: #f8f9fa;
margin: 0 auto;
} }
.connect-box { .dashboard-container {
box-shadow: 0 0 15px rgba(0,0,0,0.1); padding: 20px 0;
}
.stats-card {
border-radius: 10px; border-radius: 10px;
padding: 40px; box-shadow: 0 4px 6px rgba(0,0,0,0.05);
background-color: #fff; transition: transform 0.2s;
height: 100%;
} }
.connect-header { .stats-card:hover {
margin-bottom: 25px; transform: translateY(-5px);
text-align: center;
} }
.logo-area { .map-container {
text-align: center;
margin-bottom: 30px;
}
.logo-placeholder {
width: 120px;
height: 60px;
background-color: #e9ecef; background-color: #e9ecef;
margin: 0 auto; border-radius: 10px;
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
height: 500px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: 6px; margin-top: 20px;
} }
.steps-container { .section-title {
margin: 30px 0; margin: 30px 0 20px;
padding-bottom: 10px;
border-bottom: 1px solid #dee2e6;
} }
.step { .last-refreshed {
margin-bottom: 20px; color: #6c757d;
padding: 15px;
border-left: 3px solid #0d6efd;
background-color: #f8f9fa;
} }
.connect-btn { .logo-placeholder {
margin-top: 30px; width: 100px;
height: 40px;
background-color: #e9ecef;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
}
.metric-icon {
font-size: 2rem;
margin-bottom: 10px;
display: inline-block;
width: 50px;
height: 50px;
line-height: 50px;
text-align: center;
border-radius: 50%;
}
.metric-value {
font-size: 2rem;
font-weight: bold;
} }
{{end}} {{end}}
{{define "content"}} {{define "content"}}
<div class="container min-vh-100 d-flex align-items-center justify-content-center py-5"> <div class="container dashboard-container">
<div class="connect-container"> <!-- Dashboard Header -->
<!-- Logo Area --> <div class="row mb-4">
<div class="logo-area"> <div class="col-md-6">
<div class="logo-placeholder"> <h1>Mosquito District Dashboard</h1>
<span class="text-muted">Your Logo</span> <p class="text-muted">Overview of mosquito control activities in your district</p>
</div>
<div class="col-md-6 text-md-end d-flex align-items-center justify-content-md-end">
<p class="last-refreshed mb-0">
<i class="fas fa-sync-alt me-2"></i>Last updated: <span id="last-refreshed-time">3 hours ago</span>
<button class="btn btn-sm btn-outline-primary ms-3">Refresh Data</button>
</p>
</div> </div>
</div> </div>
<div class="connect-box"> <!-- Key Metrics -->
<div class="connect-header"> <div class="row g-4">
<h1>Connect Your ArcGIS Account</h1> <!-- Last Refreshed -->
<p class="text-muted">Link your data to get started</p> <div class="col-md-3">
<div class="card stats-card h-100">
<div class="card-body text-center">
<div class="metric-icon bg-info bg-opacity-10 text-info">
<i class="fas fa-clock"></i>
</div> </div>
<h5 class="card-title">Last Data Refresh</h5>
<div class="connect-content"> <p class="metric-value">3h</p>
<p>To provide you with the best experience, we need to connect to your ArcGIS account. This allows us to securely access and visualize your spatial data within our platform.</p> <p class="card-text text-muted">Last sync: 12:45 PM</p>
<div class="steps-container">
<h4>What to expect:</h4>
<div class="step">
<h5>1. Secure Authentication</h5>
<p>When you click the "Connect to ArcGIS" button below, you'll be redirected to the official ArcGIS login page. This connection is secure and uses OAuth 2.0 protocol.</p>
</div> </div>
<div class="step">
<h5>2. Grant Permissions</h5>
<p>After logging in with your ArcGIS credentials, you'll be asked to approve permissions for our application to access your data. We only request access to what's needed for the platform to function.</p>
</div>
<div class="step">
<h5>3. Return to Platform</h5>
<p>Once authentication is complete, you'll be automatically redirected back to our platform where your data will be available to work with.</p>
</div> </div>
</div> </div>
<div class="alert alert-info"> <!-- Service Requests -->
<strong>Note:</strong> You'll need an active ArcGIS Online account or ArcGIS Enterprise account to proceed. If you don't have one, you can <a href="https://www.arcgis.com/home/signin.html" target="_blank">create an ArcGIS account here</a>. <div class="col-md-3">
<div class="card stats-card h-100">
<div class="card-body text-center">
<div class="metric-icon bg-warning bg-opacity-10 text-warning">
<i class="fas fa-ticket-alt"></i>
</div>
<h5 class="card-title">Service Requests</h5>
<p class="metric-value">48</p>
<p class="card-text text-muted">
<span class="text-success">
<i class="fas fa-arrow-up"></i> 12%
</span> since last week
</p>
</div>
</div>
</div> </div>
<p>By connecting your ArcGIS account, you'll be able to:</p> <!-- Mosquito Sources -->
<ul> <div class="col-md-3">
<li>Access and visualize your spatial data</li> <div class="card stats-card h-100">
<li>Perform advanced analysis using our integrated tools</li> <div class="card-body text-center">
<li>Share results with team members securely</li> <div class="metric-icon bg-danger bg-opacity-10 text-danger">
<li>Keep your data synchronized across platforms</li> <i class="fas fa-bug"></i>
</ul> </div>
<h5 class="card-title">Mosquito Sources</h5>
<p class="metric-value">124</p>
<p class="card-text text-muted">
<span class="text-danger">
<i class="fas fa-arrow-up"></i> 8%
</span> since last month
</p>
</div>
</div>
</div>
<div class="text-center connect-btn"> <!-- Inspections -->
<a href="/arcgis/oauth/begin" class="btn btn-primary btn-lg"> <div class="col-md-3">
Connect to ArcGIS <div class="card stats-card h-100">
</a> <div class="card-body text-center">
<p class="mt-2 text-muted"><small>You can disconnect your account at any time in settings</small></p> <div class="metric-icon bg-success bg-opacity-10 text-success">
<i class="fas fa-clipboard-check"></i>
</div>
<h5 class="card-title">Inspections</h5>
<p class="metric-value">76</p>
<p class="card-text text-muted">
<span class="text-success">
<i class="fas fa-arrow-up"></i> 15%
</span> since last week
</p>
</div>
</div>
</div>
</div>
<!-- Map Section -->
<h3 class="section-title">Mosquito Activity Heatmap</h3>
<div class="row">
<div class="col-12">
<div class="map-container" id="mosquito-heatmap">
<div class="text-center">
<i class="fas fa-map-marked-alt fa-4x text-muted mb-3"></i>
<h4>Mosquito Activity Heatmap</h4>
<p class="text-muted">Map visualization will be displayed here</p>
<small>Showing activity data for the Central District area</small>
</div>
</div>
</div>
</div>
<!-- Recent Activity Section -->
<h3 class="section-title">Recent Activity</h3>
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Date</th>
<th>Type</th>
<th>Location</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<tr>
<td>Aug 24, 2023</td>
<td>Inspection</td>
<td>River Park Area</td>
<td><span class="badge bg-success">Completed</span></td>
<td><a href="#" class="btn btn-sm btn-outline-primary">View</a></td>
</tr>
<tr>
<td>Aug 23, 2023</td>
<td>Service Request</td>
<td>Westside Community</td>
<td><span class="badge bg-warning">Pending</span></td>
<td><a href="#" class="btn btn-sm btn-outline-primary">View</a></td>
</tr>
<tr>
<td>Aug 22, 2023</td>
<td>Source Treatment</td>
<td>Lakeside Avenue</td>
<td><span class="badge bg-info">In Progress</span></td>
<td><a href="#" class="btn btn-sm btn-outline-primary">View</a></td>
</tr>
</tbody>
</table>
</div>
</div> </div>
</div> </div>
</div> </div>

106
templates/oauth-prompt.html Normal file
View file

@ -0,0 +1,106 @@
{{template "authenticated.html" .}}
{{define "title"}}Dash{{end}}
{{define "style"}}
.connect-container {
max-width: 800px;
margin: 0 auto;
}
.connect-box {
box-shadow: 0 0 15px rgba(0,0,0,0.1);
border-radius: 10px;
padding: 40px;
background-color: #fff;
}
.connect-header {
margin-bottom: 25px;
text-align: center;
}
.logo-area {
text-align: center;
margin-bottom: 30px;
}
.logo-placeholder {
width: 120px;
height: 60px;
background-color: #e9ecef;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
}
.steps-container {
margin: 30px 0;
}
.step {
margin-bottom: 20px;
padding: 15px;
border-left: 3px solid #0d6efd;
background-color: #f8f9fa;
}
.connect-btn {
margin-top: 30px;
}
{{end}}
{{define "content"}}
<div class="container min-vh-100 d-flex align-items-center justify-content-center py-5">
<div class="connect-container">
<!-- Logo Area -->
<div class="logo-area">
<div class="logo-placeholder">
<span class="text-muted">Your Logo</span>
</div>
</div>
<div class="connect-box">
<div class="connect-header">
<h1>Connect Your ArcGIS Account</h1>
<p class="text-muted">Link your data to get started</p>
</div>
<div class="connect-content">
<p>To provide you with the best experience, we need to connect to your ArcGIS account. This allows us to securely access and visualize your spatial data within our platform.</p>
<div class="steps-container">
<h4>What to expect:</h4>
<div class="step">
<h5>1. Secure Authentication</h5>
<p>When you click the "Connect to ArcGIS" button below, you'll be redirected to the official ArcGIS login page. This connection is secure and uses OAuth 2.0 protocol.</p>
</div>
<div class="step">
<h5>2. Grant Permissions</h5>
<p>After logging in with your ArcGIS credentials, you'll be asked to approve permissions for our application to access your data. We only request access to what's needed for the platform to function.</p>
</div>
<div class="step">
<h5>3. Return to Platform</h5>
<p>Once authentication is complete, you'll be automatically redirected back to our platform where your data will be available to work with.</p>
</div>
</div>
<div class="alert alert-info">
<strong>Note:</strong> You'll need an active ArcGIS Online account or ArcGIS Enterprise account to proceed. If you don't have one, you can <a href="https://www.arcgis.com/home/signin.html" target="_blank">create an ArcGIS account here</a>.
</div>
<p>By connecting your ArcGIS account, you'll be able to:</p>
<ul>
<li>Access and visualize your spatial data</li>
<li>Perform advanced analysis using our integrated tools</li>
<li>Share results with team members securely</li>
<li>Keep your data synchronized across platforms</li>
</ul>
<div class="text-center connect-btn">
<a href="/arcgis/oauth/begin" class="btn btn-primary btn-lg">
Connect to ArcGIS
</a>
<p class="mt-2 text-muted"><small>You can disconnect your account at any time in settings</small></p>
</div>
</div>
</div>
</div>
</div>
{{end}}