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.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/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..b26260e 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.Redirect(w, r, "/forbidden", http.StatusSeeOther) + 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..eff082c 100644 --- a/cmd/web/routes.go +++ b/cmd/web/routes.go @@ -10,78 +10,107 @@ import ( func (app *application) routes() (http.Handler, error) { mux := http.NewServeMux() - - mux.HandleFunc("/", app.html.Handle(app.getNotFound)) - - 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.HandleFunc("GET /media/{id}", app.html.Handle(app.getMedia)) - mux.HandleFunc("GET /image-proxy", app.getImageProxy) + + mux.HandleFunc("/", app.html.Handle(app.getNotFound)) + mux.Handle("GET /forbidden", app.html.Handle(app.getForbidden)) + + mux.Handle("GET /{$}", http.RedirectHandler("/orgs", http.StatusSeeOther)) 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 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))) + 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))) + + // 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)))) + mux.Handle("POST /orgs/new", app.withLogin(app.html.Handle(app.postOrgsNew))) + + // 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.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", 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) @@ -93,5 +122,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" }} +