From b6287659bcf5fa290f269fd052179a220dc03332 Mon Sep 17 00:00:00 2001 From: Tobias Gleiter Date: Sat, 11 Jul 2026 16:39:48 -0400 Subject: [PATCH 1/4] chore: added login and refactored code to be easier to read and maintain (hopefully) --- .golangci.yml | 2 + Makefile | 2 +- cmd/web/handlers:equipment.go | 23 +- cmd/web/handlers:equipment:categories.go | 23 +- cmd/web/handlers:equipment:locations.go | 11 +- cmd/web/handlers:equipment:manufacturers.go | 15 +- cmd/web/handlers:login.go | 229 ++++++++++++ cmd/web/handlers:orgs.go | 169 +++++---- cmd/web/handlers:orgs:settings.go | 13 +- cmd/web/handlers:settings:account.go | 68 ++++ cmd/web/main.go | 21 +- cmd/web/middleware.go | 48 +++ cmd/web/options.go | 39 ++- cmd/web/routes.go | 145 ++++---- cmd/web/seed.go | 31 ++ cmd/web/setup.go | 81 ++++- go.mod | 7 +- go.sum | 58 ++- internal/accounts/context.go | 10 + internal/accounts/form.go | 161 +++++++++ internal/accounts/repository.go | 72 ++++ internal/accounts/service.go | 331 ++++++++++++++++++ internal/assets/css/index.css | 2 +- internal/assets/dist/index.css | 42 ++- internal/credentials/password.go | 25 ++ internal/credentials/repository.go | 82 +++++ internal/credentials/types.go | 23 ++ internal/database/migrations/migrations.go | 2 +- .../sqlite/20260110162506_sessions.sql | 13 + .../sqlite/20260110162507_accounts.sql | 16 + .../20260110162508_credential_types.sql | 12 + .../sqlite/20260110162509_credentials.sql | 17 + .../migrations/sqlite/20260110172509_orgs.sql | 2 +- .../sqlite/20260514003042_org_roles.sql | 14 + .../20260514003043_org_member_roles.sql | 23 ++ .../sqlite/20260702234518_token_scopes.sql | 12 + .../sqlite/20260702234522_tokens.sql | 18 + .../queries/gen/accounts/accounts.sql.go | 110 ++++++ internal/database/queries/gen/accounts/db.go | 31 ++ .../database/queries/gen/accounts/models.go | 5 + .../database/queries/gen/accounts/querier.go | 18 + .../gen/credentials/credentials.sql.go | 121 +++++++ .../database/queries/gen/credentials/db.go | 31 ++ .../queries/gen/credentials/models.go | 5 + .../queries/gen/credentials/querier.go | 18 + internal/database/queries/gen/orgs/models.go | 14 +- .../database/queries/gen/orgs/orgs.sql.go | 328 +++++++++++++---- internal/database/queries/gen/orgs/querier.go | 19 +- internal/database/queries/gen/tokens/db.go | 31 ++ .../database/queries/gen/tokens/models.go | 10 + .../database/queries/gen/tokens/querier.go | 19 + .../database/queries/gen/tokens/tokens.sql.go | 134 +++++++ internal/database/queries/sqlite/accounts.sql | 33 ++ .../database/queries/sqlite/credentials.sql | 39 +++ internal/database/queries/sqlite/orgs.sql | 124 +++++-- internal/database/queries/sqlite/tokens.sql | 40 +++ internal/equipment/form.go | 54 ++- internal/locale/locale.go | 135 +++++++ internal/orgs/form.go | 6 +- internal/orgs/model.go | 8 +- internal/orgs/repository.go | 159 +++++---- internal/orgs/roles.go | 49 +++ internal/orgs/service.go | 126 ++++--- internal/orgs/settings/form.go | 12 + internal/sessions/sessions.go | 44 +++ internal/templates/components/branding.tmpl | 3 + internal/templates/components/icons.tmpl | 16 + internal/templates/components/login.tmpl | 31 ++ internal/templates/layouts/layouts.go | 3 + internal/templates/layouts/login.tmpl | 5 + internal/templates/layouts/settings.tmpl | 25 +- .../templates/pages/equipment/detail.tmpl | 22 +- internal/templates/pages/equipment/print.tmpl | 2 +- .../templates/pages/equipment/properties.tmpl | 24 ++ .../pages/login/forgot-password-success.tmpl | 17 + .../pages/login/forgot-password.tmpl | 57 +++ .../templates/pages/login/reset-password.tmpl | 58 +++ internal/templates/pages/login/signin.tmpl | 81 +++++ internal/templates/pages/login/signup.tmpl | 71 ++++ .../pages/orgs/categories/detail.tmpl | 4 +- .../pages/orgs/categories/index.tmpl | 1 - internal/templates/pages/orgs/details.tmpl | 9 +- internal/templates/pages/orgs/index.tmpl | 6 +- .../pages/orgs/locations/detail.tmpl | 4 +- .../templates/pages/orgs/locations/index.tmpl | 1 - .../pages/orgs/manufacturers/detail.tmpl | 4 +- .../pages/orgs/manufacturers/index.tmpl | 1 - internal/templates/pages/orgs/new.tmpl | 2 +- internal/templates/pages/orgs/settings.tmpl | 173 +++++---- internal/templates/pages/pages.go | 14 + .../templates/pages/settings/account.tmpl | 80 +++++ internal/templates/partials/header.tmpl | 25 +- .../partials/org-settings-sidebar.tmpl | 26 ++ internal/tokens/repository.go | 74 ++++ internal/tokens/scopes.go | 28 ++ pkg/mailer/mailer.go | 58 +++ sqlc.sqlite.yml | 30 ++ wiki/ERD.md | 108 +++--- 98 files changed, 4041 insertions(+), 607 deletions(-) create mode 100644 cmd/web/handlers:login.go create mode 100644 cmd/web/handlers:settings:account.go create mode 100644 internal/accounts/context.go create mode 100644 internal/accounts/form.go create mode 100644 internal/accounts/repository.go create mode 100644 internal/accounts/service.go create mode 100644 internal/credentials/password.go create mode 100644 internal/credentials/repository.go create mode 100644 internal/credentials/types.go create mode 100644 internal/database/migrations/sqlite/20260110162506_sessions.sql create mode 100644 internal/database/migrations/sqlite/20260110162507_accounts.sql create mode 100644 internal/database/migrations/sqlite/20260110162508_credential_types.sql create mode 100644 internal/database/migrations/sqlite/20260110162509_credentials.sql create mode 100644 internal/database/migrations/sqlite/20260514003042_org_roles.sql create mode 100644 internal/database/migrations/sqlite/20260514003043_org_member_roles.sql create mode 100644 internal/database/migrations/sqlite/20260702234518_token_scopes.sql create mode 100644 internal/database/migrations/sqlite/20260702234522_tokens.sql create mode 100644 internal/database/queries/gen/accounts/accounts.sql.go create mode 100644 internal/database/queries/gen/accounts/db.go create mode 100644 internal/database/queries/gen/accounts/models.go create mode 100644 internal/database/queries/gen/accounts/querier.go create mode 100644 internal/database/queries/gen/credentials/credentials.sql.go create mode 100644 internal/database/queries/gen/credentials/db.go create mode 100644 internal/database/queries/gen/credentials/models.go create mode 100644 internal/database/queries/gen/credentials/querier.go create mode 100644 internal/database/queries/gen/tokens/db.go create mode 100644 internal/database/queries/gen/tokens/models.go create mode 100644 internal/database/queries/gen/tokens/querier.go create mode 100644 internal/database/queries/gen/tokens/tokens.sql.go create mode 100644 internal/database/queries/sqlite/accounts.sql create mode 100644 internal/database/queries/sqlite/credentials.sql create mode 100644 internal/database/queries/sqlite/tokens.sql create mode 100644 internal/locale/locale.go create mode 100644 internal/orgs/roles.go create mode 100644 internal/sessions/sessions.go create mode 100644 internal/templates/components/branding.tmpl create mode 100644 internal/templates/components/login.tmpl create mode 100644 internal/templates/layouts/login.tmpl create mode 100644 internal/templates/pages/login/forgot-password-success.tmpl create mode 100644 internal/templates/pages/login/forgot-password.tmpl create mode 100644 internal/templates/pages/login/reset-password.tmpl create mode 100644 internal/templates/pages/login/signin.tmpl create mode 100644 internal/templates/pages/login/signup.tmpl create mode 100644 internal/templates/pages/settings/account.tmpl create mode 100644 internal/templates/partials/org-settings-sidebar.tmpl create mode 100644 internal/tokens/repository.go create mode 100644 internal/tokens/scopes.go create mode 100644 pkg/mailer/mailer.go diff --git a/.golangci.yml b/.golangci.yml index 4468d7f..8c54946 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -28,6 +28,8 @@ linters: - sqlclosecheck - rowserrcheck settings: + cyclop: + max-complexity: 13 gosec: excludes: - G104 # Unhandled errors (errcheck handles this better) diff --git a/Makefile b/Makefile index fd9c2e2..90b4b78 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ confirm: ## run/web: run the web application with live reload .PHONY: run/web run/web: - reflex -s -r '\.(go|tmpl|js)$$' -- go run -tags sqlite ./cmd/web serve + reflex -s -r '\.(go|tmpl|js)$$' -- go run -tags sqlite ./cmd/web serve --max-orgs=2 ## run/www: run the web application with live reload .PHONY: run/www diff --git a/cmd/web/handlers:equipment.go b/cmd/web/handlers:equipment.go index 6453984..fe8e6f3 100644 --- a/cmd/web/handlers:equipment.go +++ b/cmd/web/handlers:equipment.go @@ -45,7 +45,7 @@ type equipmentData struct { type equipmentPrintData struct { OrgID string - OrgName string + OrgDisplayName string Inventories []equipment.Equipment Query string Category string @@ -301,7 +301,8 @@ func (app *application) getEquipmentItem(w http.ResponseWriter, r *http.Request) app.resolveItemURLs(item) data := app.html.TemplateData(r) - data.Form = &equipment.DetailsForm{} + f := equipment.DetailsFormFromEquipment(item) + data.Form = &f data.Data = equipmentItemData{OrgID: orgID, Item: item, ID: itemID, ActiveTab: "details"} return app.html.Render(w, r, http.StatusOK, pages.EquipmentDetail, data) } @@ -363,6 +364,20 @@ func (app *application) postEquipmentItemProperties(w http.ResponseWriter, r *ht return &httperr.Error{Error: err, Message: "Bad request.", Code: http.StatusBadRequest} } + if !form.Validate() { + item, err := app.services.equipment.GetByID(ctx, itemID) + if err != nil { + return &httperr.Error{Error: err, Message: "Failed to retrieve inventory item.", Code: http.StatusInternalServerError} + } + + app.resolveItemURLs(item) + + data := app.html.TemplateData(r) + data.Form = &form + data.Data = equipmentItemData{OrgID: orgID, Item: item, ID: itemID, ActiveTab: "properties"} + return app.html.Render(w, r, http.StatusUnprocessableEntity, pages.EquipmentProperties, data) + } + if err := app.services.equipment.UpdateProperties(ctx, equipment.UpdateEquipmentProperties{ ID: itemID, Properties: form.ToProperties(), @@ -974,7 +989,7 @@ func (app *application) getEquipmentPrint(w http.ResponseWriter, r *http.Request ctx := r.Context() orgID := r.PathValue("org_id") - org, err := app.services.orgs.GetByID(ctx, orgID) + org, err := app.services.orgs.Get(ctx, orgID) if err != nil { return &httperr.Error{Error: err, Message: "Failed to retrieve organization.", Code: http.StatusInternalServerError} } @@ -1006,7 +1021,7 @@ func (app *application) getEquipmentPrint(w http.ResponseWriter, r *http.Request data := app.html.TemplateData(r) data.Data = equipmentPrintData{ OrgID: orgID, - OrgName: org.Name, + OrgDisplayName: org.DisplayName, Inventories: filtered, Query: query, Category: category, diff --git a/cmd/web/handlers:equipment:categories.go b/cmd/web/handlers:equipment:categories.go index 0c5033f..ad4e90e 100644 --- a/cmd/web/handlers:equipment:categories.go +++ b/cmd/web/handlers:equipment:categories.go @@ -24,9 +24,8 @@ type equipmentCategoriesData struct { } type equipmentCategoryData struct { - OrgID string - Category *categories.EquipmentCategory - ID string + OrgID string + ID string } func (app *application) getEquipmentCategories(w http.ResponseWriter, r *http.Request) *httperr.Error { @@ -124,8 +123,8 @@ func (app *application) getEquipmentCategory(w http.ResponseWriter, r *http.Requ } data := app.html.TemplateData(r) - data.Form = &categories.Form{} - data.Data = equipmentCategoryData{OrgID: orgID, Category: category, ID: catID} + data.Form = &categories.Form{Name: category.Name} + data.Data = equipmentCategoryData{OrgID: orgID, ID: catID} return app.html.Render(w, r, http.StatusOK, pages.EquipmentCategoriesDetail, data) } @@ -139,18 +138,10 @@ func (app *application) postEquipmentCategory(w http.ResponseWriter, r *http.Req return &httperr.Error{Error: err, Message: "Bad request.", Code: http.StatusBadRequest} } - category, err := app.services.equipmentcategories.GetByID(ctx, catID) - if err != nil { - if errors.Is(err, database.ErrNotFound) { - return &httperr.Error{Error: err, Message: "Equipment category not found.", Code: http.StatusNotFound} - } - return &httperr.Error{Error: err, Message: "Failed to retrieve equipment category.", Code: http.StatusInternalServerError} - } - reRender := func(f *categories.Form) *httperr.Error { data := app.html.TemplateData(r) data.Form = f - data.Data = equipmentCategoryData{OrgID: orgID, Category: category, ID: catID} + data.Data = equipmentCategoryData{OrgID: orgID, ID: catID} return app.html.Render(w, r, http.StatusUnprocessableEntity, pages.EquipmentCategoriesDetail, data) } @@ -192,11 +183,11 @@ func (app *application) postDeleteEquipmentCategory(w http.ResponseWriter, r *ht } return &httperr.Error{Error: fetchErr, Message: "Failed to retrieve equipment category.", Code: http.StatusInternalServerError} } - f := &categories.Form{} + f := &categories.Form{Name: category.Name} f.AddError("delete", "Cannot delete: this category is assigned to one or more equipment items.") data := app.html.TemplateData(r) data.Form = f - data.Data = equipmentCategoryData{OrgID: orgID, Category: category, ID: catID} + data.Data = equipmentCategoryData{OrgID: orgID, ID: catID} return app.html.Render(w, r, http.StatusUnprocessableEntity, pages.EquipmentCategoriesDetail, data) } return &httperr.Error{ diff --git a/cmd/web/handlers:equipment:locations.go b/cmd/web/handlers:equipment:locations.go index 7594095..971f2be 100644 --- a/cmd/web/handlers:equipment:locations.go +++ b/cmd/web/handlers:equipment:locations.go @@ -24,9 +24,8 @@ type locationsData struct { } type locationData struct { - OrgID string - Location *locations.Location - ID string + OrgID string + ID string } func (app *application) getLocations(w http.ResponseWriter, r *http.Request) *httperr.Error { @@ -125,8 +124,8 @@ func (app *application) getLocation(w http.ResponseWriter, r *http.Request) *htt } data := app.html.TemplateData(r) - data.Form = &locations.Form{} - data.Data = locationData{OrgID: orgID, Location: loc, ID: locID} + data.Form = &locations.Form{Name: loc.Name} + data.Data = locationData{OrgID: orgID, ID: locID} return app.html.Render(w, r, http.StatusOK, pages.LocationsDetail, data) } @@ -150,7 +149,7 @@ func (app *application) postLocation(w http.ResponseWriter, r *http.Request) *ht reRender := func(f *locations.Form) *httperr.Error { data := app.html.TemplateData(r) data.Form = f - data.Data = locationData{OrgID: loc.OrgID, Location: loc, ID: locID} + data.Data = locationData{OrgID: loc.OrgID, ID: locID} return app.html.Render(w, r, http.StatusUnprocessableEntity, pages.LocationsDetail, data) } diff --git a/cmd/web/handlers:equipment:manufacturers.go b/cmd/web/handlers:equipment:manufacturers.go index b233ff5..f31490d 100644 --- a/cmd/web/handlers:equipment:manufacturers.go +++ b/cmd/web/handlers:equipment:manufacturers.go @@ -24,9 +24,8 @@ type manufacturersData struct { } type manufacturerData struct { - OrgID string - Manufacturer *manufacturers.Manufacturer - ID string + OrgID string + ID string } func (app *application) getManufacturers(w http.ResponseWriter, r *http.Request) *httperr.Error { @@ -125,8 +124,8 @@ func (app *application) getManufacturer(w http.ResponseWriter, r *http.Request) } data := app.html.TemplateData(r) - data.Form = &manufacturers.Form{} - data.Data = manufacturerData{OrgID: orgID, Manufacturer: manufacturer, ID: mfrID} + data.Form = &manufacturers.Form{Name: manufacturer.Name} + data.Data = manufacturerData{OrgID: orgID, ID: mfrID} return app.html.Render(w, r, http.StatusOK, pages.ManufacturersDetail, data) } @@ -150,7 +149,7 @@ func (app *application) postManufacturer(w http.ResponseWriter, r *http.Request) reRender := func(f *manufacturers.Form) *httperr.Error { data := app.html.TemplateData(r) data.Form = f - data.Data = manufacturerData{OrgID: mfr.OrgID, Manufacturer: mfr, ID: mfrID} + data.Data = manufacturerData{OrgID: mfr.OrgID, ID: mfrID} return app.html.Render(w, r, http.StatusUnprocessableEntity, pages.ManufacturersDetail, data) } @@ -193,11 +192,11 @@ func (app *application) postDeleteManufacturer(w http.ResponseWriter, r *http.Re } return &httperr.Error{Error: fetchErr, Message: "Failed to retrieve manufacturer.", Code: http.StatusInternalServerError} } - f := &manufacturers.Form{} + f := &manufacturers.Form{Name: manufacturer.Name} f.AddError("delete", "Cannot delete: this manufacturer is assigned to one or more inventory items.") data := app.html.TemplateData(r) data.Form = f - data.Data = manufacturerData{OrgID: orgID, Manufacturer: manufacturer, ID: mfrID} + data.Data = manufacturerData{OrgID: orgID, ID: mfrID} return app.html.Render(w, r, http.StatusUnprocessableEntity, pages.ManufacturersDetail, data) } return &httperr.Error{ diff --git a/cmd/web/handlers:login.go b/cmd/web/handlers:login.go new file mode 100644 index 0000000..12275e9 --- /dev/null +++ b/cmd/web/handlers:login.go @@ -0,0 +1,229 @@ +package main + +import ( + "context" + "errors" + "net/http" + "time" + + "github.com/bit8bytes/gearberg/internal/accounts" + "github.com/bit8bytes/gearberg/internal/httperr" + "github.com/bit8bytes/gearberg/internal/locale" + "github.com/bit8bytes/gearberg/internal/orgs/settings" + "github.com/bit8bytes/gearberg/internal/templates/pages" + "github.com/bit8bytes/gearberg/pkg/htmx" + "github.com/segmentio/ksuid" +) + +func (app *application) getSignIn(w http.ResponseWriter, r *http.Request) *httperr.Error { + tmplData := app.html.TemplateData(r) + tmplData.Form = &accounts.SignInForm{} + return app.html.Render(w, r, http.StatusOK, pages.SignIn, tmplData) +} + +func (app *application) postSignIn(w http.ResponseWriter, r *http.Request) *httperr.Error { + ctx := r.Context() + + reRender := func(formWithErrors *accounts.SignInForm) *httperr.Error { + data := app.html.TemplateData(r) + data.Form = formWithErrors + return app.html.Render(w, r, http.StatusUnprocessableEntity, pages.SignIn, data) + } + + form, err := accounts.ParseSignInForm(r) + if err != nil { + return reRender(&form) + } + + if !form.Validate() { + return reRender(&form) + } + + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + accountID, err := app.services.accounts.SignIn(ctx, accounts.SignInParams{ + Email: form.Email, + Password: form.Password, + }) + if err != nil { + form.AddError("email", "Invalid email or password.") + return reRender(&form) + } + + app.session.SetAccountID(ctx, accountID) + http.Redirect(w, r, "/", http.StatusSeeOther) + return nil +} + +func (app *application) getSignUp(w http.ResponseWriter, r *http.Request) *httperr.Error { + tmplData := app.html.TemplateData(r) + tmplData.Form = &accounts.SignUpForm{} + return app.html.Render(w, r, http.StatusOK, pages.SignUp, tmplData) +} + +func (app *application) postSignUp(w http.ResponseWriter, r *http.Request) *httperr.Error { + reRender := func(formWithErrors *accounts.SignUpForm) *httperr.Error { + data := app.html.TemplateData(r) + data.Form = formWithErrors + return app.html.Render(w, r, http.StatusUnprocessableEntity, pages.SignUp, data) + } + + form, err := accounts.ParseSignUpForm(r) + if err != nil { + return reRender(&form) + } + + if !form.Validate() { + return reRender(&form) + } + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + accountID, orgID, err := app.services.accounts.SignUp(ctx, accounts.SignUpParams{ + Email: form.Email, + Password: form.Password, + RepeatPassword: form.RepeatPassword, + }) + if err != nil { + switch { + case errors.Is(err, accounts.ErrUserAlreadyExists) || errors.Is(err, accounts.ErrInvalidPassword): + form.AddError("email", "Invalid email or password.") + default: + app.logger.ErrorContext(ctx, "sign up failed", "error", err) + form.AddError("email", "Invalid email or password.") + } + return reRender(&form) + } + + app.session.SetAccountID(ctx, accountID) + + ld := locale.FromAcceptLanguage(r.Header.Get("Accept-Language")) + _, _ = app.services.orgsettings.Upsert(ctx, settings.UpsertOrgSettings{ + ID: ksuid.New().String(), + OrgID: orgID, + Currency: ld.Currency, + Timezone: ld.Timezone, + }) + + http.Redirect(w, r, "/orgs/"+orgID+"/equipment", http.StatusSeeOther) + return nil +} + +func (app *application) getForgotPassword(w http.ResponseWriter, r *http.Request) *httperr.Error { + tmplData := app.html.TemplateData(r) + tmplData.Form = &accounts.ForgotPasswordForm{} + return app.html.Render(w, r, http.StatusOK, pages.ForgotPassword, tmplData) +} + +func (app *application) postForgotPassword(w http.ResponseWriter, r *http.Request) *httperr.Error { + reRender := func(formWithErrors *accounts.ForgotPasswordForm) *httperr.Error { + data := app.html.TemplateData(r) + data.Form = formWithErrors + return app.html.Render(w, r, http.StatusUnprocessableEntity, pages.ForgotPassword, data) + } + + form, err := accounts.ParseForgotPasswordForm(r) + if err != nil { + return reRender(&form) + } + + if !form.Validate() { + return reRender(&form) + } + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + // Always redirect to success to avoid leaking whether an email is registered. + token, err := app.services.accounts.ForgotPassword(ctx, form.Email) + if err != nil { + if errors.Is(err, accounts.ErrNotFound) { + app.logger.WarnContext(ctx, "password reset requested for unregistered email") + } else { + app.logger.ErrorContext(ctx, "failed to generate password reset token", "error", err) + } + http.Redirect(w, r, "/forgot-password/success", http.StatusSeeOther) + return nil + } + + resetURL := app.options.BaseURL + "/reset-password?token=" + token + if err := app.services.accounts.SendPasswordReset(ctx, form.Email, resetURL); err != nil { + app.logger.ErrorContext(ctx, "failed to send password reset email", "error", err) + } + + http.Redirect(w, r, "/forgot-password/success", http.StatusSeeOther) + return nil +} + +func (app *application) getForgotPasswordSuccess(w http.ResponseWriter, r *http.Request) *httperr.Error { + tmplData := app.html.TemplateData(r) + tmplData.Form = &accounts.ForgotPasswordForm{} + return app.html.Render(w, r, http.StatusOK, pages.ForgotPasswordSuccess, tmplData) +} + +func (app *application) getResetPassword(w http.ResponseWriter, r *http.Request) *httperr.Error { + tmplData := app.html.TemplateData(r) + tmplData.Form = &accounts.ResetPasswordForm{Token: r.URL.Query().Get("token")} + return app.html.Render(w, r, http.StatusOK, pages.ResetPassword, tmplData) +} + +func (app *application) postResetPassword(w http.ResponseWriter, r *http.Request) *httperr.Error { + reRender := func(formWithErrors *accounts.ResetPasswordForm) *httperr.Error { + data := app.html.TemplateData(r) + data.Form = formWithErrors + return app.html.Render(w, r, http.StatusUnprocessableEntity, pages.ResetPassword, data) + } + + form, err := accounts.ParseResetPasswordForm(r) + if err != nil { + return reRender(&form) + } + + if !form.Validate() { + return reRender(&form) + } + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + email, err := app.services.accounts.ResetPassword(ctx, form.Token, form.Password) + if err != nil { + switch { + case errors.Is(err, accounts.ErrSamePassword): + form.AddError("password", "New password must be different from your current password.") + default: + form.AddError("password", "Invalid or expired reset link.") + } + return reRender(&form) + } + + if err := app.services.accounts.SendPasswordChangedNotification(ctx, email); err != nil { + app.logger.ErrorContext(ctx, "failed to send password changed notification", "error", err) + } + + // Destroy any sessions if the user has one. + // If no session exists, the session manager is a no-op. + if err := app.session.Destroy(ctx); err != nil { + app.logger.ErrorContext(ctx, "failed to destroy session after password reset", "error", err) + } + + http.Redirect(w, r, "/signin", http.StatusSeeOther) + return nil +} + +func (app *application) postSignOut(w http.ResponseWriter, r *http.Request) *httperr.Error { + if err := app.session.Destroy(r.Context()); err != nil { + tmplData := app.html.TemplateData(r) + return app.html.Render(w, r, http.StatusInternalServerError, pages.Error, tmplData) + } + + url := "/signin" + if htmx.IsRequest(r) { + htmx.Redirect(w, r, url, http.StatusOK) + } else { + http.Redirect(w, r, url, http.StatusSeeOther) + } + return nil +} diff --git a/cmd/web/handlers:orgs.go b/cmd/web/handlers:orgs.go index fe3ec39..705e03b 100644 --- a/cmd/web/handlers:orgs.go +++ b/cmd/web/handlers:orgs.go @@ -9,14 +9,16 @@ import ( "github.com/bit8bytes/gearberg/internal/httperr" "github.com/bit8bytes/gearberg/internal/orgs" "github.com/bit8bytes/gearberg/internal/orgs/settings" + "github.com/bit8bytes/gearberg/internal/sessions" "github.com/bit8bytes/gearberg/internal/storage" "github.com/bit8bytes/gearberg/internal/templates/pages" + "github.com/bit8bytes/gearberg/pkg/htmx" "github.com/segmentio/ksuid" ) type orgsData struct { - Orgs []orgs.Org - MaxOrgs int + Orgs []orgs.Org + Max int } func (app *application) getOrgs(w http.ResponseWriter, r *http.Request) *httperr.Error { @@ -33,8 +35,8 @@ func (app *application) getOrgs(w http.ResponseWriter, r *http.Request) *httperr data := app.html.TemplateData(r) data.Data = orgsData{ - Orgs: allOrgs, - MaxOrgs: app.services.orgs.MaxOrgs(), + Orgs: allOrgs, + Max: app.services.orgs.Max(), } return app.html.Render(w, r, http.StatusOK, pages.Orgs, data) @@ -46,66 +48,7 @@ func (app *application) getOrgsNew(w http.ResponseWriter, r *http.Request) *http return app.html.Render(w, r, http.StatusOK, pages.OrgsNew, data) } -func (app *application) postOrgsNew(w http.ResponseWriter, r *http.Request) *httperr.Error { - ctx := r.Context() - - form, err := orgs.Parse(r) - if err != nil { - return &httperr.Error{Error: err, Message: "Bad request.", Code: http.StatusBadRequest} - } - - reRender := func(f *orgs.Form) *httperr.Error { - data := app.html.TemplateData(r) - data.Form = f - return app.html.Render(w, r, http.StatusUnprocessableEntity, pages.OrgsNew, data) - } - - if !form.Validate() { - return reRender(&form) - } - - org, err := app.services.orgs.Create(ctx, orgs.CreateOrg{ - ID: ksuid.New().String(), - Name: form.Name, - }) - if err != nil { - if errors.Is(err, database.ErrUniqueConstraint) { - form.AddError("name", "A org with this name already exists.") - return reRender(&form) - } - if errors.Is(err, database.ErrLimitExceeded) { - limit := app.services.orgs.MaxOrgs() - form.AddError("name", fmt.Sprintf("Org limit reached. Only %d orgs allowed.", limit)) - return reRender(&form) - } - return &httperr.Error{ - Error: err, - Message: "Failed to create org.", - Code: http.StatusInternalServerError, - } - } - - _, err = app.services.orgsettings.Upsert(ctx, settings.UpsertOrgSettings{ - ID: ksuid.New().String(), - OrgID: org.ID, - Currency: app.options.DefaultCurrency, - VatRate: app.options.DefaultVatRateBasisPoints(), - Timezone: app.options.DefaultTimezone, - }) - if err != nil { - return &httperr.Error{ - Error: fmt.Errorf("postOrgsNew: seed default settings: %w", err), - Message: "Failed to initialise org settings.", - Code: http.StatusInternalServerError, - } - } - - http.Redirect(w, r, fmt.Sprintf("/orgs/%s/equipment", org.ID), http.StatusSeeOther) - return nil -} - type settingsOrgData struct { - Org *orgs.Org OrgID string StorageUsage storage.Usage } @@ -114,7 +57,7 @@ func (app *application) getSettingsOrg(w http.ResponseWriter, r *http.Request) * ctx := r.Context() id := r.PathValue("org_id") - org, err := app.services.orgs.GetByID(ctx, id) + org, err := app.services.orgs.Get(ctx, id) if err != nil { return &httperr.Error{ Error: err, @@ -133,11 +76,32 @@ func (app *application) getSettingsOrg(w http.ResponseWriter, r *http.Request) * } data := app.html.TemplateData(r) - data.Form = &orgs.Form{} - data.Data = settingsOrgData{Org: org, OrgID: id, StorageUsage: usage} + data.Form = &orgs.Form{DisplayName: org.DisplayName} + data.Data = settingsOrgData{OrgID: id, StorageUsage: usage} return app.html.Render(w, r, http.StatusOK, pages.OrgSettingsDetails, data) } +func (app *application) deleteOrg(w http.ResponseWriter, r *http.Request) *httperr.Error { + ctx := r.Context() + orgID := r.PathValue("org_id") + session := sessions.MustFromRequest(r) + + if err := app.services.orgs.Delete(ctx, orgID, session.AccountID); err != nil { + return &httperr.Error{ + Error: err, + Message: "Failed to delete org. You must be the sole owner to delete it.", + Code: http.StatusForbidden, + } + } + + if htmx.IsRequest(r) { + htmx.Redirect(w, r, "/orgs", http.StatusOK) + } else { + http.Redirect(w, r, "/orgs", http.StatusSeeOther) + } + return nil +} + func (app *application) postSettingsOrg(w http.ResponseWriter, r *http.Request) *httperr.Error { ctx := r.Context() id := r.PathValue("org_id") @@ -158,9 +122,9 @@ func (app *application) postSettingsOrg(w http.ResponseWriter, r *http.Request) return reRender(&form) } - org, err := app.services.orgs.Update(ctx, orgs.UpdateOrg{ - ID: id, - Name: form.Name, + err = app.services.orgs.Update(ctx, orgs.UpdateParams{ + ID: id, + DisplayName: form.DisplayName, }) if err != nil { if errors.Is(err, database.ErrUniqueConstraint) { @@ -174,6 +138,15 @@ func (app *application) postSettingsOrg(w http.ResponseWriter, r *http.Request) } } + org, err := app.services.orgs.Get(ctx, id) + if err != nil { + return &httperr.Error{ + Error: err, + Message: "Failed to retrieve org.", + Code: http.StatusInternalServerError, + } + } + usage, err := app.services.storageManager.Info(ctx, id) if err != nil { return &httperr.Error{ @@ -184,23 +157,67 @@ func (app *application) postSettingsOrg(w http.ResponseWriter, r *http.Request) } data := app.html.TemplateData(r) - data.Form = &orgs.Form{} - data.Data = settingsOrgData{Org: org, OrgID: id, StorageUsage: usage} + data.Form = &orgs.Form{DisplayName: org.DisplayName} + data.Data = settingsOrgData{OrgID: id, StorageUsage: usage} return app.html.Render(w, r, http.StatusOK, pages.OrgSettingsDetails, data) } -func (app *application) postDeleteOrg(w http.ResponseWriter, r *http.Request) *httperr.Error { +func (app *application) postOrgsNew(w http.ResponseWriter, r *http.Request) *httperr.Error { ctx := r.Context() - id := r.PathValue("org_id") - if err := app.services.orgs.Delete(ctx, id); err != nil { + form, err := orgs.Parse(r) + if err != nil { + return &httperr.Error{Error: err, Message: "Bad request.", Code: http.StatusBadRequest} + } + + reRender := func(f *orgs.Form) *httperr.Error { + data := app.html.TemplateData(r) + data.Form = f + return app.html.Render(w, r, http.StatusUnprocessableEntity, pages.OrgsNew, data) + } + + if !form.Validate() { + return reRender(&form) + } + + session := sessions.MustFromRequest(r) + orgID, err := app.services.orgs.Create(ctx, orgs.CreateOrg{ + ID: ksuid.New().String(), + DisplayName: form.DisplayName, + AccountID: session.AccountID, + }) + if err != nil { + if errors.Is(err, database.ErrUniqueConstraint) { + form.AddError("name", "A org with this name already exists.") + return reRender(&form) + } + if errors.Is(err, database.ErrLimitExceeded) { + limit := app.services.orgs.Max() + form.AddError("name", fmt.Sprintf("Org limit reached. Only %d orgs allowed.", limit)) + return reRender(&form) + } return &httperr.Error{ Error: err, - Message: "Failed to delete org.", + Message: "Failed to create org.", + Code: http.StatusInternalServerError, + } + } + + _, err = app.services.orgsettings.Upsert(ctx, settings.UpsertOrgSettings{ + ID: ksuid.New().String(), + OrgID: orgID, + Currency: app.options.DefaultCurrency, + VatRate: app.options.DefaultVatRateBasisPoints(), + Timezone: app.options.DefaultTimezone, + }) + if err != nil { + return &httperr.Error{ + Error: fmt.Errorf("postOrgsNew: seed default settings: %w", err), + Message: "Failed to initialise org settings.", Code: http.StatusInternalServerError, } } - http.Redirect(w, r, "/orgs", http.StatusSeeOther) + http.Redirect(w, r, fmt.Sprintf("/orgs/%s/equipment", orgID), http.StatusSeeOther) return nil } diff --git a/cmd/web/handlers:orgs:settings.go b/cmd/web/handlers:orgs:settings.go index 97009a1..1116e9b 100644 --- a/cmd/web/handlers:orgs:settings.go +++ b/cmd/web/handlers:orgs:settings.go @@ -14,8 +14,7 @@ import ( ) type orgSettingsData struct { - Settings *settings.OrgSettings - OrgID string + OrgID string } type orgCurrencyData struct { @@ -64,8 +63,9 @@ func (app *application) getOrgSettings(w http.ResponseWriter, r *http.Request) * } data := app.html.TemplateData(r) - data.Form = &settings.Form{} - data.Data = orgSettingsData{Settings: s, OrgID: id} + f := settings.FormFromOrgSettings(s) + data.Form = &f + data.Data = orgSettingsData{OrgID: id} return app.html.Render(w, r, http.StatusOK, pages.OrgSettings, data) } @@ -105,7 +105,8 @@ func (app *application) postOrgSettings(w http.ResponseWriter, r *http.Request) } data := app.html.TemplateData(r) - data.Form = &settings.Form{} - data.Data = orgSettingsData{Settings: s, OrgID: id} + f := settings.FormFromOrgSettings(s) + data.Form = &f + data.Data = orgSettingsData{OrgID: id} return app.html.Render(w, r, http.StatusOK, pages.OrgSettings, data) } diff --git a/cmd/web/handlers:settings:account.go b/cmd/web/handlers:settings:account.go new file mode 100644 index 0000000..c30f1d4 --- /dev/null +++ b/cmd/web/handlers:settings:account.go @@ -0,0 +1,68 @@ +package main + +import ( + "context" + "net/http" + "time" + + "github.com/bit8bytes/gearberg/internal/accounts" + "github.com/bit8bytes/gearberg/internal/httperr" + "github.com/bit8bytes/gearberg/internal/sessions" + "github.com/bit8bytes/gearberg/internal/templates/pages" + "github.com/bit8bytes/gearberg/pkg/htmx" +) + +type accountData struct { + ID string + EmailVerified *time.Time +} + +func (app *application) getAccount(w http.ResponseWriter, r *http.Request) *httperr.Error { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + accountID := app.session.AccountID(ctx) + record, err := app.services.accounts.Get(ctx, accountID) + if err != nil { + return &httperr.Error{ + Error: err, + Message: "Failed to load account.", + Code: http.StatusInternalServerError, + } + } + + tmplData := app.html.TemplateData(r) + tmplData.Data = accountData{ + ID: record.ID, + EmailVerified: record.EmailVerified, + } + tmplData.Form = &accounts.Form{Email: record.Email} + return app.html.Render(w, r, http.StatusOK, pages.SettingsAccount, tmplData) +} + +func (app *application) deleteAccount(w http.ResponseWriter, r *http.Request) *httperr.Error { + ctx := r.Context() + + fail := func() *httperr.Error { + tmplData := app.html.TemplateData(r) + return app.html.Render(w, r, http.StatusInternalServerError, pages.Error, tmplData) + } + + session := sessions.MustFromRequest(r) + + if err := app.services.accounts.Delete(ctx, session.AccountID); err != nil { + return fail() + } + + if err := app.session.Destroy(r.Context()); err != nil { + return fail() + } + + if htmx.IsRequest(r) { + htmx.Redirect(w, r, "/", http.StatusOK) + } else { + http.Redirect(w, r, "/", http.StatusSeeOther) + } + + return nil +} diff --git a/cmd/web/main.go b/cmd/web/main.go index 0970724..1ffb2fc 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -22,6 +22,7 @@ import ( "fmt" "log" "log/slog" + "net/http" "os" "os/signal" "syscall" @@ -30,11 +31,20 @@ import ( htmlpkg "github.com/bit8bytes/gearberg/internal/html" ) +// sessionManager abstracts the SCS session store for testing and dependency inversion. +type sessionManager interface { + AccountID(ctx context.Context) string + SetAccountID(ctx context.Context, id string) + Destroy(ctx context.Context) error + LoadAndSave(next http.Handler) http.Handler +} + type application struct { logger *slog.Logger options *options html *htmlpkg.HTML db *sql.DB + session sessionManager services *services } @@ -106,7 +116,15 @@ func runServe(args []string) error { return fmt.Errorf("seed reference data: %w", err) } - services, err := setupServices(db, options, log) + scsMgr, err := setupSCS(db) + if err != nil { + return fmt.Errorf("setup session manager: %w", err) + } + sessionManager := setupSessionManager(scsMgr) + + mailer := setupMailer(options, log) + + services, err := setupServices(db, options, log, mailer) if err != nil { return fmt.Errorf("setup services: %w", err) } @@ -116,6 +134,7 @@ func runServe(args []string) error { options: options, html: html, db: db, + session: sessionManager, services: services, } diff --git a/cmd/web/middleware.go b/cmd/web/middleware.go index 3c8e9c2..e426ac1 100644 --- a/cmd/web/middleware.go +++ b/cmd/web/middleware.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/bit8bytes/gearberg/internal/nonce" + "github.com/bit8bytes/gearberg/internal/sessions" "github.com/bit8bytes/gearberg/internal/storage" "github.com/bit8bytes/gearberg/internal/trace" "github.com/bit8bytes/gearberg/pkg/tokens" @@ -175,3 +176,50 @@ func withSecurityHeaders(next http.Handler) http.Handler { next.ServeHTTP(w, r) }) } + +// withLogin ensures that the user is authenticated before allowing access to the next handler. +// If the user is not authenticated, they are redirected to the signin page. +func (app *application) withLogin(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // accountID is added to the session after successful login. + accountID := app.session.AccountID(r.Context()) + if accountID == "" { + http.Redirect(w, r, "/signin", http.StatusSeeOther) + return + } + + ctx := sessions.NewContext(r.Context(), sessions.Session{ + AccountID: accountID, + }) + + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// withPermission verifies the authenticated user is a member of the org in the URL path. +// Must be used after withLogin. Returns 403 if the user is not a member. +func (app *application) withPermission(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + orgID := r.PathValue("org_id") + session := sessions.MustFromRequest(r) + if err := app.services.orgs.GetMember(r.Context(), orgID, session.AccountID); err != nil { + http.Error(w, "Forbidden.", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) +} + +// withGuest ensures that only unauthenticated users can access the next handler. +// If the user is already signed in, they are redirected to the home page. +func (app *application) withGuest(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + accountID := app.session.AccountID(r.Context()) + if accountID == "" { + next.ServeHTTP(w, r) + return + } + + http.Redirect(w, r, "/", http.StatusSeeOther) + }) +} diff --git a/cmd/web/options.go b/cmd/web/options.go index 3388d58..9a56a3e 100644 --- a/cmd/web/options.go +++ b/cmd/web/options.go @@ -15,17 +15,19 @@ type options struct { Version bool LogLevel logLevel Port int + BaseURL string TLSMode string DbDsn string // SECRET StorageDSN string + SMTP SMTP MaxOrgs int MaxOrgCategories int MaxOrgManufacturers int MaxOrgLocations int MaxStorageBytes int64 - DefaultCurrency string - DefaultVatRate float64 // percentage e.g. 19.0 - DefaultTimezone string + DefaultCurrency string + DefaultVatRate float64 // percentage e.g. 19.0 + DefaultTimezone string } func registerCommonFlags(fs *flag.FlagSet, cfg *options) { @@ -40,6 +42,7 @@ func parseServeOptions(args []string) (*options, error) { registerCommonFlags(fs, cfg) fs.BoolVar(&cfg.Version, "version", false, "print version and exit") fs.IntVar(&cfg.Port, "port", 8080, "port to listen on") + fs.StringVar(&cfg.BaseURL, "base-url", envOr("BASE_URL", ""), "base URL for link generation (e.g. https://example.com)") fs.StringVar(&cfg.TLSMode, "tls-mode", "off", "TLS mode (off|local)") fs.IntVar(&cfg.MaxOrgs, "max-orgs", 1, "maximum number of orgs allowed") fs.IntVar(&cfg.MaxOrgCategories, "max-categories", 25, "maximum number of equipment categories per org") @@ -68,6 +71,10 @@ func parseServeOptions(args []string) (*options, error) { } } + if cfg.BaseURL == "" { + cfg.BaseURL = fmt.Sprintf("http://localhost:%d", cfg.Port) + } + if err := cfg.validate(); err != nil { return nil, err } @@ -178,3 +185,29 @@ func envOr(key, fallback string) string { } return fallback } + +type SMTP struct { + Host string + Port int + Username string + Password string // SECRET + From string +} + +// Valid returns nil when SMTP is not configured (host empty), allowing log-only mode. +// When host is set, all required fields must be present. +func (s *SMTP) Valid() error { + if s.Host == "" { + return nil + } + if s.Port <= 0 || s.Port > 65535 { + return fmt.Errorf("smtp port is not in valid range of 1-65535") + } + if s.Username == "" && s.Password != "" { + return fmt.Errorf("smtp password requires a username") + } + if s.From == "" { + return fmt.Errorf("smtp from email address cannot be empty") + } + return nil +} diff --git a/cmd/web/routes.go b/cmd/web/routes.go index 52971d5..4a1e3ba 100644 --- a/cmd/web/routes.go +++ b/cmd/web/routes.go @@ -19,69 +19,91 @@ func (app *application) routes() (http.Handler, error) { return nil, fmt.Errorf("routes: new api server: %w", err) } mux.Handle("/api/v1/", apiServer) - mux.HandleFunc("GET /media/{id}", app.html.Handle(app.getMedia)) - mux.HandleFunc("GET /image-proxy", app.getImageProxy) + mux.Handle("GET /media/{id}", app.withLogin(app.html.Handle(app.getMedia))) + mux.Handle("GET /image-proxy", app.withLogin(http.HandlerFunc(app.getImageProxy))) mux.Handle("GET /dist/", assets.ServeStaticFiles()) mux.Handle("GET /favicon.ico", http.RedirectHandler("/dist/images/favicon.ico", http.StatusMovedPermanently)) - mux.HandleFunc("GET /orgs", app.html.Handle(app.getOrgs)) - mux.HandleFunc("GET /orgs/new", app.html.Handle(app.getOrgsNew)) - mux.HandleFunc("POST /orgs/new", app.html.Handle(app.postOrgsNew)) - mux.HandleFunc("GET /orgs/{org_id}", app.html.Handle(app.getSettingsOrg)) - mux.HandleFunc("POST /orgs/{org_id}", app.html.Handle(app.postSettingsOrg)) - mux.HandleFunc("POST /orgs/{org_id}/delete", app.html.Handle(app.postDeleteOrg)) - mux.HandleFunc("GET /orgs/{org_id}/equipment", app.html.Handle(app.getEquipment)) - mux.HandleFunc("GET /orgs/{org_id}/equipment/print", app.html.Handle(app.getEquipmentPrint)) - mux.HandleFunc("GET /orgs/{org_id}/equipment/export", app.html.Handle(app.getEquipmentExport)) - mux.HandleFunc("GET /orgs/{org_id}/equipment/import", app.html.Handle(app.getEquipmentImport)) - mux.HandleFunc("GET /orgs/{org_id}/equipment/import/template", app.html.Handle(app.getEquipmentImportTemplate)) - mux.HandleFunc("POST /orgs/{org_id}/equipment/import", app.html.Handle(app.postEquipmentImport)) - mux.HandleFunc("POST /orgs/{org_id}/equipment/import/confirm", app.html.Handle(app.postEquipmentImportConfirm)) - mux.HandleFunc("GET /orgs/{org_id}/equipment/new", app.html.Handle(app.getEquipmentNew)) - mux.Handle("POST /orgs/{org_id}/equipment/new", app.withCheckQuota(app.html.Handle(app.postEquipmentNew))) - mux.HandleFunc("GET /orgs/{org_id}/equipment/{id}", app.html.Handle(app.getEquipmentItem)) - mux.HandleFunc("GET /orgs/{org_id}/equipment/{id}/units", app.html.Handle(app.getEquipmentUnits)) - mux.HandleFunc("GET /orgs/{org_id}/equipment/{id}/pricing", app.html.Handle(app.getEquipmentItemPricing)) - mux.HandleFunc("POST /orgs/{org_id}/equipment/{id}/pricing", app.html.Handle(app.postEquipmentItemPricing)) - mux.HandleFunc("GET /orgs/{org_id}/equipment/{id}/properties", app.html.Handle(app.getEquipmentItemProperties)) - mux.HandleFunc("POST /orgs/{org_id}/equipment/{id}/details", app.html.Handle(app.postEquipmentItemDetails)) - mux.HandleFunc("POST /orgs/{org_id}/equipment/{id}/properties", app.html.Handle(app.postEquipmentItemProperties)) - mux.HandleFunc("POST /orgs/{org_id}/equipment/{id}/units", app.html.Handle(app.postEquipmentAddUnit)) - mux.HandleFunc("POST /orgs/{org_id}/equipment/{id}/units/bulk-inspect", app.html.Handle(app.postEquipmentBulkUpdateInspection)) - mux.HandleFunc("POST /orgs/{org_id}/equipment/{id}/units/{unit_id}", app.html.Handle(app.postEquipmentUpdateUnit)) - mux.HandleFunc("GET /orgs/{org_id}/equipment/{id}/units/{unit_id}/qr", app.html.Handle(app.getEquipmentUnitQR)) - mux.HandleFunc("GET /orgs/{org_id}/equipment/{id}/units/{unit_id}/barcode", app.html.Handle(app.getEquipmentUnitBarcode)) - mux.HandleFunc("POST /orgs/{org_id}/equipment/{id}/units/{unit_id}/delete", app.html.Handle(app.postDeleteEquipmentUnit)) - mux.HandleFunc("POST /orgs/{org_id}/equipment/{id}/archive", app.html.Handle(app.postArchiveEquipmentItem)) - mux.HandleFunc("POST /orgs/{org_id}/equipment/{id}/delete", app.html.Handle(app.postDeleteEquipmentItem)) - mux.HandleFunc("GET /orgs/{org_id}/equipment/{id}/content", app.html.Handle(app.getEquipmentContent)) - mux.HandleFunc("POST /orgs/{org_id}/equipment/{id}/content", app.html.Handle(app.postEquipmentAssignContent)) - mux.HandleFunc("POST /orgs/{org_id}/equipment/{id}/content/{content_id}/delete", app.html.Handle(app.postEquipmentRemoveContent)) - mux.HandleFunc("GET /orgs/{org_id}/equipment/{id}/part-of", app.html.Handle(app.getEquipmentPartOfFragment)) - mux.HandleFunc("GET /orgs/{org_id}/currency", app.html.Handle(app.getOrgCurrencyFragment)) - mux.HandleFunc("GET /orgs/{org_id}/equipment-categories", app.html.Handle(app.getEquipmentCategoriesFragment)) - mux.HandleFunc("GET /orgs/{org_id}/equipment-manufacturers", app.html.Handle(app.getEquipmentManufacturersFragment)) - mux.HandleFunc("GET /orgs/{org_id}/warehouse-locations", app.html.Handle(app.getEquipmentLocationsFragment)) - mux.HandleFunc("GET /orgs/{org_id}/settings", app.html.Handle(app.getOrgSettings)) - mux.HandleFunc("POST /orgs/{org_id}/settings", app.html.Handle(app.postOrgSettings)) - mux.HandleFunc("GET /orgs/{org_id}/settings/equipment-categories", app.html.Handle(app.getEquipmentCategories)) - mux.HandleFunc("GET /orgs/{org_id}/settings/equipment-categories/new", app.html.Handle(app.getEquipmentCategoryNew)) - mux.HandleFunc("POST /orgs/{org_id}/settings/equipment-categories/new", app.html.Handle(app.postEquipmentCategoryNew)) - mux.HandleFunc("GET /orgs/{org_id}/settings/equipment-categories/{id}", app.html.Handle(app.getEquipmentCategory)) - mux.HandleFunc("POST /orgs/{org_id}/settings/equipment-categories/{id}", app.html.Handle(app.postEquipmentCategory)) - mux.HandleFunc("POST /orgs/{org_id}/settings/equipment-categories/{id}/delete", app.html.Handle(app.postDeleteEquipmentCategory)) - mux.HandleFunc("GET /orgs/{org_id}/settings/manufacturers", app.html.Handle(app.getManufacturers)) - mux.HandleFunc("GET /orgs/{org_id}/settings/manufacturers/new", app.html.Handle(app.getManufacturerNew)) - mux.HandleFunc("POST /orgs/{org_id}/settings/manufacturers/new", app.html.Handle(app.postManufacturerNew)) - mux.HandleFunc("GET /orgs/{org_id}/settings/manufacturers/{id}", app.html.Handle(app.getManufacturer)) - mux.HandleFunc("POST /orgs/{org_id}/settings/manufacturers/{id}", app.html.Handle(app.postManufacturer)) - mux.HandleFunc("POST /orgs/{org_id}/settings/manufacturers/{id}/delete", app.html.Handle(app.postDeleteManufacturer)) - mux.HandleFunc("GET /orgs/{org_id}/settings/locations", app.html.Handle(app.getLocations)) - mux.HandleFunc("GET /orgs/{org_id}/settings/locations/new", app.html.Handle(app.getLocationNew)) - mux.HandleFunc("POST /orgs/{org_id}/settings/locations/new", app.html.Handle(app.postLocationNew)) - mux.HandleFunc("GET /orgs/{org_id}/settings/locations/{id}", app.html.Handle(app.getLocation)) - mux.HandleFunc("POST /orgs/{org_id}/settings/locations/{id}", app.html.Handle(app.postLocation)) - mux.HandleFunc("POST /orgs/{org_id}/settings/locations/{id}/delete", app.html.Handle(app.postDeleteLocation)) + // Login + mux.Handle("GET /signin", app.withGuest(app.html.Handle(app.getSignIn))) + mux.Handle("POST /signin", app.withGuest(app.html.Handle(app.postSignIn))) + mux.Handle("GET /signup", app.withGuest(app.html.Handle(app.getSignUp))) + mux.Handle("POST /signup", app.withGuest(app.html.Handle(app.postSignUp))) + mux.Handle("GET /forgot-password", app.withGuest(app.html.Handle(app.getForgotPassword))) + mux.Handle("POST /forgot-password", app.withGuest(app.html.Handle(app.postForgotPassword))) + mux.Handle("GET /forgot-password/success", app.withGuest(app.html.Handle(app.getForgotPasswordSuccess))) + mux.Handle("GET /reset-password", app.withGuest(app.html.Handle(app.getResetPassword))) + mux.Handle("POST /reset-password", app.withGuest(app.html.Handle(app.postResetPassword))) + + mux.Handle("POST /signout", app.withLogin(app.html.Handle(app.postSignOut))) + + // Orgs + mux.Handle("GET /orgs", app.withLogin(app.html.Handle(app.getOrgs))) + mux.Handle("GET /orgs/new", app.withLogin(app.html.Handle(app.getOrgsNew))) + mux.Handle("POST /orgs/new", app.withLogin(app.html.Handle(app.postOrgsNew))) + mux.Handle("DELETE /orgs/{org_id}", app.withLogin(app.html.Handle(app.deleteOrg))) + mux.Handle("GET /orgs/{org_id}", app.withLogin(app.html.Handle(app.getSettingsOrg))) + mux.Handle("POST /orgs/{org_id}", app.withLogin(app.html.Handle(app.postSettingsOrg))) + + // Equipment + mux.Handle("GET /orgs/{org_id}/equipment", app.withLogin(app.withPermission(app.html.Handle(app.getEquipment)))) + mux.Handle("GET /orgs/{org_id}/equipment/print", app.withLogin(app.html.Handle(app.getEquipmentPrint))) + mux.Handle("GET /orgs/{org_id}/equipment/export", app.withLogin(app.html.Handle(app.getEquipmentExport))) + mux.Handle("GET /orgs/{org_id}/equipment/import", app.withLogin(app.html.Handle(app.getEquipmentImport))) + mux.Handle("GET /orgs/{org_id}/equipment/import/template", app.withLogin(app.html.Handle(app.getEquipmentImportTemplate))) + mux.Handle("POST /orgs/{org_id}/equipment/import", app.withLogin(app.html.Handle(app.postEquipmentImport))) + mux.Handle("POST /orgs/{org_id}/equipment/import/confirm", app.withLogin(app.html.Handle(app.postEquipmentImportConfirm))) + mux.Handle("GET /orgs/{org_id}/equipment/new", app.withLogin(app.html.Handle(app.getEquipmentNew))) + mux.Handle("POST /orgs/{org_id}/equipment/new", app.withLogin(app.withCheckQuota(app.html.Handle(app.postEquipmentNew)))) + mux.Handle("GET /orgs/{org_id}/equipment/{id}", app.withLogin(app.html.Handle(app.getEquipmentItem))) + mux.Handle("GET /orgs/{org_id}/equipment/{id}/units", app.withLogin(app.html.Handle(app.getEquipmentUnits))) + mux.Handle("GET /orgs/{org_id}/equipment/{id}/pricing", app.withLogin(app.html.Handle(app.getEquipmentItemPricing))) + mux.Handle("POST /orgs/{org_id}/equipment/{id}/pricing", app.withLogin(app.html.Handle(app.postEquipmentItemPricing))) + mux.Handle("GET /orgs/{org_id}/equipment/{id}/properties", app.withLogin(app.html.Handle(app.getEquipmentItemProperties))) + mux.Handle("POST /orgs/{org_id}/equipment/{id}/details", app.withLogin(app.html.Handle(app.postEquipmentItemDetails))) + mux.Handle("POST /orgs/{org_id}/equipment/{id}/properties", app.withLogin(app.html.Handle(app.postEquipmentItemProperties))) + mux.Handle("POST /orgs/{org_id}/equipment/{id}/units", app.withLogin(app.html.Handle(app.postEquipmentAddUnit))) + mux.Handle("POST /orgs/{org_id}/equipment/{id}/units/bulk-inspect", app.withLogin(app.html.Handle(app.postEquipmentBulkUpdateInspection))) + mux.Handle("POST /orgs/{org_id}/equipment/{id}/units/{unit_id}", app.withLogin(app.html.Handle(app.postEquipmentUpdateUnit))) + mux.Handle("GET /orgs/{org_id}/equipment/{id}/units/{unit_id}/qr", app.withLogin(app.html.Handle(app.getEquipmentUnitQR))) + mux.Handle("GET /orgs/{org_id}/equipment/{id}/units/{unit_id}/barcode", app.withLogin(app.html.Handle(app.getEquipmentUnitBarcode))) + mux.Handle("POST /orgs/{org_id}/equipment/{id}/units/{unit_id}/delete", app.withLogin(app.html.Handle(app.postDeleteEquipmentUnit))) + mux.Handle("POST /orgs/{org_id}/equipment/{id}/archive", app.withLogin(app.html.Handle(app.postArchiveEquipmentItem))) + mux.Handle("POST /orgs/{org_id}/equipment/{id}/delete", app.withLogin(app.html.Handle(app.postDeleteEquipmentItem))) + mux.Handle("GET /orgs/{org_id}/equipment/{id}/content", app.withLogin(app.html.Handle(app.getEquipmentContent))) + mux.Handle("POST /orgs/{org_id}/equipment/{id}/content", app.withLogin(app.html.Handle(app.postEquipmentAssignContent))) + mux.Handle("POST /orgs/{org_id}/equipment/{id}/content/{content_id}/delete", app.withLogin(app.html.Handle(app.postEquipmentRemoveContent))) + mux.Handle("GET /orgs/{org_id}/equipment/{id}/part-of", app.withLogin(app.html.Handle(app.getEquipmentPartOfFragment))) + mux.Handle("GET /orgs/{org_id}/currency", app.withLogin(app.html.Handle(app.getOrgCurrencyFragment))) + mux.Handle("GET /orgs/{org_id}/equipment-categories", app.withLogin(app.html.Handle(app.getEquipmentCategoriesFragment))) + mux.Handle("GET /orgs/{org_id}/equipment-manufacturers", app.withLogin(app.html.Handle(app.getEquipmentManufacturersFragment))) + mux.Handle("GET /orgs/{org_id}/warehouse-locations", app.withLogin(app.html.Handle(app.getEquipmentLocationsFragment))) + mux.Handle("GET /orgs/{org_id}/settings", app.withLogin(app.html.Handle(app.getOrgSettings))) + mux.Handle("POST /orgs/{org_id}/settings", app.withLogin(app.html.Handle(app.postOrgSettings))) + + // Account Settings + mux.Handle("GET /settings/account", app.withLogin(app.html.Handle(app.getAccount))) + mux.Handle("DELETE /settings/account", app.withLogin(app.html.Handle(app.deleteAccount))) + + // Org related settings + mux.Handle("GET /orgs/{org_id}/settings/equipment-categories", app.withLogin(app.html.Handle(app.getEquipmentCategories))) + mux.Handle("GET /orgs/{org_id}/settings/equipment-categories/new", app.withLogin(app.html.Handle(app.getEquipmentCategoryNew))) + mux.Handle("POST /orgs/{org_id}/settings/equipment-categories/new", app.withLogin(app.html.Handle(app.postEquipmentCategoryNew))) + mux.Handle("GET /orgs/{org_id}/settings/equipment-categories/{id}", app.withLogin(app.html.Handle(app.getEquipmentCategory))) + mux.Handle("POST /orgs/{org_id}/settings/equipment-categories/{id}", app.withLogin(app.html.Handle(app.postEquipmentCategory))) + mux.Handle("POST /orgs/{org_id}/settings/equipment-categories/{id}/delete", app.withLogin(app.html.Handle(app.postDeleteEquipmentCategory))) + mux.Handle("GET /orgs/{org_id}/settings/manufacturers", app.withLogin(app.html.Handle(app.getManufacturers))) + mux.Handle("GET /orgs/{org_id}/settings/manufacturers/new", app.withLogin(app.html.Handle(app.getManufacturerNew))) + mux.Handle("POST /orgs/{org_id}/settings/manufacturers/new", app.withLogin(app.html.Handle(app.postManufacturerNew))) + mux.Handle("GET /orgs/{org_id}/settings/manufacturers/{id}", app.withLogin(app.html.Handle(app.getManufacturer))) + mux.Handle("POST /orgs/{org_id}/settings/manufacturers/{id}", app.withLogin(app.html.Handle(app.postManufacturer))) + mux.Handle("POST /orgs/{org_id}/settings/manufacturers/{id}/delete", app.withLogin(app.html.Handle(app.postDeleteManufacturer))) + mux.Handle("GET /orgs/{org_id}/settings/locations", app.withLogin(app.html.Handle(app.getLocations))) + mux.Handle("GET /orgs/{org_id}/settings/locations/new", app.withLogin(app.html.Handle(app.getLocationNew))) + mux.Handle("POST /orgs/{org_id}/settings/locations/new", app.withLogin(app.html.Handle(app.postLocationNew))) + mux.Handle("GET /orgs/{org_id}/settings/locations/{id}", app.withLogin(app.html.Handle(app.getLocation))) + mux.Handle("POST /orgs/{org_id}/settings/locations/{id}", app.withLogin(app.html.Handle(app.postLocation))) + mux.Handle("POST /orgs/{org_id}/settings/locations/{id}/delete", app.withLogin(app.html.Handle(app.postDeleteLocation))) antiCSRF := http.NewCrossOriginProtection() logRequest := newRequestLogger(app.logger) @@ -93,5 +115,6 @@ func (app *application) routes() (http.Handler, error) { logRequest.handler( withSecurityHeaders( withMaxBodySize( - antiCSRF.Handler(mux))))))), nil + antiCSRF.Handler( + app.session.LoadAndSave(mux)))))))), nil } diff --git a/cmd/web/seed.go b/cmd/web/seed.go index 4868b42..b12ff6c 100644 --- a/cmd/web/seed.go +++ b/cmd/web/seed.go @@ -5,7 +5,10 @@ import ( "database/sql" "fmt" + "github.com/bit8bytes/gearberg/internal/credentials" "github.com/bit8bytes/gearberg/internal/equipment" + "github.com/bit8bytes/gearberg/internal/orgs" + "github.com/bit8bytes/gearberg/internal/tokens" ) // seedReferenceData populates fixed lookup tables on every startup using @@ -15,6 +18,34 @@ import ( // renaming a value only requires changing the relevant package constant and // this file, with no migration needed. func seedReferenceData(ctx context.Context, db *sql.DB) error { + for _, t := range []tokens.Scope{ + tokens.PasswordReset, + tokens.EmailVerification, + } { + if _, err := db.ExecContext(ctx, `INSERT OR IGNORE INTO token_scopes (id, name) VALUES (?, ?)`, t.ID(), t.String()); err != nil { + return fmt.Errorf("seed token_scopes: %w", err) + } + } + + for _, t := range []credentials.Type{ + credentials.PasswordType, + } { + if _, err := db.ExecContext(ctx, `INSERT OR IGNORE INTO credential_types (id, name) VALUES (?, ?)`, t.ID(), t.String()); err != nil { + return fmt.Errorf("seed credential_types: %w", err) + } + } + + for _, r := range []orgs.Role{ + orgs.OwnerRole, + orgs.AdminRole, + orgs.MemberRole, + orgs.ViewerRole, + } { + if _, err := db.ExecContext(ctx, `INSERT OR IGNORE INTO org_roles (id, name, rank) VALUES (?, ?, ?)`, r.ID(), r.String(), r.Rank()); err != nil { + return fmt.Errorf("seed org_roles: %w", err) + } + } + for _, t := range []equipment.Type{ equipment.Bulk, equipment.Serialized, diff --git a/cmd/web/setup.go b/cmd/web/setup.go index fc255d0..b08117f 100644 --- a/cmd/web/setup.go +++ b/cmd/web/setup.go @@ -8,12 +8,16 @@ import ( "io/fs" "log" "log/slog" + "net/http" "net/url" "path/filepath" "slices" "strings" "time" + "github.com/alexedwards/scs/v2" + "github.com/bit8bytes/gearberg/internal/accounts" + "github.com/bit8bytes/gearberg/internal/credentials" "github.com/bit8bytes/gearberg/internal/database" "github.com/bit8bytes/gearberg/internal/database/migrations" "github.com/bit8bytes/gearberg/internal/equipment" @@ -26,6 +30,8 @@ import ( "github.com/bit8bytes/gearberg/internal/storage" "github.com/bit8bytes/gearberg/internal/templates" "github.com/bit8bytes/gearberg/internal/templates/pages" + "github.com/bit8bytes/gearberg/internal/tokens" + mailerpkg "github.com/bit8bytes/gearberg/pkg/mailer" ) func setupLogger(l slog.Level) *slog.Logger { @@ -123,7 +129,64 @@ func setupDatabase(ctx context.Context, options *options) (*sql.DB, error) { return db, nil } +func setupSCS(db *sql.DB) (*scs.SessionManager, error) { + mgr := scs.New() + mgr.Lifetime = 30 * 24 * time.Hour + mgr.Cookie.Name = "filmlet" + store, err := database.SessionStore(db, time.Hour) + if err != nil { + return nil, fmt.Errorf("session store: %w", err) + } + mgr.Store = store + mgr.HashTokenInStore = true + return mgr, nil +} + +func setupSessionManager(mgr *scs.SessionManager) sessionManager { + return scsSession{mgr: mgr} +} + +// scsSession wraps *scs.SessionManager and implements sessionManager. +// It encapsulates the accounts.Key so callers never reference the raw session key. +type scsSession struct { + mgr *scs.SessionManager +} + +func (s scsSession) AccountID(ctx context.Context) string { + return s.mgr.GetString(ctx, accounts.Key.String()) +} + +func (s scsSession) SetAccountID(ctx context.Context, id string) { + s.mgr.Put(ctx, accounts.Key.String(), id) +} + +func (s scsSession) Destroy(ctx context.Context) error { + if err := s.mgr.Destroy(ctx); err != nil { + return fmt.Errorf("session destroy: %w", err) + } + return nil +} + +func (s scsSession) LoadAndSave(next http.Handler) http.Handler { + return s.mgr.LoadAndSave(next) +} + +func setupMailer(cfg *options, log *slog.Logger) mailer { + if cfg.SMTP.Host == "" { + log.Warn("SMTP not configured, emails will be logged only") + return mailerpkg.LogMailer{} + } + return mailerpkg.New( + cfg.SMTP.Host, + cfg.SMTP.Username, + cfg.SMTP.Password, + cfg.SMTP.From, + cfg.SMTP.Port, + ) +} + type services struct { + accounts *accounts.Service orgs *orgs.Service orgsettings *settings.Service equipmentcategories *categories.Service @@ -134,9 +197,20 @@ type services struct { storageManager *storage.Manager } -func setupServices(db *sql.DB, opts *options, logger *slog.Logger) (*services, error) { - orgRepo := orgs.NewRepository(db) - orgSvc := orgs.NewService(orgRepo, orgs.Options{MaxOrgs: opts.MaxOrgs}) +// mailer defines the interface for sending emails. +type mailer interface { + Mail(ctx context.Context, to, subject, body string) error +} + +func setupServices(db *sql.DB, opts *options, logger *slog.Logger, m mailer) (*services, error) { + orgRepo := orgs.NewRepository(db, int64(opts.MaxOrgs)) + orgSvc := orgs.NewService(db, orgRepo) + + accountRepo := accounts.NewRepository(db) + credRepo := credentials.NewRepository(db) + tokenRepo := tokens.NewRepository(db) + + accountSvc := accounts.NewService(db, &credentials.Password{}, accountRepo, credRepo, orgRepo, tokenRepo, m) orgsettingsRepo := settings.NewRepository(db) orgsettingsSvc := settings.NewService(orgsettingsRepo) @@ -168,6 +242,7 @@ func setupServices(db *sql.DB, opts *options, logger *slog.Logger) (*services, e ) return &services{ + accounts: accountSvc, orgs: orgSvc, orgsettings: orgsettingsSvc, equipmentcategories: equipmentcategoriesSvc, diff --git a/go.mod b/go.mod index 3ef4899..8e6c432 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,12 @@ module github.com/bit8bytes/gearberg go 1.26.2 require ( + github.com/alexedwards/argon2id v1.0.0 github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de github.com/alexedwards/scs/v2 v2.9.0 - github.com/bit8bytes/toolbox v0.7.8 + github.com/bit8bytes/toolbox v0.7.9 github.com/boombuler/barcode v1.1.0 + github.com/ccojocar/zxcvbn-go v1.0.4 github.com/go-faster/errors v0.7.1 github.com/go-faster/jx v1.2.0 github.com/klauspost/compress v1.18.6 @@ -16,6 +18,7 @@ require ( go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel/metric v1.43.0 go.opentelemetry.io/otel/trace v1.43.0 + golang.org/x/image v0.43.0 golang.org/x/sync v0.21.0 modernc.org/sqlite v1.50.1 ) @@ -41,8 +44,8 @@ require ( go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect + golang.org/x/crypto v0.50.0 // indirect golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect - golang.org/x/image v0.43.0 // indirect golang.org/x/net v0.53.0 // indirect golang.org/x/sys v0.43.0 // indirect golang.org/x/text v0.38.0 // indirect diff --git a/go.sum b/go.sum index 22b9eae..40576ac 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,15 @@ +github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w= +github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw= github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de h1:c72K9HLu6K442et0j3BUL/9HEYaUJouLkkVANdmqTOo= github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de/go.mod h1:Iyk7S76cxGaiEX/mSYmTZzYehp4KfyylcLaV3OnToss= github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90= github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8= -github.com/bit8bytes/toolbox v0.7.8 h1:rnVosLjH3Hb4L4L/ZKJp/PjzB8Kfj2LYpLyV6e5e0Rg= -github.com/bit8bytes/toolbox v0.7.8/go.mod h1:qHJ8XWGJoe41F75xM471JAc04daXq8DfyB9w37exA2A= +github.com/bit8bytes/toolbox v0.7.9 h1:yN01Vh3oH9qvwC+pTB7MQv7/tyIz8vqYUiLWvadUw9I= +github.com/bit8bytes/toolbox v0.7.9/go.mod h1:qHJ8XWGJoe41F75xM471JAc04daXq8DfyB9w37exA2A= github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo= github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/ccojocar/zxcvbn-go v1.0.4 h1:FWnCIRMXPj43ukfX000kvBZvV6raSxakYr1nzyNrUcc= +github.com/ccojocar/zxcvbn-go v1.0.4/go.mod h1:3GxGX+rHmueTUMvm5ium7irpyjmm7ikxYFOSJB21Das= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -73,6 +77,7 @@ github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= @@ -87,28 +92,61 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY= golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/accounts/context.go b/internal/accounts/context.go new file mode 100644 index 0000000..ae2798a --- /dev/null +++ b/internal/accounts/context.go @@ -0,0 +1,10 @@ +package accounts + +type contextKey string + +func (contextKey) String() string { + return string(Key) +} + +// Key is the session key used to store and retrieve the authenticated account ID. +const Key contextKey = "ACCOUNT_ID_KEY" diff --git a/internal/accounts/form.go b/internal/accounts/form.go new file mode 100644 index 0000000..ebd47b3 --- /dev/null +++ b/internal/accounts/form.go @@ -0,0 +1,161 @@ +// Package accounts defines form types and validation rules for account workflows. +package accounts + +import ( + "fmt" + "net/http" + "strings" + + "github.com/bit8bytes/toolbox/validator" + "github.com/ccojocar/zxcvbn-go" +) + +const ( + passwordMinLength = 12 + passwordMaxLength = 128 + passwordMinStrengthScore = 3 +) + +// Form holds the parsed fields from a form submission. +type Form struct { + Email string + validator.Validator +} + +// SignInForm holds the parsed fields from a sign-in submission. +type SignInForm struct { + Email string + Password string + validator.Validator +} + +// ParseSignInForm reads and parses the sign-in form fields from the request. +func ParseSignInForm(r *http.Request) (SignInForm, error) { + if err := r.ParseForm(); err != nil { + return SignInForm{}, fmt.Errorf("accounts.ParseSignInForm: %w", err) + } + return SignInForm{ + Email: strings.TrimSpace(r.PostForm.Get("email")), + Password: r.PostForm.Get("password"), + }, nil +} + +// Validate checks that email and password are present and well-formed. +func (f *SignInForm) Validate() bool { + f.Check(validator.NotBlank(f.Email), "email", "This field cannot be blank") + f.Check(validator.Matches(f.Email, validator.EmailRX), "email", "This field must be a valid email address") + f.Check(validator.NotBlank(f.Password), "password", "This field cannot be blank") + return f.Valid() +} + +// SignUpForm holds the parsed fields from a sign-up submission. +type SignUpForm struct { + Email string + Password string + RepeatPassword string + validator.Validator +} + +// ParseSignUpForm reads and parses the sign-up form fields from the request. +func ParseSignUpForm(r *http.Request) (SignUpForm, error) { + if err := r.ParseForm(); err != nil { + return SignUpForm{}, fmt.Errorf("accounts.ParseSignUpForm: %w", err) + } + return SignUpForm{ + Email: strings.TrimSpace(r.PostForm.Get("email")), + Password: r.PostForm.Get("password"), + RepeatPassword: r.PostForm.Get("repeat_password"), + }, nil +} + +// Validate checks email format, password strength, length, and confirmation match. +func (f *SignUpForm) Validate() bool { + f.Check(validator.NotBlank(f.Email), "email", "This field cannot be blank") + f.Check(validator.Matches(f.Email, validator.EmailRX), "email", "This field must be a valid email address") + + f.Check(validator.NotBlank(f.Password), "password", "This field cannot be blank") + f.Check(validator.MinChars(f.Password, passwordMinLength), "password", + fmt.Sprintf("This field must be at least %v characters long", passwordMinLength)) + f.Check(validator.MaxChars(f.Password, passwordMaxLength), + "password", fmt.Sprintf("This field must be less than %v characters long", passwordMaxLength)) + + passwordStrength := zxcvbn.PasswordStrength(f.Password, nil) + f.Check(passwordStrength.Score >= passwordMinStrengthScore, "password", "Password is too common") + + f.Check(validator.NotBlank(f.RepeatPassword), "repeat_password", "This field cannot be blank") + f.Check(f.Password == f.RepeatPassword, "repeat_password", "Passwords do not match") + return f.Valid() +} + +// ForgotPasswordForm holds the parsed fields from a forgot-password submission. +type ForgotPasswordForm struct { + Email string + validator.Validator +} + +// ParseForgotPasswordForm reads and parses the forgot-password form fields from the request. +func ParseForgotPasswordForm(r *http.Request) (ForgotPasswordForm, error) { + if err := r.ParseForm(); err != nil { + return ForgotPasswordForm{}, fmt.Errorf("accounts.ParseForgotPasswordForm: %w", err) + } + return ForgotPasswordForm{ + Email: strings.TrimSpace(r.PostForm.Get("email")), + }, nil +} + +// Validate checks that email is present and well-formed. +func (f *ForgotPasswordForm) Validate() bool { + f.Check(validator.NotBlank(f.Email), "email", "This field cannot be blank") + f.Check(validator.Matches(f.Email, validator.EmailRX), "email", "This field must be a valid email address") + return f.Valid() +} + +// ProfileForm holds form state for the profile settings page. +type ProfileForm struct { + FirstName string + LastName string + Phone string + validator.Validator +} + +// ResetPasswordForm holds the parsed fields from a password-reset submission. +type ResetPasswordForm struct { + Token string + Password string + RepeatPassword string + validator.Validator +} + +// ParseResetPasswordForm reads and parses the reset-password form fields from the request. +func ParseResetPasswordForm(r *http.Request) (ResetPasswordForm, error) { + if err := r.ParseForm(); err != nil { + return ResetPasswordForm{}, fmt.Errorf("accounts.ParseResetPasswordForm: %w", err) + } + return ResetPasswordForm{ + Token: r.PostForm.Get("token"), + Password: r.PostForm.Get("password"), + RepeatPassword: r.PostForm.Get("repeat_password"), + }, nil +} + +// PasswordMinLength returns the minimum allowed password length for use in templates. +func (f *ResetPasswordForm) PasswordMinLength() int { return passwordMinLength } + +// PasswordMaxLength returns the maximum allowed password length for use in templates. +func (f *ResetPasswordForm) PasswordMaxLength() int { return passwordMaxLength } + +// Validate checks password strength, length, and confirmation match. +func (f *ResetPasswordForm) Validate() bool { + f.Check(validator.NotBlank(f.Password), "password", "This field cannot be blank") + f.Check(validator.MinChars(f.Password, passwordMinLength), "password", + fmt.Sprintf("This field must be at least %v characters long", passwordMinLength)) + f.Check(validator.MaxChars(f.Password, passwordMaxLength), + "password", fmt.Sprintf("This field must be less than %v characters long", passwordMaxLength)) + + result := zxcvbn.PasswordStrength(f.Password, nil) + f.Check(result.Score >= passwordMinStrengthScore, "password", "Password is too common") + + f.Check(validator.NotBlank(f.RepeatPassword), "repeat_password", "This field cannot be blank") + f.Check(f.Password == f.RepeatPassword, "repeat_password", "Passwords do not match") + return f.Valid() +} diff --git a/internal/accounts/repository.go b/internal/accounts/repository.go new file mode 100644 index 0000000..0641934 --- /dev/null +++ b/internal/accounts/repository.go @@ -0,0 +1,72 @@ +package accounts + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/bit8bytes/gearberg/internal/database" + genaccounts "github.com/bit8bytes/gearberg/internal/database/queries/gen/accounts" +) + +// Record holds the data returned by account lookups. +type Record struct { + ID string + Email string + EmailVerified *time.Time +} + +// Repository provides data access for accounts. +type Repository struct { + accounts *genaccounts.Queries +} + +// NewRepository returns a new Repository. +func NewRepository(db genaccounts.DBTX) *Repository { + return &Repository{ + accounts: genaccounts.New(db), + } +} + +// Create inserts a new account and returns its ID. +func (r *Repository) Create(ctx context.Context, tx *sql.Tx, id, email string) (string, error) { + row, err := r.accounts.WithTx(tx).Create(ctx, genaccounts.CreateParams{ + ID: id, + Email: email, + }) + if err != nil { + if errors.Is(database.NormalizeError(err), database.ErrUniqueConstraint) { + return "", ErrUserAlreadyExists + } + return "", fmt.Errorf("accounts.Repository.Create: %w", err) + } + return row.ID, nil +} + +// Delete removes an account by ID. +func (r *Repository) Delete(ctx context.Context, tx *sql.Tx, id string) error { + if err := r.accounts.WithTx(tx).Delete(ctx, id); err != nil { + return fmt.Errorf("accounts.Repository.Delete: %w", err) + } + return nil +} + +// GetByAccountID retrieves an account by its ID. +func (r *Repository) GetByAccountID(ctx context.Context, accountID string) (Record, error) { + row, err := r.accounts.GetByAccountID(ctx, accountID) + if err != nil { + return Record{}, fmt.Errorf("accounts.Repository.GetByAccountID: %w", err) + } + var emailVerified *time.Time + if row.EmailVerified.Valid { + t := time.Unix(row.EmailVerified.Int64, 0) + emailVerified = &t + } + return Record{ + ID: row.ID, + Email: row.Email, + EmailVerified: emailVerified, + }, nil +} diff --git a/internal/accounts/service.go b/internal/accounts/service.go new file mode 100644 index 0000000..e242212 --- /dev/null +++ b/internal/accounts/service.go @@ -0,0 +1,331 @@ +package accounts + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/bit8bytes/gearberg/internal/credentials" + "github.com/bit8bytes/gearberg/internal/database" + "github.com/bit8bytes/gearberg/internal/orgs" + "github.com/bit8bytes/gearberg/internal/tokens" + pkgtokens "github.com/bit8bytes/gearberg/pkg/tokens" + "github.com/segmentio/ksuid" +) + +// Sentinel errors returned by account service operations. +var ( + ErrUserAlreadyExists = errors.New("user already exists") + ErrInvalidPassword = errors.New("invalid password") + ErrInvalidCredentials = errors.New("invalid email or password") + ErrInvalidToken = errors.New("invalid or expired reset token") + ErrNotFound = errors.New("account not found") + ErrSamePassword = errors.New("new password must differ from the current password") +) + +type passwordHasher interface { + CreateHash(plaintext string) ([]byte, error) + ComparePasswordAndHash(plaintext string, hash []byte) (bool, error) +} + +type accountsRepository interface { + Create(ctx context.Context, tx *sql.Tx, id, email string) (string, error) + Delete(ctx context.Context, tx *sql.Tx, id string) error + GetByAccountID(ctx context.Context, accountID string) (Record, error) +} + +type credentialsRepository interface { + Create(ctx context.Context, tx *sql.Tx, accountID string, typeID int64, secretData []byte) error + GetByEmail(ctx context.Context, email string, typeID int64) (credentials.Record, error) + GetByAccountID(ctx context.Context, accountID string, typeID int64) (credentials.Record, error) + UpdatePassword(ctx context.Context, tx *sql.Tx, accountID string, hash string) error +} + +type organizationsRepository interface { + Create(ctx context.Context, tx *sql.Tx, id, displayName string) (string, error) + CreateMember(ctx context.Context, tx *sql.Tx, accountID, organizationID string, roleID int64) error + DeleteOwnedByAccount(ctx context.Context, tx *sql.Tx, accountID string) error +} + +type tokensRepository interface { + Create(ctx context.Context, tx *sql.Tx, p tokens.CreateParams) error + Delete(ctx context.Context, tx *sql.Tx, accountID string, scopeID int64) error + VerifyAndIncrement(ctx context.Context, tx *sql.Tx, hash []byte) (tokens.Record, error) +} + +type mailer interface { + Mail(ctx context.Context, to, subject, body string) error +} + +// Service provides account creation and authentication operations. +type Service struct { + db *sql.DB + password passwordHasher + accounts accountsRepository + credentials credentialsRepository + organizations organizationsRepository + tokens tokensRepository + mailer mailer +} + +// NewService returns a new Service. +func NewService( + db *sql.DB, + hasher passwordHasher, + accounts accountsRepository, + creds credentialsRepository, + organizations organizationsRepository, + tokens tokensRepository, + mailer mailer, +) *Service { + return &Service{ + db: db, + password: hasher, + accounts: accounts, + credentials: creds, + organizations: organizations, + tokens: tokens, + mailer: mailer, + } +} + +// Get retrieves the account record for the given account ID. +func (s *Service) Get(ctx context.Context, accountID string) (Record, error) { + record, err := s.accounts.GetByAccountID(ctx, accountID) + if err != nil { + return Record{}, fmt.Errorf("accounts.Get: %w", err) + } + return record, nil +} + +// SignUpParams holds the fields required to create a new account. +type SignUpParams struct { + Email string + Password string + RepeatPassword string +} + +// SignUp creates a new account, credential, profile, and default organization atomically. +// It relies on the database UNIQUE constraint to reject duplicate emails rather +// than a separate existence check, eliminating the check-then-act race window. +func (s *Service) SignUp(ctx context.Context, params SignUpParams) (string, string, error) { + fail := func(err error) (string, string, error) { + return "", "", fmt.Errorf("accounts.SignUp: %w", err) + } + + if params.Password != params.RepeatPassword { + return fail(ErrInvalidPassword) + } + + hash, err := s.password.CreateHash(params.Password) + if err != nil { + return fail(ErrInvalidPassword) + } + + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{}) + if err != nil { + return fail(err) + } + defer func() { _ = tx.Rollback() }() + + accountID := ksuid.New().String() + if _, err := s.accounts.Create(ctx, tx, accountID, params.Email); err != nil { + return fail(err) + } + + if err := s.credentials.Create(ctx, tx, accountID, credentials.PasswordType.ID(), hash); err != nil { + return fail(err) + } + + orgID := ksuid.New().String() + if _, err := s.organizations.Create(ctx, tx, orgID, "My Organization"); err != nil { + return fail(err) + } + + if err := s.organizations.CreateMember(ctx, tx, accountID, orgID, orgs.OwnerRole.ID()); err != nil { + return fail(err) + } + + if err := tx.Commit(); err != nil { + return fail(err) + } + + return accountID, orgID, nil +} + +// SignInParams holds the fields required to authenticate an account. +type SignInParams struct { + Email string + Password string +} + +// SignIn authenticates an account with email and password, returning the account ID. +func (s *Service) SignIn(ctx context.Context, params SignInParams) (string, error) { + record, err := s.credentials.GetByEmail(ctx, params.Email, credentials.PasswordType.ID()) + if err != nil { + return "", ErrInvalidCredentials + } + + match, err := s.password.ComparePasswordAndHash(params.Password, []byte(record.SecretData)) + if err != nil || !match { + return "", ErrInvalidCredentials + } + + return record.AccountID, nil +} + +// ForgotPassword looks up the account by email, generates a password-reset token, +// replaces any existing token for that account, and returns the plaintext token. +// Returns ErrNotFound if no user with the given email exists, ensuring tokens are +// only issued for registered accounts. +func (s *Service) ForgotPassword(ctx context.Context, email string) (string, error) { + fail := func(err error) (string, error) { + return "", fmt.Errorf("accounts.ForgotPassword: %w", err) + } + + creds, err := s.credentials.GetByEmail(ctx, email, credentials.PasswordType.ID()) + if err != nil { + if errors.Is(err, database.ErrNotFound) { + return fail(ErrNotFound) + } + return fail(err) + } + + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{}) + if err != nil { + return fail(err) + } + defer func() { _ = tx.Rollback() }() + + plaintext := pkgtokens.Generate().String() + hash := pkgtokens.Hash(plaintext) + + if err := s.tokens.Delete(ctx, tx, creds.AccountID, tokens.PasswordReset.ID()); err != nil { + return fail(err) + } + + if err := s.tokens.Create(ctx, tx, tokens.CreateParams{ + AccountID: creds.AccountID, + ScopeID: tokens.PasswordReset.ID(), + Hash: hash, + ExpiresAt: time.Now().Add(15 * time.Minute).Unix(), + }, + ); err != nil { + return fail(err) + } + + if err := tx.Commit(); err != nil { + return fail(err) + } + + return plaintext, nil +} + +// SendPasswordReset sends a password reset email with a link built from resetURL. +func (s *Service) SendPasswordReset(ctx context.Context, email, resetURL string) error { + subject := "Reset your password" + body := "To reset your password, follow this link:\n\n" + resetURL + + "\n\nThis link expires in 15 minutes." + + "\n\nIf you did not request a password reset, you can safely ignore this email." + if err := s.mailer.Mail(ctx, email, subject, body); err != nil { + return fmt.Errorf("SendPasswordReset: %w", err) + } + return nil +} + +// ResetPassword verifies the plaintext token, then updates the user's password. +// The token is deleted after a successful reset. It returns the account email +// so the caller can send a notification. +func (s *Service) ResetPassword(ctx context.Context, token, passwd string) (string, error) { + fail := func(err error) (string, error) { + return "", fmt.Errorf("accounts.ResetPassword: %w", err) + } + + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{}) + if err != nil { + return fail(err) + } + defer func() { _ = tx.Rollback() }() + + tokenHash := pkgtokens.Hash(token) + tok, err := s.tokens.VerifyAndIncrement(ctx, tx, tokenHash) + if err != nil { + return fail(ErrInvalidToken) + } + + creds, err := s.credentials.GetByAccountID(ctx, tok.AccountID, credentials.PasswordType.ID()) + if err != nil { + return fail(ErrInvalidToken) + } + + // Reject if the new password is the same as the current one. + if same, err := s.password.ComparePasswordAndHash(passwd, creds.SecretData); err == nil && same { + return fail(ErrSamePassword) + } + + newHash, err := s.password.CreateHash(passwd) + if err != nil { + return fail(err) + } + + if err := s.credentials.UpdatePassword(ctx, tx, creds.AccountID, string(newHash)); err != nil { + return fail(err) + } + + if err := s.tokens.Delete(ctx, tx, tok.AccountID, tok.TokenScopeID); err != nil { + return fail(err) + } + + if err := tx.Commit(); err != nil { + return fail(err) + } + + accounts, err := s.accounts.GetByAccountID(ctx, creds.AccountID) + if err != nil { + return fail(err) + } + + return accounts.Email, nil +} + +// SendPasswordChangedNotification sends an alert email informing the user that +// their password was just changed. The caller should log but not surface any +// error returned, since the reset has already succeeded. +func (s *Service) SendPasswordChangedNotification(ctx context.Context, email string) error { + subject := "Your password was changed" + body := "Your gearberg password was just changed.\n\n" + + "If you did not make this change, please contact support immediately." + if err := s.mailer.Mail(ctx, email, subject, body); err != nil { + return fmt.Errorf("SendPasswordChangedNotification: %w", err) + } + return nil +} + +// Delete removes an account and all organizations where it is the sole owner. +func (s *Service) Delete(ctx context.Context, accountID string) error { + fail := func(err error) error { + return fmt.Errorf("accounts.Delete: %w", err) + } + + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{}) + if err != nil { + return fail(err) + } + defer func() { _ = tx.Rollback() }() + + if err := s.organizations.DeleteOwnedByAccount(ctx, tx, accountID); err != nil { + return fail(err) + } + + if err := s.accounts.Delete(ctx, tx, accountID); err != nil { + return fail(err) + } + + if err := tx.Commit(); err != nil { + return fail(err) + } + + return nil +} diff --git a/internal/assets/css/index.css b/internal/assets/css/index.css index 5d1a6b6..5fa65db 100644 --- a/internal/assets/css/index.css +++ b/internal/assets/css/index.css @@ -18,7 +18,7 @@ /* secondary — warm muted */ --color-secondary: #4a4845; - --color-secondary-content: #f5f4f0; + --color-secondary-content: #0d0d00; /* accent — softer orange tint */ --color-accent: #fef0e6; diff --git a/internal/assets/dist/index.css b/internal/assets/dist/index.css index 972efbb..2e21945 100644 --- a/internal/assets/dist/index.css +++ b/internal/assets/dist/index.css @@ -4278,6 +4278,12 @@ --toast-y: 0; } } + .top-1\/2 { + top: calc(1 / 2 * 100%); + } + .right-2 { + right: calc(var(--spacing) * 2); + } .right-6 { right: calc(var(--spacing) * 6); } @@ -5779,6 +5785,9 @@ .mb-1 { margin-bottom: calc(var(--spacing) * 1); } + .mb-2 { + margin-bottom: calc(var(--spacing) * 2); + } .mb-3 { margin-bottom: calc(var(--spacing) * 3); } @@ -6571,6 +6580,9 @@ .h-full { height: 100%; } + .h-screen { + height: 100vh; + } .max-h-60 { max-height: calc(var(--spacing) * 60); } @@ -6816,6 +6828,10 @@ .border-collapse { border-collapse: collapse; } + .-translate-y-1\/2 { + --tw-translate-y: calc(calc(1 / 2 * 100%) * -1); + translate: var(--tw-translate-x) var(--tw-translate-y); + } .swap-rotate { @layer daisyui.l1.l2 { .swap-on, input:indeterminate ~ .swap-on { @@ -6887,6 +6903,9 @@ } } } + .cursor-help { + cursor: help; + } .cursor-pointer { cursor: pointer; } @@ -8047,6 +8066,9 @@ .p-4 { padding: calc(var(--spacing) * 4); } + .p-6 { + padding: calc(var(--spacing) * 6); + } .p-8 { padding: calc(var(--spacing) * 8); } @@ -10590,7 +10612,7 @@ --color-primary: #e07c3a; --color-primary-content: #ffffff; --color-secondary: #4a4845; - --color-secondary-content: #f5f4f0; + --color-secondary-content: #0d0d00; --color-accent: #fef0e6; --color-accent-content: #e07c3a; --color-neutral: #7a7670; @@ -10613,6 +10635,21 @@ --noise: 0; } } +@property --tw-translate-x { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-translate-y { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-translate-z { + syntax: "*"; + inherits: false; + initial-value: 0; +} @property --tw-rotate-x { syntax: "*"; inherits: false; @@ -10855,6 +10892,9 @@ @layer properties { @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { *, ::before, ::after, ::backdrop { + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-translate-z: 0; --tw-rotate-x: initial; --tw-rotate-y: initial; --tw-rotate-z: initial; diff --git a/internal/credentials/password.go b/internal/credentials/password.go new file mode 100644 index 0000000..ac719da --- /dev/null +++ b/internal/credentials/password.go @@ -0,0 +1,25 @@ +package credentials + +import ( + "fmt" + + "github.com/alexedwards/argon2id" +) + +// Password hashes and verifies passwords using argon2id with default parameters. +type Password struct{} + +// CreateHash returns an encoded argon2id hash of plaintext. +func (h *Password) CreateHash(plaintext string) ([]byte, error) { + s, err := argon2id.CreateHash(plaintext, argon2id.DefaultParams) + return []byte(s), err +} + +// ComparePasswordAndHash reports whether plaintext matches the encoded argon2id hash. +func (h *Password) ComparePasswordAndHash(plaintext string, hash []byte) (bool, error) { + match, err := argon2id.ComparePasswordAndHash(plaintext, string(hash)) + if err != nil { + return false, fmt.Errorf("credentials.Hasher.ComparePasswordAndHash: %w", err) + } + return match, nil +} diff --git a/internal/credentials/repository.go b/internal/credentials/repository.go new file mode 100644 index 0000000..4163b50 --- /dev/null +++ b/internal/credentials/repository.go @@ -0,0 +1,82 @@ +// Package credentials manages credential records used for authentication. +package credentials + +import ( + "context" + "database/sql" + "fmt" + + gencredentials "github.com/bit8bytes/gearberg/internal/database/queries/gen/credentials" +) + +// Record holds the data returned by GetByEmail for authentication. +type Record struct { + AccountID string + SecretData []byte +} + +// Repository provides data access for credentials. +type Repository struct { + credentials *gencredentials.Queries +} + +// NewRepository returns a new Repository. +func NewRepository(db gencredentials.DBTX) *Repository { + return &Repository{ + credentials: gencredentials.New(db), + } +} + +// Create inserts a new credential record for accountID within the given transaction. +func (r *Repository) Create(ctx context.Context, tx *sql.Tx, accountID string, typeID int64, secretData []byte) error { + if err := r.credentials.WithTx(tx).Create(ctx, gencredentials.CreateParams{ + AccountID: accountID, + TypeID: typeID, + SecretData: secretData, + }); err != nil { + return fmt.Errorf("credentials.Repository.Create: %w", err) + } + return nil +} + +// GetByEmail looks up a credential by account email and credential type. +func (r *Repository) GetByEmail(ctx context.Context, email string, typeID int64) (Record, error) { + row, err := r.credentials.GetByEmail(ctx, gencredentials.GetByEmailParams{ + Email: email, + TypeID: typeID, + }) + if err != nil { + return Record{}, fmt.Errorf("credentials.Repository.GetByEmail: %w", err) + } + return Record{ + AccountID: row.AccountID, + SecretData: row.SecretData, + }, nil +} + +// GetByAccountID looks up a credential by account ID and credential type. +func (r *Repository) GetByAccountID(ctx context.Context, accountID string, typeID int64) (Record, error) { + row, err := r.credentials.GetByAccountID(ctx, gencredentials.GetByAccountIDParams{ + AccountID: accountID, + TypeID: typeID, + }) + if err != nil { + return Record{}, fmt.Errorf("credentials.Repository.GetByAccountID: %w", err) + } + return Record{ + AccountID: row.AccountID, + SecretData: row.SecretData, + }, nil +} + +// UpdatePassword replaces the hashed password for accountID within the given transaction. +func (r *Repository) UpdatePassword(ctx context.Context, tx *sql.Tx, accountID string, hash string) error { + if err := r.credentials.WithTx(tx).UpdateSecret(ctx, gencredentials.UpdateSecretParams{ + AccountID: accountID, + TypeID: int64(PasswordType), + SecretData: []byte(hash), + }); err != nil { + return fmt.Errorf("credentials.Repository.UpdatePassword: %w", err) + } + return nil +} diff --git a/internal/credentials/types.go b/internal/credentials/types.go new file mode 100644 index 0000000..cfed3fc --- /dev/null +++ b/internal/credentials/types.go @@ -0,0 +1,23 @@ +package credentials + +// Type represents a credential type. The integer value matches the id stored +// in the credential_types table, which is seeded at startup. +type Type int64 + +// Credential type IDs matching rows seeded in the credential_types table. +const ( + PasswordType Type = 1 +) + +// ID returns the database id for the credential type. +func (t Type) ID() int64 { return int64(t) } + +// String returns the name stored in the credential_types table. +func (t Type) String() string { + switch t { + case PasswordType: + return "password" + default: + return "" + } +} diff --git a/internal/database/migrations/migrations.go b/internal/database/migrations/migrations.go index 5f9b813..475f0e9 100644 --- a/internal/database/migrations/migrations.go +++ b/internal/database/migrations/migrations.go @@ -1,4 +1,4 @@ -// Copyright 2026 filmlet. All rights reserved. +// Copyright 2026 gearberg. All rights reserved. // Package migrations provides access to files embedded in the running Go program. // It embeds all sql migration files for single binary deployments. diff --git a/internal/database/migrations/sqlite/20260110162506_sessions.sql b/internal/database/migrations/sqlite/20260110162506_sessions.sql new file mode 100644 index 0000000..337cc16 --- /dev/null +++ b/internal/database/migrations/sqlite/20260110162506_sessions.sql @@ -0,0 +1,13 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + data BLOB NOT NULL, + expiry REAL NOT NULL +) STRICT; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS sessions; +-- +goose StatementEnd \ No newline at end of file diff --git a/internal/database/migrations/sqlite/20260110162507_accounts.sql b/internal/database/migrations/sqlite/20260110162507_accounts.sql new file mode 100644 index 0000000..e6f6f85 --- /dev/null +++ b/internal/database/migrations/sqlite/20260110162507_accounts.sql @@ -0,0 +1,16 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS accounts ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + email_verified INTEGER, + enabled INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) +) STRICT; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS accounts; +-- +goose StatementEnd \ No newline at end of file diff --git a/internal/database/migrations/sqlite/20260110162508_credential_types.sql b/internal/database/migrations/sqlite/20260110162508_credential_types.sql new file mode 100644 index 0000000..2afc162 --- /dev/null +++ b/internal/database/migrations/sqlite/20260110162508_credential_types.sql @@ -0,0 +1,12 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS credential_types ( + id INTEGER PRIMARY KEY, + name TEXT UNIQUE NOT NULL +) STRICT; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS credential_types; +-- +goose StatementEnd \ No newline at end of file diff --git a/internal/database/migrations/sqlite/20260110162509_credentials.sql b/internal/database/migrations/sqlite/20260110162509_credentials.sql new file mode 100644 index 0000000..f900a67 --- /dev/null +++ b/internal/database/migrations/sqlite/20260110162509_credentials.sql @@ -0,0 +1,17 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + type_id INTEGER NOT NULL REFERENCES credential_types(id) ON DELETE RESTRICT, + secret_data BLOB NOT NULL, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()), + UNIQUE(account_id, type_id) +) STRICT; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS credentials; +-- +goose StatementEnd diff --git a/internal/database/migrations/sqlite/20260110172509_orgs.sql b/internal/database/migrations/sqlite/20260110172509_orgs.sql index 0242c0d..6a98cfc 100644 --- a/internal/database/migrations/sqlite/20260110172509_orgs.sql +++ b/internal/database/migrations/sqlite/20260110172509_orgs.sql @@ -2,7 +2,7 @@ -- +goose StatementBegin CREATE TABLE orgs ( id TEXT PRIMARY KEY, - name TEXT UNIQUE NOT NULL, + display_name TEXT NOT NULL, updated_at INTEGER NOT NULL DEFAULT (unixepoch()), created_at INTEGER NOT NULL DEFAULT (unixepoch()) ) STRICT; diff --git a/internal/database/migrations/sqlite/20260514003042_org_roles.sql b/internal/database/migrations/sqlite/20260514003042_org_roles.sql new file mode 100644 index 0000000..c2db8b1 --- /dev/null +++ b/internal/database/migrations/sqlite/20260514003042_org_roles.sql @@ -0,0 +1,14 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS org_roles ( + id INTEGER PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + rank INTEGER NOT NULL DEFAULT 0 +) STRICT; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS org_roles; +-- +goose StatementEnd \ No newline at end of file diff --git a/internal/database/migrations/sqlite/20260514003043_org_member_roles.sql b/internal/database/migrations/sqlite/20260514003043_org_member_roles.sql new file mode 100644 index 0000000..aa65561 --- /dev/null +++ b/internal/database/migrations/sqlite/20260514003043_org_member_roles.sql @@ -0,0 +1,23 @@ +-- +goose Up +-- +goose StatementBegin + +-- Table: org_members +-- Associates accounts with orgss and their roles within those orgss +CREATE TABLE IF NOT EXISTS org_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id TEXT NOT NULL, + org_id TEXT NOT NULL, + role_id INTEGER NOT NULL, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()), + FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE, + FOREIGN KEY (org_id) REFERENCES orgs(id) ON DELETE CASCADE, + FOREIGN KEY (role_id) REFERENCES org_roles(id) ON DELETE RESTRICT, + UNIQUE(account_id, org_id) +) STRICT; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS org_members; +-- +goose StatementEnd diff --git a/internal/database/migrations/sqlite/20260702234518_token_scopes.sql b/internal/database/migrations/sqlite/20260702234518_token_scopes.sql new file mode 100644 index 0000000..3e2d614 --- /dev/null +++ b/internal/database/migrations/sqlite/20260702234518_token_scopes.sql @@ -0,0 +1,12 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS token_scopes ( + id INTEGER PRIMARY KEY, + name TEXT UNIQUE NOT NULL +) STRICT; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS token_scopes; +-- +goose StatementEnd diff --git a/internal/database/migrations/sqlite/20260702234522_tokens.sql b/internal/database/migrations/sqlite/20260702234522_tokens.sql new file mode 100644 index 0000000..db83edf --- /dev/null +++ b/internal/database/migrations/sqlite/20260702234522_tokens.sql @@ -0,0 +1,18 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + token_scope_id INTEGER NOT NULL REFERENCES token_scopes(id) ON DELETE RESTRICT, + hash BLOB NOT NULL UNIQUE, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + attempts INTEGER NOT NULL DEFAULT 0, + CHECK (length(hash) = 32) +) STRICT; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS tokens; +-- +goose StatementEnd diff --git a/internal/database/queries/gen/accounts/accounts.sql.go b/internal/database/queries/gen/accounts/accounts.sql.go new file mode 100644 index 0000000..10f7eff --- /dev/null +++ b/internal/database/queries/gen/accounts/accounts.sql.go @@ -0,0 +1,110 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: accounts.sql + +package accounts + +import ( + "context" + "database/sql" +) + +const create = `-- name: Create :one +INSERT INTO accounts ( + id, + email +) VALUES ( + ?, ? +) RETURNING + id, + email, + email_verified, + enabled, + created_at +` + +type CreateParams struct { + ID string + Email string +} + +type CreateRow struct { + ID string + Email string + EmailVerified sql.NullInt64 + Enabled int64 + CreatedAt int64 +} + +func (q *Queries) Create(ctx context.Context, arg CreateParams) (CreateRow, error) { + row := q.db.QueryRowContext(ctx, create, arg.ID, arg.Email) + var i CreateRow + err := row.Scan( + &i.ID, + &i.Email, + &i.EmailVerified, + &i.Enabled, + &i.CreatedAt, + ) + return i, err +} + +const delete = `-- name: Delete :exec +DELETE FROM accounts +WHERE id = ? +` + +func (q *Queries) Delete(ctx context.Context, id string) error { + _, err := q.db.ExecContext(ctx, delete, id) + return err +} + +const exists = `-- name: Exists :one +SELECT EXISTS( + SELECT 1 FROM accounts WHERE email = ? +) AS account_exists +` + +func (q *Queries) Exists(ctx context.Context, email string) (bool, error) { + row := q.db.QueryRowContext(ctx, exists, email) + var account_exists bool + err := row.Scan(&account_exists) + return account_exists, err +} + +const getByAccountID = `-- name: GetByAccountID :one +SELECT + id, + email, + email_verified, + enabled, + updated_at, + created_at +FROM accounts +WHERE id = ? +LIMIT 1 +` + +type GetByAccountIDRow struct { + ID string + Email string + EmailVerified sql.NullInt64 + Enabled int64 + UpdatedAt int64 + CreatedAt int64 +} + +func (q *Queries) GetByAccountID(ctx context.Context, id string) (GetByAccountIDRow, error) { + row := q.db.QueryRowContext(ctx, getByAccountID, id) + var i GetByAccountIDRow + err := row.Scan( + &i.ID, + &i.Email, + &i.EmailVerified, + &i.Enabled, + &i.UpdatedAt, + &i.CreatedAt, + ) + return i, err +} diff --git a/internal/database/queries/gen/accounts/db.go b/internal/database/queries/gen/accounts/db.go new file mode 100644 index 0000000..645ba65 --- /dev/null +++ b/internal/database/queries/gen/accounts/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package accounts + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/database/queries/gen/accounts/models.go b/internal/database/queries/gen/accounts/models.go new file mode 100644 index 0000000..16fe803 --- /dev/null +++ b/internal/database/queries/gen/accounts/models.go @@ -0,0 +1,5 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package accounts diff --git a/internal/database/queries/gen/accounts/querier.go b/internal/database/queries/gen/accounts/querier.go new file mode 100644 index 0000000..dd34003 --- /dev/null +++ b/internal/database/queries/gen/accounts/querier.go @@ -0,0 +1,18 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package accounts + +import ( + "context" +) + +type Querier interface { + Create(ctx context.Context, arg CreateParams) (CreateRow, error) + Delete(ctx context.Context, id string) error + Exists(ctx context.Context, email string) (bool, error) + GetByAccountID(ctx context.Context, id string) (GetByAccountIDRow, error) +} + +var _ Querier = (*Queries)(nil) diff --git a/internal/database/queries/gen/credentials/credentials.sql.go b/internal/database/queries/gen/credentials/credentials.sql.go new file mode 100644 index 0000000..63216ed --- /dev/null +++ b/internal/database/queries/gen/credentials/credentials.sql.go @@ -0,0 +1,121 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: credentials.sql + +package credentials + +import ( + "context" +) + +const create = `-- name: Create :exec +INSERT INTO credentials ( + account_id, + type_id, + secret_data +) VALUES ( + ?, ?, ? +) +` + +type CreateParams struct { + AccountID string + TypeID int64 + SecretData []byte +} + +func (q *Queries) Create(ctx context.Context, arg CreateParams) error { + _, err := q.db.ExecContext(ctx, create, arg.AccountID, arg.TypeID, arg.SecretData) + return err +} + +const getByAccountID = `-- name: GetByAccountID :one +SELECT + id, + account_id, + type_id, + secret_data, + updated_at, + created_at +FROM credentials +WHERE account_id = ? +AND type_id = ? +LIMIT 1 +` + +type GetByAccountIDParams struct { + AccountID string + TypeID int64 +} + +type GetByAccountIDRow struct { + ID int64 + AccountID string + TypeID int64 + SecretData []byte + UpdatedAt int64 + CreatedAt int64 +} + +func (q *Queries) GetByAccountID(ctx context.Context, arg GetByAccountIDParams) (GetByAccountIDRow, error) { + row := q.db.QueryRowContext(ctx, getByAccountID, arg.AccountID, arg.TypeID) + var i GetByAccountIDRow + err := row.Scan( + &i.ID, + &i.AccountID, + &i.TypeID, + &i.SecretData, + &i.UpdatedAt, + &i.CreatedAt, + ) + return i, err +} + +const getByEmail = `-- name: GetByEmail :one +SELECT + c.secret_data, + a.id AS account_id +FROM credentials c +JOIN accounts a ON a.id = c.account_id +WHERE a.email = ? +AND c.type_id = ? +LIMIT 1 +` + +type GetByEmailParams struct { + Email string + TypeID int64 +} + +type GetByEmailRow struct { + SecretData []byte + AccountID string +} + +func (q *Queries) GetByEmail(ctx context.Context, arg GetByEmailParams) (GetByEmailRow, error) { + row := q.db.QueryRowContext(ctx, getByEmail, arg.Email, arg.TypeID) + var i GetByEmailRow + err := row.Scan(&i.SecretData, &i.AccountID) + return i, err +} + +const updateSecret = `-- name: UpdateSecret :exec +UPDATE credentials +SET + secret_data = ?, + updated_at = unixepoch() +WHERE account_id = ? +AND type_id = ? +` + +type UpdateSecretParams struct { + SecretData []byte + AccountID string + TypeID int64 +} + +func (q *Queries) UpdateSecret(ctx context.Context, arg UpdateSecretParams) error { + _, err := q.db.ExecContext(ctx, updateSecret, arg.SecretData, arg.AccountID, arg.TypeID) + return err +} diff --git a/internal/database/queries/gen/credentials/db.go b/internal/database/queries/gen/credentials/db.go new file mode 100644 index 0000000..b9c47a2 --- /dev/null +++ b/internal/database/queries/gen/credentials/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package credentials + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/database/queries/gen/credentials/models.go b/internal/database/queries/gen/credentials/models.go new file mode 100644 index 0000000..fa4645c --- /dev/null +++ b/internal/database/queries/gen/credentials/models.go @@ -0,0 +1,5 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package credentials diff --git a/internal/database/queries/gen/credentials/querier.go b/internal/database/queries/gen/credentials/querier.go new file mode 100644 index 0000000..3616e35 --- /dev/null +++ b/internal/database/queries/gen/credentials/querier.go @@ -0,0 +1,18 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package credentials + +import ( + "context" +) + +type Querier interface { + Create(ctx context.Context, arg CreateParams) error + GetByAccountID(ctx context.Context, arg GetByAccountIDParams) (GetByAccountIDRow, error) + GetByEmail(ctx context.Context, arg GetByEmailParams) (GetByEmailRow, error) + UpdateSecret(ctx context.Context, arg UpdateSecretParams) error +} + +var _ Querier = (*Queries)(nil) diff --git a/internal/database/queries/gen/orgs/models.go b/internal/database/queries/gen/orgs/models.go index 6df8449..cd1cc34 100644 --- a/internal/database/queries/gen/orgs/models.go +++ b/internal/database/queries/gen/orgs/models.go @@ -5,8 +5,14 @@ package orgs type Org struct { - ID string - Name string - UpdatedAt int64 - CreatedAt int64 + ID string + DisplayName string + UpdatedAt int64 + CreatedAt int64 +} + +type OrgRole struct { + ID int64 + Name string + Rank int64 } diff --git a/internal/database/queries/gen/orgs/orgs.sql.go b/internal/database/queries/gen/orgs/orgs.sql.go index 0cea5e6..2a6e50f 100644 --- a/internal/database/queries/gen/orgs/orgs.sql.go +++ b/internal/database/queries/gen/orgs/orgs.sql.go @@ -9,69 +9,228 @@ import ( "context" ) -const countOrgs = `-- name: CountOrgs :one -SELECT COUNT(*) FROM orgs -` - -func (q *Queries) CountOrgs(ctx context.Context) (int64, error) { - row := q.db.QueryRowContext(ctx, countOrgs) - var count int64 - err := row.Scan(&count) - return count, err -} - const create = `-- name: Create :one INSERT INTO orgs ( id, - name -) VALUES ( - ?, - ? -) RETURNING + display_name +) +SELECT ?, ? +WHERE (SELECT COUNT(*) FROM orgs) < CAST(?3 AS INTEGER) +RETURNING id, - name, + display_name, created_at ` type CreateParams struct { - ID string - Name string + ID string + DisplayName string + MaxOrgs int64 } type CreateRow struct { - ID string - Name string - CreatedAt int64 + ID string + DisplayName string + CreatedAt int64 } func (q *Queries) Create(ctx context.Context, arg CreateParams) (CreateRow, error) { - row := q.db.QueryRowContext(ctx, create, arg.ID, arg.Name) + row := q.db.QueryRowContext(ctx, create, arg.ID, arg.DisplayName, arg.MaxOrgs) var i CreateRow - err := row.Scan(&i.ID, &i.Name, &i.CreatedAt) + err := row.Scan(&i.ID, &i.DisplayName, &i.CreatedAt) return i, err } -const deleteByID = `-- name: DeleteByID :exec +const createMember = `-- name: CreateMember :exec +INSERT INTO org_members ( + account_id, + org_id, + role_id +) VALUES ( + ?, ?, ? +) +` + +type CreateMemberParams struct { + AccountID string + OrgID string + RoleID int64 +} + +func (q *Queries) CreateMember(ctx context.Context, arg CreateMemberParams) error { + _, err := q.db.ExecContext(ctx, createMember, arg.AccountID, arg.OrgID, arg.RoleID) + return err +} + +const delete = `-- name: Delete :exec DELETE FROM orgs -WHERE id = ? +WHERE orgs.id = ?1 + AND (SELECT COUNT(*) FROM org_members WHERE org_members.org_id = ?1 AND org_members.role_id = 1) = 1 + AND EXISTS ( + SELECT 1 FROM org_members om + WHERE om.org_id = ?1 AND om.account_id = ?2 AND om.role_id = 1 + ) +` + +type DeleteParams struct { + OrgID string + AccountID string +} + +// Deletes a single org only if the given account is its sole owner. +func (q *Queries) Delete(ctx context.Context, arg DeleteParams) error { + _, err := q.db.ExecContext(ctx, delete, arg.OrgID, arg.AccountID) + return err +} + +const deleteMember = `-- name: DeleteMember :exec +DELETE FROM org_members +WHERE account_id = ? +AND org_id = ? +` + +type DeleteMemberParams struct { + AccountID string + OrgID string +} + +func (q *Queries) DeleteMember(ctx context.Context, arg DeleteMemberParams) error { + _, err := q.db.ExecContext(ctx, deleteMember, arg.AccountID, arg.OrgID) + return err +} + +const deleteOwnedByAccountID = `-- name: DeleteOwnedByAccountID :exec +DELETE FROM orgs +WHERE id IN ( + SELECT om.org_id + FROM org_members om + WHERE om.account_id = ? + AND om.role_id = 1 + AND ( + SELECT COUNT(*) + FROM org_members om2 + WHERE om2.org_id = om.org_id + AND om2.role_id = 1 + ) = 1 +) ` -func (q *Queries) DeleteByID(ctx context.Context, id string) error { - _, err := q.db.ExecContext(ctx, deleteByID, id) +// Deletes orgs where the given account is the sole owner. +func (q *Queries) DeleteOwnedByAccountID(ctx context.Context, accountID string) error { + _, err := q.db.ExecContext(ctx, deleteOwnedByAccountID, accountID) return err } -const getAll = `-- name: GetAll :many +const get = `-- name: Get :one +SELECT + id, + display_name, + updated_at, + created_at +FROM orgs +WHERE id = ? +LIMIT 1 +` + +func (q *Queries) Get(ctx context.Context, id string) (Org, error) { + row := q.db.QueryRowContext(ctx, get, id) + var i Org + err := row.Scan( + &i.ID, + &i.DisplayName, + &i.UpdatedAt, + &i.CreatedAt, + ) + return i, err +} + +const getFirstByAccountID = `-- name: GetFirstByAccountID :one +SELECT o.id +FROM orgs o +JOIN org_members om ON om.org_id = o.id +WHERE om.account_id = ? +ORDER BY o.created_at +LIMIT 1 +` + +func (q *Queries) GetFirstByAccountID(ctx context.Context, accountID string) (string, error) { + row := q.db.QueryRowContext(ctx, getFirstByAccountID, accountID) + var id string + err := row.Scan(&id) + return id, err +} + +const getMemberByAccountIDAndOrgID = `-- name: GetMemberByAccountIDAndOrgID :one +SELECT + id, + account_id, + org_id, + role_id, + updated_at, + created_at +FROM org_members +WHERE account_id = ? +AND org_id = ? +LIMIT 1 +` + +type GetMemberByAccountIDAndOrgIDParams struct { + AccountID string + OrgID string +} + +type GetMemberByAccountIDAndOrgIDRow struct { + ID int64 + AccountID string + OrgID string + RoleID int64 + UpdatedAt int64 + CreatedAt int64 +} + +func (q *Queries) GetMemberByAccountIDAndOrgID(ctx context.Context, arg GetMemberByAccountIDAndOrgIDParams) (GetMemberByAccountIDAndOrgIDRow, error) { + row := q.db.QueryRowContext(ctx, getMemberByAccountIDAndOrgID, arg.AccountID, arg.OrgID) + var i GetMemberByAccountIDAndOrgIDRow + err := row.Scan( + &i.ID, + &i.AccountID, + &i.OrgID, + &i.RoleID, + &i.UpdatedAt, + &i.CreatedAt, + ) + return i, err +} + +const getRoleByName = `-- name: GetRoleByName :one SELECT id, name, + rank +FROM org_roles +WHERE name = ? +LIMIT 1 +` + +func (q *Queries) GetRoleByName(ctx context.Context, name string) (OrgRole, error) { + row := q.db.QueryRowContext(ctx, getRoleByName, name) + var i OrgRole + err := row.Scan(&i.ID, &i.Name, &i.Rank) + return i, err +} + +const list = `-- name: List :many +SELECT + id, + display_name, updated_at, created_at FROM orgs +ORDER BY created_at ` -func (q *Queries) GetAll(ctx context.Context) ([]Org, error) { - rows, err := q.db.QueryContext(ctx, getAll) +func (q *Queries) List(ctx context.Context) ([]Org, error) { + rows, err := q.db.QueryContext(ctx, list) if err != nil { return nil, err } @@ -81,7 +240,7 @@ func (q *Queries) GetAll(ctx context.Context) ([]Org, error) { var i Org if err := rows.Scan( &i.ID, - &i.Name, + &i.DisplayName, &i.UpdatedAt, &i.CreatedAt, ); err != nil { @@ -98,54 +257,91 @@ func (q *Queries) GetAll(ctx context.Context) ([]Org, error) { return items, nil } -const getByID = `-- name: GetByID :one +const listMembersByOrgID = `-- name: ListMembersByOrgID :many SELECT id, - name, + account_id, + org_id, + role_id, updated_at, created_at -FROM orgs -WHERE id = ? +FROM org_members +WHERE org_id = ? ` -func (q *Queries) GetByID(ctx context.Context, id string) (Org, error) { - row := q.db.QueryRowContext(ctx, getByID, id) - var i Org - err := row.Scan( - &i.ID, - &i.Name, - &i.UpdatedAt, - &i.CreatedAt, - ) - return i, err +type ListMembersByOrgIDRow struct { + ID int64 + AccountID string + OrgID string + RoleID int64 + UpdatedAt int64 + CreatedAt int64 } -const updateByID = `-- name: UpdateByID :one +func (q *Queries) ListMembersByOrgID(ctx context.Context, orgID string) ([]ListMembersByOrgIDRow, error) { + rows, err := q.db.QueryContext(ctx, listMembersByOrgID, orgID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListMembersByOrgIDRow + for rows.Next() { + var i ListMembersByOrgIDRow + if err := rows.Scan( + &i.ID, + &i.AccountID, + &i.OrgID, + &i.RoleID, + &i.UpdatedAt, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const update = `-- name: Update :exec UPDATE orgs SET - name = ?, + display_name = ?, updated_at = unixepoch() WHERE id = ? -RETURNING - id, - name, - updated_at, - created_at ` -type UpdateByIDParams struct { - Name string - ID string +type UpdateParams struct { + DisplayName string + ID string } -func (q *Queries) UpdateByID(ctx context.Context, arg UpdateByIDParams) (Org, error) { - row := q.db.QueryRowContext(ctx, updateByID, arg.Name, arg.ID) - var i Org - err := row.Scan( - &i.ID, - &i.Name, - &i.UpdatedAt, - &i.CreatedAt, - ) - return i, err +func (q *Queries) Update(ctx context.Context, arg UpdateParams) error { + _, err := q.db.ExecContext(ctx, update, arg.DisplayName, arg.ID) + return err +} + +const updateMemberRole = `-- name: UpdateMemberRole :exec +UPDATE org_members +SET + role_id = ?, + updated_at = unixepoch() +WHERE account_id = ? +AND org_id = ? +` + +type UpdateMemberRoleParams struct { + RoleID int64 + AccountID string + OrgID string +} + +func (q *Queries) UpdateMemberRole(ctx context.Context, arg UpdateMemberRoleParams) error { + _, err := q.db.ExecContext(ctx, updateMemberRole, arg.RoleID, arg.AccountID, arg.OrgID) + return err } diff --git a/internal/database/queries/gen/orgs/querier.go b/internal/database/queries/gen/orgs/querier.go index 4bdb185..9ace24f 100644 --- a/internal/database/queries/gen/orgs/querier.go +++ b/internal/database/queries/gen/orgs/querier.go @@ -9,12 +9,21 @@ import ( ) type Querier interface { - CountOrgs(ctx context.Context) (int64, error) Create(ctx context.Context, arg CreateParams) (CreateRow, error) - DeleteByID(ctx context.Context, id string) error - GetAll(ctx context.Context) ([]Org, error) - GetByID(ctx context.Context, id string) (Org, error) - UpdateByID(ctx context.Context, arg UpdateByIDParams) (Org, error) + CreateMember(ctx context.Context, arg CreateMemberParams) error + // Deletes a single org only if the given account is its sole owner. + Delete(ctx context.Context, arg DeleteParams) error + DeleteMember(ctx context.Context, arg DeleteMemberParams) error + // Deletes orgs where the given account is the sole owner. + DeleteOwnedByAccountID(ctx context.Context, accountID string) error + Get(ctx context.Context, id string) (Org, error) + GetFirstByAccountID(ctx context.Context, accountID string) (string, error) + GetMemberByAccountIDAndOrgID(ctx context.Context, arg GetMemberByAccountIDAndOrgIDParams) (GetMemberByAccountIDAndOrgIDRow, error) + GetRoleByName(ctx context.Context, name string) (OrgRole, error) + List(ctx context.Context) ([]Org, error) + ListMembersByOrgID(ctx context.Context, orgID string) ([]ListMembersByOrgIDRow, error) + Update(ctx context.Context, arg UpdateParams) error + UpdateMemberRole(ctx context.Context, arg UpdateMemberRoleParams) error } var _ Querier = (*Queries)(nil) diff --git a/internal/database/queries/gen/tokens/db.go b/internal/database/queries/gen/tokens/db.go new file mode 100644 index 0000000..bd6805c --- /dev/null +++ b/internal/database/queries/gen/tokens/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package tokens + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/database/queries/gen/tokens/models.go b/internal/database/queries/gen/tokens/models.go new file mode 100644 index 0000000..959f81f --- /dev/null +++ b/internal/database/queries/gen/tokens/models.go @@ -0,0 +1,10 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package tokens + +type TokenScope struct { + ID int64 + Name string +} diff --git a/internal/database/queries/gen/tokens/querier.go b/internal/database/queries/gen/tokens/querier.go new file mode 100644 index 0000000..a43130a --- /dev/null +++ b/internal/database/queries/gen/tokens/querier.go @@ -0,0 +1,19 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package tokens + +import ( + "context" +) + +type Querier interface { + DeleteExpiredTokens(ctx context.Context) error + DeleteForAccountAndScope(ctx context.Context, arg DeleteForAccountAndScopeParams) error + GetScopeByName(ctx context.Context, name string) (TokenScope, error) + Insert(ctx context.Context, arg InsertParams) (InsertRow, error) + VerifyAndIncrement(ctx context.Context, hash []byte) (VerifyAndIncrementRow, error) +} + +var _ Querier = (*Queries)(nil) diff --git a/internal/database/queries/gen/tokens/tokens.sql.go b/internal/database/queries/gen/tokens/tokens.sql.go new file mode 100644 index 0000000..1868019 --- /dev/null +++ b/internal/database/queries/gen/tokens/tokens.sql.go @@ -0,0 +1,134 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: tokens.sql + +package tokens + +import ( + "context" +) + +const deleteExpiredTokens = `-- name: DeleteExpiredTokens :exec +DELETE FROM tokens +WHERE expires_at <= unixepoch() +` + +func (q *Queries) DeleteExpiredTokens(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, deleteExpiredTokens) + return err +} + +const deleteForAccountAndScope = `-- name: DeleteForAccountAndScope :exec +DELETE FROM tokens +WHERE account_id = ? + AND token_scope_id = ? +` + +type DeleteForAccountAndScopeParams struct { + AccountID string + TokenScopeID int64 +} + +func (q *Queries) DeleteForAccountAndScope(ctx context.Context, arg DeleteForAccountAndScopeParams) error { + _, err := q.db.ExecContext(ctx, deleteForAccountAndScope, arg.AccountID, arg.TokenScopeID) + return err +} + +const getScopeByName = `-- name: GetScopeByName :one +SELECT + id, + name +FROM token_scopes +WHERE name = ? +LIMIT 1 +` + +func (q *Queries) GetScopeByName(ctx context.Context, name string) (TokenScope, error) { + row := q.db.QueryRowContext(ctx, getScopeByName, name) + var i TokenScope + err := row.Scan(&i.ID, &i.Name) + return i, err +} + +const insert = `-- name: Insert :one +INSERT INTO tokens ( + account_id, + token_scope_id, + hash, + expires_at +) VALUES ( + ?, ?, ?, ? +) RETURNING + id, + account_id, + token_scope_id, + expires_at, + created_at +` + +type InsertParams struct { + AccountID string + TokenScopeID int64 + Hash []byte + ExpiresAt int64 +} + +type InsertRow struct { + ID int64 + AccountID string + TokenScopeID int64 + ExpiresAt int64 + CreatedAt int64 +} + +func (q *Queries) Insert(ctx context.Context, arg InsertParams) (InsertRow, error) { + row := q.db.QueryRowContext(ctx, insert, + arg.AccountID, + arg.TokenScopeID, + arg.Hash, + arg.ExpiresAt, + ) + var i InsertRow + err := row.Scan( + &i.ID, + &i.AccountID, + &i.TokenScopeID, + &i.ExpiresAt, + &i.CreatedAt, + ) + return i, err +} + +const verifyAndIncrement = `-- name: VerifyAndIncrement :one +UPDATE tokens +SET attempts = attempts + 1 +WHERE hash = ? + AND expires_at > unixepoch() + AND attempts >= 0 + AND attempts <= 5 +RETURNING id, account_id, token_scope_id, expires_at, created_at, attempts +` + +type VerifyAndIncrementRow struct { + ID int64 + AccountID string + TokenScopeID int64 + ExpiresAt int64 + CreatedAt int64 + Attempts int64 +} + +func (q *Queries) VerifyAndIncrement(ctx context.Context, hash []byte) (VerifyAndIncrementRow, error) { + row := q.db.QueryRowContext(ctx, verifyAndIncrement, hash) + var i VerifyAndIncrementRow + err := row.Scan( + &i.ID, + &i.AccountID, + &i.TokenScopeID, + &i.ExpiresAt, + &i.CreatedAt, + &i.Attempts, + ) + return i, err +} diff --git a/internal/database/queries/sqlite/accounts.sql b/internal/database/queries/sqlite/accounts.sql new file mode 100644 index 0000000..abd546a --- /dev/null +++ b/internal/database/queries/sqlite/accounts.sql @@ -0,0 +1,33 @@ +-- name: Create :one +INSERT INTO accounts ( + id, + email +) VALUES ( + ?, ? +) RETURNING + id, + email, + email_verified, + enabled, + created_at; + +-- name: GetByAccountID :one +SELECT + id, + email, + email_verified, + enabled, + updated_at, + created_at +FROM accounts +WHERE id = ? +LIMIT 1; + +-- name: Exists :one +SELECT EXISTS( + SELECT 1 FROM accounts WHERE email = ? +) AS account_exists; + +-- name: Delete :exec +DELETE FROM accounts +WHERE id = ?; diff --git a/internal/database/queries/sqlite/credentials.sql b/internal/database/queries/sqlite/credentials.sql new file mode 100644 index 0000000..557bb9b --- /dev/null +++ b/internal/database/queries/sqlite/credentials.sql @@ -0,0 +1,39 @@ +-- name: Create :exec +INSERT INTO credentials ( + account_id, + type_id, + secret_data +) VALUES ( + ?, ?, ? +); + +-- name: GetByEmail :one +SELECT + c.secret_data, + a.id AS account_id +FROM credentials c +JOIN accounts a ON a.id = c.account_id +WHERE a.email = ? +AND c.type_id = ? +LIMIT 1; + +-- name: GetByAccountID :one +SELECT + id, + account_id, + type_id, + secret_data, + updated_at, + created_at +FROM credentials +WHERE account_id = ? +AND type_id = ? +LIMIT 1; + +-- name: UpdateSecret :exec +UPDATE credentials +SET + secret_data = ?, + updated_at = unixepoch() +WHERE account_id = ? +AND type_id = ?; diff --git a/internal/database/queries/sqlite/orgs.sql b/internal/database/queries/sqlite/orgs.sql index 241e357..f306c99 100644 --- a/internal/database/queries/sqlite/orgs.sql +++ b/internal/database/queries/sqlite/orgs.sql @@ -1,46 +1,126 @@ +-- name: GetFirstByAccountID :one +SELECT o.id +FROM orgs o +JOIN org_members om ON om.org_id = o.id +WHERE om.account_id = ? +ORDER BY o.created_at +LIMIT 1; + +-- name: GetRoleByName :one +SELECT + id, + name, + rank +FROM org_roles +WHERE name = ? +LIMIT 1; + -- name: Create :one INSERT INTO orgs ( id, - name -) VALUES ( - ?, - ? -) RETURNING + display_name +) +SELECT ?, ? +WHERE (SELECT COUNT(*) FROM orgs) < CAST(sqlc.arg(max_orgs) AS INTEGER) +RETURNING id, - name, + display_name, created_at; --- name: GetAll :many +-- name: Get :one SELECT id, - name, + display_name, updated_at, created_at -FROM orgs; +FROM orgs +WHERE id = ? +LIMIT 1; --- name: GetByID :one +-- name: List :many SELECT id, - name, + display_name, updated_at, created_at FROM orgs -WHERE id = ?; --- name: UpdateByID :one +ORDER BY created_at; + +-- name: Update :exec UPDATE orgs SET - name = ?, + display_name = ?, updated_at = unixepoch() -WHERE id = ? -RETURNING +WHERE id = ?; + +-- name: CreateMember :exec +INSERT INTO org_members ( + account_id, + org_id, + role_id +) VALUES ( + ?, ?, ? +); + +-- name: GetMemberByAccountIDAndOrgID :one +SELECT id, - name, + account_id, + org_id, + role_id, updated_at, - created_at; + created_at +FROM org_members +WHERE account_id = ? +AND org_id = ? +LIMIT 1; --- name: DeleteByID :exec +-- name: ListMembersByOrgID :many +SELECT + id, + account_id, + org_id, + role_id, + updated_at, + created_at +FROM org_members +WHERE org_id = ?; + +-- name: UpdateMemberRole :exec +UPDATE org_members +SET + role_id = ?, + updated_at = unixepoch() +WHERE account_id = ? +AND org_id = ?; + +-- name: DeleteMember :exec +DELETE FROM org_members +WHERE account_id = ? +AND org_id = ?; + +-- name: DeleteOwnedByAccountID :exec +-- Deletes orgs where the given account is the sole owner. DELETE FROM orgs -WHERE id = ?; +WHERE id IN ( + SELECT om.org_id + FROM org_members om + WHERE om.account_id = ? + AND om.role_id = 1 + AND ( + SELECT COUNT(*) + FROM org_members om2 + WHERE om2.org_id = om.org_id + AND om2.role_id = 1 + ) = 1 +); --- name: CountOrgs :one -SELECT COUNT(*) FROM orgs; +-- name: Delete :exec +-- Deletes a single org only if the given account is its sole owner. +DELETE FROM orgs +WHERE orgs.id = sqlc.arg(org_id) + AND (SELECT COUNT(*) FROM org_members WHERE org_members.org_id = sqlc.arg(org_id) AND org_members.role_id = 1) = 1 + AND EXISTS ( + SELECT 1 FROM org_members om + WHERE om.org_id = sqlc.arg(org_id) AND om.account_id = sqlc.arg(account_id) AND om.role_id = 1 + ); diff --git a/internal/database/queries/sqlite/tokens.sql b/internal/database/queries/sqlite/tokens.sql new file mode 100644 index 0000000..ceb5b19 --- /dev/null +++ b/internal/database/queries/sqlite/tokens.sql @@ -0,0 +1,40 @@ +-- name: GetScopeByName :one +SELECT + id, + name +FROM token_scopes +WHERE name = ? +LIMIT 1; + +-- name: Insert :one +INSERT INTO tokens ( + account_id, + token_scope_id, + hash, + expires_at +) VALUES ( + ?, ?, ?, ? +) RETURNING + id, + account_id, + token_scope_id, + expires_at, + created_at; + +-- name: VerifyAndIncrement :one +UPDATE tokens +SET attempts = attempts + 1 +WHERE hash = ? + AND expires_at > unixepoch() + AND attempts >= 0 + AND attempts <= 5 +RETURNING id, account_id, token_scope_id, expires_at, created_at, attempts; + +-- name: DeleteForAccountAndScope :exec +DELETE FROM tokens +WHERE account_id = ? + AND token_scope_id = ?; + +-- name: DeleteExpiredTokens :exec +DELETE FROM tokens +WHERE expires_at <= unixepoch(); diff --git a/internal/equipment/form.go b/internal/equipment/form.go index 2f0a186..7005531 100644 --- a/internal/equipment/form.go +++ b/internal/equipment/form.go @@ -114,10 +114,47 @@ func (f *DetailsForm) Validate() bool { } // Validate checks PricingForm fields and returns true when all pass. -func (f *PricingForm) Validate() bool { return f.Valid() } +func (f *PricingForm) Validate() bool { + f.Check(validator.NotNil(f.PurchasePrice), "purchase_price", "This field cannot be blank") + if validator.NotNil(f.PurchasePrice) { + f.Check(*f.PurchasePrice > 0, "purchase_price", "Must be greater than 0") + } + f.Check(validator.NotNil(f.RentalPrice), "rental_price", "This field cannot be blank") + if validator.NotNil(f.RentalPrice) { + f.Check(*f.RentalPrice > 0, "rental_price", "Must be greater than 0") + } + return f.Valid() +} // Validate checks PropertiesForm fields and returns true when all pass. -func (f *PropertiesForm) Validate() bool { return f.Valid() } +// All fields are optional; when provided they must be greater than 0. +func (f *PropertiesForm) Validate() bool { + if validator.NotNil(f.Weight) { + f.Check(*f.Weight > 0, "weight_kg", "Must be greater than 0") + } + if validator.NotNil(f.Width) { + f.Check(*f.Width > 0, "width_cm", "Must be greater than 0") + } + if validator.NotNil(f.Height) { + f.Check(*f.Height > 0, "height_cm", "Must be greater than 0") + } + if validator.NotNil(f.Depth) { + f.Check(*f.Depth > 0, "depth_cm", "Must be greater than 0") + } + if validator.NotNil(f.Power) { + f.Check(*f.Power > 0, "power_w", "Must be greater than 0") + } + if validator.NotNil(f.Current) { + f.Check(*f.Current > 0, "current_a", "Must be greater than 0") + } + if validator.NotNil(f.Voltage) { + f.Check(*f.Voltage > 0, "voltage_v", "Must be greater than 0") + } + if validator.NotNil(f.WireGauge) { + f.Check(*f.WireGauge > 0, "wire_gauge_mm2_x100", "Must be greater than 0") + } + return f.Valid() +} // ToProperties converts the form's parsed values into a Properties sub-struct. func (f *PropertiesForm) ToProperties() Properties { @@ -141,6 +178,19 @@ func (f *PricingForm) ToPricing() Pricing { } } +// DetailsFormFromEquipment pre-populates a DetailsForm from an existing Equipment's stored values. +func DetailsFormFromEquipment(e *Equipment) DetailsForm { + return DetailsForm{ + TypeID: e.Type.String(), + Name: e.Name, + CategoryID: e.CategoryID, + ManufacturerID: e.ManufacturerID, + LocationID: e.LocationID, + TotalStock: e.TotalStock, + Notes: e.Notes, + } +} + // PricingFormFromEquipment pre-populates a PricingForm from an existing Equipment's stored values. func PricingFormFromEquipment(e *Equipment) PricingForm { return PricingForm{ diff --git a/internal/locale/locale.go b/internal/locale/locale.go new file mode 100644 index 0000000..636161d --- /dev/null +++ b/internal/locale/locale.go @@ -0,0 +1,135 @@ +// Package locale maps Accept-Language headers to org setting defaults. +package locale + +import ( + "strings" +) + +// Defaults holds the pre-selected currency and timezone for a locale. +type Defaults struct { + Currency string + Timezone string +} + +// fallback is returned when no locale match is found. +var fallback = Defaults{Currency: "USD", Timezone: "UTC"} + +// localeDefaults maps BCP-47 tags (region-specific first, language-only second) +// to sensible currency and timezone defaults. +// Only currencies from settings.PermittedCurrencies and timezones from +// settings.PermittedTimezones are used here. +var localeDefaults = map[string]Defaults{ + // German-speaking + "de": {Currency: "EUR", Timezone: "Europe/Berlin"}, + "de-de": {Currency: "EUR", Timezone: "Europe/Berlin"}, + "de-at": {Currency: "EUR", Timezone: "Europe/Vienna"}, + "de-ch": {Currency: "CHF", Timezone: "Europe/Zurich"}, + + // English-speaking + "en": {Currency: "USD", Timezone: "America/New_York"}, + "en-us": {Currency: "USD", Timezone: "America/New_York"}, + "en-gb": {Currency: "GBP", Timezone: "Europe/London"}, + "en-au": {Currency: "AUD", Timezone: "Australia/Sydney"}, + "en-ca": {Currency: "CAD", Timezone: "America/Toronto"}, + "en-nz": {Currency: "NZD", Timezone: "Pacific/Auckland"}, + "en-ie": {Currency: "EUR", Timezone: "Europe/Dublin"}, + + // French-speaking + "fr": {Currency: "EUR", Timezone: "Europe/Paris"}, + "fr-fr": {Currency: "EUR", Timezone: "Europe/Paris"}, + "fr-be": {Currency: "EUR", Timezone: "Europe/Brussels"}, + "fr-ch": {Currency: "CHF", Timezone: "Europe/Zurich"}, + "fr-ca": {Currency: "CAD", Timezone: "America/Toronto"}, + + // Dutch-speaking + "nl": {Currency: "EUR", Timezone: "Europe/Amsterdam"}, + "nl-nl": {Currency: "EUR", Timezone: "Europe/Amsterdam"}, + "nl-be": {Currency: "EUR", Timezone: "Europe/Brussels"}, + + // Italian + "it": {Currency: "EUR", Timezone: "Europe/Rome"}, + "it-it": {Currency: "EUR", Timezone: "Europe/Rome"}, + + // Spanish-speaking + "es": {Currency: "EUR", Timezone: "Europe/Madrid"}, + "es-es": {Currency: "EUR", Timezone: "Europe/Madrid"}, + "es-mx": {Currency: "MXN", Timezone: "America/Mexico_City"}, + "es-ar": {Currency: "USD", Timezone: "America/Argentina/Buenos_Aires"}, + + // Portuguese-speaking + "pt": {Currency: "EUR", Timezone: "Europe/Lisbon"}, + "pt-pt": {Currency: "EUR", Timezone: "Europe/Lisbon"}, + "pt-br": {Currency: "BRL", Timezone: "America/Sao_Paulo"}, + + // Nordic + "sv": {Currency: "SEK", Timezone: "Europe/Stockholm"}, + "sv-se": {Currency: "SEK", Timezone: "Europe/Stockholm"}, + "nb": {Currency: "NOK", Timezone: "Europe/Oslo"}, + "no": {Currency: "NOK", Timezone: "Europe/Oslo"}, + "no-no": {Currency: "NOK", Timezone: "Europe/Oslo"}, + "da": {Currency: "DKK", Timezone: "Europe/Oslo"}, + "da-dk": {Currency: "DKK", Timezone: "Europe/Oslo"}, + "fi": {Currency: "EUR", Timezone: "Europe/Helsinki"}, + "fi-fi": {Currency: "EUR", Timezone: "Europe/Helsinki"}, + + // Eastern European + "pl": {Currency: "PLN", Timezone: "Europe/Warsaw"}, + "pl-pl": {Currency: "PLN", Timezone: "Europe/Warsaw"}, + "cs": {Currency: "CZK", Timezone: "Europe/Prague"}, + "cs-cz": {Currency: "CZK", Timezone: "Europe/Prague"}, + "hu": {Currency: "HUF", Timezone: "Europe/Budapest"}, + "hu-hu": {Currency: "HUF", Timezone: "Europe/Budapest"}, + "ro": {Currency: "RON", Timezone: "Europe/Athens"}, + "ro-ro": {Currency: "RON", Timezone: "Europe/Athens"}, + "el": {Currency: "EUR", Timezone: "Europe/Athens"}, + "el-gr": {Currency: "EUR", Timezone: "Europe/Athens"}, + + // Turkish + "tr": {Currency: "TRY", Timezone: "Europe/Istanbul"}, + "tr-tr": {Currency: "TRY", Timezone: "Europe/Istanbul"}, + + // East Asian + "ja": {Currency: "JPY", Timezone: "Asia/Tokyo"}, + "ja-jp": {Currency: "JPY", Timezone: "Asia/Tokyo"}, + "zh": {Currency: "CNY", Timezone: "Asia/Shanghai"}, + "zh-cn": {Currency: "CNY", Timezone: "Asia/Shanghai"}, + "zh-hk": {Currency: "HKD", Timezone: "Asia/Hong_Kong"}, + "zh-tw": {Currency: "USD", Timezone: "Asia/Tokyo"}, + "zh-sg": {Currency: "SGD", Timezone: "Asia/Singapore"}, + + // South / Southeast Asian + "hi": {Currency: "INR", Timezone: "Asia/Kolkata"}, + "hi-in": {Currency: "INR", Timezone: "Asia/Kolkata"}, + "ms-sg": {Currency: "SGD", Timezone: "Asia/Singapore"}, + + // Middle Eastern + "ar-sa": {Currency: "SAR", Timezone: "Asia/Riyadh"}, + "ar-ae": {Currency: "AED", Timezone: "Asia/Dubai"}, + + // South African + "en-za": {Currency: "ZAR", Timezone: "Africa/Johannesburg"}, +} + +// FromAcceptLanguage parses the Accept-Language header and returns the best +// matching Defaults. It checks region-specific tags before language-only tags +// and falls back to USD/UTC when no match is found. +func FromAcceptLanguage(header string) Defaults { + if header == "" { + return fallback + } + + for part := range strings.SplitSeq(header, ",") { + tag := strings.ToLower(strings.TrimSpace(strings.SplitN(part, ";", 2)[0])) + if d, ok := localeDefaults[tag]; ok { + return d + } + // Try language-only (e.g. "de" from "de-DE") + if idx := strings.IndexByte(tag, '-'); idx > 0 { + if d, ok := localeDefaults[tag[:idx]]; ok { + return d + } + } + } + + return fallback +} diff --git a/internal/orgs/form.go b/internal/orgs/form.go index 6a301b1..3fc24cb 100644 --- a/internal/orgs/form.go +++ b/internal/orgs/form.go @@ -11,7 +11,7 @@ import ( // Form holds the parsed form input and validation state for org create/update requests. type Form struct { - Name string + DisplayName string validator.Validator } @@ -21,12 +21,12 @@ func Parse(r *http.Request) (Form, error) { return Form{}, fmt.Errorf("parse form: %w", err) } return Form{ - Name: strings.TrimSpace(r.PostForm.Get("name")), + DisplayName: strings.TrimSpace(r.PostForm.Get("name")), }, nil } // Validate checks form fields and returns true when all checks pass. func (f *Form) Validate() bool { - f.Check(validator.NotBlank(f.Name), "name", "This field cannot be blank") + f.Check(validator.NotBlank(f.DisplayName), "name", "This field cannot be blank") return f.Valid() } diff --git a/internal/orgs/model.go b/internal/orgs/model.go index b805940..368159d 100644 --- a/internal/orgs/model.go +++ b/internal/orgs/model.go @@ -2,8 +2,8 @@ package orgs // Org represents a org entity. type Org struct { - ID string `json:"id"` - Name string `json:"name"` - UpdatedAt string `json:"updated_at"` - CreatedAt string `json:"created_at"` + ID string `json:"id"` + DisplayName string `json:"display_name"` + UpdatedAt string `json:"updated_at"` + CreatedAt string `json:"created_at"` } diff --git a/internal/orgs/repository.go b/internal/orgs/repository.go index c6ef4b4..8d7b42d 100644 --- a/internal/orgs/repository.go +++ b/internal/orgs/repository.go @@ -5,106 +5,137 @@ import ( "database/sql" "fmt" - "github.com/bit8bytes/gearberg/internal/database" genorgs "github.com/bit8bytes/gearberg/internal/database/queries/gen/orgs" ) -// Repository provides data access for orgs. +// Repository provides data access for organizations and their members. type Repository struct { - db *sql.DB - orgs genorgs.Querier + orgs *genorgs.Queries + maxOrgs int64 } // NewRepository returns a new Repository. -func NewRepository(db *sql.DB) *Repository { +func NewRepository(db genorgs.DBTX, maxOrgs int64) *Repository { + if maxOrgs <= 0 { + panic("orgs.NewRepository: MaxOrgs must be greater than zero") + } return &Repository{ - db: db, - orgs: genorgs.New(db), + orgs: genorgs.New(db), + maxOrgs: maxOrgs, } } -// Count returns the total number of orgs. -func (r *Repository) Count(ctx context.Context) (int64, error) { - n, err := r.orgs.CountOrgs(ctx) +// Create inserts a new organization and returns its ID. +func (r *Repository) Create(ctx context.Context, tx *sql.Tx, id, displayName string) (string, error) { + row, err := r.orgs.WithTx(tx).Create(ctx, genorgs.CreateParams{ + ID: id, + DisplayName: displayName, + MaxOrgs: r.maxOrgs, + }) if err != nil { - return 0, fmt.Errorf("failed to count orgs: %w", err) + return "", fmt.Errorf("orgs.Repository.Create: %w", err) } - return n, nil + return row.ID, nil } -// GetAll returns all orgs. -func (r *Repository) GetAll(ctx context.Context) ([]Org, error) { - orgs, err := r.orgs.GetAll(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get all orgs: %w", err) +// CreateMember adds an account as a member of an organization with the given role. +func (r *Repository) CreateMember(ctx context.Context, tx *sql.Tx, accountID, organizationID string, roleID int64) error { + if err := r.orgs.WithTx(tx).CreateMember(ctx, genorgs.CreateMemberParams{ + AccountID: accountID, + OrgID: organizationID, + RoleID: roleID, + }); err != nil { + return fmt.Errorf("orgs.Repository.CreateMember: %w", err) } + return nil +} + +// Max returns the configured maximum number of organizations. +func (r *Repository) Max() int { + return int(r.maxOrgs) +} - result := make([]Org, len(orgs)) - for i, c := range orgs { - result[i] = Org{ - ID: c.ID, - Name: c.Name, +// List returns all organizations ordered by creation time. +func (r *Repository) List(ctx context.Context) ([]Org, error) { + rows, err := r.orgs.List(ctx) + if err != nil { + return nil, fmt.Errorf("orgs.Repository.List: %w", err) + } + orgs := make([]Org, len(rows)) + for i, row := range rows { + orgs[i] = Org{ + ID: row.ID, + DisplayName: row.DisplayName, } } - return result, nil + return orgs, nil } -// GetByID returns the org with the given ID. -func (r *Repository) GetByID(ctx context.Context, id string) (*Org, error) { - c, err := r.orgs.GetByID(ctx, id) +// GetFirstByAccountID returns the ID of the first organization the account belongs to. +func (r *Repository) GetFirstByAccountID(ctx context.Context, accountID string) (string, error) { + id, err := r.orgs.GetFirstByAccountID(ctx, accountID) if err != nil { - return nil, fmt.Errorf("failed to get org by id: %w", err) + return "", fmt.Errorf("orgs.Repository.GetFirstByAccountID: %w", err) } - return &Org{ - ID: c.ID, - Name: c.Name, - }, nil + return id, nil } -// CreateOrg holds the data required to create a new org. -type CreateOrg struct { - ID string - Name string +// DeleteOwnedByAccount deletes all organizations where accountID is the sole owner. +func (r *Repository) DeleteOwnedByAccount(ctx context.Context, tx *sql.Tx, accountID string) error { + if err := r.orgs.WithTx(tx).DeleteOwnedByAccountID(ctx, accountID); err != nil { + return fmt.Errorf("orgs.Repository.DeleteOwnedByAccount: %w", err) + } + return nil } -// UpdateOrg holds the data required to update a org. -type UpdateOrg struct { - ID string - Name string +// Delete removes a single org if accountID is its sole owner. +func (r *Repository) Delete(ctx context.Context, orgID, accountID string) error { + if err := r.orgs.Delete(ctx, genorgs.DeleteParams{OrgID: orgID, AccountID: accountID}); err != nil { + return fmt.Errorf("orgs.Repository.Delete: %w", err) + } + return nil } -// Update updates the name of the org with the given ID. -func (r *Repository) Update(ctx context.Context, u UpdateOrg) (*Org, error) { - org, err := r.orgs.UpdateByID(ctx, genorgs.UpdateByIDParams{ - Name: u.Name, - ID: u.ID, - }) - if err != nil { - return nil, fmt.Errorf("failed to update org: %w", database.NormalizeError(err)) +// GetMember returns an error if accountID is not a member of orgID. +func (r *Repository) GetMember(ctx context.Context, orgID, accountID string) error { + if _, err := r.orgs.GetMemberByAccountIDAndOrgID(ctx, genorgs.GetMemberByAccountIDAndOrgIDParams{ + AccountID: accountID, + OrgID: orgID, + }); err != nil { + return fmt.Errorf("orgs.Repository.GetMember: %w", err) } - return &Org{ID: org.ID, Name: org.Name}, nil + return nil +} + +// UpdateParams holds the fields to update on an organization. +type UpdateParams struct { + ID string + DisplayName string + PhoneCountryCode string + PhoneNumber string + Email string } -// Delete removes the org with the given ID. -func (r *Repository) Delete(ctx context.Context, id string) error { - if err := r.orgs.DeleteByID(ctx, id); err != nil { - return fmt.Errorf("failed to delete org: %w", err) +// Get retrieves the organization with the given ID. +func (r *Repository) Get(ctx context.Context, id string) (Org, error) { + row, err := r.orgs.Get(ctx, id) + if err != nil { + return Org{}, fmt.Errorf("orgs.Repository.Get: %w", err) } - return nil + return Org{ + ID: row.ID, + DisplayName: row.DisplayName, + }, nil } -// Create creates a new org. -func (r *Repository) Create(ctx context.Context, createOrg CreateOrg) (*Org, error) { - org, err := r.orgs.Create(ctx, genorgs.CreateParams{ - ID: createOrg.ID, - Name: createOrg.Name, +// Update saves display_name and contact fields for the given organization. +func (r *Repository) Update(ctx context.Context, p UpdateParams) error { + err := r.orgs.Update(ctx, genorgs.UpdateParams{ + ID: p.ID, + DisplayName: p.DisplayName, }) if err != nil { - return nil, fmt.Errorf("failed to create org: %w", database.NormalizeError(err)) + return fmt.Errorf("orgs.Repository.Update: %w", err) } - - return &Org{ - ID: org.ID, - Name: org.Name, - }, nil + return nil } diff --git a/internal/orgs/roles.go b/internal/orgs/roles.go new file mode 100644 index 0000000..d029838 --- /dev/null +++ b/internal/orgs/roles.go @@ -0,0 +1,49 @@ +package orgs + +// Role represents an organization role (Owner, Admin, Member, Viewer). +// The integer value matches the id stored in the org_roles table, +// which is seeded at startup rather than via migrations. +type Role int64 + +// Role IDs matching rows seeded in the org_roles table. +const ( + OwnerRole Role = 1 + AdminRole Role = 2 + MemberRole Role = 3 + ViewerRole Role = 4 +) + +// ID returns the database id for the role. +func (r Role) ID() int64 { return int64(r) } + +// String returns the name stored in the org_roles table. +func (r Role) String() string { + switch r { + case OwnerRole: + return "Owner" + case AdminRole: + return "Admin" + case MemberRole: + return "Member" + case ViewerRole: + return "Viewer" + default: + return "" + } +} + +// Rank returns the numeric rank for the role used in permission comparisons. +func (r Role) Rank() int64 { + switch r { + case OwnerRole: + return 100 + case AdminRole: + return 50 + case MemberRole: + return 25 + case ViewerRole: + return 0 + default: + return 0 + } +} diff --git a/internal/orgs/service.go b/internal/orgs/service.go index 3b213ba..ecac5f7 100644 --- a/internal/orgs/service.go +++ b/internal/orgs/service.go @@ -2,80 +2,120 @@ package orgs import ( "context" + "database/sql" "fmt" - - "github.com/bit8bytes/gearberg/internal/database" ) -// Options holds configuration for the org service. -type Options struct { - MaxOrgs int +type orgsRepository interface { + Create(ctx context.Context, tx *sql.Tx, id, displayName string) (string, error) + List(ctx context.Context) ([]Org, error) + GetFirstByAccountID(ctx context.Context, accountID string) (string, error) + Get(ctx context.Context, id string) (Org, error) + Update(ctx context.Context, p UpdateParams) error + Max() int + CreateMember(ctx context.Context, tx *sql.Tx, accountID, orgID string, roleID int64) error + Delete(ctx context.Context, orgID, accountID string) error + GetMember(ctx context.Context, orgID, accountID string) error } -// Service implements business logic for orgs. +// Service provides organization business logic. type Service struct { - repo *Repository - opts Options -} - -// NewService returns a new Service backed by repo with the given options. -func NewService(repo *Repository, opts Options) *Service { - return &Service{repo: repo, opts: opts} + db *sql.DB + orgs orgsRepository } -// MaxOrgs returns the configured maximum number of allowed orgs. -func (s *Service) MaxOrgs() int { - return s.opts.MaxOrgs +// NewService creates a new organization service. +func NewService(db *sql.DB, orgs orgsRepository) *Service { + return &Service{db: db, orgs: orgs} } -// GetAll returns all orgs. +// GetAll returns all organizations. func (s *Service) GetAll(ctx context.Context) ([]Org, error) { - orgs, err := s.repo.GetAll(ctx) + records, err := s.orgs.List(ctx) if err != nil { - return nil, fmt.Errorf("failed to get all orgs: %w", err) + return nil, fmt.Errorf("orgs.Service.GetAll: %w", err) } - return orgs, nil + return records, nil } -// GetByID returns the org with the given ID. -func (s *Service) GetByID(ctx context.Context, id string) (*Org, error) { - org, err := s.repo.GetByID(ctx, id) +// Max returns the maximum number of organizations allowed. +func (s *Service) Max() int { + return s.orgs.Max() +} + +// GetFirstByAccountID returns the ID of the first organization the account belongs to. +func (s *Service) GetFirstByAccountID(ctx context.Context, accountID string) (string, error) { + id, err := s.orgs.GetFirstByAccountID(ctx, accountID) if err != nil { - return nil, fmt.Errorf("failed to get org by id: %w", err) + return "", fmt.Errorf("orgs.Service.GetFirstByAccountID: %w", err) } - return org, nil + return id, nil } -// Update updates the name of the org with the given ID. -func (s *Service) Update(ctx context.Context, u UpdateOrg) (*Org, error) { - org, err := s.repo.Update(ctx, u) +// Get retrieves an organization by ID. +func (s *Service) Get(ctx context.Context, id string) (*Org, error) { + record, err := s.orgs.Get(ctx, id) if err != nil { - return nil, fmt.Errorf("failed to update org: %w", err) + return &Org{}, fmt.Errorf("orgs.Service.Get: %w", err) } - return org, nil + return &record, nil } -// Delete removes the org with the given ID. -func (s *Service) Delete(ctx context.Context, id string) error { - if err := s.repo.Delete(ctx, id); err != nil { - return fmt.Errorf("failed to delete org: %w", err) +// Update saves the organization fields. +func (s *Service) Update(ctx context.Context, p UpdateParams) error { + if err := s.orgs.Update(ctx, p); err != nil { + return fmt.Errorf("orgs.Service.Update: %w", err) } return nil } -// Create creates a new org, enforcing the configured MaxOrgs limit. -func (s *Service) Create(ctx context.Context, createOrg CreateOrg) (*Org, error) { - count, err := s.repo.Count(ctx) - if err != nil { - return nil, fmt.Errorf("failed to create org: %w", err) +// Delete removes an org if accountID is its sole owner. +func (s *Service) Delete(ctx context.Context, orgID, accountID string) error { + if err := s.orgs.Delete(ctx, orgID, accountID); err != nil { + return fmt.Errorf("orgs.Service.Delete: %w", err) } - if count >= int64(s.opts.MaxOrgs) { - return nil, fmt.Errorf("failed to create org: %w", database.ErrLimitExceeded) + return nil +} + +// GetMember returns an error if accountID is not a member of orgID. +func (s *Service) GetMember(ctx context.Context, orgID, accountID string) error { + if err := s.orgs.GetMember(ctx, orgID, accountID); err != nil { + return fmt.Errorf("orgs.Service.GetMember: %w", err) } + return nil +} - org, err := s.repo.Create(ctx, createOrg) +// CreateOrg holds the data required to create a new org. +type CreateOrg struct { + ID string + DisplayName string + AccountID string +} + +// Create creates a new org and adds the given account as owner, enforcing the configured MaxOrgs limit. +func (s *Service) Create(ctx context.Context, createOrg CreateOrg) (string, error) { + fail := func(err error) (string, error) { + return "", fmt.Errorf("orgs.Create: %w", err) + } + + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{}) + if err != nil { + return fail(err) + } + defer func() { _ = tx.Rollback() }() + + org, err := s.orgs.Create(ctx, tx, createOrg.ID, createOrg.DisplayName) if err != nil { - return nil, fmt.Errorf("failed to create org: %w", err) + return "", fmt.Errorf("failed to create org: %w", err) + } + + if err := s.orgs.CreateMember(ctx, tx, createOrg.AccountID, org, int64(OwnerRole)); err != nil { + return fail(err) } + + if err := tx.Commit(); err != nil { + return fail(err) + } + return org, nil } diff --git a/internal/orgs/settings/form.go b/internal/orgs/settings/form.go index e3a9678..3b6e97e 100644 --- a/internal/orgs/settings/form.go +++ b/internal/orgs/settings/form.go @@ -74,6 +74,18 @@ func (f *Form) Validate() bool { return f.Valid() } +// FormFromOrgSettings pre-populates a Form from stored OrgSettings. +func FormFromOrgSettings(s *OrgSettings) Form { + if s == nil { + return Form{} + } + return Form{ + Currency: s.Currency, + VatRate: s.VatRatePercent(), + Timezone: s.Timezone, + } +} + // VatRateBasisPoints returns the VAT rate as basis points (e.g. "19" → 1900). // Call only after Validate() returns true. func (f *Form) VatRateBasisPoints() int64 { diff --git a/internal/sessions/sessions.go b/internal/sessions/sessions.go new file mode 100644 index 0000000..7ce561b --- /dev/null +++ b/internal/sessions/sessions.go @@ -0,0 +1,44 @@ +// Package sessions provides utilities for storing and retrieving authentication +// and authorization metadata within a request context. +// +// This package is designed to support a denormalized security model where +// AccountID and OrganizationID are passed together to satisfy database queries +// without requiring complex joins. +package sessions + +import ( + "context" + "net/http" +) + +// contextKey is a private type to prevent collisions with other context keys. +type contextKey string + +// key is the unique identifier for session data within a context. +const key contextKey = "session" + +// Session represents the authenticated state of a user for the duration +// of a single request. It contains the IDs necessary to perform +// direct-lookup authorization in the database. +type Session struct { + // AccountID is the unique identifier of the authenticated user. + AccountID string +} + +// NewContext returns a new context derived from ctx that contains the Session s. +// Use this in middleware to inject session data after successful authentication. +func NewContext(ctx context.Context, s Session) context.Context { + return context.WithValue(ctx, key, s) +} + +// MustFromRequest extracts the Session from an http.Request context. +// +// If no session is found, it panics. Use this in handlers or logic layers +// where authentication is guaranteed by middleware. +func MustFromRequest(r *http.Request) Session { + s, ok := r.Context().Value(key).(Session) + if !ok { + panic("sessions: no session in context") + } + return s +} diff --git a/internal/templates/components/branding.tmpl b/internal/templates/components/branding.tmpl new file mode 100644 index 0000000..680d475 --- /dev/null +++ b/internal/templates/components/branding.tmpl @@ -0,0 +1,3 @@ +{{ define "logo" }} +

