From 72cbe2de5e6385b2d1522c1cc8be952c4520052c Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Thu, 6 Nov 2025 22:58:18 +0000 Subject: [PATCH] Check if we have oauth information, only prompt if its missing Also include a rough dashboard of information that we'll pull from Fieldseeker --- arcgis.go | 8 + endpoint.go | 11 +- html.go | 12 ++ sql/oauth_by_user_id.bob.go | 102 +++++++++++ sql/oauth_by_user_id.bob.sql | 6 + sql/oauth_by_user_id.bob_test.go | 115 ++++++++++++ sql/oauth_by_user_id.sql | 3 + templates/authenticated.html | 1 + templates/dashboard.html | 303 +++++++++++++++++++++---------- templates/oauth-prompt.html | 106 +++++++++++ 10 files changed, 567 insertions(+), 100 deletions(-) create mode 100644 sql/oauth_by_user_id.bob.go create mode 100644 sql/oauth_by_user_id.bob.sql create mode 100644 sql/oauth_by_user_id.bob_test.go create mode 100644 sql/oauth_by_user_id.sql create mode 100644 templates/oauth-prompt.html diff --git a/arcgis.go b/arcgis.go index 9d3008c8..77674623 100644 --- a/arcgis.go +++ b/arcgis.go @@ -17,6 +17,7 @@ import ( "github.com/Gleipnir-Technology/arcgis-go" "github.com/Gleipnir-Technology/nidus-sync/models" + "github.com/Gleipnir-Technology/nidus-sync/sql" "github.com/aarondl/opt/omit" ) @@ -155,6 +156,13 @@ func handleOauthAccessCode(ctx context.Context, user *models.User, code string) 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 { return BaseURL + "/arcgis/oauth/callback" } diff --git a/endpoint.go b/endpoint.go index 852cfb1d..012da7ae 100644 --- a/endpoint.go +++ b/endpoint.go @@ -167,7 +167,16 @@ func getRoot(w http.ResponseWriter, r *http.Request) { errorCode := r.URL.Query().Get("error") err = htmlSignin(w, errorCode) } else { - err = htmlDashboard(w, user) + 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) + } else { + err = htmlOauthPrompt(w, user) + } } if err != nil { respondError(w, "Failed to render root", err, http.StatusInternalServerError) diff --git a/html.go b/html.go index 2833fcdf..6efb9d3a 100644 --- a/html.go +++ b/html.go @@ -15,6 +15,7 @@ import ( var ( dashboard = newBuiltTemplate("dashboard", "authenticated") + oauthPrompt = newBuiltTemplate("oauth-prompt", "authenticated") report = newBuiltTemplate("report", "base") reportConfirmation = newBuiltTemplate("report-confirmation", "base") reportContribute = newBuiltTemplate("report-contribute", "base") @@ -99,6 +100,17 @@ func htmlDashboard(w io.Writer, user *models.User) error { 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 { url := BaseURL + "/report/t78fd3" data := ContentReportDiagnostic{ diff --git a/sql/oauth_by_user_id.bob.go b/sql/oauth_by_user_id.bob.go new file mode 100644 index 00000000..81932e6a --- /dev/null +++ b/sql/oauth_by_user_id.bob.go @@ -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) +} diff --git a/sql/oauth_by_user_id.bob.sql b/sql/oauth_by_user_id.bob.sql new file mode 100644 index 00000000..cb0f6380 --- /dev/null +++ b/sql/oauth_by_user_id.bob.sql @@ -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; diff --git a/sql/oauth_by_user_id.bob_test.go b/sql/oauth_by_user_id.bob_test.go new file mode 100644 index 00000000..4a610d1a --- /dev/null +++ b/sql/oauth_by_user_id.bob_test.go @@ -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]) + } + }) +} diff --git a/sql/oauth_by_user_id.sql b/sql/oauth_by_user_id.sql new file mode 100644 index 00000000..40a297b2 --- /dev/null +++ b/sql/oauth_by_user_id.sql @@ -0,0 +1,3 @@ +-- OauthTokenByUserId +SELECT * FROM oauth_token WHERE + user_id = $1; diff --git a/templates/authenticated.html b/templates/authenticated.html index d898960f..1a3509dc 100644 --- a/templates/authenticated.html +++ b/templates/authenticated.html @@ -5,6 +5,7 @@ {{template "title" .}} - Nidus Sync + diff --git a/templates/dashboard.html b/templates/dashboard.html index 17ea22d4..b43285f6 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -2,105 +2,210 @@ {{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; - } +body { + background-color: #f8f9fa; +} +.dashboard-container { + padding: 20px 0; +} +.stats-card { + border-radius: 10px; + box-shadow: 0 4px 6px rgba(0,0,0,0.05); + transition: transform 0.2s; + height: 100%; +} +.stats-card:hover { + transform: translateY(-5px); +} +.map-container { + background-color: #e9ecef; + border-radius: 10px; + box-shadow: 0 4px 6px rgba(0,0,0,0.05); + height: 500px; + display: flex; + align-items: center; + justify-content: center; + margin-top: 20px; +} +.section-title { + margin: 30px 0 20px; + padding-bottom: 10px; + border-bottom: 1px solid #dee2e6; +} +.last-refreshed { + color: #6c757d; +} +.logo-placeholder { + 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}} {{define "content"}} -
-
- -
-
- Your Logo -
-
- -
-
-

Connect Your ArcGIS Account

-

Link your data to get started

-
- -
-

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.

- -
-

What to expect:

- -
-
1. Secure Authentication
-

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.

-
- -
-
2. Grant Permissions
-

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.

-
- -
-
3. Return to Platform
-

Once authentication is complete, you'll be automatically redirected back to our platform where your data will be available to work with.

-
-
- -
- Note: You'll need an active ArcGIS Online account or ArcGIS Enterprise account to proceed. If you don't have one, you can create an ArcGIS account here. -
- -

By connecting your ArcGIS account, you'll be able to:

-
    -
  • Access and visualize your spatial data
  • -
  • Perform advanced analysis using our integrated tools
  • -
  • Share results with team members securely
  • -
  • Keep your data synchronized across platforms
  • -
- -
- - Connect to ArcGIS - -

You can disconnect your account at any time in settings

-
-
-
-
-
+
+ +
+
+

Mosquito District Dashboard

+

Overview of mosquito control activities in your district

+
+
+

+ Last updated: 3 hours ago + +

+
+
+ + +
+ +
+
+
+
+ +
+
Last Data Refresh
+

3h

+

Last sync: 12:45 PM

+
+
+
+ + +
+
+
+
+ +
+
Service Requests
+

48

+

+ + 12% + since last week +

+
+
+
+ + +
+
+
+
+ +
+
Mosquito Sources
+

124

+

+ + 8% + since last month +

+
+
+
+ + +
+
+
+
+ +
+
Inspections
+

76

+

+ + 15% + since last week +

+
+
+
+
+ + +

Mosquito Activity Heatmap

+
+
+
+
+ +

Mosquito Activity Heatmap

+

Map visualization will be displayed here

+ Showing activity data for the Central District area +
+
+
+
+ + +

Recent Activity

+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DateTypeLocationStatusAction
Aug 24, 2023InspectionRiver Park AreaCompletedView
Aug 23, 2023Service RequestWestside CommunityPendingView
Aug 22, 2023Source TreatmentLakeside AvenueIn ProgressView
+
+
+
+
+
+
{{end}} diff --git a/templates/oauth-prompt.html b/templates/oauth-prompt.html new file mode 100644 index 00000000..17ea22d4 --- /dev/null +++ b/templates/oauth-prompt.html @@ -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"}} +
+
+ +
+
+ Your Logo +
+
+ +
+
+

Connect Your ArcGIS Account

+

Link your data to get started

+
+ +
+

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.

+ +
+

What to expect:

+ +
+
1. Secure Authentication
+

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.

+
+ +
+
2. Grant Permissions
+

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.

+
+ +
+
3. Return to Platform
+

Once authentication is complete, you'll be automatically redirected back to our platform where your data will be available to work with.

+
+
+ +
+ Note: You'll need an active ArcGIS Online account or ArcGIS Enterprise account to proceed. If you don't have one, you can create an ArcGIS account here. +
+ +

By connecting your ArcGIS account, you'll be able to:

+
    +
  • Access and visualize your spatial data
  • +
  • Perform advanced analysis using our integrated tools
  • +
  • Share results with team members securely
  • +
  • Keep your data synchronized across platforms
  • +
+ +
+ + Connect to ArcGIS + +

You can disconnect your account at any time in settings

+
+
+
+
+
+{{end}}