Gearberg

+{{ end }} \ No newline at end of file diff --git a/internal/templates/components/icons.tmpl b/internal/templates/components/icons.tmpl index b95346d..dc41407 100644 --- a/internal/templates/components/icons.tmpl +++ b/internal/templates/components/icons.tmpl @@ -127,4 +127,20 @@ {{ define "edit-icon" }} +{{ end }} + +{{ define "logout-icon" }} + +{{ end }} + +{{ define "account-icon" }} + +{{ end }} + +{{ define "success-icon" }} + +{{ end }} + +{{ define "error-icon" }} + {{ end }} \ No newline at end of file diff --git a/internal/templates/components/login.tmpl b/internal/templates/components/login.tmpl new file mode 100644 index 0000000..97af97a --- /dev/null +++ b/internal/templates/components/login.tmpl @@ -0,0 +1,31 @@ +{{ define "password-input" }} +
+
+ + +
+ {{ with .Form.Errors.password }} +
+ {{ . }} +
+ {{ end }} +
+{{ end }} diff --git a/internal/templates/layouts/layouts.go b/internal/templates/layouts/layouts.go index d065996..e3cfe08 100644 --- a/internal/templates/layouts/layouts.go +++ b/internal/templates/layouts/layouts.go @@ -26,4 +26,7 @@ var ( // Print renders a bare HTML shell for print-only pages (no nav, no chrome). Print = Layout{File: "layouts/print.tmpl"} + + // Login renders the full HTML shell with a login-focused body. + Login = Layout{File: "layouts/login.tmpl"} ) diff --git a/internal/templates/layouts/login.tmpl b/internal/templates/layouts/login.tmpl new file mode 100644 index 0000000..9882246 --- /dev/null +++ b/internal/templates/layouts/login.tmpl @@ -0,0 +1,5 @@ +{{ define "body" }} +
+ {{ template "page" . }} +
+{{ end }} \ No newline at end of file diff --git a/internal/templates/layouts/settings.tmpl b/internal/templates/layouts/settings.tmpl index 7722a6e..bee4981 100644 --- a/internal/templates/layouts/settings.tmpl +++ b/internal/templates/layouts/settings.tmpl @@ -13,30 +13,7 @@
- + {{ template "org-settings-sidebar" . }}
{{ template "footer" . }} diff --git a/internal/templates/pages/equipment/detail.tmpl b/internal/templates/pages/equipment/detail.tmpl index 3686b8e..2a2bf56 100644 --- a/internal/templates/pages/equipment/detail.tmpl +++ b/internal/templates/pages/equipment/detail.tmpl @@ -1,4 +1,4 @@ -{{ define "title" }}{{ if .Data.Item }}{{ .Data.Item.Name }}{{ end }}{{ end }} +{{ define "title" }}{{ .Form.Name }}{{ end }} {{ define "page" }} {{ template "equipment-header" . }} @@ -7,7 +7,7 @@
- +
General @@ -24,7 +24,7 @@ id="name" type="text" name="name" - value="{{ if .Form.Name }}{{ .Form.Name }}{{ else if .Data.Item }}{{ .Data.Item.Name }}{{ end }}" + value="{{ .Form.Name }}" maxlength="200" class="input w-full" /> @@ -35,13 +35,13 @@
- +
{{ template "equipment-categories-skeleton" . }} @@ -54,7 +54,7 @@
{{ template "equipment-manufacturers-skeleton" . }} @@ -64,21 +64,21 @@
{{ template "warehouse-locations-skeleton" . }}
- {{ if .Data.Item }}{{ if eq .Data.Item.Type 1 }} + {{ if eq .Form.TypeID "bulk" }}
@@ -86,11 +86,11 @@

{{ . }}

{{ end }}
- {{ end }}{{ end }} + {{ end }}
- +
diff --git a/internal/templates/pages/equipment/print.tmpl b/internal/templates/pages/equipment/print.tmpl index 34cd852..1f4689b 100644 --- a/internal/templates/pages/equipment/print.tmpl +++ b/internal/templates/pages/equipment/print.tmpl @@ -5,7 +5,7 @@

Equipment List

- {{ .Data.OrgName }} + {{ .Data.OrgDisplayName }} · {{ .Data.PrintDate }} · {{ .Data.TotalCount }} items {{ if .Data.Query }} · Search: "{{ .Data.Query }}"{{ end }} diff --git a/internal/templates/pages/equipment/properties.tmpl b/internal/templates/pages/equipment/properties.tmpl index 8a923df..022abf1 100644 --- a/internal/templates/pages/equipment/properties.tmpl +++ b/internal/templates/pages/equipment/properties.tmpl @@ -17,30 +17,45 @@ + {{ with index .Form.Errors "weight_kg" }} +

{{ . }}

+ {{ end }}
+ {{ with index .Form.Errors "width_cm" }} +

{{ . }}

+ {{ end }}
+ {{ with index .Form.Errors "height_cm" }} +

{{ . }}

+ {{ end }}
+ {{ with index .Form.Errors "depth_cm" }} +

{{ . }}

+ {{ end }}
+ {{ with index .Form.Errors "wire_gauge_mm2_x100" }} +

{{ . }}

+ {{ end }}
@@ -55,18 +70,27 @@ + {{ with index .Form.Errors "voltage_v" }} +

{{ . }}

+ {{ end }}
+ {{ with index .Form.Errors "power_w" }} +

{{ . }}

+ {{ end }}
+ {{ with index .Form.Errors "current_a" }} +

{{ . }}

+ {{ end }}
diff --git a/internal/templates/pages/login/forgot-password-success.tmpl b/internal/templates/pages/login/forgot-password-success.tmpl new file mode 100644 index 0000000..9eee8ec --- /dev/null +++ b/internal/templates/pages/login/forgot-password-success.tmpl @@ -0,0 +1,17 @@ +{{ define "title"}}Reset Password{{ end }} +{{ define "notes" }}Reset your password for filmlet{{end }} + +{{ define "page" }} +
+
+
{{ template "logo" . }}
+

Check your email

+

+ If an account exists, we sent a password reset link. Check your inbox and follow the instructions. +

+

+ Back to login +

+
+
+{{ end }} diff --git a/internal/templates/pages/login/forgot-password.tmpl b/internal/templates/pages/login/forgot-password.tmpl new file mode 100644 index 0000000..850e755 --- /dev/null +++ b/internal/templates/pages/login/forgot-password.tmpl @@ -0,0 +1,57 @@ +{{ define "title"}}Forgot Password{{ end }} +{{ define "notes" }}Request a password reset link for your filmlet account{{end }} + +{{ define "page" }} +
+ +
+
{{ template "logo" . }}
+

Forgot Password

+

Enter your email and we'll send you a reset link.

+ +
+
+ + {{ with .Form.Errors.email }} +
+ {{ . }} +
+ {{ end }} +
+
+ + + + + + +

+ Already have an account? + Login +

+
+ +
+{{ end }} diff --git a/internal/templates/pages/login/reset-password.tmpl b/internal/templates/pages/login/reset-password.tmpl new file mode 100644 index 0000000..853ab3c --- /dev/null +++ b/internal/templates/pages/login/reset-password.tmpl @@ -0,0 +1,58 @@ +{{ define "title"}}Reset Password{{ end }} +{{ define "notes" }}Reset your password for filmlet{{end }} + +{{ define "page" }} +
+
+
+
{{ template "logo" . }}
+

Reset Password

+

Enter your new password below.

+ + + +
+ {{ template "password-input" . }} + +
+ + {{ with .Form.Errors.repeat_password }} +
+ {{ . }} +
+ {{ end }} +
+
+ + + + + +

+ Already have an account? + Login +

+
+
+
+{{ end }} diff --git a/internal/templates/pages/login/signin.tmpl b/internal/templates/pages/login/signin.tmpl new file mode 100644 index 0000000..d3b3b4d --- /dev/null +++ b/internal/templates/pages/login/signin.tmpl @@ -0,0 +1,81 @@ +{{ define "title"}}Signin{{ end }} +{{ define "notes" }}Signin to filmlet{{end }} + +{{ define "page" }} +
+
+
+
{{ template "logo" . }}
+

Welcome Back

+

Enter your email and password to access your account.

+ +
+
+ + {{ with .Form.Errors.email }} +
+ {{ . }} +
+ {{ end }} +
+ +
+
+ + +
+ {{ with .Form.Errors.password }} +
+ {{ . }} +
+ {{ end }} + +
+
+ + + + + +

+ Don't have an account? + Sign up. +

+
+
+
+{{ end }} diff --git a/internal/templates/pages/login/signup.tmpl b/internal/templates/pages/login/signup.tmpl new file mode 100644 index 0000000..f7e72e7 --- /dev/null +++ b/internal/templates/pages/login/signup.tmpl @@ -0,0 +1,71 @@ +{{ define "title"}}Signup{{ end }} +{{ define "notes" }}Signup to filmlet{{end }} + +{{ define "page" }} +
+
+
+
{{ template "logo" . }}
+

Get Ready

+

Create your account to get started.

+ +
+
+ + {{ with .Form.Errors.email }} +
+ {{ . }} +
+ {{ end }} +
+ + {{ template "password-input" . }} + +
+ + {{ with .Form.Errors.repeat_password }} +
+ {{ . }} +
+ {{ end }} +
+
+ + + + + +

+ Already have an account? + Login +

+
+
+
+{{ end }} diff --git a/internal/templates/pages/orgs/categories/detail.tmpl b/internal/templates/pages/orgs/categories/detail.tmpl index 9b32db2..2c7fd7d 100644 --- a/internal/templates/pages/orgs/categories/detail.tmpl +++ b/internal/templates/pages/orgs/categories/detail.tmpl @@ -1,4 +1,4 @@ -{{ define "title" }}{{ if .Data.Category }}{{ .Data.Category.Name }}{{ end }}{{ end }} +{{ define "title" }}{{ .Form.Name }}{{ end }} {{ define "page" }} {{ $back := printf "/orgs/%s/settings/equipment-categories" .Data.OrgID }} @@ -15,7 +15,7 @@ id="name" type="text" name="name" - value="{{ if .Form.Name }}{{ .Form.Name }}{{ else if .Data.Category }}{{ .Data.Category.Name }}{{ end }}" + value="{{ .Form.Name }}" maxlength="100" class="input" /> diff --git a/internal/templates/pages/orgs/categories/index.tmpl b/internal/templates/pages/orgs/categories/index.tmpl index df13dd4..fee8a9a 100644 --- a/internal/templates/pages/orgs/categories/index.tmpl +++ b/internal/templates/pages/orgs/categories/index.tmpl @@ -1,5 +1,4 @@ {{ define "title" }}Equipment Categories{{ end }} -{{ define "sidebar-categories" }}bg-base-300 font-semibold{{ end }} {{ define "page" }}
diff --git a/internal/templates/pages/orgs/details.tmpl b/internal/templates/pages/orgs/details.tmpl index 50fa681..8bd2b37 100644 --- a/internal/templates/pages/orgs/details.tmpl +++ b/internal/templates/pages/orgs/details.tmpl @@ -1,5 +1,4 @@ {{ define "title" }}General{{ end }} -{{ define "sidebar-general" }}bg-base-300 font-semibold{{ end }} {{ define "page" }}
@@ -13,7 +12,7 @@ id="name" type="text" name="name" - value="{{ if .Data.Org }}{{ .Data.Org.Name }}{{ else }}{{ .Form.Name }}{{ end }}" + value="{{ .Form.DisplayName }}" maxlength="100" class="input" /> @@ -48,9 +47,9 @@
-
- -
+
diff --git a/internal/templates/pages/orgs/index.tmpl b/internal/templates/pages/orgs/index.tmpl index f626c4b..d21c283 100644 --- a/internal/templates/pages/orgs/index.tmpl +++ b/internal/templates/pages/orgs/index.tmpl @@ -2,8 +2,8 @@

Orgs

- {{ if ge (len .Data.Orgs) .Data.MaxOrgs }} -
+ {{ if ge (len .Data.Orgs) .Data.Max }} +
{{ else }} @@ -17,7 +17,7 @@
  • {{ template "orgs-icon" . }} - {{ .Name }} + {{ .DisplayName }}
  • diff --git a/internal/templates/pages/orgs/locations/detail.tmpl b/internal/templates/pages/orgs/locations/detail.tmpl index d180026..f80c5d7 100644 --- a/internal/templates/pages/orgs/locations/detail.tmpl +++ b/internal/templates/pages/orgs/locations/detail.tmpl @@ -1,4 +1,4 @@ -{{ define "title" }}{{ if .Data.Location }}{{ .Data.Location.Name }}{{ end }}{{ end }} +{{ define "title" }}{{ .Form.Name }}{{ end }} {{ define "page" }}
    @@ -14,7 +14,7 @@ id="name" type="text" name="name" - value="{{ if .Form.Name }}{{ .Form.Name }}{{ else if .Data.Location }}{{ .Data.Location.Name }}{{ end }}" + value="{{ .Form.Name }}" maxlength="100" class="input" /> diff --git a/internal/templates/pages/orgs/locations/index.tmpl b/internal/templates/pages/orgs/locations/index.tmpl index e71aab5..681b76b 100644 --- a/internal/templates/pages/orgs/locations/index.tmpl +++ b/internal/templates/pages/orgs/locations/index.tmpl @@ -1,5 +1,4 @@ {{ define "title" }}Locations{{ end }} -{{ define "sidebar-locations" }}bg-base-300 font-semibold{{ end }} {{ define "page" }}
    diff --git a/internal/templates/pages/orgs/manufacturers/detail.tmpl b/internal/templates/pages/orgs/manufacturers/detail.tmpl index 85d8b6e..282e95e 100644 --- a/internal/templates/pages/orgs/manufacturers/detail.tmpl +++ b/internal/templates/pages/orgs/manufacturers/detail.tmpl @@ -1,4 +1,4 @@ -{{ define "title" }}{{ if .Data.Manufacturer }}{{ .Data.Manufacturer.Name }}{{ end }}{{ end }} +{{ define "title" }}{{ .Form.Name }}{{ end }} {{ define "page" }} {{ $back := printf "/orgs/%s/settings/manufacturers" .Data.OrgID }} @@ -15,7 +15,7 @@ id="name" type="text" name="name" - value="{{ if .Form.Name }}{{ .Form.Name }}{{ else if .Data.Manufacturer }}{{ .Data.Manufacturer.Name }}{{ end }}" + value="{{ .Form.Name }}" maxlength="100" class="input" /> diff --git a/internal/templates/pages/orgs/manufacturers/index.tmpl b/internal/templates/pages/orgs/manufacturers/index.tmpl index f73acc1..daaf9dd 100644 --- a/internal/templates/pages/orgs/manufacturers/index.tmpl +++ b/internal/templates/pages/orgs/manufacturers/index.tmpl @@ -1,5 +1,4 @@ {{ define "title" }}Manufacturers{{ end }} -{{ define "sidebar-manufacturers" }}bg-base-300 font-semibold{{ end }} {{ define "page" }}
    diff --git a/internal/templates/pages/orgs/new.tmpl b/internal/templates/pages/orgs/new.tmpl index f2fa06c..a9be9c1 100644 --- a/internal/templates/pages/orgs/new.tmpl +++ b/internal/templates/pages/orgs/new.tmpl @@ -11,7 +11,7 @@ id="name" type="text" name="name" - value="{{ .Form.Name }}" + value="{{ .Form.DisplayName }}" maxlength="100" class="input" /> diff --git a/internal/templates/pages/orgs/settings.tmpl b/internal/templates/pages/orgs/settings.tmpl index 89bed19..a6bef29 100644 --- a/internal/templates/pages/orgs/settings.tmpl +++ b/internal/templates/pages/orgs/settings.tmpl @@ -1,5 +1,4 @@ {{ define "title" }}Billing & Tax{{ end }} -{{ define "sidebar-billing" }}bg-base-300 font-semibold{{ end }} {{ define "page" }}
    @@ -9,34 +8,33 @@
    - {{ $currency := "" }}{{ if .Data.Settings }}{{ $currency = .Data.Settings.Currency }}{{ else }}{{ $currency = .Form.Currency }}{{ end }} {{ with index .Form.Errors "currency" }}

    {{ . }}

    @@ -55,7 +53,7 @@ max="100" step="1" name="vat_rate" - value="{{ if .Data.Settings }}{{ .Data.Settings.VatRatePercent }}{{ else }}{{ .Form.VatRate }}{{ end }}" + value="{{ .Form.VatRate }}" placeholder="19" /> @@ -66,81 +64,80 @@
    - {{ $timezone := "" }}{{ if .Data.Settings }}{{ $timezone = .Data.Settings.Timezone }}{{ else }}{{ $timezone = .Form.Timezone }}{{ end }} {{ with index .Form.Errors "timezone" }} diff --git a/internal/templates/pages/pages.go b/internal/templates/pages/pages.go index 1b211ed..da62247 100644 --- a/internal/templates/pages/pages.go +++ b/internal/templates/pages/pages.go @@ -111,4 +111,18 @@ var ( // Error is the generic error page. Error = newPage("pages/error.tmpl", layouts.Center) + + // SignIn is the sign-in page. + SignIn = newPage("pages/login/signin.tmpl", layouts.Login) + // SignUp is the account registration page. + SignUp = newPage("pages/login/signup.tmpl", layouts.Login) + // ForgotPassword is the forgot-password request page. + ForgotPassword = newPage("pages/login/forgot-password.tmpl", layouts.Login) + // ForgotPasswordSuccess is shown after a reset email has been sent. + ForgotPasswordSuccess = newPage("pages/login/forgot-password-success.tmpl", layouts.Login) + // ResetPassword is the page where users submit their new password. + ResetPassword = newPage("pages/login/reset-password.tmpl", layouts.Login) + + // SettingsAccount is the account settings page. + SettingsAccount = newPage("pages/settings/account.tmpl", layouts.Center) ) diff --git a/internal/templates/pages/settings/account.tmpl b/internal/templates/pages/settings/account.tmpl new file mode 100644 index 0000000..e801e7c --- /dev/null +++ b/internal/templates/pages/settings/account.tmpl @@ -0,0 +1,80 @@ +{{ define "title" }}Account Settings{{ end }} + +{{ define "page" }} +
    +
    +

    Account

    + Back +
    +

    Your email address and account information.

    + +
    +
    + +
    + + {{ if .Data.EmailVerified }} + + {{ template "success-icon" . }} + + {{ else }} + + {{ template "error-icon" . }} + + {{ end }} +
    +
    + +
    + + +
    +
    + +
    +

    Danger Zone

    +

    Permanently delete your account and all associated data.

    + +
    +
    + + + + + +{{ end }} diff --git a/internal/templates/partials/header.tmpl b/internal/templates/partials/header.tmpl index 0dba198..2e5f6d5 100644 --- a/internal/templates/partials/header.tmpl +++ b/internal/templates/partials/header.tmpl @@ -26,24 +26,37 @@ Gearberg
    +
  • + + {{ template "account-icon" . }} + Account + +
  • +
  • + + {{ template "settings-icon" . }} + Settings + +
  • +
  • {{ template "inventory-icon" . }} Equipment
  • -
  • {{ template "orgs-icon" . }} - All Orgs + Organizations
  • +
  • - - {{ template "settings-icon" . }} - Settings - +
  • diff --git a/internal/templates/partials/org-settings-sidebar.tmpl b/internal/templates/partials/org-settings-sidebar.tmpl new file mode 100644 index 0000000..0ee5b59 --- /dev/null +++ b/internal/templates/partials/org-settings-sidebar.tmpl @@ -0,0 +1,26 @@ +{{ define "org-settings-sidebar" }} + +{{ end }} diff --git a/internal/tokens/repository.go b/internal/tokens/repository.go new file mode 100644 index 0000000..8048f88 --- /dev/null +++ b/internal/tokens/repository.go @@ -0,0 +1,74 @@ +// Package tokens manages short-lived token records used for email verification +// and password reset flows. +package tokens + +import ( + "context" + "database/sql" + "fmt" + + gentokens "github.com/bit8bytes/gearberg/internal/database/queries/gen/tokens" +) + +// Record holds the account and scope associated with a verified token. +type Record struct { + AccountID string + TokenScopeID int64 +} + +// Repository provides data access for tokens. +type Repository struct { + tokens *gentokens.Queries +} + +// NewRepository returns a new Repository. +func NewRepository(db gentokens.DBTX) *Repository { + return &Repository{ + tokens: gentokens.New(db), + } +} + +// CreateParams holds the fields needed to insert a new token. +type CreateParams struct { + AccountID string + ScopeID int64 + Hash []byte + ExpiresAt int64 +} + +// Create inserts a new token record within the given transaction. +func (r *Repository) Create(ctx context.Context, tx *sql.Tx, p CreateParams) error { + _, err := r.tokens.WithTx(tx).Insert(ctx, gentokens.InsertParams{ + AccountID: p.AccountID, + TokenScopeID: p.ScopeID, + Hash: p.Hash, + ExpiresAt: p.ExpiresAt, + }) + if err != nil { + return fmt.Errorf("tokens.Repository.Create: %w", err) + } + return nil +} + +// Delete removes all tokens for accountID within scopeID. +func (r *Repository) Delete(ctx context.Context, tx *sql.Tx, accountID string, scopeID int64) error { + if err := r.tokens.WithTx(tx).DeleteForAccountAndScope(ctx, gentokens.DeleteForAccountAndScopeParams{ + AccountID: accountID, + TokenScopeID: scopeID, + }); err != nil { + return fmt.Errorf("tokens.Repository.Delete: %w", err) + } + return nil +} + +// VerifyAndIncrement validates the token hash and increments its attempt counter. +func (r *Repository) VerifyAndIncrement(ctx context.Context, tx *sql.Tx, hash []byte) (Record, error) { + row, err := r.tokens.WithTx(tx).VerifyAndIncrement(ctx, hash) + if err != nil { + return Record{}, fmt.Errorf("tokens.Repository.VerifyAndIncrement: %w", err) + } + return Record{ + AccountID: row.AccountID, + TokenScopeID: row.TokenScopeID, + }, nil +} diff --git a/internal/tokens/scopes.go b/internal/tokens/scopes.go new file mode 100644 index 0000000..4f2bfd5 --- /dev/null +++ b/internal/tokens/scopes.go @@ -0,0 +1,28 @@ +package tokens + +// Scope represents a token scope. +// The integer value matches the id stored in the token_scopes table, +// which is seeded at startup rather than via migrations. +type Scope int64 + +const ( + // PasswordReset is the scope for password reset tokens. + PasswordReset Scope = 1 + // EmailVerification is the scope for email verification tokens. + EmailVerification Scope = 2 +) + +// ID returns the database id for the token scope. +func (s Scope) ID() int64 { return int64(s) } + +// String returns the name stored in the token_scopes table. +func (s Scope) String() string { + switch s { + case PasswordReset: + return "password-reset" + case EmailVerification: + return "email-verification" + default: + return "" + } +} diff --git a/pkg/mailer/mailer.go b/pkg/mailer/mailer.go new file mode 100644 index 0000000..ea7a3d1 --- /dev/null +++ b/pkg/mailer/mailer.go @@ -0,0 +1,58 @@ +// Package mailer provides email sending implementations for production and development. +package mailer + +import ( + "context" + "fmt" + "log/slog" + "net" + "net/smtp" + "strconv" +) + +// SMTP sends emails via Go's net/smtp. +type SMTP struct { + Host string + Port int + Username string + Password string + From string +} + +// New creates a new SMTPMailer with the given SMTP configuration. +func New(host, username, password, from string, port int) *SMTP { + return &SMTP{ + Host: host, + Port: port, + Username: username, + Password: password, + From: from, + } +} + +// Mail sends a plain-text email to the given recipient. +func (m *SMTP) Mail(_ context.Context, to, subject, body string) error { + addr := net.JoinHostPort(m.Host, strconv.Itoa(m.Port)) + + msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-version: 1.0\r\nContent-Type: text/plain; charset=\"UTF-8\"\r\n\r\n%s", + m.From, to, subject, body) + + var auth smtp.Auth + if m.Username != "" { + auth = smtp.PlainAuth("", m.Username, m.Password, m.Host) + } + + if err := smtp.SendMail(addr, auth, m.From, []string{to}, []byte(msg)); err != nil { + return fmt.Errorf("Mail: %w", err) + } + return nil +} + +// LogMailer is a no-op mailer that logs emails instead of sending them. +type LogMailer struct{} + +// Mail logs the email details and returns nil. +func (LogMailer) Mail(_ context.Context, to, subject, body string) error { + slog.Info("mailer: email suppressed", "to", to, "subject", subject, "body", body) + return nil +} diff --git a/sqlc.sqlite.yml b/sqlc.sqlite.yml index 27301ac..84a1b9a 100644 --- a/sqlc.sqlite.yml +++ b/sqlc.sqlite.yml @@ -1,6 +1,26 @@ version: "2" sql: + - engine: "sqlite" + queries: "internal/database/queries/sqlite/accounts.sql" + schema: "internal/database/migrations/sqlite/" + gen: + go: + package: "accounts" + out: "internal/database/queries/gen/accounts" + emit_interface: true + omit_unused_structs: true + + - engine: "sqlite" + queries: "internal/database/queries/sqlite/credentials.sql" + schema: "internal/database/migrations/sqlite/" + gen: + go: + package: "credentials" + out: "internal/database/queries/gen/credentials" + emit_interface: true + omit_unused_structs: true + - engine: "sqlite" queries: "internal/database/queries/sqlite/orgs.sql" schema: "internal/database/migrations/sqlite/" @@ -110,3 +130,13 @@ sql: out: "internal/database/queries/gen/storage" emit_interface: true omit_unused_structs: true + + - engine: "sqlite" + queries: "internal/database/queries/sqlite/tokens.sql" + schema: "internal/database/migrations/sqlite/" + gen: + go: + package: "tokens" + out: "internal/database/queries/gen/tokens" + emit_interface: true + omit_unused_structs: true diff --git a/wiki/ERD.md b/wiki/ERD.md index 4f9c86a..0afc230 100644 --- a/wiki/ERD.md +++ b/wiki/ERD.md @@ -3,50 +3,56 @@ ```mermaid erDiagram sessions { - text token "PRIMARY KEY" - blob data "NOT NULL" - real expiry "NOT NULL" - text constraint "sessions_expiry_idx ON sessions(expiry)" + text token "PRIMARY KEY" + blob data "NOT NULL" + real expiry "NOT NULL" + text constraint "sessions_expiry_idx ON sessions(expiry)" } accounts { - integer id PK - text email "UNIQUE NOT NULL" - integer email_verified "NOT NULL DEFAULT 0" - integer created_at "NOT NULL DEFAULT unixepoch()" - integer updated_at "NOT NULL DEFAULT unixepoch()" - text constraint "INDEX on (email)" + text id PK + text email "UNIQUE NOT NULL" + integer email_verified "NULLABLE; unixepoch() when verified" + integer enabled "NOT NULL DEFAULT 1" + + integer created_at "NOT NULL DEFAULT unixepoch()" + integer updated_at "NOT NULL DEFAULT unixepoch()" } + %% PII: email - users { - integer id PK "REFERENCES accounts(id) ON DELETE CASCADE" - text given_name - text family_name - text phone - integer created_at "NOT NULL DEFAULT unixepoch()" - integer updated_at "NOT NULL DEFAULT unixepoch()" + credential_types { + integer id PK + text name "UNIQUE NOT NULL" } + %% password, totp, webauthn + + credentials { + text id PK + text account_id "NOT NULL REFERENCES accounts(id) ON DELETE CASCADE" + integer type_id "NOT NULL REFERENCES credential_types(id) ON DELETE RESTRICT" + text secret_data "NOT NULL" - identity_providers { - integer id PK - text name "UNIQUE NOT NULL" + integer created_at "NOT NULL DEFAULT unixepoch()" + integer updated_at "NOT NULL DEFAULT unixepoch()" } - %% seeded with: password, google, apple; extend by inserting new rows + %% secret_data: argon2id hash for password. one password per account enforced by partial unique index in migration - identities { - text id PK - integer account_id FK "NOT NULL REFERENCES accounts(id) ON DELETE CASCADE" - integer provider_id FK "NOT NULL REFERENCES identity_providers(id) ON DELETE RESTRICT" - text provider_subject "NOT NULL" - text provider_email - text credential - integer created_at "NOT NULL DEFAULT unixepoch()" - integer updated_at "NOT NULL DEFAULT unixepoch()" - text constraint "UNIQUE(provider_id, provider_subject)" + token_scopes { + integer id PK + text name "UNIQUE NOT NULL" + } + %% password-reset, email-verification + + tokens { + text id PK + text account_id "NOT NULL REFERENCES accounts(id) ON DELETE CASCADE" + integer token_scope_id "NOT NULL REFERENCES token_scopes(id) ON DELETE RESTRICT" + blob hash "NOT NULL UNIQUE; CHECK(length(hash) = 32)" + integer expires_at "NOT NULL" + integer attempts "NOT NULL DEFAULT 0; CHECK (attempts >= 0 AND attempts <= 5)" + + integer created_at "NOT NULL DEFAULT unixepoch()" } - %% provider_subject: OIDC sub claim; email for the password provider - %% credential: argon2 hash for password provider; NULL for OIDC providers - %% one account can have multiple identities (one per provider) orgs { text id PK @@ -66,21 +72,33 @@ erDiagram } org_roles { - integer id PK - text name "UNIQUE NOT NULL" + integer id PK + text name "UNIQUE NOT NULL" + rank integer "DEFAULT 0" } - %% seeded: owner, billing, equipment_manager, viewer - %% roles are additive; each grants specific capabilities, not a rank + %% admin, editor, viewer org_member_roles { - integer account_id FK "NOT NULL REFERENCES accounts(id) ON DELETE CASCADE" - integer org_id FK "NOT NULL REFERENCES orgs(id) ON DELETE CASCADE" - integer role_id FK "NOT NULL REFERENCES org_roles(id) ON DELETE RESTRICT" - integer created_at "NOT NULL DEFAULT unixepoch()" - text constraint "PRIMARY KEY (account_id, org_id, role_id)" + text account_id "NOT NULL REFERENCES accounts(id) ON DELETE CASCADE" + text org_id "NOT NULL REFERENCES orgs(id) ON DELETE CASCADE" + integer role_id "NOT NULL REFERENCES org_roles(id) ON DELETE RESTRICT" + integer created_at "NOT NULL DEFAULT unixepoch()" + integer updated_at "NOT NULL DEFAULT unixepoch()" + text constraint "PRIMARY KEY (account_id, org_id, role_id)" } - %% membership = having at least one row here - %% a user may hold multiple roles within the same org + + accounts ||--|| profiles : "has" + accounts ||--o{ credentials : "authenticates with" + accounts ||--o{ tokens : "requests" + accounts ||--o{ org_member_roles : "belongs to" + accounts ||--o{ federated_identities : "signs in via" + + credential_types ||--o{ credentials : "types" + token_scopes ||--o{ tokens : "scopes" + provider_types ||--o{ federated_identities : "provided by" + + orgs ||--o{ org_member_roles : "has members" + org_roles ||--o{ org_member_roles : "grants" equipment_categories { text id PK From 23e6a5eff859d90a74d1f94b748d8bb5af22e074 Mon Sep 17 00:00:00 2001 From: Tobias Gleiter Date: Sat, 11 Jul 2026 16:53:46 -0400 Subject: [PATCH 2/4] chore: added 403 forbidden page and check permission on org related paths --- cmd/web/handlers.go | 4 + cmd/web/middleware.go | 2 +- cmd/web/routes.go | 118 ++++++++++++------------ internal/templates/pages/forbidden.tmpl | 13 +++ internal/templates/pages/pages.go | 26 +----- 5 files changed, 81 insertions(+), 82 deletions(-) create mode 100644 internal/templates/pages/forbidden.tmpl diff --git a/cmd/web/handlers.go b/cmd/web/handlers.go index 2280430..d20bb82 100644 --- a/cmd/web/handlers.go +++ b/cmd/web/handlers.go @@ -16,6 +16,10 @@ func (app *application) getNotFound(w http.ResponseWriter, r *http.Request) *htt return app.html.Render(w, r, http.StatusOK, pages.NotFound, app.html.TemplateData(r)) } +func (app *application) getForbidden(w http.ResponseWriter, r *http.Request) *httperr.Error { + return app.html.Render(w, r, http.StatusOK, pages.Forbidden, app.html.TemplateData(r)) +} + func (app *application) GetHealthz(_ context.Context) (*gen.HealthzResponse, error) { return &gen.HealthzResponse{ Status: "ok", diff --git a/cmd/web/middleware.go b/cmd/web/middleware.go index e426ac1..b26260e 100644 --- a/cmd/web/middleware.go +++ b/cmd/web/middleware.go @@ -203,7 +203,7 @@ func (app *application) withPermission(next http.Handler) http.Handler { orgID := r.PathValue("org_id") session := sessions.MustFromRequest(r) if err := app.services.orgs.GetMember(r.Context(), orgID, session.AccountID); err != nil { - http.Error(w, "Forbidden.", http.StatusForbidden) + http.Redirect(w, r, "/forbidden", http.StatusSeeOther) return } next.ServeHTTP(w, r) diff --git a/cmd/web/routes.go b/cmd/web/routes.go index 4a1e3ba..fa9db4a 100644 --- a/cmd/web/routes.go +++ b/cmd/web/routes.go @@ -12,6 +12,7 @@ func (app *application) routes() (http.Handler, error) { mux := http.NewServeMux() mux.HandleFunc("/", app.html.Handle(app.getNotFound)) + mux.Handle("GET /forbidden", app.html.Handle(app.getForbidden)) mux.Handle("GET /{$}", http.RedirectHandler("/orgs", http.StatusSeeOther)) apiServer, err := gen.NewServer(app, gen.WithPathPrefix("/api/v1")) @@ -24,7 +25,7 @@ func (app *application) routes() (http.Handler, error) { mux.Handle("GET /dist/", assets.ServeStaticFiles()) mux.Handle("GET /favicon.ico", http.RedirectHandler("/dist/images/favicon.ico", http.StatusMovedPermanently)) - // Login + // Login related routes that are only accessible if the user is not logged in. mux.Handle("GET /signin", app.withGuest(app.html.Handle(app.getSignIn))) mux.Handle("POST /signin", app.withGuest(app.html.Handle(app.postSignIn))) mux.Handle("GET /signup", app.withGuest(app.html.Handle(app.getSignUp))) @@ -35,75 +36,78 @@ func (app *application) routes() (http.Handler, error) { mux.Handle("GET /reset-password", app.withGuest(app.html.Handle(app.getResetPassword))) mux.Handle("POST /reset-password", app.withGuest(app.html.Handle(app.postResetPassword))) + // Signout must only be possible for logged in users. mux.Handle("POST /signout", app.withLogin(app.html.Handle(app.postSignOut))) - // Orgs + // Org related actions. The account only needs to be logged in via [app.withLogin]. mux.Handle("GET /orgs", app.withLogin(app.html.Handle(app.getOrgs))) - mux.Handle("GET /orgs/new", app.withLogin(app.html.Handle(app.getOrgsNew))) + mux.Handle("GET /orgs/new", app.withLogin(app.withPermission(app.html.Handle(app.getOrgsNew)))) mux.Handle("POST /orgs/new", app.withLogin(app.html.Handle(app.postOrgsNew))) - mux.Handle("DELETE /orgs/{org_id}", app.withLogin(app.html.Handle(app.deleteOrg))) - mux.Handle("GET /orgs/{org_id}", app.withLogin(app.html.Handle(app.getSettingsOrg))) - mux.Handle("POST /orgs/{org_id}", app.withLogin(app.html.Handle(app.postSettingsOrg))) + + // For any route that includes org_id, the permissions of the account must be checked via [app.withPermission] + mux.Handle("DELETE /orgs/{org_id}", app.withLogin(app.withPermission(app.html.Handle(app.deleteOrg)))) + mux.Handle("GET /orgs/{org_id}", app.withLogin(app.withPermission(app.html.Handle(app.getSettingsOrg)))) + mux.Handle("POST /orgs/{org_id}", app.withLogin(app.withPermission(app.html.Handle(app.postSettingsOrg)))) // Equipment mux.Handle("GET /orgs/{org_id}/equipment", app.withLogin(app.withPermission(app.html.Handle(app.getEquipment)))) - mux.Handle("GET /orgs/{org_id}/equipment/print", app.withLogin(app.html.Handle(app.getEquipmentPrint))) - mux.Handle("GET /orgs/{org_id}/equipment/export", app.withLogin(app.html.Handle(app.getEquipmentExport))) - mux.Handle("GET /orgs/{org_id}/equipment/import", app.withLogin(app.html.Handle(app.getEquipmentImport))) - mux.Handle("GET /orgs/{org_id}/equipment/import/template", app.withLogin(app.html.Handle(app.getEquipmentImportTemplate))) - mux.Handle("POST /orgs/{org_id}/equipment/import", app.withLogin(app.html.Handle(app.postEquipmentImport))) - mux.Handle("POST /orgs/{org_id}/equipment/import/confirm", app.withLogin(app.html.Handle(app.postEquipmentImportConfirm))) - mux.Handle("GET /orgs/{org_id}/equipment/new", app.withLogin(app.html.Handle(app.getEquipmentNew))) - mux.Handle("POST /orgs/{org_id}/equipment/new", app.withLogin(app.withCheckQuota(app.html.Handle(app.postEquipmentNew)))) - mux.Handle("GET /orgs/{org_id}/equipment/{id}", app.withLogin(app.html.Handle(app.getEquipmentItem))) - mux.Handle("GET /orgs/{org_id}/equipment/{id}/units", app.withLogin(app.html.Handle(app.getEquipmentUnits))) - mux.Handle("GET /orgs/{org_id}/equipment/{id}/pricing", app.withLogin(app.html.Handle(app.getEquipmentItemPricing))) - mux.Handle("POST /orgs/{org_id}/equipment/{id}/pricing", app.withLogin(app.html.Handle(app.postEquipmentItemPricing))) - mux.Handle("GET /orgs/{org_id}/equipment/{id}/properties", app.withLogin(app.html.Handle(app.getEquipmentItemProperties))) - mux.Handle("POST /orgs/{org_id}/equipment/{id}/details", app.withLogin(app.html.Handle(app.postEquipmentItemDetails))) - mux.Handle("POST /orgs/{org_id}/equipment/{id}/properties", app.withLogin(app.html.Handle(app.postEquipmentItemProperties))) - mux.Handle("POST /orgs/{org_id}/equipment/{id}/units", app.withLogin(app.html.Handle(app.postEquipmentAddUnit))) - mux.Handle("POST /orgs/{org_id}/equipment/{id}/units/bulk-inspect", app.withLogin(app.html.Handle(app.postEquipmentBulkUpdateInspection))) - mux.Handle("POST /orgs/{org_id}/equipment/{id}/units/{unit_id}", app.withLogin(app.html.Handle(app.postEquipmentUpdateUnit))) - mux.Handle("GET /orgs/{org_id}/equipment/{id}/units/{unit_id}/qr", app.withLogin(app.html.Handle(app.getEquipmentUnitQR))) - mux.Handle("GET /orgs/{org_id}/equipment/{id}/units/{unit_id}/barcode", app.withLogin(app.html.Handle(app.getEquipmentUnitBarcode))) - mux.Handle("POST /orgs/{org_id}/equipment/{id}/units/{unit_id}/delete", app.withLogin(app.html.Handle(app.postDeleteEquipmentUnit))) - mux.Handle("POST /orgs/{org_id}/equipment/{id}/archive", app.withLogin(app.html.Handle(app.postArchiveEquipmentItem))) - mux.Handle("POST /orgs/{org_id}/equipment/{id}/delete", app.withLogin(app.html.Handle(app.postDeleteEquipmentItem))) - mux.Handle("GET /orgs/{org_id}/equipment/{id}/content", app.withLogin(app.html.Handle(app.getEquipmentContent))) - mux.Handle("POST /orgs/{org_id}/equipment/{id}/content", app.withLogin(app.html.Handle(app.postEquipmentAssignContent))) - mux.Handle("POST /orgs/{org_id}/equipment/{id}/content/{content_id}/delete", app.withLogin(app.html.Handle(app.postEquipmentRemoveContent))) - mux.Handle("GET /orgs/{org_id}/equipment/{id}/part-of", app.withLogin(app.html.Handle(app.getEquipmentPartOfFragment))) - mux.Handle("GET /orgs/{org_id}/currency", app.withLogin(app.html.Handle(app.getOrgCurrencyFragment))) - mux.Handle("GET /orgs/{org_id}/equipment-categories", app.withLogin(app.html.Handle(app.getEquipmentCategoriesFragment))) - mux.Handle("GET /orgs/{org_id}/equipment-manufacturers", app.withLogin(app.html.Handle(app.getEquipmentManufacturersFragment))) - mux.Handle("GET /orgs/{org_id}/warehouse-locations", app.withLogin(app.html.Handle(app.getEquipmentLocationsFragment))) - mux.Handle("GET /orgs/{org_id}/settings", app.withLogin(app.html.Handle(app.getOrgSettings))) - mux.Handle("POST /orgs/{org_id}/settings", app.withLogin(app.html.Handle(app.postOrgSettings))) + mux.Handle("GET /orgs/{org_id}/equipment/print", app.withLogin(app.withPermission(app.html.Handle(app.getEquipmentPrint)))) + mux.Handle("GET /orgs/{org_id}/equipment/export", app.withLogin(app.withPermission(app.html.Handle(app.getEquipmentExport)))) + mux.Handle("GET /orgs/{org_id}/equipment/import", app.withLogin(app.withPermission(app.html.Handle(app.getEquipmentImport)))) + mux.Handle("GET /orgs/{org_id}/equipment/import/template", app.withLogin(app.withPermission(app.html.Handle(app.getEquipmentImportTemplate)))) + mux.Handle("POST /orgs/{org_id}/equipment/import", app.withLogin(app.withPermission(app.html.Handle(app.postEquipmentImport)))) + mux.Handle("POST /orgs/{org_id}/equipment/import/confirm", app.withLogin(app.withPermission(app.html.Handle(app.postEquipmentImportConfirm)))) + mux.Handle("GET /orgs/{org_id}/equipment/new", app.withLogin(app.withPermission(app.html.Handle(app.getEquipmentNew)))) + mux.Handle("POST /orgs/{org_id}/equipment/new", app.withLogin(app.withPermission(app.withCheckQuota(app.html.Handle(app.postEquipmentNew))))) + mux.Handle("GET /orgs/{org_id}/equipment/{id}", app.withLogin(app.withPermission(app.html.Handle(app.getEquipmentItem)))) + mux.Handle("GET /orgs/{org_id}/equipment/{id}/units", app.withLogin(app.withPermission(app.html.Handle(app.getEquipmentUnits)))) + mux.Handle("GET /orgs/{org_id}/equipment/{id}/pricing", app.withLogin(app.withPermission(app.html.Handle(app.getEquipmentItemPricing)))) + mux.Handle("POST /orgs/{org_id}/equipment/{id}/pricing", app.withLogin(app.withPermission(app.html.Handle(app.postEquipmentItemPricing)))) + mux.Handle("GET /orgs/{org_id}/equipment/{id}/properties", app.withLogin(app.withPermission(app.html.Handle(app.getEquipmentItemProperties)))) + mux.Handle("POST /orgs/{org_id}/equipment/{id}/details", app.withLogin(app.withPermission(app.html.Handle(app.postEquipmentItemDetails)))) + mux.Handle("POST /orgs/{org_id}/equipment/{id}/properties", app.withLogin(app.withPermission(app.html.Handle(app.postEquipmentItemProperties)))) + mux.Handle("POST /orgs/{org_id}/equipment/{id}/units", app.withLogin(app.withPermission(app.html.Handle(app.postEquipmentAddUnit)))) + mux.Handle("POST /orgs/{org_id}/equipment/{id}/units/bulk-inspect", app.withLogin(app.withPermission(app.html.Handle(app.postEquipmentBulkUpdateInspection)))) + mux.Handle("POST /orgs/{org_id}/equipment/{id}/units/{unit_id}", app.withLogin(app.withPermission(app.html.Handle(app.postEquipmentUpdateUnit)))) + mux.Handle("GET /orgs/{org_id}/equipment/{id}/units/{unit_id}/qr", app.withLogin(app.withPermission(app.html.Handle(app.getEquipmentUnitQR)))) + mux.Handle("GET /orgs/{org_id}/equipment/{id}/units/{unit_id}/barcode", app.withLogin(app.withPermission(app.html.Handle(app.getEquipmentUnitBarcode)))) + mux.Handle("POST /orgs/{org_id}/equipment/{id}/units/{unit_id}/delete", app.withLogin(app.withPermission(app.html.Handle(app.postDeleteEquipmentUnit)))) + mux.Handle("POST /orgs/{org_id}/equipment/{id}/archive", app.withLogin(app.withPermission(app.html.Handle(app.postArchiveEquipmentItem)))) + mux.Handle("POST /orgs/{org_id}/equipment/{id}/delete", app.withLogin(app.withPermission(app.html.Handle(app.postDeleteEquipmentItem)))) + mux.Handle("GET /orgs/{org_id}/equipment/{id}/content", app.withLogin(app.withPermission(app.html.Handle(app.getEquipmentContent)))) + mux.Handle("POST /orgs/{org_id}/equipment/{id}/content", app.withLogin(app.withPermission(app.html.Handle(app.postEquipmentAssignContent)))) + mux.Handle("POST /orgs/{org_id}/equipment/{id}/content/{content_id}/delete", app.withLogin(app.withPermission(app.html.Handle(app.postEquipmentRemoveContent)))) + mux.Handle("GET /orgs/{org_id}/equipment/{id}/part-of", app.withLogin(app.withPermission(app.html.Handle(app.getEquipmentPartOfFragment)))) + mux.Handle("GET /orgs/{org_id}/currency", app.withLogin(app.withPermission(app.html.Handle(app.getOrgCurrencyFragment)))) + mux.Handle("GET /orgs/{org_id}/equipment-categories", app.withLogin(app.withPermission(app.html.Handle(app.getEquipmentCategoriesFragment)))) + mux.Handle("GET /orgs/{org_id}/equipment-manufacturers", app.withLogin(app.withPermission(app.html.Handle(app.getEquipmentManufacturersFragment)))) + mux.Handle("GET /orgs/{org_id}/warehouse-locations", app.withLogin(app.withPermission(app.html.Handle(app.getEquipmentLocationsFragment)))) // Account Settings mux.Handle("GET /settings/account", app.withLogin(app.html.Handle(app.getAccount))) mux.Handle("DELETE /settings/account", app.withLogin(app.html.Handle(app.deleteAccount))) // Org related settings - mux.Handle("GET /orgs/{org_id}/settings/equipment-categories", app.withLogin(app.html.Handle(app.getEquipmentCategories))) - mux.Handle("GET /orgs/{org_id}/settings/equipment-categories/new", app.withLogin(app.html.Handle(app.getEquipmentCategoryNew))) - mux.Handle("POST /orgs/{org_id}/settings/equipment-categories/new", app.withLogin(app.html.Handle(app.postEquipmentCategoryNew))) - mux.Handle("GET /orgs/{org_id}/settings/equipment-categories/{id}", app.withLogin(app.html.Handle(app.getEquipmentCategory))) - mux.Handle("POST /orgs/{org_id}/settings/equipment-categories/{id}", app.withLogin(app.html.Handle(app.postEquipmentCategory))) - mux.Handle("POST /orgs/{org_id}/settings/equipment-categories/{id}/delete", app.withLogin(app.html.Handle(app.postDeleteEquipmentCategory))) - mux.Handle("GET /orgs/{org_id}/settings/manufacturers", app.withLogin(app.html.Handle(app.getManufacturers))) - mux.Handle("GET /orgs/{org_id}/settings/manufacturers/new", app.withLogin(app.html.Handle(app.getManufacturerNew))) - mux.Handle("POST /orgs/{org_id}/settings/manufacturers/new", app.withLogin(app.html.Handle(app.postManufacturerNew))) - mux.Handle("GET /orgs/{org_id}/settings/manufacturers/{id}", app.withLogin(app.html.Handle(app.getManufacturer))) - mux.Handle("POST /orgs/{org_id}/settings/manufacturers/{id}", app.withLogin(app.html.Handle(app.postManufacturer))) - mux.Handle("POST /orgs/{org_id}/settings/manufacturers/{id}/delete", app.withLogin(app.html.Handle(app.postDeleteManufacturer))) - mux.Handle("GET /orgs/{org_id}/settings/locations", app.withLogin(app.html.Handle(app.getLocations))) - mux.Handle("GET /orgs/{org_id}/settings/locations/new", app.withLogin(app.html.Handle(app.getLocationNew))) - mux.Handle("POST /orgs/{org_id}/settings/locations/new", app.withLogin(app.html.Handle(app.postLocationNew))) - mux.Handle("GET /orgs/{org_id}/settings/locations/{id}", app.withLogin(app.html.Handle(app.getLocation))) - mux.Handle("POST /orgs/{org_id}/settings/locations/{id}", app.withLogin(app.html.Handle(app.postLocation))) - mux.Handle("POST /orgs/{org_id}/settings/locations/{id}/delete", app.withLogin(app.html.Handle(app.postDeleteLocation))) + mux.Handle("GET /orgs/{org_id}/settings", app.withLogin(app.withPermission(app.html.Handle(app.getOrgSettings)))) + mux.Handle("POST /orgs/{org_id}/settings", app.withLogin(app.withPermission(app.html.Handle(app.postOrgSettings)))) + mux.Handle("GET /orgs/{org_id}/settings/equipment-categories", app.withLogin(app.withPermission(app.html.Handle(app.getEquipmentCategories)))) + mux.Handle("GET /orgs/{org_id}/settings/equipment-categories/new", app.withLogin(app.withPermission(app.html.Handle(app.getEquipmentCategoryNew)))) + mux.Handle("POST /orgs/{org_id}/settings/equipment-categories/new", app.withLogin(app.withPermission(app.html.Handle(app.postEquipmentCategoryNew)))) + mux.Handle("GET /orgs/{org_id}/settings/equipment-categories/{id}", app.withLogin(app.withPermission(app.html.Handle(app.getEquipmentCategory)))) + mux.Handle("POST /orgs/{org_id}/settings/equipment-categories/{id}", app.withLogin(app.withPermission(app.html.Handle(app.postEquipmentCategory)))) + mux.Handle("POST /orgs/{org_id}/settings/equipment-categories/{id}/delete", app.withLogin(app.withPermission(app.html.Handle(app.postDeleteEquipmentCategory)))) + mux.Handle("GET /orgs/{org_id}/settings/manufacturers", app.withLogin(app.withPermission(app.html.Handle(app.getManufacturers)))) + mux.Handle("GET /orgs/{org_id}/settings/manufacturers/new", app.withLogin(app.withPermission(app.html.Handle(app.getManufacturerNew)))) + mux.Handle("POST /orgs/{org_id}/settings/manufacturers/new", app.withLogin(app.withPermission(app.html.Handle(app.postManufacturerNew)))) + mux.Handle("GET /orgs/{org_id}/settings/manufacturers/{id}", app.withLogin(app.withPermission(app.html.Handle(app.getManufacturer)))) + mux.Handle("POST /orgs/{org_id}/settings/manufacturers/{id}", app.withLogin(app.withPermission(app.html.Handle(app.postManufacturer)))) + mux.Handle("POST /orgs/{org_id}/settings/manufacturers/{id}/delete", app.withLogin(app.withPermission(app.html.Handle(app.postDeleteManufacturer)))) + mux.Handle("GET /orgs/{org_id}/settings/locations", app.withLogin(app.withPermission(app.html.Handle(app.getLocations)))) + mux.Handle("GET /orgs/{org_id}/settings/locations/new", app.withLogin(app.withPermission(app.html.Handle(app.getLocationNew)))) + mux.Handle("POST /orgs/{org_id}/settings/locations/new", app.withLogin(app.withPermission(app.html.Handle(app.postLocationNew)))) + mux.Handle("GET /orgs/{org_id}/settings/locations/{id}", app.withLogin(app.withPermission(app.html.Handle(app.getLocation)))) + mux.Handle("POST /orgs/{org_id}/settings/locations/{id}", app.withLogin(app.withPermission(app.html.Handle(app.postLocation)))) + mux.Handle("POST /orgs/{org_id}/settings/locations/{id}/delete", app.withLogin(app.withPermission(app.html.Handle(app.postDeleteLocation)))) antiCSRF := http.NewCrossOriginProtection() logRequest := newRequestLogger(app.logger) diff --git a/internal/templates/pages/forbidden.tmpl b/internal/templates/pages/forbidden.tmpl new file mode 100644 index 0000000..a0dfabe --- /dev/null +++ b/internal/templates/pages/forbidden.tmpl @@ -0,0 +1,13 @@ +{{ define "title" }}Forbidden{{ end }} + +{{ define "page" }} +
    +

    Access Denied

    +

    You don't have permission to view this page. If you think this is a mistake, contact your administrator.

    +
    +
    + + Go to Home +
    +
    +{{ end }} diff --git a/internal/templates/pages/pages.go b/internal/templates/pages/pages.go index da62247..9dc6109 100644 --- a/internal/templates/pages/pages.go +++ b/internal/templates/pages/pages.go @@ -33,82 +33,60 @@ var ( // Orgs is the org selector page. Orgs = newPage("pages/orgs/index.tmpl", layouts.Center) - // OrgsNew is the create-org form page. OrgsNew = newPage("pages/orgs/new.tmpl", layouts.Center) - // OrgSettingsDetails is the General settings page — edit org name. OrgSettingsDetails = newPage("pages/orgs/details.tmpl", layouts.Settings) - // OrgSettings is the Billing & Tax settings page — currency, VAT rate, timezone. OrgSettings = newPage("pages/orgs/settings.tmpl", layouts.Settings) - // EquipmentCategoriesIndex is the Equipment Categories settings page. EquipmentCategoriesIndex = newPage("pages/orgs/categories/index.tmpl", layouts.Settings) - // EquipmentCategoriesNew is the create-category form page. EquipmentCategoriesNew = newPage("pages/orgs/categories/new.tmpl", layouts.Center) - // EquipmentCategoriesDetail is the category detail/edit page. EquipmentCategoriesDetail = newPage("pages/orgs/categories/detail.tmpl", layouts.Center) - // ManufacturersIndex is the Manufacturers settings page. ManufacturersIndex = newPage("pages/orgs/manufacturers/index.tmpl", layouts.Settings) - // ManufacturersNew is the create-manufacturer form page. ManufacturersNew = newPage("pages/orgs/manufacturers/new.tmpl", layouts.Center) - // ManufacturersDetail is the manufacturer detail/edit page. ManufacturersDetail = newPage("pages/orgs/manufacturers/detail.tmpl", layouts.Center) - // LocationsIndex is the Locations settings page. LocationsIndex = newPage("pages/orgs/locations/index.tmpl", layouts.Settings) - // LocationsNew is the create-location form page. LocationsNew = newPage("pages/orgs/locations/new.tmpl", layouts.Center) - // LocationsDetail is the location detail/edit page. LocationsDetail = newPage("pages/orgs/locations/detail.tmpl", layouts.Center) - // Equipment is the equipment list page. Equipment = newPage("pages/equipment/index.tmpl", layouts.Equipment) - // EquipmentNew is the unified create-item form page (bulk or serialized). EquipmentNew = newPage("pages/equipment/new.tmpl", layouts.Center) - // EquipmentDetail is the item details tab page. EquipmentDetail = newPage("pages/equipment/detail.tmpl", layouts.Equipment) - // EquipmentPricing is the item pricing tab page. EquipmentPricing = newPage("pages/equipment/pricing.tmpl", layouts.Equipment) - // EquipmentProperties is the item properties tab page. EquipmentProperties = newPage("pages/equipment/properties.tmpl", layouts.Equipment) - // EquipmentUnits is the unit management tab page for a serialized item. EquipmentUnits = newPage("pages/equipment/units.tmpl", layouts.Equipment) - // EquipmentContent is the content management tab page for an item with has_content=true. EquipmentContent = newPage("pages/equipment/content.tmpl", layouts.Equipment) - // EquipmentImport is the CSV upload form page. EquipmentImport = newPage("pages/equipment/import.tmpl", layouts.Center) - // EquipmentImportPreview is the staging review and confirm page. EquipmentImportPreview = newPage("pages/equipment/import-preview.tmpl", layouts.Equipment) - // EquipmentPrint is the bare print view of the equipment list. EquipmentPrint = newPage("pages/equipment/print.tmpl", layouts.Print) // Imprint is the legal imprint page. Imprint = newPage("pages/imprint.tmpl", layouts.Landing) - // Privacy is the privacy policy page. Privacy = newPage("pages/privacy.tmpl", layouts.Landing) // NotFound is the 404 page. NotFound = newPage("pages/not-found.tmpl", layouts.Center) - + // Forbidden is 403 page. + Forbidden = newPage("pages/forbidden.tmpl", layouts.Center) // Error is the generic error page. Error = newPage("pages/error.tmpl", layouts.Center) From 18c79d3326af5c803afa0e47f1666d9fcc370f3a Mon Sep 17 00:00:00 2001 From: Tobias Gleiter Date: Sat, 11 Jul 2026 16:57:06 -0400 Subject: [PATCH 3/4] refactor: mvroutes that need login below login routes --- cmd/web/routes.go | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/cmd/web/routes.go b/cmd/web/routes.go index fa9db4a..eff082c 100644 --- a/cmd/web/routes.go +++ b/cmd/web/routes.go @@ -10,18 +10,15 @@ import ( func (app *application) routes() (http.Handler, error) { mux := http.NewServeMux() + apiServer, err := gen.NewServer(app, gen.WithPathPrefix("/api/v1")) + if err != nil { + return nil, fmt.Errorf("routes: new api server: %w", err) + } mux.HandleFunc("/", app.html.Handle(app.getNotFound)) mux.Handle("GET /forbidden", app.html.Handle(app.getForbidden)) mux.Handle("GET /{$}", http.RedirectHandler("/orgs", http.StatusSeeOther)) - apiServer, err := gen.NewServer(app, gen.WithPathPrefix("/api/v1")) - if err != nil { - return nil, fmt.Errorf("routes: new api server: %w", err) - } - mux.Handle("/api/v1/", apiServer) - mux.Handle("GET /media/{id}", app.withLogin(app.html.Handle(app.getMedia))) - mux.Handle("GET /image-proxy", app.withLogin(http.HandlerFunc(app.getImageProxy))) mux.Handle("GET /dist/", assets.ServeStaticFiles()) mux.Handle("GET /favicon.ico", http.RedirectHandler("/dist/images/favicon.ico", http.StatusMovedPermanently)) @@ -36,9 +33,15 @@ func (app *application) routes() (http.Handler, error) { mux.Handle("GET /reset-password", app.withGuest(app.html.Handle(app.getResetPassword))) mux.Handle("POST /reset-password", app.withGuest(app.html.Handle(app.postResetPassword))) + // TODO: withLogin needs to be replaced with api specific bearer tokens. + mux.Handle("/api/v1/", app.withLogin(apiServer)) + // Signout must only be possible for logged in users. mux.Handle("POST /signout", app.withLogin(app.html.Handle(app.postSignOut))) + mux.Handle("GET /media/{id}", app.withLogin(app.html.Handle(app.getMedia))) + mux.Handle("GET /image-proxy", app.withLogin(http.HandlerFunc(app.getImageProxy))) + // Org related actions. The account only needs to be logged in via [app.withLogin]. mux.Handle("GET /orgs", app.withLogin(app.html.Handle(app.getOrgs))) mux.Handle("GET /orgs/new", app.withLogin(app.withPermission(app.html.Handle(app.getOrgsNew)))) From 93fbe12c7d8bedf5935b1a4a180f96d657a2844a Mon Sep 17 00:00:00 2001 From: Tobias Gleiter Date: Sat, 11 Jul 2026 16:58:59 -0400 Subject: [PATCH 4/4] chore: updated vendor hash --- nixos/web.nix | 2 +- nixos/www.nix | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nixos/web.nix b/nixos/web.nix index 911d192..01e0390 100644 --- a/nixos/web.nix +++ b/nixos/web.nix @@ -12,7 +12,7 @@ buildpkgs.buildGoModule { # one folder up. src = ./..; - vendorHash = "sha256-Yk6TyLwCuMOws0cOLY56hAhYVp5SsvNYYPgEbuzZDtg="; + vendorHash = "sha256-oBulZriVyCLm1De6fWmVjtUkCS7URf/U7nsepaJlOk8="; subPackages = ["cmd/web"]; diff --git a/nixos/www.nix b/nixos/www.nix index f2701be..2134308 100644 --- a/nixos/www.nix +++ b/nixos/www.nix @@ -8,7 +8,7 @@ buildpkgs.buildGoModule { version = version; src = ./..; - vendorHash = "sha256-Yk6TyLwCuMOws0cOLY56hAhYVp5SsvNYYPgEbuzZDtg="; + vendorHash = "sha256-oBulZriVyCLm1De6fWmVjtUkCS7URf/U7nsepaJlOk8="; subPackages = ["cmd/www"];